前端知库

怎样手写一个简单的模板字符串渲染函数?

用正则 /\{\{\s*([\w.]+)\s*\}\}/g 找出模板里的 {{ name }} 这样的占位符,在 replace 的回调里按点号路径从数据对象取值替换。要处理取不到的值、防止原型链上的属性被读出,插到 HTML 里时还要转义,否则用户输入会变成 XSS。

参考答案

简答

题目是:实现 render('你好,{{ user.name }},你有 {{ count }} 条消息', data),把双大括号里的变量换成数据里的值。思路是用一个正则找出所有 {{ ... }},把里面的变量名取出来,再去数据对象里查值替换上去。

String.prototype.replace 的第二个参数可以是函数,每找到一处匹配就调用一次,函数的返回值就是替换后的内容,参数里能拿到正则的捕获组,也就是括号里的变量名。变量名可能带点号,比如 user.name,就按点号拆开,从数据对象一层层往下取。

有三处要处理。取不到值时,返回空字符串还是保留原样,要事先约定,不能把 undefined 直接拼进去;取值时只读对象自己的属性,否则 {{ constructor }} 这类写法会读出原型上的函数;结果要插进 HTML 时,必须把 <、>、&、引号转义,不然用户名里写了 <img src=x onerror=...> 就会执行脚本。

要注意,这只是一个面试级别的简单实现,不支持循环、条件这些语法。真实项目里用框架自带的模板,它们默认会转义插值内容。

代码实现

JavaScript
function render(template, data, { escape = true } = {}) {
  return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, path) => {
    let value = data;
    for (const key of path.split('.')) {
      if (value == null || !Object.hasOwn(Object(value), key)) return '';
      value = value[key];
    }
    const text = String(value ?? '');
    return escape ? escapeHtml(text) : text;
  });
}

function escapeHtml(text) {
  return text.replace(/[&<>"']/g, (char) => ({
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;',
  })[char]);
}

const data = { user: { name: '小明' }, count: 3 };
console.log(render('你好,{{ user.name }},你有 {{count}} 条消息', data)); // 你好,小明,你有 3 条消息
console.log(render('{{ user.age }}|{{ missing.x }}', data)); // |
console.log(render('{{ constructor }}', data)); //
console.log(render('{{ name }}', { name: '<b>小明</b>' })); // &lt;b&gt;小明&lt;/b&gt;

正则 \{\{\s*([\w.]+)\s*\}\} 怎么读?大括号在正则里有特殊含义,要用 \{ 转义。\s* 允许变量名两边有空格,所以 {{count}} 和 {{ count }} 都能匹配。([\w.]+) 是捕获组,匹配由字母、数字、下划线和点号组成的变量名,它的内容会作为回调的第二个参数 path 传进来。末尾的 g 表示替换所有匹配,没有它只会替换第一处。

{{ user.age }} 和 {{ missing.x }} 都输出了空字符串。前者是 user 上没有 age,后者是 missing 本身就不存在,循环里 value == null 时直接返回空,避免读 undefined.x 报错。

{{ constructor }} 为什么也是空?data.constructor 其实能读到值,就是原型上的 Object 函数。用 Object.hasOwn 只认对象自己的属性,原型上的就被挡住了。这和原型污染是同一类问题,见《原型污染为什么常出现在动态属性赋值和深合并中?》。

最后一个例子里,<b> 被转义成了 &lt;b&gt;,插进页面后显示的是文字,不会被当成标签。escape 选项默认开启,只有确定输出不进 HTML、比如生成纯文本时才关掉。

常见追问

为什么不用 new Function 或 eval 直接执行表达式?

那样能支持 {{ a + b }} 这种表达式,可是模板内容一旦来自用户,就等于让用户执行任意代码。只允许变量路径,是在功能和安全之间的取舍。

Vue 的模板编译也是这样替换字符串的吗?

不是。Vue 把模板解析成 AST,再生成渲染函数,插值变成对数据的读取,最后生成虚拟 DOM,而不是拼字符串。插值的文本会作为文本节点插入,所以默认不会被当成 HTML 执行,见《Vue 模板编译如何减少运行时更新工作?》。