webpack 原理和关键知识点
大纲
了解 webpack
打包之前,你应该要对模块化有基本了解,然后你才好更加深入的学习webpack
-
演进过程
-
文件划分的方式
- 命名冲突
- 模块变量污染
- 难以管理模块之间的关系
- 可复用性和可维护性都不高
- 命名空间的方式 (解决命名冲突)
-
IIFE 立即执行函数
- 解决了命名污染;
- 解决了私有变量随意被使用的问题;
- 模块依赖更加清晰
未解决: 模块加载的问题
-
-
模块化规范
-
CommonJS
- 导出属性: exports / module.exports
- 加载依赖: require
- 只能同步加载模块
-
AMD
- 定义模块:
define(id, dependencies, factory)
- 加载模块: require
- 优点:可以异步加载模块,可并行加载多个模块
- 缺点: 不能按需加载
- 定义模块:
-
ES Module
- 导出属性: export / export default
- 加载模块: import
- 优点: 动态导入,支持按需加载
-
基本信息
作用
- 主要是将代码分离到不同的bundle,然后可以按需加载文件
- 分离出更小的bundle,可以控制资源的优先级,提升加载性能
- 代码压缩混淆、处理js兼容问题、性能优化
好用的配置技巧
- 首先还是可以参考
webpack
官方文档,需要哪些直接去找就可以 - 使用
configuration
可以实现配置的智能提示,提升开发效率(配置完成以后需要注释掉引入)
工作机制和原理
- 读取
webpack
的配置参数; - 启动
webpack
,创建Compiler
对象并开始解析项目; - 从入口文件(
entry
)开始解析,并且找到其导入的依赖模块,递归遍历分析,形成依赖关系树; - 对不同文件类型的依赖模块文件使用对应的
Loader
进行编译,最终转为Javascript
文件; - 整个过程中
webpack
会通过发布订阅模式,向外抛出一些hooks
,而webpack
的插件即可通过监听这些关键的事件节点,执行插件任务进而达到干预输出结果的目的 - 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
- 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
loader 机制
为什么要有?
- webpack 其实是使用加载器去解析我们的模块的;默认的加载器只能解析js 文件
- 如果是要处理其他类型的文件的话,需要引入对应的加载器(loader)来进行处理
工作原理
- 类似一个管道,通过多个loader处理成js 代码才可被执行;
- loader其实就是一个个转换函数,返回的也必须为一段可执行的js代码;因为这里返回的内容会直接被合并到 chunk的 js文件中
为什么要在js 代码中加载其他资源
- 逻辑上比较合理,有整体性
- 上线时,资源不缺失,且都是有效的
手写实现一个loader
//loader/md-loader
var MarkdownIt = require('markdown-it');
const md = new MarkdownIt();
module.exports = function (source) {
const content = md.render(source);
const code = `module.exports = ${JSON.stringify(content)}`;
return code;
}
常用的loader
- file-loader:把文件输出到一个文件夹中,在代码中通过相对 URL 去引用输出的文件
- url-loader:和 file-loader 类似,但是能在文件很小的情况下以 base64 的方式把文件内容注入到代码中去
- source-map-loader:加载额外的 Source Map 文件,以方便断点调试
- image-loader:加载并且压缩图片文件
- babel-loader:把 ES6 转换成 ES5
- css-loader:加载 CSS,支持模块化、压缩、文件导入等特性
- style-loader:把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS。
- eslint-loader:通过 ESLint 检查 JavaScript 代码
plugin 机制
作用
- 资源转换以外,增强自动化构建能力
- 经常需要重复做的事情,都可以考虑转换成自动化的形式来提高效率
原理
webpack 在每个环节都预留了合适的钩子,扩展的时候只需要找到合适的时机处理即可
手写一个plugin
// 声明插件
class HelloPlugin {
// 在构造函数中获取用户给该插件传入的配置
constructor(options) {}
// Webpack 会调用 HelloPlugin 实例的 apply 方法给插件实例传入 compiler 对象
apply(compiler) {
// 在emit阶段插入钩子函数,用于特定时机处理额外的逻辑;
compiler.hooks.emit.tap('HelloPlugin', (compilation) => {
// 在功能流程完成后可以调用 webpack 提供的回调函数;
})
// 如果事件是异步的,会带两个参数,第二个参数为回调函数,在插件处理完任务时需要调用回调函数通知webpack,才会进入下一个处理流程。
compiler.plugin('emit', function (compilation, callback) {
// 支持处理逻辑
// 处理完毕后执行 callback 以通知 Webpack
// 如果不执行 callback,运行流程将会一直卡在这不往下执行
callback()
})
}
}
module.exports = HelloPlugin
// 使用
const HelloPlugin = require('./hello-plugin.js')
var webpackConfig = {
plugins: [new HelloPlugin({ options: true })],
}
常见的plugin
html-webpack-plugin
:简化 HTML 文件创建 (依赖于 html-loader)
web-webpack-plugin
:可方便地为单页应用输出 HTML,比 html-webpack-plugin 好用
uglifyjs-webpack-plugin
:不支持 ES6 压缩 (Webpack4 以前)
terser-webpack-plugin
: 支持压缩 ES6 (Webpack4)
webpack-parallel-uglify-plugin
: 多进程执行代码压缩,提升构建速度
mini-css-extract-plugin
: 分离样式文件,CSS 提取为独立文件,支持按需加载 (替代extract-text-webpack-plugin)
serviceworker-webpack-plugin
:为网页应用增加离线缓存功能
clean-webpack-plugin
: 目录清理
ModuleConcatenationPlugin
: 开启 Scope Hoisting
speed-measure-webpack-plugin
: 可以看到每个 Loader 和 Plugin 执行耗时 (整个打包耗时、每个 Plugin 和 Loader 耗时)
webpack-bundle-analyzer
: 可视化 Webpack 输出文件的体积 (业务组件、依赖第三方模块)
webpack-dev-server
作用
每次改完代码,我们点击保存之后就可以帮我们自动打包编译
配置示例
devServer: {
hot: true, // HMR开启 (然后在plugin上使用插件)
hostOnly:true, // 默认情况下当代码编译失败修复后会刷新页面
host:"0.0.0.0",
port:8080, // 设置监听的端口,默认为8080
open:true,
compress:true, // 是否为静态文件开启gzip
// proxy是我们开发中常用的一个配置选项,它的目的设置代理来解决跨域访问的问题
proxy:{
"/api":{
target:"http://localhost:8888",
pathRewrite:{
"^/api":""
},
secure:false,
changeOrigin:true
}
}
}
HMR 热更新
为什么还需要?
前面的webpack-dev-server,只是可以做到整个chunk的更新, 修改完文件后会自动刷新整个页面;(文本输入框的内容会丢失);也就是理解为页面的状态丢失;HMR
就是为了做到局部替换,不影响全局的状态
- 样式文件经过loader处理的,style-loader已经对样式文件做了热更新,所以修改完样式文件可以及时更新;
- j s 文件修改完以后,需要手动处理热更新;否则还是会自动刷新(因为j s模块没有规律)
- 脚手架搭建的项目为什么可以做到热更新,因为脚手架内部已经帮我们实现了,且vue\react都会对文件按照一定的规律要求
原理流程图
处理过程
- 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。
- 第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。
- 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。
- 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。
- webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。
- HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。
- 而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
- 最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。
source-map
基本信息
sourceMap
是一项将编译、打包、压缩后的代码映射回源代码的技术,由于打包压缩后的代码并没有阅读性可言,一旦在开发中报错或者遇到问题,直接在混淆代码中debug
问题会带来非常糟糕的体验,sourceMap
可以帮助我们快速定位到源代码的位置,提高我们的开发效率。sourceMap
其实并不是Webpack
特有的功能,而是Webpack
支持sourceMap
,像JQuery
也支持souceMap
;
这份map文件长什么样子
{
"version" : 3, // Source Map版本
"file": "out.js", // 输出文件(可选)
"sourceRoot": "", // 源文件根目录(可选)
"sources": ["foo.js", "bar.js"], // 源文件列表
"sourcesContent": [null, null], // 源内容列表(可选,和源文件列表顺序一致)
"names": ["src", "maps", "are", "fun"], // mappings使用的符号名称列表
"mappings": "A,AAAB;;ABCDE;" // 带有编码映射数据的字符串
}
- 生成文件中的一行的每个组用“;”分隔;
- 每一段用“,”分隔;
有了这份映射文件,我们只需要在我们的压缩代码的最末端加上这句注释,即可让sourceMap生效, 有了这段注释后,浏览器就会通过sourceURL
去获取这份映射文件,通过解释器解析后,实现源码和混淆代码之间的映射。因此sourceMap其实也是一项需要浏览器支持的技术
//# sourceURL=/path/to/file.js.map
推荐配置
- 开发环境:
cheap-module-eval-source-map
- 线上环境:
none
tree-shaking
实现机制
rollup
是在编译打包过程中分析程序流,得益于于 ES6 静态模块(exports 和 imports 不能在运行时修改),我们在打包时就可以确定哪些代码时我们需要的。webpack
本身在打包时只能标记未使用的代码而不移除,而识别代码未使用标记并完成 tree-shaking 的 其实是 UglifyJS、babili、terser 这类压缩代码的工具。简单来说,就是压缩工具读取 webpack 打包结果,在压缩之前移除 bundle 中未使用的代码。
压缩工具的发展
- UglifyJS(不支持ES6)
- BabelMinify(基于Babel的代码压缩,文件体积会更小)
- Terser (terser 是一个用于 ES6+ 的 JavaScript 解析器和 mangler/compressor 工具包,webpack5.0内置)
实现流程
-
Webpack 标记代码
- 所有 import 标记为
/* harmony import */
- 所有被使用过的 export 标记为
/* harmony export ([type]) */
,其中[type]
和 webpack 内部有关,可能是 binding, immutable 等等 - 没被使用过的 export 标记为
/* unused harmony export [FuncName] */
,其中[FuncName]
为 export 的方法名称
- 所有 import 标记为
-
然后使用代码压缩工具,清除未使用的代码(UglifyJS、terser)
plugin的选择: TerserWebpackPlugin > BabelMinifyWebpackPlugin > UglifyjsWebpackPlugin
sideEffects
副作用 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。 简单来讲,比如通过原型链给修改全局属性的话,这时候就表示存在副作用;
sideEffects 可以优化打包体积, 并且一定程度上可以减少 webpack 对源码分析过程, 加快打包速度
如何使用
-
在项目的 package.json 文件中,添加 "sideEffects" 属性
True: 是默认值,如果不指定其他值的话。这意味着所有的文件都有副作用,也就是没有一个文件可以 tree-shaking
False: 告诉 Webpack 没有文件有副作用,所有文件都可以 tree-shaking
第三个值 […] 是文件路径数组。它告诉 webpack,除了数组中包含的文件外,你的任何文件都没有副作用。因此,除了指定的文件之外,其他文件都可以安全地进行 tree-shaking
-
webpack.config中 开启功能
split chunk
直接看下配置示例
splitChunks: {
chunks: "all", //指定打包同步加载还是异步加载
minSize: 30000, //构建出来的chunk大于30000才会被分割
minRemainingSize: 0,
maxSize: 0, //会尝试根据这个大小进行代码分割
minChunks: 1, //制定用了几次才进行代码分割
maxAsyncRequests: 6,
maxInitialRequests: 4,
automaticNameDelimiter: "~", //文件生成的连接符
cacheGroups: {
defaultVendors: {
test: /[\/]node_modules[\/]/, //符合组的要求就给构建venders
priority: -10, //优先级用来判断打包到哪个里面去
filename: "vendors", //指定chunks名称
},
default: {
minChunks: 2, //被引用两次就提取出来
priority: -20,
reuseExistingChunk: true, //检查之前是否被引用过有的话就不被打包了
},
},
}
chunks: async、all、initial: async: 适合只将异步加载模块分离成单独chunk而其他模块合并成为一个chunk。 all: 适合在正常项目中提取同步异步公用模块到一个chunk中减少代码加载和代码量。 initial: 这个配置的特性是:如果自己当前模块的引用模块引用过自己正在使用的库那么这个库会被提取到公用的chunks中去,如果没有被引用过,就会构建到自己生成的chunk当中去,如果自己的子模块引用过这个库,也会被提取成相应的以自身为起点的公用chunk,所以这个代码分割可以尽可能的减小初始化的时候的代码量,属于有需要再提取,而不是all下的发现了就提取。在使用上我觉得这种方式比较好。
参考文献:
转载自:https://juejin.cn/post/7071929168493019172