当前位置: 首页 > Web前端 > HTML

从一个前端公共库的构建开始,深入谈谈treeshaking相关问题

时间:2023-03-28 13:40:10 HTML

项目背景随着业务的积累,很多前端项目会逐渐产生很多可以跨项目复用的逻辑或者组件.比如前端数据库indexedDB的封装,fetch请求进度和中断请求函数的扩展,可能在多个项目中使用的react和vue组件。目前已经有专门用于汇聚js逻辑复用的公共库,但是随着同一技术栈的项目逐渐增多,仅仅js层面的复用是不够的,还需要跨组件复用项目,而之前的公共库项目设计不能很好的承接react和vue组件库,所以需要一个综合的公共库来存放之前的js库,新增的react组件库,vue2组件库,vue3组件库等.新项目摒弃了多仓库的设计方式,采用了monorepo结构。原因是vue和react组件也可能有一些通用的逻辑可以抽象到js库中,也可能会用到js库中的一些工具函数。如果使用多个仓库,那么在更新js库时,只能手动更新所有依赖它的仓库,而像lerna这样的monorepo解决方案本质上是在一个git仓库中,依赖以SymbolicLink的形式关联。很自然地感知到项目的修改,自动解决这个痛点。Deadcoderemoval(去除无用代码)在组件库项目开始之前,我们首先调研了类似项目的设计,特别关注按需加载。比如antd-mobile,按照开发直觉,import{Button}from'antd-mobile'时,只要antd-mobile提供了esm规范的包文件,应该可以通过tree按需加载颤抖。事实是它确实提供了esm文件:如果我在esm文件中导入它,它将从es/index.js文件中导入。如图所示,es/index.js确实是一个符合esm规范的模块入口,所以一般情况下js部分直接按需加载即可。但是官网上有这么一段话,那么什么情况下才算是不支持treeshaking的环境呢?我的总结如下:1.使用commonjs规范导入模块,如const{Button}=require('antd-mobile')2.使用esm,但是代码用babel的preset编译后会转成commonjs-env(可以通过modules:false解决)3.代码初始化的时候可能会有副作用(不完全等同于FP的副作用,后面会解释这个名词),但是antd明明是支持treeshaking的,就是显然不是这样4.在其他编译流水线之后有时(指webpack之类的loader)可能会带来副作用代码。所以为了避免开发者出现上述情况,antd-mobile为每个组件提供了分包,通过babel-plugin-import在编译时修改参考代码。间接实现了treeshaking的功能。由于开发环境不可控,本项目也为各个组件提供了分包,以便在特殊情况下按需引入。最后的打包结果是这样的。如何分包,有兴趣的可以看rollup配置。入口处挤满了人。只要指定一个固定的目录规范,就可以找到通用入口和各个组件的入口。后面添加的新项只要符合目录规范就可以重复使用。摇树失败。拆包后,神清气爽。现在开发者可以使用treeshaking在主入口获取最小化代码,或者直接获取分包。跑着看分包是不是很简单,点进了一个Loading组件的包文件,发现一个组件显示Loading状态的代码出乎意料。初步判断多分析问题可能是引入了外部模块,导致引入未使用的代码。打包结果中确实有很多未使用的代码。源码中确实引入了一个可疑的库(上面提到的公共js库),而多余的代码也确实在这个js库中,所以初步判断问题是因为使用了px2vw函数,所以里面的一些其他代码还介绍了js库。查看js库入口文件,好像没有问题。与打包后的结果相比,多出的代码与SDK有关。其他模块确实通过treeshaking优化过,所以不太可能是转成commonjs的。那么sdk文件到底有什么问题呢?首先,我们看一下什么会导致esm环境下的treeshaking失效。esm中treeshaking的原因是看webpack文档中一段关于treeshaking的。在100%ESM模块世界中,识别副作用非常简单。然而,我们还没有做到这一点,所以与此同时,有必要向webpack的编译器提供有关代码“纯粹性”的提示。如果您都使用esm模块编写代码,则很容易识别“副作用”。但事实并非如此,所以你需要通过配置告诉webpack你的代码是“纯”的,让webpack认为它没有“副作用”。具体配置为{"name":"your-project","sideEffects":false}这里说的副作用是影响treeshaking的关键因素。它并不完全等同于FP(函数式编程)中的副作用,我认为它只是与FP副作用重叠FP维基百科中对副作用的解释是:在计算机科学中,函数副作用是调用函数时,除了返回可能的函数值之外,它对调用函数也有额外的影响。比如修改全局变量(函数外的变量),修改参数,输出字符到调用者的终端或管道,或者改变外部存储信息等。具体在js中,我总结了以下几点:函数外的变量或参数上下文被修改。以下添加和修改函数是leti=0constadd=()=>++iconsto={a:0}constmodify=(_o)=>{_o.a++return_o}modify(o)有副作用的函数产生IO操作,包括但不限于打印日志、读写、操作dom、网络请求等//打印日志constlog=(...args)=>{console.log(...args)}//读取domconstquery=(identify)=>document.querySelector(identify)//networkioconstget=(url)=>fetch(url)treeshaking中的副作用treeshaking中的“副作用”就在其中,比如导入过程中:修改window属性可能会触发getter和setter操作,因为无法判断get和set中是否有副作用来打印日志,但是FP中的一些副作用不算作副作用treeshaking,有些在treeshaking中产生的副作用的副作用在FP中不算作副作用。它们是不必要和不充分的关系,但有一个交集。请看下面我写的最小测试,看看是否有副作用。demoFP和treeshaking的区别和相同的副作用1.正常导出如上图有一个utils1.ts文件,里面有5个函数以各种方式导出,然后是函数a和函数exportdefault导出的a3在入口文件index.ts中引入,如下图。最后使用rollup打包index.ts,下面是打包结果的部分代码。从上图可以看出,结果中只包含了导入的函数a和a3,其他函数没有封装。摇树成功。2.以对象的形式导出如上图所示,在utils2.ts文件中,exportdefault有一个由c和d函数组成的对象,同时单独导出函数e。然后我们在index.tsutils2中引入exportdefault导出的对象,见下图,只使用里面的c函数。最终的打包结果如下图红框标注。可以看到它打包了整个对象,也就是里面的c和d函数,e函数是正常的treeshaking。因为导出的模块是一个对象,使用时读取对象的方法,所以rollup无法提前知道js运行时你会需要哪个方法,只能全部打包。3.产生副作用从上图中的utils3.ts文件我们可以看出,f函数是一个典型的FP副作用函数,因为它修改了外部变量。g函数也是一个FP副作用函数,因为它里面有一个print操作,声明之后还会触发一个printconsole.log(g),也就是说它的父执行上下文也有副作用。单看h函数,不是FP副作用函数,而是h函数外的context有副作用(windowasany).__h=h,但h函数本身不算。i函数没有问题,你可以在index.ts中引入它,看看其他函数如何反应。最后的x函数比较特殊。在声明它之前,会读取Proxy对象的属性。理论上,这些与x函数相关的操作不被认为是FP的副作用。看一下最终的打包结果:从上图可以发现,f函数虽然属于FP的sideeffect,但不属于treeshaking的sideeffect。可以通过deadcoderemovalg函数引入,因为console.log的sideeffect是在statement的时候产生的。为了判断console.log是哪个模块的副作用引起的,第二个package去掉了console.log(g),发现是正常的treeshaking,所以只有当console.log产生的过程中模块声明是treeshaking的副作用。h函数是因为声明后修改了窗口,所以有副作用。如果不保留,如果其他模块从window中读取,会报错,所以rollup对其treeshakingx功能没有什么特别之处,去掉了,但是保留了上面的o对象的Create和read操作。这样做的原因是开发者可能会通过Proxy和Object.defineProperty等API劫持对象的getter属性操作符。里面的操作是不可预知的(比如修改窗口)。为了安全起见,Proxy和Object.defineProperty代码必须保留。但即使x函数也读取了o对象,也不会保留,因为x在运行时只会执行getter,不引入也不会造成潜在的副作用;然而,即使没有读取o.a的代码,声明被劫持对象的代码仍然被保留,即使它没有在任何地方使用。有点意外的是,像这样的代码,rollup也会进行treeshaking(也就是不retain)要知道读属性可能会有副作用,假设在window.__n属性中添加了getter属性运算符,即used读取全局记录的次数,在utils3.ts中使用了window.__n,预计是window.__n+1,事实是最终代码中并没有读取,window.__n没有变了。这是非常违反直觉的。您可以使用以下命令在命令行上控制汇总策略:--no-treeshake.moduleSideEffects假定模块没有副作用--no-treeshake.propertyReadSideEffects忽略属性访问的副作用您还可以在其中添加注释youneedtoignoresideeffects/*@__PURE__*/从上面的几个副作用案例可以看出,treeshaking和FP的副作用是一种交集关系,并不完全相等。4、引入commonjs后,rollup是否也可以对commonjs进行treeshaking?比如下面有一个utils4.common.js文件,它以exports对象属性的形式导出j和k函数,然后在index.ts中只导入j函数。最终打包结果如下。可以看到这种形式的commonjs模块可以配合treeshaking,rollup可以静态分析出哪些函数被添加到exports,哪些函数被import了。所有commonjs都可以吗?再看另一种形式5.commonjs的另一种形式是先给module.exports赋值一个新的对象,里面包含l函数,然后给它加上m函数,然后我们只在index.ts中引入l函数并包:可以看到rollup把不需要的m函数也打包了。如果我们只在index.ts中引入m函数并打包:这一次才是正确的treeshaking。我们可以看到不同的module.exports方法和引用不同的函数都会影响treeshaking的效果。如果你把一个对象赋值给exports,以后在对象字面量(即l)中引入方法,那么这个对象剩下的方法也会被封装;而如果在非对象字面量中引入方法(即后面动态添加的方法m),则可以进行正常的treeshaking。所以第三方commonjs库存在treeshaking的不确定性。除非迫不得已,否则请尝试使用支持esm的库。以上就是对treeshaking问题的探索,具体代码在这里:https://replit.com/@HiWayne/r...顺便看看这个平台https://replit.com,可以直接运行项目,多人协作,可以直接复制别人搭建的脚手架模板,有点云开发的感觉。回到最初的问题(为什么sdk文件不是treeshaking),答案已经很明显了。由于文件没有转成commonjs且esm语法正确,无非是sdk文件中出现了上述的一些副作用。毕竟本项目中的sdk代码是从多年前的一个老项目移植过来的,而老项目并没有考虑模块化。直接把sdk交给window属性这个明显的问题在移植之初就已经发现了。但是由于需要客户端和前端进行通信,所以代码中还是有很多隐式的基于窗口的协议协商,这必然导致了treeshaking的问题。但问题的根源已经查明,只是时间问题。Tips:如果代码逻辑复杂,代码量大,老代码看不懂,其实还有个小技巧可以缩小排查范围。先保存代码,再删除你认为可疑的代码块。如果直接删除会影响上下文,就换成空实现(反正编译时不会暴露运行时问题),然后再打包,这样可以多测试几次。其次,如果treeshaking正常,那么就是被删除的代码块中的某些逻辑出了问题,再往里查。通过以上技术排查,最终发现在sdk文件中,模块初始化阶段调用了sdk类(这里用Sdk.f表示)中的方法。不清楚该方法在编译时是否也调用了其他方法,也不清楚其他方法是否有副作用,导致rollup无法判断sdk是否需要保留,所以都留下。好在Sdk.f中没有用到这个,所以可以从类中抽取出来,变成一个函数。初始化的时候调用了这个函数,然后把f函数作为Sdk的一个方法,成功解决了问题。最终的解决步骤并不复杂,但是从表面分析问题,以及对其中涉及的细节概念的掌握,是非常考验一个人的解决问题能力和前端技术的深度和广度的。一个问题的难点往往不在具体的解法操作,而在于知道如何求解,为什么。我们通常会学习各种原理、细节和设计思想。它真正的价值是在遇到困难的时候发挥作用,而不只是为了学习或者面试。很难应用所学知识。总结本文从项目创建、项目选择、按需加载设计的背景出发,以项目中遇到的treeshaking问题为讨论中心,详细介绍了treeshaking失败的细节和原因,并通过demo举例说明treeshakingsideeffects和functionalprogramming(FP)sideeffects的异同点,最后回归到如何解决最初的问题,推导出真正的学习价值。如果你也在写npm库,希望这篇文章能唤起你checktreeshaking的意识,也希望它能成为大家探索treeshaking和函数式编程的敲门砖:)