ES Modules

Nuxt는 네이티브 ES 모듈을 사용합니다.

이 가이드는 ES Modules가 무엇인지, 그리고 Nuxt 앱(또는 상위 라이브러리)을 ESM과 호환되도록 만드는 방법을 설명합니다.

Background

CommonJS Modules

CommonJS(CJS)는 Node.js에서 도입된 포맷으로, 분리된 JavaScript 모듈 간에 기능을 공유할 수 있게 해줍니다(더 읽어보기). 아마 다음과 같은 문법에 이미 익숙할 것입니다:

const a = require('./a')

module.exports.a = a

webpack과 Rollup 같은 번들러는 이 문법을 지원하며, 브라우저에서 CommonJS로 작성된 모듈을 사용할 수 있게 해줍니다.

ESM Syntax

대부분의 경우, 사람들이 ESM vs. CJS에 대해 이야기할 때는 모듈을 작성하는 서로 다른 문법에 대해 말하는 것입니다.

import a from './a'

export { a }

ECMAScript Modules(ESM)이 표준이 되기 전(10년 이상 걸렸습니다!), webpack 같은 도구와 TypeScript 같은 언어는 이른바 ESM 문법을 지원하기 시작했습니다. 하지만 실제 스펙과는 몇 가지 중요한 차이점이 있습니다. 여기에 도움이 되는 설명이 있습니다.

What is 'Native' ESM?

ESM 문법을 사용해 앱을 작성한 지는 꽤 오래되었을 수 있습니다. 결국 브라우저에서 네이티브로 지원되며, Nuxt 2에서는 여러분이 작성한 모든 코드를 적절한 포맷으로 컴파일했습니다(서버용 CJS, 브라우저용 ESM).

패키지에 모듈을 추가할 때는 상황이 조금 달랐습니다. 예시 라이브러리는 CJS와 ESM 버전을 모두 노출하고, 우리가 원하는 것을 선택할 수 있게 했습니다:

{
  "name": "sample-library",
  "main": "dist/sample-library.cjs.js",
  "module": "dist/sample-library.esm.js"
}

그래서 Nuxt 2에서는 번들러(webpack)가 서버 빌드에는 CJS 파일('main')을 가져오고, 클라이언트 빌드에는 ESM 파일('module')을 사용했습니다.

module 필드는 webpack과 Rollup 같은 번들러에서 사용하는 관례일 뿐이며, Node.js 자체에서는 인식하지 않습니다. Node.js는 모듈 해석을 위해 exportsmain 필드만 사용합니다.

하지만 최근 Node.js LTS 릴리스에서는 이제 Node.js 내에서 네이티브 ESM 모듈을 사용하는 것이 가능해졌습니다. 이는 Node.js 자체가 ESM 문법을 사용하는 JavaScript를 처리할 수 있다는 의미지만, 기본값으로 그렇게 하지는 않습니다. ESM 문법을 활성화하는 가장 일반적인 두 가지 방법은 다음과 같습니다:

  • package.json"type": "module"을 설정하고 .js 확장자를 계속 사용하기
  • .mjs 파일 확장자를 사용하기(권장)

Nuxt Nitro에서 우리가 하는 일이 바로 이것입니다. .output/server/index.mjs 파일을 출력합니다. 이는 Node.js에게 이 파일을 네이티브 ES 모듈로 처리하라고 알려줍니다.

What Are Valid Imports in a Node.js Context?

require 대신 import로 모듈을 가져오면, Node.js는 이를 다르게 해석합니다. 예를 들어 sample-library를 import하면, Node.js는 해당 라이브러리의 package.json에서 exports 항목을 찾고, exports가 정의되지 않은 경우 main 항목으로 폴백합니다.

이는 const b = await import('sample-library') 같은 동적 import에도 마찬가지입니다.

Node는 다음과 같은 종류의 import를 지원합니다(문서 참조):

  1. .mjs로 끝나는 파일 - ESM 문법을 사용할 것으로 예상됩니다
  2. .cjs로 끝나는 파일 - CJS 문법을 사용할 것으로 예상됩니다
  3. .js로 끝나는 파일 - 해당 package.json"type": "module"이 없는 한 CJS 문법을 사용할 것으로 예상됩니다

What Kinds of Problems Can There Be?

오랫동안 모듈 작성자들은 .esm.js 또는 .es.js 같은 관례적인 이름을 사용해 ESM 문법 빌드를 만들어 왔고, 이를 package.jsonmodule 필드에 추가해 왔습니다. 이는 지금까지는 문제가 되지 않았는데, webpack 같은 번들러만 이를 사용했고, 이들은 파일 확장자에 크게 신경 쓰지 않았기 때문입니다.

하지만 Node.js ESM 컨텍스트에서 .esm.js 파일이 있는 패키지를 import하려고 하면, 동작하지 않고 다음과 같은 오류가 발생합니다:

Terminal
(node:22145) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
/path/to/index.js:1

export default {}
^^^^^^

SyntaxError: Unexpected token 'export'
    at wrapSafe (internal/modules/cjs/loader.js:1001:16)
    at Module._compile (internal/modules/cjs/loader.js:1049:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
    ....
    at async Object.loadESM (internal/process/esm_loader.js:68:5)

또한 Node.js가 CJS라고 생각하는 ESM 문법 빌드에서 이름 있는 import를 사용하는 경우에도 이 오류가 발생할 수 있습니다:

Terminal
file:///path/to/index.mjs:5
import { named } from 'sample-library'
         ^^^^^
SyntaxError: Named export 'named' not found. The requested module 'sample-library' is a CommonJS module, which may not support all module.exports as named exports.

CommonJS modules can always be imported via the default export, for example using:

import pkg from 'sample-library';
const { named } = pkg;

    at ModuleJob._instantiate (internal/modules/esm/module_job.js:120:21)
    at async ModuleJob.run (internal/modules/esm/module_job.js:165:5)
    at async Loader.import (internal/modules/esm/loader.js:177:24)
    at async Object.loadESM (internal/process/esm_loader.js:68:5)

Troubleshooting ESM Issues

이러한 오류가 발생한다면, 문제는 거의 확실하게 상위 라이브러리에 있습니다. 그들은 Node에서 import될 수 있도록 라이브러리를 수정해야 합니다.

Transpiling Libraries

그동안에는, Nuxt가 이러한 라이브러리를 import하려고 시도하지 않도록 build.transpile에 추가할 수 있습니다:

export default defineNuxtConfig({
  build: {
    transpile: ['sample-library'],
  },
})

또한 이러한 라이브러리에서 import하는 다른 패키지들도 함께 추가해야 할 수도 있습니다.

Aliasing Libraries

어떤 경우에는 라이브러리를 CJS 버전에 수동으로 alias해야 할 수도 있습니다. 예를 들어:

export default defineNuxtConfig({
  alias: {
    'sample-library': 'sample-library/dist/sample-library.cjs.js',
  },
})

Default Exports

CommonJS 포맷의 의존성은 module.exports 또는 exports를 사용해 기본 내보내기를 제공할 수 있습니다:

node_modules/cjs-pkg/index.js
module.exports = { test: 123 }
// 또는
exports.test = 123

일반적으로 이러한 의존성을 require하면 잘 동작합니다:

test.cjs
const pkg = require('cjs-pkg')

console.log(pkg) // { test: 123 }

네이티브 ESM 모드의 Node.js, esModuleInterop가 활성화된 TypeScript, 그리고 webpack 같은 번들러는 이러한 라이브러리를 기본 import로 가져올 수 있도록 호환 메커니즘을 제공합니다. 이 메커니즘은 종종 "interop require default"라고 불립니다:

import pkg from 'cjs-pkg'

console.log(pkg) // { test: 123 }

하지만 문법 감지와 서로 다른 번들 포맷의 복잡성 때문에, interop default가 실패하여 다음과 같은 상황이 발생할 가능성이 항상 있습니다:

import pkg from 'cjs-pkg'

console.log(pkg) // { default: { test: 123 } }

또한 동적 import 문법을 사용할 때(CJS와 ESM 파일 모두에서), 항상 다음과 같은 상황이 발생합니다:

import('cjs-pkg').then(console.log) // [Module: null prototype] { default: { test: '123' } }

이 경우, 기본 내보내기를 수동으로 interop해야 합니다:

// 정적 import
import { default as pkg } from 'cjs-pkg'

// 동적 import
import('cjs-pkg').then(m => m.default || m).then(console.log)

더 복잡한 상황을 처리하고 더 안전하게 다루기 위해, 우리는 Nuxt에서 mlly를 사용하기를 권장하며 내부적으로도 사용하고 있습니다. 이는 이름 있는 export를 보존할 수 있습니다.

import { interopDefault } from 'mlly'

// 모양이 { default: { foo: 'bar' }, baz: 'qux' }라고 가정
import myModule from 'my-module'

console.log(interopDefault(myModule)) // { foo: 'bar', baz: 'qux' }

Library Author Guide

좋은 소식은 ESM 호환성 문제를 해결하는 것이 비교적 간단하다는 것입니다. 두 가지 주요 옵션이 있습니다:

  1. ESM 파일의 이름을 .mjs로 끝나도록 변경할 수 있습니다.

이것이 권장되며 가장 간단한 접근 방식입니다. 라이브러리의 의존성과 빌드 시스템에서 문제를 해결해야 할 수도 있지만, 대부분의 경우 이 방법으로 문제가 해결됩니다. 또한 CJS 파일의 이름을 .cjs로 끝나도록 변경하는 것도 가장 명시적이기 때문에 권장됩니다.

  1. 라이브러리 전체를 ESM 전용으로 만들 수 있습니다.

이는 package.json"type": "module"을 설정하고, 빌드된 라이브러리가 ESM 문법을 사용하도록 보장하는 것을 의미합니다. 하지만 의존성과 관련된 문제에 직면할 수 있으며, 이 접근 방식은 라이브러리가 오직 ESM 컨텍스트에서만 사용될 수 있음을 의미합니다.

Migration

CJS에서 ESM으로의 초기 단계는 require 사용을 모두 import로 업데이트하는 것입니다:

module.exports = function () { /* ... */ }

exports.hello = 'world'
const myLib = require('my-lib')

ESM 모듈에서는 CJS와 달리 require, require.resolve, __filename, __dirname 전역이 제공되지 않으며, 이를 import()import.meta.filename으로 대체해야 합니다.

const { join } = require('node:path')

const newDir = join(__dirname, 'new-dir')
const someFile = require.resolve('./lib/foo.js')

Best Practices

  • 기본 내보내기보다 이름 있는 export를 선호하세요. 이는 CJS 충돌을 줄이는 데 도움이 됩니다.(Default exports 섹션 참조)
  • 라이브러리가 브라우저와 Edge Workers에서 Nitro 폴리필 없이도 사용 가능하도록, 가능한 한 Node.js 내장 모듈과 CommonJS 또는 Node.js 전용 의존성에 의존하지 마세요.
  • 조건부 exports와 함께 새로운 exports 필드를 사용하세요.(더 읽어보기).
{
  "exports": {
    ".": {
      "import": "./dist/mymodule.mjs"
    }
  }
}
Was this helpful?