模板字符串不只是更省代码的拼接语法:普通插值会在运行时把表达式转成字符串,标签模板则把静态片段和动态值交给标签函数处理。
这条差异决定了它更适合两类工作:生成可读的动态文本,或把“如何拼接”提升为可复用的转换协议。前者使用 ${},后者使用 tag\…`;String.raw` 则用于保留静态片段中的原始转义写法。模板字符串的工程价值不只是少写几个引号,而是能否明确控制求值、转换与安全边界。
热榜文章中的“不只是拼接”指什么?
掘金热榜文章《JavaScript 模板字符串与标签模板:不只是拼接》指出了一个常见误读:把模板字符串仅仅理解为字符串拼接的语法糖。
从语言语义看,模板字符串包含三层能力:
- 反引号包围的静态文本;
${}中按运行时求值的 JavaScript 表达式;- 标签函数对静态片段和动态值进行自定义处理的调用协议。
普通模板最终得到一个字符串;标签模板把静态片段和插值结果交给函数。函数可以根据场景转义、解析、校验,也可以返回对象、数组等任意类型。它不是 JavaScript 新发布的特性,而是一项已经进入日常工程代码的语言能力。
模板字符串比普通字符串多出了哪些能力?
模板字面量由 ES2015 引入,使用反引号作为定界符。它在传统引号字符串的基础上增加了插值、多行文本和嵌套表达式等能力。
const user = { name: "Ada" };
const count = 3;
const message = `你好,${user.name}。你有 ${count} 条消息。`;
const nested = `外层:${`内层:${user.name}`}`;
const literal = `反引号:\`;插值起点:\${value}`;
这段代码包含几个容易忽略的细节:
${}内可以放变量、属性访问、函数调用、三元表达式以及嵌套模板;- 模板中的反引号需要写成
; - 如果希望输出字面量
${,需要写成\${; - 嵌套模板仍然是普通表达式,会在所在位置继续求值。
多行文本不需要额外拼接 \n:
const title = "示例";
const markup = `<section>
<h1>${title}</h1>
</section>`;
换行和缩进都会进入最终字符串,这与使用单引号、双引号加 \n 或数组 join() 的结果不同。模板字符串让文本布局和插值表达更清晰,但并非所有场景都应该放弃 +;数值运算或对非文本类型有严格处理要求的场景,仍可能适合原有写法。
${} 里的表达式何时求值、怎样变成文本?
插值表达式会在模板字符串被求值时立即执行,而不是等到结果真正参与某项操作时才执行。它们按照出现顺序求值,并从所在词法作用域查找变量。
const trace = [];
function mark(label) {
trace.push(label);
return label;
}
const result = `${mark("a")}-${mark("b")}`;
console.log(result); // a-b
console.log(trace); // ["a", "b"]
普通模板会对插值结果执行字符串强制转换。字符串、数字和布尔值会得到对应的文本表示;对象则可能在转换过程中调用自定义的 toString(),因此插值也可能触发副作用。
const calls = [];
const value = {
toString() {
calls.push("toString");
return "converted";
}
};
const text = `value=${value}`;
console.log(text); // value=converted
console.log(calls); // ["toString"]
这意味着 ${obj} 并非单纯的“读取对象文本”。如果 toString() 含有计数、缓存更新或抛错逻辑,这些逻辑也会进入模板的运行时路径。复杂表达式、函数调用和赋值尤其应该先提取到具名变量,让求值过程显式化:
const displayName = getDisplayName(user);
const message = `用户:${displayName}`;
显式提取变量既能改善可读性,也能减少测试时遗漏副作用的风险。
标签模板为什么不是先拼成字符串再调用函数?
考虑下面两种写法:
const name = "Ada";
tag`Hello ${name}!`;
tag(["Hello ", "!"], name);
从语言语义看,两者并不等价。前者不会先生成一个完整的 "Hello Ada!" 字符串再传给 tag,而是把模板中的静态片段与插值结果分别交给标签函数。
function inspect(strings, ...values) {
return {
strings: [...strings],
values,
raw: [...strings.raw]
};
}
const result = inspect`Hello ${name}!`;
在这个例子中,result 大致为:
{
strings: ["Hello ", "!"],
values: ["Ada"],
raw: ["Hello ", "!"]
}
标签函数的第一个参数 strings 是静态片段数组,模板中的插值位置把静态文本切分成若干段;后续参数 values 则对应各个插值表达式的求值结果。静态片段通常比插值结果多一段,因为最后一个片段位于最后一次插值之后。
这里还有一个关键差异:标签模板中的 values 不会自动统一转换成字符串。
const profile = { name: "Ada" };
const result = inspect`User ${profile}`;
console.log(result.values[0] === profile); // true
普通模板会立即把对象转换成文本;标签模板则可以把对象原样交给标签函数,由标签函数决定如何检查、格式化或序列化。
因此,标签函数也不必返回字符串:
function structuredTag(strings, ...values) {
return {
parts: strings,
values
};
}
const result = structuredTag`User ${profile}`;
result 是对象。标签函数可以返回字符串、对象、数组或函数,具体类型由标签自身的协议决定。
raw 和 String.raw 保留的究竟是什么?
普通模板中的静态片段会处理转义序列,strings.raw 则保留这些片段在模板源代码中的原始写法。
function showRaw(strings) {
console.log(strings[0]); // 包含实际换行
console.log(strings.raw[0]); // 保留字面量形式的 \n
}
showRaw`a\nb`;
可以把两者理解为:
strings是经过转义处理的片段,也常称为 cooked 片段;strings.raw是未经转义处理的原始片段。
二者通常具有相同的长度和切分位置,但字符内容不一定相同。自定义标签需要处理反斜杠、换行或代码片段时,应明确使用哪一个版本,避免把 cooked 与 raw 混在一起。
String.raw 是内置标签函数,最直接的用途是保留静态片段中的反斜杠:
const cooked = `a\nb`;
const raw = String.raw`a\nb`;
cooked 包含实际换行,raw 保留反斜杠和 n 的字面写法。它适合 Windows 路径、正则表达式字符串和代码生成等场景。
const account = "alice";
const path = String.raw`C:\Users\${account}\notes.txt`;
const digitPattern = new RegExp(String.raw`\d+`);
需要注意的是,String.raw 只让静态片段保持原始写法,插值表达式仍会正常求值:
const count = 2;
const mixed = String.raw`a\n${count}`;
这里静态的 \n 被原样保留,${count} 仍然得到 2。String.raw 返回的也是字符串,而不是正则对象;需要正则实例时仍要调用 RegExp。
哪些项目场景值得引入标签模板?
标签模板适合处理“结构固定、转换规则重复”的文本。它的主要收益不是替代一次性的字符串拼接,而是把转换规则收敛到一个可测试的函数中。国际化、SQL、HTML、CSS-in-JS、GraphQL 与 DSL 都是常见方向。
国际化可以先把模板选择和占位符替换分开:
function i18n(strings, ...values) {
return strings.reduce(
(result, part, index) =>
result +
part +
(index < values.length ? String(values[index]) : ""),
""
);
}
const message = i18n`你好,${name},你有 ${count} 条消息`;
这个最小实现只演示了占位符替换。实际项目通常还会先根据 locale 选择模板,再由标签完成变量替换、复数规则和缺失参数检查。
SQL 场景更适合让标签只处理结构,把值留给参数化查询:
function sql(strings, ...values) {
const text = strings.reduce(
(out, part, index) =>
out + part + (index < values.length ? "?" : ""),
""
);
return { text, values };
}
const query = sql`
SELECT id
FROM users
WHERE status = ${status}
AND id = ${id}
`;
db.execute(query.text, query.values);
这里的 ? 只是示意,具体占位符应替换为目标数据库驱动支持的格式。值没有进入 SQL 文本,表名、列名和排序字段也不能依靠插值处理,而应经过白名单校验。
HTML 场景可以把动态值统一交给上下文相关的转义函数:
function escapeText(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function html(strings, ...values) {
return strings.reduce(
(out, part, index) =>
out +
part +
(index < values.length ? escapeText(values[index]) : ""),
""
);
}
const element = html`<p>${userInput}</p>`;
这个例子只适用于 HTML 文本上下文。属性、URL、样式和脚本等上下文需要各自的编码规则;富文本也不能只靠几个字符替换完成清洗。
CSS-in-JS 可以利用标签函数返回供样式引擎消费的对象,而不是把全部处理逻辑留在调用处:
function css(strings, ...values) {
return {
cssText: strings.reduce(
(out, part, index) =>
out +
part +
(index < values.length ? String(values[index]) : ""),
""
),
values
};
}
const style = css`
color: ${color};
padding: ${spacing}px;
`;
成熟样式库还会处理单位、嵌套、作用域和唯一标识生成等问题;标签模板提供的是适合承载这些规则的调用入口。
GraphQL 查询同样适合通过 DSL 标签构造,动态参数则应保留为变量:
const document = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`;
const variables = { id: userId };
如果把不可信输入直接插入 GraphQL 源码,就仍然需要额外转义或校验;GraphQL 变量机制承担的是参数绑定,不应被普通字符串拼接替代。
微型 DSL 还可以借助标签把动态值编码进目标语法:
function jsonTag(strings, ...values) {
const source = strings.reduce(
(out, part, index) =>
out +
part +
(index < values.length ? JSON.stringify(values[index]) : ""),
""
);
return JSON.parse(source);
}
const data = jsonTag`{"name": ${name}, "active": ${true}}`;
这里插值位置代表一个 JSON 值,而不是任意源代码片段。不同 DSL 的转义规则并不相同,不能直接复用 JSON 的编码方式。
用了标签模板,注入风险会自动消失吗?
不会。标签模板是一种调用机制,不是自动生效的安全层。标签函数可以实现转义、参数分离和校验,但最终安全性取决于它是否针对具体上下文正确处理了输入。
SQL 中应使用参数化查询传递值。手工替换单引号不能替代参数绑定,也不能安全处理表名、列名等标识符。
HTML 中要区分文本、属性、URL、CSS 和脚本上下文。一个“通用 HTML 转义函数”即使转义了尖括号和引号,也不意味着它能正确处理 href 中的 URL,更不等于可以过滤 javascript: 等危险协议。允许用户提交富文本时,还需要使用面向 HTML 的清洗方案,例如资料中提到的 DOMPurify。
GraphQL 应优先使用变量,DSL 应按照自身语法编码,CSS 值则要结合目标属性的规则处理。安全标签还应配合白名单、输入清洗或上下文转义,并用恶意输入覆盖测试,而不是只验证正常路径。
真实项目里最容易漏掉哪些运行时细节?
多行缩进会进入最终结果。像 <section> 这样的模板如果从代码中间开始书写,首尾换行和内部缩进都会成为字符串内容。trim() 只能处理首尾空白,若要统一缩进,需要额外的 dedent 标签或结构化生成方式。
复杂插值会隐藏副作用。${items.shift()}、${saveRecord()} 或带业务逻辑的三元表达式都会让模板承担超出文本构建的职责,把这些操作提取出来更利于追踪和测试。
标签收到的值并不是天然字符串。数字、对象、null 和 undefined 会原样进入 values,需要字符串时应由标签显式转换,并明确转换失败时的处理方式。
cooked 与 raw 不能混用。普通转义文本通常读取 strings,检查源代码写法或生成代码时读取 strings.raw,同时使用前应明确目标。
反引号和 ${ 都需要正确处理。字符串内容来自运行时数据时,不能指望手写反斜杠代替真正的上下文编码,旧有构建链、模板编辑器和测试数据也要覆盖这两种边界。
还需考虑 ES6 兼容性。模板字面量属于 ES6 语法,旧浏览器、旧 JavaScript 引擎或尚未升级的构建配置可能无法解析它,输出结果相似并不代表解析兼容性相同。
代码更短也不代表性能更好。模板字符串的主要收益是文本可读性和扩展能力;若项目关心性能,应基于真实输入规模和目标运行环境进行测量,而不是仅根据语法长度下结论。
普通模板、标签模板还是 String.raw,最后怎么选?
| 需求 | 选择 | 原因与边界 |
|---|---|---|
| 普通文案、变量插值、简短多行文本 | 普通模板字符串 | 语义直接,不需要额外协议 |
| 固定结构、重复转义、参数分离、解析或结构化输出 | 自定义标签模板 | 把转换规则集中到可测试的函数中 |
| 保留路径、正则或生成代码中的反斜杠 | String.raw |
只让静态片段保持原始转义写法 |
| 同时需要原始片段和自定义处理 | 自定义标签并读取 strings.raw |
明确区分原始文本与 cooked 文本 |
可以从项目中挑出一段重复使用 +、手工转义或反复拼接反斜杠的代码,先确认它只是文案生成、存在固定转换协议,还是需要保留原始文本,再分别用普通模板、窄职责标签或 String.raw 做小范围重构。重构时至少补齐空值、特殊字符、换行和恶意输入的边界测试,再决定是否扩大使用范围。
[email protected]
。
我们承诺在核实信息后,将第一时间删除相关内容或断开链接。






















暂无评论内容