点点关注, 精彩内容不错过👆
在自研打包工具的过程中,发现有时会碰到不同的编译工具处理相同的代码,其大小差距可能很大,追查下来大部分是和不同工具对代码优化的处理方式不同所致。目前大部分js打包工具都支持的一种优化即tree shaking,但是不幸的是tree shaking没有比较标准的定义,各个打包工具的tree shaking实现又不尽相同。
Tree shaking在不同工具里的意义不太统一,为了统一后续讨论,我们规范各个术语。
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
andexport
. The name and concept have been popularized by the ES2015 module bundler rollup.https://webpack.js.org/guides/tree-shaking/
如以下代码,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);
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删除无用的语句。
日常经常听到各种功能的比较,什么rollup的tree shaking效果比webpack好,terser的压缩比esbuild的压缩比更高,事实上tree shaking算法其实算得上比较固定的算法,各个工具如果tree shaking实现应该不存在较多的差异,而DCE其实不同的优化工具,压缩的方式却各有不同,这通常涉及到压缩安全性和压缩比例的取舍。
比如,如下一段代码,rollup和esbuild却产出两种结果:
const obj = {};
obj.name ='obj';
export const answer =42;
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呢?
import jojo from './jojo.png'
console.log('jojo:',jojo);
这段代码本身有两层含义:
如果没有tree shaking支持,在DCE阶段难以处理和file-loader的关系,因为在DCE阶段,jojo已经只是个变量了,没有信息(或者很难有信息)来判断其是否要删除file-loader的结果了。
我们首先来看看tree shaking,相比于一般的DCE手段,tree shaking的算法比较固定,不同的bundler的行为比较一致。tree shaking的目标非常明确,就是删除掉最终bundle中,永远不会使用top-level代码,我们来一步步看如何实现完整的tree shaking算法。
const secret = 10; // stmt1
export const answer = 42; // stmt2
这里只有answer被导出,我们只需要保留answer即可,结果即为:
export const answer = 42
import { answer } from './lib';
export { answer };
export const answer = 42;
export const secret = 10;
这里虽然在lib里导出了secret,但是并未在index.js里使用,因此可以删除,结果仍然为:
export const answer = 42;
import { answer } from './lib';
export { answer };
export * from './internal';
export const answer = 42;
export const secret = 10;
这里的lib虽然本身没导出,但是对internal进行了重导出,我们深度分析,查找出answer最终定义的地方。
export const answer = 42;
import { answer } from './lib';
export { answer }
export let secret = 10
export const answer = secret + 32;
export * from './reexport';
export const internal = 100;
console.log('sideEffect');
此时,虽然在index.js没有直接依赖secret,但是getAnswer的内部实现依赖了secret,所以secret也被连带导出。
因此我们可以看出tree shaking分析,本身就是基于符号引用的可达性分析,其根据入口的使用符号,根据符号引用和模块引用,递归的进行模块和引用分析,然后包括所有可达模块的的可达符号的语句,然而现实情况要复杂很多,这是因为我们始终没考虑一个因素就是副作用,副作用可以说是优化的最大阻碍,当考虑副作用,情况就变得复杂的多了。
副作用从两个层面影响着tree shaking算法:
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 }
webpack通过sideEffects字段来标记某个模块具有模块内部副作用。
import { answer } from './lib';
export const answer = 42;
export const secret = 10;
console.log('lib');
export * from './reexport';
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.js
console.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内。
webpack的sideEffects里有个很trick的优化,就是关于reexport,首先简单的定义下reexports,如果某个模块的导出来自另一个模块的导出,那么就称该导出为reexport。
如下所示这里的internal就是b的reexport。
// index.js
import { internal } from './lib';
console.log(internal);
export const answer = 42;
export const secret = 10;
console.log('lib');
export * from './reexport';
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发生在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则标记了一个模块具有内部副作用,这样可以提高编译工具副作用分析的效率和准确性(贴近业务)。
不是所有的模块都应该配置sideEffects,请先确保改模块是否具有模块内部副作用性质,避免影响了程序的正确性。