怎样手写一个简单的模板字符串渲染函数?
用正则 /\{\{\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=...>就会执行脚本。要注意,这只是一个面试级别的简单实现,不支持循环、条件这些语法。真实项目里用框架自带的模板,它们默认会转义插值内容。
代码实现
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) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
})[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>' })); // <b>小明</b>
正则 \{\{\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> 被转义成了 <b>,插进页面后显示的是文字,不会被当成标签。escape 选项默认开启,只有确定输出不进 HTML、比如生成纯文本时才关掉。
常见追问
为什么不用 new Function 或 eval 直接执行表达式?
那样能支持
{{ a + b }}这种表达式,可是模板内容一旦来自用户,就等于让用户执行任意代码。只允许变量路径,是在功能和安全之间的取舍。
Vue 的模板编译也是这样替换字符串的吗?
不是。Vue 把模板解析成 AST,再生成渲染函数,插值变成对数据的读取,最后生成虚拟 DOM,而不是拼字符串。插值的文本会作为文本节点插入,所以默认不会被当成 HTML 执行,见《Vue 模板编译如何减少运行时更新工作?》。