模板字面量简介

1,273 阅读3分钟

模板字面量(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