关于Webpack的一切
2021-6-25
| 2023-2-19
0  |  0 分钟
password
Created
Feb 19, 2023 04:16 PM
type
Post
status
Published
date
Jun 25, 2021
slug
summary
介绍Webpack使用,源码实现,插件机制,Module、loader的开发等
tags
Webpack
原理
category
工程化
icon

一. 基本用法

1. entry

entry代表webpack的输入入口, entry可以直接传入字符串代表单入口, 也可以通过k-v的方式指定多入口

2. output

output代表webpack的输出位置
ouput一般是使用path来指代我们需要输出的位置, filename来指代我们需要输出的文件名称, 例如:
{ path: path.join(__dirname, 'dist'), filename: 'bundle.js'}
对于多入口, 我们可以将filename改为占位符声明的方式来进行声明, 如bundle.js改为[name].js
#### 3. loaders
webpack原生是只支持js和json两种文件类型
loaders用于将不支持的文件类型转变成有效的模块
loaders本质是一个函数, 接受一个源文件作为参数, 返回其处理的结果
loaders在module中, 使用rules声明的正则来进行文件匹配, 使用use来定义其需要使用的loader
{ module: { rules: [ { test: /\.txt$/, use: 'raw-loader' } ] }}

4. plugins

用于进行bundule文件的优化, 资源管理, 环境变量注入等
换言之, plugins作用于整个构建过程, 可以实现很多能力
{ plugins: [ new XXXPlugin(), ]}

5. mode

指定当前构建环境 production development none
  • development: NODE_ENV设置为development, 并开启NamedChunksPlugin和NamedNamedModulesPlugin
  • production: NODE_ENV设置为production, 开启FlagDependencyUsagePlugin, FlagIncudedChunksPlugin ModuleConcatenationPlugin NoEmitOnErrorsPlugin OccurrenceOrderPlugin SideEffectsFlagPlugin TerserPlugin
  • None: 不开启任何优化选项

二. 基本使用

1. 解析ES6

使用babel-loader
{ module: { rules: [{ test: /\.js$/, use: [{ loader: 'babel-loader', options: { presets: ['@babel/preset-env'], }] }] } }

2. 解析React

在babel中加上@babel/preset-react
{ loader: 'babel-loader', options: { presets: ['@babel/preset-env', '@babel/preset-react'] } }

3. 解析CSS SASS LESS等

css-loader
{ test: /.css$/, use: ['style-loader', 'css-loader'] }
sass-loader
{ test: /.s[ac]ss$/, use: [ 'style-loader', 'css-loader', 'sass-loader' ]}
less-loader
{ test: /\.less$/, use: [ 'style-loader', 'css-loader', 'less-loader' ]}

4. 解析图片和字体

{ test: /\.(png|svg|jpg|gif|woff|woff2|eot|ttf|otf)$/, use: [ 'file-loader' ] }
也可以使用url-loader, 是更优的解法, 因为url-loader可以设置最小的资源打包限制

5. HTML压缩

使用HTMLWebpackPlugin, 设置压缩参数即可

6. CSS压缩

使用MiniCssExtractPlugin抽取CSS内容
用OptimizeCSSAssetsPlugin来压缩CSS, 需要依赖cssnano

7. JS压缩

production下默认就压缩了

8. 构建前清理构建目录

用CleanWebpackPlugin

9. CSS样式自动补全

postcss-loader 加上 autoprefixer
{ test: /\.less$/, use: [ 'style-loader', 'css-loader', 'less-loader', { loader: 'postcss-loader', options: { plugins: () => { require('autoprefixer')({ browsers: ['last 2 version', '>1%', 'ios 7'] }) } } } ] }

10. 移动端rem适配

  1. 引入lib-flexiable
  1. 使用px2rem-loader
{ test: /\.less$/, use: [ //... { loader: 'px2rem-loader', options: { remUnit: 75, remPrecision: 8 } } ] }

11. 静态资源内联

raw-loader会读取内容, 然后将内容以string的方式进行返回
用raw-loader内联html
<script>${require('raw-loader!babel-loader!./meta.html')}</script>
用raw-loader内联JS
<script>${require('raw-loader!babel-loader!../node_modules/lib-flexible')}</script>
建议使用0.5.1版本

12. 多页面打包

手动设置是一个办法, 但是更好的办法是通过代码来进行匹配来实现多页面
例如, 我们需要对src下不同目录的内容作为独立的页面入口, 如果此时有index和search两个入口时, 可以这样写
module.exports = { entry: { index: './src/index/index.js', search: './src/search/index.js' } }
但更好的办法是, 通过glob来直接匹配, 就不用每次增加的时候都手动配置了
module.exports = { entry: glob.sync(path.join(__dirname, './src/*/index.js')) }
同时, 我们将HTMLWebpackPlugin也通过这样编码动态的进行配置, 对这块的内容可以进行封装处理, 不再赘述.

13. sourcemap

soucemap一般有如下几种:
  • eval: 使用eval包裹模块代码
  • sourcemap: 产生.map文件
  • cheap: 不包含列信息
  • inline: 将.map作为dataURI引入, 不单独生成.map文件
  • module: 包含loader的sourcemap
notion image

14. 文件指纹策略

指打包后输出的文件名的后缀
  • hash: 和整个项目的构建相关, 只要项目文件由修改, 整个项目构建的hash值就会更改
  • Chunkhash: 和webpack打包的chunk相关, 不同的entry会生成不同的chunkhash值
  • Contenthash: 根据文件内容来定义hash, 文件内容不变, 则contenthash不变
一般来讲, js文件会根据chunk模块来, 所以搬来会对js的逻辑使用chunkhash, 这样模块修改的时候才会变化hash值, 而其他没有修改的模块是不会变化hash值的

15. 提取页面公共资源

1. 基础库分离

用html-webpack-externals-plugin可以配置externals, 将公共库直接通过CDN的方式进行引入
notion image

2. 公共脚本分离

Webpack4内置了SplitChunksPlugin, 通过这个插件可以进行公共脚本的分离, 是CommonChunksPlugin这个插件的替代品
默认的SplitChunksPlugin的参数如下
module.exports = { //... optimization: { splitChunks: { chunks: 'async', // 将选择哪些chunk进行优化 minSize: 20000, // 生成 chunk 的最小体积(单位: bytes) minRemainingSize: 0, // 避免大小为零的模块 minChunks: 1, // 拆分前必须共享模块的最小 chunks 数 maxAsyncRequests: 30, // 按需加载时的最大并行请求数 maxInitialRequests: 30, // 入口点的最大并行请求数 enforceSizeThreshold: 50000, // 强制执行拆分的体积阈值和其他限制 cacheGroups: { defaultVendors: { test: /[\\/]node_modules[\\/]/, priority: -10, reuseExistingChunk: true, }, default: { minChunks: 2, priority: -20, reuseExistingChunk: true, }, }, }, }, };
一般来讲, 对于非常公共的包部分, 我们可以将其提取为公共的vendor, 例如react, react-dom, redux等
module.exports = { //... optimization: { splitChunks: { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/, name: 'vendor', chunks: 'all', }, }, }, }, };
对于一些其他的场景下, 我们也需要将公共的部分进行提取和抽象, 根据webpack构建时的引用计数规则来即可
module.exports = { //... optimization: { splitChunks: { cacheGroups: { commons: { name: 'commons', chunks: 'all', minChunks: 2 }, }, }, }, };

16. 开启ScopeHoisting

ScopeHoisting顾名思义就是"域提升", 在webpack中开启ScopeHoisting可以将只被引用了一次的代码直接进行打包, 从而避免冗余的情况
使用mode: production会自动启用ScopeHoisting
也可以使用webpack的内置Plugin来对他进行开启
考虑到 Scope Hoisting 以来 ES6 模块化语法,而现在很多 npm 包的第三方库还是使用 CommonJS 语法,为了充分发挥 Scope Hoisting 效果,我们可以增加以下 mainFields 配置:
// webpack.config.js // ... const webpack = require('webpack'); module.exports = {     // ...     resolve: {         // 针对 npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件         mainFields: ['jsnext:main', 'browser', 'main']     },     plugins: [         new webpack.optimize.ModuleConcatenationPlugin()     ] };

17. 代码分割和动态Import

懒加载代码可以将相同代码抽离到一个共享块, 同时也可以使得初始下载的代码更小, 达到性能优化的目的
懒加载主要又两种方式:
  1. CommonJS: require.ensure
  1. ES6: 动态import (目前没有原生支持, 需要babel转换)
其实二者在webpack构建的时候都会被构建为一个Promise的封装, 然后通过jsonp来请求动态js资源内容, 并将内容通过Promise.resolve来解析和逻辑注入
notion image
import被转化成了webpack_require.e
webpack require其实做了以下基建事情
  1. 根据 installedChunks 检查是否加载过该 chunk
  1. 假如没加载过,则发起一个 JSONP 请求去加载 chunk
  1. 设置一些请求的错误处理,然后返回一个 Promise
  1. 当 Promise 返回之后,就会继续执行我们之前的异步请求回调__webpack_require__.bind(null, */\*! ./async \*/* "./src/async.js")
notion image

18. SSR支持

SSR的本质在于将原来在浏览器中执行JS逻辑而产生的界面内容, 提前到服务端进行处理, 直出页面之后再对前端进行返回.
同时, 各大前端框架也提供了相应的方法来辅助SSR能力的正常进行, 例如react-dom提供了hydrate方法来生成静态的vdom文本节点
而vue则使用vue-server-renderer来实现了SSR的能力
当然, 事情肯定不会这么简单, 里面涉及到许多问题, 比较典型的有:

1. 事件绑定问题

众所周知的是, react采用了syntheticEvent机制以提升性能, 但是这样处理对SSR的渲染造成了困难: 静态文本在服务端渲染出来, 事件又在哪里绑定呢?
这里其实react和vue都采用了同构的方式来保证了事件的正常绑定
简单来说就是: 在服务端渲染生成静态节点, 同时返回了js逻辑, 到了客户端之后需要再次执行一次js逻辑, 重新将静态节点"渲染"一次, 以绑定上对应的事件.

2. 全局数据注入和同步的问题

在React中, 我们一般使用Redux, 在Vue中, 我们一般使用Vuex来进行全局的状态管理
但是在SSR的场景下我们如何将状态的数据注入到前端界面中呢?
这里其实vue和react都使用了在windows下注入全局状态, 然后再浏览器上初始化的时候使用这个全局状态作为初始状态的方式来处理状态管理的问题

3. componentDidMount(react) / mounted(vue)在SSR的场景下不调用, 那么页面默认的数据应该在哪里处理?

其实这类的数据可以认为是"初始化数据", 对于这类数据来讲, 我们可以将数据提前在服务端渲染出来的
因为如果将这类场景放在浏览器端进行的话, 就失去了SSR的意义了
其实这个也很好解决, 我们将页面的这类初始化数据, 放在Redux中, 先在服务端请求之后注入到redux, 然后一并作为初始化数据注入到返回的前端页面中即可, 这样就能够实现初始化的时候就有数据了

4. 样式在服务端是没有处理的, 如何同步处理样式以规避首次渲染的时候样式缺失导致的闪动问题?

在react中可以使用isomorphic-style-loader这个loader来处理css样式
样式经过处理之后会变成组件context中注入的样式对象, 通过_getCss就可以获取到了
我们封装一个高阶组件来处理这个样式的问题即可
//根目录下创建withStyle.js文件 import React, { Component } from 'react'; //函数返回组件 //需要传入的第一个参数是需要装饰的组件 //第二个参数是styles对象 export default (DecoratedComponent, styles) => { return class NewComponent extends Component { componentWillMount() { //判断是否为服务端渲染过程 if (this.props.staticContext) { this.props.staticContext.css.push(styles._getCss()) } } render() { return <DecoratedComponent {...this.props} /> } } }

5. 参考资料

19 组件和基础库打包

组件和基础库一般会要求支持ESM CJS UMD三种类型的调用方式
  • ESM: 新一代模块化标准, 提倡引用提前声明, 能够支持完备的tree-shaking以用于减少包体积和性能优化, ESM一般使用rollup, 或直接使用babel来进行处理打包
  • CJS: nodejs的模块化标准, 使用它可以实现在nodejs端进行调用
  • UMD: 其实是AMD CJS 浏览器全局对象的集合, 能够支持这三种调用方式
因为UMD是支持CJS的, 所以一般我们会输出两种产物: ESM和UMD
对应的, 在package.json中, 是有这几个字段与其相关的
{ "main": "lib/index.js", "module": "es/index.js", }
当我们直接调用这个包的时候, 会读取package.json的main入口, 也就相应拿到的是UMD的包
而我们使用ESM的方式来调用的时候, 会读取到module的入口, 从而拿到了ESM的包
在开源的打包工具中, 也是如此
在react中, 目前的最佳实践应该是 father-build + dumi 一个作为打包工具, 一个作为文档工具
在vue中, vant-cli可以承担这二者的能力, 可以作为打包工具和文档工具
其次, 打包过程中的一些提交信息校验, 代码风格检查等, 可以使用prettier/eslint + husky + lint-staged这些工具来配合处理
提交的commit-message规范化, 可以使用commitlint来处理
打包的版本控制, 可以用开源库release-it
再回到问题本身, webpack作为库的打包其实并非最佳的实践, 但是webpack可以用来对组件库打独立的单文件包来供引入, 加上环境的判断以区别是生产使用还是测试使用, 也是非常常见的, 示例如下:
module.exports = { entry: { 'test-lib': './src/index.js', 'test-lib.min': './src/index.js' }, output: { filename: '[name].js', library: 'testLib', libraryTarget: 'umd', libraryExport: 'default' }, mode: 'none', optimization: { minimize: true, minimizer: [ new TersetPlugin({ include: /\.min\.js$/, }) ] } }
这里面的核心就是通过两个入口来进行构建, 构建的时候使用TerserPlugin, 对min的入口文件进行压缩, 从而能够输出一个完整版, 一个压缩版的js代码包.
这是一个行之有效的工具库打包办法, 但是对组件库来讲就无法满足: 1. 样式分离 2. tree-shaking 3. 按需引入的需求了, 如果有此类需求, 还是需要打出两种包

三. 文件监听和热更新

开启监听模式的方式
  • 运行时带上--watch参数
  • 配置webpack.config.js中设置watch: true
但是缺陷是: 每次watch都需要手动刷新浏览器
文件监听的本质:
  1. 轮询判断文件的最后编辑时间是否发生变化
  1. 如果文件发生变化, 并不会直接告诉监听者, 而是先缓存起来, 等aggregateTimeout

1. webpack-dev-server

通过webpack-dev-server可以实现自动构建和热更新
但是: WDS不刷新浏览器, 且不输出文件, 而是放在内存中
因此, 我们可以使用HotModuleReplacementPlugin插件来实现模块热替换
// 在webpack.config.js中引入HotModuleReplacementPlugin // 同时, 配置devServer中hot为true { plugins: [ new webpack.HotModuleReplacementPlugin() ], devServer: { contentBase: './dist', hot: true } }

2. webpack-dev-middleware

热更新的另外方式: 使用webpack-dev-middleware
WDM可以将webpack输出的文件传输给服务器, 适用于灵活的定制场景
const express = require('express') const webpack = require('webpack') const webpackDevMiddleware = require('webpack-dev-middleware') const app = express() const config = require('./webpack.config.js') const compiler = webpacl(config) app.use(webpackDevMiddleware(compiler, { publicPath: config.output.publicPath })) app.listen(3000, () => { console.log('listening in 3000') })

3. 热更新的基本原理

webpack热更新本质上来说, 就是在启动热更新之后, webpack服务创建了BundleServer和HMRServer两个服务, 其中HMR Server的内容会与浏览器进行实时的WebSocket通信以便及时将更新内容传入到浏览器端.
当文件发生变化时, 会通过HMRServer将变化的内容传输到浏览器来进行更新, 从而达到不刷新浏览器就实现更新的目的.
下面详细说一下HMR的细节

1. 启动HMR服务做了什么?

启动HMR服务的时候, 会在entry中注入两个入口, 示例如下
// 修改后的entry入口 { entry: { index: [ // 上面获取的clientEntry 'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080', // 上面获取的hotEntry 'xxx/node_modules/webpack/hot/dev-server.js', // 开发配置的入口 './src/index.js' ], }, }
可以看到, HMR注入了一个client, 和dev-server两个js文件
其实很好猜测:
  • client其实就是启动WebSocket相关的代码, 通过这段逻辑注入到浏览器端, 就实现了WebSocket的启动, 并对开发阶段无侵入
  • dev-server其实就是处理通过WebSocket传输的JS逻辑和进行模块替换的部分逻辑

2. 浏览器是怎么知道要拉取什么热更新包的?

我们知道, webpack-dev-middleware可以实现对文件变化的实时检测和编译, 那么编译之后是如何将编译内容进行准确的输出呢?
我们注意到, 每次webpack编译的时候, 都会生成一个编译的hash
这个时候如果开启了hot reload的话, 这个hash会被实时通过WebSocket传输到浏览器上
notion image
我们可以看到, WebSocket传输了一个json文件, 和一个js文件
json文件中, 其实声明的h字段, 就是当前编译的hash唯一值, 而c其实就是我们当次热更新所需要更新的包内容
notion image
接下来我们看js文件, js文件由webpackHotUpdate包裹, 并传入了需要替换文件名, 也就是这里的index
notion image
这样做的好处就是, 即使热更新阶段, 我们刷新了浏览器, 通过hash我们也可以获取到当前最新的包内容, 同时, 对于频繁的hot场景, 有了hash也不至于产生紊乱

3. 浏览器是怎么应用到这些包的?

这里其实有一个很古老的技术叫做jsonp, webpack使用了jsonp的能力来动态拉取和应用js文件
jsonp其实很好理解, 就是我们动态请求一段js内容之后, 通过动态生成的script标签注入到页面上, 然后就可以实现对这段动态请求的js内容的执行. 这就是所谓的jsonp
那么我们通过WebSocket获取到的更新内容, 会通过jsonp来进行请求获取, 和装载.
需要注意的是, hot-update.js中, 有一个webpackHotUpdate的包裹, 其实这里就是我们在开头提到, 启动HMR服务的时候注入的另一个入口文件dev-server.js的作用
在这里, webpackHotUpdate会将内容与现有的模块进行匹配, 这时候webpack就知道需要动态替换掉哪一个模块了, 接下来要做的就是将这个模块动态替换掉就OK了
appliedUpdate[moduleId] = hotUpdate[moduleId]; for (moduleId in appliedUpdate) { if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) { modules[moduleId] = appliedUpdate[moduleId]; } }
替换掉之后, 会通过__webpack_require__来执行相关模块的代码
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) { var item = outdatedSelfAcceptedModules[i]; moduleId = item.module; try { // 执行最新的代码 __webpack_require__(moduleId); } catch (err) { // ...容错处理 } }
到这里, 热更新就基本完成了

4. 总结

本质上来说, webpack热更新的时候会在入口注入websocket服务和处理模块替换的代码
碰到有内容更新的时候, 会在tapable done阶段获取到更新内容, 推送json到浏览器
浏览器则发起jsonp请求, 将更新内容加载到浏览器
然后通过dev-server.js将加载的内容与原来的内容进行替换和应用
这样就实现了整个内容的热更新

四. Tree-shaking

tree-shaking最早的时候是有Rick Harris在Rollup中实现, 后面webpack在2.0的时候引入, 是一种广泛应用的性能优化手段
在webpack中如果需要启用tree-shaking, 需要满足三个条件
  1. ESM规范的编码方式
  1. 配置optimization.useExports为true, 启动标记功能
  1. 启动代码优化功能, 可以配置mode为production, 或optimization.minimize为true, 或提供optimization.minimizer数组
// webpack.config.js module.exports = { entry: "./src/index", mode: "production", devtool: false, optimization: { usedExports: true, }, };

1. ESM

tree-shaking的本质在于: ESM是会强制进行引用提升的, 这也方便了在起始阶段的代码静态分析
其依赖关系高度确定, 且与运行状态无关, 这是tree-shaking能力实现的必要条件

2. useExports

配置useExports为true之后, 在webpack中会做这几件事:
  1. Make阶段, 收集模块导出变量, 并记录到模块依赖关系图ModuleGraph中
  1. Seal阶段, 遍历ModuleGraph标记模块导出变量有没有被使用
  1. 生成产物时, 若变量没有被其他模块使用则删除对应的导出语句
需要注意的是, webpack本身知会影响到模块的导出语句, 真正执行shaking的其实是Terser插件, 导出的语句经过Terser的DCE功能来删除

3. 源码中的实现

1. 收集模块

  • 将模块的所有ESM导出语句转换为Dependency对象, 记录到module对象的dependencies集合
  • FlagDependencyExportsPlugin插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有 module 对象
  • 遍历 module 对象的 dependencies 数组,找到所有 HarmonyExportXXXDependency 类型的依赖对象,将其转换为 ExportInfo 对象并记录到 ModuleGraph 体系中
经过 FlagDependencyExportsPlugin 插件处理后,所有 ESM 风格的 export 语句都会记录在 ModuleGraph 体系内,后续操作就可以从 ModuleGraph 中直接读取出模块的导出值。

2. 标记模块导出

模块导出信息收集完毕后,Webpack 需要标记出各个模块的导出列表中,哪些导出值有被其它模块用到,哪些没有,这一过程发生在 Seal 阶段
  • 从 entry 开始逐步遍历 ModuleGraph 存储的所有 module 对象
  • 遍历 module 对象对应的 exportInfo 数组
  • 确定其对应的 dependency 对象有否被其它模块使用
  • 将被使用到的模块标记为已使用

3. 生成代码

Webpack 会根据导出值的使用情况生成不同的代码
notion image
如图见: 对于没有被其他引用到的导出, 会被移除掉导出关系
  • 读取 ModuleGraph 中存储的 exportsInfo 信息,判断哪些导出值被使用,哪些未被使用
  • 对已经被使用及未被使用的导出值,分别创建对应的 HarmonyExportInitFragment 对象,保存到 initFragments 数组
  • 遍历 initFragments 数组,生成最终结果
基本来说, 导出就是用前面收集好的导出信息, 生成导出语句

4. 删除DeadCode

经过前面几步操作之后,模块导出列表中未被使用的值都不会定义在 __webpack_exports__ 对象中,形成一段不可能被执行的 Dead Code 效果
在此之后,将由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。

4. 最佳实践

虽然tree-shaking看起来美好, 但是实际上由于js的动态性和复杂性, 工具并不能达成到最佳最完美的效果, 因此我们需要在日常编码中加以注意, 尽可能编码出能够被treeshaking合理识别的代码

1. 避免无意义的赋值

notion image
如上, f变量并没有使用, 但是tree-shaking并没有产生作用, 因为webpack的tree-shaking是在代码静态分析层面, 只能浅显的判断模块引用和变量出现的情况

2. 注意副作用

import { bar, foo } from "./bar"; let count = 0; const mock = {} Object.defineProperty(mock, 'f', { set(v) { mock._f = v; count += 1; } }) mock.f = foo; console.log(count);
以上代码就是会产生副作用的代码, 导致webpack无法对这些代码进行分析和摇树

3. 使用#pure标注纯函数调用

开发者可以在调用语句前添加 /*#__PURE__*/ 备注,明确告诉 Webpack 该次函数调用并不会对上下文环境产生副作用
notion image
示例中,foo('be retained') 调用没有带上 /*#__PURE__*/ 备注,代码被保留;作为对比,foo('be removed') 带上 Pure 声明后则被 Tree Shaking 删除。

4. 禁止 Babel 转译模块导入导出语句

Babel 提供的部分功能特性会致使 Tree Shaking 功能失效,例如 Babel 可以将 import/export 风格的 ESM 语句等价转译为 CommonJS 风格的模块化语句,但该功能却导致 Webpack 无法对转译后的模块导入导出内容做静态分析
因此我们需要设置modules = false来避免转变成commonjs

5. 优化导出值的粒度

Tree Shaking 逻辑作用在 ESM 的 export 语句上
因此, 下面的语句就无法利用tree-shaking
export default { bar: 'bar', foo: 'foo' }
我们应该用export来导出值的其中一个属性, 尽量保证导出颗粒度和原子性
const bar = 'bar' const foo = 'foo' export { bar, foo }

6. 使用支持 Tree Shaking 的包

使用支持ESM, 支持tree-shaking的包, 如: 使用 lodash-es 替代 lodash

5. 参考

五. 构建优化

1. 构建信息提示优化

默认情况下, webpack会全量输出构建信息, 信息非常多, 可以通过stats字段来控制构建信息的输出情况
  • errors-only: 只在错误时输出
  • minimal: 发生错误或有新的编译时输出
  • none: 没有输出
  • normal: 标准输出
  • verbose: 全部输出
一般来讲, 我们只需要在开发阶段发生错误时输出内容即可
另外, 有一个friendly-errors-webpack-plugin可以接入, 可以让我们很方便的看到warning, error的错误行数等的信息

2. 构建(产物与时间)优化

  1. 使用内置的stats
webpack --config webpack.config.js --json > stats.json
用这个方式可以将打包的一些信息输出到stats.json这个文件中
  1. 使用speed-measure-webpack-plugin收集构建的耗时情况
notion image
构建完成之后就会对插件的构建信息进行时间统计了, 会将参与构建的插件, loaders等的构建耗时展示出来
  1. 使用webpack-bundle-analyzer分析打包产物体积
notion image
构建完成之后, 会打开8888端口, 展示打包的产物模块相互之间的关系, 并展示每个模块的包体积
通过我们对可视化的模块分析, 可以手动优化我们的部分打包产物情况

3. 多进程构建

HappyPack会在初始化过程中创建多个线程池来处理
基本的做法是使用HappyPack创建一个plugin, 然后替换原来的loader即可
const HappyPack = require('happypack'); exports.module = { rules: [ { test: /.js$/, // 1) replace your original list of loaders with "happypack/loader": // loaders: [ 'babel-loader?presets[]=es2015' ], use: 'happypack/loader', include: [ /* ... */ ], exclude: [ /* ... */ ] } ] }; exports.plugins = [ // 2) create the plugin: new HappyPack({ // 3) re-add the loaders you replaced above in #1: loaders: [ 'babel-loader?presets[]=es2015' ] }) ];

4. 多进程压缩

方法1: 使用webpack-parallel-uglify-plugin
notion image
方法2: 使用uglifujs-webpack-plugin开启parallel参数
notion image
方法3: 使用terser-webpack-plugin开启parallel参数(webpack4中的推荐做法)
notion image

5. 预编译通用模块

使用DLLPlugin可以进行通用模块的预编译
DLLPlugin的核心是: 首次运行项目的时候, 需要运行一下DLL构建, 将通用的模块构建好之后, 在正常跑webpack的watch开发逻辑, 这样可以让开发阶段启动编译的时候不需要每次都构建这些通用的模块, 从而达到提升构建速度的目的.
初次需要跑一下Dll的构建:
{ // ... entry: { library: [ 'react', 'react-dom', 'redux', 'react-redux' ] }, output: { filename: '[name].dll.js', path: path.resolve(__dirname, './build/library'), library: '[name]' }, plugins: [ new webpack.DllPlugin({ name: '[name]', path: './build/library/[name].json' }) ] }
其他时间需要给构建流程加上reference以使webpack识别提前Dll出来的构建包
module.exports = { plugins: { new webpack.DllReferencePlugin({ manifest: require('./build/library/manifest.json') }) } }

6. 使用构建缓存

缓存可以分为多个层面
  • babel-loader可以开启babel编译的缓存
  • terser-webpack-plugin可以开启压缩阶段缓存
  • 使用cache-loader或hard-source-webpack-plugin提升模块转换阶段缓存

1. babel编译缓存

babel编译缓存开启比较简单, 直接通过cacheDirectory=true即可开启
{ loaders: ['babel-loader?cacheDirectory=true'] }

2. 压缩阶段缓存

terser-webpack-plugin开启缓存需要设置cache为true
new TerserPlugin({ parallel: true, cache: true })

3. 模块转换阶段缓存

直接引入hard-source-webpack-plugin即可

7. 缩小构建目标

默认情况下, webpack会分析node_modules中的构建内容, 其实很多情况下是并不需要的, 所以在构建阶段过程中, 我们可以设置排除node_modules中的包内容的构建
rules: { test: /\.js$/, loader: 'happypack-loader', exclude: 'node_modules' }
其次, webpack会识别到所有的包模块, 而这时候往往这些包已经被我们安装到了node_modules中了
而webpack的包搜索机制是找不到则一直向上查找, 直到根目录为止, 这其实是没有必要的
此时我们可以将modules的搜索范围固定到当前目录的node_modules中
modules: [path.resolve(__dirname, 'node_modules')]
resolve.mainFields可以限制模块的查找范围, 默认情况下mainFields的值为['module', 'main'], 也就是先找包中的module字段, 如果没有, 则找main字段
在一些情况下, 如果只需要找main, 可以直接设置此值为['main']即可, 以提升构建速度
resolve.extensions用于解析模块的后缀, 默认情况下, 会解析三种文件后缀['.js', '.json', '.wasm'], 但很多情况下, 我们只需要解析js即可, 所以这里也可以按情况进行优化
resolve.alias 可以优化查找路径

8. 压缩图片

可以使用node库的imagemin或者tinypng的api
几个png 压缩的机制:
notion image

9. CSS tree-shaking

由于CSS结合JS带来的巨大的动态性导致CSS的tree-shaking变得非常的困难
JS可以在任意的地方注入一个新的class类, 这时候只有渲染的时候才知道这个CSS的类被使用到
目前在市面上有两个相关的主流工具:
  1. PurifyCSS
其核心理念就是通过文本匹配来进行比对判断CSS是否成功引入
这样做的好处是它的性能可以非常快, 且能够支持到不同的文件类型, 只要CSS匹配上文本, 就认为CSS已经被使用
但是缺陷也很明显, 对于一些非常动态的样式, 例如从服务端返回的动态文本字段作为样式类名这种场景就无能为力了
PurifyCSS的提取器是可以自定义的, 这也就意味着我们可以为不同的框架和文件类型定义不同的提取器. 使其更具备动态化
  1. UnCSS
UnCSS采用了另一种完全不同的处理方案, UnCSS会使用jsdom来加载HTML文件并执行js代码, 然后再根据CSS的样式表对应HTML来进行匹配
这种行为就更能够模拟到真实的页面情况, 但是缺陷是这样会损耗其执行性能

10 . 动态Polyfill

表面上我们请求的是一个js的polyfill文件, 但是实际上是请求的是一个动态polyfill的服务
服务根据请求的浏览器的UA来判断需要返回的polyfill的内容
具体的一个示例可以查看: polyfill.io/v3/polyfill.min.js
在chrome上, 这个是返回了一个空内容, 但是在低版本浏览器比如IE9中, 返回了很多内容
这方面FinancialTimes有一个开源的polyfill-service服务, 可以直接部署:
对于UA被魔改的场景, polyfill-service会进行优雅降级, 返回所有的polyfill

六. webpack执行机制

webpack的本质上是一个基于事件流的编程范例
webpack使用tapable连接了整个的构建流程, 并基于tapable的hooks机制, 是的webpack具备了优秀的扩展能力
webpack的核心执行流程如下:
  1. 初始阶段: 通过webpack-cli或webpack-command运行启动webpack命令, 并在webpack-cli/webpack-command中进行命令的序列化和装载, 将调用参数传递给webpack包中暴露的webpack对象
  1. 准备阶段: 将调用参数与默认参数, 各种配置来进行拼接, 根据mode来判断要装载的plugin, 例如production会装载terserPlugin, 而其他mode则不会. 同时会装载传入的自定义插件, 然后使用new Webpack来完成webpack对象的初始化
  1. 开始构建: 开始构建阶段(run)会启动一个当前任务下唯一的compiler来进行构建任务的调度, 在make阶段查找入口文件, 然后根据入口文件来进行模块和依赖的构建关系, 同时相互之间的依赖关系通过graph来进行记录
  1. 执行构建: 执行构建过程中, 每一个构建关系的构建都会启动一个compilation对象来进行构建执行
  1. 完成构建: 完成构建(seal)阶段会对每个构建进行整理和优化, 合并等处理
  1. 生成产物: 生成产物(emit)阶段会将构建内容, 变成通过webpack require可以调用的一个个模块的数组, 最后进行产物输出
notion image

七. Loader

1. Loader的执行机制

loader执行始终是从右向左的
但是, loader可以设置添加一个pitch函数, 在loader真正执行之前, 会从左往右执行pitch函数
同时, 如果pitch阶段返回了非undefined值的时候, 会触发熔断效果, 将从此pitch熔断触发的loader的前一个loader开始从右往左(往前)执行(不包含熔断的该loader)

2. Loader的实现机制

  1. Loader使用loader-runner来进行运行, 运行loader的入口在runLoaders.js
  1. runLoaders将静态资源进行解析处理, 进行参数初始化, 然后调用了iteratePitchingLoaders
  1. iteratePitchingLoaders会首先判断是否已经将loader走完(下标大于等于loader的总数), 走完就调用processResource来开始进行正式的右往左的loader执行
  1. 如果非此情况, 则进行pitch函数的执行, 调用iteratePitchingLoaders并让下标加一
  1. 否则, 用loadLoader加载Loader模块
所以通过如上可以知道, iteratePitchingLoaders承担了初始化loader的职责(不知道为啥取这个变量名), 保证了loader的正常迭代执行
我们再从后续的执行顺序阐述loader的处理流程
  1. loadLoader会将模块进行加载, 通过handleResult进行标准化方便后续调用
  1. 之后iteratePitchingLoaders会加载当前loader的pitch函数, 通过runSyncOrAsync来进行执行, 如果执行返回有参数, 且非undefined, 则下标减一, 开始执行iterateNormalLoaders来开始迭代loaders, 否则, 继续迭代执行iteratePitchingLoaders, 也就是往下执行pitch
  1. 迭代执行完iteratePitchingLoaders之后, 就会开始执行iterateNormalLoaders, iterateNormalLoaders会从当前的下标的loader开始执行, 执行完之后下标减一, 迭代执行下一个loader, 并将参数传递到下一个loader
loader最后会走到doBuild时, 会通过acorn来进行产物raw的解析, 解析之后, 就可以供webpack进行使用了

3. 关于Acorn

Acorn是一款非常有名的Javscript解析器
相比于@babel/parser(前身是babylon, 从acorn fork出来的, 但是基本都被重写, 有些算法被保留)来说
有以下区别:
  • @babel/parser不支持第三方的插件。
  • acorn只支持第四阶段的提案(基本等于写入标准了,只是时间的问题 见此)。
  • AST的格式不同,不过可以启动@babel/parserestree插件来和acorn的AST格式匹配

3. Loader开发

loader的开发因为需要webpack运行的帮助, webpack官方也很贴心的提供了loader-runner来帮助我们进行loader的调试和测试
webpack-cli中有命令是可以直接创建一个loader的初始化代码的
import { runLoaders } from "loader-runner"; runLoaders({ resource: "/abs/path/to/file.txt?query", // String: Absolute path to the resource (optionally including query string) loaders: ["/abs/path/to/loader.js?query"], // String[]: Absolute paths to the loaders (optionally including query string) // {loader, options}[]: Absolute paths to the loaders with options object context: { minimize: true }, // Additional loader context which is used as base context processResource: (loaderContext, resourcePath, callback) => { ... }, // Optional: A function to process the resource // Must have signature function(context, path, function(err, buffer)) // By default readResource is used and the resource is added a fileDependency readResource: fs.readFile.bind(fs) // Optional: A function to read the resource // Only used when 'processResource' is not provided // Must have signature function(path, function(err, buffer)) // By default fs.readFile is used }, function(err, result) { // err: Error? // result.result: Buffer | String // The result // only available when no error occured // result.resourceBuffer: Buffer // The raw resource as Buffer (useful for SourceMaps) // only available when no error occured // result.cacheable: Bool // Is the result cacheable or do it require reexecution? // result.fileDependencies: String[] // An array of paths (existing files) on which the result depends on // result.missingDependencies: String[] // An array of paths (not existing files) on which the result depends on // result.contextDependencies: String[] // An array of paths (directories) on which the result depends on })
另外, 在一些更加复杂的loader开发场景下, 如果需要对loader的入参进行处理等之类的场景, 则需要使用到loader-utils来获取到构建时传入的loader参数
// 配置loader { loader: 'xxx-loader', options: { name: 'name' } } // 获取入参 const loaderUtils = require('loader-utils') module.exports = function(source) { const { name } = loaderUtils.getOptions(this) // do sth. return source; }
对于同步loader, 可以直接throw Error, 也可以通过this.callback来输出error
异步的loader, 则需要通过调用this.async返回的callback来传输出结果
const loaderUtils = require('loader-utils') module.exports = function(source) { const { name } = loaderUtils.getOptions(this) const callback = this.async() fs.readFile('./xxx.txt', 'utf-8', (err, data) => { if (err) { callback(err, '') } callback(null, data) }) }
loader中默认开启了loader缓存, 同时, 我们也可以在webpack中使用this.cacheable(false)来关闭缓存
缓存的前提是, 相同的输入下, 有相同的输出
loader可以通过this.emitFile进行文件输出

八. Plugin插件

1. 插件的基本结构

class MyPlugin { constructor(options) { this.options = options } apply(compiler) { compiler.hooks.done.tap('My Plugin', (stats) => { console.log('MyPlugin executed') }) } } module.exports = MyPlugin // usage plugins: [new MyPlugin({ ...OPTIONS })]

2. 插件的错误处理

  1. 通过throw的方式抛出
  1. 通过compilation对象的warnings和errors接收
compilation.warnings.push('warning') compilation.errors.push('error')
  1. 通过compilation进行文件写入
const { RawSource } = require('webpack-sources') module.exports = class DemoPlugin { constructor(options) { this.options = options } apply(compiler) { const { name } = this.options compiler.plugin('emit', (compilation, cb) => { compilation.assets[name] = new RawSource('demo') cb() }) } }
  1. 编写插件的插件
有部分插件可以暴露自身的hooks来进行扩展, 如html-webpack-plugin
// If your plugin is direct dependent to the html webpack plugin: const HtmlWebpackPlugin = require('html-webpack-plugin'); // If your plugin is using html-webpack-plugin as an optional dependency // you can use https://github.com/tallesl/node-safe-require instead: const HtmlWebpackPlugin = require('safe-require')('html-webpack-plugin'); class MyPlugin { apply (compiler) { compiler.hooks.compilation.tap('MyPlugin', (compilation) => { console.log('The compiler is starting a new compilation...') // Static Plugin interface |compilation |HOOK NAME | register listener HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync( 'MyPlugin', // <-- Set a meaningful name here for stacktraces (data, cb) => { // Manipulate the content data.html += 'The Magic Footer' // Tell webpack to move on cb(null, data) } ) }) } } module.exports = MyPlugin
  1. ShowCase: 编写一个生成产物zip包的插件
const JSZip = require('jszip') const path = require('path') const RawSource = require('webpack-source').RawSource const zip = new JSZip() module.exports = class ZipPlugin { constructor(options) { this.options = options } apply(compiler) { compiler.hooks.emit.tapAsync('ZipPlugin', (compilation, callback) => { // 创建一个zip的容器 const folder = zip.folder(this.options.filename) // 遍历compilation的静态产物 for (let filename in compilation.assets) { // 获取每个文件名称的源内容 const source = compilation.assets[filename].source() // 放入zip容器 folder.file(filename, source) } // 调用命令生成zip buffer zip.generateAsync({ type: 'nodebuffer' }).then((content) => { // 通过compilation的option中的输出路径来拿到输出位置 const outputPath = path.join(compilation.options.output.path. this.options.filename) // 根据输出位置得到当前相对的输出位置 const outputRelativePath = path.relative(compilation.options.output.path, outputPath) // 注入到assets静态资源中, 等待webpack构建完成后生成 compilation.assets[outputPath] = new RawSource(content) // 调用回调, 代表异步的hook结束 callback() }) }) } }

九. ModuleFederation 模块联邦

1. 是什么

多个独立的构建可以形成一个应用程序。这些独立的构建不会相互依赖,因此可以单独开发和部署它们。 这通常被称为微前端,但并不仅限于此。
鉴于mf的能力,我们可以完全实现一个去中心化的应用部署群:每个应用是单独部署在各自的服务器,每个应用都可以引用其他应用,也能被其他应用所引用,即每个应用可以充当host的角色,亦可以作为remote出现,无中心应用的概念。
对于微前端来讲, MF相当于是基于webpack的能力, 将chunk进行了模块化处理和装载, 而类似于qiankun等方案, 则是将应用进行模块化和动态化处理. 二者在实现上有比较大的差异
qiankun等方案的好处是, 老应用可以通过改造来进行接入, 但MF无法做到, 但从未来来讲, 基于MF的微前端方案因为能够实现chunk级别的控制, 理论上是一个更好的方案

2. 使用示例

生产端:
// STEP1: 配置MF的暴露和共享模块规则 const { ModuleFederationPlugin } = require('webpack').container; new ModuleFederationPlugin({ // 输出的模块名 name: 'app2', // 构建输出的文件名 filename: 'remoteEntry.js', // 被远程引用时可暴露的资源路径及其别名 exposes: { './App': './src/App', }, // 与其他应用之间可共享的依赖 shared: { react: { singleton: true }, 'react-dom': { singleton: true } }, }) // STEP2: 设置动态启动项目(使用动态的import) import("./bootstrap"); // STEP3: 项目正常运行 ...
消费端:
// STEP1: 配置MF的引入规则和远端模块地址 const {ModuleFederationPlugin} = require("webpack").container; new ModuleFederationPlugin({ // 输出的模块名 name: "app1", // 远程引用的应用名及其别名的映射 remotes: { // 远程引用的路径 app2: "app2@[app2Url]/remoteEntry.js", }, // 与其他应用之间可共享的依赖 shared: {react: {singleton: true}, "react-dom": {singleton: true}}, }) // STEP2: 动态启动项目, 并设置app2远程路径 window.app2Url = "http://localhost:3002" // 演示需要, 这个应该是判断环境进行动态的 import("./bootstrap"); // STEP3: 使用远端模块 import React, {Suspense} from "react"; const RemoteApp = React.lazy(() => import("app2/App")); const App = () => { return ( <Suspense fallback={"loading..."}> <RemoteApp/> </Suspense> ) } export default App;

3. 实现核心

在webpack4中, 我们可以使用require.ensure或动态import来在浏览器运行时引入异步的chunk来减小首屏加载数据大小
也就是说, 在webpack4中, 模块只有两种: 同步模块和异步模块
而在webpack5中, 因为MF的存在, 而MF是支持shared机制来共享第三方依赖, 以及暴露和引入MF模块, 使得原有的同步和异步模块变得不一样, 又衍生出了两种模块: 同步共享模块和异步远端模块

1. 同步共享模块

用于解决一个微前端系统的多个子系统之间依赖共享的问题,remote应用可以直接复用host应用上已经加载的共享模块,而不需要再次下载。 它跟externals很像,但是更安全更强大。
例如: 我们可以将react在MF中设置为共享模块
new ModuleFederationPlugin({ shared: { react: { 一些配置 } }})

2. 异步远端模块

host应用可以通过异步加载的方式使用的remote应用暴露出的模块。
import('remote-app/async-component')

3. 模块执行时机

  1. require模块并执行(第一个模块是入口模块),执行过程中
  1. 如果遇到静态import
  1. 如果这个模块是非共享同步模块,回到1如果这个模块是共享同步模块,前往4
  1. 如果遇到动态import
  1. 如果这个模块是本地异步模块,前往4如果这个模块是远端异步模块,也前往4
  1. 先require.ensure把异步模块相关的异步模块加载到modulesMap
  1. 如果是来自2.b,会初始化shared-scope,用来处理依赖共享相关逻辑如果是来自3.a,没有额外操作如果是来自3.b,会利用shared-scope来复用依赖,实现依赖共享
也就是说,即便在MF中,不管是共享模块还是远端模块,其实还是使用的require.ensure去加载一些异步chunk罢了。只不过稍有不同的是,因为牵扯到依赖共享的逻辑,会有一个shared-scope的概念,用来实现依赖共享的相关逻辑。
到这里,我们可以看到,MF的实现其实并没有魔法,仅仅是异步chunk罢了。这整个过程跟webpack5是没有绑定关系的,也就是说MF并非webpack5的专属功能,Rollupwebpack4都可以实现MF。

4. 实现原理

  1. 动态启动
动态启动的原因是需要前置拿到共享的chunk以保证后续内容能正常运行
  1. 加载依赖项
根据当前所需的共享依赖版本来进行依赖项加载(其实就是加载了本地的共享依赖版本), 以保证后续流程正常进行.
同时, 会将当前本地所使用的共享依赖, 以版本号为key注册到sharedScope中
  1. 下载远程依赖项定义的入口文件
下载远程依赖项入口文件, 并执行其init方法
同样的, 会将远程的共享依赖项, 以版本号为key, 注册到sharedScope中(如果两者版本号不一致的情况下)
这样做的好处就是在微前端场景下, 使用不同版本依赖包的组件等, 也能够保证其能使用预定的依赖版本号安全运行
  1. 加载远程expose的内容
根据入口文件, 对远程expose的内容进行加载和装载, 以便项目中可以通过远程地址加组件名称的方式来进行使用
  1. 继续后续流程
工程化
  • Webpack
  • 原理
  • NextCloud使用笔记rrweb原理
    目录