Pjax 的 2021 重构

Banner

GitHub PaperStrike/Pjax ,重构自 MoOx/pjax

本文介绍 顺序执行脚本、中止 Promise、Babel Polyfills 三部分,阅读时长约 30 分钟。

使用 React、Vue 等现代框架进行前端开发用不到 Pjax,但在目前众多使用 Hexo、Hugo 等工具生成的静态博客里 Pjax 依然生龙活虎,能提供更为丝滑、流畅的用户体验。

Pjax 原称 pushState + Ajax,前者指的是使用浏览器的 History API 更新浏览记录,后者全称 Asynchronous JavaScript and XML,涵盖 一系列 用于在 JS 中发送 HTTP 请求的技术。MDN 文档另有一个 pure-Ajax 的概念,涉及的技术和目标与此几乎一致。通过 JS 动态获取、更新页面,提供平滑、快速的切换过程,是 Pjax 作者、网站开发者的初衷。

但实际实现起来,Pjax 的核心就不止 History API 与 Ajax 两者了。除了展现内容,浏览器何时切换页面、如何切换页面,并不能完全通过 pushState 模拟。

顺序执行脚本

执行

这令人蛋痛的一节要从 innerHTML 不会执行内部脚本开始。脚本元素有两种来源,HTML 解析器解析和 JS 生成;有两大阶段,准备阶段和执行阶段。执行阶段只能由准备阶段或解析器触发,准备阶段会且只会在以下三种时刻触发。

  1. HTML 解析器解析生成该脚本元素。

  2. 由 JS 生成,被注入文档。

  3. 由 JS 生成且已注入文档,被插入子节点或新增 src 属性。

在使用 innerHTML 等 API 赋值时,内部会使用 HTML 解析器在一个禁用脚本的独立文档环境里解析该字符串,在这个独立文档环境里,脚本元素经历准备阶段但不会被执行,字符串解析完毕后,所生成节点被转移给被赋值元素。由于内部脚本元素并非由 JS 生成,转移到当前文档不会触发准备阶段,更不会进一步执行。

因此在使用 innerHTMLouterHTML、或者 DOMParser + replaceWith 等方法更新页面局部后,需要特殊处理脚本元素,重新触发准备阶段。

容易想到,在 JS 中使用 cloneNode 等 API 复制替换触发,而这样又有一个坑。脚本准备阶段,确认各 HTML 属性合规后,该脚本会被标记 “already started”。准备阶段第一步即为在有该标记时退出,而复制的脚本元素会保留这个标记。

A script element has a flag indicating whether or not it has been “already started”. Initially, script elements must have this flag unset (script blocks, when created, are not “already started”). The cloning steps for script elements must set the “already started” flag on the copy if it is set on the element being cloned.

—— already started

To prepare a script, the user agent must act as follows:

  1. If the script element is marked as having “already started”, then return. The script is not executed.

    … (check and determine the script’s type.)

  2. Set the element’s “already started” flag.

—— prepare a script

因此,要插入执行脚本元素,只能使用当前文档的 createElement 这类方法构造全新脚本元素,逐属性复制。构建一个 evalScript 函数为例:

const evalScript = (oldScript) => {
  const newScript = document.createElement('script');

  // Clone attributes and inner text.
  oldScript.getAttributeNames().forEach((name) => {
    newScript.setAttribute(name, oldScript.getAttribute(name));
  });
  newScript.text = oldScript.text;

  oldScript.replaceWith(newScript);
};

顺序

局部更新脚本元素执行问题在早年的 Pjax 里已经解决,上文更多的是给这一节中的顺序问题引入基本概念。如何使得页面刷新部分新脚本的执行顺序符合页面初载的脚本执行顺序规范,才是讨论的重点。

JS 动态连续插入多个可执行 <script> 元素时,其执行顺序往往不会符合页面初载时的执行顺序。

document.body.innerHTML = `
  <script>console.log(1);</script>
  <script src="https://example/log/2"></script>
  <script>console.log(3);</script>
  <script src="https://example/log/4"></script>
  <script>console.log(5);</script>
`;

// Logs 1 3 5 2 4
// or 1 3 5 4 2
[...document.body.children].forEach(evalScript);

于是查阅脚本执行规范。依规范,将各属性取值合规的可执行 <script> 元素,根据 type 属性是否为 module 分为模块脚本元素和经典脚本元素两类。对于 JS 生成的脚本,存在一个 “non-blocking” 标记,当且仅当操作该脚本的 async IDL 属性时,该标记被移除。

进一步,在脚本准备阶段分五类决定执行时机:

  1. 含有 defer 属性,不含 async 属性,并且由 HTML 解析器载入的经典脚本元素;不含 async 属性,并且由 HTML 解析器载入的模块脚本元素:

    添加进这样一个队列,HTML 解析器在解析完文档后,依序无其他脚本运行时执行该队列中的脚本。

  2. 含有 src 属性,不含 defer 也不含 async 属性,并且由 HTML 解析载入的经典脚本元素:

    无其他脚本运行时执行,执行完成前暂停该 HTML 解析器的解析。

  3. 含有 src 属性,不含 defer 也不含 async 属性,并且由 JS 生成的,没有 “non-blocking” 标记的经典脚本元素;含 async 属性,并且没有 “non-blocking” 标记的模块脚本元素:

    添加进这样一个队列,该队列依序无其他脚本运行时执行。

  4. 含有 src 属性,上述情况之外的经典脚本元素;上述情况之外的模块脚本元素:

    无其他脚本运行时执行。

  5. 不含 src 属性的经典脚本元素:

    立即执行,期间暂停任何其他脚本的运行。

默认情况下,JS 动态生成、注入文档的脚本属于后两类情况,而与页面初载时有序执行的前三类情况大相径庭。

注意到可以操作 async IDL 属性移除 “non-blocking” 标记,使之转为第三类的有序情况。在 evalScript 中添加:

// Reset async of external scripts to force synchronous loading.
// Needed since it defaults to true on dynamically injected scripts.
if (!newScript.hasAttribute('async')) newScript.async = false;

由于内联脚本只可能属于第五种情况,一定会被立即执行,只能调整脚本准备阶段的触发时机。由于外联脚本的 onload 事件在其执行完毕后触发,可以在前一个第三类脚本的该事件触发后再注入文档。

  1. … (execute)

  2. If scriptElement is from an external file, then fire an event named load at scriptElement.

—— execute a script block

结合考虑错误处理,一个第三类脚本的 error 事件可能在前一个第三个脚本的 load 事件前,即执行前触发,因此第五类脚本需要保证在前面所有第三类脚本都执行结束后再注入。将 evalScript 改为 Promise 形式,脚本元素的注入顺序就可以方便地结合数组的 reduce 方法编写:

// Package to promise
const evalScript = (oldScript) => new Promise((resolve) => {
  const newScript = document.createElement('script');
  newScript.onerror = resolve;

  // ... Original

  if (newScript.hasAttribute('src')) {
    newScript.onload = resolve;
  } else {
    resolve();
  }
});
/**
 * Evaluate external scripts first
 * to help browsers fetch them in parallel.
 * Each inline script will be evaluated as soon as
 * all its previous scripts are executed.
 */
const executeScripts = (iterable) => (
    [...iterable].reduce((promise, script) => {
      if (script.hasAttribute('src')) {
        return Promise.all([promise, evalScript(script)]);
      }
      return promise.then(() => evalScript(script));
    }, Promise.resolve())
  );

executeScripts(document.body.children);

至此,动态插入的 JS 脚本元素执行顺序问题得到解决。

中止 Promise

发送 Pjax 请求时,使用 Fetch 替代 XMLHttpRequest 是大势所趋,也没有太多可写的内容。有意思的是用来中止 fetch 请求的 AbortController 以及 AbortSignal,没有以类似 XMLHttpRequest 的形式作为 fetch 实例的属性,而是单独列为了新的 API,增强了拓展性。其设计的用意,正是成为中止 Promise 对象的普遍接口。

例如在事件侦听器中,也可以使用 signal 参数在相应的 signal 中止时移除侦听器。

const controller = new AbortController();
const { signal } = controller;

document.body.addEventListener('click', () => {
  fetch('https://example', {
    signal,
  }).then(onSuccess);
}, { signal });

// Remove the listener, too.
controller.abort();

实现一个可中止的基于 Promise 的自定义 API,规范要求 开发者结合 AbortSignal 设计中止逻辑,并至少能够:

  1. 由某个接受的参数通过 signal 成员传递一个 AbortSignal 实例。
  2. 使用名为 AbortErrorDOMException 表达有关中止的错误。
  3. 在传递的 signal 已经中止时立即抛出上述错误。
  4. 侦听所传递 signal 的中止事件,在中止时立即抛出上述错误。

一个简单的符合规范要求的可中止函数:

const somethingAbortable = ({ signal }) => {
  if (signal.aborted) {
    // Don't throw directly. Keep it chainable.
    return Promise.reject(new DOMException('Aborted', 'AbortError'));
  }

  return new Promise((resolve, reject) => {
    signal.addEventListener('abort', () => {
      reject(new DOMException('Aborted', 'AbortError'));
    });
  });
}

因为返回值始终是一个 promise,也可以结合 async 函数 特性自动将 throw 转为所返回 Promise 的 reject 值,使用 Promise 的 race 静态方法在中止事件发生时立即 reject,包装上文的顺序执行函数:

const executeScripts = async (iterable, { signal }) => {
  if (signal.aborted) {
    // Async func treats throw as reject.
    throw new DOMException('Aborted', 'AbortError');
  }
  // Abort as soon as possible.
  return Promise.race([
    // promise generated by the original reduce.
    originalGeneratedPromise,
    new Promise((resolve, reject) => {
      signal.addEventListener('abort', () => {
        reject(new DOMException('Aborted', 'AbortError'));
      });
    }),
  ]);
};

但以上函数只是符合规范,并不能直接达到中止该函数同时中止后续脚本执行的效果。这主要是由两个原因造成的:

  • 目前,要中断一个函数的运行,只能通过在内部调用 returnthrow 来完成。Promise 也不例外,executor 中简单地 resolve 或 reject 不影响后续部分的运行。

  • 一个脚本元素的准备阶段不可中止,即使是一个外联脚本元素,触发其准备阶段后在其产生的 HTTP 请求完成之前将其移除,该 HTTP 请求也不会中断,浏览器仍会载入该文件尝试解析执行。

第二点属于这里脚本执行函数的特例。第一点保持 Promise 的灵活性,允许开发者自定义中止行为。不过这里我们不需要特别的中止行为,只需在 evalScript 里判断 signal 的中止状态再执行即可。

例如,把 evalScript 声明在 executeScripts 函数里,使其直接访问 signal:

const executeScripts = async (iterable, { signal }) => {
  // ... some other code.
  const evalScript = (script) => {
    if (signal.aborted) return;
    // Original steps to execute the script.
  }
  // ... some other code.
};

以此类推,将 Pjax 步骤均改为可中止形式。

Babel Polyfills

Babel polyfillBabel polyfills 就一个 s 之遥,前者是已被弃用的旧时 Babel 官方基于 regenerator-runtimecore-js 维护的 polyfill,后者是仍在测试的现在 Babel 官方维护的 polyfill 选择 - 策略 - 插件 - 集。

相较于维护自己的 polyfill,Babel 更专注于提供更为灵活的 polyfill 选择策略。

当前,@babel/preset-env 支持指定目标浏览器,通过 useBuiltIns 提供 entryusage 两种注入模式;@babel/plugin-transform-runtime 不污染全局作用域,复用辅助函数为库开发者减小 bundle 体积。 但是,这两个组件并不能很好地配合使用,二者的 polyfill 注入模式只能任选其一。另外,它们只支持 core-js,有很大的局限性。

Babel 社区在 历时一年的讨论 后,设计开发 Babel polyfills 作为这些问题的统一解决方案。它同时

  • 支持指定目标浏览器;
  • 支持不污染全局作用域;
  • 支持配合 @babel/plugin-transform-runtime 复用辅助函数;
  • 支持 core-jses-shims,并支持、鼓励开发者写自己的 polyfill provider。

致力于统一 Babel 对 polyfill 的选择策略。Babel polyfills 优点很多,使用是大势所趋。官方的使用文档 写得很清晰,有需要的同学可以点击链接查看。

Exclude

使用 Babel 很容易引入 “不太需要的” polyfill,使得 Pjax 打包后的库大小剧增。

  • 例如,使用 URL API 很容易引入 web.url 模块,在压缩后大小占 11 KB,比目前整个 Pjax 核心压缩后大小都大。它还牵涉到 web.url-search-paramses.array.iteratores.string.iterator 三个模块,压缩后四者总大小约 16 KB;考虑到其引入的 core-js 内部模块(引入任意 core-js polyfill 几乎都会引入的部分),总大小约 32 KB,使 Pjax 压缩后大小由 9 KB -> 41 KB。

这其实不算 Babel 的锅。core-js 提供的各 API 浏览器兼容性 core-js-compat 明确地写明 web.url 需要 Safari 14 ,因此在目标 Safari 版本小于 14 时就会引入 web.url polyfill。那为什么 core-js-compat 会这样要求?因为 Safari 的这些早期版本的 URL() constructor 存在这样一个 BUG ,在给定第二个参数且给定值为 undefined 时会报错。

类似的问题,

类似的问题其实有很多,只是目前 Pjax 重构遇到的基本只有这三个。在代码中加上相应的判断、排除极端情况,就可以完全不使用这几个 polyfill,减少 Pjax bundle 大小。在 Babel 配置文件的插件中设置 “exclude” :

["polyfill-corejs3", {
  "method": "usage-pure",
  "exclude": [
    "web.url",
    "es.array.reduce",
    "es.promise"
  ]
}]

结语

重构的过程也是学习的过程。

Pjax 的重构还涉及 History API 的包装,DOM ParserOptional chaining (?.) 等其他新 API 的使用,JestNock 单元测试工具的迁移……

作者有过一种想法,本文三部分拆分成三篇文章会不会更好,Pjax 重构里就只写上一段这些不疼不痒的东西。但因为太懒,就酱吧。