查看原文
其他

推开“微前端”的门

马海娜 百度Geek说 2022-03-17

导读:“微前端”和“微服务”类似,是这两年被频繁提及的名词。web开发从前后端放在一起的单体应用,演进成前后端分离的SPA,这些改变让前后端实现了开发解耦、独立发布。解耦让开发、调试、发布的过程都更加自由灵活,但随着业务的发展,中大型的SPA逐渐成为了“巨石应用”(Monolithic Applications),当初因为前后端分离带来的“自由”也渐行渐远,模块的拆解越来越被需要。


全文5574字,预计阅读时间14分钟


本文主要分享两方面内容:
  1. 思考什么样的系统或者前端需要微前端
  2. 简述微前端工程中需要关注的一些设计要点
本文仅会从选型和设计上做一些思考总结,「不会」重点介绍以下内容:
  1. 深入介绍某些开源框架并对比
  2. 如何设计一个非常通用的微前端框架
  3. 针对某个设计点的实现方式非常详尽的介绍
希望能给正在踌躇是否使用微前端的你一些思路。

一、什么样的系统或者前端团队需要微前端?

如果你有下面案例的困境,微前端可能是你的一个选择。假设一个SPA的前端模块,包含了ABCD四个模块,它们错综复杂地依赖了多个单独部署的后端服务,如下图:
图一
上线当天,你们可能需要梳理一个模块依赖图谱,以确定当前的上线顺序。
  • 前端模块A、B、C、D均属于一个需要整体发布的SPA;
  • 模块C上线依赖服务2、3,模块D依赖服务1、4;
  • 因为模块C和D需要一起上线,和服务2毫无关系的模块D、服务1、4都需要等待服务2的发布。
这个SPA模块将成为整个上线日忙碌的十字路口,上线、验证操作需要排队等待,相关同学不厌其烦。
更可怕的是,当大家千辛万苦完成深夜上线后,任何发现了问题需要紧急止损的模块,都常会带来雪崩似的回滚操作。如果上线方案准备不完备,还需要临时确认影响面,梳理回滚操作的图谱顺序。这种消耗掉的止损时间,在一些系统、产品中常常是难以接受的,造成回滚反应链的模块别提压力有多大了。
结合这个例子,有以下几个参考点,如果它们恰好命中了你的痛点,微前端会是你的一个「解决办法」
  1. 并行开发的模块数量多
    当你有多个功能相对解耦独立的模块,需要并行开发、发布。
  2. 协同开发的人员数量多
    当单个应用开发同学的人数超过一定规模(如大于10人),且每个人负责的子模块相对固定,人数增多协同效率往往会指数增长。
  3. 协同开发的团队数量多
    这是一个有趣的点,和2的区别,主要在“屁股决定脑袋”的“屁股”上。如果你们由于种种原因,不得不跨团队维护同一个工程,那你们可能面对着开发权限、规范、节奏等很多协同点,这些协同有时候会因为跨团队效率更低。
  4. 发布频率高
    这条需要与1和2结合起来看,人数多但是发布频率较低的模块,协同沟通等成本就成了伪命题。当然,多人协同开发的应用,大概率是发展中的业务,需要频繁的迭代。
  5. 需要跨技术栈
    这一点可能是很多团队选择微前端的重要理由。你们可能维护了一个经营多年、系统繁杂、技术栈较陈旧的系统,你希望能引入新的技术栈但又没有人力一下子重写系统。先把系统微前端化,再渐进式地分子模块重写,会是一个工作节奏可控、质量风险较低的办法。
    但是,针对跨技术栈这个命题,对于单独产品或平台来说,我个人认为有个
    「“陷阱”」 。从团队长远开发效率、平台的性能优化空间、体验一致等多种角度来看,长期不加管控的跨技术栈是有风险的。一些小粒度的抽象(组件、业务模块等)难以被高效复用,一些升级难以被直接应用到全局等。这些问题导致的效率下降,可能会掩盖独立开发、发布带来的效率提升。Martin Fowler在介绍微前端的收益之一时时也写的是 「Incremental upgrades」 ,渐进式更新不等同于永久区别。因此,除非你做的只是一个门户,对子模块的一致性没有很高的协同要求,我更建议跨技术栈是渐进式迁移的中间态。

二、选择微前端需要关注的设计点

经过了checklist,如果一些问题让你决定微前端改造,下面的一些应用设计点也许能对你有所帮助。

2.1 主模块与子模块

首先你需要一个主模块,它可能是一个HTML,一个或一组top app bundle,我们称它为「APP Shell」「Nutshell」(https://martinfowler.com/articles/micro-frontends.html#InANutshell),你的顶层逻辑需要加载一些前置依赖,初始化entry内容,根据路由等信息加载渲染子模块。关于运行时加载sub app的实现方式有很多,这里简单列举几个,就不一一展开了:
  1. subapp是一个iframe,最简单暴力的实现方式,同时有iframe实现页面的一切限制。优化空间、顶层控制力有限,个人不推荐。
  2. subapp是web component,跟随路由切换实例化组件。你需要考虑浏览器的兼容性限制。
  3. subapp是一个独立发布的子bundle。子bundle需要定义一些生命周期hook,如register、mount、unmount等。这个方法应该是比较普及使用的。

2.2 单实例 vs 多实例

根据运行时实例的数量,业界有了「单实例」「多实例」两种APP。「单实例」是指,在运行时同一时刻仅一个子模块被激活,下图二是一个常见场景,子模块通常跟随路由装载/卸载。
图二
「多实例」则是运行时同一时刻可能会有多个子模块实例在激活状态。下图三是一个场景,路由和子模块不再是一对一关系,可能成了多对多。
图三
实现方案是由业务和服务拆解来决定的,建议把功能内聚、维护团队相对收敛的模块单独拆分,然后再看运行时是否需要在同个路由下同时出现「多实例」。无论运行时有几个子模块,你都需要一个路由和页面内容的映射关系。这个关系对某些APP来说可能是开发编译时可以预判的枚举关系,那么你可能需要维护一个如下的map:
// 单实例-示意
const subAppRoutes = {
 route1: 'https://your.static.server.com/app1/index.js',
 route2: 'https://my.static.server.com/app2/index.js',
 route3: 'https://other.static.server.com/app3/index.js'
};
// 多实例-示意
const subAppRoutes = {
 route1: [
  {
   subApp: 'https://your.static.server.com/app1/index.js',
   layout: {
              // 省去布局描述信息
          }
  },
  {
   subApp: 'https://my.static.server.com/app2/index.js'
  }
 ],
 route2: [
  {
   subApp: 'https://your.static.server.com/app2/index.js'
  },
  {
   subApp: 'https://my.static.server.com/app3/index.js'
  }
 ],
 route3: [
  {
   subApp: 'https://my.static.server.com/app3/index.js'
  }
 ]
}
入口模块会根据路由和相关配置,完成资源的加载,并根据生命周期协议挂载子APP。前面已经提到,你需要一个路由。可以通过一些路由前缀约束规则来避免子模块冲突。关于路由的设计实现文章很多,不是本文重点,暂且按下不表。
如果你的系统非常灵活、页面类型和数量不可收敛,那么你可能需要一个动态存储的数据结构以及服务来代替浏览器中的location 路由,通过定义系统的页面结构schema,来描述当前页面中每个微前端模块需要放置的位置和布局(如上面“多实例-示意”中的route1)。schema的引入让低码(Low-code)开发成为可能,未来你通过简单的数据结构配置,即可用微前端模块拼凑出一个APP页。当然,这需要有额外的存储和业务逻辑,系统的复杂度会增加。

2.3 子模块通信

大多数系统的子模块还是不能规避一些互相影响的。假设你有一个功能模块A,和内容推荐模块B,B的内容需要根据A的操作连锁反应。有几种常见的做法:
  1. 全局数据共享
    有个全局store,A将数据变更写入store,B监听store的change并做出响应,单向数据流的设计能让开发调试变的更加容易,当然你需要规避分模块对单一store内容的冲突问题,这个和路由冲突的解决方案类似,比如增加一些命名空间。剩下的内容和你接触过的各种单一store的设计都类同,不再赘述。
  2. 事件通信
    提供全局的EventBus能力,A派发事件,B接受事件并响应。这是个平平无奇的事件通信,但在微前端实现中有一个点需要关注。各子模块之间加载和实例化的过程大多是独立的、异步的,A发布事件时,B还没有实例化完成,那么这个消息可能会被漏掉。通过1中共享数据的方式可以解决大部分问题,如果你更喜欢用事件通信来解决,则需要在设计实现EventBus的时候考虑这个功能,例如缓存事件队列,当B在启用事件监听的时刻,回顾一下缓存事件队列中有没有已经派发且需要被响应的事件。
当然,你可能还有一些思路,比如二次封装。在A和B之上封装C,负责两个模块的调度,但这个办法和上述两条不是一个层面,比如C的实现也往往需要和A、B的通信或者数据共享。同时过度二次封装缺点也很明显,封装的模块数量会随着业务的组合膨胀,难以收敛维护。在实践过程中,1和2的能力你应该「都需要建设」。

三、性能优化小贴士

除了平日性能优化的常规操作,在微前端架构下,有两个小点值得关注。

3.1 多实例按需渲染

如果你的APP页面子模块较多,可以考虑按需渲染。如下图四的场景,可以根据视口和模块位置决策当前模块是否需要实例化渲染。以此来减少首屏浏览器scripting和资源加载的时间。当然,随着视窗滚动延迟渲染模块的体验,也可以通过闲时预渲染来优化解决。
图四

3.2 重复打包优化

微前端的一个问题是,一些公共能力如何处理。“微”带来了自由,但自由过头了可能就是重复、难以规范要求。比如一些基础的UI、动态请求能力等,每个模块单独实现打包,都会成为线上运行时的负累。
对于公共能力的抽取也有一些“套路”:
  1. Entry提供的全局实例。
    你的顶层APP可以给每个实例化的子模块注入一个全局能力引用,提供一些几乎每个模块都要使用的能力,例如ajax请求能力、业务埋点监控能力等。好处是一些底层能力你可以很好地控制起来,缺点是子模块和entry之间的耦合会更深一些。如果你有一个子模块需要被应用在不同的entry APP中,那针对每个APP,你可能需要一个适配器层来屏蔽差异。
  2. 抽取公共内容打包。
    公共内容中最常见的就是polyfill了,每个模块通过使用「usage」(https://babeljs.io/docs/en/babel-preset-env#usebuiltins)单独打包,好处是开发、线上环境一致,缺点是polyfill内容会高度重复,如图五。
图五
优化的思路也显而易见,将polyfill以「entry」方式仅引入一次,如图六。
图六
但如何保证这个公共polyfill也是按需优化的呢?有一些办法,例如:
  • 根据运行时环境实时加载polyfill,如polyfill.io。比较重的方案,个人觉得收益不一定非常划算。
  • webpack@5的「module federation」。还在beta阶段,建议生产环境慎重(如果你webpack升5之后工程还跑得起来的话)。
  • 约定polyfill的白名单/黑名单。这个是我们工程中最后使用的方案,理由是轻量、稳妥,工程角度觉得“划算”。当然这个方式会有一个问题呼之欲出,单独开发的模块怎么保证使用的语法不会超出白名单?我们的解决方案是:为了在灵活性上有一定的规范约束,开发微服务子模块需要使用一个我们封装的dev cli workspace,在开发时完成App级别的变量注入、语法校验。发布编译阶段也会有相应的控制,因此「开发环境工具」也是微服务改造的利器。
以上是我们在微前端工程化技术选型、架构设计中的部分思考和经验,篇幅限制不再展开更多。

3.3 自由 vs 规范

最后,总结一下我对微前端的一些个人看法。灵活、自由可能是很多大型项目、团队设计的期望。但我认为,一个APP在灵活同时,控制力和规范也是非常重要的。你可能会希望系统能从数据上规范、交互视觉上高度一致、体验优良、性能良好。那么,在一开始向微服务改造的时候,就需要提前设计规范控制。否则,微前端热潮之下不久,可能又要开启一片“微前端治理”相关的讨论。毕竟,从“限制”到“自由”容易,从“自由”到“限制”可难了。

参考文献

  • Micro Frontends: https://martinfowler.com/articles/micro-frontends.html#IncrementalUpgrades
  • @babel/preset-env · Babel: https://babeljs.io/docs/en/babel-preset-env#usebuiltins
  • Polyfill.io: http://polyfill.io/
  • Module Federation | webpack: https://webpack.js.org/concepts/module-federation/

嘉宾介绍:


马海娜,百度商业平台研发部前端资深研发工程师,主要负责广告托管业务的前端架构。专注于在快速迭代的业务中打造高效、可扩展、体验良好的前端业务架构。爱好撸码和撸猫。


推荐阅读:

|百度商业大规模高性能全息日志检索技术揭秘

|快速剪辑-助力度咔智能剪辑提效实践

|短视频个性化Push工程精进之路


#文末福利#

「爱奇艺会员月卡1张」

看完文章转发该文
并把截图发到公众号后台
我们将抽取一位朋友送出爱奇艺会员月卡1张
截止时间:12月3日18:00
一键三连,好运连连,bug不见👇

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存