跳到主要内容

模板引擎

模板引擎让你可以使用静态模板文件,在运行时将变量替换为实际值,生成最终的 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>&copy; 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 &copy; 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>&copy; 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 支持 extendsblock 实现模板继承:

//- 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 &copy; 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 使用双大括号 {{ }} 作为表达式:

{{!-- views/index.hbs --}}
<!DOCTYPE html>
<html>
<head>
<title>{{title}}</title>
</head>
<body>
<h1>{{title}}</h1>
<p>欢迎,{{user.name}}!</p>

{{!-- 不转义输出 --}}
<div>{{{htmlContent}}}</div>
</body>
</html>

变量输出

{{!-- 转义输出(推荐) --}}
<h1>{{title}}</h1>
<p>{{message}}</p>

{{!-- 嵌套属性 --}}
<p>{{user.profile.name}}</p>

{{!-- 不转义输出 --}}
<div>{{{rawHtml}}}</div>

{{!-- 注释(不会出现在输出中) --}}
{{!-- 这是注释 --}}

内置助手

Handlebars 提供了几个内置助手:

{{!-- #if 条件判断 --}}
{{#if user}}
<p>欢迎,{{user.name}}!</p>
{{else}}
<p>请先登录</p>
{{/if}}

{{!-- #unless(与 if 相反) --}}
{{#unless banned}}
<p>你可以正常使用</p>
{{/unless}}

{{!-- #each 循环 --}}
<ul>
{{#each users}}
<li>{{this.name}} - {{this.email}}</li>
{{/each}}
</ul>

{{!-- 带索引的循环 --}}
{{#each users}}
<tr>
<td>{{@index}}</td>
<td>{{this.name}}</td>
</tr>
{{/each}}

{{!-- 遍历对象 --}}
{{#each settings}}
<p>{{@key}}: {{this}}</p>
{{/each}}

{{!-- #with 改变上下文 --}}
{{#with user}}
<p>姓名:{{name}}</p>
<p>邮箱:{{email}}</p>
{{/with}}

自定义助手

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');
});

在模板中使用:

{{!-- 简单助手 --}}
<h1>{{uppercase title}}</h1>

{{!-- 带块的助手 --}}
{{#list users}}
{{name}} - {{email}}
{{/list}}

{{!-- 条件助手 --}}
{{#if (eq user.role 'admin')}}
<span>管理员</span>
{{/if}}

{{!-- 格式化日期 --}}
<span>{{formatDate user.createdAt}}</span>

Partials(局部模板)

Handlebars 支持 Partials 实现代码复用:

// 注册 partial
hbs.registerPartial('header', `
<header>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
</nav>
</header>
`);

// 或从文件加载
hbs.registerPartials(__dirname + '/views/partials');
{{!-- 使用 partial --}}
{{> header}}

{{!-- 传递参数给 partial --}}
{{> userCard user}}

{{!-- 内联 partial --}}
{{#inline 'myPartial'}}
<p>这是内联 partial</p>
{{/inline}}

{{> myPartial}}

布局模板

使用 express-handlebars 可以轻松实现布局:

views/
├── layouts/
│ └── main.hbs # 主布局
├── partials/
│ ├── header.hbs
│ └── footer.hbs
└── index.hbs # 页面模板
{{!-- views/layouts/main.hbs --}}
<!DOCTYPE html>
<html>
<head>
<title>{{title}}</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
{{> header}}

<main>
{{{body}}}
</main>

{{> footer}}
</body>
</html>
{{!-- views/index.hbs --}}
<h1>首页</h1>
<p>欢迎来到首页</p>

模板引擎对比

特性EJSPugHandlebars
语法风格原生 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 中三种主流模板引擎的使用方法:

  1. EJS:语法简单,学习成本低,适合快速开发
  2. Pug:代码简洁,支持模板继承,适合追求代码质量的团队
  3. Handlebars:逻辑分离,前后端通用,适合大型项目

选择模板引擎时,应考虑团队熟悉度、项目需求和可维护性。无论选择哪种引擎,都要注意安全性,始终转义用户输入。

练习

  1. 使用 EJS 创建一个用户列表页面,包含分页功能
  2. 使用 Pug 的模板继承功能创建一个包含头部、主体和脚部的布局
  3. 使用 Handlebars 注册自定义助手,实现文章摘要截断功能
  4. 创建一个博客系统,包含文章列表、文章详情和分类页面

参考资料