前端渲染模式文章封面

前端渲染模式:CSR、SSR 与 SPA

发布于:
(更新于: )
作者: MongoRolls
11 分钟阅读

CSR

目前国内主流的前端框架,比如 Vue 和 React,基本上都采用 CSR(Client Side Rendering,客户端渲染)。用户访问页面时,浏览器先拿到一份几乎为空的 HTML,以及相关 JS:

<html>
  <head>
    <title>title</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="./index.js"></script>
  </body>
</html>

框架再把内容动态渲到特定节点上,可以粗理解成 document.getElementById("root").innerHTML = "..."

CSR 里点页面内导航通常不会再向服务端要一整页。React 推荐用 <Link> 而不是 <a>,由 React Router 这类模块(底层 history / hash)重新执行 JS 来跳转和渲染。这种方式也叫 单页面应用(SPA)

CSR 优点:

  • 初始化之后,单页跳转很快,不必每次都打服务器就能局部更新。
  • 可用 AJAX 等动态取数再渲染。

CSR 缺点:

  • 强依赖 JavaScript,要等 JS 下载和执行。
  • 首屏慢,初始 HTML 为空,容易白屏。
  • 对 SEO 不友好,搜索引擎不好抓实际内容。

SSR

SSR(Server Side Rendering)以 Next.js 为例:服务端先把 HTML 渲好再返回给客户端。

服务端刚返回的页面通常还没有交互(点击等 DOM 事件)。HTML 里仍带着 script;客户端加载这些脚本后,通过 hydrate(水合)把数据和交互补上,页面才完整可点。水合之后,后续渲染和路由由客户端接管。主流 SSR 框架(React 的 Next.js、Vue 的 Nuxt.js)都是建在传统 CSR 框架之上的。

CSR 的流程是返回简单 HTML,再下载 JS 改 DOM。SSR 是服务器预先渲好,直接返回完整 HTML,所以首屏更好。Next、Nuxt 都是常见方案。

为什么需要水合?

为什么不能把 JS 逻辑在服务端渲染时一起处理完?React、Vue 的组件系统天生不太具备「状态序列化」能力,依赖运行时的 JavaScript,尤其是闭包和事件处理函数。

Hydration(水合)就是在 SSR 返回的静态 HTML 上,由客户端 JS 接管,把页面变成可交互的:加载脚本后给静态内容「激活」(加事件监听等)。同构 / 通用应用里,前后端共享一套渲染逻辑:初始 HTML 在服务端生成,利于首屏和 SEO;随后客户端把事件和状态补齐。

同构要保证客户端与服务端 DOM 一致,方便映射,否则会 hydration 报错。React 也提供 suppressHydrationWarning 之类 API 跳过警告。

function Counter() {
  const [count, setCount] = useState(0);

  // increment 捕获了外部作用域中的状态
  const increment = () => {
    setCount(count + 1);
  };

  return <button onClick={increment}>{count}</button>;
}

Hydration 的难点在于:要知道附加哪些事件处理函数、附加到哪些 DOM 节点,并恢复事件相关状态。

  • what:处理函数里往往有和组件状态相关的闭包,需要 JS 重新执行以恢复 APP_STATE。
  • where:每个处理函数还要绑到正确的 DOM 节点和事件类型。

还要修框架内部状态(FRAMEWORK_STATE),比如哪些组件该重渲、哪些数据要同步。一句话:在客户端用 JS 恢复应用和框架的全部状态,让页面重新可交互。

SSR 优点:

  • 不强依赖 JavaScript,禁用 JS 时仍能显示内容。
  • 首屏更快,不必等客户端 JS 下载执行。
  • SEO 更好,服务端直接下发完整 HTML。
  • 可做缓存,降低客户端压力。

SSR 缺点:

  • 必须依赖服务器,不能像纯静态页那样全量丢到 CDN。
  • 服务端有并发和性能压力,需要部署和压测。
  • TTI 可能变长,要等 JS 下载和水合完才能交互。

自己做 SSR:同构实践

有个比较老的项目,当时还没有 Next,是自己实现的 SSR 框架。

参考:

react-dom/serverrenderToString 可以把 JSX 直接转成字符串:

// server/index.js
import express from "express";
import { renderToString } from "react-dom/server";
import Home from "./containers/Home";

const app = express();
const content = renderToString(<Home />);
app.get("/", function (req, res) {
  res.send(
    `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id="root">${content}</div>
      </body>
    </html>
   `
  );
});
app.listen(3001, () => {
  // console.log('listen:3001');
});

同构可以简单理解成:同一套代码在客户端和服务端执行,结果一致。JSX 上绑了点击事件时,renderToString 返回的字符串里并没有这些绑定,所以还要再塞 script。React 提供客户端 API hydrateRoot 来接手。

常见坑是水合错误:客户端和服务端 DOM 对不上,挂载会出问题(和 React 虚拟 DOM 有关)。往 div 上加 suppressHydrationWarning 只能把警告藏掉。

同构还会带出这些具体问题:

路由。 客户端和服务端都要理解同一套路由。React 常用 React Router;SSR 里要用 StaticRouter,不能用 BrowserRouter

Redux。 同构项目里 store 要分两头接:客户端一份、服务端一份,都通过 react-redux 的 Provider 往下传。服务端 Store 还要单独处理,不然导出去会变成同一个 Store。

Redux 同构中的 store 连接

状态注入。 初始化 store,把 state 存到 window 上。

其余还要处理:异步数据的注水和脱水、多级路由渲染、Node 中间层、CSS 的服务端渲染,以及 SEO(react-helmet)。

SSG

SSG(Static Site Generation,静态站点生成)是 SSR 的一种拓展:构建阶段就把页面渲成纯静态 HTML,运行时不再依赖服务端计算,也不必在客户端跑复杂 JS。静态页可以上 CDN,比 SSR 更省服务器。

适合文档、博客、官网、产品介绍这类偏展示、交互不多的站点。

ISR

ISR(Incremental Static Regeneration,增量静态再生成)介于 SSR 和 SSG:可以像 SSG 一样先生成静态页,再按需增量更新,不必整站重建。

Next.js 里用 revalidate 指定后台再生条件。时间到了之后,下一次有人访问,服务端拉新数据、重生页面、换掉旧静态页。CMS 文章更新后,也可以 on-demand 或 revalidate 去拉最新内容。

// pages/blog/[slug].tsx
import { GetStaticProps, GetStaticPaths } from "next";

interface Post {
  slug: string;
  title: string;
  content: string;
}

export default function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

// 生成静态路径
export const getStaticPaths: GetStaticPaths = async () => {
  // 从 API/数据库获取所有文章
  const posts = await fetch("https://api.example.com/posts").then((r) =>
    r.json()
  );

  const paths = posts.map((post: Post) => ({
    params: { slug: post.slug },
  }));

  return {
    paths,
    fallback: "false",
  };
};

// 为每个路径生成静态页面
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await fetch(
    `https://api.example.com/posts/${params?.slug}`
  ).then((r) => r.json());

  return {
    props: { post },
    // 启用 ISR. 60 minutes
    revalidate: 60 * 60,
  };
};

Next

Next.js 是 Vercel 做的、基于 React 的开源框架,主打 SSR 和 SSG。

主要特性:

  1. 开箱支持 SSR、SSG、ISR,提升首屏和 SEO,页面可后台更新。
  2. 文件结构即路由,不用手写配置。
  3. 内置 API 路由,前后端可以放一起。
  4. 原生 CSS / Sass / CSS-in-JS,静态资源和图片优化也齐。
  5. 按路由自动分割和懒加载。
  6. 插件多,数据获取、状态管理都好接。
  7. 一键部署 Vercel,也兼容 Node 和各家 Serverless。

Next.js 有 page routeapp route。项目里更常见的 page route 写 SSR 页大致是:

function Home({ serverData }: { serverData: string }) {
  return (
    <div>
      <h1>服务端渲染页面</h1>
      <p>来自服务端的数据:{serverData}</p>
    </div>
  );
}

export async function getServerSideProps() {
  // 这里进行服务端数据获取
  // const data = await fetch(...)
  const serverData = "Hello, SSR!";
  // 返回的对象中的 props 会传递给页面组件
  return {
    props: { serverData },
  };
}

export default Home;

getServerSideProps 是页面级、每次请求都会跑的服务端取数函数,返回的 props 交给页面组件,这就是 Next 的 SSR。

Nuxt.js 可以看成 Vue 生态里的 Next.js。

Qwik

Qwik 的核心是跳过水合:服务端直接把 JS 逻辑和状态序列化进 HTML,省掉传统水合。Resumable 可以概括成 按需下载、执行 JS

<!-- Qwik 序列化到 HTML 的核心部分 -->
<div q:host>
  <div q:host>
    <!-- 事件处理函数引用:指向具体的 JS 文件和函数 -->
    <button on:click="./component_onClick.js#handler">添加</button>
  </div>
  <div q:host>
    <!-- q:obj 用于存储组件状态引用 -->
    <button q:obj="1" on:click="./component_onClick.js#handler[0]">10</button>
  </div>
</div>

<script id="qwikloader">
  /* qwik 中设置全局事件监听器的代码 */
</script>
<script type="qwik/json">
  /* 事件监听器管理和状态反序列化信息 */
</script>

用户第一次点按钮时,Qwik 才下载对应处理函数;之后不再下载,直接跑已加载的函数。TTI 很好,但生态不如 Next.js,除非对性能要求极高,一般不选。

还有 Islands 这类切法,例如 Astro,这里不展开。

总结

  • SSG & ISR:加载最快(静态 HTML),SEO 最优,成本低;内容更新要重建。ISR 能按需增量更新,适合大规模内容站。
  • SSR:首屏快,动态内容灵活,SEO 友好;要服务器,成本更高。自己做时,同构、水合、路由和 store 是主要坑。
  • CSR:交互最好,后期跳转快;首屏慢,SEO 一般,要跑大量 JS。
访问量:0