優(yōu)化單頁(yè)應(yīng)用 (SPA) 加載時(shí)間:異步代碼塊預(yù)加載

在本文中略吨,將解釋如何通過(guò)避免基于路由的懶加載引發(fā)的瀑布效應(yīng),提升客戶端渲染應(yīng)用的性能考阱。我們會(huì)通過(guò)注入一個(gè)自定義腳本來(lái)預(yù)加載當(dāng)前路由的代碼塊翠忠,確保這些代碼塊能與入口代碼塊并行下載。我將使用 Rsbuild 來(lái)實(shí)現(xiàn)腳本注入乞榨,但代碼可以很容易地適配到 Webpack 和其他打包工具秽之。

代碼示例基于一個(gè)只有兩個(gè)頁(yè)面的小型應(yīng)用:一個(gè)首頁(yè)(路徑為 //home)和一個(gè)設(shè)置頁(yè)面(路徑為 /settings)。

基于路由的代碼拆分

在客戶端渲染的應(yīng)用中姜凄,代碼拆分是提升整體性能的主要策略之一政溃。通過(guò)代碼拆分,可以只加載必要的代碼塊态秧,而不是一次性加載全部代碼。

最常見(jiàn)的實(shí)現(xiàn)代碼拆分的方法是通過(guò)懶加載路由(或頁(yè)面)對(duì)應(yīng)的代碼塊扼鞋。這意味著只有當(dāng)用戶訪問(wèn)相應(yīng)頁(yè)面時(shí)申鱼,這些代碼塊才會(huì)加載,而不會(huì)提前加載云头。這不僅減少了加載應(yīng)用所需的包大小捐友,還優(yōu)化了緩存:應(yīng)用包拆分得越細(xì),緩存失效的概率就越欣;薄(前提是靜態(tài)文件正確地使用了哈希處理)匣砖。

像 Next.js 和 Remix 這樣的服務(wù)端渲染框架通常會(huì)自動(dòng)處理代碼拆分和懶加載。而對(duì)于客戶端渲染的單頁(yè)應(yīng)用昏滴,你可以通過(guò)在路由中懶加載需要的組件來(lái)實(shí)現(xiàn)代碼拆分:

const Home = lazy(() => import("./pages/home-page"));
const Settings = lazy(() => import("./pages/settings-page"));

在這種設(shè)置下猴鲫,當(dāng)用戶訪問(wèn)應(yīng)用的 / 路由時(shí),只有首頁(yè)對(duì)應(yīng)的代碼塊(例如 home.[hash].js)會(huì)被下載谣殊。設(shè)置頁(yè)面的代碼塊(例如 settings.[hash].js)只有在用戶導(dǎo)航到設(shè)置頁(yè)面時(shí)才會(huì)下載拂共。

懶加載的缺點(diǎn)

盡管代碼拆分有很多好處,但也存在一些缺點(diǎn)姻几。默認(rèn)情況下宜狐,代碼塊只有在需要時(shí)才會(huì)下載,這可能導(dǎo)致以下兩種明顯的延遲:

  1. 初始加載延遲:在應(yīng)用首次加載時(shí)蛇捌,會(huì)存在從加載入口代碼塊(如頂層應(yīng)用及客戶端路由器)到加載初始頁(yè)面代碼塊(如首頁(yè))的延遲抚恒。這是因?yàn)闉g覽器需要先下載、解析并執(zhí)行入口代碼塊络拌,接著應(yīng)用路由器決定當(dāng)前是首頁(yè)路由俭驮,然后再提示瀏覽器下載、解析并執(zhí)行首頁(yè)代碼盒音。

  2. 導(dǎo)航延遲:同樣地表鳍,每次在頁(yè)面之間導(dǎo)航時(shí)也會(huì)有延遲馅而。這是因?yàn)闉g覽器只會(huì)在導(dǎo)航開(kāi)始時(shí)下載、解析并執(zhí)行新的代碼塊(例如譬圣,只有點(diǎn)擊“設(shè)置”鏈接時(shí)才會(huì)加載設(shè)置頁(yè)面的代碼塊)瓮恭。

一個(gè)穩(wěn)健的緩存策略(例如,將這些代碼塊標(biāo)記為不可變并預(yù)緩存它們)和使用支持預(yù)加載功能的路由器可以緩解第二點(diǎn)厘熟。我可能會(huì)在后續(xù)文章中更深入地探討這些話題屯蹦。而現(xiàn)在,我們將重點(diǎn)解決第一個(gè)問(wèn)題绳姨。

預(yù)加載異步頁(yè)面

我們的目標(biāo)是解決瀑布問(wèn)題登澜,即在頁(yè)面可以下載之前,必須等待入口代碼塊完成請(qǐng)求的情況:

我們已經(jīng)知道飘庄,當(dāng)用戶導(dǎo)航到 / 路徑時(shí)脑蠕,應(yīng)該下載首頁(yè)代碼塊。沒(méi)有必要等到應(yīng)用完全加載后再開(kāi)始下載首頁(yè)代碼塊跪削,對(duì)吧谴仙?因此,我們可以讓它與入口代碼塊并行下載碾盐。

根據(jù)我的經(jīng)驗(yàn)晃跺,最好的方法是通過(guò)在 HTML 的 <head> 中注入一個(gè)小型腳本,預(yù)加載當(dāng)前訪問(wèn) URL 的異步代碼塊毫玖。

從高層次來(lái)看掀虎,這個(gè)方法是使用構(gòu)建工具(本文中是 Rsbuild)將一個(gè)小型腳本注入到 HTML 文檔的 <head> 中。這個(gè)腳本包含每個(gè)路由與其需要預(yù)加載文件的映射關(guān)系付枫。在執(zhí)行時(shí)烹玉,它通過(guò)手動(dòng)將這些文件添加為 <link rel="preload"> 的形式來(lái)預(yù)加載當(dāng)前路徑所需的文件。

讓我們深入了解具體實(shí)現(xiàn)示例励背。

為異步導(dǎo)入添加 webpackChunkName 注釋

由于在構(gòu)建完成之前春霍,我們無(wú)法知道代碼塊文件的具體名稱(chēng),因此腳本生成和注入邏輯必須在打包工具層面實(shí)現(xiàn)叶眉。例如址儒,根據(jù)良好的緩存實(shí)踐,首頁(yè)代碼塊的文件名可能包含哈希值(如 page.12ab33.js)衅疙,這個(gè)名稱(chēng)由打包工具分配莲趣。

為了確定是否應(yīng)該預(yù)加載某個(gè)代碼塊,建議維護(hù)頁(yè)面路徑與其 webpackChunkName 的映射關(guān)系饱溢。webpackChunkName 是一個(gè)支持多個(gè)打包工具的注釋?zhuān)梢杂脕?lái)為 JavaScript 代碼塊分配一個(gè)可讀名稱(chēng)喧伞,供打包工具訪問(wèn):

const Home = lazy(() => import(/* webpackChunkName: "home" */ "./pages/home-page"));
const Settings = lazy(() => import(/* webpackChunkName: "settings" */ "./pages/settings-page"));

route-chunk-mapping.ts

// 路徑與其 webpackChunkName 的映射關(guān)系
export const routeChunkMapping = {
  "/": "home",
  "/home": "home",
  "/settings": "settings",
};

為每個(gè)路由構(gòu)建需要加載的文件列表

在構(gòu)建了每個(gè)路由與其需要預(yù)加載頁(yè)面的映射后,下一步是確定組成該頁(yè)面代碼塊的文件列表。我建議創(chuàng)建一個(gè)插件(以 Rsbuild 為例潘鲫,但代碼也可以輕松適配 Webpack)翁逞,用于檢查編譯輸出并確定每個(gè)代碼塊所依賴(lài)文件的名稱(chēng)。

需要注意溉仑,這里涉及多個(gè)文件挖函,因?yàn)閱蝹€(gè)代碼塊可能依賴(lài)其他代碼塊。例如浊竟,假設(shè)我們有兩個(gè)代碼塊怨喘,一個(gè)用于首頁(yè),一個(gè)用于設(shè)置頁(yè)面振定。如果它們都導(dǎo)入了一個(gè)不屬于入口代碼塊的模塊(如 lodash)必怜,那么加載這些頁(yè)面時(shí)需要同時(shí)加載 lodash.[hash].jshome.[hash].js/settings.[hash].js。此外后频,文件的加載順序也很重要梳庆。

幸運(yùn)的是,打包工具通過(guò)其 API 暴露了這些依賴(lài)關(guān)系徘郭,稱(chēng)為 "chunk groups"靠益。

示例配置:

import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { chunksPreloadPlugin } from "./rsbuild-chunks-preload-plugin";
import { routeChunkMapping } from "./src/router-chunk-mapping.ts";
 
export default defineConfig({
  plugins: [pluginReact(), chunksPreloadPlugin({ routeChunkMapping })],
});

插件實(shí)現(xiàn):

import type { RsbuildPlugin } from "@rsbuild/core";
 
type RouteChunkMapping = { [path: string]: string };
 
type PluginParams = {
  routeChunkMapping: RouteChunkMapping;
};
 
export const chunksPreloadPlugin = (params: PluginParams): RsbuildPlugin => ({
  name: "chunks-preload-plugin",
  setup: (api) => {
    api.processAssets(
      { stage: "report" },
      ({ assets, sources, compilation }) => {
        const { routeChunkMapping } = params;
        // 生成異步代碼塊名稱(chēng)與其加載所需文件的映射關(guān)系
        const chunkFilesMapping = {};
        for (const chunkGroup of compilation.chunkGroups) {
          chunkFilesMapping[chunkGroup.name || "undefined"] =
            chunkGroup.getFiles();
        }
        // 構(gòu)建 URL 路徑與需要預(yù)加載文件的映射關(guān)系
        const pathToFilesToPreloadMapping = {};
        for (const [path, chunkName] of Object.entries(routeChunkMapping)) {
          const chunkFiles = chunkFilesMapping[chunkName].filter((file) =>
            file.endsWith(".js"),
          );
          pathToFilesToPreloadMapping[path] = chunkFiles;
        }
        // 后續(xù)操作待補(bǔ)充
      },
    );
  },
});

需要注意的是,api.processAssets 也是 Webpack 中的同名 API残揉。將這個(gè)插件移植到 Webpack 基本只需要將 api.processAssets 的實(shí)現(xiàn)復(fù)制粘貼到一個(gè) Webpack 插件中即可 ??。

生成預(yù)加載腳本

最后芋浮,我們通過(guò)讓插件將自定義腳本注入 HTML 文件來(lái)完成實(shí)現(xiàn)抱环。該腳本會(huì)在頁(yè)面加載時(shí)(入口代碼塊之前)執(zhí)行,并為當(dāng)前路徑(window.location.pathname)需要預(yù)加載的每個(gè)文件添加 <link rel="preload">纸巷。


插件實(shí)現(xiàn)代碼

import type { RsbuildPlugin } from "@rsbuild/core";

type RouteChunkMapping = { [path: string]: string };

type PluginParams = {
  routeChunkMapping: RouteChunkMapping;
};

export const chunksPreloadPlugin = (params: PluginParams): RsbuildPlugin => ({
  name: "chunks-preload-plugin",
  setup: (api) => {
    api.processAssets(
      { stage: "report" },
      ({ assets, sources, compilation }) => {
        const { routeChunkMapping } = params;

        // 生成異步代碼塊名稱(chēng)與其加載所需文件的映射關(guān)系
        const chunkFilesMapping = {};
        for (const chunkGroup of compilation.chunkGroups) {
          chunkFilesMapping[chunkGroup.name || "undefined"] = chunkGroup.getFiles();
        }

        // 構(gòu)建 URL 路徑與需要預(yù)加載文件的映射關(guān)系
        const pathToFilesToPreloadMapping = {};
        for (const [path, chunkName] of Object.entries(routeChunkMapping)) {
          const chunkFiles = chunkFilesMapping[chunkName].filter((file) =>
            file.endsWith(".js"),
          );
          pathToFilesToPreloadMapping[path] = chunkFiles;
        }

        // 生成用于預(yù)加載異步代碼塊的腳本
        const scriptToInject = generatePreloadScriptToInject(pathToFilesToPreloadMapping);

        // 將生成的腳本注入到 index.html 的 <head> 中镇草,在其他腳本之前
        const indexHTML = assets["index.html"];
        if (!indexHTML) {
          return;
        }
        const oldIndexHTMLContent = indexHTML.source();
        const firstScriptInIndexHTMLIndex = oldIndexHTMLContent.indexOf("<script");
        const newIndexHTMLContent = `${oldIndexHTMLContent.slice(
          0,
          firstScriptInIndexHTMLIndex,
        )}${scriptToInject}${oldIndexHTMLContent.slice(
          firstScriptInIndexHTMLIndex,
        )}`;
        const source = new sources.RawSource(newIndexHTMLContent);
        compilation.updateAsset("index.html", source);
      },
    );
  },
});

// 生成注入到 HTML 的預(yù)加載腳本
const generatePreloadScriptToInject = (pathToFilesToPreloadMapping: {
  [path: string]: Array<string>;
}): string => {
  const scriptContent = `
    try {
      (function () {
        const pathToFilesToPreloadMapping = ${JSON.stringify(pathToFilesToPreloadMapping)};
        const filesToPreload = pathToFilesToPreloadMapping[window.location.pathname];
        if (!filesToPreload) return;
        for (const fileToPreload of filesToPreload) {
          const preloadLinkEl = document.createElement("link");
          preloadLinkEl.setAttribute("href", fileToPreload);
          preloadLinkEl.setAttribute("rel", "preload");
          preloadLinkEl.setAttribute("as", "script");
          document.head.appendChild(preloadLinkEl);
        }
      })();
    } catch (err) {
      console.warn("Unable to run the scripts preloading.");
    }
  `;
  const script = `<script>${scriptContent}</script>`;
  return script;
};

現(xiàn)在,當(dāng)前頁(yè)面的所有異步代碼塊會(huì)與入口代碼塊并行加載瘤旨,提升加載性能梯啤。

進(jìn)一步優(yōu)化建議

增強(qiáng)路由邏輯

上例中預(yù)加載腳本的路徑識(shí)別邏輯較為簡(jiǎn)單〈嬲埽可以?xún)?yōu)化插件的 API因宇,使其支持與 React Router(或其他路由庫(kù))一致的配置。實(shí)際場(chǎng)景可能涉及更復(fù)雜的路徑祟偷,例如 /user/:user-id察滑,這需要實(shí)現(xiàn)動(dòng)態(tài)路徑識(shí)別和模式匹配來(lái)支持更強(qiáng)大的路由方案。

壓縮注入腳本

對(duì)于擁有數(shù)百個(gè)代碼塊的大型 SPA修肠,硬編碼到預(yù)加載腳本中的代碼塊可能會(huì)導(dǎo)致腳本過(guò)大贺辰。你可以通過(guò)以下方式優(yōu)化腳本大小:

  • 對(duì)腳本代碼進(jìn)行 壓縮(minify)。
  • 避免重復(fù)的代碼塊 URL(或其子路徑)饲化。
將預(yù)加載 API 暴露出來(lái)

可以通過(guò)在全局對(duì)象(如 window)上暴露預(yù)加載函數(shù)莽鸭,使預(yù)加載執(zhí)行變得可編程。例如:

// 在預(yù)加載腳本中
window.__preloadPathChunks = function (path = window.location.pathname) {
  // 腳本邏輯...
};

這樣可以在需要時(shí)手動(dòng)調(diào)用吃靠,比如當(dāng)用戶懸停在某些鏈接上時(shí)預(yù)加載頁(yè)面代碼塊硫眨。

使用 Service Worker 預(yù)緩存代碼塊

另一種優(yōu)化是使用 Service Worker 將 SPA 的所有代碼塊預(yù)緩存。Google 的 Workbox 是一個(gè)常用工具撩笆,可幫助實(shí)現(xiàn)此目的捺球。

探索其他優(yōu)化

還可以考慮其他性能優(yōu)化,例如:

  • 確保入口代碼塊加載優(yōu)先級(jí)仍高于預(yù)加載的代碼塊夕冲。
  • 在非路由組件級(jí)別進(jìn)行更細(xì)粒度的預(yù)加載整合氮兵。

通過(guò)這些改進(jìn),可以進(jìn)一步優(yōu)化 SPA 的加載性能和用戶體驗(yàn)歹鱼。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末泣栈,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子弥姻,更是在濱河造成了極大的恐慌南片,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,013評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件庭敦,死亡現(xiàn)場(chǎng)離奇詭異疼进,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)秧廉,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,205評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門(mén)伞广,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人疼电,你說(shuō)我怎么就攤上這事嚼锄。” “怎么了蔽豺?”我有些...
    開(kāi)封第一講書(shū)人閱讀 152,370評(píng)論 0 342
  • 文/不壞的土叔 我叫張陵区丑,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我修陡,道長(zhǎng)沧侥,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,168評(píng)論 1 278
  • 正文 為了忘掉前任濒析,我火速辦了婚禮正什,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘号杏。我一直安慰自己婴氮,他們只是感情好斯棒,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,153評(píng)論 5 371
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著主经,像睡著了一般荣暮。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上罩驻,一...
    開(kāi)封第一講書(shū)人閱讀 48,954評(píng)論 1 283
  • 那天穗酥,我揣著相機(jī)與錄音,去河邊找鬼惠遏。 笑死砾跃,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的节吮。 我是一名探鬼主播抽高,決...
    沈念sama閱讀 38,271評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼透绩!你這毒婦竟也來(lái)了翘骂?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 36,916評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤帚豪,失蹤者是張志新(化名)和其女友劉穎碳竟,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體狸臣,經(jīng)...
    沈念sama閱讀 43,382評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡莹桅,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,877評(píng)論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了烛亦。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片统翩。...
    茶點(diǎn)故事閱讀 37,989評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖此洲,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情委粉,我是刑警寧澤呜师,帶...
    沈念sama閱讀 33,624評(píng)論 4 322
  • 正文 年R本政府宣布,位于F島的核電站贾节,受9級(jí)特大地震影響汁汗,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜栗涂,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,209評(píng)論 3 307
  • 文/蒙蒙 一知牌、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧斤程,春花似錦角寸、人聲如沸菩混。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,199評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)沮峡。三九已至,卻和暖如春亿柑,著一層夾襖步出監(jiān)牢的瞬間邢疙,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,418評(píng)論 1 260
  • 我被黑心中介騙來(lái)泰國(guó)打工望薄, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留疟游,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,401評(píng)論 2 352
  • 正文 我出身青樓痕支,卻偏偏與公主長(zhǎng)得像颁虐,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子采转,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,700評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容