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 有兩個新的字面值

請務必記住,範本字串和標記範本的名稱有點誤導。它們與網頁開發中經常使用的範本無關:可透過(例如)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 借來的,該語言將此功能稱為 準字面值

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. 變數和範圍