模板字面量

JS 的字符串相对其他语言来说功能总是有限的。例如,本章介绍过的字符串方法在 ES6 之前都是缺失的,而字符串拼接的功能则尽可能简单。为了让开发者能够解决复杂的问题,ES6 的模板字面量(template literal)提供了创建领域专用语言(domain-specific language,DSL)的语法,与 ES5 及更早版本的解决方案相比,处理内容可以更安全(领域专用语言是被设计用于特定有限目的的编程语言,与通用目的语言如 JavaScript 相反)。ECMAScript wiki 在 template literal strawman 上提供了如下描述:

本方案通过语法糖扩展了 ECMAScript 的语法,允许语言库提供 DSL 以便制作、查询并操纵来自于其它语言的内容,并且对注入攻击(如 XSS、SQL 注入,等等)能够免疫或具有抗性。

在 ES6 之前,JavaScript 一直以来缺少许多特性:

  • 多行字符串:针对多行字符串的形式概念;

  • 基本的字符串格式化:将字符串部分替换为已存在的变量值的能力;

  • HTML 转义:能转换字符串以便将其安全插入到 HTML 中的能力。

模板字面量以一种新的方式解决了这些问题,而并未给 JS 已有的字符串添加额外功能。

基本语法

模板字面量的最简单语法,是使用反引号(`)来包裹普通字符串,而不是用双引号或单引号。参考以下例子:

let message = `Hello world!`;

console.log(message);               // "Hello world!"
console.log(typeof message);        // "string"
console.log(message.length);        // 12

此代码说明了 message 变量包含的是一个普通的 JS 字符串。模板字面量语法被用于创建一个字符串值,并被赋值给了 message 变量。

若你想在字符串中包含反引号,只需使用反斜杠(\)转义即可,就像下面这个版本的 message 变量:

let message = `\`Hello\` world!`;

console.log(message);               // "`Hello` world!"
console.log(typeof message);        // "string"
console.log(message.length);        // 14

在模板字面量中无需对双引号或单引号进行转义。

多行字符串

JS 开发者从该语言最初版本起就一直想要一种能创建多行字符串的方法。但在使用双引号或单引号时,整个字符串只能放在单独一行。

ES6 之前的权宜之计

感谢存在已久的一个语法 bug,JS 的确有一种权宜之计:在换行之前的反斜线(\)可以用于创建多行字符串。这里有个范例:

var message = "Multiline \
string";

console.log(message);       // "Multiline string"

message 字符串打印输出时不会有换行,因为反斜线被视为续延符号而不是新行的符号。为了在输出中显示换行,你需要手动包含它:

var message = "Multiline \n\
string";

console.log(message);       // "Multiline
                            //  string"

在所有主流的 JS 引擎中,此代码都会输出两行,但是该行为被认定为一个 bug,并且许多开发者都建议应避免这么做。

其他 ES6 之前创建多行字符串的尝试,一般都基于数组或字符串的拼接,就像这样:

var message = [
    "Multiline ",
    "string"
].join("\n");

var message = "Multiline \n" +
    "string";

关于 JS 缺失的多行字符串功能,开发者的所有解决方法都不够完美。

多行字符串的简单解决方法

ES6 的模板字面量使多行字符串更易创建,因为它不需要特殊的语法。只需在想要的位置包含换行即可,而且它会显示在结果中。例如:

let message = `Multiline
string`;

console.log(message);           // "Multiline
                                //  string"
console.log(message.length);    // 16

反引号之内的所有空白符都是字符串的一部分,因此需要留意缩进。例如:

let message = `Multiline
               string`;

console.log(message);           // "Multiline
                                //                 string"
console.log(message.length);    // 31

此代码中,模板字面量第二行前面的所有空白符都被视为字符串自身的一部分。如果让多行文本保持合适的缩进对你来说很重要,请考虑将多行模板字面量的第一行空置并在此后进行缩进,如下所示:

let html = `
<div>
    <h1>Title</h1>
</div>`.trim();

此代码从第一行开始创建模板字面量,但在第二行之前并没有包含任何文本。HTML 标签的缩进增强了可读性,之后再调用 trim() 方法移除了起始的空行。

如果你喜欢的话,也可以在模板字面量中使用 \n 来指示换行的插入位置:

let message = `Multiline\nstring`;

console.log(message); // "Multiline
// string"
console.log(message.length); // 16

使用替换位

此时模板字面量看上去仅仅是普通 JS 字符串的升级版,但二者之间真正的区别在于前者的“替换位”。替换位允许你将任何有效的 JS 表达式嵌入到模板字面量中,并将其结果输出为字符串的一部分。

替换位由起始的 ${与结束的} 来界定,之间允许放入任意的 JS 表达式。最简单的替换位允许你将本地变量直接嵌入到结果字符串中,例如:

let name = "Nicholas",
    message = `Hello, ${name}.`;

console.log(message);       // "Hello, Nicholas."

替换位 ${name} 会访问本地变量 name,并将其值插入到 message 字符串中。message 变量会立即保留该替换位的结果。

模板字面量能访问到作用域中任意的可访问变量。试图使用未定义的变量会抛出错误,无论是严格模式还是非严格模式。

既然替换位是 JS 表达式,那么可替换的就不仅仅是简单的变量名。你可以轻易嵌入计算、函数调用,等等。例如:

let count = 10,
    price = 0.25,
    message = `${count} items cost $${(count * price).toFixed(2)}.`;

console.log(message);       // "10 items cost $2.50."

此代码在模板字面量的一部分执行了一次计算,count 与 price 变量相乘,再使用 .toFixed() 方法将结果格式化为两位小数。而在第二个替换位之前的美元符号被照常输出,因为没有左花括号紧随其后。

模板字面量本身也是 JS 表达式,意味着你可以将模板字面量嵌入到另一个模板字面量内部,如同下例:

let name = "Nicholas",
    message = `Hello, ${
        `my name is ${ name }`
    }.`;

console.log(message);        // "Hello, my name is Nicholas."

此例在第一个模板字面量中套入了第二个。在首个 ${ 之后使用了另一个模板字面量,第二个 ${ 标示了嵌入到内层模板字面量的表达式的开始,该表达式为被插入结果的 name 变量。

标签化模板

现在你已了解模板字面量在无须连接的情况下,是如何创建多行字符串以及将值插入字符串。不过模板字面量真正的力量来源于标签化模板。一个模板标签(template tag)能对模板字面量进行转换并返回最终的字符串值,标签在模板的起始处被指定,即在第一个 ` 之前,如下所示:

// tag就是`Hello world!`模板字面量的标签模板
let message = tag`Hello world`;

在本例中,tag 就是会被应用到 Hello world 模板字面量上的模板标签。

定义标签

一个标签(tag)可以是一个函数,它被调用时接收需要处理的模板字面量数据。标签所接收的数据被划分为独立片段,并且必须将它们组合起来以创建结果。第一个参数是个数组,包含被 JS 解释过的字面量字符串,随后的参数是每个替换位的解释值。

标签函数的参数一般定义为剩余参数形式,以便更容易处理数据,如下:

function tag(literals, ...substitutions) {
  // 返回一个字符串
}
const name = 'why'
const age = 23
const message = tag`${name} is ${age} years old!`

其中 literals 是一个数组,它包含:

  • 第一个占位符前的空白字符串:""。

  • 第一个、第二个占位符之间的字符串:" is "。

  • 第二个占位符后的字符串:" years old!"

substitutions 也是一个数组:

  • 数组第一项为:name 的值,即:why。

  • 数组第二项为:age 的值,即:23。

通过以上规律我们可以发现:

  • literals[0] 始终代表字符串的开头。

  • literals 总比 substitutions 多一个。

为了更好地理解传递给标签的是什么参数,可研究下例:

let count = 10,
    price = 0.25,
    message = passthru`${count} items cost $${(count * price).toFixed(2)}.`;

如果你拥有一个名为 passthru() 的函数,该函数将会接收到三个参数。首先是一个 literals 数组,包含如下元素:

  • 在首个替换位之前的空字符串( "" ) ;

  • 首个替换位与第二个替换位之间的字符串( " items cost $" ) ;

  • 第二个替换位之后的字符串( "." ) 。

接下来的参数会是 10,也就是 count 变量的解释值,它也会成为 substitutions 数组的第一个元素。最后一个参数则会是 "2.50",即 (count * price).toFixed(2) 的解释值,并且会是 substitutions 数组的第二个元素。

需要注意 literals 的第一个元素是空字符串,以确保 literals[0] 总是字符串的起始部分,正如 literals[literals.length - 1] 总是字符串的结尾部分。同时替换位的元素数量也总是比字面量元素少 1,意味着表达式 substitutions.length === literals.length - 1 的值总是 true。

使用这种模式,可以交替使用 literals 与 substitutions 数组来创建一个结果字符串:以 literals 中的首个元素开始,后面紧跟着 substitutions 中的首个元素,如此反复,直到结果字符串被创建完毕。你可以像下例这样交替使用两个数组中的值来模拟模板字面量的默认行为:

function passthru(literals, ...substitutions) {
    let result = "";

    // run the loop only for the substitution count
    for (let i = 0; i < substitutions.length; i++) {
        result += literals[i];
        result += substitutions[i];
    }

    // add the last literal
    result += literals[literals.length - 1];

    return result;
}

let count = 10,
    price = 0.25,
    message = passthru`${count} items cost $${(count * price).toFixed(2)}.`;

console.log(message);       // "10 items cost $2.50."

本例定义了 passthru 标签,能够执行与模板字面量的默认行为相同的转换操作。唯一的诀窍是在循环中使用 substituions.length 而不是 literals.length 来避免 substituions 数组的越界。它能工作是由于 ES6 对 literals 和 substituions 的良好定义。

substituions 中包含的值不必是字符串。若表达式的计算结果为数字(就像上例),那么该数值也会被传入。决定这些值如何在结果中输出是标签的工作之一。

使用模板字面量中的原始值

模板标签也能访问字符串的原始信息(即,字符串转义被转换成等价字符串前的原生字符串),主要指的是可以访问字符在转义之前的形式。获取原始字符串值的最简单方式是使用内置的 String.raw() 标签。例如:

let message1 = `Multiline\nstring`,
    message2 = String.raw`Multiline\nstring`;

console.log(message1);          // "Multiline
                                //  string"
console.log(message2);          // "Multiline\nstring"

此代码中,message1 中的 \n 被解释为一个换行,而 message2 中的 \n 返回了它的原始形式 "\\n" (反斜线与 n 字符)。像这样提取原始字符串信息可以在必要时进行更复杂的处理。

字符串的原始信息同样会被传递给模板标签。标签函数的第一个参数为包含额外属性 raw 的数组,而 raw 属性则是含有与每个字面量值等价的原始值的数组。例如,literals[0] 的值总是等价于包含字符串原始信息的 literals.raw[0] 的值。知道这些之后,你可以用如下代码来模拟 String.raw() :

function raw(literals, ...substitutions) {
    let result = "";

    // run the loop only for the substitution count
    for (let i = 0; i < substitutions.length; i++) {
        result += literals.raw[i];      // use raw values instead
        result += substitutions[i];
    }

    // add the last literal
    result += literals.raw[literals.length - 1];

    return result;
}

let message = raw`Multiline\nstring`;

console.log(message);           // "Multiline\nstring"
console.log(message.length);    // 17

这里使用 literals.raw 而非 literals 来输出结果字符串。这意味着任何转义字符(包括 Unicode 代码点的转义)都会以原始的形式返回。当你想在输出的字符串中包含转义字符时,原始字符串会很有帮助(例如,若想要生成包含代码的文档,那么应当输出如表面看到那样的实际代码)。