tsc、babel、webpack對模組匯入匯出的處理

記得要微笑發表於2022-02-11

問題引入

很多 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 到底做了什麼?

相關文章