模板引擎
模板引擎让你可以使用静态模板文件,在运行时将变量替换为实际值,生成最终的 HTML 页面。Express 支持多种模板引擎,包括 EJS、Pug、Handlebars 等。本章将详细介绍这些模板引擎的使用方法和最佳实践。
为什么需要模板引擎?
在传统的 Web 开发中,我们需要动态生成 HTML 页面。如果直接拼接字符串,代码会变得难以维护:
// 不推荐:字符串拼接
app.get('/profile', (req, res) => {
const user = { name: 'John', email: '[email protected]' };
const html = `
<!DOCTYPE html>
<html>
<head><title>${user.name} 的资料</title></head>
<body>
<h1>${user.name}</h1>
<p>邮箱:${user.email}</p>
</body>
</html>
`;
res.send(html);
});
模板引擎将 HTML 结构与数据分离,让代码更清晰、更易维护:
// 推荐:使用模板引擎
app.get('/profile', (req, res) => {
res.render('profile', {
title: '用户资料',
user: { name: 'John', email: '[email protected]' }
});
});
Express 模板引擎配置
基本配置
无论使用哪种模板引擎,Express 的配置方式基本一致:
const express = require('express');
const app = express();
// 设置模板文件目录(默认为 views 目录)
app.set('views', './views');
// 设置模板引擎
app.set('view engine', 'ejs'); // 或 'pug'、'hbs' 等
渲染模板
使用 res.render() 方法渲染模板:
// 渲染模板并传入数据
app.get('/', (req, res) => {
res.render('index', {
title: '首页',
message: '欢迎来到 Express'
});
});
// 如果没有设置 view engine,需要指定扩展名
app.get('/about', (req, res) => {
res.render('about.ejs', { title: '关于我们' });
});
模板缓存
在生产环境中,Express 会自动缓存编译后的模板函数以提升性能:
// 开发环境禁用缓存(方便调试)
if (process.env.NODE_ENV === 'development') {
app.set('view cache', false);
}
// 生产环境启用缓存(默认行为)
if (process.env.NODE_ENV === 'production') {
app.set('view cache', true);
}
EJS 模板引擎
EJS(Embedded JavaScript)是最简单的模板引擎之一,语法接近原生 HTML,学习成本极低。
安装与配置
npm install ejs
const app = express();
app.set('view engine', 'ejs');
基本语法
EJS 使用 <% %> 标签嵌入 JavaScript 代码:
| 标签 | 说明 | 示例 |
|---|---|---|
<%= %> | 输出并转义 HTML | <%= name %> |
<%- %> | 输出不转义 HTML | <%- htmlContent %> |
<% %> | 执行 JavaScript 代码(不输出) | <% if (user) { %> |
<%# %> | 注释 | <%# 这是注释 %> |
<%% | 输出 <% 字符 | <%% |
变量输出
<!-- views/user.ejs -->
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
</head>
<body>
<h1>用户信息</h1>
<!-- 转义输出(推荐,防止 XSS) -->
<p>姓名:<%= user.name %></p>
<p>邮箱:<%= user.email %></p>
<!-- 不转义输出(谨慎使用,有 XSS 风险) -->
<div><%- user.bio %></div>
<!-- 默认值 -->
<p>简介:<%= user.bio || '暂无简介' %></p>
</body>
</html>
条件判断
<!-- if-else 语句 -->
<% if (user) { %>
<p>欢迎回来,<%= user.name %>!</p>
<% } else { %>
<p>请先 <a href="/login">登录</a></p>
<% } %>
<!-- 多重条件 -->
<% if (user.role === 'admin') { %>
<span class="badge">管理员</span>
<% } else if (user.role === 'editor') { %>
<span class="badge">编辑</span>
<% } else { %>
<span class="badge">用户</span>
<% } %>
循环遍历
<!-- 遍历数组 -->
<ul>
<% users.forEach(function(user) { %>
<li>
<%= user.name %> - <%= user.email %>
</li>
<% }); %>
</ul>
<!-- 遍历对象 -->
<% for (const key in settings) { %>
<p><%= key %>: <%= settings[key] %></p>
<% } %>
<!-- 带索引的循环 -->
<% users.forEach(function(user, index) { %>
<tr class="<%= index % 2 === 0 ? 'even' : 'odd' %>">
<td><%= index + 1 %></td>
<td><%= user.name %></td>
</tr>
<% }); %>
模板包含(Partials)
EJS 支持将公共部分拆分为独立文件,然后在其他模板中引入:
<!-- views/partials/header.ejs -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title><%= title %> - 我的网站</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
</nav>
</header>
<!-- views/partials/footer.ejs -->
<footer>
<p>© 2024 我的网站</p>
</footer>
</body>
</html>
<!-- views/index.ejs -->
<%- include('partials/header', { title: '首页' }) %>
<main>
<h1>欢迎来到首页</h1>
<p>这是首页内容</p>
</main>
<%- include('partials/footer') %>
注意:include 的路径是相对于当前模板文件的路径,也可以使用绝对路径(相对于 views 目录)。
布局模板
EJS 本身不支持布局继承,但可以通过手动实现:
<!-- views/layout.ejs -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title><%= title %></title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<%- include('partials/header') %>
<main>
<%- body %> <!-- 这里插入内容 -->
</main>
<%- include('partials/footer') %>
</body>
</html>
// 渲染时手动组合
app.get('/', (req, res) => {
const body = ejs.render('<h1>首页内容</h1>', {});
res.render('layout', { title: '首页', body });
});
或者使用 express-ejs-layouts 中间件:
npm install express-ejs-layouts
const expressEjsLayouts = require('express-ejs-layouts');
app.use(expressEjsLayouts);
app.set('layout', 'layout'); // 默认布局文件
// 现在可以直接渲染,内容会自动填充到布局的 <%- body %> 中
app.get('/', (req, res) => {
res.render('index', { title: '首页' });
});
自定义分隔符
如果不喜欢默认的 <% %> 分隔符,可以自定义:
app.set('view options', {
delimiter: '?', // 使用 <? ?> 代替 <% %>
openDelimiter: '[',
closeDelimiter: ']'
// 现在使用 <[ ]> 作为分隔符
});
Pug 模板引擎
Pug(原名 Jade)采用缩进式语法,简洁优雅,不需要写闭合标签。
安装与配置
npm install pug
app.set('view engine', 'pug');
基本语法
Pug 使用缩进表示层级关系,使用简洁的标签语法:
//- views/index.pug
doctype html
html
head
title= title
meta(charset='UTF-8')
link(rel='stylesheet', href='/css/style.css')
body
header
h1= title
nav
a(href='/') 首页
a(href='/about') 关于
main
p 欢迎来到 #{siteName}
footer
p © 2024 我的网站
编译后的 HTML:
<!DOCTYPE html>
<html>
<head>
<title>首页</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<h1>首页</h1>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
</nav>
</header>
<main>
<p>欢迎来到我的网站</p>
</main>
<footer>
<p>© 2024 我的网站</p>
</footer>
</body>
</html>
变量输出
//- 转义输出(推荐)
h1= title
p= message
//- 插值语法
p 欢迎回来,#{user.name}!
//- 不转义输出(谨慎使用)
div!= htmlContent
//- 属性中使用变量
a(href=user.url, class=user.active ? 'active' : '')= user.name
条件判断
//- if-else
if user
p 欢迎回来,#{user.name}!
else
p 请先
a(href='/login') 登录
//- unless(相当于 if not)
unless user.banned
p 你可以正常使用
//- case(相当于 switch)
case user.role
when 'admin'
span.badge 管理员
when 'editor'
span.badge 编辑
default
span.badge 用户
循环遍历
//- 遍历数组
ul
each user in users
li= user.name
//- 带索引
ul
each user, index in users
li(class=index % 2 === 0 ? 'even' : 'odd')
span= index + 1
span= user.name
//- 遍历对象
dl
each value, key in settings
dt= key
dd= value
//- 遍历时获取索引和数组长度信息
each user, index in users
- const isLast = index === users.length - 1
li(class={ last: isLast })= user.name
模板继承
Pug 支持 extends 和 block 实现模板继承:
//- views/layout.pug
doctype html
html
head
block head
title 默认标题
link(rel='stylesheet', href='/css/style.css')
body
header
block header
h1 默认头部
main
block content
p 默认内容
footer
block footer
p © 2024 我的网站
//- views/index.pug
extends layout
block head
title 首页 - 我的网站
link(rel='stylesheet', href='/css/home.css')
block content
h2 欢迎来到首页
p 这是首页的内容
//- 可以使用 append 和 prepend 追加内容
block sidebar
p 侧边栏内容
//- views/about.pug
extends layout
block head
title 关于我们
block content
h2 关于我们
p 这是关于页面
//- append 追加到 block 末尾
block append footer
p 联系我们:[email protected]
包含文件
//- 包含其他 Pug 文件
include partials/header
//- 包含纯文本文件(原样输出)
include README.md
//- 包含 HTML 文件
include partials/analytics.html
Mixin(混合)
Mixin 是可复用的模板片段,类似函数:
//- 定义 mixin
mixin userCard(user)
.user-card
img(src=user.avatar, alt=user.name)
h3= user.name
p= user.email
//- 使用 mixin
+userCard({ name: 'John', avatar: '/john.jpg', email: '[email protected]' })
//- 使用块内容
mixin article(title)
article
h2= title
if block
block
else
p 暂无内容
+article('文章标题')
p 这是文章内容
内联 JavaScript
//- 使用 - 执行代码(不输出)
- const items = ['苹果', '香蕉', '橙子']
- const isActive = true
//- 使用 = 输出表达式的值
p= items.join(', ')
//- 使用 = 输出表达式的值
p= isActive ? '激活' : '未激活'
Handlebars 模板引擎
Handlebars 是一个轻量级模板引擎,语法简洁,逻辑分离,适合前端和后端通用。
安装与配置
npm install hbs
app.set('view engine', 'hbs');
// 或使用 express-handlebars(功能更丰富)
// npm install express-handlebars
const exphbs = require('express-handlebars');
app.engine('hbs', exphbs.engine({
extname: '.hbs',
defaultLayout: 'main',
layoutsDir: 'views/layouts',
partialsDir: 'views/partials'
}));
app.set('view engine', 'hbs');
基本语法
Handlebars 使用双大括号 {{ }} 作为表达式:
变量输出
内置助手
Handlebars 提供了几个内置助手:
自定义助手
Handlebars 支持注册自定义助手:
const hbs = require('hbs');
// 简单助手
hbs.registerHelper('uppercase', function(str) {
return str.toUpperCase();
});
// 带块的助手
hbs.registerHelper('list', function(items, options) {
const itemsHtml = items.map(item =>
`<li>${options.fn(item)}</li>`
).join('');
return `<ul>${itemsHtml}</ul>`;
});
// 条件助手
hbs.registerHelper('eq', function(a, b) {
return a === b;
});
// 格式化日期
hbs.registerHelper('formatDate', function(date) {
return new Date(date).toLocaleDateString('zh-CN');
});
在模板中使用:
Partials(局部模板)
Handlebars 支持 Partials 实现代码复用:
// 注册 partial
hbs.registerPartial('header', `
<header>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
</nav>
</header>
`);
// 或从文件加载
hbs.registerPartials(__dirname + '/views/partials');
布局模板
使用 express-handlebars 可以轻松实现布局:
views/
├── layouts/
│ └── main.hbs # 主布局
├── partials/
│ ├── header.hbs
│ └── footer.hbs
└── index.hbs # 页面模板
模板引擎对比
| 特性 | EJS | Pug | Handlebars |
|---|---|---|---|
| 语法风格 | 原生 HTML + JS 标签 | 缩进式简洁语法 | 双大括号语法 |
| 学习曲线 | 低 | 中 | 低 |
| 代码量 | 较多 | 最少 | 中等 |
| 逻辑能力 | 强(完整 JS) | 中等 | 弱(助手系统) |
| 可读性 | 高 | 取决于习惯 | 高 |
| 前端通用 | 否 | 否 | 是 |
| 性能 | 中 | 高 | 高 |
| 布局继承 | 需插件 | 原生支持 | 需配置 |
选择建议
选择 EJS 如果:
- 团队熟悉 HTML,不想学习新语法
- 需要在模板中执行复杂 JavaScript 逻辑
- 项目迁移成本低
选择 Pug 如果:
- 追求代码简洁,减少重复标签
- 团队愿意学习新语法
- 需要强大的模板继承功能
选择 Handlebars 如果:
- 希望前后端模板统一
- 需要严格的逻辑分离
- 团队追求模板的可维护性
安全最佳实践
1. 始终转义用户输入
<!-- EJS -->
<p><%= userInput %></p> <!-- 转义 -->
<p><%- userInput %></p> <!-- 不转义,有 XSS 风险 -->
<!-- Pug -->
p= userInput <!-- 转义 -->
p!= userInput <!-- 不转义 -->
<!-- Handlebars -->
<p>{{userInput}}</p> <!-- 转义 -->
<p>{{{userInput}}}</p> <!-- 不转义 -->
2. 避免在模板中执行危险操作
// 危险:在模板中执行数据库操作
app.get('/', (req, res) => {
res.render('index', {
getData: () => User.find() // 不要这样做
});
});
// 安全:在路由中获取数据,再传给模板
app.get('/', async (req, res) => {
const users = await User.find();
res.render('index', { users });
});
3. 使用 CSP 防护
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"]
}
}));
4. 禁用客户端缓存敏感数据
app.get('/profile', (req, res) => {
res.set('Cache-Control', 'no-store');
res.render('profile', { user: req.user });
});
性能优化
1. 启用模板缓存
// 生产环境默认启用
app.set('view cache', process.env.NODE_ENV === 'production');
2. 减少模板复杂度
// 不推荐:模板中做复杂计算
app.get('/dashboard', (req, res) => {
res.render('dashboard', { users });
});
// 推荐:路由中预处理数据
app.get('/dashboard', async (req, res) => {
const users = await User.find();
const stats = {
total: users.length,
active: users.filter(u => u.isActive).length,
admins: users.filter(u => u.role === 'admin').length
};
res.render('dashboard', { users, stats });
});
3. 合理使用 Partials
将公共部分抽取为 Partials 可以减少重复代码,但过多的 Partials 会影响性能:
<!-- 推荐:抽取真正重复的部分 -->
<%- include('partials/header') %>
<%- include('partials/footer') %>
<!-- 不推荐:过度拆分 -->
<%- include('partials/head') %>
<%- include('partials/meta') %>
<%- include('partials/styles') %>
实战示例
完整的博客页面
// routes/blog.js
const router = require('express').Router();
const Post = require('../models/Post');
router.get('/', async (req, res) => {
const { page = 1, limit = 10 } = req.query;
const [posts, total] = await Promise.all([
Post.find()
.populate('author', 'name avatar')
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(parseInt(limit)),
Post.countDocuments()
]);
res.render('blog/index', {
title: '博客列表',
posts,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
});
router.get('/:slug', async (req, res) => {
const post = await Post.findOne({ slug: req.params.slug })
.populate('author', 'name avatar bio')
.populate('comments.user', 'name avatar');
if (!post) {
return res.status(404).render('404', { title: '页面未找到' });
}
res.render('blog/show', {
title: post.title,
post,
relatedPosts: await Post.find({
category: post.category,
_id: { $ne: post._id }
}).limit(3)
});
});
module.exports = router;
<!-- views/blog/index.ejs -->
<%- include('../partials/header', { title: title }) %>
<main class="blog-list">
<h1><%= title %></h1>
<% if (posts.length === 0) { %>
<p class="empty">暂无文章</p>
<% } else { %>
<% posts.forEach(function(post) { %>
<article class="post-card">
<h2>
<a href="/blog/<%= post.slug %>"><%= post.title %></a>
</h2>
<div class="meta">
<span class="author">
<img src="<%= post.author.avatar %>" alt="">
<%= post.author.name %>
</span>
<span class="date">
<%= new Date(post.createdAt).toLocaleDateString('zh-CN') %>
</span>
</div>
<p class="excerpt"><%= post.excerpt %></p>
<a href="/blog/<%= post.slug %>" class="read-more">阅读全文</a>
</article>
<% }); %>
<% } %>
<% if (pagination.pages > 1) { %>
<nav class="pagination">
<% if (pagination.page > 1) { %>
<a href="?page=<%= pagination.page - 1 %>">上一页</a>
<% } %>
<span><%= pagination.page %> / <%= pagination.pages %></span>
<% if (pagination.page < pagination.pages) { %>
<a href="?page=<%= pagination.page + 1 %>">下一页</a>
<% } %>
</nav>
<% } %>
</main>
<%- include('../partials/footer') %>
小结
本章介绍了 Express 中三种主流模板引擎的使用方法:
- EJS:语法简单,学习成本低,适合快速开发
- Pug:代码简洁,支持模板继承,适合追求代码质量的团队
- Handlebars:逻辑分离,前后端通用,适合大型项目
选择模板引擎时,应考虑团队熟悉度、项目需求和可维护性。无论选择哪种引擎,都要注意安全性,始终转义用户输入。
练习
- 使用 EJS 创建一个用户列表页面,包含分页功能
- 使用 Pug 的模板继承功能创建一个包含头部、主体和脚部的布局
- 使用 Handlebars 注册自定义助手,实现文章摘要截断功能
- 创建一个博客系统,包含文章列表、文章详情和分类页面