1.什么是TreeShaking?Tree-Shaking是一种基于ESModule规范的DeadCodeElimination技术。运行时会静态分析模块间的导入导出,判断ESM模块中哪些导出值没有被其他模块使用,并删除,从而优化打包后的产品。TreeShaking早先由RichHarris在Rollup中实现,从2.0版本开始与Webpack打通,成为目前应用广泛的性能优化方法。1.1启动Webpack中的TreeShaking在Webpack中启动TreeShaking功能必须同时满足三个条件:使用ESM规范编写模块代码,配置optimization.usedExports为true,开启标记功能开启代码优化功能,可以通过以下方式实现:Configurationmode=productionconfigureoptimization.minimize=trueprovideoptimization.minimizerarrayeg://webpack.config.jsmodule.exports={entry:"./src/index",mode:"production",devtool:false,optimization:{usedExports:true,},};1.2理论基础在CommonJs、A??MD、CMD等老版本的JavaScript模块化方案中,导入导出行为是高度动态的,不可预测,例如:if(process.env.NODE_ENV==='development'){require('./bar');exports.foo='foo';}并且ESM方案从规范级别避免了这种行为,它要求所有的进出口报表只能出现在p模块的级别,并且导入导出模块的名称必须是字符串常量,这意味着以下代码在ESM方案下是非法的:if(process.env.NODE_ENV==='development'){importbarfrom'bar';exportconstfoo='foo';}因此,ESM下模块之间的依赖关系具有很强的确定性,与运行状态无关。编译工具只需要对ESM模块进行静态分析,就可以从代码字面量中推断出哪些模块的值还没有被其他模块使用过,这是实现TreeShaking技术的必要条件。1.3示例对于以下代码://index.jsimport{bar}from'./bar';console.log(bar);//bar.jsexportconstbar='bar';exportconstfoo='foo';在示例中,栏。js模块导出bar和foo,但只有bar的导出值被其他模块使用。经过TreeShaking处理后,foo变量将被视为无用代码并删除。2.实现原理在Webpack中,Tree-shaking的实现首先是“标记”模块的哪些导出值没有被使用,其次是使用Terser删除这些未使用的导出语句。标记过程大致分为三个步骤:Make阶段,收集模块导出变量并记录到模块依赖图中ModuleGraph变量Seal阶段,遍历ModuleGraph标记模块导出变量是否用于生成产品,如果变量是不被其他模块使用使用删除相应的导出语句。标记功能需要配置optimization.usedExports=true才能启用。也就是说,标记的作用是删除没有被其他模块使用的导出语句。例如:例子中,bar.js模块(左起第二个)导出了两个变量:bar和foo,其中foo没有被其他模块使用,所以标记之后,构建中变量foo对应的export语句产品(右一)被删除。反之,如果不开启标记功能(optimization.usedExports=false),那么无论是否使用,变量都会保留export语句,如上图右二产品代码所示。注意此时foo变量对应的代码constfoo='foo'保持原样,因为标记功能只影响模块的export语句,Terser插件实际执行的是“Shaking”操作。例如,在例子中,foo变量被标记后,就变成了DeadCode——无法执行的代码。这时候只需要使用Terser提供的DCE功能,删除这条定义语句,就可以达到完整的TreeShaking效果。.接下来我将展开标记过程的源码,详细讲解Webpack5中TreeShaking的实现过程,对源码不感兴趣的同学可以直接跳到下一章。2.1收集模块导出首先,Webpack需要弄清楚每个模块有什么导出值。这个过程发生在制作阶段。大体流程:更多关于Make阶段的说明,可以参考之前的文章【总结】了解Webpack的核心原理。1.将模块的所有ESM导出语句转换为Dependency对象,记录在模块对象的dependencies集合中。转换规则:命名导出转换为HarmonyExportSpecifierDependency对象默认导出转换为HarmonyExportExpressionDependency对象。例如,对于以下模块:exportconstbar='bar';exportconstfoo='foo';exportdefault'foo-bar'对应的dependencies的值为:2.所有模块编译完成后,触发compilation.hooks.finishModuleshook,开始执行FlagDependencyExportsPlugin插件回调3.FlagDependencyExportsPlugin插件开始从入口读取ModuleGraph4.遍历module对象的dependencies数组,找到所有HarmonyExportXXXDependency类型的依赖对象,转换成ExportInfo对象记录在ModuleGraph系统中,经过FlagDependencyExportsPlugin插件处理后,所有ESMstyleexport语句会记录在ModuleGraph系统中,后续操作可以直接从ModuleGraph中读取模块的导出值。参考资料:【万字总结】用一篇文章理解Webpack的核心原理有点吃力。webpack知识点:DependencyGraph深入解析2.2标记模块导出收集到模块导出信息后,Webpack需要标记每个模块的导出列表中包含哪些导出值。还有哪些模块用到,哪些不用,这个过程发生在Seal阶段,主要流程:触发compilation.hooks.optimizeDependencieshook,开始执行FlagDependencyUsagePlugin插件逻辑在FlagDependencyUsagePlugin插件中,从遍历ModuleGraph中存储的所有模块对象的入口,遍历模块对象对应的exportInfo数组,对每个exportInfo对象执行compilation.getDependencyReferencedExports方法,判断对应的依赖对象是否有任何模块使用的导出值,以及调用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只保留了定义语句,并没有在块中生成相应的导出。Webpack产品的内容和__webpack_require__.d方法的含义可以参考《Webpack原理六:深入理解Webpack运行时》一文。这个生成逻辑由export语句对应的HarmonyExportXXXDependency类实现。大体流程:在打包阶段调用HarmonyExportXXXDependency.Template.apply方法生成代码。在apply方法中,读取ModuleGraph中存储的exportsInfo信息,判断哪些导出值被使用,哪些未被使用,哪些已用和未使用的导出值,分别创建对应的HarmonyExportInitFragment对象,保存在initFragments数组中,遍历initFragments数组,生成最终结果。基本上,这一步的逻辑是使用之前收集的exportsInfo对象从模块中导出的值与export语句分开生成。2.4DeleteDeadCode经过前面的步骤,moduleexportlist中未使用的值将不会定义在__webpack_exports__对象中,形成无法执行的DeadCode效果,比如上例中的foo变量:这里Afterwards、Terser、UglifyJS等DCE工具会对这部分无效代码进行“摇动”,形成一个完整的TreeShaking操作。2.5小结综上,Webpack中TreeShaking的实现分为以下几个步骤:在FlagDependencyExportsPlugin插件中,根据模块的依赖列表收集模块导出值,记录在ModuleGraph系统的exportsInfo中收集FlagDependencyUsagePlugin插件Usage中模块的导出值,记录在exportInfo._usedInRuntime集合中。在HarmonyExportXXXDependency.Template.apply方法中,根据导出值的用途生成不同的导出语句。使用DCE工具删除DeadCode,达到完整的treeshaking效果。以上实现原理对背景知识要求比较高,建议读者结合以下文档食用:【万字总结】有点难的Webpack知识点,一口读懂Webpack核心原理文章:DependencyGraph深入剖析Webpack原理系列6:深入理解Webpack运行时3.最佳实践Webpack虽然从2.x开始就已经原生支持了TreeShaking功能,但是受限于JS的动态特性和复杂度modules,直到最新的5.0版本,很多代码副作用带来的问题都没有得到解决,使得优化效果没有TreeShaking最初设想的那么完美,所以用户需要有意识地优化代码结构,或者使用一些patch技术帮助Webpack更准确地检测无效代码,并完成TreeShaking操作。3.1避免无意义的赋值在使用Webpack时,需要有意识地避免一些不必要的赋值操作。观察下面的示例代码:示例中,index.js模块引用了bar.js模块的foo,并将其赋值给f变量,但后面的foo或f变量就不再使用了。在这个场景中,bar.js模块导出的foo值实际上并没有被使用,应该删除,但是Webpack的TreeShaking操作没有生效,产品中仍然保留了foo导出:Thesuperficialreasonfor这个结果是Webpack的TreeShaking逻辑停留在代码静态分析层面,只是粗浅的判断:模块的导出变量是否被其他模块引用这个变量是否出现在被引用模块的主代码中不去更进一步,从语义上分析模块导出的值是否真正被有效使用。更深层次的原因是JavaScript的赋值语句并不“纯粹”,根据具体场景的不同,可能会产生意想不到的副作用,例如:import{bar,foo}from"./bar";letcount=0;constmock={}Object.defineProperty(mock,'f',{set(v){mock._f=v;count+=1;}})mock.f=foo;console.log(count);在示例中,mock对象应用mock.f=foo赋值语句的Object.defineProperty调用对count变量产生了副作用。在这种场景下,即使进行复杂的动态语义分析,也很难在保证副作用正确的前提下完美甩掉所有无用的。代码分支。因此开发者在使用Webpack时需要有意识地避免这些无意义的重复赋值操作。3.2使用#pure标记纯函数调用与赋值语句类似,JavaScript中的函数调用语句也可能有副作用,所以默认情况下Webpack不会对函数调用进行TreeShaking操作。不过,开发者可以在调用语句前加上/*#__PURE__*/注释,明确告诉Webpack这个函数调用不会对上下文产生副作用。例如:例子中,foo('beretained')调用没有带/*#__PURE__*/备注,代码保留;作为比较,foo('beremoved')在它有一个Pure语句后被TreeShaking删除。3.3禁止Babel翻译模块导入导出语句Babel是一款非常流行的JavaScript代码转换器,可以将高版本的JS代码等价地翻译成兼容性更好的低版本代码,让前端开发者可以使用最新的语言特性开发代码与旧浏览器兼容。但是,Babel提供的一些特性会使TreeShaking功能失效。例如,Babel可以将import/export风格的ESM语句等效地翻译成CommonJS风格的模块化语句,但是这个特性会阻止Webpack导入和导出翻译后的模块。内容静态分析,示例:示例使用babel-loader处理*.js文件,设置babel配置项modules='commonjs'将模块化方案从ESM转为CommonJS,导致翻译代码不正确(上右边一个)标出未使用的导出值foo。作为对比,右图2是modules=false时的打包结果,此时foo变量被正确标记为DeadCode。因此,在Webpack中使用babel-loader时,建议将babel-preset-env的moduels配置项设置为false,关闭模块导入导出语句的翻译。3.4优化导出值粒度TreeShaking逻辑作用于ESM的导出语句,所以对于以下导出场景:exportdefault{bar:'bar',foo:'foo'}即使实际上只有默认导出值used一个属性,整个默认对象仍将完好无损。因此,在实际开发中,应尽量保持导出值的粒度和原子性。上面示例代码的优化版本:constbar='bar'constfoo='foo'export{bar,foo}3.5使用支持TreeShaking的包如果可能,尽量使用支持TreeShaking的npm包,例如:使用lodash-es代替lodash,或者使用babel-plugin-lodash来实现类似的效果但是,并不是所有的npm包都有TreeShaking的空间,比如React,Vue2之类的框架已经针对生产版本进行了足够的优化。这个时候业务代码需要整个代码包提供的完整功能,基本不需要TreeShaking。本文转载自微信公众号「Tecvan」
