← 中文资源

简体中文版本

构建文本模板渲染库

把占位符、条件与循环渲染为文本、按需转义 HTML,并提供库 API 与命令行两种用法。

阅读英文原文

项目目标

模板引擎是一个紧凑的编译问题:分词、解析、生成函数。本项目实现一个小型引擎,编译一次后反复渲染,支持插值、条件与循环,并在 HTML 与纯文本模式间切换转义,可用于生成邮件与报表。

核心特性

  • 编译时解析模板并插值变量
  • 支持 if 与 else 块的真值判断
  • 支持对数组的循环并可访问项与下标
  • HTML 模式转义取值,文本模式保留原样

架构与分层

编译器把模板分词为文本与标签,再据此生成渲染函数;运行时提供变量查找、真值与转义辅助;CLI 读取模板与 JSON 上下文并输出渲染结果。编译与渲染分离,使模板只解析一次。

实施阶段

六个阶段为:分词器、编译器、运行时辅助、渲染上下文、CLI、测试与加固。HTML 模式与文本模式通过同一个转义收口点切换。

测试与验收

用 vitest 运行。分词测试覆盖纯文本、插值与畸形标签;编译测试断言变量、条件与循环渲染正确,且 HTML 模式转义脚本标签而文本模式保留原样;CLI 测试用样例渲染并断言输出。

延伸方向

日期格式化等过滤器、局部模板与布局继承都是分词器与运行时的增量。编译产出普通函数,因此运行时增长而不触及解析器。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function compile(source, { escape = true } = {}) {
  const tokens = tokenize(source);
  const body = tokens.map((token) => {
    if (token.type === 'text') return 'out.push(' + JSON.stringify(token.value) + ');';
    if (token.type === 'var') {
      const expr = 'lookup(ctx, ' + JSON.stringify(token.path) + ')';
      return 'out.push(' + (escape ? 'esc(' + expr + ')' : expr) + ');';
    }
    return '';
  }).join('\n');
  return new Function('ctx', 'lookup', 'esc', 'const out = [];\n' + body + '\nreturn out.join("");');
}
mkdir template-engine && cd template-engine
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "template-engine",
  "type": "module",
  "scripts": {
    "render": "node src/cli.js",
    "test": "vitest run"
  }
}
export function tokenize(source) {
  const tokens = [];
  const re = /\{\{\s*([#/]?)([^}]+?)\s*\}\}/g;
  let last = 0;
  let match;
  while ((match = re.exec(source))) {
    if (match.index > last) tokens.push({ type: 'text', value: source.slice(last, match.index) });
    const [, marker, expr] = match;
    if (marker === '#') tokens.push({ type: 'block', name: expr.trim() });
    else if (marker === '/') tokens.push({ type: 'endblock', name: expr.trim() });
    else tokens.push({ type: 'var', path: expr.trim() });
    last = re.lastIndex;
  }
  if (last < source.length) tokens.push({ type: 'text', value: source.slice(last) });
  return tokens;
}
import { tokenize } from './tokenize.js';

export function compile(source, { escape = true } = {}) {
  const tokens = tokenize(source);
  const lines = [];
  const stack = [];
  for (const token of tokens) {
    if (token.type === 'text') lines.push('out.push(' + JSON.stringify(token.value) + ');');
    else if (token.type === 'var') {
      const expr = 'lookup(ctx, ' + JSON.stringify(token.path) + ')';
      lines.push('out.push(' + (escape ? 'esc(' + expr + ')' : expr) + ');');
    } else if (token.type === 'block') {
      const [kind, arg] = token.name.split(/\s+/, 2);
      if (kind === 'if') { lines.push('if (truthy(' + arg + ')) {'); stack.push('if'); }
      else if (kind === 'each') {
        lines.push('(lookup(ctx, ' + JSON.stringify(arg) + ') || []).forEach((item, i) => {');
        lines.push('ctx = { ...ctx, item, index: i };');
        stack.push('each');
      }
    } else if (token.type === 'endblock') { lines.push('}'); stack.pop(); }
  }
  return new Function('ctx', 'lookup', 'truthy', 'esc', 'const out = [];\n' + lines.join('\n') + '\nreturn out.join("");');
}
export function lookup(scope, path) {
  return path.split('.').reduce((value, key) => (value == null ? undefined : value[key]), scope);
}

export function truthy(value) {
  if (Array.isArray(value)) return value.length > 0;
  return Boolean(value);
}

export function escapeHtml(value) {
  return String(value ?? '')
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
import { compile } from './compile.js';
import { lookup, truthy, escapeHtml } from './runtime.js';

export function render(template, context, options = {}) {
  const fn = compile(template, options);
  const esc = options.escape === false ? (v) => String(v ?? '') : escapeHtml;
  return fn(context, lookup, truthy, esc);
}

export function createRenderer(template, options = {}) {
  const fn = compile(template, options);
  const esc = options.escape === false ? (v) => String(v ?? '') : escapeHtml;
  return (context) => fn(context, lookup, truthy, esc);
}
import { readFile, writeFile } from 'node:fs/promises';
import { render } from './render.js';

const [templatePath, contextPath, outPath] = process.argv.slice(2);
if (!templatePath || !contextPath) {
  console.error('usage: render <template> <context.json> [out]');
  process.exit(1);
}
const template = await readFile(templatePath, 'utf8');
const context = JSON.parse(await readFile(contextPath, 'utf8'));
const output = render(template, context, { escape: outPath?.endsWith('.html') });
if (outPath) await writeFile(outPath, output);
else process.stdout.write(output);
import { render } from '../src/render.js';

test('interpolates, escapes, and loops', () => {
  const tpl = 'Hi {{name}}!{{#each items}} {{item}}{{/each}}';
  expect(render(tpl, { name: 'Sam', items: ['a', 'b'] }, { escape: false })).toBe('Hi Sam! a b');
});

test('html mode escapes values', () => {
  expect(render('{{x}}', { x: '<b>' })).toBe('&lt;b&gt;');
});

test('if block honours emptiness', () => {
  expect(render('{{#if items}}yes{{/if}}', { items: [] }, { escape: false })).toBe('');
});