Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从微组件到代码共享 - 云+社区 - 腾讯云 #65

Open
eightHundreds opened this issue Apr 17, 2022 · 0 comments
Open

从微组件到代码共享 - 云+社区 - 腾讯云 #65

eightHundreds opened this issue Apr 17, 2022 · 0 comments

Comments

@eightHundreds
Copy link
Owner

前言

随着前端应用越来越复杂,越来越庞大。前有巨石应用像滚雪球一般不断的叠高,后有中后台应用随着历史长河不断地积累负债,或者急需得到改善。微前端的工程方案在前端 er 心中像一道曙光不断的被提起,被实践,多年至今终于有了比较好的指引。它在解决大型应用之间复杂的依赖关系,或是解决我们技术栈的迁移历史负担,都在一定程度上扮演了极其关键的桥梁。

本文会先从复用组件,窥探到代码共享。聊一聊中后台项目在微前端的场景下,从工程化的角度下如何跨技术栈复用业务组件,再介绍一下其它的共享代码方案。

在正文开始之前,希望读者能对以下关键词有所了解,以便后文一起交流探讨

  • 微前端
  • 共享组件
  • Garfish(字节开源的微前端框架)
  • Webpack & module federation
  • Bit

业务背景

如上图,我们先看这么个场景。这个 modal 被红色框起来的部分,其实是一个业务复杂较复杂的 react 组件来渲染的。在这里就需要渲染出 5 个 react 组件。同时这个 modal 是过去用 vue 实现的代码,我们的 react 组件是需要被渲染在 vue 代码中的,也就是 React in Vue。

在我们的中后台系统里,过去全都是 vue 的技术栈。而我们新的业务希望全面的往 react 迁移,其中不乏有比较复杂的业务组件。如下

基于微前端的工程方案,我们就可以尽可能少的修改 vue 的代码。同时,我们也能达到组件级别的嵌入。

从工程的角度解决微组件共享

项目介绍

先试想一下,其实大多数中后台项目,都是像如上的场景一般。我们可能仅是为了应用之间的解耦,这有利于构建,团队独立维护,改善项目结构,代码复用等等。其实更需要解决的是团队内部自身的工程问题,基本不会涉及到跨产品部门的复用或业务共享。我们更多关注的是,当下在不同 repo 之间的代码和在不同技术栈之间的组件,如何达到共享。那么我们需要共享微组件的职责就很清晰了。

在我们团队的中后台应用有三个 repo,过去的巨石应用(vue),新建的两个 monorepo(react)。(拆了两个是业务之间比较独立。)

在我们有了 monorepo 之后,其实所有的业务组件或者业务代码,都已经在物理的层面上可以良好的复用。剩下的问题就在于如何跨 repo(跨物理层面)在过去的技术栈(vue)中直接复用。而我们的方式就是基于微前端来做。

当我们有了 master 这样的宿主介入之后,项目的可操作空间就不太一样了。微前端为的是能在同一个应用下,提供一个相同的运行环境。(本文不过多探讨 iframe 的方式。)

monorepo 能很好地解决我们同一个 repo 下的代码复用问题。如果我们把每一个 repo 都抽象的看做一个模块,那就只需要想办法在这个模块能 exports 东西出去,不就可以达到跨 repo 之间的复用?同时它也是一种解决了物理层面上无法复用的手段。

所以我们的做法就变得很清晰了,在新的 react repo 里,其实我们就会自然的沉淀下许许多多的基础组件或者是带有复杂业务的业务组件。比如上图的 biz-ui,每一个 biz-ui 里的组件,都是一个完整的业务组件。而我们最终的目标,就是想办法把这些业务组件通过微前端的方式,给其它项目使用。

Micro-components app 子应用,就是我们的 exports,它也是一个子应用。所有需要在当前 repo exports 的业务组件,都可以在这里被注册。

利用子应用复用微组件

从一个用法开始

如果是一个组件很简单,也很好实现,我们知道 garfish 有提供 loadApp 的接口,我们可以直接通过加载一个子应用,这个子应用渲染某个 react 组件。大致代码如下

这样的代码在我们系统里还是跑了几个月的,没有任何问题。但是如果有了多例就不一样了,我们会调用多次 loadApp,加载了大量子应用的代码,导致性能很差,甚至直接卡死。有人说加 cache 行不行?其实也是不可行的,上述的代码过于简陋,我们还需要处理 props 变化的情况,以及 loadApp,传递 props 给 react 的情况。如果单纯只是 cashe 解决不了这样的场景。

所以我们特意设计了一个子应用,这个子应用专门作为组件级别的渲染,暂且称之为 微组件子应用

而在 vue 那,我们需要保证全局只会 load 一个微组件子应用,这个子应用的 domGetter 可挂在到 body 上,仅仅作为一个 container。而我们的 react 组件,全通过 portal 的形式进行渲染到任意位置即可。

基于这个思路,我们需要去设计一个微组件渲染的数据结构。再看一眼这个图,我们这个数据结构会有哪些东西

每个组件其实所需要接收的参数有 domId、props 和事件或其它属性。所以我们的数据结构其实可以大致如下。

有了这个结构,我们 react 的 render 函数就简单了,统一渲染一个 protal 数组即可。

在 vue 这边,我们先设想一下应该如何使用这样的组件呢?当然肯定是和单纯的一个 vue 组件没有区别。比如这样。

所以我们就需要封装一个底层的 vue 组件去负责管理子应用的 load 和 props 的传递。

而 MicroComponent,需要去负责保持只能 load 一个子应用单例以及 props 的传递和变化

我们需要用两个 flag 来控制 mount 和 unmunt。为了保证只能 load 一个子应用,用一个 loaded 开关来控制。而 count 是因为我们有多例其实就是个引用计数,必须保证每个微组件都卸载了,才能去 unmount 掉我们的子应用。

props 如何传递呢?这里其实就是如何进行不同应用之间的数据共享,同时他是保持一份的。我们可以通过 garfish 提供的 API 来实现。

基于这 2 个 API,我们可以在 garfish 上构建出这么个对象来传递我们的数据。在之前提到过,我们可能是多个子应用 export 出来的组件,其实这部分的数据存储就是一个二维结构。

当我们初始化一个 vue 的组件时,就需要把对应的 meta 数据挂载到 garfish 上。修改一下我们刚刚上面的组件代码

因为需要保持每一个子应用都是唯一的单例,我们继续引入 microComponentManager 来帮我们管理所有的子应用实例。

搞定了初始化和数据传递的的问题后,我们来思考一下 props change 的问题。其实也很简单,只要三个步骤。

  1. 监听 vue 组件的 props 变化,重新修改数据 set 到 garfish 上
  2. 发送事件,通知 react 获取最新的数据
  3. React rerender

react 组件则接收到事件后,对数据进行更新,重新渲染

我们的 MicroComponent 也需要增加相应的事件发送代码。

我们用一个 pending 队列来存放所有的事件,这是避免一瞬间发送过多事件导致无意义的开销。比如一个列表的页面,可能同时创建了 100 个微组件,此时如果不做一次 debounce 则会一瞬间发送 100 次。一个优化的小细节。

另外需要注意的是注意到我们发送事件的地方用了个 setTimeout,这是由于我们的 app.mount,其实仅仅只是把子应用给渲染完了,此时不代表 react 的组件被渲染完毕,我们在 react 里的 useEffect 还是没有执行的。所以我们需要放到下一个 macroTask 来发送事件,为了保证 react 里先监听。

以上其实就是整套方案的核心代码了

总结

总的来说,我们的实现方案就是基于 loadApp,把一个子应用仅仅当做多应用之间渲染和通信的媒介挂在在了 body 上。所有的组件都通过 portal 的方式,挂载到指定的 dom 位置上。

优势

  1. 原理代码实现简单轻量,复用便捷,开发高效,无关技术栈
  2. 接入简单,可以实现 ReactInVue,VueInReact
  3. 无论需要复用多少个组件,都只需要 load1 个子应用,开销低
  4. 可以挂载到任何 garfish 的应用里,组件复用,达到跨团队级别的复用
  5. 只需要发布一次,所有地方全都生效且最新版本
  6. 可以跨 repo 搭建自己需要共享的组件子应用

劣势

  1. 无法对组件版本进行管理
  2. 需要基于garfish的环境才能达到共享
  3. 需要创建一个子项目,相比共享组件的方案更重
  4. keep-alive 场景下可能有问题
  5. 依赖管理不方便控制 (React,组件库等)

可以看出这个方案也有一个最大的局限性。版本不可控,在我们的业务里是不需要对这样需要共享的组件进行版本管理的。以下介绍的方案大家需要注意下,如果你的共享组件需要版本管理则不可采用这种方案。所以,我们再来看看,现在共享组件的标准实现方案。

运行时组件市场

我们上述的方案,其实是通过组件复用的场景细分采用工程化的方案来解决物理隔离,技术栈不同的组件复用。而如果我们需要一个更加通用化的微组件方案,必然会需要平台的支持,版本的支持,loader 的支持。所以我们来看看现有的组件市场的发展方向。

Garfish 提供了 loadComponent[1] 的 API,可以直接远程加载一个组件资源。在现有的设计下,大多数这个资源都是一个已经被编译好的 umd 的 js 文件。

不过在字节内部的另一个微前端框架有另外一种设计,使用的 API 与 federation 非常相似。

以上的例子无论是哪种 API 的设计,都不妨碍我们深入理解微组件。不难发现,需要抽象一个微组件必须具备的 API 需要有

  • Load(指定资源,无论是 key 还是 url)
  • mount/unmout (生命周期)
  • update (props change)

当组件的 API 被合理的设计好之后,我们还有一个关键就在于如何管理这些组件。于是「组件市场」就这么诞生了。组件市场必须具备的职责只需要两点

  • 组件的上传与下架
  • 可以是以 name 的方式或者 url 的方式下载代码

以往我们已经现有的物料平台或者是区块平台,都可以很简单且自然的支持这两个功能。

共享代码

其实上面讲了两种微组件的方案。我们可以扩展性的思考一下,共享组件其实就是共享代码的一种细分,解决了共享代码,我们就顺便解决了共享组件的问题。而往往共享代码会有更大的使用场景。

Module Federation

概念

Module Federation(以下简称 MF)的中文直译为 “模块联邦”,从 Webpack 官网中我们可以找到使用其的动机:

Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually.

This is often known as Micro-Frontends, but is not limited to that. 可以看出 MF 想要达到的目的就是把多个无相互依赖、单独部署的应用合并为一个应用,即 MF 提供了在某个应用中可以远程加载其他服务器上应用的能力。对于 MF 来说,有两种角色:

  • Host:引用了其他应用的应用
  • Remote:被其他应用所使用的应用

同时,一个应用既可以作为 host 也可以作为 remote,即可以利用 MF 实现一个去中心化的应用部署群。并且,MF 允许应用之间共享依赖实例,例如:host 使用了 react,remote 也使用了 react,remote 经过配置后,可以在被 host 加载运行时优先使用 host 的 react 实例,而不会重复加载,这样可以做到在一个应用中只存在一个 react 实例。

示例

我们将使用**Webpack 官网[2]给出的demo[3]**作为示例,向大家展示如何使 host 应用(app1)在运行时动态加载并使用 remote 应用(app2)的内容。先来看看 demo 中的文件结构:

  • app1
    • App.js(react 页面入口)
    • bootstrap.js(项目启动文件)
    • index.js(项目入口文件)
    • src
    • webpack.config.js(webpack 配置文件)
  • app2
    • App.js(react 页面入口)
    • Button.js(Button Component)
    • bootstrap.js(项目启动文件)
    • index.js(项目入口文件)
    • src
    • webpack.config.js(webpack 配置文件)

app1 和 app2 是两个独立部署的应用。

下面来看看 app1 中的具体代码内容:

可以发现 App.js 中有一行非常关键的代码:

那么问题来了:

  1. 这个app2/Button是从哪里来的呢?
  2. 这一段引用的组件代码长啥样?

我们先来看看 app2 项目中的 webpack 配置(这里我们就不贴 app2 的代码内容了,因为没有什么特别的地方并且在这里并不需要关心):

从上面配置可以知道:

  1. app2 项目作为 remote 时的模块名是 app2;
  2. export 的内容是 Button 组件;
  3. 要 export 的内容会独立打包成一个名叫 remoteEntry.js 的文件;
  4. export 的内容在被 host 消费时,会优先使用 host 的 react 和 react-dom 实例。

那么 app1 中又是如何配置使用 app2 模块的内容的呢,下面我们来看看 app1 的 webpack 配置中关于 MF 的部分:

从上面配置中中可以知道 app1 中使用了跑在 localhost:3002 上的 app2 模块内容。至此,在 app1 如何配置使用 app2 内容的问题就解决了。

把项目跑起来,可以看到 app1 的页面,从前面的代码可以知道,App2 Button 组件是来自 app2 中的。

并且可以看到,app1 下载了 app2 的 remoteEntry.js 文件,并使用了里面的相关内容,共享代码成功。

实现原理

在讲 MF 的实现原理之前,我们先来简单简单讲下 webpack 的模块打包原理,这对理解 MF 的模块原理至关重要,如果你对这部分内容已经熟知,可以跳过。

先看个简单的栗子🌰(webpack 配置没有什么特殊的,这里就不贴了):

经过 webpack 打包后形成两个 chunk 文件:

  1. main.js (其中包含 index.js 和 ModuleA.js 的内容)
  2. src_ModuleB_js.js

来看看 main.js 里的内容(简化过后):

这就是整个项目的启动文件,其实就是一个 IIFE。

其中内部变量__webpack_modules__维护了一个该 chunk 所包含的所有 modules 的 map,key 就是 module id,value 就是模块内容。

从上面的 main.js 中可以知道其实__webpack_require__模块加载的核心所在,主要做了两件事:

  1. 先从缓存的模块列表中寻找,若找到直接返回该模块的内容;
  2. 若在缓存模块列表中未找到,则执行该模块的加载函数并加入缓存列表中。

当我们是动态 import 时则会调用__webpack_require__.e

至此可以发现__webpack_require__.e只是返回了一个 promise,然后再执行了__webpack_require__方法。可见,在__webpack_require__.e执行完成后,main chunk 中的__webpack_modules__就会有 ModuleB 的内容,这是怎么做到的呢:

简单来说就是 main chunk 中维护了一个__webpack_modules__的 map,用于维护该 chunk 中有哪些 module,而其他的 chunk,也会将自己内部的 modules 加到 main chunk 的__webpack_modules__

讲到这里,想必那么 MF 的实现方式,会不会也是将下载好的远程模块放进主 chunk 所维护的模块列表,从而实现代码共享 🤔。

仔细看了上面的 MF Demo 打包后的结果,发现果真如此。下面让我们来简单看看下面两个问题:

  1. app1 如何下载和使用 app2 的代码;
  2. app1 与 app2 如何实现依赖共享。

来看看从 app2 的 remoteEntry.js 里的实现,它了一个全局变量 app2,它的值为一个包含 init 和 get 方法的对象:

既然要从 app2 下载代码,那么 main.js 中的__webpack_modules__必然维护着 app2/remoteEntry.js 的模块加载方法:

其中调用了__webpack_require__.l来下载 app2/remoteEntry.js 文件,具体代码不贴了,简单讲讲这个方法做了那些事情:

  1. 新建一个 script 标签
  2. src 设置为 app2/remoteEntry.js 的地址
  3. 将 script 标签添加到 document 中
  4. 下载结束后执行回调方法(第二个参数)

而 federation 实现的核心在于加载器的变化__webpack_require__.e。通过之前的介绍,我们知道它的功能就是异步加载模块。但是在 federation 中它就完全不一样了,他会作为 remote 的加载器!

核心关键就在于__webpack_require__.f对象 我们可以把 f 理解为 federation 的缩写。在. f 上挂了 3 个方法分别为

webpack_require.f.j 负责创建 script 加载代码

webpack_require.f.consumes 负责执行 app2.init

webpack_require.f.remotes 负责执行 app2.get

到这里基本我们就明白了,federation 基于__webpack__require__这个对象作为 window 上的 runtime,而 f 这个对象管理了其它应用的依赖和初始化。在 federation 下,每一个模块 (main.js 或 remoteEntry.js) 其实都是一个__webpack_modules__,是一个不断套娃的过程。

总结一下,federation 给我们前端模块化和应用模块化打开了一种新的思路,他基于 window(实际上是__webpack_require__)这个桥梁作为不同的模块和应用之间的通信媒介。而 host 和 expose 本身就是一种场景的设计,不难发现,我们前文所述的微组件解决方案也是基于这种抽象的思维 (基于微前端把 repo 直接作为 host 和 expose) 来实现的。

而 federation 也有一些局限性,比如我们必须要求新项目都是 webpack5 以上,我们的技术栈需要保持一致,共享代码时在 runtime 下如何解决单例问题,在 TS 中的话,还需要去考虑如何共享类型的问题等等。

应用场景

federation 还有许多实用的场景

一、当我们是一个巨大的应用想要拆分独立部署和构建,但是 host 和 subapp 之间又有应用之间的依赖需要共享同时我们的依赖是有状态关系的。我们可以人为的抽离一个 shared 层,把需要复用的 api 或组件放在这个 shared 层上,不同的 sub 之间直接互相使用。

二、另外在某些微前端的场景下,我们的路由配置表其实是可以通过 federation 直接进行共享,无需统一配置在 master 上。

三、federation 还能解决构建时长的问题。比如 Umi 甚至通过 federation 带来的灵感解决了**构建时长[4]**的问题。有兴趣的可以点击链接看一看。

Bit

一句话介绍 Bit:是一个集成了 npm + git 功能,组件文档,可视化,CI/CD 一站式的标准化的组件管理平台

提到代码复用,就不得不说一下 bit 这个平台。bit 整体使用上手都非常简单,由于篇幅原因就不过多介绍。首先跟着**官网教程[5]**走一遍,初始化一个 bit 组件库 workspace 并且发布好一个组件。

我们在任意一个已有的项目下,我们通过 bit init 即可初始化我们的 workspace。再通过 bit import 来 “download” 一个组件,比如我们这里就 bit import meckodo.test/ui/button

修改一下这个默认的组件代码。比如这里我们把 div 换成了 button

修改完成后,做一个类似 git 一样的提交 bit tag --all --message "change to button" 再通过 bit export 发布一个新版本

到官网上就可以预览到我们更新的组件了

  • before:

  • after:

不难发现,bit 的好处就在于。我们任意一个项目都可以非常方便的 “download”(import) 组件同时,在当前项目下很方便的直接发布 (export) 新版本。bit 不仅仅支持了组件的形式,其实还支持了普通的 js/ts 代码。在团队内部的业务下,如果有这样跨 repo 级别共享代码的需求就会非常方便。

总结

本文介绍在微前端项目中我们是如何跨项目跨技术栈复用组件的的使用场景,进而思考到其他工具的是如何复用代码的原理和更广泛的适用范围。

其中较为重要的个人认为是去熟悉内在的一些思想。深入的思考分层和抽象搭建新的 “桥梁”,如何去寻找“桥梁” 把不同的模块组织起来。会发现前文所说的工程角度来解决组件的共享,其实就是基于 garfish 这个桥梁,对共享的数据进行了一些同步,这就和 webpack 的__webpack__require__有异曲同工之处。而把 repo 抽象为模块,针对性的进行 exports,也是从 federation 中借鉴了灵感。

参考链接

https://garfish.top/

探索 webpack5 新特性 Module federation 在腾讯文档的应用[7]

webpack 打包的代码怎么在浏览器跑起来的[7]

https://mp.weixin.qq.com/s/zhkgudIjeuKWi536U5pJhw

参考资料

[1]

loadComponent: https://github.com/bytedance/garfish/wiki/API#loadcomponentname-string-options-loadcomponentoptions--component

[2]

Webpack 官网: https://webpack.js.org/concepts/module-federation/

[3]

demo: https://github.com/module-federation/module-federation-examples/tree/master/basic-host-remote

[4]

构建时长: https://umijs.org/zh-CN/docs/mfsu

[5]

官网教程: https://harmony-docs.bit.dev/getting-started/initializing-workspace

[6]

探索 webpack5 新特性 Module federation 在腾讯文档的应用: http://www.alloyteam.com/2020/04/14338/

[7]

webpack 打包的代码怎么在浏览器跑起来的: https://segmentfault.com/a/1190000022669224
https://cloud.tencent.com/developer/article/1872455

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant