掃二維碼與項目經(jīng)理溝通
我們在微信上24小時期待你的聲音
解答本文疑問/技術(shù)咨詢/運營咨詢/技術(shù)建議/互聯(lián)網(wǎng)交流
制定向用戶提供文件的***方式可能是一項棘手的工作。 有很多不同的場景,不同的技術(shù),不同的術(shù)語。

在這篇文章中,我希望給你所有你需要的東西,這樣你就可以:
根據(jù) Webpack glossary,有兩種不同類型的文件分割。 這些術(shù)語聽起來可以互換,但顯然不是。
Webpack 文件分離包括兩個部分,一個是 Bundle splitting,一個是 Code splitting:
第二個聽起來更吸引人,不是嗎?事實上,關(guān)于這個問題的許多文章似乎都假設這是制作更小的JavaScript 文件的惟一值得的情況。
但我在這里要告訴你的是,***個在很多網(wǎng)站上都更有價值,應該是你為所有網(wǎng)站做的***件事。
就讓我們一探究竟吧。
Bundle splitting
bundle splitting 背后的思想非常簡單,如果你有一個巨大的文件,并且更改了一行代碼,那么用戶必須再次下載整個文件。但是如果將其分成兩個文件,那么用戶只需要下載更改的文件,瀏覽器將從緩存中提供另一個文件。
值得注意的是,由于 bundle splitting 都是關(guān)于緩存的,所以對于***次訪問來說沒有什么區(qū)別。
(我認為太多關(guān)于性能的討論都是關(guān)于***次訪問一個站點,或許部分原因是“***印象很重要”,部分原因是它很好、很容易衡量。
對于經(jīng)常訪問的用戶來說,量化性能增強所帶來的影響可能比較棘手,但是我們必須進行量化!
這將需要一個電子表格,因此我們需要鎖定一組非常特定的環(huán)境,我們可以針對這些環(huán)境測試每個緩存策略。
這是我在前一段中提到的情況:
某些類型的人(比如我)會嘗試讓這個場景盡可能的真實。不要這樣做。實際情況并不重要,稍后我們將找出原因。
基線
假設我們的 JavaScript 包的總?cè)萘渴?00 KB,目前我們將它作為一個名為 main.js 的文件加載。
我們有一個 Webpack 配置如下(我省略了一些無關(guān)的配置):
- // 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,其中里面的字符串是文件內(nèi)容的散列。這意味著不同的文件名 當應用程序中的代碼發(fā)生更改時,從而強制瀏覽器下載新文件。
每周當我們對站點進行一些新的更改時,這個包的 contenthash 都會發(fā)生變化。因此,Alice 每周都要訪問我們的站點并下載一個新的 400kb 文件。
如果我們把這些事件做成一張表格,它會是這樣的。
也就是10周內(nèi), 4.12 MB, 我們可以做得更好。
分解 vendor 包
讓我們將包分成 main.js 和 vendor.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 是如何分包的知之甚少,結(jié)果有人會問 “你到底在對我的包裹做什么?”
添加 optimization.splitChunks.chunks ='all'的一種說法是 “將 node_modules 中的所有內(nèi)容放入名為 vendors~main.js 的文件中”。
有了這個基本的 bundle splitting,Alice 每次訪問時仍然下載一個新的 200kb 的 main.js,但是在***周、第8周和第5周只下載 200kb 的 vendor.js (不是按此順序)。
總共:2.64 MB。
減少36%。 在我們的配置中添加五行代碼并不錯。 在進一步閱讀之前,先去做。 如果你需要從 Webpack 3 升級到 4,請不要擔心,它非常簡單。
我認為這種性能改進似乎更抽象,因為它是在10周內(nèi)進行的,但是它確實為忠實用戶減少了36%的字節(jié),我們應該為自己感到自豪。
但我們可以做得更好。
分離每個 npm 包
我們的 vendor.js 遇到了與我們的 main.js 文件相同的問題——對其中一部分的更改意味著重新下載它的所有部分。
那么為什么不為每 個npm 包創(chuàng)建一個單獨的文件呢?這很容易做到。
所以把 react、lodash、redux、moment 等拆分成不同的文件:
- 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('@', '')}`;
- },
- },
- },
- },
- },
- };
文檔將很好地解釋這里的大部分內(nèi)容,但是我將稍微解釋一下需要注意的部分,因為它們花了我太多的時間。
Alice 仍然會每周重新下載 200 KB 的 main.js 文件,并且在***次訪問時仍會下載 200 KB 的npm包,但她絕不會兩次下載相同的包。
總共: 2.24 MB.
與基線相比減少了44%,這對于一些可以從博客文章中復制/粘貼的代碼來說非??帷?/p>
我想知道是否有可能超過 50% ? 這完全沒有問題。
分離應用程序代碼的區(qū)域
讓我們轉(zhuǎn)到 main.js 文件,可憐的 Alice 一次又一次地下載這個文件。
我之前提到過,我們在此站點上有兩個不同的部分:產(chǎn)品列表和產(chǎn)品詳細信息頁面。 每個區(qū)域中的唯一代碼為25 KB(共享代碼為150 KB)。
我們的產(chǎn)品詳情頁面現(xiàn)在變化不大,因為我們做得太***了。 因此,如果我們將其做為單獨的文件,則可以在大多數(shù)時間從緩存中獲取到它。
另外,我們網(wǎng)站有一個較大的內(nèi)聯(lián)SVG文件用于渲染圖標,重量只有25 KB,而這個也是很少變化的, 我們也需要優(yōu)化它。
我們只需手動添加一些入口點,告訴 Webpack 為每個項創(chuàng)建一個文件。
- 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 還會為 ProductList 和 ProductPage 之間共享的內(nèi)容創(chuàng)建文件,這樣我們就不會得到重復的代碼。
這將為 Alice 在大多數(shù)情況下節(jié)省 50 KB 的下載。
只有 1.815 MB!
我們已經(jīng)為 Alice 節(jié)省了高達56%的下載量,這種節(jié)省將(在我們的理論場景中)持續(xù)到時間結(jié)束。
所有這些都只在Webpack配置中進行了更改——我們沒有對應用程序代碼進行任何更改。
我在前面提到過,測試中的確切場景并不重要。這是因為,無論你提出什么場景,結(jié)論都是一樣的:將應用程序分割成合理的小文件,以便用戶下載更少的代碼。
很快,=將討論“code splitting”——另一種類型的文件分割——但首先我想解決你現(xiàn)在正在考慮的三個問題。
#1:大量的網(wǎng)絡請求不是更慢嗎?
答案當然是不會。
在 HTTP/1.1 時代,這曾經(jīng)是一種情況,但在 HTTP/2 時代就不是這樣了。
盡管如此,這篇2016年的文章 和 Khan Academy 2015年的文章都得出結(jié)論,即使使用 HTTP/2,下載太多的文件還是比較慢。但在這兩篇文章中,“太多”的意思都是“幾百個”。所以請記住,如果你有數(shù)百個文件,你可能一開始就會遇到并發(fā)限制。
如果您想知道,對 HTTP/2 的支持可以追溯到 Windows 10 上的 ie11。我做了一個詳盡的調(diào)查,每個人都使用比那更舊的設置,他們一致向我保證,他們不在乎網(wǎng)站加載有多快。
#2:每個webpack包中沒有 開銷/引用 代碼嗎?
是的,這也是真的。
好吧,狗屎:
讓我們量化一下,這樣我們就能確切地知道需要擔心多少。
好的,我剛做了一個測試,一個 190 KB 的站點拆分成 19 個文件,增加了大約 2%發(fā)送到瀏覽器的總字節(jié)數(shù)。
因此......在***次訪問時增加 2%,在每次訪問之前減少60%直到網(wǎng)站下架。
正確的擔憂是:完全沒有。
當我測試1個文件對19個時,我想我會在一些不同的網(wǎng)絡上試一試,包括HTTP / 1.1
在 3G 和4G上,這個站點在有19個文件的情況下加載時間減少了30%。
這是非常雜亂的數(shù)據(jù)。 例如,在運行2號 的 4G 上,站點加載時間為 646ms,然后運行兩次之后,加載時間為1116ms,比之前長73%,沒有變化。因此,聲稱 HTTP/2 “快30%” 似乎有點鬼鬼祟祟。
我創(chuàng)建這個表是為了嘗試量化 HTTP/2 所帶來的差異,但實際上我唯一能說的是“它可能沒有顯著的差異”。
真正令人吃驚的是***兩行。那是舊的 Windows 和 HTTP/1.1,我打賭會慢得多,我想我需把網(wǎng)速調(diào)慢一點。
我從微軟的網(wǎng)站上下載了一個Windows 7 虛擬機來測試這些東西。它是 IE8 自帶的,我想把它升級到IE9,所以我轉(zhuǎn)到微軟的IE9下載頁面…
關(guān)于HTTP/2 的***一個問題,你知道它現(xiàn)在已經(jīng)內(nèi)置到 Node中了嗎?如果你想體驗一下,我編寫了一個帶有g(shù)zip、brotli和響應緩存的小型100行HTTP/2服務器
,以滿足你的測試樂趣。
這就是我要講的關(guān)于 bundle splitting 的所有內(nèi)容。我認為這種方法唯一的缺點是必須不斷地說服人們加載大量的小文件是可以的。
Code splitting (加載你需要的代碼)
我說,這種特殊的方法只有在某些網(wǎng)站上才有意義。
我喜歡應用我剛剛編造的 20/20 規(guī)則:如果你的站點的某個部分只有 20% 的用戶訪問,并且它大于站點的 JavaScript 的 20%,那么你應該按需加載該代碼。
如何決定?
假設你有一個購物網(wǎng)站,想知道是否應該將“checkout”的代碼分開,因為只有30%的訪問者才會訪問那里。
首先要做的是賣更好的東西。
第二件事是弄清楚多少代碼對于結(jié)賬功能是完全獨立的。 由于在執(zhí)行“code splitting” 之前應始終先“bundle splitting’ ”,因此你可能已經(jīng)知道代碼的這一部分有多大。
它可能比你想象的要小,所以在你太興奮之前做一下加法。例如,如果你有一個 React 站點,那么你的 store、reducer、routing、actions 等都將在整個站點上共享。唯一的部分將主要是組件和它們的幫助類。
因此,你注意到你的結(jié)帳頁面完全獨特的代碼是 7KB。 該網(wǎng)站的其余部分是 300 KB。 我會看著這個,然后說,我不打算把它拆分,原因如下:
* 如果你稍后加載此代碼,則用戶必須在單擊“TAKE MY MONEY”之后等待該文件 - 你希望延遲的最小的時間。
讓我們看兩個 code splitting 的例子。
Polyfills
我將從這個開始,因為它適用于大多數(shù)站點,并且是一個很好的簡單介紹。
我在我的網(wǎng)站上使用了一些奇特的功能,所以我有一個文件可以導入我需要的所有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(
, document.getElementById('root')); - }
- render(); // yes I am pointless, for now
使用 bundle splitting 的 Webpack 配置,我的 polyfills 將自動拆分為四個不同的文件,因為這里有四個 npm 包。 它們總共大約 25 KB,并且 90% 的瀏覽器不需要它們,因此值得動態(tài)加載它們。
使用 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(
, 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);
- }
合理? 如果支持所有這些內(nèi)容,則渲染頁面。 否則,導入 polyfill 然后渲染頁面。 當這個代碼在瀏覽器中運行時,Webpack 的運行時將處理這四個 npm 包的加載,當它們被下載和解析時,將調(diào)用 render() 并繼續(xù)進行。
順便說一句,要使用 import(),你需要 Babel 的動態(tài)導入插件。另外,正如 Webpack 文檔解釋的那樣,import() 使用 promises,所以你需要將其與其他polyfill分開填充。
基于路由的動態(tài)加載(特定于React)
回到 Alice 的例子,假設站點現(xiàn)在有一個“管理”部分,產(chǎn)品的銷售者可以登錄并管理他們所銷售的一些沒用的記錄。
本節(jié)有許多精彩的特性、大量的圖表和來自 npm 的大型圖表庫。因為我已經(jīng)在做 bundle splittin 了,我可以看到這些都是超過 100 KB 的陰影。
目前,我有一個路由設置,當用戶查看 /admin URL時,它將渲染
但我們不希望這樣,我們需要將這個引用放到一個動態(tài)導入的管理頁面中,比如import('./AdminPage.js') ,這樣 Webpack 就知道動態(tài)加載它。
它非???,不需要配置。
因此,不必直接引用 AdminPage,我可以創(chuàng)建另一個組件,當用戶訪問 /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
- ?
- :
Loading...;- }
- }
- export default AdminPageLoader;
這個概念很簡單,對吧? 當這個組件掛載時(意味著用戶位于 /admin URL),我們將動態(tài)加載 ./AdminPage.js,然后在狀態(tài)中保存對該組件的引用。
在 render 方法中,我們只是在等待
我想自己做這個只是為了好玩,但是在現(xiàn)實世界中,你只需要使用 react-loadable ,如關(guān)于 code-splitting 的React文檔 中所述。
總結(jié)
對于上面總結(jié)以下兩點:
代碼部署后可能存在的BUG沒法實時知道,事后為了解決這些BUG,花了大量的時間進行l(wèi)og 調(diào)試,這邊順便給大家推薦一個好用的BUG監(jiān)控工具 Fundebug。

我們在微信上24小時期待你的聲音
解答本文疑問/技術(shù)咨詢/運營咨詢/技術(shù)建議/互聯(lián)網(wǎng)交流