模板字面量(Template Literal)是 ES2015 引入的一种新字面量。相比起字符串字面量,模板字面量可以跨多行,并且支持插值。它的出现使得繁琐的通过 + 连接字符串的时代成为了过去。而如果在模板字面量前面加一个函数,那么这个结构就被称为带标签的模板(Tagged Template)。而它又为 JavaScript 带来了很多的新的可能性。
模板字面量
模板字面量是由 ` 括起来的一种用于生成字符串的字面量。除了普通字符串以外,表达式也可以被嵌入模板字面量中,只要它被 ${ 和 } 包围起来。举一些例子:
const firstName = 'Jane';
const lastName = 'Smith';
const fullName = `${firstName} ${lastName}`;
const gretting = `Hello, ${fullName}!`
如果要在模板字面量中使用 ` 或者 ${,需要在前面加上转义字符 \。
带标签的模板
带标签模板(Tagged Template)由一个函数和模板字面量组成。其中,这个函数被称为标签函数(Tag Function)。带标签模板本质上就是函数调用,而传入函数的参数则是从这个模板字面量获得。
熟悉 CSS-in-JS 的同学应该对带标签模板不陌生。styled-components 作为影响力比较大的 CSS-in-JS 库就利用到了这个特性,使得在 JavaScript 中创建样式变成了一件轻松的事。来看一个摘自 styled-components 官方文档的一个例子:
const Button = styled.button<{ $primary?: boolean; }>`
/* Adapt the colors based on primary prop */
background: ${props => props.$primary ? "#BF4F74" : "white"};
color: ${props => props.$primary ? "white" : "#BF4F74"};
font-size: 1em;
margin: 1em;
padding: 0.25em 1em;
border: 2px solid #BF4F74;
border-radius: 3px;
`;
render(
<div>
<Button>Normal</Button>
<Button $primary>Primary</Button>
</div>
);
其中, styled.button 就是一个标签函数。由此也可以看到,标签函数的返回值可以是任何类型,并不局限于字符串。在这个例子中,标签函数返回的就是一个 React 组件。
再来看标签函数接收到的参数。假如有如下带标签模板:
tagFunction`Hello ${firstName} ${lastName}!`;
标签函数 tagFunction 接收到的参数并不直接是之后的字面量,而是根据这个字面量计算得到的一个模板对象(template object)以及若干个对嵌入的表达式进行求值的结果。如果字面量并没有表达式嵌于其中,那么参数仅为模板对象。
模板对象
模板对象是一个字符串数组。其中的字符串来自模板字面量:
function tagFunction(tempObj, ...subs) {
console.log(tempObj);
// ...
}
tagFunction`Welcome, ${userName}!`
// 输出 [ 'Welcome, ', '!' ]
模板对象长度总是
嵌入表达式的个数 + 1。
可以看出来,
tagFunction`Welcome, ${userName}!`
大致上等价于:
tagFunction([ 'Welcome, ', '!' ], userName)
不过真正的函数调用更像是这样:
// Globally: add template object to per-realm template map
{
const templateObject = ['lit1\n', ' lit2 ', ''];
templateObject.raw = ['lit1\n', ' lit2 ', ''];
// The Arrays with template strings are frozen
Object.freeze(templateObject.raw);
Object.freeze(templateObject);
__templateMap__[716] = templateObject;
}
// In-place: invocation of tag function
tagFunction(__templateMap__[716], userName)
也就是说,每个模板字面量都有一个模板对象与之对应。当执行带标签模板时,对应的模板对象就会被传给标签函数。而该模板对象的创建是发生在第一次求值时还是在那之前在 JS 层面是观察不到的。这样做的好处是,避免同一模板字面量的多次执行导致模板对象多次被创建。假设有如下语句:
const names = ['Mary', 'Tom', 'Alex'];
for (const name of names) {
tagFunction`Welcome, ${name}!`
}
每次调用,tagFunction 接收到的模板对象都是同一个。也就是说,模板对象得到了重用。
模板对象有一个 raw 属性,其值为由未经处理(raw)版字符串构成的数组。两版字符串的区别在于对转义字符 \ 的处理:
function tagFunction(tempObj, ...subs) {
console.log(tempObj);
console.log(tempObj.raw);
}
tagFunction`Hello\${`;
tagFunction`Hello\``;
tagFunction`Hello\n`;
// 输出依次为
// [ 'Hello${' ]
// [ 'Hello\\${' ]
// [ 'Hello`' ]
// [ 'Hello\\`' ]
// [ 'Hello\n' ]
// [ 'Hello\\n' ]
可以看到,在模板字面量中出现的转义字符 \ 都会原原本本地出现在未处理版本中,失去了转义的作用。还有,不要忘了之前提到的在模板字面量中使用 ` 或者 ${ 时,要在前面加上转义字符 \ 的要求。
应用的例子
带标签模板非常适合用来实现 DSL。之前提到的 styled-components 就可以看作是在 JavaScript 中嵌入了一种 DSL。那接下来我们也来尝试实现一个简易的 DSL。
markdown 是一种轻量的标记语言,为人们提供使用纯文本格式编写文档的能力。因为其语法的简单易用,markdown 几乎成为了文字编辑软件的标配。比如,这篇文字就是使用 markdown 写成的。那我们就来试试在 JavaScript 中嵌入 markdown。
import markdownIt from "markdown-it";
const md = new markdownIt();
function mark(template, ...substitutions) {
const raw = template.raw;
let result = "";
substitutions.forEach((substitution, idx) => {
const t = raw[idx];
result += t;
result += String(substitution);
});
result += template[template.length - 1];
const html = md.render(result);
return String(html);
}
在上述代码,实现了一个简单的标签函数 mark。 它的作用很简单,就是将传入的 substitutions 全部转为字符串然后和模板中字符串拼接成为完整的 markdown 字符串。然后,使用 markdown-it 将该字符串转成 html 字符串。来看它是如何使用的:
const name = "Gary";
const html = mark`Hello *${name}*`; // "<p>Hello <em>Gary</em></p>\n"
可能有人会有疑问,这和下面的代码有什么区别:
import markdownIt from "markdown-it";
const md = new markdownIt();
const name = "Gary";
md.render(`Hello *${name}*`); // "<p>Hello <em>Gary</em></p>\n"
相比起直接使用模板字面量的版本,用带标签模板实现的版本的优势在于 mark 函数有机会对传入的 substitutions 做转换和处理。举一个最简单的例子,mark 为数组类型的值进行贴别处理:
if (Array.isArray(substitution)) {
substitution = substitution.join(" ");
}
于是,
const names = ["Gary", "Tomas"];
mark`Hello *${names}*`; // "<p>Hello <em>Gary Tomas</em></p>\n"
而使用 md.render 版本则返回 <p>Hello <em>Gary,Tomas</em></p>\n。这是因为在对模板字面量进行求值的时候,names 被转成了字符串。
使用带标签模板的另外一个优势在于,标签函数接收到了处理和未处理版的字符串。开发者可以根据自己的需求选择对应版本的字符串。mark 中使用的就是未处理版本的字符串,这会导致以下的差异:
mark`\# Hello *World*`; // "<p># Hello <em>World</em></p>\n"
md.render(`\# Hello *World*`); // "<h1>Hello <em>World</em></h1>\n"
\在 markdown 中也有转义的作用。
在 mark 版本中,转义字符正确的起作用,防止了 # 被当成 Heading 的起始字符。而在 md.render 中,\ 却没有起作用。这是因为 \ 在模板字面量中也担任转义字符的作用。而对 # 进行转义的结果就是 # 本身。所以 md.render 接收到的字符串实际上就是# Hello *World*。要让 \ 正确的在 markdown 语法中起效,可以选择对 \ 进行转义,也就是传入 \\# Hello *World*,或者使用标签函数 String.raw。
mark 的完整代码在 GitHub。