問題引入
很多 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.esModuleInterop 為 true,報錯就消失了。
要搞清楚這個問題的原因,首先需要知道 JS 的模塊系統。常用的 JS 的模塊系統有三個:
CommonJS(後文簡稱cjs)ES module(後文簡稱esm)UMD
(AMD 現在用得比較少了,故忽略掉)
babel、TS 等編譯器更加偏愛 cjs。默認情況下,代碼裏寫的 esm 都會被 babel、TS 轉成 cjs。這個原因我推測有以下幾點:
cjs出現得比esm更早,所以已有大量的npm庫是基於cjs的(數量遠高於esm),比如reactcjs有着非常成熟、流行、使用率高的runtime:Node.js,而esm的runtime目前支持非常有限(瀏覽器端需要高級瀏覽器,node需要一些稀奇古怪的配置和修改文件後綴名)- 有很多
npm庫是基於UMD的,UMD兼容cjs,但因為esm是靜態的,UMD無法兼容esm
回到上面那個問題。打開 react 庫的 index.js:
可以看到 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,因為 react 的 module.exports 中根本就沒有 default 和這個屬性。所以後續獲取 React.createElement、React.Component 自然都會報錯。
這個問題引申出來的問題其實是,目前已有的大量的第三方庫大多都是用 UMD / cjs 寫的(或者説,使用的是他們編譯之後的產物,而編譯之後的產物一般都為 cjs),但現在前端代碼基本上都是用 esm 來寫,所以 esm 與 cjs 需要一套規則來兼容。
-
esm導入esm- 兩邊都會被轉為
cjs - 嚴格按照
esm的標準寫,一般不會出現問題
- 兩邊都會被轉為
-
esm導入cjs- 引用第三方庫時最常見,比如本文舉例的
react - 兼容問題的產生是因為
esm有default這個概念,而cjs沒有。任何導出的變量在cjs看來都是module.exports這個對象上的屬性,esm的default導出也只是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會直接讀該模塊
TS、babel 對 export 變量的轉譯規則為:(代碼經過簡化)
// 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.exports的default屬性上 - 對於
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。它做的事情是:
- 如果目標模塊是
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。它做的事情是:
- 如果目標模塊是
esm,就直接返回目標模塊。否則 - 將目標模塊上所有的除了
default以外的屬性挪到result上 - 將目標模塊自己掛到
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 的模塊處理
一般開發中,babel 和 TS 都會配合 webpack 來使用。一般是以下兩種方式:
ts-loaderbabel-loader
如果是使用 ts-loader,那麼 webpack 會將源代碼先交給 tsc 來編譯,然後處理編譯後的代碼。經過 tsc 編譯後,所有的模塊都會變成 cjs,所以 babel 也不會處理,直接交給 webpack 來以 cjs 的方式處理模塊。ts-loader實際上就是調用了tsc命令,所以需要tsconfig.json配置文件
如果是使用的 babel-loader,那麼 webpack 不會調用 tsc,tsconfig.json 也會被忽略掉。而是直接用 babel 去編譯 ts 文件。這個編譯過程相比調用 tsc 會輕量許多,因為 babel 只會簡單的移除所有 ts 相關的代碼,不會做類型檢查。一般在這種情況下,一個 ts 模塊經過 babel 的 @babel/preset-env 和 @babel/preset-typescript 兩個 preset 處理。後者做的事情很簡單,僅僅去掉所有 ts 相關的代碼,不會處理模塊,而前者會將 esm 轉成 cjs。babel7開始支持編譯ts,這樣一來,tsc的存在就被弱化了。 webpack 的 babel-loader實際上就是調用了babel命令,需要babel.config.js配置文件
然而 webpack 的 babel-loader 在調用 babel.transform 時,傳了這樣一個 caller 選項:
從而導致 babel 保留了 esm 的 import export
tsc、babel可以將esm編譯成cjs,但是cjs只有在node環境下才能運行,而 webpack 自己擁有一套模塊機制,用來處理 cjs esm AMD UMD 等各種各樣的模塊,並且為模塊提供runtime。因此,需要在瀏覽器運行的代碼最終還需要webpack進行模塊化處理
對於 cjs 引用 esm,webpack 的編譯機制比較特別:
// 代碼經過簡化
// 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.js 的 module.exports
由於 webpack 為模塊提供了一個 runtime,所以 webpack 處理模塊對於 webpack 自己而言很自由,在模塊閉包裏注入代表 module require exports 的變量就可以了
總結:
目前很多常用的包是基於 cjs / UMD 開發的,而寫前端代碼一般是寫 esm,所以常見的場景是 esm 導入 cjs 的庫。但是由於 esm 和 cjs 存在概念上的差異,最大的差異點在於 esm 有 default 的概念而 cjs 沒有,所以在 default 上會出問題。
TS babel webpack 都有自己的一套處理機制來處理這個兼容問題,核心思想基本都是通過 default 屬性的增添和讀取
參考
esModuleInterop 到底做了什麼?