[Engineering] Explore Module Federation in webpack5

Module Federation is an exciting new feature in webpack5, and a feature that claims to be a game-changer for JavaScript architecture. Next, let's slowly unveil the mystery of Module Federation

Module sharing scheme comparison

Scenario: Currently we have project A and project B, and we found that they have certain commonalities, such as common UI components, utils, and so on. So how do we share this public information?

Simple and rude - CV Dafa

Copying the components of project A directly to project B is sometimes faster, but it also has the problem of extremely low maintenance. Both subsequent projects maintain a set of their own.

abstracted into npm

We can abstract some common modules into npm, and each project installs the npm package to achieve the purpose of sharing

But there are the following problems with the way npm packages:

  • Compile and build: Some public tool libraries, frameworks and UI libraries are built repeatedly, resulting in low performance
  • Version update: All projects need to be upgraded. The "publish->notify->update" approach is relatively inefficient

CDN + webpack externals

Similar to npm, but upload it to CDN and load it by combining webpack externals. In addition to the problems mentioned above, externals are not loaded on demand

git submodule

Submodules allow you to make one Git repository a subdirectory of another Git repository. It allows you to clone another repository into your own project while still keeping the commits separate

It will still have the problem of repeated construction, and there will also be a certain cost of getting started

Related commands:

  • git submodule add <submodule repository> : add a submodule
  • git submodule update --recursive --remote : pull updates for all submodules

What is Module Federation?

The official documentation explains its motivation as follows:

Multiple independent builds can compose an application, and there should be no dependencies between these independent builds, so they can be developed and deployed separately. This is often called a micro frontend, but it's not limited to that

Module federation 使 JavaScript 应用得以从另一个 JavaScript 应用中动态地加载代码,这就解决了我们上面提到的模块共享的问题

它不仅仅是微前端,而且场景粒度可以更加细,一般微前端更多的是应用级别,但它更偏向模块级别的共享

Module Federation 配置

在实战之前,我们了解一下 Module Federation 的配置项

首先是两个基础角色的约定:

  • Host。消费模块的一方
  • Remote。提供模块的一方

每个应用都既可以作为 host,也可以作为 remote

Module Federation 配置项如下:

  • name: 必须且唯一
  • filename: 若没有提供 filename,那么构建生成的文件名与容器名称同名
  • remotes: 可选,作为引用方最关键的配置项,用于声明需要引用的远程资源包的名称与模块名称,作为 Host 时,去消费哪些 Remote
  • exposes: 可选,表示作为 Remote 时,export 哪些属性被消费
  • library: 可选,定义了 remote 应用如何将输出内容暴露给 host 应用。配置项的值是一个对象,如 { type: 'xxx', name: 'xxx'}
  • shared,可选,指示 remote 应用的输出内容和 host 应用可以共用哪些依赖。 shared 要想生效,则 host 应用和 remote 应用的 shared 配置的依赖要一致
    • Singleton: 是否开启单例模式。默认值为 false,开启后remote 应用组件和 host 应用共享的依赖只加载一次,而且是两者中版本比较高的
    • requiredVersion:指定共享依赖的版本,默认值为当前应用的依赖版本
    • eager:共享依赖在打包过程中是否被分离为 async chunk。设置为 true, 共享依赖会打包到 main、remoteEntry,不会被分离,因此当设置为true时共享依赖是没有意义的

实战演示

这里我们用 Github 中 Module Federation Examples进行演示。这里包含了基础的用法、高级用法以及和一些框架的结合实践

注:该仓库使用 lerna 维护。所以你需要安装 lerna

npm install lerna -g
复制代码

通过 lerna bootstrap 安装依赖

简单示例

来看 basic-host-remote 目录下有两个独立的 project,分别为 app1 和 app2。其中 app2 中实现了一个 Button 组件,现在 app1 要用这个 Button 组件

import React from 'react';

const Button = () => <button>App 2 Button</button>;

export default Button;
复制代码

app2 暴露组件

此时,app2 的角色就是 Remote,核心 webpack 配置:

const { ModuleFederationPlugin } = require('webpack').container;
// ...
  
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2',
      library: { type: 'var', name: 'app2' },
      filename: 'remoteEntry.js', // 生成的文件名
      exposes: {
        './Button': './src/Button', // Export Button 组件
      },
      // 共享 react 和 react-dom
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
// ...
复制代码

app1 消费组件

此时,app1 的角色是 Host,webpack 核心配置:

const { ModuleFederationPlugin } = require('webpack').container;
// ...

  //http://localhost:3002/remoteEntry.js
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      remotes: {
        // http://localhost:3002/remoteEntry.js
        // 上面配置生成的模块文件
        app2: `app2@${getRemoteEntryUrl(3002)}`,
      },
      // 共享模块
      shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
    }),
  ],
// ...
复制代码

模块使用:

const RemoteButton = React.lazy(() => import('app2/Button'));

const App = () => (
  <div>
    <h1>Basic Host-Remote</h1>
    <h2>App 1</h2>
    <React.Suspense fallback="Loading Button">
      <RemoteButton />
    </React.Suspense>
  </div>
);

export default App;
复制代码

效果

而且可以看到 react 和 react-dom 也是加载了一次:

高级示例-动态加载远程模块

假如初始化的时候,不加载远程的模块,在一定的交互之后再去加载远程模块,该怎么实现呢?

本示例在 advanced-api/dynamic-remotes 中可以找到

示例中有三个 project,app1/app2/app3。app1 是 Host,消费 app2 和 app3 提供的组件,而且点击相应按钮的时候才去加载对应的远程模块。另外 app2 和 app3 都用到了 moment.js

app2 和 app3 暴露模块

两个 project 的配置是相似的,都是暴露了 Widget 组件,而且都同享了 react 和 react-dom,以及 moment.js。这里可以留意的是,假如不声明 requiredVersion,就会使用它能找到的当前大版本中最高的 version

const deps = require('./package.json').dependencies;
// ...
new ModuleFederationPlugin({
  name: 'app3',
  library: { type: 'var', name: 'app3' },
  filename: 'remoteEntry.js',
  exposes: {
    './Widget': './src/Widget',
  },
  // adds react as shared module
  // version is inferred from package.json
  // there is no version check for the required version
  // so it will always use the higher version found
  shared: {
    react: {
      requiredVersion: deps.react,
      import: 'react', // the "react" package will be used a provided and fallback module
      shareKey: 'react', // under this name the shared module will be placed in the share scope
      shareScope: 'default', // share scope with this name will be used
      singleton: true, // only a single version of the shared module is allowed
    },
    'react-dom': {
      requiredVersion: deps['react-dom'],
      singleton: true, // only a single version of the shared module is allowed
    },
// adds moment as shared module
// version is inferred from package.json
// it will use the highest moment version that is>=2.24and 小于 3
    moment: deps.moment,
  },
})
复制代码

app1 消费模块

app1 作为 Host,这里都是常规配置,不再赘述

主要来看它负责动态加载的代码,在点击相应的按钮的时候,会触发 useFederatedComponent 方法,入参中 remoteUrl 为远程地址,scope 为对应应用名称,module 为指定的模块。其中 useDynamicScript 负责加载的远程 JavaScript 脚本,加载完成之后,通过 loadComponent 方法动态加载组件

export const useFederatedComponent = (remoteUrl, scope, module) => {
  const key = `${remoteUrl}-${scope}-${module}`;
  const [Component, setComponent] = React.useState(null);
  
  const { ready, errorLoading } = useDynamicScript(remoteUrl);
  React.useEffect(() => {
    if (Component) setComponent(null);
    // Only recalculate when key changes
  }, [key]);

  React.useEffect(() => {
    if (ready && !Component) {
      const Comp = React.lazy(loadComponent(scope, module));
      componentCache.set(key, Comp);
      setComponent(Comp);
    }
    // key includes all dependencies (scope/module)
  }, [Component, ready, key]);

  return { errorLoading, Component };
};
复制代码

再来重点看下 loadComponent,其中 __webpack_init_sharing__ ,进行了初始化共享作用域,用提供的已知此构建和所有远程的模块填充它。然后获得远程容器 container,支持 get 和 init 方法。 init 是一个兼容 async 的方法,调用时,只含有一个参数:共享作用域对象(shared scope object)——__webpack_share_scopes__.default。最后调用容器的 get 方法,获取到对应的模块

function loadComponent(scope, module) {
  return async () => {
    // Initializes the share scope. This fills it with known provided modules from this build and all remotes
    // 初始化共享作用域(shared scope)用提供的已知此构建和所有远程的模块填充它
    await __webpack_init_sharing__('default');
    const container = window[scope]; // or get the container somewhere else
    // 初始化容器 它可能提供共享模块
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}
复制代码

效果演示

  • 点击不同的按钮,加载不同的组件
  • moment.js 在首次加载后不用再重新加载

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

Module Federation 的问题

谈了这么多 Module Federation 的优点,我们来看看它有哪些缺点

1、 对环境要求略高,需要使用 webpack5,旧项目改造成本大

2、 拆分粒度需要权衡,虽然能做到依赖共享,但是被共享的 lib 不能做 tree-shaking,也就是说如果共享了一个 lodash,那么整个 lodash 库都会被打包到 shared-chunk 中

3、 Webpack 为了支持加载 remote 模块对 runtime 做了大量改造,在运行时要做的事情也因此陡然增加,可能会对我们页面的运行时性能造成负面影响

4、 运行时共享也是一把双刃剑,如何去做版本控制以及控制共享模块的影响是需要去考虑的问题

对于问题1,未来应该会慢慢变好。问题2 感觉还好,场景应该不会特别多,而且相比于共享模块,不重复编译的优点来讲,相对可以接受。问题3,感受不大。

问题4,算是比较头疼的一件事,比如几个项目,都需要版本 react/react-dom/antd 的版本一致,假如版本更新的话,怎么办?

我们可以使用 Module Federation 的能力,将一些核心的依赖例如 react、react-dom、antd,使用一个 remote 服务维护,然后每个项目分别引用这个服务导出的 library。我们只需要维护这个 remote 服务上依赖的版本,就能保证每个项目核心依赖的版本是一致的,而且升级的时候,也不用每个项目自己升级,大大提升了效率

总结

使用 Module Federation,我们可以在一个应用中动态加载并执行另一个应用的代码,且与技术栈无关,并且能够共享模块,从而减小编译时间以及降低包体积

但在使用 Module Federation 的时候也需要权衡模块拆分的粒度以及做好版本的控制

参考

Guess you like

Origin juejin.im/post/7085868002205237279