4-3~8 code-splitting,懶加載,預拉取,預加載

1. 簡介

代碼分離是 webpack 中最引人注目的特性之一。此特性能夠把代碼分離到不同的 bundle 中,然后可以按需加載或并行加載這些文件。代碼分離可以用于獲取更小的 bundle,以及控制資源加載優先級,如果使用合理,會極大影響加載時間。

2. 入口分離

我們看下面這種情況:

// index.js

import _ from 'lodash';
import './another-module';

console.log(
    _.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
// another-module.js

import _ from 'lodash';
import $ from 'jquery';

console.log(
    _.join(['another', 'module', 'loaded!'], ' ')
);
$('body').click(() => {
    $('body').css('background', 'green')
});

npm run dev 打包后如下:


image.png

image.png

可以看到,雖然 index 展示的時候不需要 another-module,但兩者最終被打包到同一個文件輸出,這樣的話有兩個缺點:

  1. index 和 another-module 邏輯混合到一起,增大了需要下載的包的體積。如果此時 index 是首屏必須的邏輯,那么由于包體增大,延遲了首屏展示時間。
  2. 修改 index 或者 another-module 邏輯,都會導致最終輸出的文件被改變,用戶需要重新下載和當前改動無關的模塊內容。
    解決這兩個問題,最好的辦法,就是將無關的 index 和 another-module 分離。如下:
    entry: {
        index: "./src/index.js",
        another: "./src/another-module.js"
    },
// index.js

// index.js

import _ from 'lodash';

console.log(
    _.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);

打包后如下:


image.png

![image](https://upload-images.jianshu.io/upload_images/4761597-6bbb88ad600937dc.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

可以看到,首屏加載的資源 index 明顯變小了,可是加載時間反而延長了。這是由于 another 被并行加載,而且 index 和 another 的總體大小增大了很多。仔細分析,可以發現 lodash 模塊被分別打包到了 index 和 another。我們按照上面的思路,繼續將三方庫 lodash 和 jquery 也分離出來:

// index.js

console.log(
    _.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
// another-module.js

console.log(
    _.join(['another', 'module', 'loaded!'], ' ')
);
$('body').click(() => {
    $('body').css('background', 'green')
});
// jquery.js

import $ from 'jquery';
window.$ = $;
// lodash.js

import _ from 'lodash';
window._ = _;
image.png

image.png

可以看到,jquery 和 lodash 被分離后,index 和 another 顯著變小,而第三方模塊基本上是很少改變的,也就是當某個業務模塊改變時,我們只需要重新上傳新的業務模塊代碼,用戶更新的時候也只需要更新較小的業務模塊代碼。不過可以看到,這里仍然有兩個缺點:

  1. 手動做代碼抽取非常麻煩,我們需要自己把握分離的先后順序,以及手動指定入口。
  2. 首次進入且沒有緩存的時候,由于并行的資源較多,并沒有減少首屏加載的時間,反而可能延長了這個時間。
    下面我們來嘗試解決這兩個問題。

3. 代碼自動抽取

SplitChunksPlugin插件可以將公共的依賴模塊提取到已有的入口 chunk 中,或者提取到一個新生成的 chunk。

3.1 代碼自動抽取

讓我們使用這個插件,將之前的示例中重復的 lodash 模塊 和 jquery 模塊抽取出來。(ps: 這里 webpack4 已經移除了 CommonsChunkPlugin 插件,改為 SplitChunksPlugin 插件了)。

// index.js
import _ from 'lodash';

console.log(
    _.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
// another-module.js
import _ from 'lodash';
import $ from 'jquery';

console.log(
    _.join(['another', 'module', 'loaded!'], ' ')
);
$('body').click(() => {
    $('body').css('background', 'green')
});
    optimization: {
        splitChunks: {
            chunks: 'all'
        }
    }
image.png
image.png

可以看到,兩個公共模塊各自被自動抽取到了新生成的 chunk 中。

3.2 SplitChunksPlugin 配置參數詳解

SplitChunksPlugin 默認配置如下:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 30000,
      minRemainingSize: 0,
      maxSize: 0,
      minChunks: 1,
      maxAsyncRequests: 6,
      maxInitialRequests: 4,
      automaticNameDelimiter: '~',
      automaticNameMaxLength: 30,
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

各項缺省時會自動取默認值,也就是如果傳入:

module.exports = {
  //...
  optimization: {
    splitChunks: {}
  }
};

等同于全部取默認值。下面我們來看一下每一項的含義。首先修改一下源文件,抽取 log-util 模塊:

// log-util.js
export const log = (info) => {
    console.log(info);
};

export const err = (info) => {
    console.log(info);
};
// index.js
import _ from 'lodash';
import { log } from './log-util';

log(
    _.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
// another-module.js
import _ from 'lodash';
import $ from 'jquery';
import { log } from './log-util';

log(
    _.join(['another', 'module', 'loaded!'], ' ')
);
$('body').click(() => {
    $('body').css('background', 'green')
});

3.2.1 splitChunks.chunks

chunks 有三個值,分別是:
async: 異步模塊(即按需加載模塊,默認值)
initial: 初始模塊(即初始存在的模塊)
all: 全部模塊(異步模塊 + 初始模塊)
因為更改初始塊會影響 HTML 文件應該包含的用于運行項目的腳本標簽。我們可以修改該配置項如下(這里對 cacheGroups 做了簡單的修改,是為了方便后續的比較,大家簡單理解為,node_modules 的模塊,會放在 verdors 下,其他的會放在 default 下即可,后面會有更詳細的解釋):

    optimization: {
        splitChunks: {
            chunks: 'all',
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    }
image.png

3.2.2 splitChunks.minSize

生成塊的最小大小(以字節為單位)。

    optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 800000,
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    }
image.png

可以看到 lodash 并沒有從 index 中拆出,lodash 和 jquery 從another 拆出后一起被打包在一個公共的 vendors~another 中。這是由于如果 lodash 和 jquery 單獨拆出后 jquery 是不到 800k 的,無法拆成單獨的兩個 chunk。

    optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 0,
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
     
image.png

可以看到每個模塊都被分離了出來。

3.2.3 splitChunks.minRemainingSize

在 webpack 5 中引入了該選項,通過確保分割后剩余塊的最小大小超過指定限制,從而避免了零大小的模塊。在“開發”模式下默認為0。對于其他情況,該選項默認為 minSize 的值。所以它不需要手動指定,除非在需要采取特定的深度控制的情況下。

3.2.4 splitChunks.maxSize

使用 maxSize 告訴 webpack 嘗試將大于 maxSize 字節的塊分割成更小的部分。每塊至少是 minSize 大小。該算法是確定性的,對模塊的更改只會產生局部影響。因此,它在使用長期緩存時是可用的,并且不需要記錄。maxSize只是一個提示,當模塊大于 maxSize 時可能不會分割也可能分割后大小小于 minSize。
當塊已經有一個名稱時,每個部分將從該名稱派生出一個新名稱。取決于值optimization.splitChunks.hidePathInfo,它將從第一個模塊名或其散列派生一個
key。
需要注意:

  1. maxSize比maxInitialRequest/ maxasyncrequest具有更高的優先級。實際的優先級是maxInitialRequest/maxAsyncRequests < maxSize < minSize。
  2. 設置maxSize的值將同時設置maxAsyncSize和maxInitialSize的值。
    maxSize選項用于HTTP/2和長期緩存。它增加了請求數,以便更好地進行緩存。它還可以用來減小文件大小,以便更快地重建。
    optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 0,
            maxSize: 30000,
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    }

image.png

可以看到,defaultVendorsanotherindex~ 又分離出了 defaultVendorsanotherindex._node_modules_lodash_lodash.js2ef0e502.js 和 defaultVendorsanotherindex~._node_modules_webpack_buildin_g.js。

3.2.5 splitChunks.minChunks

代碼分割前共享一個模塊的最小 chunk 數,我們來看一下:

optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 10,
            minChunks: 2,
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    }
image.png

可以看到, jquery 由于引用次數小于 2,沒有被單獨分離出來。如果改為 3,

    optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 10,
            minChunks: 3,
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    }
image.png

可以看到, jquery 和 lodash 由于引用次數小于 3,都沒有被單獨分離出來。

3.2.6 splitChunks.maxAsyncRequests

按需加載時的最大并行請求數。

3.2.7 splitChunks.maxInitialRequests

一個入口點的最大并行請求數。

3.2.8 splitChunks.automaticNameDelimiter

默認情況下,webpack將使用塊的來源和名稱來生成名稱(例如: vendors~main.js)。此選項允許您指定用于生成的名稱的分隔符。。

3.2.9 splitChunks.automaticNameMaxLength

插件生成的 chunk 名稱所允許的最大字符數。防止名稱過長,增大代碼和傳輸包體,保持默認即可。

3.2.10 splitChunks.cacheGroups

緩存組可以繼承和/或覆蓋splitChunks中的任何選項。但是test、priority和reuseExistingChunk只能在緩存組級配置。若要禁用任何缺省緩存組,請將它們設置為false。

3.2.10.1 splitChunks.cacheGroups.{cacheGroup}.test

控制此緩存組選擇哪些模塊。省略它將選擇所有模塊。它可以匹配絕對模塊資源路徑或塊名稱。當一個 chunk 名匹配時,chunk 中的所有模塊都被選中。

optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 0,
            minChunks: 1,
            cacheGroups: {
                log: {
                    test(module, chunks) {
                        // `module.resource` contains the absolute path of the file on disk.
                        // Note the usage of `path.sep` instead of / or \, for cross-platform compatibility.
                        return module.resource &&
                            module.resource.indexOf('log') > -1;
                    }
                },
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    }

image.png

可以看到,log-util 模塊被匹配到了 loganotherindex chunk。

3.2.10.2 splitChunks.cacheGroups.{cacheGroup}.priority

一個模塊可以屬于多個緩存組。該優化將優先選擇具有較高優先級的緩存組。默認組具有負優先級,以允許自定義組具有更高的優先級(默認值為0的自定義組)。

optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 0,
            minChunks: 1,
            cacheGroups: {
                log: {
                    test(module, chunks) {
                        // `module.resource` contains the absolute path of the file on disk.
                        // Note the usage of `path.sep` instead of / or \, for cross-platform compatibility.
                        return module.resource &&
                            module.resource.indexOf('log') > -1;
                    },
                    priority: -20,
                },
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -15,
                    reuseExistingChunk: true
                }
            }
        }
    }
image.png

可以看到 log 緩存組下不會輸出了,事實上,比 default 的 prioity 低的緩存組都是不會輸出的。

3.2.10.3 splitChunks.cacheGroups.{cacheGroup}.reuseExistingChunk

如果當前 chunk 包含已經從主包中分離出來的模塊,那么它將被重用,而不是生成一個新的 chunk。這可能會影響 chunk 的結果文件名。

3.3 小結

可以看到,提取公共代碼單獨輸出后,我們加載資源的時間并沒有變短,因為帶寬是一定的,并行資源過多,反而會增加 http 耗時。我們獲得的主要好處是,充分利用了緩存,這對于用戶資源更新時有很大的好處,不過也需要衡量公共代碼提取的條件,防止負優化。這里一般使用默認的四個條件即可(至于作用的模塊我們可以改為 all):

  1. 新的 chunk 可以被共享,或者是來自 node_modules 文件夾
  2. 新的 chunk 大于30kb(在 min + gz 壓縮之前)
  3. 當按需加載 chunk 時,并行請求的最大數量小于或等于 6
  4. 初始頁面加載時并行請求的最大數量將小于或等于 4

4. 動態引入和懶加載

我們進一步考慮,初始的時候并行了這么多資源,導致加載時間變慢,那么其中是否所有的資源都是需要的呢。顯然不是的。這里我們其實是想先加載首屏邏輯,然后點擊 body 時才去加載 another-module 的邏輯。
首先,webpack 資源是支持動態引入的。當涉及到動態代碼拆分時,webpack 提供了兩個類似的技術。對于動態導入,第一種,也是優先選擇的方式是,使用符合 ECMAScript 提案import() 語法。第二種,則是使用 webpack 特定的 require.ensure。更推薦使用第一種,適應范圍更大。
而在用戶真正需要的時候才去動態引入資源,也就是所謂的懶加載了。
我們作如下修改:

// index.js
import _ from 'lodash';
import { log } from './log-util';

log(
    _.join(['index', 'module', 'loaded!'], ' ')
);
const div = document.createElement('div');
div.innerText = 'first screen content';
document.getElementById('root').appendChild(div);
document.body.addEventListener('click', () => {
    import ('./another-module').then(anotherModule => {
        anotherModule.default.run();
    });
});
// another-module.js
import _ from 'lodash';
import $ from 'jquery';
import { log } from './log-util';
const anotherModule = {
    run() {
        log(
            _.join(['another', 'module', 'loaded!'], ' ')
        );
        $('body').css('background', 'green');
    }
};

export default anotherModule;
    optimization: {
        splitChunks: {
            chunks: 'all',
            minSize: 0,
            minChunks: 1,
            cacheGroups: {
                defaultVendors: {
                    test: /[\\/]node_modules[\\/]/,
                    priority: -10
                },
                default: {
                    minChunks: 1,
                    priority: -20,
                    reuseExistingChunk: true
                }
            }
        }
    }

打包后如下:


image.png

image.png

可以看到,another 的輔助加載和 log,lodash 邏輯被提前加載,但是模塊內部邏輯和 jquery 模塊都被單獨拎出來了,且并沒有加載。


async.gif

點擊body后,該部分內容才被加載并執行。這樣就能有效提升首屏加載速度。
如果我們想改變異步加載包的名稱,可以使用 magic-comment,如下:
document.body.addEventListener('click', () => {
    import (/* webpackChunkName: "anotherModule" */ './another-module').then(anotherModule => {
        anotherModule.default.run();
    });
});

打包發現:


image.png

image.png

但是尷尬地是,由于新增了 another-module,和 another 相同的部分被打包并且提前加載了,導致我們的懶加載策略失效了,這個坑大家要注意。

5. 預拉取和預加載

我們考慮一下這個問題,懶加載雖然減少了首屏加載時間,但是在交互操作或者其他異步渲染的響應。我們該如何解決這個問題呢?
webpack 4.6.0+增加了對預拉取和預加載的支持。
預拉取: 將來某些導航可能需要一些資源
預加載: 在當前導航可能需要一些資源
假設有一個主頁組件,它呈現一個LoginButton組件,然后在單擊后按需加載一個LoginModal組件。

// LoginButton.js
//...
import(/* webpackPrefetch: true */ 'LoginModal');

這將導致 <link rel="prefetch" href="login-modal-chunk.js"> 被附加在頁面的頭部,指示瀏覽器在空閑時間預拉取login-modal-chunk.js文件。
ps:webpack將在加載父模塊后立即添加預拉取提示。
Preload 不同于 prefetch:

  • 一個預加載的塊開始與父塊并行加載。預拉取的塊在父塊完成加載后啟動。
  • 預加載塊具有中等優先級,可以立即下載。在瀏覽器空閑時下載預拉取的塊。
  • 一個預加載的塊應該被父塊立即請求。預拉取的塊可以在將來的任何時候使用。
  • 瀏覽器支持是不同的。
    讓我們想象一個組件 ChartComponent,它需要一個巨大的圖表庫。它在渲染時顯示一個 LoadingIndicator,并立即按需導入圖表庫:
// ChartComponent.js
//...
import(/* webpackPreload: true */ 'ChartingLibrary');

當使用 ChartComponent 的頁面被請求時,還會通過請求圖表庫塊。假設頁面塊更小,完成速度更快,那么頁面將使用 LoadingIndicator 顯示,直到已經請求的圖表庫塊完成。這將對加載時間有一定優化,因為它只需要一次往返而不是兩次。特別是在高延遲環境中。

ps: 不正確地使用 webpackPreload 實際上會損害性能,所以在使用它時要小心。
對于本文所列的例子,顯然更符合預拉取的情況,如下:

document.body.addEventListener('click', () => {
    import (/* webpackPrefetch: true */ './another-module').then(anotherModule => {
        anotherModule.default.run();
    });
});
image.png

圖示資源,提前被下載好,在點擊的時候再去下載資源時就可以直接使用緩存。

document.body.addEventListener('click', () => {
    import (/* webpackLoad: true */ './another-module').then(anotherModule => {
        anotherModule.default.run();
    });
});

6. 小結

本文內容比較多,統合了多個章節,而且內容上有很大的不一致。如果大家有同步看視屏,應該也會發現之前也有很多不一致的地方。學習記錄切忌照本宣科,多查資料,多實踐,才能有更多收獲。

參考

https://webpack.js.org/guides/code-splitting/#root
https://www.webpackjs.com/guides/code-splitting/
Webpack 的 Bundle Split 和 Code Split 區別和應用
https://webpack.js.org/plugins/split-chunks-plugin/
手摸手,帶你用合理的姿勢使用webpack4
webpack4 splitChunks的reuseExistingChunk選項有什么作用

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370