关于 Side Effects 副作用 ESM:你不想知道的事
很多项目使用 webpack 作为他们的打包工具,它可以通过使用一种叫做 tree-shaking (移除无用代码技术的一种)的技术减少最终输出代码包的大小。然而,tree-shaking 有效工作的前提是它能够明确的知道你要打包的代码、项目里哪些部分是有副作用的,为什么 webpack 需要知道这些呢?webpack 又通过这些信息做了些什么呢?
什么是副作用(side effects)
副作用,在 ECMAScript 模块(也就是我们常说的 esm
)的上下文中指的是当该模块被加载时执行了一些外部可见的行为(换句话说产生了外部可以观察到的变化)
举个例子:
import { registerThing } from 'thing-registry'; const store = registerThing( THING_KEY, { /* ... */ } );
registerStore
在最外层被调用,这意味着该模块一旦被首次加载这个函数就会被执行。这些变化在外部是可见的,因为一个存在于 thing-registry
模块的外部 store 被修改了。又例如设置一些挂在 window
上的全局变量或者通过添加 polyfills
为浏览器添加一些目前还不支持的功能。
但是,当 registerThing
的函数调用发生在了一个叫做 init
的模块被加载时不会被调用的函数里时,那么模块在加载时就不会产生任何副作用了。
import { registerThing } from 'thing-registry'; export function init() { const store = registerThing( THING_KEY, { /* ... */ } ); } // `init` 没有在模块内的最外层被调用 // 所以引入这个模块不会产生任何副作用
在模块内最外层声明一个变量或只修改了当前模块内的状态对于模块外部而言也不是一种副作用,因为修改作用域都在模块内:
import list from './list'; // 不是一种副作用 let localVariable = []; // 这里也不是副作用,因为没有修改模块外部状态 for ( const entry of list ) { localVariable.push( processListEntry( entry ) ); }
副作用对打包过程产生的影响
大多数的现代打包工具都实现了 tree-shaking
,也就是无用代码在打包过程中被彻底移除不会出现在最终的代码包里。这种技术在某些提供了非常多不同功能的库的打包过程中尤其重要,因为库的使用者并不想因为只使用了库中的几个功能,而必须把所有的库代码(包括不需要的代码)打包进自己的项目中。
因此,库作者们应该采取措施确保他们的库能够正确地被 tree-shaking
,尤其是在公开发布后。
这就又回到了副作用。通过前面我们已经知道了,副作用指的是仅仅因为导入了一个模块而运行的代码。这意味着代码不能被 tree-shaking
;它需要运行,因为它改变了模块外的东西,而这些东西可能在其他地方需要。
不幸的是,副作用很难通过自动检测的方式被确定,一些打包工具(如webpack)会谨慎行事,假设每个模块都有潜在的副作用。这对那些从其他模块重新导出的索引模块来说是个问题,因为这实际上意味着里面的所有东西都必须被打包在一起。
// index.js export { a, b } from './module1'; export { c, d, e } from './module2'; export { f } from './module3'; // 没有任何东西可以被 tree-shaing 掉,因为打包工具 // 并不知道这个模块或被重新导出的模块是否有任何副作用。
告诉打包工具你的代码有哪些副作用
由于打包工具不能自己检测出副作用,我们需要明确地声明它们。这可以在包的 package.json
文件中完成。
这意味着声明副作用是软件包作者的责任,对于不这样做的软件包作者(没有在 package.json
中声明副作用),webpack
很可能无法将任何东西 tree-shaing
掉。这样的软件包的用户最终可能会把所有的包内的所有模块都包含他们的构建中,即使他们只使用了软件包的一小部分,通常也没有简单的方法来解决这个问题。在写这篇文章的时候,diff
、react-dates
和许多其他流行的软件包都是这种情况,令人遗憾。
那么你如何声明在你的代码中有哪些副作用呢?最好的方法取决于你对它们的使用程度以及它们在你的代码中的位置。
很多时候,你的包根本就不会使用任何副作用。在这种情况下,你可以简单地将 sideEffects
设置为 false
。
{ "name": "package", "sideEffects": false }
如果项目内有一些有副作用的文件,你可以列出它们。
{ "name": "package", "sideEffects": [ "dist/store.js", "dist/polyfill.js" ] }
这允许打包工具假定只有被声明的模块有副作用,而其他的没有。当然,这意味着我们需要小心翼翼地包括所有有副作用的东西,否则在使用该包的应用程序中会出现问题。
webpack
支持许多复杂的 glob
匹配功能,所以你可以采取的另一种方法是反其道而行之,声明哪些路径是没有副作用的,让 webpack
来假设其他的东西都可能有副作用。
{ "name": "package", "sideEffects": [ "!(dist/(components|utils)/**)" ] }
上面的例子告诉打包工具,它应该假定除了 components
和 utils
目录之外的任何东西都包含副作用,而这些目录中的东西都不包含。这种方法应该能保证 components
和 utils
中的所有东西都能被 tree-shaking
而没有其中可能有副作用的担忧,而且只有在其中的某个文件使用了副作用时才有可能引起问题。
修订(2020-06-15)。在某些情况下,将一个特定的函数调用标记为无副作用,而不是将整个文件标记为无副作用,可能是更可取的做法。考虑一下下面的例子。
function noSideEffects() { // 做一些事 } noSideEffects();
打包工具无法判断对 noSideEffects
的顶层调用是否包含任何副作用。一个解决方案是在package.json
文件内的 sideEffects
字段中包含该模块,就像我们上面看到的那样。不过,我们也可以在代码中用 PURE指令
提示来处理它。
function noSideEffects() { // 做一些事 } /*#__PURE__*/ noSideEffects();
非常感谢 [Jake Archibald](https://twitter.com/jaffathecake) 让我知道了这个消息。
作为包的使用者,副作用意外的没有运行
让我们换一种角度。假如你现在是包的用户,而且你正在导入一个内含副作用模块的包。你想使用它们;事实上,你依赖于这些副作用的发生,否则你的代码将无法正常工作。
你可能会遇到一些副作用意外消失的情况。
举个例子:
// index.js import 'side-effectful-module'; export { a, b } from './impl';
// impl.js function a() { // Do something. } function b() { // Do something that depends on the side effect having run. }
在 index.js
文件 中,我们看到了通常被称为 "裸导入" 的东西;这种语法意味着我们没有使用模块的任何导出,而且我们实际上只对其副作用感兴趣。裸导入本身并没有副作用,但它们是一个非常强烈的信号,表明在你导入的东西中存在着副作用。
index.js
文件除了导入有副作用的模块外,并没有做什么,只是重新导出了 impl.js
文件中的函数。这里的关键之处,也是可能导致问题的地方,是 impl.js
文件中的函数实际上依赖于 index.js
文件种所导入的副作用。
如果 tree-shaking
被禁用,比如在开发中,这一切都会正常工作。然而,如果 tree-shaking
被打开,index.js
模块可能会被 tree-shaking
掉,只留下 impl.js
文件中的实际函数。如果发生这种情况,副作用就会消失,b 函数也会出问题。
如果在一个子模块上进行导入,同样的事情也会发生:
// index.js import { unused } from './util'; function b() { // 做一些依赖于副作用的事 } // 本模块没有使用 `unused`.
// util.js import "side-effectful-module"; export function unused() { // 做一些依赖于副作用的事 }
由于 unused
在 index.js
文件中没有被使用,所以它将被 tree-shaking 掉。这意味着 impl.js
文件中的任何东西都不再需要了,所以它不会被加载。我们再一次失去了副作用,尽管它是需要的。
避免副作用被 tree-shaking
掉
由于这些副作用本质上是自动执行的未命名的依赖,所以我们必须把它们当作自动执行的依赖。如果一个模块的代码依赖于一个副作用的运行,我们需要确保在那里导入它。第二个例子可以很容易地通过在 index.js
文件中增加一个导入来解决。
// index.js import "side-effectful-module"; import { unused } from './util'; function b() { // 做一些依赖于副作用的事 } // 这个模块仍然没有使用 `unused`.
这将保证 side-effectful-module
在 index.js
文件中的任何代码之前运行,除非 index.js
也是如此,否则不会被 tree-shaking
掉。
注意,我们现在在两个模块中都导入了副作用,但这没关系!ES
模块只运行一次。ES
模块只运行一次,这意味着它们的副作用也只运行一次,无论它们被导入多少个文件中。
让我们来总结一下
本文中包含了大量的信息,所以让我们试着总结一下实用的建议:
- 如果你的库不包括副作用,在它的
package.json
文件中设置sideEffects: false
。 - 如果你的库包含了副作用,你仍然可以尽可能多地启用
tree-shaking
。明确地列出有副作用的文件,或者使用反选globs
来列出没有副作用的路径。 - 如果你依赖于外部模块的副作用,请确保在你使用它们的地方导入该模块。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论