译文——提升性能:Faire 的 NextJS 迁移

本文详细记录了 Faire 工程团队从传统单页应用程序(SPA)架构成功迁移到 NextJS 服务器端渲染(SSR)框架的完整过程。面对日益复杂的代码库导致的性能下降问题,Faire 选择了 NextJS 作为现代化解决方案,并通过创新的渐进式迁移策略,在不重写整个应用的前提下实现了架构升级。

核心成果:通过实施 SSR 使 LCP 提升 50% 以上,FCP 提升 20% 以上,TTFB 提升 50% 以上,某常见落地页的用户离开率降低超过 40%。

技术亮点:文章分享了多项 Faire 特有的技术创新,包括自定义 UPLT(用户感知加载时间)性能衡量体系、ReactRouterCompat 路由适配器实现无缝兼容、针对灰度发布的倾斜保护机制、以及通过 Babel 插件实现的原地本地化方案。

迁移策略:采用渐进式迁移方法,先实现基础 SSR 功能验证效果,再逐步反转架构让 NextJS 前置处理 HTTP 请求。整个过程通过严格的 A/B 测试验证,确保用户体验不受影响。

未来 Faire 将继续探索响应流式传输、服务端组件、局部预渲染等现代技术,并计划完成从 MobX 到 Hooks 的状态管理迁移,重构设计系统以充分发挥 NextJS 的性能潜力。

原文标题:Boosting performance: Faire's transition to NextJS

作者:Luke Bjerring、Jude Gao and Chris Krogh

地址:craft.faire.com/boosting-pe...

Faire 是如何从成熟的单页应用程序迁移到服务器端 NextJS 框架

Faire,速度至关重要。我们的产品必须如闪电般迅速,以便客户能够快速实现他们的目标------无论是在我们平台上开设店铺、找到他们想要的商品、还是其他任何事情------然后回到他们实际的业务运营中。

在过去的一年里,Faire 工程团队把许多提升速度的措施当作优先事项,以改善我们的客户体验。其中最庞大的项目之一是迁移到 NextJS 框架。在本文中,我们将分享关于这个更改,背后的动机、面临的挑战、目前的成效以及在此过程中解锁的机会。我们还将探讨 SPA 和 SSR 之间的区别,如何处理成熟代码库的渐进式迁移,以及一些 Faire 特有的挑战------例如倒置服务器架构、为 NextJS 打补丁来实现倾斜保护和原地本地化,以及打造我们的经过精心微调的性能测量方案。请继续阅读来了解更多内容吧。

我们原先的代码库结构

运行在 faire.com 上的 web 应用程序使用的是 React,这是一个流行的 web 界面库。随着 Faire 的产品需求不断变化,代码库的复杂性逐渐增加,最终应用的加载性能受到了影响。原本,我们刻意维持 web 应用程序架构足够简单,将 API 和网页服务在同一个单体服务中。意思是我们使用 Kotlin 服务器通过 Mustache 模板编写 HTML 来响应页面请求,将 SPA(单页应用程序)的 JavaScript 代码包含其中。

要渲染 SPA 网页,浏览器需要按顺序执行:

  • 请求 HTML,然后服务器解析响应
  • 获取 JavaScript 包,再解析、执行它
  • 获取视图的特定数据,然后渲染 DOM
  • 获取 DOM 中存在的任何图像

单页应用程序中 React 的加载阶段。

这个顺序意味着网页速度取决于我们获取和执行 JavaScript 包的速度。保持 SPAs 核心 JavaScript 包的大小合理变得困难。即使使用 vendor bundles、code splitting 和其他优化技术,我们仍然看到最终用户的加载时间变差。是时候采用更现代的架构了。

经过一番研究,我们决定将渲染过程迁移到 NodeJS,并充分利用 SSR(服务器端渲染)及其他更现代的技术。通过充分利用 NextJS 这样的框架,把这个过渡将更加可管理,因为 NextJS 提供了必要的功能集合来降低我们的页面加载时间。接下来,问题变成了: "如何从由 Kotlin 和 Mustache 提供服务的 SPA 迁移到完整的 NextJS 服务器------而无需重写整个应用?"

为什么是 NextJS?

我们开始寻找最适合我们现有开发栈的框架。我们考察了多种选项,如 Remix、NextJS 和 Fresh/Deno。在速度优先的前提下,权衡了框架成熟度、社区参与度、预期迁移工作量和开发者体验等其他维度,我们最终选择了 NextJS。

我们的单页应用程序充分使用了 React Router,若使用 Remix,这是一大优势(Remix 和 React Router 相当契合,现在他们计划合并)。然而,NextJS 全面实现了构成 React 完整栈架构愿景的功能(如服务器组件、在 Suspense 组件下的数据获取)。NextJS 与 React 的紧密合作是该项目前途光明的一个积极信号,我们还注意到许多其他大公司也采用了 NextJS------这是另一个积极的信号。尽管迁移到 NextJS 比迁移到 Remix 带来了更多未知因素,但我们希望从长远来看取得成功,并避免后续的大规模迁移。

此外,正如上文所述,我们的打包文件在过去几年中不断膨胀,影响了应用的初始加载性能。那时我们确信,仅在服务器上渲染更多组件将有助于减少浏览器的工作量。

建立性能基准

为了更好地理解加载性能的影响,我们添加了监控代码(埋点),监听自定义的"页面加载时机"事件,这些事件覆盖了一系列彼此不同的阶段,例如:

  • HTML Requested 页面处于请求 HTML
  • HTML Initialized 页面处于解析 HTML head
  • HTML Completed 页面处于解析 HTML body
  • Resource Completed DOMContentLoaded 事件触发时
  • Load Completed load 事件触发时

搜集了大量事件数据,我们总结了两个重要发现:

1. 用户在页面加载完成前就走了

通过简单地统计每个事件的上报数量,我们量化了 HTML 初始化后每个阶段之间的流失率。数据显示,用户在页面有机会加载之前就放弃了并关闭了浏览器标签。这与行业发现一致(可以查看这个关于轶事的大集合)。我们许多长尾页面(访问量较低、但页面种类很多)的加载时间长达 10 秒以上,远高于 5 秒的行业标准目标。

2. LCP 和加载时间都是不完整的衡量指标

LCP(最大内容绘制)和 Load Completed(加载完成)之间存在明显的时间间隔,尤其是后来我们应用了 SSR。我们在实际中发现,这两个指标都不能准确反映用户是否觉得页面真正加载完成。LCP 告诉我们页面看起来已加载,但它不可交互。相反,"加载完成"可能会因等待一系列不必要的网络活动而被拖慢,例如无关紧要的文件资源或实际被折叠不显示的内容的数据。

由于这两个指标都无法满足我们的需求,我们不得不重新开始,进一步优化页面加载时机事件。

UPLT: 用户感知加载时间

UPLT(User Perceived Load Time 用户感知加载时间)是 Faire 内部用于衡量延迟的指标,旨在适应页面性能的细微差别。通过实施 UPLT,我们设计了一种报告机制,该机制对浏览器和 DOM 生命周期中的活动做出反应,以进一步标注加载的各个阶段,并明确标示页面"感觉已加载完成"的时刻。这避免了现成指标(如 TTFB、FCP、LCP、加载时间等)在优劣上的差异,通过为 Faire 网页应用中针对各种差异巨大的界面,明确界定什么才是合适的"完成点"。

例如,我们可能会有一系列阶段:空白页面,然后是加载页面骨架的 SSR,接着是 JavaScript 加载后的水合 DOM,之后是产品图片。直到所有产品图片都可见,页面准备好可交互,用户才会觉得页面加载完成,

加载时间在渲染页面的各个不同阶段中所占的比例。

加载时间在渲染页面的各个不同阶段中所占的比例。

如同 HTML 生命周期事件,我们创建了一个 UPLT 分解图来定位延迟的来源。我们细化了 DOMContentLoadedload 事件,使其成为我们 UPLT 指标的阶段之一,其他阶段还有例如渲染和挂载、数据xx`获取、图像获取等。有一个突出的阶段主导着蛋糕的基底:加载 HTML 和 JS。

从 Kotlin 迁移到 NodeJS

在我们的现代化技术栈路径上,我们需要做的第一个重大改变是在 NodeJS 环境中渲染文档,来解锁 React SSR。为此,我们需要让我们的代码库对 Node 友好。

我们应用程序的代码历史上一直假设它将在浏览器的生命周期内执行。因此,各种浏览器 API 散布在整个代码库中。一些简单的例子包括:

  • 浏览器导航,例如 window.location 的变化
  • 事件监听器,例如 resize
  • fetch API(Node 18 之后不再是问题)

我们使用代码 lint 规则来识别、禁止并替换浏览器 API 的直接使用,并在服务器上下文中使用 jsdom 来模拟这些 API。

渲染请求

一旦我们的 React 应用程序代码变得适合服务端运行,我们就必须决定我们的服务端架构和 NodeJS 请求框架。我们知道最终需要直接处理 HTTP 请求,但这里有一长串只有我们的 Kotlin 堆栈中存在的专有请求行为------比如身份验证和速率限制。

最简单的方法是保留请求流程不变,仅将文档 HTML 渲染阶段委托给 NodeJS(替换 Mustache)。我们在 Faire 中广泛使用 Protobuf,因此决定使用 gRPC 来通信来与后端的单体服务通信,同时推迟将 HTTP 请求处理前置迁移到 Node.js 层的重大架构改造。

我们传统架构中"后端前置"的请求处理流程。

我们添加了一个 gRPC 服务器,将 NodeJS 二进制文件 Docker 化(将 Node.js 可执行程序打包为 Docker 镜像),并将其部署到和后端单体应用相邻的 Kotlin 服务集群中。后端单体应用向 NodeJS 服务器发起嵌套请求以获取 HTML,并将响应转发给客户端。

服务端渲染

既然现在我们在 NodeJS 中渲染 HTML,因此可以利用 React 的服务端渲染(SSR)能力。我们迫不及待地想尽早评估它的影响,当时我们(事后看来)过于天真地认为利用服务器 DOM API 足够简单(实际不是),于是尝试自己实现。

我们成功实现了服务端渲染,通过将应用原本依赖于 window 中的"全局数据"进行抽象,并使用 zone.js 从一个隔离的"zone"中获取这些数据。我们将服务端生成的 HTML 注入到响应体中,并对客户端的初始化代码做了调整,以便对现有 DOM 结构进行 React 的水合处理。

现在通过在服务器端渲染的一些帮助,即在服务器上渲染 HTML 并在客户端水合,我们将 LCP 降低了 50%。🔥

使用 SSR 的 React 加载阶段。

这看起来很棒,但还不是庆祝的时候。虽然这大大提高了 LCP,但我们的自定义 SSR 的实现是一个性能权衡------ SSR 阶段给 HTML 请求增加了大约 100ms 的开销。此外,我们的自定义 SSR 流程只影响初始加载,而不影响后续的导航。

除了权衡利弊之外,我们内部实现的代码难以维护,容易产生内存泄漏,并且在特定表面上难以采用------由于全局数据复杂性导致出现 hydration 错误。尽管我们尽了最大努力,但仍然存在一段代码,它对浏览器 API 做出了错误的假设,并且与我们的实现不兼容。

我们决定止损,并转向利用 NextJS 的实现。

路由适配器

为了尽量减少迁移到 NextJS 所需的工作量,团队设定了避免重写代码的目标。这个目标是通过为我们的单页应用构建向后兼容支持来实现的,具体做法是为 React Router 构建了一个适配器,我们称之为 ReactRouterCompat。

React Router 和 NextJS App Router 之间的导航同步

这个适配器层在 Next.js 的 App Router 和我们现有的单页应用使用的旧版 React Router 之间传递了路由历史的生命周期。借助这个适配器层,我们能够将原有的 gRPC 处理器调整为构造一个等效的 HTTP 请求,并将其传递给自定义的 Next.js 服务端以生成 HTML。瞧,现在我们既可以用单页应用,也可以用 Next.js 来处理用户请求了!

同时管理两个框架来处理流量请求,需要非常细致的维护。虽然可以合理期望工程师在两个框架中都测试他们的代码,但单靠自律是不具备可扩展性的。为了解决这个问题,我们设计了一种代码模式,将路由定义统一放在一个专门的文件夹中,且与 React Router 和 NextJS Router 解耦。随后,NextJS 和 React Router 各自独立地从这个公共文件夹导入路由到它们的入口文件中。

与 React 和 NextJS Router 解耦的独立路由定义。

这种方法保证了路由逻辑的任何变更都能被集中管理,使得两个框架都能无缝地采纳每一次更新。这种编码模式大大增强了我们对保持路由一致性的信心,同时也避免了频繁的人工测试。

在此基础上,我们在生产环境中进行了第一次"无损害"实验,仔细监控性能和业务指标,关注用户体验是否出现回退。整个过程经历了几次修复 bug 和重启。第三次尝试时,实验达到了统计效能(通过采集了足够的数据,我们能以 95% 的置信度证明结果的有效性),且没有出现任何回退。最终,我们成功发布了浅层 NextJS 实现的方案 ------ 这是我们的第一个重要里程碑!

前置 NextJS

我们在推进 NextJS 部署的下一步是反转架构(即调整请求流程的主导结构),将初始的 HTTP 请求由 NextJS 接收,而不再是我们的单体后端。为了实现这一点,我们仍需利用请求栈中的一些 Kotlin 组件,比如身份验证服务。因此,我们在客户端和 NextJS 之间引入了一个浅层的 Web 代理服务。

随后,NodeJS 服务会在服务器端获取页面渲染所需的数据。这种架构带来了几个重要优势,其中之一是支持响应的流式传输。但在我们开展下一轮"不破坏现有功能"的实验时,遇到了一些问题------灰度发布(canary deployment)流程会导致 NextJS 触发强制刷新。

倾斜保护

为了避免服务端与客户端运行的代码出现不一致,NextJS 提供了一种机制,用于检测连续请求之间的 Build ID 是否发生变化。一旦检测到 ID 变了,它会在客户端触发一次强制刷新,以确保加载的是新的 NextJS 构建版本,从而防止客户端与服务端之间产生版本漂移的问题。

这与我们现有的 canary 部署策略不太兼容。我们目前使用 Linkerd 的临时流量分流机制,将请求随机地分发到稳定版本或 canary 版本。在发布新版本的过程中,这意味着我们的服务架构实际运作时大致如下图所示:

一个 canary 的流量分流。

你是否命中 canary 版本或稳定版本,完全取决于随机概率。因此,在 50% canary 部署的情况下,每个请求都有 50% 的概率会命中一个与你最初加载时不同的构建版本,从而有 50% 的可能触发一次强制刷新。而巧合的是,我们会维持 canary 状态保持一个小时以上来收集相关指标,因此这种不良影响非常明显。

针对客户端与服务端版本不一致的问题,虽然已有一些现成的解决方案,但我们最终确定,最快的办法是:将客户端初始渲染时的 canary role(版本标识)信息传递到服务端,并通过明确指定的方式将代理请求直接发送到原始的 canary 节点,实现"粘性"流量分发,避免在版本间随机切换(一开始请求的是 canary 的服务端渲染服务,后续的请求依旧)。

本地化 L10n

我们现有的 SPA 和 NextJS 之间另一个重要的区别是它们处理本地化的方式。在我们的 SPA 中,我们使用了一个开源的自定义 Babel 插件,它会将翻译文本"内联"进代码中,从而为每个语言环境(locale)生成一个独立的 JS 包。这样我们就不需要再去额外执行一次数据请求,也避免了在 JS 中打包不需要的翻译内容。

在 NextJS 中,本地化策略依赖于 React 服务器组件(React Server Components),并将翻译后的内容从服务端传递给客户端。这就意味着,我们现有代码库中那些完全基于客户端组件(client components)的部分,将无法完成翻译。为了解决 NextJS 在本地化方面的这一限制,我们通过自定义 Babel 插件生成了按语言环境划分的 JavaScript 文件,将原始构建产物转化为多个特定语言版本。

实时流块拦截,为不同语言环境提供对应内容。

在我们自定义的 Node.js 服务端中,我们会拦截响应流,根据用户的语言环境,动态地将 JS 包的 CDN 链接替换为对应语言版本的文件。这种定制化的做法超出了 NextJS 官方文档中描述的标准方式,在无需对客户端逻辑进行大幅改动的前提下,实现了对加载速度和运行效率的优化。

影响

我们的 NextJS 之旅仍在继续,但在整个过程中,我们进行了一系列 A/B 实验,以建立对该策略的信心,并量化工作的影响。到目前为止,我们发现了以下几点:

  • 通过实施 SSR,LCP 提升 50%以上
  • 迁移至新的 NextJS 架构后,FCP 提升 20% 以上
  • 通过将某个常见的落地页重写为基于 Server Components 的实现,其用户离开率降低了超过 40%。
  • 通过从 NextJS 流式传输 <head> 标签,TTFB 提升 50%以上

Faire 的 SSR 之旅接下来要做什么?

我们对 NextJS 带来的可能性感到兴奋。我们已经推出了一种架构,使我们能够开始探索现代技术,包括:

  • 响应流式传输:我们可以将 <head> 内容提前推送至客户端,从而更早启动文档处理流程(例如资源加载),并将服务端获取的数据逐步下发到客户端。
  • 服务端组件:NextJS 支持服务端组件功能,它使我们能够将大量组件的渲染逻辑迁移至云端,并在组件渲染就绪时,将生成的 UI 以流式方式传输至客户端。
  • 局部预渲染:尽管这项技术仍处于实验阶段,我们相信它具备优化潜力,即在构建阶段预编译部分静态内容,从而减少对一些变化频率低的数据(例如 <head> 中的 meta 标签)的请求需求。

完成 NextJS 架构首版改造之后,Faire 已经准备好在特定页面上进一步探索这些全新的页面渲染方式。我们已经在服务端数据获取方面取得了不错的成果,而这一点若没有 React 框架的支持是无法实现的。

敬请期待后续进展!我们仍有大量工作要完成,以升级现有代码库,使其能够充分发挥 NextJS 的能力。我们将需要完成从 MobX 向 Hooks 的状态管理迁移,重构设计系统以支持 Server Components,并重写大多数页面视图,以充分利用 NextJS 的生命周期机制,比如服务端数据获取与加载状态处理等。

这还只是冰山一角,我们对此充满期待!Faire 以工程质量和用户体验的高标准而自豪,而快速加载对于这种体验至关重要。 🏎️

这对 Faire 的整个前端团队来说是一项庞大的工程,但最终的成功离不开 Luke Bjerring、Troy Tao、Chris Krogh、Jude Gao、Jocelyn Stericker、Laszlo Pandy 和 Blair McAlpine 的巨大贡献!

补充说明

Canary (金丝雀):这个词原本来自矿井安全:以前矿工下井前会带金丝雀,如果空气有毒,金丝雀会先死,从而预警危险。软件工程中借用了这个概念:Canary Deployment 就是将新版本先部署给一小部分用户或服务器节点观察效果,一旦确认稳定,再逐步推广到全部。可以视为灰度发布的一种具体形式

**A/B test:**又叫 对照实验(Controlled Experiment) ,是一种通过对比两个(或多个)版本的表现差异,来评估改动是否带来正向效果的实验方法。

in the long tail:网站中访问量较低、但页面种类很多的那些页面或情况,比如某些特定用户路径、偏远地域、冷门设备、复杂的页面组合等。

Localization(本地化,L18n):是"具体实施"国际化后的过程。它包括:翻译 UI 文本;调整货币、日期格式;替换图像、颜色或符号等。 国际化(Internationalization,i18n) 是一种为软件"设计结构"以支持多个语言和地区的准备过程。它不涉及翻译本身,而是让你的代码具备"适配不同语言文化的能力"。

相关推荐
打小就很皮...2 小时前
简单实现Ajax基础应用
前端·javascript·ajax
wanhengidc3 小时前
服务器租用:高防CDN和加速CDN的区别
运维·服务器·前端
哆啦刘小洋4 小时前
HTML Day04
前端·html
再学一点就睡4 小时前
JSON Schema:禁锢的枷锁还是突破的阶梯?
前端·json
从零开始学习人工智能6 小时前
FastMCP:构建 MCP 服务器和客户端的高效 Python 框架
服务器·前端·网络
烛阴6 小时前
自动化测试、前后端mock数据量产利器:Chance.js深度教程
前端·javascript·后端
好好学习O(∩_∩)O6 小时前
QT6引入QMediaPlaylist类
前端·c++·ffmpeg·前端框架
敲代码的小吉米6 小时前
前端HTML contenteditable 属性使用指南
前端·html
testleaf6 小时前
React知识点梳理
前端·react.js·typescript
站在风口的猪11086 小时前
《前端面试题:HTML5、CSS3、ES6新特性》
前端·css3·html5