
Jest의 패키지는 모든 종류의 JavaScript 도구를 구축하는데 유용한 전체 패키지의 생태계를 구성합니다. "전체는 부분의 합보다 크다"는 Jest에게 적용되지 않습니다. 이 글에서는 Jest의 패키지 중 일부를 활용해서 JavaScript 번들러가 동작하는 방식을 배울 것입니다. 글이 끝날 때 여러분들은 토이 번들러를 갖게 되고, 자바스크립트 번들링의 기본 개념을 이해하게 될 것입니다.
이 게시물은 JavaScript 인프라에 대한 시리즈의 일부입니다. 현재 위치는 다음과 같습니다.
- 의존성 관리자는 의존성을 관리하지 않습니다
- 자바스크립트 인프라 다시 생각하기
- 자바스크립트 테스팅 프레임워크 만들기
- 자바스크립트 번들러 만들기 (현재는 여기)
- 기본 값은 중요합니다: Jest 이야기
- 제목 미정
Subscribe to get notified about new posts.
번들러 만들기
나는 Jest가 기본 번들러와 함께 제공되어야 하고, 기본적인 기능들을 갖춘 번들러를 만드는데 약 1시간 정도 밖에 걸리지 않을 거라고 자주 농담 삼아 말했습니다. 소스 코드가 브라우저에서 실행할 수 있는 JavaScript 번들(Bundle)로 번들링되는 단계를 나누어보겠습니다.
- 파일 시스템의 모든 파일을 효율적으로 검색
- 의존성 그래프 해결
- 번들 직렬화
- 런타임을 사용해서 번들 실행
- 각 파일을 병렬로 컴파일
JavaScript 테스팅을 모든 테스트 파일을 테스트 결과로 "리듀스"하는 맵-리듀스(map-reduce) 연산이라고 생각한다면, JavaScript 번들링은 모든 소스 파일을 번들로 "리듀스"하는 작업입니다. 동작하는 jest-bundler
를 한 시간 안에 만들 수 있는지 봅시다! 이 시리즈의 이전 글에서 많은 개념과 모듈을 재사용하기 때문에 Building a JavaScript Testing Framework를 읽지 않았다면 여기부터 시작하는 것이 좋습니다.
프로젝트를 초기화하고 몇 가지 테스트 파일을 추가해보겠습니다.
# 터미널 안에서:
mkdir jest-bundler
cd jest-bundler
yarn init --yes
mkdir product
echo "console.log(require('./apple'));" > product/entry-point.js
echo "module.exports = 'apple ' + require('./banana') + ' ' + require('./kiwi');" > product/apple.js
echo "module.exports = 'banana ' + require('./kiwi');" > product/banana.js
echo "module.exports = 'kiwi ' + require('./melon') + ' ' + require('./tomato');" > product/kiwi.js
echo "module.exports = 'melon';" > product/melon.js
echo "module.exports = 'tomato';" > product/tomato.js
touch index.mjs
yarn add chalk yargs jest-haste-map
과일과 채소는 훌륭하고 더 많이 먹어야 합니다! 진행하면서 테스트 코드를 확장하겠지만, 지금은 진입점을 실행하면 다음 단어들이 순서대로 출력됩니다.
# 터미널 안에서:
node product/entry-point.js
# apple banana kiwi melon tomato kiwi melon tomato
이것은 node에서 동작하지만 브라우저에서 실행하려면 모든 파일을 단일 파일로 번들링 해야합니다.
파일 시스템의 모든 파일을 효율적으로 검색
이전 글를 따라왔다면, 이 섹션은 지난 시간에 시작했던 방법과 거의 동일하게 보일 것입니다. 대부분의 JavaScript 도구는 프로젝트의 모든 코드에서 동작하며, jest-haste-map
은 모든 파일을 추적하고 파일 간의 관계를 분석하고 변경 사항에 대해 파일 시스템을 계속 모니터링하는 효율적인 방법입니다.
// index.mjs
import JestHasteMap from "jest-haste-map";
import { cpus } from "os";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
// 프로젝트의 루트 경로를 가져옵니다(예: `__dirname`).
const root = join(dirname(fileURLToPath(import.meta.url)), "product");
const hasteMapOptions = {
extensions: ["js"],
maxWorkers: cpus().length,
name: "jest-bundler",
platforms: [],
rootDir: root,
roots: [root],
};
// Jest 27버전부터 `.default`를 사용해야 합니다.
const hasteMap = new JestHasteMap.default(hasteMapOptions);
// 이 줄은 `jest-haste-map` 28 버전 이상에서만 필요합니다.
await hasteMap.setupCachePath(hasteMapOptions);
const { hasteFS, moduleMap } = await hasteMap.build();
console.log(hasteFS.getAllFiles());
// ['/path/to/product/apple.js', '/path/to/product/banana.js', …]
좋아, 이제 빠르게 시작해봅시다. 이제 번들러에는 일반적으로 많은 구성 또는 커맨드 라인 옵션이 필요합니다. --entry-point
옵션을 추가하기 위해 yargs
를 사용해서 번들러에게 번들링을 시작할 위치를 알릴 수 있도록 합시다. 번들러는 다양한 단계로 구성되어 있으므로, 사용자에게 무슨 일이 일어나고 있는지 알려주는 출력물도 추가해 보겠습니다.
// index.mjs
import { resolve } from "path";
import chalk from "chalk";
import yargs from "yargs";
const options = yargs(process.argv).argv;
const entryPoint = resolve(process.cwd(), options.entryPoint);
if (!hasteFS.exists(entryPoint)) {
throw new Error(
"`--entry-point` does not exist. Please provide a path to a valid file."
);
}
console.log(chalk.bold(`❯ Building ${chalk.blue(options.entryPoint)}`));
프로젝트 루트에서 node index.mjs --entry-point product/entry-point.js
를 사용해서 실행하면, 해당 파일을 빌드 중이라고 알려줍니다. 첫 번째 작업✅ 을 확인하면서 준비 운동하기 딱 좋았어요. 본격적으로 시작해봅시다.
의존성 그래프 해결
출력 번들에 어떤 파일이 있어야 하는지 결정하려면, 진입점부터 모든 리프 노드까지 모든 의존성을 재귀적으로 해결해야 합니다. 이전 게시물에서는 jest-haste-map
에 유용한 부가 기능이 있음을 암시했습니다. 파일 목록을 제공할 즈음에는 실제로 보이는 것보다 훨씬 더 많은 유효한 정보가 있습니다. 개별 파일의 의존성을 제공할 수 있도록 요청할 수 있습니다.
// index.mjs에 첨부:
console.log(hasteFS.getDependencies(entryPoint));
// ['./apple.js']
훌륭하지만 이름이 리졸브(resolve)되지 않았습니다. 즉, 매핑되는 파일을 파악하기 위해 전체 노드 분해 알고리즘(node resolution algorithm)을 구현해야 합니다. 예를 들어, 일반적으로 파일 확장자를 제공하지 않고 모듈이 필요할 수 있거나 패키지가 package.json
의 항목을 통해 주 모듈을 다시 보낼 수 있습니다. 파일을 리졸브하기 위해 만들어진 jest-resolve
와 jest-resolve-dependencies
가 있으니 yarn add jest-resolve jest-resolve-dependencies
를 입력해서 사용해봅시다. 일부 jest-haste-map
데이터 구조와 일부 구성 옵션을 전달하여 설정할 수 있습니다.
// index.mjs에 첨부:
import Resolver from "jest-resolve";
import { DependencyResolver } from "jest-resolve-dependencies";
const resolver = new Resolver.default(moduleMap, {
extensions: [".js"],
hasCoreModules: false,
rootDir: root,
});
const dependencyResolver = new DependencyResolver(resolver, hasteFS);
console.log(dependencyResolver.resolve(entryPoint));
// ['/path/to/apple.js']
멋집니다! 이 솔루션을 사용하면 이제 진입점이 의존하는 각 모듈의 전체 파일 경로를 검색할 수 있습니다. 전체 의존성 그래프를 만들려면 각 의존성을 한 번씩 처리해야 합니다. 처리해야 하는 모듈을 위한 큐(Queue)를 사용하고, 이미 처리된 모듈을 추적하기 위해 Set
을 사용합니다.
이것은 모듈을 두 번 이상 처리하고 싶지 않기 때문에 필요합니다. 의존성 그래프에 A → B → C → A
와 같은 순환이 있는 경우 발생할 수 있습니다. 오버플로가 발생할 수 있으므로 재귀를 사용하지 않습니다.
// index.mjs
const allFiles = new Set();
const queue = [entryPoint];
while (queue.length) {
const module = queue.shift();
// 순환을 방지하기 위해 각 모듈을 최대 한 번만 처리해야 합니다.
if (allFiles.has(module)) {
continue;
}
allFiles.add(module);
queue.push(...dependencyResolver.resolve(module));
}
console.log(chalk.bold(`❯ Found ${chalk.blue(allFiles.size)} files`));
console.log(Array.from(allFiles));
// ['/path/to/entry-point.js', '/path/to/apple.js', …]
성공입니다! 이제 의존성 그래프에 모든 모듈 목록이 있습니다. 테스트 파일 또는 require
호출을 추가/제거해서 이를 가지고 놀 수 있고, 그에 따라 출력이 변경되는 것을 볼 수 있습니다. 두 번째 단계인 의존성 그래프 해결이 완료되었습니다 ✅
번들 직렬화
이제 번들을 "직렬화"하는 데 필요한 모든 정보가 있습니다. 직렬화는 의존성 정보와 모든 코드를 브라우저에서 단일 파일로 실행할 수 있는 번들로 변환하는 프로세스입니다. 다음은 초기 접근 방식입니다.
import fs from "fs";
console.log(chalk.bold(`❯ Serializing bundle`));
const allCode = [];
await Promise.all(
Array.from(allFiles).map(async (file) => {
const code = await fs.promises.readFile(file, "utf8");
allCode.push(code);
})
);
console.log(allCode.join("\n"));
위의 예는 모든 소스 파일을 연결하고 출력합니다. 불행히도 출력을 실행하려고 시도하면 작동하지 않습니다. 브라우저에 존재하지 않는 require
를 호출하고 모듈을 참조할 방법이 없습니다. 실제로 동작할 다른 전략에 대해 생각할 필요가 있습니다. 여기 또 다른 아이디어가 있습니다. 모든 모듈을 인라인하면 어떻게 될까요? 코드의 의존성 이름을 전체 경로를 추적하도록 의존성 컬렉션을 변경하고, 각 require('...')
호출 부분을 모듈 구현으로 교체하여 모듈을 인라인하려고 시도합니다. 약간 더 복잡한 작업을 수행해야 하므로 더 이상 jest-resolve-dependencies
가 필요하지 않습니다. 따라서 여기 인라인이 포함된 전체 번들러가 있습니다.
// index.mjs
import { cpus } from "os";
import { dirname, resolve, join } from "path";
import { fileURLToPath } from "url";
import chalk from "chalk";
import JestHasteMap from "jest-haste-map";
import Resolver from "jest-resolve";
import yargs from "yargs";
import fs from "fs";
const root = join(dirname(fileURLToPath(import.meta.url)), "product");
const hasteMapOptions = {
extensions: ["js"],
maxWorkers: cpus().length,
name: "jest-bundler",
platforms: [],
rootDir: root,
roots: [root],
};
const hasteMap = new JestHasteMap.default(hasteMapOptions);
// 이 줄은 `jest-haste-map` 28 버전 이상에서만 필요합니다.
await hasteMap.setupCachePath(hasteMapOptions);
const { hasteFS, moduleMap } = await hasteMap.build();
const options = yargs(process.argv).argv;
const entryPoint = resolve(process.cwd(), options.entryPoint);
if (!hasteFS.exists(entryPoint)) {
throw new Error(
"`--entry-point` does not exist. Please provide a path to a valid file."
);
}
console.log(chalk.bold(`❯ Building ${chalk.blue(options.entryPoint)}`));
const resolver = new Resolver.default(moduleMap, {
extensions: [".js"],
hasCoreModules: false,
rootDir: root,
});
const seen = new Set();
const modules = new Map();
const queue = [entryPoint];
while (queue.length) {
const module = queue.shift();
if (seen.has(module)) {
continue;
}
seen.add(module);
// 각 의존성을 해결하고 "이름" 기반으로 저장하고
// `require('<name>');`를 통해 코드에서 실제로 발생합니다.
const dependencyMap = new Map(
hasteFS
.getDependencies(module)
.map((dependencyName) => [
dependencyName,
resolver.resolveModule(module, dependencyName),
])
);
const code = fs.readFileSync(module, "utf8");
// "모듈 부분"을 추출합니다. 여기에서는 `module.exports =` 이후의 모든 것을 추출했습니다.
const moduleBody = code.match(/module\.exports\s+=\s+(.*?);/)?.[1] || "";
const metadata = {
code: moduleBody || code,
dependencyMap,
};
modules.set(module, metadata);
queue.push(...dependencyMap.values());
}
console.log(chalk.bold(`❯ Found ${chalk.blue(seen.size)} files`));
console.log(chalk.bold(`❯ Serializing bundle`));
// 각 모듈을 통해 이동합니다(진입점을 마지막으로 처리하기 위해 뒤로).
for (const [module, metadata] of Array.from(modules).reverse()) {
let { code } = metadata;
for (const [dependencyName, dependencyPath] of metadata.dependencyMap) {
// 의존성의 모듈 본문을 필요한 모듈에 인라인합니다.
code = code.replace(
new RegExp(
// `.`와 `/`를 이스케이프 처리 합니다.
`require\\(('|")${dependencyName.replace(/[\/.]/g, "\\$&")}\\1\\)`
),
modules.get(dependencyPath).code
);
}
metadata.code = code;
}
console.log(modules.get(entryPoint).code);
// console.log('apple ' + 'banana ' + 'kiwi ' + 'melon' + ' ' + 'tomato' + ' ' + 'kiwi ' + 'melon' + ' ' + 'tomato');
축하합니다. 방금 _모듈을 인라인하는 컴파일러_인 rollup.js를 구축했습니다. 한 가지 트릭을 더 적용해 보겠습니다.
console.log(modules.get(entryPoint).code.replace(/' \+ '/g, ""));
// console.log('apple banana kiwi melon tomato kiwi melon tomato');
이제 대부분의 실제 JavaScript 컴파일러보다 더 발전된 최적화 컴파일러_가 있습니다. 물론 이 처리 방법은 금방 무너질 겁니다. 먼저 정규 표현식을 사용합니다. 두 번째로 module.exports =
뒤에 오는 것만 추출하고, 모듈 스코프에 있는 다른 코드는 무시하므로 모듈에서 복잡한 작업을 수행할 수 없습니다. rollup.js는 이것이 실제로 가능함을 보여주었지만(_굉장합니다!), 이 가이드는 더 간단하지만 강력한 솔루션에 중점을 둡니다. 각 모듈에 스코프와 상태를 제공하고 모듈 실행을 체계화하기 위해 런타임을 사용합니다.
런타임을 사용해서 번들 실행
한 걸음 물러나서 한 번 생각해 봅시다. 모든 JavaScript 환경에서 실행할 수 있는 이식 가능한 아티팩트를 만들고 싶다면 번들러의 출력이 어떻게 보여야 할까요? 우리는 방금 모든 모듈을 단일문으로 축소하는 직렬화 형식에 대해 배웠습니다. 우리는 선택할 수 있는 다른 많은 것들이 있습니다. 읽는 것을 멈추고 자신만의 솔루션을 생각할 수 있는지 확인할 좋은 시간입니다!
다음과 같은 직렬화 형식을 생각해낼 수 있습니다.
// 직렬화 형식 2번째 시도.
let module;
// tomato.js
module = {};
module.exports = "tomato";
const tomatoModule = module.exports;
// melon.js
module = {};
module.exports = "melon";
const melonModule = module.exports;
// kiwi.js
module = {};
module.exports = "kiwi " + melonModule + " " + tomatoModule;
const kiwiModule = module.exports;
이 직렬화된 형식은 여전히 모든 모듈을 연결하지만 각 모듈 앞뒤에 코드를 삽입합니다. 모듈을 실행하기 전에 module
변수를 재설정하고 모듈을 실행한 후 결과를 모듈 특정 변수에 저장합니다. 또한 'require' 호출을 각 모듈의 export에 대한 참조로 교체합니다. 이것은 각 모듈에서 실제로 하나 이상의 export 문을 실행할 수 있기 때문에 이전에 사용했던 것과 비교하면 훨씬 더 나은 솔루션입니다. 그러나 이 솔루션에도 단점은 있습니다. 두 모듈이 동일한 변수 이름을 사용하거나 module
변수가 느리게 참조되는 등 빠르게 한계에 부딪힐 것입니다.
우리가 만들고 있는 번들러에는 모듈을 보존하고 모듈 실행 및 import 기능이 있는 런타임을 가져올 수 있는 직렬화 형식을 사용할 것입니다. 이것은 또한 어떻게든 모듈을 등록해야 함을 의미합니다. 우리는 이전 포스트에서 vm
컨텍스트에서 eval
을 사용하고 함수로 코드를 래핑한 테스트 러너를 빌드할 때 (function(module) {${code}})
라는 흥미로운 패턴을 사용했습니다. 이것을 번들러에 사용할 수 있을까요?
// 직렬화 형식 3번째 시도.
// tomato.js
(function (module) {
module.exports = "tomato";
});
// melon.js
(function (module) {
module.exports = "melon";
});
// kiwi.js
(function (module) {
module.exports = "kiwi " + require("./melon") + " " + require("./tomato");
});
좋습니다. 이제 모든 모듈을 moduleFactories
로 변환하여 분리했습니다! 그러나 이 코드를 실행하려고 하면 아무 일도 일어나지 않습니다. 우리는 모듈을 참조하고 실행할 방법이 없으며 몇 가지 기능들을 정의하는 즉시 잊어 버립니다. 모듈을 _정의_하는 몇 가지 기능을 추가해 보겠습니다.
// 직렬화 형식 4번째 시도.
const modules = new Map();
const define = (name, moduleFactory) => {
modules.set(name, moduleFactory);
};
// tomato.js
define("tomato", function (module) {
module.exports = "tomato";
});
// melon.js
define("melon", function (module) {
module.exports = "melon";
});
// kiwi.js
define("kiwi", function (module) {
module.exports = "kiwi " + require("./melon") + " " + require("./tomato");
});
이제 프로그램을 실행하고 모듈을 _정의_할 수 있습니다. 이 코드는 여전히 코드를 _실행_하지 않습니다. 모듈은 일반적으로 요구(require)될 때 실행됩니다. 따라서 모듈을 실행하고 요구(require)하는 구현을 추가해 보겠습니다.
// 직렬화 형식 5번째 시도.
const modules = new Map();
const define = (name, moduleFactory) => {
modules.set(name, moduleFactory);
};
const moduleCache = new Map();
const requireModule = (name) => {
// 이 모듈이 이미 실행된 경우 해당 모듈에 대한 참조를 반환합니다.
if (moduleCache.has(name)) {
return moduleCache.get(name).exports;
}
// 모듈이 없으면 throw합니다.
if (!modules.has(name)) {
throw new Error(`Module '${name}' does not exist.`);
}
const moduleFactory = modules.get(name);
// 모듈 객체를 만듭니다.
const module = {
exports: {},
};
// 순환 의존성으로 무한 루프에 빠지지 않도록 moduleCache를 즉시 설정하십시오.
moduleCache.set(name, module);
// 모듈 팩토리를 실행합니다. 아마도 `module` 객체를 변형시킬 것입니다.
moduleFactory(module, module.exports, requireModule);
// 내보낸 데이터를 반환합니다.
return module.exports;
};
// tomato.js
define("tomato", function (module, exports, require) {
module.exports = "tomato";
});
// melon.js
define("melon", function (module, exports, require) {
module.exports = "melon";
});
// kiwi.js
define("kiwi", function (module, exports, require) {
module.exports = "kiwi " + require("./melon") + " " + require("./tomato");
});
이 코드를 사용하면 번들 끝에 requireModule('kiwi');
을 추가하여 실제로 실행할 수 있습니다. 유일한 문제는 Module './melon' does not exist.
가 발생하는 겁니다. 이는 모듈을 요구(require) 할 때 일반적으로 파일 시스템의 파일을 참조하지만 여기서는 모듈을 동일한 파일로 컴파일하고 임의의 ID를 부여하기 때문입니다. require('./melon')
호출을 require('melon')
로 변경할 수 있지만 실제 시나리오에서는 모듈 이름 충돌이 빠르게 발생합니다. 우리는 각 모듈에 고유한 ID를 할당하여 최종 번들 출력을 다음과 같이 표시해서 이 문제를 피할 수 있습니다.
// 직렬화 형식 마지막 시도.
const modules = new Map();
const define = (name, moduleFactory) => {
modules.set(name, moduleFactory);
};
const moduleCache = new Map();
const requireModule = (name) => {
if (moduleCache.has(name)) {
return moduleCache.get(name).exports;
}
if (!modules.has(name)) {
throw new Error(`Module '${name}' does not exist.`);
}
const moduleFactory = modules.get(name);
const module = {
exports: {},
};
moduleCache.set(name, module);
moduleFactory(module, module.exports, requireModule);
return module.exports;
};
// tomato.js
define(2, function (module, exports, require) {
module.exports = "tomato";
});
// melon.js
define(1, function (module, exports, require) {
module.exports = "melon";
});
// kiwi.js
define(0, function (module, exports, require) {
module.exports = "kiwi " + require(1) + " " + require(2);
});
requireModule(0);
굉장합니다! 이제 번들러에서 이러한 종류의 코드를 실제로 출력하는 방법을 알아보겠습니다. 먼저 require
런타임을 가져와 별도의 템플릿 파일에 넣습니다.
// require.js
const modules = new Map();
const define = (name, moduleFactory) => {
modules.set(name, moduleFactory);
};
const moduleCache = new Map();
const requireModule = (name) => {
if (moduleCache.has(name)) {
return moduleCache.get(name).exports;
}
if (!modules.has(name)) {
throw new Error(`Module '${name}' does not exist.`);
}
const moduleFactory = modules.get(name);
const module = {
exports: {},
};
moduleCache.set(name, module);
moduleFactory(module, module.exports, requireModule);
return module.exports;
};
번들링 코드를 건드린 지 꽤 됐네요. 이전 버전을 최적화하고 많은 코드를 인라인했기 때문에 우리가 작성한 것 중 일부를 버려야 합니다. 코드 추출 부분을 제거하고 ID 생성기를 추가해서 의존성 수집기에 대한 작은 업데이트를 시작하겠습니다.
const seen = new Set();
const modules = new Map();
const queue = [entryPoint];
let id = 0;
while (queue.length) {
const module = queue.shift();
if (seen.has(module)) {
continue;
}
seen.add(module);
const dependencyMap = new Map(
hasteFS
.getDependencies(module)
.map((dependencyName) => [
dependencyName,
resolver.resolveModule(module, dependencyName),
])
);
const code = fs.readFileSync(module, "utf8");
const metadata = {
// 각 모듈에 고유한 ID를 할당합니다.
id: id++,
code,
dependencyMap,
};
modules.set(module, metadata);
queue.push(...dependencyMap.values());
}
위의 코드를 사용하면 이제 각 모듈에 대해 고유한 오름차순 ID를 가집니다. 우리의 진입점(entry point)은 우리가 보는 첫 번째 모듈이기 때문에 id는 항상 0
입니다. 다음 단계로 세 가지 업데이트로 직렬 변환기(serializer)를 조정해야 합니다.
- 각 모듈을 함수로 감싸고
define
을 호출합니다. require
런타임을 출력합니다.- 진입점(entry point)을 실행하려면 번들 끝에
requireModule(0);
을 추가하세요.
여기 다음과 같습니다.
console.log(chalk.bold(`❯ Serializing bundle`));
// `define(<id>, function(module, export, require){
// <code> });`으로 모듈을 래핑합니다.
const wrapModule = (id, code) =>
`define(${id}, function(module, exports, require) {\n${code}});`;
// 각 모듈의 코드가 이 배열에 추가됩니다.
const output = [];
for (const [module, metadata] of Array.from(modules).reverse()) {
let { id, code } = metadata;
for (const [dependencyName, dependencyPath] of metadata.dependencyMap) {
const dependency = modules.get(dependencyPath);
// 필요한 모듈의 참조를 생성된 모듈로 바꿉니다. 우리는 단순함을 위해 정규식을 사용합니다. 실제 번들러는 Babel 또는 이와 유사한 것을 사용하여 AST 변환을 수행할 가능성이 높습니다.
code = code.replace(
new RegExp(
`require\\(('|")${dependencyName.replace(/[\/.]/g, "\\$&")}\\1\\)`
),
`require(${dependency.id})`
);
}
// 코드를 감싸고 출력 배열에 추가합니다.
output.push(wrapModule(id, code));
}
// 번들 시작 부분에 `require` 런타임을 추가합니다.
output.unshift(fs.readFileSync("./require.js", "utf8"));
// 그리고 번들 끝에 진입점이 필요합니다.
output.push(["requireModule(0);"]);
// stdout에 씁니다.
console.log(output.join("\n"));
그리고 이건 동작합니다! node index.mjs --entry-point product/entry-point.js
를 통해 번들러를 다시 실행하면 번들을 이전에 설계된 대로 정확하게 출력합니다. 편의를 위해 번들을 파일에 쓰기 위해 --output
플래그를 추가해 보겠습니다.
if (options.output) {
fs.writeFileSync(options.output, output.join("\n"), "utf8");
}
# In your terminal:
node index.mjs --entry-point product/entry-point.js --output test.js
node test.js
# apple banana kiwi melon tomato kiwi melon tomato
이것은 우리의 코드를 번들하고 Node.js에서 실행할 것입니다. 또한 브라우저의 HTML 파일 내에서 test.js
를 로드하면 코드가 실행됩니다. 'jest-bundler'는 살아있습니다!
각 파일을 병렬로 컴파일
우리는 의존성 해결, 번들 직렬화 및 코드 실행을 위한 런타임 생성과 관련된 근본적인 문제들을 해결했습니다. 그러나 한 가지 큰 과제가 남아 있습니다. Babel과 같은 도구를 사용하여 소스 파일을 컴파일하는 것입니다. Babel을 추가하면 최신 문법(modern syntax)을 사용할 수 있습니다. 예를 들어 require
런타임을 사용하여 번들 코드를 계속 실행하면서 import
및 export
와 같은 ECMAScript 모듈 구문을 사용할 수 있습니다. Babel을 컴파일러로 추가해봅시다: yarn add @babel/core @babel/plugin-transform-modules-commonjs
그리고 우리의 예제 코드 중 일부를 업데이트합니다.
// product/entry-point.js
import Apple from "./apple";
console.log(Apple);
// product/apple.js
import Banana from "./banana";
import Kiwi from "./kiwi";
export default "apple " + Banana + " " + Kiwi;
좋습니다. 하나의 파일에 대해 다음과 같이 Babel 컴파일을 수행할 수 있는 충분한 테스트 코드를 제공합니다.
import { transformSync } from "@babel/core";
const result = transformSync(code, {
plugins: ["@babel/plugin-transform-modules-commonjs"],
}).code;
현재 우리 코드는 각 모듈을 직렬로 처리합니다. 각 병렬로 변환하기 위해 Promise.all
을 사용해서 이전 for-of
루프를 다시 작성해 보겠습니다.
const results = await Promise.all(
Array.from(modules)
.reverse()
.map(async ([module, metadata]) => {
let { id, code } = metadata;
code = transformSync(code, {
plugins: ["@babel/plugin-transform-modules-commonjs"],
}).code;
for (const [dependencyName, dependencyPath] of metadata.dependencyMap) {
const dependency = modules.get(dependencyPath);
code = code.replace(
new RegExp(
`require\\(('|")${dependencyName.replace(/[\/.]/g, "\\$&")}\\1\\)`
),
`require(${dependency.id})`
);
}
return wrapModule(id, code);
})
);
// output 배열에 결과를 추가합니다
output.push(...results);
실제로, 이제 출력 코드를 정리할 수 있습니다. 다음과 같이 번들러의 직렬화 부분을 다시 작성해 보겠습니다.
const output = [
fs.readFileSync("./require.js", "utf8"),
...results,
"requireModule(0);",
].join("\n");
console.log(output);
if (options.output) {
fs.writeFileSync(options.output, output, "utf8");
}
테스트 실행기를 만들때 테스트 실행을 병렬화하는 것과 유사하게 코드 변환도 병렬화 할 수 있습니다. 동일한 프로세스에서 모든 코드를 변환하는 대신 성능 향상을 위해 jest-worker
를 추가할 수 있습니다. yarn add jest-worker
를 실행하고 새 worker.js
파일을 생성해 보겠습니다.
const { transformSync } = require("@babel/core");
exports.transformFile = function (code) {
const transformResult = { code: "" };
try {
transformResult.code = transformSync(code, {
plugins: ["@babel/plugin-transform-modules-commonjs"],
}).code;
} catch (error) {
transformResult.errorMessage = error.message;
}
return transformResult;
};
그런 다음 index.mjs
파일 맨 위에 Worker 인스턴스를 만듭니다.
import { Worker } from "jest-worker";
const worker = new Worker(
join(dirname(fileURLToPath(import.meta.url)), "worker.js"),
{
enableWorkerThreads: true,
}
);
이제 변환 호출 부분을 다음과 같이 수정하는 일만 남았습니다.
const results = await Promise.all(
Array.from(modules)
.reverse()
.map(async ([module, metadata]) => {
let { id, code } = metadata;
({ code } = await worker.transformFile(code));
for (const [dependencyName, dependencyPath] of metadata.dependencyMap) {
const dependency = modules.get(dependencyPath);
code = code.replace(
new RegExp(
`require\\(('|")${dependencyName.replace(/[\/.]/g, "\\$&")}\\1\\)`
),
`require(${dependency.id})`
);
}
return wrapModule(id, code);
})
);
이제 그냥 번들러가 아니라 빠른 번들러가 됐습니다. 흥미진진했어요!
최신 번들링
GitHub에서 jest-bundler
의 전체 구현부를 찾을 수 있습니다. 이 가이드를 통해 우리는 "전통적인 번들러"라고 부르는 것을 구축했습니다. 요즘 많은 번들러가 ECMAScript 모듈 또는 고급 컴파일 옵션을 즉시 지원합니다. 실제 번들러는 증분 컴파일을 수행하고, 데드 코드를 제거하고, 전체 프로그램 분석을 실행하여 불필요한 기능을 제거하거나 여러 모듈을 단일 범위로 축소할 수 있습니다. 그러나 오늘날 거의 모든 프로덕션 번들러는 런타임 및 모듈 팩토리와 함께 제공됩니다. 그리고 그것은 종속성 해결 및 모듈 직렬화의 유사한 흐름을 거칩니다. 개념은 양도할 수 있으며 자신의 번들러를 구축할 수 있도록 설정해야 합니다.
여기까지 했다면 더 깊이 파고들 수 있는 몇 가지 흥미로운 후속 프로젝트가 있습니다.
- 번들의 각 개별 파일에서
terser
와 같은 압축기(minifier)를 실행하는--minify
플래그를 추가합니다. - 변환된 파일을 저장할 캐시를 추가하고 변경된 파일만 다시 컴파일합니다.
- 중급: 소스 맵에 대해 알아보고 번들에 해당하는
.map
파일을 생성합니다. - 중급: HTTP 엔드포인트를 통해 번들 코드를 제공하는 HTTP 서버를 시작하는
--dev
옵션을 추가합니다. - 중급: HTTP 서버를 구현한 후
jest-haste-map
의 [watch
](https://github.com/facebook/jest/blob/04b75978178ccb31bccb9f9b2f8a0db2fecc271e/packages/jest-haste-map/src/ index.ts#L75) 함수를 사용하여 변경 사항을 수신하고 자동으로 다시 번들링합니다. - 고급: Import Maps에 대해 알아보고 번들러를 'require' 기반에서 기본 ESM과 함께 작동하도록 변경하십시오!
- 고급: 핫 리로딩: 먼저 등록을 취소한 다음 모듈과 모든 의존성을 다시 실행하여 모듈을 업데이트할 수 있도록 런타임을 조정합니다.
- 고급: 위의 번들러를 Rust와 같은 다른 프로그래밍 언어로 다시 작성하십시오.
지금까지 우리는 테스트 프레임워크와 번들러를 구축했습니다. 이 시리즈를 계속 확장하고 린터(Linter), 리팩토링 도구(Refactoring Tool), 포맷터(Formatter) 또는 JavaScript 공간의 모든 툴을 빌드할 수 있습니다. 이 모든 도구는 동일한 소스에서 동작하고 유사한 개념을 공유합니다. 동일한 인프라를 공유하지 못할 이유가 없습니다.
'아티클 번역' 카테고리의 다른 글
Next.js 13에서 웹 폰트 최적화 (1) | 2022.12.25 |
---|---|
[번역] Ecma 인터네셔널에서 ECMAScript 2022를 승인했습니다. 새로운 기능은 무엇인가요? (0) | 2022.12.11 |
[번역] 전문가처럼 타입스크립트 infer 사용하기 (0) | 2022.12.11 |
[번역] 문 vs 표현식 (0) | 2022.12.11 |
[번역] JavaScript 패키지 매니저 비교 - npm, Yarn 또는 pnpm? (0) | 2022.12.11 |