解析 Module Federation 的 Shared 原理

2,482 阅读3分钟

webpack5 的 Module Federation 相信大家都不陌生了,它为前端的模块共享,带来了全新的解决方案

这里不再对其基础的使用做说明了,还没使用的过的同学可以先去了解一下,官方提供的这里示例仓库覆盖了绝大部分使用场景,有兴趣的同学建议看一下。

本文主要是对其中的 Shared 的原理做下解析,此前我一直对它是怎么实现的十分好奇

shared: 主要是用来避免项目出现多个公共依赖。例如 react、react-dom。一般 shared 都会在共享模块的服务都 配置了对应的模块,他们打包时会将其单独抽成一个 chunk,如果远程的模块没有提供或者还没有加载,那么就加载自己服务的资源。

个人觉得就算不用于共享, shared 也可以用于一种的优化手段,对公共的模块都单独抽成一个 chunk,降低 js 体积,并充分利用缓存

先贴一下示例的代码,部署两个服务 app1 app2, 共享模块 semver 和 lodash, app2 还作为 remote 提供了 一个 Button 组件

app1

    # app1 config
    new ModuleFederationPlugin({
      name: 'app1',
      remotes: {
        app2: `app2@${getRemoteEntryUrl(3002)}`,
      },
      shared: { semver: { singleton: true  }, 'lodash': { singleton: true } },
    }),
    
    # app1 index.js
    import('./bootstrap');
    
    # app1 bootstrap.js
    import semver from 'semver';
    import lodash from 'lodash';

    const obj = {
      v: '1.2.3'
    }
    console.log('demo', semver.valid(lodash.get(obj, 'v')))


    import('app2/Button').then((res) => {
      console.log('res', res.default())
    })

app2

 new ModuleFederationPlugin({
    name: 'app2',
    library: { type: 'var', name: 'app2' },
    filename: 'remoteEntry.js',
    exposes: {
      './Button': './src/Button',
    },
    shared: { semver: { singleton: true }, 'lodash': { singleton: true } },
  }),
  
  
  // app2 index.js
  import('./bootstrap');
  
  // app2 button.js
  import semver from 'semver';
  import lodash from 'lodash';

  const Button = () => {
    const obj = {
      v: '1.2.3'
    }
    return semver.valid(lodash.get(obj, 'v'))
  }

  export default Button;

怎么判断加载远程服务,还是自己服务的 shared 模块?

当我们异步加载 bootstrap.js 时,会编译成如下代码

// src_bootstrap 会单独抽取为一个 chunk, 当__webpack_require__.e 的 Promise 状态变为 resolve 时
// 表示 src_bootstrap 已经在 __webpack_modules__ 中添加完毕了,就可以用__webpack_require__去执行了
	__webpack_require__.e("src_bootstrap_js").then(
		__webpack_require__.bind(__webpack_require__, "./src/bootstrap.js"));

当使用 webpack_require.e 加载模块的时候,其实会经过下面三个方法的处理


// 会去加载 remote 的 entry,并加载配置的 shared
__webpack_require__.f.consumes = (chunkId, promises) = {}

// 判断是否已经加载,会创建 script 标签 去加载
__webpack_require__.f.j = (chunkId, promises) = {} 


// 加载远程的模块,比如我们这里的 Button,这里逻辑比较简单,原理跟加载异步的 js 一样,可参考:https://juejin.cn/post/6918281070026686478
__webpack_require__.f.remotes = (chunkId, promises) = {}

从一开始的代码中,可以知道在 app1 中,我们的 bootstrap.js 是依赖于 semver 和 lodash 这两个库的,在打包后,会用这里一个结构来表示依赖关系

var chunkMapping = {
   "src_bootstrap_js": [
 	"webpack/sharing/consume/default/semver/semver",
        "webpack/sharing/consume/default/lodash/lodash"
   ]
 };
 

在调用 __webpack_require__.e("src_bootstrap_js")时,根据这个可以知道 boostrap 的依赖模块,这里,会等待 boostrap 以及它的依赖 semver 、 lodash 都注册到了 webpack_modules 中后,才认为src_bootstrap_js 完成了加载,可以认为:

当我们的模块中,import 了对应的 shared 配置的模块,那么会等待其全部加载完毕后,才会执行模块代码

那么它是如何判断,shared 配置的模块,是消费本服务的,还是消费 remote 服务的? 只要你在 ModuleFederationPlugin 中配置了 shared, 都会根据你的配置生成这样的结构

{
	'lodash': {
		'4.17.21': {
 			get: factory, // get 方法会去创建 script 加载,并注册到 __webpack_modules__  
                        from: uniqueName,
                        eager: !!eager
		}
		
	}

}

其实就是在 webpack_require.f.consumes 加载 remote (App2)的 remoteEntry.js 时,会调用 app2 中暴露出来的 window.app2.init 方法, 并将 app1 的 shared 结构作为参数传递过去,如果名称、版本都能匹配上,那么 app2 会重写这里的 get 方法,让其加载 app2 中的 shared 模块

image.png