Writing a Loader
로더는 함수를 export 하는 node 모듈입니다. 이 함수는 리소스가 로더에 의해 변형되어야 할 때 호출됩니다. 주어진 함수는 같이 제공되는 this 컨텍스트를 사용해 Loader API에 접근할 것입니다.
Setup
다른 타입의 로더와 사용법과 예시를 살펴보기 전에, 로컬에서 개발하고 테스트할 수 있는 세 가지 방법을 봅시다.
단일 로더를 테스트하기 위해, rule object 내의 로컬 파일을 resolve하기 위한 path를 사용할 수 있습니다.
webpack.config.js
import path from "node:path";
export default {
// ...
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: path.resolve("path/to/loader.js"),
options: {
/* ... */
},
},
],
},
],
},
};다중 로더를 테스트하기 위해, webpack이 로더를 검색할 위치를 업데이트하기 위한 resolveLoader.modules 설정을 활용할 수 있습니다. 예를 들어, 프로젝트에서 로컬 /loaders 디렉터리를 생성했다고 가정해봅시다.
webpack.config.js
import path from "node:path";
const __dirname = import.meta.dirname;
export default {
// ...
resolveLoader: {
modules: ["node_modules", path.resolve(__dirname, "loaders")],
},
};그런데, 이미 로더에 대한 독립된 저장소와 패키지를 생성했다면, 테스트하려는 프로젝트와 npm link해줄 수 있습니다.
Changes to a loader need a restart
이것은 데모 프로젝트에서 로더를 개발하는 대부분의 사람들이 겪는 문제이며, 어느 쪽도 명확한 오류를 발생시키지 않습니다:
- 편집이 재빌드를 트리거하지 않습니다. webpack은 모듈의
buildDependencies에 로더의 경로를 기록하여 다음 시작 시 퍼시스턴트 캐시를 무효화합니다. 감시자는 파일, 컨텍스트, 누락된 의존성만 감시하므로 로더 파일이 변경되어도 아무것도 재빌드되지 않습니다. - 강제 재빌드도 도움이 되지 않습니다. webpack은 로더를
require()(또는 ESM 로더의 경우import())로 한 번 로드하고, Node는 프로세스가 살아있는 동안 해당 모듈을 캐시합니다. 편집 후 재빌드된 모듈은 이전 로더 함수를 받아 이전 출력을 생성하며, 오류나 경고가 없습니다.
따라서 로더에서 this.addDependency(__filename)를 호출하는 것은 해결책이 되지 않습니다. 이것은 말 그대로의 동작을 합니다. 즉, 로더 파일이 감시되어 편집하면 해당 파일을 사용하는 모듈이 재빌드됩니다. 하지만 그 재빌드는 프로세스 시작 시 로드된 로더 코드를 실행하므로, 전혀 재빌드하지 않는 것보다 더 나쁩니다. 빌드가 실제로 다시 수행하지 않은 작업을 보고하게 됩니다.
대신 각 실행이 새로운 프로세스인 자체 테스트를 통해 로더를 개발하세요(Testing 참고). 데모에서 변경 사항을 확인하려면 webpack을 재시작하세요. nodemon --watch loaders -- node_modules/.bin/webpack serve 또는 이와 유사한 래퍼를 사용하면 이를 자동화할 수 있습니다.
Simple Usage
단일 로더가 리소스에 적용되었을 때, 로더는 오직 하나의 파라미터(리소스 파일의 콘텐츠를 담고 있는 문자열)와 호출됩니다.
동기 로더는 변형된 모듈을 대표하는 하나의 값을 return 할 수 있습니다. 더 복잡한 경우에서, 로더는 this.callback(err, values...) 함수를 사용함으로써 여러 값을 반환할 수 있습니다. 에러는 this.callback 함수를 통과하거나 동기 로더에 throw 됩니다.
로더는 하나 혹은 두 개의 값을 반환해야 합니다. 첫 값은 문자열 혹은 버퍼로서의 JavaScript 코드의 결과입니다. 두 번째 선택적 값은 JavaScript 객체인 SourceMap입니다.
Complex Usage
다중 로더가 연결될 때, 역순(배열 형식에 따라 오른쪽에서 왼쪽 혹은 아래에서 위)으로 실행되는지 기억해야 합니다.
- 처음이라 불리는 마지막 로더는 원상태의 리소스의 콘텐츠가 통과될 것입니다.
- 마지막이라 불리는 첫 로더는 JavaScript와 선택적 소스맵을 반환해야 합니다.
- 사이에 있는 로더는 체인에 있는 이전 로더의 결과와 함께 실행됩니다.
다음 예시에서, foo-loader는 raw 리소스를 통과할 것이고, bar-loader는 foo-loader의 출력을 하고 최종 변형된 모듈과 필요하다면 소스맵을 반환하겠습니다.
webpack.config.js
export default {
// ...
module: {
rules: [
{
test: /\.js/,
use: ["bar-loader", "foo-loader"],
},
],
},
};Pitching loaders
로더는 일반적으로 오른쪽에서 왼쪽으로 실행됩니다.
로더는 또한 일반 단계가 시작되기 전에 왼쪽에서 오른쪽으로 실행되는 피치 함수를 내보낼 수 있습니다.
이를 통해 로더는 데이터를 자체 정상 단계로 전달하거나 나머지 로더 체인을 단락시킬 수 있습니다.
// my-loader.js
export default function (source) {
// 일반 단계 - 오른쪽에서 왼쪽으로 실행됩니다.
const prefix = this.data.value ?? "";
return `${prefix}\n${source}`;
}
export function pitch(remainingRequest, previousRequest, data) {
// 피치 단계 - 일반 로더보다 먼저 왼쪽에서 오른쪽으로 이동합니다.
data.value = "/* my-loader에 의해 처리됨 */";
}Short-circuiting the loader chain
pitch 함수가 값을 반환하면 webpack은 나머지
오른쪽 로더를 건너뛰고 즉시 역순으로 진행합니다. 이는 모듈 코드를 조기에 생성하고 일반적인 단계를 건너뛰려는 경우에 유용할 수 있습니다.
export function pitch(remainingRequest) {
return `
import style from ${JSON.stringify(`!!${remainingRequest}`)};
const el = document.createElement("style");
el.textContent = style;
document.head.appendChild(el);
`;
}Guidelines
다음 가이드라인은 로더를 작성할 때 지켜져야 합니다. 중요도 측면에서 순서가 지정되며, 일부는 특정 시나리오에서 적용됩니다. 자세한 내용은 세부 섹션을 참고하세요.
- 단순하게 하세요.
- chaining을 활용하세요.
- 모듈의 출력을 내보내세요.
- stateless인지 확인하세요.
- 로더 유틸리티를 사용하세요.
- 로더 의존성을 표시하세요.
- 모듈 의존성을 해석하세요.
- 공통된 코드를 추출하세요.
- 절대 경로를 지양하세요.
- peer dependencies를 사용하세요.
Simple
로더는 오직 하나의 일만 수행해야 합니다. 로더를 유지 보수하기 쉽게 만들 뿐만 아니라, 더 많은 시나리오에서 사용할 수 있도록 체인으로 묶을 수 있습니다.
Chaining
로더가 함께 연결될 수 있다는 사실을 활용하세요. 다섯 가지의 일을 처리하는 하나의 로더를 작성하는 것 대신에, 수고를 덜 다섯 개의 간단한 로더들을 작성하세요. 로더를 분할하는 것은 각각의 로더를 간단히 유지할 수 있을 뿐만 아니라 생각지도 못한 일에 사용될 수 있게 해줄 수 있습니다.
로더 옵션 또는 쿼리 파라미터를 통해 지정된 데이터로 템플릿 파일을 렌더링하는 경우를 생각해 봅시다. 소스에서 템플릿을 컴파일 하는 싱글 로더로 작성되고, 그를 실행하고 HTML 코드를 포함하고 있는 문자열을 export 하는 모듈을 반환할 수도 있습니다. 하지만, 가이드라인에 따르면, apply-loader는 다른 오픈 소스 로더와 연결될 수 있을 때만 존재합니다.
pug-loader: 템플릿을 함수를 export 하는 모듈로 전환합니다.apply-loader: 로더 옵션으로 함수를 실행시키고 raw HTML을 반환합니다.
webpack이 나머지를 처리합니다. 체인에서 나오는 HTML은 내장 HTML 지원으로 처리되므로 모듈로 변환할 필요가 없습니다.
Modular
출력을 모듈식으로 유지하세요. 모듈을 생성한 로더는 일반 모듈로써 같은 디자인 요소를 반영해야 합니다.
Stateless
로더가 모듈 변형 사이에 상태를 유지하지 않는지 확인하세요. 각 실행은 항상 컴파일된 다른 모듈 및 동일한 모듈의 이전 컴파일과는 독립적이어야 합니다.
Loader Utilities
다양한 유용한 도구를 제공하는 loader-utils 패키지를 활용하세요. loader-utils과 함께, schema-utils 패키지는 로더 옵션 유효성에 기반한 일관된 JSON 스키마를 위해 사용돼야 합니다. 여기 둘 다 활용하는 간단한 예시가 있습니다.
loader.js
import { urlToRequest } from "loader-utils";
import { validate } from "schema-utils";
const schema = {
type: "object",
properties: {
test: {
type: "string",
},
},
};
export default function (source) {
const options = this.getOptions();
validate(schema, options, {
name: "Example Loader",
baseDataPath: "options",
});
console.log("The request path", urlToRequest(this.resourcePath));
// 소스에 몇 가지의 변형을 적용하세요...
return `export default ${JSON.stringify(source)}`;
}Data Sharing
webpack에서는 로더를 체인화하여 체인의 후속 로더와 데이터를 공유할 수 있습니다. 이를 위해서는 this.callback 메소드를 사용하여 콘텐츠(소스 코드)와 함께 데이터를 전달할 수 있습니다. 로더의 기본 내보내기 함수에서는 this.callback의 네 번째 인수를 사용하여 데이터를 전달할 수 있습니다.
export default function (source) {
const options = this.getOptions();
// this.callback의 네 번째 인수를 사용해 데이터를 전달합니다.
this.callback(null, `export default ${JSON.stringify(source)}`, null, {
some: data,
});
}위의 예에서 this.callback의 네 번째 인수에 있는 some 속성은 다음 체인 로더에 데이터를 전달하는 데 사용됩니다.
Sharing data with a plugin
로더는 모듈 소스만 반환할 수 있으므로, 다른 무언가를 추출하는 로더(예: 연결할 스타일 조각, 스프라이트에 묶을 아이콘)는 결합된 애셋을 내보내는 플러그인에 해당 데이터를 전달할 다른 방법이 필요합니다. 모듈의 buildInfo에 저장하세요:
loader.js
export default function (source) {
this._module.buildInfo.myLoaderData = collectIcons(source);
return source;
}그러면 플러그인은 컴파일의 모듈에서 해당 데이터를 읽어옵니다:
plugin.js
compiler.hooks.thisCompilation.tap("MyPlugin", (compilation) => {
compilation.hooks.processAssets.tap(
{
name: "MyPlugin",
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
const collected = [];
for (const module of compilation.modules) {
const data = module.buildInfo.myLoaderData;
if (data) collected.push(...data);
}
compilation.emitAsset(
"sprite.svg",
new compiler.webpack.sources.RawSource(buildSprite(collected)),
);
},
);
});buildInfo는 모듈과 함께 직렬화되므로, 데이터는 퍼시스턴트 캐시에서도 유지되며 로더 자체가 다시 실행되지 않는 빌드에서도 존재합니다. 이것이 로더 자체의 모듈 스코프 변수 대신 이를 사용하는 이유입니다. 캐시된 빌드에서는 그 변수가 비어있게 됩니다. 직렬화 가능한 순수한 값만 저장하고, 두 로더가 충돌하지 않도록 속성에 네임스페이스를 부여하세요.
Loader Dependencies
로더가 외부 리소스(예를 들면, 파일 시스템으로부터 읽는 것)를 사용한다면, 반드시 표기해야 합니다. 이 정보는 캐시 가능한 로더를 무효로 하고 읽기 모드로 다시 컴파일하기 위해 사용됩니다. addDependency 메소드를 사용해서 완수하는 방법의 간략한 예시가 있습니다.
loader.js
import path from "node:path";
export default function (source) {
const callback = this.async();
const headerPath = path.resolve("header.js");
this.addDependency(headerPath);
fs.readFile(headerPath, "utf8", (err, header) => {
if (err) return callback(err);
callback(null, `${header}\n${source}`);
});
}Module Dependencies
모듈의 타입에 따라, 특정한 의존성을 사용하는 다른 스키마가 있을지도 모릅니다. 예를 들어, CSS에서 @import와 url(...) 구문이 사용됩니다. 이런 의존성은 모듈 시스템에 의해 결정되어야 합니다.
둘 중 하나의 방법으로 할 수 있습니다.
require구문으로 변형- 경로를 결정하기 위해
this.resolve함수를 사용
css-loader는 처음으로 접근하기에 좋은 예시입니다. 다른 스타일시트에 대해서 @import 구문을 require로 대체하고, 참조된 파일에 대해서 url(...)을 require로 바꿈으로써, 의존성들을 require으로 변형합니다.
less-loader의 예시에서, 모든 .less파일은 변수와 믹스인 추정을 위해 반드시 한 번에 컴파일되어야 하므로 각 @import를 require로 바꿀 수 없습니다. 그러므로, less-loader는 사용자 경로 해석 로직으로 더 적은 컴파일러를 확장합니다. 그런 다음 두 번째 접근 방식인 this.resolve를 활용하여 webpack을 통해 의존성을 해결할 수 있습니다.
Hot Module Replacement
로더는 생성하는 코드를 통해 HMR에 참여합니다. webpack은 런타임에 변경된 모듈을 교체하며, 생성된 모듈은 module.hot.accept를 호출하여 새 버전으로 무엇을 할지 결정합니다. 사이드 이펙트를 발생시키는 로더(예: 스타일시트 설치, 템플릿 등록, 스토어 초기화)는 이 호출을 직접 내보내야 합니다. 그렇지 않으면 파일을 편집할 때 즉시 적용되는 대신 전체 페이지가 리로드됩니다.
this.hot은 해당 코드를 내보낼 가치가 있는지 알려줍니다. HotModuleReplacementPlugin이 적용될 때만 true이므로, 이 조건으로 추가 코드를 보호하고 프로덕션 출력을 깔끔하게 유지하세요.
export default function (source) {
const installed = `install(${JSON.stringify(transform(source))});`;
if (!this.hot) return installed;
return `${installed}
if (import.meta.webpackHot) {
import.meta.webpackHot.accept();
}`;
}가져오는 측에 맡기지 않고 생성된 모듈에서 accept 하는 것이 업데이트가 엔트리 포인트까지 버블링되어 전체 리로드를 강제하는 것을 막습니다.
생성된 코드에서는 module.hot 대신 import.meta.webpackHot을 사용하세요. webpack은 모듈이 자동 감지되든 엄격한 ESM이든 상관없이 이를 인식합니다. 반면 module.hot은 엄격한 ESM 모듈에서 사용할 수 없습니다. 정의되지 않은 module에 대한 참조로 컴파일되어 오류 없이 빌드되지만 런타임에 예외가 발생합니다. 로더는 출력이 어떤 형태가 될지 제어할 수 없으며, 이는 처리하는 파일에 따라 결정됩니다.
Common Code
- 로더가 처리하는 모든 모듈에 공통 코드를 생성하지 마세요. 대신 로더 내에 런타임 파일을 생성하고
import(또는require)하여 공유 모듈로 사용하세요.
src/loader-runtime.js
import { someOtherModule } from "./some-other-module.js";
export default function runtime(params) {
const x = params.y * 2;
return someOtherModule(params, x);
}src/loader.js
import runtime from "./loader-runtime.js";
export default function loader(source) {
// 커스텀 로더 로직
return `${runtime({
source,
y: Math.random(),
})}`;
}Absolute Paths
절대 경로는 프로젝트의 루트가 이동될 때 해싱이 끊어지므로 모듈 코드에 삽입하지 마세요. 아래 코드를 사용하여 절대 경로를 상대 경로로 변환할 수 있습니다.
// `loaderContext`는 로더 함수 내부의 `this`와 동일합니다.
JSON.stringify(
loaderContext.utils.contextify(
loaderContext.context || loaderContext.rootContext,
request,
),
);Peer Dependencies
작업하고 있는 로드가 다른 패키지의 간단한 wrapper라면, peerDependency로써 패키지를 포함할 수 있습니다. 이런 접근은 애플리케이션 개발자가 필요하다면 package.json안의 정확한 버전을 지정하도록 해줍니다.
예를 들어, sass-loader는 peer dependency와 같이 node-sass를 지정합니다.
{
"peerDependencies": {
"node-sass": "^4.0.0"
}
}Testing
로더를 작성하고 위의 가이드라인에 따라 로컬에서 실행하도록 설정했습니다. 다음은 무엇일까요? 로더가 예상대로 작동하는지 확인하기 위해 유닛 테스트 예시를 살펴보겠습니다. 이를 위해 Jest 프레임워크를 사용할 예정입니다. 또한 import/export와 async/await를 사용할 수 있는 babel-jest와 일부 프리셋도 설치할 것입니다. 먼저 devDependencies로 설치하고 저장하겠습니다.
npm install --save-dev jest babel-jest @babel/core @babel/preset-envbabel.config.js
export default {
presets: [
[
"@babel/preset-env",
{
targets: {
node: "current",
},
},
],
],
};로더가 .txt파일을 처리하고 어느 인스턴스의 [name]을 로더에 주어진 name 옵션으로 바꿀 것입니다. 그러고 나서, 기본 출력으로써 문자를 담고 있는 유효한 JavaScript 모듈을 출력할 것입니다.
src/loader.js
export default function loader(source) {
const options = this.getOptions();
source = source.replaceAll("[name]", options.name);
return `export default ${JSON.stringify(source)}`;
}다음 파일을 처리하기 위해 이 로더를 사용하겠습니다.
test/example.txt
Hey [name]!webpack을 실행하기 위해 Node.js API와 memfs 사용할 다음 단계에 세심한 주의를 기울이세요. 이는 디스크에 output을 출력하는 것을 막고, 변형한 모듈을 파악하기 위해 사용할 수 있는 stats 데이터에 접근할 수 있도록 할 것입니다.
npm install --save-dev webpack memfstest/compiler.js
import path from "node:path";
import { Volume, createFsFromVolume } from "memfs";
import webpack from "webpack";
const __dirname = import.meta.dirname;
export default (fixture, options = {}) => {
const compiler = webpack({
context: __dirname,
entry: `./${fixture}`,
output: {
path: path.resolve(__dirname),
filename: "bundle.js",
},
module: {
rules: [
{
test: /\.txt$/,
use: {
loader: path.resolve(__dirname, "../src/loader.js"),
options,
},
},
],
},
});
compiler.outputFileSystem = createFsFromVolume(new Volume());
compiler.outputFileSystem.join = path.join.bind(path);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) reject(err);
if (stats.hasErrors()) reject(stats.toJson().errors);
resolve(stats);
});
});
};이제, 마지막으로, 테스트를 작성하고 npm script에 실행하도록 추가할 수 있습니다.
test/loader.test.js
/**
* @jest-environment node
*/
import compiler from "./compiler.js";
test("Inserts name and outputs JavaScript", async () => {
const stats = await compiler("example.txt", { name: "Alice" });
const output = stats.toJson({ source: true }).modules[0].source;
expect(output).toBe('export default "Hey Alice!\\n"');
});package.json
{
"scripts": {
"test": "jest"
},
"jest": {
"testEnvironment": "node"
}
}모든 것이 갖춰져 있으면, 그것을 실행하고 새로운 로더가 테스트를 통과했는지 볼 수 있습니다.
PASS test/loader.test.js
✓ Inserts name and outputs JavaScript (229ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.853s, estimated 2s
Ran all test suites.성공했습니다! 이제 자신만의 로더를 개발, 테스트 및 배포할 준비가 되어 있어야 합니다. 창작물을 커뮤니티의 다른 사람들과 공유해 주시기 바랍니다.