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

阿里雲最近在作活動,低至2折,有興趣能夠看看:
https://promotion.aliyun.com/...

爲了保證的可讀性,本文采用意譯而非直譯。css

制定向用戶提供文件的最佳方式多是一項棘手的工做。 有不少不一樣的場景,不一樣的技術,不一樣的術語。html

在這篇文章中,我但願給你全部你須要的東西,這樣你就能夠:前端

  1. 瞭解哪一種文件分割策略最適合你的網站和用戶
  2. 知道怎麼作

根據 Webpack glossary,有兩種不一樣類型的文件分割。 這些術語聽起來能夠互換,但顯然不是。node

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

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

第二個聽起來更吸引人,不是嗎?事實上,關於這個問題的許多文章彷佛都假設這是製做更小的JavaScript 文件的唯一值得的狀況。webpack

但我在這裏要告訴你的是,第一個在不少網站上都更有價值,應該是你爲全部網站作的第一件事。git

就讓咱們一探究竟吧。es6

Bundle splitting

bundle splitting 背後的思想很是簡單,若是你有一個巨大的文件,而且更改了一行代碼,那麼用戶必須再次下載整個文件。可是若是將其分紅兩個文件,那麼用戶只須要下載更改的文件,瀏覽器將從緩存中提供另外一個文件。github

值得注意的是,因爲 bundle splitting 都是關於緩存的,因此對於第一次訪問來講沒有什麼區別。web

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

對於常常訪問的用戶來講,量化性能加強所帶來的影響可能比較棘手,可是咱們必須進行量化!

這將須要一個電子表格,所以咱們須要鎖定一組很是特定的環境,咱們能夠針對這些環境測試每一個緩存策略。

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

  • 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

原文:

https://hackernoon.com/the-10...

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

交流

乾貨系列文章彙總以下,以爲不錯點個Star,歡迎 加羣 互相學習。

https://github.com/qq44924588...

我是小智,公衆號「大遷世界」做者,對前端技術保持學習愛好者。我會常常分享本身所學所看的乾貨,在進階的路上,共勉!

關注公衆號,後臺回覆福利,便可看到福利,你懂的。

clipboard.png

相關文章
相關標籤/搜索