•
阅读时长 2 分钟

CDN 脚本加载失败,换地址这件事也要有限度

目录

页面依赖 CDN 上的一段脚本,地址加载失败时,可以尝试备用地址。但“失败就换一个”还不够,至少要明确失败的类型、重试次数,以及是否可能重复执行。

下面只处理经典 script 的资源加载错误:候选地址按顺序尝试,成功就停止,全部失败就提示。

两个地址,一个故意失败

创建 index.html:

<!doctype html>
<html lang="zh-CN">
  <meta charset="utf-8" /><meta name="viewport" content="width=device-width" />
  <title>脚本备用地址</title
  ><button id="load">加载</button>
  <pre id="result"></pre>
  <script type="module">
    async function loadScript(urls) {
      for (const url of urls) {
        const loaded = await new Promise((resolve) => {
          const script = document.createElement("script");
          script.src = url;
          script.onload = () => resolve(true);
          script.onerror = () => {
            script.remove();
            resolve(false);
          };
          document.head.append(script);
        });
        if (loaded) return;
      }
      throw new Error("所有脚本地址都加载失败");
    }

    window.loadScript = loadScript;
    document.querySelector("#load").addEventListener("click", async (event) => {
      event.currentTarget.disabled = true;
      try {
        await loadScript(["/missing.js", "/working.js"]);
        document.querySelector("#result").textContent =
          "执行次数 " + window.fallbackExecuted;
      } catch (error) {
        document.querySelector("#result").textContent = error.message;
      }
    });
  </script>
</html>

创建 server.mjs:

import http from "node:http";
import { readFile } from "node:fs/promises";
http
  .createServer(async (req, res) => {
    const url = new URL(req.url, "http://localhost");
    if (url.pathname === "/working.js") {
      res.setHeader("Content-Type", "text/javascript");
      return res.end("window.fallbackExecuted=(window.fallbackExecuted||0)+1;");
    }
    if (url.pathname !== "/") {
      res.writeHead(404);
      return res.end();
    }
    res.setHeader("Content-Type", "text/html; charset=utf-8");
    try {
      res.end(await readFile(new URL("index.html", import.meta.url)));
    } catch {
      res.writeHead(500);
      res.end("缺少 index.html");
    }
  })
  .listen(3000, "127.0.0.1");

node server.mjs 运行后访问 http://127.0.0.1:3000,点击加载。missing.js 返回 404,working.js 成功,页面显示执行次数 1。按钮禁用,避免这个演示重复加载成功脚本。

在控制台执行 await loadScript([‘/missing-a.js’,‘/missing-b.js’]),两个地址都失败,应得到明确的错误。之前成功脚本的计数不会因为这次失败增加。

函数的候选列表有限,但如果请求一直挂住,仍可能一直等待。它没有实现超时切换,不能称为完整的 CDN 容灾方案。

资源错误和业务错误分开判断

script 的 onload 不保证业务初始化成功。脚本抛运行时错误,也不能直接认定下载失败再执行一个副本:第一份代码可能已经注册事件或修改了全局状态。

同样,定时器到期就切换地址,旧请求后续可能仍会执行。需要超时方案时,还得设计执行唯一性,而不是只增加一个 Promise.race。

这段函数适合独立经典脚本。模块脚本、带依赖顺序的资源和样式要按各自的加载契约处理。事件说明参考 MDN script 文档。

备用资源也要进入发布流程

两个 CDN 上的同名文件可能不是同一版本。可以使用内容哈希命名、同步发布并检查备用资源实际存在。CSP 要允许候选来源,使用 SRI 时内容也要一致,跨源配置要匹配。

这个本地例子确认了 404 后尝试下一项,以及全部失败时结束,没有模拟真实区域故障。接入构建之前,先明确运行时契约,再考虑从构建产物生成备用地址列表。