CommanderXL / Biu-blog

个人博客
431 stars 39 forks source link

循环依赖 #49

Open CommanderXL opened 4 years ago

CommanderXL commented 4 years ago

Es Module 和 CommonJS 对于循环依赖功能支持的执行策略

Es Module 和 CommonJS 规范都支持循环依赖。不过2个规范对于模块的加载和执行的策略不一样,所以在实际场景当中如果遇到了循环依赖的情况的话,

CommonJS 规范从加载策略来说是运行时加载,即代码执行到需要加载模块的那一行代码的时候,才会去加载并执行对应依赖的模块。举个例子:

foo.jsbar.js形成了一个循环依赖。这里可以看到foo.js导出变量书写的位置直接影响到了bar.js代码的执行。当bar.js开始执行的时候,如果在foo.js还未执行到exports.setFooParams导出这个方法的时候,在bar.js里面是访问不到对应方法的,这样程序执行的时候也就会报错。那么规避这种情况的一个处理方法就提前将exports.setFooParams方法置于文件的顶部,提前导出。

// foo.js
const { setBarParams } = require('./bar')
const params = {}

exports.setFooParams = function (obj) {
  Object.assign(params, obj);
}

setBarParams({ a: 1 });

// bar.js
const { setFooParams } = require('./foo')

const params = {};

exports.setBarParams = function (obj) {
  Object.assign(params, obj);
}

setFooParams({ b: 2 });

Es Module 规范当中,规定使用 import 来导入模块,export 来导出模块的接口,导出的变量或方法为引用类型,这也是和 CommonJS 规范导出变量为值拷贝的不同点之一。Es Module 在代码的静态编译阶段就确定了模块之间的相互依赖关系,在 Es Module 执行的过程中,import 导入模块有提升的效果,即在一个文件当中,可能你的导入的其他模块的代码是在文件的下方,但是在实际执行的过程当中首先会执行 import 导入模块的代码的。因为这样的特性,在 Es Module 当中如果遇到了循环依赖的情况:

// a.js
console.log('this is a file')

import { setBParams } from './b'
var params = {}

export function setAParams(obj) {
  Object.assign(params, obj)
}

setBParams({ a: 1 })

// b.js
var params = {}

import { setAParams } from './a'

export function setBParams(obj) {
  Object.assign(params, obj)
}

console.log('this is b file')
setAParams({ b: 2 })

开始执行a.js代码的时候,实际上会先执行b.js的代码,因此在b.js当中当执行到setAParams方法的时候,在a.js里面params变量仅仅完成了申明,还未走到赋值的阶段,因此会在setAParams调用的时候出现报错的情况(Object.assign方法接受的第一个参数类型不能为undefined或者null)。

案例

一般在书写代码的过程中不会特别留心有关 ES Module 出现循环依赖的场景,不过随着项目的迭代和应用代码的增加,以及不同的开发人员参与到项目的开发迭代当中,如果对于模块之间的相互引用关系不清晰或者是在模块设计开发阶段没有做好合理的规划,在日后的开发过程中还是会比较容易出现这种循环依赖的场景。

接下来简单总结下最近在开发过程中遇到循环依赖这一问题以及针对这个问题如何解决的。

在我们日常的开发过程当中,将一些通用的功能抽离成 sdk 单独的去维护。sdk 内部的模块是以职责功能维度去进行划分的:

这里暂且将 Init、Login、Omega、Ajax 分别成为一级模块。然后每个一级模块内部可能还会进行相关子模块(二级模块)的拆解,例如目前:

Init 一级模块里面包含了:

Ajax 一级模块里包含了:

当然每个模块是有一级导出(index.js文件一般会集合不同的二级模块并将它们导出)的,不同模块之间有相互引用关系,例如 Ajax 模块可能会使用 Init 模块当中提供的一些公参。

随着 sdk 功能的迭代,目前这些一级模块的导出(index.js)所承担的职责越来越多:

这也为之后的模块的迭代拓展埋下的隐患。

比如这次需要增加一个global.js挂载相关全局对象的一个二级模块,从功能职责角度来说将它放到 Init 一级模块之下是合理的。不过在global.js里面是引入了Ajax的一级模块(index.js)的,这个Ajax一级模块所遇到的问题和我上面描述的一样:所承担的职责越来越多。在这个一级模块当中,引入了Init的一级模块来完成一些公参的初始化的工作。这样一来二去,也就遇到了循环依赖的问题,最终导致代码不是按我们的预期去执行。

circle-dependency-1

Init(global.js 作为 Init 模块的子模块被引入,且被导出相关 API) <- global.js <- Ajax(Ajax 模块依赖 Init 模块提供的方法完成基础参数的初始化) <- Init

针对这个问题,首先确保 sdk 在接口和调用方式不变的前提下,进行进一步的功能模块的拆解和文件组织,最终所达到的效果就是:

circle-dependency-2

这样在完成global.js的功能的时候,如果需要挂载 Ajax 模块提供的全局变量,那么global.js不再需要依赖 Ajax 的一级模块,直接引入对应的二级模块(instance.js)导出的实例即可。同时在 Ajax 模块当中原本依赖 Init 模块提供的公参也只需要从 Init 的二级模块(params.js)当中引入。这样也就解除了 Init <-> Ajax 一级模块之间的相互引用,因此他们之间的循环依赖也就被打破了。

在写代码的过程中一般不会特别去留心这种模块之间的相互依赖,特别是涉及到模块多,且 module graph 较为复杂的情况,在不经意之间也就会出现模块之间的循环依赖。不过需要留心的就是:

  1. 仅做导出,没有导入的(即不依赖其他模块,在 module graph 当中处于端点的模块,例如在图二当中的 params.js)模块是安全的。那些除了导出,还有导入的模块存在这种出现循环依赖的风险;

  2. 循环依赖的模块因为 Es Module 的执行机制,如果是同步代码执行,则会出现未获取到导出模块的接口情况,这样会出现代码执行不合符预期的情况(在文中一开始举的那个 Es Module 的例子)。如果是异步执行的代码,或者是需要被调用的方法例如事件响应等,则不会出现这个问题。所以说相互之间出现循环依赖的模块执行期间是否符合预期,还和模块里面的代码执行的时机有关;

  3. 如果循环依赖之间的模块执行不符合预期,那么就需要重新思考模块的设计和拆分是否合理。

其他拓展阅读:

CommanderXL commented 3 years ago

拓展阅读:

export-default-thing-vs-thing-as-default

里面探讨了一些有关 es module 规范以及用法的问题。一些非常小的细节注意点。

CommanderXL commented 3 years ago

在上文最后部分提到了一些模块设计方面的建议,但是在设计一些业务上的 SDK 功能模块的时候,难免会出现一些相互依赖的一级模块。例如 Store 里面封装的 actions 里面使用了一些 api 方法,而这个 api 方法可能是由其他业务模块给暴露的。那么这个时候第一反应是这个 api 方法能否作为一个公共模块而拆分出来,这样导致的问题就是因为依赖关系的顾虑,导致模块非常的碎片化,这个时候的一个解决方案就是考虑是否使用类 MonoRepo 的方式去管理模块依赖。

比如在 vue 里面,每个单独模块功能单元都是作为一个独立的 npm package 去发布的。不同模块之间相互引用是直接通过包名,而非相对路径这种方式。这样算是一种物理上的隔离。那么在模块引用上也可考虑通过包名的形式去引用。只不过需要注意的是 SDK 需要暴露一个总的入口,统一由这个总的入口来做模块的引入分发。

CommanderXL commented 3 years ago

拓展阅读:

构建下一代基础设施 PDN

里面提到了一些 commonJsesModule 处理的思路。主要是为了做一些 unbundle 的功能增强,同时 esModule 也用于 tree shaking.

CommanderXL commented 3 years ago

拓展阅读:

渐进式 Unbundled 开发工具探索之路

esModule 来做 unBundled 的一些工程化的实践。