首页 / Node.js 教程 / Nunjucks 模板与 MVC 架构

Node.js 教程

Nunjucks 模板与 MVC 架构

本教程共 76 篇 · 第 46 篇 · 更新于 2026-07-25 · 约 4 分钟阅读

Node.jskoaNunjucksMVC模板

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>你好,&lt;script&gt;alert(1)&lt;/script&gt;</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。