GitHub PaperStrike/Pjax ,重構自 MoOx/pjax 。html
本文介紹 順序執行腳本、停止 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
src
屬性。在使用 innerHTML
等 API 賦值時,內部會使用 HTML 解析器在一個禁用腳本的獨立文檔環境裏解析該字符串,在這個獨立文檔環境裏,腳本元素經歷準備階段但不會被執行,字符串解析完畢後,所生成節點被轉移給被賦值元素。因爲內部腳本元素並不是由 JS 生成,轉移到當前文檔不會觸發準備階段,更不會進一步執行。數據庫
所以在使用 innerHTML
、outerHTML
、或者 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 forscript
elements must set the "already started" flag on the copy if it is set on the element being cloned.數組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.
...
所以,要插入執行腳本元素,只能使用當前文檔的 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.
結合考慮錯誤處理,一個第三類腳本的 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 腳本元素執行順序問題獲得解決。
發送 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 表達有關停止的錯誤。一個簡單的符合規範要求的可停止函數:
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 不影響後續部分的運行。第二點屬於這裏腳本執行函數的特例。第一點保持 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 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 做爲這些問題的統一解決方案。它同時:
core-js
和 es-shims
,並支持、鼓勵開發者寫本身的 polyfill provider。致力於統一 Babel 對 polyfill 的選擇策略。Babel polyfills 優勢不少,使用是大勢所趨。官方的使用文檔 寫得很清晰,有須要的同窗能夠點擊連接查看。
使用 Babel 很容易引入 「不太須要的」 polyfill,使得 Pjax 打包後的庫大小劇增。
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+;相似的問題其實有不少,只是目前 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 重構裏就只寫上一段這些不疼不癢的東西。但由於太懶,就醬吧。