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

387 阅读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 -vnpm -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-cliwebpack 区别:

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

小问题:

为什么使用reactvue框架打包项目文件时不是输入 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 基本概念

webpackConcepts 板块

官方文档

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-loaderstyle-loadercss-loaderless-loadersass-loaderpostcss-loaderurl-loaderfile-loader 等等

图片(Images)

打包图片文件

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

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

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

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

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



样式(CSS)

打包样式文件

需要 css-loaderstyle-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.jsmodule.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.jsmodule.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.jsconst 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.jsconst 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.jsconst 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.jsconst 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.jsmodule.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 本次打包的所有内容

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

相关代码运行在 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 高级概念

webpackGuides 板块

官方文档

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.jsconst 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的原型和原型链(新手懵懂想学会原型链?看这篇文章就足够啦!!!)》