Roger Leung‘s Epcot

vuePress-theme-reco Roger Leung ( z3rog )    2018 - 2021
Roger Leung‘s Epcot

Choose mode

  • dark
  • auto
  • light
Blog
Note
Github (opens new window)
author-avatar

Roger Leung ( z3rog )

18

Article

20

Tag

Blog
Note
Github (opens new window)

浅谈微前端

vuePress-theme-reco Roger Leung ( z3rog )    2018 - 2021

浅谈微前端

Roger Leung ( z3rog ) 2020-09-13 Micro Front EndFront EndWebpack

本文结合工作经历谈谈个人对微前端的理解及感受

最近时间比较宽裕,终于“闲”了下来写写博客,所以这一两周可能会比较高产。本文的写作灵感来源于一次很偶然的「发现」——偶阅《微前端的核心价值 (opens new window)》有感。

# 为什么需要做微前端

相信上面的文章同样会给你带来一些思考。在此我摘取其中一段:

我们去统计一下业界关于“微前端“发过声的公司,会发现 adopt 微前端的公司,基本上都是做 ToB 软件服务的,没有哪家 ToC 公司会有微前端的诉求(有也是内部的中后台系统),为什么会这样?很简单,因为很少有 ToC 软件活得过 3 年以上的。而对于 ToB 应用而言,3 - 5 年太常见了好吗!去看看阿里云最早的那些产品的控制台,去看看那些电信软件、银行软件,哪个不是 10+ 年的寿命?企业软件的升级有多痛这个我就不多说了。所以大部分企业应用都会有一个核心的诉求,就是如何确保我的遗产代码能平滑的迁移,以及如何确保我在若干年后还能用上时下热门的技术栈?

基于上面的表述,我认为微前端想要解决的两个问题是:

  • 迁移:遗产代码 / 项目如何能平稳过渡而不被时代淘汰,且能用上时下热门技术栈赋予新生命
  • 整合:多个系统或应用可以被整合为一个新的系统,且原有系统保持独立性

对于企业软件来说,整合是很常见的。以我工作所在的公司为例,我们本身有好几个独立的 Web 端产品,分别提供数据的采集、清洗、可视化等各个专一场景下丰富的能力,但实际仍会有将各个产品中的部分模块做整合,生成新产品的需求。在没有更好的微前端架构时,iframe 是最简单的方案。

但是 iframe 会带来很多问题,这些问题在实际项目中真实地体验过:

  • URL 不同步,即 BFCache 失效:浏览器刷新页面后,原有的状态丢失。
  • 上下文完全隔离,DOM 结构不能共享:iframe 会构建出独立的、完整的 document,iframe 子页面的遮罩层不能透穿至父页面文档。
  • 性能低:每一个 iframe 的加载都是 document 重建和资源重载的过程,渲染性能低;且 iframe 的渲染使用独立的进程,内存不共享。所以,如果 iframe 嵌的是同源站点,几乎完全相同的资源被多次重复加载。

# 不用 iframe 如何做

这里参考 Phodal 《微前端的那些事儿 (opens new window)》一书中总结的实施微前端的六种方式:

  • 使用 HTTP 服务器的路由来重定向多个应用
  • 在不同的框架之上设计通讯、加载机制,诸如 Mooa 和 Single-SPA
  • 通过组合多个独立应用、组件来构建一个单体应用
  • iframe。使用 iframe 及自定义消息传递机制
  • 使用纯 Web Component 构建应用
  • 结合 Web Component 构建

以及他总结的快速选型指南图:

微前端选型

如果不需要考虑兼容 IE 的新项目,完全可以使用纯 Web Component 实现,因为 Web Component 提供了很好的沙箱环境,组件之间除了共享一样的全局上下文环境,CSS 与 DOM 都是独立的,不会互相影响。这种独立性让前端页面的发布、更新就可以控制到单个 Web Component 粒度,并且可以保证引用该组件的所有应用 / 页面,都能同步更新。

粒度变细和同步更新为什么这么重要?我以我的工作经历来举例。 假设有存放通用模块 shared1、shared2 的私仓 repo1,有引用 shared1 的项目 repo2 及引用 shared2 的项目 repo3,其中 repo2、repo3 都是通过 git package 的形式引用 repo1:

  "dependencies": {
    "repo1": "git+https://your-git-host.com/repo1#branch",
  }
1
2
3

git-package-relation

这样做确实可以让通用模块的代码复用,但是 build 完之后,shared1、shared2 都被打包进 repo2、repo3 各自的 dist chunk 里,模块之间的依赖关系靠 webpackJsonp 及其内部的 __webpack_require_ 来维护,dist 与 repo1 中的代码已经没有任何关联关系。当某一天 shared1 或者 shared2 或者它们两者都有更新,repo2、repo3 都必须拉取 repo1 的代码重新打包发布。换言之,shared1、shared2 并不能独立发布。设想一下如果有十多个项目都引用了这个模块,而各个项目又分别是多个团队维护的,通用模块一更新所有人跟着发版,这样的工程结构你还能接受吗?

youtube-web-component

油管的 Web Component

Web Component 固然是好,但毕竟是新鲜事物,基于 Web Component 的框架非常有限,所以全量 Web Component 是比较激进的,尤其是在国内还要兼容各种 IE 的大环境下,还要舍弃项目原有的技术栈,成本太高。如果能在构建系统上兼容这个问题,那么 Web Component 也不是那么必须,起码在目前来说,修改构建系统是一个更平稳更稳健的方案。

Webpack 5 就是在这种环境下诞生了。

# Module Federation

Webpack 5 一项重大更新——Module Federation,正是在构建工具层面解决了这个问题。你可以参考阅读这篇文章 (opens new window) 来了解这个 feature。现在假设你对该功能已经有所了解,我将该文下的例子摘下重要部分进行讲解。

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

module.exports = {
  // ..
  output: {
    publicPath: 'http://localhost:3001/',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'app1',
      library: { type: 'var', name: 'app1' },
      filename: 'remoteEntry.js',
      exposes: {
        './Header': './src/components/Header',
      },
      shared: ['react', 'react-dom'],
    })
  ],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

以上配置表明,当前项目向外暴露一个名为 removeEntry 的入口文件,通过该文件向外暴露变量 app1 作为访问当前项目各模块的命名空间,其中暴露的组件是 Header 组件。

注意,在早期的 Webpack 5 Beta 版本中,exposes: { Header: './src/components/Header' } 是正确的配置,但后续有一 PR (opens new window) 明确要求 exposes 对象的 key 需要以 ./ 开头。否则你可能会遇到形如 TypeError: fn is not a function while loading "./Header" from webpack/container/reference/... 的报错

需要引入 Header 组件的项目也要做相应的配置:

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

module.exports = {
  // ...
  output: {
    publicPath: 'http://localhost:3002/',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'app2',
      library: { type: 'var', name: 'app2' },
      remotes: {
        app1: 'app1',
      },
      shared: ['react', 'react-dom'],
    })
  ],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

与 app1 不同,app2 的 ModuleFederationPlugin 需要声明 remotes,即声明使用哪些远程模块及对应的命名空间。上述配置表明当前项目需要引用命名空间为 app1 的远程入口。若 app2 没有需要向外暴露的模块或组件,则不必声明 exposes。

这时在 app2 的 index.html 中引入 app1 所提供的 remoteEntry:

<html>
  <head>
    <script src="http://localhost:3001/remoteEntry.js"></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
1
2
3
4
5
6
7
8

app2/src/App.js 中的 Header 就可以被引入进来:

import React from 'react'
const Header = React.lazy(() => import('app1/Header'))

export default () => (
  <div style={{margin: '20px'}}>
    <React.Suspense fallback='Loading header'>
      <Header>Hello this is App 2</Header>
    </React.Suspense>
  </div>
)
1
2
3
4
5
6
7
8
9
10

引入工作是 app1 向外暴露的 remoteEntry 所提供的,这是为什么 app2 的 index.html 需要引入这个文件。

这篇文章提供的是一个本地的例子,实际开发中我们几乎不会同时关注两个 app 及对应的仓库代码,但它演示的这种能力给我们提供了新的思路,可以用来解决我前面所遇到的发布粒度过大、更新不同步的问题。

module-federation-optimization

我们将 repo1 使用 Webpack 5 打包后,发布至私有的 CDN 中,其余引用 shared1、share2... sharedN 模块的项目均以 ModuleFederation 提供的方式引入 repo1,如:

  • 引入 repo1 暴露的 remoteEntry(一般只有几 KB)
<script src="https://your-cdn-url.com/repo1/remoteEntry.js">
1
  • 以命名空间形式引入模块
const { lazy } from 'react'
const shared1 = lazy(() => import('repo1/shared1'))
1
2

如果 repo1 中某一(些)shared 模块更新,只需要 repo1 重新 build 并重新发布,其余项目引入的 repo1/remoteEntry 及具体被引入的 shared 也会随之同步,repo2、repo3 不必再拉取代码重新发布。