如何正確地配置入口文件?

第三方庫作者就需要編寫相應的入口文件,來達到 “動態” 引入的目的,同時也方便於打包工具對於無用代碼的剔除,減少代碼體積,本篇文章主要聚焦於前端工程如何正確地配置入口文件。

寫在前面

在 node 中支持兩種模塊方案——CommonJS(cjs) 和 ECMAScript modules (esm)。

隨着 ESModule 的廣泛使用,社區生態也在逐漸轉向 ESModule,ESModule 相比於 require 的運行時執行,可以用來做一些靜態代碼分析如 tree shaking 等來減小代碼體積,但是由於 CommonJS 已有龐大的用戶基礎,對於第三方庫作者來說,不能完全一刀切只用 ESModule,還要兼容 CommonJS 場景的使用,所以最合理的方式就是 “魚和熊掌兼得”,即使用 ESModule 編寫庫代碼,然後通過 TypeScript、Babel 等工具輔助生成對應的 CommonJS 格式的代碼,然後根據開發者的引用方式來動態替換爲指定格式的代碼。

有了兩種版本的代碼,第三方庫作者就需要編寫相應的入口文件,來達到 “動態” 引入的目的(即 import 引用的時候指向 ESModule 的代碼,require 引入則指向 CommonJS 的代碼),同時也方便於打包工具對於無用代碼的剔除,減少代碼體積,本篇文章主要聚焦於如何正確地配置入口文件。

注:本篇文章以 node 規範爲準,對於打包工具額外支持的配置方式會進行額外標註
本文的涉及的示例代碼可以通過 https://github.com/HomyeeKing/test-entry 進行查看、測試

main

package.json 的 main字段是最常見的指定入口文件的形式

{
  "name": "@homy/test-entry",
  "version": "1.0.0",
  "description": "",
  "main": "index.js"
}

當開發者引入@homy/test-entry這個包的時候,可以確定@homy/test-entry 這個 npm 包的入口文件指向的是 index.js

const pkg = require('@homy/test-entry')

但是index.js究竟是 cjs or esm?

一種方式是我們可以通過後綴名來顯示地標註出當前文件是 cjs 還是 esm 格式的:

  1. cjs ---> .cjs

  2. esm ---> .mjs

那麼不同模塊格式的文件如何相互引用呢?解釋規則大致如下

  1. import 了 CJS 格式的文件,module.exports 會等同於 export default, 具名導入會根據靜態分析來兼容,但是一般推薦在 ESM 中使用 defaultExport 格式來引入 CJS 文件

  2. 在 CJS 中,如果想要引入 ESM 文件,因爲 ESM 模塊異步執行的機制,必須使用 Dynamic Import 即import()來引用

// index.cjs
const pkg = require('./index.mjs')  // ❌ Error
const pkg = await import('./index.mjs')  // ✅
// index.mjs
import { someVar } from './index.cjs' //  ⚠️ it dependens 推薦下邊方式引入
import pkg from './index.cjs'  //  ✅

另一種方式是通過 package.json 的 type字段來標識

type

package.json 裏也提供了一個 type 字段 用於標註用什麼格式來執行.js文件,

{
  "name": "@homy/test-entry",
  "version": "1.0.0",
  "description": "",
  "type": "commonjs", // or "module", 默認是 commonjs
  "main": "index.js"
}

如果手動設置type: module, 則將index.js當做 esmodule 處理,否則視爲 CommonJS

type: module ,只有 Node.js >= 14 且使用 import 才能使用,不支持 require 引入

注:關於. js 的詳細解析策略推薦閱讀 https://nodejs.org/api/modules.html#enabling

通過 type 和 main 字段,我們可以指定入口文件以及入口文件是什麼類型,但是指定的只是_一個_入口文件,仍然不能夠滿足我們 “動態” 引入的需求,所以 node 又引入exports這個新的字段作爲main更強大的替代品。

exports

相比較於main字段,exports 可以指定多個入口文件,且優先級高於 main

{
 "name": "@homy/test-entry",
  "main": "index.js",
  "exports":{
    "import":"./index.mjs",
    "require":"./index.cjs",
    "default": "./index.mjs"  // 兜底使用 
  },
}

而且還有效限制了入口文件的範圍,即如果你引入指定入口文件範圍之外的文件,則會報錯

const pkg = require('@homy/test-entry/test.js'); 
// 報錯!Package subpath './test.js' is not defined by "exports"

如果想指定submodule, 我們可以這樣編寫

"exports": {
    "." : "./index.mjs",
    "./mobile": "./mobile.mjs",
    "./pc": "./pc.mjs"
  },
// or 更詳細的配置
"exports": {
    ".":{
         "import":"./index.mjs",
         "require":"./index.cjs",
         "default": "./index.mjs"  
    },
    "./mobile": {
         "import":"./mobile.mjs",
         "require":"./mobile.cjs",
         "default": "./mobile.mjs" 
    }
  },

然後通過如下方式可以訪問到子模塊文件

import pkg from 'pkg/mobile'

另外還有一個imports 字段,主要用於控制 import 的解析路徑,類似於 Import Maps, 不過在 node 中指定的入口需要以#開頭,感興趣的可以閱讀 subpath-imports

對於前端日常開發來說,我們的運行環境主要還是瀏覽器和各種 webview,我們會使用各種打包工具來壓縮、轉譯我們的代碼,除了上面提到的main exports字段,被主流打包工具廣泛支持的還有一個module字段

module

大部分時候 我們也能在第三方庫中看到 module 這個字段,用來指定 esm 的入口,但是這個提案沒有被 node 採納(使用 exports)但是大多數打包工具比如 webpack、rollup 以及 esbuild 等支持了這一特性,方便進行 tree shaking 等優化策略

另外,TypeScript 已經成爲前端的主流開發方式,同時 TypeScript 也有自己的一套入口解析方式,只不過解析的是類型的入口文件,有效輔助開發者進行類型檢查和代碼提示,來提高我們編碼的效率和準確性,下面我們繼續瞭解下 TypeScript 是怎麼解析類型文件的。

Type Script 的凱瑞小入口文件

TypeScript 有着對 Node 的原生支持,所以會先檢查main字段,然後找對應文件是否存在類型聲明文件,比如 main 指向的是lib/index.js, TypeScript 就會查找有沒有lib/index.d.ts文件。

另外一種方式,開發者可以在 package.json 中通過types字段來指定類型文件,exports中同理。

{
  "name": "my-package",
    "type": "module",
    "exports": {
        ".": {
            // Entry-point for TypeScript resolution - must occur first!
            "types": "./types/index.d.ts",
            // Entry-point for `import "my-package"` in ESM
            "import": "./esm/index.js",
            // Entry-point for `require("my-package") in CJS
            "require": "./commonjs/index.cjs",
        },
    },
    // CJS fall-back for older versions of Node.js
    "main": "./commonjs/index.cjs",
    // Fall-back for older versions of TypeScript
    "types": "./types/index.d.ts"
}

TypeScript 模塊解析策略

tsconfig.json 包含一個moduleResolution字段,支持 classic(默認)和 node 兩種解析策略,主要針對相對路徑引入和非相對路徑引入兩種方式,我們可以通過示例來理解下

classic

查找以.ts 或.d.ts結尾的文件

//  /root/src/folder/A.ts
import { b } from "./moduleB"
// process:
/root/src/folder/moduleB.ts
/root/src/folder/moduleB.d.ts

相對路徑會找當前目錄下的. ts 或. d.ts 的文件

//  /root/src/folder/A.ts
import { b } from "moduleB"
// process:
/root/src/folder/moduleB.ts
/root/src/folder/moduleB.d.ts
/root/src/moduleB.ts
/root/src/moduleB.d.ts
/root/moduleB.ts
/root/moduleB.d.ts
/moduleB.ts

則會向上查找,直到找到 moduleB 相關的. ts 或. d.ts 文件

node

以類似於 node 的解析策略來查找,但是相應的查找的範圍是以.ts .tsx .d.ts爲後綴的文件,而且會讀取 package.json 中對應的types(或typings)字段

/root/src/moduleA
const pkg = require('./moduleB')
// process:
/root/src/moduleB.js
/root/src/package.json (查找/root/src下有無package.json 如果指定了main字段 則指向main字段對應的文件)
/root/src/moduleB/index.js

在 node 環境下,會依次解析.js 當前 package.json 中main字段指向的文件以及是否存在對應的index.js文件。

TypeScript 解析的時候則是把後綴名替換成 ts 專屬的後綴.ts .tsx .d.ts,而且 ts 這時候會讀取types字段 而非 main

/root/src/moduleB.ts
/root/src/moduleB.tsx
/root/src/moduleB.d.ts
/root/src/moduleB/package.json (if it specifies a types property)
/root/src/moduleB/index.ts
/root/src/moduleB/index.tsx
/root/src/moduleB/index.d.ts

no-relative 就直接查看指定node_modules下有沒有對應文件

/root/src/moduleA
const pkg = require('moduleB')
// process:
/root/src/node_modules/moduleB.js
/root/src/node_modules/package.json 
/root/src/node_modules/moduleB/index.js
/root/node_modules/moduleB.js
/root/node_modules/moduleB/package.json (if it specifies a "main" property)
/root/node_modules/moduleB/index.js
/node_modules/moduleB.js
/node_modules/moduleB/package.json (if it specifies a "main" property)
/node_modules/moduleB/index.js

類似的 TypeScript 也會替換對應後綴名,而且多了@types下類型的查找

/root/src/node_modules/moduleB.ts
/root/src/node_modules/moduleB.tsx
/root/src/node_modules/moduleB.d.ts
/root/src/node_modules/moduleB/package.json (if it specifies a types property)
/root/src/node_modules/@types/moduleB.d.ts     <----- check out @types
/root/src/node_modules/moduleB/index.ts
/root/src/node_modules/moduleB/index.tsx
/root/src/node_modules/moduleB/index.d.ts
....

另外 TypeScript 支持版本選擇來映射不同的文件,感興趣的可以閱讀 version-selection-with-typesversions(地址:https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#version-selection-with-typesversions)

總結

  1. node 中可以通過main 和 type: module | commonjs 來指定入口文件及其模塊類型, exports 則是更強大的替代品,擁有更靈活的配置方式

  2. 主流打包工具如 webpack rollup esbuild 則在此基礎上增加了對 top-level module的支持

  3. TypeScript 則會先查看 package.json 中有沒有types字段,否則查看 main 字段指定的文件有沒有對應的類型聲明文件

參考

  1. https://webpack.js.org/guides/package-exports/

  2. https://nodejs.org/api/packages.html#packages_package_entry_points

  3. https://esbuild.github.io/api/#main-fields

  4. https://www.typescriptlang.org/docs/handbook/module-resolution.html#relative-vs-non-relative-module-imports

  5. https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#version-selection-with-typesversions

  6. https://www.typescriptlang.org/docs/handbook/esm-node.html#type-in-packagejson-and-new-extensions

團隊介紹

我們是大淘寶技術行業與商家技術前端團隊,主要服務的業務包括電商運營工作臺,商家千牛平臺,服務市場以及淘系的垂直行業。團隊致力於通過技術創新建設阿里運營、商家商業操作系統,打通新品的全週期運營,促進行業垂直化運營能力的升級。

作者 | 王宏業(莽原)

編輯 | 橙子君

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DUC7YhEw5S83qcfXoNJOrw