探索鲜为人知的浏览器API:document.currentScript的实用案例

747 阅读8分钟

我时常会在浏览器中发现一些本应多年前就了解的成熟 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 或 SVG script 元素。因此,它在新环境中不可用,例如运行模块脚本或在影子树中运行脚本时。我们正在研究为这些场景创建新的解决方案来识别运行中的脚本,而不会使其全局可用:参见 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.envprocess.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