tsconfig.json的esModuleInterop使用場景是怎樣的?

趁你還年輕發表於2022-07-09
  • 問題場景
  • npm包改造前,僅支援esm
  • npm包改造後,既支援esm,又支援cjs
  • 為什麼改造後,還是會報錯?
  • 如何理解ts編譯配置esModuleInterop?
  • 總結

問題場景

遇到一個很有趣的場景,cjs中需要引入原先打包方式為esm方式的模組。

也就是想要通過require(),去引入一個export的模組。

my-npm-package包的暴露方式為:


import foo from "./foo";
import bar from './bar';
export { foo, bar };

支援的方式為

import {foo, bar} from 'my-npm-package';

cjs中想要使用esm方式的包

const { foo } = require("my-npm-package");

會報錯:SyntaxError: Cannot use import statement outside a module

那麼如何使得原先僅支援esm方式的包,改造為既支援esm又支援cjs呢?
打包方式commonjs。
這隻支援了cjs,esm怎麼支援呢?
支援esm是通過引入包的專案的babel進行轉化進行支援的。

npm包改造前,僅支援esm

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "esnext",
  }
}

打包結果:

import foo from "./foo";
import bar from './bar';
export { foo, bar };
//# sourceMappingURL=index.js.map

npm包改造後,既支援esm,又支援cjs

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "commonjs"
  }
}

打包結果:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.bar = exports.foo = void 0;
const foo_1 = require("./foo");
exports.foo = foo_1.default;
const bar_1 = require("./bar");
exports.bar = bar_1.default;
//# sourceMappingURL=index.js.map

cjs: exports.xxx
esm: Object.defineProperty(exports, "__esModule", { value: true });

可以“csj引入原先方式為esm包”的原因是什麼?

exports.xxx

原先esm方式的包,還可以正常使用的原因是什麼?

Object.defineProperty(exports, "__esModule", { value: true });

那就是“__esModule”,webpack會根據__esModule,將模組識別為esm,最後通過babel轉化為cjs模組方式引入。

回到我們的場景:改造esm模組為既支援cjs,又支援esm,能實現的原因是什麼?

第一步:target從esm改為commonjs,從而支援cjs
第二步:這一步其實不用做,主專案的babel已經做了配置,對於所有esm和cjs的包,都可以通過esm方式引入。

為什麼改造後,還是會報錯?

先說結論:因為tsc cjs方式打包,預設會把import a from 'a', a.method()的包,轉化為const a_1 = require('a'), a_1.default.method()。而有些npm包,沒有exports.default。
如何解決:開啟esModuleInterop。

TypeError: Cannot read properties of undefined (reading 'stringify')

這是因為,在我們的npm包中,有使用到query-string這個依賴。

import queryString from 'query-string';
const query_string_1 = require("query-string");
query_string_1.default.stringify(body) // 這裡發生了報錯

經過tsc打包後,會轉換為為query_string_1.default。

但是query-string@7.1.1的index.js,並沒有暴露default。

轉換後

const query_string_1 = exports;
// query-string@7.1.1
exports.parseUrl
exports.stringifyUrl 
exports.pick
exports.exclude
exports.stringify
exports.extract
exports.parse

那麼如何解決這個問題呢?開啟tsconfig.json中的esModuleInterop為true。
從而將exports作為default返回。

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "commonjs",
    "esModuleInterop": true
  }
}

打包結果:

// index.js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.bar = exports.foo = void 0;
const foo_1 = __importDefault(require("./foo"));
exports.foo = foo_1.default;
const bar_1 = __importDefault(require("./bar"));
exports.bar = bar_1.default;
//# sourceMappingURL=index.js.map

不僅僅是index.js會注入__importDefault ,所有經過tsc編譯的ts檔案,都會注入__importDefault。

// foo.js
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
const query_string_1 = __importDefault(require("query-string"));

經過__importDefault 轉換後,變為

const query_string_1 = __importDefault( exports );

轉換後

const query_string_1 = { default: exports };
query_string_1.default.stringify(body) // 這裡就沒問題了。

如何理解ts編譯配置esModuleInterop?

除了預設引入缺少default的情況,按照namespace方式引入的情況,也需要配置esModuleInterop去相容。

先來看看ts官方文件:https://www.typescriptlang.or...

預設情況下,esModuleInterop關閉,ts按照CommonJS/AMD/UMD模組處理為es6模組一樣去處理。有兩種情況下不能這樣去處理:

  • ❌ import * as moment from "moment" 當做const moment = require("moment")
  • ❌import moment from "moment"當做const moment = require("moment").default

開啟後可以避免這2個問題:

import * as fs from "fs";
import _ from "lodash";
fs.readFileSync("file.txt", "utf8");
_.chunk(["a", "b", "c", "d"], 2);

禁用時(直接require):

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const fs = require("fs");
const lodash_1 = require("lodash");
fs.readFileSync("file.txt", "utf8");
lodash_1.default.chunk(["a", "b", "c", "d"], 2);

開啟時(輔助匯入函式__importStar, __importDefault):

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(require("fs"));
const lodash_1 = __importDefault(require("lodash"));
fs.readFileSync("file.txt", "utf8");
lodash_1.default.chunk(["a", "b", "c", "d"], 2);

再來看一下知乎上一位前端同學的文章:https://zhuanlan.zhihu.com/p/...

esm引入cjs可以interop(互操作)的核心思想是:esm有default,而cjs沒有,為cjs模組增加default。

引用一段作者的話,很精簡:

目前很多常用的包是基於 cjs / UMD 開發的,而寫前端程式碼一般是寫 esm,所以常見的場景是 esm 匯入 cjs 的庫。但是由於 esm 和 cjs 存在概念上的差異,最大的差異點在於 esm 有 default 的概念而 cjs 沒有,所以在 default 上會出問題。TS babel webpack 都有自己的一套處理機制來處理這個相容問題,核心思想基本都是通過 default 屬性的增添和讀取

總結

1.如何將esm模組打包為cjs?

module改為commonjs。

2.為什麼esm可以通過import引用cjs的包?

babel會把import轉為require。

3.如何理解esModuleInterop?

相容只有umd,cjs方式且沒有暴露deault屬性的包,新增default屬性,從而使得import a from "a"或者import * as a from "a"引入的包,不會報沒有default屬性。例如query-string@7.1.1這樣的包。
保險起見,建議開啟這個配置。

4.為什麼module為esnext時不會報錯?

因為module為esnext時,程式碼直接就是esModule模式,也就是import, default模式,不會被轉為cjs並帶一個尾綴default的方式。

可以說,怎麼寫的,打包出來就是原模原樣的。

import webcVCS from "./webcVCS";
import generateAssets from './generateAssets';
export { webcVCS, generateAssets, };
import queryString from 'query-string';

5.以後打包,module怎麼配置?

  • esnext: 只在esm環境使用的包
  • commonjs:純cjs或既在cjs又在esm環境使用的包(esm環境使用一般是由安裝包的專案,結合webpack,babel等打包工具支援的)
  • umd: 同commonjs,且需要同時支援cjs,amd, cmd

相關文章