从一个问题看模块循环依赖

今天在开发一个react native需求的时候写了一些代码,然后发现运行不起来了,报了一个访问了未注册的页面的错误。

去控制台查看信息,发现了一个报错

9243AC96-DDC2-48ED-9259-EDB4742A4254.png

猜想就是因为这个报错,导致js不往下执行了,然后我们的页面就没有注册,但是为什么会报这个错误呢?检查了代码也并没有发现什么逻辑错误。

继续查看控制台,发现了很多warning信息,可以看到报了一些模块循环依赖的警告,并且提示可能会存在取值为undefined的风险

image.png

什么是循环依赖?

比如a模块引用了b模块,b模块又引用了a模块

又比如a 模块引用了b模块,b模块引用了c模块,而c模块又引用了a模块,从而形成了一个引用循环

image.png

首先来简单看一下目前前端比较流行的两个模块化规范:CommonJS和ES Module

CommonJS

这里主要分析一下Node.js实现的CommonJS规范。在Node.js中,每个脚本文件就是一个模块,require命令第一次加载该脚本时就会执行整个脚本,然后在内存中生成一个该模块的说明对象:

{
  this.id = id;
  this.exports = {};
  this.loaded = false;
  ...
}
复制代码

对于一个模块,无论加载多少次,都只会在第一次加载时运行一次,之后再次加载就会到这个对象的exports属性中取值。

大家有没有想过,我们在文件中为什么可以使用require、module.exports、exports、__dirname等方法或属性吗?

const path = require('path')

console.log(__dirname);
console.log(__filename);

module.exports = {
  name: 'rn',
};

复制代码

下面简单模拟一下Node.js中的 require, 具体实现源码可以看这里

const path = require('path');
const fs = require('fs');
const vm = require('vm');

function Module(id) {
  this.id = id;
  this.exports = {};
}

Module._extenstions = {
  '.js'(module) {
    const script = fs.readFileSync(module.id, 'utf-8');
    const fn = vm.compileFunction(script, ['exports', 'require', 'module', '__filename', '__dirname']);
    // `(function (exports, require, module, __filename, __dirname) {
    //   ${script}
    // });`;
    const exports = module.exports; // 默认空对象
    const require = myRequire;
    const filename = module.id;
    const dirname = path.dirname(filename);

    Reflect.apply(fn, exports, [exports, require, module, filename, dirname]);
  },
  '.json'(module) {
    const jsonStr = fs.readFileSync(module.id, 'utf-8');
    module.exports = JSON.parse(jsonStr);
  },
};

Module._cache = Object.create(null);

Module._resolveFilename = function (id) {
  const filepath = path.resolve(__dirname, id);
  if (fs.existsSync(filepath)) {
    return filepath;
  }
  const exts = Object.keys(Module._extenstions);
  for (let i = 0; i < exts.length; i++) {
    let newPath = filepath + exts[i];
    if (fs.existsSync(newPath)) return newPath;
  }
  throw new Error(`Cannot find module ${id}`);
};

Module.prototype.load = function (filename) {
  // 获得到扩展名
  let ext = path.extname(filename);
  // 根据扩展名加载对应的策略
  Module._extenstions[ext](this);
};

function myRequire(id) {
  // 解析路径,包括内置包、第三方包、其他文件路径
  const absPath = Module._resolveFilename(id);
  // 获取之前的缓存
  let existsModule = Module._cache[absPath];
  if (existsModule) {
    return existsModule.exports;
  }
  // 创建一个module 对象
  const module = new Module(absPath);
  // 缓存文件
  Module._cache[absPath] = module;
  // 加载模块
  module.load(absPath);
  // 返回module对象的exports属性
  return module.exports;
}
复制代码

从代码中可以看到,在模块执行前就会创建好对应的模块对象,并进行缓存。模块执行的过程实际是在给该模块对象计算需要导出的变量属性。因此,CommonJS 模块在启动执行时,就已经处于可以被获取的状态,这个特点可以很好地解决模块循环依赖的问题。

CommonJS中的循环依赖

CommonJS模块采用深度优先遍历,并且是加载时执行,即脚本代码在require时就全部执行。一旦出现某个模块被“循环加载”,就只输出已经执行的部分,没有执行的部分不会输出。

这里引用node的一个官方例子来讲解其原理

// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');

// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');

// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);
复制代码

执行main.js

image-20220309145020931.png

代码执行顺序如下:

  • main.js中先加载a.js,a脚本先输出done变量,值为false,然后加载b脚本,a的代码停止执行,等待b脚本执行完成后,才会继续往下执行。

  • b.js执行到第三行会去加载a.js,这时发生循环加载,系统会去a.js模块对应对象的exports属性取值,因为a.js没执行完,从exports属性只能取回已经执行的部分,未执行的部分不返回,所以取回的值并不是最后的值。

  • a.js已执行的代码只有一行,exports.done = false;所以对于b.js来说,require a.js只输出了一个变量done,值为false。往下执行,控制台打印出:

in b, a.done = false

  • b.js继续往下执行,done变量设置为true,console.log('b done');,等到全部执行完毕,将执行权交还给a.js。此时控制台输出:

b done

  • 执行权交给a.js后,a.js接着往下执行,执行console.log('in a, b.done = %j', b.done);;控制台打印出:

n a, b.done = true

  • a.js继续执行,变量done设置为true,直到a.js执行完毕。

a done

  • main.js中第二行不会再次执行b.js,直接输出缓存结果。最后控制台输出:

in main, a.done = true, b.done = true

使用不当的问题

由于在加载执行时只会输出已经执行的部分,所以可能会出现获取的值为undefined的情况,导致不符合预期

// a.js
require('./b');
module.exports = {
  obj: {
    name: 'this is a',
  },
};

// b.js
const { obj } = require('./a');
console.log(obj.name)
module.exports = 'this is b';
复制代码

执行a.js

image-20220309181147215.png

ES Module

ES Module是ES6官方发布的模块化规范,借助js引擎实现。js引擎实现了ES6 Module的底层的核心逻辑,js运行时需要在上层做适配。

ES6 模块的处理包括Construction(构造) 、Instantiation(实例化) 和 Evaluation(执行或者赋值) 三个阶段。

三个阶段.png

  1. 构建阶段主要是获取所有模块并且解析为模块记录(Module Record)、分析模块之间的依赖关系。

module_record.png

  • ES Module 在构建过程不会实例化和执行任何的js代码,也就是所谓的 **静态解析`**过程。

与CommonJS不同的是,在ES Module中,在任何求值之前建立了完整的依赖树,这说明你不能在模块说明符里面使用变量,因为这个变量目前还没有值。

require_import.png

  1. 在实例化阶段,会创建模块的执行上下文,并且初始化模块环境记录,用来管理所有模块中的变量。接着给导出的模块变量创建绑定、给导入模块变量创建绑定初始化为子模块的对应变量(相当于export import语句提升),给 var 声明的变量创建绑定并初始化为 undefined、给函数声明变量创建绑定并初始化为函数体的实例化值、给其他变量创建绑定(let、const声明的)但不进行初始化。

为了实例化模块图(module graph),JS引擎会做一个所谓的“深度优先遍历”的操作。意思就是说,JS引擎会先走到模块图的最底层即找到不依赖任何其他模块的那些模块,并且绑定好它们的导出(export)。

write_up_export.png

当JS引擎完成一个模块的所有导出的绑定,它就会返回上一个层级去绑定来自于这个模块的导入(import)。需要注意的是,导出和导入都是指向同一片内存地址。先绑定导出保证了所有的导入都能找到对应的导出。

write_up_import.png

  1. 在执行阶段,JS引擎通过执行最上层的代码也就是function以外的代码来对环境记录中的变量初始化(赋值)。

根据上述介绍得知,ES6 模块的导入导出语句的位置不影响模块代码语句的执行结果。

console.log(a);

import { a } from 'child.js';
复制代码

如果对ES Module感兴趣,,更多详细信息可以参考:

ES Module中的循环依赖

image.png

模块处理顺序为:

image.png

处理不当的问题

// a.mjs
import { b } from './b.mjs'
console.log(b);
export let a = 'this is a'

// b.mjs
import { a } from './a.mjs';
console.log(a);
export let b = 'this is b';
复制代码

执行a.mjs

image-20220309192241298.png

由于子模块先于父模块被执行,当执行b的时候,a模块还没有被执行,所以变量a还没有被初始化,仅仅是a模块的环境记录中创建了绑定,所以我们在b中使用a会报错。

但是如果我们将a模块中的let a改为var a,就会有一些变化

// a.mjs
import { b } from './b.mjs'
console.log(b);
export var a = 'this is a'

// b.mjs
import { a } from './a.mjs';
console.log(a);
export let b = 'this is b';
复制代码

执行a.mjs

image-20220309195330790.png

这是为什么呢?还记得我们前面提到的实例化阶段吗,如果遇到var声明的变量,会在环境记录中创建绑定并初始化为undefined ,所以我们在子模块中不会报未初始化的错误,这也是let、const和var的区别。(let暂时性死区)

如果是异步执行则没有问题,因为异步执行的时候,a变量已经被执行了,所以已经被初始化过了

// a.mjs
import { b } from './b.mjs'
console.log(b);
export var a = 'this is a'

// b.mjs
import { a } from './a.mjs';
setTimeout(() => console.log(a));
export let b = 'this is b';
复制代码

执行a.mjs

image-20220309195846580.png

项目中遇到的问题思考

在简单介绍了CommonJS和ES Module模块化和循环依赖使用不当的情况后,再来看之前遇到的问题,我们项目中使用的是ES Module,按照我们上面的分析,我们的项目中没有使用var关键字,应该会报一个ReferenceError错误才对,为什么会报一个undefined错误呢?

在我们开发RN业务时

  • 一般会使用 JSX 语法『糖』描述 UI 视图,然而标准的 JS 引擎显然不支持 JSX

  • 通常使用的是 ES6,目前有些iOS、Android 上的 JS 引擎还不支持 ES6

  • ......

所以我们需要把我们写的代码打包成一个手机上JS引擎能识别的代码,一般我们使用的是metro打包成一个bundle供native加载,RN Bundle 从本质上讲是一个 JS 文件,其主要由三部分组成:polyfills、module 定义、require 调用。

我们写一个简单的例子看看metro打包后的效果,下面简写了主要的核心代码

// a.js
import { b } from './b'
console.log(b);
export let a = 'this is a'

// b.js
import { a } from './a';
console.log(a);
export let b = 'this is a';
复制代码

执行一下命令进行打包

react-native bundle --entry-file src/a.js --bundle-output metro-dist/ios.bundle --platform ios --assets-dest metro-dist/ios --dev true
复制代码

其中a.js为入口文件,下面写出了打包后的主要核心代码

global.__d = define;
global.__r = metroRequire

// 用来缓存所有的模块
modules = Object.create(null)

// 定义模块
function define(factory, moduleId, dependencyMap) {
  var mod = {
    dependencyMap: dependencyMap,
    factory: factory,
    hasError: false,
    importedAll: EMPTY,
    importedDefault: EMPTY,
    isInitialized: false,
    publicModule: {
      exports: {},
    },
  };
  modules[moduleId] = mod;
}

function metroImportDefault(moduleId) {
    var exports = metroRequire(moduleId);
    var importedDefault = exports && exports.__esModule ? exports.default : exports;
    return importedDefault;
}

// 引用模块
function metroRequire(moduleId) {
  var module = modules[moduleId];
  // 如果模块已经被初始化
  if (module && module.isInitialized) {
    return module.publicModule.exports;
  }
  
  var moduleObject = module.publicModule;
  moduleObject.id = moduleId;
  factory(
    global,
    metroRequire,
    metroImportDefault,
    moduleObject,
    moduleObject.exports,
    dependencyMap
  );
}

// 定义a模块
__d(
  function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, module, exports, _dependencyMap) {
    Object.defineProperty(exports, '__esModule', {
      value: true,
    });
    exports.a = void 0;

    var _b = _$$_REQUIRE(_dependencyMap[0], './b');

    console.log(_b.b);
    var a = 'this is a';
    exports.a = a;
  },
  0,
  [1],
  'src/a.js'
);

// 定义b模块
__d(
  function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, module, exports, _dependencyMap) {
    Object.defineProperty(exports, '__esModule', {
      value: true,
    });
    exports.b = void 0;

    var _a = _$$_REQUIRE(_dependencyMap[0], './a');

    console.log(_a.a);
    var b = 'this is a';
    exports.b = b;
  },
  1,
  [0],
  'src/b.js'
);

// 引入入口文件,开始执行代码
__r(0)

复制代码

其实可以看到,打包出来的结果本质上还是CommonJS规范

执行打包后的bundle

image.png

怎么避免循环依赖或者解决循环依赖?

  • 转行不干程序员了

  • 单个功能函数作为一个独立的文件

    • 像utils/index.js、common/index.js或者shared/index.js等文件不应该存在了,这些都是黑洞、泥沼。不应该在把很多函数放到这些的文件中,单独的函数应该使用一个单独的文件。如果不这样做当你在一个文件中import一个函数时,会引入这些文件中额外的import,你却不知道这些额外的import是否又会重新import你当前的文件,从而形成循环依赖。
  • 依赖注入和反向注册

Guess you like

Origin juejin.im/post/7077059417052545031