[go: up one dir, main page]

Module Federation

Motivation

여러 개의 개별 빌드가 단일 애플리케이션을 형성해야 합니다. 이러한 개별 빌드는 컨테이너처럼 작동하며, 빌드 간에 서로 코드를 노출하고 소비하여 단일 통합 애플리케이션을 생성할 수 있습니다.

이것은 종종 Micro-Frontends라고 알려져 있지만, 그것에만 국한되지는 않습니다.

Low-level concepts

로컬 모듈과 원격 모듈을 구별합니다. 로컬 모듈은 현재 빌드의 일부인 일반 모듈입니다. 원격 모듈은 현재 빌드의 일부가 아니며 원격 컨테이너에서 런타임에 로드되는 모듈입니다.

원격 모듈을 로드 하는 것은 비동기 작업으로 간주됩니다. 원격 모듈을 사용할 때 이러한 비동기 작업은 원격 모듈과 엔트리 포인트 사이에 있는 다음 청크 로드 작업에 배치됩니다. 청크 로드 작업 없이는 원격 모듈을 사용할 수 없습니다.

청크 로드 작업은 일반적으로 import()를 호출하는 것이지만, require.ensure 또는 require([...]) 같은 이전 구조도 지원됩니다.

특정 모듈에 대한 비동기 접근을 노출하는 컨테이너 엔트리를 통해 컨테이너가 생성됩니다. 노출된 접근은 두 단계로 구분됩니다.

  1. 모듈 로드(비동기)
  2. 모듈 평가(동기)

1단계는 청크 로드 중에 수행됩니다. 2단계는 다른 로컬 및 원격 모듈과 인터리브된 모듈 평가 중에 수행됩니다. 이렇게 하면, 모듈을 로컬에서 원격으로 또는 그 반대로 변환해도 평가 순서가 영향을 받지 않습니다.

컨테이너를 중첩할 수 있습니다. 컨테이너는 다른 컨테이너의 모듈을 사용할 수 있습니다. 컨테이너 간의 순환 의존성도 가능합니다.

High-level concepts

각각의 빌드는 컨테이너의 역할을 하며 다른 빌드를 컨테이너로 소비하기도 합니다. 이렇게 하면 각각의 빌드가 해당 컨테이너에서 로드 하여 다른 노출된 모듈에 접근할 수 있습니다.

공유 모듈은 빌드가 자체적인 private 사본을 번들링하는 대신 share scope에 제공하고 그로부터 소비하는 모듈입니다. 일반적으로 각 빌드에서 동일한 모듈(예: 동일한 라이브러리)을 가리킵니다. 이 스코프가 어떻게 구성되고 전달되는지는 아래 Share scopes에서 설명합니다.

packageName 옵션을 사용하면 requiredVersion을 찾도록 패키지 이름을 설정할 수 있습니다. 기본적으로 모듈 요청에 자동으로 추론되며, 자동 추론을 비활성화해야 하는 경우 requiredVersion을 false로 설정합니다.

Every build needs its own unique name

하나의 문서에 로드된 빌드들은 output.uniqueName으로 자신을 식별하며, webpack은 이 값을 package.json의 name 필드에서 가져옵니다. 동일한 이름을 공유하는 두 빌드는 런타임이 청크를 로드하는 전역 배열(output.chunkLoadingGlobal)도 공유하게 되어 공유 모듈 런타임이 어느 빌드의 사본이 우선인지 결정할 때 구별할 수 없게 됩니다.

이는 호스트와 단일 리모트 간에도, 여러 리모트 간에도 동일하게 적용됩니다. 기존 프로젝트에서 리모트를 분리할 때 package.json을 그대로 유지하는 경우 이 문제가 쉽게 발생합니다. 해결 방법은 output.uniqueName 설정하기를 참고하세요.

Building blocks

ContainerPlugin (low level)

이 플러그인은 지정된 노출 모듈로 추가 컨테이너 엔트리를 생성합니다. 모듈만 노출하고 소비하지 않는 경우, 또는 컨테이너 엔트리 자체를 제어해야 하는 경우에 사용하세요.

옵션설명
name컨테이너의 이름이며, 소비자가 전역에서 읽는 이름이기도 합니다.
exposes노출할 모듈로, exposes에서 설명한 것과 동일합니다.
filename컨테이너 엔트리의 파일 이름으로, output.path에 대한 상대 경로입니다.
library컨테이너가 노출되는 방식으로, library로서 type과 name을 지정합니다.
runtime런타임 청크의 이름입니다. 설정되면 컨테이너는 자체 런타임 대신 해당 런타임에 추가됩니다.
shareScope이 컨테이너가 공유 모듈을 넣는 share scope로, 기본값은 'default'입니다.

ContainerReferencePlugin (low level)

이 플러그인은 컨테이너에 대한 특정 참조를 설정된 remoteType의 외부(externals)로 추가하고, 해당 컨테이너에서 원격 모듈을 가져올 수 있도록 합니다. 런타임에 __webpack_init_sharing__을 통해 share scope를 초기화하고, 그 스코프를 각 참조된 컨테이너의 init() 메서드에 전달한 다음, 컨테이너의 get() 메서드를 통해 원격 모듈을 로드합니다.

옵션설명
remotes소비할 컨테이너로, name: "container@url" 형식으로 지정합니다. ModuleFederationPlugin이 사용하는 값과 동일합니다.
remoteType리모트가 로드되는 방식으로, externalsType으로 지정합니다. 웹에서는 보통 'script'입니다.
shareScope모든 리모트의 init()에 전달되는 share scope로, 기본값은 'default'입니다.

ModuleFederationPlugin (high level)

ModuleFederationPlugin은 ContainerPlugin과 ContainerReferencePlugin을 결합합니다.

Share scopes

Share scope는 webpack 런타임에서 스코프 이름별로 유지되는 일반 객체로, 모듈 키를 페이지에서 사용 가능한 버전에 매핑합니다.

const shareScope = {
  react: {
    // 페이지의 모든 빌드가 등록한 버전마다 하나의 항목
    "18.3.1": { get, from: "host", eager: false },
  },
};

두 개의 플러그인이 이를 동작시키며, shared 옵션에 적용되는 SharePlugin은 이 둘을 합친 것입니다.

  • ProvideSharedPlugin 은 이 빌드의 모듈 사본을 스코프에 등록합니다.
  • ConsumeSharedPlugin 은 모듈을 번들링하는 대신 스코프에서 조회하며, 만족하는 것이 없으면 로컬 사본으로 폴백합니다.

따라서 shared만 나열한 빌드도 결국 둘 다 수행합니다. 모든 항목이 제공되고 또한 소비됩니다.

How the scope gets populated

호스트는 __webpack_init_sharing__을 통해 먼저 스코프를 생성한 다음, 로드하는 각 컨테이너에 동일한 객체를 전달합니다.

await __webpack_init_sharing__("default");
// `container`는 리모트의 엔트리입니다. 예: remoteEntry.js가 로드된 후의 window[scope]
await container.init(__webpack_share_scopes__.default);

init()은 스코프를 복사하지 않습니다. 컨테이너는 전달받은 객체를 채택하고 자체 제공 모듈을 그 안에 등록합니다. 따라서 제공이 양방향으로 흐릅니다. init() 이후 호스트는 리모트가 제공한 것을 소비할 수 있고, 리모트도 호스트가 제공한 것을 소비할 수 있습니다. init()은 해당 컨테이너 자체의 리모트로도 전파되므로 중첩된 컨테이너가 동일한 스코프에 합류하며, 순환 컨테이너 그래프를 안전하게 처리하는 가드가 있습니다.

Which copy wins

여러 빌드가 동일한 모듈을 등록할 때는 requiredVersion이 결정합니다. 소비자는 자신의 범위를 만족하는 스코프의 최신 버전을 가져오며, 이를 통해 서로 다른 메이저 버전이 필요한 두 빌드가 공존할 수 있습니다. 모듈별 옵션은 requiredVersion과 singleton을 참고하세요.

두 빌드가 동일한 모듈의 동일한 버전을 등록한 경우, 런타임은 output.uniqueName 값을 비교하여 우선순위를 결정합니다. 이것이 두 빌드가 절대로 같은 고유 이름을 공유해서는 안 되는 또 다른 이유입니다. 이미 로드된 버전은 교체되지 않습니다.

Concept goals

  • webpack이 지원하는 모든 모듈 유형을 노출하고 소비할 수 있어야 합니다.
  • 청크 로드는 필요한 모든 것을 병렬 로드 해야 합니다(웹: 서버까지 한 번의 라운드트립).
  • 공유는 한 빌드가 다른 빌드를 덮어쓰는 방식이 아닌 share scope를 통해 이루어집니다.
    • 컨테이너는 전달받은 스코프를 채택하고 자체 모듈을 그 안에 등록합니다.
    • 동일한 스코프를 전달받은 컨테이너들은, 공통 호스트의 형제들을 포함하여, 모두 다른 컨테이너가 제공한 것을 소비할 수 있습니다.
  • 환경에 의존하지 않아야 합니다.
    • 웹, Node.js 등에서 사용 가능합니다.
  • shared의 상대 및 절대 요청:
    • 사용하지 않더라도 항상 제공됩니다.
    • config.context에 대한 상대 경로로 resolve됩니다.
    • 기본적으로 requiredVersion을 사용하지 않습니다.
  • shared의 모듈 요청:
    • 사용할 때만 제공됩니다.
    • 빌드에서 사용된 모든 동일 모듈 요청과 일치합니다.
    • 일치하는 모든 모듈을 제공합니다.
    • 그래프의 이 위치에 있는 package.json에서 requiredVersion을 추출합니다.
    • 중첩된 node_modules가 있는 경우 여러 다른 버전을 제공하고 소비할 수 있습니다.
  • shared에서 후행에 /가 있는 모듈 요청은 이 접두사의 모든 모듈 요청과 일치합니다.

Use cases

Separate builds per page

단일 페이지 애플리케이션의 각 페이지는 별도의 빌드에서 컨테이너 빌드로부터 노출됩니다. 또한 애플리케이션 쉘은 모든 페이지를 원격 모듈로 참조하는 별도의 빌드입니다. 이렇게 하면 각 페이지를 별도로 배포할 수 있습니다. 경로가 업데이트되거나 새 경로가 추가되면 애플리케이션 쉘이 배포됩니다. 애플리케이션 쉘은 페이지 빌드에서 중복을 방지하기 위해 일반적으로 사용되는 라이브러리를 공유 모듈로 정의합니다.

Components library as container

많은 애플리케이션은 각 컴포넌트가 노출된 컨테이너로 빌드 할 수 있는 공통 컴포넌트 라이브러리를 공유합니다. 각 애플리케이션은 컴포넌트 라이브러리 컨테이너의 컴포넌트를 사용합니다. 모든 애플리케이션을 다시 배포할 필요 없이 컴포넌트 라이브러리의 변경을 별도로 배포할 수 있습니다. 애플리케이션은 컴포넌트 라이브러리의 최신 버전을 자동으로 사용합니다.

Dynamic Remote Containers

컨테이너 인터페이스는 get 및 init 메소드를 지원합니다. init은 하나의 인수(공유 범위 객체)로 호출되는 async 호환 메소드입니다. 이 객체는 원격 컨테이너에서 공유 범위로 사용되며 호스트에서 제공된 모듈로 채워집니다. 원격 컨테이너를 런타임에 동적으로 호스트 컨테이너에 연결하는데 사용할 수 있습니다.

init.js

(async () => {
  // 공유 범위를 초기화 합니다. 이 빌드와 모든 원격에서 제공된 모듈로 채웁니다
  await __webpack_init_sharing__("default");
  const container = globalThis.someContainer; // 또는 다른 곳에서 컨테이너를 얻으십시오
  // 컨테이너 초기화, 공유 모듈 제공 가능합니다
  await container.init(__webpack_share_scopes__.default);
  const factory = await container.get("./module");
  const module = factory();
})();

Who loads the container

remotes에 나열된 리모트의 경우 webpack이 로드합니다. 생성된 코드가 <script>를 삽입하고 @ 앞에 지정된 전역이 나타날 때까지 기다리며, 끝내 나타나지 않으면 ScriptExternalLoadError로 거부합니다. 따라서 import Button from "app1/Button"만 작성하면 됩니다.

빌드 시점에 위치를 알 수 없는 경우에만 해당 라이프사이클을 직접 관리해야 하며, 위의 코드가 바로 그런 경우입니다. 이때 스크립트 로딩은 개발자의 몫이며, container는 로딩이 완료될 때까지 undefined입니다.

function loadRemoteEntry(url, scope) {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = url;
    script.onload = () => resolve(window[scope]);
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

리모트당 한 번만 로드하고 결과를 유지하세요. 서로 다른 스코프로 하나의 컨테이너에서 init()을 두 번 호출하면 오류가 발생하며, 스크립트를 다시 추가하면 리모트의 엔트리가 다시 실행됩니다.

컨테이너는 공유 모듈을 제공하려고 시도하지만, 공유 모듈이 이미 사용 중인 경우(로드된 경우) 새로 제공된 모듈은 경고 없이 무시됩니다. 하지만 컨테이너는 필요에 따라 해당 모듈을 폴백으로 사용할 수 있습니다.

이렇게 하면 다른 버전의 공유 모듈을 제공하는 A/B 테스트를 동적으로 로드 할 수 있습니다.

예:

init.js

function loadComponent(scope, module) {
  return async () => {
    // 공유 범위를 초기화 합니다. 이 빌드와 모든 원격에서 제공된 모듈로 채웁니다
    await __webpack_init_sharing__("default");
    const container = window[scope]; // 로드된 remoteEntry.js 스크립트에 의해 노출된 원격 컨테이너
    // 컨테이너를 초기화하면 공유 모듈이 제공될 수 있습니다.
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

loadComponent("abtests", "test123");

전체 구현 보기

Promise Based Dynamic Remotes

일반적으로, 원격은 다음 예시와 같은 URL을 사용하여 설정합니다.

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        app1: "app1@http://localhost:3001/remoteEntry.js",
      },
    }),
  ],
};

하지만 원격에 promise를 전달할 수도 있고, 이는 런타임에 해석됩니다. 위에서 설명한 get/init 인터페이스에 맞는 모듈로 이 promise를 해석해야 합니다. 예를 들어 사용할 통합 모듈의 버전을 전달하려는 경우 쿼리 매개 변수를 통해 다음과 같은 작업을 수행할 수 있습니다.

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        app1: `promise new Promise(resolve => {
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // 이 부분은 통합 모듈 호스팅 및 버전의 관리 계획에 따라 달라집니다.
      const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script. => {
        // 주입된 스크립트가 로드되어 window에서 사용할 수 있습니다.
        // 이제 Promise를 해석할 수 있습니다.
        const proxy = {
          get: (request) => window.app1.get(request),
          init: (...arg) => {
            try {
              return window.app1.init(...arg)
            } catch(e) {
              console.log('remote container already initialized')
            }
          }
        }
        resolve(proxy)
      }
      // 이 스크립트를 버전이 지정된 remoteEntry.js로 src 세트와 함께 주입합니다.
      document.head.appendChild(script);
    })
    `,
      },
      // ...
    }),
  ],
};

이 API를 사용할 때는 get/init API가 포함된 개체를 확인해야 합니다.

Dynamic Public Path

Offer a host API to set the publicPath

호스트가 원격 모듈의 메소드를 노출하여 런타임 원격 모듈의 publicPath를 설정하도록 허용할 수 있습니다.

이 접근 방식은 호스트 도메인의 하위 경로에 독립적으로 배포된 하위 응용 프로그램을 마운트 할 때 특히 유용합니다.

시나리오입니다.

https://my-host.com/app/*에 호스팅 된 호스트 앱과 https://foo-app.com에 호스팅 된 하위 앱이 있습니다. 하위 앱도 호스트에 마운트되므로, https://foo-app.com은 https://my-host.com/app/foo-app와 https://my-host.com/app/foo-app/*를 통해 접근할 수 있습니다. 요청은 프록시를 통해 https://foo-app.com/*로 리다이렉션 됩니다.

예제입니다.

webpack.config.js (remote)

export default {
  entry: {
    remote: "./public-path",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "remote", // 이 이름은 엔트리 이름과 일치해야 합니다.
      exposes: ["./public-path"],
      // ...
    }),
  ],
};

public-path.js (remote)

export function set(value) {
  __webpack_public_path__ = value;
}

src/index.js (host)

const publicPath = await import("remote/public-path");
publicPath.set("/your-public-path");

//bootstrap app  e.g. import('./bootstrap.js')

Infer publicPath from script

document.currentScript.src의 스크립트 태그에서 publicPath를 유추하고 런타임에 __webpack_public_path__ 모듈 변수로 설정할 수 있습니다.

예제입니다.

webpack.config.js (remote)

export default {
  entry: {
    remote: "./setup-public-path",
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "remote", // 이 이름은 엔트리 이름과 일치해야 합니다.
      // ...
    }),
  ],
};

setup-public-path.js (remote)

// 자체 로직으로 publicPath를 파생하고 __webpack_public_path__ API로 설정하세요.
__webpack_public_path__ = `${document.currentScript.src}/../`;

Troubleshooting

Uncaught Error: Shared module is not available for eager consumption

애플리케이션은 전 방향 호스트로 작동하는 애플리케이션을 실행하고 있습니다. 선택할 수 있는 옵션이 있습니다.

모듈을 비동기 청크에 넣지 않고 동기식으로 제공하는 Module Federation 고급 API 내에서 의존성을 eager로 설정할 수 있습니다. 이를 통해 초기 청크에서 이러한 공유 모듈을 사용할 수 있습니다. 그러나 제공된 모든 모듈과 예비 모듈은 항상 다운로드 되므로 주의하세요. 쉘 같이 애플리케이션의 한 지점에서만 제공하는 것이 좋습니다.

비동기 경계를 사용하는 것을 추천합니다. 추가 왕복을 방지하고 일반적인 성능 향상을 위해 더 큰 청크의 초기화 코드를 분할합니다.

예를 들어, 엔트리는 다음과 같습니다.

index.js

import { createRoot } from "react-dom/client";
import App from "./App";

const root = createRoot(document.getElementById("root"));
root.render(<App />);

bootstrap.js 파일을 만들고 엔트리의 콘텐츠을 이 파일로 이동한 다음, 부트스트랩을 엔트리로 가져오겠습니다.

index.js

+ import('./bootstrap');
- import { createRoot } from 'react-dom/client';
- import App from './App';

- const root = createRoot(document.getElementById('root'));
- root.render(<App />);

bootstrap.js

+ import { createRoot } from 'react-dom/client';
+ import App from './App';
+ const root = createRoot(document.getElementById('root'));
+ root.render(<App />);

이 메소드는 동작하지만 제약사항이나 단점이 있을 수 있습니다.

ModuleFederationPlugin을 통해 의존성을 eager: true로 설정합니다.

webpack.config.js

// ...
new ModuleFederationPlugin({
  shared: {
    ...deps,
    react: {
      eager: true,
    },
  },
});

Uncaught Error: Module "./Button" does not exist in container.

"./Button"은 표시되지 않지만, 오류 메세지는 유사하게 표시됩니다. 이 문제는 일반적으로 webpack beta.16에서 webpack beta.17로 업그레이드 하는 경우에 나타납니다.

ModuleFederationPlugin 내에서 다음 위치에서 exposes를 변경합니다.

new ModuleFederationPlugin({
  exposes: {
-   'Button': './src/Button'
+   './Button':'./src/Button'
  }
});

Container initialization failed as it has already been initialized with a different share scope

컨테이너의 init()이 서로 다른 두 스코프 객체로 두 번 호출되었습니다. 컨테이너는 전달받은 객체를 채택하고 유지하므로, 이미 첫 번째에 등록된 모듈을 자동으로 분리하지 않고는 두 번째 호출을 처리할 수 없습니다.

이는 일반적으로 페이지에서 공유 초기화가 두 번 이상 발생했음을 의미합니다. 두 호스트 번들이 각각 __webpack_init_sharing__을 호출하고 동일한 리모트를 초기화하거나, 이전 import()가 이미 설정한 컨테이너에 코드가 다시 init()을 호출하는 경우입니다. 각 컨테이너를 한 번만 초기화하고 결과 컨테이너 객체를 재사용하세요. 리모트를 동적으로 로드할 때는 init() 호출에 가드를 추가하여 반복 로드 시 건너뛸 수 있도록 하세요.

Uncaught TypeError: fn is not a function

원격 컨테이너가 누락되었을 가능성이 있으므로 추가되었는지 확인하세요. 사용하려는 원격 컨테이너가 로드되었지만 이 오류가 계속 나타나면 호스트 컨테이너의 원격 컨테이너 파일도 HTML에 추가하세요.

Setting output.uniqueName

모듈 페더레이션 설정에서 호스트와 각 원격 저장소는 전역적으로 고유한 output.uniqueName 값을 가져야 합니다. Webpack은 기본적으로 package.json 파일의 name 필드에서 이 값을 가져옵니다. 즉, 동일한 package.json 파일의 name 필드를 공유하는 두 빌드(기존 프로젝트에서 원격 저장소를 분리할 때 흔히 발생하는 패턴)는 런타임에 충돌을 일으킬 수 있습니다.

한 가지 해결책은 각 구성마다 고유한 이름을 가진 별도의 package.json 파일을 사용하는 것입니다.

또는 각 webpack 설정에서 output.uniqueName을 명시적으로 설정할 수도 있습니다.

webpack.config.js (host)

export default {
  output: {
    uniqueName: "my-host-app",
  },
  plugins: [
    new ModuleFederationPlugin({
      // ...
    }),
  ],
};

webpack.config.js (remote)

export default {
  output: {
    uniqueName: "my-remote-app", // must differ from host and all other remotes
  },
  plugins: [
    new ModuleFederationPlugin({
      // ...
    }),
  ],
};

주어진 페이지에 로드된 모든 federated build에서 고유하다면 어떤 문자열이든 사용할 수 있습니다.

Edit this page·

11 Contributors

sokrachenxsanEugeneHlushkojamesgeorge007ScriptedAlchemysnitin315XiaofengXie16KyleBastienAlevaleburhanudayRexSkz