最后
整理面试题,不是让大家去只刷面试题,而是熟悉目前实际面试中常见的考察方式和知识点,做到心中有数,也可以用来自查及完善知识体系。
开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
《前端基础面试题》,《前端校招面试题精编解析大全》,《前端面试题宝典》,《前端面试题:常用算法》
import {bar} from ‘./bar’;
console.log(bar);
// bar.js
export const bar = ‘bar’;
export const foo = ‘foo’;
示例中,bar.js
模块导出了 bar
、foo
,但只有 bar
导出值被其它模块使用,经过 Tree Shaking 处理后,foo
变量会被视作无用代码删除。
二、实现原理
======
Webpack 中,Tree-shaking 的实现一是先**「标记」**出模块导出值中哪些没有被用过,二是使用 Terser 删掉这些没被用到的导出语句。标记过程大致可划分为三个步骤:
-
Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
-
Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
-
生成产物时,若变量没有被其它模块使用则删除对应的导出语句
标记功能需要配置
optimization.usedExports = true
开启
也就是说,标记的效果就是删除没有被其它模块使用的导出语句,比如:
示例中,bar.js
模块(左二)导出了两个变量:bar
与 foo
,其中 foo
没有被其它模块用到,所以经过标记后,构建产物(右一)中 foo
变量对应的导出语句就被删除了。作为对比,如果没有启动标记功能(optimization.usedExports = false
时),则变量无论有没有被用到都会保留导出语句,如上图右二的产物代码所示。
注意,这个时候 foo
变量对应的代码 const foo='foo'
都还保留完整,这是因为标记功能只会影响到模块的导出语句,真正执行“「Shaking」”操作的是 Terser 插件。例如在上例中 foo
变量经过标记后,已经变成一段 Dead Code —— 不可能被执行到的代码,这个时候只需要用 Terser 提供的 DCE 功能就可以删除这一段定义语句,以此实现完整的 Tree Shaking 效果。
接下来我会展开标记过程的源码,详细讲解 Webpack 5 中 Tree Shaking 的实现过程,对源码不感兴趣的同学可以直接跳到下一章。
2.1 收集模块导出
首先,Webpack 需要弄清楚每个模块分别有什么导出值,这一过程发生在 make 阶段,大体流程:
关于 Mak e 阶段的更多说明,请参考前文 [万字总结] 一文吃透 Webpack 核心原理 。
- 将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到
module
对象的dependencies
集合,转换规则:
-
具名导出转换为
HarmonyExportSpecifierDependency
对象 -
default
导出转换为HarmonyExportExpressionDependency
对象
例如对于下面的模块:
export const bar = ‘bar’;
export const foo = ‘foo’;
export default ‘foo-bar’
对应的dependencies
值为:
-
所有模块都编译完毕后,触发
compilation.hooks.finishModules
钩子,开始执行FlagDependencyExportsPlugin
插件回调 -
FlagDependencyExportsPlugin
插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有module
对象 -
遍历
module
对象的dependencies
数组,找到所有HarmonyExportXXXDependency
类型的依赖对象,将其转换为ExportInfo
对象并记录到 ModuleGraph 体系中
经过 FlagDependencyExportsPlugin
插件处理后,所有 ESM 风格的 export 语句都会记录在 ModuleGraph 体系内,后续操作就可以从 ModuleGraph 中直接读取出模块的导出值。
参考资料:
2.2 标记模块导出
模块导出信息收集完毕后,Webpack 需要标记出各个模块的导出列表中,哪些导出值有被其它模块用到,哪些没有,这一过程发生在 Seal 阶段,主流程:
-
触发
compilation.hooks.optimizeDependencies
钩子,开始执行FlagDependencyUsagePlugin
插件逻辑 -
在
FlagDependencyUsagePlugin
插件中,从 entry 开始逐步遍历 ModuleGraph 存储的所有module
对象 -
遍历
module
对象对应的exportInfo
数组 -
为每一个
exportInfo
对象执行compilation.getDependencyReferencedExports
方法,确定其对应的dependency
对象有否被其它模块使用 -
被任意模块使用到的导出值,调用
exportInfo.setUsedConditionally
方法将其标记为已被使用。 -
exportInfo.setUsedConditionally
内部修改exportInfo._usedInRuntime
属性,记录该导出被如何使用 -
结束
上面是极度简化过的版本,中间还存在非常多的分支逻辑与复杂的集合操作,我们抓住重点:标记模块导出这一操作集中在 FlagDependencyUsagePlugin
插件中,执行结果最终会记录在模块导出语句对应的 exportInfo._usedInRuntime
字典中。
2.3 生成代码
经过前面的收集与标记步骤后,Webpack 已经在 ModuleGraph 体系中清楚地记录了每个模块都导出了哪些值,每个导出值又没那块模块所使用。接下来,Webpack 会根据导出值的使用情况生成不同的代码,例如:
重点关注 bar.js
文件,同样是导出值,bar
被 index.js
模块使用因此对应生成了 __webpack_require__.d
调用 "bar": ()=>(/* binding */ bar)
,作为对比 foo
则仅仅保留了定义语句,没有在 chunk 中生成对应的 export。
关于 W ebpack 产物的内容及
__webpack_require__.d
方法的含义,可参考 Webpack 原理系列六:彻底理解 Webpack 运行时 一文。
这一段生成逻辑均由导出语句对应的 HarmonyExportXXXDependency
类实现,大体的流程:
-
打包阶段,调用
HarmonyExportXXXDependency.Template.apply
方法生成代码 -
在
apply
方法内,读取 ModuleGraph 中存储的exportsInfo
信息,判断哪些导出值被使用,哪些未被使用 -
对已经被使用及未被使用的导出值,分别创建对应的
HarmonyExportInitFragment
对象,保存到initFragments
数组 -
遍历
initFragments
数组,生成最终结果
基本上,这一步的逻辑就是用前面收集好的 exportsInfo
对象未模块的导出值分别生成导出语句。
2.4 删除 Dead Code
经过前面几步操作之后,模块导出列表中未被使用的值都不会定义在 __webpack_exports__
对象中,形成一段不可能被执行的 Dead Code 效果,如上例中的 foo
变量:
在此之后,将由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。
2.5 总结
综上所述,Webpack 中 Tree Shaking 的实现分为如下步骤:
-
在
FlagDependencyExportsPlugin
插件中根据模块的dependencies
列表收集模块导出值,并记录到 ModuleGraph 体系的exportsInfo
中 -
在
FlagDependencyUsagePlugin
插件中收集模块的导出值的使用情况,并记录到exportInfo._usedInRuntime
集合中 -
在
HarmonyExportXXXDependency.Template.apply
方法中根据导出值的使用情况生成不同的导出语句 -
使用 DCE 工具删除 Dead Code,实现完整的树摇效果
上述实现原理对背景知识要求较高,建议读者同步配合以下文档食用:
三、最佳实践
======
虽然 Webpack 自 2.x 开始就原生支持 Tree Shaking 功能,但受限于 JS 的动态特性与模块的复杂性,直至最新的 5.0 版本依然没有解决许多代码副作用带来的问题,使得优化效果并不如 Tree Shaking 原本设想的那么完美,所以需要使用者有意识地优化代码结构,或使用一些补丁技术帮助 Webpack 更精确地检测无效代码,完成 Tree Shaking 操作。
3.1 避免无意义的赋值
使用 Webpack 时,需要有意识规避一些不必要的赋值操作,观察下面这段示例代码:
示例中,index.js
模块引用了 bar.js
模块的 foo
并赋值给 f
变量,但后续并没有继续用到 foo
或 f
变量,这种场景下 bar.js
模块导出的 foo
值实际上并没有被使用,理应被删除,但 Webpack 的 Tree Shaking 操作并没有生效,产物中依然保留 foo
导出:
造成这一结果,浅层原因是 Webpack 的 Tree Shaking 逻辑停留在代码静态分析层面,只是浅显地判断:
-
模块导出变量是否被其它模块引用
-
引用模块的主体代码中有没有出现这个变量
没有进一步,从语义上分析模块导出值是不是真的被有效使用。
更深层次的原因则是 JavaScript 的赋值语句并不**「纯」**,视具体场景有可能产生意料之外的副作用,例如:
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);
最后
文章到这里就结束了,如果觉得对你有帮助可以点个赞哦
开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
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);
最后
文章到这里就结束了,如果觉得对你有帮助可以点个赞哦
开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】
[外链图片转存中…(img-acy2HpD7-1715462062598)]