共计 7734 个字符,预计需要花费 20 分钟才能阅读完成。
GitHub PaperStrike/Pjax,重构自 MoOx/pjax。
本文介绍 程序执行脚本、停止 Promise、Babel Polyfills 三局部,浏览时长约 30 分钟。首发于 https://sliphua.work/pjax-in-2021/。
应用 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 生成;有两大阶段,筹备阶段和执行阶段。执行阶段只能由筹备阶段或解析器触发,筹备阶段会且只会在以下三种时刻触发。
- HTML 解析器解析生成该脚本元素。
- 由 JS 生成,被注入文档。
- 由 JS 生成且已注入文档,被插入子节点或新增
src
属性。
在应用 innerHTML
等 API 赋值时,外部会应用 HTML 解析器在一个禁用脚本的独立文档环境里解析该字符串,在这个独立文档环境里,脚本元素经验筹备阶段但不会被执行,字符串解析结束后,所生成节点被转移给被赋值元素。因为外部脚本元素并非由 JS 生成,转移到以后文档不会触发筹备阶段,更不会进一步执行。
因而在应用 innerHTML
、outerHTML
、或者 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 forscript
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:
If the
script
element is marked as having “already started”, then return. The script is not executed.… (check and determine the script’s type.)
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 属性时,该标记被移除。
进一步,在脚本筹备阶段分五类决定执行机会:
-
含有
defer
属性,不含async
属性,并且由 HTML 解析器载入的经典脚本元素;不含async
属性,并且由 HTML 解析器载入的模块脚本元素:增加进这样一个队列,HTML 解析器在解析完文档后,依序 在无其余脚本运行时 执行该队列中的脚本。
-
含有
src
属性,不含defer
也不含async
属性,并且由 HTML 解析载入的经典脚本元素:在 无其余脚本运行时 执行,执行实现前 暂停 该 HTML 解析器的解析。
-
含有
src
属性,不含defer
也不含async
属性,并且由 JS 生成的,没有 “non-blocking” 标记的经典脚本元素;含async
属性,并且没有 “non-blocking” 标记的模块脚本元素:增加进这样一个队列,该队列 依序 在无其余脚本运行时 执行。
-
含有
src
属性,上述情况之外的经典脚本元素;上述情况之外的模块脚本元素:在 无其余脚本运行时 执行。
-
不含
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
事件在其执行结束后触发,能够在前一个第三类脚本的该事件触发后再注入文档。
- … (execute)
- 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
设计停止逻辑,并至多可能:
- 由某个承受的参数通过
signal
成员传递一个 AbortSignal 实例。 - 应用名为
AbortError
的 DOMException 表白无关停止的谬误。 - 在传递的 signal 曾经停止时立刻抛出上述谬误。
- 侦听所传递 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'));
});
}),
]);
};
但以上函数只是符合规范,并不能间接达到停止该函数同时停止后续脚本执行的成果。这次要是由两个起因造成的:
- 目前,要中断一个函数的运行,只能通过在外部调用
return
或throw
来实现。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 polyfill 和 Babel polyfills 就一个 s 之遥,前者是已被弃用的旧时 Babel 官网基于 regenerator-runtime 和 core-js 保护的 polyfill,后者是仍在测试的当初 Babel 官网保护的 polyfill 抉择 – 策略 – 插件 – 集。
相较于保护本人的 polyfill,Babel 更专一于提供更为灵便的 polyfill 抉择策略。
以后,@babel/preset-env 反对指定指标浏览器,通过 useBuiltIns
提供 entry
和 usage
两种注入模式;@babel/plugin-transform-runtime 不净化全局作用域,复用辅助函数为库开发者减小 bundle 体积。然而,这两个组件并不能很好地配合应用,二者的 polyfill 注入模式只能任选其一。另外,它们只反对 core-js
,有很大的局限性。
Babel 社区在 历时一年的探讨 后,设计开发 Babel polyfills 作为这些问题的对立解决方案。它 同时:
- 反对指定指标浏览器;
- 反对不净化全局作用域;
- 反对配合 @babel/plugin-transform-runtime 复用辅助函数;
- 反对
core-js
和es-shims
,并反对、激励开发者写本人的 polyfill provider。
致力于对立 Babel 对 polyfill 的抉择策略。Babel polyfills 长处很多,应用是大势所趋。官网的应用文档 写得很清晰,有须要的同学能够点击链接查看。
Exclude
应用 Babel 很容易引入“不太须要的”polyfill,使得 Pjax 打包后的库大小剧增。
- 例如,应用 URL API 很容易引入
web.url
模块,在压缩后大小占 11 KB,比目前整个 Pjax 外围压缩后大小都大。它还牵涉到web.url-search-params
、es.array.iterator
和es.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
时会报错。
相似的问题,
- 呈现在数组的
reduce
办法上,并且没有在 caniuse 等数据库里失去体现:Chromium 80-83 该办法有时会给出谬误初值,core-js-compat 也因而将该办法的兼容 Chrome 要求进步到了 83+; - 呈现在 Promise Rejection Event 上,指标浏览器不反对该 Event(Firefox < 69)时会引入整个 Promise polyfill。
相似的问题其实有很多,只是目前 Pjax 重构遇到的根本只有这三个。在代码中加上相应的判断、排除极其状况,就能够齐全不应用这几个 polyfill,缩小 Pjax bundle 大小。在 Babel 配置文件的插件中设置 “exclude”:
["polyfill-corejs3", {
"method": "usage-pure",
"exclude": [
"web.url",
"es.array.reduce",
"es.promise"
]
}]
结语
重构的过程也是学习的过程。
Pjax 的重构还波及 History API 的包装,DOM Parser、Optional chaining (?.) 等其余新 API 的应用,Jest、Nock 单元测试工具的迁徙……
作者有过一种想法,本文三局部拆分成三篇文章会不会更好,Pjax 重构里就只写上一段这些不疼不痒的货色。但因为太懒,就酱吧。