Skip to content

SSR 与 RSC(WIP)

Published: at 20:00

本文主要以 React 和 Next.js 为例

经典 Web

打开 Wikipedia,在 devtools 中关闭 JavaScript,能看到网站的主要内容仍然可以正常显示,在 devtools 中切换到 Network,可以看到浏览器向访问的资源请求了一个 HTML 文档。这就是最早网站的样子 —— 使用 HTML 文档展示内容,它们更像是网站而非现代的 Web 应用。那么二者的区别是什么呢?关键在于“交互性”(interactivity),交互性越强,网站就越像应用。

如果你没能直觉地理解交互性,可以想象一个极端的场景,如果让操作系统上运行的应用程序失去交互性……

Hacker News 是一个可交互的新闻网站,它相比 Wikipedia 会更像是一个 Web 应用。如果关闭 JavaScript,它的内容同样可以显示,并且还可以进行评论、upvote 等操作。但不论做什么操作,页面都会刷新一下,这是因为禁用 JavaScript 之后,Hacker News 会把这些功能回退到使用 HTML Form。

Hacker News 不但通过 JavaScript 脚本提升了交互性,还兼容了禁用 JavaScript 的浏览器,这实际上非常酷,但是现在的 Web 开发者们几乎都不在乎了。

Form 是一个 HTML 功能,可以用来向服务器主动发送数据,但每次提交数据,都需要重新请求 HTML 文档并刷新页面,与 JavaScript 脚本相比,它在交互上并不流畅。我们来看看 Hacker News 的开发者是怎么用脚本来增强交互的,启用 JavaScript,再点 upvote,页面没有刷新,并且 upvote 的操作正确地传递给了服务器,同时也响应到了页面的变化上。

Hacker News 实际上只加载了一个很短的脚本,打开 devtools - Network,找到 hn.js,可以看到在函数 onclick 中调用了 preventDefault 等方法,这个脚本劫持了浏览器 Form 的默认行为,转为手动发送 HTTP 请求,并调用浏览器的 DOM API 操作更新页面。通过加载脚本,Hacker News 实现了根据用户的操作更新页面的内容。Hacker News 的交互性体现在可以程序化更新页面内容的脚本。

Hacker News 开发的年代,浏览器还不能在脚本中主动发送数据,实际上它通过欺骗浏览器加载一张图片的方式发送了数据,这在当时是一种常用方法。

内容和交互是 Web 页面中最重要的两种信息,网站侧重于内容,而应用侧重于交互,但在现代 Web 中更多的是全都要。虽然我们称 Wikipedia 为网站,但如果不禁用 JavaScript,页面上会出现不少交互性强的功能,应用中的内容更不用说。

在早期 Web 的模型中,HTML 同时承载了内容和交互,但随着 JavaScript 的发展,开发者们发现用脚本可以做出交互非常流畅的 Web 应用,但随着应用越来越复杂,在源代码中大量、重复地直接调用 DOM API 产生了一些问题,于是诞生了 jQuery 这样封装 DOM API 的库。用 HTML 文档承载内容;JavaScript 脚本实现交互的模型开始流行。Old Reddit 是一个很好的例子,它用了很多 jQuery 来增强交互性,但与 Form 相似,在跳转不同页面时,页面刷新导致的不流畅还是没有解决。

SPA

在 History API 发布后,用脚本更新 DOM 并管理路由模拟页面跳转,可以实现不需要刷新页面的客户端路由。同时随着 Web 应用越来越复杂,存在大量 DOM 操作的源代码变得越来越难以维护和开发,像 AngularJS、React、Vue.js 这样的组件化库和框架也开始流行,结合客户端路由,单页面应用(SPA)逐渐变成 Web 开发的主流模型。

Vue Element Admin 是 SPA 年代中国最火的项目之一,尝试点击不同的页面,可以感受到明显流畅的交互和动画。它使用 Vue.js 构建且较为复杂,不适合本文分析。

饿了么的官网 https://ele.me 是一个使用 React 开发的 SPA 页面,在 devtools 使用 performance 观察它的渲染过程,可以看出在下载 84kB 大小的 main.b4a402f9.js,并且经过短暂的计算后,页面才显示出内容,在 devtools 中第一次显示出内容的时间点叫做 FCP(First Contentful Paint),这是在讨论 SSR 相关话题时经常需要关注的指标。

如果关闭 JavaScript 再刷新页面,将会卡在一个空白页面上。这是因为像 https://ele.me 这样的 SPA 通常会用脚本构建一种可以供框架和库来生成并维护 DOM 的中间数据结构,在 React 中叫做虚拟 DOM,这样就可以完全只用脚本来定义页面内容而不需要编写 HTML 文档,大多数的 SPA 应用在 HTML 文档中会放一个空页面或加载页面,在加载并执行脚本后把计算得到的 DOM 显示出来,在 React 中这个过程叫渲染(render)。

与早期 Web 使用 HTML 文档同时承载内容和交互相反,SPA 使用脚本同时承载内容和交互,这就需要用户在访问页面时下载并执行脚本进行渲染才能显示内容。由于这一特点,SPA 也会产生一些问题:

交互性强的 Web 应用中,用户通常会反复访问应用,只要文件没有更新,浏览器就可以把曾经下载过的脚本缓存起来,用户只需要首次访问时下载,之后便可以流畅地反复使用。而以展示信息和内容为主的网站则不同,像产品宣传页这样的页面,用户很可能只访问一次,并且这一次访问的加载时间会很大程度上影响用户对产品的印象,并且相比于 Web 应用,对于更看重内容的网站来说 SEO 通常更加重要。不难看出,SPA 非常适合用于构建 Web 应用,但不适合网站。

SSG 与 SSR

HTML 文档更擅长承载内容,JavaScript 脚本更擅长实现交互。在 Web 的历史上,有过用 HTML 文档中的 Form 来实现交互,也有过用 JavaScript 来承载内容,这些都是受限于发展过程而做的妥协,真正理想的模型是用 HTML 文档承载内容,同时用 JavaScript 脚本实现交互,这实际上是 JavaScript 发展早期 Web 应用的模型,然而由于像 React 这样的库和框架带来的优势,我们不想回到那样原始的年代。

回看 https://ele.me ,不难注意到页面上的大多数内容,实际上在编码时就可以确定并且不会变化,如果能在更早的时间运行脚本,提前将 HTML 文档渲染出来,就可以同时解决 SPA 遇到的两种问题,让模型更加接近理想,这便是 Next.js(Pages Router)中预渲染(pre-rendering)。预渲染生成的 HTML 文档中会附带页面所需的最小量的脚本,浏览器加载页面时,使用这些脚本让页面可交互。 (这一过程称为 hydration,我个人称之为充水)

“更早”是一个模糊的时间,在预渲染中究竟应该何时渲染,取决于何时可以得到渲染页面所需要的信息。Next.js 提供两种预渲染,分别是:

https://next-blog-wordpress.vercel.app/ 是一个使用 SSG 预渲染的 WordPress 博客网站,在 devtools 中禁用 JavaScript 并刷新,与之前的 SPA 不同,页面内容可以正常显示,这和之前看到过的 Wikipedia 关闭 JavaScript 后的行为一致,他们同样把内容放在了 HTML 文档中。但在禁用 JavaScript 时,页面跳转仍然需要刷新,这是因为我们禁用了实现客户端路由的脚本,重新启用 JavaScript,就能带回客户端路由。这种模型相比于 Hacker News 这样 JavaScript 发展早期的模型,可以用 React 而非 DOM API 更好得实现交互;相比于 SPA,加入 SSG 不但可以免除不必要的、重复的代码执行,还可以用 CDN 提升速度,这简直就是免费的午餐。

获取外部数据是我们在此之前一直避而不谈一个话题,这是因为对于之前的案例和场景,我们将获取数据也看作是一种承载内容,将它计入脚本运行的时间。在 SPA 中,获取数据的流程是,浏览器首先下载一个不包含内容的 HTML 文档,然后下载并执行脚本,在脚本中发起网络请求。然而实际上,服务器在用户第一次访问页面并开始下载 HTML 时,通常已经知道需要获取哪些数据,使用 SSR 就可以将发起请求的时间提前并且减少一次请求。在 SSR 中,每次页面访问,服务器都会从用户发起请求开始,直到返回完整的 HTML 文档为止,执行一整套 React 组件的渲染过程以及数据的获取操作。

与 SSG 相比,SSR 实际上更加难以驾驭。Next.js 做了很多工作来让优化 SSR 的性能,但选择正确的技术并不等于写出优秀的程序。如果在 React 代码中调用 setTimeout 来等待 10 秒钟时间,那么在访问页面时,服务器就需要至少 10 秒时间才能返回 HTML,加上网络延时以及 hydration,FCP 通常大于10秒。这个卡住服务器返回 HTML 的 10 秒钟在真实场景中通常就是获取外部数据导致的。

export async function getServerSideProps() {
  await new Promise(resolve => setTimeout(resolve, 10000));

  return {
    props: {},
  };
}

export default function Home() {
  return <>Content</>;
}

需要注意的是,在这段代码中 SSR 预渲染并非只在服务器运行。用户请求时,服务器会将执行 React 代码渲染得到的 HTML 和 getServerSideProps 返回的数据一起发送给浏览器;浏览器下载 HTML 文档和脚本后,再次执行 React 代码,然后将得到的虚拟 DOM 与服务器最初返回的 HTML 进行对比,由于 HTML 已经在服务器完成渲染,并且加载成 DOM,React 不需要重新创建 DOM 元素,只需要将事件处理程序和内部状态关联到已有的 HTML 上。在这个过程中,React 代码在服务器和浏览器中都会执行,而 getServerSideProps 只会在服务器执行。

RSC

React 团队在 2020 年末与 Next.js 团队合作推出了服务端组件 RSC(React Server Component),一种新的组件,作为区分,之前的组件叫做客户端组件(Client Component)。RSC 只在服务端运行,并且支持 JavaScript 的 Promise 异步 API,可以直接在 React 代码中调用异步操作来获取数据。也因此,服务端组件不能包含交互,只能用于承载内容,想要构建可交互的组件,必须显式声明一个组件为客户端组件。打开 https://nextjs.org ,在 devtools 打开 Network 搜索 rsc 可以看到一些请求都含有 _rsc 这个 query parameter,这些就是 RSC。

Next.js App Router 为 RSC 提供了第一方支。

但只是这样这并没有解我们之前 SSR 中遇到的,服务器返回 HTML 文档前需要等待的问题,实际上在真实场景中通常只有一部分页面需要依赖外部数据,如果服务器可以先把页面上不需要依赖外部数据的部分发给浏览器,再等数据获取完成后发送剩下的部分,就可以解决这个问题,这就是 App Router 和 RSC 的一个重要特性 —— 流式传输(Streaming)。

export default async function Home() {
  await new Promise(resolve => setTimeout(resolve, 10000));
  return <>Content</>;
}

我们把等待 10 秒放进另一个组件中,然后在外侧加一个 <Suspense>

import { Suspense } from "React";

async function TenSecondsLater() {
  await new Promise(resolve => setTimeout(resolve, 10000));
  return <>10 seconds passed</>;
}

export default async function Home() {
  return (
    <>
      <div>Home Page</div>
      <Suspense fallback={<>10 seconds not passed yet</>}>
        <TenSecondsLater />
      </Suspense>
    </>
  );
}

这样当我们访问这个网页时,

Home Page
10 seconds not passed yet

这样的内容会立刻显示在页面上,等待 10 秒钟后,第二行的文字变成 10 seconds not passed yet。打开 devtools 的 network,我们发现这十秒钟内并没有发生别的请求,变化的文字作为最初 HTML 文档的一部分发送到浏览器。