예전에는 브라우저 환경이 아닌 Node 환경에서 ESM 모듈을 사용하려면 Babel 과 같은 트랜스파일러를 사용해서 코드 변환을 해줘야 했다.
하지만 Node.js 13.2버전 부터는 ESM에 대한 정식지원이 시작되어서 굳이 트랜스파일을 안해줘도 ESM모듈을 사용 할 수 있게 되었다.
바벨은 JS 트랜스파일러이다.
소스 코드를 머신 코드로 바꿔주는 compile과 달리, transpile은 같은 언어를 유지한체 다른 런타임에서 해당 코드가 정상적으로 해석될 수 있도록 형태만 바꿔준다는 차이가 있다.
보통 3가지 => babel core, babel cli, babel preset이 필요함. 자세한건 위의 링크 참고하자
여기서 3가지를 설치 한 후 런타임에 => 트랜스파일링 된 코드를 동시에 실행가능함.
.babelrc 이라는 설정파일을 추가하거나, package.json에 preset 설정도 추가 가능함.
- $ npx babel --presets @babel/env index.js | node
https://vite.dev/guide/troubleshooting.html#this-package-is-esm-only
vite.config가 ESM 모듈이라서, Node 버전을 업그레이드 했을 때 찾이 못하는 문제가 있었다.
확장자를 .js .ts 에서 => .mjs나 .mts 로 변경하는 방식이 있다. => 가장 쉽고 빠르게 ESM 모듈을 사용 할 수 있다.
{
// 생략
"type": "module"
// 생략
}
ref) https://toss.tech/article/commonjs-esm-exports-field
프로젝트에서 두개의 Module System 을 지원하는 것은 중요하다. => SSR 방식에서는 Node.js를 사용하기 때문에 CJS가 기본방식.
Tree-shaking(번들링시 필요하지 않은 모듈이나 코드 삭제) 과정에서도 CJS는 Tree-shaking이 어렵고 ESM은 쉽게 가능하다.
CJS는 기본적으로 require / module.exports 를 동적으로 하는 것에 아무런 제약이 없다. => 즉 빌드타임이 아닌 런타임에 모듈간의 관계를 파악하는 것. (모든 파일을 분석해야한다.)
// require
const utilName = /* 동적인 값 */
const util = require(`./utils/${utilName}`);
// module.exports
function foo() {
if (/* 동적인 조건 */) {
module.exports = /* ... */;
}
}
foo();
하지만 ESM은 정직인 구조로 모듈끼리 의존하도록 강제한다. import path에 동적인 값을 사용할 수 없고, export는 항상 최상위 스코프에서만 사용할 수 있다.
ESM은 그래서 빌드단계에서 모듈관의 의존관계를 파악 할 수 있고, Tree Shaking이 가능하다.
import util from `./utils/${utilName}.js`; // 불가능
import { add } from "./utils/math.js"; // 가능
function foo() {
export const value = "foo"; // 불가능
}
export const value = "foo"; // 가능
- .js 파일의 Module System은 package.json의 type field에 따라 결정됩니다.
- type field의 기본값은 "commonjs" 이고, 이 때 .js 는 CJS로 해석됩니다.
- 다른 하나는 "module" 입니다. 이 때 .js 는 ESM으로 해석됩니다.
- .cjs 는 항상 CJS로 해석됩니다.
- .mjs 는 항상 ESM으로 해석됩니다.
이때 package.json에 type : 'module'을 명시하면, 해당 package에 있는 .js파일이 모두 ESM으로 해석된다.
이때 두가지 방법 모두 매끄럽게 제공하려면 => export field에 대해 알아봐야한다.
export field는 package.json의 main field와 같은 역할을 한다. 패키지 entry point 를 지정 할 수 있다.
// 디렉토리 구조
/modules
a.js
b.js
c.js
index.js
require("package/a"); // 불가능
require("package/modules/a"); // 가능
// CJS 패키지
{
"name": "cjs-package",
"exports": {
".": "./index.js",
"./a": "./modules/a.js",
},
}
// ./a.js가 아니라
// ./modules/a.js를 불러온다.
require("cjs-package/a");
// 에러
// ./b는 exports field에 명시하지 않은 subpath이다.
require("cjs-package/b");
{
"name": "cjs-package",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./esm/index.mjs"
}
}
}
// ESM은 .mjs로 명시해야함 : 해당 package가 CJS 모듈일때
{
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
}
}
// CJS는 .cjs로 명시해야함 : 해당 package가 ESM 모듈일 때
{
"type": "module"
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.js"
}
}
}
// X
{
"exports": {
"sub-module": "dist/modules/sub-module.js"
}
}
// O
{
"exports": {
".": "./dist/index.js",
"./sub-module": "./dist/modules/sub-module.js"
}
}
Typescirpt 자체의 컴파일러를 통해 module import시 항상 Type Definition을 찾는다.
기존에는 fileSystem 기반으로 모듈을 탐색했다.
하지만 TypeScript 4.7부터 moduleResolution 옵션에 node16 과 nodenext 가 정식으로 추가되었고, node16 과 nodenext 는 filesystem 기반이 아닌 exports field로부터 Type Definition을 탐색한다.
또한, CJS TypeScript( .cts )와 ESM TypeScript( .mts )를 구분한다.
// CJS package
// ESM TS는 mts로 명시해야함
{
"exports": {
".": {
"require": {
"types": "./index.d.ts",
"default": "./index.js"
},
"import": {
"types": "./index.d.mts",
"default": "./index.mjs"
}
}
}
}
// ESM package
// CJS TS는 cts로 명시해야함
{
"type": "module",
"exports": {
".": {
"require": {
"types": "./index.d.cts",
"default": "./index.cjs"
},
"import": {
"types": "./index.d.ts",
"default": "./index.js"
}
}
}
}
https://www.typescriptlang.org/docs/handbook/modules/theory.html#module-resolution-is-host-defined
{
"compilerOptions": {
"module": "esNext",
"target": "es2022",
"moduleResolution": "node"
}
}
{
"compilerOptions": {
"module": "nodeNext",
"target": "es2022",
"moduleResolution": "node"
}
}