CondorHero

学贤社

Tree shaking 和 No side effects

发布于 # Blog # 技术

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");

查看副作用被保留的编译结果

引入此模块且模块中有副作用,但没有使用模块的任何导出,关于副作用有两种处理方法:

  1. 保留,虽然没有用到模块的导出,但是希望保留副作用。
  2. 删除,没有用到模块的导出,里面的副作用也不需要保留。

第一种是打包工具的默认行为,第二种,可以在项目的 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

参考链接

上海 2024 年 01 月 18 日 19:20:24