浅聊前端中的模块互通问题

114 阅读6分钟

模块互通

esModule的技术规范一经出台就收到前端社区的广大追捧,大家都急切着希望拥抱新的技术,但我们又不可能立刻放弃原本占据广大生态的CommonJS,我们还面临着这样的问题:两种截然不同的模块系统应该如何进行交互和引用?因此我们不仅需要esModule这种新的模块化方案,还需要一种友好的模块互通方案

NodeJS需要较长的一段时间才能够正式支持esModule和它的模块互通方案,前端开发者显然有些等不及,而正巧此时Babel风头正盛,开发者通过使用Babel就可以提前用到最新的esModule语法,并且由于它的本质是将esModule语法的代码编译为CommonJS语法的代码,因此Babel也相当于提供了一套自己的模块互通方案。然而,Babel最初提供的实现存在着比较明显的错误,尽管后续修复后已经是相对自洽的,但和如今NodeJS官方所提供的实现是存在着较大的割裂的,并且由于Babel的强大的影响力,导致包括TypeScript、Esbuild等前端工具也采纳相同的实现,导致事实上前端社区和Node社区是存在两种模块互通方案的。

在深入介绍Babel的模块互动方案之前,让我们首先了解NodeJS所提供的官方实现是怎样的吧。

Node

在NodeJS正式发布它的esModule实现和模块互通方案的时候,前端社区普通采用的Babel所提供的模块互通方案,NodeJS并没有向不优雅的Babel实现妥协,但这却使得社区存在着两种割裂的模块互通实现。

在NodeJS的实现中,esModule的默认导出会被视为等价于CommonJS中的module.exports。这是基于esModule和CommonJS本身的特性决定的,我们都知道esModule是可以被静态分析的,而CommonJS是动态的(即我们无法提前知道会有哪些导出接口),因此我们把module.exports整体视为一种默认导出

import mo, { level } from './test.cjs'console.log(mo, level) // { hp: 100, mp: 200, level: 999 } 999
module.exports = {
    hp: 100,
    mp: 200
}
​
module.exports.level = 999;

Babel

上文提到过Babel的模块互通方式是一种经过修复后的不优雅的方案,那么不妨让我们直接观察esModule代码会被其编译成怎样的CommonJS代码吧。

模块导出

export default {
    hp: 100,
    mp: 200
}
Object.defineProperty(exports, "__esModule", {
    value: true
});
​
exports.default = {
    hp: 100,
    mp: 200
};

从这个例子中可以得知,在Babel的模块互通方案中**export default会被编译成module.exports.default,这一点和NodeJS的实现完全不同,这就是Babel早期所引入的错误实现**,后续为了修复这个错误实现,Babel通过定义__esModule字段来标识这是由esModule模块转化而来的CommonJS模块,在导入该模块时发现存在这个__esModule时会取它的default值,从而抹平差异。

模块导入

例子一:🌰

import mo from './test.js'console.log(mo)
var _test = _interopRequireDefault(require("./test.js"));
function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}
console.log(_test.default);

从这个例子中可以得知,在引入模块时Babel会根据模块的类型不同返回不同的结果。当被引用存在__esModule属性时,Babel会返回这个模块module.exportsdefault属性,从而修复自身在模块导出时所引入的错误;当被引用的模块不存在__esModule属性时,表明这是原生的CommonJS模块,此时直接返回module.exports,从而完成了export default等价于module.exports的正确实现。

根据上述内容,我们了解到Babel在模块导出时的错误实现,以及在模块导入时根据被引用模块的类型不同,分别采取了修复方案和正确的实现。这样的Babel实现了一定程度的自洽,但我们需要特别注意的是,对于一个通过Babel(由于包括TypeScript、ESBuild在内的诸多前端工具也采用了和Babel一致的方案,这里的Babel可以被替代成相关的名词)从esModule转化而来的CommonJS模块,在不同的模块系统中(可以理解为在前端工具链中使用或在Node中直接使用)被引用后会得到不同的结果。

@babel/traverse这个库举例子,通过观察它的入口文件会发现存在__esModule属性的定义,就表明它是由esModule转化而来的CommonJS,原本的export default被挂载在module.exports.default上。当我们基于前端工具链引用该库时,由于我们编写的esModule代码也同样会被转化,因此能够正确的获取到它的默认导出;但当我们在Node中通过import traverse from "@babel/traverse"引用该库时,我们实际上只能拿到module.exports,因此需要手动的去取default的值,如下:

import _traverse from "@babel/traverse";
const traverse = _traverse.default;

实际上这种情况还是很容易遇见的,如今了解了根本性的原理后,再碰到类似的情况也不会感到奇怪了。

例子二:🌰

import mo, { hp } from './test.js'console.log(mo, hp)
var _test = _interopRequireWildcard(require("./test.js"));
function _getRequireWildcardCache(nodeInterop) {
  if (typeof WeakMap !== "function") return null;
  var cacheBabelInterop = new WeakMap();
  var cacheNodeInterop = new WeakMap();
  return (_getRequireWildcardCache = function _getRequireWildcardCache(
    nodeInterop
  ) {
    return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
  })(nodeInterop);
}
function _interopRequireWildcard(obj, nodeInterop) {
  if (!nodeInterop && obj && obj.__esModule) {
    return obj;
  }
  if (obj === null || (typeof obj !== "object" && typeof obj !== "function")) {
    return { default: obj };
  }
  var cache = _getRequireWildcardCache(nodeInterop);
  if (cache && cache.has(obj)) {
    return cache.get(obj);
  }
  var newObj = {};
  var hasPropertyDescriptor =
    Object.defineProperty && Object.getOwnPropertyDescriptor;
  for (var key in obj) {
    if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) {
      var desc = hasPropertyDescriptor
        ? Object.getOwnPropertyDescriptor(obj, key)
        : null;
      if (desc && (desc.get || desc.set)) {
        Object.defineProperty(newObj, key, desc);
      } else {
        newObj[key] = obj[key];
      }
    }
  }
  newObj.default = obj;
  if (cache) {
    cache.set(obj, newObj);
  }
  return newObj;
}
console.log(_test.default, _test.hp);

我们在例子一的基础上做了微小的修改,编译后的代码有兴趣的读者可以看看,从结论来说表现是和Node基本一致的。

TypeScript

通过设置module: 'CommonJS',TypeScript也能实现esModuleCommonJS的转化,事实上TypeScript的模块导出实现和Babel完全一致,只是在模块导入的实现上根据esModuleInterop的不同会有对应的区别。

模块导出

和Babel完全一致。

模块导入

esModuleInterop: false
import mo from './test.js'console.log(mo)
Object.defineProperty(exports, "__esModule", { value: true });
var test_js_1 = require("./test.js");
console.log(test_js_1.default);

从这个例子中可以得知,当该字段为false时,import mo from相当于引入module.exports.default,这是和NodeJS完全不同的实现,也是Babel通过辅助函数来修复的问题。

esModuleInterop: true
import mo from './test.js'console.log(mo)
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
var test_js_1 = __importDefault(require("./test.js"));
console.log(test_js_1.default);

从这个例子中可以得知,当该字段为true时(推荐),TypeScript将会通过辅助函数来修复导出时的错误实现,这和Babel的做法是完全一致的。

扩展阅读

Module "react" has no default export

早期TypeScript项目并没有支持esModuleInterop选项,此时通过import React from "react"引用React时,会碰到这样的报错Module "react" has no default export

首先我们先观察React的CommonJS产物的形式,部分代码如下,它并没有在module.exports.default挂载任何东西,因此TypeScript尝试引入module.exports.default自然就会报错。

// 注:并没有__esModule属性
exports.useRef = useRef;
exports.useState = useState;
exports.version = ReactVersion;

后来TypeScript被迫妥协,开启esModuleInterop选项后的表现和Babel完全一致,此时引入import React from 'react'等价于const React = require('react'),符合我们预期的结果。