Cloudflare Workers 和微前端:为彼此而生

简介: Cloudflare Workers 和微前端:为彼此而生

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 的原文:

  客户端微前端方案的问题

文章中首先指出了当前常见的客户端微前端方案的问题:

  • 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 的最终效果:


这里面有几点特性值得关注:

  1. 首屏会首先返回页面的 critical 部分内容,且渲染出来是可以立即交互的。比如 Login 页面的中间表单部分,Todos 页面的列表部分。
  2. 所有的渲染都是流式的,包括客户端路由切换引发的重新渲染

整体架构如下图,本文将会通过问题来解构,看看 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 页面的中间表单部分。而不是按照完整的页面结构渲染。

总共分两步:

  1. 原始渲染的 Fragment 本身就是包含 UI 且可响应的(借助 Qwik),本身由piercing-fragment-host 这个统一的 web component 负责组件激活。每一个 Fragment 在注册之初便有基础样式,这些基础样式用于确保首屏的 Fragment 在客户端 hydration 之后不会出现视觉闪烁或者失焦。
  2. Legacy App 会后置渲染出piercing-fragment-outlet组件,当组件激活后,会将对应的piercing-fragment-host移动到 outlet 对应的 DOM 树里。

片段穿孔

片段穿孔指的便是上述的两步流程,作用就是如何将新的 Fragment 动态的整合到 Legacy 应用中。其中有两个重要的 web component 组件,piercing-fragment-hostpiercing-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));

不过前提是目标资源本身也支持流式响应才有意义。

  未解决的问题

  1. 文章开篇提到的,客户端微前端方案的依赖 tree-shaking 及共享问题,并没有解决只是绕过了(片段够小,不需要共享库代码)。
  2. 虽说支持多 web 框架,但是并没有客户端的沙箱方案,一样可能出现样式冲突、同一技术栈多版本冲突等问题。

  最后

过去几个月最让我兴奋的便是接连看到 AWS、Cloudflare 这些云厂商开始入局微前端,并提供了基于他们的云基础设施的微前端解决方案。这至少说明微前端现在不仅只是一个广泛被热议的话题,其背后的复杂度更是足以撑起一个商业化解决方案的,「微前端不过是又一个 buzz word」这一论断其实可以不攻自破了。


相关文章
|
3月前
|
前端开发 JavaScript 大数据
React与Web Workers:开启前端多线程时代的钥匙——深入探索计算密集型任务的优化策略与最佳实践
【8月更文挑战第31天】随着Web应用复杂性的提升,单线程JavaScript已难以胜任高计算量任务。Web Workers通过多线程编程解决了这一问题,使耗时任务独立运行而不阻塞主线程。结合React的组件化与虚拟DOM优势,可将大数据处理等任务交由Web Workers完成,确保UI流畅。最佳实践包括定义清晰接口、加强错误处理及合理评估任务特性。这一结合不仅提升了用户体验,更为前端开发带来多线程时代的全新可能。
67 1
|
6月前
|
移动开发 前端开发 数据处理
探索前端性能优化的新思路:使用Web Workers提升网页响应速度
传统的前端性能优化方法已经不能完全满足日益增长的网页需求。本文提出了一种新的思路,即利用Web Workers技术来提升网页的响应速度。通过将耗时的计算任务交给Web Workers处理,可以避免主线程阻塞,从而提高网页的用户体验。本文将介绍Web Workers的基本原理、使用方法以及在前端性能优化中的应用实例,帮助开发者更好地理解和运用这一技术。
|
前端开发 JavaScript 安全
🎉🎉🎉 Web Workers 使用秘籍,祝您早日通关前端多线程!
Web Workers 是新一代的异步编程解决方案,它可以让我们在后台运行一个脚本,而不会阻塞用户界面。对于前端开发者来说,Web Workers 是一个非常有用的工具,它可以让我们在后台运行一些
261 0
🎉🎉🎉 Web Workers 使用秘籍,祝您早日通关前端多线程!
|
缓存 前端开发 JavaScript
前端培训-中级阶段(24)- Web Workers多线程(2019-11-7期)
前端最基础的就是 HTML+CSS+Javascript。掌握了这三门技术就算入门,但也仅仅是入门,现在前端开发的定义已经远远不止这些。前端小课堂(HTML/CSS/JS),本着提升技术水平,打牢基础知识的中心思想,我们开课啦(每周四)。 JS 是单线程,事件循环模型。使用上来说如果有大量、高强度的计算会导致 UI渲染进程卡顿。 按照 FPS: 60 来计算 1000ms/60 = 16.666ms。我们一段程序的占用时间需要低于16.666ms。
217 0
|
23天前
|
存储 人工智能 前端开发
前端大模型应用笔记(三):Vue3+Antdv+transformers+本地模型实现浏览器端侧增强搜索
本文介绍了一个纯前端实现的增强列表搜索应用,通过使用Transformer模型,实现了更智能的搜索功能,如使用“番茄”可以搜索到“西红柿”。项目基于Vue3和Ant Design Vue,使用了Xenova的bge-base-zh-v1.5模型。文章详细介绍了从环境搭建、数据准备到具体实现的全过程,并展示了实际效果和待改进点。
103 2
|
23天前
|
JavaScript 前端开发 程序员
前端学习笔记——node.js
前端学习笔记——node.js
34 0
|
23天前
|
人工智能 自然语言处理 运维
前端大模型应用笔记(一):两个指令反过来说大模型就理解不了啦?或许该让第三者插足啦 -通过引入中间LLM预处理用户输入以提高多任务处理能力
本文探讨了在多任务处理场景下,自然语言指令解析的困境及解决方案。通过增加一个LLM解析层,将复杂的指令拆解为多个明确的步骤,明确操作类型与对象识别,处理任务依赖关系,并将自然语言转化为具体的工具命令,从而提高指令解析的准确性和执行效率。
|
23天前
|
存储 弹性计算 算法
前端大模型应用笔记(四):如何在资源受限例如1核和1G内存的端侧或ECS上运行一个合适的向量存储库及如何优化
本文探讨了在资源受限的嵌入式设备(如1核处理器和1GB内存)上实现高效向量存储和检索的方法,旨在支持端侧大模型应用。文章分析了Annoy、HNSWLib、NMSLib、FLANN、VP-Trees和Lshbox等向量存储库的特点与适用场景,推荐Annoy作为多数情况下的首选方案,并提出了数据预处理、索引优化、查询优化等策略以提升性能。通过这些方法,即使在资源受限的环境中也能实现高效的向量检索。
|
23天前
|
机器学习/深度学习 弹性计算 自然语言处理
前端大模型应用笔记(二):最新llama3.2小参数版本1B的古董机测试 - 支持128K上下文,表现优异,和移动端更配
llama3.1支持128K上下文,6万字+输入,适用于多种场景。模型能力超出预期,但处理中文时需加中英翻译。测试显示,其英文支持较好,中文则需改进。llama3.2 1B参数量小,适合移动端和资源受限环境,可在阿里云2vCPU和4G ECS上运行。
|
23天前
|
前端开发 算法 测试技术
前端大模型应用笔记(五):大模型基础能力大比拼-计数篇-通义千文 vs 文心一言 vs 智谱 vs 讯飞vsGPT
本文对比测试了通义千文、文心一言、智谱和讯飞等多个国产大模型在处理基础计数问题上的表现,特别是通过链式推理(COT)提示的效果。结果显示,GPTo1-mini、文心一言3.5和讯飞4.0Ultra在首轮测试中表现优秀,而其他模型在COT提示后也能显著提升正确率,唯有讯飞4.0-Lite表现不佳。测试强调了COT在提升模型逻辑推理能力中的重要性,并指出免费版本中智谱GLM较为可靠。
前端大模型应用笔记(五):大模型基础能力大比拼-计数篇-通义千文 vs 文心一言 vs 智谱 vs 讯飞vsGPT