简单记录一下最近工作中遇到的关于循环依赖的问题

587 阅读6分钟

先上简化后的代码

<!-- /index.html -->
<html lang="en">
  <body>
    <script type="module" src="./src/main.js"></script>
  </body>
</html>
// /src/main.js Main组件
import A from './a.js'

export const ENUMS = {
  X: 1,
  Y: 2,
}

export function fn() {
  console.log('这是Main组件的一个函数')
}

console.log(A, '渲染A组件')
// /src/a.js A组件
import { ENUMS, fn } from './main.js'

fn()
console.log(ENUMS, '使用枚举值')

export default 'A'

场景说明

html 是测试文件,这个不用说。我们这里有两个组件,Main 组件和 A 组件。A 组件是 Main 组件的子功能模块,所以 在 Main 组件中用到了并渲染了 A 组件。A 组件同时又用到了 Main 组件提供的公共静态数据(动态公共数据通过属性传),比如枚举数据。因为这些数据将来可能会在其它的子功能组件中用到,所以把枚举这样的公共数据就维护在了父组件中。

父组件需要子组件来渲染 UI,子组件需要使用到父组件维护的公共静态数据,这样一来就出现了循环依赖

由于模块加载的策略是深度优先遍历,所以在子模块的加载过程中,父模块的ENUMS还没有加载完成,所以console.log(ENUMS, '使用枚举值')会报错。如下:

截屏2024-02-23 下午11.02.17.png

为什么函数可以成功执行?

报错倒是没什么问题,很好理解。但是神奇的是同样的导入导出,为什么函数却执行成功了?

我立马想到的猜测是:可能是变量提升,于是我将 const 改成了 var。

// /src/main.js Main组件
import A from './a.js'

export var ENUMS = {
  X: 1,
  Y: 2,
}

export function fn() {
  console.log('这是Main组件的一个函数')
}

console.log(A, '渲染A组件')

果不其然,测试结果如下:

截屏2024-02-23 下午11.19.17.png 问题到这里就很清楚了,就是函数之所以可以执行是因为函数提升。

函数提升与变量提升

都到这里我们就顺便复习一下函数(function 声明的函数)提升和变量(var 声明的变量)提升。

  • 函数提升的是实现;变量提升的是声明
  • 函数比变量提升的更早

解决方案

以下是我目前能立刻想到的两个比较简单的方案

方案一:单独抽取文件

比如都提取到 utils.js

// /src/a.js A组件
import { ENUMS, fn } from './utils.js'

fn()
console.log(ENUMS, '使用枚举值')

export default 'A'

大家可以根据具体实际场景再细化

方案二:延迟使用

// /src/a.js A组件
import { ENUMS, fn } from './utils.js'

setTimeout(() => {
  fn()
  console.log(ENUMS, '使用枚举值')
})

export default 'A'

这里的 setTimeout 就好比我们在组件中使用,因为组件不可能是我们文件一加载就立即实例化(这也是我们平时遇上循环依赖也基本不会出问题的原因,因为基本上都是在组件中使用的)。

但是我今天遇到的场景又特殊一点,就是我想用枚举数据来做一个组件 Map,而很显然组件 Map 这样的数据也是静态的,所以我就把它提升到组件外部,进而就遇到了这个问题。大概是这样:

// /src/a.js A组件
import { ENUMS } from './main.js'
import X from './x.js'
import Y from './y.js'

const CompMap = {
  [ENUMS.X]: X,
  [ENUMS.Y]: Y,
}

看看 webpack 具体打包处理情况

webpack 关于循环依赖的处理其实很简单的:就是给模块运行结果加一层缓存,有缓存的就不用重新计算,这样就避免了无限循环。

webpack 环境搭建

我们就简单配一下,配置一个 devsever 是为了方便运行。只需要安装 webpackwebpack-cliwebpack-dev-server 三个包即可。

// webpack.config.js
const { resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  mode: 'development',
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: resolve(__dirname, './index.html'),
    }),
  ],
  devServer: {
    port: 8080,
    open: true,
  },
}

再简单配个命令(大家也可以不用配,直接使用原命令):

// package.json
{
  "scripts": {
    "build": "npx webpack",
    "start": "npx webpack-dev-server"
  }
}

同时为了方便测试,我们简化一下main.jsa.js的内容:

// /src/main.js
import A from './a.js'
export const ENUMS = 'hello'
// /src/a.js
import { ENUMS } from './main.js'
console.log(ENUMS)
export default 'A'

测试

接下来我们运行一下 yarn start,看看结果:

截屏2024-02-24 上午9.34.54.png

没问题和前面的原生报错一样,接下来我们看看 webpack 对 main.jsa.js的打包结果。

// main.js
__webpack_require__.r(__webpack_exports__)
__webpack_require__.d(__webpack_exports__, {
  ENUMS: () => ENUMS,
})
var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./src/a.js')
const ENUMS = 'hello'
// a.js
__webpack_require__.r(__webpack_exports__)
__webpack_require__.d(__webpack_exports__, {
  default: () => __WEBPACK_DEFAULT_EXPORT__,
})

var _main_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./src/main.js')
console.log(_main_js__WEBPACK_IMPORTED_MODULE_0__.ENUMS)
const __WEBPACK_DEFAULT_EXPORT__ = 'A'

看到这里肯定会有疑问:_main_js__WEBPACK_IMPORTED_MODULE_0__.ENUMS 中,为什么运行时会报 Uncaught ReferenceError: Cannot access 'ENUMS' before initialization 这个错。

require

接下来我们先看看 require 的实现,其实很简单:

function __webpack_require__(moduleId) {
  // Check if module is in cache
  var cachedModule = __webpack_module_cache__[moduleId]
  if (cachedModule !== undefined) {
    return cachedModule.exports
  }
  // Create a new module (and put it into the cache)
  var module = (__webpack_module_cache__[moduleId] = {
    // no module.id needed
    // no module.loaded needed
    exports: {},
  })

  // Execute the module function
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__)

  // Return the exports of the module
  return module.exports
}

我们知道 webpack 打包模块的原理:其实就是将各个模块用函数包裹起来,然后路径做 key,函数做 value 存在一个 map 结构中。那么 require 要做的事情其实就很简单了,运行模块函数和加一层缓存逻辑。所以跟 require 没什么关系。

rd

先看看 rr 其实很简单,就是标记一下是不是 esModule

__webpack_require__.r = (exports) => {
  if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
    Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
  }
  Object.defineProperty(exports, '__esModule', { value: true })
}

接下来看 d

__webpack_require__.d = (exports, definition) => {
  for (var key in definition) {
    if (
      __webpack_require__.o(definition, key) &&
      !__webpack_require__.o(exports, key)
    ) {
      Object.defineProperty(exports, key, {
        enumerable: true,
        get: definition[key],
      })
    }
  }
}

// o的逻辑很简单,就是判断一个对象是否有某个属性
Object.prototype.hasOwnProperty.call(obj, prop)

所以 d 的逻辑总结一下就是:把单独导出合并到默认导出中,同时把单独导出的内容包装成了一个 getter 函数。看到这里,其实我们就已经明白那个报错了。回到这段代码,

// main.js
__webpack_require__.d(__webpack_exports__, {
  ENUMS: () => ENUMS,
})
var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./src/a.js')
const ENUMS = 'hello'

通过 d 函数,main.js 中需要暴露内容就暴露出去了。当我们执行到 __webpack_require__('./src/a.js') 时,此时开始执行 a.js 中的内容。当 a.js 运行到 _main_js__WEBPACK_IMPORTED_MODULE_0__.ENUMS 时,调用 _main_js__WEBPACK_IMPORTED_MODULE_0__ 的 getter 访问 ENUMS,进而执行 () => ENUMS 函数,而此时 const ENUMS = 'hello' 这一行并没有得到运行,所以就报了那个错误。

这里也说明一个问题:webpack 帮我们做了一件事情叫做: export 提升。先处理导出内容,再执行文件其它内容。但是在原生中其实还是一行一行执行的,export 并没有类似于 var、function 这样的提升特性

为什么要把导出内容包装成 getter,而不是直接对象合并?

其实这样运行起来也是完全没有问题的,就是把 main.js 改成这样:

// main.js
__webpack_require__.d(__webpack_exports__, {
  ENUMS,
})
var _a_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./src/a.js')
const ENUMS = 'hello'

d 函数改成这样:

__webpack_require__.d = (exports, definition) => {
  return { ...definition, ...exports }
}

大家有兴趣的话可以自己测一下,我这边测试是 OK 的。

其实这个问题的答案也很简单,就是做一层浅保护,外界不允许修改。有兴趣的朋友可以看一看我的另外一篇相关的文章:为什么 export 导出一个字面量会报错,而使用 export default 就不会报错?