一份不可多得的 Webpack 学习指南(1万字长文带你入门 Webpack 并掌握常用的进阶配置)

819 阅读17分钟

「这是我参与2022首次更文挑战的第七天,活动详情查看:2022首次更文挑战」。

🧨 大家好,我是 Smooth,一名大二的 SCAU 前端er
🏆 本篇文章会带你入门 Webpack 并对基本配置以及进阶配置做比较通俗易懂的介绍!
🙌 如文章有误,恳请评论区指正,谢谢!

自定义 Loader 内容于 2022/02/25 更新
自定义 Plugin 内容于 2022/02/26 更新

Webpack

本教程包管理方式统一使用 npm 进行讲解

学习背景

由于在平时使用 vue、react 框架进行项目开发时,vue-cli 和 create-react-app 脚手架已经为你默认配置了 webpack 的常用参数,所以没有额外需求不用另外配置 webpack。

但在一次项目开发中,突然自己想给项目增加一个优化打包速度和体积的需求,所以就开始学习起了 webpack,逐渐明白了这打包工具的强大之处,在查看了脚手架给我们默认配置好的 webpack 配置文件后,也明白了脚手架这种东西的便捷之处。

当然,系统学习后 webpack,你也可以做到去阉割脚手架的 webpack 配置文件用不到的一些配置选项,并增加一些你需要的配置,例如优化打包体积、提升打包速度等等。

此篇文章便为你进行系统地讲解 webpack 的基本使用

PS:对于 webpack 的配置文件,vue-cli 可以通过修改 vue.config.js 进行配置修改,create-react-app 需要通过 craco 覆盖,或 eject 进行暴露。

webpack介绍

webpack 是什么

bundler:模块打包工具

webpack 作用

对项目进行打包,明确项目入口,文件层次结构,翻译代码(将代码翻译成浏览器认识的代码,例如import/export)

webpack 环境配置

webpack安装前提:已安装 node (node安装在此不做赘述),用指令 node -v 和 npm -v 来测试node安装有没成功

npm install webpack webpack-cli --save-dev // 推荐,--save-dev结尾(或直接一个 -D),该项目内安装
npm install webpack webpack-cli -g // 不推荐,-g结尾,全局安装(如果两个项目用的两个webpack版本,会造成版本冲突)
​
安装后查询版本:
webpack -v:查找全局的 webpack 版本,非 -g 全局安装是找不到的
npx webpack -v:查找该项目下的 webpack 版本
​
其他指令:
npm init -y:初始化 npm 仓库,-y 后缀意思是创建package.json文件时默认所有选项都为yes
npm info webpack:查询 webpack 有哪些版本号
npm install webpack@版本号 webpack-cli -D:安装指定版本号的webpack
npx webpack;进行打包

webpack-cli 和 webpack 区别:

webpack-cli 能让我们在命令行运行webpack 相关指令,例如 webpack, npx webpack 等等

webpack 配置文件

默认配置文件:webpack.config.js

const path = require('path');
module.exports = {
    mode: "production", // 环境,默认 production 即生产环境,打包出来的文件经过压缩(可以不写),development没压缩
    entry: 'index.js', // 入口文件(要写路径)
    output: {  // 出口位置
        filename: 'bundle.js', // 出口文件名
        path: path.resolve(__dirname, 'bundle'), // 出口文件打包到哪个文件夹下,参数(绝对路径根目录下,文件名)
    }
}

如果想让 webpack 按其他配置文件规则进行打包,比如叫做 webpackconfig.js

npx webpack --config webpackconfig.js

小问题:

为什么使用react、vue框架打包项目文件时不是输入 npx webpack 而是输入 npm start/npm run dev等等?

原因:更改 package.json 文件里的 scripts 脚本指令(该文件:项目的说明,包括所需依赖、可运行脚本、项目名、版本号等等)

{
    "scripts": {
        "bundle": "webpack" // 运行 npm run 脚本名,相当于运行 原始指令,即 `npm run bundle -> webpack`
    }
}

回顾:

webpack index.js // 全局安装 webpack 后,单独对这个js文件进行打包
npx webpack index.js // 局部(项目内)安装 webpack 后,单独对这个js文件进行打包
npm run bundle -> webpack // 运行脚本,进行 webpack 打包,先在项目内查找webpack进行打包,没有再全局----前两者融合

后面开始用 npm run bundle 代替 webpack 进行打包

Webpack 基本概念

webpack 的 Concepts 板块

官方文档

Loader

Loader 是什么?

由于 webpack 默认只认识、支持打包js文件,想要拓展其能力进行打包 css文件、图片文件等等,需要安装 Loader 进行拓展

Loader 的使用

在 webpack.config.js 的配置文件中进行配置

在文件中新增 module 字段,module 中新增 rules 的数组,进行一系列规则的配置,每个规则对象有两个字段

test :匹配所有以 xxx 为后缀的文件的打包,用正则表达式进行匹配

use :指明要使用的 loader 名称 ,且要对该 loader 进行安装

拓展,use 还有 options 可选择字段,name 指明打包后的文件命名,[name].[ext] 代表打包后和打包前 命名 和 后缀 一样

{
    module: {
        rules: [
            {
                test: /.jpg$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        // placeholder 占位符
                        name: '[name].[ext]'
                    }
                }
            }
        ]
    }
}

在项目根目录下使用 npm install loader名字 或 yarn add loader名字 进行所需 loader 的安装

常用 Loader 推荐

babel-loader、style-loader、css-loader、less-loader、sass-loader、postcss-loader、url-loader、file-loader 等等

图片(Images)

打包图片文件

图片静态资源,所以都对应 file-loader ,且一般项目中这些静态资源被放到 images 文件夹,通过 use 字段配置额外参数

{
    module: {
        rules: [
            {
                test: /.(jpg|png|gif)$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: 'images/' // 匹配到上面后缀的文件时,都打包到的的文件夹路径
                    }
                }
            }
        ]
    }
}

当然,对于 file-loader,url-loader 会更具拓展性

推荐用 url-loader 进行替换,因为可以设置limit参数,当图片大于对应字节大小,会打包到指定文件夹目录,若小于,则会生成base64(不会打包图片到文件夹下,而是生成 base64 到 output的js文件里 )

{
    module: {
        rules: [
            {
                test: /.(jpg|png|gif)$/,
                use: {
                    loader: 'url-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: 'images/', // 匹配到上面后缀的文件时,都打包到该文件夹路径
                        limit: 2048 // 指定大小
                    }
                }
            }
        ]
    }
}



样式(CSS)

打包样式文件

需要 css-loader 和 style-loader ,在 use 字段进行配置

说明:设置 css 样式后,挂载到 style 的属性上,所以要两个

{
    module: {
        rules: [
            {
                test: /.css$/,
                use: ['style-loader', 'css-loader']
            }
        ]
    }
}

对于 scss 文件,除了上面两个 loader 以外,还要在 use 中额外配置 sass-loader,然后安装两个文件

npm install sass-loader node-sass webpack --save-dev

注意事项:

use 中的 loader 数组,是有打包顺序的,按从右到左,从上到下,即 scss,要先 style,然后 css,最后sass,从右到左

use: ['style-loader', 'css-loader', 'sass-loader']

postcss.loader

对于样式,如果老版本的浏览器可能需要兼容,即在 css 属性中加 -webkit 等前缀,可以通过 postcss.loader 实现,在上面例子在后面加上这个 loader 并进行下载后,新建一个 postcss.config.js 文件进行该 loader 的配置即可

module.exports = {
    plugins: [
        require('autoprefixer')
    ]
}

样式拓展

如何让 webpack 识别 less 文件内再引入的 less 文件,并进行打包?

如何模块化导出和使用样式?(css in js)

{
    module: {
        rules: [
            {
                test: /.scss$/,
                use: ['style-loader', 
                        {
                            loader: 'css-loader',
                            options: {
                                importLoaders: 2, // 允许less文件内引入less文件
                                modules: true // 允许模块化导入导出使用css,css in js 同理
                            }
                        },
                        'sass-loader,
                        'postcss-loader'
                    ]
            }
        ]
    }
}



字体(Fonts)

打包字体文件(借助iconfont)

从 iconfont 网站下载对应图标的字体文件并压缩到目录后,会发现由于下载的 iconfont.css 文件内部又引入了 eot、ttf、svg文件,webpack无法识别,引入需给这三个后缀的文件再配置打包规则,用 file-loader 即可

{
    module: {
        rules: [
            {
                test: /.scss$/,
                use: ['style-loader', 
                        {
                            loader: 'css-loader',
                            options: {
                                importLoaders: 2, // 允许less文件内引入less文件
                                modules: true // 允许模块化导入导出使用css,css in js 同理
                            }
                        },
                        'sass-loade,
                        'postcss-loader'
                    ]
            },
            {   // 配置这个规则即可
                test: /.(eot|ttf|svg)$/,
                use: {
                    loader: 'file-loader'
                }
            }
        ]
    }
}



自定义 Loader

首先明确最基本的,编写 Loader 其实就是编写一个函数并暴露出去给 Webpack 使用

例如编写一个 replaceLoader ,作用是当遇到某个字符时替换成其他字符,例如遇到 hello 字符串时,替换成 hi

// 在根目录的 loaders 文件夹下的 replaceLoader.js    即路径:'./loaders/replaceLoader.js'
​
module.exports = function(source) {
    return source.replace('hello', 'hi');
}

这样,一个简易的 Loader 就写好啦

注意

暴露的函数不能写成箭头函数,即不能写成如下:

// replaceLoader.js
​
module.exports = (source) => {
    return source.replace('hello', 'hi');
}

由于箭头函数没有 this 指针,而 Webpack在使用 Loader 时会做些变更,绑定一些方法到 this 上,所以会没法调用原本属于 this 的一些方法了。

例如:获取传入 Loader 的参数是通过 this.query 获取

当然,要用你自定义的 Loader,除了上面的编写 Loader 外,还需要对他进行使用,在 Webpack 配置文件进行相关配置

// webpack.config.js

const path = require('path');
​
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js',
    },
    module: {
        rules: [{
            test: /.js/,
            use: [
                path.resolve(__dirname, './loaders/replaceLoader.js') // 这里要书写该 js 文件的路径
            ]
        }]
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    }
}

如果你想往你的自定义 Loader 传入一些参数,传参的方式如下:

// webpack.config.js

const path = require('path');
​
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js',
    },
    module: {
        rules: [{
            test: /.js/,
            use: [
                {
                    loader: path.resolve(__dirname, './loaders/replaceLoader.js'), // 这里要书写该 js 文件的路径
                    options: {
                        name: 'hi'
                    }
                }
            ]
        }]
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    }
}

这样,Webpack 在进行打包时,会将 {name: 'hi'} 参数传入 replaceLoader.js

在 replaceLoader.js 中,参数的接收形式如下:

// replaceLoader.js
​
module.exports = (source) => {
    return source.replace('hello', this.query.name);
}

这样,原项目所有 js 文件中的 hello 字符串都被替换成了 hi

这样,一个简易的 Loader 就完成啦

更多

loader-utils

但有时往自定义 Loader 传参时会比较诡异,例如上述例子,传入的明明是一个对象,但可能变成只有一个字符串 ,此时就需要用到 loader-utils 模块,对传入的参数进行分析,解析成正确的内容

使用方法

先运行 npm install loader-utils --save-dev 安装,然后

// replaceLoader.js
​
const loaderUtils = require('loader-utils'); // 引入该模块
​
module.exports = function(source) {
    const options = loaderUtils.getOptions(this); // 使用
    return source.replace('hello', options.name);
}



callback()

有时,除了用自定义 Loader 对原项目做出更改以外,如果启用了 sourceMap,还希望 sourceMap 对应的映射也发生更改,

由于该函数只返回了项目内容的更改,而没返回 sourceMap 的更改,所以要用 callback 做一些配置

this.callback(
    err: Error | null,
    content: string | Buffer,
    sourceMap?: SourceMap,
    meta?: any
)

通过该函数进行回调,可以返回除了项目内容更改外,还可以返回 sourceMap 、错误、meta 的更改

由于,我只需要返回项目内容以及 sourceMap 的更改,所以配置示例如下:

// replaceLoader.js
​
const loaderUtils = require('loader-utils'); // 引入该模块
​
module.exports = function(source) {
    const options = loaderUtils.getOptions(this); // 使用
    const result = source.replace('hello', options.name);
    
    this.callback(null, result, source);
}



async()

自定义 Loader 中有时会有异步操作,例如设置延时器1s后再进行打包(方便摸鱼),那如果直接 setTimeout(),设置一个延时器再返回肯定是不行的,会报错无返回内容,因为正常来说是不允许在延时器中返回内容的。

我们可以通过 async() 来解决,如下:

// replaceLoader.js
​
const loaderUtils = require('loader-utils'); // 引入该模块
​
module.exports = function(source) {
    const options = loaderUtils.getOptions(this); // 使用
    const callback = this.async();
    
    setTimeout(() => {
        const result = source.replace('hello', options.name);
        callback(null, result); // 参数同上面的 callback()
    }, 1000);
}

可以看出,其实 async() 跟 callback() 很类似,只不过用于异步返回而已

同时自定义多个 Loader

例如想实现一个需求:打包后项目先是将项目中的所有字符串 hello 替换成 hi,再把 hi 替换成 Wow

那么就要编写两个 Loader,第一个将 hello 替换成 hi,第二个将 hi 替换成 Wow

第一个 replaceLoader.js

// replaceLoader.js
​
const loaderUtils = require('loader-utils'); // 引入该模块
​
module.exports = function(source) {
    const options = loaderUtils.getOptions(this); // 使用
    const callback = this.async();
    
    setTimeout(() => {
        const result = source.replace('hello', options.name);
        callback(null, result); // 参数同上面的 callback()
    }, 1000);
}

第二个 replaceLoader2.js

// replaceLoader2.js
​
module.exports = function(source) {
    return source.replace('hi', 'wow');
}

同时对 webpack.config.js 进行配置

// webpack.config.js

const path = require('path');
​
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js',
    },
    module: {
        rules: [{
            test: /.js/,
            use: [
                {
                    loader: path.resolve(__dirname, './loaders/replaceLoader2.js')
                },
                {
                    loader: path.resolve(__dirname, './loaders/replaceLoader.js') // 这里要书写该 js 文件的路径,
                    options: {
                        name: 'hi'
                    }
                },
            ]
        }]
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    }
}

要注意的地方

由于前面提到 Loader 执行顺序是从下到上,从右到左,所以要将第一个写在下面,第二个写在上面

Loader 引入转换成官方的引入方式

在上面的例子中,引入 loader 时,方式都是

loader: path.resolve(__dirname, './loaders/replaceLoader2.js')

太长了,太麻烦了,不美观,想更换成官方的引入方式,该怎么做呢

loader: 'replaceLoader2'

Webpack 配置文件中配置 resolveLoader 字段

// webpack.config.js

const path = require('path');
​
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js',
    },
    resolveLoader: {
        modules: ['node_modules', './loaders']
    },
    module: {
        rules: [{
            test: /.js/,
            use: [
                {
                    loader: 'replaceLoader2'
                },
                {
                    loader: 'replaceLoader', // 这里要书写该 js 文件的路径,
                    options: {
                        name: 'hi'
                    }
                },
            ]
        }]
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    }
}

参数意思:如果在 node_modules 文件夹下没找到配置的 Loader,那么就会进入同级目录下的 loaders 文件夹进行查找

更多 Loader 的设计思考

推荐一些自定义的实用的 loader

  1. 全局异常监控,思路:给所有函数外面包裹 try{} catch(err) {console.log(err)} 语句
  2. style-loader
module.exports = function(source) {
    const style = `
        let style = document.createElement("style");
        style.innerHTML = ${JSON.stringify(source)};
        document.head.appendChild(style)
    `
    return style;
}



Plugins

  • 使用插件让打包更快捷、多样化

  • 在打包的某个生命周期,插件会帮助你做一些事情

下面介绍几个常用插件

html-webpack-plugin

作用:由于 webpack 默认打包不会生成 index.html 文件, htmlWebpackPlugin 会在打包结束后,自动生成一个html文件,并把打包生成的js自动引入到这个html文件中

插件运行生命周期:打包之后

参数:对象

template 指定一个模板,打包生成的 html 文件根据这个模板来生成,比如会多生成一个 <div id="root"></div>

new HtmlWebpackPlugin({
    template: 'index.html'
})

clean-webpack-plugin

作用:打包前删除某个目录下的所有内容,主要用于删除之前的打包内容,防止重复

插件运行生命周期:打包之前

参数:数组形式

['要删除的文件夹名']

new HtmlWebpackPlugin(['dist'])



自定义一个 Plugin

首先明确最基本的,编写 plugin 其实就是编写一个类并暴露出去给 Webpack 在打包的某个生命周期进行相关操作。

例如编写一个 copyright-webpack-plugin

// copyright-webpack-plugin.js  我定义该文件位于根目录的 plugins 文件夹下
​
class CopyrightWebpackPlugin {
    constructor() {
        console.log('插件被使用了')
    }
    
    apply(compiler) {
    
    }
}
​
module.exports = CopyrightWebpackPlugin;

webpack.config.js

// webpack.config.js
​
const path = require('path');
const CopyRightWebpackPlugin = require('./plugins/copyright-webpack-plugin');
​
module.exports = {
    mode: 'development',
    entry: {
        main: './src/index.js',
    },
    plugins: [
        new CopyRightWebpackPlugin()
    ],
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].js'
    }
}

这样,一个简易的 Plugin 就完成啦

更多

往插件里传参

在 Webpack 配置文件创建插件实例时,同时传入参数就行

plugins: [
    new CopyRightWebpackPlugin({
        name: 'Smoothzjc'
    })
],
​

这样,就可以在类的构造函数中接收到该参数了

class CopyrightWebpackPlugin {
    constructor(options) {
        console.log('我是', options.name)
    }
    
    apply(compiler) {
    
    }
}
​
module.exports = CopyrightWebpackPlugin;



不同生命周期

前面我有提到,在打包的某个生命周期,插件会帮助你做一些事情,

所以我们可以在 Webpack 打包的不同生命周期时,写一些想让 Webpack 帮我们做的事

常用生命周期:

  • emit 异步钩子,打包完成准备将打包内容放到生成目录前,即打包完成的最后时刻
  • compile 同步钩子,准备进行打包前

下面示例的一些参数解释:

compiler 配置的所有内容,包括打包相关的内容

compilation 本次打包的所有内容

如果你想在打包完成前新加一个文件到打包目录下,可以配置 compilation 的 assets 属性

相关代码运行在 apply 属性中

class CopyrightWebpackPlugin {
    constructor(options) {
        console.log('我是', options.name)
    }
    
    apply(compiler) {
    
        // 同步钩子 compile
        compiler.hooks.compile.tap('CopyrightWebpackPlugin', () => {
            console.log('同步钩子 compile 生效');
        })
    
        // 异步钩子 emit
        compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, cb) => {
            compilation.assets['copyright.txt'] = {
                // 文件内容配置在 source 属性中,该例子意思是文件内容是一个函数,且返回值是如下
                source: funciton() {
                    return 'copyright write by Smoothzjc'
                },
                // 该文件大小
                size: function() {
                    return 28
                }
            }
            // 由于异步钩子,所以要运行回调函数
            cb();
        })
    }
}
​
module.exports = CopyrightWebpackPlugin;



编写插件时进行调试

大部分调试工具都是基于 node 编写,在此我举个例子,如何在编写 plugin 时使用调试工具进行 debug

  1. 先添加脚本指令,通过 node 运行调试工具
// package.json
​
{
    "scripts": {
        "debug": node --inspect --inspect-brk node_modules/webpack/bin/webpack.js,
        "build": "webpack"
    }
}
  1. 在需要调试的地方打断点
class CopyrightWebpackPlugin {
    constructor(options) {
        console.log('我是', options.name)
    }
    
    apply(compiler) {
    
        compiler.hooks.compile.tap('CopyrightWebpackPlugin', () => {
            console.log('compiler');
        })
    
        compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, cb) => {
            debugger; // 在此处打断点
            compilation.assets['copyright.txt'] = {
                // 文件内容配置在 source 属性中,该例子意思是文件内容是一个函数,且返回值是如下
                source: funciton() {
                    return 'copyright write by Smoothzjc'
                },
                // 该文件大小
                size: function() {
                    return 28
                }
            }
            // 由于异步钩子,所以要运行回调函数
            cb();
        })
    }
}
​
module.exports = CopyrightWebpackPlugin;
  1. 控制台运行 debug 指令 npm run debug 后

  2. 打开浏览器按 F12 打开控制台,可以在开发者工具左上角看到 node 图标,点击即可进入 webpack 打包时经过的一些页面

image.png

  1. 可以将鼠标放上想查看的变量的属性

image.png

  1. 或在右边的 Watch 属性输入想查看的属性名称进行查看

image.png



Entry

打包的入口文件,并指定打包后生成的 js文件名

参数:字符串 或 一个对象,默认生成的文件名是 main,即 生成的文件名:'入口文件路径'

entry: './src/index.js'
或
entry: {
    main: './src/index.js'
}
同时上面跟下面的等价

同时也可以打包成多个 js 文件,即多入口

entry:{
    main: './src/index.js',
    sub: './src/index.js'
}



Output

输出js文件名

参数:

  • filename 最后打包出来的 js 文件名,可以直接 bundle.js 指定,也可以 [name].js 根据 entry 指定的名字,也可以 [hash].js 根据 entry 指定的哈希值
  • chunkFilename 通过异步引入的文件的文件名
  • path 打包后所处文件夹和路径
  • publicPath 给打包出来的 js 文件的 src 引入路径都加个前缀,一般用于 cdn 配置 publicPath 详解

例如:

index.html 中引入 js 板块的代码

<script type="text/javascript" src="main.js"></script>

如果想将打包后的js 文件都放到 cdn上,减少打包后体积(此时该js就不用放在打包后的文件夹中了),例如

<script type="text/javascript" src="http://cdn.com.cn/main.js"></script>

可配置成如下

publicPath: 'http://cdn.com.cn'

output配置示例

output: {
    publicPath: 'http://cdn.com.cn',
    filename: '[name].js',
    chunkFilename: '[name].chunk.js',
    path: path.resolve(__dirname, 'dist')
}



resolve

模块引入时的更多拓展操作

  • extensions 模块引入时的后缀名查找
  • alias 为路径配置别名
resolve: {
    extensions: ['.css', '.jpg', '.js', '.jsx'],
    alias: {
        '@': '/src/pages' // 当输入 @ 时,自动替换为 /src/pages
    }
}

extensions

在项目中以下面的方式引入模块时

import Child from './child'

由于没写文件后缀名,会通过上面的配置按序查找,先找 child.css 存不存在如果存在就引入这个,如果不存在,则继续找 child.jpg 存不存在,一直找到 child.jsx,如果还不存在,就会报错

alias

为路径配置别名

alias: {
    '@': '/src/pages'
}

解释:当输入 @ 时,自动替换为 /src/pages

常用场景:当你频繁使用根路径的方式引用某些文件,比如:

import a from '/src/pages/a.js';
import b from '/src/pages/b.js';
import c from '/src/pages/c.js';
import d from '/src/pages/d.js';

写多次 /src/pages 会很麻烦,用 @ 代替 /src/pages 会简化了输入,提高开发效率

注意:resolve 要进行合理配置,不然会降低性能,因为如果你要找 child.jsx,按照上面配置,要经过前面三个没必要的步骤

SourceMap

打包后的文件是否开启映射关系,他知道打包后文件与打包前源代码文件的代码映射

例如:知道 dist 目录下 main.js 文件96行报错,实际上对应的是 src 目录下 index.js 文件中的第一行

通常不用开启,默认 none 关闭,因为开启后会减缓打包速度和增大打包体积

参数

devtool: '参数'
​
'none' // 不开启source-map
'source-map' // 开启source-map进行映射
'inline-source-map' // 开启source-map进行映射的前提下,精确到哪一行哪一列
'cheap-source-map' // 开启source-map进行映射的前提下,只精确到哪一行
'eval' // 通过 eval 开启映射,效率最快,但不全面

推荐:

开发环境:devtool: 'cheap-module-eval-source-map'

生产环境(线上环境):devtool: 'cheap-module-source-map'

生产环境一般不用配置 devtool,但如果想报错时快速定位错误,可开启,建议使用上面推荐的参数

mode: 'development' 是开发环境

mode: 'production' 是生产环境

更多其他参数查看下表

image.png

SourceMap 配置示例

devtool: 'cheap-module-source-map'



WebpackDevServer

开启一个本地web服务器,可提高开发效率

webpack 指令(一般直接配置第二个脚本就行)

1. webpack --watch  保存后自动重新打包
2. webpack-dev-server  启动一个web服务器,并将对应目录资源进行打开,对应目录资源修改后保存会重新进行打包,且自动对网页进行刷新

我们下载的每个项目都经过两条指令(安装依赖 + 打包运行),下面以 create-react-app 脚手架生成的 react 项目为例

npm install
npm run start

第二步,其实就是运行 WebpackDevServer,你会发现,start 后会直接打开浏览器,且每次保存后都会自动重新打包、重新刷新网页。

WebpackDevServer 隐藏特性:打包后的资源不会生成一个 dist 文件夹,而是将打包后资源放在电脑内存中,能有效提高打包速度

参数

  • contentBase 将哪个目录下的文件放到 web 服务器上进行打开
  • open 是否在打包时同时打开浏览器访问项目对应预览 url
  • port 端口号
  • proxy 设置代理

WebpackDevServer 配置示例

webpackDevServer: {
    contentBase: './dist',
    open: true,
    port: 8080,
    proxy: {
        'api': 'xxxxx'
    }
}

拓展内容

其实相当于自己手写一个 webpack-dev-server,但人家官方已经帮我们写好一个各配置项都齐全的一个了,自己不用手写了,只是带大家进行拓展,理解一下 webpack-dev-server 背后的源码是如何搭配 node 实现的

在 node 中使用 webpack

查看官方文档 的 Node.js API 板块

在命令行中使用 webpack

查看官方文档 的 Command Line Interface 板块

Hot Module Replacement

热模块更新 HMR

当内容发生更改时,只有更改的那部分发生变化,其他已加载的部分不会重新加载(例如修改css样式,只有对应样式更改,js不会改变)

参数

  • hot 开启热模块更新
  • hotOnly 设置为 true 后,无论热更新是否开启,都禁用 webpackDevServer 的保存后自动刷新浏览器功能

也是配置到 webpackDevServer 里

HMR配置示例

const webpack = require('webpack');
devServer: {
    hot: true,
    hotOnly: true
}
plugins: [
    new webpack.HotModuleReplacementPlugin()
]

上面是让 HMR 生效,下面是对 HMR 进行使用

// 例子:当 number.js 发生更改时,会调用函数
import number from './number';
number();
if(module.hot) {
    module.hot.accept('./number', () => {
        number();
    })
}

但上面的 使用 一般不用写,因为其实很多地方都已经写好了,内置了 HMR 组件,例如css的话 css-loader 里面给你写好了,vue 的话 vue-loader 里写好了,react 的话 babel-preset 写好了。

如果你要引入比较冷门的数据文件,没有内置 HMR,就需要写。

使用 Babel 处理 ES6 语法

babel-loader、@babel/preset-env、@babel/polyfill

  • babel-loader 的配置选项可以单独写进 .babelrc 文件里
  • 除了将 ES6 转换成 ES5 还不够,有些低版本浏览器还需要将 Promise、Array.map 注入额外代码,需要引入 @babel/polyfill
@babel/preset-env 的参数

useBuiltIns: 'usage' // 对于使用的代码,才转译成 ES5 并打包至 dist 文件夹
targets: 该代码运行环境,根据环境来判定是否要做 ES6 的转化
{
    chrome: '67' // 谷歌浏览器版本大于67,对 ES6 能直接正常编译,所以没必要做 ES5 的转换了
}

使用示例:

module: {
    rules: [
        {
            test: /.js$/,
            exclude: /node_modules/,
            loader: "babel-loader",
            options: {
                // 以下演示两种方案
                presets: [["@babel/preset-env", {
                    targets: {
                        edge: '17',
                        firefox: '60',
                        chrome: '67',
                        safari: '11.1'
                    },
                    useBuiltIns: 'usage'
                }]]
                
                // 以下是生成第三方库或源组件,不希望 babel 污染时才使用,可替换上面的 presets
                plugins: [["babel/plugin-transform-runtime", {
                    "corejs": 2,
                    "helpers": true,
                    "regenerator": true,
                    "useESModules": false
                }]]
            }
        }
    ]
}

如果你想在 react 使用 babel

实现对 React 框架代码的打包

下载 @babel/preset-react

.babelrc 文件

{
    presets: [
        [
            "babel/preset-env", {
                targets: {
                    chrome: "67",
                },
                useBuiltIns: "usage"
            }
        ],
        "@babel/preset-react"
    ]
}

Webpack 高级概念

webpack 的 Guides 板块

官方文档

Tree Shaking

只会对引入进行使用的代码进行打包,没引入进行使用的代码不会打包(可减少代码体积),webpack 2.0 之后默认开启该功能

  • 只支持 ES Module(静态引入)
  • 不支持 Common JS(动态引入)
import { } from '' // ESM  支持
const xxx = require('') // Common JS  不支持

development(开发环境) 默认不打开 Tree Shaking

因为如果开发环境进行调试时,如果每次重新编译打包后的代码都进行了 Tree Shaking,那就会让 debug 时代码行数对不上,不利于调试

如果你想开发环境打开

// package.json
{
    "sideEffects": false 或 数组
}
​
如果是数组,则配置你不想哪些代码进行 Tree Shaking
例如:如果不想 css 文件进行 Tree Shaking,则
{
    "sideEffects": ["*.css"]
}



Development 和 Production 模式的区分打包

通常来说,两个环境的 webpack 配置文件不会有变化,但如果非得区分,可以不同文件形式

webpack.dev.js 根据名字可知,是 development(开发环境)

webpack.proud.js 根据名字可知,是 production(生产环境)

打包脚本也要更改,如果区别开

// package.json
{
    "scripts": {
        "dev-build": webpack --config webpack.dev.js, // 相对路径
        
        或
        
        "proud-build": webpack --config webpack.proud.js, // 相对路径
    }
}



Webpack 和 Code Splitting

为什么要进行代码分割?

如果用户一个页面要加载的 js 文件很大,足足有2MB,那么用户每次访问这个页面,都要加载完2MB的资源页面才能正常显示,但其中可能有很多代码块是当前页面不需要使用的,那么将没使用的代码分割成其他 js 文件,当要使用的时候再进行加载、当页面变更时只有那部分进行重新加载,这样可以大大加快页面加载速度。

即通过配置进行合理的代码分割,能让文件结构更清晰,项目运行更快,比如如果用到 lodash 库,就分割出来。

下面通过一个例子进行解释:

假设现在有 main.js(2MB),里面含有 lodash.js(1MB)
1. 该种方式
首次访问页面时,加载 main.js(2MB)
当页面业务逻辑发生变化时,又要重新加载2MB内容
​
2. 将 lodash.js 抽离出来,即现在是 main.js(1MB) 和 lodash.js(1MB)
由于浏览器的并行机制,首次访问页面时,并行渲染两个1MB的文件是要比只渲染一个2MB的文件要快的。
其次,当页面业务逻辑发生变化时,只要重新加载 main.js(1MB) 即可。
​

同时,如果对于两个文件,如果都有用到某个模块,如果两个文件各自写一次这个模块,就会有重复,此时如果将这个公共模块抽离出来,两个文件分别去引用他,那么就会减少一次该模块的撰写(减少包体积)。

即对代码进行合理分割,还可以加快首屏加载速度,加快重新打包速度(包体积减少)

代码分割:通俗解释就是将一坨代码分割成多个 js 文件

代码分割自己可以手动,例如我们平时的抽离公共组件,但为什么现在 webpack 几乎跟 Code Splitting 绑定在一起了呢?

因为 webpack中有一个插件 SplitChunksPlugin,会让代码分割变得非常简单, 这也是 webpack 的一个强大的竞争力点

// webpack.config.js
{
    optimization: {
        splitChunks: {
            chunks: 'all'
        }
    }
}

总结一下

Webpack 进行 Code Splitting 有两种方式

1. 通过配置插件 SplitChunksPlugin

```
// webpack.config.js
{
    optimization: {
        splitChunks: {
            chunks: 'all'
        }
    }
}
```

然后编写同步代码

```
import _ from 'lodash';
// 编写业务代码
```

2. 通过异步地动态引入

function getComponent() {
    return import('lodash').then(({ default: _ }) => {
        let element = document.createElement('div');
        element.innerHTML = _.join(['zjc', 'handsome'], '-');
        return element;
    })
}
​
getComponent().then(element => {
    document.body.appendChild(element);
})

当然,想要支持异步地动态引入某个模块,需要先下载 babel-plugin-dynamic-import-webpack

然后

// .babelrc
{
    presets: [
        [
            "@babel/preset-env", {
                targets: {
                    chrome: "67"
                },
                useBuiltIns: 'usage'
            }
        ],
        "@babel/preset-react"
    ],
    plugins: ["dynamic-import-webpack"]
}



SplitChunksPlugin

该板块会对该插件配置参数进行详解

重要作用是可以减少包体积,缓存组中的 reuseExistingChunk 属性:开启true后,如果要分割进该组的模块在之前已经被缓存到了某个组内,那就不会再缓存

配置示例

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'all', // 只对哪些代码进行分割(all 的话全部都,async 的话只对异步代码进行分割)
      minSize: 30000, // 要做代码分割的引入库的最小大小,即30000代表如果你引入的库大小超过30KB才做代码分割
      minRemainingSize: 0,
      minChunks: 1, // 一个库被引入至少多少次才做代码分割
      maxAsyncRequests: 5, // 同时分割的库数
      maxInitialRequests: 3, // 最多能分割出多少个 js 文件
      automaticNameDelimiter: '~', // 代码分割出来的 js 文件名 和下面的组名 用什么符号进行连接
      name: 'true' // 当为 true 时,下面组的 filename 属性才会生效
      enforceSizeThreshold: 50000,
      // 缓存组,当打包同步代码时,除了走完上面的设置流程,还会额外再走进下面的组设置,即代码分割进下面符合要求的各组
      cacheGroups: {
        vendors: {
          test: /[\/]node_modules[\/]/, // 只有 node_modules 里面的库被引入时,才做代码分割到 vendor 组
          priority: -10, // 优先级,越大优先级越高
          reuseExistingChunk: true,
          filename: 'vendors.js', // vendors 组的代码分割都分割到 filename 文件内
          name: 'vendors' // 生成 vendors.chunk.js,该属性和上面的 filename 写一个就行
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true, // 开启true后,如果要分割进该组的模块在之前已经被缓存到了某个组内,那就不会再缓存
        },
      },
    },
  },
};

更多配置项请看官方文档

Lazy Loading

通过异步地动态引入某个模块,通常在路由配置页面,对引入的组件进行懒加载

function getComponent() {
    return import('lodash').then(({ default: _ }) => {
        let element = document.createElement('div');
        element.innerHTML = _.join(['zjc', 'handsome'], '-');
        return element;
    })
}
​
getComponent().then(element => {
    document.body.appendChild(element);
})



打包分析

应用 webpack 官方工具 Bundle Analysis

Preloading、Prefetching

Preloading 懒加载,当进行某个事件时,才会引入某个组件,例如点击某个元素时,才会 import 引入组件

PreFetching 预加载,当主页面的核心功能和交互都加载完成后,如果网络空闲,那么就会预先加载某个组件,这样在某个时刻引入该组件时,就能一下子打开

实现预加载:

webpack 搭配魔法注释,在引入的路径前加上 webpackPrefetch: xxx

document.addEventListener('click', () => {
    import(/* webpackPrefetch: true */ './click.js').then((func) => {
        func()
    })
})

这也是 webpack 最为推荐的首屏加载优化手段,异步引入 + 预加载

CSS 文件的代码分割

前面的 Code splitting 都是针对 js 的,将 js 文件进行代码分割,而打包出来的 css 都在 js 文件里

如果想 CSS 文件也代码分割出来,可以使用 MiniCssExtractPlugin 插件,该插件由于依赖热更新,所以只能运行在线上打包环境中

// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 
​
module.exports = {
  plugins: [ new MiniCssExtractPlugin() ],
  module: {
    rules: [
      {
        test: /.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
};

且默认将引入的各 css 文件合并到同一个 css 文件里

如果你想代码分割出来的 css 文件做代码压缩,重复属性合并到一起,可以使用 OptimizeCSSAssetsPlugin 插件

// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
​
module.exports = {
    optimization: {
        minimizer: [
            new OptimizeCSSAssetsPlugin({})
        ]
    },
    plugins: [ new MiniCssExtractPlugin() ],
    module: {
      rules: [
        {
          test: /.css$/,
          use: [MiniCssExtractPlugin.loader, "css-loader"],
        },
      ],
    },
}

如果想多入口引入的 css文件也合并在一起,同样需要用到代码分割

// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
​
module.exports = {
    optimization: {
        splitChunks: {
            cacheGroups: {
                styles: {
                    name: 'styles', // 都打包进 styles.css 的文件里
                    test: /.css$/,
                    chunks: 'all',
                    enforce: true // 无视默认参数,如果你在代码分割时设置过一些参数,当你对css文件进行代码分割时可以无视
                }
            }
        },
        plugins: [ new MiniCssExtractPlugin() ],
        module: {
          rules: [
            {
              test: /.css$/,
              use: [MiniCssExtractPlugin.loader, "css-loader"],
            },
          ],
        },
    }
}



Webpack 与浏览器缓存(Cache)

当浏览器加载过某项资源时会在本地进行缓存记忆,这样当用户下次再重新访问该页面加载该资源时,浏览器可以根据缓存过的文件快速加载该资源,直到该文件名发生改变,浏览器才知道该文件发生改变,需要重新渲染。

浏览器该特性的作用

可以加快加载速度,当该页面某个部分发生改变时,可以只重新渲染改变的部分,做到局部渲染

问题:如何保证每次打包时只有做了更改的文件的文件名发生更改,没做更改的文件的文件名不变?

如果在 webpack 配置文件中的 output 属性设置为如下

output: {
    filename: '[name].js',
    chunkFilename: '[name].chunk.js'
}

如果对项目中文件做了更改,而文件名没变,打包的 filename 没变,由于浏览器已经加载过该文件,缓存了这个文件名,当你该文件发生更改而文件名没改变时,浏览器不会重新渲染,为了让每次文件更改后文件名都发生改变,可以使用哈希值命名

output: {
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].chunk.js'
}

让 webpack 根据文件内容创建对应独立的一个哈希值,同时在文件内容发生改变时,由于哈希值也会改变,所以文件名也会改变

拓展

在老版本 webpack 中(webpack 4.0 以下),如果在每次运行 npm run build 进行打包时,发现即使文件没变更,每次重新打包他们的哈希值都会变,可以通过配置 runtimeChunk 解决

optimization: {
    runtimeChunk: {
        name: 'runtime'
    }
}
// 同时打包后会多生成一个 runtime.hash.js 的文件,hash每次都不一样

runtimeChunk 原理

假设我通过 webpack 打包出来有两个 js文件,A文件是业务逻辑相关代码,B文件是作代码分割时的库代码(例如 lodash),由于业务逻辑中有引入库的操作,所以他们之间会有关联,而这个关联的相关代码同时存在于A和B文件(这种关联我们一般称之为 manifest),而在每次打包时,manifest 内置的包和包的关系、js和js文件的嵌套关系会发生微小改变,所以即使A和B文件没做更改时,打包出来的哈希值还是会发生变化。

通过配置 runtimeChunk ,可以将这些 manifest 相关的代码抽离出来单独放在 runtimeChunk 中,因此每次重新打包,改变的只有runtime.hash.js,A文件只有业务逻辑,B文件只有库文件,A和B文件内都不会有任何 manifest 的代码了,这样A和B文件都不会发生改变了,因此哈希值就不会变了

Shimming

打包兼容,自动引入

在你页面使用到某个库,但没进行引入时,webpack 打包后的代码会帮你自动、"偷偷"进行引入

plugins: [
    new webpack.ProvidePlugin({
        $: 'jquery', // 当使用 $ 时,会自动在那个页面引入 jquery 库
        _: 'lodash', // 当使用 _ 时,会自动在那个页面引入 lodash 库
        _join: ['lodash', 'join'] // 当输入 _join 时,会引入 lodash库的 join 方法
    })
]



更多

环境变量的使用

对于开发环境和生产环境,可能有时真的需要单独写不同的 webpack 配置文件进行配置

而单独写,肯定有许多属性是重复的,又不想多写,怎么办呢?

例如A和B文件是两个环境中不同的配置参数,而C文件是共同的配置文件,那么开发环境打包时希望按照 A+C 的打包规则,生产环境打包时希望按照 B+C的打包规则

可以通过 webpack-merge 配置 开发环境 和 生产环境 的 不同配置文件

// webpack.common.js
​
const merge = require('webpack-merge');
const devConfig = require('./webpack.dev.js'); // 假设有这个文件,且导出的是开发环境的一些配置参数
const prodConfig = require('./webpack.prod.js'); // 假设有这个文件,且导出的是生产环境的一些配置参数
const commonConfig = {
    // 这里放开发和生产环境共有的一些配置参数
}
​
module.exports = (env) => {
    // 如果 env 参数存在,且传进来了 production 属性,说明是生产环境
    if(env && env.production) {
        return merge(commonConfig, prodConfig);
    } else {
        return merge(commonConfig, devConfig);
    }
}
​

同时 package.json 文件中修改配置

{
    scripts: {
        "dev": "webpack-dev-server --config webpack.common.js",
        "build": "webpack --env.production --config webpack.common.js" // 通过--env.production 传递参数进文件,执行线上环境的打包配置文件
    }
}

当然,脚本配置时,向配置文件传入参数也可以如下方式:

"build": "webpack --env production --config webpack.common.js"

同时,webpack.common.js 参数判断时也要改成

module.exports = (env, production) => {
    // 如果 env 参数存在,且传进来了 production 属性,说明是生产环境
    if(env && production) {
        return merge(commonConfig, prodConfig);
    } else {
        return merge(commonConfig, devConfig);
    }
}



🎁 谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。
🎁 我是 Smoothzjc,如果觉得写得可以的话,请点个赞吧❤
🎁 我也会在今后努力产出更多好文。
🎁 感兴趣的小伙伴也可以关注我的公众号:Smooth前端成长记录,公众号同步更新

写作不易,「点赞」+「收藏」+「转发」 谢谢支持❤

往期推荐

《都2022年了还不考虑来学React Hook吗?6k字带你从入门到吃透》

《Github + hexo 实现自己的个人博客、配置主题(超详细)》

《10分钟让你彻底理解如何配置子域名来部署多个项目》

《一文理解配置伪静态解决 部署项目刷新页面404问题

《带你3分钟掌握常见的水平垂直居中面试题》

《React实战:使用Antd+EMOJIALL 实现emoji表情符号的输入》

《【建议收藏】长达万字的git常用指令总结!!!适合小白及在工作中想要对git基本指令有所了解的人群》

《浅谈javascript的原型和原型链(新手懵懂想学会原型链?看这篇文章就足够啦!!!)》