我时常会在浏览器中发现一些本应多年前就了解的成熟 JavaScript API。比如 window.screen
属性 和 CSS.supports()
方法。令人欣慰的是,我发现并非只有我一个人对这些 API 一无所知。记得我曾发推提到 window.screen
,结果收到了大量反馈,原来很多人也不知道这个 API,这让我感觉没那么愚蠢了。
我认为人们对某个 API 的认知度,与其存在时间长短关系不大,更多取决于它是否适用于我们正在解决的问题。如果 window.screen
的使用场景不多,人们自然容易忘记它的存在。
但偶尔,我们会惊喜地发现这些冷门特性的用武之地。我认为 document.currentScript
就是这样一个特性,我打算好好品味它的妙用。
它能做什么?
查看 MDN 文档 就能明白:它返回当前正在执行代码的 <script>
元素的引用。
<script>
console.log("标签名:", document.currentScript.tagName);
console.log(
"是 script 元素吗?",
document.currentScript instanceof HTMLScriptElement
);
// 输出:
// 标签名: SCRIPT
// 是 script 元素吗? true
</script>
由于获取的是元素本身,你可以像操作其他 DOM 节点一样访问其属性:
<script data-external-key="123urmom" defer>
console.log("外部键:", document.currentScript.dataset.externalKey);
if (document.currentScript.defer) {
console.log("脚本已延迟执行!");
}
</script>
// 输出:
// 外部键: 123urmom
// 脚本已延迟执行!
非常直观。显然,浏览器兼容性完全不是问题,这个 API 已在所有主流浏览器中支持了十多年,这在互联网时代足以形成天然钻石了。
模块脚本中不可用
关于 document.currentScript
需要注意:它在模块中不可用。但奇怪的是,尝试访问它不会返回 undefined
,而是 null
:
<script type="module">
console.log(document.currentScript);
console.log(document.doesNotExist);
// 输出:
// null
// undefined
</script>
这是规范中的明确规定。文档创建时,currentScript
就被初始化为 null
:
currentScript
属性在获取时必须返回其最近设置的值。当Document
被创建时,currentScript
必须初始化为 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>
由于模块仍缺乏这类特性,未来如何解决这个问题值得关注。规范中的说明表明这仍在讨论中:
这个 API 在实现者和标准社区中已不再受欢迎,因为它全局暴露了
script
或 SVGscript
元素。因此,它在新环境中不可用,例如运行模块脚本或在影子树中运行脚本时。我们正在研究为这些场景创建新的解决方案来识别运行中的脚本,而不会使其全局可用:参见 issue #1013。
这个 issue 自 2016 年就存在,有大量贡献者参与讨论。在解决方案落地前,查询元素可能是最佳选择:
<script type="module" id="moduleScript">
const scriptTag = document.getElementById("moduleScript");
// 执行操作
</script>
实际需求:传递配置属性
我在 PicPerf 网站上使用了 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
那么简单。我需要独立于页面渲染的标记动态注入这些值。
问题是无法将表格的渲染与配置属性的访问和设置分开,这些值必须在元素初始化时就可用。
于是我想到了通过客户端脚本注入整个元素(包括属性值),在 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="STANY_TUNED"
client-reference-id="picperf">
</stripe-pricing-table>
`;
})
</script>
现在唯一缺少的是属性值本身。虽然可以将它们服务器渲染到 window
对象上,但我不喜欢这种污染全局作用域的做法。
坦白说
老实说,我本可以在 14 秒内解决这个问题。PicPerf.io 是用 Astro 构建的,它提供了 define:vars
指令,可以轻松地将服务器端变量提供给客户端脚本:
---
const truth = "Taxation is theft.";
---
<style define:vars={{ truth }}>
console.log(truth);
// 输出: Taxation is theft.
</style>
但这样快速解决问题既无趣,也写不出博客文章。更重要的是,define:vars
是一种高度专有的解决方案,而许多其他平台和内容管理系统(我曾使用过)也面临同样的挑战。
比想象更常见的挑战
内容管理系统的限制通常是设计使然。编辑器可能可以配置标记的某些部分,但很少能修改 <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} />
);
显然,我的解决方案方向已经很明确了。数据属性是从服务器向客户端传递特定值的整洁方式。在上面的 SPA 示例中,唯一挑剔的缺点是必须先查询元素才能访问其属性。
但由于我的场景使用的是 <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>
这种感觉很棒。没有使用任何专有魔法,没有将值塞入全局作用域,还能让我在 X 上炫耀"使用平台特性"。一举多得。
其他应用场景
探索这个特性时,我还想到了其他几个可能的用例,其中一个是在本文初稿发布后读者建议的。
安装指导
假设你维护一个必须异步加载的 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> 开始标签后立即加载。"
);
}
这样的错误信息非常明确:
总之,它提供了很好的引导性强化,是完善文档的有力补充。
行为局部性
这个用例是 Reddit 用户 ShotgunPayDay 提出的。行为局部性原则主张只需查看代码块本身就能理解其行为(Carson Gross 有篇相关文章)。任何具有"单文件组件"(SFC)的框架都可能想到这一点,所有内容都在一个地方。
对于 document.currentScript
,这意味着只需将元素彼此相邻放置,就能构建可移植的体验。例如,下面的脚本通过放置在表单后,就能异步提交任何表单。中心脚本知道要定位 <script>
之前的元素:
// 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: 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 特性的实用性,让人非常有成就感。这让我对早期和成长期 Web 的 API 设计者产生了敬意,特别是知道他们经常要面对现代程序员傲慢的抱怨。我渴望发现更多这样的特性,也许 AGI 已经存在于 HTML 规范中,只是我们还没发现而已。
原文链接:macarthur.me/posts/curre…
作者: Alex MacArthur