08

路由与渲染架构

1,113 行约 54 分钟32 个代码块23 张表格更新于 2026-11-03

08 · 路由与渲染架构(详解版)

一句话定位:路由决定「用户在哪个页面」,渲染架构决定「这个页面的 HTML 在哪、什么时候、由谁生成」。两者共同决定了首屏速度、SEO、服务器成本与交互体验。

读完本篇你应该能做到

  1. 说清 CSR / SSR / SSG / ISR / RSC 的区别、成本与适用边界;
  2. 独立搭一套带嵌套路由、懒加载、数据预取、错误边界的路由;
  3. 定位并修复水合不一致、刷新 404、首屏白屏、路由切换状态错乱;
  4. 为一个业务做出可解释的渲染架构选型,并给出量化依据。

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/1pushState + popstateURL 干净、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 只在同源且可序列化时保留
hashchangehash 变化不触发服务端请求,SEO 较弱
scrollRestoration导航后默认 auto,SPA 常需手动管理

三个工程细节:

  1. 刷新 404:history 模式需要服务端把所有路由回退到 index.html,否则刷新即 404;
  2. state 体积:history.state 会被序列化,不能放函数/DOM/大对象,否则抛错或丢失;
  3. 滚动位置:路由切换后要显式 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 放在布局内

自检题

  1. pushState 为什么不会触发页面跳转?前进后退靠什么事件?——它只改 history 与地址栏,不发请求也不刷新;前进/后退由 popstate 通知,hash 路由则监听 hashchange。
  2. 刷新详情页 404,是前端还是服务端的问题?怎么修?——服务端问题:该路径没有对应文件;把所有前端路由 fallback 到 index.html,排除静态资源与 API 前缀,未知路径返回真正的 404 页。
  3. 为什么权限校验不能只做在前端?——前端代码可被修改,接口可被直接调用;前端只做展示与路由守卫,服务端必须做接口鉴权与数据过滤。
  4. 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>`);
});

三个必须注意的点:

  1. 每次请求新建 store:模块级单例会让用户 A 的数据泄漏给用户 B(严重事故);
  2. 序列化安全:JSON.stringify 遇到 </script> 要转义,否则 XSS;
  3. 接口要直连: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>
  );
}

预取的三个层次:

  1. 代码预取:import() 提前加载 chunk;
  2. 数据预取:提前请求并写入缓存;
  3. 渲染预取: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 无闪烁,渲染后请求更灵活
预取提升感知速度,但要控制预算

自检题

  1. SSR 为什么不能把 store 定义成模块级单例?——Node 进程被所有请求共享,单例会串用户数据;应每请求创建 store 或容器,渲染时注入。
  2. 水合警告「Text content did not match」通常有哪三种原因?——时间/随机数不一致(Date.now、Math.random);环境差异(window、locale、时区);数据不一致(服务端与客户端请求结果不同)。
  3. 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>

流式的三个前提:

  1. 服务端支持分块传输(不要被反向代理缓冲);
  2. 数据请求能并行,否则流式也快不了;
  3. 水合要支持「选择性水合」,否则用户看到内容却点不动。

3.2 RSC:把「组件执行」搬到服务端

维度传统 SSRRSC
组件代码何时执行服务端一次 + 客户端一次服务端执行,客户端不打包
传输内容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

层缓存什么失效方式风险
CDNHTML / 静态资源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组件执行位置是成本优化手段,不是单纯加速
边缘适合轻量、地理相关逻辑,不适合重查询
缓存个性化内容绝不能进共享缓存
微前端路由归属必须先定契约

自检题

  1. 流式渲染为什么可能「内容到了但点不动」?怎么解决?——HTML 到了但对应 JS 未下载或未水合;按区域分割并优先水合可交互部分,未就绪时用禁用态占位。
  2. RSC 的 payload 里到底传了什么?为什么包体能变小?——传序列化的组件树、属性与客户端组件引用,不传组件实现;客户端包只保留交互组件,所以体积下降。
  3. SSR + CDN 缓存,如何避免用户数据串号?——缓存键必须包含用户/租户维度,或对个性化页面用 private / no-store;Vary 与 Cookie 配置要一致,边缘层做鉴权后再取缓存。
  4. 岛屿架构在什么页面上反而更差?——交互密集、跨区域共享状态的页面:岛与岛之间通信成本高、状态同步困难,统一水合或 CSR 更简单。

4. L4 专家:选型、成本与治理

4.1 渲染架构决策树

页面是否需要被搜索引擎收录?
├─ 否 → 是否登录后可见?
│        ├─ 是 → CSR(可加 BFF 聚合)
│        └─ 否 → SSG(营销页首选)
└─ 是 → 内容是否每人不同?
         ├─ 否 → 更新频率?
         │        ├─ 低 → SSG
         │        └─ 中 → ISR
         └─ 是 → 能否接受服务器成本?
                  ├─ 能 → SSR / RSC(+ 流式)
                  └─ 不能 → SSR + 分层缓存,或 RSC 局部化

4.2 成本模型

成本项CSRSSRSSG/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 自检

  1. 一个 10 万 SKU 的电商,列表页/详情页/购物车分别用什么渲染模式?为什么?——列表页 SSG/ISR 加客户端筛选(量大、可缓存);详情页 ISR 加按需 revalidate(SEO 与首屏);购物车 CSR 加本地状态,登录后与服务端合并(强个性化、无需 SEO)。
  2. 老板要求「全站 SSR」,你会怎么用数据反驳或改造这个需求?——用数据说明收益集中在首屏与 SEO 页面,后台与强交互页 SSR 只增加 TTFB、机器与运维成本;改为分层渲染并对关键页做 A/B 验证。
  3. 微前端下三个团队都要控制路由,契约怎么定?——主应用拥有顶层路由、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 控制新鲜度。
  • 追问:SSR 的 HTML 到了,为什么页面还是不能点?
    • 要点:HTML 只负责「看得见」,交互依赖 JS 下载、解析、执行与水合。水合要复用 DOM、绑定事件、恢复状态,成本与组件树规模成正比;这就是「TTI 远晚于 FCP」的原因。
  • 再追问:什么时候 SSR 反而更慢?
    • 要点:服务端渲染耗时长导致 TTFB 上升;依赖个人化数据无法缓存;客户端水合包过大。此时「SSR + 流式 + 部分水合」或「静态壳 + 客户端取数」更合适。

链 2 · 流式渲染 → Suspense → 选择性水合

  • 面试官:流式 SSR 解决了什么问题?
    • 要点:把 HTML 分块下发,浏览器边收边解析,缩短「白屏到首字节内容」的时间;配合 Suspense 可以先发壳与已完成部分,慢数据后补。
  • 追问:后到的片段怎么插到正确位置?
    • 要点: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 关闭预取。

链 4 · SSR 成本 → 缓存命中 → 扩容

  • 面试官:SSR 上线后 TTFB 从 200ms 涨到 1.2s,服务端 CPU 打满,怎么处理?
    • 要点:先拆 TTFB:DNS/TLS、排队、渲染、数据请求。若是渲染耗 CPU,先看组件树与序列化体积;若是数据请求串行,改为并行或缓存。
  • 追问:哪些页面可以缓存?
    • 要点:与用户无关的公共页(首页、详情、榜单)可 CDN 缓存;个性化区块单独走客户端取数或用边缘组装。缓存键要含路由参数、语言、实验分组。
  • 再追问:缓存命中率上不去怎么办?
    • 要点:看 key 是否包含随机/时间参数、Vary 是否过宽、TTL 是否过短;把个性化降到区块级,而不是整页放弃缓存。

链 5 · 多团队路由 → 契约 → 微前端

  • 面试官:微前端下路由应该归谁管?
    • 要点:先定契约:基座负责顶层路径与鉴权,子应用只接管自己的前缀;跨应用跳转走统一 API,不直接改 history;404 与错误边界由基座兜底。
  • 追问:子应用独立部署后,路由刷新 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 内。技术栈可改,你怎么选型与验收?

评估点:是否按内容特征选型、是否给出度量与回退。

参考方案:

  1. 拆现状:用 WebPageTest 分城市跑,拆出 DNS/TLS、TTFB、HTML 下载、关键资源、JS 执行与水合;确认瓶颈在服务端还是客户端。
  2. 选型:内容型且更新不频繁 → SSG + CDN;有个人化但可缓存 → ISR + 边缘缓存;强交互后台 → CSR + 关键资源预加载。不要为「看起来先进」上全站 SSR。
  3. 首屏:内联关键 CSS、字体 preload + font-display: swap、首屏图片 fetchpriority="high"、非关键 JS defer 或动态导入。
  4. 交互:路由级代码分割 + 预取;水合按区域拆分,首屏可交互区域优先。
  5. 验收:p75 的 LCP < 1s、INP < 200ms、CLS < 0.1;缓存命中率与回源率达标;灰度期间对比错误率与转化率。

加分:指出营销页的主要成本是图片与第三方脚本,先治理第三方;指出 SSG 的构建时间会随页面数增长,需要增量构建。

减分:只说「上 SSR」;不区分 p75 与实验室数据;没有回退方案。

题 2:SSR 上线后,服务端 CPU 打满,TTFB 从 200ms 涨到 1.2s,业务方要求「要么变快,要么回滚」。你怎么在一周内给出方案?

评估点:是否能拆解 TTFB、是否区分「渲染慢」与「数据慢」、是否有缓存与降级。

参考方案:

  1. 定位:按路由统计「排队时间 / 渲染时间 / 数据请求时间 / 序列化时间」,找出 Top 5 慢路由与慢组件。
  2. 止血:对公共页加 CDN 缓存(含参数与语言 key);对个性化区块改为客户端取数;高峰期对非核心页降级为 CSR 壳。
  3. 减负:并行化数据请求、给下游加超时与缓存、移除 SSR 中不必要的大依赖(图表、编辑器)、限制单次渲染的组件数。
  4. 扩容与保护:按 CPU 设并发上限与队列,超时快速失败并返回可用的 CSR 兜底页;加 TTFB SLO 与告警。
  5. 验收: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 团队文章