從零搭建一個 webpack 腳手架工具(三)
- 2019 年 12 月 26 日
- 筆記
webpack 優化
配置了那麼多,優化處理卻很少,特別是導出的文件只有一個,這樣會讓文件非常大,這時候就需要切片處理以及分離文件。
分離樣式文件
在面前的配置中,css 樣式是通過附加 style 標籤的方式引入樣式的。在生產環境下我們希望將樣式存於 CSS 文件中,文件更有利於客戶端進行快取。 這時就可以使用 webpack 提供的一個插件 —— mini-css-extract-plugin
,使用這個插件需要先進行下載:yarn add mini-css-extract-plugin -D
。
// 來到 webpack 配置文件的 module.rules 配置項,修改 css-loader 內容: module: { rules: { test: /.(sa|sc|c)ss$/, use: [{ // 這裡就不再使用 style-loader 了 // 而是使用這個插件 loader: MiniCssExtractPlugin.loader, options: {}, }, "css-loader", "postcss-loader", -loader", "sass-loader" ] } }, plugins: [ new MiniCssExtractPlugin({ filename: 'static/css/[name].css', chunkFilename: 'static/css/[id].css' }) ]
程式碼分片
在 js 文件中,常常會引入第三方模組,比如 React、Vue 等。我們只設置了一個出口文件,這導致一個 js 文件會很大。我們可以使用插件給第三方的模組和業務中不常更新的模組創建一個入口。這裡就要再添加一個配置項 —— optimization.SplitChunks
。webpack 會根據你選擇的 mode 來執行不同的優化,不過所有的優化還是可以手動配置和重寫。優化配置大部分都在 optimization
這個配置項中。 默認情況下,optimization 的配置是這樣的:
module.exports = { //... optimization: { // 程式碼分片 // 用於多頁應用 splitChunks: { chunks: 'async', minSize: 30000, maxSize: 0, minChunks: 1, maxAsyncRequests: 5, maxInitialRequests: 3, automaticNameDelimiter: '~', name: true, // 快取組 (快取某些程式碼) cacheGroups: { // 公共模組 // 規定什麼樣的程式碼被抽離 common: { // 從入口處提取程式碼 chunks: 'initial' // 大於 0 個位元組就需要抽離 minSize: 0, // 公共程式碼被引用了多少次才被抽離 minChunks: 3, }, // 第三方模組分片設置 vendor: { // 只要是在 /node_modules/ 目錄下引入的模組就會被抽離出來成為一個單獨的文件 test: /node_modules/, minSize: 0, minChunks: 2, // 權重,權重越高,越先被抽離(這樣可以做到 vendor 與 common 的分離) priority: 10 }, default: { minChunks: 2, priority: -20, reuseExistingChunk: true } } } } };
在上面默認項中,有一個 chunks
屬性,他有三個選項:async(默認)、initial、all。async 只提取非同步 chunk,initial 則只針對入口 chunk 生效,而 all 表示兩種模式都開啟。minChunks
表示該模組被 n 個入口同時引用才會進行提取,比如在寫 React 程式時,React 模組會被經常引入,這時候就有必要進行提取一些,當然也可以設置成 Infinity
表示所有模組都不會被提取;name
欄位默認是 true,表示 SplitChunks
可以根據 cacheGroups
和作用範圍自動為新生成的 chunk
命名,並以 automaticNameDelimiter
的值的形式進行分隔。如:vendors~a~b~c.js 的意思就是 cacheGroups
為 vendors
並且該 chunk 是由 a、b、c 三個入口 chunk 所產生的。
cacheGroupts
可以理解為分離 chunks 時的規則。默認情況下有兩種規則 —— vendors 和 default。vendors 用於提取所有 node_modules 中符合條件的模組,default 則作用於被多次引用的模組。下面程式碼更改了 chunks 屬性值,將它設置成 all,這意味著即使在非同步和非非同步塊之間也可以共享塊。
module.exports = { // ... optimization: { // 程式碼分割 splitChunks: { chunks: 'all', minSize: 30000, maxSize: 0, minChunks: 1, maxAsyncRequests: 5, maxInitialRequests: 3, name: true, cacheGroups: { vendor: { // 第三方模組 打包出 vendors.js 文件 name: 'vendors', test: /node_modules/, priority: 2 }, default: { // 自己的模組 導出成 commons.js 文件名 name: 'commons', minChunks: 2, priority: -20, reuseExistingChunk: true } } } }, }
在 output 配置項中添加 chunkFilename
欄位,就會導出非同步載入的模組,實現程式程式碼與工具模組的分離。
{ output: { chunkFilename: "[name].js" } }
在開發階段,如果給 html-webpack-plugin 定義了 chunk ,改變 splitChunks 屬性後,別忘了添加 chunks:
chunks: ["vendors","commons","index"]
。(先後順序也很重要!) 對於 html-webpack-plugin 中的 chunks 不用指定,它會自動按順序添加<script>
標籤(這時就是引入多個script
標籤了)。
webpack-merge
使用 webpack-merge
插件可以讓不同環境的 webpack 配置分別寫在不同的文件上。在配置 webpack 時可以將開發環境和生產環節相同的配置項提取出來,寫在一個單獨的文件中,這樣做可以更好的管理配置。
// 提取出 webpack-base文件 module.exports = { // .... } // webpack-config-dev.js 文件 const merge = require('webpack-merge'); const webpackBase = require('./webpack-config-base'); // 合併: module.exports = merge(webpackBase,{ // 開發環境配置 // 如果配置相同項時 // base 中的配置項會被覆蓋 }); // webpack-config-prod.js 文件 const merge = require('webpack-merge'); const webpackBase = require('./webpack-config-base'); // 合併: module.exports = merge(webpackBase,{ // 開發環境配置 // 如果配置相同項時 // base 中的配置項會被覆蓋 });
生產環境配置
在生產環境主要是讓程式碼壓縮,而 webpack 打包壓縮後的程式碼基本不具有可讀性,如果此時程式碼拋出錯誤是很難找到原因的。因此在生產環境還應該有線上問題追查的方法,這個方法在 webpack 中可以配置生成程式碼對應的 source map
。
module.exports = { devtool: 'source-map', }
devtool
還有幾個配置值:eval-source-map
,這個表示不會產生單獨的文件(集成在打包後的文件中),但是可以顯示行和列(程式碼有異常時);cheap-module-source-map
不會產生列,但是會產生一個 source-map;cheap-module-eval-source-map
配置不會生成 source-map 文件,集成在打包後的文件中,不會產生列。
如果想讓 css 或 sass 也生成 map,需要在 loader 的 options 中指定:
{ module:{ rules: [ { test: /.css$/, use: [ "style-loader", options: { // 將該屬性值設為 true sourceMap: true }, "css-loader", "postcss-loader" ] } ] } }
壓縮程式碼
當指定了 mode: production
後 ,webpack 會自動壓縮。當然也可以自己來指定:
module.exports = { optimization: { // 指定該項後,程式碼會被壓縮 minimiza: true, } }
除了這個還可以使用插件進行壓縮,terser-webpack-plugin
,使用時需要先下載。 該插件提供了幾個配置項:
下面是一個配置的例子:
module.exports = webpackMerge(webpackBase,{ mode: 'production', devtool: 'source-map', optimization: { minimizer: [ new TerserPlugin({ parallel: true, sourceMap: true, exclude: //excludes/, }), ] } });
壓縮 CSS
壓縮 CSS 的前提是將 css 提取出來,比如使用 mini-css-extract-plugin 插件進行提取。而壓縮 CSS 需要使用別的插件 —— optimize-css-assets-webpack-plugin
。也需要先下載。配置如下:
module.exports = { optimization: { minimizer: [ new OptimizeCssAssetsPlugin({ // 壓縮處理器,默認為 cssnano cssProcessor:require('cssnano'), // 壓縮處理器的配置 cssProcessorOptions: { discardComments: { removeAll: true }, // 是否展示 log canPrint: true } }), ] } }
需要注意的是,使用 OptimizeCssAssetsPlugin 插件壓縮 CSS 文件後,JS 文件壓縮就會失效。這時候就需要使用 JS 壓縮插件:UglifyJsPlugin
。
下載:yarn add uglify-js-plugin -D
。
在 optimization
的 minimizer
配置項中配置:
{ minimizer: [ new UglifyJsPlugin({ // 是否需要快取(是) cache: true, // 是否是並發打包(是) parallel: true, // 是否生成源碼映射(是) sourceMap: true }), new OptimizeCssAssetsPlugin({ // ... }) ] }
noParse
當引入一個模組時,webpack 會檢測這個模組有沒有依賴別的模組,如果沒有依賴別的模組,可以使用 noParse 配置項讓 webpack 不再做多餘的解析。noParse: /jquery/
表示不再解析 jquery 模組。
ignorePlugin
webpack 的一個內置插件。該插件可以忽略掉指定的文件。
比如當在項目中使用 moment 插件時,moment 插件中會引入別的模組,比如:locale 目錄下所有的模組,這些模組都是語言模組(包含了許多語言來格式化本地時間),但有許多是用不到的。因此可以使用 ignorePlugin 插件忽略掉 locale 模組:
{ plugin: [ // 從 moment 中引入了 locale 時,就會把 locale 忽略掉 new webpack.IgnorePlugin(/./locale/,/moment/), ] }
這樣所有的語言包都會被忽略掉。但我們需要時就需要手動引入:
import 'moment/locale/zh-cn';
dllPlugin
當使用 React 庫時,需要引入 React-dom,這兩個庫文件很大,每次打包會浪費很長時間。而且沒必要這麼一次次的重複打包(第三方庫又不會被修改)。因此,我們希望把 React、React-dom 兩個庫單獨打包出來,以後再用,直接引用打包好的文件即可。這樣可以的減少打包時間和打包的文件大小。
使用 dllPlugin 可以做到。首先,需要再創建一個專門打包 react、react-dom 的 webpack 配置文件。並寫入以下內容:
// webpack.config.react.js const webpack = require('webpack'); const path = require('path'); module.exports = { mode: 'development', entry: { react: ['react','react-dom'], }, output: { filename: '_dll_[name].js', path: path.resolve(__dirnamem,'dist'), // 將打包好的模組給個名字(導出的變數的名字) library: '_dll_[name]', }, plugins: [ new webpack.DllPlugin({ // name 屬性值規定與 library 屬性值相同 name: '_dll-[name]', // path 表示產生一個清單,這個清單可以找到打包的文件 path: path.resolve(__dirname,'dist','manifest.json'), }), ] }
定義完之後,在 HTML 中引入打包好的文件:
<script src="/_dll_react.js"></script>
運行 webpack.config.react.js 文件進行打包:
npx webpack --config webpack.config.react.js
打包後,還需要來到之前 webpack.config.dev.js 配置文件中,讓 webpack 知道,在打包之前應該先找到打包好的 _dll_react.js
文件,這樣就避免了再次打包 react、react-dom 文件。這時就需要對該文件進行配置:
// webpack.config.dev.js { plugins: [ // 引用動態鏈接庫 new webpack.DllReferencePlugin({ manifest: path.resolve(__dirname,'dist','manifest.json') }), ] }
再次打包時,打包的文件就變小了。使用 dllPlugin 可以優化打包效率。
happypack 多執行緒打包
需要下載和引用該模組:
npm install happypack
// webpack.config.dev.js import Happypack = require('happypack'); module.exports = { // ... rules: [ { test: /.js$/, exclude: /node_modules/, include: path.resolve('src'), // 將 babel-loader 替換成 happypack-loader // ? 表示傳參,id=js 表示開啟多執行緒打包 js 文件 use: 'Happypack/loader?id=js' } ], plugins: [ new Happypack({ // id 就是上面的 ?id=js 中的參數 id: 'js', // 將 rules 中的 babel-loader 配置剪切到 Happypack 中 use: [ { loader: 'babel-loader', options: { presets: [ '@babel/preset-env', '@babel/preset-react' ] } } ] }) ] }
同樣的,也可以改寫 css loader,讓 css 打包時也能使用多執行緒(多添加一個 new Happypack 實例)。
{ rules: [ { test: /.css$/, use: 'Happypack/loader?id=css', } ], plugins: [ new Happypack({ id: 'css', use: [ 'style-loader', 'css-loader' ] }), ] }
tree-shaking
import 語法在生產環境下會自動去除掉沒用的程式碼。這種方式稱為 tree-shaking
。如果使用 require 語法是做不到的(不支援)。
需要注意的是,如果使用 es6 的 export default 形式進行導出,會把導出的模組放在 default 屬性上。如果要使用 require 方式引入 es6 的導出模組,應該使用下面的方式:
// 01.js function calc(a,b){ return a + b; } export default{ calc, } // 02.js // 使用 common.js 方式引入模組 let calc = require('./01.js'); console.log(calc.default.calc(1,2)); // 3
webpack 還會自動簡化程式碼。這種技術稱為 scope hosting
。
懶載入
有時候需要這樣一個功能,當點擊按鈕時,會動態載入一個資源。
// source.js export default "hello!"; // index.js let btn = document.querySelector(".btn"); btn.onclick = function(){ // 點擊按鈕後,動態導入資源(相當於 JSONP) import('/source.js').then(data => { alert(data); // hello! }); }
上面的 import('/source.js').then()
的語法是不支援的,這是需要下載一個 babel 插件:@babel/plugin-syntax-dynamic-import
。下載好後在 babel 配置中添加該插件即可實現懶載入。
{ rules: [ { test: /.js$/, use: { loader: 'babel-loader', options: { presets: [ '@babel/preset-env' ], plugins: [ '@babel/plugin-syntax-dynamic-import' ] } } } ] }
改造 create-react-app
最後,說一下如何將 create-react-app 從單頁應用改造成一個多頁應用。通過運行 npm run eject 讓配置文件暴露出來,然後修改配置文件的一些內容,使之成為多頁應用腳手架。
指演示一個有四個頁面的多頁應用配製方案(當然,其他多個頁面配製都是一樣的)。 首先需要運行 npx create-react-app project-name
生成一個框架。然後運行 npm run eject
讓 create-react-app
中的配置文件暴露出來。
暴露出配置文件後,來到 paths.js
文件中,再增加三個 html 模板路徑和 js 入口文件路徑。
{ appHtml: resolveApp('public/index.html'), appSecondHtml: resolveApp('public/01.html'), appThirdHtml: resolveApp('public/02.html'), appFourthHtml: resolveApp('public/03.html'), appIndexJs: resolveModule(resolveApp, 'src/index/index'), appSecondJs: resolveModule(resolveApp, 'src/query/01'), appThirdJs: resolveModule(resolveApp, 'src/ticket/02'), appFourthJs: resolveModule(resolveApp, 'src/order/03'), }
配置完後,就需要在這些路徑下創建對應的文件。
然後來到 webpack.config.js 文件中做修改。首先是修改 output 中的導出文件,讓導出多個文件(其他的沒改):
{ output: { filename: isEnvProduction ? 'static/js/[name].[contenthash:8].js' : isEnvDevelopment && 'static/js/[name].bundle.js', // TODO: remove this when upgrading to webpack 5 futureEmitAssets: true, // There are also additional JS chunk files if you use code splitting. chunkFilename: isEnvProduction ? 'static/js/[name].[contenthash:8].chunk.js' : isEnvDevelopment && 'static/js/[name].chunk.js', } }
增加 entry 的入口數量:
{ entry: { ticket: [ paths.appIndexJs, isEnvDevelopment && require.resolve('react-dev-utils/webpackHotDevClient') ].filter(Boolean), order: [ paths.appSecondJs, isEnvDevelopment && require.resolve('react-dev-utils/webpackHotDevClient') ].filter(Boolean), query: [ paths.appThirdJs, isEnvDevelopment && require.resolve('react-dev-utils/webpackHotDevClient') ].filter(Boolean), index: [ paths.appFourthJs, isEnvDevelopment && require.resolve('react-dev-utils/webpackHotDevClient') ].filter(Boolean), } }
最後修改 html-webpack-plugin 中的內容(四個頁面,生成四個實例),更換了 template、chunks 和 filename 的值。
[ new HtmlWebpackPlugin( Object.assign({}, { inject: true, // 模板就是 paths.js 中添加的 template: paths.appHtml, fileName: 'index.html', chunks: ['index'] }, isEnvProduction ? { minify: { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true, }, } : undefined ) ), new HtmlWebpackPlugin( Object.assign({}, { inject: true, template: paths.appSecondHtml, fileName: '02.html', chunks: ['second'] }, isEnvProduction ? { minify: { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true, }, } : undefined ) ), new HtmlWebpackPlugin( Object.assign({}, { inject: true, template: paths.appThirdHtml, fileName: '03.html', chunks: ['third'] }, isEnvProduction ? { minify: { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true, }, } : undefined ) ), new HtmlWebpackPlugin( Object.assign({}, { inject: true, template: paths.appFourthHtml, fileName: '04.html', chunks: ['fourth'] }, isEnvProduction ? { minify: { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true, }, } : undefined ) ), ]
完成以上配置後,就完成了多頁應用的配置。當然,也可以使用 react-app-rewired
模組對 webpack 配置做修改,該模組的好處是,你不需要將配置文件暴露出來就進行修改。這裡就不做介紹了。
關於 webpack 的所有配置內容就說到這裡。下一次將詳細介紹一下 webpack loader 的工作原理,以及如何寫一個自己的 webapck loader。