問題引入
很多 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
),比如react
cjs
有著非常成熟、流行、使用率高的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-loader
babel-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
屬性的增添和讀取