Webpack 비동기 지연 로딩

✍🏼 작성일 2016년 05월 02일   
❗️ 참고: 이 글이 작성된 지 이미 일이 지났습니다. 시의성에 유의하세요

서문

webpack은 비동기 로딩을 구현하고자 합니다. 즉, 주요 모듈을 먼저 로드하고, 특정 모듈이나 여러 모듈(즉, 번들된 chunk)이 필요할 때 요청을 보내 로드하는 방식입니다.

이렇게 하는 목적은 당연히 페이지의 초기 로딩 속도를 높이기 위함이지만, 추가적인 요청을 보내는 것은 피할 수 없습니다. 이 두 가지는 본래 양립하기 어려운 관계에 있으며, 여기서는 비동기 로딩의 세부 사항을 설명하겠습니다.

본문

구현은 주로 require.ensure([], callback)이라는 것을 통해 이루어집니다. 솔직히 말해 이 것을 주목하게 된 이유는 webpack.config.jsoutput 필드에 chunkFilename이라는 필드가 있었기 때문입니다. 꼼꼼하게 파고드는 습관 때문에 이 필드와 filename 필드의 차이점을 알아보고 싶었고, 검색해 보니 filename (가정해 bundle.js)는 페이지에 필요한 모든 js를 번들링하여 최종적으로 생성된 전체 js을 생성합니다(물론 다중 페이지일 때 공통 모듈을 추출할 수 있지만, 이는 이번 주제의 핵심이 아닙니다). 반면 chunkFilename은 진입점이 아닌(entry에 나열된 필드) chunk 파일을 번들링하여 생성된 파일로, 주로 모듈을 비동기적으로 필요할 때 로드하는 데 사용됩니다.

이러한 파일들은 bundle.js에 번들링되지 않으며, 일부(전체가 아닌) 모듈에 의존하고 동시에 비동기 로딩이 필요하기 때문에 require.ensure을 사용하여 추가적인 js로 번들링됩니다. 그리고 이러한 js은 최종 bundle.js를 통해 script 태그를 생성한 후 append에 페이지에 로드됩니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// This file contains only the entry chunk.
// The chunk loading function for additional chunks
__webpack_require__.e = function requireEnsure(chunkId, callback) {
// "0" is the signal for "already loaded"
if (installedChunks[chunkId] === 0)
return callback.call(null, __webpack_require__);

// an array means "currently loading".
if (installedChunks[chunkId] !== undefined) {
installedChunks[chunkId].push(callback);
} else {
// start chunk loading
installedChunks[chunkId] = [callback];
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.type = 'text/javascript';
script.charset = 'utf-8';
script.async = true;

script.src = __webpack_require__.p + '' + ({}[chunkId] || chunkId) + '.js';
head.appendChild(script);
}
};

좋습니다, 이 모든 것은 이해하기 쉽지만, 공식 문서]를 확인할 때 몇 가지 주의할 세부 사항을 발견했습니다.

_CommonJS_와 AMD require 시의 차이점

CommonJSrequire.ensure([''], callback)을 사용하여 비동기 로딩 모듈을 처리합니다. AMD는 일반적인 AMD 모듈과 마찬가지로 require 배열 의존 형태로 처리됩니다 require([''], callback).

하지만 CommonJS이 배열의 모듈을 로드할 때는 로드만 하고 실행하지 않습니다. callback에서 다시 require을 호출해야 실행됩니다:

1
2
3
4
5
6
7
require(['./other/ensure.js', './other/ensure2.js'], function () {
var ensure = require('./other/ensure.js');
var ensure2 = require('./other/ensure2.js');

module1();
module2();
}, chunkFilename);

require.ensure 메서드는 콜백 호출 시 dependencies의 모든 의존성이 동기적으로 요청될 수 있도록 보장합니다. require 함수의 구현이 콜백의 매개변수로 전송됩니다.

또한 이 callback의 매개변수는 require 인터페이스를 구현한 함수입니다(맞다면 단지 require 함수의 참조일 것입니다).

chunkFilenameoutputchunkFilename 설정에 의해 덮어씌워질 수 있습니다.

반면 AMD는 일반적인 의존성 전치 방식이므로 require 시점에 모듈을 실행합니다:

1
2
3
4
5
6
7
require(['./other/ensure.js', './other/ensure2.js'], function (
ensure,
ensure2
) {
ensure();
ensure2();
});

좋습니다, AMD의 예시는 익숙하지 않으니, CommonJS을 예로 들어 몇 가지 세부 사항을 설명하겠습니다.

먼저, require.ensurecallback을 전달하면 콜백 함수에서 require로 가져온 모듈도 모두 최종 비동기 로딩 파일에 번들링됩니다.

chunk 번들링 최적화 전략

  1. chunk이 동일한 모듈을 포함하는 경우, 이들은 하나로 병합됩니다.
  2. 모듈이 chunk의 모든 상위 chunk에서 사용 가능한 경우, 해당 모듈은 chunk에서 제거됩니다.
  3. chunk이 다른 chunk의 모든 모듈을 포함하는 경우, 최종적으로 더 많은 module를 포함하는 chunk이 번들링됩니다. 이 규칙은 chunk이 여러 chunk의 모든 module을 포함하는 경우에도 적용됩니다.

두 번째 규칙은 이해하기 어려울 수 있습니다. 설명하자면, 진입 파일 A.jsb 모듈이 포함되어 있고, require.ensure을 사용하여 생성된 chunk.js 파일에도 이 b 모듈이 포함되어 있는 경우입니다. require.ensureA.js 파일에서 호출되므로 A.js은 이 chunk.js의 상위 chunk로 간주됩니다. 따라서 최종적으로 생성된 chunk.js에 포함된 b 모듈 내용은 제거됩니다. 在所有父级 chunk 都可用은 첫 번째 규칙에서 설명한 경우를 가리킵니다: 여러 chunk이 동일한 module을 포함하는 경우, 최종적으로 하나의 bundle.js만 생성됩니다. 그러나 이로 인해 이 chunk이 여러 상위 chunk (즉, entry에 해당하는 chunk 파일)을 가질 수 있습니다.

검증해 보겠습니다:

진입 파일 app.js의 코드:

1
2
3
4
5
6
7
8
require('../other/if_be_remove.js')();
require.ensure(
['../other/ensure.js'],
function () {
require('../other/ensure.js')();
},
'love'
);

다른 진입 파일 app2.js의 코드:

1
2
3
4
5
6
7
8
require('../other/if_be_remove.js')();
require.ensure(
['../other/ensure2.js'],
function () {
require('../other/ensure2.js')();
},
'hate'
);

ensure.js의 코드:

1
2
3
4
require('./if_be_remove.js')();
module.exports = function () {
console.log("i'm be ensure!");
};

ensure2.js의 코드:

1
2
3
4
require('./if_be_remove.js')();
module.exports = function () {
console.log("i'm be ensure2!");
};

마지막으로, 하위 chunk와 상위 chunk가 모두 존재하는 if_be_remove.js의 코드:

1
2
3
module.exports = function () {
console.log('im be removed!');
};

Chrome 브라우저 콘솔에서 Network에 로드된 js의 내용을 확인해보세요(여기서는 [id].[name].js 명명 규칙을 사용함)

app.js 페이지:

webpack-async

app2.js 페이지

webpack-async

보시다시피, if_be_remove.js이 두 개의 chunk1-love.js3.hate.js에서 참조되면서 동시에 이 두 chunk의 상위인 app.jsapp2.js에서도 참조되기 때문에, 이 두 chunk에서는 if_be_remove.js 코드가 나타나지 않습니다.

보충: chunk 개념과 정의

여기서 추가로 설명하자면, chunk이란 하나 또는 여러 개의 module로 구성된 독립적인 js 파일을 말하며, chunk은 다음과 같은 유형으로 나뉩니다:

  1. Entry Chunks: Entry Chunks은 가장 흔히 접하는 Chunks 유형으로, 우리가 작성한 비즈니스 로직 관련 코드(대부분의 경우 공통 chunks로 추출되지 않는 고유한 코드)를 포함하며, 일반적으로 Initial Chunks 로딩이 완료된 후에 실행됩니다(또는 module 번호가 0인 모듈을 만날 때 실행됨).
  2. Normal Chunks: Normal Chunks은 주로 애플리케이션 런타임에 동적으로 로드되는 모듈을 의미하며, WebpackJSONP과 같은 적절한 로더를 생성하여 동적 로드를 수행합니다.
  3. Initial Chunks: Initial Chunks은 근본적으로 Normal Chunks이지만, 애플리케이션 초기화 시점에 로딩이 완료됩니다. 이 유형의 ChunksCommonsChunkPlugin에 의해 생성되며, 전역 모듈 위치 정보를 포함하고 있습니다. Entry chunks의 코드 실행은 이 chunk에 의존하므로, 이 js을 우선적으로 로드해야 합니다.

앞서 예시에서 공통 js으로 패키징되어 전체 또는 일부 페이지에서 사용되는 bundule.jsInitial Chunks이며, 현재 페이지만 사용되는 chunk 예를 들어 app.xxxxxx.jsEntry Chunks입니다. require.ensure을 통해 비동기적으로 로드되는 chunk 예를 들어 3-hate 1-loveNormal Chunks입니다.

- EOF -
이 글의 최초 게시: Webpack 비동기 지연 로딩 - Xheldon Blog