【微前端实战】webpack5模块联邦实战及原理解析第一篇

1,723 阅读12分钟

如果对react源码感兴趣的朋友可以关注这个项目mini-react。如果对webpack源码感兴趣的朋友,可以关注这个项目:mini-webpack。如果对webpack tapable感兴趣的朋友可以关注这个项目:mini-tapable

前言

本系列文章主要介绍webpack5微前端实战。由于在项目中使用模块联邦遇到了不少问题,沉淀了一些经验,有必要彻底搞懂webpack5模块联邦的原理及源码实现。这里会分成几篇文章介绍:

  • webpack5模块联邦基本配置及原理解析。基本配置可以看webpack官网介绍。这也是本篇文章的主要内容。
  • webpack5模块联邦高级用法
  • webpack5微前端实战之主应用及子应用如何共享状态
  • webpack5模块联邦插件源码解析

本篇文章主要是通过介绍webpack5模块联邦的基本配置,分析主应用和子应用打包后的产物,结合网络请求、源码等手段,以熟悉子应用如何和主应用共享依赖,子应用是如何加载主应用的脚本,搞懂模块联邦的加载原理。

Demo

主应用示例代码:mfe-remote

子应用示例代码:mfe-host

webpack5模块联邦基本配置实践

shared 共享依赖

默认情况下,在 shared 里面配置的依赖都会单独打包成一个独立的文件。以下面的配置为例

主应用 webpack 配置:

output: {
  //...
  publicPath: '/', // publicPath会影响子应用加载远程脚本
  //...
}
//...
new ModuleFederationPlugin({
  name: "containerApp",
  filename: "remoteEntry.js",
  exposes: {
    "./Components": "./src/components/Button",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
  },
});

子应用配置:

new ModuleFederationPlugin({
  remotes: {
    container: "containerApp@http://localhost:8081/remoteEntry.js",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {},
    "js-cookie": {},
  },
});

主应用打包后的产物如下:

shared_01.jpg

可以看到,react、react-dom 都被打包成单独的 chunk 文件 node_modules_react_index_js 以及 vendors-node_modules_react-dom_index_js。同时,exposes 里面的 Components 也被打包成独立的 chunk 文件 src_components_Button_index_js

子应用打包后的产物如下:

shared_02.jpg

可以看到 react、react-dom、axios、js-cookie 分别被打包成独立的 chunk 文件 node_modules_react_index_js、vendors-node_modules_react-dom_index_js、vendors-node_modules_axios_index_js、node_modules_js-cookie_dist_js_cookie_mjs

打开浏览器,查看主应用控制台网络请求,如下:

shared_03.jpg

可以发现主应用也请求了 remoteEntry 文件,实际上主应用在打包时,会默认把 remoteEntry 打包到 index.html 文件里面。即使删除这个请求,也不会影响主应用的运行。

再看看子应用控制台网络请求,如下:

shared_04.jpg

上面请求 404 的脚本实际上都是子应用请求的主应用的脚本,这些请求都是在 remoteEntry.js 脚本中发起的。但是由于主应用的 publicPath 没有设置正确,导致这些请求都代理到了自应用,子应用没有这些脚本,请求肯定都是 404。我们暂且不管 404 的请求。思考另一个问题:我们在子应用的 shared 配置里面配置了四个共享依赖 react、react-dom、axios、js-cookie。为啥 react、react-dom 请求出错,而 js-cookie、axios 的请求却正常?

要回答这个问题,我们需要先来看下子应用的 main 文件请求

shared_05.jpg

在子应用的 main 文件中,会首先注册 4 个共享依赖,然后调用 initExternal 加载主应用的 remoteEntry.js,并调用主应用的 init 方法。remoteEntry.js 的 init 逻辑如下:

shared_06.jpg

可以看到,init 方法中的 sharedScope 是子应用的 main.js 传递过来的参数,也是我们在子应用 webpack 配置里面定义的 shared 依赖,其结构如上图所示。这里需要特别注意每个依赖都有一个 get 方法,这个 get 方法默认都是在 main.js 中定义的。同时在 init 方法中,还通过webpack_require.S[name] = shareScope 将子应用的共享依赖挂在 remoteEntry 的webpack_require上。

shared_07.jpg

从上面的流程可以看出:子应用配置的 shared 依赖,会通过 register 注册到 sharedScope 里面,每个 sharedScope 里面的依赖都有一个 get 方法。默认情况下,这些 get 方法都是走的子应用的 main.js 的 get 方法,请求的是子应用打包后的 chunk 文件。但是,子应用请求主应用的 remoteEntry 后,remoteEntry.js 注册的是主应用的 shared 依赖,同时 remoteEntry.js 的 register 方法注册的是主应用的依赖,会覆盖掉子应用 shareScope 依赖里面的 get 方法,retemoEntry.js 的 get 方法请求的是主应用打包后的 chunk 文件。由于主应用只定义了 react、react-dom 这两个依赖,因此这两个依赖请求的是主应用的。而 js-cookie、axios 只有子应用定义了,因此请求的是子应用的。

具体的 register、get 方法加载模块的流程可以看源码。

shared 共享依赖版本

默认情况下,子应用的依赖以子应用的版本为主。以下面的配置为例:

主应用 webpack 配置:

output: {
  //...
  publicPath: '/', // publicPath会影响子应用加载远程脚本
  //...
}
//...
new ModuleFederationPlugin({
  name: "containerApp",
  filename: "remoteEntry.js",
  exposes: {
    "./Components": "./src/components/Button",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {},
  },
});

子应用配置:

new ModuleFederationPlugin({
  remotes: {
    container: "containerApp@http://localhost:8081/remoteEntry.js",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {},
    "js-cookie": {},
  },
});

主应用和子应用都在 shared 里定义了 axios 依赖。两个应用打包后的产物如下:

shared_08.jpg

那么问题来了,子应用加载的是主应用的 axios chunk 文件还是子应用自身的 axios chunk 文件?

实际上,对于上面的配置而言,没有指定版本号的情况下,子应用加载的永远都是自身的 axios chunk 文件。下面以具体的例子说明

场景一:主应用依赖版本比子应用高

主应用安装axios@1.5.1版本,子应用安装axios@1.0.0版本。查看子应用的源码,可以发现 findValidVersion 会根据子应用 shared 配置里面的版本号从 scope 中查找对应的 entry,由于我们没有指定版本,因此默认使用子应用的 package.json 里面的版本号。因此子应用加载的是自身的 axios chunk 文件。

shared_09.jpg

场景二:主应用依赖版本比子应用低

主应用安装axios@1.0.0版本,子应用安装axios@1.5.1版本。可以看到,子应用加载的依旧是自身的 axios chunk 文件

shared_10.jpg

场景三:主应用依赖版本与子应用相同

主应用安装axios@1.5.1版本,子应用安装axios@1.5.1版本

shared_11.jpg

从图中可以看出,当主应用和子应用依赖版本相同时,子应用加载的是主应用的 axios chunk 文件,而不是自身的。通过浏览器 network 请求也可以看出:

shared_12.jpg

场景四:主应用 shared 指定版本

实际上,在主应用 shared 配置里面指定版本是没什么意义的,对子应用的加载没啥影响。这次我们在主应用安装axios@1.5.1版本,子应用安装axios@1.0.0版本。同时配置如下:

主应用 webpack 配置:

output: {
  //...
  publicPath: '/', // publicPath会影响子应用加载远程脚本
  //...
}
//...
new ModuleFederationPlugin({
  name: "containerApp",
  filename: "remoteEntry.js",
  exposes: {
    "./Components": "./src/components/Button",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {
      requiredVersion: '^1.1.0',
    },
  },
});

子应用配置:

new ModuleFederationPlugin({
  remotes: {
    container: "containerApp@http://localhost:8081/remoteEntry.js",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {},
    "js-cookie": {},
  },
});

如果按照主应用配置的 shared 里面的 requiredVersion,按理子应用应该加载的是 1.5.1 版本号的 axios,即主应用的 axios chunk 才对,但实际情况并非如此。

shared_13.jpg

可以看到,主应用的 shared 指定版本不会影响子应用的 axios 加载逻辑,子应用依然加载的是自身的 axios chunk

场景五:子应用 shared 指定版本

这次我们在主应用安装axios@1.5.1版本,子应用安装axios@1.0.0版本,同时在子应用 shared 指定 axios 版本,配置如下:

主应用 webpack 配置:

output: {
  //...
  publicPath: '/', // publicPath会影响子应用加载远程脚本
  //...
}
//...
new ModuleFederationPlugin({
  name: "containerApp",
  filename: "remoteEntry.js",
  exposes: {
    "./Components": "./src/components/Button",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {
      // requiredVersion: '^1.1.0',
    },
  },
});

子应用配置:

new ModuleFederationPlugin({
  remotes: {
    container: "containerApp@http://localhost:8081/remoteEntry.js",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {
      requiredVersion: "^1.1.0",
    },
    "js-cookie": {},
  },
});

由于子应用 shared 里面指定了 axios 的 requiredVersion,即版本号必须大于 1.1.0。由于子应用自身的依赖为 1.0.0,显然不满足要求,但是主应用的 axios 版本为 1.5.1,因此,这里子应用将会加载主应用的 axios chunk。

shared_14.jpg

场景六:子应用 shared 指定的版本不存在

这次我们在主应用安装axios@1.5.1版本,子应用安装axios@1.0.0版本,同时在子应用 shared 指定 axios 版本,配置如下:

主应用 webpack 配置:

output: {
  //...
  publicPath: '/', // publicPath会影响子应用加载远程脚本
  //...
}
//...
new ModuleFederationPlugin({
  name: "containerApp",
  filename: "remoteEntry.js",
  exposes: {
    "./Components": "./src/components/Button",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {
      // requiredVersion: '^1.1.0',
    },
  },
});

子应用配置:

new ModuleFederationPlugin({
  remotes: {
    container: "containerApp@http://localhost:8081/remoteEntry.js",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {
      requiredVersion: "^2.0.0",
    },
    "js-cookie": {},
  },
});

在子应用中指定 axios 的版本必须大于 2.0.0,但是由于主应用和子应用的版本号都不满足。因此子应用在加载时会走兜底的 fallback 逻辑,如下图所示,即子应用加载自身的 axios chunk 文件。

shared_15.jpg

publicPath

主应用的 publicPath 会影响子应用加载远程模块。在前面的例子中,主应用的 publicPath 都设置为'/',因此子应用加载主应用提供的模块请求 404。

shared_16.jpg

要搞清楚这个问题。我们需要了解一下远程模块的加载逻辑。我们前面讲到,sharedScope 里面每个依赖都有一个 get 方法,模块加载的入口就从这里开始。如下图所示:

shared_17.jpg

remoteEntry.js 中远程模块的加载逻辑如下:

shared_18.jpg

资源的拼接逻辑为:

var url = __webpack_require__.p + __webpack_require__.u(chunkId);

可以看出最重要的是__webpack_require__.p的设置,这个值决定了最终的请求 url 是到哪个域名。接下来就看下主应用的 output.publicPath 的设置会对子应用远程模块的加载有啥影响

如果远程脚本请求 404,大概率是主应用的 output.publicPath 设置不正确导致的

主应用不设置 output.publicPath

默认情况下,如果主应用不设置 output.publicPath 时,webpack 在构建阶段会往 remoteEntry.js 中注入设置 publicPath 的逻辑。这段逻辑主要是获取 remoteEntry.js 这个请求的域名,获取的结果就是主应用的域名

shared_19.jpg

可想而知,此时子应用能够正确获取到远程模块

shared_20.jpg

主应用设置 output.publicPath 为主应用域名

当主应用设置 output.publicPath 为具体的值,webpack 在构建阶段会在 remoteEntry.js 中注入设置__webpack_require__.p

shared_21.jpg

主应用设置 output.publicPath 为 'auto'

实际上,设置 output.publicPath 为'auto'和不设置 output.publicPath 的效果一样。

shared_22.jpg

从 remoteEntry 脚本中推断 publicPath

前面介绍的 publicPath 设置的例子中,不够灵活。在真实的业务场景中,主应用都会设置特定的 output.publicPath,而这又分为两种场景:

  • 1.大部分情况下都是相对路径。如果设置成相对路径,对于主应用来说是比较友好的,因为不需要关注域名。但是对于子应用来说,此时加载远程模块就会导致脚本请求 404
  • 2.小部分情况下设置成绝对路径,这种情况虽然能够解决子应用加载远程模块的问题,但也意味着子应用需要关注主应用的域名,如果主应用域名一旦发生变化,则所有子应用都需要更新,不够灵活。(当然,实际上子应用还是需要关注 remoteEntry 的域名的)

现在就介绍一种可以根据 remoteEntry 脚本的来源,动态设置子应用远程模块的 publicPath

修改主应用的 webpack 配置

entry: {
  main: paths.appIndexJs,
  containerApp: paths.publicPathJs, // entry name必须和ModuleFederationPlugin一致
},
//...
new ModuleFederationPlugin({
  name: "containerApp", // 必须和entry入口名字一致
  filename: "remoteEntry.js",
  exposes: {
    "./Components": "./src/components/Button",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {
      // requiredVersion: '^1.1.0',
    },
  },
});

同时,在主应用的 src 下新增一个setup-public-path.js文件

shared_23.jpg

看下打包后的代码:

shared_24.jpg

shared_25.jpg

shared_26.jpg

子应用动态设置 publicPath

修改主应用的 webpack 配置

entry: {
  main: paths.appIndexJs,
  containerApp: paths.publicPathJs, // entry name必须和ModuleFederationPlugin一致
},
//...
new ModuleFederationPlugin({
  name: 'containerApp',
  filename: 'remoteEntry.js',
  exposes: {
    // 暴露的模块名称
    './Components': './src/components/Button',
    './public-path': './src/setup-public-path',
  },
  shared: {
    react: {
      singleton: true,
    },
    'react-dom': {
      singleton: true,
    },
    axios: {},
  },
})

在主应用的 src 下新增一个setup-public-path.js文件

shared_27.jpg

修改子应用的 src/index.js 文件:

shared_28.jpg

打包后运行结果如下,可以看到已经成功设置了 publicPath

shared_29.jpg

动态远程容器

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

子应用 webpack 配置:

new ModuleFederationPlugin({
  remotes: {
    container: "containerApp@http://localhost:8081/remoteEntry.js",
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {},
    "js-cookie": {},
  },
});

运行结果如下:

shared_30.jpg

可以看到,子应用加载主应用的 remoteEntry.js 逻辑。实际上,如果我们可以动态修改这部分加载逻辑。修改子应用的 webpack 配置,如下所示:

new ModuleFederationPlugin({
  remotes: {
    container: `promise new Promise(resolve => {
      const timeStamp = Date.now();
      const remoteUrlWithTimeStamp = 'http://localhost:8081/remoteEntry.js?time=' + timeStamp;
      const script = document.createElement('script')
      script.src = remoteUrlWithTimeStamp
      script.onload = () => {
        // the injected script has loaded and is available on window
        // we can now resolve this Promise
        const proxy = {
          get: (request) => window.containerApp.get(request),
          init: (arg) => {
            try {
              return window.containerApp.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);
    })
    `,
  },
  shared: {
    react: {
      singleton: true,
    },
    "react-dom": {
      singleton: true,
    },
    axios: {},
    "js-cookie": {},
  },
});

运行结果如下:

shared_31.jpg

动态远程容器在有些场景下比较有用,如果主应用域名需要定制,则可以通过在子应用中动态设置主应用的域名以实现灵活性。

在本例中,我们通过在remoteEntry.js后面追加时间戳,可以有效解决子应用加载remoteEntry.js时的缓存问题。

总结

至此,我们基本上熟悉了webpack5模块联邦基本配置以及远程模块的加载原理,了解到主应用的publicPath是如何影响到子应用加载远程模块的。在实际项目中,需要特别注意publicPath的设置。同时,我们也熟悉了动态远程容器的玩法及其原理。