标签模板是什么?String.raw 有什么用?
在模板字符串前面写一个函数名,就成了标签模板,这个函数会收到拆开的字符串片段数组和每个插值表达式的值,返回什么都可以。字符串数组带一个 raw 属性,保存没有处理转义的原始文本,String.raw 就是用它拼出原始字符串的内置标签,常用来写 Windows 路径和正则。
简答
写
tag`你好,${name}`时,JavaScript 不会先拼出字符串,而是调用tag函数:第一个参数是被插值切开的字符串片段数组['你好,', ''],后面的参数依次是每个${}的值。函数返回什么,整个表达式的值就是什么,不一定是字符串。有了这个机制,库就能在拼接之前处理插值,比如给每个值做 HTML 转义,而不是直接拼进去。有些工具还会认特定的标签名,比如格式化工具会把
html标签里的内容当 HTML 来格式化。片段数组有一个
raw属性,存的是源码里原样写的文本,\n还是反斜杠加n两个字符。String.raw就是用raw拼接的内置标签,写String.raw`C:\new`能得到原样的路径,写正则的字符串时也不用把反斜杠写两遍。有几个细节常被问:片段数组的长度总是插值个数加一;同一处标签模板每次执行收到的都是同一个冻结的数组;标签模板里写了不合法的转义,比如
\unicode,不会报语法错误,那个片段的处理后值是undefined,raw里照样有原文。
标签函数收到什么参数
下面写一个最简单的标签,把收到的参数打印出来。注意插值在开头或结尾时,片段数组里会出现空字符串。
function inspect(strings, ...values) {
return `${strings.length}|${JSON.stringify([...strings])}|${values.join(',')}`;
}
const name = '小明';
const count = 3;
console.log(inspect`${name}有${count}条消息`); // 3|["","有","条消息"]|小明,3
console.log(inspect`没有插值`); // 1|["没有插值"]|
第一次调用有两个插值,片段数组就有三项。${name} 在最前面,所以它前面的片段是空字符串 ''。这条规则保证了片段和值总是交替出现:strings[0]、values[0]、strings[1]、values[1]……最后以 strings 结尾。所以写标签函数时,拼接的循环通常是以 strings[0] 开头,每轮加一个值和下一个片段。
没有插值时,片段数组只有一项,值的列表是空的。MDN 写明,片段数组的长度等于插值个数加一,所以它永远不会是空数组。
用标签在拼接前处理插值
标签函数最常见的用法,是对插值的值做处理,而原样保留写在模板里的片段。下面的 safeHtml 只转义插值部分,因为片段是开发者自己写的,插值才可能来自用户输入。
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"');
}
function safeHtml(strings, ...values) {
let result = strings[0];
values.forEach((value, i) => {
result += escapeHtml(value) + strings[i + 1];
});
return result;
}
const input = '<img src=x onerror=alert(1)>';
console.log(safeHtml`<p>${input}</p>`); // <p><img src=x onerror=alert(1)></p>
<p> 是片段,原样保留;用户输入的 <img ...> 被转义成了文本。如果用普通模板字符串,插值在调用任何函数之前就已经拼进去了,再处理就分不清哪部分是用户输入。
这里的转义只是演示思路,只覆盖了放在元素内容和双引号属性值里的情况。真实项目输出 HTML 时应该用框架自带的转义,或者经过审查的库。
raw 和 String.raw
片段数组里的字符串已经处理过转义,\n 变成了换行符。可是有时候要的是源码里写的原文,这就要用 strings.raw。
function showRaw(strings) {
return `${strings[0].length}|${strings.raw[0].length}`;
}
console.log(showRaw`a\nb`); // 3|4
console.log(String.raw`C:\new\table`); // C:\new\table
console.log(String.raw`第${1 + 1}行\n`); // 第2行\n
console.log(String.raw({ raw: ['x', 'y', 'z'] }, 1, 2)); // x1y2z
a\nb 处理转义以后是 a、换行、b 三个字符,raw 里是 a、\、n、b 四个字符。
String.raw 就是拿 raw 里的片段,和插值交替拼起来。所以 C:\new\table 里的 \n、\t 没有变成换行和制表符,插值 ${1 + 1} 照样会计算。最后一行说明它也能当普通函数调用,传入一个带 raw 属性的对象,后面的参数依次插到片段之间。
同一处模板每次拿到同一个数组
MDN 写明,对于同一个标签模板表达式,无论执行多少次,标签函数收到的都是同一个片段数组;这个数组和它的 raw 属性都被冻结,不能修改。
const seen = [];
function remember(strings) {
seen.push(strings);
return strings;
}
function run() {
return remember`固定内容`;
}
run();
run();
console.log(String(seen[0] === seen[1])); // true
console.log(String(Object.isFrozen(seen[0]))); // true
console.log(String(Object.isFrozen(seen[0].raw))); // true
调用了两次 run,两次收到的片段数组是同一个对象。所以标签函数可以用片段数组当缓存的键,比如解析模板的结果只算一次。注意这里说的是同一处源码,两段内容一样的标签模板写在不同位置,收到的是不同的数组。
标签模板里的非法转义
普通模板字符串里写 \unicode 会报语法错误,因为 \u 后面必须跟合法的 Unicode 转义。标签模板放宽了这个限制,因为标签函数可能根本不需要处理后的值,比如要解析 LaTeX 的标签。
function cookedAndRaw(strings) {
return `${String(strings[0])}|${strings.raw[0]}`;
}
console.log(cookedAndRaw`\unicode`); // undefined|\unicode
处理后的片段变成了 undefined,raw 里还是原文。这个放宽只对标签模板有效。
常见追问
可选链后面能接标签模板吗?
不能。MDN 写明
console.log?.`Hello`和console?.log`Hello`都是语法错误,标签模板只允许接在不带可选链的表达式后面。
普通模板字符串和字符串拼接,String.raw 和直接写有什么区别?
模板字符串里没有转义序列时,
String.raw的结果和普通模板字符串一样,可以看成一个原样拼接的标签。有反斜杠时才不同:普通模板字符串会处理转义,String.raw保留反斜杠。
怎样手写一个模板渲染函数?
那是另一类题,是在运行时解析
{{name}}这类占位符,见《怎样手写一个简单的模板字符串渲染函数?》。