kangyana / daily-question

When your heart is set on something, you get closer to your goal with each passing day.
https://www.webpack.top
MIT License
3 stars 0 forks source link

【Q098】模块联邦 #98

Open kangyana opened 1 year ago

kangyana commented 1 year ago

1. 为什么使用模块联邦?

多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。

这通常被称作 微前端,但并不仅限于此。

2. 底层原理

我们区分 本地模块远程模块。 本地模块即为普通模块,是当前构建的一部分。 远程模块不属于当前构建,并在运行时从所谓的容器加载。

加载远程模块被认为是异步操作。 当使用远程模块时,这些异步操作将被放置在远程模块和入口之间的下一个 chunk 的加载操作中。 如果没有 chunk 加载操作,就不能使用远程模块。

chunk 的加载操作通常是通过调用 import() 实现的,但也支持像 require.ensurerequire([...]) 之类的旧语法。

容器是由容器入口创建的,该入口暴露了对特定模块的异步访问。暴露的访问分为两个步骤:

  1. 加载模块(异步的)
  2. 执行模块(同步的)

步骤 1 将在 chunk 加载期间完成。 步骤 2 将在与其他(本地和远程)的模块交错执行期间完成。 这样一来,执行顺序不受模块从本地转换为远程或从远程转为本地的影响。

容器可以嵌套使用,容器可以使用来自其他容器的模块。容器之间也可以循环依赖。

3. 高级原理

每个构建都充当一个容器,也可将其他构建作为容器。 通过这种方式,每个构建都能够通过从对应容器中加载模块来访问其他容器暴露出来的模块。

共享模块是指既可重写的又可作为向嵌套容器提供重写的模块。 它们通常指向每个构建中的相同模块,例如相同的库。

packageName 选项允许通过设置包名来查找所需的版本。 默认情况下,它会自动推断模块请求,当想禁用自动推断时,请将 requiredVersion 设置为 false

kangyana commented 1 year ago

4. 构建块(Building blocks)

ContainerPlugin (low level)

该插件使用指定的公开模块来创建一个额外的容器入口。

ContainerReferencePlugin (low level)

该插件将特定的引用添加到作为外部资源(externals)的容器中,并允许从这些容器中导入远程模块。 它还会调用这些容器的 override API 来为它们提供重载。 本地的重载(当构建也是一个容器时,通过 __webpack_override__override API)和指定的重载被提供给所有引用的容器。

ModuleFederationPlugin (high level)

ModuleFederationPlugin 组合了 ContainerPluginContainerReferencePlugin

5. 概念目标

6. 使用场景

每个页面单独构建

单页应用的每个页面都是在单独的构建中从容器暴露出来的。 主体应用程序(application shell)也是独立构建,会将所有页面作为远程模块来引用。 通过这种方式,可以单独部署每个页面。 在更新路由或添加新路由时部署主体应用程序。 主体应用程序将常用库定义为共享模块,以避免在页面构建中出现重复。

将组件库作为容器

许多应用程序共享一个通用的组件库,可以将其构建成暴露所有组件的容器。 每个应用程序使用来自组件库容器的组件。 可以单独部署对组件库的更改,而不需要重新部署所有应用程序。 应用程序自动使用组件库的最新版本。

kangyana commented 1 year ago

7. 动态远程容器

该容器接口支持 getinit 方法。 init 是一个兼容 async 的方法,调用时,只含有一个参数:共享作用域对象(shared scope object)。 此对象在远程容器中用作共享作用域,并由 host 提供的模块填充。 可以利用它在运行时动态地将远程容器连接到 host 容器。

init.js

(async () => {
  // 初始化共享作用域(shared scope)用提供的已知此构建和所有远程的模块填充它
  await __webpack_init_sharing__('default');
  const container = window.someContainer; // 或从其他地方获取容器
  // 初始化容器 它可能提供共享模块
  await container.init(__webpack_share_scopes__.default);
  const module = await container.get('./module');
})();

容器尝试提供共享模块,但是如果共享模块已经被使用,则会发出警告,并忽略所提供的共享模块。 容器仍能将其作为降级模块。

你可以通过动态加载的方式,提供一个共享模块的不同版本,从而实现 A/B 测试。

注意:在尝试动态连接远程容器之前,确保已加载容器。

使用示例:

init.js

function loadComponent(scope, module) {
  return async () => {
    // 初始化共享作用域(shared scope)用提供的已知此构建和所有远程的模块填充它
    await __webpack_init_sharing__('default');
    const container = window[scope]; // 或从其他地方获取容器
    // 初始化容器 它可能提供共享模块
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

loadComponent('abtests', 'test123');

8. 基于 Promise 的动态 Remote

一般来说,remote 是使用 URL 配置的,示例如下:

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: 'app1@http://localhost:3001/remoteEntry.js',
      },
    }),
  ],
};

但是你也可以向 remote 传递一个 promise,其会在运行时被调用。 你应该用任何符合上面描述的 get/init 接口的模块来调用这个 promise。 例如,如果你想传递你应该使用哪个版本的联邦模块,你可以通过一个查询参数做以下事情:

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: `promise new Promise(resolve => {
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // This part depends on how you plan on hosting and versioning your federated modules
      const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script.onload = () => {
        // the injected script has loaded and is available on window
        // we can now resolve this 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)
      }
      // inject this script with the src set to the versioned remoteEntry.js
      document.head.appendChild(script);
    })
    `,
      },
      // ...
    }),
  ],
};

请注意当使用该 API 时,你 必须 resolve 一个包含 get/init API 的对象。

9. 动态 Public Path

提供一个 host api 以设置 publicPath

可以允许 host 在运行时通过公开远程模块的方法来设置远程模块的 publicPath

当你在 host 域的子路径上挂载独立部署的子应用程序时,这种方法特别有用。

使用场景: 你在 https://my-host.com/app/* 上有一个 host 应用,并且在 https://foo-app.com 上有一个子应用。 子应用程序也挂载在 host 域上, 因此, 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)

module.exports = {
  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');

从 script 中推断出 publicPath

我们可以从 document.currentScript.src 的 script 标签中推断出 publicPath,并在运行时用 __webpack_public_path__模块变量来设置它。

示例: webpack.config.js (remote)

module.exports = {
  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 + '/../';

注意:output.publicPath 配置项也可设置为 auto,它将为你自动决定一个 publicPath