以下是本文的思维导图:

环境: webpack v4.46.0,Nodejs v16.6.2

安装

新建项目文件夹并安装 webpack 和 webpack-cli:

mkdir project
cd project
npm init -y
npm install webpack@4.46.0 webpack-cli@3.3.2 --save-dev

在项目根目录下新建 webpack.config.js,作为 webpack 的默认配置文件。

核心概念

module、chunk 和 bundle

用一张图来方便理解:

简单地说,module 是任何通过 import 或者 require 导入的代码,包括 js、css、图片资源等;多个 module 可以组成一个 chunk,每个 chunk 经过处理后会输出一个 bundle 文件

entry 和 output

1)单入口单出口

以 src 目录下的 index.js 作为入口文件,打包输出文件 bundle.js 到 dist 目录下

const path = require('path')
module.exports = {
    entry: './src/index.js',
    output: {
        path:path.resolve(__dirname,'dist'),
        filename: 'bundle.js'
    }
}

这种配置一般用于单页应用,最终只会产生一个 chunk。

2)多入口单出口

对应的 entry 改用数组:

const path = require('path')
module.exports = {
    entry: ['./src/index1.js','./src/index2.js'],
    output: {
        path:path.resolve(__dirname,'dist'),
        filename: 'bundle.js'
    }
}

这种配置一般用于多页应用,但最终也只会产生一个 chunk。

3)多入口多出口

对应的 enrty 改用对象,key 表示打包后输出文件的名字,output.filename 中用占位符表示这个名字:

const path = require('path')
module.exports = {
    entry: {
        bundle1: './src/index1.js',
        bundle2: './src/index2.js'
    },
    output: {
        path:path.resolve(__dirname,'dist'),
        filename: '[name].js'
    }
}

这种配置一般用于多页应用,且最终会产生多个 chunk。

mode

指定 webpack 进行打包构建的环境是开发环境还是生产环境 —— 根据环境的不同,webpack 会默认开启不同的优化选项。

loader

loader 相当于是一个转换器。webpack 默认只能解析 js / json 文件,对于其他类型的文件需要借助 loader 进行转换,之后才能解析。

plugin

webpack 执行打包构建的生命周期中会触发很多事件,plugin 监听某些事件并执行那些 loader 做不了的特定任务。

易混淆的配置项

filename 和 chunkFilename

filename 很好理解,就是 entry 入口文件对应输出文件的名字,而 chunkFilename 指的是没有被列入 entry 中,但在某些情况下又不得不单独打包输出的文件的名字。

典型的例子就是动态加载模块,比如说入口文件 index.js 如下:

// 注意这里使用了魔法注释指定了 chunk 名
const res = await import('/* webpackChunkName: "test"*/ ./test.js')

webpack 的配置如下:

module.exports = {
    entry: {
        index: path.resolve(__dirname,'./src/index.js')
    },
    output: {
    	path: path.resolve(__dirname,'./dist'),
        filename: '[name].js',
        chunkFilename: '[name].chunk.js'    
	}
}

打包后,entry 入口文件会对应产生一个叫做 index 的 chunk,同时输出一个叫做 index.js 的 bundle 文件。而 test.js 是动态加载的,它会被单独打包到另一个叫做 test (通过魔法注释指定)的 chunk 中,同时输出一个叫做 test.chunk.js 的文件。

path 和 publicPath

path 很好理解,就是 entry 入口文件对应输出文件的路径,而 publicPath 指的是引用静态资源时的固定前缀。项目上线后通常可以配置 publicPath 为一个 cdn 地址,这样就可以引用部署到 cdn 上的静态资源了。

hash、contenthash 和 chunkhash

配置 bundle 文件名的时候,通常可以使用 xxx.[hash].js 这样的命名形式,这里的占位符可以使用 hash、chunkhash 以及 contenthash。

这三个 hash 通常和 CDN 缓存有关,代码改变触发文件 hash 改变,hash 改变导致资源引用的 URL 改变,从而触发 CDN 回源从服务器重新拉取最新的资源。

以多页面应用为例,假设有 A、B 两个页面。

  • hash:是项目编译层面的 hash,全局一致,任何文件的修改都会导致它发生改变。这意味着 A 页面文件的改变会导致整体 hash 改变,从而影响采用了 hash 命名的 B 页面文件,这样是无法实现静态资源缓存的
  • chunkhash:是 chunk 层面的 hash,每个入口页面对应一个 chunk,其产生的相关 bundle 共用同一个 chunkhash。修改 A 页面文件只会影响 chunk A,不会影响 chunk B
  • contenthash:是单文件层面的 hash,粒度要更精细。假设 A 页面中的 js 引用了 css,那么 js 文件改变所导致的 chunkhash 改变也会作用到 css 文件上,因此这时候的做法是利用 plugin 抽离出 css,并采用 contenthash 命名,标志每一个单独的文件

PS:另外需要注意的是,chunkhash/contenthash 和 HMR 热更新不能一起使用。因为热更新针对的是开发环境,chunkhash 以及 contenthash 针对的是生产环境(涉及到 CDN 缓存)。

资源解析

解析 ES6 语法

安装 babel 相关依赖(preset-env 对应的是 ES6 的 preset):

cd project
npm install babel-loader @babel/core @babel/preset-env --save-dev

项目根目录下增加 .babelrc 文件:

{
    "presets":[
        "@babel/preset-env"
    ]
}

增加 module.rules 项,配置 loader:

module.exports = {
    module:{
        rules:[
            {test: '/\.js$/', use: 'babel-loader'}
        ]
    }
}

解析 CSS 和 LESS/SASS/Stylus

以加载和解析 less 文件为例。less-loader 将 less 文件解析为 css 文件,css-loader 解析 css 文件,style-loader 将 css 文件中的样式注入到 style 标签中。

安装:

// 注意 less 属于 less-loader 的 peerDependencies,npm 会自动安装
npm i less-loader css-loader style-loader -D

配置:

module.exports = {
    module: {
        rules:[
            {
                test: /\.less$/ ,
                // 注意要按照从右到左依赖的顺序编写
                use: ['style-loader','css-loader','less-loader']
            }
        ]
    }
}

解析图片和字体

解析图片或者字体需要用到 file-loader:

module.exports = {
    module:{
        rules:[
            {
                test: /\.(png|jpg|gif|svg)$/,
                use: 'file-loader'
            }
        ]
    }
}

url-loader 是对 file-loader 的封装,可以提供 limit 参数:当图片体积小于 limit 参数的时候,使用 url-loader 进行处理,会用 base64 对图片进行编码,通过 dataUrl 引用图片;否则使用 file-loader 进行处理。注意这里一定要设置 esModule: false,否则图片和字体默认会被视为 ES 模块,无法在页面中正常引用。

module.exports = {
    module:{
        rules:[
            {
                test: /\.(png|svg|jpg)$/,
                use: [{
                    loader: 'url-loader',
                    options: {
                        limit: 4000,
                        esModule: false
                    }
                }]
            }
        ]
    }
}

资源内联

资源内联可以提高项目文件的可维护程度,并减少请求数量。以多页面应用为例,假如每个页面都有公用的 meta 信息,不可能每个 .html 文件都去写一遍,这时候就可以把 meta 信息集中到一个 meta.html 中,在需要用到的页面内联进去即可。

源文件内联:HTML 和 JS

内联 HTML 和 JS 可以使用 raw-loader@0.5.1。

inline.html 文件:

<span>我是内联 HTML 文件</span>

inline.js 文件:

const message = '我是内联 JS 文件'

模板 HTML 文件:

<!-- html-webpack-plugin 支持解析 ejs 语法 -->

<div>我是模板 HTML 文件</div>
<%= require('raw-loader!./inline.html')%>
<script>
	<%= require('raw-loader!babel-loader!./inline.js')%>
</script>

构建后生成的 index.html 文件:

<div>我是模板 HTML 文件</div>
<span>我是内联 HTML 文件</span>
<script>
	const message = '我是内联 JS 文件'
</script>

PS:这里之所以要使用 0.5.1 版本的 raw-loader,是因为这个版本的 raw-loader 默认采用 CJS 的模块导出方案,所以可以使用 require 直接导入;之后的版本则默认采用 ES Module 导出方案,如果想使用高版本,有两种方法:

  • 通过 <%= require('...').default %> require 一个 ES6 模块
  • 修改 raw-loader 源代码,默认导出方式改为采用 CJS

源文件内联:CSS

两种方案:

  • 方案一:直接使用上面提到的 style-loader,通过 JS 将样式动态注入到 style 中,这种方式下构建产物中不会直接出现样式代码;
  • 方案二:先使用 mini-css-extract-plugin 抽离出 css 文件到构建产物中,并且在 html 文件中通过 link 引用该 css,再使用 html-inline-css-webpack-plugin 将对于 css 文件的 link 引用转化为内联形式。下面介绍第二种方案。

安装:

npm i mini-css-extract-plugin html-inline-css-webpack-plugin -D

配置:

const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HTMLInlineCSSWebpackPlugin = require('html-inline-css-webpack-plugin').default

module.exports = {
    plugins: [
        // 先导出成 css 文件,通过 link 引用
        new MiniCssExtractPlugin()
        // 再将 link 引用转化为内联形式
        new HTMLInlineCSSWebpackPlugin()
    ]
    module:{
      rules: [
          {
              test: /\.css$/,
              loader: [
                  // 这里必须开启 plugin 的 loader
                  MiniCssExtractPlugin.loader,
    			  'css-loader'	
              ]
          }
      ]  
    }
}

注意:

  • 需要同时配置 loader 和 plugin。另外, MiniCssExtractPlugin.loaderstyle-loader 功能上是冲突的,不能一起使用
  • require 的时候需要使用 require(..).default,因为 html-inline-css-webpack-plugin 导出方式是采用 ES Module。
  • 默认情况下,使用了 html-inline-css-webpack-plugin 之后,不会保留由 mini-css-extract-plugin 导出的 css 文件

构建产物内联:CSS 和 JS

前面讲的内联,都是内联 src 下的文件到 html 中,那么有没有办法可以将 bundle 中的 css 和 js 文件内联到 html 中呢?这就要使用 html-webpack-inline-source-plugin 了。

它是 html-webpack-plugin 的一个插件,所以两者都要安装。之后进行配置:

module.exports = {
    plugins: [
        new HtmlWebpackPlugin({
            // 正则匹配需要内联的文件
            inlineSource: /\.(js|css)$/
        }),
        // 注意这里必须传参
        new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin)
    ]
}

PS:注意必须指定安装 @1.0.0-beta.2 版本的 plugin,npm 默认安装的是旧版本,有 bug。

图片和字体内联

图片和字体的内联,其实就是使用前面提到过的 url-loader,注意需要设置 esModule: false

开发和构建体验

文件监听

文件监听也就是 watch mode。开启文件监听后,每次源代码发生更改,都会自动重新进行构建

module.exports = {
    watch: true,
    watchOptions: {
        ignored: /node_modules/,
        aggregateTimeout: 2000,
        poll: 1000
    }
}

文件监听的原理是轮询文件的“最后一次编辑时间”是否改变。ignored 指定忽略监听的文件或者文件夹;poll 表示每秒轮询多少次;此外,并不是文件一更改就马上重新构建,必须是在 aggregateTimeout 指定的时间内没有再次更改之后,才会重新构建,有点类似于做了一层防抖处理,避免频繁构建。

热重载

热重载也就是 live reload,可以在每次源代码发生更改时自动重新进行构建 + 自动刷新浏览器。这里需要使用 webpack-dev-server 实现热重载。

安装:

npm i webpack-dev-server -D

在 package.json 中配置指令:

{
    "scripts": {
        "dev": "webpack-dev-server"             // npm run dev 运行开启本地服务器
    }
}

配置 webpack-dev-server:

module.exports = {
    devServer: {
        open: true,                    // 构建完成后自动打开浏览器
        port: 8888,                    // 监听端口
        contentBase: './dist'          // 服务器根路径
    }
}

热更新

热重载也有缺点,就是每次都会全局刷新浏览器,所有的状态都会重置。所以在热重载的基础上引入了热更新 —— 也就是 HMR(模块热替换),它既可以实现局部视图刷新,也可以保存数据状态。这里需要使用 webpack 内置的 HotModuleReplacementPlugin 实现热更新。

const webpack = require('webpack')
module.exports = {
    devServer: {
        hot: true                 // 开启热更新
    },
    plugins:[
        new webpack.HotModuleReplacementPlugin()
    ]
}

开启 sourcemap

在生产环境下(mode: production),打包后的文件都是经过压缩的,代码出错后不容易调试。而开启 sourcemap 之后,可以直接定位到出错的源代码,调试就很方便了。

module.exports = {
    mode: 'none',
    devtool: 'sourcemap'
}

代理跨域请求

本地开发的时候我们会起一个 http://localhost:8080 的服务器,这时候请求后端接口 http://mysite/api/getData会报跨域错误。此时可以通过 webpack-dev-server 配置一层与本地服务器同源的代理服务器,它会接受请求,再将请求转发给真正的后端服务器(同源仅作用于浏览器和服务器之间,所以这个转发是没问题的)。

举个例子,这是我们发起的请求:

axios.get('/api/getData/')
	.then(res => console.log(res))

这是 webpack-dev-server 的跨域配置:

module.exports = {
    devServer: {
        proxy: {
            '/api': {
                target: 'http://mysite'
            }
        }
    }
}

我们发起请求的 url /api/getData/ 会自动加上同源前缀,变成 http://localhost/api/getData,相当于现在是向同源的代理服务器发起请求;又由于 url 可以匹配 /api,所以这个请求会被进一步转发给真正的后端服务器,相当于发起 http://mysite/api/getData 这个请求。

自动引用构建产物

默认情况下,我们可能需要在 dist 目录下手动创建一个 html 文件去引用构建产物,这是比较麻烦的。通过 html-webpack-plugin,可以在 dist 目录下自动生成一个引用构建产物(资源)的 html 文件。

安装:

npm i html-webpack-plugin -D

配置:

const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
    plugins: [
        new HtmlWebpackPlugin({
            // 提供一个模板 html 文件作为基础
            tamplate: path.resolve(__dirname,'./src/index.html')
        })
    ]
}

优化构建日志的显示

构建日志中可能包含很多我们并不关心的信息,可以借助 plugin 优化一下。

npm i friendly-errors-webpack-plugin -D

配置:

const friendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
module.exports = {
	stats: 'errors-only'
    devServer: {
        stats: 'errors-only'
    }
	plugins: [
        new friendlyErrorsWebpackPlugin()
    ]
}

捕获构建错误

每次构建结束后会触发 compiler 对象的 done 钩子函数,可以在这个 hook 中捕获构建错误并进行相关处理:

module.exports = {
    plugins: [
        function() {
            this.hooks.done.tap('done', (stats) => {
                if (stats.compilation.errors &&
                    stats.compilation.errors.length && 
                    process.argv.indexOf('--watch') == -1)
                {
                    // 进行相关处理
                    process.exit(1);
                }
            })
        }
    ]
}

分离配置文件

开发应用的时候一般有两种环境,一个是方便开发调试的开发环境,一个是上线后给用户使用的生产环境。不同的环境,webpack 的配置也不同,比如生产环境需要配置代码压缩,开发环境需要配置热更新等。我们现在是共用一个 webpack.config.js 文件,所以需要解决的问题是:如何根据不同环境使用不同的 webpack 配置文件

方案一: cross-env + NODE_ENV

我们的基本策略是分离三个配置文件:

  • webpack.base.js:开发环境和生产环境共用的配置放在这里
  • webpack.dev.js:开发环境专用的配置放在这里
  • webpack.prod.js:生产环境专用的配置放在这里

node 有一个 process 对象,我们在 process.env 上挂载一个 NODE_ENV 环境变量,用来标记当前是什么环境。因为不同操作系统设置环境变量的方式不同,为了方便统一设置,这里使用 cross-env 这个库。接着,我们在所有文件中都可以通过 node.env.NODE_ENV 获取当前环境类型。

package.json 文件:

"scripts": {
    // 运行 build 肯定是构建打包,所以设置为生产环境,并使用指定文件作为配置文件
    "build": cross-env NODE_ENV = 'production' webpack --config webpack.prod.js
    // 运行 dev 肯定是构建调试,所以设置为开发环境,并使用指定文件作为配置文件
    "dev": cross-env NODE_ENV = 'development' webpack-dev-server webpack.dev.js
}

webpack.base.js 文件:

module.exports = {
    // entry、output 等共用配置,以及一些共用的 loader 和 plugin
    
    // 有的配置虽然是共用,但因为环境不同,需要使用不同值,比如 sourcemap 是否开启:
    devtool: process.env.NODE_ENV === 'production' ? 'none' : 'sourcemap'
}

webpack.prod.js 文件:

// merge 可以快速合并两个 webpack 配置
const merge = require('webpack-merge')
const base = require('./webpack.base.js')

module.exports = merge(base,{
    mode: 'production'
    // 生产环境专用的配置    
})

webpack.dev.js 文件:

const merge = require('webpack-merge')
const base = require('./webpack.base.js')

module.exports = merge(base,{
    mode: 'development'
    // 开发环境专用的配置       
})

方案二:配置文件导出函数

基本策略是仍然使用单个配置文件,但是导出的不再是对象,而是函数。运行构建命令的时候传入一个 mode 参数,这个参数被函数接受,从而判断当前环境。

"scripts": {
    // 这里配置的 mode 参数会传给配置文件中导出的函数
    "build": webpack --config webpack.config.js --mode production
    "dev":  webpack-dev-server webpack.config.js --mode development
}

webpack.config.js 文件:

module.exports = (mode) => {
    // 公共配置
    if(mode === 'production'){
        // 生产环境配置
    } else {
        // 开发环境配置
    }
}

项目开发

处理 CSS

postcss 本身提供了一个强大的插件系统,可以对 css 进行后处理。在 webpack 中,需要通过 postcss-loader 去使用 postcss。

1)自动补齐前缀

为了向下兼容旧浏览器,某些比较新的 css 属性必须加上浏览器厂商的前缀,可以通过 postcss-loader 和 autoprefixer 实现前缀的自动补齐。

安装:

npm i postcss-loader autoprefixer -D

配置:

module.exports = {
   module: {
       rules:[
           {
               test: /\.css$/,
               loader:[
                   'style-loader',
                   'css-loader',
                   {
                       loader: 'postcss-loader',
                       options: {
                           // 使用 postcss 的插件
                           plugins: [
                               require('autoprefixer')({
                                   browsers:['last 2 version','>1%','iOS 7']
                               })
                           ]
                       }
                   }
               ]
           }
       ]
   }   
}

2)单位转换

移动端的适配分为两步:

  • 根据屏幕分辨率动态设置根元素的字体大小。这里可以使用手淘的 lib-flexible 或者 viewport 单位来实现,这样,1rem 的大小就是动态的了
  • 根据设计稿的 px 进行开发,最后通过插件将 px 统一转化为当前开发使用的分辨率下对应的 rem。

这里进行单位转化的插件,就可以使用 px2rem-laoder、postcss-plugin-rem 等来完成。

代码规范

集成 eslint

1)单独使用

单独使用 eslint,首先需要安装 eslint 本体:

npm i eslint -D	

生成 eslint 配置文件 .eslintrc.json:

npx eslint --init

根据我们回答的问题,会预先配置好文件中的一些选项。如果想要使用其它公司团队的 eslint 规范,需要单独安装:

npm i eslint-config-airbnb -D

并修改配置文件中的 extends 选项:

{
    "env": {
        "browser": true,
        "es2021": true,
        "node": true
    },
    "extends": [
        // 这里修改
        "airbnb"
    ],
    "parserOptions": {
        "ecmaVersion": 12,
        "sourceType": "module"
    },
    "rules": {
        // 自定义 lint 规则
    }
}

运行下面命令对指定文件进行 lint 检测:

npx eslint ./src/index.js

2)集成到 webpack 中使用

在 webpack 中集成 eslint 有两种方式,一种是 eslint-loader,但它存在一些问题,不久将被弃用;webpack 5 开始更推崇使用 eslint-webpack-plugin。这里以后者为例。

首先安装:

npm i eslint-webpack-plugin -D

接着到项目根目录下新建 .eslintrc.json 文件,内容和上面差不多。如果想要使用某个公司团队的 eslint 规范,同样需要单独安装 npm 包。

然后配置 webpack.config.js:

const ESLintPlugin = require('eslint-webpack-plugin')
module.exports = {
    plugins: [
        new ESLintPlugin()
    ]
}

基本使用就是这样了,每次运行构建 eslint 都会检测代码。默认情况下 eslint 的报错信息采用的是 stylish 的展示风格,可能不太直观,可以使用特定的插件修改报错信息的展示风格。安装:

npm i eslint-formatter-friendly -D

配置:

const ESLintPlugin = require('eslint-webpack-plugin')
module.exports = {
    plugins: [
        new ESLintPlugin({
            formatter: require('eslint-formatter-friendly')
        })
    ]
}

集成 stylelint

在 webpack 中集成 stylelint 有三种方式:

  • 使用 stylint-loader(官方已弃用,不推荐)
  • 使用 postcss-loader + stylelint
  • 使用 stylelint-webpack-plugin

这里以第三种方式为例。首先安装:

npm i stylelint-webpack-plugin -D

在项目根目录下新建 .stylelintrc.json 文件:

{
    "rules": {
        "unit-no-unknown": true
    }
}

配置:

const StylelintPlugin = require('stylelint-webpack-plugin')
module.exports = {
    plugins: [
        new StylelintPlugin()
    ]
}

每次运行构建 stylelint 都会检测代码。

PS:以上两种 lintPlugin 的安装都不需要额外安装 eslint 或者 stylelint,因为 npm 从 v7 开始会自动安装 peerDependencies。

搭建 Vue 开发环境

vue-cli 集成了 vue 和 webpack,但还是有必要掌握如何用 webpack 搭建一个基础的 Vue 开发环境。

创建目录并安装相关依赖:

mkdir vue-webpack && cd $_
npm init -y

// 安装 webpack
npm i webpack webpack-cli -D

// 安装解析 html 的插件
npm html-webpack-plugin -D

// 安装 vue(默认安装 vue2,vue@next 可以安装 vue3)
npm i vue --save

// 安装 vue-loader 和 vue-temlplate-compiler
npm i vue-loader vue-temlplate-compiler -D

新建 src 文件夹,src/App.vue 是 SFC 单文件组件:

<template>
  <div id="app">
    Hello Vue
  </div>
</template>

<script>
	export default {}
</script>

<style scoped>
</style>

src/index.js 作为打包入口:

import Vue from 'vue'
import app from './App.vue'

new Vue({
    el: '#project',
    render: h=>h(app)
})

src/index.html 作为构建产物使用的模板 html:

// vue 挂载在这个 dom 上
<div id="project"></div>

配置 webpack:

const VueLoaderPlugin = require('vue-laoder/lib/plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
    entry: path.resolve(__dirname,'./src/index.js')
    output: {
    	path: path.resolve(__dirname,'./dist')
        filename: '[name].js'
	}
    module: {
        rules: [
            {
                test: /\.vue$/,
                use: 'vue-loader'
            }
        ]
    }
	plugins: [
        new VueLoaderPlugin()
        new HtmlWebpackPlugin({
        	template: './src/index.html'
        })
    ]
}

注意关于 vue 的几个依赖的作用:

  • vue-loader:用于提取 .vue 中的各个语言块
  • VueLoaderPlugin:将 webpack 声明的规则应用于对应的语言块,比如 css-loader、style-loader 会同时作用在 .vue 文件中的 <style></style> 语言块
  • vue-template-compiler:将 template 预编译成函数,避免运行时进行模板编译,从而加快应用运行速度

项目打包

多页面应用打包

对于多页面应用来说,每个页面都对应“一个 entry + 一个 HtmlWebpackPlugin 实例”。如果每次添加或者删除页面都需要重新配置那就太麻烦了,因此理想的方案是根据页面情况实现自动配置。

假设项目结构如下:

+-- src
|   +-- page1
|		+-- index.js
|		+-- index.css
|		+-- index.html
|   +-- page2
|		+-- index.js
|		+-- index.css
|		+-- index.html

page1 和 page2 目录下都有一个 index.js 表示页面入口,index.html 表示页面的模板 html。

首先安装 glob 方便读取文件路径:

npm i glob -D

通过一个 setMPA 函数处理多页面应用配置:

const glob = require('glob')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const setMPA = () => {
    const entry = {}
    const HtmlWebpackPlugins = []
    // 返回所有匹配的路径
    const entryPaths = glob.sync(path.join(__dirname,'./src/*/index.js'))
    entryPaths.forEach(path => {
        // 从路径中提取出页面名
        const entryName = path.match(/\/src\/(.*?)\/index\.js/)
        entry[entryName] = path
        HtmlWebpackPlugins.push(new HtmlWebpackPlugin({
            // 构建的 html 采用的模板
            template: `./src/${entryName}/index.html`,
            // 构建的 html 的名字
            filename: `${entryName}.html`,
            // 为构建的 html 注入哪些 chunk(MPA 一定要设置这个,否则会注入所有 chunk)
            chunks: [entryName]
        }))
    })
}

调用 setMPA 生成配置对象,注入到 webpack 的配置中:

const {entry,HtmlWebpackPlugins} = setMPA()
module.exports = {
    entry,
    plugins:[
        ...HtmlWebpackPlugins
    ]
}

基础组件库打包

以打包 + 发布一个 promise 为例。

mkdir chor-promise
cd chor-promise
npm init -y
npm i webpack webpack-cli -D

新建 ./src/index.js ,编写核心代码。接着新建 webpack.config.js 进行打包配置:

const path = require('path')
module.exports = {
    entry: {
        // 打包两份文件,分别是压缩版和未压缩版
        'chor-promise.min': path.resolve(__dirname,'./src/index.js'),
        'chor-promise': path.resolve(__dirname,'./src/index.js')
    },
	output: {
        path: path.resolve(__dirname,'./dist'),
        filename: '[name].js',
        // 使用该库时 import 的名字    
        library: 'myPromise',
        // 不设置的话需要通过 xxx.default 才能使用该库    
        librarayExport: 'default',
        // 可以以 AMD、CJS 或者 ESM 的方式使用该库    
    	librarayTarget: 'umd',
        // 定义全局对象,必须设置,否则会报 self 或者 window 未定义的错误
        globalObject: 'this'
    }
}

新建 ./index.js 作为该库的入口文件:

// 根据用户使用该库的时候是开发环境还是生产环境,决定导出压缩版还是未压缩版

if(process.env.NODE_ENV === 'production'){
    module.exports = require('./dist/chor-promise.min')
} else {
    module.exports = require('./dist/chor-promise')
}

打包:

npm run build

发布:

// 登录 npm 账号
npm login

// 将该库作为 npm 包发布
npm publish

使用:

首先正常安装

npm i chor-promise -D

在需要的文件中导入使用:

import myPromise from 'chor-promise'

myPromsie.resolve(123).then(r => console.log(r))

webpack 性能优化

如何进行性能分析

webpack 本身可以配置 stats 展示打包构建的信息,但这些信息颗粒度比较大,不利于进行性能分析。所以这里还是要借助插件来分析。

分析构建速度

性能分析其一,用 speed-measure-webpack-plugin 分析构建速度,它可以分析每个 loader 和 plugin 的耗时。安装后配置如下:

const speedMeasureWebpackPlugin = require('speed-measure-webpack-plugin')
const smp = new speedMeasureWebpackPlugin()

// 用 smp.wrap 去包裹 webpack 的配置对象
module.exports = smp.wrap({
    //.....
})

耗时长的 loader 或者 plugin 会显示为红色,这也是我们需要关注并优化的重心。

分析打包体积

性能分析其二,用 webpack-bundle-analyzer 分析打包体积,在浏览器的 8888 端口下可以看到每个文件的体积信息以及各个 chunk 的包含关系,方便我们进行分析。

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
module.exports = {
    plugins: [
        new BundleAnalyzerPlugin()
    ]
}

PS:安装这个 npm 包可能会报很恶心的 node gyp 错误,可以尝试切换回 npm 原来的镜像源(不要使用淘宝镜像源)

文件结构优化

文件结构优化指的是要合理地拆分代码文件。为什么要拆分代码文件呢?一般是从两个角度考量:

  • 更好地利用缓存:假如 css 没有从 js 文件中分离出来,那么每次 js 或者 css 改变,用户都得重新下载整个文件;而分离之后,两者独立,一方改变后,另一方的缓存仍可利用,无需重新下载
  • 更好地复用代码:如果开发的是多页面应用,可以把公共样式单独提取成一个文件,这样公共样式文件只需要下载一次,而不是每进入一个页面就要重复下载

合理使用动态加载

通过 import() 或者 require.ensure() 对某些体积较大的模块实现按需加载、动态加载的时候,这些模块会打包到单独的文件中。如果用户用不到这个模块,那么他们就无需加载它,不再像之前那样一股脑地加载整个代码文件。

多页面应用使用动态路由

对于多页面应用,采用之前提到的多页面应用打包方案,使每个页面都有自己对应的文件,这样用户在进入某个页面的时候,只需要加载和这个页面相关的资源,而不是全部一次性加载。

splitChunks 代码分割

形成 chunk 的方法有三种:

  • 设置多个 entry 入口点,每个 entry 会被打包到一个 chunk 中
  • 动态导入某些代码,这些代码会被打包到一个 chunk 中
  • 通过 splitChunks 分割代码,分割的代码会被打包到一个 chunk 中

通过配置 optimization.splitChunks.cacheGroups ,可以将公用的第三方库代码抽离成一个单独的 chunk,下面通过一个例子来理解这个过程。

假设我们的应用有两个页面,对应两个 entry 入口文件:

// page1.js
import $ from 'jquery'
import React from 'react'
import(/* webpackChunkName: "page1-lodash"*/ 'lodash')

// page2.js
import $ from 'jquery'
import(/* webpackChunkName: "page2-react" */ 'react')
import(/* webpackChunkName: "page2-lodash"*/ 'lodash')

webpack 配置如下:

module.exports = {
    optimization: {
        splitChunks: {
            // 这里的默认配置项省略,它们最终都会作用到 cacheGroups 上
            cacheGroups: {
                vendors: {
                    test: /[\\/]node_modules[\\/]/,
                    // 这里的 chunks 字段指的是分离 chunk 的标准
                    chunks: 'async'
                }
            }
        }
    }
}

chunks: “async”

chunks 的默认值就是 async,表示会将异步导入(动态导入)的模块抽离成单独的 chunk。

**对于 page1.js:**本身 entry 文件就会对应一个 chunk,而 jq 和 react 都是同步导入的,因此不会从这个 chunk 中分离,它们三个最终会打包到一起,并输出到 page1.bundle.js 文件。而 lodash 是动态导入的,会分离到一个单独的 chunk 中,并输出到 vendors~page1-lodash.js 文件

**对于 page2.js:**本身 entry 文件就会对应一个 chunk,而 jq 是同步导入的,因此不会从这个 chunk 中分离,它们两个最终会打包到一起,并输出到 page2.bundle.js 文件。而 lodash 是动态导入的,它会和 page1.js 中同样动态导入的 lodash 一起打包到同一个 chunk 中,最终输出到 vendors~page1-lodash.js 文件。react 也是动态导入的,它也会打包到一个单独的 chunk 中,最终输出到 vendors~page2-react.js 文件

综上,最终会有 4 个 chunk,输出到 4 个 bundle 文件中。从控制台打印信息可以看出确实是这样的:

借助 BundleAnalyzePlugun 可以更加直观地分析 bundle 的成分,如下图:

chunks: “initial”

initial 表示,不管模块是同步导入还是异步导入,都会被抽离成单独的 chunk。如果不同的 chunk 都通过同步导入的方式共用了同一个模块,则这两个模块可以被抽离到同一个 chunk 中。

  • 首先还是同样的,page1.js 和 page2.js 本身的 entry 文件会分别对应两个 chunk,并输出到 page1.bundle.js 和 page2.bundle.js 中。
  • 由于两个 chunk 都同步导入了 jq,因此 jq 最终被抽离到一个 chunk 中,并输出到 vendors~page1-page2.js 文件。
  • 对于都异步导入的 lodash 也是一样,会输出到 page1-lodash.js 文件。
  • 而对于 react 的处理就不同了,虽然两个文件都导入了 react,但一个是同步导入,一个是异步导入,这种情况下,react 会被分别抽离到两个 chunk 中,同步导入的 react 输出到 vendors~page1.js,异步导入的 react 输出到 page2-react.js。

综上,最终会有 6 个 chunk,输出到 6 个 bundle 文件中。

控制台打印信息如下:

BundleAnalyzePlugun 的分析情况如下:

PS:使用 chunks:"initial" 的时候需要注意,会有一个 minSize 字段表示被抽离成单独 chunk 的模块至少需要多大,如果模块体积本身小于这个值,则它也不会被单独抽离成 chunk,而是和 entry 对应的 chunk 打包在一起。

chunks: “all”

all 的特点在于,只要两个 chunk 共用了同一个模块,则不管模块在各自的 chunk 中是同步导入还是异步导入,最终都可以被抽离到同一个单独的 chunk 中。

  • page1.js 和 page2.js 本身的 entry 文件会分别对应两个 chunk。
  • 由于这两个 chunk 共用了 jq,所以 jq 被抽离到一个单独的 chunk 中,最终输出到 vendors~page1-page2.js
  • 由于这两个 chunk 共用了 lodash,所以一样的,被抽离到一个 chunk 中,最终输出到 vendors~page1-lodash.js
  • 对于 react,虽然在各自 chunk 中导入方式不同,但确实是属于共用的模块,所以也会被抽离到一个 chunk 中,最终输出到 vendors~page1-page2-react.js

控制台打印信息如下:

BundleAnalyzePlugun 的分析情况如下:

从这三种设置的结果可以看出,chunks:"all" 是可以最大程度复用代码的,因为在它的规则下,只要是模块被共用了,就可以被抽离到同一个 chunk 中。

构建速度优化

减小文件搜索范围

webpack 打包构建的过程中会进行很多搜索过程,如果可以通过修改配置减小文件搜索的范围,那么就可以提高构建速度。

从配置 noParse 的角度来说:

默认情况下,我们导入 jq 或者 lodash 这样的库时,webpack 会去递归地解析这些库是否有其他第三方依赖。这个过程其实是不必要的,所以可以通过 noParse 配置不需要递归解析的模块:

module.exports = {
    noParse: '/lodash|jquery/'
}

从配置 resolve 的角度来说:

resolve.alias

可以配置路径的别名,减少类似 import xxx from ‘…/…/a/b’ 这样繁琐的导入语句。不仅开发上更加方便,而且 webpack 解析到别名的时候,可以直接去对应的目录找到模块。

module.exports = {
    resolve: {
        alias: {
            // 用 @ 代替路径 '/project/src'
            '@': '/project/src'
        }
    }
}

使用:

// 模块导入
import xxx from '@/assets/test.png'

// HTML 中使用 
<img src="~@/assets/test.png">

// CSS 中使用
.box {
	background: url('~@/assets/test.png')
}

注意,在 HTML 或者 CSS 中使用别名路径的时候,必须加 ~ 前缀。另外,必须安装 html-loader 和 css-loader,webpack 才能正确解析别名路径对于资源的引用。

resolve.extensions

提供一个后缀名数组,如果像 import 这样的导入语句省略了文件后缀名,则会为文件依次加上数组中的后缀名,看文件是否存在。这意味着我们经过配置后,在导入语句中可以省略文件的后缀名。

module.exports = {
    resolve: {
        // 默认可以省略 .js 和 .json 后缀名
        extensions: ['.js','.json']
        // 在默认省略的后缀名基础上自定义
        extensions: ['vue','...']
    }
}

一般来说,应该将出现频率较高的后缀名写在前面,加快 webpack 解析时的匹配速度。另外不应该配置并省略过多的后缀名,否则会增加 webpack 解析时的查找时间。

resolve.modules

指定 webpack 去哪些路径下查找模块,默认会从项目根目录开始找,找不到就往外层找。一般我们自己写的模块或者第三方模块都在项目根目录下了,所以可以指定一下目录,减少不必要的向外查找。

module.exports = {
    modules: [
        path.resolve(__dirname,'./node_modules'),
        path.resolve(__dirname,'./src')
    ]
}

resolve.mainFields

指定的是去查找第三方模块的 package.json 文件的哪些字段,从而找到模块的入口文件。一般来说入口文件都配置在 main 字段中,所以可以直接将其配置为 ['main']

从配置 loader 的角度来说

可以排除掉一些不需要解析的文件,或者精准指定需要解析的文件,从而减小解析时间,加快构建速度。

以 babel-loader 为例,默认情况下它会解析根目录中的所有 js 文件,但实际上,node_modules 中的很多第三方包本身就已经经过处理了,无需再进行解析,那么这部分就可以排除掉;同时,我们需要解析的通常是自己编写的代码,所以可以明确指定解析 src 目录下的文件:

module.exports = {
    module: {
        rules:[
            {
                test: /\.js$/,
                use: 'babel-loader',
                include: path.resolve(__dirname,'./src'),
                exclude: path.resolve(__dirname,'./node_modules')
            }
        ]
    }
}

多进程并行构建

webpack@4 之前使用 HappyPack,webpack@4 之后使用 thread-loader。安装后配置:

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/,
                use:[
                    {
                        loader: 'thread-loader',
                        options: {
                            workers: 3
                        }
                    },
                    'babel-loader'
                ]
            }
        ]
    }
}

排在 thread-loader 之后的那些 loader 会被放在一个进程池中单独运行。这里需要注意,进程池中的 loader 不能产生新文件,因此类似 MiniCssExtractPlugin.loader 这样产生 css 文件的 loader 是不能使用 thread-loader 的。

PS:不管是 HappyPack 还是 thread-loader 都只适用于大型项目,小型项目中的优化很不明显,甚至可能反而降低打包构建的速度。

多进程并行压缩

CSS 和 JS 的压缩可以开启多进程并行压缩(默认开启):

module.exports = {
    optimization: {
        minimizer: [
            // JS 并行压缩
            new TerserPlugin({
                parallel: 4
            })
            // CSS 并行压缩
            new CssMinimizerPlugin({
            	parallel: 4
            })
        ]
    }
}

利用缓存提升二次构建速度

前面讲到的都是提升首次构建速度,我们可以将首次构建的结果缓存下来,然后利用缓存提升二次构建的速度。

开启 babel-loader 的缓存功能:

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/.
                use: [
                	{
                		loader: 'babel-loader',
                		options: {
                			cacheDirectory: true
            			}
            		}
                ]
            }
        ]
    }
}

开启 terser-webpack-plugin 的缓存功能:

module.exports = {
    plugins: [
        new TerserPlugin({
            cache: true
        })
    ]
}

或者使用 cache-loader 缓存构建结果:

module.exports = {
    module: {
        rules: [
            {
                test: /\.js$/.
                use: ['cache-loader','babel-loader']
            }
        ]
    }
}

或者使用 hard-source-webpack-plugin 缓存构建结果:

module.exports = {
    plugins: [
        new HardSourceWebpackPlugin(),   
        // 和 MiniCssExtractPlugin 的 loader 冲突,必须对其进行排除
        new HardSourceWebpackPlugin.ExcludeModulePlugin([
          {
            test: /mini-css-extract-plugin[\\/]dist[\\/]loader/,
          }
        ])
    ]
}

打包体积优化

优化打包体积即减小打包体积,基本策略是对文件体积进行压缩,或者设法减少文件的数量。

webpack 自带:Tree-Shaking

tree-shaking 就是所谓的摇树优化,可以实现 DCE,即去除没有用到的代码,包括:

  • 永远不会执行的、不可达的代码
  • 执行结果没有被用到的代码
  • 只会影响死变量(指变量赋值了,但之后再也没有读取)的代码

在讲 tree-shaking 之前,首先要理解静态分析的概念。静态分析指的是不需要实际执行代码,仅从字面量就可以对代码进行分析,诸如 import() 和 CommonJS 的 require() 都是动态的,而 ESModule 则不一样,它支持静态分析 —— 在使用 ESModule 的时候,模块是否导入、是否导出、模块之间的依赖关系,这些都是可以提前确定好的,而这种静态分析的特性正是实现 tree-shaking 的关键。

tree-shaking 如何发挥作用呢?以下面的代码为例:

// export.js
export function a(){}
export function b(){}

// index.js
import {a} from ''./export.js'
a()

虽然只是导入并使用了 a,但实际上最终 a 和 b 都会被打包到 bundle 中,这会无形增加代码体积。但是如果使用了 tree-shaking,则最终只有 a 会被打包。

同样的,如果是下面这种情况:

// export.js
export function a(){}
export function b(){}

// index.js
import {a,b} from ''./export.js'
a()

使用了 tree-shaking 之后,由于没有用到 b,所以最终 b 也不会被打包。

对于有副作用的代码(会向外界产生可观察的变化),tree-shaking 无法将其修剪掉。如果确定自己的项目没有副作用,可以配置 webpack.config.js 的 optimization.sideEffects: true(生产环境自动开启),同时配置 package.json 的 sideEffects: false ,告知 webpack 无需考虑副作用的问题,可以放心进行 tree-shaking;另外也可以指定一个路径数组,明确告知 webpack 哪些文件有副作用,从而让 webpack 为其它没有副作用的文件进行 tree-shaking 处理。

前面也说过,tree-shaking 依赖于 ESModule 的静态分析,那么对于 lodash 这样不使用 ESModule 模块规范的第三方库,怎么进行 tree-shaking 呢?这时候可以考虑使用这种库的 es 版本,比如 lodash 对应的就有一个 lodash-es 版本。

webpack 在生产环境下默认开启 tree-shaking,当然也可以手动开启:

module.exports = {
    optimization: {
        minimizer: [
            new TerserPlugin()
        ]
    }
}

webpack 自带:Scope-Hoisting

scope-hoisting 可以将多个函数声明压缩为一个,这样做的好处一个是减少声明语句,从而减小代码体积;一个是减少函数作用域数量,从而降低内存开销。

webpack 在生产环境下默认开启 scope-hoisting,当然也可以手动开启:

module.exports = {
    plugins:[
        new webpack.optimize.ModuleConcatenationPlugin()
    ]
}

资源优化:压缩 HTML

由 html-webpack-plugin 生成的 html 文件,可以通过设置 minify: true 开启压缩功能(生产环境下默认开启)。

资源优化:压缩 JS

webpack 默认内置了 uglifyjs-webpack-plugin或者 terser-webpack-plugin,并且在生产环境下自动开启,因此 JS 代码默认就是经过压缩的。

当然也可以自定义配置(具体配置项需要参考 terser):

const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
    optimization: {
        minimizer: {
            new TerserPlugin({
	            // 只压缩 `.min.js` 后缀的文件
            	test: /\.min\.js$/
            	// 加快二次构建的速度
            	cache: true,
            	// 多线程压缩
            	parallel: true,
	            terserOptions: {
        			compress: {
            			unused: true,
            			drop_debugger: true,
            			drop_console: true,
            			dead_code: true	
			        }    
		        }
	        })
        }
    }
}

上面指的是对 JS 代码进行压缩,还有一种减少 JS 代码的策略是动态引入 polyfill。

babel 所做的事情只是转换语法,比如 const 转化为 var,箭头函数转化为普通函数等,对于诸如 map、Promise 这样比较新的 api 则无法进行处理,这时候就需要借助 polyfill 实现向下兼容。但是单纯使用 babel-polyfill 的问题在于,任何时候都是全量引入的,而有些用户的浏览器比较新,其实用不着使用 polyfill

所以如果能实现动态引入 polyfill,也可以减少代码体积。这里借助的是 polyfill-service,我们引用它提供的 polyfill CDN,对于不同的浏览器,它会返回不同版本的 polyfill。

<srcipt src="https://polyfill.io/v3/polyfill.js"></srcipt>

资源优化:压缩 CSS

有三种方案,不管是哪一种,底层使用的压缩引擎都是 cssnano,而 css-loader 已经内置了 cssnano,因此无需额外安装。

方案一:postcss+ cssnano

需要安装 postcss-loader,接着进行配置:

module.exports = {
    module: {
        rules: [
            {
                test: /\.css$/,
                use: [
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            plugins: () => [
                                require('cssnano')
                            ]
                        }
                    }
                ]
            }
        ]
    }
}

方案二:optimize-css-assets-webpack-plugin + cssnano

适用于 webpack@5 之前的版本。需要安装 optimize-css-assets-webpack-plugin,接着进行配置:

const optimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
    plugins:[
        new optimizeCssAssetsPlugin({
            assetNameRegExp: /\.css$/g,
            cssProcessor: require('cssnano')
        })
    ]
}

方案三: css-minimizer-webpack-plugin

适用于 webpack@5 之后的版本。需要安装 css-minimizer-webpack-plugin,接着进行配置:

const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
module.exports = {
    optimization: {
        minimizer: new CssMinimizerPlugin()
    }
}

上面的 css 压缩都基于 cssnano,它很好用,但无法移除那些没有使用过的样式。这里可以使用 purgecss-webpack-plugin 实现 css 中的 tree-shaking。

module.exports = {
    // 必须和这个插件一起使用
    new MiniCSSExtractPlugin()
    new PurgeCSSPlugin({       
      paths: glob.sync(path.join(__dirname,'./src/*/*'),{nodir: true})
    })
}

注意这里的 path 并不是指要移除无用样式的 css 所在的路径,而是指引用这个 css 的文件所在的路径,可以直接配置为 src 下的所有文件。purgecss 会对这些文件进行分析,最终产出一个移除了无用样式的 css 文件。

资源优化:处理图片

从减小文件数量的角度来说:

1)可以使用前面提到的 url-loader,对体积小于 limit 的图片进行 base64 编码,转化为 dataUrl 内联进我们的应用

2)对于 svg 图片,可以使用类似的 svg-url-loader 对其进行 utf-8 编码,转化为 dataUrl 内联进应用。它相比 base64 编码的优势在于,字符串更短,浏览器解析更快

3)对于图标类图片,可以使用 postcss-loader + postcss-sprites(需要额外安装 phantomjs),它可以将多张图片合并成一张雪碧图,并且自动调整图片的背景位置。

大致配置如下:

module.exports = {
    module: [
        rules:[
    		{
        		test: /\.css$/,
        		use: [
        			'style-loader','css-loader',
        			{
        				loader: 'postcss-loader',
        				options: {
        					plugins: [
        						require('postcss-sprites')({
    								spritePath: './src/sprites'
								})
						    ]
        				}
        			}
			    ]
        	}    
	    ]
    ]
}

postcss-sprites 作用的原理可以简单理解为,将 css 文件中引用到的图片资源合并成一张雪碧图,并自动处理背景图的展示位置。经由 file-loader 处理后,最后产出的 bundle 中只包含雪碧图这一张图片。

这里需要注意,spritePath 配置的是雪碧图的存放路径。一般雪碧图放在 src 中而不是 dist 中,因为 dist 中本来就会在 file-loader 的作用下产出图片,没有必要重复导出雪碧图到 dist 中 —— 即使导出了,也属于没有被使用的静态资源,会被 clean-webpack-plugin 清理掉。

此外,postcss-sprites 这个插件不支持识别 resolve.alias 配置的别名。


从减小文件大小的角度来说,对大体积的图片可以使用 image-webpack-loader 进行无损压缩。这个 loader 必须在 file-loader 之前处理图片,所以最好配置 enforce: 'pre'