Shimming
webpack 컴파일러는 ES2015 모듈, CommonJS 또는 AMD로 작성된 모듈을 이해할 수 있습니다. 그러나 일부 써드 파티 라이브러리는 전역 종속성을 필요로 할 수 있습니다. (예: jQuery의 경우 $) 라이브러리는 내보낼 필요가 있는 전역 변수를 만들 수도 있습니다. 이러한 "깨진 모듈은" shimming이 작동하는 하나의 인스턴스입니다.
shimming 이 유용한 또 다른 경우는 더 많은 사용자를 지원하기 위해 브라우저 기능을 폴리필하려는 경우입니다. 이 경우 패치가 필요한 브라우저에만 해당 폴리필을 제공할 수 있습니다. (예: 요청 시 로드)
해당 글에서는 이러한 두 가지 사용 사례를 모두 살펴봅니다.
Shimming Globals
전역 변수 shimming의 첫 번째 사용 사례부터 시작하겠습니다. 시작하기 전에 프로젝트를 다시 한번 살펴보겠습니다.
프로젝트
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ └── index.html
├── /src
│ └── index.js
└── /node_modules우리가 사용했던 lodash 패키지를 기억하시나요? 데모 목적으로 애플리케이션에서 전역적으로 제공하고 싶다고 가정해 보겠습니다. 이를 위해 ProvidePlugin을 사용할 수 있습니다.
ProvidePlugin은 webpack을 통해 컴파일된 모든 모듈에서 패키지를 변수로 사용할 수 있게 해줍니다. 변수가 사용되는 것을 webpack에서 확인하면 최종 번들에 주어진 패키지를 포함합니다. lodash에 대한 import문을 제거하고 플러그인을 통해 제공해보겠습니다.
src/index.js
-import _ from 'lodash';
-
function component() {
const element = document.createElement('div');
- // 이제 이 스크립트로 Lodash를 가져옵니다.
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'url';
+import webpack from "webpack";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
+ plugins: [
+ new webpack.ProvidePlugin({
+ _: 'lodash',
+ }),
+ ],
};여기서 우리가 실질적으로 한 것은 webpack에게 알려주는 것입니다.
변수
_의 인스턴스가 하나 이상 존재한다면lodash패키지를 포함하고 필요한 모듈에 제공합니다.
빌드를 실행해도 동일한 출력이 표시되어야 합니다.
$ npm run build
..
[webpack-cli] Compilation finished
asset main.js 69.1 KiB [emitted] [minimized] (name: main) 1 related asset
runtime modules 344 bytes 2 modules
cacheable modules 530 KiB
./src/index.js 191 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 2910 ms또한 ProvidePlugin에서 "배열 경로"(예: [module, child, ...children?])를 구성하여 모듈의 일부분만 내보낼 수 있습니다. 호출될 때마다 lodash에서 join 메소드만 제공하고 싶다고 가정해 보겠습니다.
src/index.js
function component() {
const element = document.createElement('div');
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.innerHTML = join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());webpack.config.js
import path from "node:path";
import webpack from "webpack";
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new webpack.ProvidePlugin({
- _: 'lodash',
+ join: ['lodash', 'join'],
}),
],
};lodash 라이브러리의 나머지는 삭제되므로 트리 쉐이킹이 잘 수행됩니다.
Granular Shimming
일부 레거시 모듈은 this가 window 객체에 의존합니다. index.js를 업데이트해 보겠습니다.
+// Assume we are in the context of `window`
+this.alert("Hmmm, this probably isn't a great idea...");
+
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());이것은 모듈이 CommonJS 컨텍스트에서 실행될 때 문제가 됩니다. 모듈 자체의 this가 전역 객체가 아니라 module.exports이기 때문입니다. 전역 객체로 호출되는 함수로 모듈을 래핑하면 이를 해결할 수 있으며, 로더가 필요 없습니다. NormalModule의 processResult 훅은 플러그인에 로더가 생성한 것을 전달하고 대체물을 받으므로, webpack이 파싱하기 전에 파일 주위에 코드를 추가하는 것은 설정에 넣을 수 있을 만큼 짧은 플러그인으로 충분합니다.
webpack의 add-imports 예제가 바로 그 플러그인입니다. 위의 래퍼, 파일이 직접 명시하지 않는 사이드 이펙트 import, 실제 바인딩을 처리합니다.
Global Exports
라이브러리가 사용자가 사용할 것으로 예상하는 전역 변수를 생성한다고 가정해 보겠습니다. 이를 증명하기 위해 작은 모듈을 추가할 수 있습니다.
프로젝트
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
+ │ ├── globals.js
└── /node_modulessrc/globals.js
const file = "blah.txt";
const helpers = {
test() {
console.log("test something");
},
parse() {
console.log("parse something");
},
};소스 코드에서 이러한 작업을 수행할 가능성은 낮지만, 위에 표시된 코드와 유사한 코드를 포함하는 오래된 라이브러리를 만날 수 있습니다. 아무것도 내보내지 않는 파일에는 export를 추가해야 하는데, 이는 파일의 반대편에서 동일한 훅을 사용합니다. webpack의 add-exports 예제가 바로 그 플러그인입니다.
위의 globals.js에 대해 module.exports = { file, parse: helpers.parse };를 추가하면 file을 file로, helpers.parse를 parse로 내보냅니다. 엔트리 스크립트(예: src/index.js)에서 const { file, parse } = require('./globals.js');를 사용할 수 있으며 원활하게 작동합니다.
Loading Polyfills
지금까지 논의한 대부분은 레거시 패키지 처리와 관련이 있습니다. 두 번째 주제인 폴리필로 넘어가겠습니다.
폴리필을 로드하는 방법에는 여러 가지가 있습니다. 예를 들어 babel-polyfill을 포함하려면 다음과 같이하면 됩니다.
npm install --save babel-polyfill메인 번들에 포함되도록 import 합니다.
src/index.js
+import 'babel-polyfill';
+
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
// `window`의 컨텍스트에 있다고 가정합니다.
this.alert("Hmmm, this probably isn't a great idea...");
return element;
}
document.body.appendChild(component());이 접근 방식은 번들 크기보다 정확성을 우선시합니다. 안전과 견고함을 위해서는 폴리필이나 shim이 다른 모든 코드보다 먼저 실행되어야 하므로 동기식으로 로드하거나 모든 앱 코드는 모든 폴리필이나 shim이 로드된 후에 로드해야 합니다. 또한 커뮤니티에는 최신 브라우저에 폴리필이 "필요하지 않다"거나 폴리필이나 shim이 누락된 기능을 추가하는 역할만 한다는 오해가 많이 있습니다. 사실, 가장 최신 브라우저에서도 종종 깨진 구현을 복구 합니다. 따라서 번들 크기 비용이 발생하더라도 모든 폴리필이나 shim을 무조건 동기식으로 로드하는 것이 모범 사례입니다.
문제가 해결됐다고 생각하고 위험을 감수하고 싶다면 다음과 같은 방법도 있습니다.
import를 새 파일로 이동하고 whatwg-fetch 폴리필을 추가해 보겠습니다.
npm install --save whatwg-fetchsrc/index.js
-import 'babel-polyfill';
-
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
// `window`의 컨텍스트에 있다고 가정합니다.
this.alert("Hmmm, this probably isn't a great idea...");
return element;
}
document.body.appendChild(component());project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
│ ├── globals.js
+ │ └── polyfills.js
└── /node_modulessrc/polyfills.js
import "babel-polyfill";
import "whatwg-fetch";webpack.config.js
import path from "node:path";
import webpack from "webpack";
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
- entry: './src/index.js',
+ entry: {
+ polyfills: './src/polyfills',
+ index: './src/index.js',
+ },
output: {
- filename: 'main.js',
+ filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new webpack.ProvidePlugin({
join: ['lodash', 'join'],
}),
],
};이를 통해 새로운 polyfills.bundle.js 파일을 조건부로 로드하는 로직을 추가 할 수 있습니다. 이 결정을 내리는 방법은 지원 기술과 브라우저에 따라 다릅니다. polyfill이 필요한지 여부를 확인하기 위해 몇 가지 간단한 테스트를 수행합니다.
dist/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Getting Started</title>
+ <script>
+ const modernBrowser = 'fetch' in window && 'assign' in Object;
+
+ if (!modernBrowser) {
+ const scriptElement = document.createElement('script');
+
+ scriptElement.async = false;
+ scriptElement.src = '/polyfills.bundle.js';
+ document.head.appendChild(scriptElement);
+ }
+ </script>
</head>
<body>
- <script src="main.js"></script>
+ <script src="index.bundle.js"></script>
</body>
</html>이제 엔트리 스크립트에서 일부 데이터를 가져올 수 있습니다.
src/index.js
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
// `window`의 컨텍스트에 있다고 가정합니다.
this.alert("Hmmm, this probably isn't a great idea...");
return element;
}
document.body.appendChild(component());
+
+fetch('https://jsonplaceholder.typicode.com/users')
+ .then((response) => response.json())
+ .then((json) => {
+ console.log(
+ "We retrieved some data! AND we're confident it will work on a variety of browser distributions."
+ );
+ console.log(json);
+ })
+ .catch((error) =>
+ console.error('Something went wrong when fetching this data: ', error)
+ );빌드를 실행하면 polyfills.bundle.js 파일이 생성되고 브라우저에서 원활하게 동작하게 됩니다. 이 설정은 개선될 수 있지만 실제로 필요한 사용자에게만 폴리필을 제공하는 방법에 대한 좋은 아이디어입니다.
Further Optimizations
babel-preset-env 패키지는 browserslist를 사용하여 브라우저 매트릭스에서 지원되지 않는 항목만 트랜스파일합니다. 이 사전 설정은 useBuiltIns 옵션(기본값 false)과 함께 제공되며, 전역 babel-polyfill을 가져오는 것을 import 패턴을 통해 더 세분화 된 기능으로 변환할 수 있습니다.
import "core-js/modules/es7.string.pad-start";
import "core-js/modules/es7.string.pad-end";
import "core-js/modules/web.timers";
import "core-js/modules/web.immediate";
import "core-js/modules/web.dom.iterable";자세한 내용은 babel-preset-env 문서를 참고하세요.
Serving a smaller polyfill to modern browsers
동일한 폴리필 세트를 두 번 컴파일하고 런타임에 적합한 것을 선택하여, 필요가 적은 브라우저는 조금만 다운로드할 수 있습니다. 엔트리가 어떤 빌드를 가져올지 결정하며, 두 빌드 모두 애플리케이션 이전에 로드됩니다.
src/index.js
(async () => {
if (isLegacyBrowser()) {
await import("./polyfills?legacy");
} else {
await import(/* webpackMode: "eager" */ "./polyfills");
}
await import(/* webpackMode: "eager" */ "./app");
})();src/polyfills.js
import "core-js";
import "regenerator-runtime/runtime";두 빌드의 차이는 Babel에게 지정하는 타겟뿐이며, 이는 Rule.resourceQuery가 ?legacy 쿼리를 통해 선택합니다.
webpack.config.js
export default {
module: {
rules: [
{
test: /\.js$/,
include: [path.resolve(__dirname, "src")],
oneOf: [
{
resourceQuery: /legacy/,
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{ useBuiltIns: "entry", targets: "ie 11" },
],
],
},
},
{
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{ useBuiltIns: "entry", targets: "last 2 versions" },
],
],
},
},
],
},
],
},
};Two bundles from one source with layers
위의 ?legacy 쿼리는 import를 하나씩 태그합니다. 대신 layer는 전체 엔트리를 태그합니다. webpack은 레이어당 한 번씩 엔트리의 그래프를 탐색하므로 도달하는 모든 파일이 두 개의 별도 모듈로 두 번 존재하며, Rule.issuerLayer가 각 복사본에 대한 로더를 선택합니다. 폴리필은 레거시 엔트리에만 포함되며, 페이지에서 브라우저 감지가 실행되지 않습니다.
webpack.config.js
import path from "node:path";
const src = path.resolve(process.cwd(), "src");
const babel = (targets, corejs) => ({
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{
targets,
useBuiltIns: corejs ? "usage" : false,
corejs,
},
],
],
},
});
export default {
entry: {
modern: { import: "./src/index.js", layer: "modern" },
legacy: { import: "./src/index.js", layer: "legacy" },
},
output: {
filename: "[name].js",
path: path.resolve(process.cwd(), "dist"),
environment: {
arrowFunction: false,
const: false,
destructuring: false,
dynamicImport: false,
forOf: false,
module: false,
optionalChaining: false,
templateLiteral: false,
},
},
module: {
rules: [
{
test: /\.js$/,
include: src,
issuerLayer: "modern",
use: babel("last 2 chrome versions, last 2 firefox versions", false),
},
{
test: /\.js$/,
include: src,
issuerLayer: "legacy",
use: babel("ie 11", 3),
},
],
},
};엔트리의 layer는 엔트리 모듈 자체에 대해 issuerLayer가 일치하는 것이며, 엔트리가 import하는 모든 모듈이 이를 상속하므로 레이어당 하나의 규칙으로 전체 그래프를 커버합니다. 하나의 컴파일이 두 번들을 모두 내보내며, 페이지에서 브라우저가 선택하도록 합니다. type="module"을 이해하는 브라우저는 nomodule을 무시하고, 이해하지 못하는 브라우저는 module script를 무시합니다.
<script type="module" src="/modern.js"></script>
<script nomodule defer src="/legacy.js"></script>Two configurations, when the modern build has to be ESM
레이어는 출력 형식을 다르게 설정할 수 없습니다. output.module은 output.environment와 같은 이유로 컴파일 전체에 적용되기 때문입니다. 모던 빌드가 실제 ECMAScript 모듈이어야 하는 경우 — export 구문, 청크 로딩을 위한 import() — 설정 배열을 전달하고 각각이 자체 컴파일이 되도록 하세요.
import path from "node:path";
const common = {
mode: "production",
entry: { app: "./src/index.js" },
};
export default [
{
...common,
name: "modern",
output: {
path: path.resolve(process.cwd(), "dist/modern"),
filename: "[name].mjs",
chunkFilename: "[name].chunk.mjs",
module: true,
chunkFormat: "module",
chunkLoading: "import",
library: { type: "module" },
},
},
{
...common,
name: "legacy",
output: {
path: path.resolve(process.cwd(), "dist/legacy"),
filename: "[name].js",
chunkFilename: "[name].chunk.js",
library: { type: "var", name: "App" },
environment: {
arrowFunction: false,
const: false,
destructuring: false,
dynamicImport: false,
forOf: false,
module: false,
optionalChaining: false,
templateLiteral: false,
},
},
},
];모던 컴파일은 export를 내보내고 import()로 청크를 로드합니다. 레거시 컴파일은 var 라이브러리를 내보내고 environment.dynamicImport가 false이기 때문에 주입된 <script>로 청크를 로드합니다. 각 설정의 module.rules에 타겟별 babel-loader 규칙을 평소처럼 추가하세요 — 두 개의 컴파일로는 issuerLayer가 구분할 것이 없으므로 레이어가 필요하지 않습니다.
Node Built-Ins
global, __dirname, __filename 같은 Node 내장 기능은 특별한 로더나 플러그인을 사용하지 않고도 node 옵션으로 설정 파일에서 직접 처리할 수 있습니다. process와 buffer 같은 Node 핵심 모듈은 webpack 5에서 폴리필되지 않습니다. ProvidePlugin이나 resolve.fallback으로 제공하세요. 자세한 내용과 예제는 node 설정 페이지를 참고하세요.
Other Utilities
레거시 모듈을 다룰 때 도움이 될 수 있는 몇 가지 도구가 있습니다.
모듈의 AMD/CommonJS 버전이 없고 dist를 포함시키려는 경우, /configuration/module/#modulenoparse에 해당 모듈을 noParse로 지정할 수 있습니다. 이렇게 하면 webpack은 모듈을 파싱하거나 import 및 require() 문을 해석하지 않고 포함합니다. 이 방법은 빌드 성능을 향상시키는 데에도 사용됩니다.
마지막으로 AMD, CommonJS, 레거시의 조합과 같이 여러 모듈 스타일을 지원하는 모듈이 있습니다. 이러한 경우의 대부분은 먼저 define을 확인한 후 약간 특이한 코드를 사용하여 속성을 내보냅니다. 이런 경우 Granular Shimming에서 사용한 동일한 add-imports 예제 플러그인으로 해당 파일에 var define = false;를 앞에 추가하여 CommonJS 경로를 강제하는 것이 도움이 될 수 있습니다.