date | tags |
---|---|
2020-11-26 |
javascript |
看了个不错讲循环引用以及解决方案的文章。正好之前也写过一个循环引用的文章,这次也正好在深化下,顺便翻译。
至今,在我维护过的很多项目中,我总是或早或晚会碰到相同的问题:循环模块依赖。虽然现在有很多如何解决循环依赖的策略和最佳实践,但是却几乎没有一致的可预测的修复方式。通常,人们随机移动import语句或者代码块,直到“它突然起作用”。事实证明,鉴于对此推文的回应,我并不是唯一碰到这个问题的人。
(tweet无法直接引用,内容大概就是一个网友赞同了作者。有兴趣可以看原文)
幸运的是,正如我下面即将演示的那样,有一个一致的方法可以解决这些依赖问题。
JavaScript中的模块加载顺序是确定的,但是在大型项目中很难去准守,原因是一旦有了(间接的)循环引用,你可能在代码中使用一个加载一半的模块。这样的例子包括:引用使用尚未初始化的基类,读取尚未初始化的变量。
在此博客中我们将使用一个mock页面,它会美化对象树的打印,以一种类型yaml的格式。
你可以自己在这个codesandbox中尝试。这个程序的实现非常简单,这里有个基类AbstractNode
,它定义接口并且提供了通用的功能,像parent
和getDepth()
,接下来有两个专有实现,Node
和Leaf
,这个程序运行的很好。但是在同一个文件里维护三个类不是理想的方式,所以我们重构一下然后看看有什么事情会发生...
export class AbstractNode {
constructor(parent) {
this.parent = parent
}
getDepth() {
if (this.parent) return this.parent.getDepth() + 1
return 0
}
print() {
throw 'abstract; not implemented'
}
static from(thing, parent) {
if (thing && typeof thing === 'object') return new Node(parent, thing)
else return new Leaf(parent, thing)
}
}
export class Node extends AbstractNode {
constructor(parent, thing) {
super(parent)
this.children = {}
Object.keys(thing).forEach(key => {
this.children[key] = AbstractNode.from(thing[key], this)
})
}
print() {
return (
'\n' +
Object.keys(this.children)
.map(key => `${''.padStart(this.getDepth() * 2)}${key}: ${this.children[key].print()}`)
.join('\n')
)
}
}
export class Leaf extends AbstractNode {
constructor(parent, value) {
super(parent)
this.value = value
}
print() {
return this.value
}
}
一旦我们把每个类移动到它自己的文件中,实际情况是这个十分相似的应用突然完全崩溃了,似乎无法修复,并且报了一个相当模糊的异常:TypeError: Super expression must either be null or a function, not undefined.
¯\(ツ)/¯!
目前的改动是相当小的,如下所示(点击查看报错的sandbox):
// -- AbstractNode.js --
import { Leaf } from './Leaf'
import { Node } from './Node'
export class AbstractNode {
/* as is */
}
// -- Node.js --
import { AbstractNode } from './Node'
export class Node extends AbstractNode {
/* as is */
}
// -- Leaf.js --
import { AbstractNode } from './AbstractNode'
export class Leaf extends AbstractNode {
/* as is */
}
上面的改动足以使这个应用中断。请注意,Node
和Leaf
在AbstractNode.js
中被导入,因为这这些类在静态方法from
中被使用。
应用中断的原因是,当应用尝试导入Leaf
类时,AbstractNode
还没有被定义。这可能有点令人惊讶,因为毕竟,在Leaf类定义上方的导入语句是没有问题的。但是加载模块的过程中会发生这些事:
index.js
导入AbstractNode.js
- 模块加载器开始加载
AbstractNode.js
并且执行模块代码,第一行就是对Leaf
的导入语句 - 所以加载器开始加载
Leaf.js
,它反过来以导入Abstractnode.js
开始 AbstractNode.js
已经被加载,并且立即从模块缓存中返回,然而,因为这个模块至今没有运行过第一行(Leaf的导入),添加AbstractNode
类的语句暂未被执行!- 所以
Leaf
类尝试去继承一个undefined
值,而不是一个合法的类,抛出了上面显示的异常!
所以事实证明,我们的循环引用造成了麻烦的问题。然而,如果我们仔细观察的话,非常容易决定模块加载顺序应该的样子:
- 先加载
AbstractNode
- 在那之后再加载
Node
和Leaf
换句话说,我们先定义AbstractNode
,然后让它导入Leaf
和Node
。这样可以运行,因为在定义AbstractNode
的过程中没有必要知道Leaf
和Node
是什么,只要它们在AbstractNode.from
第一次调用之前定义即可。所以我们尝试一下下面的改动:
export class AbstractNode {
/* as is */
}
import { Node } from './Node'
import { Leaf } from './Leaf'
事实证明,这个解决方案有一些问题:
首先,写法丑陋不易拓展。在一个大型的代码库中,它会导致导入语句到处随机移动,直到突然可以正常运行。这通常作为一个临时的方案,因为未来一个小的重构或者导入语句的变化,都会微妙的调整模块的加载顺序,导致问题重新导入。
其次,这个方案是否有效很大程度上取决于用了什么模块打包器。举个例子,在codesandbox中,当使用Parcel(或者Webpack或者Rollup)时,这个解决方案不会正常运行。然而,当在本地使用Node.js和CommonJS模块时,这个变通方案就会很好的运行。
所以,显然这个问题不是容易解决的。能避免这个问题吗?答案是能,这里有一些方式去避免这个问题。首先,我们可以把代码放在同一个文件里,如我们最初的例子所示。这样我们就可以解决这个问题,因为它可以完全控制模块初始化代码的运行顺序。
其次,有一些人以上述问题为理由,发表类似“不应该使用类”,“别使用继承”的陈述,但这是把这个问题想的太简单了。虽然我认同现在的编程者总是习惯迅速的导入继承方案,对于一些场景它是完美的,并且在代码结构,复用性,性能方面都产出很大收益。但是最重要的是,这个问题不仅仅局限于类的继承,当模块变量和模块初始化期间运行的函数之间存在循环依赖关系时,也会导入完全相同的问题!
我们可以通过以下方式重新组织我们的代码,把AbstractNode
类拆解成多个更小的片段,以便AbstractNode
不依赖Node
或Leaf
。在这个sandbox中from
方法被移出AbstractNode
类并且放入到一个单独的文件中。这确实解决了问题,但是现在我们的项目和api结构不同了,在一个大型项目中,可能很难确定如何实现这个技巧,甚至不可能实现。想象下,如果app的下个版本中Node
和Leaf
依赖print
方法,会发生什么。。。
彩蛋:我以前用过的另一个丑陋的技巧:利用function的作用域提升,在一个function中返回基类,达到按顺序加载的效果,我甚至不确定如何正确的表达。
我曾在许多项目中与这个问题斗争过,一些例子包括我在 Mendix, MobX, MobX-state-tree的工作,以及一些个人项目中。在过去几年的某个时间,我甚至写了一个脚本去串联所有的源文件并且删除所有的导入语句,一个穷人版的模块打包器,只是为了避免模块加载顺序问题。
然而解决了几次这个问题后,一种模式出现了。一种可以完全控制模块加载顺序的模式,不需要重组项目结构或者使用奇怪的技巧!这个模式可以与我尝试过的所有工具链完美搭配(Rollup, Webpack, Parcel, Node)。
这个模式的关键是导入一个index.js
文件和internal.js
文件。游戏规则如下:
internal.js
模块从项目中的每个本地模块导入和导出所有东西。- 项目中的所有其他模块只从
internal.js
中导入,并且从不直接从项目其他文件中导入。 index.js
文件是主入口,负责将你想暴露给外部的所有东西从internal.js
导入导出。注意,只有在你发布一个供他人使用的lib时,这一步才有意义。所以,在我们的例子中跳过了这一步。
请注意,以上规则仅适应于本地依赖,外部模块的导入保持原样,毕竟它们与循环依赖问题无关。如果在我们的示例app中应用这个策略,代码将会是这样:
// -- app.js --
import { AbstractNode } from './internal'
/* as is */
// -- internal.js --
export * from './AbstractNode'
export * from './Node'
export * from './Leaf'
// -- AbstractNode.js --
import { Node, Leaf } from './internal'
export class AbstractNode {
/* as is */
}
// -- Node.js --
import { AbstractNode } from './internal'
export class Node extends AbstractNode {
/* as is */
}
// -- Leaf.js --
import { AbstractNode } from './internal'
export class Leaf extends AbstractNode {
/* as is */
}
第一次用这个模式时可能会感觉很生硬,但是它有一些重要的收益。
- 首先我们解决了问题!就像这里演示的一样,我们的app又正常工作了。
- 这个模式可以解决我们的问题的原因是:现在我们完全掌控了模块的加载顺序。(你可以看下面的图,或者读上面的模块顺序说明,了解为什么会这样)。
- 不需要去做我们不想要的重构,也不必被迫使用丑陋的技巧,像是把导入语句放到文件的底部。我们不必破坏代码库的架构,API或语义结构。
- 彩蛋:导入语句会变得更简洁,因为我们会从更少的文件引入代码。以
AbstractNode.js
为例,原来又两个引入语句,现在只有一个。 - 彩蛋:有了
index.js
,我们有单一文件入口,可以对暴露给外界的模块进行精细的控制。
这就是我近期解决循环依赖问题的方式。如果将其应用于现有项目,则需要对导入语句进行一些基本的重构工作。但是过程是傻瓜且直截了当的。并且结束重构之后,你完全掌控了文件加载顺序,使得在未来快速定位循环依赖问题成为可能。
这里是一些实际工作中,使用这个方案重构的commit
MobX (大改动,但是影响不大,因为它很简单) MobX-state-tree (注意文件末尾导入语句移除的方式) Smaller personal project
我至今从未在大型项目中引用这个模式,只有在lib中使用。但是在大型项目中它可以正常工作,只是将这个技术应用到某些发生问题的项目子目录里,如果它们是一个独立的lib。