Express 项目中选择 EJS 模板引擎的实战指南
1. 为什么在 Express 项目里坚持用 EJS而不是 Pug 或 Handlebars我第一次在生产环境里替换模板引擎是接手一个上线半年的后台管理系统。原团队用的是 Pug当时还叫 Jade代码缩进严格得像军训——少一个空格整个页面白屏多一个换行渲染就报错。更麻烦的是前端同事改个按钮文案得先问后端“这个 div 是嵌套在第几层 .card-body 里class 名要不要加前缀”——协作成本高到离谱。后来我把它全换成 EJS不是因为 EJS 多先进而是它最“不折腾”。EJS 的核心逻辑就一条HTML 是主体JavaScript 是插件不是反过来。你写div classuser-name% user.name %/div它就老老实实把user.name插进去你写% if (user.isAdmin) { %button删除用户/button% } %它就按条件渲染。没有自创语法没有强制缩进没有隐式闭合标签。对刚学 Node 的新人来说打开.ejs文件就像打开.html文件一样自然对有经验的开发者来说它不抢控制权你随时可以切回纯 HTML 写法或者嵌入复杂逻辑——只要逻辑写得清楚EJS 绝不干涉。这背后其实是模板引擎的设计哲学差异。Pug 把 HTML 当作需要被“优化”的冗余物于是发明了一套新 DSLHandlebars 追求逻辑剥离连if都得注册 helper而 EJS 的选择很务实不改变开发者的肌肉记忆只解决变量插入、循环、复用这三个刚需。它不试图教你“怎么写模板”它只帮你“把数据塞进模板”。所以当我在团队内部做技术选型评审时没讲什么抽象架构图就放了三段等效代码Pug 版本带缩进、管道符、隐式标签ul.users each user in users li(classuser.active ? active : ) a(href/user/ user.id) user.nameHandlebars 版本需预编译、注册 helper、双大括号ul classusers {{#each users}} li class{{#if active}}active{{/if}} a href/user/{{id}}{{name}}/a /li {{/each}} /ulEJS 版本就是 HTML 嵌入 JSul classusers % users.forEach(function(user) { % li class% user.active ? active : % a href/user/% user.id %% user.name %/a /li % }); % /ul结果所有前端和后端同学都指着第三段说“这个我一眼就懂。”——技术选型从来不是比谁更炫而是比谁让团队少踩坑、少解释、少返工。EJS 就是那个“不用解释”的答案。提示EJS 不是万能的它不适合超大型组件化前端项目比如需要 Vue/React 式响应式更新的场景但对绝大多数 Express 后台管理页、内容展示页、表单提交页、邮件模板生成等场景它的开发效率、可读性、调试便利性至今没被真正超越。2. 从零配置一个支持 EJS 的 Express 项目避开 npm install 后的五个典型失败很多人卡在第一步npm install ejs express之后浏览器打开http://localhost:3000显示 “Cannot GET /” 或者直接报错Error: Failed to lookup view。这不是你代码写错了而是 Express 默认根本不认识.ejs文件——它连模板引擎都没注册。下面是我实测过、踩过坑、验证过的完整初始化流程每一步都带原理说明。2.1 初始化项目与基础依赖安装先确保你本地有 Node 环境别纠结版本Node 18 和 20 都稳。执行mkdir my-express-app cd my-express-app npm init -y npm install express ejs注意不要装ejs-mate、express-ejs-layouts这类封装库。新手一上来就装这些等于还没学会走路就想跑马拉松。它们会掩盖底层机制一旦出问题你连日志都看不懂。我们先用原生 API 把路走通。2.2 创建标准目录结构关键Express 对目录结构没强制要求但 EJS 有默认约定。如果你不按它预期的路径放文件res.render()就会找不到视图。必须建立如下结构my-express-app/ ├── app.js # 入口文件 ├── package.json ├── views/ # ← 必须叫这个名字Express 默认只在这里找 .ejs │ ├── index.ejs # 主页模板 │ └── partials/ # ← 子模板推荐放这里非强制但强烈建议 │ └── header.ejs └── public/ # 静态资源CSS/JS/图片 └── style.css注意views文件夹名不能改成templates或src/views。Express 的app.set(views, ...)可以指定路径但新手请先用默认值避免引入额外变量。等你跑通第一个页面再改。2.3 编写最小可行 app.js含三处易错点这是最容易出错的代码段我标出了三个新手必踩的坑const express require(express); const app express(); const PORT 3000; // ✅ 坑1必须显式设置视图引擎且顺序不能错 app.set(view engine, ejs); // ← 告诉 Express我用 ejs 当模板引擎 app.set(views, ./views); // ← 告诉 Express模板文件在 ./views 目录 app.set(view engine, ejs); // ← 这行必须在 set(views) 之后 // ✅ 坑2必须启用静态资源服务否则 CSS/JS 加载 404 app.use(express.static(public)); // ✅ 坑3路由必须在所有中间件之后定义且路径要匹配 app.get(/, (req, res) { // 渲染 views/index.ejs传入数据对象 res.render(index, { title: 我的首页, message: Hello from EJS! }); }); app.listen(PORT, () { console.log(Server running on http://localhost:${PORT}); });常见错误还原错误1app.set(views, ...)写在app.set(view engine, ...)之后 → Express 找不到模板路径报Failed to lookup view。错误2忘了app.use(express.static(public))→ 浏览器控制台疯狂报GET http://localhost:3000/style.css net::ERR_ABORTED 404页面光秃秃没样式。错误3res.render(index)写成res.render(./views/index)→ Express 会拼成./views/./views/index.ejs路径爆炸。2.4 编写 index.ejs验证基础渲染在views/index.ejs中写!DOCTYPE html html head meta charsetUTF-8 title% title %/title link relstylesheet href/style.css !-- 注意/ 开头走 static 中间件 -- /head body h1% message %/h1 p当前时间% new Date().toLocaleString() %/p /body /html同时在public/style.css中写一行测试样式body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto; } h1 { color: #2c3e50; }启动服务node app.js打开http://localhost:3000如果看到带样式的标题和时间恭喜——你的 EJS 环境已通。如果失败请回头检查上面三个“坑”90% 的问题都在这里。实操心得我见过太多人花两小时查“EJS 不渲染”最后发现只是views文件夹名拼错了或者app.set(views, ...)漏写了。建议把console.log(app.get(views))加在app.listen()前确认路径输出是否正确。这是最朴实也最有效的调试手段。3. EJS 核心语法实战从变量插入到布局复用拒绝“只会 % %”很多教程只教% %和% %导致开发者遇到复杂页面就懵导航栏要复用怎么办不同页面要共享头部 footer 怎么办数据要分页怎么写其实 EJS 提供了四类核心能力覆盖 95% 的模板需求。下面用真实场景代码演示每段都附带“为什么这么写”的原理。3.1 变量输出% %vs%- %的生死之别% const rawHtml strong危险内容/strong; % % const safeText 普通文本; % !-- ✅ 安全输出自动转义 HTML 实体 -- p% safeText %/p !-- 输出普通文本 -- p% rawHtml %/p !-- 输出lt;stronggt;危险内容lt;/stronggt; -- !-- ⚠️ 非安全输出直接插入 HTML仅用于可信内容 -- p%- rawHtml %/p !-- 输出strong危险内容/strong加粗显示 --原理% %调用的是escapeHTML()函数把变成lt;变成gt;防止 XSS 攻击。这是 ExpressEJS 的默认安全策略。只有当你明确知道rawHtml是后端生成的、绝对可信的 HTML比如富文本编辑器保存的内容才用%- %。日常开发中99% 的场景用% %就够了。踩坑记录曾有个项目用户昵称字段用了%- %渲染结果有人昵称设为scriptalert(1)/script所有访问他主页的人都弹窗。修复方案就是统一换成% %前端再用DOMPurify做二次过滤。3.2 条件与循环用 JavaScript 原生语法不造轮子EJS 不提供if或{{#each}}这种语法糖它直接让你写 JS。好处是——你写的 JS就是 JS!-- 条件判断 -- % if (user user.role admin) { % button classbtn-danger删除用户/button % } else if (user) { % button classbtn-secondary编辑资料/button % } else { % a href/login classbtn-primary登录/a % } % !-- 数组遍历推荐 forEach语义清晰 -- ul classproduct-list % products.forEach(function(product) { % li classproduct-item h3% product.name %/h3 p¥% product.price.toFixed(2) %/p button onclickaddToCart(% product.id %)加入购物车/button /li % }); % /ul !-- for 循环兼容性更好适合老项目 -- % for (let i 0; i comments.length; i) { % div classcomment strong% comments[i].author %:/strong p% comments[i].content %/p /div % } %关键细节forEach回调函数里product是局部变量不会污染全局作用域for循环中i是循环变量注意别在循环外误用所有 JS 代码块% ... %内不能有return会中断渲染但可以break/continue。3.3 局部模板Partials把重复代码抽成“零件”这是 EJS 最被低估的能力。所谓partials就是把公共模块如 header、footer、侧边栏单独抽成.ejs文件然后在主模板里“引用”。它不是黑魔法就是一次include在views/partials/header.ejs中写header classsite-header nav a href/首页/a a href/products商品/a a href/about关于/a /nav /header在views/index.ejs中引用!DOCTYPE html html headtitle% title %/title/head body !-- ✅ 正确使用相对路径从 views 目录开始算 -- %- include(partials/header) % main h1% message %/h1 /main !-- ✅ footer 同理 -- %- include(partials/footer) % /body /html为什么路径是partials/header而不是./partials/header因为include()是 EJS 的内置函数它的工作目录就是app.set(views, ...)指定的根目录即./views。你写partials/headerEJS 自动拼成./views/partials/header.ejs。加./反而会报错。实操技巧include()支持传参让局部模板更灵活。比如header.ejs需要动态标题%- include(partials/header, { pageTitle: 用户列表页 }) %然后在header.ejs里用% pageTitle || 默认标题 %接收。3.4 布局模板Layouts一套壳千张脸partials解决“复用零件”layouts解决“统一外壳”。想象一个网站所有页面都有相同的htmlheadbody结构只有main内容不同。这时你应该用layout.ejs作为母版创建views/layouts/main.ejs!DOCTYPE html html head meta charsetUTF-8 title% title || 我的网站 %/title link relstylesheet href/style.css /head body %- include(partials/header) % main classcontainer !-- ✅ 关键用 %- body % 占位子页面内容将注入此处 -- %- body % /main %- include(partials/footer) % /body /html创建子页面views/dashboard.ejs不写完整 HTML只写main里的内容h2仪表盘/h2 div classstats-grid div classstat-card用户数% stats.users %/div div classstat-card订单数% stats.orders %/div /div在路由中指定 layoutapp.get(/dashboard, (req, res) { res.render(dashboard, { title: 管理后台 - 仪表盘, stats: { users: 1247, orders: 89 } }); });原理EJS 本身不内置 layout 功能但res.render()会把子模板内容作为字符串传给 layout 的body变量。你只需在 layout 里用%- body %接收即可。这是最轻量、最可控的布局方案。注意如果你用express-ejs-layouts库它会自动帮你注入body但会增加一层抽象。我建议新手先手写 layout理解透原理后再用库。4. 生产环境避坑指南EJS 在真实项目中的六个血泪教训EJS 上手快但真正在高流量、多团队协作的生产环境里一堆隐藏雷等着你。下面这些不是理论推演而是我在线上系统里亲手踩过、修过、监控过的实战教训每一条都配了可落地的解决方案。4.1 错误1模板文件编码为 GBK导致中文乱码成 “æäº›æ‡æ¬”现象本地开发一切正常部署到 Linux 服务器后.ejs文件里的中文全变成乱码页面显示一堆方块或问号。根因Windows 记事本默认保存为 GBK 编码而 Linux 服务器及 Node.js默认按 UTF-8 读取文件。EJS 读取模板时把 GBK 字节流当 UTF-8 解码必然错乱。解决方案开发阶段强制所有.ejs文件用 UTF-8 无 BOM 编码保存。VS Code 用户右下角点击编码名称 → 选择 “Save with Encoding” → “UTF-8”。CI/CD 阶段在构建脚本中加入校验Linux 下# 检查 views 目录下所有 .ejs 文件是否为 UTF-8 find ./views -name *.ejs -exec file -i {} \; | grep -v charsetutf-8终极保险在app.js中显式指定模板编码Node.js 16const ejs require(ejs); ejs.options { ...ejs.options, encoding: utf8 // 强制以 UTF-8 读取模板文件 };个人经验这个坑我踩过三次。第一次花了 4 小时查 nginx 配置第二次怀疑是数据库连接问题第三次才意识到是文件编码。现在我的 VS Code 设置里files.encoding: utf8是第一行。4.2 错误2res.render()传入 undefined 数据页面崩溃白屏现象某个路由偶尔返回空白页日志里没有错误res.render()调用后直接结束。根因EJS 渲染时遇到undefined或null比如% user.name %中user是undefinedEJS 默认抛出TypeError: Cannot read property name of undefined但 Express 默认错误处理中间件没捕获导致请求静默失败。解决方案防御性编程永远假设数据可能为空!-- ❌ 危险 -- h2% user.name %/h2 !-- ✅ 安全推荐 -- h2% user?.name || 未知用户 %/h2 !-- ✅ 安全兼容老 Node -- h2% (user user.name) || 未知用户 %/h2全局错误兜底在 Express 中添加错误处理中间件// 放在所有路由之后 app.use((err, req, res, next) { console.error(Template render error:, err); res.status(500).render(error, { message: 页面加载失败请稍后重试, error: process.env.NODE_ENV development ? err.message : {} }); });4.3 错误3include()路径错误报Error: Could not find include file现象%- include(partials/header) %报错提示找不到文件但文件明明存在。排查链路必须按顺序检查app.set(views, ...)输出的路径是否正确console.log(app.get(views))检查include()路径是否相对于views目录不能有./或../检查文件扩展名include(partials/header)会自动尝试header.ejs、header.html但如果你文件叫header.ejs.bak它找不到检查大小写Linux 文件系统区分大小写Header.ejs和header.ejs是两个文件。快速验证法在app.js中临时加一段代码手动读取文件const fs require(fs); console.log(fs.readFileSync(./views/partials/header.ejs, utf8));如果这行报错说明路径或权限有问题如果不报错那一定是include()调用方式不对。4.4 错误4大量include()导致渲染变慢首屏 TTFB 超过 2s现象页面包含 10 个include()用户明显感觉到卡顿Chrome DevTools 显示TTFBTime to First Byte飙升。根因每个include()都是一次文件 I/O 操作。Node.js 是单线程同步读取多个小文件会阻塞事件循环。优化方案合并局部模板把高频一起出现的partials合并成一个文件比如nav-and-search.ejs启用 EJS 缓存开发关生产开if (process.env.NODE_ENV production) { app.set(view cache, true); // 启用模板缓存 }开启后EJS 第一次读取并编译模板后续直接执行编译后的函数I/O 归零预编译模板高级用ejs.compile()提前编译存入内存const compiledIndex ejs.compile( fs.readFileSync(./views/index.ejs, utf8) ); // 使用时compiledIndex({ title: xxx })4.5 错误5%- body %在 layout 中被多次渲染页面结构错乱现象一个页面里出现了两次 header或者 footer 被渲染了三遍。根因在子模板中误写了%- include(layouts/main) %而不是靠res.render()的机制自动注入body。include()是简单文本替换它会把 layout 内容原样插入如果 layout 里又有%- body %而你又在子模板里手动调用了include就会形成递归嵌套。正确姿势子模板如dashboard.ejs只写内容不写 HTML 结构Layout如main.ejs只写结构用%- body %占位res.render(dashboard, {...})会自动把dashboard.ejs的内容作为body传给 layout。一句话口诀include()是“复制粘贴”res.render()layout是“内容注入”。二者不可混用。4.6 错误6EJS 模板被搜索引擎抓取为纯文本SEO 效果差现象Google 搜索你的网站关键词结果页显示的是% title %这样的原始代码而不是真实标题。根因EJS 是服务端渲染SSR但如果你的页面是通过 AJAX 加载的或者 Nginx 配置错误返回了.ejs源文件搜索引擎爬虫就看不到渲染后的内容。验证方法在 Chrome 里按CtrlU查看网页源代码确认看到的是h1Hello from EJS!/h1还是h1% message %/h1用 curl 模拟爬虫curl -I http://your-domain.com检查Content-Type是否为text/html。解决方案确保 Express 路由返回的是text/html不是text/plainNginx 配置中禁止直接暴露.ejs文件location ~ \.ejs$ { deny all; }在app.js中添加 SEO 友好头app.use((req, res, next) { res.set(X-Content-Type-Options, nosniff); res.set(X-Frame-Options, DENY); next(); });最后提醒EJS 本身不影响 SEO影响 SEO 的是你的部署方式和 HTTP 响应头。只要curl http://localhost:3000返回的是渲染后的 HTML搜索引擎就能正常索引。5. EJS 进阶实战用真实业务场景串联所有知识点前面讲的都是“零件”现在我们组装一台“整车”——一个真实的用户管理后台页面。它会用到路由传参、partials 复用、layout 统一、条件判断、循环列表、表单提交、错误提示。代码全部可运行你复制粘贴就能看到效果。5.1 需求描述与最终效果我们要实现一个/users页面功能包括展示用户列表从模拟数据读取每个用户显示姓名、邮箱、状态激活/禁用状态旁有“启用/禁用”按钮点击发送 AJAX 请求页面顶部有搜索框可按姓名过滤全局 header 和 footer 复用无用户时显示友好提示。最终效果一个干净、可交互、符合生产标准的后台列表页。5.2 完整代码实现含注释1.app.js—— 路由与数据准备const express require(express); const app express(); // 设置视图 app.set(view engine, ejs); app.set(views, ./views); // 静态资源 app.use(express.static(public)); app.use(express.urlencoded({ extended: true })); // 解析表单 app.use(express.json()); // 解析 JSON // 模拟用户数据实际项目中从数据库读取 const users [ { id: 1, name: 张三, email: zhangsanexample.com, active: true }, { id: 2, name: 李四, email: lisiexample.com, active: false }, { id: 3, name: 王五, email: wangwuexample.com, active: true } ]; // GET /users渲染用户列表页 app.get(/users, (req, res) { const { q } req.query; // 获取搜索关键词 let filteredUsers users; if (q) { filteredUsers users.filter(user user.name.includes(q) || user.email.includes(q) ); } res.render(users, { title: 用户管理, users: filteredUsers, searchQuery: q }); }); // POST /users/toggle/:id切换用户状态AJAX 接口 app.post(/users/toggle/:id, (req, res) { const userId parseInt(req.params.id); const user users.find(u u.id userId); if (user) { user.active !user.active; return res.json({ success: true, active: user.active }); } res.status(404).json({ success: false, error: 用户不存在 }); }); app.listen(3000, () { console.log(Server running on http://localhost:3000); });2.views/layouts/main.ejs—— 全局布局!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title% title %/title link relstylesheet href/style.css /head body %- include(partials/header) % main classmain-content div classcontainer %- body % /div /main %- include(partials/footer) % !-- 公共 JS -- script src/script.js/script /body /html3.views/partials/header.ejs—— 复用头部header classnavbar div classcontainer h1 classlogoAdmin Panel/h1 nav classnav-links a href/users用户管理/a a href/settings系统设置/a /nav /div /header4.views/partials/footer.ejs—— 复用底部footer classsite-footer div classcontainer pcopy; % new Date().getFullYear() % Admin System. All rights reserved./p /div /footer5.views/users.ejs—— 核心页面只写内容h1 classpage-title% title %/h1 !-- 搜索表单 -- form methodGET classsearch-form input typetext nameq placeholder搜索姓名或邮箱... value% searchQuery % button typesubmit搜索/button % if (searchQuery) { % a href/users classclear-search清除/a % } % /form !-- 用户列表 -- % if (users.length 0) { % div classempty-state p暂无用户。请检查搜索关键词或a href/users查看全部用户/a。/p /div % } else { % table classuser-table thead tr th姓名/th th邮箱/th th状态/th th操作/th /tr /thead tbody % users.forEach(function(user) { % tr td% user.name %/td td% user.email %/td td span classstatus-badge % user.active ? active : inactive % % user.active ? 已激活 : 已禁用 % /span /td td button classtoggle-btn >* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Helvetica Neue, Arial, sans-serif; line-height: 1.6; } .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; } .navbar { background: #3498db; color: white; padding: 1rem 0; } .logo { display: inline-block; font-size: 1.5rem; margin-right: 2rem; } .search-form { margin: 1.5rem 0; } .search-form input { padding: 0.5rem; width: 300px; margin-right: 0.5rem; } .user-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } .user-table th, .user-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #eee; } .status-badge { padding: 0.25rem 0.5rem; border-radius: 3px; font-size: 0.85rem; } .status-badge.active { background: #2ecc71; color: white; } .status-badge.inactive { background: #e74c3c; color: white; } .toggle-btn { background: #3498db; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 3px; cursor: pointer; } .toggle-btn:hover { background: #2980b9; }5.3 运行与验证步骤创建上述所有文件确保目录结构正确在项目根目录执行node app.js浏览器打开http://localhost:3000/users