Writing a Loader

로더는 함수를 export 하는 node 모듈입니다. 이 함수는 리소스가 로더에 의해 변형되어야 할 때 호출됩니다. 주어진 함수는 같이 제공되는 this 컨텍스트를 사용해 Loader API에 접근할 것입니다.

Setup

다른 타입의 로더와 사용법과 예시를 살펴보기 전에, 로컬에서 개발하고 테스트할 수 있는 세 가지 방법을 봅시다.

단일 로더를 테스트하기 위해, rule object 내의 로컬 파일을 resolve하기 위한 path를 사용할 수 있습니다.

webpack.config.js

const path = require('path');

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: path.resolve('path/to/loader.js'),
            options: {
              /* ... */
            },
          },
        ],
      },
    ],
  },
};

다중 로더를 테스트하기 위해, webpack이 로더를 검색할 위치를 업데이트하기 위한 resolveLoader.modules 설정을 활용할 수 있습니다. 예를 들어, 프로젝트에서 로컬 /loaders 디렉터리를 생성했다고 가정해봅시다.

webpack.config.js

const path = require('path');

module.exports = {
  //...
  resolveLoader: {
    modules: ['node_modules', path.resolve(__dirname, 'loaders')],
  },
};

그런데, 이미 로더에 대한 독립된 저장소와 패키지를 생성했다면, 테스트하려는 프로젝트와 npm link해줄 수 있습니다.

Simple Usage

단일 로더가 리소스에 적용되었을 때, 로더는 오직 하나의 파라미터(리소스 파일의 콘텐츠를 담고 있는 문자열)와 호출됩니다.

동기 로더는 변형된 모듈을 대표하는 하나의 값을 return 할 수 있습니다. 더 복잡한 경우에서, 로더는 this.callback(err, values...) 함수를 사용함으로써 여러 값을 반환할 수 있습니다. 에러는 this.callback 함수를 통과하거나 동기 로더에 throw 됩니다.

로더는 하나 혹은 두 개의 값을 반환해야 합니다. 첫 값은 문자열 혹은 버퍼로서의 JavaScript 코드의 결과입니다. 두 번째 선택적 값은 JavaScript 객체인 SourceMap입니다.

Complex Usage

다중 로더가 연결될 때, 역순(배열 형식에 따라 오른쪽에서 왼쪽 혹은 아래에서 위)으로 실행되는지 기억해야 합니다.

  • 처음이라 불리는 마지막 로더는 원상태의 리소스의 콘텐츠가 통과될 것입니다.
  • 마지막이라 불리는 첫 로더는 JavaScript와 선택적 소스맵을 반환해야 합니다.
  • 사이에 있는 로더는 체인에 있는 이전 로더의 결과와 함께 실행됩니다.

다음 예시에서, foo-loader는 raw 리소스를 통과할 것이고, bar-loaderfoo-loader의 출력을 하고 최종 변형된 모듈과 필요하다면 소스맵을 반환하겠습니다.

webpack.config.js

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js/,
        use: ['bar-loader', 'foo-loader'],
      },
    ],
  },
};

Guidelines

다음 가이드라인은 로더를 작성할 때 지켜져야 합니다. 중요도 측면에서 순서가 지정되며, 일부는 특정 시나리오에서 적용됩니다. 자세한 내용은 세부 섹션을 참고하세요.

  • 단순하게 하세요.
  • chaining을 활용하세요.
  • 모듈의 출력을 내보내세요.
  • stateless인지 확인하세요.
  • 로더 유틸리티를 사용하세요.
  • 로더 의존성을 표시하세요.
  • 모듈 의존성을 해석하세요.
  • 공통된 코드를 추출하세요.
  • 절대 경로를 지양하세요.
  • peer dependencies를 사용하세요.

Simple

로더는 오직 하나의 일만 수행해야 합니다. 로더를 유지 보수하기 쉽게 만들 뿐만 아니라, 더 많은 시나리오에서 사용할 수 있도록 체인으로 묶을 수 있습니다.

Chaining

로더가 함께 연결될 수 있다는 사실을 활용하세요. 다섯 가지의 일을 처리하는 하나의 로더를 작성하는 것 대신에, 수고를 덜 다섯 개의 간단한 로더들을 작성하세요. 로더를 분할하는 것은 각각의 로더를 간단히 유지할 수 있을 뿐만 아니라 생각지도 못한 일에 사용될 수 있게 해줄 수 있습니다.

로더 옵션 또는 쿼리 파라미터를 통해 지정된 데이터로 템플릿 파일을 렌더링하는 경우를 생각해 봅시다. 소스에서 템플릿을 컴파일 하는 싱글 로더로 작성되고, 그를 실행하고 HTML 코드를 포함하고 있는 문자열을 export 하는 모듈을 반환할 수도 있습니다. 하지만, 가이드라인에 따르면, apply-loader는 다른 오픈 소스 로더와 연결될 수 있을 때만 존재합니다.

  • pug-loader: 템플릿을 함수를 export 하는 모듈로 전환합니다.
  • apply-loader: 로더 옵션으로 함수를 실행시키고, raw HTML을 반환합니다.
  • html-loader: HTML을 받아들이고 유효한 JavaScript 모듈을 출력합니다.

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에서는 로더를 체인화하여 체인의 후속 로더와 데이터를 공유할 수 있습니다. 이를 위해서는 raw 로더에서 this.callback 메소드를 사용하여 콘텐츠(소스 코드)와 함께 데이터를 전달할 수 있습니다. Raw 로더의 기본 내보내기 함수에서는 this.callback의 네 번째 인수를 사용하여 데이터를 전달할 수 있습니다.

export default function (source) {
  const options = getOptions(this);
  // Pass data using the fourth argument of this.callback
  this.callback(null, `export default ${JSON.stringify(source)}`, null, {
    some: data,
  });
}

위의 예에서 this.callback의 네 번째 인수에 있는 some 속성은 다음 체인 로더에 데이터를 전달하는 데 사용됩니다.

Loader Dependencies

로더가 외부 리소스(예를 들면, 파일 시스템으로부터 읽는 것)를 사용한다면, 반드시 표기해야 합니다. 이 정보는 캐시 가능한 로더를 무효로 하고 읽기 모드로 다시 컴파일하기 위해 사용됩니다. addDependency 메소드를 사용해서 완수하는 방법의 간략한 예시가 있습니다.

loader.js

import path from 'path';

export default function (source) {
  var callback = this.async();
  var headerPath = path.resolve('header.js');

  this.addDependency(headerPath);

  fs.readFile(headerPath, 'utf-8', function (err, header) {
    if (err) return callback(err);
    callback(null, header + '\n' + source);
  });
}

Module Dependencies

모듈의 타입에 따라, 특정한 의존성을 사용하는 다른 스키마가 있을지도 모릅니다. 예를 들어, CSS에서 @importurl(...) 구문이 사용됩니다. 이런 의존성은 모듈 시스템에 의해 결정되어야 합니다.

둘 중 하나의 방법으로 할 수 있습니다.

  • require 구문으로 변형
  • 경로를 결정하기 위해 this.resolve 함수를 사용

css-loader는 처음으로 접근하기에 좋은 예시입니다. 다른 스타일시트에 대해서 @import 구문을 require로 대체하고, 참조된 파일에 대해서 url(...)require로 바꿈으로써, 의존성들을 require으로 변형합니다.

less-loader의 예시에서, 모든 .less파일은 변수와 믹스인 추정을 위해 반드시 한 번에 컴파일되어야 하므로 각 @importrequire로 바꿀 수 없습니다. 그러므로, less-loader는 사용자 경로 해석 로직으로 더 적은 컴파일러를 확장합니다. 그런 다음 두 번째 접근 방식인 this.resolve를 활용하여 webpack을 통해 의존성을 해결할 수 있습니다.

Common Code

로더가 처리하는 모든 모듈 안의 공통적인 코드가 생성되지 않도록 하세요. 대신, 로더 안에 런타임 파일을 만들고, 해당 공유 모듈에 require를 생성하세요.

src/loader-runtime.js

const { someOtherModule } = require('./some-other-module');

module.exports = 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/exportasync/await를 사용할 수 있는 babel-jest와 일부 프리셋도 설치할 것입니다. 먼저 devDependencies로 설치하고 저장하겠습니다.

npm install --save-dev jest babel-jest @babel/core @babel/preset-env

babel.config.js

module.exports = {
  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.replace(/\[name\]/g, options.name);

  return `export default ${JSON.stringify(source)}`;
}

다음 파일을 처리하기 위해 이 로더를 사용하겠습니다.

test/example.txt

Hey [name]!

webpack을 실행하기 위해 Node.js APImemfs 사용할 다음 단계에 세심한 주의를 기울이세요. 이는 디스크에 output을 출력하는 것을 막고, 변형한 모듈을 파악하기 위해 사용할 수 있는 stats 데이터에 접근할 수 있도록 할 것입니다.

npm install --save-dev webpack memfs

test/compiler.js

import path from 'path';
import webpack from 'webpack';
import { createFsFromVolume, Volume } from 'memfs';

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.

성공했습니다! 이제 자신만의 로더를 개발, 테스트 및 배포할 준비가 되어 있어야 합니다. 창작물을 커뮤니티의 다른 사람들과 공유해 주시기 바랍니다.

7 Contributors

asulaimanmichael-ciniawskybyzykanikethsahajamesgeorge007chenxsandev-itsheng