【第2335期】面向未来与浏览器规范的前端DDD架构设计
前言
来自美团沙龙之《微前端架构设计和实践》。今日前端早读课文章由字节跳动@郑仁杰授权分享。
@郑仁杰,字节跳动微前端开源项目 Magic(https://github.com/bytedance/magic-microservices)的负责人及核心贡献者
正文从这开始~~
Hello 大家好,我是来自字节跳动的郑仁杰,目前是公司微前端开源项目 Magic(https://github.com/bytedance/magic-microservices)的负责人及核心贡献者,今天我带来的分享主题是《面向未来与浏览器规范的前端 DDD 架构设计》,这次我们站在了一个与行业传统微前端解决方案完全不同的全新的视角去思考如何设计和实践前端微服务,如何更优雅的借助浏览器原生能力将微前端的粒度做到组件级别,期待能给大家带来不一样的收获和体验。
今天的分享我们主要会分为以下四个部分的内容,首先是我们做这件事情的技术背景,为什么我们要去做微前端,我们要解决的现实问题是什么,行业上的微前端解决方案都有哪些优劣?
虽然说今天我们是在介绍一个我们自己沉淀的解决方案,但是我们不会把太多的篇幅放在我们的解决方案的介绍以及宣传上,今天的分享,更多地是和大家一起回到我们刚做这件事的起点,遵循我们的思考足迹一起去探寻和发掘现阶段的前端技术热点和他们的现实价值。
首先进入我们的第一个主题 —— 技术背景。
其实现如今微前端的概念早已经不是什么新鲜的词了,业内也有很多实现优秀的实现或者框架和库。比如大家最熟悉的那些已经被投放在生产中的实践,例如以 single-spa(https://single-spa.js.org/)为核心支撑起的传统“微前端”解决方案,亦或是基于它封装的优秀实现 Qiankun(https://qiankun.umijs.org/),一直都是近来微前端生态中的热点;再比如图中的 Shoelace(https://shoelace.style/)组件库,去年一度成为了前端行业的热点,它借助一些浏览器的原生能力以及特定的实现,例如浏览器提出的 Web Components 规范,能够产出一套抹平框架的组件库;当然,还有 Webpack5 推出的 Module Federation 能力,也再一次把微前端理念推上了“热搜”,很多业内的同僚都认为,它或许会成为微前端在生产中更进一步的“银弹”。
那在这过程中大家是否有去思考过一个问题:为什么微前端在过去一段时间会火起来呢,或者说,微服务这样一套后端设计,前端工程师们又在其中发现了什么价值而让这么多人热衷于此呢?是大家在追赶技术噱头,还是真的有迫切需要这项技术去解决的问题呢?我们带着这些问题,透过这个现象去看一个在我们的日常开发中经常会遇到的一个案例:
我们经常会沉淀一些业务组件(如果你的逻辑足够优雅可抽象🤔),当别的同学或者系统有同样的实现需求时,就会希望复用我们的组件能力,最简单的方式可能就是 copy 对应的代码或者文件,当然如果是更优秀的实践的话,我们会把可复用的组件打成一个 npm package,再配上友好的文档,也可以快速供给其他同学快速复用。
但是有一天,问题出现了...
有一个使用 Vue 的团队觉得我们的实现很优秀,他们也希望能复用我们已经梳理好的这样一个业务组件,这时候问题就来了:我们通过 React 技术栈编写的组件,怎么才能更友好地被 Vue 团队所复用呢?
当然问题肯定是有解决方案的, ReactDOM.render 可以帮助我们实现跨框架复用组件挂载的能力,但是我们也需要额外去处理 props 的传递以及变化的监听,不然组件的传参机制相当于就无法使用了。我们开始发现,逻辑或者组件复用的流程似乎开始变得复杂了起来。
不单单是组件层面的跨框架复用受到了挑战,很多有一定历史的前端团队也会遇到这样的问题,可能在过去,我们因为一些历史问题会沉淀一些 Angular 或者 Vue 的系统,但随着团队的开发流程逐步规范以及对框架选型的收敛,不免就会遇见如何让这些系统能够相互兼容和嵌入的问题。是“重构”,还是用 Iframe?还是有更现代化的其他成熟方案?因此,行业上普遍的微前端解决方案也是在尝试如何做到在一个系统内完成多个框架的子系统调度。
组件的跨框架复用问题对于组件库而言可能要更加是痛点,所以为了更好地解决开发者的使用体验,并扩大自己的用户群体,一个优秀的组件库可能不得不产出基于不同框架的多套组件库。同理,在很多我们开发的过程中,也会遇到“看到了一个实现优秀的组件,很贴合我们的需求,但是 Vue 却成为了它的原罪,因为团队的技术栈是 react,我们会增加许多为了复用跨框架组件而搭建胶水层的额外成本而无法选择它”的尴尬情况。
这里不禁让我们产生了一个疑问:前端领域本质都是 HTML、CSS 和 JS,似乎前端越发展,复用能力越弱了?上到一个完整的系统,下到一个小而轻组件,技术栈的差异,往往成为了它复用能力的护城河。
因此,在这样的背景下,我们所熟悉的微前端解决方案就应运而生了。
综上所述,对于前端工程师们而言,我们所需要的,就是在逻辑抽象或者复用的过程中,不再需要考虑语言是什么,框架是什么,亦或是原本的项目环境是什么。这不才是前端开发应有的模样嘛?既然本质都是相通的,为什么不能快速做到相互复用呢?回想 JQuery 时代,我们搜到一个优秀的实现,不就可以通过 JQuery 拓展的形式快速使用嘛。
那么,在最开始我们说到,社区上有如此多优秀的微前端实践,那它们能否丝滑地解决上述问题呢?
在我们深入调研和理性分析之后,我们得出了一个结论:能解决问题,但还不够丝滑。相信大家现在看到这个结论一定会有争议或者质疑,先不着急,待我们细细品来。
这里就拿我们认为实现最优秀的框架之一 —— single-spa 举例,之所以我们会这么认为,原因在于业内特别是很多国内各家公司的最终解决方案,或是像 Qiankun 这样优秀的微前端框架,也都是基于 single-spa 建设或者封装的,所以至少说在国内,它是很具代表,或者很有参考价值的。
在深入分析它之前,咱们先看看 single-spa 能为我们带来什么,这里我们一起快速过一下:
参考上图的示例,我们可以将 single-spa 的使用过程,或是以 single-spa 设计理念的微前端改造过程,简单总结为以下两个步骤:
首先,我们需要有微前端体系中的“子应用”,也可以说是待抽象复用的子系统。就如 PPT 中的左图所示,我们可以将我们的一些旧逻辑快速通过 single-spa 提供的工具函数包裹成一个符合 single-spa 规定的子应用生命周期的复用模块,比如这里的 rootComponent,通常就是我们的可复用组件。然后我们需要在主应用里预留子应用的承载容器,当 single-spa 匹配到激活的子应用时,会自动触发调用子应用内导出的 mount 方法,实现子应用的渲染,当然在这里你也可以自定义其他渲染逻辑。
接下来,我们需要搭建我们的主应用基座,用以调度这些子应用。就如 PPT 中的右图所示,通过 single-spa 提供的工具方法配置不同子应用的激活路由,当浏览器路由发生变化时,single-spa 便会针对匹配到路由的子应用,触发我们通过子应用导出的例如 mount、unmount 等回调,从而实现不同子应用的挂载和卸载。自然,你需要通过特定的机制获取到子应用模块导出的回调,可以是一些特定的模块化加载机制,亦或是像 Qiankun 这样帮你封装的 HTML Entry。
所以综上所述我们可以发现,其实 single-spa 已经能够承接很大一部分的微前端解决方案了,得益于生命周期的设计,开发者完全可以自由配置子应用的渲染逻辑,这样就能达到抹平框架的效果。对于一个新型框架在初期想要快速发展或者是快速能支撑起一套生态,无缝对接原有逻辑必然会成为它披荆斩棘的利器。
所以 single-spa 在实践中的劣势可以总结为:主从应用在设计上其实是没有太大关联的,主应用更多时候只是借助路由变化完成了子应用的生命周期调度。所以有了像 qiankun 这样的解决方案来解决一些 single-spa 层面在开发上的痛点,但是调用执行生命周期这样的设计的确会在先天上有一些约束,并且抛弃了一些优秀的原生 DOM 能力。而通过监听路由级别的变化来完成应用切换,也基本表明了 single-spa 的设计目标就是聚焦于页面级或者说是路由级的微前端应用。
带着对 single-spa 的思考,我们再一起看一下在另一个方向上,使用另一种完全不同的实现方式,对微前端之路的探索。
在前端社区上一直有一个颇有争议的规范 —— Web Components,你可能在过去时常会听到一些社区上对它的吐槽,其实早在 Web Components 被提出之后,很快就有一批“有志之士”投入到这项新规范的探索历程中了,在这个过程中,也产出很多极具代表性和里程碑意义的工具,就比如业内比较有名的 x-tag(https://x-tag.github.io/) 以及 Polymer(https://www.polymer-project.org/,同时它也是 LitElement 的前身),借助这类框架提供的体系,你就不需要关注底层的 Web Components 如何实现,就能快速将一个你自己的业务组件“变成”一个浏览器原生就能支持的 HTML 标签啦~
比如上图中 x-tag 官网提供的示例,通过 Chrome Devtools 我们可以发现,我们自定义的时钟组件,完全变成了一个浏览器的原生 HTML 标签。
不仅仅是如此,很多社区爱好者们也借鉴这套思想产出了一些对开发更加友好的基于 Web Components 的抹平开发体系的微前端框架,腾讯开源的 Omi(https://github.com/Tencent/omi) 就是一个很好例子,使用 Omi 体系编写的组件,可以快速被拿到任意逻辑中去复用,因为借助了 Web Components 的能力,这些组件最终就变成了一个我们再熟悉不过的 HTML Tag,所有框架都能像支持 Div 一样支持这些标签,因为他们本质上就已经是浏览器的「原生标签」。
不仅如此,甚至也有一些解决方案,连组件封装的逻辑都帮你写好了,我们在一开始提到的 Shoelace(https://shoelace.style/) 就是一个再好不过的例子了,这里的组件生态就像 Antd 一样让你再熟悉不过了,并且比起 Antd 更舒适的是,你都不需要关心开发的技术栈,就像图中 Demo 所示,在引入 Shoelace 的 SDK 之后,你就可以直接通过使用原生 HTML Tag 的方式来使用这些早已帮你封装好的组件。并且正如 Shoelace 的官网简介中说到,它们也完全可以称得上是真正意义上的“服务化组件”。
但是上述的这些基于 Web Components 的框架都有一个局限,那就是都是自建的体系或 DSL,如上述示例所示,如果想要快速承接已有逻辑,势必会有较大的改造或者对接成本,要么就是,你一开始就应该在这整套体系下编程。试想一下,如果让你现在将手上现有的系统重构成 Omi 框架,那将会是一个多大的工程。
那如果我们想要自己依照原生的 Web Components API 封装一个微应用,这样的方案也是可行的嘛?其实一切并不复杂,这里我提供了一个快速实现一个 Web Component 的 Demo(https://codepen.io/jerryonlyzrj/pen/PoPzavX),整体的逻辑也就是十多行代码就能解决的事。
所以我们就在思考,有没一种解决方案既能像 single-spa 一样既对原有逻辑友好,但不限制微应用的粒度,又能复用 Web Components 符合工程师开发直觉的灵活插拔性,能把我们的原有逻辑变成一个前端开发者再熟悉不过的原生 HTML 标签被快速复用,这样我们的主应用也不需要有什么支持成本。
很幸运,在 Magic 项目启动前,我们并没有找到这样的实践,因此我们也坚信,这或许会是一个契机。
既然要实现微前端能力,或者说要解决前端的分治复用问题,那我们势必就得先理解微前端体系的内核是什么?作为分支复用的代表的 DDD 架构设计,它又是如何在后端工程中发挥价值的?
我们可以先看下后端微服务的实现是怎样的。后端的微服务的实现,其实就是把能够独立的逻辑抽象成单一职责的实体,来让它达到更灵活的“可插拔”复用性,不用考虑实现的语言,不用考虑实现的框架,甚至不用考虑环境(基于 Docker),和我们最初的预期很相符。就如我们图中的示例所示,一个复杂系统中的各项服务,都可以拆分成单一实体,封装成镜像之后,可以被带到任何需要它的地方实例化以便复用。
所以我们是不是可以这样去总结:在编程的世界中,最重要的便是抽象能力,微服务改造的过程本质上就是个抽象与分治的过程。
那什么又是前端微服务呢?所谓前端微服务,不单单指一个系统,或者说是一个网页,正如后端微服务的设计理念一样,其实前端网页上的任意部分或者说任意结构、任意元素,都能被抽象成为一个微服务的实体,在其他系统或者网页中快速集成复用,与现在的组件化模式不同,微服务的形式,更在于让这些抽象不再需要考虑语言是什么,框架是什么,并且在独立的开发、构建、部署体系的支撑下,让这些复用单元成为真正意义上的「服务」。但其实本质上,微前端也是实现组件化理念的一种形式,也可以说,是组件化体系中的一个子集。
综上所述,微前端要解决的第一个核心问题就是便捷地跨框架,因为现如今,前端框架已经成为每个前端项目都难以逾越的一座围城。
那我们回到每个框架的本质来看,其实我们可以发现,不论是 ReactDOM.render 还是 Vue.$mount 等等前端框架的渲染逻辑,虽然底层的实现不一定是 appendChild,但是具体的结果,我们是否完全可以理解为就是往我们传入的 MOUNT_NODE 上 appendChild。所以我们可以归结为:所有的框架的 render 逻辑,本质就是 appendChild。
这样一来,我们的视线思路就会更加清晰了,既然我们即希望有一套解决方案能够像 single-spa 一样对原有逻辑友好,又能够拥有 Web Components 的灵活性,那我们是否能够这样实现:用 Web Components 提供微应用的容器,借鉴 Single-spa 的设计,通过子应用生命周期等方式向用户提供自定义渲染逻辑,以便快速支持原有逻辑,最终将框架的生命周期与 Web Components 的生命周期对接,不就是我们最期待的微前端解决方案了嘛。
既然有了想法,那就操手做起来吧。
如果说到要使用 Web Components,大多数人的第一反应就是兼容性问题,所以我们首先需要明确一点,Web Components 本身不是一个规范,它是一个规范的合集,其中的明细因为实现上还有逻辑上的原因,兼容性各不相同,而就我们目前的规划而言,我们最多使用到的,可能也就是 Custom Elements 和 Shadow DOM 这两个特性,也都是业内很多主流的 Web Components 框架都会使用的特性,其实在 Can I use 上我们发现,并不会像大家印象里那么悲观。
并且社区也有优良的 Polyfill(https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs)支持,能够继续向下拓展相关规范的兼容能力。对于许多 B 端或者对内系统而言,IE11+ 的兼容能力早已能满足他们的诉求。
商业产品团队所支撑的内部系统就是一个再好不过的例子,我们通过埋点数据发现,超过 98% 的用户的浏览器都已经支持了这些特性,并且对内系统对我们而言还有一个最大的利好,就是当我们发现用户的浏览器不支持这些特性的时候,我们可以定位到相应的用户并主动联系他升级浏览器😛。
最重要的是,如果我们坚定地要使用 Web Components 作为本次方案实现的核心,那我们现在要去做的所有事情势必就将是一场面向未来的探索,正如社区也在逐步放弃低版本 IE 一样,你如今可以看到的是,包括 React17 修复了事件绑定机制以解决被长期诟病的无法兼容 Shadow DOM 的问题,没有那个框架或者说是生态,能够自信到与浏览器原生能力保持对立或者不予兼容的。因此,未来对于兼容性问题的考量一定会成为历史并日趋乐观,我们不应该因为历史包袱束缚我们前行的脚步,技术的探索,更重要的是向前看,我们也相信如果要向下兼容,我们也一定能够想出解决办法。
所以在明确了可行性以及具体的实践思路之后,我们产出了 Magic(https://github.com/bytedance/magic-microservices)这样一套解决方案,上图就是我们的开源项目的 Github 首页截图以及项目地址,当然你在 Github Bytedance 的 Organizations 下搜索 Magic 也能快速搜到我们的项目。
这套解决方案的实际形态,其实就是一个特别简单而又纯粹的工具函数 —— magic。如上图示例代码所示,你的自定义组件只需要用这个工具函数一包,就马上能够变成一个 Web Components 微应用。
在示例中我们可以看到,引入了我们的工具函数 Magic 之后,你只需要像 single-spa 那样,把你的原有逻辑(组件、区块、甚至是一个完整的业务系统)包装成一个符合 Magic 生命周期的 JS Module,并且想一个让你自己满意的 component tag name,Magic 就会自动帮你处理完剩下的所有事情。而 Magic 的子应用生命周期可以参考如下声明:
生命周期的定义也像 single-spa 一样语义化,通过生命周期的名称你就能够知道它需要去做什么事情。
所以在这样一套模式下,组件的包装变得更加灵活,你可以以任意形式将你的微应用 Module 作为参数传入 Magic,不论是本地引入还是服务化的形态,只要它是符合 Magic 生命周期规范的子应用模块,export 了对应的事件回调即可。
这里我们举个现阶段开发中最常见的例子,比如你想要将当前 repo 里的一个组件封装成微应用,然后在另一个地方通过微应用的方式将其嵌入,就可以直接通过大家最熟悉的 import from 的方式实现。
当然,我们发现 Magic ?所能带来的开发模式变革远不止此,能让 Magic 更具现时代意义的模块加载方式,应当是服务化组件的形态。
比如现阶段我们在开发中接触最多的 UMD 形式(如果你配置过 Webpack Externals,那你一定离不开它),如果你的微应用 Module 已经通过 UMD 等方式部署在远端,并通过 scripts 标签在你的页面中引入之后,则可以像上图示例所示直接使用 Magic 对已经挂载在 window 上的 Module 模块进行包裹。
当然,我们不希望 UMD 带来的 window 污染可能会造成一些不可预知的风险,比如有一个组件往 window 上挂载与另一个组件相同的全局变量名而导致的组件覆盖问题等等,并且我们也期望更加拥抱未来的方式,比如说浏览器已经支持的通过 ESM 拉取一个远端包的方式,就像上图示例所示。你是否也发现了,这样的方式与 Deno 再相似不过了,模块加载的未来,一定也会向着服务化机制发展。
这里其实有一个被大众所遗忘但是实践时特别好用的模块化工具 —— SystemJS,如果你是使用 SystemJS 规范打包的 Module 包,那一切就变得格外简单了,直接通过 System.import 引入即可。SystemJS 有多舒服?试想一下,未来你只需要复制一行代码,就可以快速复用别人的优秀实现了。
而 SystemJS 相比于之前的其他模块化机制的优势,也是显而易见的:
比起 CJS 或者说我们所熟知的传统本地模块化加载方式,它有不需要 npm install 的过程,这也是服务化组件的优势之一
比起 UMD,它不需要关注 window 上的全局变量
比起 Browser ESM,它不需要关心兼容性问题
而最重要的是,这样的组件复用方式,能够让你的项目达到更精确的缓存更新粒度以及按需加载粒度,未来页面的加载都会是完全按需和动态的,只有那些真正需要被渲染的组件才会加载对应的资源,并且,当你每次更新某个组件时,你的页面上的所有其他组件都能复用之前的缓存(Better Long term cache),现在的 Vite 或者一些其他 Bundless 工具,不就是在做这样一件事情嘛。并且,在 HTTP2 已经大量普及以及网络基建能力不断完善的今天,未来更细粒度的切片资源加载不但不会再是性能瓶颈,反而能给我们带来更大的收益。
经过上述分析我们在回头看看被称为微前端解决方案“银弹”的 Webpack Module Federation 能力,它其实只不过是另一种模块化方式而已,但如果使用了这套方案,那么复用逻辑的「生产 - 复用」的过程也会因此受限于构建工具带来的体系,你就必须使用 Webpack 来构建你的组件,并且只能够在使用 Webpack 作为构建机制的项目中使用你的组件,这对开发者是很荒谬的,更多时候,我们往往会使用 Rollup 这类更轻量级的构建工具来构建组件。况且在 Vite 这些新型构建工具蓬勃发展的今天,你就一定坚信:未来,一定还是 Webpack 吗?
但不可否认得失,Webpack Module Federation 能力一定会成为现有业务系统之间快速复用逻辑的最佳选择,因为现阶段几乎所有的 Web 应用,都是使用 Webpack 作为底层构建能力。
其实聊完了 Module 的加载大家是不是感觉 Magic 的基本功能和逻辑都已经说完了,但一切都还只是开始,我们还有一个核心的问题还没有解决:
由于我们的方案是基于 Web Components 实现的,所以这个方案的根本局限来源于 Web Components 的根本局限,主要是因为 Web Components 是 HTML 规范,而众所周知,HTML 作为超文本标记语言,只能承接 String 类型的 Attribute(Props),就像大家平时通过 Devtools 看到我们编写的 React 组件,在 HTML 里显示出来的往往都是 [object Object] 这样的数据,因为像指针、引用类型数据、函数、数组这些概念,是脚本语言或者编程语言中才有的概念,但是作为微前端工程师的我们在实际开发时一定会经常与这些引用类型的数据打交道,如果这个方案无法做到像日常开发一样丝滑使用引用类型的数据,那就无法满足我们设想的「符合工程师开发直觉」的设计理念。
所以面对 Web Components 的根本局限,所以很多 Web Components 框架,都会通过各种语法糖或者自定义的 DSL,通过编译时的解析和一些特殊处理以解决 Web Components 组件透传引用类型数据的问题。但是这样的方式对于开发者而言有一个最致命的弊端,那就是所有的框架封装者都尝试去搭建另一套自己的围城,这些 DSL 或者特定的语法对于开发者而言完全是另学的一套框架和体系,并且如果这套框架未来如果无法成为主流,那这对于开发者而言势必是无意义的学习成本,只是单纯为了「解决问题」,最关键的是,现在社区上还并没有这套语法成为主流的一丝丝趋势。所以我们觉得,如果去为 Magic 定制另一套语法,那无疑是“过度设计”,因为我们有更简单的办法解决问题:
如果你希望向你的微前端子应用传递一个引用类型的数据,正如上图左图示例所示,你可以使用 magic 通过命名导出的 useProps 方法对引用类型的数据进行包裹,示例就是我们在 React 中想要透传引用类型数据的实现,看起来是不是特别便捷,并且在控制台中的输出也是符合我们的预期,所有引用类型的数据都保留着它们的原始引用。
并且大家也能够发现,useProps 这样的语法糖看起来很像 React Hooks,正是这样的设计能够更加贴合大家的开发习惯,并且仅通过名字,你就能快速记住这个方法的功能。
PS:在这边我们可能需要额外补充一点,大家可能想到要解决这种只能透传字符串的问题,或者说增强这种字符串限制能力的解决办法,还有一个方式就是序列化,比如说我把 Function 或者一个对象通过一些工具(例如:https://github.com/yahoo/serialize-javascript)序列化转化一下,是不是也能实现透传。其实我们最开始也是这么去思考的,但大家会习惯性地遗漏一点,就是序列化和反序列化过程,对象的引用是会丢失的。因为序列化之后,除非你再加一个参数或者一段其他内存用来存放这个数据的原始指向,这样新的这个引用数据才能找到它原本的指向,否则很多判断逻辑是不是就会直接不符合预期了。不仅仅如此,像对象上的 setter、getter 这些挂在原型链的数据,也都会随着序列化全部丢失,这其实是致命的,或者说是一个框架,为了实现数据透传的能力把用户的数据完全变样了,那肯定是不行的,所以我们就采用这样的一个 useProps 的方式。这样既能保留这样一个引用,也能让大家写起来更舒服,就像写 React Hooks 一样,也更加符合大家的编码习惯。
解决了上述问题之后,Magic 的核心设计基本也已经有了雏形,接下来我们还需要出尝试解决任何一个复用单元实际在业务落地时一定都会遇到的问题,那就是如何保证一个复用单元在千变万化的环境中还能够做到自由插拔、多处对接的能力?当微应用被拿到不同的逻辑里,可能需要有一个适配层或者说胶水层来处理多方的适配,但这些逻辑肯定不能耦合在微应用的核心逻辑里面,因为他们并不是通用的。
所有的 DDD 架构都会遇到这个问题,因此,在领域驱动设计的架构里有一个比较著名的“六边形架构”的概念,在这个架构设计中,内部逻辑不会关注外部逻辑的定义,可以通过适配器模式让同一套逻辑适配不同的对接逻辑。其实这套设计也没有多么复杂,就是大家再熟悉不过的「适配器模式」。这样的设计能够很大程度提升同一套微应用的复用潜力,而 Magic 插件的设计,就是借鉴了这样一套架构思想,插件在 Magic 体系中扮演的,就是适配器的角色。
其实 Magic 的定位就只是一个运行时的资源加载工具函数,它的职责很单一,就像 Webpack Core 只是一个单纯的模块打包工具一样,之所以它现在能成为一个全能的构建工具并且在长期的前端技术迭代中屹立不倒,根本上就是取决于它个性化的 Loader 还有 Plugin 配置能力,所以 Magic 的设计也是借鉴了如此,我们开放了插件机制,Magic 会将执行过程中的所有元数据都交给用户自由变更、组装以及重新定义,所以借助 Plugin 机制,用户能够实现任何他们期望的效果或者说为 Magic 提供的核心逻辑赋能,包括沙箱、HTML Entry 支持等等。Magic 希望也能像 Webpack 那样,借助 Plugin 体系,支撑起一套更全面、更自由的定制化生态。
上图的示例就是一个最简单不过的 Magic 插件的使用例子:例如组件市场中已经有一个其他工程师贡献的 user-info 组件,它支持传入一个用户的名称并使用封装好的原生样式将其展示出来,这个组件能够满足网站开发者在样式和交互上的需求,但是网站从数据库中获取到的用户名称都是小写的,产品或者业务方要求网站在页面中需要把名称中的所有字符转化为大写的形式。考虑到逻辑的封装和抽象,网站开发者没必要每次都在传递名称的时候做一遍大小写的转化,此时就可以将名称转换成大写的逻辑抽象成 Magic Plugin,通过插件机制为 magic 的数据传递过程统一增加一个数据格式化的流程,而添加一个插件的方法也很简单,就如上图中的示例代码所示。使用了封装到的转化大写名称的 Magic Plugin 之后,开发者就可以直接向组件中传递从后端数据库中拿到的名称数据,不需要显式地在逻辑中执行名称的大小写转化操作,如果需要展示大写的控件,可以直接使用经过 Plugin 改造自定义的 user-info-upper 控件。
当然,名称转大写只是一个特别简单的示例,真正在业务上的定制化实现肯定会是更加复杂的场景,而插件机制,就是为了将这个定制化的口子放开交给开发者自由去定义。所以,当你需要在 Magic 执行时定制化一些你自己的逻辑,就可以在社区中搜索已有的,或者是自己编写一个符合你需求预期的 Plugin 来实现为 magic 原有的逻辑赋能。
所以到这里我们已经基本介绍完了 Magic 的核心能力以及它要去解决的问题,但是 Magic 还有很多高阶的能力以及周边生态期待对前端未来感兴趣的你一同来探索。
综上所述,Magic 的使用过程就可以概括为以下三步:
引用:通过 CDN 或者 NPM 包的形式引入 Magic 工具函数依赖(类比于你在服务器上安装 Docker 的一些基础能力)
注册:为你的自定义组件 Module 注册“微应用”(类比于实例化某个 docker 镜像)
使用:通过 HTML 标签直接使用你的“微应用”(类比直接调用实例化的微服务的功能)
果然,行业上的最佳实践最终都是趋于一致的。
讲到这里,肯定会有同学提到,社区上其实比较主流的框架都有自己的 Web Components wrapper,比如 Vue,为什么我们不是通过类似 @vue/web-component-wrapper 这样的能力快速将写好的 Vue 项目封装成一个 Web Components 组件呢?
Magic 和这些框架提供的 Wrapper 相比的灵活之处就在于,只是一个抹平了框架对接层的轻薄 Bridge,不论是组件的开发方,还是使用组件的项目业务方,都不需要感知具体的技术选型,只需要遵行相应的接口规范,就能实现抹平框架的能力复用。而这些框架所提供的 Wrapper 所解决的问题,更多只是上图右半部分的内容,更多是在提升自己框架的运用场景,而 Magic 更加希望成为一个前端通用的解决方案。
所以 Magic 只是通过工具的形态尝试成为前端框架生态的粘合剂,而非另一套生态体系,这才是我们要尽可能降低这个「工具」的使用成本的必然原因。因此我们会把 Magic 这样的抹平层做的足够薄,因为它的职责仅仅只是通过浏览器的原生能力,为不同的框架提供统一的接口接入,所以正如现在大家所看到的如此轻量的 Magic,它的 ESM 包,在压缩后也仅仅只有 2.4KB。
看完了我们的介绍,你是不是对 Magic 有了一个更全面的认识了,因此我们之所以要做 Magic 这样一个工具函数,它最具价值的部分,或者说它能够在前文中所提到的那么多优秀的微前端框架中脱颖而出的条件,可以总结为以下两个方面:
首先,在设计理念上,它是更“前沿”的:
Magic 等价于 Web Components Plus,我们在设计上始终严格遵循浏览器规范及原生能力的呈现,因此我们坚信未来当浏览器标准成熟时,现有的 Magic 使用体验甚至能完全做到向浏览器原生能力的无缝迁移
得益于 Single SPA 提供的设计思路,Magic 同样能平滑承接原有逻辑,能够做到让你在开发时「一处编码,随处复用」(抹平框架),只需要为你的原有逻辑包装一层 Magic 生命周期即可
在 Magic 这套设计理念中,JS Module 即是实体,微前端的粒度不受限制,Everything can be microfrontends
其次,在上手成本上,Magic 也有绝对的亮点。我们始终秉持着「一个函数解决微前端」的设计理念,在设计上尽可能将开发者的使用和认知成本降到最低,如果你使用 magic,你只需要了解生命周期和 Magic 函数需要的传参,你一开始可能并不需要知道 Plugin、shadow 这些特性你,就能把 Magic 的能力用起来,这是一个渐进增强的过程,当你需要使用的高阶功能和玩法越多,你可能才需要了解地越多。
当然,前面所说的内容都是我们的设计,更重要的是,这套方案在实际业务场景中,能否真正我们的现实问题。
本质上,业务上的现有的微前端场景都可以抽象为以下主要两类:
页面模块化复用场景:页面组件化复用场景指的就是将某个系统或者是某个系统中的某个页面,作为类似组件的方式快速嵌入到别的系统中,在过去,这样的场景我们往往都会通过 Iframe 解决
集群型微前端场景:传统微前端方案主要解决的业务场景。但本质上,集群型微前端场景也是页面模块化复用场景的一个上层分支,其中被门户通过路由能力调度的这些子页面模块,就是可以视为具备页面组件化复用能力的子模块
所以现如今的微前端方案,很大程度上都是在实现一套现代化的 Iframe 能力。不仅是如此,我们在浏览器的提案上也开始发现,浏览器也在尝试打造下一代的 Iframe 能力。这里以 Chrome 提出的 Portals(https://github.com/WICG/portals)提案最为有名,如上图所示,这套提案或者更可以说社区期望的是,有这样一个名为 portal 的原生能力,能够实现快速嵌入一个已有的页面,并拥有比起 iframe 更符合开发者直觉和习惯的通信能力。
Chrome 也为 Portals 提案实现了试验性能力,你只需要在全局设置中开启这部分能力,就可以在这个 Demo 站点里体验到 Portals 的效果:https://uskay-portals-demo.glitch.me/。
所以到这里大家就会有疑问,既然 Portals ?和 Iframe 这么像,为什么我们不直接使用浏览器现有的能力呢?其实上图 Portals 提案中的这段话再好不过地解释了这个问题。就我自己而言,我可以将其总结为这套现代化的 Iframe 方案能够让开发者拥有「更灵活的触达渲染底层的自定义能力」。众所周知,Iframe 作为一个老牌的方案,提供了一个完全的黑盒,这样的设计的确能够给予开发者特别舒适的体验,但在前端技术快速发展的今天,前端工程师们对性能以及体验的追求更加苛刻,Iframe 的种种弊端也在不断抛出,而这个大黑盒,也完全成为了前端开发者们无法深度定制和优化 Iframe 底层实现而导致的 Iframe 方案无法继续跟上时代的主要原因。因此,在这样的背景下,社区就期望能有一套更灵活的方案,来让开发者们有更深度的定制化能力以及更加舒适的开发体验。
通过之前的介绍我们了解到,其实 Magic 就已经具备了实现一个浏览器原生标签的能力,那我们完全可以参考社区的 Portals 提案实现一套既符合浏览器发展趋势,又能够解决现实问题的现代化 Iframe 能力。我们只需要再整合一些例如 JS 沙箱、事件通信机制的能力,就能够快速使用 Magic 实现 Portals 的所有能力,并解决最开始我们提到的页面模块化复用及集群型微前端两个业务场景。
正如上图示例所示,我们通过 Magic 实现了一个名为 magic-portal 的标签(寓意:使用 magic 实现的 portal 能力,与浏览器原生的 portal 标签加以区分),它完整实现了提案的所有能力,例如上图中展示的事件通信等等,并且依托于 Magic 提供的能力,还能做到实现子应用全局变量注入等等功能。而这套实现,未来无疑能够成为浏览器原生 Portals 能力的 Polyfill,因为只要是能够支持 Web Components 的浏览器(前文中我们知道是 IE11+),都能够使用“未来”的 Portals 能力。
比如作为商业化数据中台的商业产品团队,经常会输出一些数据报表用以其他系统的使用和嵌入,原来,我们大多都是通过 Iframe 的方式,但现如今,借助 magic-portal,我们也可以实现一个标签快速嵌入。
这里同时要说一件比较有意思的事,在我们对现有的系统做微组件抽离时,其实有一个最快的解决办法,那就是直接把某个组件在系统里单独开一个路由,然后将组件的完整地址赋给 magic,这样的改造成本是特别低的,甚至有些现有的页面几乎完全不需要任何改造,就可以成为一个微前端服务,既不需要抽包,也不需要单独处理 CI CD 流程。因此你的使用过程完全适合 Iframe 的体验一模一样的。
借助 magi-portal 的能力也能快速搭建一个用以调度多个子系统的微前端基座门户,这里的实现就再简单不过了,你可以配合任何你最熟悉的 router 能力,比如 react-router 或 vue-router,甚至是你自己实现一个 router,成本也不会很高(为什么一定要再学个 single-spa),就能搭建起集群型微前端的门户网站,因为本质上你只需要在路由变化时更新 magic-portal 的 src 属性就能够实现子应用的切换,正如你使用 iframe 一样。
同时,因为 Magic Portal 参考 Portals 提案内部模拟了 Document 的结构,因此,你不需要像传统的其他微前端解决方案一样,通过 export 一些特定的生命周期来完成子应用的改造,对于子应用和基座应用而言,使用 magic portal 的效果与使用 iframe 的效果完全一致。
在实现了上述能力之后,我们也对未来的前端世界有了更加美好的期待。我们相信,Magic 能够在未来的前端开发演进中承担更多的职能,因为借助 Magic 的能力,前端框架不会再是未来前端开发技术选型的第一要素。今天的前端框架不就是一座现实的围城,在围城中建设你不必担心会犯什么低级的错误,因为框架借助自己的门槛帮你约束了许多行为,在潜移默化中框架提升了开发者的下限,但你在享受框架为你带来的开发红利时,最终也会在不断探索中触到框架给你限定的天花板。
我们期望 Magic 在前端框架可以垄断一个团队的技术选型的如今,能够成为一个快速打通与抹平框架的 Meta Framework。什么是 Meta Framework?你可以理解为就是框架之上的框架,或者组合多种框架的能力封装。现如今,我们开发应用程序时,不管是网页应用还是其他应用,开发者经常需要依赖不同的框架来作为开发的出发点,并且由此牵引出了一整套组件、工具链和或者其他服务组成,用于最终的能力开发。但每种框架都提供了用来创建应用程序或组件的所有东西,因此你可以很方便的使用他们,但是如果需要将不同的框架集成起来,让它们一起工作,就会产生问题,最常见的问题有两种:
许多框架是和相关的工具链(业内也常称为全家桶)捆绑在一起的,是紧耦合的,它们只能在推荐的组合下工作良好,就比如你使用 React,你自然就会使用 React Router 而不是 Vue Router 这类在同一套框架上的封装来完成开发。但这种紧耦合限制了开发者对其他衍生工具的选择,如果选择了一种框架,出于成本,安全,易用性,性能方面的考虑,我们很多时候就无法使用其他框架的生态。
缺乏统一的接口,不同的框架之间无法顺畅地通信。就好比你想在 Vue 项目中使用 React 的组件,那么你就必须得有一层胶水层用来处理 React 的 render 逻辑以及 Vue 向 React 传递 Props 的能力。这无疑增加了系统集成的难度,开发者不得不编写非标准的代码来完成通信和集成,这种代码也长期需要随着框架的演进不断地升级维护。
而 Magic 所扮演的 Meta Framework 的角色,正是提供了让不同框架可以通过统一的接口定义相互接入的能力,并且不会带来多大的成本。
在前端社区快速发展的如今,我们早已有琳琅满目的前端框架可以用来尝试技术选型,每一个前端框架能够在社区上存活下来并独立发展,都因为它们拥有着独一无二的特性和设计理念。我们相信在未来,会有更具经验的同学来把关甚至把控项目的技术选型和实现落地,来保证在特定的场景下能够选择最合适的框架(例如:项目的整体技术框架采用 React,既能收口技术栈,也能借助 React 繁荣的社区生态保证项目底线及提升团队平均素质,而那些需要抽象的纯展示组件或者富交互组件,可以尝试使用 Svelte 或者 Vue3 等社区新型的前端框架来实现,技能拥抱新兴技术,又能借助 AOT 优化来达到运行时的最优性能)。并且我们始终乐观坚信,随着社区生态的自然法则长期优胜劣汰后,组件的开发者势必会拿出对比数据来证明自己的实现在性能、体验或者其他方面是优于社区的同类竞品的,微组件或者微应用的实现以及在社区上的选择最终只会向着性能、体验的最佳实践逐步趋近,并且在社区的探索中逐步沉淀出一套应对不同场景的开发范式,虽然从现在来看,这或许还会是一个漫长的过程,但我们更相信在这过程中随着各色生态的百花齐放,一定会为前端社区产生许多倍具价值和潜力的机会。
所以大家不用觉得在一个项目里使用不同的框架实现不同的逻辑是不会出现的场景,或许“百家争鸣”比起被单一框架“垄断”反而才是更符合社区技术演进的,因为每种框架都有每种框架的设计和特点,没有兼具所有场景的最优解,只有特定场景下的最优解,作为前端开发者们我们只有“取长补短”,才能探索并发挥出未来前端框架的最大优势。
在未来,如果前端开发不再需要考虑框架带来的隔阂,那我们就可以更加自由与方便地将逻辑进行抽象和复用,每一位工程师、每一个前端团队,都可以更加专注于“单一职责”的模块领域,将这些自己所负责的模块打磨到极致,最后快速组装成可交付的页面或者系统。
所以,当所有的前端资源都被抽象以及划分职责领域之后,我们就可以借助类似物料市场这样的平台将开发过程中抽象的物料进行收集和分发,并且物料复用的过程也会变得更加简单,只需要 copy 一行代码,不需要安装任何依赖,你就可以快速完成一个物料的复用,这就是跨框架服务化组件的魔力所在。
这相比于现有的行业上在做的搭建系统而言具备的最大优势在于:前端完全不需要自己实现搭建引擎或者渲染引擎,因为完全可以借助浏览器的原生能力实现组件的组装和渲染,并且,我们也完全不需要为搭建系统定制一套让开发者还有额外学习成本的 DSL 或者 JSON 配置语法,开发者们所需要面对和使用的,是任何一位前端工程师都再熟悉不过的 HTML 语法,开发者们只需要通过组装 HTML 标签,就可以快速完成一个系统的搭建,这对于面向未来的搭建平台,无疑是增益的。
在分享的最后,我们想说的是,我们始终坚持不会去搭建另一套体系并尝试将更多的开发者“囚禁”于此,我们更想做的,是一套真正能让行业通用并促进前端开发模式演进的解决方案,所以在今天的分享和 Magic 的相关能力中,你很难见到让你“学不动”的新奇语法,亦或是什么晦涩难懂的高深概念,我们相信真正能够促进技术演进的,始终是那些真正解决开发痛点以及现实问题的工具。
相信你经过这次分享了解完 Magic 的相关能力后,如果你想要实现一个像 Shoelace 这样的跨框架组件库,亦或是基于现有的逻辑快速封装一套属于你自己的微前端体系,一切都变得再简单不过了,只需要将你的逻辑包装成符合 Magic 生命周期的 Module,就已足够。
之所以我们实现了 Magic,并不只是为了向社区传达我们对 Web Components 多么熟悉或者对这套 API 的底层设计掌握的多深,包括 Magic 的源码实现,总共也不过千行,因为我们始终觉得,使用一些工具 API 甚至掌握 Web Components 的能力对于大部分前端工程师而言都是再简单不过的事情,但我们更希望的是,?能让前端社区从这个实践中能够看到 Web Components 规范能给未来的前端开发带来的价值,而如何用这部分能力助力组件服务化,助力前端项目的领域拆分,如何发挥 DDD 真正的抽象和分治思想的价值,才是作为工程师的我们应该去思考的事情。
我们之所以将 Magic 相关的逻辑早早的开源,并在这套核心逻辑开发的从始至终,都一直在尝试用最贴合浏览器发展趋势的方式去实现真正被浏览器所期待的微前端能力,目的就是希望能让前端社区更多地关注到 Web Components 这套长期处于争议中的面向未来的规范特性,这样开发者们一定就能够发现,原来浏览器的原生能力,也能让我们的日常开发变得如此舒服。也希望 Magic 能像设计时我们对它的期待那样,为前端开发的模式升级增添一份力量。
为你推荐
欢迎自荐投稿,前端早读课等你来。