微前端是模块化后的最终选择


微前端应作为彻底解耦代码和依赖关系后的最后手段。

分布式单体很难管理,并有可能在多个代码库中重新引入相同的问题。

在拆分之前,需要进行彻底的重构,以尽量减少孤立部分之间的相互依赖。
虽然拆分代码可以带来好处,但也会带来开销,除非确有必要,否则最好避免拆分。

从头开始重写代码也应避免,因为这有可能造成范围扩大和遗漏代码中的现有知识。
在考虑微前端之前,渐进式模块化和在 monorepo 中分离严格的架构边界是更安全的方法。

问题:

  • 微前端允许不同团队独立发布,有助于解决组织问题。微前端可以实现独立开发和发布,有助于扩展到大型代码库,即使从架构的角度看并不理想,也是一种实用的解决方案。
  • 模块化固然重要,但微前端也能解决一些问题,如避免全局回滚或迫使不同产品的发布节奏过快。
  • 微前端被过度使用,导致代码重用和依赖性问题,
  • 其他问题:管理共享库、依赖性问题以及团队间缺乏协作。

原文点击标题,摘要:

如今,在大型科技公司中,前端开发人员谈论 "微前端 "已成为一种普遍现象。微前端类似于后端系统中的微服务。但是,就像我们都见过糟糕的微服务架构只会使分布式单体更加糟糕一样,糟糕的微前端架构只会将紧耦合代码分散到许多移动部件上。

我的观点并不是说微前端架构永远无法弥补其复杂性。我认为,对于一个规模足够大的团队来说,在一个单核架构中拥有独立的管道是保持团队独立运行的合理设计。

但我怀疑的是,很少有团队处于这种状况,而处于这种状况的团队,在采用更复杂的架构之前,应该首先对他们的领域进行分解。MFE 有许多可移动的部分,这使得以协调一致的方式发布、移动和测试代码变得更加困难。

相反,从模块化单体开始,在创建新管道的简单工作之前,先做重构领域的艰苦工作。

微前端的问题
微前端在会议上非常流行,所以我可能会因为批评微前端而让未来的求职面试变得尴尬。但我看到有足够多的团队在错误的时间采用了微前端,因此我对这种炒作感到担忧。我的反对意见可以归结为三个核心问题

  • 分布在不同部分的单体是一个痛苦的世界
  • 在拆分之前需要重构,但先拆分看起来更简单
  • MFE 可能是一种重写热

不要分散你的单体!
我们这些从事后端微服务转型工作的人可能都见过这样一种反模式:我们得到的不是分离得干干净净的独立微服务,而是一个复杂的依赖关系毛团,现在又被分割到了不同的服务器上。这种情况被称为 "分布式单体",它令人痛苦,因为同样的相互依赖关系依然存在,只不过现在是通过 HTTP 调用或事件进行调解。

大多数适用于 MFE 的大型前端应用程序都具有这种程度的相互依赖性。这就导致 MFE 架构提出了某种上下文共享或状态传递机制。这可以是公开全局函数的 JavaScript 捆绑程序,或者使用 postMessage 作为事件总线。共享的状态越多,存在的依赖性就越大,离真正的隔离就越远。

为什么这比在单体中存在相互依赖关系更糟糕呢?因为在单体中,你可以非常容易地进行重构。只需在一次提交中修改 A 和 B,就能移除 A -> B 之间的依赖关系。你可以通过查看单个版本库来了解依赖关系。你可以让 TypeScript 验证没有任何东西在使用 A 的方法。你可能只需要一个测试管道来验证更改。

如果你在这方面不够聪明,微前端就会产生开销。最终,你可能不得不在多个地方精心协调部署代码。最后,你可能不得不以笨拙的版本机制发布共享代码。你必须发明一种方法来集成测试所有内容。你的工程师不得不在多个版本库中四处打探,以了解整个系统是如何整合在一起的。这不是一种可扩展的开发体验。

我对 MFE 的看法是,任何依赖关系都应经过仔细审查,并将其考虑到最低限度。在《卫报》,我们的 "MFE "根本不共享任何状态。但是,当你的代码是一团紧密耦合的乱麻时,就很难将依赖性考虑进去,这就引出了下一点。

拆分之前先解耦
假设我们的前端应用程序是一个充满相互依赖关系的毛球。我们该如何打破这种联系呢?我认为工程师和经理们- 和经理们都对摆脱 "单体 "的前景感到兴奋不已,却忘记了实际拆分毛球的中间阶段。

重构任何代码都是艰难的、不舒服的、吃力不讨好的,但就像刷牙或去健身房一样,你只能逃避这么长时间,否则后果不堪设想。如果你试图拆分一个应用程序而不对其各个部分进行解耦,那么会发生以下两种情况之一

  • 你会得到一个分布式单体(见上文)
  • 你设法将孤立的部分转化为 MFE(没有依赖关系的页面),但大型复杂页面实际上从未迁移过

好的解耦是什么样的?

  • 代码的每一部分都属于一个不同的域,一个特定的业务领域,或者属于一个共享的 "支持 "域
  • 反过来说,每个领域都在代码的一个独立部分中实现--而不是分散在几个部分中
  • 代码的各个部分通过一个小的、结构合理的接口进行交互。最好是类型安全的。

你可能很想跳过解耦(decoupling),并说你会在迁移代码时进行解耦。这是一个坏主意。你最终会陷入半迁移状态,即部分代码被迁移,但其余代码无法解耦。你需要尽量缩小每次增量变更的范围,而不是试图将重构和重平台化合并为一项任务。

MFE 可能是重写热的一种形式
有时,工程师(和经理)厌倦了在一个糟糕的代码库中工作,以至于他们幻想将代码库全部扔掉。在微前端项目中,团队有时会觉得他们可以使用 MFE 从头开始有效地重写代码库,从而避免原始项目的所有复杂性。

重写确实很危险。重写代码之所以诱人,是因为编写代码比阅读代码容易得多。开发人员会幻想,凭借现代化的工具和扎实的知识,他们能比原来的工程师做得更好。这里有一点主角综合症(上一个项目是个灾难,因为不是我写的)。但结果证明重写比想象的要复杂得多,因为旧代码中充满了秘密和边缘情况,而这些都不是显而易见的。

我对重写的经验是,重写开始时都很顺利。在重写初期,速度似乎很高,因为你专注于为新代码搭建脚手架,并为代码库添加工具。当你开始实施功能时,事情就变得棘手了。重写工作被当作所有人的一切,最终成为一个比你最初想象的还要大的项目。

通常,在 MFE 中,重写的动机是一个足够糟糕的单体代码库。但如果你已经到了这一步,你的页面和用户界面就太复杂了,不可能在几周内重写。积攒了五年代码的产品页面需要花费大量精力去理解和拆解。将其移入 Next.js 应用程序并不能缩短这一过程。通过 MFE 进行增量重写要比在一个新的单体中进行全面重写要好,但好不了多少。

MFE 之前的模块化
那么,如果前端资源库达到了无法有效工作的临界质量,该怎么办呢?我的建议是从模块化开始。

暂时保持单一的版本库和构建,但开始将代码组织成具有非常严格架构边界的软件包。让这些软件包只通过定义明确的最小接口进行交互,理想情况下只需要一个带有 ID 和令牌等基本信息的小型用户上下文对象。软件包可以像 API 缓存一样共享惰性状态,但不能像 Redux 存储或 React 上下文一样共享上下文。

当你将代码拆分后,你就可以实现 Yarn 工作空间等专为管理 monorepo 而设计的功能。这样,您就可以拆分单元测试,只执行已更改部分的测试套件。这将带来类似于 MFE 的好处,而不会产生任何开销。

随着时间的推移,你可以逐步过渡到类似于 microfrontend 的架构,它位于 monorepo 内部,拥有独立的管道和完全差异化的构建。

为什么要使用 monorepo?根据我的经验,这是在监督和独立性之间的良好权衡。您仍可将 monorepo 应用程序作为一个整体进行类型检查,同时独立构建和部署模块。无需进行复杂的版本控制和打包,就能共享代码;只需稍加努力,就能实现独立的部署管道,而且在需要时跨包移动代码也更容易。

监督很重要。当复杂度达到这种程度时,你需要一个团队来监督整体架构并提供通用工具。如果让每个团队各行其是,就会造成混乱。

重构很困难。在我工作过的一个前端项目中,有超过 40 万行的源代码(我指的是不包括依赖关系),我最终写了一个程序来理解代码库本身。它使用每个文件的 AST(抽象语法树)来发现组件读取或写入 Redux 数据的时间,将其解析为图表,然后让你对图表执行查询,以了解不同页面之间的联系。仅图形数据就有数百千字节。

但通过这样做,我们现在可以将问题可视化。以前,我们的观点是,我们可以通过 MFE 重写一切,工程师们可以边做边想。当我们能够直观地看到数据依赖关系图时,我们就会明白,如果不在代码中专门进行域解耦(domain)工作,这将是多么困难。

权衡利弊
尽管有上述种种批评,但我认为假以时日,这个应用程序可以成为类似微前端项目的东西,尽管比我看到的其他组织采用的 polyrepo MFE 有更多的控制和监督。但是,只有在领域隔离和设计级重构基本完成后,它才能成为微前端项目。届时,代码库的各个部分将被充分隔离,我们可以独立工作。

你可以称其为隐蔽式微前端,而我称之为敏捷架构。

不要为了实现你所读到的 "伟大的新架构 "而去做大项目。找出问题所在,不断迭代,不要试图跳过如何将代码拆分成真正独立单元的艰苦工作,否则你得到的只会是可怕的前端单体变成了分布式单体 MFE。