我的项目背景

随着业务的积攒,前端我的项目之间逐步会产生许多能够跨我的项目复用的逻辑或组件。比方对前端数据库indexedDB的封装、对fetch申请进度和中断请求性能的扩大、以及可能会在多个我的项目应用的react和vue组件。以后曾经有一个公共库专门用来收敛js的逻辑复用,然而随着雷同技术栈的我的项目逐步减少,仅仅js层面的复用曾经不够了,组件也须要跨我的项目复用,而之前的公共库我的项目设计无奈较好的承接react和vue组件库,于是须要有一个综合的公共库,收纳之前的js库、新增react组件库、vue2组件库、vue3组件库等。

新我的项目摈弃了多仓库的设计形式,采纳了monorepo构造。究其原因是因为,vue、react组件也可能有一部分独特逻辑能够形象到js库中,也可能用到js库里的一些工具函数。如果采纳多仓库,那么当js库更新时,只能手动更新所有依赖它的仓库,而像lerna之类的monorepo计划,实质上在一个git仓库中,依赖通过Symbolic Link的模式关联,本仓库的我的项目产生批改天然能够感知到,则能够自动化的解决这个痛点。

dead code removal(无用代码移除)

在组件库我的项目开始前,先调研了同类我的项目设计,尤其关怀其中的按需加载。例如antd-mobile,依照开发直觉,在import { Button } from 'antd-mobile'的时候,只有antd-mobile提供了esm标准的打包文件,应该能够通过tree shaking按需加载。事实是它的确提供了esm文件:

如果我在esm文件import它,它会从es/index.js这个文件引入。

如图所示,es/index.js的确是一个合乎esm标准的模块入口,所以失常状况下js局部是间接能够按需加载的。

然而官网中有这样一段话

那么什么状况下算是不反对tree shaking的环境呢?我的总结如下:

1. 应用commonjs标准引入模块,例如const { Button } = require('antd-mobile')

2. 应用了esm,但代码通过babel的preset-env编译会被转成commonjs(能够通过modules: false解决)

3. 代码初始化时可能有副作用(不齐全等于FP的副作用,前面我会解释这一词),但antd明确反对tree shaking,显然不会是这一种

4. 通过其余编译管道时(指像webpack的loader那样的)可能带入副作用代码

于是为了防止开发者呈现以上状况,antd-mobile提供了每个组件的分包,并通过babel-plugin-import在编译时批改援用代码,间接的达到tree shaking的性能。

既然开发环境不可控,所以本我的项目也提供了每个组件的分包,以便在非凡状况下能够按需引入,最初的打包后果像这样

具体如何分包的,感兴趣的能够看看rollup配置多入口打包。只有规定一个固定的目录标准,找到总入口和每个组件的入口就好了,前期新增我的项目,只有合乎目录标准,就能够复用。

tree shaking生效

分完包后顿感神清气爽,当初开发者既能够在总入口利用tree shaking拿到minimized code,也能够间接拿分包了。奔着看看分包有多简洁的心态,点进一个Loading组件的打包文件,后果发现区区一个展现Loading状态的组件代码超乎预料的多

剖析问题

初步判断可能是因为引入了内部的某个模块,导致把没有应用到的代码也带入了,看打包后果中的确有许多没有应用的代码

源码中的确引入了一个可疑的库(前文所提到的公共js库),多进去的代码也的确是这个js库里的

那么问题初步认定为,因为应用了px2vw函数,导致把js库里的其余某些代码也引进来了。

查看那个js库

入口文件看上去貌似没什么问题,比照打包后果中多进去的代码是和sdk相干的,其余模块的确曾经被tree shaking优化掉了,所以也不太可能是它们被转成commonjs。

那么sdk文件到底有什么问题呢?首先咱们来看看哪些状况会让esm环境中的tree shaking生效

esm中tree shaking生效的起因

先看webpack文档里对于tree shaking的一段话

In a 100% ESM module world, identifying side effects is straightforward. However, we aren't there quite yet, so in the mean time it's necessary to provide hints to webpack's compiler on the "pureness" of your code.

如果大家全副都用esm模块编写代码,那么辨认『副作用』是很简略的。然而事实不是如此,所以你须要通过配置通知webpack,你的代码是『污浊的』,以此让webpack认为它没有『副作用』。

具体的配置是

{  "name": "your-project",  "sideEffects": false}

这里说的副作用就是影响tree shaking的关键因素。它不齐全等于FP(函数式编程)中的副作用,我认为它只是和FP副作用有交加

FP中的副作用

维基百科对副作用的解释是:

在计算机科学中,函数副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。例如批改全局变量(函数外的变量),批改参数,向主调方的终端、管道输入字符或扭转内部存储信息等。

具体在js中我总结了以下几点:

  1. 批改了函数上下文以外的变量或参数。上面的add和modify函数就是有副作用的

    let i = 0const add = () => ++iconst o = {  a: 0}const modify = (_o) => {  _o.a++  return _o}modify(o)
  2. 函数产生了IO操作,包含但不限于打印日志、读取写入、操作dom、网络申请等

    // 打印日志const log = (...args) => {  console.log(...args)}// 读取domconst query = (identify) => document.querySelector(identify)// 网络ioconst get = (url) => fetch(url)

tree shaking中的副作用

tree shaking中的『副作用』就在这其中,比方在import过程中:

  1. 批改了window属性
  2. 可能会触发getter、setter的操作,因为没法判断get、set中有没有副作用
  3. 打印日志

有些FP中的副作用并不算tree shaking中的副作用,有些tree shaking中的副作用不算FP中的副作用,它们是不必要不充沛的关系,只是有交加,上面请看我写的最小测试是否有副作用的demo

FP与tree shaking副作用的不同与雷同

1. 失常的export

如上图所示,有一个utils1.ts文件,其中用多种形式export了5个函数

接着在入口文件index.ts中引入它们中的函数a和用export default导出的函数a3,具体见下图

最初用rollup打包index.ts,上面是打包后果的局部代码

从上图能够看到后果中只有被引入的aa3两个函数,,其余函数都没有打包进来,tree shaking胜利了。

2. 以对象的模式export

如上图所示,在utils2.ts文件中,export default了一个cd函数组成的对象,同时独自export了函数e

接着咱们在index.ts中引入export default导出的对象utils2,见下图

并只应用其中的c函数,下图用红框标出

最终的打包后果如下

能够看到它把整个对象,即其中的cd函数都打包进去了,e函数失常tree shaking。因为导出的模块是对象,应用时读取对象的办法,所以rollup不能提前晓得js运行时你会须要哪个办法,只能全副打包进来。

3. 产生副作用

从上图的utils3.ts文件中咱们能够看到

  • f函数是一个典型的FP副作用函数,因为它会批改内部变量。
  • g函数也是一个FP副作用函数,因为它外面有打印操作,同时在它申明后也会触发一个打印console.log(g),即它的父级执行上下文里也有副作用。
  • 单看h函数并不是FP副作用函数,然而h函数内部的上下文是有副作用行为的(window as any).__h = h,然而h函数自身并不算。
  • i函数没什么问题,能够在index.ts中只引入它,看看其余函数是什么反馈。
  • 最初的x函数比拟非凡,在申明它之前先读取了被Proxy的对象的属性,实践上x函数相干的这些操作都不算FP的副作用

而后让咱们来看看最终的打包后果:

从上图能够发现

  • f函数尽管属于FP的副作用,但不属于tree shaking的副作用,它能够被dead code removal
  • g函数因为在申明期间产生了console.log的副作用所以被引入了,为了判断是哪个console.log带来的副作用导致的,第二次打包把console.log(g)去掉发现它被失常的tree shaking了,所以只有在模块申明的过程中产生console.log才算tree shaking的副作用
  • h函数因为在申明后批改了window,所以产生了副作用,如果不保留它,万一其余模块会从window中读取它就会造成谬误,所以rollup不对它tree shaking
  • x函数很非凡,它被去掉了,但下面的o对象创立、读取的操作被保留了。之所以这样是因为,开发者可能通过ProxyObject.defineProperty等api劫持对象的getter属性操作符,外面的操作是不可预知的(比方批改了window),为了保险起见,ProxyObject.defineProperty相干的代码必须保留。然而x函数哪怕外面也读取了o对象也仍然不会保留,因为x只有运行时才会执行getter,而它没有被引入就不会造成潜在的副作用影响。

有一点令人诧异的是,像这样的代码,rollup也会对它tree shaking

要晓得读取属性可能产生副作用,假如对window.__n属性增加了getter属性操作符,外面用来在全局记录被读取的次数,在utils3.ts里应用了window.__n,预期是window.__n + 1,事实是最终的代码里并没有读取它,window.__n没有变动。这样就很不合乎直觉。

从以上的几个副作用案例能够看出,tree shaking的副作用和FP的副作用是一个交加的关系,并不齐全相等。

4. import commonjs

那么rollup能不能对commonjs也能够tree shaking呢?比方上面有一个utils4.common.js文件

以exports对象属性的模式导出了jk函数

接着在index.ts中只引入j函数

最终的打包后果如下所示

能够看到这种模式的commonjs模块是能够被tree shaking的,rollup能动态剖析出exports被增加了哪些函数,其中哪些被import了。那是不是所有的commonjs都能够呢?上面再看一种模式

5. 另一种模式的commonjs

先将module.exports赋值为一个新的对象,对象里有l函数,接着再向其中增加m函数

而后咱们在index.ts里只引入l函数并打包:

能够看到rollup把不须要的m函数也打包进来了。

如果咱们在index.ts里只引入m函数并打包:

这次才是正确的tree shaking。

咱们能够看到不同的module.exports形式以及援用不同的函数是会影响tree shaking成果的,如果你拿对象赋值给exports,并且未来引入了这个对象字面量中的办法(即l),那么这个对象残余的办法也会被打包进来;而如果你引入的是非对象字面量中的办法(即前面动静增加的办法m)时,则能失常tree shaking。

所以第三方commonjs库有着tree shaking的不确定性,除非不得不,否则尽量用反对esm的库。

以上就是对tree shaking问题的摸索,具体的代码都在这里:https://replit.com/@HiWayne/r...

顺便安利一下这个平台https://replit.com ,它能够间接运行我的项目和多人合作,并且能够间接复制他人搭好的脚手架模板,有点云开发的感觉。

回到最后的问题(为什么sdk文件没有被tree shaking)

其实答案曾经很显著了,既然文件没有被转换成commonjs,esm语法也正确,那无非是sdk文件中有以上的某些副作用。毕竟本我的项目中的sdk代码是从很多年前的老我的项目里移植过去的,老我的项目并没有思考模块化。间接把sdk交给window属性这种显著的问题点在移植之初就曾经发现了。但因为客户端和前端通信的须要,代码中还有很多隐式的基于window的约定协商,想必可能造成了tree shaking的问题。但问题起源曾经确定,剩下的只是工夫问题。

tips:如果代码逻辑简单、代码量大、对老代码不理解,其实有个小技巧能够放大排查范畴。先保留好代码,而后把你感觉可疑的代码块删掉,如果间接删掉会影响上下文那就替换成空实现(反正runtime的问题不会在编译期裸露),而后再次打包,这样多测试几次,如果失常tree shaking了,那么就是被删掉的代码块内的某些逻辑作怪,再向内排查。

总结

本文从我的项目创建的背景我的项目的选型按需加载的设计登程,通过在我的项目中遇到的tree shaking问题为探讨中心点,具体介绍了造成tree shaking生效的细节和起因,并通过demo举例了tree shaking副作用和函数式编程(FP)副作用的共同点和区别。如果你也在写npm库,心愿通过本文能唤起你对查看tree shaking的意识,也心愿能作为大家初探tree shaking和函数式编程的stepping stones :)