tsc、babel、webpack对模块导入导出的处理

记得要微笑
English

问题引入

很多 react 使用者在从 JS 迁移到 TS 时,可能会遇到这样一个问题:

JS 引入 react 是这样的:

// js
import React from 'react'

TS 却是这样的:

// ts
import * as React from 'react'

如果直接在 TS 里改成 JS 一样的写法,在安装了 @types/react 的情况下,编辑器会抛出一个错误:此模块是使用 "export =" 声明的,在使用 "esModuleInterop" 标志时只能与默认导入一起使用。

根据提示,在 tsconfig.json 中设置 compilerOptions.esModuleInteroptrue,报错就消失了。

要搞清楚这个问题的原因,首先需要知道 JS 的模块系统。常用的 JS 的模块系统有三个:

  • CommonJS(后文简称 cjs
  • ES module(后文简称 esm
  • UMD

AMD 现在用得比较少了,故忽略掉)

babelTS 等编译器更加偏爱 cjs。默认情况下,代码里写的 esm 都会被 babelTS 转成 cjs。这个原因我推测有以下几点:

  1. cjs 出现得比 esm 更早,所以已有大量的 npm 库是基于 cjs 的(数量远高于 esm),比如 react
  2. cjs 有着非常成熟、流行、使用率高的 runtime:Node.js,而 esmruntime 目前支持非常有限(浏览器端需要高级浏览器,node 需要一些稀奇古怪的配置和修改文件后缀名)
  3. 有很多 npm 库是基于 UMD 的,UMD 兼容 cjs,但因为 esm 是静态的,UMD 无法兼容 esm

回到上面那个问题。打开 react 库的 index.js

img

可以看到 react 是基于 cjs的,相当于:

module.exports = {
  Children: Children,
  Component: Component
}

而在 index.ts 中,写一段

import React from "react";
console.log(React);

默认情况下,经过 tsc 编译后的代码为:

"use strict";
exports.__esModule = true;
var react_1 = require("react");
console.log(react_1["default"]);

显然,打印出来的结果为 undefined,因为 reactmodule.exports 中根本就没有 default 和这个属性。所以后续获取 React.createElementReact.Component 自然都会报错。

这个问题引申出来的问题其实是,目前已有的大量的第三方库大多都是用 UMD / cjs 写的(或者说,使用的是他们编译之后的产物,而编译之后的产物一般都为 cjs),但现在前端代码基本上都是用 esm 来写,所以 esmcjs 需要一套规则来兼容。

  • esm 导入 esm

    • 两边都会被转为 cjs
    • 严格按照 esm 的标准写,一般不会出现问题
  • esm 导入 cjs

    • 引用第三方库时最常见,比如本文举例的 react
    • 兼容问题的产生是因为 esmdefault 这个概念,而 cjs 没有。任何导出的变量在 cjs 看来都是 module.exports 这个对象上的属性,esmdefault 导出也只是 cjs 上的 module.exports.default 属性而已
    • 导入方 esm 会被转为 cjs
  • cjs 导入 esm (一般不会这样使用)
  • cjs 导入 cjs

    • 不会被编译器处理
    • 严格按照 cjs 的标准写,不会出现问题

TS 默认编译规则

TS 对于 import 变量的转译规则为:

 // before
 import React from 'react';
 console.log(React)
 // after
 var React = require('react');
 console.log(React['default'])


 // before
 import { Component } from 'react';
 console.log(Component);
 // after
 var React = require('react');
 console.log(React.Component)
 

 // before 
 import * as React from 'react';
 console.log(React);
 // after
 var React = require('react');
 console.log(React);

可以看到:

  • 对于 import 导入默认导出的模块,TS 在读这个模块的时候会去读取上面的 default 属性
  • 对于 import 导入非默认导出的变量,TS 会去读这个模块上面对应的属性
  • 对于 import *TS 会直接读该模块

TSbabelexport 变量的转译规则为:(代码经过简化)

 // before
 export const name = "esm";
 export default {
   name: "esm default",
 };

 // after
 exports.__esModule = true;
 exports.name = "esm";
 exports["default"] = {
   name: "esm default"
 }

可以看到:

  • 对于 export default 的变量,TS 会将其放在 module.exportsdefault 属性上
  • 对于 export 的变量,TS 会将其放在 module.exports 对应变量名的属性上
  • 额外给 module.exports 增加一个 __esModule: true 的属性,用来告诉编译器,这本来是一个 esm 模块

TS 开启 esModuleInterop 后的编译规则

回到标题上,esModuleInterop 这个属性默认为 false。改成 true 之后,TS 对于 import 的转译规则会发生一些变化(export 的规则不会变):

 // before
 import React from 'react';
 console.log(React);
 // after 代码经过简化
 var react = __importDefault(require('react'));
 console.log(react['default']);


 // before
 import {Component} from 'react';
 console.log(Component);
 // after 代码经过简化
 var react = require('react');
 console.log(react.Component);
 
 
 // before
 import * as React from 'react';
 console.log(React);
 // after 代码经过简化
 var react = _importStar(require('react'));
 console.log(react);

可以看到,对于默认导入和 namespace(*)导入,TS 使用了两个 helper 函数来帮忙

// 代码经过简化
var __importDefault = function (mod) {
  return mod && mod.__esModule ? mod : { default: mod };
};

var __importStar = function (mod) {
  if (mod && mod.__esModule) {
    return mod;
  }

  var result = {};
  for (var k in mod) {
    if (k !== "default" && mod.hasOwnProperty(k)) {
      result[k] = mod[k]
    }
  }
  result["default"] = mod;

  return result;
};

首先看__importDefault。它做的事情是:

  1. 如果目标模块是 esm,就直接返回目标模块;否则将目标模块挂在一个对象的 defalut 上,返回该对象。

比如上面的

import React from 'react';

// ------

console.log(React);

编译后再层层翻译:

// TS 编译
const React = __importDefault(require('react'));

// 翻译 require
const React = __importDefault( { Children: Children, Component: Component } );

// 翻译 __importDefault
const React = { default: { Children: Children, Component: Component } };

// -------

// 读取 React:
console.log(React.default);

// 最后一步翻译:
console.log({ Children: Children, Component: Component })

这样就成功获取了 react 模块的 modue.exports

再看 __importStar。它做的事情是:

  1. 如果目标模块是 esm,就直接返回目标模块。否则
  2. 将目标模块上所有的除了 default 以外的属性挪到 result
  3. 将目标模块自己挂到 result.default

(类似上面 __importDefault 一样层层翻译分析过程略过)

babel 编译的规则

babel 默认的转译规则和 TS 开启 esModuleInterop 的情况差不多,也是通过两个 helper 函数来处理的

// before
import config from 'config';

console.log(config);
// after
"use strict";

var _config = _interopRequireDefault(require("config"));

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

console.log(_config["default"]);

// before
import * as config from 'config';

console.log(config);

// after
"use strict";

function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }

var config = _interopRequireWildcard(require("config"));

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(config);

_interopRequireDefault 类似 __importDefault

_interopRequireWildcard 类似 __importStar

webpack 的模块处理

一般开发中,babelTS 都会配合 webpack 来使用。一般是以下两种方式:

  • ts-loader
  • babel-loader

如果是使用 ts-loader,那么 webpack 会将源代码先交给 tsc 来编译,然后处理编译后的代码。经过 tsc 编译后,所有的模块都会变成 cjs,所以 babel 也不会处理,直接交给 webpack 来以 cjs 的方式处理模块。ts-loader实际上就是调用了tsc命令,所以需要tsconfig.json配置文件

如果是使用的 babel-loader,那么 webpack 不会调用 tsctsconfig.json 也会被忽略掉。而是直接用 babel 去编译 ts 文件。这个编译过程相比调用 tsc 会轻量许多,因为 babel 只会简单的移除所有 ts 相关的代码,不会做类型检查。一般在这种情况下,一个 ts 模块经过 babel@babel/preset-env@babel/preset-typescript 两个 preset 处理。后者做的事情很简单,仅仅去掉所有 ts 相关的代码,不会处理模块,而前者会将 esm 转成 cjsbabel7开始支持编译ts,这样一来,tsc的存在就被弱化了。 webpackbabel-loader实际上就是调用了babel命令,需要babel.config.js配置文件

然而 webpackbabel-loader 在调用 babel.transform 时,传了这样一个 caller 选项:

img

从而导致 babel 保留了 esmimport export

tscbabel可以将esm编译成cjs,但是cjs只有在node环境下才能运行,而 webpack 自己拥有一套模块机制,用来处理 cjs esm AMD UMD 等各种各样的模块,并且为模块提供runtime。因此,需要在浏览器运行的代码最终还需要webpack进行模块化处理

对于 cjs 引用 esmwebpack 的编译机制比较特别:

// 代码经过简化
// before
import cjs from "./cjs";
console.log(cjs);
// after
var cjs = __webpack_require__("./src/cjs.js");
var cjsdefault = __webpack_require__.n(cjs);
console.log(cjsdefault.a);

// before
import esm from "./esm";
console.log(esm);
// after
var esm = __webpack_require__("./src/esm.js");
console.log(esm["default"]);

其中__webpack_require__ 类似于 require,返回目标模块的 module.exports 对象。__webpack_require__.n 这个函数接收一个参数对象,返回一个对象,该返回对象的 a 属性(我也不知道为什么属性名叫 a)会被设为参数对象。所以上面源代码的 console.log(cjs) 会打印出 cjs.jsmodule.exports

由于 webpack 为模块提供了一个 runtime,所以 webpack 处理模块对于 webpack 自己而言很自由,在模块闭包里注入代表 module require exports 的变量就可以了

总结:

目前很多常用的包是基于 cjs / UMD 开发的,而写前端代码一般是写 esm,所以常见的场景是 esm 导入 cjs 的库。但是由于 esmcjs 存在概念上的差异,最大的差异点在于 esmdefault 的概念而 cjs 没有,所以在 default 上会出问题。

TS babel webpack 都有自己的一套处理机制来处理这个兼容问题,核心思想基本都是通过 default 属性的增添和读取

参考

esModuleInterop 到底做了什么?

阅读 1.6k
avatar
记得要微笑
前端工程师

求上而得中,求中而得下,求下而不得

1.5k 声望
4.3k 粉丝
0 条评论
avatar
记得要微笑
前端工程师

求上而得中,求中而得下,求下而不得

1.5k 声望
4.3k 粉丝
文章目录
宣传栏