Pjax 的 2021 重构

2021年05月31日 阅读数:30
这篇文章主要向大家介绍Pjax 的 2021 重构,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

GitHub PaperStrike/Pjax ,重构自 MoOx/pjaxhtml

本文介绍 顺序执行脚本、停止 Promise、Babel Polyfills 三部分,阅读时长约 30 分钟。首发于 https://sliphua.work/pjax-in-2021/前端

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

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

但实际实现起来,Pjax 的核心就不止 History API 与 Ajax 二者了。除了展示内容,浏览器什么时候切换页面、如何切换页面,并不能彻底经过 pushState 模拟。github

顺序执行脚本

执行

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

  1. HTML 解析器解析生成该脚本元素。
  2. 由 JS 生成,被注入文档。
  3. 由 JS 生成且已注入文档,被插入子节点或新增 src 属性。

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

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

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

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 重构里就只写上一段这些不疼不痒的东西。但由于太懒,就酱吧。