Web 性能優化: 使用 Webpack 分離數據的正確方法


摘要: Webpack騷操作。

Fundebug經授權轉載,版權歸原作者所有。

制定向用戶提供文件的最佳方式可能是一項棘手的工作。 有很多不同的場景,不同的技術,不同的術語。

在這篇文章中,我希望給你所有你需要的東西,這樣你就可以:

  1. 了解哪種文件分割策略最適合你的網站和用戶
  2. 知道怎么做

根據 Webpack glossary,有兩種不同類型的文件分割。 這些術語聽起來可以互換,但顯然不是。

Webpack 文件分離包括兩個部分,一個是 Bundle splitting,一個是 Code splitting:

  • Bundle splitting: 創建更多更小的文件,並行加載,以獲得更好的緩存效果,主要作用就是使瀏覽器並行下載,提高下載速度。並且運用瀏覽器緩存,只有代碼被修改,文件名中的哈希值改變了才會去再次加載。
  • Code splitting:只加載用戶最需要的部分,其余的代碼都遵從懶加載的策略,主要的作用就是加快頁面的加載速度,不加載不必要的代碼。

第二個聽起來更吸引人,不是嗎?事實上,關於這個問題的許多文章似乎都假設這是制作更小的JavaScript 文件的惟一值得的情況。

但我在這里要告訴你的是,第一個在很多網站上都更有價值,應該是你為所有網站做的第一件事。

就讓我們一探究竟吧。

Bundle splitting

bundle splitting 背后的思想非常簡單,如果你有一個巨大的文件,並且更改了一行代碼,那么用戶必須再次下載整個文件。但是如果將其分成兩個文件,那么用戶只需要下載更改的文件,瀏覽器將從緩存中提供另一個文件。

值得注意的是,由於 bundle splitting 都是關於緩存的,所以對於第一次訪問來說沒有什么區別。

(我認為太多關於性能的討論都是關於第一次訪問一個站點,或許部分原因是“第一印象很重要”,部分原因是它很好、很容易衡量。

對於經常訪問的用戶來說,量化性能增強所帶來的影響可能比較棘手,但是我們必須進行量化!

這將需要一個電子表格,因此我們需要鎖定一組非常特定的環境,我們可以針對這些環境測試每個緩存策略。

這是我在前一段中提到的情況:

  • Alice 每周訪問我們的網站一次,持續 10 周
  • 我們每周更新一次網站
  • 我們每周都會更新我們的“產品列表”頁面
  • 我們也有一個“產品詳細信息”頁面,但我們目前還沒有開發
  • 在第 5 周,我們向站點添加了一個新的 npm 包
  • 在第 8 周,我們更新了一個現有的 npm 包

某些類型的人(比如我)會嘗試讓這個場景盡可能的真實。不要這樣做。實際情況並不重要,稍后我們將找出原因。

基線

假設我們的 JavaScript 包的總容量是400 KB,目前我們將它作為一個名為 main.js 的文件加載。

我們有一個 Webpack 配置如下(我省略了一些無關的配置):

// webpack.config.js 

const path = require('path')

module.exports = {
  entry: path.resolve(__dirame, 'src/index.js')
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js'
  }
}

對於那些新的緩存破壞:任何時候我說 main.js,我實際上是指 main.xMePWxHo.js,其中里面的字符串是文件內容的散列。這意味着不同的文件名 當應用程序中的代碼發生更改時,從而強制瀏覽器下載新文件。

每周當我們對站點進行一些新的更改時,這個包的 contenthash 都會發生變化。因此,Alice 每周都要訪問我們的站點並下載一個新的 400kb 文件。

如果我們把這些事件做成一張表格,它會是這樣的。

也就是10周內, 4.12 MB, 我們可以做得更好。

分解 vendor 包

讓我們將包分成 main.jsvendor.js 文件。

 // webpack.config.js 

const path = require('path')

module.exports = {
  entry: path.resolve(__dirname, 'src/index.js'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
  },
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
}

Webpack4 為你做最好的事情,而沒有告訴你想要如何拆分包。這導致我們對 webpack 是如何分包的知之甚少,結果有人會問 “你到底在對我的包裹做什么?”

添加 optimization.splitChunks.chunks ='all'的一種說法是 “將 node_modules 中的所有內容放入名為 vendors~main.js 的文件中”。

有了這個基本的 bundle splitting,Alice 每次訪問時仍然下載一個新的 200kb 的 main.js,但是在第一周、第8周和第5周只下載 200kb 的 vendor.js (不是按此順序)。

總共:2.64 MB

減少36%。 在我們的配置中添加五行代碼並不錯。 在進一步閱讀之前,先去做。 如果你需要從 Webpack 3 升級到 4,請不要擔心,它非常簡單。

我認為這種性能改進似乎更抽象,因為它是在10周內進行的,但是它確實為忠實用戶減少了36%的字節,我們應該為自己感到自豪。

但我們可以做得更好。

分離每個 npm 包

我們的 vendor.js 遇到了與我們的 main.js 文件相同的問題——對其中一部分的更改意味着重新下載它的所有部分。

那么為什么不為每 個npm 包創建一個單獨的文件呢?這很容易做到。

所以把 reactlodashreduxmoment 等拆分成不同的文件:

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: path.resolve(__dirname, 'src/index.js'),
  plugins: [
    new webpack.HashedModuleIdsPlugin(), // so that file hashes don't change unexpectedly
  ],
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
  },
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: Infinity,
      minSize: 0,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name(module) {
            // get the name. E.g. node_modules/packageName/not/this/part.js
            // or node_modules/packageName
            const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];

            // npm package names are URL-safe, but some servers don't like @ symbols
            return `npm.${packageName.replace('@', '')}`;
          },
        },
      },
    },
  },
};

文檔將很好地解釋這里的大部分內容,但是我將稍微解釋一下需要注意的部分,因為它們花了我太多的時間。

  • Webpack 有一些不太聰明的默認設置,比如分割輸出文件時最多3個文件,最小文件大小為30 KB(所有較小的文件將連接在一起),所以我重寫了這些。
  • cacheGroups 是我們定義 Webpack 應該如何將數據塊分組到輸出文件中的規則的地方。這里有一個名為 “vendor” 的模塊,它將用於從 node_modules 加載的任何模塊。通常,你只需將輸出文件的名稱定義為字符串。但是我將 name定義為一個函數(將為每個解析的文件調用這個函數)。然后從模塊的路徑返回包的名稱。因此,我們將為每個包獲得一個文件,例如 npm.react-dom.899sadfhj4.js
  • NPM 包名稱必須是 URL 安全的才能發布,因此我們不需要 encodeURIpackageName。 但是,我遇到一個.NET服務器不能提供名稱中帶有 @(來自一個限定范圍的包)的文件,所以我在這個代碼片段中替換了 @
  • 整個設置很棒,因為它是一成不變的。 無需維護 - 不需要按名稱引用任何包。

Alice 仍然會每周重新下載 200 KB 的 main.js 文件,並且在第一次訪問時仍會下載 200 KB 的npm包,但她絕不會兩次下載相同的包。

總共: 2.24 MB.

與基線相比減少了44%,這對於一些可以從博客文章中復制/粘貼的代碼來說非常酷。

我想知道是否有可能超過 50% ? 這完全沒有問題。

代碼部署后可能存在的BUG沒法實時知道,事后為了解決這些BUG,花了大量的時間進行log 調試,這邊順便給大家推薦一個好用的BUG監控工具Fundebug

分離應用程序代碼的區域

讓我們轉到 main.js 文件,可憐的 Alice 一次又一次地下載這個文件。

我之前提到過,我們在此站點上有兩個不同的部分:產品列表和產品詳細信息頁面。 每個區域中的唯一代碼為25 KB(共享代碼為150 KB)。

我們的產品詳情頁面現在變化不大,因為我們做得太完美了。 因此,如果我們將其做為單獨的文件,則可以在大多數時間從緩存中獲取到它。

另外,我們網站有一個較大的內聯SVG文件用於渲染圖標,重量只有25 KB,而這個也是很少變化的, 我們也需要優化它。

我們只需手動添加一些入口點,告訴 Webpack 為每個項創建一個文件。

module.exports = {
  entry: {
    main: path.resolve(__dirname, 'src/index.js'),
    ProductList: path.resolve(__dirname, 'src/ProductList/ProductList.js'),
    ProductPage: path.resolve(__dirname, 'src/ProductPage/ProductPage.js'),
    Icon: path.resolve(__dirname, 'src/Icon/Icon.js'),
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash:8].js',
  },
  plugins: [
    new webpack.HashedModuleIdsPlugin(), // so that file hashes don't change unexpectedly
  ],
  optimization: {
    runtimeChunk: 'single',
    splitChunks: {
      chunks: 'all',
      maxInitialRequests: Infinity,
      minSize: 0,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name(module) {
            // get the name. E.g. node_modules/packageName/not/this/part.js
            // or node_modules/packageName
            const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];

            // npm package names are URL-safe, but some servers don't like @ symbols
            return `npm.${packageName.replace('@', '')}`;
          },
        },
      },
    },
  },
};

Webpack 還會為 ProductListProductPage 之間共享的內容創建文件,這樣我們就不會得到重復的代碼。

這將為 Alice 在大多數情況下節省 50 KB 的下載。

只有 1.815 MB!

我們已經為 Alice 節省了高達56%的下載量,這種節省將(在我們的理論場景中)持續到時間結束。

所有這些都只在Webpack配置中進行了更改——我們沒有對應用程序代碼進行任何更改。

我在前面提到過,測試中的確切場景並不重要。這是因為,無論你提出什么場景,結論都是一樣的:將應用程序分割成合理的小文件,以便用戶下載更少的代碼。

很快,=將討論“code splitting”——另一種類型的文件分割——但首先我想解決你現在正在考慮的三個問題。

#1:大量的網絡請求不是更慢嗎?

答案當然是不會

在 HTTP/1.1 時代,這曾經是一種情況,但在 HTTP/2 時代就不是這樣了。

盡管如此,這篇2016年的文章Khan Academy 2015年的文章都得出結論,即使使用 HTTP/2,下載太多的文件還是比較慢。但在這兩篇文章中,“太多”的意思都是“幾百個”。所以請記住,如果你有數百個文件,你可能一開始就會遇到並發限制。

如果您想知道,對 HTTP/2 的支持可以追溯到 Windows 10 上的 ie11。我做了一個詳盡的調查,每個人都使用比那更舊的設置,他們一致向我保證,他們不在乎網站加載有多快。

#2:每個webpack包中沒有 開銷/引用 代碼嗎?

是的,這也是真的。

好吧,狗屎:

  • more files = 更多 Webpack 引用
  • more files = 不壓縮

讓我們量化一下,這樣我們就能確切地知道需要擔心多少。

好的,我剛做了一個測試,一個 190 KB 的站點拆分成 19 個文件,增加了大約 2%發送到瀏覽器的總字節數。

因此......在第一次訪問時增加 2%,在每次訪問之前減少60%直到網站下架。

正確的擔憂是:完全沒有。

當我測試1個文件對19個時,我想我會在一些不同的網絡上試一試,包括HTTP / 1.1

在 3G 和4G上,這個站點在有19個文件的情況下加載時間減少了30%。

這是非常雜亂的數據。 例如,在運行2號 的 4G 上,站點加載時間為 646ms,然后運行兩次之后,加載時間為1116ms,比之前長73%,沒有變化。因此,聲稱 HTTP/2 “快30%” 似乎有點鬼鬼祟祟。

我創建這個表是為了嘗試量化 HTTP/2 所帶來的差異,但實際上我唯一能說的是“它可能沒有顯著的差異”。

真正令人吃驚的是最后兩行。那是舊的 Windows 和 HTTP/1.1,我打賭會慢得多,我想我需把網速調慢一點。

我從微軟的網站上下載了一個Windows 7 虛擬機來測試這些東西。它是 IE8 自帶的,我想把它升級到IE9,所以我轉到微軟的IE9下載頁面…

06關於HTTP/2 的最后一個問題,你知道它現在已經內置到 Node中了嗎?如果你想體驗一下,我編寫了一個帶有gzip、brotli和響應緩存的小型100行HTTP/2服務器點擊預覽,以滿足你的測試樂趣。

這就是我要講的關於 bundle splitting 的所有內容。我認為這種方法唯一的缺點是必須不斷地說服人們加載大量的小文件是可以的。

Code splitting (加載你需要的代碼)

我說,這種特殊的方法只有在某些網站上才有意義。

我喜歡應用我剛剛編造的 20/20 規則:如果你的站點的某個部分只有 20% 的用戶訪問,並且它大於站點的 JavaScript 的 20%,那么你應該按需加載該代碼。

如何決定?

假設你有一個購物網站,想知道是否應該將“checkout”的代碼分開,因為只有30%的訪問者才會訪問那里。

首先要做的是賣更好的東西。

第二件事是弄清楚多少代碼對於結賬功能是完全獨立的。 由於在執行“code splitting” 之前應始終先“bundle splitting’ ”,因此你可能已經知道代碼的這一部分有多大。

它可能比你想象的要小,所以在你太興奮之前做一下加法。例如,如果你有一個 React 站點,那么你的 storereducerroutingactions 等都將在整個站點上共享。唯一的部分將主要是組件和它們的幫助類。

因此,你注意到你的結帳頁面完全獨特的代碼是 7KB。 該網站的其余部分是 300 KB。 我會看着這個,然后說,我不打算把它拆分,原因如下:

  • 提前加載不會變慢。記住,你是在並行加載所有這些文件。查看是否可以記錄 300KB307KB 之間的加載時間差異。

  • 如果你稍后加載此代碼,則用戶必須在單擊“TAKE MY MONEY”之后等待該文件 - 你希望延遲的最小的時間。

  • Code splitting 需要更改應用程序代碼。 它引入了異步邏輯,以前只有同步邏輯。 這不是火箭科學,但我認為應該通過可感知的用戶體驗改進來證明其復雜性。

讓我們看兩個 code splitting 的例子。

Polyfills

我將從這個開始,因為它適用於大多數站點,並且是一個很好的簡單介紹。

我在我的網站上使用了一些奇特的功能,所以我有一個文件可以導入我需要的所有polyfill, 它包括以下八行:

// polyfills.js 
require('whatwg-fetch');
require('intl');
require('url-polyfill');
require('core-js/web/dom-collections');
require('core-js/es6/map');
require('core-js/es6/string');
require('core-js/es6/array');
require('core-js/es6/object');

index.js 中導入這個文件。

// index-always-poly.js
import './polyfills';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App/App';
import './index.css';

const render = () => {
  ReactDOM.render(<App />, document.getElementById('root'));
}

render(); // yes I am pointless, for now

使用 bundle splitting 的 Webpack 配置,我的 polyfills 將自動拆分為四個不同的文件,因為這里有四個 npm 包。 它們總共大約 25 KB,並且 90% 的瀏覽器不需要它們,因此值得動態加載它們。

使用 Webpack 4 和 import() 語法(不要與 import 語法混淆),有條件地加載polyfill 非常容易。

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App/App';
import './index.css';

const render = () => {
  ReactDOM.render(<App />, document.getElementById('root'));
}

if (
  'fetch' in window &&
  'Intl' in window &&
  'URL' in window &&
  'Map' in window &&
  'forEach' in NodeList.prototype &&
  'startsWith' in String.prototype &&
  'endsWith' in String.prototype &&
  'includes' in String.prototype &&
  'includes' in Array.prototype &&
  'assign' in Object &&
  'entries' in Object &&
  'keys' in Object
) {
  render();
} else {
  import('./polyfills').then(render);
}

合理? 如果支持所有這些內容,則渲染頁面。 否則,導入 polyfill 然后渲染頁面。 當這個代碼在瀏覽器中運行時,Webpack 的運行時將處理這四個 npm 包的加載,當它們被下載和解析時,將調用 render() 並繼續進行。

順便說一句,要使用 import(),你需要 Babel 的動態導入插件。另外,正如 Webpack 文檔解釋的那樣,import() 使用 promises,所以你需要將其與其他polyfill分開填充。

基於路由的動態加載(特定於React)

回到 Alice 的例子,假設站點現在有一個“管理”部分,產品的銷售者可以登錄並管理他們所銷售的一些沒用的記錄。

本節有許多精彩的特性、大量的圖表和來自 npm 的大型圖表庫。因為我已經在做 bundle splittin 了,我可以看到這些都是超過 100 KB 的陰影。

目前,我有一個路由設置,當用戶查看 /admin URL時,它將渲染 <AdminPage>。當Webpack 打包所有東西時,它會找到 import AdminPage from './AdminPage.js'。然后說"嘿,我需要在初始負載中包含這個"

但我們不希望這樣,我們需要將這個引用放到一個動態導入的管理頁面中,比如import('./AdminPage.js') ,這樣 Webpack 就知道動態加載它。

它非常酷,不需要配置。

因此,不必直接引用 AdminPage,我可以創建另一個組件,當用戶訪問 /admin URL時將渲染該組件,它可能是這樣的:

// AdminPageLoader.js 
import React from 'react';

class AdminPageLoader extends React.PureComponent {
  constructor(props) {
    super(props);

    this.state = {
      AdminPage: null,
    }
  }

  componentDidMount() {
    import('./AdminPage').then(module => {
      this.setState({ AdminPage: module.default });
    });
  }

  render() {
    const { AdminPage } = this.state;

    return AdminPage
      ? <AdminPage {...this.props} />
      : <div>Loading...</div>;
  }
}

export default AdminPageLoader;

這個概念很簡單,對吧? 當這個組件掛載時(意味着用戶位於 /admin URL),我們將動態加載 ./AdminPage.js,然后在狀態中保存對該組件的引用。

render 方法中,我們只是在等待 <AdminPage> 加載時渲染 <div>Loading...</div>,或者在加載並存儲狀態時渲染 <AdminPage>

我想自己做這個只是為了好玩,但是在現實世界中,你只需要使用 react-loadable ,如關於 code-splitting 的React文檔中所述。

總結

對於上面總結以下兩點:

  • 如果有人不止一次訪問你的網站,把你的代碼分成許多小文件。
  • 如果你的站點有大部分用戶不訪問的部分,則動態加載該代碼。

原文: The 100% correct way to split your chunks with Webpack

關於Fundebug

Fundebug專注於JavaScript、微信小程序、微信小游戲、支付寶小程序、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了10億+錯誤事件,付費客戶有Google、360、金山軟件、百姓網等眾多品牌企業。歡迎大家免費試用


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM