Tree shaking 和 No side effects
CJS 和 ESM
前端代码的模块化,是从无开始的,然后从 CJS 过渡到 ESM。
在 CommonJS 时代,模块之间通过 require 来建立联系,这种方式,使用非常的灵活,但是也带来一个问题,过于灵活导致,导致无法对引用的模块进行分析,假如引用的文件中有大量用不到的代码,我们也清除不掉,只能照单全收,造成打包之后的代码体积大,拖慢代码的执行速度。
随着 ES6(ES2015)的普及,这种情况得到了改变,ES6 语法的静态结构 特性,使得我们知道引用模块的代码,这样就可以提前删除无用的代码,这些无用的代码通常叫做死代码。因为它们对程序没有任何影响。
死代码(dead code)是指程序中一段已经不会被执行的代码,通常是因为重构、优化或者逻辑错误导致的。这些代码可能是之前版本的遗留物,或者某些条件下永远不会被执行的代码。
什么是 Tree Shaking
假如有 src/math.js
模块,它的内容如下:
// src/math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
export const twentyFive = square(5);
我们在模块 src/main.js
中使用:
// main.js
import { cube } from './maths.js';
console.log(cube(5)); // 125
很明显函数 square 和变量 twentyFive 没有被使用,都是死代码,删除它们可以节省空间和代码执行速度。
合理的删除没用的死代码,这就是 Tree Shaking 的作用。
sideEffects
基于上面 src/math.js
的例子,我们在模块中新增一行代码 window.effect1 = 10;
。
// src/math.js
// 新增
window.effect1 = 10;
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
export const twentyFive = square(5);
同时在 src/main
中只引入模块,但不使用引入的内容:
import { cube } from "./math.js";
console.log("main");
因为没有使用 twentyFive、 square 和 cube 都被 Tree Shaking 掉了,window.effect1
如何处理呢。
这种直接修改外部变量的行为我们称之为副作用。
最后代码会被编译为下面这种,副作用将会被保留:
window.effect1 = 10;
console.log("main");
引入此模块且模块中有副作用,但没有使用模块的任何导出,关于副作用有两种处理方法:
- 保留,虽然没有用到模块的导出,但是希望保留副作用。
- 删除,没有用到模块的导出,里面的副作用也不需要保留。
第一种是打包工具的默认行为,第二种,可以在项目的 package.json
中添加 "sideEffects": false
,像 Webpack 和 Rollup 打包的时候,可以无脑让这些包含副作用,引用但未使用的模块安全删除:
而且针对第二种,除了正常的副作用,打包工具还存在误判副作用的情况,误判主要因为速度和打包颗粒度之间的权衡,假如 math.js
的代码如下。
// src/math.js
export function square (x) {
return x * x;
}
function getMessage (callback) {
return callback();
}
export const hi = getMessage(() => "Hi");
export const hello = getMessage(() => "Hello");
打包之后输出:
// src/math.js
function getMessage (callback) {
return callback();
}
getMessage(() => "Hi");
// main.js
console.log("main");
getMessage 本来不是副作用,但是因为调用过于灵活,不知道暴露给用户的 callback 中会写什么,打包工具直接判断为副作用,代码保留。
这种情况下 sideEffects 就很有用了,当你确定代码中没有副作用,无脑在项目的 package.json
中添加 "sideEffects": false
。
// main.js
console.log("main");
#__PURE__
src/math.js
的代码保持不变:
// src/math.js
export function square (x) {
return x * x;
}
function getMessage (callback) {
return callback();
}
export const hi = getMessage(() => "Hi");
修改 src/main.js
为:
// main.js
import { square } from './maths.js';
console.log(square(5));
在开启 Tree Shaking 和 sideEffects 的情况下,代码被编译为:
// src/math.js
function square (x) {
return x * x;
}
function getMessage (callback) {
return callback();
}
getMessage(() => "Hi");
// main.js
console.log(square(5));
观察上面的代码我们发现 getMessage(() => "Hi")
被保留了下来。因为 getMessage 被直接调用,不符合 Tree Shaking 的规则,且 square 函数被外部引入调用不符合 sideEffects。
问题来了,Tree Shaking 和 sideEffects 的都失效的情况下,我们怎么清除这段无用的代码?
答案是注释的形式使用 #__PURE__
(或者 @__PURE__
两个等效) 插入到 函数调用时 的前面:
export const hi = /* #__PURE__ */ getMessage(() => "Hi");
神奇的事情发生了,无用的代码被安全的删除了:
// src/math.js
function square (x) {
return x * x;
}
// main.js
console.log(square(5));
点击眼见为实 无用的代码被删除
这种方法好用,但是有一个缺点,大型项目中某个方法或者类可能高频使用,调用如果是多处,很容易就会忘记在调用的前面添加 #__PURE__
,而且因为高频书写还可能出错,这些都导致 getMessage 不能正确被清除,比如 hi 有注释,hello 没有注释:
export const hi = /* #__PURE__ */ getMessage(() => "Hi");
export const hello = getMessage(() => "Hello");
编译之后 getMessage 被保留了:
// src/math.js
function square (x) {
return x * x;
}
function getMessage (callback) {
return callback();
}
getMessage(() => "Hello");
// main.js
console.log(square(5));
现在急需一种类似 #__PURE__
的方式,但是不作用在调用的时候,而是作用在类和方法的声明的时候,只需要聚焦一次,便可永久使用,减少维护的成本。
#__NO_SIDE_EFFECTS__
#__NO_SIDE_EFFECTS__
和@__NO_SIDE_EFFECTS__
都是有效的。
给 getMessage 方法添加 #__NO_SIDE_EFFECTS__
注释,getMessage 调用的时候都不使用 #__PURE__
:
// src/math.js
export function square (x) {
return x * x;
}
/* #__NO_SIDE_EFFECTS__ */
function getMessage (callback) {
return callback();
}
export const hi = getMessage(() => "Hi");
export const hello = getMessage(() => "Hello");
最完美的编译结果:
// src/math.js
function square (x) {
return x * x;
}
// main.js
console.log(square(5));
使用建议
Tree Shaking 是打包工具默认支持的,不用自己操心,除非你需要 import "xxx"
来直接执行一些逻辑,否则一定要把 sideEffects 设置为 false。
如果你需要在 模块中 直接 调用函数 或 实例化类,这些函数和类本身比较复杂,且只有一两处地方则应该使用 @__PURE__
。
// https://github.com/mrdoob/three.js/blob/84aab91a703881ff3778db1b4d702b729b66c70f/src/math/Sphere.js#L4
const _box = /*@__PURE__*/ new Box3();
const _v1 = /*@__PURE__*/ new Vector3();
const _v2 = /*@__PURE__*/ new Vector3();
// https://github.com/mrdoob/three.js/blob/84aab91a703881ff3778db1b4d702b729b66c70f/src/extras/DataUtils.js#L5
const _tables = /*@__PURE__*/ _generateTables();
// https://github.com/mrdoob/three.js/blob/84aab91a703881ff3778db1b4d702b729b66c70f/examples/jsm/libs/fflate.module.js#L1302
var Unzlib = /*#__PURE__*/ (function () {
/**
* Creates a Zlib decompression stream
* @param cb The callback to call whenever data is inflated
*/
function Unzlib(cb) {
this.v = 1;
Inflate.call(this, cb);
}
/**
* Pushes a chunk to be unzlibbed
* @param chunk The chunk to push
* @param final Whether this is the last chunk
*/
Unzlib.prototype.push = function (chunk, final) {
Inflate.prototype.e.call(this, chunk);
if (this.v) {
if (this.p.length < 2 && !final)
return;
this.p = this.p.subarray(2), this.v = 0;
}
if (final) {
if (this.p.length < 4)
throw 'invalid zlib stream';
this.p = this.p.subarray(0, -4);
}
// necessary to prevent TS from using the closure value
// This allows for workerization to function correctly
Inflate.prototype.c.call(this, final);
};
return Unzlib;
}());
调用地方过多,你就应该考虑使用 @__NO_SIDE_EFFECTS__
来减轻你的工作,比如:
// https://github.com/sveltejs/svelte/blob/0fd1c92822246ca2a98f8de7c87ac2e5ff743534/packages/svelte/src/internal/client/operations.js#L156
/*#__NO_SIDE_EFFECTS__*/
export function clone_node(node, deep) {
return /** @type {N} */ (clone_node_method.call(node, deep));
}
// https://github.com/intlify/vue-i18n-next/blob/1e66f58c7f0305474cb904594ebfd2e003b3a19b/packages/vue-i18n-core/src/i18n.ts#L954
/* #__NO_SIDE_EFFECTS__ */
export const castToVueI18n = (
i18n: I18n
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): VueI18n & { install: (Vue: any, options?: any) => void } => {
if (!(__VUE_I18N_BRIDGE__ in i18n)) {
throw createI18nError(I18nErrorCodes.NOT_COMPATIBLE_LEGACY_VUE_I18N)
}
return i18n as unknown as VueI18n & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
install: (Vue: any, options?: any) => void
}
}
// https://github.com/vuejs/core/blob/ee4cd78a06e6aa92b12564e527d131d1064c2cd0/packages/runtime-dom/src/apiCustomElement.ts#L143
/*! #__NO_SIDE_EFFECTS__ */
export function defineCustomElement(
options: any,
hydrate?: RootHydrateFunction,
): VueElementConstructor {
const Comp = defineComponent(options) as any
class VueCustomElement extends VueElement {
static def = Comp
constructor(initialProps?: Record<string, any>) {
super(Comp, initialProps, hydrate)
}
}
return VueCustomElement
}
/*! #__NO_SIDE_EFFECTS__ */
export const defineSSRCustomElement = ((options: any) => {
// @ts-expect-error
return defineCustomElement(options, hydrate)
}) as typeof defineCustomElement
参考链接
- javascript-compiler-hints compiler-notations-spec
- Add options and hooks to control module side effects
- Rollup configuration options pure
- Support marking a call as pure
- Make
/*#__PURE__*/
not only for call, but also for callable value? __PURE__
- Pure annotation for functions
- Support
#__NO_SIDE_EFFECTS__
annotation for function declaration - Support
#__NO_SIDE_EFFECTS__
comment from Rollup - Perf: mark defineComponent as side-effects-free
- Webpack4+ Tree shaking
上海 2024 年 01 月 18 日 19:20:24