(給前端大全加星標(biāo),提升前端技能) 作者:前端森林 公號 / 前端森林
引言webpack 的打包優(yōu)化一直是個老生常談的話題,常規(guī)的無非就分塊、拆包、壓縮等。
本文以我自己的經(jīng)驗(yàn)向大家分享如何通過一些分析工具、插件以及webpack 新版本中的一些新特性來顯著提升webpack 的打包速度和改善包體積,學(xué)會分析打包的瓶頸以及問題所在。 本文演示代碼,倉庫地址:https://github.com/Jack-cool/webpack4 速度分析 ??webpack 有時候打包很慢,而我們在項目中可能用了很多的 plugin 和 loader ,想知道到底是哪個環(huán)節(jié)慢,下面這個插件可以計算 plugin 和 loader 的耗時。 yarn add -D speed-measure-webpack-plugin
配置也很簡單,把 webpack 配置對象包裹起來即可: const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
const webpackConfig = smp.wrap({ plugins: [ new MyPlugin(), new MyOtherPlugin() ] });
來看下在項目中引入speed-measure-webpack-plugin 后的打包情況: 從上圖可以看出這個插件主要做了兩件事情: - 分析每個插件和 loader 的耗時情況 知道了具體
loader 和plugin 的耗時情況,我們就可以“對癥下藥”了
體積分析 ??打包后的體積優(yōu)化是一個可以著重優(yōu)化的點(diǎn),比如引入的一些第三方組件庫過大,這時就要考慮是否需要尋找替代品了。 這里采用的是webpack-bundle-analyzer ,也是我平時工作中用的最多的一款插件了。 它可以用交互式可縮放樹形圖顯示webpack 輸出文件的大小。用起來非常的方便。 首先安裝插件: yarn add -D webpack-bundle-analyzer
安裝完在webpack.config.js 中簡單的配置一下: const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = { plugins: [ new BundleAnalyzerPlugin({ // 可以是`server`,`static`或`disabled`。 // 在`server`模式下,分析器將啟動HTTP服務(wù)器來顯示軟件包報告。 // 在“靜態(tài)”模式下,會生成帶有報告的單個HTML文件。 // 在`disabled`模式下,你可以使用這個插件來將`generateStatsFile`設(shè)置為`true`來生成Webpack Stats JSON文件。 analyzerMode: 'server', // 將在“服務(wù)器”模式下使用的主機(jī)啟動HTTP服務(wù)器。 analyzerHost: '127.0.0.1', // 將在“服務(wù)器”模式下使用的端口啟動HTTP服務(wù)器。 analyzerPort: 8866, // 路徑捆綁,將在`static`模式下生成的報告文件。 // 相對于捆綁輸出目錄。 reportFilename: 'report.html', // 模塊大小默認(rèn)顯示在報告中。 // 應(yīng)該是`stat`,`parsed`或者`gzip`中的一個。 // 有關(guān)更多信息,請參見“定義”一節(jié)。 defaultSizes: 'parsed', // 在默認(rèn)瀏覽器中自動打開報告 openAnalyzer: true, // 如果為true,則Webpack Stats JSON文件將在bundle輸出目錄中生成 generateStatsFile: false, // 如果`generateStatsFile`為`true`,將會生成Webpack Stats JSON文件的名字。 // 相對于捆綁輸出目錄。 statsFilename: 'stats.json', // stats.toJson()方法的選項。 // 例如,您可以使用`source:false`選項排除統(tǒng)計文件中模塊的來源。 // 在這里查看更多選項:https: //github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21 statsOptions: null, logLevel: 'info' ) ] }
然后在命令行工具中輸入npm run dev ,它默認(rèn)會起一個端口號為 8888 的本地服務(wù)器: 圖中的每一塊清晰的展示了組件、第三方庫的代碼體積。 有了它,我們就可以針對體積偏大的模塊進(jìn)行相關(guān)優(yōu)化了。 多進(jìn)程/多實(shí)例構(gòu)建 ??大家都知道 webpack 是運(yùn)行在 node 環(huán)境中,而 node 是單線程的。webpack 的打包過程是 io 密集和計算密集型的操作,如果能同時 fork 多個進(jìn)程并行處理各個任務(wù),將會有效的縮短構(gòu)建時間。 平時用的比較多的兩個是thread-loader 和HappyPack 。 先來看下thread-loader 吧,這個也是webpack4 官方所推薦的。 thread-loader
安裝yarn add -D thread-loader
thread-loader 會將你的 loader 放置在一個 worker 池里面運(yùn)行,以達(dá)到多線程構(gòu)建。
?把這個 loader 放置在其他 loader 之前(如下面示例的位置), 放置在這個 loader 之后的 loader 就會在一個單獨(dú)的 worker 池(worker pool )中運(yùn)行。 ? 示例module.exports = { module: { rules: [ { test: /\.js$/, include: path.resolve('src'), use: [ 'thread-loader', // your expensive loader (e.g babel-loader) ] } ] } }
HappyPack安裝yarn add -D happypack
HappyPack 可以讓 Webpack 同一時間處理多個任務(wù),發(fā)揮多核 CPU 的能力,將任務(wù)分解給多個子進(jìn)程去并發(fā)的執(zhí)行,子進(jìn)程處理完后,再把結(jié)果發(fā)送給主進(jìn)程。通過多進(jìn)程模型,來加速代碼構(gòu)建。
示例// webpack.config.js const HappyPack = require('happypack');
exports.module = { rules: [ { test: /.js$/, // 1) replace your original list of loaders with 'happypack/loader': // loaders: [ 'babel-loader?presets[]=es2015' ], use: 'happypack/loader', include: [ /* ... */ ], exclude: [ /* ... */ ] } ] };
exports.plugins = [ // 2) create the plugin: new HappyPack({ // 3) re-add the loaders you replaced above in #1: loaders: [ 'babel-loader?presets[]=es2015' ] }) ];
這里有一點(diǎn)需要說明的是,HappyPack 的作者表示已不再維護(hù)此項目,這個可以在github 倉庫看到: 作者也是推薦使用webpack 官方提供的thread-loader 。 ?thread-loader 和 happypack 對于小型項目來說打包速度幾乎沒有影響,甚至可能會增加開銷,所以建議盡量在大項目中采用。 ? 多進(jìn)程并行壓縮代碼 ??通常我們在開發(fā)環(huán)境,代碼構(gòu)建時間比較快,而構(gòu)建用于發(fā)布到線上的代碼時會添加壓縮代碼這一流程,則會導(dǎo)致計算量大耗時多。 webpack 默認(rèn)提供了UglifyJS 插件來壓縮JS 代碼,但是它使用的是單線程壓縮代碼,也就是說多個js 文件需要被壓縮,它需要一個個文件進(jìn)行壓縮。所以說在正式環(huán)境打包壓縮代碼速度非常慢(因?yàn)閴嚎sJS 代碼需要先把代碼解析成用Object 抽象表示的AST 語法樹,再應(yīng)用各種規(guī)則分析和處理AST ,導(dǎo)致這個過程耗時非常大)。
所以我們要對壓縮代碼這一步驟進(jìn)行優(yōu)化,常用的做法就是多進(jìn)程并行壓縮。 目前有三種主流的壓縮方案: parallel-uglify-plugin
上面介紹的HappyPack 的思想是使用多個子進(jìn)程去解析和編譯JS ,CSS 等,這樣就可以并行處理多個子任務(wù),多個子任務(wù)完成后,再將結(jié)果發(fā)到主進(jìn)程中,有了這個思想后,ParallelUglifyPlugin 插件就產(chǎn)生了。 當(dāng)webpack 有多個JS 文件需要輸出和壓縮時,原來會使用UglifyJS 去一個個壓縮并且輸出,而ParallelUglifyPlugin 插件則會開啟多個子進(jìn)程,把對多個文件壓縮的工作分給多個子進(jìn)程去完成,但是每個子進(jìn)程還是通過UglifyJS 去壓縮代碼。并行壓縮可以顯著的提升效率。 安裝yarn add -D webpack-parallel-uglify-plugin
示例import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';
module.exports = { plugins: [ new ParallelUglifyPlugin({ // Optional regex, or array of regex to match file against. Only matching files get minified. // Defaults to /.js$/, any file ending in .js. test, include, // Optional regex, or array of regex to include in minification. Only matching files get minified. exclude, // Optional regex, or array of regex to exclude from minification. Matching files are not minified. cacheDir, // Optional absolute path to use as a cache. If not provided, caching will not be used. workerCount, // Optional int. Number of workers to run uglify. Defaults to num of cpus - 1 or asset count (whichever is smaller) sourceMap, // Optional Boolean. This slows down the compilation. Defaults to false. uglifyJS: { // These pass straight through to uglify-js@3. // Cannot be used with uglifyES. // Defaults to {} if not neither uglifyJS or uglifyES are provided. // You should use this option if you need to ensure es5 support. uglify-js will produce an error message // if it comes across any es6 code that it can't parse. }, uglifyES: { // These pass straight through to uglify-es. // Cannot be used with uglifyJS. // uglify-es is a version of uglify that understands newer es6 syntax. You should use this option if the // files that you're minifying do not need to run in older browsers/versions of node. } }), ], };
?webpack-parallel-uglify-plugin 已不再維護(hù),這里不推薦使用 ? uglifyjs-webpack-plugin
安裝yarn add -D uglifyjs-webpack-plugin
示例const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = { plugins: [ new UglifyJsPlugin({ uglifyOptions: { warnings: false, parse: {}, compress: {}, ie8: false }, parallel: true }) ] };
其實(shí)它和上面的parallel-uglify-plugin 類似,也可通過設(shè)置parallel: true 開啟多進(jìn)程壓縮。 terser-webpack-plugin
不知道你有沒有發(fā)現(xiàn):webpack4 已經(jīng)默認(rèn)支持 ES6 語法的壓縮。 而這離不開terser-webpack-plugin 。 安裝yarn add -D terser-webpack-plugin
示例const TerserPlugin = require('terser-webpack-plugin');
module.exports = { optimization: { minimize: true, minimizer: [ new TerserPlugin({ parallel: 4, }), ], }, };
預(yù)編譯資源模塊 ??什么是預(yù)編譯資源模塊?在使用webpack 進(jìn)行打包時候,對于依賴的第三方庫,比如vue ,vuex 等這些不會修改的依賴,我們可以讓它和我們自己編寫的代碼分開打包,這樣做的好處是每次更改我本地代碼的文件的時候,webpack 只需要打包我項目本身的文件代碼,而不會再去編譯第三方庫。 那么第三方庫在第一次打包的時候只打包一次,以后只要我們不升級第三方包的時候,那么webpack 就不會對這些庫去打包,這樣的可以快速的提高打包的速度。其實(shí)也就是預(yù)編譯資源模塊 。 webpack 中,我們可以結(jié)合DllPlugin 和 DllReferencePlugin 插件來實(shí)現(xiàn)。
DllPlugin 是什么?
它能把第三方庫代碼分離開,并且每次文件更改的時候,它只會打包該項目自身的代碼。所以打包速度會更快。 DLLPlugin 插件是在一個額外獨(dú)立的webpack 設(shè)置中創(chuàng)建一個只有dll 的bundle ,也就是說我們在項目根目錄下除了有webpack.config.js ,還會新建一個webpack.dll.js 文件。
webpack.dll.js 的作用是把所有的第三方庫依賴打包到一個bundle 的dll 文件里面,還會生成一個名為 manifest.json 文件。該manifest.json 的作用是用來讓 DllReferencePlugin 映射到相關(guān)的依賴上去的。
DllReferencePlugin 又是什么?
這個插件是在webpack.config.js 中使用的,該插件的作用是把剛剛在webpack.dll.js 中打包生成的dll 文件引用到需要的預(yù)編譯的依賴上來。 什么意思呢?就是說在webpack.dll.js 中打包后比如會生成 vendor.dll.js 文件和vendor-manifest.json 文件,vendor.dll.js 文件包含了所有的第三方庫文件,vendor-manifest.json 文件會包含所有庫代碼的一個索引,當(dāng)在使用webpack.config.js 文件打包DllReferencePlugin 插件的時候,會使用該DllReferencePlugin 插件讀取vendor-manifest.json 文件,看看是否有該第三方庫。 vendor-manifest.json 文件就是一個第三方庫的映射而已。
怎么在項目中使用?上面說了這么多,主要是為了方便大家對于預(yù)編譯資源模塊 和DllPlugin 和、DllReferencePlugin 插件作用的理解(我第一次使用看了好久才明白~~) 先來看下完成的項目目錄結(jié)構(gòu): 主要在兩塊配置,分別是webpack.dll.js 和webpack.config.js (對應(yīng)這里我是webpack.base.js ) webpack.dll.js
const path = require('path'); const webpack = require('webpack');
module.exports = { mode: 'production', entry: { vendors: ['lodash', 'jquery'], react: ['react', 'react-dom'] }, output: { filename: '[name].dll.js', path: path.resolve(__dirname, './dll'), library: '[name]' }, plugins: [ new webpack.DllPlugin({ name: '[name]', path: path.resolve(__dirname, './dll/[name].manifest.json') }) ] }
這里我拆了兩部分:vendors (存放了lodash 、jquery 等)和react (存放了 react 相關(guān)的庫,react 、react-dom 等) webpack.config.js (對應(yīng)我這里就是webpack.base.js )
const path = require('path'); const fs = require('fs'); // ... const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin'); const webpack = require('webpack');
const plugins = [ // ... ];
const files = fs.readdirSync(path.resolve(__dirname, './dll')); files.forEach(file => { if(/.*\.dll.js/.test(file)) { plugins.push(new AddAssetHtmlWebpackPlugin({ filepath: path.resolve(__dirname, './dll', file) })) } if(/.*\.manifest.json/.test(file)) { plugins.push(new webpack.DllReferencePlugin({ manifest: path.resolve(__dirname, './dll', file) })) } })
module.exports = { entry: { main: './src/index.js' }, module: { rules: [] }, plugins,
output: { // publicPath: './', path: path.resolve(__dirname, 'dist') } }
這里為了演示省略了很多代碼,項目完整代碼在這里 由于上面我把第三方庫做了一個拆分,所以對應(yīng)生成也就會是多個文件,這里讀取了一下文件,做了一層遍歷。 最后在package.json 里面再添加一條腳本就可以了: 'scripts': { 'build:dll': 'webpack --config ./webpack.dll.js', },
運(yùn)行yarn build:dll 就會生成本小節(jié)開頭貼的那張項目結(jié)構(gòu)圖了~ 利用緩存提升二次構(gòu)建速度 ??一般來說,對于靜態(tài)資源,我們都希望瀏覽器能夠進(jìn)行緩存,那樣以后進(jìn)入頁面就可以直接使用緩存資源,頁面打開速度會顯著加快,既提高了用戶的體驗(yàn)也節(jié)省了寬帶資源。 當(dāng)然瀏覽器緩存方法有很多種,這里只簡單討論下在webpack 中如何利用緩存來提升二次構(gòu)建速度。 在webpack 中利用緩存一般有以下幾種思路: - 使用
hard-source-webpack-plugin
babel-loader
babel-loader 在執(zhí)行的時候,可能會產(chǎn)生一些運(yùn)行期間重復(fù)的公共文件,造成代碼體積冗余,同時也會減慢編譯效率。
可以加上cacheDirectory 參數(shù)開啟緩存: { test: /\.js$/, exclude: /node_modules/, use: [{ loader: 'babel-loader', options: { cacheDirectory: true } }], },
cache-loader
在一些性能開銷較大的 loader 之前添加此 loader ,以將結(jié)果緩存到磁盤里。 安裝yarn add -D cache-loader
使用cache-loader 的配置很簡單,放在其他 loader 之前即可。修改Webpack 的配置如下:
// webpack.config.js module.exports = { module: { rules: [ { test: /\.ext$/, use: [ 'cache-loader', ...loaders ], include: path.resolve('src') } ] } }
?請注意,保存和讀取這些緩存文件會有一些時間開銷,所以請只對性能開銷較大的 loader 使用此 loader 。 ? hard-source-webpack-plugin
HardSourceWebpackPlugin 為模塊提供了中間緩存,緩存默認(rèn)的存放路徑是: node_modules/.cache/hard-source 。
配置 hard-source-webpack-plugin 后,首次構(gòu)建時間并不會有太大的變化,但是從第二次開始,構(gòu)建時間大約可以減少 80% 左右。 安裝yarn add -D hard-source-webpack-plugin
使用// webpack.config.js var HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
module.exports = { entry: // ... output: // ... plugins: [ new HardSourceWebpackPlugin() ] }
?webpack5 中會內(nèi)置hard-source-webpack-plugin 。 ? 縮小構(gòu)建目標(biāo)/減少文件搜索范圍 ??有時候我們的項目中會用到很多模塊,但有些模塊其實(shí)是不需要被解析的。這時我們就可以通過縮小構(gòu)建目標(biāo)或者減少文件搜索范圍的方式來對構(gòu)建做適當(dāng)?shù)膬?yōu)化。 縮小構(gòu)建目標(biāo)主要是exclude 與 include 的使用: // webpack.config.js const path = require('path'); module.exports = { ... module: { rules: [ { test: /\.js$/, exclude: /node_modules/, // include: path.resolve('src'), use: ['babel-loader'] } ] }
這里babel-loader 就會排除對node_modules 下對應(yīng) js 的解析,提升構(gòu)建速度。 減少文件搜索范圍這個主要是resolve 相關(guān)的配置,用來設(shè)置模塊如何被解析。通過resolve 的配置,可以幫助Webpack 快速查找依賴,也可以替換對應(yīng)的依賴。 resolve.modules :告訴 webpack 解析模塊時應(yīng)該搜索的目錄resolve.mainFields :當(dāng)從 npm 包中導(dǎo)入模塊時(例如,import * as React from 'react' ),此選項將決定在 package.json 中使用哪個字段導(dǎo)入模塊。根據(jù) webpack 配置中指定的 target 不同,默認(rèn)值也會有所不同resolve.mainFiles :解析目錄時要使用的文件名,默認(rèn)是index resolve.extensions :文件擴(kuò)展名
// webpack.config.js const path = require('path'); module.exports = { ... resolve: { alias: { react: path.resolve(__dirname, './node_modules/react/umd/react.production.min.js') }, //直接指定react搜索模塊,不設(shè)置默認(rèn)會一層層的搜尋 modules: [path.resolve(__dirname, 'node_modules')], //限定模塊路徑 extensions: ['.js'], //限定文件擴(kuò)展名 mainFields: ['main'] //限定模塊入口文件名
動態(tài) Polyfill 服務(wù) ??介紹動態(tài)Polyfill 前,我們先來看下什么是babel-polyfill 。 什么是 babel-polyfill?babel 只負(fù)責(zé)語法轉(zhuǎn)換,比如將ES6 的語法轉(zhuǎn)換成ES5 。但如果有些對象、方法,瀏覽器本身不支持,比如:
- 全局靜態(tài)函數(shù):
Array.from 、Object.assign 等。 - 實(shí)例方法:比如
Array.prototype.includes 等。
此時,需要引入babel-polyfill 來模擬實(shí)現(xiàn)這些對象、方法。 這種一般也稱為墊片 。 怎么使用babel-polyfill ?使用也非常簡單,在webpack.config.js 文件作如下配置就可以了: module.exports = { entry: ['@babel/polyfill', './app/js'], };
為什么還要用動態(tài)Polyfill ?babel-polyfill 由于是一次性全部導(dǎo)入整個polyfill ,所以用起來很方便,但與此同時也帶來了一個大問題:文件很大,所以后續(xù)的方案都是針對這個問題做的優(yōu)化。
來看下打包后babel-polyfill 的占比: 占比 29.6%,有點(diǎn)太大了! 介于上述原因,動態(tài)Polyfill 服務(wù)誕生了。通過一張圖來了解下Polyfill Service 的原理: 每次打開頁面,瀏覽器都會向Polyfill Service 發(fā)送請求,Polyfill Service 識別 User Agent ,下發(fā)不同的 Polyfill ,做到按需加載Polyfill 的效果。 怎么使用動態(tài)Polyfill 服務(wù)?采用官方提供的服務(wù)地址即可: //訪問url,根據(jù)User Agent 直接返回瀏覽器所需的 polyfills https://polyfill.io/v3/polyfill.min.js
Scope Hoisting ??
什么是Scope Hoisting ?Scope hoisting 直譯過來就是「作用域提升」。熟悉 JavaScript 都應(yīng)該知道「函數(shù)提升」和「變量提升」,JavaScript 會把函數(shù)和變量聲明提升到當(dāng)前作用域的頂部?!缸饔糜蛱嵘挂差愃朴诖?,webpack 會把引入的 js 文件“提升到”它的引入者頂部。
Scope Hoisting 可以讓 Webpack 打包出來的代碼文件更小、運(yùn)行的更快。
啟用Scope Hoisting 要在 Webpack 中使用 Scope Hoisting 非常簡單,因?yàn)檫@是 Webpack 內(nèi)置的功能,只需要配置一個插件,相關(guān)代碼如下: // webpack.config.js const webpack = require('webpack')
module.exports = mode => { if (mode === 'production') { return {} }
return { devtool: 'source-map', plugins: [new webpack.optimize.ModuleConcatenationPlugin()], } }
啟用Scope Hoisting 后的對比讓我們先來看看在沒有 Scope Hoisting 之前 Webpack 的打包方式。 假如現(xiàn)在有兩個文件分別是 export default 'Hello,Jack-cool';
import str from './constant.js'; console.log(str);
以上源碼用 Webpack 打包后的部分代碼如下: [ (function (module, __webpack_exports__, __webpack_require__) { var __WEBPACK_IMPORTED_MODULE_0__constant_js__ = __webpack_require__(1); console.log(__WEBPACK_IMPORTED_MODULE_0__constant_js__['a']); }), (function (module, __webpack_exports__, __webpack_require__) { __webpack_exports__['a'] = ('Hello,Jack-cool'); }) ]
在開啟 Scope Hoisting 后,同樣的源碼輸出的部分代碼如下: [ (function (module, __webpack_exports__, __webpack_require__) { var constant = ('Hello,Jack-cool'); console.log(constant); }) ]
從中可以看出開啟 Scope Hoisting 后,函數(shù)申明由兩個變成了一個,constant.js 中定義的內(nèi)容被直接注入到了 main.js 對應(yīng)的模塊中。這樣做的好處是: - 代碼體積更小,因?yàn)楹瘮?shù)申明語句會產(chǎn)生大量代碼;
- 代碼在運(yùn)行時因?yàn)閯?chuàng)建的函數(shù)作用域更少了,內(nèi)存開銷也隨之變小。
Scope Hoisting 的實(shí)現(xiàn)原理其實(shí)很簡單:分析出模塊之間的依賴關(guān)系,盡可能的把打散的模塊合并到一個函數(shù)中去,但前提是不能造成代碼冗余。因此只有那些被引用了一次的模塊才能被合并。
?由于 Scope Hoisting 需要分析出模塊之間的依賴關(guān)系,因此源碼必須采用 ES6 模塊化語句,不然它將無法生效。 ? 參考極客時間 【玩轉(zhuǎn) webpack】 - EOF -
|