JavaScript 库作者的 Tree Shaking
与大多数开发者的想法相反,摇树优化 (tree shake) 并不复杂。关于术语(死代码消除 vs. tree shake)的讨论可能会引起一些困惑,但这个问题以及其他一些问题在整篇文章中都得到了澄清。作为 JavaScript 库的作者,我们希望实现尽可能轻量的代码包。在这篇文章中,我将带您了解一些最流行的、会降低代码优化效率的模式,并分享一些关于如何处理某些情况或测试库的建议。
一些理论
Tree Shaking 是“死代码消除”的一个花哨说法。它没有确切的定义。我们可以把它当作“死代码消除”的同义词,或者尝试只将某些算法归入这个总称。
如果我们查看webpack 文档页面上列出的定义,它似乎提到了这两种方法。
Tree Shaking 是 JavaScript 中常用的术语,指消除死代码。它依赖于 ES2015 模块语法的静态结构,即 import 和 export。
第一句话暗示它是一个同义词,而第二句话提到了该算法使用的某些特定语言特征。
命名争议
Rich Harris 在其关于该主题的精彩文章中指出:“我们不是排除死代码(死代码消除),而是包括活代码(树摇消除)” 。
这两种方法之间的一个实际区别是,所谓的 tree shake 通常指的是由打包工具完成的工作,而死代码消除则由压缩工具(比如 Terser)完成。因此,如果我们讨论创建可用于生产的文件,那么优化最终输出的整个过程通常分为两个步骤。事实上,webpack 会主动避免消除死代码,并将部分工作交给 Terser,只删除必要的部分。所有这些都是为了让 Terser 的工作更轻松,因为它操作文件,并且不了解模块或项目结构。另一方面,Rollup 的做法比较复杂,在其核心中实现了更多的启发式方法,从而可以生成更少的代码。不过,仍然建议通过 Terser 运行生成的代码,以达到最佳整体效果。
如果你问我,争论哪个定义正确毫无意义。这就像争论我们应该说函数形参 (function parameter) 还是函数实参 (function argument)。它们的含义确实不同,但人们长期以来误用这两个术语,以至于它们在日常使用中可以互换。说到摇树,我理解 Rich 的观点,但我也认为,试图区分不同的方法带来的困惑多于澄清,而且最终,这两种技术检查的是完全相同的东西。这就是为什么我将在整篇文章中交替使用这两个术语。
何必呢?
前端社区似乎常常对我们发送给客户的 JavaScript 包的大小过于执着。这种担忧背后有一些很好的理由,我们绝对应该关注如何编写代码、如何构建应用程序以及包含哪些依赖项。
主要的动机是向浏览器发送更少的代码,这意味着更快的下载和执行,进而意味着我们的网站可以更快地显示或变得可交互。
没有魔法
目前流行的工具,例如 webpack、Rollup、Terser 等,并没有实现大量过于复杂的算法来追踪函数/方法边界等。在 JavaScript 这种高度动态的语言中实现这些算法极其困难。像 Google Closure Compiler 这样的工具则更加复杂,能够执行更高级的分析,但它们不太受欢迎,而且往往难以配置。
鉴于这些工具的功能本身并无太多魔法,有些地方根本无法优化。黄金法则是,如果您在意代码包大小,那么应该优先选择可组合的组件,而不是包含大量选项的函数或包含大量方法的类等等。如果您的逻辑嵌入过多,而用户只使用了其中的 10%,他们仍然需要支付全部 100% 的费用——使用目前流行的工具,这是无法避免的。
关于压缩器和打包器工作原理的概述
任何执行静态代码分析的工具都会对代码的抽象语法树表示进行操作。它基本上是用构成树的对象表示的程序源文本。翻译几乎是一对一的,并且源文本和 AST 之间的转换在语义上是可逆的 - 您始终可以将源代码反序列化为 AST,然后将其序列化回语义上等效的文本。请注意,在 JavaScript 中,空格或注释等没有语义含义,并且大多数工具不会保留格式。这些工具要做的是在不实际执行程序的情况下弄清楚程序的行为。它涉及大量的簿记和基于 AST 的交叉引用推断信息。基于此,工具可以在证明某些节点不会影响程序的整体逻辑后,从树中删除它。
副作用
考虑到你使用的语言,某些语言结构在静态代码分析方面比其他语言结构更好。我们考虑这个非常基本的程序:
function add(a, b) {
return a + b
}
function multiply(a, b) {
return a * b
}
console.log(add(2, 2))
我们可以放心地说,该程序并未使用整个multiply
函数,因此无需将其包含在最终代码中。要记住的一条简单规则是,如果函数一直未使用,几乎总是可以安全地删除,因为单纯的声明不会产生任何副作用。
理解副作用是这里最重要的部分。它们实际上影响着外部世界,例如,对 a 的调用console.log
就是一种副作用,因为它会产生程序可观察到的结果。删除这样的调用并不合适,因为用户通常希望看到它。很难列出程序可能存在的所有副作用类型,但可以列举以下几种:
- 将属性分配给全局对象,例如
window
- 更改所有其他对象
- 调用许多内置函数,例如
fetch
- 调用包含副作用的用户定义函数
没有副作用的代码被称为纯代码。
压缩器和打包器必须始终做好最坏的打算,谨慎行事,因为错误地删除任何一行代码都可能代价高昂。这可能会极大地改变程序的行为,并浪费人们的时间去调试那些只会在生产环境中出现的奇怪问题。(在开发阶段压缩代码并不是一种流行的选择。)
常见的去优化模式及其解决方法
正如开篇所述,本文主要面向库作者。应用程序开发通常注重功能,而非优化。通常不建议在应用程序代码中过度优化以下方面。为什么?因为应用程序代码库应该只包含实际使用的代码——实施一些令人眼花缭乱的技术所带来的收益微乎其微。保持应用程序简洁易懂。
💡 值得注意的是,本文给出的任何建议仅适用于模块的初始化路径,即导入特定模块时立即执行的操作。函数、类和其他模块中的代码通常不在本分析范围内。或者换句话说,这类代码很少被闲置,并且很容易被诸如no-unused-vars和no-unreachable 之类的 linting 规则发现。
属性访问
这可能令人惊讶,但即使读取属性也不能安全地删除:
const test = someFunction()
test.bar
问题在于,该bar
属性实际上可能是一个 getter 函数,而函数总是会有副作用。鉴于我们对 了解不多someFunction
,其实现可能过于复杂而难以分析,我们应该假设最坏的情况:这是一个潜在的副作用,因此无法移除。赋值给属性时也适用同样的规则。
函数调用
请注意,即使我们能够删除该属性读取操作,我们仍然会留下以下内容:
someFunction()
因为该函数的执行可能会导致副作用。
让我们考虑一个稍微不同的例子,它可能类似于一些现实世界的代码:
export const test = someFunction()
假设由于捆绑器中的摇树算法,我们已经知道它test
没有被使用,因此可以将其删除,这样我们就得到了:
const test = someFunction()
简单的变量声明语句也不包含任何副作用,因此也可以被删除:
someFunction()
然而,在很多情况下,通话本身是不能被挂断的。
纯注释
有什么办法吗?其实解决办法很简单。我们需要给调用添加一个压缩工具能理解的特殊注释。让我们把所有步骤整合起来:
export const test = /* #__PURE__ */ someFunction()
这个小东西告诉我们的工具,如果注释函数的结果保持未使用状态,那么该调用就可以被删除,如果没有其他内容引用它,这反过来会导致整个函数声明被删除。
事实上,捆绑器生成的部分运行时代码也被这样的注释所标注,从而为生成的代码留下了以后被删除的机会。
纯注释与属性访问
这对 getter 和 setter 有效吗/* #__PURE__ */
?很遗憾,不行。如果不修改代码本身,几乎无法对其进行任何改进。最好的办法是将它们移到函数中。根据具体情况,或许可以重构以下代码:
const heavy = getFoo().heavy
export function test() {
return heavy.compute()
}
对此:
export function test() {
let heavy = getFoo().heavy
return heavy.compute()
}
如果heavy
以后的所有调用都需要同一个实例,您可以尝试以下操作:
let heavy
export function test() {
// lazy initialization
heavy = heavy || getFoo().heavy
return heavy.compute()
}
您甚至可以尝试#__PURE__
利用 IIFE,但它看起来非常奇怪并且可能会引起人们的注意:
const heavy = /* #__PURE__ */ (() => getFoo().heavy)()
export function test() {
return heavy.compute()
}
相关副作用
像这样注释有副作用的函数安全吗?在库环境中,通常是安全的。即使某个函数有一些副作用(毕竟这种情况很常见),它们通常也只在该函数的结果持续使用的情况下才有意义。如果函数中的代码无法在不改变整个程序行为的情况下安全地删除,那么绝对不应该像这样注释函数。
内置函数
令人惊讶的是,即使是一些众所周知的内置函数也常常不会被自动识别为“纯”函数。
有一些很好的理由:
- 处理工具无法知道您的代码将在什么环境中实际执行,因此,例如,
Object.assign({}, { foo: 'bar' })
很可能会抛出一个错误,如“Uncaught TypeError:Object.assign 不是一个函数”。 - JavaScript 环境很容易被处理工具无法识别的其他代码操纵。考虑一个执行以下操作的恶意模块
Math.random = function () { throw new Error('Oops.') }
:
正如您所见,即使是采取基本行为也并不总是安全的。
一些工具,例如 Rollup,则更加自由,更注重实用性而非保证正确性。它们可能会假设环境保持不变,从而在最常见的场景下产生更优的结果。
转译器生成的代码
只要在代码中添加注释,优化起来就相当容易#__PURE__
,前提是你不使用任何其他代码转换工具。然而,我们经常通过 Babel 或 TypeScript 等工具来生成最终要执行的代码,而生成的代码很难控制。
不幸的是,一些基本的转换可能会在树状图可优化性方面降低代码的优化程度,因此有时检查生成的代码有助于找到这些降低优化的模式。
我将用一个包含静态字段的简单类来说明我的意思。(静态类字段将在即将发布的 ES2021 规范中成为该语言的正式组成部分,但它们已经被开发人员广泛使用。)
class Foo {
static defaultProps = {}
}
Babel 输出:
class Foo {}
_defineProperty(Foo, "defaultProps", {});
TypeScript 输出:
class Foo {}
Foo.defaultProps = {};
利用本文获得的知识,我们可以看到,这两种输出都经过了反优化,而其他工具可能难以正确处理。这两种输出都将一个静态字段放在类声明之外,并将一个表达式赋值给该属性——要么直接赋值,要么通过defineProperty
调用赋值(根据规范,后者更为正确)。通常,像 Terser 这样的工具无法处理这种情况。
副作用:false
人们很快意识到,摇树优化(tree shake)自动为大多数用户带来的益处有限。其结果高度依赖于代码本身,因为很多现存的代码都使用了上述的去优化模式。事实上,这些去优化模式本身并不坏,大多数情况下也不应该被视为问题;它们只是正常的代码。
目前,确保代码不使用这些不利于优化的模式主要需要手动操作,因此从长远来看,维护一个可摇树的库往往颇具挑战性。很容易引入一些看似无害的正常代码,而这些代码最终会意外地导致过多的引用。
因此,引入了一种将整个包(或仅包中的某些特定文件)注释为无副作用的新方法。
可以将一个"sideEffects": false
放在package.json
包中,以告诉捆绑程序该包中的文件是纯净的,其意义与前面在#__PURE__
注释上下文中描述的意义类似。
然而,我认为它的作用被严重误解了。它实际上并不像#__PURE__
该模块中函数调用的全局变量那样工作,也不会影响 getter、setter 或包中的任何其他内容。它只是向打包器提供一条信息,如果某个包中的文件没有被使用,那么就可以删除整个文件,而无需查看其内容。
为了说明这个概念,我们可以想象以下模块:
// foo.js
console.log('foo initialized!')
export function foo() {
console.log('foo called!')
}
// bar.js
console.log('bar initialized!')
export function bar() {
console.log('bar called!')
}
// index.js
import { foo } from './foo'
import { bar } from './bar'
export function first() {
foo()
}
export function second() {
bar()
}
如果我们只first
从模块导入,那么打包器就会知道它可以省略整个./bar.js
文件(这要归功于该"sideEffects": false
标志)。所以,最终会输出如下内容:
foo initialized!
foo called!
这确实是一个很大的改进,但同时,在我看来,它并非灵丹妙药。这种方法的主要问题在于,为了达到最佳效果,需要格外注意代码的内部组织方式(文件结构等)。过去,人们通常建议对库代码进行“扁平打包”,但在这种情况下,情况恰恰相反——扁平打包会严重损害此标志。
如果我们决定使用文件中的任何其他内容,这也可以很容易地取消优化,因为只有当模块中没有导出被使用./bar.js
时它才会被删除。
如何测试
测试很难,尤其是不同的工具会产生不同的结果。虽然有一些不错的软件包可以帮到你,但我发现它们总有这样或那样的问题。
我通常会尝试手动检查在文件上运行 webpack 和 Rollup 后得到的捆绑包,如下所示:
import 'some-library'
理想的结果是空的 bundle,里面没有任何代码。这种情况很少发生,因此需要手动调查。我们可以检查 bundle 中的内容,并调查导致这种情况发生的原因,从而了解哪些因素会导致此类工具的优化失败。
由于存在"sideEffects": false
,我的方法很容易产生误报。您可能已经注意到,上面的导入没有使用 的任何导出some-library
,因此这给打包器发出了一个信号,表明整个库可以删除。但这并不能反映现实世界中的使用情况。
在这种情况下,我尝试在从库中删除此标志后对其进行测试,package.json
以检查没有它会发生什么,并看看是否有办法改善这种情况。
快乐摇树!
别忘了查看我们在 dev.to 上的其他内容
! 如果您想与我们合作拓展商业消息传递领域,请访问我们的开发者计划!