[全文高能] 一文吃透 Webpack 核心原理,看这里

三者协作构成 webpack 的主体框架:

理解了这三块内容就算是入了个门,对 Webpack 有了一个最最基础的认知了,工作中再遇到问题也就能按图索骥了。补充一句,作为一份入门教程,本文不会展开太多 webpack 代码层面的细节 —— 我的精力也不允许,所以读者也不需要看到一堆文字就产生特别大的心理负担。

核心流程解析

======

首先,我们要理解一个点,Webpack 最核心的功能:

At its core, webpack is a static module bundler for modern JavaScript applications.

也就是将各种类型的资源,包括图片、css、js等,转译、组合、拼接、生成 JS 格式的 bundler 文件。官网首页的动画很形象地表达了这一点:

这个过程核心完成了 内容转换 + 资源合并 两种功能,实现上包含三个阶段:

  1. 初始化阶段:

    1. 初始化参数:从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数
  2. 创建编译器对象:用上一步得到的参数创建 Compiler 对象

  3. 初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等

  4. 开始编译:执行 compiler 对象的 run 方法

  5. 确定入口:根据配置中的 entry 找出所有的入口文件,调用 compilition.addEntry 将入口文件转换为 dependence 对象

  6. 构建阶段:

    1. 编译模块(make):根据 entry 对应的 dependence 创建 module 对象,调用 loader 将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理
  7. 完成模块编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图

  8. 生成阶段:

    1. 输出资源(seal):根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
  9. 写入文件系统(emitAssets):在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

单次构建过程自上而下按顺序执行,下面会展开聊聊细节,在此之前,对上述提及的各类技术名词不太熟悉的同学,可以先看看简介:

  • Entry:编译入口,webpack 编译的起点

  • Compiler:编译管理器,webpack 启动后会创建 compiler 对象,该对象一直存活知道结束退出

  • Compilation:单次编辑过程的管理器,比如 watch = true 时,运行过程中只有一个 compiler 但每次文件变更触发重新编译时,都会创建一个新的 compilation 对象

  • Dependence:依赖对象,webpack 基于该类型记录模块间依赖关系

  • Module:webpack 内部所有资源都会以“module”对象形式存在,所有关于资源的操作、转译、合并都是以 “module” 为基本单位进行的

  • Chunk:编译完成准备输出时,webpack 会将 module 按特定的规则组织成一个一个的 chunk,这些 chunk 某种程度上跟最终输出一一对应

  • Loader:资源内容转换器,其实就是实现从内容 A 转换 B 的转换器

  • Plugin:webpack构建过程中,会在特定的时机广播对应的事件,插件监听这些事件,在特定时间点介入编译过程

webpack 编译过程都是围绕着这些关键对象展开的,更详细完整的信息,可以参考 Webpack 知识图谱 。

初始化阶段


基本流程

学习一个项目的源码通常都是从入口开始看起,按图索骥慢慢摸索出套路的,所以先来看看 webpack 的初始化过程:

解释一下:

  1. process.args + webpack.config.js 合并成用户配置

  2. 调用 validateSchema 校验配置

  3. 调用 getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults 合并出最终配置

  4. 创建 compiler 对象

  5. 遍历用户定义的 plugins 集合,执行插件的 apply 方法

  6. 调用 new WebpackOptionsApply().process 方法,加载各种内置插件

主要逻辑集中在 WebpackOptionsApply 类,webpack 内置了数百个插件,这些插件并不需要我们手动配置,WebpackOptionsApply 会在初始化阶段根据配置内容动态注入对应的插件,包括:

  • 注入 EntryOptionPlugin 插件,处理 entry 配置

  • 根据 devtool 值判断后续用那个插件处理 sourcemap,可选值:EvalSourceMapDevToolPluginSourceMapDevToolPluginEvalDevToolModulePlugin

  • 注入 RuntimePlugin ,用于根据代码内容动态注入 webpack 运行时

到这里,compiler 实例就被创建出来了,相应的环境参数也预设好了,紧接着开始调用 compiler.compile 函数:

// 取自 webpack/lib/compiler.js

compile(callback) {

const params = this.newCompilationParams();

this.hooks.beforeCompile.callAsync(params, err => {

// …

const compilation = this.newCompilation(params);

this.hooks.make.callAsync(compilation, err => {

// …

this.hooks.finishMake.callAsync(compilation, err => {

// …

process.nextTick(() => {

compilation.finish(err => {

compilation.seal(err => {…});

});

});

});

});

});

}

Webpack 架构很灵活,但代价是牺牲了源码的直观性,比如说上面说的初始化流程,从创建 compiler 实例到调用 make 钩子,逻辑链路很长:

  • 启动 webpack ,触发 lib/webpack.js 文件中 createCompiler 方法

  • createCompiler 方法内部调用 WebpackOptionsApply 插件

  • WebpackOptionsApply 定义在 lib/WebpackOptionsApply.js 文件,内部根据 entry 配置决定注入 entry 相关的插件,包括:DllEntryPluginDynamicEntryPluginEntryPluginPrefetchPluginProgressPluginContainerPlugin

  • Entry 相关插件,如 lib/EntryPlugin.jsEntryPlugin 监听 compiler.make 钩子

  • lib/compiler.jscompile 函数内调用 this.hooks.make.callAsync

  • 触发 EntryPluginmake 回调,在回调中执行 compilation.addEntry 函数

  • compilation.addEntry 函数内部经过一坨与主流程无关的 hook 之后,再调用 handleModuleCreate 函数,正式开始构建内容

这个过程需要在 webpack 初始化的时候预埋下各种插件,经历 4 个文件,7次跳转才开始进入主题,前戏太足了,如果读者对 webpack 的概念、架构、组件没有足够了解时,源码阅读过程会很痛苦。

关于这个问题,我在文章最后总结了一些技巧和建议,有兴趣的可以滑到附录阅读模块。

构建阶段


基本流程

你有没有思考过这样的问题:

  • Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?

  • Webpack 编译过程中,如何识别资源对其他资源的依赖?

  • 相对于 grunt、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?

这些问题,基本上在构建阶段都能看出一些端倪。构建阶段从 entry 开始递归解析资源与资源的依赖,在 compilation 对象内逐步构建出 module 集合以及 module 之间的依赖关系,核心流程:

解释一下,构建阶段从入口文件开始:

  1. 调用 handleModuleCreate ,根据文件类型构建 module 子类

  2. 调用 loader-runner 仓库的 runLoaders 转译 module 内容,通常是从各类资源类型转译为 JavaScript 文本

  3. 调用 acorn 将 JS 文本解析为AST

  4. 遍历 AST,触发各种钩子

    1. HarmonyExportDependencyParserPlugin 插件监听 exportImportSpecifier 钩子,解读 JS 文本对应的资源依赖
  5. 调用 module 对象的 addDependency 将依赖对象加入到 module 依赖列表中

  6. AST 遍历完毕后,调用 module.handleParseResult 处理模块依赖

  7. 对于 module 新增的依赖,调用 handleModuleCreate ,控制流回到第一步

  8. 所有依赖都解析完毕后,构建阶段结束

这个过程中数据流 module => ast => dependences => module ,先转 AST 再从 AST 找依赖。这就要求 loaders 处理完的最后结果必须是可以被 acorn 处理的标准 JavaScript 语法,比如说对于图片,需要从图像二进制转换成类似于 export default "data:image/png;base64,xxx" 这类 base64 格式或者 export default "http://xxx" 这类 url 格式。

compilation 按这个流程递归处理,逐步解析出每个模块的内容以及 module 依赖关系,后续就可以根据这些内容打包输出。

示例:层级递进

假如有如下图所示的文件依赖树:

其中 index.jsentry 文件,依赖于 a/b 文件;a 依赖于 c/d 文件。初始化编译环境之后,EntryPlugin 根据 entry 配置找到 index.js 文件,调用 compilation.addEntry 函数触发构建流程,构建完毕后内部会生成这样的数据结构:

此时得到 module[index.js] 的内容以及对应的依赖对象 dependence[a.js]dependence[b.js] 。OK,这就得到下一步的线索:a.js、b.js,根据上面流程图的逻辑继续调用 module[index.js]handleParseResult 函数,继续处理 a.js、b.js 文件,递归上述流程,进一步得到 a、b 模块:

从 a.js 模块中又解析到 c.js/d.js 依赖,于是再再继续调用 module[a.js]handleParseResult ,再再递归上述流程:

到这里解析完所有模块后,发现没有更多新的依赖,就可以继续推进,进入下一步。

总结

回顾章节开始时提到的问题:

  • Webpack 编译过程会将源码解析为 AST 吗?webpack 与 babel 分别实现了什么?

    • 构建阶段会读取源码,解析为 AST 集合。
  • Webpack 读出 AST 之后仅遍历 AST 集合;babel 则对源码做等价转换

  • Webpack 编译过程中,如何识别资源对其他资源的依赖?

    • Webpack 遍历 AST 集合过程中,识别 require/ import 之类的导入语句,确定模块对其他资源的依赖关系
  • 相对于 grant、gulp 等流式构建工具,为什么 webpack 会被认为是新一代的构建工具?

    • Grant、Gulp 仅执行开发者预定义的任务流;而 webpack 则深入处理资源的内容,功能上更强大

生成阶段


基本流程

构建阶段围绕 module 展开,生成阶段则围绕 chunks 展开。经过构建阶段之后,webpack 得到足够的模块内容与模块关系信息,接下来开始生成最终资源了。代码层面,就是开始执行 compilation.seal 函数:

// 取自 webpack/lib/compiler.js

compile(callback) {

const params = this.newCompilationParams();

this.hooks.beforeCompile.callAsync(params, err => {

// …

const compilation = this.newCompilation(params);

this.hooks.make.callAsync(compilation, err => {

// …

this.hooks.finishMake.callAsync(compilation, err => {

// …

process.nextTick(() => {

compilation.finish(err => {

compilation.seal(err => {…});

});

});

});

});

});

}

seal 原意密封、上锁,我个人理解在 webpack 语境下接近于 “将模块装进蜜罐”seal 函数主要完成从 modulechunks 的转化,核心流程:

简单梳理一下:

  1. 构建本次编译的 ChunkGraph 对象;

  2. 遍历 compilation.modules 集合,将 moduleentry/动态引入 的规则分配给不同的 Chunk 对象;

  3. compilation.modules 集合遍历完毕后,得到完整的 chunks 集合对象,调用 createXxxAssets 方法

  4. createXxxAssets 遍历 module/chunk ,调用 compilation.emitAssets 方法将 assets 信息记录到 compilation.assets 对象中

  5. 触发 seal 回调,控制流回到 compiler 对象

这一步的关键逻辑是将 module 按规则组织成 chunks ,webpack 内置的 chunk 封装规则比较简单:

  • entry 及 entry 触达到的模块,组合成一个 chunk

  • 使用动态引入语句引入的模块,各自组合成一个 chunk

chunk 是输出的基本单位,默认情况下这些 chunks 与最终输出的资源一一对应,那按上面的规则大致上可以推导出一个 entry 会对应打包出一个资源,而通过动态引入语句引入的模块,也对应会打包出相应的资源,我们来看个示例。

示例:多入口打包

假如有这样的配置:

const path = require(“path”);

module.exports = {

mode: “development”,

context: path.join(__dirname),

entry: {

a: “./src/index-a.js”,

b: “./src/index-b.js”,

},

output: {

filename: “[name].js”,

path: path.join(__dirname, “./dist”),

},

devtool: false,

target: “web”,

plugins: [],

};

实例配置中有两个入口,对应的文件结构:

index-a 依赖于c,且动态引入了 e;index-b 依赖于 c/d 。根据上面说的规则:

  • entry 及entry触达到的模块,组合成一个 chunk

  • 使用动态引入语句引入的模块,各自组合成一个 chunk

生成的 chunks 结构为:

也就是根据依赖关系,chunk[a] 包含了 index-a/c 两个模块;chunk[b] 包含了 c/index-b/d 三个模块;chunk[e-hash] 为动态引入 e 对应的 chunk。

不知道大家注意到没有,chunk[a]chunk[b] 同时包含了 c,这个问题放到具体业务场景可能就是,一个多页面应用,所有页面都依赖于相同的基础库,那么这些所有页面对应的 entry 都会包含有基础库代码,这岂不浪费?为了解决这个问题,webpack 提供了一些插件如 CommonsChunkPluginSplitChunksPlugin,在基本规则之外进一步优化 chunks 结构。

SplitChunksPlugin 的作用

SplitChunksPlugin 是 webpack 架构高扩展的一个绝好的示例,我们上面说了 webpack 主流程里面是按 entry / 动态引入 两种情况组织 chunks 的,这必然会引发一些不必要的重复打包,webpack 通过插件的形式解决这个问题。

回顾 compilation.seal 函数的代码,大致上可以梳理成这么4个步骤:

  1. 遍历 compilation.modules ,记录下模块与 chunk 关系

  2. 触发各种模块优化钩子,这一步优化的主要是模块依赖关系

  3. 遍历 module 构建 chunk 集合

  4. 触发各种优化钩子

上面 1-3 都是预处理 + chunks 默认规则的实现,不在我们讨论范围,这里重点关注第4个步骤触发的 optimizeChunks 钩子,这个时候已经跑完主流程的逻辑,得到 chunks 集合,SplitChunksPlugin 正是使用这个钩子,分析 chunks 集合的内容,按配置规则增加一些通用的 chunk :

module.exports = class SplitChunksPlugin {

constructor(options = {}) {

// …

}

_getCacheGroup(cacheGroupSource) {

// …

}

apply(compiler) {

// …

compiler.hooks.thisCompilation.tap(“SplitChunksPlugin”, (compilation) => {

// …

compilation.hooks.optimizeChunks.tap(

{

name: “SplitChunksPlugin”,

stage: STAGE_ADVANCED,

},

(chunks) => {

// …

}

);

});

}

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新

如果你觉得这些内容对你有帮助,可以添加V获取:vip1024c (备注前端)
img

总结

=============================================================

从转行到现在,差不多两年的时间,虽不能和大佬相比,但也是学了很多东西。我个人在学习的过程中,习惯简单做做笔记,方便自己复习的时候能够快速理解,现在将自己的笔记分享出来,和大家共同学习。

个人将这段时间所学的知识,分为三个阶段:

第一阶段:HTML&CSS&JavaScript基础

第二阶段:移动端开发技术

第三阶段:前端常用框架

  • 推荐学习方式:针对某个知识点,可以先简单过一下我的笔记,如果理解,那是最好,可以帮助快速解决问题;如果因为我的笔记太过简陋不理解,可以关注我以后我还会继续分享。

  • 大厂的面试难在,针对一个基础知识点,比如JS的事件循环机制,不会上来就问概念,而是换个角度,从题目入手,看你是否真正掌握。所以对于概念的理解真的很重要。

如果你觉得这些内容对你有帮助,可以添加V获取:vip1024c (备注前端)*
[外链图片转存中…(img-mY4IG4Rp-1711803122734)]

总结

=============================================================

从转行到现在,差不多两年的时间,虽不能和大佬相比,但也是学了很多东西。我个人在学习的过程中,习惯简单做做笔记,方便自己复习的时候能够快速理解,现在将自己的笔记分享出来,和大家共同学习。

个人将这段时间所学的知识,分为三个阶段:

第一阶段:HTML&CSS&JavaScript基础

第二阶段:移动端开发技术

第三阶段:前端常用框架

  • 推荐学习方式:针对某个知识点,可以先简单过一下我的笔记,如果理解,那是最好,可以帮助快速解决问题;如果因为我的笔记太过简陋不理解,可以关注我以后我还会继续分享。

  • 大厂的面试难在,针对一个基础知识点,比如JS的事件循环机制,不会上来就问概念,而是换个角度,从题目入手,看你是否真正掌握。所以对于概念的理解真的很重要。

  • 14
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值