项目目标
模板引擎是一个紧凑的编译问题:分词、解析、生成函数。本项目实现一个小型引擎,编译一次后反复渲染,支持插值、条件与循环,并在 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
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('<b>');
});
test('if block honours emptiness', () => {
expect(render('{{#if items}}yes{{/if}}', { items: [] }, { escape: false })).toBe('');
});