JavaScript for impatient programmers (ES2022 edition)
请支持本书:购买捐赠
(广告,请勿屏蔽。)

21 使用模板字面量和标签模板



在深入探讨*模板字面量*和*标签模板*这两个特性之前,让我们先来看看*模板*一词的多重含义。

21.1 消除歧义:“模板”

以下三件事尽管名称中都带有*模板*,而且看起来都很相似,但它们之间存在着显著差异

21.2 模板字面量

与普通字符串字面量相比,模板字面量有两个新特性。

首先,它支持*字符串插值*:如果我们将一个动态计算的值放在 ${} 中,它将被转换为字符串并插入到字面量返回的字符串中。

const MAX = 100;
function doSomeWork(x) {
  if (x > MAX) {
    throw new Error(`At most ${MAX} allowed: ${x}!`);
  }
  // ···
}
assert.throws(
  () => doSomeWork(101),
  {message: 'At most 100 allowed: 101!'});

其次,模板字面量可以跨越多行

const str = `this is
a text with
multiple lines`;

模板字面量始终生成字符串。

21.3 标签模板

A 行中的表达式是一个*标签模板*。它等效于使用 B 行中数组中列出的参数调用 tagFunc()

function tagFunc(...args) {
  return args;
}

const setting = 'dark mode';
const value = true;

assert.deepEqual(
  tagFunc`Setting ${setting} is ${value}!`, // (A)
  [['Setting ', ' is ', '!'], 'dark mode', true] // (B)
);

第一个反引号之前的函数 tagFunc 被称为*标签函数*。它的参数是

字面量的静态(固定)部分(模板字符串)与动态部分(替换)分开保存。

标签函数可以返回任意值。

21.3.1 已处理与原始模板字符串(高级)

到目前为止,我们只看到了模板字符串的*已处理解释*。但标签函数实际上有两种解释

原始解释可以通过 String.raw 实现原始字符串字面量 (稍后描述) 和类似的应用。

以下标签函数 cookedRaw 同时使用了两种解释

function cookedRaw(templateStrings, ...substitutions) {
  return {
    cooked: Array.from(templateStrings), // copy only Array elements
    raw: templateStrings.raw,
    substitutions,
  };
}
assert.deepEqual(
  cookedRaw`\tab${'subst'}\newline\\`,
  {
    cooked: ['\tab', '\newline\\'],
    raw:    ['\\tab', '\\newline\\\\'],
    substitutions: ['subst'],
  });

我们还可以在标签模板中使用 Unicode 代码点转义符(\u{1F642})、Unicode 代码单元转义符(\u03A9)和 ASCII 转义符(\x52

assert.deepEqual(
  cookedRaw`\u{54}\u0065\x78t`,
  {
    cooked: ['Text'],
    raw:    ['\\u{54}\\u0065\\x78t'],
    substitutions: [],
  });

如果其中一个转义符的语法不正确,则相应的已处理模板字符串为 undefined,而原始版本仍然是逐字的

assert.deepEqual(
  cookedRaw`\uu\xx ${1} after`,
  {
    cooked: [undefined, ' after'],
    raw:    ['\\uu\\xx ', ' after'],
    substitutions: [1],
  });

不正确的转义符会在模板字面量和字符串字面量中产生语法错误。在 ES2018 之前,它们甚至会在标签模板中产生错误。为什么要改变呢?我们现在可以使用标签模板来处理以前非法的文本——例如

windowsPath`C:\uuu\xxx\111`
latex`\unicode`

21.4 标签模板示例(通过库提供)

标签模板非常适合支持小型嵌入式语言(所谓的*领域特定语言*)。我们将继续举几个例子。

21.4.1 标签函数库:lit-html

lit-html 是一个基于标签模板的模板库,由 前端框架 Polymer 使用

import {html, render} from 'lit-html';

const template = (items) => html`
  <ul>
    ${
      repeat(items,
        (item) => item.id,
        (item, index) => html`<li>${index}. ${item.name}</li>`
      )
    }
  </ul>
`;

repeat() 是一个用于循环的自定义函数。它的第二个参数为第三个参数返回的值生成唯一的键。请注意该参数使用的嵌套标签模板。

21.4.2 标签函数库:re-template-tag

re-template-tag 是一个用于组合正则表达式的简单库。用 re 标记的模板会生成正则表达式。主要好处是我们可以通过 ${} 插值正则表达式和纯文本(A 行)

const RE_YEAR = re`(?<year>[0-9]{4})`;
const RE_MONTH = re`(?<month>[0-9]{2})`;
const RE_DAY = re`(?<day>[0-9]{2})`;
const RE_DATE = re`/${RE_YEAR}-${RE_MONTH}-${RE_DAY}/u`; // (A)

const match = RE_DATE.exec('2017-01-27');
assert.equal(match.groups.year, '2017');

21.4.3 标签函数库:graphql-tag

库 graphql-tag 允许我们通过标签模板创建 GraphQL 查询

import gql from 'graphql-tag';

const query = gql`
  {
    user(id: 5) {
      firstName
      lastName
    }
  }
  `;

此外,还有一些插件可以在 Babel、TypeScript 等中预编译此类查询。

21.5 原始字符串字面量

原始字符串字面量是通过标签函数 String.raw 实现的。它们是字符串字面量,其中反斜杠不做任何特殊处理(例如转义字符等)

assert.equal(String.raw`\back`, '\\back');

每当数据包含反斜杠时,这都会有所帮助——例如,包含正则表达式的字符串

const regex1 = /^\./;
const regex2 = new RegExp('^\\.');
const regex3 = new RegExp(String.raw`^\.`);

所有三个正则表达式都是等效的。使用普通的字符串字面量,我们必须将反斜杠写两次,以便为该字面量转义它。使用原始字符串字面量,我们不必这样做。

原始字符串字面量也可用于指定 Windows 文件名路径

const WIN_PATH = String.raw`C:\foo\bar`;
assert.equal(WIN_PATH, 'C:\\foo\\bar');

21.6 (高级)

所有剩余部分都是高级内容

21.7 多行模板字面量和缩进

如果我们将多行文本放入模板字面量中,则有两个目标会发生冲突:一方面,应缩进模板字面量以使其适合源代码。另一方面,其内容的行应从最左侧的列开始。

例如

function div(text) {
  return `
    <div>
      ${text}
    </div>
  `;
}
console.log('Output:');
console.log(
  div('Hello!')
  // Replace spaces with mid-dots:
  .replace(/ /g, '·')
  // Replace \n with #\n:
  .replace(/\n/g, '#\n')
);

由于缩进,模板字面量很好地融入了源代码。唉,输出也被缩进了。我们不希望开头有回车符,结尾有回车符加两个空格。

Output:
#
····<div>#
······Hello!#
····</div>#
··

有两种方法可以解决这个问题:通过标签模板或修剪模板字面量的结果。

21.7.1 修复:用于取消缩进的模板标签

第一个解决方法是使用自定义模板标签来删除不需要的空格。它使用初始换行符后的第一行来确定文本从哪一列开始,并在所有地方缩短缩进。它还会删除开头处的换行符和结尾处的缩进。一个这样的模板标签是 Desmond Brand 的 dedent

import dedent from 'dedent';
function divDedented(text) {
  return dedent`
    <div>
      ${text}
    </div>
  `.replace(/\n/g, '#\n');
}
console.log('Output:');
console.log(divDedented('Hello!'));

这一次,输出没有缩进

Output:
<div>#
  Hello!#
</div>

21.7.2 修复:.trim()

第二个解决方法更快,但也更脏乱

function divDedented(text) {
  return `
<div>
  ${text}
</div>
  `.trim().replace(/\n/g, '#\n');
}
console.log('Output:');
console.log(divDedented('Hello!'));

字符串方法 .trim() 会删除开头和结尾处多余的空格,但内容本身必须从最左侧的列开始。这种解决方案的优点是我们不需要自定义标签函数。缺点是它看起来很丑陋。

输出与使用 dedent 时相同

Output:
<div>#
  Hello!#
</div>

21.8 通过模板字面量进行简单模板化

虽然模板字面量看起来像文本模板,但如何将它们用于(文本)模板化并不一目了然:文本模板从对象获取数据,而模板字面量从变量获取数据。解决方案是在函数体中使用模板字面量,该函数的参数接收模板数据——例如

const tmpl = (data) => `Hello ${data.name}!`;
assert.equal(tmpl({name: 'Jane'}), 'Hello Jane!');

21.8.1 更复杂的示例

作为一个更复杂的示例,我们想获取一个地址数组并生成一个 HTML 表格。这就是数组

const addresses = [
  { first: '<Jane>', last: 'Bond' },
  { first: 'Lars', last: '<Croft>' },
];

生成 HTML 表格的函数 tmpl() 如下所示

const tmpl = (addrs) => `
<table>
  ${addrs.map(
    (addr) => `
      <tr>
        <td>${escapeHtml(addr.first)}</td>
        <td>${escapeHtml(addr.last)}</td>
      </tr>
      `.trim()
  ).join('')}
</table>
`.trim();

此代码包含两个模板函数

第一个模板函数通过将表格元素包裹在连接成字符串的数组周围来生成其结果(第 10 行)。该数组是通过将第二个模板函数映射到 addrs 的每个元素生成的(第 3 行)。因此,它包含带有表格行的字符串。

辅助函数 escapeHtml() 用于转义特殊的 HTML 字符(第 6 行和第 7 行)。它的实现显示在下一小节中。

让我们使用地址调用 tmpl() 并记录结果

console.log(tmpl(addresses));

输出为

<table>
  <tr>
        <td>&lt;Jane&gt;</td>
        <td>Bond</td>
      </tr><tr>
        <td>Lars</td>
        <td>&lt;Croft&gt;</td>
      </tr>
</table>

21.8.2 简单的 HTML 转义

以下函数会转义纯文本,以便在 HTML 中逐字显示

function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;') // first!
    .replace(/>/g, '&gt;')
    .replace(/</g, '&lt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
    .replace(/`/g, '&#96;')
    ;
}
assert.equal(
  escapeHtml('Rock & Roll'), 'Rock &amp; Roll');
assert.equal(
  escapeHtml('<blank>'), '&lt;blank&gt;');

  练习:HTML 模板化

带奖励挑战的练习:exercises/template-literals/templating_test.mjs

  测验

参见测验应用程序