2024年前端最新[全文高能] 一文吃透 Webpack 核心原理(2),2024年大厂Web前端面经

Vue

  • 什么是MVVM?

  • mvvm和mvc区别?它和其它框架(jquery)的区别是什么?哪些场景适合?

  • 组件之间的传值?

  • Vue 双向绑定原理

  • 描述下 vue 从初始化页面–修改数据–刷新页面 UI 的过程?

  • 虚拟 DOM 实现原理

  • Vue 中 key 值的作用?

  • Vue 的生命周期

  • Vue 组件间通信有哪些方式?

  • vue 中怎么重置 data?

  • 组件中写 name 选项有什么作用?

  • Vue 的 nextTick 的原理是什么?

  • Vuex 有哪几种属性?

    开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

这些问题,基本上在构建阶段都能看出一些端倪。构建阶段从 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) => {

// …

}

);

});

}

};

理解了吗?webpack 插件架构的高扩展性,使得整个编译的主流程是可以固化下来的,分支逻辑和细节需求“外包”出去由第三方实现,这套规则架设起了庞大的 webpack 生态,关于插件架构的更多细节,下面 plugin 部分有详细介绍,这里先跳过。

写入文件系统

经过构建阶段后,compilation 会获知资源模块的内容与依赖关系,也就知道“输入”是什么;而经过 seal 阶段处理后, compilation 则获知资源输出的图谱,也就是知道怎么“输出”:哪些模块跟那些模块“绑定”在一起输出到哪里。seal 后大致的数据结构:

compilation = {

// …

modules: [

/* … */

],

chunks: [

{

id: “entry name”,

files: [“output file name”],

hash: “xxx”,

runtime: “xxx”,

entryPoint: {xxx}

// …

},

// …

],

};

seal 结束之后,紧接着调用 compiler.emitAssets 函数,函数内部调用 compiler.outputFileSystem.writeFile 方法将 assets 集合写入文件系统,实现逻辑比较曲折,但是与主流程没有太多关系,所以这里就不展开讲了。

资源形态流转


OK,上面已经把逻辑层面的构造主流程梳理完了,这里结合资源形态流转的角度重新考察整个过程,加深理解:

  • compiler.make 阶段:

    • entry 文件以 dependence 对象形式加入 compilation 的依赖列表,dependence 对象记录有 entry 的类型、路径等信息
  • 根据 dependence 调用对应的工厂函数创建 module 对象,之后读入 module 对应的文件内容,调用 loader-runner 对内容做转化,转化结果若有其它依赖则继续读入依赖资源,重复此过程直到所有依赖均被转化为 module

  • compilation.seal 阶段:

    • 遍历 module 集合,根据 entry 配置及引入资源的方式,将 module 分配到不同的 chunk
  • 遍历 chunk 集合,调用 compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合

  • compiler.emitAssets 阶段:

    • assets 写入文件系统

Plugin 解析

=========

网上不少资料将 webpack 的插件架构归类为“事件/订阅”模式,我认为这种归纳有失偏颇。订阅模式是一种松耦合架构,发布器只是在特定时机发布事件消息,订阅者并不或者很少与事件直接发生交互,举例来说,我们平常在使用 HTML 事件的时候很多时候只是在这个时机触发业务逻辑,很少调用上下文操作。而 webpack 的钩子体系是一种强耦合架构,它在特定时机触发钩子时会附带上足够的上下文信息,插件定义的钩子回调中,能也只能与这些上下文背后的数据结构、接口交互产生 side effect,进而影响到编译状态和后续流程。

学习插件架构,需要理解三个关键问题:

  • WHAT: 什么是插件

  • WHEN: 什么时间点会有什么钩子被触发

  • HOW: 在钩子回调中,如何影响编译状态

What: 什么是插件


从形态上看,插件通常是一个带有 apply 函数的类:

class SomePlugin {

apply(compiler) {

}

}

apply 函数运行时会得到参数 compiler ,以此为起点可以调用 hook 对象注册各种钩子回调,例如:compiler.hooks.make.tapAsync ,这里面 make 是钩子名称,tapAsync 定义了钩子的调用方式,webpack 的插件架构基于这种模式构建而成,插件开发者可以使用这种模式在钩子回调中,插入特定代码。webpack 各种内置对象都带有 hooks 属性,比如 compilation 对象:

class SomePlugin {

apply(compiler) {

compiler.hooks.thisCompilation.tap(‘SomePlugin’, (compilation) => {

compilation.hooks.optimizeChunkAssets.tapAsync(‘SomePlugin’, ()=>{});

})

}

}

钩子的核心逻辑定义在 Tapable 仓库,内部定义了如下类型的钩子:

const {

SyncHook,

SyncBailHook,

SyncWaterfallHook,

SyncLoopHook,

AsyncParallelHook,

AsyncParallelBailHook,

AsyncSeriesHook,

AsyncSeriesBailHook,

AsyncSeriesWaterfallHook

} = require(“tapable”);

不同类型的钩子根据其并行度、熔断方式、同步异步,调用方式会略有不同,插件开发者需要根据这些的特性,编写不同的交互逻辑,这部分内容也特别多,回头展开聊聊。

When: 什么时候会触发钩子


了解 webpack 插件的基本形态之后,接下来需要弄清楚一个问题:webpack 会在什么时间节点触发什么钩子?这一块我认为是知识量最大的一部分,毕竟源码里面有237个钩子,但官网只介绍了不到100个,且官网对每个钩子的说明都太简短,就我个人而言看完并没有太大收获,所以有必要展开聊一下这个话题。先看几个例子:

  • compiler.hooks.compilation

    • 时机:启动编译创建出 compilation 对象后触发
  • 参数:当前编译的 compilation 对象

  • 示例:很多插件基于此事件获取 compilation 实例

  • compiler.hooks.make

    • 时机:正式开始编译时触发
  • 参数:同样是当前编译的 compilation 对象

  • 示例:webpack 内置的 EntryPlugin 基于此钩子实现 entry 模块的初始化

  • compilation.hooks.optimizeChunks

    • 时机:seal 函数中,chunk 集合构建完毕后触发
  • 参数:chunks 集合与 chunkGroups 集合

  • 示例:SplitChunksPlugin 插件基于此钩子实现 chunk 拆分优化

  • compiler.hooks.done

    • 时机:编译完成后触发
  • 参数:stats 对象,包含编译过程中的各类统计信息

  • 示例:webpack-bundle-analyzer 插件基于此钩子实现打包分析

这是我总结的钩子的三个学习要素:触发时机、传递参数、示例代码。

触发时机

触发时机与 webpack 工作过程紧密相关,大体上从启动到结束,compiler 对象逐次触发如下钩子:

compilation 对象逐次触发:

所以,理解清楚前面说的 webpack 工作的主流程,基本上就可以捋清楚“什么时候会触发什么钩子”。

参数

传递参数与具体的钩子强相关,官网对这方面没有做出进一步解释,我的做法是直接在源码里面搜索调用语句,例如对于 compilation.hooks.optimizeTree ,可以在 webpack 源码中搜索 hooks.optimizeTree.call 关键字,就可以找到调用代码:

// lib/compilation.js#2297

this.hooks.optimizeTree.callAsync(this.chunks, this.modules, err => {

});

结合代码所在的上下文,可以判断出此时传递的是经过优化的 chunksmodules 集合。

找到示例

Webpack 的钩子复杂程度不一,我认为最好的学习方法还是带着目的去查询其他插件中如何使用这些钩子。例如,在 compilation.seal 函数内部有 optimizeModulesafterOptimizeModules 这一对看起来很对偶的钩子,optimizeModules 从字面上可以理解为用于优化已经编译出的 modules ,那 afterOptimizeModules 呢?

从 webpack 源码中唯一搜索到的用途是 ProgressPlugin ,大体上逻辑如下:

compilation.hooks.afterOptimizeModules.intercept({

name: “ProgressPlugin”,

call() {

handler(percentage, “sealing”, title);

},

done() {

progressReporters.set(compiler, undefined);

handler(percentage, “sealing”, title);

},

result() {

handler(percentage, “sealing”, title);

},

error() {

handler(percentage, “sealing”, title);

},

tap(tap) {

// p is percentage from 0 to 1

// args is any number of messages in a hierarchical matter

progressReporters.set(compilation.compiler, (p, …args) => {

handler(percentage, “sealing”, title, tap.name, …args);

});

handler(percentage, “sealing”, title, tap.name);

}

});

基本上可以猜测出,afterOptimizeModules 的设计初衷就是用于通知优化行为的结束。

apply 虽然是一个函数,但是从设计上就只有输入,webpack 不 care 输出,所以在插件中只能通过调用类型实体的各种方法来或者更改实体的配置信息,变更编译行为。例如:

  • compilation.addModule :添加模块,可以在原有的 module 构建规则之外,添加自定义模块

  • compilation.emitAsset:直译是“提交资产”,功能可以理解将内容写入到特定路径

到这里,插件的工作机理和写法已经有一个很粗浅的介绍了,回头单拎出来细讲吧。

How: 如何影响编译状态


解决上述两个问题之后,我们就能理解“如何将特定逻辑插入 webpack 编译过程”,接下来才是重点 —— 如何影响编译状态?强调一下,webpack 的插件体系与平常所见的 订阅/发布 模式差别很大,是一种非常强耦合的设计,hooks 回调由 webpack 决定何时,以何种方式执行;而在 hooks 回调内部可以通过修改状态、调用上下文 api 等方式对 webpack 产生 side effect

比如,EntryPlugin 插件:

web浏览器中的javascript

window对象

  • 计时器

  • 浏览器定位和导航

  • 浏览历史

  • 浏览器和屏幕信息

  • 对话框

  • 错误处理

  • 作为window对象属性的文档元素

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值