>  기사  >  웹 프론트엔드  >  이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.

이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.

青灯夜游
青灯夜游앞으로
2023-01-14 19:09:482545검색

이 글은 노드를 쉽고 간단하게 배우고 노드의 모듈 시스템을 이해하는 데 도움이 되기를 바랍니다!

이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.

저는 2년 전에 모듈 시스템을 소개하는 글을 썼습니다: 프론트엔드 모듈 개념 이해하기: CommonJs 및 ES6Module. 이 기사의 지식은 초보자를 대상으로 하며 비교적 간단합니다. 또한 기사의 몇 가지 오류를 수정하고 싶습니다.

  • [모듈]과 [모듈 시스템]은 서로 다른 두 가지입니다. 모듈은 소프트웨어의 단위이고, 모듈 시스템은 개발자가 프로젝트에서 모듈을 정의하고 사용할 수 있는 구문 또는 도구 집합입니다.
  • ECMAScript Module의 약자는 ES6Module이 아닌 ESM, 즉 ESModule입니다.

모듈 시스템에 대한 기본 지식은 이전 기사에서 거의 다루었으므로 이번 기사에서는 모듈 시스템의 내부 원리에 초점을 맞추고 더 완전한 소개 다른 모듈 시스템 간의 차이점에 대해 설명합니다. 한 기사에 나오는 내용은 여기서 반복되지 않습니다. [관련 튜토리얼 추천 : nodejs 영상 튜토리얼, 프로그래밍 교육]

모듈 시스템

모든 프로그래밍 언어에 모듈 시스템이 내장되어 있지는 않지만 탄생 이후 오랫동안 모듈 시스템이 없었습니다. 자바스크립트의.

브라우저 환경에서는 <script> 태그만 사용하여 사용하지 않는 코드 파일을 도입할 수 있습니다. 이 방법은 전역 범위를 공유하며 이는 프런트 엔드의 급속한 발전과 결합되어 문제가 많다고 할 수 있습니다. 더 이상 현재의 요구 사항을 충족합니다. 공식 모듈 시스템이 등장하기 전에 프런트 엔드 커뮤니티는 자체 타사 모듈 시스템을 만들었습니다. 가장 일반적으로 사용되는 모듈 시스템은 비동기 모듈 정의 AMD, 범용 모듈 정의 UMD 등입니다. 물론 가장 유명한 모듈 시스템은 다음과 같습니다. 하나는 CommonJS입니다.

Node.js는 JavaScript 실행 환경이므로 기본 파일 시스템에 직접 액세스할 수 있습니다. 그래서 개발자들은 이를 채택하고 CommonJS 사양에 따른 모듈 시스템을 구현했습니다.

처음에는 CommonJS를 Node.js 플랫폼에서만 사용할 수 있었지만 Browserify 및 Webpack과 같은 모듈 패키징 도구가 등장하면서 CommonJS는 마침내 브라우저 측에서 실행될 수 있게 되었습니다.

모듈 시스템에 대한 공식적인 표준은 2015년 ECMAScript6 사양이 출시되기 전까지는 이 표준에 따라 구축된 모듈 시스템을 ECMAScript 모듈이라고 불렀으며, 이후 ESM으로 약칭되었습니다. Node.js 환경과 브라우저 환경을 통합하기 시작했습니다. 물론 ECMAScript6은 구문과 의미만 제공하며, 구현에 있어서는 브라우저 서비스 공급업체와 Node 개발자의 몫입니다. 그렇기 때문에 다른 프로그래밍 언어가 부러워하는 babel 아티팩트가 있습니다. 모듈 시스템을 구현하는 것은 쉬운 작업이 아닙니다. Node.js는 버전 13.2에서만 ESM을 지원하는 데 비교적 안정적입니다.

하지만 어쨌든 ESM은 JavaScript의 "아들"이므로 배우는 데 아무런 문제가 없습니다!

모듈 시스템의 기본 아이디어

화전 시대에는 JavaScript를 사용하여 애플리케이션을 개발했는데, 스크립트 파일은 스크립트 태그를 통해서만 도입할 수 있었습니다. 직면하게 되는 더 심각한 문제 중 하나는 네임스페이스 메커니즘이 부족하다는 것입니다. 이는 각 스크립트가 동일한 범위를 공유한다는 것을 의미합니다. 커뮤니티에는 이 문제에 대한 더 나은 해결책이 있습니다. Revevaling module

const myModule = (() => {
    const _privateFn = () => {}
    const _privateAttr = 1
    return {
        publicFn: () => {},
        publicAttr: 2
    }
})()

console.log(myModule)
console.log(myModule.publicFn, myModule._privateFn)

실행 결과는 다음과 같습니다.

이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.

이 모드는 IIFE를 사용하여 개인 범위를 만들고 return을 사용합니다. 변수를 노출시킵니다. 내부 변수(예: _privateFn, _privateAttr)는 외부 범위에서 액세스할 수 없습니다.

[공개 모듈]은 이러한 기능을 활용하여 개인 정보를 숨기고 외부에 노출되어야 하는 API를 내보냅니다. 후속 모듈 시스템도 이 아이디어를 바탕으로 개발되었습니다.

CommonJS

위 아이디어를 바탕으로 모듈 로더를 개발해 보세요.

먼저 모듈의 내용을 로드하는 함수를 작성하고, 이 함수를 비공개 범위로 래핑한 다음 eval()을 통해 평가하여 함수를 실행합니다.

function loadModule (filename, module, require) {
  const wrappedSrc = 
    `(function (module, exports, require) {
      ${fs.readFileSync(filename, 'utf8)}
    }(module, module.exports, require)`
  eval(wrappedSrc)
}

[공개 모듈]과 동일, 소스 코드를 넣습니다. 모듈은 함수로 래핑되지만 차이점은 일련의 변수(모듈, 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函数的时候,有一个很重要的细节:require函数必须是同步的。它的作用仅仅是直接将模块内容返回而已,并没有用到回调机制。Node.js中的require也是如此。所以针对module.exports的赋值操作,也必须是同步的,如果用异步就会出问题:

// 出问题
setTimeout(() => {
    module.exports = function () {}
}, 1000)

require是同步函数这一点对定义模块的方式有着非常重要的影响,因为它迫使我们在定义模块时只能使用同步的代码,以至于Node.js都为此,提供了大多数异步API的同步版本。

早期的Node.js有异步版本的require函数,但很快就移除了,因为这会让函数的功能变得十分复杂。

ESM

ESM是ECMAScript2015规范的一部分,该规范给JavaScript语言指定了一套官方的模块系统,以适应各种执行环境。

在Node.js中使用ESM

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用于JSON
  • application/wasm用于Wasm
import '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

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的加载过程

用一个循环依赖的例子来说明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

先看看运行结果:

이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.

loaded를 통해 foo와 bar 모듈 모두 로드된 전체 모듈 정보를 기록할 수 있음을 확인할 수 있습니다. 하지만 CommonJS는 완전히 로드된 후의 모습을 인쇄할 수 없는 모듈이 있어야 합니다.

왜 이런 결과가 나타나는지 로딩 과정을 파헤쳐 보겠습니다.
로드 프로세스는 세 단계로 나눌 수 있습니다.

  • 첫 번째 단계: 구문 분석
  • 두 번째 단계: 선언
  • 세 번째 단계: 실행

파싱 단계:
통역사는 항목 파일에서 시작합니다(즉, index.js)는 모듈 간의 종속성을 분석하여 그래프 형태로 표시합니다. 이 그래프를 종속성 그래프라고도 합니다.

이 단계에서는 import 문에만 집중하고, 이 문에서 소개하려는 모듈에 해당하는 소스 코드를 로드합니다. 그리고 심층 분석을 통해 최종 종속성 그래프를 얻습니다. 위의 예를 들어 설명하겠습니다.
1. index.js에서 시작하여 import * as foo from './foo.js' 문을 찾은 다음 foo.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'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. import * as bar from './bar.js'는 index.js에 있지만 bar.js가 이미 구문 분석되었기 때문에 건너뛰고 계속 실행됩니다.

이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.마지막으로 깊이 우선 방법을 통해 종속성 그래프가 완전히 표시됩니다.



선언 단계:

인터프리터는 획득한 종속성 그래프에서 시작하여 아래에서 위로 순서대로 각 모듈을 선언합니다. 특히, 모듈에 도달할 때마다 모듈에서 내보낼 모든 속성이 검색되고 내보낸 값의 식별자가 메모리에 선언됩니다. 이 단계에서는 선언만 이루어지며 할당 작업은 수행되지 않습니다.

1. 인터프리터는 bar.js 모듈에서 시작하여 로드된 식별자와 foo를 선언합니다.

2. foo.js 모듈까지 추적하고 로드된 식별자와 bar 식별자를 선언합니다. 이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.3. index.js 모듈에 도착했지만 이 모듈에는 내보내기 문이 없으므로 식별자가 선언되지 않습니다.

이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.모든 내보내기 식별자를 선언한 후 종속성 그래프를 다시 살펴보고 가져오기와 내보내기 간의 관계를 연결하세요.

import로 도입된 모듈과 import로 내보낸 값 사이에 const와 유사한 바인딩 관계가 설정되는 것을 볼 수 있습니다. 게다가 index.js에서 읽은 bar 모듈과 foo.js에서 읽은 bar 모듈은 본질적으로 동일한 인스턴스입니다.

이것이 이 예시의 결과에 완전한 분석 결과가 출력되는 이유입니다.

이것은 CommonJS 시스템에서 사용하는 방법과 근본적으로 다릅니다. 모듈이 CommonJS 모듈을 가져오는 경우 시스템은 후자의 전체 내보내기 개체를 복사하고 해당 내용을 현재 모듈에 복사합니다. 이 경우 가져온 모듈이 자체 복사 변수를 수정하면 사용자는 새 값을 볼 수 없습니다. .

실행 단계:

이 단계에서 엔진은 모듈 코드를 실행합니다. 종속성 그래프는 여전히 상향식으로 액세스되며 액세스된 파일은 하나씩 실행됩니다. 실행은 bar.js 파일에서 시작하여 foo.js로, 마지막으로 index.js로 시작됩니다. 이 과정에서 내보내기 테이블의 식별자 값이 점차 향상됩니다.

이 과정은 CommonJS와 크게 다르지 않은 것 같지만 실제로는 큰 차이점이 있습니다. CommonJS는 동적이기 때문에 관련 파일을 실행하는 동안 종속성 그래프를 구문 분석합니다. 따라서 require 문을 보는 한 프로그램이 이 문에 도달할 때 이전 코드가 모두 실행되었다고 확실히 말할 수 있습니다. 따라서 require 문은 반드시 파일 시작 부분에 나타날 필요는 없지만 어디에나 나타날 수 있으며 모듈 식별자는 변수에서 구성될 수도 있습니다.

하지만 ESM에서는 위의 세 단계가 서로 분리되어 있으므로 코드를 실행하려면 먼저 종속성 그래프를 완전히 구성해야 합니다. 따라서 모듈을 내보내는 작업은 정적이어야 합니다. 코드가 실행될 때까지 기다리는 대신.

ESM과 CommonJS의 차이점

앞서 언급한 몇 가지 차이점 외에도 주목할 만한 몇 가지 차이점이 있습니다.

강제 파일 확장자

🎜🎜import 키워드를 사용하여 ESM 또는 절대 지정자의 상대 구문을 분석합니다. 파일 확장자를 제공해야 하며 디렉터리 인덱스('./path/index.js')를 완전히 지정해야 합니다. CommonJS require 함수를 사용하면 이 확장을 생략할 수 있습니다. 🎜

严格模式

ESM是默认运行于严格模式之下,而且该严格模式是不能禁用。所以不能使用未声明的变量,也不能使用那些仅仅在非严格模式下才能使用的特性(例如with)。

ESM不支持CommonJS提供的某些引用

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)

this指向

在ESM的全局作用域中,this是未定义(undefined),但是在CommonJS模块系统中,它是一个指向exports的引用:

// ESM
console.log(this) // undefined

// CommonJS
console.log(this === exports) // true

ESM加载CommonJS

上面提到过在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的支持也是必不可少的。有两种广泛使用的方法可以使得组件库同时支持两个模块系统的导出。

使用ES模块封装器

在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之间共享它包的实例:

  • 如果可能,在实例化对象中包含所有状态。比如JavaScript的Date,需要实例化包含状态;如果是包,会这样使用:
import Date from 'date';
const someDate = new Date();
// someDate 包含状态;Date 不包含

new关键字不是必需的;包的函数可以返回新的对象,或修改传入的对象,以保持包外部的状态。

  • 在包的CommonJS和ESM版本之间共享的一个或过个CommonJS文件中隔离状态。比如CommonJS和ESM入口点分别是index.cjs和index.mjs:
// 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.js 공식 문서
  • Node.js 디자인 패턴

노드 관련 지식을 더 보려면 nodejs 튜토리얼을 방문하세요!

위 내용은 이 기사에서는 노드의 모듈 시스템을 이해하도록 안내합니다.의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 juejin.cn에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제