【譯】Web 效能優化: 使用 Webpack 分離資料的正確方法

前端小智發表於2019-04-12

譯者:前端小智

原文:hackernoon.com/the-100-cor…

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

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

  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% ? 這完全沒有問題。

分離應用程式程式碼的區域

讓我們轉到 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下載頁面…

圖片描述

關於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文件 中所述。

總結

對於上面總結以下兩點:

  • 如果有人不止一次訪問你的網站,把你的程式碼分成許多小檔案。

  • 如果你的站點有大部分使用者不訪問的部分,則動態載入該程式碼。

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

原文:

hackernoon.com/the-100-cor…

你的點贊是我持續分享好東西的動力,歡迎點贊!

歡迎加入前端大家庭,裡面會經常分享一些技術資源。

圖片描述

相關文章