前端知库

标签模板是什么?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 里照样有原文。

标签函数收到什么参数

下面写一个最简单的标签,把收到的参数打印出来。注意插值在开头或结尾时,片段数组里会出现空字符串。

JavaScript
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 只转义插值部分,因为片段是开发者自己写的,插值才可能来自用户输入。

JavaScript
function escapeHtml(value) {
  return String(value)
    .replaceAll('&', '&')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;');
}

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>&lt;img src=x onerror=alert(1)&gt;</p>

<p> 是片段,原样保留;用户输入的 <img ...> 被转义成了文本。如果用普通模板字符串,插值在调用任何函数之前就已经拼进去了,再处理就分不清哪部分是用户输入。

这里的转义只是演示思路,只覆盖了放在元素内容和双引号属性值里的情况。真实项目输出 HTML 时应该用框架自带的转义,或者经过审查的库。

raw 和 String.raw

片段数组里的字符串已经处理过转义,\n 变成了换行符。可是有时候要的是源码里写的原文,这就要用 strings.raw。

JavaScript
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 属性都被冻结,不能修改。

JavaScript
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 的标签。

JavaScript
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}} 这类占位符,见《怎样手写一个简单的模板字符串渲染函数?》。