Skip to content

Latest commit

 

History

History
224 lines (154 loc) · 11.7 KB

解决循环依赖问题.md

File metadata and controls

224 lines (154 loc) · 11.7 KB
date tags
2020-11-26
javascript

看了个不错讲循环引用以及解决方案的文章。正好之前也写过一个循环引用的文章,这次也正好在深化下,顺便翻译。

原文地址:https://medium.com/visual-development/how-to-fix-nasty-circular-dependency-issues-once-and-for-all-in-javascript-typescript-a04c987cf0de

如何一劳永逸的解决麻烦的js/ts循环依赖问题?

至今,在我维护过的很多项目中,我总是或早或晚会碰到相同的问题:循环模块依赖。虽然现在有很多如何解决循环依赖的策略和最佳实践,但是却几乎没有一致的可预测的修复方式。通常,人们随机移动import语句或者代码块,直到“它突然起作用”。事实证明,鉴于对此推文的回应,我并不是唯一碰到这个问题的人。

(tweet无法直接引用,内容大概就是一个网友赞同了作者。有兴趣可以看原文)

幸运的是,正如我下面即将演示的那样,有一个一致的方法可以解决这些依赖问题。

例子

JavaScript中的模块加载顺序是确定的,但是在大型项目中很难去准守,原因是一旦有了(间接的)循环引用,你可能在代码中使用一个加载一半的模块。这样的例子包括:引用使用尚未初始化的基类,读取尚未初始化的变量。

在此博客中我们将使用一个mock页面,它会美化对象树的打印,以一种类型yaml的格式。

你可以自己在这个codesandbox中尝试。这个程序的实现非常简单,这里有个基类AbstractNode,它定义接口并且提供了通用的功能,像parentgetDepth(),接下来有两个专有实现,NodeLeaf,这个程序运行的很好。但是在同一个文件里维护三个类不是理想的方式,所以我们重构一下然后看看有什么事情会发生...

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 */   
}

上面的改动足以使这个应用中断。请注意,NodeLeafAbstractNode.js中被导入,因为这这些类在静态方法from中被使用。

应用中断的原因是,当应用尝试导入Leaf类时,AbstractNode还没有被定义。这可能有点令人惊讶,因为毕竟,在Leaf类定义上方的导入语句是没有问题的。但是加载模块的过程中会发生这些事:

  1. index.js导入AbstractNode.js
  2. 模块加载器开始加载AbstractNode.js并且执行模块代码,第一行就是对Leaf的导入语句
  3. 所以加载器开始加载Leaf.js,它反过来以导入Abstractnode.js开始
  4. AbstractNode.js已经被加载,并且立即从模块缓存中返回,然而,因为这个模块至今没有运行过第一行(Leaf的导入),添加AbstractNode类的语句暂未被执行!
  5. 所以Leaf类尝试去继承一个undefined值,而不是一个合法的类,抛出了上面显示的异常!

尝试修复

所以事实证明,我们的循环引用造成了麻烦的问题。然而,如果我们仔细观察的话,非常容易决定模块加载顺序应该的样子:

  1. 先加载AbstractNode
  2. 在那之后再加载NodeLeaf

换句话说,我们先定义AbstractNode,然后让它导入LeafNode。这样可以运行,因为在定义AbstractNode的过程中没有必要知道LeafNode是什么,只要它们在AbstractNode.from第一次调用之前定义即可。所以我们尝试一下下面的改动:

export class AbstractNode {
  /* as is */
}

import { Node } from './Node'
import { Leaf } from './Leaf'

事实证明,这个解决方案有一些问题:

首先,写法丑陋不易拓展。在一个大型的代码库中,它会导致导入语句到处随机移动,直到突然可以正常运行。这通常作为一个临时的方案,因为未来一个小的重构或者导入语句的变化,都会微妙的调整模块的加载顺序,导致问题重新导入。

其次,这个方案是否有效很大程度上取决于用了什么模块打包器。举个例子,在codesandbox中,当使用Parcel(或者Webpack或者Rollup)时,这个解决方案不会正常运行。然而,当在本地使用Node.js和CommonJS模块时,这个变通方案就会很好的运行。

避免问题

所以,显然这个问题不是容易解决的。能避免这个问题吗?答案是能,这里有一些方式去避免这个问题。首先,我们可以把代码放在同一个文件里,如我们最初的例子所示。这样我们就可以解决这个问题,因为它可以完全控制模块初始化代码的运行顺序。

其次,有一些人以上述问题为理由,发表类似“不应该使用类”,“别使用继承”的陈述,但这是把这个问题想的太简单了。虽然我认同现在的编程者总是习惯迅速的导入继承方案,对于一些场景它是完美的,并且在代码结构,复用性,性能方面都产出很大收益。但是最重要的是,这个问题不仅仅局限于类的继承,当模块变量和模块初始化期间运行的函数之间存在循环依赖关系时,也会导入完全相同的问题!

我们可以通过以下方式重新组织我们的代码,把AbstractNode类拆解成多个更小的片段,以便AbstractNode不依赖NodeLeaf。在这个sandboxfrom方法被移出AbstractNode类并且放入到一个单独的文件中。这确实解决了问题,但是现在我们的项目和api结构不同了,在一个大型项目中,可能很难确定如何实现这个技巧,甚至不可能实现。想象下,如果app的下个版本中NodeLeaf依赖print方法,会发生什么。。。

彩蛋:我以前用过的另一个丑陋的技巧:利用function的作用域提升,在一个function中返回基类,达到按顺序加载的效果,我甚至不确定如何正确的表达。

内部模块模式来救场!

我曾在许多项目中与这个问题斗争过,一些例子包括我在 Mendix, MobX, MobX-state-tree的工作,以及一些个人项目中。在过去几年的某个时间,我甚至写了一个脚本去串联所有的源文件并且删除所有的导入语句,一个穷人版的模块打包器,只是为了避免模块加载顺序问题。

然而解决了几次这个问题后,一种模式出现了。一种可以完全控制模块加载顺序的模式,不需要重组项目结构或者使用奇怪的技巧!这个模式可以与我尝试过的所有工具链完美搭配(Rollup, Webpack, Parcel, Node)。

这个模式的关键是导入一个index.js文件和internal.js文件。游戏规则如下:

  1. internal.js模块从项目中的每个本地模块导入和导出所有东西。
  2. 项目中的所有其他模块只从internal.js中导入,并且从不直接从项目其他文件中导入。
  3. 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 */
}

第一次用这个模式时可能会感觉很生硬,但是它有一些重要的收益。

  1. 首先我们解决了问题!就像这里演示的一样,我们的app又正常工作了。
  2. 这个模式可以解决我们的问题的原因是:现在我们完全掌控了模块的加载顺序。(你可以看下面的图,或者读上面的模块顺序说明,了解为什么会这样)。
  3. 不需要去做我们不想要的重构,也不必被迫使用丑陋的技巧,像是把导入语句放到文件的底部。我们不必破坏代码库的架构,API或语义结构。
  4. 彩蛋:导入语句会变得更简洁,因为我们会从更少的文件引入代码。以AbstractNode.js为例,原来又两个引入语句,现在只有一个。
  5. 彩蛋:有了index.js,我们有单一文件入口,可以对暴露给外界的模块进行精细的控制。

结论

这就是我近期解决循环依赖问题的方式。如果将其应用于现有项目,则需要对导入语句进行一些基本的重构工作。但是过程是傻瓜且直截了当的。并且结束重构之后,你完全掌控了文件加载顺序,使得在未来快速定位循环依赖问题成为可能。

这里是一些实际工作中,使用这个方案重构的commit

MobX (大改动,但是影响不大,因为它很简单) MobX-state-tree (注意文件末尾导入语句移除的方式) Smaller personal project

我至今从未在大型项目中引用这个模式,只有在lib中使用。但是在大型项目中它可以正常工作,只是将这个技术应用到某些发生问题的项目子目录里,如果它们是一个独立的lib。