查看原文
其他

Tree shaking问题排查指南来啦!

杨健 ByteDance Web Infra 2022-05-13

点点关注, 精彩内容不错过👆


背景

在自研打包工具的过程中,发现有时会碰到不同的编译工具处理相同的代码,其大小差距可能很大,追查下来大部分是和不同工具对代码优化的处理方式不同所致。目前大部分js打包工具都支持的一种优化即tree shaking,但是不幸的是tree shaking没有比较标准的定义,各个打包工具的tree shaking实现又不尽相同。

术语

Tree shaking在不同工具里的意义不太统一,为了统一后续讨论,我们规范各个术语。

  • minify:编译优化手段,指在不影响代码语义的情况下,尽可能的减小程序的体积,常见的minify工具如terser、uglify,swc和esbuid也自带minify功能。
  • Dead code elimination(DCE):即死代码优化,一种编译器优化手段,用于移除不影响程序结果的代码,实现DCE的手段有很多种,如const folding(常量折叠)、Control flow analysis、也包括下面的LTO。
  • Link Time Optimization:指link期优化的手段,可以进行跨模块的分析优化,如可以分析模块之间的引用关系,删掉其他模块未使用的导出变量,也可以进行跨模块对符号进行mangle。
    http://johanengelen.github.io/ldc/2016/11/10/Link-Time-Optimization-LDC.html
  • Tree shaking:一种在Javascript社区流行的一个术语,是一种死代码优化手段,其依赖于ES2015的模块语法,由rollup引入。这里的tree shaking 通常指的是基于module的跨模块死代码删除技术,即基于LTO的DCE,其区别于一般的DCE在于,其只进行top-level和跨模块引用分析,并不会去尝试优化如函数里的实现的DCE。

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup.   

https://webpack.js.org/guides/tree-shaking/

  • mangle:即符号压缩,将变量名以更短的变量名进行替换。
  • 副作用:对程序状态造成影响,死代码优化一般不能删除副作用代码,即使副作用代码的结果在其他地方没用到。
  • 模块内部副作用:副作用影响范围仅限于当前模块,如果外部模块不依赖当前模块,那么该副作用代码可以跟随当前模块一起被删除,如果外部模块依赖了当前模块,则该副作用代码不能被删除。

如以下代码,import './button.css'  本身是具有副作用的,因此即使在button.tsx里没有使用其导出值,其仍然不能简单的删除,而需要包含其副作用(emit css),但是如果外部不使用button这个组件,那么可以不触发button.css的副作用,所以说import './button.css'具有内部副作用。

// components/button.tsx
import style from './button.css';
export const Button = () => {
   return <div class="button">button</div>
}
/
/ components/button.css

.button {
  color: red;
}

// app.tsx
import { Tab, Button } from './components';
console.log('Button', Button);
  • top-level: 顶层的语句
function top_level()// this is top_level function declartion
   function inner(){  // this is not top_level
   }
   let b = 20// this is not top_level
}
let a = 10// this is top_level variable declartion


因此我们的后续讨论,所说的tree shaking均是指基于LTO的DCE,而DCE指的是不包含tree shaking的其他DCE部分。

简单来说即是,tree shaking负责移除未引用的top-level 语句,而DCE删除无用的语句。

安全 VS 优化级别

日常经常听到各种功能的比较,什么rollup的tree shaking效果比webpack好,terser的压缩比esbuild的压缩比更高,事实上tree shaking算法其实算得上比较固定的算法,各个工具如果tree shaking实现应该不存在较多的差异,而DCE其实不同的优化工具,压缩的方式却各有不同,这通常涉及到压缩安全性和压缩比例的取舍。

比如,如下一段代码,rollup和esbuild却产出两种结果:

const obj = {};
obj.name ='obj';

export const answer =42;
  • rollup

  • esbuild

rollup成功的删除掉了没有导出的obj,此时你可能会说esbuild真辣鸡啊,这么简单的代码都删除不掉,但是实际上rollup的这个优化并不安全,如果该代码运行在如下的环境下,rollup的优化则可能导致出错。

function render(val){
  console.log('render',val)
}
Object.defineProperty(Object.prototype, 'name', {
  set(val){
    render(val);
  }
})

本来代码的意思是每次设置一个变量属性的时候,都要触发一次render,结果由于obj.name代码被删除,导致render没被触发,这明显改变了语义。

https://github.com/evanw/esbuild/issues/2010

因此我们评估一个工具的压缩效果是否好的时候,不能简单的评估压缩比。

tree shaking相比DCE的优势

既然tree shaking是DCE的一个子集,为什么我们还要单独支持tree shaking,而不是直接依赖DCE去做tree shaking呢?

  • tree shaking可以省略一些模块的处理开销:如果我们确定某些模块的所有导出变量都未被引用,我们可以直接跳过这些模块的parse、loader开销。
  • tree shaking有更多的局部性信息,我们可以约定某个模块是否包含内部副作用(即使该模块的代码内部实现有副作用),这样我们可以利用内部副作用性质对整个模块进行删除,否则如果在DCE阶段在进行删除,那么这些模块内部副作用会对全局进行污染,很难进行删除了。
  • tree shaking可以关联模块的副作用信息,考察如下一段代码:
import jojo from './jojo.png'
console.log('jojo:',jojo);

这段代码本身有两层含义:

  • 触发file-loader:将jojo.png 生成到assets里,可能还会伴随着图片压缩等操作。
  • 将生成assets的地址赋值给jojo变量,作为变量给后续使用。

如果没有tree shaking支持,在DCE阶段难以处理和file-loader的关系,因为在DCE阶段,jojo已经只是个变量了,没有信息(或者很难有信息)来判断其是否要删除file-loader的结果了。

tree shaking算法

我们首先来看看tree shaking,相比于一般的DCE手段,tree shaking的算法比较固定,不同的bundler的行为比较一致。tree shaking的目标非常明确,就是删除掉最终bundle中,永远不会使用top-level代码,我们来一步步看如何实现完整的tree shaking算法。

Without sideEffect

保留引用的导出语句

  • lib.js
const secret = 10// stmt1
export const answer = 42// stmt2

这里只有answer被导出,我们只需要保留answer即可,结果即为:

export const answer = 42

跨模块分析引用的已导出语句

  • index.js
import { answer } from './lib';
export { answer };
  • lib.js
export const answer = 42;
export const secret = 10;

这里虽然在lib里导出了secret,但是并未在index.js里使用,因此可以删除,结果仍然为:

export const answer = 42;

深度分析引用的已导出语句

  • index.js
import { answer } from './lib';
export { answer };
  • lib.js
export * from './internal';
  • internal.js
export const answer = 42;
export const secret = 10;

这里的lib虽然本身没导出,但是对internal进行了重导出,我们深度分析,查找出answer最终定义的地方。

export const answer = 42;

符号关联

  • index.js
import { answer } from './lib';
export { answer } 
  • lib.js
export let secret = 10
export const answer = secret + 32;
export * from './reexport';
  • reexport.js
export const internal = 100;
console.log('sideEffect');

此时,虽然在index.js没有直接依赖secret,但是getAnswer的内部实现依赖了secret,所以secret也被连带导出。

因此我们可以看出tree shaking分析,本身就是基于符号引用的可达性分析,其根据入口的使用符号,根据符号引用和模块引用,递归的进行模块和引用分析,然后包括所有可达模块的的可达符号的语句,然而现实情况要复杂很多,这是因为我们始终没考虑一个因素就是副作用,副作用可以说是优化的最大阻碍,当考虑副作用,情况就变得复杂的多了。

sideEffects

副作用从两个层面影响着tree shaking算法:

  • 分析的入口不仅仅是导出语句,还包括副作用语句(不包含副作用的语句不会触发分析)。如下的console.log('secret',secret)因为包含副作用,因此:
import { answer, secret } from './lib';
export { answer }; //  导出语句
secret; // 不会触发secret的分析
console.log('secret', secret); // 触发secret的分析

由于Javascript灵活的特性,一个语句是不是包含副作用其实是很难界定的,往往和当时的宿主执行相关,如简单的answer+1会被rollup判定为不包含副作用,但被esbuild判定为包含副作用,所以一般编译器可以通过pure annoation(/* #PURE */)来强行指定一个语句是否包含副作用。

  • 模块的导出变量即使未被使用,也需要递归的对模块进行分析。

因为导入的模块可能包含副作用,即使改导出模块导出变量均未使用,我们也不能直接删除该模块,而应该仍然进行递归分析检查是否存在其他副作用语句。实际上我们直接把import语句视为副作用语句即可。即当考虑副作用的情况下,可达性不仅仅要考虑符号引用,也要考虑副作用引用。

这里实线是副作用引用,虚线是符号引用。

包含了副作用引用后,我们的生成代码就可以包含所有的副作用代码了。

副作用引用优化

在考虑了副作用引用后,tree shaking的全部功能算支持了,但是仍然存在很多待优化的地方受限于JavaScript的特性,用户的很多代码,本意并不是想引入副作用,但是仍然可能被编译视为副作用(倾向安全的保守设计), 这导致了最终的结果包含了很多不需要的代码。虽然可以通过pure annotation来对各个语句进行标记,但显得过于麻烦,且对代码侵入较强(如三方代码难以修改),因此webpack引入了一个机制来对整个模块进行副作用标记。

模块内部副作用

webpack的sideEffects的命名其实存在一定误导性,其意义并非是说该模块不存在副作用,而是说该模块存在的副作用是内部副作用,即该副作用只对自身模块产生影响,并不对其他没有使用该模块导出变量的模块造成影响,或者说,如果你不包含该模块的导出变量那么也不应该包含该模块的副作用,如果依赖了该模块的导出变量,才应该依赖该模块的副作用。这一点非常类似C++|Rust的内部可变性,这点也是最容易遭受误解的地方。

我们以vue为例,虽然vue的源码里充斥了各种副作用,但是如果你并没使用vue导出的变量,那么仍然应该可以安全删除整个vue模块。

https://github.com/vuejs/vue/pull/8099

import Vue from 'vue';
import { answer, secret } from './lib'
export { secret }

sideEffects field

webpack通过sideEffects字段来标记某个模块具有模块内部副作用。

  • index.js
import { answer } from './lib';
  • lib.js
export const answer = 42;
export const secret = 10;
console.log('lib');
export * from './reexport';
  • reexport.js
console.log('internal');
export const internal = 100;

上述代码在没配置sideEffects的情况下,编译结果如下:

// src/reexport.js
console.log("internal");

// src/lib.js
var answer = 42;
console.log("lib");
// src/index.jsconsole.log("answer:", answer);

各个模块的副作用代码都得到保留,这符合预期。在配置下sideEffects:false看下结果:

// src/lib.js
var answer = 42;
console.log("lib");

// src/index.js
console.log("answer:", answer);

对比发现,src/reexports里的副作用代码已经被删掉了,这是因为我们已经标记了该模块是模块内部副作用且外部模块并没有引入该模块的导出变量,因此可以安全的将该模块代码删除。另外src/lib.js的副作用代码并没被删除,因为index.js依赖了src/lib导出的answer变量,这导致其副作用分析完全交给了webpack,webpack成功的识别出其包含的副作用代码,包含在最后的bundle内。

reexport机制

webpack的sideEffects里有个很trick的优化,就是关于reexport,首先简单的定义下reexports,如果某个模块的导出来自另一个模块的导出,那么就称该导出为reexport。

如下所示这里的internal就是b的reexport。

  • index.js
// index.js
import { internal } from './lib';

console.log(internal);
  • lib.js
export const answer = 42;
export const secret = 10;
console.log('lib');
export * from './reexport';
  • reexport.js
console.log('internal');
export const internal = 100;

如果都标记的sideEffects:false,那么即使index从lib里引入了internal,但是因为internal来自rexports.js,而index.js里没引入lib.js本身的任何变量,所以lib本身的副作用会被全部跳过。

结果如下:


如果index.js里引入了lib.js自身定义的导出如:

import { internal, answer } from './lib';

console.log(internal, answer);

那么结果会包含lib.js的副作用代码:


不同的工具对reexport的判定存在差异,如esbuild只会将export * from 'xxx'识别为reexport,并不识别 export { internal } from './reexport'识别为reexport,但是webpack会将export { internal} from './reexport'识别为reexport。

tree shaking 与DCE

大部分工具的tree shaking和DCE是工作在不同的阶段,一般的tree shaking发生在module link的阶段,而DCE发生在bundle的print阶段,所以很多工具是不支持tree shaking结果依赖DCE结果的。

总结

生产环境通常通过minify进行代码压缩,minfy的一个场景手段就是DCE,在Javascript社区中一个场景的DCE手段就是tree shaking,即基于符号分析和模块引用的DCE机制,tree shaking过程中通常不可避免的碰到副作用,因为Javascript自身灵活的动态性质,编译工具很难直接为Javascript做很好的优化,为了优化副作用的处理,引入了pure annotation和sideEffects字段,pure annotation用于辅助工具识别非副作用代码,而sideEffects则标记了一个模块具有内部副作用,这样可以提高编译工具副作用分析的效率和准确性(贴近业务)。

tree shaking的常见误区

  • 包含副作用的代码,不能配置sideEffects:sideEffects实际和代码里是否具有副作用无关,而是该副作用设计是作用在模块内还是模块外,如vue代码,虽然有副作用,但是这些副作用是给vue的内部实现使用的,而非给外部用的。
  • 为css配置sideEffects:false: 为了实现css的tree shaking,想通过配置css的sideEffects来实现css的tree shaking,结果导致业务直接import css的css没有打包进来,css的tree shaking应该跟着相关组件走,如果改组件配置了sideEffects:false,当没引入改组件的时候,其css会自动跟随tree shaking掉。

tree shaking问题排查方式

  • step1:确定是DCE问题还是tree shaking问题。
    • 根据代码出现在 top-level还是非top-level,我们能比较容易的区分是tree shaking失效还是 DCE失效,如果是函数内的优化失败那么肯定是DCE,如果是top-level 的优化失效,则大概率是tree shaking失效(也可能是DCE失效,如top level的constant folding)
  • step2:如果是DCE失效,那么很可能是terser|esbuild的优化级别过低,或者terser没有开启某些优化,请检查terser相关配置参数,适当的调整terser的passes级别。
    https://github.com/terser/terser#compress-options
  • step3:如果是tree shaking失败,先确认失效模块的路径信息,很多编译工具编译中会保留模块信息(通常需要先关闭minify,因为minify有时会删除掉模块信息),如esbuild。


  • step4:确认了模块路径信息,进一步确认该模块是否具有副作用以及是否esModule,有时候是否具有副作用难以判定,可以尝试配置该模块的路径的sideEffects:false,然后对比配置前后的产物大小是否具有差异。

不是所有的模块都应该配置sideEffects,请先确保改模块是否具有模块内部副作用性质,避免影响了程序的正确性。

  • step5:如果配置了sideEffects:false,大小仍然没有明显改变,此时存在两种可能:
    • 该模块本身就不能tree shaking,在其他地方存在着对该模块变量的引用,导致了该模块没被shaking掉,这一般通过编辑器的go to reference或者自己字符串搜索能查到引用的地方。
    • 编译工具存在bug,如不支持sideEffects,或者sideEffects计算错误,请联系编译工具的开发进行协助排查。
- END -


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存