私は 2 年前にモジュール システムを紹介する記事を書きました: フロントエンド モジュールの概念を理解する: CommonJs と ES6Module。この記事の知識は初心者向けであり、比較的簡単です。また、記事内のいくつかの間違いも修正したいと思います。
モジュール システムに関する基本的な知識は前の記事でほぼカバーされているため、この記事では モジュール システムの内部原理 とより完全な紹介に焦点を当てます異なるモジュール システムの違い、前の記事に登場した内容はここでは繰り返しません。
すべてのプログラミング言語にモジュール システムが組み込まれているわけではなく、JavaScript が誕生してから長い間モジュール システムは存在しませんでした。
ブラウザ環境では、未使用のコードファイルを導入するには <script></script>
タグしか使用できず、スコープがグローバルに共有されるため問題が多いと言えます。さらに、フロントエンドは開発のたびに変化しており、この方法は現在のニーズを満たせなくなりました。公式モジュール システムが登場する前に、フロントエンド コミュニティは独自のサードパーティ モジュール システムを作成しました。最も一般的に使用されるものは、非同期モジュール定義 AMD、ユニバーサル モジュール定義 UMD、もちろん、最も人気のあるものは次のとおりです。最も有名なものは CommonJS です。
Node.js は JavaScript 実行環境であるため、基盤となるファイル システムに直接アクセスできます。そこで開発者はそれを採用し、CommonJS 仕様に従ってモジュール システムを実装しました。
当初、CommonJS は Node.js プラットフォーム上でのみ使用できましたが、Browserify や Webpack などのモジュール パッケージ化ツールの登場により、ついにブラウザ側で CommonJS を実行できるようになりました。
2015 年に ECMAScript6 仕様がリリースされるまで、モジュール システムの正式な標準は存在しませんでした。この標準に従って構築されたモジュール システムは、ECMAScript モジュールと呼ばれます。 [ESM]、つまりESMはNode.js環境とブラウザ環境を統合し始めました。もちろん、ECMAScript6 は構文とセマンティクスを提供するだけで、実装に関してはブラウザ サービス ベンダーと Node 開発者の努力次第です。他のプログラミング言語の羨望の的である babel アーティファクトがあるのはそのためです。モジュール システムの実装は簡単な作業ではありません。Node.js もバージョン 13.2 で比較的安定してサポートされています。 ESM。
しかし、何はともあれ、ESM は JavaScript の「息子」であり、それを学ぶことに何の問題もありません。
焼畑農業の時代には、アプリケーションの開発には JavaScript が使用され、スクリプト ファイルはscript タグを通じて導入されます。より深刻な問題の 1 つは、名前空間メカニズムがないことです。これは、各スクリプトが同じスコープを共有することを意味します。この問題に対するより良い解決策がコミュニティにあります: Revevaling module
const myModule = (() => { const _privateFn = () => {} const _privateAttr = 1 return { publicFn: () => {}, publicAttr: 2 } })() console.log(myModule) console.log(myModule.publicFn, myModule._privateFn)
実行結果は次のとおりです:
Thisモードは非常にシンプルで、IIFE を使用してプライベート スコープを作成し、return を使用して変数を公開します。内部変数 (_privateFn、_privateAttr など) にはスコープ外からアクセスできません。
[公開モジュール] は、これらの機能を使用して個人情報を隠し、外部に公開する必要がある API をエクスポートします。その後のモジュールシステムもこの考え方に基づいて開発されています。
上記の考え方に基づいて、モジュールローダーを開発します。
最初にモジュールのコンテンツをロードする関数を作成し、この関数をプライベート スコープでラップし、それから eval() を通じて評価して関数を実行します。
function loadModule (filename, module, require) { const wrappedSrc = `(function (module, exports, require) { ${fs.readFileSync(filename, 'utf8)} }(module, module.exports, require)` eval(wrappedSrc) }
と [モジュールを明らかにする] 同じ、モジュールのソース コードは関数内にラップされていますが、一連の変数 (module、module.exports、require) も関数に渡される点が異なります。
モジュールのコンテンツは [readFileSync] を通じて読み取られることに注意してください。一般に、ファイル システムに関係する API を呼び出す場合は、同期バージョンを使用しないでください。ただし、今回は異なります。CommonJs システム自体を介したモジュールのロードは、複数のモジュールを正しい依存関係の順序で導入できるように、同期操作として実装する必要があるからです。
次に、require() 関数をシミュレートします。主な関数はモジュールをロードすることです。
function require(moduleName) { const id = require.resolve(moduleName) if (require.cache[id]) { return require.cache[id].exports } // 模块的元数据 const module = { exports: {}, id } // 更新缓存 require.cache[id] = module // 载入模块 loadModule(id, module, require) // 返回导出的变量 return module.exports } require.cache = {} require.resolve = (moduleName) => { // 根据moduleName解析出完整的模块id }
(1)函数接收到moduleName后,首先解析出模块的完整路径,赋值给id。
(2)如果cache[id]
为true,说明该模块已经被加载过了,直接返回缓存结果
(3)否则,就配置一套环境,用于首次加载。具体来说,创建module对象,包含exports(也就是导出内容),id(作用如上)
(4)将首次加载的module缓存起来
(5)通过loadModule从模块的源文件中读取源代码
(6)最后return module.exports
返回想要导出的内容。
在模拟require函数的时候,有一个很重要的细节:require函数必须是同步的。它的作用仅仅是直接将模块内容返回而已,并没有用到回调机制。Node.js中的require也是如此。所以针对module.exports的赋值操作,也必须是同步的,如果用异步就会出问题:
// 出问题 setTimeout(() => { module.exports = function () {} }, 1000)
require是同步函数这一点对定义模块的方式有着非常重要的影响,因为它迫使我们在定义模块时只能使用同步的代码,以至于Node.js都为此,提供了大多数异步API的同步版本。
早期的Node.js有异步版本的require函数,但很快就移除了,因为这会让函数的功能变得十分复杂。
ESM是ECMAScript2015规范的一部分,该规范给JavaScript语言指定了一套官方的模块系统,以适应各种执行环境。
Node.js默认会把.js后缀的文件,都当成是采用CommonJS语法所写的。如果直接在.js文件中采用ESM语法,解释器会报错。
有三种方法可以在让Node.js解释器转为ESM语法:
1、把文件后缀名改为.mjs;
2、给最近的package.json文件添加type字段,值为“module”;
3、字符串作为参数传入--eval
,或通过STDIN管道传输到node,带有标志--input-type=module
比如:
node --input-type=module --eval "import { sep } from 'node:path'; console.log(sep);"
ESM可以被解析并缓存为URL(这也意味着特殊字符必须是百分比编码)。支持file:
、node:
和data:
等的URL协议
file:URL
如果用于解析模块的import说明符具有不同的查询或片段,则会多次加载模块
// 被认为是两个不同的模块 import './foo.mjs?query=1'; import './foo.mjs?query=2';
data:URL
支持使用MIME类型导入:
text/javascript
用于ES模块application/json
用于JSONapplication/wasm
用于Wasmimport 'data:text/javascript,console.log("hello!");'; import _ from 'data:application/json,"world!"' assert { type: 'json' };
data:URL
仅解析内置模块的裸说明符和绝对说明符。解析相对说明符不起作用,因为data:
不是特殊协议,没有相对解析的概念。
导入断言
这个属性为模块导入语句添加了内联语法,以便在模块说明符旁边传入更多信息。
import fooData from './foo.json' assert { type: 'json' }; const { default: barData } = await import('./bar.json', { assert: { type: 'json' } });
目前只支持JSON模块,而且assert { type: 'json' }
语法是具有强制性的。
导入Wash模块
在--experimental-wasm-modules
标志下支持导入WebAssembly模块,允许将任何.wasm文件作为普通模块导入,同时也支持它们的模块导入。
// index.mjs import * as M from './module.wasm'; console.log(M)
使用如下命令执行:
node --experimental-wasm-modules index.mjs
await关键字可以用在ESM中的顶层。
// a.mjs export const five = await Promise.resolve(5) // b.mjs import { five } from './a.mjs' console.log(five) // 5
前面说过,import语句对模块依赖的解决是静态的,因此有两项著名的限制:
然而,对于某些情况来说,这两项限制无疑是过于严格。就比如说有一个还算是比较常见的需求:延迟加载:
在遇到一个体积很大的模块时,只想在真正需要用到模块里的某个功能时,再去加载这个庞大的模块。
为此,ESM提供了异步引入机制。这种引入操作,可以在程序运行的时候,通过import()
运算符实现。从语法上看,相当于一个函数,接收模块标识符作为参数,并返回一个Promise,待Promise resolve后就能得到解析后的模块对象。
用一个循环依赖的例子来说明ESM的加载过程:
// index.js import * as foo from './foo.js'; import * as bar from './bar.js'; console.log(foo); console.log(bar); // foo.js import * as Bar from './bar.js' export let loaded = false; export const bar = Bar; loaded = true; // bar.js import * as Foo from './foo.js'; export let loaded = false; export const foo = Foo; loaded = true
先看看运行结果:
ロードを通じて、モジュール foo と bar の両方が、ロードされた完全なモジュール情報をログに記録できることがわかります。しかし、CommonJS は異なり、完全にロードされた後、どのように表示されるかを出力できないモジュールが存在する必要があります。
読み込みプロセスを詳しく見て、なぜそのような結果が発生するのかを見てみましょう。
読み込みプロセスは 3 つのフェーズに分けることができます。
解析フェーズ:
インタープリタはエントリ ファイル (つまり、index.js) から開始し、モジュール間の依存関係を解析し、それらを図 、このグラフは依存関係グラフとも呼ばれます。
この段階では、import ステートメントのみに注目し、これらのステートメントが導入するモジュールに対応するソース コードをロードします。そして、詳細な分析を通じて最終的な依存関係グラフを取得します。上の例を例に挙げて説明します:
1.index.js から開始して、import * as foo from './foo.js'
ステートメントを見つけて、foo.js ファイルに移動します。
2. foo.js ファイルから解析を続けて、import * as Bar from './bar.js'
ステートメントを見つけて、bar.js に移動します。
3. bar.js から解析を続けると、import * as Foo from './foo.js'
ステートメントが循環依存関係を形成していることがわかりますが、インタープリターはすでに foo.js を処理しているためです。したがって、再度モジュールに入ることはなく、bar モジュールの解析が続行されます。
4. bar モジュールを解析した後、import ステートメントがないことが判明したため、foo.js に戻って解析を続けます。 import 文は最後まで見つからず、index.js が返されました。
5.index.js に import * as bar from './bar.js'
が見つかりましたが、bar.js はすでに解析されているため、スキップして実行を続行します。
最後に、依存関係グラフは深さ優先法によって完全に表示されます:
宣言フェーズ:
インタープリタから開始 取得した依存関係グラフを起点として、下から上の順に各モジュールを宣言します。具体的には、モジュールに到達するたびに、モジュールによってエクスポートされるすべてのプロパティが検索され、エクスポートされた値の識別子がメモリ内で宣言されます。この段階では宣言のみが行われ、代入操作は実行されないことに注意してください。
1. インタプリタは bar.js モジュールから開始され、loaded と foo の識別子を宣言します。
2. foo.js モジュールまでトレースバックし、ロードされた識別子と bar 識別子を宣言します。
3.index.js モジュールに到達しましたが、このモジュールにはエクスポート ステートメントがないため、識別子は宣言されていません。
#すべてのエクスポート識別子を宣言した後、依存関係グラフをもう一度調べて、インポートとエクスポートの間の関係を結び付けます。
import で導入されたモジュールと、export でエクスポートされた値の間に const 的な結合関係が確立されていることがわかります。書く。さらに、index.js で読み取られる bar モジュールと foo.js で読み込まれる bar モジュールは本質的に同じインスタンスです。
これが、完全な解析結果がこの例の結果に出力される理由です。
これは、CommonJS システムで使用される方法とは根本的に異なります。モジュールが CommonJS モジュールをインポートする場合、システムは後者のエクスポート オブジェクト全体をコピーし、その内容を現在のモジュールにコピーします。この場合、インポートされたモジュールが独自のコピー変数を変更すると、ユーザーは新しい値を確認できません。
実行フェーズ:
このフェーズでは、エンジンはモジュール コードを実行します。依存関係グラフは依然としてボトムアップ順序でアクセスされ、アクセスされたファイルは 1 つずつ実行されます。実行は bar.js ファイルから始まり、foo.js、最後にindex.js まで行われます。このプロセスでは、エクスポート テーブル内の識別子の値が徐々に改善されます。
このプロセスは CommonJS とあまり変わらないように見えますが、実際には大きな違いがあります。 CommonJS は動的であるため、関連ファイルの実行中に依存関係グラフを解析します。そのため、require ステートメントが表示されている限り、プログラムがこのステートメントに到達した時点で、以前のすべてのコードが実行されていることがわかります。したがって、require ステートメントは必ずしもファイルの先頭に出現する必要はなく、どこにでも出現することができ、モジュール識別子は変数から構築することもできます。
しかし、ESM は異なります。ESM では、上記の 3 つの段階は互いに分離されています。コードを実行する前に、まず依存関係グラフを完全に構築する必要があります。そのため、モジュールの導入とモジュールのエクスポートの操作は、これらは静的である必要があり、コードが実行されるまで待つことはできません。
上記のいくつかの違いに加えて、注目に値するいくつかの違いがあります:
在ESM中使用import关键字解析相对或绝对的说明符时,必须提供文件扩展名,还必须完全指定目录索引('./path/index.js')。而CommonJS的require函数则允许省略这个扩展名。
ESM是默认运行于严格模式之下,而且该严格模式是不能禁用。所以不能使用未声明的变量,也不能使用那些仅仅在非严格模式下才能使用的特性(例如with)。
CommonJS中提供了一些全局变量,这些变量不能在ESM下使用,如果试图使用这些变量会导致ReferenceError错误。包括
require
exports
module.exports
__filename
__dirname
其中__filename
指的是当前这个模块文件的绝对路径,__dirname
则是该文件所在文件夹的绝对路径。这连个变量在构建当前文件的相对路径时很有帮助,所以ESM提供了一些方法去实现两个变量的功能。
在ESM中,可以使用import.meta
对象来获取一个引用,这个引用指的是当前文件的URL。具体来说,就是通过import.meta.url
来获取当前模块的文件路径,这个路径的格式类似file:///path/to/current_module.js
。根据这条路径,构造出__filename
和__dirname
所表达的绝对路径:
import { fileURLToPath } from 'url' import { dirname } from 'path' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename)
而且还能模拟CommonJS中require()函数
import { createRequire } from 'module' const require = createRequire(import.meta.url)
在ESM的全局作用域中,this是未定义(undefined),但是在CommonJS模块系统中,它是一个指向exports的引用:
// ESM console.log(this) // undefined // CommonJS console.log(this === exports) // true
上面提到过在ESM中可以模拟CommonJS的require()函数,以此来加载CommonJS的模块。除此之外,还可以使用标准的import语法引入CommonJS模块,不过这种引入方式只能把默认导出的东西给引进来:
import packageMain from 'commonjs-package' // 完全可以 import { method } from 'commonjs-package' // 出错
而CommonJS模块的require总是将它引用的文件视为CommonJS。不支持使用require加载ES模块,因为ES模块具有异步执行。但可以使用import()
从CommonJS模块中加载ES模块。
虽然ESM已经推出了7年,node.js也已经稳定支持了,我们开发组件库的时候可以只支持ESM。但为了兼容旧项目,对CommonJS的支持也是必不可少的。有两种广泛使用的方法可以使得组件库同时支持两个模块系统的导出。
在CommonJS中编写包或将ES模块源代码转换为CommonJS,并创建定义命名导出的ES模块封装文件。使用条件导出,import使用ES模块封装器,require使用CommonJS入口点。举个例子,example模块中
// package.json { "type": "module", "exports": { "import": "./wrapper.mjs", "require": "./index.cjs" } }
使用显示扩展名.cjs
和.mjs
,因为只用.js
的话,要么是被默认为CommonJS,要么"type": "module"
会导致这些文件都被视为ES模块。
// ./index.cjs export.name = 'name'; // ./wrapper.mjs import cjsModule from './index.cjs' export const name = cjsModule.name;
在这个例子中:
// 使用ESM引入 import { name } from 'example' // 使用CommonJS引入 const { name } = require('example')
这两种方式引入的name都是相同的单例。
package.json文件可以直接定义单独的CommonJS和ES模块入口点:
// package.json { "type": "module", "exports": { "import": "./index.mjs", "require": "./index.cjs" } }
如果包的CommonJS和ESM版本是等效的,则可以做到这一点,例如因为一个是另一个的转译输出;并且包的状态管理被仔细隔离(或包是无状态的)
状态是一个问题的原因是因为包的CommonJS和ESM版本都可能在应用程序中使用;例如,用户的引用程序代码可以importESM版本,而依赖项require CommonJS版本。如果发生这种情况,包的两个副本将被加载到内存中,因此将出现两个不同的状态。这可能会导致难以解决的错误。
除了编写无状态包(例如,如果JavaScript的Math是一个包,它将是无状态的,因为它的所有方法都是静态的),还有一些方法可以隔离状态,以便在可能加载的CommonJS和ESM之间共享它包的实例:
import Date from 'date'; const someDate = new Date(); // someDate 包含状态;Date 不包含
new关键字不是必需的;包的函数可以返回新的对象,或修改传入的对象,以保持包外部的状态。
// index.cjs const state = require('./state.cjs') module.exports.state = state; // index.mjs import state from './state.cjs' export { state }
即使example在应用程序中通过require和import使用example的每个引用都包含相同的状态;并且任一模块系统修改状态将适用二者皆是。
如果本文对你有帮助,就点个赞支持下吧,你的「赞」是我持续进行创作的动力。
本文引用以下资料:
更多node相关知识,请访问:nodejs 教程!
以上がノード内のモジュールシステムを分析した記事の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。