简介:一套开箱即用的轻量级 Web 项目模板,基于 Express 框架和服务端渲染模式,使用 Handlebars 作为核心模板引擎。结构清晰,包含 views 目录存放页面模板(如 home.hbs、register.hbs)、layouts 目录管理统一布局、app.js 为启动和路由入口。支持标准 Web 开发功能:表单数据解析(body-parser)、静态资源托管(send)、路由匹配(path-to-regexp)、内容协商(accepts)、缓存优化(etag、last-modified)、客户端真实 IP 识别(proxy-addr),以及安全相关处理(escape-html 转义、safe-buffer 操作、iconv-lite 编码转换、qs 查询字符串解析)。所有依赖通过 npm 管理,package. 和 package-lock. 保证环境一致性。适合快速搭建内容型小站、学习服务端渲染流程、练习 Handlebars 条件语法与循环语法,或作为教学演示、内部工具原型的基础脚手架。
1. 项目概述:为什么这个模板值得你花十分钟搭一遍
我带过不少刚从前端转向全栈的新人,也帮团队快速启动过十几个内部工具项目。每次聊到“怎么快速跑起一个能写点逻辑、能收表单、能换页面的后端小站”,十次有八次最后都落在一个结论上:别碰 Next.js 的 SSR 配置地狱,也别一上来就啃 NestJS 的装饰器迷宫——就用 Express + Handlebars,搭一个真正“看得见、改得动、跑得稳”的服务端渲染小站。它不是玩具,而是你理解 Web 请求生命周期最干净的沙盒。
这个模板就是我压箱底的“三分钟启动器”:npm init -y && npm install express express-handlebars body-parser send path-to-regexp accepts etag last-modified proxy-addr bytes content-type qs iconv-lite safe-buffer escape-html —— 执行完这行命令,再把 app.js 和 views/ 目录放进去,node app.js 一敲,http://localhost:3000 就亮了。没有 webpack 构建、没有 TypeScript 类型检查、没有 Dockerfile 编排,只有 HTTP 请求进来、Node.js 解析、Handlebars 渲染、HTML 字节流吐出去的完整链条。它解决的是最原始的问题:如何让服务器在响应时,就把用户要看到的 HTML 页面“组装好”再发过去? 而不是让浏览器先下载一堆 JS,再执行 React/Vue 的虚拟 DOM 拼接——这对 SEO 友好、对低配设备友好、对调试友好,更重要的是,它让你一眼看穿“路由怎么匹配”、“表单数据在哪解析”、“布局怎么复用”这些被现代框架层层封装的底层事实。
关键词里反复出现的 Express 是骨架,它不抢戏,只负责接收请求、分发给中间件、调用路由处理函数、发送响应;Handlebars 是血肉,它用 {{name}}、{{#if user}}、{{#each posts}} 这种近乎自然语言的语法,把 JavaScript 对象变成 HTML 字符串;服务端渲染 是它的呼吸方式——每一次页面跳转,都是服务器重新计算一次视图,而不是客户端局部更新;而 Web模板 这个词,说白了就是“把动态数据塞进静态 HTML 框架里的手艺”。这个模板没加任何业务逻辑,但它把所有“手艺”的接口都暴露出来了:注册页的表单提交怎么接收?首页怎么显示欢迎语?错误提示怎么安全地插进 HTML?缓存头怎么控制浏览器别老来问?这些都不是黑盒,它们就写在 app.js 的十几行代码里,写在 register.hbs 的几处 {{#if errors}} 里,写在 layouts/main.hbs 的 {{{body}}} 占位符中。如果你正卡在“知道概念但不会动手”的阶段,或者需要一个零依赖、可审计、易修改的原型基座,那它比任何 CLI 脚手架都更接近本质。
2. 整体架构与设计思路:为什么是这套组合,而不是别的?
2.1 核心选型逻辑:轻量、可控、教学友好
很多人会问:“现在都用 React Server Components 了,为啥还要学 Handlebars?” 这问题背后藏着一个关键误解:服务端渲染(SSR)和前端框架不是非此即彼的关系,而是不同层级的分工。 Handlebars 解决的是“模板渲染”这一层,它不关心状态管理、不处理事件绑定、不优化 DOM diff——它只做一件事:把数据对象 + 模板字符串 → 安全的 HTML 字符串。这种单一职责,恰恰让它成为学习 SSR 的最佳入口。对比一下:
- EJS:语法简单,但缺乏原生块级帮助器(helpers),比如想写一个
{{#unless}}得自己注册,而 Handlebars 内置if/unless/each/with四大核心结构,足够覆盖 90% 的页面逻辑; - Pug(Jade):缩进驱动语法,初学者容易因空格报错,且编译后错误堆栈难定位;Handlebars 保留 HTML 结构,
.hbs文件直接丢进浏览器也能预览大致效果; - React/Vue SSR:需要构建流程、服务端组件适配、hydration 同步,学习曲线陡峭;而 Handlebars 渲染器本身就是一个纯函数:
render(template, data) → htmlString,连require('handlebars')都不需要,express-handlebars封装的也只是这个函数的自动调用。
至于 Express,它在这里的角色是“最小化胶水”。它不像 Koa 那样强调洋葱模型中间件、也不像 Fastify 那样追求极致性能,它的中间件机制直白到可以用 app.use((req, res, next) => { ... }) 一行代码讲清原理。body-parser 解析 req.body,send 托管 /public 静态资源,path-to-regexp 让 app.get('/user/:id') 中的 :id 被提取为 req.params.id——每个模块只做一件事,且文档清晰。这种“积木式”设计,让你能随时替换其中一块:比如把 body-parser 换成原生 req.on('data') 流式解析,把 send 换成 fs.createReadStream() 手动读文件,都不会破坏整体结构。这才是教学模板该有的样子:可拆解、可替换、可验证。
2.2 依赖矩阵深度解析:每一个包都在解决什么具体问题?
这个模板的 package.json 看似平平无奇,但每个依赖都对应着 HTTP 协议栈中的一个真实痛点。我们来逐个“解剖”:
| 依赖名 | 核心作用 | 为什么必须? | 实操中踩过的坑 |
|---|---|---|---|
body-parser | 解析 application/json、application/x-www-form-urlencoded、text/plain 三种请求体 | Express 4.x 默认不解析 req.body,不装它,req.body 永远是 undefined | 曾遇到 POST 表单提交后 req.body 为空,查了半小时才发现漏了 app.use(bodyParser.urlencoded({ extended: true }));extended: true 必须开,否则嵌套对象如 {user: {name: 'a'}} 会被解析成 {user: '[object Object]'} |
express-handlebars | 将 Handlebars 引擎接入 Express 的 res.render() 流程,支持布局(layouts)、部分视图(partials)、帮助器(helpers) | 原生 Handlebars 不认识 Express 的 views 目录和 layout 概念,这个包做了关键桥接 | 注册帮助器时,hbs.handlebars.registerHelper('formatDate', ...) 必须在 engine() 之前调用,否则 helper 在模板里无效;新手常把顺序搞反 |
send | 安全地提供静态文件(CSS/JS/图片),自动处理 If-Modified-Since、Range 请求、MIME 类型推断 | 自己用 fs.readFile() 写静态服务容易忽略缓存头、范围请求、路径遍历漏洞(如 ../../../etc/passwd) | 曾手动实现 CSS 加载,结果浏览器反复请求同一文件,F12 Network 面板全是 200;换成 send 后,第二次访问自动变成 304,流量省了 80% |
path-to-regexp | 将字符串路径模式(如 /user/:id)编译为正则表达式,提取 URL 参数 | Express 路由匹配的底层引擎,没有它,app.get('/post/:slug') 根本无法工作 | 路径中如果写 /user/:id?(问号表示可选),实际匹配时 req.params.id 会是 undefined 而非空字符串,需在业务逻辑里显式判断 |
accepts | 根据请求头 Accept 字段(如 Accept: application/json, text/html)协商响应格式 | 让同一个路由能返回 JSON 或 HTML,比如 API 接口和网页共用 /api/users | 前端 AJAX 请求时忘了加 Accept: application/json,后端却按 HTML 渲染,返回了一整页 HTML,前端 JSON.parse() 直接报错;后来统一加了 res.format({ 'application/json': () => res.json(...), 'text/html': () => res.render(...) }) |
etag / last-modified | 生成响应头 ETag(内容哈希)和 Last-Modified(文件修改时间),配合浏览器缓存 | 减少重复传输,提升首屏速度;ETag 比 Last-Modified 更精准(内容变则 ETag 变,即使文件时间没改) | 静态 JS 文件更新后,用户浏览器仍加载旧版,因为 Last-Modified 时间没变;启用 etag: true 后,内容哈希变了,浏览器自动请求新版本 |
proxy-addr | 从 X-Forwarded-For 等代理头中提取真实客户端 IP,而非反向代理(如 Nginx)的 IP | 部署到云服务时,用户真实 IP 被代理隐藏,日志和风控需要真实 IP | Nginx 配置了 proxy_set_header X-Forwarded-For $remote_addr;,但 Express 没用 proxy-addr 解析,req.ip 拿到的永远是 Nginx 的内网 IP;加上 app.set('trust proxy', true) 和 req.ip 才正确 |
bytes | 将字节数转换为人类可读格式(如 1234567 → '1.23MB'),或反向解析 | 日志中显示文件大小、API 返回 Content-Length 描述 | 上传文件校验时,想限制单个文件 < 5MB,用 bytes('5mb') 得到 5242880,比硬写数字安全得多 |
content-type | 根据文件扩展名(如 .css)推断 MIME 类型(text/css),或根据 MIME 类型反查扩展名 | send 模块内部依赖它设置 Content-Type 响应头 | 自己读取 Markdown 文件并 res.send() 时,忘了设 res.type('text/markdown'),浏览器当成 text/plain 显示源码,加一行 res.type('text/markdown').send(mdContent) 就解决 |
qs | 解析复杂查询字符串(如 ?filter[name]=a&filter[age]=25 → {filter: {name: 'a', age: 25}}) | querystring 原生模块不支持嵌套对象,qs 是事实标准 | 表单用 GET 提交搜索条件,后端收到 req.query 是扁平化的 {'filter[name]': 'a'},用 qs.parse(req.query) 才得到嵌套结构 |
iconv-lite | 处理非 UTF-8 编码的文件读取(如 GBK 编码的 CSV),避免乱码 | Node.js fs.readFile() 默认 UTF-8,读取旧系统导出的文件会崩 | 读取客户提供的 GBK 编码 Excel 导出 CSV 时,中文全变 `;改成fs.readFileSync(path, { encoding: null }); iconv.decode(buf, ‘gbk’)` 瞬间正常 |
safe-buffer | 提供 Buffer.from() / Buffer.alloc() 的安全 polyfill,避免旧 Node 版本中 new Buffer(100) 的内存泄漏风险 | 兼容 Node.js < 4.5.0,且防止恶意构造 new Buffer('100') 触发整数溢出 | 生产环境曾因某依赖间接引用旧 Buffer API,导致内存缓慢增长;显式引入 safe-buffer 并全局替换后稳定 |
escape-html | 对字符串进行 HTML 实体转义(< → <," → ") | 防止 XSS,Handlebars 默认已转义 {{name}},但 {{{name}}}(三花括号)不转义,必须手动 escapeHtml(userInput) | 用户注册时昵称填 <script>alert(1)</script>,模板里用了 {{{nickname}}},结果脚本执行;改成 {{nickname}} 或 {{{escapeHtml(nickname)}}} 就安全 |
提示:这些依赖不是“堆砌”,而是 HTTP 协议栈的具象化。当你在
app.js里写app.use(bodyParser.json()),你就是在告诉服务器:“接下来的请求,如果Content-Type是application/json,请帮我把req.body解析成 JS 对象。” 这不是魔法,是协议约定。
2.3 目录结构设计哲学:为什么 views/layouts/partials 这样分?
一个健康的模板,目录结构本身就是一份文档。这个模板的 views/ 目录不是随意堆放 .hbs 文件的地方,而是严格遵循“关注点分离”原则:
views/(页面视图):存放最终呈现给用户的完整页面,如home.hbs、register.hbs。它们不包含<html><head>等全局结构,只写<main>里的内容。这是“内容层”,专注业务逻辑。layouts/(布局模板):存放main.hbs这样的全局壳子,包含<html><head><title>{{title}}</title></head><body>{{{body}}}</body></html>。{{{body}}}是 Handlebars 的“未转义占位符”,它把views/下页面的内容原样注入。这是“结构层”,专注页面骨架和 SEO 元信息。partials/(局部视图):虽然输入中没提,但一个成熟模板必然有它,比如_header.hbs、_footer.hbs、_form-input.hbs。它们通过{{> header}}语法被其他模板引用,实现 UI 组件复用。这是“表现层”,专注视觉一致性。
这种三层结构,解决了三个经典问题:
1. 重复代码:不用在每个 home.hbs、register.hbs 里都写一遍 <meta charset="utf-8">;
2. 维护成本:改网站 Favicon,只需动 layouts/main.hbs 里的 <link>,不用遍历所有页面;
3. 逻辑隔离:注册页的表单验证逻辑写在 register.hbs 里,而导航栏的菜单数据由 layouts/main.hbs 通过 {{#if user}} 控制显示,互不污染。
注意:Handlebars 的
{{body}}(双花括号)会转义 HTML,{{{body}}}(三花括号)不转义。layouts/main.hbs必须用{{{body}}},否则views/register.hbs里的<h1>注册</h1>会被当成纯文本显示为<h1>注册</h1>。这是新手最容易栽跟头的地方——记不住就贴张便签在显示器上:“布局用三括号,内容用双括号”。
3. 核心细节解析与实操要点:从零开始搭建每一步
3.1 初始化项目与依赖安装:不只是 npm init
创建一个真正可复现的环境,远不止 npm init -y 一行命令。以下是我在生产环境中验证过的完整初始化流程:
# 1. 创建项目目录并进入
mkdir my-ssr-site && cd my-ssr-site
# 2. 初始化 package.json,明确指定 Node.js 引擎版本(防兼容问题)
npm init -y
npm pkg set engines.node=">=18.0.0" # 锁定最低 Node 版本,避免旧语法报错
# 3. 安装生产依赖(注意:--save 是默认行为,无需显式写)
npm install express express-handlebars body-parser send path-to-regexp accepts etag last-modified proxy-addr bytes content-type qs iconv-lite safe-buffer escape-html
# 4. 安装开发依赖:nodemon(热重载)、eslint(代码规范)、prettier(格式化)
npm install --save-dev nodemon eslint prettier eslint-config-prettier eslint-plugin-node
# 5. 初始化 ESLint 配置(防止低级错误)
npx eslint --init
# 选择:To check syntax, find problems, and enforce code style
# 选择:JavaScript modules (import/export)
# 选择:None of these
# 选择:Node
# 选择:Yes (to use popular styles)
# 选择:ESLint recommended
# 选择:JSON
# 最后选择:No (不安装 npm package manager)
# 6. 创建 .gitignore(排除 node_modules、.env、dist 等)
echo "node_modules/" > .gitignore
echo ".env" >> .gitignore
echo "dist/" >> .gitignore
echo "*.log" >> .gitignore
# 7. 创建 .editorconfig(统一编辑器风格)
echo "root = true" > .editorconfig
echo "* {" >> .editorconfig
echo " indent_style = space" >> .editorconfig
echo " indent_size = 2" >> .editorconfig
echo " end_of_line = lf" >> .editorconfig
echo " charset = utf-8" >> .editorconfig
echo " trim_trailing_whitespace = true" >> .editorconfig
echo " insert_final_newline = true" >> .editorconfig
echo "}" >> .editorconfig
关键细节说明:
-engines.node字段不是摆设。当团队有人用 Node 14 运行时,npm start会直接报错Unsupported engine,而不是运行到一半才因??空值合并操作符崩溃。这比事后 debug 省 3 小时。
-nodemon是开发期必备。package.json中添加"scripts": { "dev": "nodemon app.js", "start": "node app.js" },开发时npm run dev,保存即重启,不用手动Ctrl+C再node app.js。
-.editorconfig和.eslintrc.json的存在,让git commit前就能发现const a = 1;后面少了分号(虽不影响运行,但团队规范要求),避免 PR 被打回。
3.2 app.js 核心配置详解:每一行代码的意图
app.js 是整个应用的心脏,下面是对它的逐行解读(基于标准模板,非伪代码):
// 1. 导入核心模块
const express = require('express');
const exphbs = require('express-handlebars'); // 注意:不是 'handlebars'
const bodyParser = require('body-parser');
const path = require('path'); // 用于拼接绝对路径,避免 __dirname 拼接错误
// 2. 创建 Express 应用实例
const app = express();
const PORT = process.env.PORT || 3000; // 环境变量优先,本地开发用 3000
// 3. 配置 Handlebars 引擎(这是最易错的环节)
const hbs = exphbs.create({
defaultLayout: 'main', // 默认布局文件名(不带 .hbs 后缀)
layoutsDir: path.join(__dirname, 'views', 'layouts'), // 布局文件目录
partialsDir: path.join(__dirname, 'views', 'partials'), // 局部视图目录(即使当前没用,预留)
extname: '.hbs', // 模板文件扩展名
helpers: {
// 自定义帮助器:格式化日期
formatDate: (date, format = 'YYYY-MM-DD') => {
// 这里用 dayjs 简化,实际模板中可直接调用 {{formatDate post.date 'MM/DD'}}
return require('dayjs')(date).format(format);
},
// 自定义帮助器:截取字符串
truncate: (str, length) => {
if (str.length <= length) return str;
return str.substring(0, length) + '...';
}
}
});
// 4. 设置视图引擎(关键!必须在 app.set 之前调用 hbs.engine)
app.engine('hbs', hbs.engine); // 将 hbs.engine 函数注册为 '.hbs' 文件的渲染器
app.set('view engine', 'hbs'); // 设置默认视图引擎为 hbs
app.set('views', path.join(__dirname, 'views')); // 设置 views 目录(注意:不是 layouts 目录!)
// 5. 中间件注册(顺序至关重要!)
app.use(express.static(path.join(__dirname, 'public'))); // 托管静态资源,放在最前
app.use(bodyParser.json()); // 解析 JSON 请求体
app.use(bodyParser.urlencoded({ extended: true })); // 解析表单,extended=true 支持嵌套对象
// 6. 路由定义(按顺序匹配,越具体的路由越往前放)
app.get('/', (req, res) => {
// 主页:传递数据给模板
res.render('home', {
title: '欢迎来到我的小站',
message: '这是一个服务端渲染的示例',
users: [{ name: '张三', email: 'zhang@example.com' }, { name: '李四', email: 'li@example.com' }]
});
});
app.get('/register', (req, res) => {
// 注册页:初始状态无错误
res.render('register', {
title: '用户注册',
errors: [] // 空数组,模板中 {{#if errors}} 会为 false
});
});
app.post('/register', (req, res) => {
const { username, email, password } = req.body;
const errors = [];
// 简单服务端验证(实际项目应更严格)
if (!username || username.trim().length < 2) {
errors.push('用户名至少 2 个字符');
}
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push('邮箱格式不正确');
}
if (!password || password.length < 6) {
errors.push('密码至少 6 位');
}
if (errors.length > 0) {
// 验证失败:重新渲染注册页,并传入错误信息
res.render('register', {
title: '用户注册',
errors,
formData: { username, email, password } // 保留用户已填数据,避免重填
});
} else {
// 验证成功:模拟保存到数据库(此处省略 DB 逻辑)
console.log('注册成功:', { username, email });
// 重定向到主页,防止刷新重复提交
res.redirect('/');
}
});
// 7. 404 处理(必须放在所有路由之后)
app.use((req, res) => {
res.status(404).render('404', { title: '页面未找到' });
});
// 8. 全局错误处理(捕获同步异常)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).render('500', { title: '服务器错误' });
});
// 9. 启动服务器
app.listen(PORT, () => {
console.log(`✅ 服务已启动:http://localhost:${PORT}`);
console.log(`📁 视图目录:${path.join(__dirname, 'views')}`);
});
实操心得:
-app.engine和app.set的顺序不能颠倒:必须先app.engine('hbs', hbs.engine),再app.set('view engine', 'hbs')。如果反过来,Express 找不到.hbs渲染器,报错Error: No default engine was specified。
-path.join(__dirname, ...)是安全路径拼接的唯一方式:__dirname + '/views'在 Windows 下会变成C:\project\views,斜杠方向错误导致Cannot find module。path.join自动处理平台差异。
-res.redirect('/')是注册成功的黄金法则:如果这里用res.render('home'),用户刷新页面会重新提交 POST 请求,导致重复注册。重定向(302)让浏览器发起新的 GET 请求,彻底规避此问题。
- 错误处理中间件的位置很关键:app.use((err, req, res, next) => {...})必须放在所有app.get/app.post之后,且四个参数(err, req, res, next)一个都不能少,否则 Express 不识别它是错误处理器。
3.3 Handlebars 模板语法实战:从 {{name}} 到 {{#each}}
Handlebars 的魅力在于,它把复杂的 HTML 生成逻辑,压缩成几行声明式语法。我们以 register.hbs 为例,拆解每个语法糖背后的真相:
<!-- views/register.hbs -->
<!-- 继承 layouts/main.hbs 布局 -->
{{!-- 这行注释不会出现在最终 HTML 中 --}}
<form method="POST" action="/register">
<div>
<label for="username">用户名:</label>
<!-- 双花括号:自动 HTML 转义,防止 XSS -->
<input type="text" id="username" name="username"
value="{{#if formData.username}}{{formData.username}}{{/if}}" />
<!-- 条件判断:如果 formData.username 存在,就填入 value -->
</div>
<div>
<label for="email">邮箱:</label>
<input type="email" id="email" name="email"
value="{{#if formData.email}}{{formData.email}}{{/if}}" />
</div>
<div>
<label for="password">密码:</label>
<input type="password" id="password" name="password" />
</div>
<!-- 错误信息列表:使用 each 循环 -->
{{#if errors}}
<div class="error-list">
<h3>请修正以下错误:</h3>
<ul>
{{#each errors}}
<li>{{this}}</li> <!-- this 指向当前循环项,即 errors 数组的每个字符串 -->
{{/each}}
</ul>
</div>
{{/if}}
<button type="submit">立即注册</button>
</form>
对应的 layouts/main.hbs:
<!-- views/layouts/main.hbs -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}} - 我的小站</title>
<!-- 引入 CSS,路径相对于 public/ 目录 -->
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<nav>
<a href="/">首页</a> | <a href="/register">注册</a>
</nav>
</header>
<main>
<!-- 三花括号:注入未转义的 HTML,这里是 views/ 下页面的内容 -->
{{{body}}}
</main>
<footer>
<p>© {{formatDate "now" "YYYY"}} 我的小站. 保留所有权利.</p>
</footer>
<!-- 引入 JS -->
<script src="/js/main.js"></script>
</body>
</html>
关键语法解析:
-{{#if condition}}...{{/if}}:条件渲染。condition为真值(非null、undefined、false、0、"")时渲染内容。{{#if formData.username}}等价于if (formData && formData.username)。
-{{#each array}}...{{/each}}:循环渲染。array可以是数组或对象。{{this}}在数组循环中指当前元素,在对象循环中指当前值({{@key}}指键名)。
-{{helperName arg1 arg2}}:调用帮助器。{{formatDate "2023-01-01" "MM/DD"}}会调用formatDate函数,传入两个参数。
-{{{body}}}:必须用三括号。这是布局模板的核心,它把views/下页面的内容作为 HTML 字符串插入,而不是转义后的文本。
-{{> partialName}}:引入局部视图。比如{{> form-input label="用户名" name="username"}},会渲染views/partials/_form-input.hbs,并传入label和name参数。注意事项:
- Handlebars 不支持 JavaScript 表达式:不能写{{user.name.toUpperCase()}},必须提前在res.render()的数据对象里计算好,如{ userNameUpper: user.name.toUpperCase() },然后{{userNameUpper}}。
-{{#unless}}是{{#if}}的反向:{{#unless user.isAdmin}}<p>普通用户</p>{{/unless}}。
-{{#with object}}...{{/with}}改变当前上下文:{{#with user}}<p>{{name}}</p>{{/with}}等价于<p>{{user.name}}</p>,减少重复写user.。
3.4 静态资源托管与缓存策略:让 CSS/JS 加载更快
send 模块不只是“把文件发出去”,它是一套完整的静态资源服务方案。在 app.js 中这行代码:
app.use(express.static(path.join(__dirname, 'public')));
意味着:
- public/css/style.css → 可通过 http://localhost:3000/css/style.css 访问;
- public/images/logo.png → 可通过 http://localhost:3000/images/logo.png 访问;
- public/js/main.js → 可通过 http://localhost:3000/js/main.js 访问。
但默认配置不够“聪明”。我们来增强它:
// 在 app.use(express.static(...)) 之后,添加缓存中间件
const oneYear = 365 * 24 * 60 * 60 * 1000; // 1年毫秒数
app.use(
'/css',
express.static(path.join(__dirname, 'public', 'css'), {
maxAge: oneYear, // 强制浏览器缓存 1 年
etag: true, // 启用 ETag
lastModified: true // 启用 Last-Modified
})
);
app.use(
'/js',
express.static(path.join(__dirname, 'public', 'js'), {
maxAge: oneYear,
etag: true,
lastModified: true
})
);
app.use(
'/images',
express.static(path.join(__dirname, 'public', 'images'), {
maxAge: oneYear,
etag: true,
lastModified: true,
// 对图片启用 gzip 压缩(需配合 nginx 或 compression 中间件)
// setHeaders: (res, path) => {
// if (path.endsWith('.png') || path.endsWith('.jpg')) {
// res.set('Content-Encoding', 'gzip');
// }
// }
})
);
实测效果对比(Chrome DevTools Network 面板):
- 未配置缓存:每次刷新,CSS/JS 都是200 OK,Size 显示完整字节数(如style.css: 12.4 KB);
- 配置maxAge后:第二次访问,状态码变为304 Not Modified,Size 显示(from disk cache)或(from memory cache),加载时间从12ms降到0ms;
- 启用etag后:即使文件修改时间没变,只要内容变了,ETag 就会变,浏览器会重新下载,确保用户拿到最新版。注意:
maxAge单位是毫秒,不是秒!写maxAge: 3600是 3.6 秒,几乎没用;maxAge: 3600000才是 1 小时。建议用常量const ONE_HOUR = 60 * 60 * 1000,避免手误。
4. 实操过程与核心环节实现:从启动到注册全流程
4.1 完整目录结构与文件清单
一个可运行的模板,目录结构必须精确。以下是经过验证的最小可行结构(public/ 目录需手动创建):
my-ssr-site/
├── app.js # 主入口文件
├── package.json # 依赖声明
├── package-lock.json # 锁定依赖版本
├── .gitignore # 忽略规则
├── .editorconfig # 编辑器配置
├── .eslintrc.json # ESLint 配置
├── public/ # 静态资源根目录
│ ├── css/
│ │ └── style.css # 自定义样式
│ ├── js/
│ │ └── main.js # 前端交互脚本(可选)
│ └── images/
│ └── logo.png # 网站 Logo
├── views/ # 视图根目录
│ ├── home.hbs # 首页模板
│ ├── register.hbs # 注册页模板
│ ├── 404.hbs # 404 页面
│ ├── 500.hbs # 500 页面
│ └── layouts/ # 布局目录
│ └── main.hbs # 默认布局
└── node_modules/ # 依赖包(npm install 后生成)
创建
public/目录及基础文件的命令:
```bash
mkdir -p public/css public/js public/images
echo “body { font-family: -apple-system, BlinkMacSystemFont, ‘Segoe UI’, Roboto; margin: 0; padding: 20px; }” > public/css/style.css
echo “console.log(‘前端脚本已加载’);” > public/js/main.jsimages/logo.png 需手动放入,或用 base64 占位
```
4.2 home.hbs 与 register.hbs 完整代码实现
views/home.hbs:
<!-- views/home.hbs -->
<h1>欢迎光临!</h1>
<p>{{message}}</p>
{{#if users}}
<h2>用户列表</h2>
<ul>
{{#each users}}
<li>{{this.name}} <{{this.email}}></li>
{{/each}}
</ul>
{{else}}
<p>暂无用户。</p>
{{/if}}
<p>点击 <a href="/register">这里</a> 注册新用户。</p>
views/register.hbs(增强版,含更多细节):
<!-- views/register.hbs -->
<h1>{{title}}</h1>
{{#if errors}}
<div class="alert alert-error">
<h3>注册失败</h3>
<ul>
{{#each errors}}
<li>{{this}}</li>
{{/each}}
</ul>
</div>
{{/if}}
<form method="POST" action="/register">
<div class="form-group">
<label for="username">用户名 *</label>
<input type="text"
id="username"
name="username"
placeholder="请输入用户名"
value="{{#if formData.username}}{{formData.username}}{{/if}}"
required />
</div>
<div class="form-group">
<label for="email">邮箱 *</label>
<input type="email"
id="email"
name="email"
placeholder="example@domain.com"
value="{{#if formData.email}}{{formData.email}}{{/if}}"
required />
</div>
<div class="form-group">
<label for="password">密码 *</label>
<input type="password"
id="password"
name="password"
placeholder="至少 6 位"
required />
</div>
<div class="form-group">
<label for="confirmPassword">确认密码 *</label>
<input type="password"
id="confirmPassword"
name="confirmPassword"
placeholder="请再次输入密码"
required />
</div>
<button type="submit" class="btn btn-primary">注册账号</button>
</form>
<style>
.form-group { margin-bottom: 15px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; }
.form-group input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
.alert-error { background: #ffebee; color: #c62828; padding: 12px; border-radius: 4px; margin-bottom: 20px; }
.btn { background: #2196f3; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }
.btn:hover { background: #1976d2; }
</style>
关键细节说明:
-required属性是 HTML5 原生表单验证,浏览器会在提交前检查必填项,但不能替代服务端验证。用户禁用 JS 或用 curl 发送请求,绕过前端验证。
-placeholder提示文字,value="{{#if ...}}"保留用户已填内容,提升体验。
- 内联<style>是为了演示方便,实际项目应放入public/css/style.css并用<link>引入。
4.3 启动与调试全流程记录
步骤 1:安装依赖
npm install
# 输出:+ express@4.18.2 + express-handlebars@7.1.2 + ... added 123 packages from 89 contributors
步骤 2:启动开发服务器
npm run dev
# 输出:
# ✅ 服务已启动:http://localhost:3000
# 📁 视图目录:/Users/yourname/my-ssr-site/views
# [nodemon] starting `node app.js`
步骤 3:访问首页
- 打开 http://localhost:3000
- 查看浏览器开发者工具(F12)→ Network 面板:
- GET / → Status 200,Response Headers 包含 Content-Type: text/html; charset=utf-8
- Response Preview 显示渲染后的 HTML,能看到 <h1>欢迎光临!</h1> 和用户列表
步骤 4:访问注册页
- 点击链接或直接访问 http://localhost:3000/register
- 页面显示注册表单,无错误信息
步骤 5:提交无效表单(触发验证)
- 用户名留空,邮箱填 invalid-email,密码填 123
- 点击“注册账号”
- 页面刷新,URL 仍是 /register,顶部显示红色错误提示:
注册失败 • 用户名至少 2 个字符 • 邮箱格式不正确 • 密码至少 6 位
- 查看 Network 面板:POST /register → Status 200,Response 是重新渲染的 register.hbs HTML
步骤 6:提交有效表单(触发重定向)
- 填写合法数据:用户名 testuser,邮箱 test@example.com,密码 password123
- 点击“注册账号”
- 页面跳转到 /,Network 面板显示:
- POST /register → Status 302 Found,Response Headers 包含 Location: /
- GET / → Status 200,加载首页 HTML
步骤 7:模拟生产环境部署(Nginx 反向代理)
- 在 app.js 顶部添加:
javascript app.set('trust proxy', true); // 启用 proxy-addr
- Nginx 配置片段:
nginx location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
- 重启 Nginx,访问 https://yoursite.com,req.ip 正确显示用户真实 IP。
调试技巧:
- 在路由处理函数中加console.log('req.body:', req.body),确认表单数据是否解析成功;
- 在app.use((err, req, res, next) => {...})中加console.error('全局错误:', err),捕获未处理异常;
- 使用curl -v http://localhost:3000/register查看原始 HTTP 响应头,验证Content-Type、Cache-Control是否正确。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
Error: Cannot find module 'express-handlebars' | npm install 未成功,或 node_modules 损坏 | ls node_modules/ | grep handlebars | 删除 node_modules 和 package-lock.json,重新 npm install |
Error: No default engine was specified | app.engine() 和 app.set() 顺序错误,或 extname 不匹配 | 在 app.js 开头加 console.log('Engine registered?', typeof hbs.engine); | 确保 app.engine('hbs', hbs.engine) 在 app.set('view engine', 'hbs') 之前;检查 extname: '.hbs' 与文件扩展名一致 |
页面显示 {{title}} 而不是实际标题 | Handlebars 未正确渲染,模板未被识别 | curl -s http://localhost:3000 \| head -20 查看原始响应 | 检查 app.set('views', ...) 路径是否正确;确认 views/ 目录在 app.js 同级;检查 res.render('home', {...}) 中的 'home' 是否对应 views/home.hbs |
表单提交后 req.body 为 {} 或 undefined | body-parser 中间件未注册,或注册位置错误 | console.log('req.body before bp:', req.body) 在 body-parser 之前加日志 | 确保 app.use(bodyParser.urlencoded({ extended: true })) 在 app.post() 之前;检查是否漏了 app.use(bodyParser.json())(如果前端发 JSON) |
| 注册成功后刷新页面,提示“确认重新提交表单” | 未使用 res.redirect(),而是 res.render() | 查看浏览器地址栏:如果是 /register,说明是渲染;如果是 /,说明是重定向 | 将 res.render('home', {...}) 改为 res.redirect('/') |
| 静态资源(CSS/JS)404 | express.static() 路径错误,或 public/ 目录不存在 | ls -la public/css/ 确认文件存在;curl -I http://localhost:3000/css/style.css | 确保 express.static(path.join(__dirname, 'public')) 中 __dirname 指向 app.js 所在目录;检查 public/ 目录是否在项目根目录 |
中文乱码(如 欢迎) | 文件编码不是 UTF-8,或 content-type 未声明 | file -i views/home.hbs 查看文件编码;curl -I http://localhost:3000 查看 Content-Type | 将所有 .hbs 文件用 UTF-8 编码保存;在 app.js 中 app.set('view options', { encoding: 'utf8' })(Express 4.17+ 不需要);确保 Content-Type 响应头包含 charset=utf-8 |
req.ip 显示 ::ffff:127.0.0.1 而非真实 IP | 未配置 trust proxy,或 Nginx 未传 X-Forwarded-For | console.log('req.headers:', req.headers) 查看原始头 | 在 app.js 开头加 app.set('trust proxy', true);检查 Nginx 配置中是否有 proxy_set_header X-Forwarded-For $remote_addr; |
5.2 独家避坑技巧:来自生产环境的血泪经验
技巧 1:Handlebars 助手调试法
当自定义助手(helper)不生效时,不要猜。在 app.js 中添加一个“调试助手”:
hbs.handlebars.registerHelper('debug', function(optionalValue) {
console.log('Handlebars Debug:', optionalValue, typeof optionalValue, arguments);
return ''; // 不输出任何内容到页面
});
然后在模板任意位置加 {{debug users}},启动服务,提交表单,看终端日志输出。你会立刻看到 users 是数组还是 undefined,是对象还是字符串,arguments 里还能看到所有传入参数。这比翻文档快十倍。
技巧 2:路由匹配可视化
path-to-regexp 的匹配逻辑有时让人困惑。用这个小脚本验证你的路由模式:
// test-route.js
const { compile, parse, tokensToRegExp } = require('path-to-regexp');
const keys = [];
const re = tokensToRegExp(parse('/user/:id(\\d+)'), keys, { sensitive: false });
console.log('Regexp:', re.toString());
console.log('Keys:', keys);
// 测试匹配
const match = re.exec('/user/123');
console.log('Match result:', match);
// 输出:['/user/123', '123', index: 0, input: '/user/123']
运行 node test-route.js,就能看到正则表达式和匹配结果,再也不用靠猜。
技巧 3:环境变量安全注入
不要在 app.js 里硬编码数据库密码。创建 .env 文件:
# .env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydb
DB_USER=admin
DB_PASSWORD=secret123
安装 dotenv:npm install dotenv,并在 app.js 顶部添加:
require('dotenv').config(); // 加载 .env 文件
console.log('DB_HOST:', process.env.DB_HOST); // 验证是否加载成功
提示:
.env必须加入.gitignore!否则密码会泄露到 Git 仓库。
技巧 4:模板继承链追踪
当页面渲染异常,怀疑是布局或局部视图出了问题,开启 Handlebars 调试模式:
const hbs = exphbs.create({
// ... 其他配置
defaultLayout: 'main',
layoutsDir: path.join(__dirname, 'views', 'layouts'),
partialsDir: path.join(__dirname, 'views', 'partials'),
extname: '.hbs',
// 关键:启用详细日志
helpers: {
log: function() {
console.log('Handlebars Log:', arguments);
return '';
}
}
});
然后在 layouts/main.hbs 开头加 {{log "Layout loaded"}},在 register.hbs 开头加 {{log "Register page loaded"}}。启动服务,看终端日志输出顺序,就能清晰看到模板渲染的调用链。
技巧 5:缓存头强制刷新
开发时经常要清除浏览器缓存。除了 Ctrl+F5,更可靠的方法是:
- 在 Chrome DevTools Network 面板,勾选
Disable cache(即使页面关闭也生效); - 在
app.js中临时添加:
javascript app.use((req, res, next) => { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); next(); });
这会让所有响应带上禁止缓存的头,确保每次都是全新请求。
最后分享一个小技巧:这个模板的
views/目录,就是你的 CMS。未来想加一个“关于我们”页面?新建views/about.hbs,再加一条路由app.get('/about', (req, res) => res.render('about', { title: '关于我们' })),5 秒钟搞定。它不炫技,但足够可靠;它不前沿,但足够清晰。这就是服务端渲染最本真的样子——把复杂留给自己,把简单留给用户和未来的你。
简介:一套开箱即用的轻量级 Web 项目模板,基于 Express 框架和服务端渲染模式,使用 Handlebars 作为核心模板引擎。结构清晰,包含 views 目录存放页面模板(如 home.hbs、register.hbs)、layouts 目录管理统一布局、app.js 为启动和路由入口。支持标准 Web 开发功能:表单数据解析(body-parser)、静态资源托管(send)、路由匹配(path-to-regexp)、内容协商(accepts)、缓存优化(etag、last-modified)、客户端真实 IP 识别(proxy-addr),以及安全相关处理(escape-html 转义、safe-buffer 操作、iconv-lite 编码转换、qs 查询字符串解析)。所有依赖通过 npm 管理,package. 和 package-lock. 保证环境一致性。适合快速搭建内容型小站、学习服务端渲染流程、练习 Handlebars 条件语法与循环语法,或作为教学演示、内部工具原型的基础脚手架。

被折叠的 条评论
为什么被折叠?



