https://blog.cloudflare.com/better-micro-frontends/
PS:关注过 Angular 的同学应该对 Igor Minar 这个名字不陌生,他是 AngularJS/Angular 的 co-founder 之一,并长期担任 Angular Team 的 Tech Leader 负责 Angular 团队的整体运转,2021 年 11 月离开了工作 12 年的 Google 去了 Cloudflare。
本文主要为大家介绍下 Cloudflare 提出的一种「新的」微前端方案以及其极致的首屏优化背后的实现原理,有兴趣的同学也可以直接去看 Cloudflare 的原文:
- Cloudflare Workers 和微前端:为彼此而生 https://blog.cloudflare.com/zh-cn/better-micro-frontends-zh-cn/
- 通过 Cloudflare Workers 增加采用微前端 https://blog.cloudflare.com/zh-cn/fragment-piercing-zh-cn/
客户端微前端方案的问题
文章中首先指出了当前常见的客户端微前端方案的问题:
- Module Federation:共享库本身不能与微应用一起做构建时 tree-shaking。
- Module Federation:共享库本身会带来隐式的耦合,版本升级会变得非常麻烦。这也是 qiankun 不推荐用 externals 的方式来复用依赖的原因。
- 瀑布式请求:必须等顶层的主应用启动之后,才能开始启动微应用,微应用才能发起请求,导致运行时间进一步被延时。这个是微前端方案首屏慢的最根本原因。
- hydration delay:即便我们加上了服务端渲染(SSR),用户的首屏虽然快了,但仍然需要等待客户端框架完成 hydration 之后才能开始交互。
Cloudflare 的片段架构(Fragments Architecture)
为解决这些问题,Cloudflare 提出了他们针对微前端的片段架构:
整个渲染流里有几个关键要素:
- 应用程序由一颗片段树组成
- 浏览器向根片段发起请求,根片段与子片段通信,生成最终响应
- 每个片段运行在独立的 worker 中
- 整个响应过程都是 并行 + 流式的
优势
片段架构的主要优势有这些:
- 安全性:前置的关键接口请求都是在服务端 worker 中进行,不用担心前端敏感代码泄露的问题
- 基于 Worker 的服务端渲染:SSR + CDN 加持,Lighthouse 评分极高
- 尽早交互(Eager interactivity):不用等框架走完 hydration 流程,响应回来浏览器渲染开始就能开始交互(实际需要框架配合,比如 demo 里的 Qwik)
实现原理
先看下 Demo 的最终效果:
这里面有几点特性值得关注:
- 首屏会首先返回页面的 critical 部分内容,且渲染出来是可以立即交互的。比如 Login 页面的中间表单部分,Todos 页面的列表部分。
- 所有的渲染都是流式的,包括客户端路由切换引发的重新渲染
整体架构如下图,本文将会通过问题来解构,看看 Cloudflare 是如何基于 Worker 来实现更优异性能表现的微前端的。
Demo 源码:https://github.com/cloudflare/workers-web-experiments/tree/main/productivity-suite
1. 如何实现流式聚合响应
Gateway Worker
所有流量的入口是一个 gateway worker,gateway worker 的作用是用来分发流量,包括稳定流量、静态资源流量等。本质就是一个实现了 fetch 的 Cloudflare Worker。
fetch = async ( request: Request, env: Env, ctx: ExecutionContext ): Promise<Response> => { this.validateFragmentConfigs(env); request = await this.createSSRMessageBusAndUpdateRequest(request, env, ctx); // 分发 Fragment 静态资源 const fragmentResponse = await this.handleFragmentFetch(request, env); if (fragmentResponse) return fragmentResponse; const fragmentAssetResponse = await this.handleFragmentAssetFetch( request, env ); if (fragmentAssetResponse) return fragmentAssetResponse; // 聚合流式响应 const htmlResponse = await this.handleHtmlRequest(request, env, ctx); if (htmlResponse) return htmlResponse; return this.forwardFetchToBaseApp(request, env); };
重点是 handleHtmlRequest 这里,这里会将当前访问 url 需要的 Fragments 流聚合成一个流:
// 聚合 legacy index.html 和 Fragments html return this.returnCombinedIndexPage( indexBody, concatenateStreams(fragmentStreamsToInclude) );
export function concatenateStreams(streams: ReadableStream[]): ReadableStream { async function writeStreams( writer: WritableStreamDefaultWriter ): Promise<void> { try { for (const stream of streams) { const reader = stream.getReader(); let chunk = await reader.read(); while (!chunk.done) { writer.write(chunk.value); chunk = await reader.read(); } } writer.close(); } catch (error: any) { writer.abort(error); } } const { writable, readable } = new TransformStream(); const writer = writable.getWriter(); writeStreams(writer); return readable; }
只需要确保 legacy index.html 是第一个响应,其他 Fragments 流的顺序无所谓。最后出来的响应:
每个 Fragment 由 piercing-fragment-host
标签包裹,每个标签内都是一个完整的 HTML 字符串(含 head、body 部分)。
有似曾相识的感觉吗?
2. 如何确保先渲染的 Fragment 流能渲染到正确的位置
这个主要作用是将首屏最先需要交互的片段渲染出来,并渲染到正确的位置,比如一个 Login 页面的中间表单部分。而不是按照完整的页面结构渲染。
总共分两步:
- 原始渲染的 Fragment 本身就是包含 UI 且可响应的(借助 Qwik),本身由
piercing-fragment-host
这个统一的 web component 负责组件激活。每一个 Fragment 在注册之初便有基础样式,这些基础样式用于确保首屏的 Fragment 在客户端 hydration 之后不会出现视觉闪烁或者失焦。 - Legacy App 会后置渲染出
piercing-fragment-outlet
组件,当组件激活后,会将对应的piercing-fragment-host
移动到 outlet 对应的 DOM 树里。
片段穿孔
片段穿孔指的便是上述的两步流程,作用就是如何将新的 Fragment 动态的整合到 Legacy 应用中。其中有两个重要的 web component 组件,piercing-fragment-host
和piercing-fragment-outlet
。
piercing-fragment-host
Fragment 整个 HTML 的片段宿主,如首屏响应可能长这样:
<body> <div id="root"></div> <piercing-fragment-host fragment-id="todos"> <script type="module" crossorigin src="/_fragment/todos/assets/index.3a0c2f77.js"></script> <style>...</style> <div id="todos-fragment-root">...</div> </piercing-fragment-host> </body>
Legacy 应用的 root 节点还未渲染,但是 todos Fragment 的内容已经通过 ssr 响应正常渲染出来了。这里有一个细节处理,跟 qiankun 的方案一毛一样,谁能看出来是用来处理什么场景的?
private setStylesEmbeddingObserver() { this.stylesEmbeddingObserver = new MutationObserver((mutationsList) => { const elementsHaveBeenAdded = mutationsList.some((mutationRecord) => { for (const addedNode of mutationRecord.addedNodes) { if (addedNode.nodeType === Node.ELEMENT_NODE) { return true; } } return false; }); if (elementsHaveBeenAdded) { // if any element has been added then we need to make sure that // there aren't external css links (and embed them if there are) this.embedStyles(); } }); this.stylesEmbeddingObserver.observe(this, { childList: true, subtree: true, }); } private embedStyles() { this.querySelectorAll<HTMLStyleElement>( 'link[href][rel="stylesheet"]' ).forEach((styleLink) => { if (styleLink.sheet) { let rulesText = ""; for (const { cssText } of styleLink.sheet.cssRules) { rulesText += cssText + "\n"; } const styleElement = document.createElement("style"); styleElement.textContent = rulesText; styleLink.replaceWith(styleElement); } }); }
piercing-fragment-outlet
Legacy App 渲染时会提供的 web component,当它开始激活时,会去 dom 树中寻找对应 Fragment ID 的 host 节点,并将其移动到正确的位置。
比如激活前:
<body> <div id="root"> <main> <div class="todo-page"> <piercing-fragment-outlet fragment-id="todos"></piercing-fragment-outlet> </div> </main> </div> <piercing-fragment-host fragment-id="todos">...</piercing-fragment-host> </body>
激活后:
<body> <div id="root"> <main> <div class="todo-page"> <piercing-fragment-outlet fragment-id="todos"> <piercing-fragment-host fragment-id="todos">...</piercing-fragment-host> </piercing-fragment-outlet> </div> </main> </div> </body>
为了避免 DOM 树移动过程中产生的视觉闪烁或者失焦,每个 Fragment 在注册之前必须提供一些基础样式,比如 todos Fragment 的基础样式是整个 DOM 渲染在屏幕的中心区域:
gateway.registerFragment({ fragmentId: "todos", prePiercingStyles: ` :not(piercing-fragment-outlet) > piercing-fragment-host[fragment-id="todos"] { position: absolute; top: 25.65rem; left: 0; right: 0; } @media (max-width: 52rem) { :not(piercing-fragment-outlet) > piercing-fragment-host[fragment-id="todos"] { top: 25.84rem; } } @media (max-width: 45rem) { :not(piercing-fragment-outlet) > piercing-fragment-host[fragment-id="todos"] { top: 25.979rem; } } @media (max-width: 35rem) { :not(piercing-fragment-outlet) > piercing-fragment-host[fragment-id="todos"] { top: 32.14rem; } } @media (max-width: 25rem) { :not(piercing-fragment-outlet) > piercing-fragment-host[fragment-id="todos"] { top: 35.3rem; } } `, });
通过片段穿孔,可以渐进式的使用微前端,一次一个 Fragment,且每个 Fragment 之间技术栈可以是独立的。
3. Fragment 之间如何通信
假如 Fragment 之间需要知道彼此的存在并通信,比如 TodoMVC 的场景里,服务端在响应之前,TodoList Fragment 需要展示当前用户选择的分组,Todos Fragment 需要展示当前用户选择的分组下对应的具体的 ToDo 项,这中间是如何实现通信的。
Cloudflare 这里的解决方案是实现了一个同构的、框架无关的消息总线 MessageBus。
比如 Todos Fragment 在 Worker 里通过这种方式获取当前用户登录用户名及已选的分组信息:
const currentUser = getBus().latestValue<{ username: string }>("authentication")?.username ?? null; const todoListName = getBus().latestValue<{ name: string }>("todo-list-selected")?.name ?? null; const requestCookie = request.headers.get("Cookie") || ""; const todoList = await getCurrentTodoList( requestCookie, currentUser, todoListName );
在浏览器 React 环境里通过类似方式监听分组 Fragment 的选中事件:
useEffect(() => { if (ref.current) { return getBus(ref.current).listen<{ name: string }>( "todo-list-selected", async (listDetails) => { if (listDetails) { const list = await getTodoList(currentUser, listDetails.name); if (list) { setListName(list.name); setTodos(list.todos); } } } ); } }, [ref.current]);
4. 其他问题
关于片段穿孔的预留位置
片段在首次渲染时需要提供预置样式,用于在穿孔发生之前也能渲染在用户屏幕合适的位置。Demo 中用的是绝对定位的方案,这其实会受限于用户屏幕的尺寸和分辨率,且都是手动的,非常容易出错。这个问题的解决方案会比较麻烦,可能涉及到针对 Legacy App 的代码侵入,比如在 Legacy App 里提前针对 Fragment 预先写好 placeholder 元素及其样式。
Fragment 副作用的 reapply
片段在加载、卸载的过程中,有一些全局文档脚本的副作用可能需要重新执行(比如切换到 TodoList 时发起请求)。Demo 里的解决方案是通过 addDefaultFnExportToBundle
插件,将 entry 的副作用生成一个 default 导出,并在每次片段加载的时候重新执行 default function。
function moduleFn() { // side-effectful code... } moduleFn(); export default moduleFn;
publicPath 的问题
每个片段都运行在同一个域名上下文中,导致原始片段中的相对路径的静态资源请求都会打向当前域名。Demo 里的解法是在构建时指定固定的 publicPath 来区分,比如 /_fragments/todos,而 gateway worker 则通过前缀来分发静态资源流量。
qiankun 没这个问题原因是,我们选择的解法是运行时动态设置 publicPath,而不会是构建时决策。
可借鉴的方向
服务端组合流式响应
可组合、非阻塞式的流式响应非常有吸引力,结合低代码搭建、配置直出等场景应该会很有效果。
客户端流式渲染 writable-dom
writable-dom 是一个用于在客户端将流响应写入到指定的 document dom 节点中,这还不是最强的,最强的是它能保证渲染过程中资源的阻塞逻辑跟原生的浏览器解析逻辑一致,比如样式表加载完成之后,再去渲染后续的 DOM 树,从而避免异步样式表带来的内容闪烁的问题。
import WritableDOMStream from "writable-dom"; const res = await fetch("http://www.com"); const myEl = document.getElementById("root"); await res.body .pipeThrough(new TextDecoderStream()) .pipeTo(new WritableDOMStream(myEl));
不过前提是目标资源本身也支持流式响应才有意义。
未解决的问题
- 文章开篇提到的,客户端微前端方案的依赖 tree-shaking 及共享问题,并没有解决只是绕过了(片段够小,不需要共享库代码)。
- 虽说支持多 web 框架,但是并没有客户端的沙箱方案,一样可能出现样式冲突、同一技术栈多版本冲突等问题。
最后
过去几个月最让我兴奋的便是接连看到 AWS、Cloudflare 这些云厂商开始入局微前端,并提供了基于他们的云基础设施的微前端解决方案。这至少说明微前端现在不仅只是一个广泛被热议的话题,其背后的复杂度更是足以撑起一个商业化解决方案的,「微前端不过是又一个 buzz word」这一论断其实可以不攻自破了。