谈谈你对webpack的理解

webpack主要是用来解决模块化打包问题的。

什么是模块

将某一个复杂的项目按照某种规则或者规范划分为多个文件,每个文件就是一个模块。模块内部是数据是私有的。

模块化实现历程

  • 通过script标签引入js文件
  • 在前者的基础上,使用命名空间的方式,每个模块只暴露一个对象。
  • 在前者的基础上,使用立即执行函数

早期模块化的方式中,每个能实现某些功能js文件被设计为一个单独的模块,然后通过script标签引入

1
2
<script src="module-a.js"></script>
<script src="module-b.js"></script>

这种方式的缺点很明显,被引入后,模块中的变量都成为全局变量,存在变量污染问题,而且模块之间没有依赖关系

随后,就出现了命名空间方式,规定每个模块暴露一个全局对象,然后模块的内容都挂载到这个对象中。

1
2
3
4
5
6
7
//moduleA.js
window.moduleA = {
data:20
method1: function () {
console.log('moduleA#method1')
}
}

这样在很大程度上解决了全局变量污染的问题,但是没有解决依赖混乱的问题,而且不安全,模块内部的数据可以被随意修改

后来又选择用立即执行函数为模块添加私有空间, 解决了内部数据可以被随意修改的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//moduleA.js
(function ($) {
//私有变量,不会挂载到window上,所以不能直接修改
var name = 'module-a'

function method1 () {
console.log(name + '#method1')
$('body').animate({ margin: '200px' })
}
//挂载到window对象上只向外暴露方法,而且暴露的方法也使用了命名空间的思想,避免了全局冲突
window.moduleA = {
method1
}
})(jQuery)

支持传入参数,能在一定程度上解决模块依赖问题,但是必须注意引入模块的先后顺序,否则就会出现undefined的问题。

理想的解决方式是,在页面中通过script标签引入一个JS入口文件,其余用到的模块可以通过代码控制,按需加载进来。

除了模块加载的问题以外,还需要规定模块化的规范,如今流行的则是CommonJSES Modules,关于二者的详细介绍参考本博客内的

nodejs | 三叶的博客一文

我们上述讨论的模块化的范围只限于js文件,后来html,css等文件也可以被模块化,这就需要借助webpack

模块化的好处

  • 解决了全局变量污染的问题

  • 提高了代码的可维护性与复用性

  • 使得项目中文件的依赖关系明确,支持按需加载。

什么是webpack

用于现代JavaScript应用程序的静态模块打包工具。

webpack的构建流程

初始化阶段

合并配置文件shell语句[Shell 语句指的是在命令行界面(CLI)或脚本中使用的指令]中的配置参数,得到最终的配置对象options

完成上述步骤之后,创建,并根据options对象初始化Compiler对象,该对象掌控者webpack生命周期,不执行具体的任务,只是进行一些调度工作

初始化插件(执行 new MyPlugin() 并调用插件的 apply 方法,注册回调函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Compiler extends Tapable {
constructor(context) {
super();
this.hooks = {
beforeCompile: new AsyncSeriesHook(["params"]),
compile: new SyncHook(["params"]),
afterCompile: new AsyncSeriesHook(["compilation"]),
make: new AsyncParallelHook(["compilation"]),
entryOption: new SyncBailHook(["context", "entry"])
//定义了很多不同类型的钩子
};
// ...
}
}

function webpack(options) {
var compiler = new Compiler();
// 检查options, 若watch字段为true, 则开启watch线程
// 后续的代码就是根据options来配置compiler
return compiler;
}
...

简单来说就做了这些事

  • 得到options配置对象
  • 创建,初始化compiler对象
  • 初始化插件(plugins)

编译阶段

Compiler初始化完成后会调用Compilerrun方法来真正启动webpack编译构建流程,主要流程如下:

  • compile 开始编译
  • 从入口文件开始,使用配置的loader转换文件,构建模块,并分析模块的依赖关系,递归构建模块。
  • build-module 构建模块
  • seal 封装构建结果
  • emit 把各个chunk输出到结果文件

compile 编译

执行了run方法后,首先会触发compile,主要是构建一个Compilation对象

该对象是编译阶段的主要执行者,主要会依次执行下述流程:执行模块创建依赖收集、分块、打包等主要任务。

make 编译模块

当创建了上述的compilation对象后,就开始从Entry入口文件开始读取,主要执行_addModuleChain()函数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
_addModuleChain(context, dependency, onModule, callback) {
// 根据依赖查找对应的工厂函数
const Dep = /** @type {DepConstructor} */ (dependency.constructor);
const moduleFactory = this.dependencyFactories.get(Dep);

// 调用工厂函数 NormalModuleFactory 的 create 方法来生成一个空的 NormalModule 对象
moduleFactory.create(
{
dependencies: [dependency],
// 其他配置项...
},
(err, module) => {
if (err) return callback(err);

// 定义每个模块构建完成后的回调函数
const afterBuild = () => {
//构建完模块之后,再分析模块的依赖
this.processModuleDependencies(module, (err) => {
if (err) return callback(err);
callback(null, module);
});
};
// 构建模块的过程中,Webpack 会读取模块内容并应用指定的 loaders。
// loaders在这里对模块的源代码进行转换(例如编译、转换语言特性、添加 polyfills 等)。
this.buildModule(module, false, null, null, (err) => {
if (err) return callback(err);
//就是上面定义的箭头函数
afterBuild();
});
}
);
}

build module 完成模块编译

对应上述代码中的this.buildModule()

这里主要调用配置的loaders,将我们的模块转成标准的JS模块

模块构建完成后,开始分析模块的依赖关系,对应上述代码中的 this.processModuleDependencies

从配置的入口模块开始,分析其 AST,当遇到require等导入其它模块的语句时,便将其加入到依赖的模块列表,如果有需要,则递归构建依赖的模块。

webpack是先使用loader处理入口文件,构建好模块后,进行依赖分析,发现其他模块,然后才对其他模块递归使用loader构建模块,再进行依赖分析,以此类推,就能构建完所有模块,并构建好模块依赖图。

打包并输出

seal 输出资源

seal方法主要任务是生成chunks,对chunks进行一系列的优化操作,并生成要输出的代码

根据入口文件和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk(如果只有一个入口文件,一般只有一个chunk),再把每个 Chunk 转换成一个单独的文件。

chunk

webpack 中的 chunk ,可以理解为配置在 entry(入口文件) 中的模块,或者是动态引入的模块。

每个 chunk 可以包含一个或多个模块,并且可以每个chunk可以被单独加载

  1. 入口点(Entry Points):每个入口点都会创建一个初始 chunk。

  2. 动态导入(Dynamic Imports): import() 动态加载模块时,会创建异步 chunks,它们是按需加载的。

  3. 代码分割(Code Splitting):开发者可以通过配置让 Webpack 根据某些规则自动分割代码到不同的 chunks 中。

emit 输出完成

在确定好输出内容后,根据配置确定输出的路径文件名

1
2
3
4
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js'
}

Compiler 开始生成文件前,钩子 compiler.hooks.emit 会被执行,这是我们修改最终文件的最后一个机会

从而webpack整个打包过程则结束了

小结

说说webpack中常见的Loader?解决了什么问题?

是什么

loader 本质是一个函数,用于对文件源代码进行转换,使之变为webpack可用的模块,在 import加载模块时预处理文件

webpack做的事情,仅仅是分析出各种模块的依赖关系,然后形成资源列表,最终打包生成到指定的文件中。如下图所示:

webpack内部中,任何文件都是模块,不仅仅只是js文件,这得益于loader扩大了模块化的范围

默认情况下,在遇到import或者require加载模块的时候,webpack只支持对jsjson 文件打包

csssasspng等这些类型的文件的时候,webpack则无能为力,这时候就需要配置对应的loader进行文件内容的解析

配置方式

推荐在配置文件中配置,rules是一个数组,意味着我们可以给多种文件配置loader,每一类文件对应一个对象。

use也是一个数组,这意味着我们可以对任意一种文件使用多个loader,每个loader是一个对象的格式,loader是支持链式调用的,调用的顺序是从右至左的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{ loader: 'style-loader' },
{
loader: 'css-loader',
options: {
modules: true
}
},
{ loader: 'sass-loader' }
]
}
]
}
};

常见的loader

css-loader

分析 css 模块之间的关系,并合成⼀个 css

1
npm install --save-dev css-loader

如果只通过css-loader加载文件,这时候页面代码设置的样式并没有生效

原因在于,css-loader只是负责将.css文件进行一个解析,而并不会将解析后的css插入到页面中

如果我们希望再完成插入style的操作,那么我们还需要另外一个loader,就是style-loader

style-loader

css-loader 生成的内容,用 style 标签挂载到页面的 head 中,简单来说就是把css-loader生成的css代码内联到html文件中。

1
npm install --save-dev style-loader
1
2
3
4
5
6
7
rules: [
...,
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
}
]

less-loader

开发中,我们也常常会使用lesssassstylus预处理器编写css样式,使开发效率提高,这里需要使用less-loader

1
npm install less-loader -D
1
2
3
4
5
6
7
rules: [
...,
{
test: /\.css$/,
use: ["style-loader", "css-loader","less-loader"]
}
]

编写loader

在编写 loader 前,我们首先需要了解 loader 的本质

其本质为函数,函数中的 this 作为上下文会被 webpack 填充,指向 webpack 提供的对象,能够获取当前 loader 所需要的各种信息,因此我们不能将 loader设为一个箭头函数

函数接受一个参数source,为 webpack 传递给 loader 的文件源内容

函数中有异步操作或同步操作,异步操作通过 this.callback 返回,返回值要求为 string 或者 Buffer

代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 导出一个函数,source为webpack传递给loader的文件源内容
module.exports = function(source) {
// content:String | Buffer,经过 loader 编译后需要导出的内容
const content = doSomeThing2JsString(source);

// 如果 loader 配置了 options 对象,那么this.query将指向 options
const options = this.query;

// 可以用作解析其他模块路径的上下文
console.log(this.context);

/*
* this.callback 参数:
* error:Error | null,当 loader 出错时向外抛出一个 error
* content:处理后的模块内容
* sourceMap:供一个 sourcemap,方便调试原始代码和编译后的代码之间的映射关系。
* 这对于开发者工具来说非常有用,因为它们可以利用这些信息来显示原始代码而非编译后的代码。
* ast:本次编译生成的AST抽象语法树,常用的解析器如 Babel可以将代码转换为 AST
* 之后执行的loader可以直接使用这个AST,进而省去重复生成 AST 的过程
*/
this.callback(null, content); // 异步
//返回经过loader编译后的内容
return content; // 同步
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = function(source, inputSourceMap) {
// 模拟异步操作,如读取文件或进行复杂的代码转换
setTimeout(() => {
try {
// 假设我们进行了某些操作并产生了新的 source 和 sourceMap
const newSource = source.replace(/foo/g, 'bar');
const newSourceMap = JSON.stringify(inputSourceMap); // 示例中简单地序列化 source map
// 成功完成任务,调用 callback 并传入结果
this.callback(null, newSource, newSourceMap);
} catch (error) {
// 发生错误,通过 error 参数返回错误信息
this.callback(error);
}
}, 1000);
};

一般在编写loader的过程中,保持功能单一,避免做多种功能

less文件转换成 css文件也不是一步到位,而是 less-loadercss-loaderstyle-loader几个 loader的链式调用才能完成转换

说说webpack中常见的Plugin?解决了什么问题?

是什么

Plugin(Plug-in)是一种计算机应用程序,它和主应用程序互相交互,以提供特定的功能

是一种遵循一定规范的应用程序接口编写出来的程序,只能运行在程序规定的系统下,因为其需要调用原纯净系统提供的函数库或者数据

webpack中的plugin也是如此,plugin赋予其各种灵活的功能,例如打包优化、资源管理、环境变量注入等,它们会运行在 webpack 的不同阶段(钩子 / 生命周期),贯穿了webpack整个编译周期

主要用来解决loader无法解决的其他事情,本质是一个具有apply方法的js对象(区别于vue的插件本质是一个具有install方法的对象),这个方法会被compiler对象调用。webpack构建过程中会广播很多事件,plugin可以监听自己感兴趣的事件,从而改变最后的打包结果。

配置方式

这里讲述文件的配置方式,一般情况,通过配置文件导出对象中plugins属性传入new实例对象。如下所示:

1
2
3
4
5
6
7
8
9
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通过 npm 安装
const webpack = require('webpack'); // 访问内置的插件
module.exports = {
...
plugins: [
new webpack.ProgressPlugin(),
new HtmlWebpackPlugin({ template: './src/index.html' }),
],
};

特性

插件本质是一个,插件实例其本质是一个具有apply方法javascript对象

apply方法被调用的时候会传入compiler对象

1
2
3
4
5
6
7
8
9
10
11
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, (compilation) => {
console.log('webpack 构建过程开始!');
});
}
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

tap 方法是用来注册一个函数,当某个特定的钩子被触发时,这个函数就会被执行。你可以把它看作是一种订阅模式,你的插件“订阅”了特定事件,并提供了一个回调函数,在该事件发生时执行。

compiler hooktap方法的第一个参数,应是驼峰式命名的插件名称

关于整个编译生命周期钩子(hooks),有如下:

  • entry-option :初始化 option
  • run:会在 Webpack 开始编译之前触发
  • compile: 真正开始的编译,在创建 compilation 对象之前
  • compilation :生成好了 compilation 对象
  • make:从 entry 开始递归分析依赖,准备对每个模块进行 build
  • after-compile: 编译 build 过程结束
  • emit :在将内存中 assets 内容写到磁盘文件夹之前
  • after-emit :在将内存中 assets 内容写到磁盘文件夹之后
  • done: 完成所有的编译过程
  • failed: 编译失败的时候

常见的plugin

  • html-webpack-plugin:

    在打包结束后,自动生成⼀个 html 文件,并自动引入打包后的js,css文件(自动注入到head标签中)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const HtmlWebpackPlugin = require("html-webpack-plugin");
    module.exports = {
    plugins: [
    new HtmlWebpackPlugin({
    title: "My App",
    filename: "app.html",
    template: "./src/html/index.html"
    })
    ]
    };
  • mini-css-extract-plugin:

    提取 CSS 代码到一个单独的文件中,通常用来代替style-loader

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    const MiniCssExtractPlugin = require('mini-css-extract-plugin');
    module.exports = {
    module: {
    rules: [
    {
    test: /\.s[ac]ss$/,
    use: [MiniCssExtractPlugin.loader,'css-loader','sass-loader']
    }
    ]
    },
    plugins: [
    new MiniCssExtractPlugin({
    filename: '[name].css'//放置提取出的css代码的css文件的文件名
    })
    ]
    }

更多的常见plugin可参考:webpack基础 | 三叶的博客

编写plugin

由于webpack基于发布订阅模式,在整个编译周期中会广播出许多事件,插件通过监听感兴趣的事件,并调用webpack提供的api,就可以在特定的阶段执行自己的插件任务。

在之前也了解过,webpack编译会创建两个核心对象:

  • compiler:包含了 webpack 环境的所有的配置信息,包括 options,loader 和 plugin,和 webpack 整个生命周期相关的钩子
  • compilation:作为 plugin内置事件回调函数的参数,包含了当前的模块资源、编译生成资源、变化的文件以及被跟踪依赖的状态信息。当检测到一个文件变化,一次新的 Compilation 将被创建

如果自己要实现plugin,也需要遵循一定的规范:

  • 插件必须是一个函数或者是一个包含 apply 方法的对象,这样才能访问compiler实例
  • 传给每个插件的 compilercompilation 对象都是同一个引用,因此不建议修改
  • 异步的事件需要在插件处理完任务时,调用回调函数通知 Webpack 进入下一个流程,不然会卡住

实现plugin的模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
class MyPlugin {
// Webpack 会调用 MyPlugin 实例的 apply 方法给插件实例传入 compiler 对象
// 所以初始化插件的时候,
apply (compiler) {
// 找到合适的事件钩子,实现自己的插件功能
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation: 当前打包构建流程的上下文
console.log(compilation);
// do something...
})
}
}

emit 钩子:是一个特殊的钩子,它在 Webpack 准备好要输出所有资源文件到磁盘之前触发,允许插件作者在这个关键时刻介入处理或修改即将输出的内容。

tap 方法:用来注册一个回调函数到特定的 Webpack 钩子上,使得当这个钩子被触发时,能够执行你的自定义逻辑。

说说Loader和Plugin的区别?

前面两节我们有提到LoaderPlugin对应的概念,先来回顾下

  • loader 是文件加载器,能够加载资源文件,并对这些文件进行一些处理,诸如编译、压缩等,最终一起打包到指定的文件中

  • plugin 赋予了 webpack 各种灵活的功能,例如打包优化、资源管理、环境变量注入等,目的是解决 loader 无法实现的其他事,

    比如提取css代码到一个单独的文件。

从整个运行时机上来看,如下图所示:

可以看到,两者在运行时机上的区别:

  • loader 运行在打包文件之前
  • plugins 在整个编译周期都起作用

Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过Webpack提供的 API改变输出结果

对于loader,实质是一个转换器,将A文件进行编译形成B文件,操作的是文件,比如将A.scssA.less转变为B.css,单纯的文件转换过程。

webpack类似的工具还有哪些?区别?

模块化是一种处理复杂系统分解为更好的可管理模块的方式

每个模块完成一个特定的子功能,所有的模块按某种方法组装起来,成为一个整体(bundle)

在前端领域中,并非只有webpack这一款优秀的模块打包工具,还有其他类似的工具,例如RollupParcelsnowpack,以及最近风头无两的Vite

这里没有提及gulpgrunt是因为它们只是定义为构建工具,不能类比,关于gulp的介绍可参考hexo博客搭建的一些思考 | 三叶的博客

Rollup

Rollup 是一款 ES Modules 打包器,从作用上来看,RollupWebpack 非常类似。不过相比于 WebpackRollup小巧的多

现在很多我们熟知的库都都使用它进行打包,比如:VueReactthree.js

举个例子:

1
2
3
4
// ./src/messages.js
export default {
hi: 'Hey Guys, I am zce~'
}
1
2
3
4
5
6
7
8
9
10
11
// ./src/logger.js
export const log = msg => {
console.log('---------- INFO ----------')
console.log(msg)
console.log('--------------------------')
}
export const error = msg => {
console.error('---------- ERROR ----------')
console.error(msg)
console.error('---------------------------')
}
1
2
3
4
// ./src/index.js
import { log } from './logger'
import messages from './messages'
log(messages.hi)

然后通过rollup进行打包,把index.js文件和它依赖的模块打包成一个chunk,结果如下:

1
2
3
4
5
6
7
8
9
const log = msg => {
console.log('---------- INFO ----------')
console.log(msg)
console.log('--------------------------')
}
var messages = {
hi:'Hey Guys,I am zce~'
}
log(messages.hi);

可以看到,代码非常简洁,完成不像webpack那样存在大量引导代码和模块函数

并且error方法由于没有被使用,输出的结果中并无error方法,可以看到,rollup默认使用Tree-shaking 优化输出结果

因此,可以看到Rollup优点

  • 打包后的代码更简洁、打包效率更高
  • 默认支持 Tree-shaking

缺点也十分明显,不能处理其他类型的资源文件CommonJS 模块,又或是编译 ES 新特性,这些额外的需求 ,Rollup需要使用插件去完成。

综合来看,rollup并不适合开发应用,因为需要使用第三方模块,而目前第三方模块大多数使用CommonJs方式导出成员,并且rollup不支持HMR,使开发效率降低(所以vite只在生产打包的时候使用rollup)

但是在用于打包JavaScript 库时,rollupwebpack 更有优势,因为其打包出来的代码更小、速度更快,其存在的缺点可以忽略。

Parcel

Parcel ,是一款完全零配置的前端打包器,它提供了 “傻瓜式” 的使用体验,只需了解简单的命令,就能构建前端应用程序。

ParcelWebpack 一样都支持以任意类型文件作为打包入口,但建议使用HTML文件作为入口

1
2
3
4
5
6
7
8
9
10
11
<!-- ./src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Parcel Tutorials</title>
</head>
<body>
<script src="main.js"></script>
</body>
</html>

main.js文件通过ES Moudle方法导入其他模块成员

1
2
3
4
5
// ./src/logger.js
export const log = msg => {
console.log('---------- INFO ----------')
console.log(msg)
}
1
2
3
// ./src/main.js
import { log } from './logger'
log('hello parcel')

运行之后,使用命令打包

1
npx parcel src/index.html

执行命令后,Parcel不仅打包了应用,同时也启动了一个开发服务器,跟webpack Dev Server一样

webpack类似,也支持模块热替换(HMR),但用法更简单

同时,Parcel有个十分好用的功能:支持自动安装依赖,像webpack开发阶段突然需要安装某个第三方依赖,必然会终止dev server然后安装再启动。而Parcel则免了这繁琐的工作流程。

同时,Parcel能够零配置加载其他类型的资源文件,无须像webpack那样配置对应的loader

由于打包过程是多进程同时工作,构建速度会比Webpack 快,输出文件也会被压缩,并且样式代码也会被单独提取到单个文件

可以感受到,Parcel给开发者一种很大的自由度,只管去实现业务代码,其他事情用Parcel解决

Snowpack

Snowpack,是一种闪电般快速的前端构建工具,专为现代Web设计,较复杂的打包工具(如WebpackParcel)的替代方案,利用JavaScript的本机模块系统,避免不必要的工作并保持流畅的开发体验。

开发阶段,每次保存单个文件时,WebpackParcel都需要重新构建重新打包应用程序的整个bundle,这个过程包括:重新解析依赖关系,重新优化和压缩,重新生成资源文件。而Snowpack为你的应用程序每个文件构建一次,就可以永久缓存,文件更改时,Snowpack只会重新构建该单个文件

Vite

vite ,是一种新型前端构建工具,能够显著提升前端开发体验

它主要由两部分组成:

  • 一个开发服务器,它基于原生ES 模块提供了丰富的内建功能,如速度快到惊人的模块热更新HMR
  • 一套构建指令,它使用 Rollup打包你的代码,并且它是预配置的,可以输出用于生产环境的优化过的静态资源

其作用类似webpack+ webpack-dev-server,其特点如下:

  • 快速的冷启动
  • 即时的模块热更新
  • 真正的按需编译

vite会直接启动开发服务器,不需要进行打包操作,因此启动速度非常快

利用现代浏览器支持ES Module的特性,当浏览器请求某个模块的时候,再根据需要对模块的内容进行编译,这种方式大大缩短了编译时间。

原理图如下所示:

在热模块HMR方面,当修改一个模块的时候,仅需让浏览器重新请求该模块即可,无须像webpack那样需要把该模块的相关依赖模块全部编译一次,效率更高。

简述

Webpack

相比上述的模块化工具,webpack大而全,很多常用的功能做到开箱即用。有两大最核心的特点:一切皆模块按需加载

与其他构建工具相比,有如下优势:

  • 智能解析:对 CommonJS 、 AMD 、ES6 的语法做了兼容
  • 万物模块:对 js、css、图片等资源文件都支持打包,不过需要通过配置loader来实现
  • 开箱即用:HMR、Tree-shaking等功能
  • 代码分割:可以将代码切割成不同的 chunk,实现按需加载,降低了初始化时间
  • 插件系统,具有强大的 Plugin 接口,具有更好的灵活性和扩展性
  • 易于调试:支持 SourceUrls 和 SourceMaps
  • 快速运行:webpack 使用异步 IO 并具有多级缓存,这使得 webpack 很快且在增量编译上更加快
  • 生态环境好:社区更丰富,出现的问题更容易解决

简述vite和webpack的区别

我们只讨论最主要的区别:核心构建机制

  • Vite
    开发阶段基于浏览器原生 ​ESM 模块,按需编译请求的模块,无需全量打包。直接启动开发服务器,冷启动速度极快(毫秒级)。生产环境则使用Rollup进行打包,优化 Tree-Shaking 和代码体积。优势:避免全量编译,启动和热更新效率高,适合现代浏览器优先的项目。

    关键点:

    • 基于现代浏览器对esm模块的支持
    • 按需编译模块
    • 立即启动开发服务器,启动速度非常快
    • 某个模块被修改只需重新请求该模块即可
  • Webpack
    始终采用 ​全量打包 机制,递归分析所有依赖并生成 Bundle 文件,启动和热更新速度随项目规模增大显著变慢。生产构建依赖自身插件体系,成熟但复杂度高。优势:兼容性强,支持老旧浏览器和复杂代码分割需求。

    关键点:

    • 全量打包,开发服务器启动速度较慢

    • 某个模块被修改并保存后,Webpack 重新编译被修改的模块,和直接依赖这个模块的模块,而非整个项目(不再是全量打包)

      1
      A → B → C(修改了 C)

      当文件 C 被修改并保存时:

      1. Webpack 检测到 C 的变化。
      2. 重新编译 C 及其父模块 B(若 B 的代码依赖 C 的逻辑)。
      3. 不重新编译无关模块(如 A
      4. 通过 HMR 将更新后的模块推送到浏览器,局部更新界面。
    • 浏览器兼容性更好,不需要浏览器对esm模块的支持

说说如何借助webpack来优化前端性能?

背景

随着前端的项目逐渐扩大,必然会带来的一个问题就是性能

尤其在大型复杂的项目中,前端业务可能因为一个小小的数据依赖,导致整个页面卡顿甚至奔溃

一般项目在完成后,会通过webpack进行打包,利用webpack对前端项目性能优化是一个十分重要的环节

如何优化

通过webpack优化前端的手段有:

  • JS,CSS,Html代码压缩
  • 文件大小压缩
  • 图片压缩
  • Tree Shaking
  • 代码分离
  • 内联 chunk

js代码压缩

terser是一个JavaScript的解释、绞肉机、压缩机的工具集,可以帮助我们压缩、丑化我们的代码,让bundle更小

production模式下,webpack 默认就是使用 TerserPlugin 来处理我们的代码的。如果想要自定义配置它,配置方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
...
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true // 电脑cpu核数-1
})
]
}
}

压缩css代码

CSS压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等

CSS的压缩我们可以使用另外一个插件:css-minimizer-webpack-plugin

1
npm install css-minimizer-webpack-plugin -D
1
2
3
4
5
6
7
8
9
10
11
12
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
module.exports = {
// ...
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin({
parallel: true
})
]
}
}

HTML代码压缩

使用HtmlWebpackPlugin插件来生成HTML的模板时候,通过配置属性minify进行html优化

关于HtmlWebpackPlugin插件的详细使用方法,可参考webpack基础 | 三叶的博客

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
...
plugin:[
new HtmlwebpackPlugin({
...
minify:{
minifyCSS:false, // 是否压缩css
collapseWhitespace:false, // 是否折叠空格
removeComments:true // 是否移除注释
}
})
]
}

设置了minify,实际会使用另一个插件html-minifier-terser

文件大小压缩

前面介绍的都是代码压缩,是指对源代码进行处理,以减小其体积而不改变其功能。

代码压缩通常涉及以下几种操作:

  • 移除空白字符:包括空格、制表符、换行符等。
  • 缩短变量名和函数名:将长的标识符替换为短的名字,比如从myVariableName变成a
  • 移除注释:在生产环境中,注释是没有必要的,所以会被删除。
  • 简化语句:例如,合并多个var声明或者将一些表达式简化。

文件大小压缩则是指使用算法对文件内容进行编码,从而生成一个更小的表示形式,有时可能会导致文件类型的改变(如压缩成.zip或.rar档案)

常见的文件压缩方法有:

  • 无损压缩:如ZIP、Gzip、Brotli等,可以完全还原原始文件的内容。这些压缩方法适用于所有类型的文件,并且特别适合于文本文件,因为文本文件中往往存在很多重复模式,容易被压缩算法利用。
  • 有损压缩:如JPEG图片压缩,视频编码等,通过去除一些人类视觉或听觉不易察觉的信息来减小文件大小,但不能完全恢复原始文件。

在网络传输中,服务器常常会在发送响应之前使用GzipBrotli等压缩算法对整个响应体(包含HTML、JS、CSS等)进行压缩,以减少传输的数据量。当客户端接收到这个压缩后的数据后,会自动解压并处理

1
npm install compression-webpack-plugin -D
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
// 其他 webpack 配置...
plugins: [
new CompressionPlugin({
filename: '[path][base].gz', // 输出文件名,默认会加上 .gz 后缀
algorithm: 'gzip', // 指定使用的压缩算法
test: /\.js$|\.css$|\.html$/, // 匹配要压缩的文件类型
threshold: 10240, // 只有大于该值的资源会被压缩,单位是 bytes (默认值是 10KB)
minRatio: 0.8, // 压缩率小于这个值的资源会被压缩 (默认值是 0.8)
deleteOriginalAssets: false // 是否删除原文件 (默认为 false)
}),
],
};

图片压缩

一般来说在打包之后,一些图片文件的大小,是远远要比 js 或者 css 文件要来的大,所以图片压缩较为重要

image-webpack-loader,这是一个专门用来压缩图片的加载器。它不会影响文件的存储位置或名称,而是专注于减少图像文件的大小。

TreeShaking

Tree Shaking 是一个术语,在计算机中表示消除死代码(一般指的是js代码),基于ES Module的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系)

在 Webpack5 中,Tree Shaking 在生产环境下默认启动,这就意味着不需要配置usedExports,同时还会自动启用代码压缩

如果想在开发环境启动 Tree Shaking,需要配置 optimization.usedExports 为 true,启动标记功能;

1
2
3
4
5
6
module.exports = {
...
optimization:{
usedExports:true
}
}

usedExports 用于在 Webpack 编译过程中启动标记功能,使用之后,没被用上的变量/函数(包括未导入的函数/变量和导入后未使用的函数/变量),在webpack打包中会被加上unused harmony export注释,当生成产物时,被标记的变量/函数对应的导出语句会被删除。

当然,仅仅删除未被使用的变量/函数的导出语句是不够的,若 Webpack 配置启用了代码压缩工具,如 Terser 插件,那么在打包的最后它还会删除所有引用被标记内容的代码语句,这些语句一般称作 Dead Code。可以说,真正执行 Tree Shaking 操作的是 Terser 插件。

如下面sum函数没被用到,webpack打包会添加注释,terser在优化时,则将该函数连同引用该函数的代码删除掉。

要注意的是,上述注释只有在开发打包下,开启usedExports,不开启代码压缩,才能看到。

但是,并不是所有 Dead Code 都会被 Terser 删除

1
2
3
4
5
6
7
8
9
10
11
// src/math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
console.log(square(10));
// src/index.js
import { cube } from './math.js';
console.log(cube(5));

我们添加一条console.log语句,它打印了调用 squre 函数的返回结果,index.js 保留原样。按照我们之前的设想,打包后会删除与 square 函数相关的代码语句,即 square 函数的声明语句、打印语句都会被删除。

然而实际上,打包后的math.js 模块中,square 函数的痕迹被完全清除,但是打印语句仍然被保留。这是因为,这条语句存在副作用

副作用(side effect) 的定义是,在导入时会执行特殊行为的代码(不是export,而是比如调用函数之类的代码)。例如 polyfill,它影响全局作用域,因而存在副作用。

显然,以上示例的 console.log() 语句存在副作用。Terser 在执行 Tree Shaking 时,会保留它认为存在副作用的代码,而不是将其删除,即便这个代码是Dead code。

作为开发者,如果你非常清楚某条语句会被判别为有副作用,但其实是无害的(删除后无影响),应该被删除,可以使用 /*#__PURE__*/ 注释,来向 terser 传递信息,表明这条语句是纯的,没有副作用,terser 可以放心将它删除:

1
2
3
4
5
6
7
8
// src/math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
/*#__PURE__*/ console.log(square(10));

然后在打包结果中就不会有console.log语句

sideEffects

sideEffects用于告知webpack compiler哪些模块是有副作用(区别于pure注释的代码层面),

"sideEffects"package.json 的一个字段,默认值为 true,即认为所有模块都可能是有副作用的。如果你非常清楚你的 package 是纯粹的,不包含副作用,那么可以简单地将该属性标记为 false,来告知 webpack 整个包都是没有副作用的,可以安全地删除所有未被使用的代码(Dead Code),执行比较激进的tree-shaking;如果你的 package 中有些模块确实有一些副作用,可以改为提供一个数组:

1
2
3
4
"sideEffects":[
"./src/util/format.js",
"*.css" // 所有的css文件
]

更多内容参考:Webpack 5 实践:你不知道的 Tree Shaking本篇文章从 什么是 Tree Shaking、如何使用 T - 掘金

在本博客内的《前端面试-vue》一文中也有对tree-shaking的部分介绍。

总结一下关键点:

  • tree-shaking的作用是用来删除未使用的js代码,减小最终代码的体积

  • tree-shaking基于esm的静态导入,即在编译的时候就能确定哪些模块的具体哪些功能被使用

  • 生产模式打包,会默认开启tree-shaking,开发模式打包,需要手动开启tree-shaking,即设置usedExports属性为true。

  • 默认情况,tree-shaking会标记模块中未被导入的变量和函数,以及导入了但是没有被使用的变量和函数,然后使用terser压缩js代码的时候,不仅仅会删除这些被标记的的变量和函数,还会删除引用了这些被标记的变量和函数的代码,这些代码被叫做dead code

  • 但是如果某条dead code被认为是有副作用的,则不会被tree-shaking删除,比如console.log语句

代码分离

将代码分离到不同的bundle中,之后我们可以按需加载,或者并行加载这些文件

默认情况下,所有的JavaScript代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载,就会影响首页的加载速度

代码分离可以分出更小的bundle,以及控制资源加载优先级,提供代码的加载性能

这里通过splitChunksPlugin来实现,该插件webpack已经默认安装和集成,只需要配置即可

默认配置中,chunks仅仅针对于异步(async)请求,我们可以设置为initial或者all

1
2
3
4
5
6
7
8
module.exports = {
...
optimization:{
splitChunks:{
chunks:"all"
}
}
}

splitChunks主要属性有如下:

  • Chunks:对同步代码还是异步代码进行处理
  • minSize: 拆分包的大小, 至少为minSize,如果包的大小不超过minSize,这个包不会拆分
  • maxSize: 将大于maxSize的包,拆分为不小于minSize的包
  • minChunks:被引入的次数,默认是1

内联chunk

可以通过InlineChunkHtmlPlugin插件将一些chunk的模块内联到html,如runtime的代码(对模块进行解析、加载、模块信息相关的代码),代码量并不大,但是必须加载的。

1
2
3
4
5
6
7
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
...
plugin:[
new InlineChunkHtmlPlugin(HtmlWebpackPlugin,[/runtime.+\.js/]
}

说说webpack的热更新是如何做到的?原理是什么?

是什么

HMR全称 Hot Module Replacement,可以理解为模块热替换,指在应用程序运行过程中,替换、添加、删除模块,而无需重新刷新整个应用。

例如,我们在应用运行过程中修改了某个模块,通过自动刷新,会导致整个应用的整体刷新,那页面中的状态信息都会丢失

如果使用的是 HMR,就可以实现只将修改的模块实时替换至应用中,不必完全刷新整个应用。

webpack中配置开启热模块也非常的简单,如下代码:

1
2
3
4
5
6
7
8
9
const webpack = require('webpack')
module.exports = {
// ...
devServer: {
// 开启 HMR 特性
hot: true
// hotOnly: true
}
}

通过上述这种配置,如果我们修改并保存css文件,确实能够以不刷新的形式更新到页面中

但是,当我们修改并保存js文件之后,页面依旧自动刷新了,这里并没有触发热模块

所以,HMR并不像 Webpack 的其他特性一样可以开箱即用,需要有一些额外的操作

我们需要去指定哪些模块发生更新时进行HRM,如下代码:

1
2
3
4
5
if(module.hot){
module.hot.accept('./util.js',()=>{
console.log("util.js更新了")
})
}

实现原理

  • Webpack Compile:将 JS 源代码编译成 bundle.js
  • HMR Server:用来将热更新的文件输出给 HMR Runtime
  • Bundle Server:静态资源文件服务器,提供文件访问路径
  • HMR Runtime:socket服务器,会被注入到浏览器,更新文件的变化
  • bundle.js:构建输出的文件
  • 在HMR Runtime 和 HMR Server之间建立 websocket,即图上4号线,用于实时更新文件变化

上面图中,可以分成两个阶段:

  • 启动阶段为上图 1 - 2 - A - B

在编写未经过webpack打包的源代码后,Webpack Compile 将源代码和 HMR Runtime 一起编译成 bundle文件,传输给Bundle Server 静态资源服务器

  • 更新阶段为上图 1 - 2 - 3 - 4

当某一个文件或者模块发生变化时,webpack监听到文件变化对文件重新编译打包,编译生成唯一的hash值,这个hash值用来作为下一次热更新的标识

根据变化的内容生成两个补丁文件:manifest(包含了 hashchundId,用来说明变化的内容)和chunk.js 模块

由于socket服务器在HMR RuntimeHMR Server之间建立 websocket链接,当文件发生改动的时候,服务端会向浏览器推送一条消息,消息包含文件改动后生成的hash值,如下图的h属性,作为下一次热更细的标识

在浏览器接受到这条消息之前,浏览器已经在上一次socket 消息中已经记住了此时的hash 标识,这时候我们会创建一个 ajax 去服务端请求获取到变化内容的 manifest 文件

mainfest文件包含重新build生成的hash值,以及变化的模块,对应上图的c属性

浏览器根据 manifest 文件获取模块变化的内容,从而触发render流程,实现局部模块更新

总结

关于webpack热模块更新的总结如下:

  • 通过webpack-dev-server创建两个服务器:提供静态资源的服务(express)和Socket服务
  • express server 负责直接提供静态资源的服务(打包后的资源直接被浏览器请求和解析)
  • socket server 是一个 websocket 的长连接,双方可以通信
  • 当 socket server 监听到对应的模块发生变化时,会生成两个文件.json(manifest文件)和.js文件(update chunk)
  • 通过长连接,socket server 可以直接将这两个文件主动发送给客户端(浏览器)
  • 浏览器拿到两个新的文件后,通过HMR runtime机制,加载这两个文件,并且针对修改的模块进行更新。

如何提高webpack的构建速度?

背景

随着我们的项目涉及到页面越来越多,功能和业务代码也会随着越多,相应的 webpack 的构建时间也会越来越久

构建时间与我们日常开发效率密切相关,当我们本地开发启动 devServer 或者 build 的时候,如果时间过长,会大大降低我们的工作效率

所以,优化webpack 构建速度是十分重要的环节

如何优化

常见的提升构建速度的手段有如下:

  • 优化 loader 配置
  • 合理使用 resolve.extensions
  • 优化 resolve.modules
  • 优化 resolve.alias
  • 使用 DLLPlugin 插件
  • 使用 cache-loader
  • terser 启动多线程
  • 合理使用 sourceMap

优化loader配置

在使用loader时,可以通过配置includeexcludetest属性来匹配文件

如采用 ES6 的项目为例,在配置 babel-loader时,可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.exports = {
module: {
rules: [
{
// 如果项目源码中只有 js 文件就不要写成 /\.jsx?$/,提升正则表达式性能
test: /\.js$/,
// 只对项目根目录下的 src文件夹中的文件采用 babel-loader
include: path.resolve(__dirname, 'src'),
// babel-loader支持缓存转换出的结果,通过 cacheDirectory 选项开启
use: ['babel-loader?cacheDirectory'],
},
]
},
};

说白了就是使用loader的时候,尽可能精确的匹配文件。

合理使用 resolve.extensions

在开发中我们会有各种各样的模块依赖,这些模块可能来自于自己编写的代码,也可能来自第三方库, resolve可以帮助webpack从每个 require/import 语句中,找到需要引入的,合适的模块代码

解析到未加扩展名的文件时,通过resolve.extensions,自动给文件添加拓展名,默认情况如下:

1
2
3
4
5
6
module.exports = {
...
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
}
}

当我们引入文件的时候,若没有文件后缀名,则会根据数组内的值依次查找

当我们配置的时候,则不要随便把所有后缀都写在里面,这会调用多次文件的查找,这样就会减慢打包速度

简单的来说就是,后缀自动填充数组的长度不要太长了。

优化 resolve.modules

resolve.modules 用于配置 webpack 去哪些目录下寻找第三方模块。默认值为['node_modules'],所以默认会从node_modules中查找文件。当安装的第三方模块都放在项目根目录下的 ./node_modules目录下时,所以可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:

1
2
3
4
5
6
7
module.exports = {
resolve: {
// 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
// 其中 __dirname 表示当前工作目录,也就是项目根目录
modules: [path.resolve(__dirname, 'node_modules')]
},
};

优化 resolve.alias

alias给一些常用路径起一个别名,特别当我们的项目目录结构比较深的时候,一个文件的路径可能是./../../的形式

通过配置alias以减少查找过程,在vue的脚手架中,这是自动配置好的。

1
2
3
4
5
6
7
8
module.exports = {
...
resolve:{
alias:{
"@":path.resolve(__dirname,'./src')
}
}
}

使用 cache-loader

在一些性能开销较大的 loader之前添加 cache-loader,以将结果缓存到磁盘里,显著提升二次构建速度

保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此loader

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
module: {
rules: [
{
test: /\.ext$/,
use: ['cache-loader', ...loaders],
include: path.resolve('src'),
},
],
},
};

terser 启动多线程

使用多进程并行运行来提高构建速度,其实默认就是使用多线程

1
2
3
4
5
6
7
8
9
module.exports = {
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
},
};

更多优化方式参考:面试官:如何提高webpack的构建速度?

说说webpack proxy工作原理?为什么能解决跨域?

是什么

webpack proxy,即webpack提供的代理服务

基本行为就是接收客户端发送的请求后,转发给其他服务器

其目的是为了便于开发者在开发模式下解决跨域问题(浏览器安全策略限制)

想要实现代理首先需要一个中间服务器(代理服务器),webpack中提供服务器的工具为webpack-dev-server

webpack-dev-server

webpack-dev-serverwebpack 官方推出的一款开发工具,将自动编译自动刷新浏览器等一系列对开发友好的功能,全部集成在了一起,目的是为了提高开发者日常的开发效率,只适用在开发阶段

关于配置方面,在webpack配置对象属性中,通过devServer属性提供,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ./webpack.config.js
const path = require('path')

module.exports = {
// ...
devServer: {
contentBase: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
proxy: {
'/api': {
target: 'https://api.github.com',
changeOrigin:true,
pathRewrite: { '^/api': '' }
}
}
// ...
}
}

proxy属性的名称是需要被代理的请求路径前缀,一般为了辨别都会设置前缀为/api,值为对应的代理匹配规则,对应如下:

  • target:表示的是代理到的目标地址
  • pathRewrite:默认情况下,我们的 /api也会被写入到URL中,如果希望删除,可以使用pathRewrite
  • secure:默认情况下,不接收转发到https的服务器上,如果希望支持,可以设置为false
  • changeOrigin:它表示是否更新代理请求headershost字段,如果这个值为true,则修改代理请求(代理服务器发送给目标服务器的请求)的host从localhost:8080api.github.com

工作原理

参考前端面试—vue部分一文中的跨域解决部分。