从一次前端公共库的搭建中,深入谈谈tree shaking相关问题

发布时间 2023-11-27 17:00:58作者: 柯基与佩奇

项目背景

随着业务的积累,前端项目之间逐渐会产生许多可以跨项目复用的逻辑或组件。比如对前端数据库 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 文件:

antd-entry.png

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

image.png

如图所示,es/index.js确实是一个符合 esm 规范的模块入口,所以正常情况下 js 部分是直接可以按需加载的。

但是官网中有这样一段话

image.png

那么什么情况下算是不支持 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 的功能。

既然开发环境不可控,所以本项目也提供了每个组件的分包,以便在特殊情况下可以按需引入,最后的打包结果像这样

image.png

具体如何分包的,感兴趣的可以看看 rollup 配置多入口打包。只要规定一个固定的目录规范,找到总入口和每个组件的入口就好了,后期新增项目,只要符合目录规范,就可以复用。

tree shaking 失效

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

image.png

分析问题

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

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

image.png

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

查看那个 js 库

image.png

入口文件看上去貌似没什么问题,对比打包结果中多出来的代码是和 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)
  1. 函数产生了 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

image.png

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

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

image.png

最后用 rollup 打包index.ts,下面是打包结果的部分代码

image.png

从上图可以看到结果中只有被引入的aa3两个函数,,其他函数都没有打包进来,tree shaking 成功了。

2. 以对象的形式export

image.png

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

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

image.png

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

image.png

最终的打包结果如下

image.png

可以看到它把整个对象,即其中的cd函数都打包进去了,e函数正常 tree shaking。因为导出的模块是对象,使用时读取对象的方法,所以 rollup 不能提前知道 js 运行时你会需要哪个方法,只能全部打包进来。

3. 产生副作用

image.png

从上图的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 的副作用

image.png

然后让我们来看看最终的打包结果:

image.png

从上图可以发现

  • 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的代码,声明被劫持的对象的代码依然会被保留,哪怕没有任何地方用到它。

有一点令人惊讶的是,像这样的代码,rollup 也会对它 tree shaking(即不保留)

image.png

要知道读取属性可能产生副作用,假设对window.__n属性添加了 getter 属性操作符,里面用来在全局记录被读取的次数,在utils3.ts里使用了window.__n,预期是window.__n + 1,事实是最终的代码里并没有读取它,window.__n没有变化。这样就很不符合直觉。 你可以在命令行通过如下命令控制 rollup 的策略:

--no-treeshake.moduleSideEffects 假设模块没有副作用
--no-treeshake.propertyReadSideEffects 忽略属性访问的副作用

你也可以在需要忽略副作用的地方加上注释

/*@__PURE__*/

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

4. import commonjs

那么 rollup 能不能对 commonjs 也可以 tree shaking 呢?比如下面有一个utils4.common.js文件

image.png

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

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

image.png

最终的打包结果如下所示

image.png

可以看到这种形式的 commonjs 模块是可以被 tree shaking 的,rollup 能静态分析出exports被添加了哪些函数,其中哪些被import了。那是不是所有的 commonjs 都可以呢?下面再看一种形式

5. 另一种形式的commonjs

image.png

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

然后我们在index.ts里只引入l函数并打包: image.png image.png

可以看到 rollup 把不需要的m函数也打包进来了。

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

image.png

image.png

这次才是正确的 tree shaking。

我们可以看到不同的module.exports方式以及引用不同的函数是会影响 tree shaking 效果的,如果你拿对象赋值给 exports,并且将来引入了这个对象字面量中的方法(即l),那么这个对象剩余的方法也会被打包进来;而如果你引入的是非对象字面量中的方法(即后面动态添加的方法m)时,则能正常 tree shaking。

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

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

顺便安利一下这个平台replit.com ,它可以直接运行项目和多人协作,并且可以直接复制别人搭好的脚手架模板,有点云开发的感觉。

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

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

tips:如果代码逻辑复杂、代码量大、对老代码不了解,其实有个小技巧可以缩小排查范围。先保存好代码,然后把你觉得可疑的代码块删掉,如果直接删掉会影响上下文那就替换成空实现(反正 runtime 的问题不会在编译期暴露),然后再次打包,这样多测试几次,如果正常 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 :