給急躁的程式設計師的 JavaScript(ES2022 版)
請支持這本書:購買捐款
(廣告,請不要封鎖。)

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 修復:用於縮排的範本標籤

第一個修復方法是使用自訂範本標籤,以移除不需要的空白。它使用初始換行符號後的第 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

  測驗

請參閱 測驗應用程式