likes
comments
collection
share

前端性能优化:模块联邦-从入门到放弃

作者站长头像
站长
· 阅读数 22

性能优化已是老生常谈的问题,但不知大家对于模块联邦这个概念是否熟悉。一句话介绍,模块联邦可以让当前前端应用动态地加载来自其他应用的代码。

听着有点绕?那我先来说说模块联邦带来的一个特性:依赖共享,它可以解决微前端各个应用之间相同依赖重复加载的问题,对于我们优化页面加载性能有很大的帮助。想了解具体怎么回事的话就请接着往下看吧!

简要介绍

先简单介绍一下模块联邦的由来。

模块联邦(Module Federation)最早是由Zack Jackson这位工程师提出的一种JavaScript架构,后来被他和其他几个工程师共同编写集成进了webpack5。 我们都知道webpack5正式发布是在2020年,所以模块联邦发展到现在也不过3年时间,在前端领域算是一个比较新的概念。

模块联邦的核心作用官网介绍的很明确,那就是:

在当前构建中动态地加载来自另一个bundle或者build的代码

我们往简单翻译一下就是:

在当前应用中动态地加载来自另一个应用的代码

模块联邦还带来了一个特别重要的特性:依赖共享。它的作用上边已经做过简要说明,如果大家还不明白也不用着急,等到后边看完例子其作用自然不言而喻。

核心概念

模块联邦中核心的概念并不多:

  1. Local Modules:Local Modules即本地模块,本地模块就是当前应用内部的模块,也就是应用中的普通模块。
  2. Remote Modules:Remote Modules即远程模块,远程模块不属于当前应用,它是在运行时从其他应用动态加载而来的模块。
  3. Host:Host是一种应用(也可以说容器),是页面中首先被加载的应用,其内部可以动态地加载、运行远程模块。
  4. Remote:Remote是另一种提供远程模块的应用,其内部的本地模块会暴露出去供Host消费。
  5. Bidirectional-hosts:双向Host,既是Host又是Remote的应用。其内部既会消费来自其他Remote的模块,又会作为Remote对外提供模块。

我们通过两张图片来解释一下这些概念之间的关系。

图中的白框方块代表应用(容器),蓝框方块代表模块。

下面这幅图中上总共有两个应用,分别是Remote和Host。它们两个内部都有一个Local Modules,不同在于Host内部引用了Remote内部的Local Modules。那么这个模块对于Host来说就是Remote Modules。 前端性能优化:模块联邦-从入门到放弃

再来看下Bidirectional-hosts这个概念的图示。双向主机重点就在于Bidirectional双向上边。看这幅图我们可以发现,这两个应用在既是远程模块的消费者,又是远程模块的消费者。 前端性能优化:模块联邦-从入门到放弃

实现微前端的新方案

模块联邦的给微前端的实现提供了一种新的途径。我们比较熟悉的框架中,Umi4目前已经支持模块联邦配置,另外也有完全基于模块联邦的微前端框架EMP。

使用模块联邦实现微前端,最大的优点就在于依赖共享、减少资源重复加载。下面我们就以一个实例项目来进行展示。

代码示例

背景介绍

假设我们有App-Main和Child-A两个应用,App-Main是基座应用,Child-A是引入到基座应用中的子应用。

App-Main应用中有全局数据查看页面,页面内容是antd表格。 Child-A应用中有资金数据查看页面,内容同样是antd表格。

这两个页面中使用到组件相同且版本一致。在我们以往普通的微前端架构中,App-Main加载时antd对应的依赖会加载一次;Child-A中的页面加载时,antd对应的依赖又会加载一次。这里就产生相同依赖重复加载的问题。

前端性能优化:模块联邦-从入门到放弃

这里我们正好再结合之前提到的核心概念来做一下说明: 很明显:App-Main应用在这里是一个Host应用,Child-A应用是一个Remote应用。 资金数据查看页面对于Child-A来说是本地模块,对于App-Main来说则是远程模块。 全局数据查看页面是App-Main自身的本地模块。 这里不涉及互相引用的情况,所以这里就没有双向主机的概念。

项目结构

下面是两个项目的项目结构(项目是通过umi4生成的)。

APP-Main的项目结构很简单:有一个自身的GlobalData页面,其中展示全局数据表格;另外有一个Fund页面,其中是引用展示了Child-A中的资金查看表格。

Child-A的项目结构也不复杂,只有一个展示Fund页面,其中展示的就是资金查看表格。但Child-A中的Fund页面并没有在pages文件夹下,而是在exposes文件夹下。这个是umi4的约定配置,所有需要通过MF导出的模块,都需要单独放在exposes文件夹。

前端性能优化:模块联邦-从入门到放弃

前端性能优化:模块联邦-从入门到放弃

页面代码

接下来我们详细看一下每个页面的详细内容。

代码内容-Child-A

Child-A-Fund页面中只是一个简单的Table展示,外部用PageContainer进行了包裹。

// Child-A-Fund
import { PageContainer } from '@ant-design/pro-components';
import { Table } from 'antd';

const dataSource = [
  //...
];

const columns = [
  //...
];

export default function Fund() {
  return (
    <PageContainer ghost>
      <Table dataSource={dataSource} columns={columns} bordered={true} />
    </PageContainer>
  );
}
代码内容-APP-Main-GlobalData

APP-Main-GlobalData页面除了数据之外,组件和上边的页面一致。

// APP-Main-GlobalData
import { PageContainer } from '@ant-design/pro-components';
import { Table } from 'antd';

const dataSource = [
  //...
];

const columns = [
  //...
];

export default function GlobalData() {
  return (
    <PageContainer ghost>
      <Table dataSource={dataSource} columns={columns} bordered={true} />
    </PageContainer>
  );
}
代码内容-APP-Main-Fund

APP-Main-Fund页面中引入了远程的Fund页面:引用路径里的remoteA是我们定义的远程应用的名称。我们一般会给页面做懒加载处理,像下面这段代码这样。

// APP-Main-Fund
import RemoteFund from 'remoteA/Fund';

export default function Fund() {
  return <RemoteFund></RemoteFund>;
}

// 懒加载处理
import { lazy } from 'react';
  
const RemoteFund = lazy(() => import('remoteA/Fund'));

export default function Fund()  {
    return <RemoteFund></RemoteFund>;
};

配置内容

接下来我们来看看最核心的部分,MF如何进行配置。umi max中集成了MF配置项。只需要在config文件中配置mf的相关属性即可。关键配置项的含义参见注释。

配置-APP-Main

// APP-Main-.umirc.ts
import { defineConfig } from '@umijs/max';
import dependencies from './package.json';

const shared = {
  react: {
    singleton: true,//是否使用单例。如果则false,则代表各个应用都会使用同一个react实例
    eager: true,//是否直接使用本地库依赖。设置为true也就意味着当前依赖不会被共享
    requiredVersion: dependencies['react'], // 指定依赖的版本(确保各应用之间的依赖版本相同。一般情况下会自动推断模块版本)
  },
  'react-dom': {
    singleton: true,
    eager: true,
    requiredVersion: dependencies['react-dom'], 
  },
  antd: {
    singleton: true,
    eager: false,
    requiredVersion: dependencies['antd'],
  },
  '@ant-design/icons': {
    singleton: true,
    eager: false,
    requiredVersion: dependencies['@ant-design/icons'],
  },
  '@ant-design/pro-components': {
    singleton: true,
    eager: false,
    requiredVersion: dependencies['@ant-design/pro-components'],
  },
};


export default defineConfig({
  // ...
  mf: {
    name: 'HostMain',//定义当前容器名称
    remotes: [
      // 远程导入的模块
      {
        name: 'remoteA',//远程容器名称
        entries: {
          DEV: 'http://localhost:3001/remote.js',
          PROD: 'http://localhost:3001/remote.js',
        },//各个环境对应的远程组件入口,格式是`${远程应用地址}/remote.js`
        keyResolver: `(function(){ 
                    try { 
                        return window.injectInfo.env || 'PROD'
                    } catch(e) { 
                        return 'PROD'
                        } 
                    })()`,//环境值的取值逻辑,决定具体使用哪个环境的远程组件入口文件,与entries对应
      },
    ],
    shared,//指定将哪些库用于共享
  },
});

配置-Child-A

// Child-A-.umirc.ts
import { defineConfig } from '@umijs/max';
import dependencies from './package.json';

const remoteMFName = 'remoteA';

//必须保证shared配置与Host应用一致
const shared = {
  react: {
    singleton: true,
    eager: true,
    requiredVersion: dependencies['react'],
  },
  'react-dom': {
    singleton: true,
    eager: true,
    requiredVersion: dependencies['react-dom'],
  },
  antd: {
    singleton: true,
    eager: false,
    requiredVersion: dependencies['antd'],
  },
  '@ant-design/icons': {
    singleton: true,
    eager: false,
    requiredVersion: dependencies['@ant-design/icons'],
  },
  '@ant-design/pro-components': {
    singleton: true,
    eager: false,
    requiredVersion: dependencies['@ant-design/pro-components'],
  },
};
export default defineConfig({
  //...

  mf: {
    name: remoteMFName,//定义当前容器名称,这里也就是我们之前页面内引入远程组件时的一级路径
    shared,
  },
});

至此,这样我们就完成了模块联邦的基本配置,项目可以成功启动。整体来说,模块联邦需要配置的关键内容并不是很多。但上面所提到的配置并不是MF配置的全部内容,如果随着后续项目的复杂性越来越高,需要更多特定配置的时候,可以参考webpack中模块联邦插件的说明。

效果分析

分析资源加载情况

模块联邦对于我们(特指我们的案场项目体系)最大的意义,在于依赖共享、减少相同依赖的重复加载。为了便于直观感受依赖共享的效果,我们这里接下来做一组对照实验。

在APP-Main应用中,一组通过iframe动态加载Fund页面,另一组通过MF共享依赖动态加载Fund页面。

iframe方式下,点击资金查看页面,可以看到加载了很多来自远程应用的内容。有页面本身的代码,也有各种依赖库的代码。

前端性能优化:模块联邦-从入门到放弃

MF方式下,点击资金查看页面。可以看到来自远程应用的内容只有一个,这就是因为当前远程模块已经使用了共享依赖。(细心的同学可能会发现,我们之前定义过react/react-dom不被共享,为什么这里也没有加载?其实这些依赖在页面初次进入时已经被加载了)

前端性能优化:模块联邦-从入门到放弃

分析编译后的代码

我们已经直观地感受到,依赖库的共享已经实现了。具体哪些依赖通过共享引用?哪些依赖直接引用自自身应用?我们可以观察分析编译后的页面代码。

在webpack打包后,应用会生成以下几类文件:

  1. 共享依赖库。文件名格式为依赖库名称_at_版本.async.js(这里我配置了按依赖库独立生成文件)。

    同样的配置下,Host应用和Remote应用经打包后都会生成此类文件。远程组件在Host中加载时,会优先引用Host中的同名文件,以达到共享依库赖的效果。

前端性能优化:模块联邦-从入门到放弃

  1. remote.js

    该文件文件名一般是固定的,是远程应用特有的打包产物,其内部主要包含了远程组件所依赖的的各类非共享库的源码及共享库的引用地址。

前端性能优化:模块联邦-从入门到放弃

  1. exposes__Fund__index.async.js

    该文件也是远程应用特有的打包产物,对应导出的远程组件-Fund。在本例中对应Child-A应用中的Fund页面。

前端性能优化:模块联邦-从入门到放弃

这里我们从exposes__Fund__index.async.js文件入手简单分析一下依赖的引用关系。隐藏掉一些代码里的一部分次要内容,文件中的代码如下。可以看到antd__WEBPACK_IMPORTED_MODULE_1__模块对应的moduleId为74611,react_jsx_runtime__WEBPACK_IMPORTED_MODULE_2__对应的moduleId为62086

按照webpack的引用id进行一层层查找,我们会发现,74611(也就是antd)这个moduleId对应的定义为一系列异步引入(如果再去查找2290对应的定义,就会发现如下2290的定义为一个字符串:2290: 'npm._at_ant-design_icons-svg_at_4.2.1',这个字符串对应的其实就是该模块打包后的文件的名称。这里不再展示代码。):

 /******/ 74611: function () {
        return loadSingletonVersionCheckFallback(
          'default',
          'antd',
          [4, 5, 4, 0],
          function () {
            return Promise.all([
              __webpack_require__.e(2290),
              __webpack_require__.e(4243),
              __webpack_require__.e(6306),
              __webpack_require__.e(2434),
              __webpack_require__.e(3679),
              //...
            ]).then(function () {
              return function () {
                return __webpack_require__(95890);
              };
            });
          },
        );
      },

62086(也就是react)这个moduleId对应的最终定义为react编译后的源码,这些源码编译在我们之前提到的remote.js中:

//99564是62086对应的最终引入id
    /***/ 99564: /***/ function (__unused_webpack_module, exports) {
      'use strict';
      /**
       * @license React
       * react.production.min.js
       *
       * Copyright (c) Facebook, Inc. and its affiliates.
       *
       * This source code is licensed under the MIT license found in the
       * LICENSE file in the root directory of this source tree.
       */
      let l = Symbol.for('react.element'),
        n = Symbol.for('react.portal'),
        p = Symbol.for('react.fragment'),
        q = Symbol.for('react.strict_mode'),
        r = Symbol.for('react.profiler'),
        t = Symbol.for('react.provider'),
        u = Symbol.for('react.context'),
        v = Symbol.for('react.forward_ref'),
        w = Symbol.for('react.suspense'),
        x = Symbol.for('react.memo'),
        y = Symbol.for('react.lazy'),
        z = Symbol.iterator;
        
      //...
      exports.useEffect = function (a, b) {
        return U.current.useEffect(a, b);
      };
      exports.useId = function () {
        return U.current.useId();
      };
      exports.useImperativeHandle = function (a, b, e) {
        return U.current.useImperativeHandle(a, b, e);
      };
      exports.useInsertionEffect = function (a, b) {
        return U.current.useInsertionEffect(a, b);
      };
      exports.useLayoutEffect = function (a, b) {
        return U.current.useLayoutEffect(a, b);
      };
      exports.useMemo = function (a, b) {
        return U.current.useMemo(a, b);
      };
      // ...
      exports.version = '18.1.0';
      /***/
    },

资源参考

最后,补上一些模块联邦知识的参考链接。

  1. Webpack-ModuleFederation
  2. Webpack-ModuleFederationPlugin
  3. Module Federation
  4. umi4-mf
  5. github-module-federation
  6. github-module-federation-examples
  7. medium-module-federation-game-changer
  8. medium-share-as-app-shell
  9. dev-enterprise-module-federation