본문으로 건너뛰기

Node 모듈 로딩 메커니즘

무료2020-04-12#Node#Node模块寻径#Node Module Resolution#Node模块别名#Node虚拟模块#Node virtual module

require() 시에 무슨 일이 일어나는가? Node.js 내부는 어떻게 구현되어 있는가? 이것을 알아서 무슨 소용이 있는가?

일.require() 시에 무슨 일이 일어나는가?

Node.js 에서, 모듈 로딩 프로세스는 5 단계로 나뉩니다:

  • 경로 해석 (Resolution): 모듈 식별자에 따라 대응하는 모듈 (엔트리) 파일의 절대 경로를 찾음

  • 로딩 (Loading): JSON 또는 JS 파일인 경우, 파일 내용을 메모리에 읽어들임. 내장된 네이티브 모듈인 경우, 해당 공유 라이브러리를 현재 Node.js 프로세스에 동적 링크

  • 래핑 (Wrapping): 파일 내용 (JS 코드) 을 함수로 감싸고, 모듈 스코프를 확립하며, exports, require, module 등을 파라미터로 주입

  • 실행 (Evaluation): 파라미터를 전달하고, 래핑된 함수를 실행

  • 캐싱 (Caching): 함수 실행 완료 후, module 을 캐시하고, module.exportsrequire() 의 반환값으로 반환

그 중에서, 모듈 식별자 (Module Identifiers)require(id) 에 전달되는 첫 번째 문자열 파라미터 id 를 가리키며, 예를 들어 require('./myModule') 중의 './myModule' 으로, 확장자를 지정할 필요가 없습니다 (하지만 붙여도 문제없음)

.../ 로 시작하는 파일 경로의 경우, 파일, 디렉터리로 매칭하려고 시도합니다. 구체적인 프로세스 는 다음과 같습니다:

  1. 경로가 존재하고 파일이면, JS 코드로서 로딩합니다 (파일 확장자가 무엇이든, require(./myModule.abcd) 는 완전히 올바름)

  2. 존재하지 않는 경우, 순서대로 .js.json.node(Node.js 가 지원하는 바이너리 확장) 확장자를 붙여 시도

  3. 경로가 존재하고 디렉터리이면, 해당 디렉터리 하에서 package.json 을 찾고, 그 main 필드를 취득하며, 지정된 모듈을 로딩합니다 (리다이렉트에 상당)

  4. package.json 이 없는 경우, 순서대로 index.jsindex.jsonindex.node 를 시도

모듈 식별자가 파일 경로가 아닌 경우, 먼저 Node.js 내장 모듈 (fspath 등) 인지 확인합니다. 그렇지 않은 경우, 현재 디렉터리에서 시작하여, 순서대로 상위의 각 node_modules 하에서 찾고, 최상위 /node_modules 까지, 및 몇 개의 글로벌 디렉터리까지 찾습니다:

  • NODE_PATH 환경 변수로 지정된 위치

  • 기본 글로벌 디렉터리: $HOME/.node_modules$HOME/.node_libraries$PREFIX/lib/node

P.S. 글로벌 디렉터리에 대한 더 많은 정보는, Loading from the global folders 참조

모듈 파일을 찾은 후, 내용을 읽고, 함수로 1 층 감쌉니다:

(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});

The module wrapper 에서 발췌)

실행 시에 이들 모듈 변수 (exports, require, module, __filename, __dirname) 를 외부에서 주입하고, 모듈이 익스포트하는 것은 module.exports 를 통해 꺼내며, module 오브젝트 전체를 캐시하고, 마지막으로 require() 결과를 반환합니다

순환 의존

特殊的, 모듈 간에 순환 의존이 발생할 수 있습니다. 이에 대해, Node.js 의 처리 전략은 매우 심플합니다:

// module1.js
exports.a = 1;
require('./module2');
exports.b = 2;
exports.c = 3;

// module2.js
const module1 = require('./module1');
console.log('module1 is partially loaded here', module1);

module1.js 실행 중에 module2.js 를 참조하고, module2 가 또 module1 을 참조합니다.此時 module1 은 아직 로딩 완료되지 않았습니다 (exports.b = 2; exports.c = 3; 가 아직 실행되지 않았습니다). Node.js 에서는, 일부만 로딩된 모듈도 정상적으로 참조할 수 있습니다:

When there are circular require() calls, a module might not have finished executing when it is returned.

따라서 module1.js 의 실행 결과는:

module1 is partially loaded here { a: 1 }

P.S. 순환 참조에 대한 더 많은 정보는, Cycles 참조

이.Node.js 내부는 어떻게 구현되어 있는가?

구현상, 모듈 로딩의 대다수 작업은 module 모듈에 의해 완료됩니다:

const Module = require('module');
console.log(Module);

Module 은 함수/클래스입니다:

function Module(id = '', parent) {
  this.id = id;
  this.path = path.dirname(id);
  // 即 module.exports
  this.exports = {};
  this.parent = parent;
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

모듈을 1 개 로딩할 때마다 Module 인스턴스를 1 개 생성하고, 모듈 파일 실행 완료 후, 해당 인스턴스는 여전히 보유되며, 모듈이 익스포트하는 것은 Module 인스턴스에 부속하여 존재합니다

모듈 로딩의 모든 작업은 module 내장 모듈에 의해 완료되며, Module._loadModule.prototype._compile 을 포함합니다

Module._load

Module._load() 는 새로운 모듈의 로딩, 캐시 관리를 담당합니다. 구체적으로는:

Module._load = function(request, parent, isMain) {
  // 0.모듈 경로를 해석
  const filename = Module._resolveFilename(request, parent, isMain);
  // 1.우선적으로 캐시 Module._cache 를 찾음
  const cachedModule = Module._cache[filename];
  // 2.내장 모듈에 매칭하려고 시도
  const mod = loadNativeModule(filename, request, experimentalModules);
  // 3.캐시에 히트하지 않고, 내장 모듈에도 매칭하지 않은 경우, 새로운 Module 인스턴스를 생성
  const module = new Module(filename, parent);
  // 4.새로운 인스턴스를 캐시
  Module._cache[filename] = module;
  // 5.모듈을 로딩
  module.load(filename);
  // 6.로딩/실행에서 오류가 발생한 경우, 캐시를 삭제
  if (threw) {
    delete Module._cache[filename];
  }
  // 7.module.exports 를 반환
  return module.exports;
};

Module.prototype.load = function(filename) {
  // 0.모듈 타입을 판정
  const extension = findLongestRegisteredExtension(filename);
  // 1.타입별로 모듈 내용을 로딩
  Module._extensions[extension](this, filename);
};

지원되는 타입은 .js.json.node 의 3 종류입니다:

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  // 1.JS 파일 내용을 읽음
  const content = fs.readFileSync(filename, 'utf8');
  // 2.래핑, 실행
  module._compile(content, filename);
};

// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
  // 1.JSON 파일 내용을 읽음
  const content = fs.readFileSync(filename, 'utf8');
  // 2.직접 JSON.parse() 로 완료
  module.exports = JSONParse(stripBOM(content));
};

// Native extension for .node
Module._extensions['.node'] = function(module, filename) {
  // 공유 라이브러리를 동적으로 로딩
  return process.dlopen(module, path.toNamespacedPath(filename));
};

P.S. process.dlopen 구체적으로는 process.dlopen(module, filename[, flags]) 참조

Module.prototype._compile

Module.prototype._compile = function(content, filename) {
  // 1.함수로 1 층 감쌈
  const compiledWrapper = wrapSafe(filename, content, this);
  // 2.주입할 파라미터를 준비
  const dirname = path.dirname(filename);
  const require = makeRequireFunction(this, redirects);
  const exports = this.exports;
  const thisValue = exports;
  const module = this;
  // 3.파라미터를 주입, 실행
  compiledWrapper.call(thisValue, exports, require, module, filename, dirname);
};

래핑 부분의 구현은 다음과 같습니다:

function wrapSafe(filename, content, cjsModuleInstance) {
  let compiled = compileFunction(
    content,
    filename,
    0,
    0,
    undefined,
    false,
    undefined,
    [],
    [
      'exports',
      'require',
      'module',
      '__filename',
      '__dirname',
    ]
  );

  return compiled.function;
}

P.S. 모듈 로딩의 완전한 구현은 node/lib/internal/modules/cjs/loader.js 참조

삼.이것을 알아서 무슨 소용이 있는가?

모듈의 로딩 메커니즘을 알면, 확장 로딩 로직을 개변할 필요가 있는 시나리오에서 매우 유용합니다. 예를 들어 가상 모듈, 모듈 앨리어스 등의 구현에 사용할 수 있습니다

가상 모듈

예를 들어, VS Code 플러그인은 require('vscode') 를 통해 플러그인 API 에 액세스합니다:

// The module 'vscode' contains the VS Code extensibility API
import * as vscode from 'vscode';

vscode 모듈은 실제로는 존재하지 않으며, 런타임에서 확장된 가상 모듈입니다:

// ref: src/vs/workbench/api/node/extHost.api.impl.ts
function defineAPI() {
  const node_module = <any>require.__$__nodeRequire('module');
  const original = node_module._load;
  // 1.Module._load 를 하이재킹
  node_module._load = function load(request, parent, isMain) {
    if (request !== 'vscode') {
      return original.apply(this, arguments);
    }

    // 2.가상 모듈 vscode 를 주입
    // get extension id from filename and api for extension
    const ext = extensionPaths.findSubstr(parent.filename);
    let apiImpl = extApiImpl.get(ext.id);
    if (!apiImpl) {
      apiImpl = factory(ext);
      extApiImpl.set(ext.id, apiImpl);
    }
    return apiImpl;
  };
}

구체적으로는 [API 주입 메커니즘 및 플러그인 기동 플로우_VSCode 플러그인 개발 노트 2](/articles/api 주입 메커니즘 및 플러그인 기동 플로우-vscode 플러그인 개발 노트 2/) 참조. 여기서는 자세히 설명하지 않습니다

모듈 앨리어스

마찬가지로, Module._resolveFilename 을 다시 써서 모듈 앨리어스를 구현할 수 있습니다. 예를 들어 proj/src 중의 @lib/my-module 모듈 참조를 proj/lib/my-module 에 매핑:

// src/index.js
require('./patchModule');

const myModule = require('@lib/my-module');
console.log(myModule);

patchModule 의 구체적 구현은 다음과 같습니다:

const Module = require('module');
const path = require('path');

const _resolveFilename =  Module._resolveFilename;
Module._resolveFilename = function(request) {
  const args = Array.from(arguments);
  // 앨리어스 매핑
  const LIB_PREFIX = '@lib/';
  if (request.startsWith(LIB_PREFIX)) {
    console.log(request);
    request = path.resolve(__dirname, '../' + request.slice(1));
    args[0] = request;
    console.log(` => ${request}`);
  }
  return _resolveFilename.apply(null, args);
}

P.S. 물론, 일반적으로 이렇게 할 필요는 없습니다. Webpack 등의 구축 도구를 통해 완료할 수 있습니다

캐시를 지움

기본적으로 Node.js 모듈은 로딩 후 캐시되지만, 어떤 경우에는 캐시를 무효화하고, 모듈을 강제로 재로딩하고 싶을 수 있습니다. 예를 들어 사용자가 빈번하게 수정하는 JS 파일 (webpack.config.js 등) 을 읽고 싶을 때

此時 require.cache 에 매달려 있는 module.exports 캐시를 수동으로 삭제할 수 있습니다:

delete require.cache[require.resolve('./b.js')]

그러나, b.js 가 다른 외부 (비내장) 모듈을 참조하는 경우, 그들도一并에 삭제해야 합니다:

const mod = require.cache[require.resolve('./b.js')];
// 참조 트리상의 모든 모듈 캐시를 모두 삭제
(function traverse(mod) {
  mod.children.forEach((child) => {
    traverse(child);
  });

  console.log('decache ' + mod.id);
  delete require.cache[mod.id];
}(mod));

P.S. 또는 decache 모듈을 채택

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성