从零搭建一个 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。