08 · 路由与渲染架构(详解版)
一句话定位:路由决定「用户在哪个页面」,渲染架构决定「这个页面的 HTML 在哪、什么时候、由谁生成」。两者共同决定了首屏速度、SEO、服务器成本与交互体验。
读完本篇你应该能做到
- 说清 CSR / SSR / SSG / ISR / RSC 的区别、成本与适用边界;
- 独立搭一套带嵌套路由、懒加载、数据预取、错误边界的路由;
- 定位并修复水合不一致、刷新 404、首屏白屏、路由切换状态错乱;
- 为一个业务做出可解释的渲染架构选型,并给出量化依据。
0. 领域导论
0.1 这个领域解决什么问题
四个「代码没报错但用户很难受」的故障:
| 现象 | 根因 | 归属 |
|---|---|---|
| 首屏白屏 3 秒,弱网更久 | CSR 要先下载 JS 再渲染,HTML 是空壳 | 渲染模式选错 |
| 刷新详情页 404,从首页点进去却正常 | history 路由没配服务端 fallback | 路由与部署不匹配 |
| 页面加载后闪一下、控制台报 hydration 警告 | 服务端与客户端首次渲染结果不一致 | 水合不一致 |
| 从列表进详情再返回,滚动位置和筛选全丢 | 路由切换没有恢复策略 | 路由状态管理缺失 |
路由与渲染不是「配个库」的事,它是一条完整链路:
用户输入 URL
→ 服务端(是否预渲染?缓存?)
→ 返回 HTML(有多大?多快?)
→ 浏览器解析 + 下载 JS
→ 水合(是否一致?是否阻塞交互?)
→ 客户端路由接管(切换是否够快?状态是否保留?)
链路上任何一环选错,后面的性能优化都是在补窟窿。
0.2 一个类比
把「渲染」想成餐厅出餐:
- CSR(客户端渲染):客人坐下后才开始点菜、现做。等待久,但后续加菜(交互)很灵活。
- SSR(服务端渲染):中央厨房先做好半成品,客人一到就能上桌,服务员再现场加热(水合)。首屏快,但厨房有成本。
- SSG(静态生成):提前做成预制菜,放在便利店(CDN),拿了就吃。最快最便宜,但菜品不能按人定制。
- ISR(增量静态再生成):预制菜定时补货,过期了后台重做一份,客人始终拿到「不太旧」的。
- RSC(React Server Components):只把食材(序列化数据)送到桌上,锅碗(组件代码)不占客人的桌面。客户端包体小,但需要新的上菜流程。
没有最好的模式,只有和菜品(业务)匹配的模式。
0.3 学习地图
08 路由与渲染架构
├─ 前置:06 框架与组件化、07 状态管理(URL 即状态)
├─ 紧接:11 性能(LCP/TTFB/水合)、10 网络(缓存与 CDN)
├─ 交叉:14 Node/BFF(SSR 服务)、15 跨端(容器内路由)
└─ 长期:17 架构(微前端路由、多团队契约)
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 会配置路由、跳转、动态参数 | 多页应用路由表 | 能解释前端路由如何拦截跳转 |
| L2 进阶 | 会用 SSR/SSG,能处理水合与数据加载 | 一个 SSR 页面、预取方案 | 能解释首屏链路每一段 |
| L3 高级 | 能做流式、边缘、缓存分层 | 渲染架构方案、性能专项 | 能说出每种模式的成本 |
| L4 专家 | 能做多团队渲染架构治理 | 架构标准、迁移路线 | 能对成本与体验同时负责 |
1. L1 基础:先把路由跑通
1.1 前端路由的两种实现
| 方式 | URL 形态 | 原理 | 优点 | 缺点 |
|---|---|---|---|---|
| hash 路由 | /#/order/1 | 监听 hashchange | 无需服务端配置,部署简单 | URL 丑、不参与 SEO、有 # 语义问题 |
| history 路由 | /order/1 | pushState + popstate | URL 干净、SEO 友好 | 服务端必须把所有路径回退到 index.html |
/**
* hash 路由的最小实现:只依赖浏览器事件。
* 面试常问「前端路由原理」,这就是答案的第一层。
*/
window.addEventListener('hashchange', () => {
const path = location.hash.slice(1) || '/';
render(path);
});
function navigate(path) {
location.hash = path; // 会触发 hashchange,并写入历史
}
/**
* history 路由:pushState 不会刷新页面,但也不会触发任何事件,
* 所以「前进/后退」必须自己监听 popstate。
*/
function navigate(path) {
history.pushState({ path }, '', path);
render(path); // 手动渲染
}
window.addEventListener('popstate', (e) => {
render(e.state?.path ?? location.pathname);
});
部署必踩的坑:history 路由要求服务端把
/order/1也返回index.html(Nginx 用try_files $uri /index.html;)。否则用户刷新就是 404。
1.2 路由表的三个核心概念
/**
* 路由表 = 路径模式 → 组件 + 元信息。
* 三个概念:嵌套(children)、动态段(:id)、索引路由(index)。
*/
const routes = [
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: 'orders', element: <OrderList /> },
{ path: 'orders/:id', element: <OrderDetail /> },
{ path: '*', element: <NotFound /> },
],
},
];
| 概念 | 作用 | 易错点 |
|---|---|---|
| 嵌套路由 | 共享布局(导航/侧边栏) | 父级必须渲染 <Outlet />,否则子路由不显示 |
动态段 :id | 详情页 | id 变化时组件不一定重建,要处理 useEffect 依赖 |
| 索引路由 | 父路径的默认子页 | 与 path: '/' 混用导致重复匹配 |
通配 * | 404 | 放在最后,顺序敏感 |
1.3 动态参数与查询参数
/**
* 路径参数用于「资源身份」,查询参数用于「视图配置」。
* 判断标准:这个值变了,是不是同一个资源?
*/
function OrderDetail() {
const { id } = useParams(); // /orders/123 → id = '123'
const [params] = useSearchParams(); // ?tab=log&page=2
const tab = params.get('tab') ?? 'detail';
// id 变了要重新请求;tab 变了只是切视图
const { data } = useQuery({ queryKey: ['order', id], queryFn: () => fetchOrder(id) });
return <Tabs value={tab} items={[{ key: 'detail', content: <Detail data={data} /> }]} />;
}
选择原则:
- 资源身份(订单号、用户 id)→ 路径参数;
- 视图配置(Tab、分页、排序、筛选)→ 查询参数;
- 敏感信息(token)→ 都不要放 URL。
1.4 导航:链接与编程式跳转
// ✅ 用 Link:可右键新开、可被爬虫抓取、可访问
<Link to="/orders">订单</Link>
// ✅ 编程式:需要先做副作用(保存、校验)时
const navigate = useNavigate();
async function handleSubmit() {
await save();
navigate('/orders', { replace: true }); // replace:不留下表单页历史
}
// ❌ 用 div + onClick:丢失链接语义与键盘可达性
<div onClick={() => navigate('/orders')}>订单</div>
1.5 路由级懒加载
/**
* 每个路由一个 chunk:首屏只加载当前页面。
* fallback 要放在「布局内部」,否则整页闪白。
*/
const OrderList = lazy(() => import('./pages/OrderList'));
<Route path="orders" element={
<Suspense fallback={<ListSkeleton />}>
<OrderList />
</Suspense>
} />
关键细节:懒加载的收益取决于「拆分粒度」。拆得太细会产生大量小请求(HTTP/2 下开销可控,但仍增加调度成本);拆得太粗则首屏包体大。经验值:按路由拆,公共依赖进 vendor chunk。
1.6 路由守卫与权限
/**
* 守卫本质是「渲染前判断」。
* 注意:前端守卫只解决体验,真正的权限必须服务端校验。
*/
function RequireAuth({ children }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <FullPageLoading />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return children;
}
登录后回跳:
const location = useLocation();
const from = location.state?.from?.pathname ?? '/';
navigate(from, { replace: true });
1.7 History API 与滚动恢复
前端路由的两块基石:URL 变化不刷新页面、前进后退能回到原状态。
/**
* 最小 history 路由:拦截链接点击 + 监听 popstate。
* 注意:pushState 不会触发 popstate,只有前进/后退才会。
*/
function createRouter(onChange) {
const navigate = (url, state = {}) => {
history.pushState(state, '', url);
onChange(location.pathname);
};
document.addEventListener('click', (event) => {
const link = event.target.closest('a[data-link]');
if (!link || event.metaKey || event.ctrlKey) return;
event.preventDefault();
navigate(link.getAttribute('href'));
});
window.addEventListener('popstate', (event) => {
onChange(location.pathname, event.state);
});
return { navigate };
}
| API | 触发时机 | 常见误区 |
|---|---|---|
pushState | 主动调用 | 不会触发 popstate,需手动通知视图 |
replaceState | 主动替换 | 用于同步筛选条件,避免污染历史栈 |
popstate | 前进/后退 | state 只在同源且可序列化时保留 |
hashchange | hash 变化 | 不触发服务端请求,SEO 较弱 |
scrollRestoration | 导航后 | 默认 auto,SPA 常需手动管理 |
三个工程细节:
- 刷新 404:history 模式需要服务端把所有路由回退到
index.html,否则刷新即 404; - state 体积:
history.state会被序列化,不能放函数/DOM/大对象,否则抛错或丢失; - 滚动位置:路由切换后要显式
scrollTo(0, 0),返回时再恢复保存的位置,否则会停在上一页的滚动点。
/**
* 保存并恢复滚动位置:按 history key 存,避免不同条目互相覆盖。
*/
const positions = new Map();
window.addEventListener('scroll', () => {
positions.set(history.state?.key ?? location.pathname, window.scrollY);
}, { passive: true });
function restoreScroll() {
const key = history.state?.key ?? location.pathname;
window.scrollTo(0, positions.get(key) ?? 0);
}
趋势:Chrome 正在推进 Navigation API(
navigation.navigate、navigate事件),能统一拦截与取消导航;目前仍需为其他浏览器保留 History 方案。
1.8 L1 小结与自检
| 主题 | 关键结论 |
|---|---|
| 路由原理 | hash 监听事件;history 用 pushState + popstate |
| 部署 | history 路由必须服务端 fallback |
| 参数 | 身份放路径,配置放查询 |
| 导航 | 优先 Link,保住可访问性与可分享性 |
| 懒加载 | 按路由拆,fallback 放在布局内 |
自检题
pushState为什么不会触发页面跳转?前进后退靠什么事件?——它只改 history 与地址栏,不发请求也不刷新;前进/后退由popstate通知,hash 路由则监听hashchange。- 刷新详情页 404,是前端还是服务端的问题?怎么修?——服务端问题:该路径没有对应文件;把所有前端路由 fallback 到
index.html,排除静态资源与 API 前缀,未知路径返回真正的 404 页。 - 为什么权限校验不能只做在前端?——前端代码可被修改,接口可被直接调用;前端只做展示与路由守卫,服务端必须做接口鉴权与数据过滤。
history.state里为什么不能放函数或大对象?——它会被结构化序列化并随历史记录保存,函数会丢失、大对象会占用内存或被浏览器限制;只放可序列化的轻量标记(如 key、来源)。
2. L2 进阶:渲染模式与首屏链路
2.1 四种渲染模式全景
| 模式 | HTML 生成时机 | 首屏 | SEO | 服务器成本 | 数据新鲜度 | 典型场景 |
|---|---|---|---|---|---|---|
| CSR | 浏览器运行时 | 慢(等 JS) | 差 | 几乎为零 | 实时 | 后台系统、登录后页面 |
| SSR | 每次请求 | 快 | 好 | 高(每次渲染) | 实时 | 电商详情、资讯 |
| SSG | 构建时 | 最快 | 好 | 零(CDN) | 构建时 | 文档、博客、营销页 |
| ISR | 构建 + 定时/按需再生成 | 最快 | 好 | 低 | 准实时 | 商品列表、内容站 |
| RSC | 服务端组件流式输出 | 快 | 好 | 中 | 实时 | 数据密集页面 |
一句话决策:
需要登录后才看 → CSR(或 SSR + 私有缓存)
内容要 SEO、更新频繁 → SSR 或 RSC
内容稳定、量大 → SSG
内容较稳定但要能更新 → ISR
2.2 SSR 的完整链路
1. 浏览器请求 /orders/1
2. Node 服务收到请求
3. 调用接口拿数据(可能聚合多个)
4. renderToString(<App url="/orders/1" />) → HTML 字符串
5. 返回 HTML(首屏可见,但按钮点不动)
6. 浏览器下载 JS
7. hydrate:复用 DOM,绑定事件
8. 之后由客户端路由接管
/**
* 最小 SSR 服务:理解「同构」最直接的方式。
* 真实框架多了:路由匹配、数据预取、流式、缓存、安全。
*/
import express from 'express';
import { renderToString } from 'react-dom/server';
const app = express();
app.get('*', async (req, res) => {
const store = createStore(); // 每次请求独立 store
const data = await loadDataFor(req.path); // 服务端预取
store.dispatch({ type: 'hydrate', payload: data });
const html = renderToString(
<Provider store={store}>
<App location={req.path} />
</Provider>,
);
res.send(`<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
<script>window.__INITIAL_STATE__=${JSON.stringify(store.getState())}</script>
<script type="module" src="/client.js"></script>
</body>
</html>`);
});
三个必须注意的点:
- 每次请求新建 store:模块级单例会让用户 A 的数据泄漏给用户 B(严重事故);
- 序列化安全:
JSON.stringify遇到</script>要转义,否则 XSS; - 接口要直连:SSR 里请求自己的 HTTP 接口会多绕一圈,应该直接调用服务层。
2.3 水合不一致:六类原因与修法
| 原因 | 例子 | 修法 |
|---|---|---|
| 时间/随机数 | Date.now()、Math.random() 直接渲染 | 用 useEffect 里设置,或 useId;服务端传固定值 |
| 环境差异 | 服务端无 window,客户端有 | 分支渲染放到 useEffect |
| 非法 HTML 嵌套 | <p><div/></p> 被浏览器改写 | 修正标签结构 |
| 数据快照不一致 | 服务端用 A 数据,客户端又请求了 B | 把初始数据序列化传给客户端 |
| 客户端独有状态 | 首屏读 localStorage | 首屏用默认值,挂载后再读 |
| 第三方组件改 DOM | 挂载时立刻插入节点 | 延迟到 effect,或 suppressHydrationWarning(仅限无害场景) |
/**
* 典型修法:把「客户端才知道的东西」推迟到挂载后。
* 不要用 suppressHydrationWarning 掩盖真实问题。
*/
function LocalTime({ iso }) {
const [text, setText] = useState(''); // 首屏两边都渲染空
useEffect(() => {
setText(new Date(iso).toLocaleString()); // 挂载后才有本地时区
}, [iso]);
return <time dateTime={iso}>{text}</time>;
}
为什么水合贵:客户端要把整棵组件树重新执行一遍,才能把事件挂上。组件越多、树越深,这段「看得见但点不动」的时间越长,直接体现在 INP/TBT 上。
2.4 同构代码的边界
// ❌ 模块顶层访问 window:服务端 import 时就崩
const width = window.innerWidth;
// ✅ 延迟到运行时,并做存在性判断
function getWidth() {
return typeof window === 'undefined' ? 0 : window.innerWidth;
}
// ✅ 浏览器专属库用动态 import
useEffect(() => {
import('chart-lib').then(({ render }) => render(ref.current));
}, []);
判断标准:只要一段代码在「没有 DOM 的环境」里执行会报错,就必须放进 useEffect、事件回调或动态 import。
2.5 路由数据加载:避免「渲染后再请求」
/**
* loader 模式:路由匹配后、渲染前拿数据。
* 好处:没有「加载中闪烁」,数据与路由强绑定。
* 代价:导航会被数据请求阻塞,需要 loading 反馈。
*/
const router = createBrowserRouter([
{
path: '/orders/:id',
loader: async ({ params }) => {
const order = await fetchOrder(params.id);
if (!order) throw new Response('Not Found', { status: 404 });
return order;
},
element: <OrderDetail />,
errorElement: <RouteError />,
},
]);
function OrderDetail() {
const order = useLoaderData(); // 同步拿到,无需 loading 分支
return <Detail data={order} />;
}
两种策略的取舍:
| 策略 | 体验 | 实现 |
|---|---|---|
渲染后请求(useEffect/useQuery) | 先出骨架,再填数据 | 简单,易做局部刷新 |
| 渲染前加载(loader) | 导航时等待,进入即完整 | 无闪烁,但需要 pending UI |
2.6 预取:让「点击」感觉是瞬时的
/**
* 意图预取:hover / 可见时提前加载下一页的代码与数据。
* 注意节制:预取过多会挤占带宽,反而拖慢当前页。
*/
function OrderLink({ id }) {
const queryClient = useQueryClient();
const prefetch = () => {
queryClient.prefetchQuery({ queryKey: ['order', id], queryFn: () => fetchOrder(id), staleTime: 30_000 });
};
return (
<Link to={`/orders/${id}`} onMouseEnter={prefetch} onFocus={prefetch}>
订单 {id}
</Link>
);
}
预取的三个层次:
- 代码预取:
import()提前加载 chunk; - 数据预取:提前请求并写入缓存;
- 渲染预取:Speculation Rules API(实验性)让浏览器预渲染整页。
2.7 错误、404 与滚动恢复
/**
* 路由级错误边界:一个页面崩了,导航仍然可用。
* 404 也用 errorElement 承载(loader 抛 404 Response)。
*/
<Route path="orders/:id" element={<OrderDetail />} errorElement={<RouteError />} />
/**
* 滚动恢复:SPA 默认不会像 MPA 那样还原位置。
* 规则:前进/后退还原,新导航置顶。
*/
function ScrollRestoration() {
const { pathname } = useLocation();
const navigationType = useNavigationType(); // POP | PUSH | REPLACE
useEffect(() => {
if (navigationType === 'POP') return; // 后退交给浏览器/自行缓存
window.scrollTo(0, 0);
}, [pathname, navigationType]);
return null;
}
焦点管理(可访问性常被忽略):路由切换后,把焦点移到页面主标题,读屏用户才知道内容变了。
2.8 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| 模式选择 | 按「是否需要 SEO / 数据新鲜度 / 成本」三问决策 |
| SSR | 每次请求独立 store;序列化要防 XSS |
| 水合 | 首屏两边必须渲染出同样结果 |
| 同构 | 浏览器 API 只能在 effect / 回调里用 |
| 数据加载 | loader 无闪烁,渲染后请求更灵活 |
| 预取 | 提升感知速度,但要控制预算 |
自检题
- SSR 为什么不能把 store 定义成模块级单例?——Node 进程被所有请求共享,单例会串用户数据;应每请求创建 store 或容器,渲染时注入。
- 水合警告「Text content did not match」通常有哪三种原因?——时间/随机数不一致(
Date.now、Math.random);环境差异(window、locale、时区);数据不一致(服务端与客户端请求结果不同)。 - loader 模式会让导航变慢吗?如何缓解?——会,因为要等数据才渲染;用并行加载、缓存与预取,非关键数据延后或流式补入,并给出加载态。
3. L3 高级:流式、RSC 与边缘
3.1 流式渲染:让首屏「分批到达」
传统 SSR 必须等整页渲染完才返回 HTML。流式渲染可以先返回「壳 + 已完成的部分」,慢的部分后补。
时间轴(传统 SSR)
[等待数据 800ms][渲染 200ms][传输 300ms] → 首字节 1.3s
时间轴(流式 SSR)
[壳 50ms] → 立即返回 → [商品区 400ms] → 追加 → [推荐区 800ms] → 追加
首字节 50ms,用户先看到布局与骨架
/**
* Suspense 是流式的边界:fallback 先发给浏览器,
* 数据就绪后,服务端把「补充 HTML + 切换脚本」一起推过去。
*/
<Suspense fallback={<Skeleton />}>
<SlowRecommendations /> {/* 服务端 await 数据,期间先发 fallback */}
</Suspense>
流式的三个前提:
- 服务端支持分块传输(不要被反向代理缓冲);
- 数据请求能并行,否则流式也快不了;
- 水合要支持「选择性水合」,否则用户看到内容却点不动。
3.2 RSC:把「组件执行」搬到服务端
| 维度 | 传统 SSR | RSC |
|---|---|---|
| 组件代码何时执行 | 服务端一次 + 客户端一次 | 服务端执行,客户端不打包 |
| 传输内容 | HTML + 全部组件 JS | 序列化的组件树(RSC Payload)+ 少量客户端 JS |
| 能否用 state/effect | 可以 | 不可以(服务端组件) |
| 交互 | 全量水合 | 只有客户端组件水合 |
| 包体 | 大 | 小 |
/**
* 服务端组件:直接查库、读文件,不会进入客户端包。
* 需要交互的部分,拆成带 'use client' 的客户端组件。
*/
async function OrderPage({ id }) {
const order = await db.order.findUnique({ where: { id } }); // 只在服务端执行
return (
<section>
<h1>{order.title}</h1>
<PayButton orderId={order.id} /> {/* 客户端组件:有交互 */}
</section>
);
}
心智模型:RSC 不是「更快的 SSR」,而是把组件的执行位置当作一种资源分配手段。数据密集的部分留在服务端,交互密集的部分送到客户端。
代价:心智复杂度上升(两种组件、序列化边界、第三方库兼容),调试链路变长。
3.3 边缘渲染
用户 → 最近的边缘节点(50ms 内)
├─ 命中缓存 → 直接返回(10ms)
└─ 未命中 → 边缘执行渲染 / 回源
适合放边缘的:A/B 实验、地理定向、鉴权前置、个性化片段、轻量聚合。 不适合:重计算、需要访问中心数据库的复杂查询(跨地域延迟反而更高)。
3.4 缓存分层:HTML、数据、CDN
| 层 | 缓存什么 | 失效方式 | 风险 |
|---|---|---|---|
| CDN | HTML / 静态资源 | purge、TTL、stale-while-revalidate | 用户看到旧页面 |
| 服务端数据缓存 | 接口结果 | key 版本、TTL、主动失效 | 跨用户数据串号(必须区分 key) |
| 客户端缓存 | 请求结果 | staleTime、手动 invalidate | 陈旧数据 |
| 浏览器 HTTP 缓存 | 资源 | Cache-Control、文件名 hash | 发布后仍用旧 JS |
SSR 缓存的经典事故:把带用户信息的 HTML 缓存到 CDN,导致 A 用户看到 B 用户的订单。规则:个性化内容不进共享缓存,或使用 Vary/私有缓存并严格校验 key。
3.5 并行路由与路由级状态
/dashboard
├─ @analytics (并行槽位:独立加载、独立 Suspense)
├─ @team
└─ children (主内容)
并行路由的价值:多个区块独立流式输出、独立错误边界,一个区块慢不拖累整页。
3.6 微前端下的路由
三种归属模式:
| 模式 | 谁控制路由 | 优点 | 风险 |
|---|---|---|---|
| 主应用统一路由 | 基座 | 体验一致、易治理 | 子应用难独立演进 |
| 子应用自治 | 各自 | 解耦彻底 | 跳转割裂、历史栈混乱 |
| 混合(前缀约定) | 基座分发 + 子应用内部路由 | 平衡 | 需要严格的前缀契约 |
必须约定:路由前缀归属、跨应用跳转 API、历史栈策略(pushState 还是 replaceState)、404 兜底归属。
3.7 选择性水合与岛屿架构
水合(hydration)的本质:服务端给了 HTML,但 DOM 上没有事件与状态;客户端要重新执行组件、把事件绑回去。问题在于——用户可能永远不会和大部分页面交互,但水合要为整页付出代价。
| 方案 | 原理 | 代表 | 代价 |
|---|---|---|---|
| 全量水合 | 整页重新执行并绑定 | 传统 SSR | 主线程长时间占用,TTI 差 |
| 渐进式水合 | 按优先级分批水合 | React + Suspense | 边界划分需要设计 |
| 选择性水合 | 交互发生时才水合该区域 | React 事件驱动 | 首次交互有额外延迟 |
| 岛屿架构 | 静态 HTML + 独立交互岛 | Astro | 跨岛状态共享困难 |
| 可恢复性 | 序列化状态,跳过重复执行 | Qwik | 框架与生态绑定深 |
React 的选择性水合:Suspense 边界内的内容在需要时才水合,用户点击某区域会优先水合它。
/**
* 用 Suspense 划分可独立水合的边界:
* 首屏关键区先水合,评论区等次要区域延后。
*/
function App() {
return (
<Layout>
<Suspense fallback={<Skeleton />}>
<Header />
<ProductInfo />
</Suspense>
<Suspense fallback={<CommentSkeleton />}>
<Comments />
</Suspense>
</Layout>
);
}
判断要不要上岛屿架构:
| 页面特征 | 建议 |
|---|---|
| 内容为主、交互少(文档、营销页) | 岛屿架构收益最大 |
| 交互密集(后台、编辑器) | 全量水合或 CSR 更简单 |
| 混合页面(电商详情) | 关键区水合 + 次要区岛屿/懒加载 |
四个常见坑:
| 坑 | 后果 | 修复 |
|---|---|---|
| 水合前后 DOM 结构不一致 | 整块被丢弃重建、闪烁 | 首屏渲染使用同一份数据与分支 |
在渲染期读 window | 服务端报错或水合不匹配 | 放到 useEffect 或做环境判断 |
| 时间/随机数直接渲染 | 两端输出不同 | 用固定时区/种子,或仅在客户端渲染 |
| 岛屿之间共享全局状态 | 状态不同步 | 提升为共享 store 或改用事件通信 |
度量水合成本:看 TBT/TTI 与「首次交互延迟」;对比关闭 JS 后的 HTML 可用性(内容是否已可读)。如果 HTML 已经完整、交互很少,岛屿架构通常比全量水合更划算。
3.8 L3 小结与自检
| 主题 | 关键结论 |
|---|---|
| 流式 | 先发壳,Suspense 边界后补内容 |
| RSC | 组件执行位置是成本优化手段,不是单纯加速 |
| 边缘 | 适合轻量、地理相关逻辑,不适合重查询 |
| 缓存 | 个性化内容绝不能进共享缓存 |
| 微前端 | 路由归属必须先定契约 |
自检题
- 流式渲染为什么可能「内容到了但点不动」?怎么解决?——HTML 到了但对应 JS 未下载或未水合;按区域分割并优先水合可交互部分,未就绪时用禁用态占位。
- RSC 的 payload 里到底传了什么?为什么包体能变小?——传序列化的组件树、属性与客户端组件引用,不传组件实现;客户端包只保留交互组件,所以体积下降。
- SSR + CDN 缓存,如何避免用户数据串号?——缓存键必须包含用户/租户维度,或对个性化页面用
private/no-store;Vary与 Cookie 配置要一致,边缘层做鉴权后再取缓存。 - 岛屿架构在什么页面上反而更差?——交互密集、跨区域共享状态的页面:岛与岛之间通信成本高、状态同步困难,统一水合或 CSR 更简单。
4. L4 专家:选型、成本与治理
4.1 渲染架构决策树
页面是否需要被搜索引擎收录?
├─ 否 → 是否登录后可见?
│ ├─ 是 → CSR(可加 BFF 聚合)
│ └─ 否 → SSG(营销页首选)
└─ 是 → 内容是否每人不同?
├─ 否 → 更新频率?
│ ├─ 低 → SSG
│ └─ 中 → ISR
└─ 是 → 能否接受服务器成本?
├─ 能 → SSR / RSC(+ 流式)
└─ 不能 → SSR + 分层缓存,或 RSC 局部化
4.2 成本模型
| 成本项 | CSR | SSR | SSG/ISR |
|---|---|---|---|
| 服务器 CPU | 极低 | 高(每请求渲染) | 低(构建/再生成) |
| CDN 流量 | 中(JS 大) | 中 | 低(HTML 可缓存) |
| 客户端 CPU | 高(渲染 + 水合) | 中 | 中 |
| 工程复杂度 | 低 | 中 | 中 |
| 数据新鲜度 | 实时 | 实时 | 取决于再生成策略 |
估算方法:QPS × 单次渲染耗时 = 需要的 CPU 核数。一个 50ms 的 SSR 页面,100 QPS 就需要约 5 核(不含峰值余量)。这个数字能直接说服业务做缓存。
4.3 多团队路由契约
- 路径命名空间:
/order/*属于订单团队,其他团队不得占用; - 跳转 API:统一
navigateTo('order.detail', { id }),禁止硬编码字符串; - 版本与灰度:路由级灰度(按用户/地域),保留回退开关;
- 废弃流程:旧路径 301 到新路径,观察流量归零再删除。
4.4 度量与治理
| 指标 | 含义 | 目标参考 |
|---|---|---|
| TTFB | 首字节时间 | < 800ms(含边缘) |
| LCP | 最大内容绘制 | < 2.5s |
| 水合时间 | HTML 到可交互 | < 1s |
| 路由切换 | 点击到新页可交互 | < 200ms(有预取) |
| 缓存命中率 | CDN/数据缓存 | > 80% |
| SSR 错误率 | 渲染失败占比 | < 0.1% |
治理机制:把指标接入 RUM,按路由维度看 P75;发布前用合成监控对比;超过预算阻断发布。
4.5 L4 自检
- 一个 10 万 SKU 的电商,列表页/详情页/购物车分别用什么渲染模式?为什么?——列表页 SSG/ISR 加客户端筛选(量大、可缓存);详情页 ISR 加按需 revalidate(SEO 与首屏);购物车 CSR 加本地状态,登录后与服务端合并(强个性化、无需 SEO)。
- 老板要求「全站 SSR」,你会怎么用数据反驳或改造这个需求?——用数据说明收益集中在首屏与 SEO 页面,后台与强交互页 SSR 只增加 TTFB、机器与运维成本;改为分层渲染并对关键页做 A/B 验证。
- 微前端下三个团队都要控制路由,契约怎么定?——主应用拥有顶层路由、404 与导航 API,子应用只注册自己的前缀;统一
basename、滚动恢复与权限钩子,冲突通过 RFC 决策。
5. 综合实战:把 CSR 后台改造成「SSR + 流式」
背景:运营后台,首屏 3.5s,SEO 无要求,但分享链接需要能直接打开。
步骤 1 · 先判断是否需要 SSR
| 判断项 | 结论 |
|---|---|
| SEO | 不需要(登录后可见) |
| 首屏 | 需要优化,但可用「骨架 + 分包 + 预取」解决 |
| 分享 | 用 URL 状态即可,不需要服务端渲染 |
结论:不该上 SSR。先做 CSR 优化,省下的服务器成本更划算。这是专家最常做的判断——拒绝不必要的复杂度。
步骤 2 · 若确实需要(如对外开放的订单分享页)
/order/share/:token → SSR + 短缓存(60s)+ 流式
其余 /app/* → CSR
步骤 3 · 关键实现
/**
* 分享页:SSR + 流式 + CDN 缓存。
* 注意:token 相关数据不进共享缓存,按 token 做 key 或直接 no-store。
*/
export async function loader({ params }) {
const order = await getSharedOrder(params.token);
if (!order) throw new Response('Not Found', { status: 404 });
return order;
}
export default function SharedOrder() {
const order = useLoaderData();
return (
<>
<OrderSummary order={order} /> {/* 关键内容先渲染 */}
<Suspense fallback={<Skeleton />}>
<Recommendations orderId={order.id} /> {/* 慢区块后补 */}
</Suspense>
</>
);
}
步骤 4 · 验收清单
- 冷启动 TTFB < 500ms,LCP < 1.5s
- 弱网(3G)下先出骨架,不白屏
- 水合无警告,首屏按钮 < 1s 可点
- 分享链接直接打开不 404
- CDN 命中率 > 80%,且不含用户私有数据
- SSR 异常时有降级(回退到 CSR 壳)
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
| 「SSR 一定比 CSR 快」 | 只快在首屏;服务器慢或缓存差时可能更慢 |
| 「上了 SSR 就不用管包体」 | JS 仍要下载与水合,包体依旧影响可交互时间 |
| 「水合警告可以忽略」 | 它意味着服务端与客户端渲染不一致,可能引发状态错乱 |
| 「SSG 不能有动态数据」 | 可配合客户端请求或 ISR 局部更新 |
| 「ISR 就是定时重建整站」 | 是按页面/按需再生成,粒度可控 |
| 「路由懒加载越细越好」 | 过细会产生大量请求与调度开销 |
| 「缓存 HTML 越久越好」 | 个性化内容会串号,且发布后用户看到旧版本 |
| 「前端路由不需要服务端配合」 | history 模式必须 fallback,否则刷新 404 |
| 「RSC 只是更快的 SSR」 | 它改变的是组件执行位置与包体构成 |
| 「预取没有成本」 | 会挤占带宽与服务器资源,需要预算控制 |
7. 面试高频题(附答题思路)
Q1:CSR 和 SSR 的本质区别? 答:HTML 生成位置不同 → 首屏内容是否依赖 JS → 水合成本 → 服务器成本 → 适用场景。最后补一句「SSR 不减少 JS,只提前内容」。
Q2:水合是什么?为什么会有不一致?
答:复用服务端 DOM 并绑事件;不一致来自时间/随机/环境/数据/非法嵌套/第三方改 DOM;再说修法与 useId。
Q3:前端路由原理?
答:hash 用 hashchange;history 用 pushState + popstate;pushState 不触发事件所以要手动渲染;部署需 fallback。
Q4:SSG、ISR、SSR 怎么选? 答:按内容变化频率与个性化程度;给三档例子(文档/商品列表/订单详情);提成本与缓存。
Q5:流式渲染解决了什么? 答:TTFB 与首屏可见时间;Suspense 边界;配合选择性水合;前提是分块传输不被缓冲。
Q6:RSC 和 SSR 的区别? 答:执行位置与传输内容;客户端包体;交互组件边界;心智与生态代价。
Q7:SPA 路由切换后滚动与焦点怎么处理?
答:scrollRestoration,POP 还原、PUSH 置顶;焦点移到主标题;读屏播报。
Q8:如何评估要不要上 SSR? 答:先看 SEO 与首屏数据;再算服务器成本(QPS × 渲染耗时);最后看团队维护能力。能给「不上」的理由同样是正确答案。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 渲染模式 | 能否按业务约束选型 | 用首屏、SEO、数据实时性三个维度决策 | 「SSR 一定比 CSR 快」 |
| 首屏链路 | 是否知道时间花在哪 | 拆 TTFB / 下载 / 执行 / 水合 | 「加个骨架屏就行」 |
| 水合 | 不一致的根因 | 列出时间、随机、环境三类来源 | 「水合就是再渲染一次」 |
| 路由 | history 与 hash 的边界 | 说清服务端 fallback 与 404 | 「路由就是切组件」 |
| 成本 | 服务端与缓存的账 | 给出缓存命中率与机器成本估算 | 「上 SSR 就完事了」 |
8.2 追问链
链 1 · 四种渲染模式 → 首屏指标 → 水合成本
- 面试官:CSR、SSR、SSG、ISR 分别解决什么问题?
- 要点:CSR 把渲染放浏览器,TTFB 低但 FCP/LCP 依赖 JS;SSR 服务端出 HTML,首屏快但 TTFB 受服务端影响;SSG 构建期生成,最快且可 CDN 化,但内容更新需重建;ISR 是「按需/定时再生成」的折中,用
revalidate控制新鲜度。
- 要点:CSR 把渲染放浏览器,TTFB 低但 FCP/LCP 依赖 JS;SSR 服务端出 HTML,首屏快但 TTFB 受服务端影响;SSG 构建期生成,最快且可 CDN 化,但内容更新需重建;ISR 是「按需/定时再生成」的折中,用
- 追问:SSR 的 HTML 到了,为什么页面还是不能点?
- 要点:HTML 只负责「看得见」,交互依赖 JS 下载、解析、执行与水合。水合要复用 DOM、绑定事件、恢复状态,成本与组件树规模成正比;这就是「TTI 远晚于 FCP」的原因。
- 再追问:什么时候 SSR 反而更慢?
- 要点:服务端渲染耗时长导致 TTFB 上升;依赖个人化数据无法缓存;客户端水合包过大。此时「SSR + 流式 + 部分水合」或「静态壳 + 客户端取数」更合适。
链 2 · 流式渲染 → Suspense → 选择性水合
- 面试官:流式 SSR 解决了什么问题?
- 要点:把 HTML 分块下发,浏览器边收边解析,缩短「白屏到首字节内容」的时间;配合
Suspense可以先发壳与已完成部分,慢数据后补。
- 要点:把 HTML 分块下发,浏览器边收边解析,缩短「白屏到首字节内容」的时间;配合
- 追问:后到的片段怎么插到正确位置?
- 要点:React 用占位注释节点 + 内联脚本把新 HTML 替换进占位处(out-of-order streaming);代价是依赖内联脚本,CSP 需允许对应 nonce/hash。
- 再追问:选择性水合的前提是什么?
- 要点:需要给每个可交互区域划分边界并标记优先级;框架要能在用户交互时才加载对应代码。前提是组件边界清晰、状态可序列化,否则会退化成全量水合。
链 3 · 路由实现 → 服务端 fallback → 预加载
- 面试官:history 路由为什么刷新会 404?
- 要点:
pushState只改地址不发请求,刷新时浏览器真的去请求该路径,服务端没有对应文件就 404。需要服务端把所有前端路由 fallback 到index.html,同时用真实 404 页面处理未知路径。
- 要点:
- 追问:
popstate与pushState的时机差别?- 要点:
pushState不触发popstate,所以要在调用后手动通知路由;前进/后退才触发popstate。hashchange只对 hash 变化生效。
- 要点:
- 再追问:预加载怎么做才不浪费带宽?
- 要点:按「意图」预取(hover/focus/视口内可见)而不是全量;用
requestIdleCallback在空闲时预取高概率路由;对低端网络用navigator.connection.saveData关闭预取。
- 要点:按「意图」预取(hover/focus/视口内可见)而不是全量;用
链 4 · SSR 成本 → 缓存命中 → 扩容
- 面试官:SSR 上线后 TTFB 从 200ms 涨到 1.2s,服务端 CPU 打满,怎么处理?
- 要点:先拆 TTFB:DNS/TLS、排队、渲染、数据请求。若是渲染耗 CPU,先看组件树与序列化体积;若是数据请求串行,改为并行或缓存。
- 追问:哪些页面可以缓存?
- 要点:与用户无关的公共页(首页、详情、榜单)可 CDN 缓存;个性化区块单独走客户端取数或用边缘组装。缓存键要含路由参数、语言、实验分组。
- 再追问:缓存命中率上不去怎么办?
- 要点:看 key 是否包含随机/时间参数、
Vary是否过宽、TTL 是否过短;把个性化降到区块级,而不是整页放弃缓存。
- 要点:看 key 是否包含随机/时间参数、
链 5 · 多团队路由 → 契约 → 微前端
- 面试官:微前端下路由应该归谁管?
- 要点:先定契约:基座负责顶层路径与鉴权,子应用只接管自己的前缀;跨应用跳转走统一 API,不直接改
history;404 与错误边界由基座兜底。
- 要点:先定契约:基座负责顶层路径与鉴权,子应用只接管自己的前缀;跨应用跳转走统一 API,不直接改
- 追问:子应用独立部署后,路由刷新 404 怎么办?
- 要点:服务端按前缀把请求分发到对应子应用的
index.html,或统一 fallback 后由基座路由;静态资源路径要带前缀与 hash。
- 要点:服务端按前缀把请求分发到对应子应用的
- 再追问:多个子应用同时改 URL 会怎样?
- 要点:历史栈互相覆盖、返回行为错乱。必须约定「谁拥有当前路径」,并在切换时清理监听与定时器。
8.3 手写题
题 1 · 极简 history 路由
/**
* 极简 history 路由:支持动态参数与通配。
* @param {Array<{path: string, render: (params: object) => void}>} routes
*/
function createRouter(routes) {
/** 把 /user/:id 编译为正则与参数名 */
const compile = (path) => {
const keys = [];
const source = path
.replace(/\/:(\w+)/g, (_, key) => {
keys.push(key);
return '/([^/]+)';
})
.replace(/\*/g, '(.*)');
return { keys, regex: new RegExp(`^${source}$`) };
};
const table = routes.map((route) => ({ ...route, ...compile(route.path) }));
/** 匹配当前路径并渲染 */
const resolve = () => {
const path = location.pathname;
for (const route of table) {
const matched = path.match(route.regex);
if (!matched) continue;
const params = Object.fromEntries(route.keys.map((k, i) => [k, decodeURIComponent(matched[i + 1])]));
route.render(params);
return;
}
// 未匹配:交给 404 处理
};
/**
* 编程式导航。
* @param {string} url 目标地址
* @param {{replace?: boolean}} [options]
*/
const push = (url, { replace = false } = {}) => {
history[replace ? 'replaceState' : 'pushState']({}, '', url);
resolve();
};
window.addEventListener('popstate', resolve);
document.addEventListener('click', (e) => {
const link = e.target.closest('a[data-link]');
if (!link || e.metaKey || e.ctrlKey || link.target === '_blank') return;
e.preventDefault();
push(link.getAttribute('href'));
});
resolve();
return { push };
}
追问:
- 为什么
pushState后要手动调用resolve?→ 它不触发popstate,不手动通知视图就不会更新。 - 动态参数为什么要
decodeURIComponent?→ 路径中的中文/空格会被编码,直接使用会得到%E4%...。 - 路由顺序有什么要求?→ 通配与短路径会抢先匹配,静态路径应排在动态路径之前,或用打分排序。
题 2 · 路由匹配器(参数 + 通配 + 优先级)
/**
* 将路由模式转为匹配器,静态段得分高于动态段。
* @param {string} pattern 形如 /order/:id/detail
*/
function createMatcher(pattern) {
const segments = pattern.split('/').filter(Boolean);
const keys = [];
const regex = new RegExp(
'^' +
segments
.map((seg) => {
if (seg === '*') {
keys.push('wildcard');
return '(.*)';
}
if (seg.startsWith(':')) {
keys.push(seg.slice(1));
return '([^/]+)';
}
return '/' + seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
})
.join('')
.replace(/^\/\//, '/') +
'/?$'
);
/** 静态段越多越优先,通配最少优先 */
const score = segments.reduce((acc, seg) => {
if (seg === '*') return acc - 10;
if (seg.startsWith(':')) return acc + 1;
return acc + 3;
}, 0);
return {
score,
match(path) {
const m = path.match(regex);
if (!m) return null;
return Object.fromEntries(keys.map((k, i) => [k, m[i + 1]]));
},
};
}
追问:
- 为什么不用
path-to-regexp的简化正则拼接?→ 自写容易漏转义与嵌套可选段,生产应使用成熟库。 - 尾斜杠怎么处理?→ 正则里加
/?,同时在规范化阶段统一去除,避免缓存键分裂。 - 查询参数算不算匹配的一部分?→ 不算,路由匹配只看
pathname,search单独解析。
8.4 场景设计题
题:一个营销活动页首屏 3s,要求 1s 内可见、可交互在 2s 内。技术栈可改,你怎么选型与验收?
评估点:是否按内容特征选型、是否给出度量与回退。
参考方案:
- 拆现状:用 WebPageTest 分城市跑,拆出 DNS/TLS、TTFB、HTML 下载、关键资源、JS 执行与水合;确认瓶颈在服务端还是客户端。
- 选型:内容型且更新不频繁 → SSG + CDN;有个人化但可缓存 → ISR + 边缘缓存;强交互后台 → CSR + 关键资源预加载。不要为「看起来先进」上全站 SSR。
- 首屏:内联关键 CSS、字体
preload+font-display: swap、首屏图片fetchpriority="high"、非关键 JSdefer或动态导入。 - 交互:路由级代码分割 + 预取;水合按区域拆分,首屏可交互区域优先。
- 验收:p75 的 LCP < 1s、INP < 200ms、CLS < 0.1;缓存命中率与回源率达标;灰度期间对比错误率与转化率。
加分:指出营销页的主要成本是图片与第三方脚本,先治理第三方;指出 SSG 的构建时间会随页面数增长,需要增量构建。
减分:只说「上 SSR」;不区分 p75 与实验室数据;没有回退方案。
题 2:SSR 上线后,服务端 CPU 打满,TTFB 从 200ms 涨到 1.2s,业务方要求「要么变快,要么回滚」。你怎么在一周内给出方案?
评估点:是否能拆解 TTFB、是否区分「渲染慢」与「数据慢」、是否有缓存与降级。
参考方案:
- 定位:按路由统计「排队时间 / 渲染时间 / 数据请求时间 / 序列化时间」,找出 Top 5 慢路由与慢组件。
- 止血:对公共页加 CDN 缓存(含参数与语言 key);对个性化区块改为客户端取数;高峰期对非核心页降级为 CSR 壳。
- 减负:并行化数据请求、给下游加超时与缓存、移除 SSR 中不必要的大依赖(图表、编辑器)、限制单次渲染的组件数。
- 扩容与保护:按 CPU 设并发上限与队列,超时快速失败并返回可用的 CSR 兜底页;加 TTFB SLO 与告警。
- 验收:p75 TTFB 回到 300ms 内、错误率不升、CPU 利用率有峰值保护;一周后复盘是否保留 SSR。
加分:指出「SSR 不是免费的」,渲染成本随流量线性增长;指出可用「静态壳 + 流式 + 区块级缓存」而非整页 SSR。
减分:只加机器;把超时调大;不做缓存键设计就上 CDN。
8.5 最容易答错的点
| 常见说法 | 纠正 |
|---|---|
| 「SSR 一定比 CSR 首屏快」 | TTFB 与服务端渲染耗时可能抵消收益,需实测 |
| 「SSG 就不能有动态内容」 | 可用客户端取数或 ISR 增量更新 |
| 「水合只是绑定事件」 | 还要恢复状态、校验 DOM,成本与树规模相关 |
「pushState 会触发路由更新」 | 不触发 popstate,需手动通知 |
| 「预加载越多越好」 | 会抢占带宽,低端网络应关闭 |
| 「流式渲染能提升 LCP 一定有效」 | 若 LCP 元素在最后一块才到达,收益有限 |
| 「ISR 就是定时重新生成」 | 还支持按需 revalidatePath,且要处理并发重建 |
| 「边缘渲染等于没有服务端成本」 | 仍有冷启动、回源与计费,需看命中率 |
| 「SSR 能解决所有首屏问题」 | 它只缩短 HTML 到达时间,交互仍取决于 JS 与水合 |
| 「路由懒加载一定更好」 | 过度拆分会增加请求与等待,首屏关键路由不宜再拆 |
| 「缓存 HTML 就等于缓存整页」 | 个性化区块必须单独处理,否则会串号 |
| 「水合不一致只是警告」 | 生产环境可能整块重建,造成闪烁与状态丢失 |
9. 延伸资源
- MDN:History API、
popstate、Navigation API - web.dev:Rendering on the Web(CSR/SSR/SSG 的经典对比)
- React 官方文档:Server Components、Suspense、
hydrateRoot - Next.js 文档:App Router、Streaming、ISR、Partial Prerendering
- Remix / React Router 文档:loader、action、错误边界
- Nuxt 文档:SSR、Nitro、混合渲染(route rules)
- Vercel / Cloudflare 博客:边缘渲染与流式实践
- 「Speculation Rules API」规范与 Chrome 团队文章