标签模板字面量:在 JavaScript 中构建迷你 DSL
JavaScript 标签模板字面量详解:cooked 与 raw 字符串、WeakMap 缓存、安全 HTML、SQL 参数化和迷你 DSL 示例。
标签模板字面量是一种函数调用,其参数来自模板字面量:运行时在每个 ${...} 边界处拆分字面量,将静态字符串片段和已求值的插值传递给你指定的函数,并允许该函数返回任意值。最后这一点才是关键所在。当你编写 styled.div`...` 或 gql`...` 时,你实际上是在调用一个函数,该函数接收模板的各个片段,并返回一个非字符串的值——一个 React 组件,或一个已解析的 GraphQL 文档。本文将精确解释调用机制,然后用它构建四个可运行的迷你 DSL,其中包括一个通过结构性设计来抵御值注入的参数化 SQL 标签。
你已经使用过这种语法。接下来将介绍它的工作原理,以及如何构建你自己的标签。
核心要点
- 标签模板
tag`...`是tag(strings, ...values)的语法糖,其中strings是静态片段的数组,...values是从左到右依次求值的插值表达式。 strings数组的元素数量始终比values多一个——即strings.length === values.length + 1——因为模板字面量始终以字符串片段开头和结尾,即使该片段为空字符串。- 标签函数不必返回字符串:
styled-components返回 React 组件,graphql-tag返回DocumentNodeAST,lit-html返回TemplateResult。 - 由于同一个冻结的模板对象会在特定标签模板的每次求值中被复用,因此可以用以
strings数组为键的WeakMap来缓存对该字面量的解析结果。 - 安全 SQL 标签通过将插值转换为查询参数而非拼接到 SQL 文本中,来防止基于值的 SQL 注入——前提是它始终对值进行参数化处理,而不是将原始 SQL 或标识符直接拼接到查询中。
调用签名:tag`...` 解糖为 tag(strings, ...values)
标签模板是普通函数调用的语法糖。表达式 tag`Hi, ${name}!` 等价于以静态片段作为第一个参数、以各插值作为后续参数来调用 tag。以下两种形式在说明调用签名方面大致等价:
const name = "Zach";
// 标签模板字面量
tag`Hi, ${name}!`;
// 等价的解糖形式
tag(["Hi, ", "!"], name);
第一个参数是静态字符串片段的数组,在每个 ${...} 处拆分。其余参数是插值,通过剩余参数收集。(MDN 的标签模板文档描述了这种调用形式。)
function tag(strings, ...values) {
console.log(strings); // ["Hi, ", "!"]
console.log(values); // ["Zach"]
}
const name = "Zach";
tag`Hi, ${name}!`;
插值在调用时从左到右求值,并以其真实值传入——不会被字符串化。标签接收到的是函数对象、数组或对象的原始形式:
function tag(strings, ...values) {
return values;
}
tag`${() => 42} and ${[1, 2, 3]}`;
// [ [Function], [1, 2, 3] ] — 真实值,而非字符串
这是与普通模板字面量的第一个实质性区别:普通模板字面量始终将插值强制转换为字符串,并始终返回字符串。而标签函数在任何强制转换发生之前就拦截了这些片段。
strings.length === values.length + 1 不变式
strings 数组的元素数量始终比 values 数组多一个——即 strings.length === values.length + 1——因为模板字面量始终以字符串片段开头和结尾,即使该片段是空字符串。含有三个插值的字面量有四个字符串片段;以 ${x} 开头的字面量,其第一个片段就是空字符串:
function tag(strings, ...values) {
console.log(strings.length, values.length);
}
tag`${1}${2}`;
// strings: ["", "", ""] → 3
// values: [1, 2] → 2
正是这个不变式使得对 strings 进行 reduce 操作成为交错片段与值的最简洁方式:遍历字符串片段,在每个片段之后追加 values[i],最后一个片段除外。
已处理值与原始值:差异至关重要的场景
Discover how at OpenReplay.com.
strings 数组保存的是*已处理(cooked)的解释,其中 \n 是换行符,\t 是制表符。strings.raw 属性保存的是原始(raw)*源字符,其中 \n 是反斜杠和字母 n 这两个字符组成的序列。当你的 DSL 处理结构性地使用反斜杠的文本时——例如正则表达式模式、LaTeX 标记或 Windows 文件路径——这一区别至关重要。
function showBoth(strings) {
console.log(strings[0]); // 已处理值:真实的换行符
console.log(strings.raw[0]); // 原始值:字符 \ 和 n
}
showBoth`line\nbreak`;
// cooked → "line",然后下一行是 "break"
// raw → "line\nbreak"
JavaScript 内置了一个专门用于此目的的标签:String.raw。它返回字面量的原始解释,使转义序列以字面字符的形式保留下来。(MDN 的 String.raw 参考文档将其记录为唯一的内置模板标签;仅当字面量不包含转义序列时,它的行为才类似于恒等标签。)
const winPath = String.raw`C:\Users\dev\project`;
console.log(winPath); // "C:\Users\dev\project" — 反斜杠被保留
const pattern = new RegExp(String.raw`\d{3}-\d{4}`);
pattern.test("555-1234"); // true — 无需双重转义
如果不使用 String.raw,正则表达式的源字符串需要写成 \\d{3}-\\d{4} 才能在处理后保持正确。你编写的任何标签都可以通过读取 strings.raw 而非 strings 来提供相同的行为。
标签函数可以返回任意值
标签函数本质上是一个普通函数,其返回值完全由作者决定。这一认识将标签模板从字符串格式化的小技巧,转变为构建领域特定语言的有力工具。静态片段和值是原始材料,标签决定从中构建什么。styled-components 返回 React 组件,graphql-tag 返回 DocumentNode AST,lit-html 返回 TemplateResult。
以下四个 DSL 从字符串输出逐步升级到参数化查询对象,展示了普通模板字面量无法产生的返回类型。
HTML 转义标签(防御 XSS)
HTML 转义标签在每个插值到达 DOM 之前,将其包装为 HTML 实体编码,从而防止用户输入突破其文本上下文。它所防御的漏洞是:将不可信输入直接插值到标记中是一种存储型或反射型 XSS 攻击向量——值中的恶意载荷在字符串被插入 DOM 时就会执行:
const userInput = '<img src="x" onerror="alert(1)">';
const markup = `<div>${userInput}</div>`;
// element.innerHTML = markup → onerror 处理程序被执行
safeHtml 标签拦截每个插值并在其进入输出之前进行转义,同时保持静态片段——由开发者而非用户编写——不变:
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function safeHtml(strings, ...values) {
return strings.reduce((acc, segment, i) => {
const value = i < values.length ? escapeHtml(values[i]) : "";
return acc + segment + value;
}, "");
}
const userInput = '<img src="x" onerror="alert(1)">';
const markup = safeHtml`<div>${userInput}</div>`;
// "<div><img src="x" onerror="alert(1)"></div>" — 已无害化
这种不对称性正是关键所在:静态片段是可信的(由你编写),插值是不可信的(可能携带用户输入),而标签是强制执行这一区分的唯一位置。
安全 SQL 标签(通过结构性设计实现参数化查询)
安全 SQL 标签通过将插值转换为查询参数而非拼接到 SQL 文本中,来防止基于值的 SQL 注入。字符串拼接版本的查询是经典的 SQL 注入漏洞——将值拼接到查询文本中,使该值得以改变查询的结构:
// 错误示范 — 请勿使用
const id = "1 OR 1=1";
const query = `SELECT * FROM users WHERE id = ${id}`;
// "SELECT * FROM users WHERE id = 1 OR 1=1" → 返回所有行
安全 SQL 标签通过将插值转换为查询参数而非拼接到 SQL 文本中,来防止基于值的 SQL 注入——前提是该标签始终对插值进行参数化处理,而不是将原始 SQL 或标识符拼接到查询中。该标签返回一个 { text, values } 对象:text 字段包含占位符($1、$2……),values 字段包含实际数据,与查询结构分离存储:
function sql(strings, ...values) {
const text = strings.reduce((acc, segment, i) => {
return acc + segment + (i < values.length ? `$${i + 1}` : "");
}, "");
return { text, values };
}
const id = "1 OR 1=1";
const query = sql`SELECT * FROM users WHERE id = ${id}`;
// {
// text: "SELECT * FROM users WHERE id = $1",
// values: ["1 OR 1=1"]
// }
这种 { text, values } 结构正是 node-postgres 接受参数化查询的格式:client.query(query) 将查询文本和参数值分别传递给 PostgreSQL,数据库永远不会将参数值解释为 SQL 语法。恶意字符串 "1 OR 1=1" 被视为单个参数值,而非 SQL 语法,因此无法成为查询结构的一部分。
这比字符串拼接版本更具防御性的原因在于结构:使用标签时,插值只能成为参数。在标签形式中,不存在任何将值拼接到查询文本中的语法。这种安全性适用于值;它不适用于动态表名或列名——这些是标识符,必须单独进行验证,而不能通过插值处理。
带 WeakMap 身份缓存的 i18n 标签
由于同一个冻结的模板对象会在特定标签模板的每次求值中被复用——这是 ECMAScript 规范定义的行为——因此可以用以 strings 数组为键的 WeakMap 来缓存对该字面量的解析结果:只需解析一次,后续求值时复用缓存结果。这正是基于模板的库得以避免在每次渲染时重新解析同一模板的身份识别技巧。
这种复用是可以观察到的。同一个 strings 数组在对同一标签模板的多次调用中是引用相同的:
let first, second;
function capture(strings) {
return strings;
}
function render() {
return capture`hello ${1} world`;
}
first = render();
second = render();
console.log(first === second); // true — 同一个冻结的数组对象
你可以在任何浏览器控制台或 Node.js REPL 中验证这一点;这是规范定义的模板对象复用行为,而非实现细节。(ECMAScript 规范的模板求值语义定义了对给定模板字面量返回同一模板对象的行为。)
以 strings 为键的 WeakMap 可将其转化为缓存。以下示例中,i18n 标签对每个字面量只预计算一次格式字符串,然后复用:
const cache = new WeakMap();
function t(strings, ...values) {
let key = cache.get(strings);
if (key === undefined) {
// 每个唯一字面量只执行一次的耗时操作
key = strings.join("{}");
cache.set(strings, key);
}
return lookupTranslation(key, values);
}
WeakMap 以弱引用方式持有其键,因此一旦模板对象不再被引用,缓存条目就可以被垃圾回收——无需手动清除,也不存在字符串键冲突。
CSS-in-JS 样式标签
样式标签可以返回一个普通对象,供使用方在其他地方进行组合,这与 styled-components 和 Emotion 将 CSS 字面量转换为组件系统可消费的形式的方式如出一辙。该标签不返回字符串,而是将声明解析为键值对象:
function css(strings, ...values) {
const cssText = strings.reduce(
(acc, segment, i) => acc + segment + (values[i] ?? ""),
""
);
return Object.fromEntries(
cssText
.split(";")
.map((d) => d.trim())
.filter(Boolean)
.map((d) => {
const [prop, val] = d.split(":").map((s) => s.trim());
return [prop, val];
})
);
}
const styles = css`
display: flex;
align-items: center;
`;
// { display: "flex", "align-items": "center" }
这正是真实实现的基本形态。在 styled-components 中,styled.div`...` 返回一个 React 组件;styled-components 文档涵盖了完整的 API。其机制与上面的示例完全相同——标签函数消费 CSS 字面量,并返回框架知道如何使用的非字符串值。
基于标签模板构建的真实库
以下库均以标签模板作为其公共 API。每个库标记一种不同的语言,并返回不同的类型,这最清晰地说明了返回值完全由作者决定。
| 库 | 标记的内容 | 返回值 | 使用场景 |
|---|---|---|---|
| styled-components | CSS | React 组件 | 组件级 CSS-in-JS |
| Emotion | CSS | 类名 / 样式组件 | 支持对象和标签模板 API 的 CSS-in-JS |
| lit-html | HTML | TemplateResult | 高效 DOM 渲染 |
| graphql-tag | GraphQL | DocumentNode AST | 为 GraphQL 客户端解析查询 |
| sql-template-strings | SQL | 预处理语句对象 | 参数化 SQL(注意:已不再积极维护) |
| common-tags | 文本 | 格式化字符串 | 适用于 ES2015+ 的可复用模板字面量标签函数 |
标签函数的 TypeScript 签名
标签函数的 TypeScript 签名为 function tag(strings: TemplateStringsArray, ...values: unknown[]): T,其中 TemplateStringsArray 被类型化为带有额外 readonly raw: readonly string[] 成员的 ReadonlyArray<string>。该类型内置于 TypeScript 的标准库中,无需导入。
// 标签函数的通用形式
type Tag<T> = (
strings: TemplateStringsArray,
...values: unknown[]
) => T;
// 应用于安全 SQL 标签:
interface SqlQuery {
text: string;
values: unknown[];
}
function sql(strings: TemplateStringsArray, ...values: unknown[]): SqlQuery {
const text = strings.reduce(
(acc, segment, i) => acc + segment + (i < values.length ? `$${i + 1}` : ""),
""
);
return { text, values };
}
TemplateStringsArray 定义于 lib.es5.d.ts,自 TypeScript 早期版本起即已提供。(TypeScript 1.5 发布说明在其标签模板示例中使用了 TemplateStringsArray。)请注意,此处的 readonly 建模是类型层面的描述:模板对象在运行时的冻结是独立的 ECMAScript 行为,并非由 TypeScript 类型所引起。
不适合使用标签模板的场景
如果你的 DSL 需要真正的语法——运算符优先级、前瞻或递归结构——那么标签模板并非合适的工具。标签函数接收的是字符串片段和已求值的值的扁平列表,而非解析树,因此任何需要超越从左到右对插值求值的场景,都应交由解析器组合子或 PEG 语法处理。当遇到真正的语言时,请使用专用解析器,如 nearley 或 Peggy。
以下三个具体信号表明你已超出标签模板的适用范围:
- 你需要语法规则。 优先级、嵌套作用域或回溯无法用扁平的字符串-值拆分来表达。请使用解析器。
- 求值顺序超出从左到右的范围。 插值在调用时从左到右求值,在标签运行之前完成。标签无法推迟或重排它们——它只能看到结果。
- 你处于热循环中。 即使同一模板对象被复用,插值表达式在每次调用时都会被求值,因此在紧密渲染循环中,代价高昂的插值每次迭代都要付出其开销。
strings数组通过身份标识缓存;值则不然。
对于介于普通插值和完整语言之间的一切场景——转义、参数化、键控缓存、构建样式组件——标签模板是恰到好处的工具。
从安全 SQL 标签入手。它足够小,可以一次性读完,返回与 node-postgres 兼容的 { text, values } 对象,并展示了标签模板值得使用的核心特性:跨越 ${...} 边界的值可以被强制进入一种安全角色,而这是字符串拼接永远无法保证的。
常见问题
为什么标签模板中 strings.length 始终比 values.length 多一个?
模板字面量始终以字符串片段开头和结尾,即使该片段为空,因此静态片段在两侧都包裹着每个插值。有 n 个插值就有 n+1 个字符串片段,由此得出不变式 strings.length === values.length + 1。对于像 tag`${x}` 这样的字面量,插值前有一个空字符串,插值后也有一个空字符串,因此两个插值会产生三个字符串片段。
标签函数中已处理值(cooked)和原始值(raw)字符串有什么区别?
已处理数组(通过 strings 访问)会解释转义序列:\\n 变为真实的换行符,\\t 变为制表符。原始数组(通过 strings.raw 访问)保留字面源字符,因此 \\n 保持为反斜杠和 n 这两个字符。当 DSL 处理结构性地使用反斜杠的文本时,这一区别至关重要,例如正则表达式模式、LaTeX 标记或 Windows 文件路径——在这些场景中,处理(cooking)会破坏输入内容。
标签模板字面量在 TypeScript 中无需额外配置即可使用吗?
是的。标签函数签名为 function tag(strings: TemplateStringsArray, ...values: unknown[]): T,其中 TemplateStringsArray 定义于 TypeScript 标准库文件 lib.es5.d.ts 中,无需导入。它被类型化为带有额外 readonly raw 成员的字符串 ReadonlyArray。readonly 建模仅为类型层面的描述;模板对象在运行时的冻结是独立的 ECMAScript 行为,并非由 TypeScript 类型所强制执行。
标签模板的标签函数可以返回非字符串的值吗?
可以。标签函数是普通函数,可以返回作者选择的任意值。styled-components 返回 React 组件,graphql-tag 返回 DocumentNode AST,lit-html 返回 TemplateResult。安全 SQL 标签可以返回与 node-postgres 兼容的 text 和 values 对象。普通模板字面量始终强制转换为字符串,但标签函数在强制转换发生之前就拦截了片段和值,因此返回类型完全取决于实现。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k