# 原理简述
Webpack 已经成为了现在前端工程化中最重要的一环,通过 Webpack
与 Node
的配合,前端领域完成了不可思议的进步。通过预编译,将软件编程中先进的思想和理念能够真正运用于生产,让前端开发领域告别原始的蛮荒阶段。深入理解 Webpack
,可以让你在编程思维及技术领域上产生质的成长,极大拓展技术边界。这也是在面试中必不可少的一个内容。
核心概念
JavaScript 的 模块打包工具 (module bundler)。通过分析模块之间的依赖,最终将所有模块打包成一份或者多份代码包 (bundler),供 HTML 直接引用。实质上,Webpack 仅仅提供了 打包功能 和一套 文件处理机制,然后通过生态中的各种 Loader 和 Plugin 对代码进行预编译和打包。因此 Webpack 具有高度的可拓展性,能更好的发挥社区生态的力量。
- Entry: 入口文件,Webpack 会从该文件开始进行分析与编译。
- Output: 出口路径,打包后创建 bundler 的文件路径以及文件名。
- Module: 模块,在 Webpack 中任何文件都可以作为一个模块,会根据配置的不同的 Loader 进行加载和打包。
- Chunk: 代码块,可以根据配置,将所有模块代码合并成一个或多个代码块,以便按需加载,提高性能。
- Loader: 模块加载器,进行各种文件类型的加载与转换。
- Plugin: 拓展插件,可以通过 Webpack 相应的事件钩子,介入到打包过程中的任意环节,从而对代码按需修改。
工作流程 (初始化 - 编译 - 输出)
- 读取配置文件,按命令 初始化 配置参数,创建 Compiler 对象。
- 调用插件的 apply 方法 挂载插件 监听,然后从入口文件开始执行编译。
- 按文件类型,调用相应的 Loader 对模块进行 编译,并在合适的时机点触发对应的事件,调用 Plugin 执行,最后再根据模块 依赖查找 到所依赖的模块,递归执行第三步。
- 将编译后的所有代码包装成一个个代码块 (Chuck), 并按依赖和配置确定 输出内容。这个步骤,仍然可以通过 Plugin 进行文件的修改。
- 最后,根据 Output 把文件内容一一写入到指定的文件夹中,完成整个过程。
模块包装
;(function (modules) {
// 模拟 require 函数,从内存中加载模块;
function __webpack_require__(moduleId) {
// 缓存模块
if (installedModules[moduleId]) {
return installedModules[moduleId].exports
}
var module = (installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {},
})
// 执行代码;
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
)
// Flag: 标记是否加载完成;
module.l = true
return module.exports
}
// ...
// 开始执行加载入口文件;
return __webpack_require__((__webpack_require__.s = './src/index.js'))
})({
'./src/index.js': function (
module,
__webpack_exports__,
__webpack_require__
) {
// 使用 eval 执行编译后的代码;
// 继续递归引用模块内部依赖;
// 实际情况并不是使用模板字符串,这里是为了代码的可读性;
eval(`
__webpack_require__.r(__webpack_exports__);
//
var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("test", ./src/test.js");
`)
},
'./src/test.js': function (module, __webpack_exports__, __webpack_require__) {
// ...
},
})
总结
- 模块机制: webpack 自己实现了一套模拟模块的机制,将其包裹于业务代码的外部,从而提供了一套模块机制.
- 文件编译: webpack 规定了一套编译规则,通过 Loader 和 Plugin,以管道的形式对文件字符串进行处理.
# Loader
于 Webpack 是基于 Node,因此 Webpack 其实是只能识别 js 模块,比如 css / html / 图片等类型的文件并无法加载,因此就需要一个对 不同格式文件转换器。其实 Loader 做的事,也并不难理解: 对 Webpack 传入的字符串进行按需修改。例如一个最简单的 Loader:
// html-loader/index.js
module.exports = function (htmlSource) {
// 返回处理后的代码字符串
// 删除 html 文件中的所有注释
return htmlSource.replace(/<!--[\w\W]*?-->/g, '')
}
当然,实际的 Loader 不会这么简单,通常是需要将代码进行分析,构建 AST (抽象语法树), 遍历进行定向的修改后,再重新生成新的代码字符串。如我们常用的 Babel-loader 会执行以下步骤:
- babylon 将 ES6/ES7 代码解析成 AST
- babel-traverse 对 AST 进行遍历转译,得到新的 AST
- 新 AST 通过 babel-generator 转换成 ES5
# Loader 特性
- 链式传递,按照配置时相反的顺序链式执行。
- 基于 Node 环境,拥有 较高权限,比如文件的增删查改。
- 可同步也可异步。
# 常用 Loader
- file-loader: 加载文件资源,如 字体 / 图片 等,具有移动/复制/命名等功能。
- url-loader: 通常用于加载图片,可以将小图片直接转换为 Date Url,减少请求。
- babel-loader: 加载 js / jsx 文件, 将 ES6 / ES7 代码转换成 ES5,抹平兼容性问题。
- ts-loader: 加载 ts / tsx 文件,编译 TypeScript。
- style-loader: 将 css 代码以
<style>
标签的形式插入到 html 中。 - css-loader: 分析
@import
和url()
,引用 css 文件与对应的资源。 - postcss-loader: 用于 css 的兼容性处理,具有众多功能,例如 添加前缀、单位转换 等。
- less-loader / sass-loader: css 预处理器,在 css 中新增了许多语法,提高了开发效率。
# 编写原则
- 单一原则: 每个 Loader 只做一件事。
- 链式调用: Webpack 会按顺序链式调用每个 Loader。
- 统一原则: 遵循 Webpack 制定的设计规则和结构,输入与输出均为字符串,各个 Loader 完全独立,即插即用。
# Plugin
插件系统是 Webpack 成功的一个关键性因素。在编译的整个生命周期中,Webpack 会触发许多事件钩子,Plugin 可以监听这些事件,根据需求在相应的时间点对打包内容进行定向的修改。
一个最简单的 plugin 是这样的:
class Plugin {
// 注册插件时,会调用 apply 方法
// apply 方法接收 compiler 对象
// 通过 compiler 上提供的 Api,可以对事件进行监听,执行相应的操作
apply(compiler) {
// compilation 是监听每次编译循环
// 每次文件变化,都会生成新的 compilation 对象并触发该事件
compiler.plugin('compilation', function (compilation) {})
}
}
# 注册插件
// webpack.config.js
module.export = {
plugins: [new Plugin(options)],
}
# 事件流机制
Webpack 就像工厂中的一条产品流水线。原材料经过 Loader 与 Plugin 的一道道处理,最后输出结果。
- 通过链式调用,按顺序串起一个个 Loader。
- 通过事件流机制,让 Plugin 可以插入到整个生产过程中的每个步骤中。
Webpack 事件流编程范式的核心是基础类 Tapable,是一种 观察者模式 的实现事件的订阅与广播:
const { SyncHook } = require('tapable')
const hook = new SyncHook(['arg'])
// 订阅
hook.tap('event', (arg) => {
// 'event-hook'
console.log(arg)
})
// 广播
hook.call('event-hook')
Webpack 中两个最重要的类 Compiler 与 Compilation 便是继承于 Tapable,也拥有这样的事件流机制。
Compiler: 可以简单的理解为 Webpack 实例,它包含了当前 Webpack 中的所有配置信息,如 options, loaders, plugins 等信息,全局唯一,只在启动时完成初始化创建,随着生命周期逐一传递。
Compilation: 可以称为 编译实例。当监听到文件发生改变时,Webpack 会创建一个新的 Compilation 对象,开始一次新的编译。它包含了当前的输入资源,输出资源,变化的文件等,同时通过它提供的 api,可以监听每次编译过程中触发的事件钩子。
区别在于 Compiler 全局唯一,且从启动生存到结束;Compilation 对应每次编译,每轮编译循环均会重新创建。
# 常用 Plugin
- UglifyJsPlugin: 压缩、混淆代码
- CommonsChunkPlugin: 代码分割
- ProvidePlugin: 自动加载模块
- html-webpack-plugin: 加载 html 文件,并引入 css / js 文件
- extract-text-webpack-plugin / mini-css-extract-plugin: 抽离样式,生成 css 文件
- DefinePlugin: 定义全局变量
- optimize-css-assets-webpack-plugin: CSS 代码去重
- webpack-bundle-analyzer: 代码分析
- compression-webpack-plugin: 使用 gzip 压缩 js 和 css
- happypack: 使用多进程,加速代码构建
- EnvironmentPlugin: 定义环境变量
# 编译优化
代码优化
无用代码消除,是许多编程语言都具有的优化手段,这个过程称为 DCE (dead code elimination),即 删除不可能执行的代码
- 例如我们的 UglifyJs,它就会帮我们在生产环境中删除不可能被执行的代码。
摇树优化 (Tree-shaking),这是一种形象比喻。我们把打包后的代码比喻成一棵树,这里其实表示的就是,通过工具 "摇" 我们打包后的 js 代码,将没有使用到的无用代码 "摇" 下来 (删除)。即 消除那些被 引用了但未被使用 的模块代码。
- 原理: 由于是在编译时优化,因此最基本的前提就是语法的静态分析,ES6 的模块机制 提供了这种可能性。不需要运行时,便可进行代码字面上的静态分析,确定相应的依赖关系。
- 问题: 具有 副作用 的函数无法被 tree-shaking。
- 在引用一些第三方库,需要去观察其引入的代码量是不是符合预期。
- 尽量写纯函数,减少函数的副作用。
- 可使用 webpack-deep-scope-plugin,可以进行作用域分析,减少此类情况的发生,但仍需要注意。
code-spliting: 代码分割 技术,将代码分割成多份进行 懒加载 或 异步加载,避免打包成一份后导致体积过大,影响页面的首屏加载。
- Webpack 中使用 SplitChunksPlugin 进行拆分。
- 按 页面 拆分: 不同页面打包成不同的文件。
- 按 功能 拆分:
- 将类似于播放器,计算库等大模块进行拆分后再懒加载引入。
- 提取复用的业务代码,减少冗余代码。
- 按 文件修改频率 拆分: 将第三方库等不常修改的代码单独打包,而且不改变其文件 hash 值,能最大化运用浏览器的缓存。
scope hoisting: 作用域提升,将分散的模块划分到同一个作用域中,避免了代码的重复引入,有效减少打包后的代码体积和运行时的内存损耗。
编译性能优化:
- 升级至 最新 版本的 webpack,能有效提升编译性能。
- 使用 dev-server / 模块热替换 (HMR) 提升开发体验。
- 监听文件变动 忽略 node_modules 目录能有效提高监听时的编译效率。
- 缩小编译范围
- modules: 指定模块路径,减少递归搜索。
- mainFields: 指定入口文件描述字段,减少搜索。
- noParse: 避免对非模块化文件的加载。
- includes/exclude: 指定搜索范围/排除不必要的搜索范围。
- alias: 缓存目录,避免重复寻址。
babel-loader
- 忽略
node_moudles
,避免编译第三方库中已经被编译过的代码。 - 使用
cacheDirectory
,可以缓存编译结果,避免多次重复编译。
- 忽略
- 多进程并发
- webpack-parallel-uglify-plugin: 可多进程并发压缩 js 文件,提高压缩速度。
- HappyPack: 多进程并发文件的 Loader 解析。
- 第三方库模块缓存
- DLLPlugin 和 DLLReferencePlugin 可以提前进行打包并缓存,避免每次都重新编译。
- 使用分析
- Webpack Analyse / webpack-bundle-analyzer 对打包后的文件进行分析,寻找可优化的地方。
- 配置
profile:true
,对各个编译阶段耗时进行监控,寻找耗时最多的地方。
source-map
- 开发:
cheap-module-eval-source-map
- 生产:
hidden-source-map
- 开发:
# 性能优化思路
对于正常的项目优化,一般都涉及到几个方面,开发过程中、上线之后的首屏、运行过程的状态
- 开发过程和打包
- 分析打包速度
speed-measure-webpack-plugin
- 分析影响打包速度环节
- 获取依赖模块(搜索时间)
- 解析依赖模块(解析时间)
- 依赖模块的打包(压缩时间)
- 运行时的修改(二次打包时间)
- 优化解析时间 - 开启多进程打包
thread-loader
HappyPack
- 合理利用缓存(缩短连续构建时间,增加初始构建时间)
cache-loader
HardSourceWebpackPlugin
- 优化压缩时间
- webpack3
- 启动打包时加上
--optimize-minimize
(UglifyJsPlugin
) ParallelUglifyPlugin
- 启动打包时加上
- webpack4
- webpack4 默认内置使用
terser-webpack-plugin
插件压缩优化代码,terser
启动多进程。
- webpack4 默认内置使用
- webpack3
- 优化搜索时间- 缩小文件搜索范围
- loader 的
test
,include
,exclude
- resolve.modules
- resolve.alias 减少耗时的递归解析操作
- resolve.extensions (提高命中导入语句文件后缀)
- resolve.mainFields
- module.noParse
- loader 的
- 分析打包速度
- 上线之后的首屏
- 首屏优化一般涉及到几个指标 FP、FCP、FMP;要有一个良好的体验是尽可能的把 FCP 提前,需要做一些工程化的处理,去优化资源的加载
- 方式及分包策略,资源的减少是最有效的加快首屏打开的方式
- 骨架屏及预渲染(部分结构预渲染)、suspence 与 lazy 做懒加载动态组件的方式
- SSR 对于 SEO 和首屏的优化有一定的优势
- 运行状态
- react 项目上线之后,首先需要保障的是可用性,所以可以通过 React.Profiler 分析组件的渲染次数及耗时的一些任务,但是 Profile 记录的是 commit 阶段的数据,所以对于 react 的调和阶段就需要结合 performance API 一起分析
- 由于 React 是父级 props 改变之后,所有与 props 不相关子组件在没有添加条件控制的情况之下,也会触发 render 渲染,这是没有必要的,可以结合 React 的 PureComponent 以及 React.memo 等做浅比较处理,这中间有涉及到不可变数据的处理,当然也可以结合使用 ShouldComponentUpdate 做深比较处理
- 所有的运行状态优化,都是减少不必要的 render,React.useMemo 与 React.useCallback 也是可以做很多优化的地方
- 在很多应用中,都会涉及到使用 redux 以及使用 context,这两个都可能造成许多不必要的 render,所以在使用的时候,也需要谨慎的处理一些数据
- 最后就是保证整个应用的可用性,为组件创建错误边界,可以使用 componentDidCatch 来处理