Nunjucks 模板与 MVC 架构
本教程共 76 篇 · 第 46 篇 · 更新于 2026-07-25 · 约 4 分钟阅读
46. Nunjucks 模板与 MVC 架构
本节目标:Nunjucks 模板引擎和用 MVC 分层架构组织代码。
路由能分发请求了,但直接返回字符串拼接 HTML 太原始。这一章我们引入 Nunjucks 模板引擎,把项目按 MVC(Model-View-Controller)架构重新组织,让代码各司其职。
什么是模板引擎
模板引擎的工作很简单:拿「模板文件 + 数据」拼出最终字符串。你自己用模板字符串也能干:
const html = `<h1>你好,${name}</h1>`;
但页面复杂起来,条件判断、循环列表、HTML 转义这些琐事会让你疯掉。Nunjucks 是 Mozilla 出品的模板引擎,语法接近 Python 的 Jinja2,在 Node.js 和浏览器都能跑。
安装与基本用法
npm install nunjucks
独立的渲染示例:
import nunjucks from 'nunjucks';
const env = nunjucks.configure('views', { autoescape: true });
const html = env.render('hello.html', { name: '<script>alert(1)</script>' });
console.log(html);
// <h1>你好,<script>alert(1)</script></h1>
views/hello.html:
<h1>你好,{{ name }}</h1>
注意到恶意脚本被自动转义了。autoescape: true 是安全底线,永远别关。
模板继承
网站的大部分页面结构都一样:头部导航、中间内容、底部版权。用继承可以避免重复:
views/base.html:
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}默认标题{% endblock %}</title>
</head>
<body>
<nav>网站导航</nav>
{% block content %}{% endblock %}
<footer>版权所有</footer>
</body>
</html>
views/home.html:
{% extends 'base.html' %}
{% block title %}首页{% endblock %}
{% block content %}
<h1>欢迎,{{ user.name }}</h1>
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% endblock %}
子模板只填自己要变的「块」,其余全部沿用父模板。改一次头部,全站生效。
集成到 koa
把 Nunjucks 挂到 koa 的 ctx 上,让控制器能直接调用 ctx.render:
view.mjs:
import nunjucks from 'nunjucks';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const env = nunjucks.configure(join(__dirname, 'views'), {
autoescape: true,
noCache: process.env.NODE_ENV !== 'production'
});
export default function viewMiddleware() {
return async (ctx, next) => {
ctx.render = (view, model = {}) => {
ctx.type = 'text/html; charset=utf-8';
ctx.body = env.render(view, Object.assign({}, ctx.state, model));
};
await next();
};
}
app.mjs:
import Koa from 'koa';
import Router from '@koa/router';
import { bodyParser } from '@koa/bodyparser';
import viewMiddleware from './view.mjs';
const app = new Koa();
const router = new Router();
app.use(viewMiddleware());
router.get('/', async (ctx) => {
ctx.render('home.html', { user: { name: 'Bob' }, items: ['A', 'B'] });
});
app.use(router.routes());
app.listen(3000);
Tip
noCache在开发环境关闭缓存,改模板不用重启服务器;生产环境一定要开缓存,否则每次请求都读磁盘,性能血崩。
MVC 目录结构
现在我们把项目按 MVC 拆分:
project/
├── app.mjs # 入口:组装中间件和路由
├── controllers/ # C:控制器,处理业务逻辑
│ ├── index.mjs
│ └── user.mjs
├── views/ # V:视图,Nunjucks 模板
│ ├── base.html
│ ├── home.html
│ └── user.html
├── models/ # M:模型,数据操作(后面接数据库)
│ └── user.mjs
├── static/ # 静态资源
│ └── style.css
└── package.json
controllers/index.mjs:
export async function home(ctx) {
ctx.render('home.html', {
title: '首页',
user: ctx.state.user
});
}
controllers/user.mjs:
export async function profile(ctx) {
const userId = ctx.params.id;
// 这里将来查数据库
ctx.render('user.html', {
title: '用户资料',
user: { id: userId, name: 'Alice' }
});
}
app.mjs 只负责「组装」:
import Router from '@koa/router';
import * as indexCtrl from './controllers/index.mjs';
import * as userCtrl from './controllers/user.mjs';
const router = new Router();
router.get('/', indexCtrl.home);
router.get('/users/:id', userCtrl.profile);
自动扫描控制器
懒得每加一个控制器就手动导入?写个自动扫描:
import { readdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export async function autoRegister(router, dir = 'controllers') {
const files = readdirSync(join(__dirname, dir))
.filter(f => f.endsWith('.mjs'));
for (const file of files) {
const mod = await import(`./${dir}/${file}`);
for (const [name, fn] of Object.entries(mod)) {
if (typeof fn === 'function') {
// 约定:函数名即路由,这里简化处理
router.get(`/${name}`, fn);
}
}
}
}
生产环境更常见的做法是显式导入,自动扫描虽然省事,但 IDE 跳转和静态分析会受影响。小项目玩玩可以,大项目还是老老实实 import。