项目背景

随着业务的积累,前端项目之间逐渐会产生许多可以跨项目复用的逻辑或组件。比如对前端数据库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 = 0
    const add = () => ++i
    
    const o = {
      a: 0
    }
    const modify = (_o) => {
      _o.a++
      return _o
    }
    modify(o)
  2. 函数产生了IO操作,包括但不限于打印日志、读取写入、操作dom、网络请求等

    // 打印日志
    const log = (...args) => {
      console.log(...args)
    }
    
    // 读取dom
    const query = (identify) => document.querySelector(identify)
    
    // 网络io
    const 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,而它没有被引入就不会造成潜在的副作用影响;然而就算没有读取o.a的代码,声明被劫持的对象的代码依然会被保留,哪怕没有任何地方用到它。

从以上的几个副作用案例可以看出,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的问题。但问题来源已经确定,剩下的只是时间问题。

通过以上技巧排查,最终发现sdk文件中,在模块初始化阶段就调用了sdk类中的方法(这里用Sdk.f表示),编译期不清楚是否方法还调用了其他方法,也不清楚其他方法是否有副作用,导致rollup无法判断sdk是否需要留下,于是全部留了下来。幸好Sdk.f中并没有用到this,那么完全可以把它从class中抽离出来变成函数,初始化时调用这个函数,然后再将f函数作为Sdk的方法,成功解决了问题。

最终解决的步骤并不复杂,但是对问题从表象开始抽丝剥茧的分析,对其中涉及的细节概念的掌握,是十分考验一个人的解决问题的能力以及前端技术的深度与广度的。一个问题的难点往往不在具体的解决操作上,而是知道如何找到解决方法以及为什么这样解决。我们平时学习各种原理、细节、设计思想,它的真正价值正是为了在遇到难点时起到作用,而不是单纯为了学习而学习或者面向面试学习,这样难以学以致用。

总结

本文从项目创立的背景项目的选型按需加载的设计出发,通过在项目中遇到的tree shaking问题为讨论中心点,详细介绍了造成tree shaking失效的细节和原因,并通过demo举例了tree shaking副作用和函数式编程(FP)副作用的共同点和区别,最后又回到最初的问题如何解决并衍生出学习的真正价值。如果你也在写npm库,希望通过本文能唤起你对检查tree shaking的意识,也希望能作为大家初探tree shaking和函数式编程的stepping stones :)

03-05 16:32