webpack 构建 流程
webpack 整体是一个插件的架构,所有的功能都是插件的方式集成在构建流程中,通过发布订阅来触发各个插件的执行,webpack 的核心使用Tapable
webpack 关键节点对应的事件
- entry-option 初始化 option
- run 开始编译
- make 从entry 开始递归的分析依赖模块进行build
- before-resolve -after-resolve 对其中的一个模块进行解析
- build-module 开始构建这个module 这里将使用文件对应的loader加载
- normal-module-loader 对用loader加载完成的module(是一段js代码)进行编译,用 acorn 编译,生成ast抽象语法树
- program 开始对ast进行遍历,当遇到require等一些调用表达式时,触发call require事件的handler执行,收集依赖,并。如:AMDRequireDependenciesBlockParserPlugin等
- seal 所有依赖build完成,下面将开始对chunk进行优化,比如合并,抽取公共模块,加hash
- emit 把各个chunk输出到结果文件
webpack 准备阶段
modules 和chunks 的生成阶段
文件生成 阶段
webpack 准备工作
- 当我们 运行webpack 的时候就会创建Compiler 实例并且加载内部插件,这里跟构建流程相关性比较大的内部插件是 EntryOptionPlugin
- EntryOptionPlugin 会解析webpack 配置中的entry 属性,然后生成不同的插件应用到Compiler实例上,不管哪个插件内部都会监听Compiler 实例对make任务点
这里的make 任务点将成为后面解析modules和chunks的起点
当Compiler实例加载完成之后,下一步就会直接调用compiler.run方法来启动,值得注意的是此时只有options 属性是解析完成的
-
run 一旦执行就开始了编辑和构建流程 其中比较关键的几个webpack 事件节点
- compile 开始编译
- make 从入口点分析模块及其依赖的模块,创建这些模块对象
- build-module 构建模块
- after-compile 完成构建
- seal 封装构建结果
- emit 把各个chunk输出到结果文件
- after-emit 完成输出
接下来Compiler 对象开始实例化两个核心工厂的对象 NormalModuleFactory 和 ContextModuleFactory。 这两个对象工厂会在任务点compile 触发时传递过去,所以任务点compile 是间接监听这两个对象任务点的一个入口
module.exports = class EntryOptionPlugin {
/**
* @param {Compiler} compiler the compiler instance one is tapping into
* @returns {void}
*/
apply(compiler) {
compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
if (typeof entry === "string" || Array.isArray(entry)) {
itemToPlugin(context, entry, "main").apply(compiler);
} else if (typeof entry === "object") {
for (const name of Object.keys(entry)) {
itemToPlugin(context, entry[name], name).apply(compiler);
}
} else if (typeof entry === "function") {
new DynamicEntryPlugin(context, entry).apply(compiler);
}
return true;
});
}
};
- 当compilation 实例创建完成之后,webpack 准备工作已经完成,下一步将开始modules 和chunks 的生成阶段
总结:
1、编译过程启动的入口 compiler.run
开始,触发了一系列的生命周期钩子后,执行 compiler.compile
2、获取 compilation 所需 params,实例化 NormalModuleFactory 类(插件会去注册其钩子) 及 ContextModuleFactory 类,在实例化 NormalModuleFactory 的过程中,会实例化 RuleSet 及注册钩子 factory 和 resolver。
3、实例化 Compilation,传入 params 参数,触发之前在注册 plugin 阶段所注册的 NormalModuleFactory 下的 hooks。
4、触发 make 钩子执行compilation.addEntry
,通过编译队列控制 semaphore.acquire 执行moduleFactory.create
开始创建 module。
modules 和chunks 的生成阶段
这个阶段的主要内容,是先解析项目依赖的所有的modules ,再根据modules生成chunks;
modules解析主要包含: 创建实例、loader应用以及依赖手机 chunks 生成,主要步骤找到chunk 所需要包含的modules
chunks 生成,主要的步骤就是找到chunk所需要包含的modules
- 上一个阶段完成,任务点 make 触发,此时内部插件 SingleEntryPlugin 的监听器开始执行,监听器调用Compilation 实例的 addEntry 方法,该方法将第一批module 解析
modules 解析过程
- 第一个步骤是创建 NormalModule 实例
实例化 NormalModule 得到初始化的 module,然后在 build 过程中先 run loader 处理源码,得到一个编译后的字符串或 buffer。
NormalModuleFactory 的creat 方法 创建 NormalModule 实例的入口 主要解析的 module 需要用到一些属性
比如要用到的 loaders, 资源路径 resource 等等 将解析完的参数给 NormalModule 构建函数直接实例化
-
在创建完 NormalModule 实例之后会调用 build 方法继续进行内部的构建
这时候loader 将会开始使用, 应用 loaders 的过程相对简单,直接调用了 loader-runner 这个模块
NormalModule 最终都是js 模块,所以loader 作用之一就是将不同的资源文件转为 js 模块
我们需要得到这个 module 所依赖的其他模块,所以就有一个依赖收集的过程。webpack 的依赖收集过程是将 js 源码传给 js parser-
调用 parser 将前面 runloaders 的编译结果通过 acorn 转换为 ast;
parser 将 js 源码解析后得到对应的AST。 然后 webpack 会遍历 AST,按照一定规则触发任务点
有了AST的任务点,依赖收集就相对简单了。比如遇到任务点 callrequire,说明在代码中是有调用了 require函数,那么就应该给 module 添加新的依赖。
- 当 parser 解析完成之后,module 的解析过程就完成了,就会触发任务点 succeed-module。。最终我们会得到所有依赖的module,此时任务点 make 结束
- Compialtion 实例的 seal 方法会被调用并马上触发任务点 seal。 webpack 会开始生成 chunks
-
chunk 生成
- webpack 先将entry 对应的 module 生成一个新的 chunk
- 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中
- 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个新的 chunk,继续遍历依赖
- 重复上面的过程,直至得到所有的 chunks
对module 和chunk 进行优化 操作
- 这些任务点一般是 webpack.optimize 属性下的插件会使用到
文件生成
根据chunks 生成最终文件 :模板hash 更新,模板渲染chunk,生成文件
webpack 基本框架
- webpack的基础架构 是基于一种类似时间的方式,webpack 的大部分功能通过注册任务点的方式来实现。
- 在我们运行 webpack的时候 会注册 一个Compiler 实例,然后 调用WebpackOptionsApply这个模块给 Compiler添加插件
hash 和 chunkhash, contenthash
hash: 如果使用hansh 对js css 签名每次都不一样,这样导致无法利用缓存,
chunkhash: 根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的哈希值,比如我们将一些公共模块,或者第三方依赖包独立开来,接着用chunkhash 生成哈希值,只要不改变公共代码,就不需要重新构建
打包时发现,js和js引入的css的 chunkhash 是相同的,导致无法区分css和js的更新,如下
webpack 把css 当成js 的一部分,所以计算chunkhash 是混合在一起计算的,解决的办法: css是使用 ExtractTextPlugin 插件引入的,这时候可以使用到这个插件提供的 contenthash,
当chunkhash 用在css 中时, 由于css 和js 用了同一个chunkhash,所以当只改变js 时,css 文件也会重新生成, 所以css 中我们使用contenthash
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css'
}),
-
在webpack 中配置 CommonsChunkPlugin (
webpack4 已经废弃
)- 配置webpack的output项时,filename 和 chunkFilename 必要使用chunkhash 不要使用hash,否则
- 对于抽取的css样式文件,需要使用contenthash, 与file-loader中的hash意义相同。此处不能为chunkhash,否则其与抽取该样式文件的entry chunk的chunkhash保持一致,打不到缓存的目的
前面对webpack 部分细节一知半解,又来更新,重新更新 webpack 的打包,webpack 的一些api 也有所更新... 来吧! 再一次宠幸下 webpack 吧!
webpack 运行机制:
下面写贴上 webpack 运行机制 一篇不错的文章,和大致的流程图
https://github.com/jerryOnlyZRJ/webpack-loader/blob/master/docs/webpack-principle.md
webpack基本打包机制:
https://juejin.im/post/5df884ad6fb9a0164e7f979d#heading-8
基本的打包过程:
- 1、利用babel完成代码转换,并生成单个文件的依赖
- 2、从入口开始递归分析,并生成依赖图谱
- 3、将各个引用模块打包为一个立即执行函数
- 4、将最终的bundle文件写入bundle.js中
webpack 打包过程:
1、初始化参数 从配置文件和 Shell 语句中读取与合并参数,得出最终的参数
2、开始编译
用上一步得到的参数初始Compiler对象
,加载所有配置的插件,通 过执行对象的run方法开始执行编译
3、确定入口
根据配置中的 Entry 找出所有入口文件
4、编译模块 从入口文件出发,
调用所有配置的 Loader 对模块进行编译
,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件
都经过了本步骤的处理5、完成模块编译 在经过第4步使用 Loader 翻译完所有模块后, 得到了每个模块被编译后的最终内容及它们之间的依赖关系
6、输出资源:根据入口和模块之间的依赖关系,
组装成一个个包含多个模块的 Chunk,再将每个 Chunk 转换成一个单独的文件加入输出列表中,
这是可以修改输出内容的最后机会7、输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,将文件的内容写入文件系统中。
webpack 的事件流Tapable
为啥要说这个 Tapable 呢?
前面说过:webpack 整体是一个插件的架构,所有的功能都是插件的方式集成在构建流程中,通过发布订阅来触发各个插件的执行,webpack 的核心使用Tapable,它允许你创建钩子,为钩子挂载函数(webpack 里面挂载插件)
webpack最核心的模块例如:Compiler、Compilation等,他们都继承于Tapable。
webpack 里面有100多个插件,webpack 本身就像一条生产线,经过一个一个插件去处理,webpack 通过Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。 —吴浩麟《深入浅出webpack》
Tapable:
Tapable 通过发布者- 订阅者模式实现,
想具体了解的可以参考Tapable
Tapable 提供了一系列钩子:
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
\
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
Hook Type:
Basic hook: 这个比较简单一点,就是按照tap 注册顺序 一个一个向下就可以了
Waterfall :虽然也是按照tap的顺序一个个向下执行,但是如果上一个tap有返回值,那么下一个tap的传入参数就是上一个tap的返回值。
Bail : 如果返回了null以外的值,就不继续执行了。
Loop :当监听函数被触发的时候,如果该监听函数返回true时则这个监听函数会反复执行,如果返回 undefined 则表示退出循环
// 钩子语法
const hook = new SyncHook(["arg1", "arg2", "arg3"]);
举个例子:
class MyDaily {
constructor() {
this.hooks = {
beforeWork: new SyncHook(["getUp"]),
atWork: new SyncWaterfallHook(["workTask"]),
afterWork: new SyncBailHook(["activity"])
};
}
tapTap(){
//此处是行为
}
run(){
this.hooks.beforeWork.call()
this.hooks.atWork.call()
this.hooks.afterWork.call()
}
}
tapTap(){
this.hooks.beforeWork.tap("putOnCloth",()=>{
console.log("穿衣服!")
})
this.hooks.beforeWork.tap("getOut",()=>{
console.log("出门!")
})
this.hooks.atWork.tap("makePPT",()=>{
console.log("做PPT!")
return "你的ppt"
})
this.hooks.atWork.tap("meeting",(work)=>{
console.log("带着你的"+work+"开会!")
})
this.hooks.afterWork.tap("haveADate",()=>{
console.log("约会咯!")
return "约会真开心~"
})
this.hooks.afterWork.tap("goHome",()=>{
console.log("溜了溜了!")
})
}
//是熔断型的钩子,这个钩子的存在意义就是,可以中断一系列的事情,比如有地方出错了,或者不需要进行下一步的操作我们就可以及时结束
上面都是同步类型,如果我们做的事情是异步的呢?
this.hooks.beforeWork.tapAsync("putOnCloth",(params,callback)=>{
console.log("穿衣服!")
callback();//此处无callback,则getOut这个挂载的函数便不会运行
})
this.hooks.beforeWork.tapAsync("getOut",(params,callback)=>{
console.log("出门!")
callback()//此处无callback,则beforeWork这个钩子的回调函数不会执行
})
this.hooks.beforeWork.callAsync("working",err=>{
console.log(err+" end!")//如果最后一个tap的函数没有callback则不会执行
})
可以将callback当作next函数,也就是下一个tap的函数的意思。 如果函数出错,后面的函数不会运行,如果里面加入错误原因,callback("errorReason"),那么就直接回调用当前钩子的callAsync绑定的函数。
this.hooks.beforeWork.tapAsync("putOnCloth",(params,callback)=>{
console.log("穿衣服!")
callback("error");此处加入了错误原因,那么直接callAsync,抛弃了getOut
})
this.hooks.beforeWork.tapAsync("getOut",(params,callback)=>{//直接skip了
console.log("出门!")
})
this.hooks.beforeWork.callAsync("working",err=>{
console.log(err+" end!")//error end!直接打出错误原因。
})
Async和sync的区别在于Async通过callback来和后续的函数沟通,
sync则是通过return一个值来做交流。所以,Async自带sync中bail类型的钩子。
webpack 的整个运行机制 都是建立在tapable 之上的,webpack 最核心的模块 compiler、compilation 都是集成于 Tapable
webpack成功的原因之一就是灵活的插件机制,webpack一共提供了180多个勾子,你可以在任何位置,挂载任何的插件,来做一系列的操作。并且本身webpack的运行机制就是:声明一系列勾子,挂载一系列内部插件、调用插件
上面介绍了webpack 里面的事件流机制,下面就开始进入webpack 运行:
1、前面介绍过 EntryOptionPlugin阶段, webpack开始读取配置entry ,递归遍历所有的入口。
|
|
2、Webpack进入其中一个入口文件,开始compilation过程;(使用用户配置好的loader对文件内容进行编译(buildModule),我们可以从传入事件回调的compilation上拿到module的resource(资源路径)、loaders(经过的loaders)等信息
)
|
|
3、 将编译好的文件内容使用acorn 解析成AST 静态语法树;分析文件的依赖关系逐个拉取依赖模块并重复上述过程,最后将所有模块中的require语法替换成。
|
|
4、 emit阶段,所有文件的编译及转化都已经完成,包含了最终输出的资源,
webpack4 splitChunk:
而在webpack4中CommonsChunkPlugin被移除,取而代之的是 optimization.splitChunks 和 optimization.runtimeChunk 配置项,
webpack 将根据以下条件自动拆分代码块:
- 会被共享的代码块或者 node_mudules 文件夹中的代码块
- 体积大于30KB的代码块(在gz压缩前)(可修改)
- 按需加载代码块时的并行请求数量不超过5个(可修改)
- 加载初始页面时的并行请求数量不超过3个(可修改)
splitChunk
splitChunks: {
chunks: "async”,//默认作用于异步chunk,值为all/initial/async/function(chunk),值为function时第一个参数为遍历所有入口chunk时的chunk模块,chunk._modules为gaichunk所有依赖的模块,通过chunk的名字和所有依赖模块的resource可以自由配置,会抽取所有满足条件chunk的公有模块,以及模块的所有依赖模块,包括css
minSize: 30000, //默认值是30kb
minChunks: 1, //被多少模块共享
maxAsyncRequests: 5, //所有异步请求不得超过5个
maxInitialRequests: 3, //初始话并行请求不得超过3个
name: true, //打包后的名称,默认是chunk的名字通过分隔符(默认是~)分隔开,如vendor~
cacheGroups: { //设置缓存组用来抽取满足不同规则的chunk,下面以生成common为例
common: {
name: 'common', //抽取的chunk的名字
chunks(chunk) { //同外层的参数配置,覆盖外层的chunks,以chunk为维度进行抽取
},
test(module, chunks) { //可以为字符串,正则表达式,函数,以module为维度进行抽取,只要是满足条件的module都会被抽取到该common的chunk中,为函数时第一个参数是遍历到的每一个模块,第二个参数是每一个引用到该模块的chunks数组。自己尝试过程中发现不能提取出css,待进一步验证。
},
priority: 10, //优先级,一个chunk很可能满足多个缓存组,会被抽取到优先级高的缓存组中
minChunks: 2, //最少被几个chunk引用
reuseExistingChunk: true,// 如果该chunk中引用了已经被抽取的chunk,直接引用该chunk,不会重复打包代码
enforce: true // 如果cacheGroup中没有设置minSize,则据此判断是否使用上层的minSize,true:则使用0,false:使用上层minSize
}
}
}
- 假如如果一个模块被多个chunks使用,(分割出它之后)就能很容易的在这些chunks之间共享
// a.js
import _ from 'lodash';
import users from './users';
const adam = _.find(users, { firstName: 'Adam' });
// b.js
import _ from 'lodash';
import users from './users';
const lucy = _.find(users, { firstName: 'Lucy' });
// webpack.config.js
module.exports = {
entry: {
a: "./src/a.js",
b: "./src/b.js"
},
output: {
filename: "[name].[chunkhash].bundle.js",
path: __dirname + "/dist"
},
optimization: {
splitChunks: {
chunks: "all"
}
},
};
// a.[chunkhash].bundle.js和b.[chunkhash].bundle.js
// vendors~a~b.[chunkhash].bundle.js的文件,其包含了Lodash库
SplitChunksPlugin插件默认只分割超过30kb的文件, 如果是 user.js 不超过30kb 不会被单独打包出来的。修改配置 也可单独打包出来
module.exports = {
entry: {
a: "./src/a.js",
b: "./src/b.js"
},
output: {
filename: "[name].[chunkhash].bundle.js",
path: __dirname + "/dist"
},
optimization: {
splitChunks: {
chunks: "all",
minSize: 0
}
}
};
// 多加一个 一个名为a~b.[chunkhash].bundle.js的文件
// 因为(如分割)并不能带来性能确实的提升,反而使得浏览器多了一次对utilities.js的请求,
//而这个utilities.js又是如此之小(不划算)
举个例子:
//info.js
const a = 1
export a
// index.js
import info from './info.js'
console.log(info)
//testExport.js
const exportDependencies = require('./exportDependencies')
console.log(exportDependencies('./src/index.js'))
打包之后:
要注意的是,webpack4中只有
optimization.namedModules为true
,此时moduleId才会为模块路径,否则是数字id。为了方便开发者调试,在development模式下optimization.namedModules参数默认为true。
我们可能需要了解到:
- @babel/parser:
负责将代码解析为抽象语法树
- @babel/traverse:遍历抽象语法树的工具,我们可以在语法树中解析特定的节点,然后做一些操作,如ImportDeclaration获取通过import引入的模块,FunctionDeclaration获取函数
- @babel/core:代码转换,如ES6的代码转为ES5的模式
// test.js
export default function cc() {
console.log("cc");
}
// index.js
import cc from './test.js';
console.log(cc)
打包后的 转化成函数
// test.js
function(module, __webpack_exports__, __webpack_require__) {
"use strict";
/* harmony export (binding) */
__webpack_require__.d(__webpack_exports__, "a",
function() {
return cc;
});
// 模块中的代码
function cc() {
console.log("cc");
}
}
模块运行时候被解析成对象:
// 'src/test.js'
{
i: moduleId, // 模块id
l: false, // 是否已加载
exports: {
__esModule: bool, // 是否是es模块
cc: fn // **
}
}
// 缓存起来避免重复解析,其中key是文件名
{
'src/test.js': { // key是文件名
i: moduleId, // 模块id
l: false, // 是否已加载
exports: {
__esModule: bool,
cc: fn // **
}
}
}
我们简单看下打包之后的代码:
有个Module对象,key是模块名,value是代码块。输出的也是立即执行函数,从入口开始执行...
webpack_require实现
这里的moduleId就是模块路径,如./src/commonjs/index.js。压缩之后变成数字
要注意的是,webpack4中只有optimization.namedModules为true,此时moduleId才会为模块路径,否则是数字id。
为了方便开发者调试,在development模式下optimization.namedModules参数默认为true。
splitChunks配置:
tree shaking
tree shaking出手的地方了,因为它能帮助我们干掉那些死代码,大大减少打包的尺寸。
必须使用ES6模块,不能使用其它类型的模块如CommonJS之流。
需要使用UglifyJsPlugin插件。如果在mode:"production"模式,这个插件已经默认添加了,如果在其它模式下,可以手工添加它。
打开optimization.usedExports。在mode: "production"模式下,它也是默认打开了的。它告诉webpack每个模块明确使用exports。
记得配置optimization,把usedExports和sideEffects设为true
参考文章:
https://juejin.im/post/5de099886fb9a071562facad
插件与Tapable
webpack 优化
Tapable 详解
http://taobaofed.org/blog/2016/09/09/webpack-flow/
https://lihuanghe.github.io/2016/05/30/webpack-source-analyse.html
winty Webpack4打包机制原理简析