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 重構裏就只寫上一段這些不疼不癢的東西。但由於太懶,就醬吧。

相關文章
相關標籤/搜索