浏览器、服务器还是构建时间:前端架构决策指南
看看 SSG、SSR、流式渲染、服务器组件、BFF、边缘渲染、模块化单体架构以及微前端是如何各自回答同一个问题:工作应在何处执行?
在架构讨论中,尤其是高级前端面试时,很少会关心你构建了什么。他们更关注你为何以那种方式构建,而“因为团队本来就是这么做的”并非一个可行的答案。好消息是,几乎所有前端架构模式都在回应同一个核心问题:多少工作应在浏览器中完成,多少在服务器上完成,又有多少应在构建时完成?一旦你将SSR、服务端组件、前端专用后端以及微前端视为划分这些工作范围的不同方式,那么在它们之间进行选择并为其辩护就会容易得多。
关于资料来源:下文描述的公司案例均来自公开的工程技术文章和官方文档,相关出处也已明确标注。
客户端与服务器之间的界限如何变化
早期的网络非常简单。静态HTML文件几乎可以瞬间加载,且很少会出现故障。
随后,Django、Rails和ASP.NET等服务器端MVC框架开始在每次请求时生成HTML。页面现在能够显示动态数据,但每次点击都需要重新加载整个页面。
使用 React、Vue 或 Angular 构建的单页应用则将趋势彻底转向了另一个方向。浏览器接管了路由、状态管理、数据验证,有时甚至还包括身份认证功能。这样做的结果是界面看起来能即时响应,但实际上只有在内容加载完成后才是如此。问题在于:庞大的 JavaScript 包、缓慢的首次渲染速度,以及较差的可发现性。虽然谷歌会执行 JavaScript,但在内容量巨大的网站上,渲染过程可能会延迟索引生成;而许多其他爬虫,包括社交媒体预览机器人和各类 AI 爬虫,根本不会运行脚本。对于它们而言,无法有效看到的内容就等同于不存在。
从远处看,前端架构的发展史就是介于瘦客户端与胖客户端之间的一系列演变——在瘦客户端模式下,服务器承担大部分工作;而在胖客户端模式下,则由浏览器负责处理。本指南中介绍的每种架构模式,其实都是对这一界限的不同定位。
前端专用后端:为每个客户端定制 API
在前端专用后端(BFF)架构中,前端团队会在真正的后端服务之前运行一个独立的瘦服务。该服务的唯一职责就是将后端数据转换为单个客户端所需的具体格式。
这种模式可追溯至SoundCloud。2013年左右,该公司在将单一系统拆分为微服务时发现,网页版、iOS版和Android版的客户端都在争夺同一个无法满足任何一方需求的共享API。于是每个客户端都获得了专用的轻量级后端。参与过那次迁移的Phil Calçado后来详细阐述了其发展历程,而Sam Newman的文章则将其整理成广为引用的模式描述,也由此得名。Netflix也独立找到了类似的解决方案,即通过针对不同设备的适配层来处理问题,因为电视应用和手机应用所需的传输数据格式截然不同。
以一个具体案例为例。移动应用需要产品的名称、价格和缩略图,而网页应用则还需评论、库存数量及相关商品信息。如果使用单一的共享接口,要么会向移动端发送过多数据,要么会向网页端发送过少数据,因此各团队不得不通过添加查询参数来弥补这一问题。
BFF模式通过为不同终端提供定制化的响应来解决这个问题。下面的Express服务利用Node 18及更高版本内置的fetch功能,调用上游产品API后仅返回四个字段,同时将title重命名为name,并将图片列表简化为单个缩略图:
// bff.js — Node 18+, fetch is built in
import express from 'express';
const app = express();
const API = 'https://api.example.com';
app.get('/products/:id', async (req, res) => {
const response = await fetch(`${API}/products/${req.params.id}`);
const data = await response.json();
res.json({
id: data.id,
name: data.title,
price: data.price,
thumbnail: data.images[0],
});
});
app.listen(4000, () => console.log('BFF listening on :4000'));
客户端请求 /products/123 后能恰好得到所需的数据格式,既没有多余的字段,也不需要额外的往返请求。在实际生产代码中,解析数据前还需检查 response.ok,并防范那些没有图片的产品,因为 data.images[0] 假设至少存在一张图片。
这种方案的代价往往被忽视。BFF 是另一项需要部署、监控并保持正常运行的服务。一旦它出现故障,即使真正的后端运行良好,界面也会随之出问题。这并非免费的基础设施,而是一个额外的故障点,其主要价值在于为前端团队带来更便捷的工作体验。如需深入了解如何在 Next.js 应用中构建 BFF,可参阅 将 Next.js 路由处理程序转化为专门的 BFF 层。
渲染策略:HTML的生成方式
近年来,渲染领域的变化比其他任何领域都要大,实际上各种分类之间的界限比图表所示的要模糊得多。
静态生成与增量重建
静态站点生成(SSG)会在构建时渲染所有页面,然后通过CDN提供纯文本文件。这种方式在传输速度和成本上都是最优的,但内容会在下一次部署之前保持不变。
增量静态重建(ISR)则提供了一种解决方案:页面可以在设定的时间间隔后于后台自动重新生成。这样既能保持静态文件的高速度,又无需在内容每次更改时都进行部署。
服务器端渲染与流式传输
服务器端渲染(SSR)会为每个请求生成HTML。在传统模式下,服务器会发送完整的页面,随后浏览器会对该页面进行动态加载:即下载JavaScript代码包,并为已显示在屏幕上的标记添加事件处理程序和状态。
React 18通过renderToPipeableStream引入了流式SSR,这种方式会在页面结构的每个部分准备好后立即发送对应的HTML片段,而无需等待最耗时的组件处理完成。虽然许多生产环境中的应用仍能顺利使用传统的一次性SSR,但流式渲染已成为新应用的常见选择。
服务器组件、隔离区域与可恢复性
React Server Components (RSC)与岛屿架构更进一步:只有页面中的交互部分才会加载 JavaScript。静态内容则保持为纯 HTML,无需进行任何 hydrating 操作。Astro 的岛屿架构将这一理念应用到了 React 之外。Qwik则通过可恢复性功能实现了更进一步的优化,它通过将应用状态序列化为 HTML,从而在很大程度上避免了 hydrating 过程,使客户端能够从服务器停止的地方继续执行。
下方的示例展示了 Next.js App Router 中的 Server Component 页面。从 Next.js 15 开始,params 是一个必须等待其解析完成的 Promise,因此该组件会在 await params 之后才解构出 id。数据获取在服务器端执行,所以产品名称和价格会以已渲染好的 HTML 形式传递过来:
// app/products/[id]/page.tsx (Next.js 15+)
import { fetchProduct } from '@/lib/api';
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await fetchProduct(id); // runs on the server
return (
<main>
<h1>{product.name}</h1>
<p>${product.price}</p>
<AddToCartButton productId={product.id} />
</main>
);
}
只有 AddToCartButton 会包含客户端 JavaScript。为使其能够正常工作,该代码必须放在标有 'use client' 指令的独立文件中,并被导入到页面中;为简洁起见,示例中省略了该导入步骤。这样一来,只有在需要交互的地方才会生成可交互的小型代码包,其余部分则保持静态。
边缘渲染以及行业为何部分改变策略
边缘渲染是现代前端领域中,某个理念经过公开测试并不断改进的最典型例子之一。
在2021年到2023年左右,这一理念极具吸引力:在靠近每个用户的数据中心里,利用Cloudflare Workers或Vercel Edge Functions等平台在边缘端执行SSR。距离更短,页面加载速度更快。Vercel大力推广了这种方案。
Vercel后来公开改变了立场;其当时的产品副总裁将此情况总结为“这次我被骗了”。其中的原因很有启示意义:计算功能需要靠近用户,同时也需靠近数据,而大多数数据库都位于同一区域。在东京运行的边缘函数若需要多次往返于弗吉尼亚州的数据库,其速度往往比直接在弗吉尼亚州进行渲染还要慢。当Vercel在自己的产品v0上进行测试时,普通的Node.js渲染方式比边缘函数渲染更高效。此后Vercel不再使用独立的边缘函数,而是推荐将计算功能与数据置于同一区域的Node.js运行时;具体各运行时的现状可查阅当前的平台文档。
现存的理念更为简洁:直接从边缘节点提供页面的静态结构,再从靠近数据的计算节点流式传输动态内容。这大致就是部分预渲染所做的事情;关于部分预渲染与并发渲染的说明则详细阐述了其实现机制。
Cloudflare Workers依然是一个真正的边缘SSR平台,当数据本身分布在全球各地时表现尤为出色。一个重要的经验是数据本地性通常优于用户本地性。了解行业为何改变观念比记住相关术语更有价值。
模块化前端单体架构
当单页应用的发展涉及多个团队时,单一的代码仓库就会带来风险。所有人都在编辑相同的共享组件,却没人清楚哪些内容由谁负责。
模块化单体架构将代码库分为两层,但仅保留一层用于部署:
- 平台层,由平台团队负责,包含设计系统、共享钩子、日志记录以及类似的基础设施。
- 领域层,由各个功能团队负责,包含如
user/或payments/这样的功能文件夹。
这种设计理念类似于整洁架构或六边形架构,只是去掉了大部分繁琐的形式要求。在前端实现完全的整洁架构往往过于复杂;一个按钮和一个数据获取请求之间并不需要三层抽象结构。重要的是明确的职责划分与严格的边界控制,比如通过代码检查规则来防止一个模块域导入另一个模块域的内部实现。
微前端:独立性的代价
微前端架构将每个功能域视为可独立部署的微型应用,通常由壳层应用在运行时通过诸如Webpack Module Federation之类的机制来加载这些微型应用。
这样做能带来真正的自主性:团队可以按照自己的时间表发布产品,必要时甚至可以使用不同的框架。Zalando、IKEA和DAZN都曾介绍过大规模应用这种模式的经验,这些公司都拥有庞大的工程团队,并在共享工具方面投入了大量资金。micro-frontends.org依然是了解相关完整案例的标准参考资料。
真正会带来问题的故障模式
在实际事故报告中反复出现的问题并非框架混用,而是共享依赖漂移。某个远程应用升级了共享库,而另一个应用没有,结果同一页面上突然运行着两个版本的React,它们会争夺同一个DOM。模块联邦机制可以通过声明共享单例和版本范围来避免这种情况,但前提是各团队必须就这些约束达成一致并严格执行。正是这种协调问题才真正让团队苦不堪言,远比人们常提到的那种模糊的“复杂性”更为严重。
另一个方向也有一个警示性的例子。据报道,Spotify多年前曾在其桌面客户端尝试过基于iframe的微前端方案,但后来又将其整合为统一的架构,部分原因是各组件之间的衔接成本超过了由此带来的独立性优势。即便在大规模应用中,这种模式也并非必然能带来好处。
让团队规模决定决策
在架构图中很少出现的问题是你们实际拥有多少工程师。以下范围是根据团队事后对决策的描述总结出的经验法则,并非固定规则:
- 工程师人数少于15人:模块化单体架构几乎总是更优选择。由于人手不足,没有必要为每个组件单独设置部署流程。
令人信服地阐述架构选择
高级职位的决策者需要的是最终决定,而非选项清单。一个有力的回答通常包含四个部分:
- 你所运行的系统。例如:营销页面为静态生成,控制台采用SSR技术,而移动应用前端则设有BFF。
最后一点最为重要。阐明自己刻意未选择什么,才是区分简单罗列与真正做出决策的关键。边缘渲染策略的转变就是一个典型例证:即便最初推崇该方案的团队,在数据结果出现矛盾时也改变了做法。
常见问题
SSR与SSG有何区别?
由于SSR会在每次请求时进行渲染,因此它可以包含实时数据或针对特定用户的数据。而SSG在构建阶段仅生成一次HTML并提供静态文件,这种方式速度更快、成本更低,但内容仅保持最新部署时的状态。ISR则介于两者之间,通过定时重新生成单个页面。
仅有一个前端时是否需要BFF?
通常不需要。只有当多个客户端,如移动端、网页端以及合作伙伴的API,都需要从同一个后端获取截然不同的数据时,BFF才有存在的价值。对于仅有单个前端的情况,使用BFF大多只会增加一次网络传输环节以及需要维护的额外服务。
边缘渲染已经过时了吗?
不是的,但默认设置已经改变了。从边缘节点提供静态壳层仍然具有价值,而当数据在全球范围内分布时,Cloudflare Workers这样的边缘平台就能发挥优势。对于那些由单区域数据库支持的普通应用,现在的原则是将计算资源部署在数据附近,而非用户附近。
团队何时应该转向微前端?
只有当团队间的发布协调成为真正的瓶颈时才需要考虑,此前无需如此。由单个团队开发的复杂应用,通过模块化单体架构同样能获得组织层面的好处,且开销要小得多。
关键要点
- 这里的每种架构模式都是对同一个问题的回答:哪些工作应由浏览器完成,哪些由服务器处理,还有哪些属于构建阶段。