Module Federation 2.0 尝鲜

1,502 阅读17分钟

Module Federation 2.0 最近正式发布,整体思路有了不同的高度和较大的改变,故浅浅的深入研究一下。 水平有限,不足之处敬请指正 。

介绍

从微前端开始

微前端,个人认为是一个将不同的相对独立的业务单元分离于不同的构建中,从而实现不同团队,不同仓库单独开发,单独部署的架构。从用户的角度来说,它实现了多个较大模块作为一个产品交付的能力;从开发者的角度来说,将一个应用拆分成多个不同模块给不同团队开发,提升了团队协作的能力。

目前主流的基于 singal-spa 的微前端架构(如 qiankun):

  1. Loader:通过 html 或 js 入口加载子应用
  2. Sandbox: 实现代码副作用的隔离
  3. router: 基于 router 的子应用切换
  4. Store: 提供通信机制

qiankunmodule federation
兼容性问题:不同模块之间可能存在不一样的开发规范和工具,如何避免他们互相影响且做到兼容通过沙箱、样式作用域、html 入口、webComponet 、shadowDom 等实现
子应用切换问题在运行时通过监听路由实现
共享能力props/ global state 等大量的、私有的、更新频繁的代码重复?
........

一般的,如何在不同子应用之间避免一些代码的重复?

  • 在开发环境,一般通过 npm 包或者 mono repo 实现。但是实际打包还是会将一些代码重复打包进去。

  • 在生产环境,一般还是通过 webpack external / dll + cdn 等实现共享一些包,减少运行时体积,但是在多版本管理、代码频繁更新等情况下比较困难。

  • 现在 webpack 5 中提出了解决依赖共享的更灵活更强大的方式,module federation :模块联邦

模块联邦

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

加载远程模块被认为是异步操作。容器是由容器入口创建的,该入口暴露了对特定模块的异步访问。暴露的访问分为两个步骤:

  1. 加载模块(异步的)

  2. 执行模块(同步的)

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

来源:webpack.docschina.org/concepts/mo…

大意:

  1. 将模块分为本地和远程模块,远程模块在运行时动态加载,这个加载是异步的而执行是同步的。

  2. 每个模块都可以成为一个容器,可以互相导入导出代码和依赖,可以循环引用。

使用 plugin 的示例

分析两个 cra 仓库:

host :本地构建

config-overrides.js

config.plugins.push(
    // ...
    new ModuleFederationPlugin(
      {
        // 其他项目远程引用时的webpack构建产物名
        name: "host",
        // 声明需要引用的其他构建入口文件
        remotes: {
          remote: `remote@http://localhost:3001/remoteEntry.js`,
        },
        // 扔进共享池的包
        shared: {
          react: {
            // 单例模式
            // share 配置看: https://module-federation.io/zh/configure/shared.html 
            singleton: true,
            requiredVersion: dependencies["react"],
          },
          "react-dom": {
            singleton: true,
            requiredVersion: dependencies["react-dom"],
          },
        },
      }
    )
  );

app.tsx

// 可以通过同步引入的写法加载远程模块,plugin 会自动帮我们处理
import Utils from 'remote/utils';

// 也可以通过异步的写法加载
const Card = React.lazy(() =>
    import("remote/Card").then((module) => {
        return {
            // 默认导出一个 react 组件,异步加载,即可使用
            default: module.Card,
        };
    }).catch(() => {
        return {
            default: <div>这还是本地的代码</div>
        }
    })
);

function App() {
    Utils.log(1, 2);

    return (
        <div className="App">
            <header className="App-header">
                <div>
                    这是本地的代码
                </div>
                <div style={{margin: 20}}>
                    <React.Suspense fallback={<div>Loading...</div>}>
                        <Card />
                    </React.Suspense>
                </div>
            </header>
        </div>
    );
}

index.tsx

这里需要注意的是入口需要异步导入。remote 的入口也是同理

// src/index.tsx
const bootstrap = import("./bootstrap");
export default bootstrap;

// src/bootstrap.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

remote : 远程构建

config-overrides.js

config.plugins.push(
       // ...
      new ModuleFederationPlugin(
      {
        name: "remote",
        // 需要给其他应用导出的部分
        exposes: {
          "./Card": "./src/components/Card",
        },
        // 导出的代码对应的文件名
        filename: "remoteEntry.js",
        shared: {
          react: {
            singleton: true,
            requiredVersion: dependencies["react"],
          },
          "react-dom": {
            singleton: true,
            requiredVersion: dependencies["react-dom"],
          },
        },
      }
    )
  );

/src/component/Card.tsx

// 默认导出一个组件即可
export const Card = () => (
    <div
        style={{border: '2px dotted red', padding: 20}}
        data-e2e="REMOTE_COMPONENT_INFO"
    >
        这是远程代码
    </div>
);

export default Card;

小结:

  1. 消费者和生产者都需要配置 ModuleFederationPlugin 插件,声明需要引用的其他构建入口文件,并将想共享的包扔到 share 中,配置好版本等信息即可。

  2. 消费一个 remote 构建的代码与异步导入或同步导入文件等的代码无异。

  3. 使用插件时,构建的整个入口都需要异步导入。

通过上面两个仓库的简单配置,就可以实现不同 webpack 构建的代码复用和依赖共享。

build 文件分析

host/mf-manifest.json

这个文件是 mf 2.0 新增,用于更清晰的展示仓库的运行时配置,同时也是支持不同构建工具和 devops 工具,更好的支持动态能力的基础。

{
  "id": "host",
  "name": "host",
  // host 信息
  "metaData": {
    "name": "host",
    "type": "app",
    "buildInfo": {
      "buildVersion": "0.1.0",
      "buildName": "cra-react-app-rewired_host"
    },
    // 没有定义导出,因此这个配置为空
    "remoteEntry": {
      "name": "",
      "path": "",
      "type": "global"
    },
    // 。。。
  },
  // 共享的依赖
  "shared": [
    {
      "singleton": true,
      "requiredVersion": "^18.2.0",
      "shareScope": "default",
      "name": "react-dom",
      "version": "18.2.0",
      "eager": false,
      // react-dom
      "id": "host:react-dom",
      "assets": {
        "js": {
          // 这里多了一个 react 的包是由于 react-dom peer 依赖于 react
          "async": [
            "static/js/vendors-node_modules_pnpm_react_18_2_0_node_modules_react_index_js.b455ac3e.chunk.js"
          ],
          "sync": [
            "static/js/vendors-node_modules_pnpm_react-dom_18_2_0_react_18_2_0_node_modules_react-dom_index_js.cdcf041d.chunk.js"
          ]
        },
        "css": {
          "async": [],
          "sync": []
        }
      },
      "usedIn": []
    },
    {
      "singleton": true,
      "requiredVersion": "^18.2.0",
      "shareScope": "default",
      "name": "react",
      "version": "18.2.0",
      "eager": false,
      // react
      "id": "host:react",
      "assets": {
        "js": {
          "async": [],
          "sync": [
            "static/js/vendors-node_modules_pnpm_react_18_2_0_node_modules_react_index_js.b455ac3e.chunk.js"
          ]
        },
        "css": {
          "async": [],
          "sync": []
        }
      },
      "usedIn": []
    }
  ],
  // 定义远程构建
  "remotes": [
    {
      "alias": "remote",
      "consumingFederationContainerName": "host",
      "federationContainerName": "remote",
      "moduleName": "Card",
      "usedIn": [
        "src/App.tsx"
      ],
      "entry": "http://localhost:3001/remoteEntry.js"
    }
  ],
  "exposes": []
}

remote/mf-manifest.json

{
  "id": "remote",
   // ...
  // 从导出者的 share 配置可以看出,自定义导出的代码也被放到 share 中,他们被看作是一类东西
  "shared": [
    // ...
    {
      "id": "remote:react",
      "name": "react",
      "version": "18.2.0",
      "singleton": true,
      "requiredVersion": "^18.2.0",
      "assets": {
        "js": {
          "async": [],
          "sync": [
            "static/js/429.83c2a9b5.chunk.js"
          ]
        },
        "css": {
          "async": [],
          "sync": []
        }
      }
    }
  ],
  // ...
  // 定义本地导出的东西
  "exposes": [
    {
      "id": "remote:Card",
      "name": "Card",
      "assets": {
        "js": {
          "sync": [
            "static/js/__federation_expose_Card.f5d9d790.chunk.js"
          ],
          "async": [
            "static/js/429.83c2a9b5.chunk.js"
          ]
        },
        "css": {
          "sync": [],
          "async": []
        }
      },
      "path": "./Card"
    }
  ]
}

小结:

  1. mf2.0 将模块的一些共享配置提取成了一个 json 文件,以支持用户自定义的 runtime 代码和不同的构建工具。
  2. 从 runtime 的角度看,用户导出的代码和需要共享的包的代码被看作是一类东西,都会被分包。
  3. share / expose 的包间也会有依赖关系。
  4. 为了保证每个构建都能单独运行,share 的包也会默认在当前构建中重新打包并分包。
  5. 从而形成了这么一个结构:

image.png

Network 及 runtime 分析

  1. main.xxxxxx.js :

    1. 可以看见 runtime 代码在这里不是由 webpack runtime 模版生成的,而是 runtime 包在打包后作为一个模块注入的。
    2. 最后执行的文件,是 federation 的入口文件,猜测是将包括本地模块在内的所有模块,都统一由 mf runtime 入口去动态管理。这也印证了为什么需要将本地的入口代码也动态 import 并分包,这样其实入口文件的主要结构,就变为: runtime 提供的入口,异步引用本地入口和远程入口。

  1. remoteEntry.js 顾名思义是远端文件的入口,与本地的入口文件结构基本一样。
  2. 其他的文件都是一些 share 和 exposed 的被分包的代码,也看到本地 host 的真正的入口文件也因为异步的 import 被分包成了 src_bootstrap_tsx.....js

小结:

  1. mf runtime 的入口文件 将包括 host 入口在内的所有代码都视为一个个异步模块,这样入口文件代码量会比较少,且插件自己进行转换和按顺序加载,使得我们可以在代码中使用同步的写法去获取原本需要异步获取的代码。

  2. 文件大小的问题:

    1. 模块共享机制减少了运行时代码量
    2. 但是每个模块的入口文件都有 mf runtime 的代码量,大小约 20~30KB,无法避免
    3. 被 share 的模块无法被树摇,这也好理解,因为其他模块可能用到当前模块没有用到的东西。
  3. 配置共享依赖:

    1. 会发现 react-dom-client 是主应用提供的;react-index 和 react-dom-index 却是子应用提供的

    2. 文件的加载顺序为:入口 -> react-dom-client -> host 入口 -> 远程入口 -> react -> react-dom,这个其实没有搞清楚是为什么,欢迎大牛解惑。

单独运行?

那么这两个构建能否真正单独运行呢?理论上可以。但个人觉得这个意义也不大,一般的微前端架构不会要求每个应用都能单独线上运行。

  1. 单独运行 host:请求不到远程入口,做一个 catch 就可以了。

  1. 单独运行 remote:能正常运行,但是不知道为什么 main 本地入口文件还是去加载了 remoteEntry。

动态远程代码示例

相较于 mf 1.0 局限于 webpack ,且是通过插件在编译时提供给用户声明模块依赖和共享的功能,mf 2.0 将 runtime 包单独提出,因而带来了更灵活的动态能力。如不同构建工具间共享,动态导入/动态依赖等。

host

host 中去除 plugin 代码,index.ts 文件中不再使用异步引入的形式,并新增 init 函数调用:

import { init } from '@module-federation/enhanced/runtime';

init({
  name: 'host',
  remotes: [
      {
          name: "remote",
          // 指向打包生成的 json 文件
          entry: "http://localhost:3001/mf-manifest.json",
          alias: "app_remote"
      },
  ],
});

app.tsx 中消费:

import { loadRemote } from '@module-federation/enhanced/runtime';
import Utils, { setUtils} from './utils';

// 消费 react 组件
const Card = React.lazy(() => loadRemote<any>("remote/Card").then((module) => {
        return {
            default: module.Card,
        };
    }).catch(() => {
        return {
            default: <div>这是本地的代码</div>
        }
    })
);

function App() {
    const [loading, setLoading] = useState(true);

    // 需要自己把握 load 远程代码的时机,不能在 init 函数没执行完就执行 load
    loadRemote<any>("remote/utils").then(({log}) => {
        // 这里假设我们本地有一个用于缓存该工具类的变量
        setUtils('log', log); 
        setLoading(false);
    });

    // 加载的远程代码有可能还没拿到,需要非空判断
    Utils.log && Utils.log(1, 2);

    return (
        <div className="App">
            <div style={{display: loading ? '' : 'none'}}>
                loading...
            </div>
            <header className="App-header" style={{display: loading ? 'none' : ''}}>
                <div>
                    这是本地的代码
                </div>
                <div style={{margin: 20}}>
                    <React.Suspense fallback={<div>Loading...</div>}>
                        <Card />
                    </React.Suspense>
                </div>
            </header>
        </div>
    );
}

export default App;

观察 network,发现这样打包之后的代码入口文件变成了我们自己的 index 文件,因为没有使用到构建的能力,它是一个纯运行时的逻辑,但也说明做到了与打包工具无关。但是也因此需要我们自己保证 init,load 等代码的执行顺序和时机。

相较于 1.0 提供的示例

官网提供的实例:可以看出,其实是对打包后 runtime 代码有很多入侵性的,而且打包后的 runtime 代码不太好去阅读和调试。

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');

动态依赖示例

向 host 中 init 方法添加 share 参数,将 react / react-dom 配置进去,同时配置 moment 包。 remote 也加上相应的配置。

  shared: {
    react: {
      version: '18.2.0',
      scope: 'default',
      // lib 是同步加载的,因此不会分包
      lib: () => React,
      // get 是异步加载,可以分包
      // get: () => import('react');
      shareConfig: {
        singleton: true,
        requiredVersion: '18.2.0',
      },
    },
    'react-dom': {
      version: '18.2.0',
      scope: 'default',
      lib: () => ReactDOM,
      shareConfig: {
        singleton: true,
        requiredVersion: '18.2.0',
      },
    },
 }

react 的共享

启动服务,可以看到,host 虽然不能分包,但是还是能将依赖共享出去。查看 window.__FEDERATION__.__SHARE__就可以窥得天机:

并且相比于前面没有配置共享的情况,实际请求减少了远程模块引用的一个小文件,也就是导出的组件使用的 react 部分代码,对比:

配置前:

配置后:

但是会有不稳定的情况,因为共享的规则是谁先用到某个包,如果发现缓存中没有且自己配置了共享,就会从自己这里拿文件并缓存(具体可以看 loadShare 的代码)。因此在某些网络状况下,会出现共享的是远程的 react 代码,这种情况实际就没有共享依赖,算是一种特殊情况。。

moment 的共享

在 app.tsx 中消费 moment ,为了确保让远程的 moment 先被共享,我们设置一个按钮,点击后才进行 loadShare,同时在远端导出的 Card 组件中直接调用 moment。

const load = () => loadShare<any>("moment").then((fac) => {         if(!fac) return;         const moment = fac();         console.log('local', moment());         setLoading(false); });
<button onClick={() => load()}>本地加载 moment </button>

注意此时 init 中没有配置 moment 的 share 信息。因此就不会将 moment 打包进入口文件。而我们进行 loadShare ,理论上拿到的文件就是 remote 共享出去的文件。但是实际上会出现报错:

尽管 host 已经有了共享的 moment 实例(在加载远程模块时拿到的),却不能实际消费。

这个问题比较头疼。官网称直接使用 runtime 包在只能供外部使用,而无法使用外部 shared 依赖 。因此只能通过本地 init 声明 share ,然后将代码打包进自己的入口,自己消费自己。

runtime 包分析

webpack runtime 代码对于我个人来说阅读体验一直很炸裂,所以从来没有读过。这次更新能将这块 runtime 逻辑提出来一个包,看起来会轻松很多。

注册模块

主要逻辑在 FederationHost 这个类:

  1. 它表示一个需要消费别的模块或依赖的代码类,因此其实本地有多个该实例(因为从远程拉来的模块,也可能消费 share 或 其他模块)
  2. 支持轻量版的插件体系,类似 webpack
  3. 它的实例就会被直接挂载在 window.__FEDERATION__.__INSTANCES__数组中
  4. 在实例化或追加参数时都会注册远程模块和共享模块
  // runtime/src/core.ts
  export class FederationHost {
      this.sharedHandler = new SharedHandler(this);
      this.remoteHandler = new RemoteHandler(this);
      constructor(userOptions: UserOptions) {
        // ...
        
        // 注册插件,生成 options    
        this.registerPlugins([
          ...defaultOptions.plugins,
          ...(userOptions.plugins || []),
        ]);
        this.options = this.formatOptions(defaultOptions, userOptions);
      }
      
      // ...
      private formatOptions(
        globalOptions: Options,
        userOptions: UserOptions,
      ): Options {
        const { shared } = formatShareConfigs(globalOptions, userOptions);
        // ...
    
        // 注册 远程模块
        const remotes = this.remoteHandler.formatAndRegisterRemote(
          globalOptionsRes,
          userOptionsRes,
        );
    
        // 注册 共享模块
        const { shared: handledShared } = this.sharedHandler.registerShared(
          globalOptionsRes,
          userOptionsRes,
        );
        // ... 初始化插件逻辑
      }
 }

formatAndRegisterRemote: 注册远程构建

// runtime/src/remote/inedex.ts

formatAndRegisterRemote(globalOptions: Options, userOptions: UserOptions) {
    const userRemotes = userOptions.remotes || [];

    // 读取用户配置,合并注册 默认的 和 用户定义的 远程构建的信息
    return userRemotes.reduce((res, remote) => {
      this.registerRemote(remote, res, { force: false });
      return res;
    }, globalOptions.remotes);
}


registerRemote(
    remote: Remote,
    targetRemotes: Remote[],
    options?: { force?: boolean },
  ): void {
    const { host } = this;
    
    // 判断是否已经注册了
    const registeredRemote = targetRemotes.find(
      (item) => item.name === remote.name,
    );
    
    if (!registeredRemote) {
      // ... 一些校验逻辑
      
      // 拼接远程入口的完整路径
      if ('entry' in remote) {
        if (isBrowserEnv() && !remote.entry.startsWith('http')) {
          remote.entry = new URL(remote.entry, window.location.origin).href;
        }
      }
      
      /**
          配置一些共享的规则:
          ../constants.ts:
              export const DEFAULT_SCOPE = 'default';
              export const DEFAULT_REMOTE_TYPE = 'global';
      */
      if (!remote.shareScope) {
        remote.shareScope = DEFAULT_SCOPE;
      }
      if (!remote.type) {
        remote.type = DEFAULT_REMOTE_TYPE;
      }
      
      // 合并信息
      targetRemotes.push(remote);
    } else {
      // 复用缓存
    }
  }

registerShared:注册共享模块

registerShared(globalOptions: Options, userOptions: UserOptions) {
    // 合并用户提供的和和默认的配置 生成该模块的共享信息
    const { shareInfos, shared } = formatShareConfigs(
      globalOptions,
      userOptions,
    );
    const sharedKeys = Object.keys(shareInfos);
    sharedKeys.forEach((sharedKey) => {
      const sharedVals = shareInfos[sharedKey];
      sharedVals.forEach((sharedVal) => {
        // 是否注册过
        const registeredShared = getRegisteredShare(
          this.shareScopeMap,
          sharedKey,
          sharedVal,
          this.hooks.lifecycle.resolveShare,
        );
        // 如果该共享模块没有注册
        if (!registeredShared && sharedVal && sharedVal.lib) {
          this.setShared({
            pkgName: sharedKey,
            lib: sharedVal.lib,
            get: sharedVal.get,
            loaded: true,
            shared: sharedVal,
            from: userOptions.name,
          });
        }
      });
    });

    return {
      shareInfos,
      shared,
    };
  }
  
 
  private setShared({
   // ...
  }: {
    // ...
  }): void {
    const { version, scope = 'default', ...shareInfo } = shared;
    
    const scopes: string[] = Array.isArray(scope) ? scope : [scope];
    scopes.forEach((sc) => {
      // ... 还是判重逻辑

      // 添加共享模块的信息
      // 层级: 当前 Host 实例 -> ShareHandler -> scope -> package名 -> 版本
      this.shareScopeMap[sc][pkgName][version] = {
        version,
        scope: ['default'],
        ...shareInfo,
        lib,
        loaded,
        loading,
      };

      if (get) {
        this.shareScopeMap[sc][pkgName][version].get = get;
      }
    });
  }
小结:
  1. 注册一个模块,会实例化 FederationHost 这个类,并

    1. 这个实例的信息会被推到window.__FEDERATION__.__INSTANCES__(浏览器环境)的全局变量数组内。后面可以通过遍历数组找到想要的已经加载的模块。

    2. 远程构建在 remoteHandler 管理、共享依赖在 sharedHandler 中管理。值得注意的是注册的是当前 Host 实例的共享模块,并不是想当然的直接挂在全局中。

    3. 此时还没有实际拿到共享和远程模块的代码

消费 - 远程构建

主要逻辑在 RemoteHandler 的 loadRemote 方法中:

export class RemoteHandler {
    // ...
    
    async loadRemote<T>(
        id: string,
        options?: { loadFactory?: boolean; from: 'build' | 'runtime' },
      ): Promise<T | null> {
        const { host } = this;
        try {
          const { loadFactory = true } = options || { loadFactory: true };
          
          // 拿 host 请求的参数
          // 在这里会 new Module() 类
          const { module, moduleOptions, remoteMatchInfo } =
            await this.getRemoteModuleAndOptions({
              id,
            });
          const { pkgNameOrAlias, remote, expose, id: idRes } = remoteMatchInfo;
          
          // 在这里拿到模块代码
          const moduleOrFactory = (await module.get(expose, options)) as T;
    
          // ...
    
          return moduleOrFactory;
        } catch (error) {
          // ...
      }
}

Module 类:

  • 同样是表示模块,与上面 FederationHost 不同的是,它是表示一个被消费的模块的类,装着模块实际代码。
  • 通过 get 获取模块导出的代码。
  • 导出的模块也可以消费共享依赖或其他的模块,因此也包含 runtime/init 方法。会产生隐形的递归操作,继续去创建上面提到的 FederationHost 类。
class Module {
  // ...
  constructor({remoteInfo, host}) {
    this.remoteInfo = remoteInfo;
    this.host = host;
  }

  async get(expose: string, options?: { loadFactory?: boolean }) {
    // ...

    // 通过 jsonp (或 esm) 拿 remoteEntry.js 文件
    const remoteEntryExports = await this.getEntry();

    if (!this.inited) {
      // 拿到当前 host 下的共享依赖
      const localShareScopeMap = this.host.shareScopeMap;
      // 远程的共享作用域域名
      const remoteShareScope = this.remoteInfo.shareScope || 'default';
      if (!localShareScopeMap[remoteShareScope]) {
        localShareScopeMap[remoteShareScope] = {};
      }
      const shareScope = localShareScopeMap[remoteShareScope];
      const initScope: InitScope = [];

      // ...

      // remote 导出的初始化逻辑
      await remoteEntryExports.init(
        initContainerOptions.shareScope,
        initContainerOptions.initScope,
        initContainerOptions.remoteEntryInitOptions,
      );
    }
    
    // ...

    // 拿到模块代码
    const moduleFactory = await remoteEntryExports.get(expose);
    const exposeContent = await moduleFactory();
    return exposeContent;
  }
}
小结:
  1. 获取远程的代码,需要拼接参数,然后构造 jsonp 获取代码并初始化后拿到。

  2. 从逻辑上看,与上面 官方提供的 1.0 的动态加载的逻辑是基本一样的。

消费 - 共享依赖

主要逻辑在 ShareHandler 的 loadShare 方法中:

 async loadShare<T>(
    pkgName: string,
    extraOptions?: {
      customShareInfo?: Partial<Shared>;
      resolver?: (sharedOptions: ShareInfos[string]) => Shared;
    },
  ): Promise<false | (() => T | undefined)> {
  
    // ...
    // 初始化共享作用域
    if (shareInfo?.scope) {
      await Promise.all(
        shareInfo.scope.map(async (shareScope) => {
          await Promise.all(
            // 这个函数将 share 信息注册到当前 host 实例的指定作用域下
            this.initializeSharing(shareScope, shareInfo.strategy),
          );
          return;
        }),
      );
    }
    
    // 从 host 中的缓存中获取,用户可以配置一系列缓存的策略
    const registeredShared = getRegisteredShare(
      this.shareScopeMap,
      pkgName,
      shareInfoRes,
      this.hooks.lifecycle.resolveShare,
    );
    
    // 根据缓存的情况与否制定 share 策略
    
    // 依赖已经加载在作用域内
    if (registeredShared && registeredShared.lib) {
      return registeredShared.lib as () => T;
    } 
    // 依赖还在加载中
    else if (
      registeredShared &&
      registeredShared.loading &&
      !registeredShared.loaded
    ) {
      const factory = await registeredShared.loading;
      registeredShared.loaded = true;
      if (!registeredShared.lib) {
        registeredShared.lib = factory;
      }
      return factory;
    } 
    // 已经注册信息,但是没有加载
    else if (registeredShared) {
      const asyncLoadProcess = async () => {
        // 调用 get 异步拿文件
        const factory = await registeredShared.get();
        shareInfoRes.lib = factory;
        shareInfoRes.loaded = true;
        addUseIn(shareInfoRes);
        const gShared = getRegisteredShare(
          this.shareScopeMap,
          pkgName,
          shareInfoRes,
          this.hooks.lifecycle.resolveShare,
        );
        if (gShared) {
          gShared.lib = factory;
          gShared.loaded = true;
        }
        return factory as () => T;
      };
      const loading = asyncLoadProcess();
      this.setShared({
        pkgName,
        loaded: false,
        shared: registeredShared,
        from: host.options.name,
        lib: null,
        loading,
      });
      return loading;
    } 
    // share 连注册信息都没有
    else {
      const asyncLoadProcess = async () => {
        // 尝试从 shareInfoRes 中拿,这个变量是有 「beforeLoadShare」 这个钩子参与的。
        // 如果钩子没有定义插件行为,返回值默认值,在这里会报错。
        const factory = await shareInfoRes.get();
        shareInfoRes.lib = factory;
        shareInfoRes.loaded = true;
        addUseIn(shareInfoRes);
        const gShared = getRegisteredShare(
          this.shareScopeMap,
          pkgName,
          shareInfoRes,
          this.hooks.lifecycle.resolveShare,
        );
        if (gShared) {
          gShared.lib = factory;
          gShared.loaded = true;
        }
        return factory as () => T;
      };
      const loading = asyncLoadProcess();
      this.setShared({
        pkgName,
        loaded: false,
        shared: shareInfoRes,
        from: host.options.name,
        lib: null,
        loading,
      });
      return loading;
    }
  }
}
小结:
  1. 找到了上面报错的代码

  1. share 并不是想当然的一个直接的全局对象管理,他的结构更像是: 全局 -> Host 实例 -> Share ,如果一个 host 没有去注册 share 的信息,哪怕全局作用域存在这个依赖,也不会共享,也就是无法像我们上面例子中的“白嫖”。但是我自己去搓了一下,还是有一个小妙招能够去拿到:

在不导出的情况下“白嫖” Share

上面的例子中,想要在不导出share的时候“白嫖”,可以在 init 时注入一个插件,这个插件拿到 全局的 share 信息(因为本身 moment 代码已经被加载了),将整个信息返回,就能走通后面的流程:

plugins: [
    {
      name: 'my-plugin',
      async beforeLoadShare (args) {
        const { shareInfo, origin, pkgName } = args;
        // 找不到 Share 时
        if(Object.getOwnPropertyNames(shareInfo).length === 0) {
          return {
            ...args,
            shareInfo: {
              // 从当前 host 拿,因为他引用的远程模块用到了 moment ,因此有注册信息。
              // 如果想拿 当前 host 也没有的,可以去遍历 INSTSANCE 找就行了
              ...origin.shareScopeMap.default[pkgName][dep['moment']],
            }
          }
        }
        return args;
      }
    }
  ]

runtime 相应位置:

const loadShareRes = await this.hooks.lifecycle.beforeLoadShare.emit({
      pkgName,
      shareInfo,
      shared: host.options.shared,
      origin: host,
});

const { shareInfo: shareInfoRes } = loadShareRes;

// ...
// share 连注册信息都没有
    else {
      const asyncLoadProcess = async () => {
        // 尝试从 shareInfoRes 中拿,这个变量是有 「beforeLoadShare」 这个钩子参与的。
        // 如果钩子没有定义插件行为,返回值默认值,在这里会报错。
        // 返回值唯一重要的信息就是 obj.get 方法
        const factory = await shareInfoRes.get();
        shareInfoRes.lib = factory;
        shareInfoRes.loaded = true;
        // ...

总结:

  • mf 2.0 升级,主要是将 runtime 逻辑从之前的 webpack 自己生成的 runtime 代码中单独抽离出来,支持的插件系统也使得不必为了实现某些功能而对打包后的运行时代码有侵入。个人认为提高了使用它的下限。下面是官网提供的对比:

  • 但是一些关于 share 的复杂场景个人觉得还是用着不顺手,比如已经注册在全局下的依赖,但是构建仍然拿不到的情况;又或者是想指定某个模块去做依赖的导出,或者是加载顺序,这些在包里貌似都是没有提供的。

参考:

module-federation.io/

webpack.docschina.org/concepts/mo…

github.com/module-fede…

github.com/module-fede…