看似冷门的 document.currentScript,其实能优雅地解决脚本配置、组件通信和加载控制等问题,让你的前端代码更简洁、更“原生”。
我偶然发现了这个 API,起初并不确定它的用途。后来我才意识到,它在为 <script> 元素暴露配置属性时,其实非常方便。
有时候,我会遇到一些在浏览器中存在已久的 JavaScript API——其实我早该知道它们。比如 window.screen 属性和 CSS.supports() 方法。让我稍感安慰的是,我发现自己并不是唯一一个不知道的人。我记得我曾发帖提到 window.screen,结果收到了一堆评论,很多人也不知道它的存在。
一个 API 的知名度,更多取决于它在我们解决问题时的适用性,而不是它存在了多久。如果 window.screen 这种 API 没有太多实际应用场景,人们自然容易忘记它。
不过,偶尔也会有一些机会,让这些不太被注意的特性派上用场。而我最近就发现了这样一个例子 —— document.currentScript,而且我打算好好利用它。
它有什么作用?
从定义上看,document.currentScript 的作用很简单:它能返回当前正在执行的 <script> 元素本身的引用。作为一个基础的 DOM API,它的浏览器兼容性非常好,在所有主流浏览器中已经存在十多年了。
<script>
console.log("tag name:", document.currentScript.tagName);
console.log(
"script element?",
document.currentScript instanceof HTMLScriptElement
);
// tag name: SCRIPT
// script element? true
</script>
既然拿到了这个元素本身,你就能像操作任何 DOM 节点一样访问它的属性。
<script data-external-key="123urmom" defer>
console.log("external key:", document.currentScript.dataset.externalKey);
if (document.currentScript.defer) {
console.log("script is deferred!");
}
</script>
// external key: 123urmom
// script is deferred!
模块脚本的限制
document.currentScript 有一个重要的限制:它在模块脚本中不可用。不过奇怪的是,如果你在模块中访问它,不会得到 undefined,而是 null:
<script type="module">
console.log(document.currentScript);
console.log(document.doesNotExist);
// null
// undefined
</script>
这其实是标准中明确规定的行为。文档创建时,currentScript 会被初始化为 null。此外,当脚本执行完毕后,它也会恢复为 null。所以如果你在异步代码中访问它,同样会得到 null:
<script>
console.log(document.currentScript);
// <script> 标签
setTimeout(() => {
console.log(document.currentScript);
// null
}, 1000);
</script>
也就是说,在 <script type="module"> 内部,是没有办法访问当前脚本标签的。如果你只是想知道代码是否在模块中同步运行,可以简单地通过判断是否为 null 来实现。
function isInModule() {
return document.currentScript === null;
}
顺带一提,不要尝试用 import.meta 来检测,即使放在 try/catch 里也不行。因为只要它出现在普通 <script> 标签中,浏览器在解析时就会直接抛出 SyntaxError,甚至不需要执行代码。
<script>
// 还没执行就会抛出 SyntaxError!
function isInModule() {
try {
return !!import.meta;
} catch (e) {
return false;
}
};
// 这也会报错:
console.log(typeof import?.meta);
</script>
由于模块脚本目前还不支持类似 document.currentScript 的机制,未来如何解决这个问题还在讨论中。在那之前,最简单的替代方案就是——直接通过 DOM 查询脚本元素:
<script type="module" id="moduleScript">
const scriptTag = document.getElementById("moduleScript");
// 在这里操作 scriptTag。
</script>
实际需求:传递配置属性
我在一个网站上使用了 Stripe 的定价表,它是一个可以直接嵌入的原生 Web 组件。使用方式很简单:加载一个脚本,在 HTML 中放入组件标签,并设置几个属性:
<script
async
src="https://js.stripe.com/v3/pricing-table.js">
</script>
<stripe-pricing-table
pricing-table-id="prctbl_blahblahblah"
publishable-key="pk_test_blahblahblah"
>
</stripe-pricing-table>
如果 HTML 渲染时就能取到一些环境变量,这种方式完全没问题。但我想把这个表格嵌入到 Markdown 文件里。Markdown 虽然支持原生 HTML,但在里面拿到这些属性值可不像使用 import.meta.env 或 process.env 那么容易。因此,我需要一种方法,在页面渲染后再动态注入这些值。
不幸的是,Stripe 的定价表组件在初始化时就需要这些属性值,无法将渲染和配置分开。所以我不得不通过客户端脚本,连同属性值一起,把整个元素动态插入页面。做法是先在 Markdown 里放一个占位符元素,然后在脚本中填充它的内容。
我的做法如下:
<div data-pricing-table></div>
<script>
document.querySelectorAll('[data-pricing-table]').forEach(table => {
table.innerHTML = `
<stripe-pricing-table
pricing-table-id="STAY_TUNED"
publishable-key="STAY_TUNED"
client-reference-id="picperf"
></stripe-pricing-table>
`;
})
</script>
到这一步,我唯一缺少的就是属性值本身。我可以在服务器端把这些值写入全局对象(如 window),但那种方式让我感觉不太舒服——我不喜欢随意往全局作用域里塞数据。
一个比想象中更常见的问题
很多内容管理系统出于安全或架构考虑,往往会严格限制编辑器能控制的内容。编辑者可能能修改页面的一些结构或内容,但几乎无法改动 <script> 标签里的内容——这是有道理的,毕竟那会引入很多潜在的安全风险。
更复杂的是,这些脚本往往引用外部团队维护的共享包,但又需要某些配置参数。在这种情况下,就算想在服务器端把变量渲染到脚本里,也做不到。
<!-- 共享库,但仍需要配置! -->
<script src="path/to/shared/signup-form.js"></script>
在类似的场景中,一种常用做法是通过服务器渲染的数据属性传递配置值。服务端定义这些属性,脚本在客户端读取即可。这种模式在单页应用中尤其常见,比如配置写在根节点上:
<div
id="app"
data-recaptcha-site-key="{{ siteKey }}"
></div>
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const appNode = document.getElementById('app');
const root = ReactDOM.createRoot(appNode);
root.render(
// 从根节点读取配置值
<App recaptchaSiteKey={appNode.dataset.recaptchaSiteKey} />
);
应该已经很明显了:使用 data 属性,是从服务器传递特定值到客户端的一种简洁方式。
在上面的单页应用例子里,唯一略显麻烦的地方是——在访问属性前,你得先查询对应的元素。但在我的场景中,既然是 <script> 标签本身在执行代码,那连这一步都可以省掉。因为 document.currentScript 能直接提供当前脚本的引用。
<script
data-stripe-pricing-table="{{pricingTableId}}"
data-stripe-publishable-key="{{publishableKey}}"
>
const scriptData = document.currentScript.dataset;
document.querySelectorAll('[data-pricing-table]').forEach(table => {
table.innerHTML = `
<stripe-pricing-table
pricing-table-id="${scriptData.stripePricingTable}"
publishable-key="${scriptData.stripePublishableKey}"
client-reference-id="picperf"
></stripe-pricing-table>
`;
})
</script>
这感觉就很完美了。既没有依赖什么神秘的框架特性,也没污染全局作用域,而且完全利用了平台本身的能力。
其他应用场景
在研究 document.currentScript 的过程中,我又想到了一些潜在的用法。
安装提示
假设你维护着一个必须异步加载的 JavaScript 库。你可以利用 document.currentScript 为开发者提供清晰直接的加载反馈:
<script defer src="./script.js"></script>
// script.js
if (!document.currentScript.async) {
throw new Error("这个脚本必须以异步方式加载!!!");
}
// 你的库的其他代码……
你甚至还能强制要求脚本加载在页面的某个特定位置。比如要求它必须紧跟在 <body> 标签的开始处加载:
const isFirstBodyChild =
document.body.firstElementChild === document.currentScript;
if (!isFirstBodyChild) {
throw new Error(
"这个脚本必须紧贴在 <body> 标签的开头加载。"
);
}
这样的报错几乎没有歧义,能给出友好、直观的加载指导,算是良好文档的有力补充。
行为局部性
“行为局部性原则”认为:你应该能够仅通过查看某段代码本身,就理解它的行为。与 document.currentScript 结合时,这个原则意味着:你可以仅凭相邻元素的存在,就构建出可移植的小型交互功能。
例如,下面的例子可以让任意表单在提交时自动通过 AJAX 异步提交。只需把脚本标签放在表单后面即可。脚本会自动找到它前面的那个元素:
// form-submitter.js
const form = document.currentScript.previousElementSibling;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(form);
const method = form.method || "POST";
const submitGet = () =>
fetch(`${form.action}?${params}`, { method: "GET" });
const submitPost = () =>
fetch(form.action, { method, body: formData });
const submit = method === "GET" ? submitGet : submitPost;
const response = await submit();
form.reset();
alert(response.ok ? "提交成功!" : "发生错误!");
});
然后,只需在表单后面引入这个脚本即可:
<form action="/endpoint-one" method="POST">
<input type="text" name="firstName"/>
<input type="text" name="lastName"/>
<input type="submit" value="提交" />
</form>
<script src="form-submitter.js"></script>
<form action="/endpoint-two" method="POST">
<input type="email" name="emailAddress" />
<input type="submit" value="提交" />
</form>
<script src="form-submitter.js"></script>
总结
搞清楚这些“老旧但被忽视”的 Web 特性到底有多实用,这种感觉非常棒。document.currentScript 为我们提供了一种在脚本标签内部访问自身属性的原生方式,特别适合用于从服务端向客户端脚本传递配置,避免了全局变量的污染,实现了代码的优雅和解耦。虽然它在模块脚本中不可用,但在传统的脚本加载场景下,依然是一个强大且兼容性极佳的工具。
希望这个技巧能为你带来启发。如果你对这类原生 API 的巧妙用法感兴趣,欢迎到 云栈社区 与其他开发者一起交流探讨。
原文:https://macarthur.me/posts/current-script/