8. 模板字面量
目录
请支持本书:购买 (PDF, EPUB, MOBI)捐赠
(广告,请不要屏蔽。)

8. 模板字面量



8.1 概述

ES6 有两种新的字面量:模板字面量带标签的模板字面量。这两种字面量名称相似,外观也相似,但它们却截然不同。因此,区分以下几点非常重要

模板字面量 是可以跨越多行并包含插值表达式(通过 ${···} 插入)的字符串字面量

const firstName = 'Jane';
console.log(`Hello ${firstName}!
How are you
today?`);

// Output:
// Hello Jane!
// How are you
// today?

带标签的模板字面量(简称:带标签的模板)是通过在模板字面量之前提及函数来创建的

> String.raw`A \tagged\ template`
'A \\tagged\\ template'

带标签的模板是函数调用。在上一个示例中,调用方法 String.raw 来生成带标签的模板的结果。

8.2 介绍

字面量是生成值的语法结构。例如,字符串字面量(生成字符串)和正则表达式字面量(生成正则表达式对象)。ECMAScript 6 有两种新的字面量

重要的是要记住,模板字面量和带标签的模板的名称略有误导性。它们与 Web 开发中经常使用的模板无关:模板是包含可以通过(例如)JSON 数据填充的空白的文本文件。

8.2.1 模板字面量

模板字面量是一种新型的字符串字面量,它可以跨越多行并插入表达式(包含其结果)。例如

const firstName = 'Jane';
console.log(`Hello ${firstName}!
How are you
today?`);

// Output:
// Hello Jane!
// How are you
// today?

字面量本身由反引号 (`) 分隔,字面量内的插值表达式由 ${} 分隔。模板字面量始终生成字符串。

8.2.2 模板字面量中的转义

反斜杠用于在模板字面量中进行转义。

它使您能够在模板字面量中提及反引号和 ${

> `\``
'`'
> `$` // OK
'$'
> `${`
SyntaxError
> `\${`
'${'
> `\${}`
'${}'

除此之外,反斜杠的工作方式与字符串字面量相同

> `\\`
'\\'
> `\n`
'\n'
> `\u{58}`
'X'

8.2.3 模板字面量中的行终止符始终为 LF (\n)

终止行的常见方法是

所有这些行终止符在模板字面量中都规范化为 LF。也就是说,以下代码在所有平台上都记录 true

const str = `BEFORE
AFTER`;
console.log(str === 'BEFORE\nAFTER'); // true

8.2.4 带标签的模板字面量

以下是带标签的模板字面量(简称:带标签的模板

tagFunction`Hello ${firstName} ${lastName}!`

在表达式后放置模板字面量会触发函数调用,类似于参数列表(括号中以逗号分隔的值)触发函数调用的方式。前面的代码等效于以下函数调用(实际上,第一个参数不仅仅是一个数组,但稍后会解释)。

tagFunction(['Hello ', ' ', '!'], firstName, lastName)

因此,反引号中内容之前的名称是要调用的函数的名称,即标签函数。标签函数接收两种不同类型的数据

模板字符串是静态已知的(在编译时),而替换仅在运行时才知道。标签函数可以随意处理其参数:它可以完全忽略模板字符串,返回任何类型的值,等等。

此外,标签函数会获取每个模板字符串的两个版本

这允许 String.raw(稍后解释)完成其工作

> String.raw`\n` === '\\n'
true

8.3 使用带标签的模板字面量的示例

带标签的模板字面量允许您轻松实现自定义嵌入式子语言(有时称为领域特定语言),因为 JavaScript 会为您完成大部分解析工作。您只需编写一个接收结果的函数。

让我们看一些例子。其中一些例子受到模板字面量的原始提案的启发,该提案通过其旧名称准字面量来指代它们。

8.3.1 原始字符串

ES6 包含用于原始字符串的标签函数 String.raw,其中反斜杠没有特殊含义

const str = String.raw`This is a text
with multiple lines.
Escapes are not interpreted,
\n is not a newline.`;

每当您需要创建包含反斜杠的字符串时,这都很有用。例如

function createNumberRegExp(english) {
    const PERIOD = english ? String.raw`\.` : ','; // (A)
    return new RegExp(`[0-9]+(${PERIOD}[0-9]+)?`);
}

在 A 行中,String.raw 使我们能够像在正则表达式字面量中那样编写反斜杠。使用普通的字符串字面量,我们必须转义两次:首先,我们需要为正则表达式转义点。其次,我们需要为字符串字面量转义反斜杠。

8.3.2 Shell 命令

const proc = sh`ps ax | grep ${pid}`;

(来源:David Herman

8.3.3 字节字符串

const buffer = bytes`455336465457210a`;

(来源:David Herman

8.3.4 HTTP 请求

POST`http://foo.org/bar?a=${a}&b=${b}
     Content-Type: application/json
     X-Credentials: ${credentials}

     { "foo": ${foo},
       "bar": ${bar}}
     `
     (myOnReadyStateChangeHandler);

(来源:Luke Hoban

8.3.5 更强大的正则表达式

Steven Levithan 举了一个例子,说明如何将带标签的模板字面量用于他的正则表达式库 XRegExp

如果没有带标签的模板,则需要编写如下代码

var parts = '/2015/10/Page.html'.match(XRegExp(
  '^ # match at start of string only \n' +
  '/ (?<year> [^/]+ ) # capture top dir name as year \n' +
  '/ (?<month> [^/]+ ) # capture subdir name as month \n' +
  '/ (?<title> [^/]+ ) # capture base name as title \n' +
  '\\.html? $ # .htm or .html file ext at end of path ', 'x'
));

console.log(parts.year); // 2015

我们可以看到 XRegExp 为我们提供了命名组(yearmonthtitle)和 x 标志。使用该标志,大多数空格将被忽略,并且可以插入注释。

字符串字面量在这里不能很好地工作有两个原因。首先,我们必须将每个正则表达式反斜杠键入两次,以便为字符串字面量对其进行转义。其次,输入多行很麻烦。

除了添加字符串之外,如果使用反斜杠结束当前行,您还可以在下一行继续字符串字面量。但这仍然涉及很多视觉混乱,特别是因为您仍然需要在每行的末尾通过 \n 显式换行。

var parts = '/2015/10/Page.html'.match(XRegExp(
  '^ # match at start of string only \n\
  / (?<year> [^/]+ ) # capture top dir name as year \n\
  / (?<month> [^/]+ ) # capture subdir name as month \n\
  / (?<title> [^/]+ ) # capture base name as title \n\
  \\.html? $ # .htm or .html file ext at end of path ', 'x'
));

使用带标签的模板可以解决反斜杠和多行问题

var parts = '/2015/10/Page.html'.match(XRegExp.rx`
    ^ # match at start of string only
    / (?<year> [^/]+ ) # capture top dir name as year
    / (?<month> [^/]+ ) # capture subdir name as month
    / (?<title> [^/]+ ) # capture base name as title
    \.html? $ # .htm or .html file ext at end of path
`);

此外,带标签的模板允许您通过 ${v} 插入值 v。我希望正则表达式库能够转义字符串并逐字插入正则表达式。例如

var str   = 'really?';
var regex = XRegExp.rx`(${str})*`;

这等效于

var regex = XRegExp.rx`(really\?)*`;

8.3.6 查询语言

示例

$`a.${className}[href*='//${domain}/']`

这是一个 DOM 查询,它查找所有 CSS 类为 className 且目标是具有给定域的 URL 的 <a> 标签。标签函数 $ 确保参数被正确转义,从而使这种方法比手动字符串连接更安全。

8.3.7 通过带标签的模板实现 React JSX

Facebook React 是“用于构建用户界面的 JavaScript 库”。它具有可选的语言扩展 JSX,使您能够为用户界面构建虚拟 DOM 树。此扩展使您的代码更简洁,但它也是非标准的,并且会破坏与 JavaScript 生态系统其余部分的兼容性。

库 t7.js 提供了 JSX 的替代方案,并使用带有 t7 标签的模板

t7.module(function(t7) {
  function MyWidget(props) {
    return t7`
      <div>
        <span>I'm a widget ${ props.welcome }</span>
      </div>
    `;
  }

  t7.assign('Widget', MyWidget);

  t7`
    <div>
      <header>
        <Widget welcome="Hello world" />
      </header>
    </div>
  `;
});

在“为什么不使用模板字面量?”中,React 团队解释了他们为什么选择不使用模板字面量。一个挑战是在带标签的模板中访问组件。例如,在前面的示例中,从第二个带标签的模板访问 MyWidget。一种冗长的做法是

<${MyWidget} welcome="Hello world" />

相反,t7.js 使用通过 t7.assign() 填充的注册表。这需要额外的配置,但模板字面量看起来更好;尤其是在同时存在开始标签和结束标签的情况下。

8.3.8 Facebook GraphQL

Facebook Relay 是“用于构建数据驱动的 React 应用程序的 JavaScript 框架”。它的其中一部分是查询语言 GraphQL,其查询可以通过带有 Relay.QL 标签的模板创建。例如(摘自 Relay 主页

class Tea extends React.Component {
  render() {
    var {name, steepingTime} = this.props.tea;
    return (
      <li key={name}>
        {name} (<em>{steepingTime} min</em>)
      </li>
    );
  }
}
Tea = Relay.createContainer(Tea, {
  fragments: { // (A)
    tea: () => Relay.QL`
      fragment on Tea {
        name,
        steepingTime,
      }
    `,
  },
});

class TeaStore extends React.Component {
  render() {
    return <ul>
      {this.props.store.teas.map(
        tea => <Tea tea={tea} />
      )}
    </ul>;
  }
}
TeaStore = Relay.createContainer(TeaStore, {
  fragments: { // (B)
    store: () => Relay.QL`
      fragment on Store {
        teas { ${Tea.getFragment('tea')} },
      }
    `,
  },
});

从 A 行和 B 行开始的对象定义了片段,这些片段是通过返回查询的回调定义的。片段 tea 的结果放入 this.props.tea 中。片段 store 的结果放入 this.props.store 中。

这是查询操作的数据

const STORE = {
  teas: [
    {name: 'Earl Grey Blue Star', steepingTime: 5},
    ···
  ],
};

此数据包装在一个 GraphQLSchema 实例中,并在其中获得名称 Store(如 fragment on Store 中所述)。

8.3.9 文本本地化 (L10N)

本节介绍一种简单的文本本地化方法,它支持不同的语言和不同的地区(如何格式化数字、时间等)。假设有以下消息。

alert(msg`Welcome to ${siteName}, you are visitor
          number ${visitorNumber}:d!`);

标签函数 msg 的工作原理如下。

首先,将字面量部分连接起来形成一个字符串,该字符串可用于在表中查找翻译。上一个示例的查找字符串是

'Welcome to {0}, you are visitor number {1}!'

例如,此查找字符串可以映射到德语翻译:

'Besucher Nr. {1}, willkommen bei {0}!'

英语“翻译”将与查找字符串相同。

其次,使用查找结果来显示替换。由于查找结果包含索引,因此它可以重新排列替换的顺序。这已经在德语中完成,其中访问者编号位于站点名称之前。如何格式化替换可以通过注释来影响,例如 :d。此注释表示应为 visitorNumber 使用特定于地区的十进制分隔符。因此,可能的英语结果是

Welcome to ACME Corp., you are visitor number 1,300!

在德语中,我们有如下结果

Besucher Nr. 1.300, willkommen bei ACME Corp.!

8.3.10 通过无标签模板字面量进行文本模板化

假设我们要创建 HTML,将以下数据显示在表格中

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

如前所述,模板字面量不是模板

模板基本上是一个函数:输入数据,输出文本。该描述为我们提供了一个线索,说明如何将模板字面量转换为实际模板。让我们实现一个模板 tmpl 作为将数组 addrs 映射到字符串的函数

const tmpl = addrs => `
    <table>
    ${addrs.map(addr => `
        <tr><td>${addr.first}</td></tr>
        <tr><td>${addr.last}</td></tr>
    `).join('')}
    </table>
`;
console.log(tmpl(data));
// Output:
// <table>
//
//     <tr><td><Jane></td></tr>
//     <tr><td>Bond</td></tr>
//
//     <tr><td>Lars</td></tr>
//     <tr><td><Croft></td></tr>
//
// </table>

外部模板字面量提供括号 <table></table>。在内部,我们嵌入了 JavaScript 代码,该代码通过连接字符串数组来生成字符串。该数组是通过将每个地址映射到两行表格来创建的。请注意,纯文本片段 <Jane><Croft> 未正确转义。下一节将解释如何通过标记模板执行此操作。

8.3.10.1 我应该在生产代码中使用这种技术吗?

对于较小的模板任务,这是一个有用的快速解决方案。对于较大的任务,您可能需要更强大的解决方案,例如模板引擎 Handlebars.js 或 React 中使用的 JSX 语法。

**致谢:**这种文本模板化方法基于 Claus Reinke 的一个想法

8.3.11 用于 HTML 模板化的标签函数

与上一节中使用无标签模板进行 HTML 模板化相比,标记模板有两个优点

然后模板的代码如下所示。标签函数的名称是 html

const tmpl = addrs => html`
    <table>
    ${addrs.map(addr => html`
        <tr><td>!${addr.first}</td></tr>
        <tr><td>!${addr.last}</td></tr>
    `)}
    </table>
`;
const data = [
    { first: '<Jane>', last: 'Bond' },
    { first: 'Lars', last: '<Croft>' },
];
console.log(tmpl(data));
// Output:
// <table>
//
//     <tr><td>&lt;Jane&gt;</td></tr>
//     <tr><td>Bond</td></tr>
//
//     <tr><td>Lars</td></tr>
//     <tr><td>&lt;Croft&gt;</td></tr>
//
// </table>

请注意,JaneCroft 周围的尖括号已转义,而 trtd 周围的尖括号未转义。

如果在替换前面加上感叹号 (!${addr.first}),则它将进行 HTML 转义。标签函数检查替换前的文本以确定是否进行转义。

html 的实现稍后显示

8.4 实现标签函数

以下是一个标记模板字面量

tagFunction`lit1\n${subst1} lit2 ${subst2}`

此字面量(大致)触发以下函数调用

tagFunction(['lit1\n',  ' lit2 ', ''], subst1, subst2)

确切的函数调用看起来更像这样

// Globally: add template object to per-realm template map
{
    // “Cooked” template strings: backslash is interpreted
    const templateObject = ['lit1\n',  ' lit2 ', ''];
    // “Raw” template strings: backslash is verbatim
    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], subst1, subst2)

标签函数接收两种输入

  1. 模板字符串(第一个参数):标记模板中不变的静态部分(例如 ' lit2 ')。模板对象存储模板字符串的两个版本
    • 已处理:解释了 \n 等转义符。存储在 templateObject[0] 等中。
    • 原始:未解释的转义符。存储在 templateObject.raw[0] 等中。
  2. 替换(剩余参数):通过 ${} 嵌入模板字面量中的值(例如 subst1)。替换是动态的,它们可以随着每次调用而改变。

全局模板对象背后的想法是,相同的标记模板可能会执行多次(例如,在循环或函数中)。模板对象使标签函数能够缓存来自先前调用的数据:它可以将其从输入类型 #1(模板字符串)派生的数据放入对象中,以避免重新计算它。缓存按_领域_发生(想想浏览器中的框架)。也就是说,每个调用站点和领域都有一个模板对象。

8.4.1 模板字符串的数量与替换的数量

让我们使用以下标签函数来探索有多少个模板字符串与替换相比。

function tagFunc(templateObject, ...substs) {
    return { templateObject, substs };
}

模板字符串的数量始终比替换的数量多一个。换句话说:每个替换始终由两个模板字符串包围。

templateObject.length === substs.length + 1

如果替换在字面量中排在第一位,则在其前面加上一个空的模板字符串

> tagFunc`${'subst'}xyz`
{ templateObject: [ '', 'xyz' ], substs: [ 'subst' ] }

如果替换在字面量中排在最后一位,则在其后面加上一个空的模板字符串

> tagFunc`abc${'subst'}`
{ templateObject: [ 'abc', '' ], substs: [ 'subst' ] }

空的模板字面量生成一个模板字符串,并且没有替换

> tagFunc``
{ templateObject: [ '' ], substs: [] }

8.4.2 标记模板字面量中的转义:已处理与原始

模板字符串有两种解释——已处理和原始。这些解释会影响转义

标签函数 describe 允许我们探索这意味着什么。

function describe(tmplObj, ...substs) {
    return {
        Cooked: merge(tmplObj, substs),
        Raw: merge(tmplObj.raw, substs),
    };
}
function merge(tmplStrs, substs) {
    // There is always at least one element in tmplStrs
    let result = tmplStrs[0];
    substs.forEach((subst, i) => {
        result += String(subst);
        result += tmplStrs[i+1];
    });
    return result;
}

让我们使用这个标签函数

> describe`${3+3}`
{ Cooked: '6', Raw: '6' }

> describe`\${3+3}`
{ Cooked: '${3+3}', Raw: '\\${3+3}' }

> describe`\\${3+3}`
{ Cooked: '\\6', Raw: '\\\\6' }

> describe`\``
{ Cooked: '`', Raw: '\\`' }

如您所见,只要已处理的解释中有替换或反引号,则原始解释中也会有。但是,字面量中的所有反斜杠都会出现在原始解释中。

反斜杠的其他出现解释如下

例如

> describe`\\`
{ Cooked: '\\', Raw: '\\\\' }

> describe`\n`
{ Cooked: '\n', Raw: '\\n' }

> describe`\u{58}`
{ Cooked: 'X', Raw: '\\u{58}' }

总而言之:反斜杠在原始模式下的唯一作用是转义替换和反引号。

8.4.3 示例:String.raw

以下是您如何实现 String.raw

function raw(strs, ...substs) {
    let result = strs.raw[0];
    for (const [i,subst] of substs.entries()) {
        result += subst;
        result += strs.raw[i+1];
    }
    return result;
}

8.4.4 示例:实现用于 HTML 模板化的标签函数

我之前演示了用于 HTML 模板化的标签函数 html

const tmpl = addrs => html`
    <table>
    ${addrs.map(addr => html`
        <tr><td>!${addr.first}</td></tr>
        <tr><td>!${addr.last}</td></tr>
    `)}
    </table>
`;
const data = [
    { first: '<Jane>', last: 'Bond' },
    { first: 'Lars', last: '<Croft>' },
];
console.log(tmpl(data));
// Output:
// <table>
//
//     <tr><td>&lt;Jane&gt;</td></tr>
//     <tr><td>Bond</td></tr>
//
//     <tr><td>Lars</td></tr>
//     <tr><td>&lt;Croft&gt;</td></tr>
//
// </table>

如果在替换前面加上感叹号 (!${addr.first}),则它将进行 HTML 转义。标签函数检查替换前的文本以确定是否进行转义。

这是 html 的实现

function html(templateObject, ...substs) {
    // Use raw template strings: we don’t want
    // backslashes (\n etc.) to be interpreted
    const raw = templateObject.raw;

    let result = '';

    substs.forEach((subst, i) => {
        // Retrieve the template string preceding
        // the current substitution
        let lit = raw[i];

        // In the example, map() returns an Array:
        // If `subst` is an Array (and not a string),
        // we turn it into a string
        if (Array.isArray(subst)) {
            subst = subst.join('');
        }

        // If the substitution is preceded by an exclamation
        // mark, we escape special characters in it
        if (lit.endsWith('!')) {
            subst = htmlEscape(subst);
            lit = lit.slice(0, -1);
        }
        result += lit;
        result += subst;
    });
    // Take care of last template string
    result += raw[raw.length-1]; // (A)

    return result;
}

模板字符串始终比替换多一个,这就是为什么我们需要在 A 行中追加最后一个模板字符串的原因。

以下是 htmlEscape() 的简单实现。

function htmlEscape(str) {
    return str.replace(/&/g, '&amp;') // first!
              .replace(/>/g, '&gt;')
              .replace(/</g, '&lt;')
              .replace(/"/g, '&quot;')
              .replace(/'/g, '&#39;')
              .replace(/`/g, '&#96;');
}
8.4.4.1 更多想法

使用这种模板化方法,您可以做更多的事情

8.4.5 示例:组装正则表达式

创建正则表达式实例有两种方法。

如果您使用后者,那是因为您必须等到运行时才能获得所有必要的成分。您正在通过连接三种片段来创建正则表达式

  1. 静态文本
  2. 动态正则表达式
  3. 动态文本

对于 #3,特殊字符(点、方括号等)必须进行转义,而 #1 和 #2 可以按字面使用。正则表达式标签函数 regex 可以帮助完成此任务

const INTEGER = /\d+/;
const decimalPoint = '.'; // locale-specific! E.g. ',' in Germany
const NUMBER = regex`${INTEGER}(${decimalPoint}${INTEGER})?`;

regex 如下所示

function regex(tmplObj, ...substs) {
    // Static text: verbatim
    let regexText = tmplObj.raw[0];
    for ([i, subst] of substs.entries()) {
        if (subst instanceof RegExp) {
            // Dynamic regular expressions: verbatim
            regexText += String(subst);
        } else {
            // Other dynamic data: escaped
            regexText += quoteText(String(subst));
        }
        // Static text: verbatim
        regexText += tmplObj.raw[i+1];
    }
    return new RegExp(regexText);
}
function quoteText(text) {
    return text.replace(/[\\^$.*+?()[\]{}|=!<>:-]/g, '\\$&');
}

8.5 常见问题解答:模板字面量和标记模板字面量

8.5.1 模板字面量和标记模板字面量来自哪里?

模板字面量和标记模板字面量是从 E 语言借用的,E 语言将此功能称为_准字面量_

8.5.2 宏和标记模板字面量之间有什么区别?

宏允许您实现具有自定义语法的语言结构。为语法像 JavaScript 这样复杂的编程语言提供宏是很困难的。该领域的研究正在进行中(请参阅 Mozilla 的 sweet.js)。

虽然宏在实现子语言方面比标记模板强大得多,但它们依赖于语言的标记化。因此,标记模板是互补的,因为它们专门用于文本内容。

8.5.3 我可以从外部源加载模板字面量吗?

如果我想从外部源(例如文件)加载模板字面量(例如 `Hello ${name}!`)怎么办?

如果你这样做,你就是在滥用模板字面量。鉴于模板字面量可以包含任意表达式并且是一个字面量,从其他地方加载它类似于加载表达式或字符串字面量——你必须使用 eval() 或类似的东西。

8.5.4 为什么反引号是模板字面量的分隔符?

反引号是 JavaScript 中为数不多的尚未使用的 ASCII 字符之一。语法 ${} 用于插值非常常见(Unix shell 等)。

8.5.5 模板字面量以前不是叫做模板字符串吗?

模板字面量这个术语在 ES6 规范制定过程中改的比较晚。以下是旧术语

下一页:9. 变量和作用域