原文:https://frontendmastery.com/posts/navigating-the-future-of-frontend/
作者:FRONTEND MASTERY
译者:ChatGPT 4 Turbo
编者注:叙事宏大,看完感觉学到了很多,想要记录时又发现没啥好写的。
引言
前端生态系统正处于过渡期。新兴的前端开发者在竞争激烈的框架、概念、倡导者、偏好和最佳实践的复杂环境中导航。
如今,网络和网络技术驱动了许多最常用的软件。
从 Java 小程序,到 Flash,以及 Javascript 的快速进化,无数工具已经被构建来管理广泛的浏览器、屏幕尺寸、设备能力、网络条件和日益增长的用户期望。
但在追求全功能软件分发平台的过程中,网络的基本限制并没有改变。
在这篇文章中,我们将探索现代 Javascript 元框架如何在这些限制中导航。
我们将构建一个高层次的心智模型,了解编译、路由、数据加载和变更、缓存和失效等基础部件如何汇聚在一起。这将帮助我们理解新兴的架构模式,包括 React 服务器组件提供的能力。
尽管细节是魔鬼,我们还是会发现框架之间的更多一致性。
最终,我们将掌握超越框架兴衰和浅层 API 的基本概念,并更好地理解前端生态系统正向何种方向发展。
框架的兴衰
网络平台是一个缓慢移动的全面层,位于其上构建的用户界面工具之下。随着时间的推移,平台改进,消除了对高级抽象的需要。
但平台并不总是提供必要的能力。Flash 的消亡见证了 Javascript 生态系统填补了空白,成为构建丰富交互体验的首选技术。
过去十年的技术热潮见证了许多组织利用网络作为直接面向客户的主要分发渠道,创造了类似于早期网络开发者相比的厚客户端桌面开发的前端专业化。
如今流行的框架,如 Angular、React、Vue、Svelte、Solid、Qwik 等,解决了客户端交互性问题,同时能够组合渲染一致性组件,随着数据变化。
细节导致了不同的权衡,因此仍有争议,比如处理反应性的最佳方式、对模板优于函数的偏好,或对复杂性的接受程度。但在高层次上,它们汇聚于相似的概念,并提供类似的能力。
一个大主题是在它们之上构建应用级框架的元框架日益增加的复杂性。让我们通过了解两个普遍的循环开始我们的调查。
能力与适用性
工具的能力是指其表达想法的能力。随着获得新的能力,工具变得更加复杂。
工具的适用性是指其在特定情况下是否适合目的。在组织环境中,这意味着能够让尽可能多的人满意,而不是经常遇到陷阱或潜在错误。
增加能力的创新循环也导致了复杂性和混乱。这导致了建立抽象的循环,以限制能力,或使其更易于使用。通常会出现反趋势,偏好更直接的方法。
绑定未绑定的
Javascript 疲劳现象源于必须在多项技术的不同能力之间做出选择,并使它们协同工作。
绑定是指我们将这些能力连接在一起,形成一个使使用它们更加容易的单一产品。绑定的抽象必须同时解决许多问题。结果,它们往往变得庞大,并具有通常使开发者感到不舒服的抽象层次和魔法。
当这些被感知为太慢或限制性时,一个新的循环开始,其中的一些部分被分解并在孤立中创新。
正是通过这种持续的迭代和随着时间体验到的陷阱的痛苦,我们学习到什么是重要的,什么不是。
多年来,在客户端组件框架的迭代中,每个框架都形成了自己的 “元” 框架,将不同的工具和最佳实践捆绑在一起。许多 React 的新 API 都是未捆绑的能力,旨在集成到这些更高级别的应用框架中。
回归基础
“广泛了解道路,你将在一切中见道路” – 宫本武藏
计算机系统的两个主要成分是计算和存储。
系统的性能受限于计算杂项的操作和 I/O(输入、输出)操作,如从存储中获取数据。
在网络上,这些基本限制表现为 Javascript 的单线程和用户与服务器之间的网络延迟。
网络上的所有连接都涉及客户端和服务器,遵循请求-响应模式。服务器总是第一个访问点(即使它是从 CDN 提供静态文件)。我们如何在服务器和客户端之间分配这些操作,将导致不同的权衡。
在客户端进行计算可以实现快速交互,但如果太多,主线程就会变得无响应。服务器没有这个约束,但让它们执行任务会产生网络延迟。平衡这些约束,以在网络上提供交互式体验是关键。
读取和写入
另一个基本成分是读取和写入数据的能力。
网络最初作为只读的静态文档开始。最终,我们开始持久化数据和动态生成 HTML。配备了可靠的 form
元素,我们现在可以执行写入操作,扩展了网络对新类型应用的能力。
在这个模型中,客户端上的用户交互转换为 同步 HTTP 请求。写入后的体验更新是 粗粒度 的,因为服务器响应一个新生成的文档。浏览器重新加载自身以显示它。
最终,我们获得了 XMLHttpRequest
,这开启了更多的能力。用户操作现在可以是异步的,更新是细粒度的,只更新页面的相关部分。
在这两种情况下,渲染的真实来源和应用状态都是由服务器驱动的。
到目前为止,我们已经很熟悉这个故事了。随着时间的推移,模板和应用状态越来越多地转移到了客户端,在这里应用状态变成了客户端驱动,允许快速乐观的写入,掩盖了底层网络。
这是过去十年许多刚进入这个行业的人熟悉的主导模型。在一个速度为王的行业里,随着功能的增长,所有的代码只有一个去处。
当使用单一机器的方法成为性能的阻碍时,我们可以放弃它;否则,我们就进入了分布式系统的领域。
分布式系统前端
客户端唯一方法的心理模型就像一个长时间运行的桌面应用程序,它与后端异步同步。
转向服务器驱动的应用状态是最重要的心理模型变化之一,因为大部分“前端背后”的代码都回到了服务器上。
与不同生态系统中其他服务器优先的应用框架的区别在于,丰富的客户端交互能力和稳定的导航能力得以保持。
混淆产生于知道何时以及如何在同一个框架和产品内利用服务器驱动模式的性能特征与客户端驱动方法的能力。
React 服务器组件更进一步,追求跨服务器和客户端编织的可组合组件的统一作者体验。这对于行业最主导的框架来说是一个重大转变。
其他语言生态系统也在探索类似的概念。例如,C# 的 Blazor,Clojure 的 Electric,以及 Rust 的 Leptos 追求类似的想法。
在我们迷失在细节之前,让我们退后一步,理解为什么现在?。
除了更高的性能外,让我们来了解一些关键因素,这些因素让我们在 Javascript 生态系统中走向了这个新的 web 开发方向。
-
一种语言统治一切
作为 web 的通用语言,没有其他语言像 JavaScript 这样普遍。
当 Node.js 出现时,我们可以编写在客户端和服务器上都能运行的同构代码。像 Meteor 这样的早期先锋全栈框架拥抱了这些能力。
Meteor 是一个早期的同构 Javascript 框架,具有全栈响应性和 RPC,它抽象了浏览器和服务器是非常不同的环境这一事实。
当时,这种一体化方法失去了业界的关注,转而关注更不严格、更松散的方法,如 React,作为最小化的视图库。
从那时起,TypeScript 产生了巨大的影响,成为许多开发者和组织的默认选择。
像 tPRC 和 T3 栈 这样的工具使用了同构的 Typescript,为代码、类型、模式和 执行模型 提供了端到端的类型安全性,这些都位于同一个仓库中。
-
下一代编译器和打包器
我们可以将编译器视为转换、准备和优化我们写的代码以供后续执行的程序。我们在 大规模构建和提供前端 中讨论了这在 web 上的应用。
打包器和编译器技术的稳步进步导致了从头开始重写的快速下一代打包器,它们在管理 Javascript 模块图方面非常出色。
这些能力允许框架为客户端和服务器分离模块图,这些模块图在不同的运行时中执行。
这种 代码提取 的理念推动了统一的客户端-服务器作者体验,并且是许多需要在幕后发生的魔法的基础。
-
“Suspense” 的等待结束了
理解 Suspense 解锁的能力是把握在 React 框架中出现的服务器优先心智模型的关键。与此同时,其他框架如 Solid、Vue、Preact 和 Astro 也在探索变体。
从用户体验视角来看的一个关键洞察是,我们可以更有意图地设计数据密集型体验的加载阶段。
从性能视角来看的一个关键洞察是,
Suspense
提供了资源加载和渲染的并行化。受到 Facebook 中 BigPipe 概念的启发,它缓解了服务器获取数据和渲染 HTML 的同步等待时间,而浏览器则处于闲置状态。
客户端可以在解析 HTML 时遇到这些标签时开始下载资源,如字体、CSS 和 JS。同时,服务器正在并行加载数据。
这减少了 TTFB 的打击和在纯服务器驱动的 “fetch then render” 模型中较慢的 最大内容绘制。
但与仅简单地提前刷新
<head>
和从客户端异步加载所有内容相比,这可以通过分阶段加载阶段的精细控制来完成。与数据和代码以瀑布式加载时页面内出现和消失的未经请求的 throbbers “爆米花” 式,引起 累积布局偏移 相比,这是一个显著的对比。除了初始页面加载外,它还允许 RSCs 为就地过渡推送序列化的虚拟 DOM。从技术角度来看,这里的洞察是,Suspense 解决了异步渲染和可以乱序流输出时的一致性渲染问题。
更多关于那个词汇沙拉
框架的响应性系统解决的核心问题是如何确保用户界面在数据随时间变化时能够一致地渲染。
一致性意味着显示的内容准确反映了当前真实情况。它确保了 UI 不会显示过时的数据,或与使用相同数据的另一个元素的数据不同。
虚拟 DOM 和信号都是解决此问题的方法。对差异的简化理解是,虚拟 DOM 是粗粒度的,因为它“对视图做差异化处理”,而信号是细粒度的,“对模型做差异化处理”。每种方法都有不同的权衡。
Suspense
从另一个角度解决了渲染一致性问题,即当组件树通过网络加载时,资源作为 I/O 绑定操作异步加载。这意味着我们可以从不同的数据源流式传输响应,而不必手动管理竞争条件,并在网络上乱序到达时,将占位符 DOM 内容替换为最终 DOM 内容。
它还可以与构建时编译器巧妙结合,创建新的渲染方法,如部分预渲染。
-
基础设施的进步
在 JavaScript 生态系统中酝酿这些能力的同时,云基础设施也在迅速发展,这些基础设施支撑着网络。
例如,从服务器运行时到浏览器的流式传输功能需要后端基础设施的支持。
许多服务器基础设施提供商现在支持这种能力。随着无服务器和边缘计算的普及,我们看到像 Deno、Bun、txki.js 和 LLRT 这样的新运行时出现,它们建立在能够在边缘和无服务器环境中快速启动和运行,并实现像
fetch
和ReadableStream
这样的网络标准 API。伴随着精品“前端云”提供商的崛起,他们提供的解决方案抽象化了所有底层基础设施的复杂性。
路由无处不在
“你必须明白,通往山顶的路径不止一条” – 宫本武藏
在整个技术栈中,路由是基础。互联网和网络可以被看作是一系列的路由器。路由也是任何框架的支柱。它是编译器在构建时、初始请求以及许多用户互动后的目的地的第一个入口点。
URL(和二维码)的便利性和可共享性是网络作为软件发布机制成功的基础。路由器是连接 URL 到需要加载的代码和数据的连接器。
可以将路由器视为 URL 的状态管理器,将路由视为应用内的一个可共享目的地。这是因为 URL 是显示哪些布局、需要加载什么代码和数据的主要输入。
路由器与数据获取和缓存、变更以及重新验证等关键操作紧密相连。因此,应用路由器的位置以及工作方式对前端架构至关重要。
客户端与服务器
在传统的服务器驱动方法中,路由器将请求映射到获取数据并渲染 HTML 模板的 URL。URL 之间的转换会生成 一个新文档,需要浏览器刷新。
在客户端驱动方法中,路由器的代码必须下载到浏览器,在一切都初始化后,它开始监听浏览器历史记录的变化:例如链接点击以及前进和后退导航事件。
从这里开始,而不是请求一个全新的文档,它将 URL 的变化映射到客户端组件代码,重新渲染 现有文档。
客户端路由是 SPA 架构的核心。路由转换保留了客户端的当前状态,现有的 JS、CSS 和其他资源不需要重新评估。代码和数据可以在转换之前 预加载。这也使得路由转换之间的动画体验成为可能(现在类似的 UX 模式已经被内置到平台中)。
大多数元框架提供了将服务器驱动的应用程序状态与保留客户端路由的整体能力结合在一起的功能。随着 Qwik 和 RSC 架构所采取的方法,客户端和服务器之间的区别开始变得模糊。
瀑布历史
一段时间以来,动态客户端路由是一种常见模式。也就是说,让路由器在树的任何地方将路由渲染为组件。
这种设计在运行时提供了高度灵活和动态的能力,比如渲染一个 <Redirect />
组件。然而,要知道加载什么代码和数据,我们必须渲染组件树来确定路由。
实际上,这意味着许多使用这种模式的客户端驱动组件架构遇到了相当多的客户端-服务器网络瀑布。
瀑布是一系列顺序的网络请求,其中每个请求都依赖于前一个请求的完成。瀑布是潜伏在网络选项卡中的性能的悄无声息的杀手。
元框架路由器聚焦于静态定义的路由定义。
一个流行的体现是基于文件系统的路由 – 一种直观的方式,将文件夹结构映射到 URL,然后将这些 URL 映射到那些文件夹中的特定文件。编译器通过遍历文件系统来生成路由。
使用配置文件定义所有路由是另一种简单且类型安全的方法。
这些路由定义自然形成了一个层次化的树状结构。大多数路由器创建一个嵌套路由树,将 URL 段映射到相应的组件子树。接下来我们将看到为什么这一点很重要,以及如何利用 URL 是许多服务器优先数据加载模式的关键。
前端的新后端
在我们将 URL 映射到组件树之后,我们需要加载代码和数据。正如我们所看到的,元框架的一个大主题是在不放弃基于组件的丰富客户端方法的能力的前提下,调和服务器驱动应用程序状态的性能好处。
共位是组件模型及其易于组合能力的重要组成部分。此处存在一个折衷之处,即在于管理自己的数据依赖的组件可移植性,即通过自行获取数据。但在组合时可能造成不必要的瀑布效应。与之相对的是接受数据作为 prop (或一个 Promise),并将获取数据的操作提升到路由级别。
我们谈到了 Relay 作为一个高能的“前端背后”的客户端库的例子。允许数据与组件共位,但提升了获取操作。这种能力以复杂性和包大小为代价,并且需要 GraphQL。让我们了解在转向服务器时,不打包客户端获取库或使用 GraphQL,如何导航这些折衷。
永远的好朋友
后端对前端(BFFs)是 设计模式 在面向服务的后端环境中很熟悉的一个概念。
基本思想是,一个为每个客户端平台(Web、移动、CLI 等)量身定制的后端服务紧密地位于每个前端应用之后,以满足该前端应用的特定需求。
例如,使用像 HTMX 这样的服务器驱动方法,后端响应 HTML 部分内容到一个执行 AJAX 风格更新的轻量客户端。
在 RSCs 的情况下,它是一个量身定制的后端,根据经验,将序列化的组件树返回给一个轻量或减肥的客户端。
让我们了解与在服务器上运行此层相比的一些好处。
-
简化客户端包,通过将大部分数据获取和数据转换逻辑保持在服务器上,包括重型转换库(日期格式化、国际化等),以及任何令牌或秘密不发送给浏览器的代码。
-
组合多个数据需求 并剪裁数据避免过度获取。正如我们所见,较慢的 API 调用可以流入一个 Suspense 边界而不阻塞渲染。
-
为前端工程师赋能,通过允许产品开发者指定体验所需的精确数据需求,提供了与 GraphQL 解决方案类似的开发体验(DX)。
-
利用 URL 状态 – 在响应式系统中,状态管理的黄金规则是存储最小表示的状态,并使用该状态派生出附加状态。
我们可以将这一原则应用于 URL,其中各个 URL 段映射到组件子树及其内部组件的当前状态。例如,通过查询参数映射到当前搜索过滤器或当前选定的选项。
从性能的角度来看,以这种方式管理状态允许应用层接近服务器(数据所在之处)预先获取所有代码和数据。
因此,在大多数情况下,当我们在客户端运行时,我们提前拥有所有需要的信息,无需从客户端请求回服务器。这对于初始加载和随后的过渡都是一个好位置。
这也意味着我们充分利用了网络作为分发机制的力量 —— 通过 URL 的可分享链接提供了看到内容的一致性,并确保当你分享时,其他人也能看到诸如选定的过滤器、打开的模型等。
考虑到这一点,如果 URL 允许你提前获取代码或数据,那么它是存储某些客户端状态的好地方。
缓存围绕我而生
将这些层移出客户端意味着我们可以在服务器上做更多事情和缓存更多内容。性能的一个基本原则是少做。一种做到这一点的方法是尽可能提前完成更多工作,并将结果存储在缓存中。
有多种类型的缓存(以及缓存内的更深层次),了解这些缓存至关重要。
公共缓存存储的是工作结果,这些数据不敏感也不个性化。一个例子是公共 CDN,其中缓存了服务器构建的 HTML 输出。
一个私有缓存仅对单个用户(或单独的用户群)可访问。一个例子是客户端的内存中远程数据缓存。或者是浏览器的原生 HTTP 缓存。
在任何系统中,一个主要的复杂性来源是状态管理。在前端,其中很大一部分是管理前端与其交互的远程数据的同步,这实际上是一种缓存管理。
新的远端数据缓存
如我们所见,有了浏览器中的内存缓存作为视图的真实来源,允许进行乐观写入以实现快速交互。每当我们有缓存时,我们需要了解它们是如何被使无效的。让我们检查与客户端缓存交互的不同方式。
- 手动缓存管理:这涉及使用像 Redux 这样的状态管理工具手动管理规范化缓存。它需要强制直接缓存更新以进行乐观更新,这些更新通常在响应返回时再次更新。
- 基于键的失效消除了对手动管理的需求。其中一个最佳工具的例子是 React Query,它处理了许多其他棘手的缓存管理问题。而 Apollo 或 Relay 采取类似的方法,即所有东西都在底层为你处理。
将这层移到服务器意味着移动视图的主真实来源。了解了在客户端模型中如何进行缓存管理后,让我们理解在服务器优先模型中是如何完成的。
缓存失效和服务器操作
在“传统”的请求-响应模型中,更新服务器状态的写入与导航相关联,因为浏览器需要在更新后渲染新文档。一个典型的模式是 POST, 重定向, GET 请求流程。
<!-- 浏览器将表单数据发送到 "action" 传递的 url -->
<form action="form_action.php" method="post">
<!-- 字段 -->
</form>
大多数框架都会采用这种模式作为执行写入操作的默认起点。这使得 SPA(PESPA)可以逐步增强变得更加容易。
表单的 action
属性接受一个 URL 作为端点,用于接收浏览器发送的表单数据。像 Remix 和 Sveltekit 这样的框架会将带有表单数据的写入操作发送到路由级服务器操作。而 Next 和 SolidStart 允许在组件树的任何位置调用服务器操作,使它们更类似于 RPCs。
一旦我们已经写入到服务器状态(数据库和任何服务器缓存),客户端框架使用其响应系统来对比响应和原地更新页面,而不是返回一个全新的文档。
返回编码到视图中的数据的一个好处,而不仅仅是数据,是响应可以在单次服务器往返中返回更新的 UI,与浏览器接收重定向后不得不再次执行 GET 以更新视图相比;这是 React 服务器组件所具有的优势,我们接下来会看到。
与手动管理客户端缓存相比,这种方法要简单得多,而且也不需要捆绑一个数据获取库。但正如我们之前看到的,请求-响应模型在路由(或嵌套路由)级别具有粗粒度的更新。
这对于大部分体验来说是一个很好的默认选择。然而,对于某些功能,我们可能仍然需要细粒度缓存管理和客户端数据加载的好处。
例如在轮询时,或者当粗粒度的请求-响应流程不能很好地映射到你正在构建的内容,并且你想避免在写入操作时重新运行服务器组件或 loader
函数时。
在模块图中任何地方都可以使用的服务器操作的好处在于,你可以混合匹配适合的方法。例如,你可以使用服务器操作的结果来填充客户端缓存。
// 使用 RPC 风格的服务器动作进行客户端获取和缓存
useQuery({
queryKey: ['cool-beans'],
// 任何返回 promise 的函数
queryFn: () => myServerActionThatReturnsJson(),
})
在这个领域还有更多的细微之处需要我们花时间去探索。让我们通过了解一些 React 服务器组件结合服务器动作提供的新功能,以及它们与其他新兴技术的交集来结束。
多维组件
React 服务器组件是一个重大的范式转变。在它们的初期阶段,它们很难跟随,因为有许多不同的方式可以概念化它们。
从 岛屿架构 的角度来看,不同于 React 的各种服务器组件也在其他框架生态系统中被探索,如 Nuxt 和 Deno 的 Fresh。
React 做出的所有权衡都是为了保留组件模型和随之而来的组合能力。在架构层面理解它们的另一种方式是作为 组件化的 BFF。
从客户端的角度看,RSCs 是提前运行的组件,例如,在静态构建期间,或在客户端运行之前的服务器上。
一个简单的心智模型是将它们视为 序列化组件。通过序列化组件的输出,在主线程之外运行 React 的想法已经酝酿了一段时间。
这种新能力允许 React 表达多种架构风格:
提前构建的静态网站,带有 HTMX 风格 AJAX 更新的服务器驱动架构,渐进式增强的 SPA,或者带有单一入口点的纯客户端渲染 SPA。或者在同一应用程序中根据特定体验的需要,所有这些都有可能。除了潜在的性能优势,让我们探索这种流动架构的一些有趣潜在好处。
-
跨网络的组合
服务器组件提供了分享和组合全栈功能切片以及一种新型前端创作体验的能力。
在组织内部,这是对分离的前端和后端团队模型的一种新看法,更倾向于那些在全栈垂直切片或 钢线(steel threads) 中工作的团队。
对于具有标准化基础设施的大型组织,拥有可以被产品团队使用和组合的全栈平台组件是一个引人注目的用例。在 联合模型(federated model) 中组合 RSCs 输出的能力是另一个新兴的能力。
尚不清楚这将如何在生态系统层面发挥作用,但无疑会给组件 API 设计带来有趣的变化。例如,包可能还会导出可以提升到路由级别以避免服务器瀑布的预加载功能。因为这是一个新范式,许多最佳实践还需要探索,以及需要发现的陷阱。
-
服务器驱动的 UI
这是一些大型组织如 AirBnb 和 Uber 用于更精细地控制其原生移动前端的服务器驱动渲染的概念。
react-strict-dom 的引入提供了 React Native 和 RSCs 的有趣结合,使得在 Web 之外的平台上更容易利用这些思想,包括像 AR 和空间用户界面这样的新兴平台。
-
生成式 AI UI
很难预测生成式 AI 在这个领域的未来将如何发展。但它已经是留下来的。在这个模型中一个新兴的能力是能够动态地生成高度个性化、丰富的交互体验。
一个更贴近实际的例子是在你知道要渲染哪些组件之前需要数据的情况。在这种情况下,你需要提前打包多种不同类型的交互组件。因为像这样的 UI 组件数量可能会无限增长(例如 CMS 内容类型),否则这种类型的动态组件渲染将需要将所有代码发送到客户端或在客户端延迟加载不同组件类型时引入延迟。
拥有端到端的组件意味着我们可以在不增加庞大捆绑包的情况下流式传输组件。一个有趣的探索使用了 AI 函数调用以及服务器操作的灵活性来返回序列化的交互组件。
前端的未来
我们在这篇文章中覆盖了很多内容,但对一些网络应用框架的基本层面仅仅触及了皮毛。更不用说像 WebAssembly 和 WebGPU 这样的技术可能会以意想不到的方式发挥作用。或者是大型 Javascript 框架之外的其他生态系统用 有状态服务器方法 或 本地优先 开发的兴起做出不同的权衡。
处于所有这些技术的前沿是令人兴奋的。然而,也很容易感到不知所措。
一个必须发展的重要技能是识别问题的固有复杂性,以及由该问题的解决方案引起的偶发复杂性。对于前端新手来说,这意味着将你的注意力缩小到基本不变的概念上。
工程(和生活)的一大部分是做出决策并承诺一个方向。你对用户和团队需求的了解越多,你就能做出更好的权衡,并且在你的决策中更有信心。