# 渲染模式通识

  • CSR:Client Side Rendering,客户端(通常是浏览器)渲染;
  • SSR:Server Side Rendering,服务端渲染;
  • SSG:Static Site Generation,静态网站生成;
  • ISR:Incremental Site Rendering,增量式的网站渲染;
  • DPR:Distributed Persistent Rendering,分布式的持续渲染。

# 从 CSR 到 SSR

CSR(Client Side Rendering),通俗的讲就是由客户端完成页面的渲染。其大致渲染流程是这样:在浏览器请求页面时,服务端先返回一个无具体内容的 HTML,浏览器还需要再加载并执行 JS,动态地将内容和数据渲染到页面中,才能完成页面具体内容的显示。

CSR 的优点

  • 由于客户端渲染架构包含静态文件,因此可以非常轻松地通过 CDN 提供服务;
  • 所有渲染都是在客户端完成的,因此 CSR 允许我们在不刷新整个页面的情况下进行导航,从而提供良好的用户体验。
  • TTFB 时间很快,因此浏览器可以立即开始加载字体、CSS 和 JavaScript。

CSR 的缺点

  • 由于所有内容都在客户端渲染,因此性能受到很大影响,因为用户首先需要下载并处理它才能看到页面上的内容。
  • 客户端渲染应用通常会在组件挂载时获取所需的数据,这会导致糟糕的用户体验,因为在初始页面加载时会遇到很多 loaders。此外,如果子组件需要获取数据,情况可能会变得更糟,这样它们的父组件获取完所有数据后才会渲染它们,这可能会导致大量 loaders 和糟糕的 Network Waterfall。
  • SEO 是客户端渲染应用的一个问题,因为网络爬虫可以轻松读取服务端渲染的 HTML,但它们可能不会等待下载完所有 JavaScript 包,执行它们并等待客户端数据获取瀑布流完成 ,这可能会导致不正确的索引。

相比于客户端渲染,服务端渲染有什么优势?

  • 首屏时间更短。采用客户端渲染的页面,在 JS bundle 返回之前,页面一直是空白的。所以要拉取并执行 JS 代码,动态创建 DOM 结构,客户端逻辑越重,初始化需要执行的 JS 越多(bundle 体积过大)或者网络条件不好,首屏性能就越慢;客户端渲染前置的第三方类库/框架、polyfill 等都会在一定程度上拖慢首屏性能。Code splitting、lazy-load 等优化措施能够缓解一部分,但优化空间相对有限。相比而言,服务端渲染的页面直接拉取 HTML 就能显示内容,更短的首屏时间创造更多的可能性。

  • 利于 SEO。在别人使用搜索引擎搜索相关的内容时,你的网页排行能靠得更前,这样你的流量就有越高,这就是SEO的意义所在。那为什么服务端渲染更利于爬虫爬你的页面呢?因为对于很多搜索引擎爬虫(非google)HTML返回是什么内容就爬什么内容,而不会动态执行 JS 代码内容。对客户端渲染的页面来说,简直无能为力,因为返回的 HTML 是一个空壳。而服务端渲染返回的HTML是有内容的。

SSR 的出现,就是为了解决这些 CSR 的弊端。

# 从 SSR 到 SSG

SSR (Server-side Rendering) ,顾名思义,就是在浏览器发起页面请求后由服务端完成页面的HTML结构拼接,返回给浏览器解析后能直接构建出有内容的页面。SSR 最早是为了解决单页应用(SPA)产生的 SEO、首屏渲染时间等问题而诞生的,在服务端直接实时同构渲染用户看到的页面,能最大程度上提高用户的体验,流程类似下面:

SSR1

React 从框架层面直接提供支持,只需要调用 renderToString(Component) 函数即可得到 HTML 内容。Next.js 提供 getServerSideProps 异步函数,以在 SSR 场景下获取额外的数据并返回给组件进行渲染。getServerSideProps 可以拿到每次请求的上下文(Context),举个例子:

export default function FirstPost(props) {
  // 在 props 中拿到数据
  const { title } = props;
  return (
    <Layout>
      <h1>{title}</h1>
    </Layout>
  )
}

export async function getServerSideProps(context) {
  console.log('context', context.req);
  // 模拟获取数据
  const title = await getTitle(context.req);
  // 把数据放在 props 对象中返回出去
  return {
    props: {
      title
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

但 SSR 引入了另一个问题,既然要做服务端渲染,就必然需要一个实时在线的后台服务(通常是基于 Node.js 的服务)用来承载页面请求,那么:

1、需要服务器的计算资源和公网流量来部署这套服务,并且消耗的资源与页面的访问量成正相关,当页面的访问量突增时,渲染服务也需要进行扩容;

2、服务端只能部署在有限的几个地域,对于距离服务端较远的用户而言,加载速度跟静态资源的 CDN 相比,慢了一个数量级(通常是 1-5ms VS 50-100+ms);

3、日常也存在传统服务端同样的运维、监控告警等方面的负担,团队需要额外的人力来开发和维护

有没有办法解决这些问题呢?我们重新对 SSR 进行审视,服务端渲染出的页面,逻辑上讲可以分成下面两大块:

1、变化不频繁,甚至不会变化的内容:例如文章、排行榜、商品信息、推荐列表等等,这些数据非常适合缓存;

2、变化比较频繁,或者千人千面的内容:例如用户头像、Timeline、登录状态、实时评论等。

例如,在一篇文章的页面中,文章的主题内容是偏向于静态的,很少有改动,那么每次用户的页面请求,都通过服务端来渲染就变得非常不值得,我们完全可以将文章的页面渲染为静态页面,至于页面内那些动态的内容(用户头像、评论框等),就通过 HTTP API 的形式进行浏览器端渲染(CSR)。那么这就是 静态站点生成(SSG,也叫构建时预渲染)所做的事情。

SSG(Static Site Generation,也叫构建时预渲染) 是指在应用编译构建时预先渲染页面,并生成静态的 HTML。把生成的 HTML 静态资源部署到服务器后,浏览器不仅首次能请求到带页面内容的 HTML ,而且不需要服务器实时渲染和响应,大大节约了服务器运维成本和资源。

SSR2

这样做有很多好处:

1、由于文章内容已经被静态化了,所以它是 SEO 友好的,能被搜索引擎轻松爬取;

2、大大减轻了服务端渲染的资源负担,不需要额外做一套 Node.js 服务;

3、用户始终通过 CDN 加载页面核心内容,CDN 的边缘节点有缓存,速度极快;

4、通过 HTTP API + CSR,页面内次要的动态内容也可以被很好地渲染;

5、数据有变化时,重新触发一次网站的异步渲染,然后推送新的内容到 CDN 即可。

6、由于每次都是全站渲染,所以网站的版本可以很好的与 Git 的版本对应上,甚至可以做到原子化发布和回滚。

这便是 Next.js、Gatsby.js 这样的网站生成器解决的问题,他们属于 React/Vue 更上一层的框架(Meta Framework),通过 SSR 把动态化的 Web 应用渲染为多个静态页面,并且对高度动态的内容也保留了 CSR 的能力。

Next.js 默认为每个页面开启 SSG。对于页面内容需要依赖静态数据的场景,允许在每个页面中 export 一个 getStaticProps 异步函数,在这个函数中可以把该页面组件所需要的数据收集并返回。当 getStaticProps 函数执行完成后,页面组件就能在 props 中拿到这些数据并执行静态渲染。举个在静态路由中使用 SSG 的例子:


// pages/posts/first-post.js
function Post(props) {
  const { postData } = props;
  
  return <div>{postData.title}</div>
}

export async function getStaticProps() {
  // 模拟获取静态数据
  const postData = await getPostData();
  return {
    props: { postData }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

对于动态路由的场景,Next.js 是如何做 SSG 的呢?Next.js 提供 getStaticPaths 异步函数,在这个方法中,会返回一个 paths 数组,这个数组包含了这个动态路由在构建时需要预渲染的页面数据。举个例子:

// pages/posts/[id].js
function Post(props) {
  const { postData } = props;
  
  return <div>{postData.title}</div>
}

export async function getStaticPaths() {
  // 返回该动态路由可能会渲染的页面数据,比如 params.id
  const paths = [
    {
      params: { id: 'ssg-ssr' }
    },
    {
      params: { id: 'pre-rendering' }
    }
  ]
  return {
    paths,
    // 命中尚未生成静态页面的路由直接返回 404 页面
    fallback: false
  }
}

export async function getStaticProps({ params }) {
  // 使用 params.id 获取对应的静态数据
  const postData = await getPostData(params.id)
  return {
    props: {
      postData
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

当我们执行 nextjs build 后,可以看到打包结果包含 pre-rendering.htmlssg-ssr.html 两个 HTML 页面,也就是说在执行 SSG 时,会对 getStaticPaths 函数返回的 paths 数组进行循环,逐一预渲染页面组件并生成 HTML。

# 从 SSG 到 ISR/DPR

SSG 虽然很好解决了白屏时间过长和 SEO 不友好的问题,但是它仅仅适合于页面内容较为静态的场景,比如官网、博客等。面对页面数据更新频繁或页面数量很多的情况,它似乎显得有点束手无策,毕竟在静态构建时不能拿到最新的数据和无法枚举海量页面。

例如,对于只有几十个页面的个人博客、小型文档站而言,数据有变化时,跑一次全页面渲染的消耗是可以接受的。但对于百万级、千万级、亿级页面的大型网站而言,一旦有数据改动,要进行一次全部页面的渲染,需要的时间可能是按小时甚至按天计的,这是不可接受的。

为了解决这个问题,各种框架和静态网站托管平台都提供了不同的方案:

# ISR

既然全量预渲染整个网站是不现实的,那么我们可以做一个切分:

1、关键性的页面(如网站首页、热点数据等)预渲染为静态页面,缓存至 CDN,保证最佳的访问性能;

2、非关键性的页面(如流量很少的老旧内容)先响应 fallback 内容,然后浏览器渲染(CSR)为实际数据;同时对页面进行异步预渲染,之后缓存至 CDN,提升后续用户访问的性能。

SSR3

页面的更新始终返回 CDN 的缓存数据(无论是否过期);如果数据已经过期,那么触发异步的预渲染,异步更新 CDN 的缓存。请求页面,页面数据未过期,返回预渲染页面。页面数据过期,拉取最新数据,重新预渲染。

Next.js 推出的 ISR(Incremental Static Regeneration) 方案,允许在应用运行时再重新生成每个页面 HTML,而不需要重新构建整个应用。这样即使有海量页面,也能使用上 SSG 的特性。一般来说,使用 ISR 需要 getStaticPathsgetStaticProps 同时配合使用。举个例子:

// pages/posts/[id].js
function Post(props) {
  const { postData } = props;
  
  return <div>{postData.title}</div>
}

export async function getStaticPaths() {
  const paths = await fetch('https://.../posts');
  return {
    paths,
    // 页面请求的降级策略,这里是指不降级,等待页面生成后再返回,类似于 SSR
    fallback: 'blocking'
  }
}

export async function getStaticProps({ params }) {
  // 使用 params.id 获取对应的静态数据
  const postData = await getPostData(params.id)
  return {
    props: {
      postData
    },
    // 开启 ISR,最多每10s重新生成一次页面
    revalidate: 10,
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

在应用编译构建阶段,会生成已经确定的静态页面,和上面 SSG 执行流程一致。

getStaticProps 函数返回的对象中增加 revalidate 属性,表示开启 ISR。在上面的例子中,指定 revalidate = 10,表示最多10秒内重新生成一次静态 HTML。当浏览器请求已在构建时渲染生成的页面时,首先返回的是缓存的 HTML,10s 后页面开始重新渲染,页面成功生成后,更新缓存,浏览器再次请求页面时就能拿到最新渲染的页面内容了。

对于浏览器请求构建时未生成的页面时,会马上生成静态 HTML。在这个过程中,getStaticPaths 返回的 fallback 字段有以下的选项:

  • fallback: 'blocking':不降级,并且要求用户请求一直等到新页面静态生成结束,静态页面生成结束后会缓存
  • fallback: true:降级,先返回降级页面,当静态页面生成结束后,会返回一个 JSON 供降级页面 CSR 使用,经过二次渲染后,完整页面出来了

在上面的例子中,使用的是不降级方案(fallback: 'blocking'),实际上和 SSR 方案有相似之处,都是阻塞渲染,只不过多了缓存而已。也不是所有场景都适合使用 ISR。对于实时性要求较高的场景,比如新闻资讯类的网站,可能 SSR 才是最好的选择。

但 ISR 存在部分缺陷:

1、对于没有预渲染的页面,用户首次访问将会看到一个 fallback 页面,此时服务端才开始渲染页面,直到渲染完毕。这就导致用户体验上的不一致

2、对于已经被预渲染的页面,用户直接从 CDN 加载,但这些页面可能是已经过期,甚至过期很久的,只有在用户刷新一次,第二次访问之后,才能看到新的数据。对于电商这样的场景而言,是不可接受的(比如商品已经卖完了,但用户看到的过期数据上显示还有)。

# DPR

为了解决 ISR 的一系列问题,Netlify 在前段时间发起了一个新的提案 - Distributed Persistent Rendering (DPR)。DPR 本质上讲,是对 ISR 的模型做了几处改动,并且搭配上 CDN 的能力:

1、去除了 fallback 行为,而是直接用 On-demand Builder(按需构建器)来响应未经过预渲染的页面,然后将结果缓存至 CDN;

2、数据页面过期时,不再响应过期的缓存页面,而是 CDN 回源到 Builder 上,渲染出最新的数据;

3、每次发布新版本时,自动清除 CDN 的缓存数据。

当然 DPR 还在很初期的阶段,就目前的讨论来看,依然有一些问题:

  • 新页面的访问可能会触发 On-demand Builder 同步渲染,导致当次请求的响应时间比较长;
  • 比较难防御 DoS 攻击,因为攻击者可能会大量访问新页面,导致 Builder 被大量并行地运行,这里需要平台方实现 Builder 的归一化和串行运行。

# 混合渲染模式

# SSR + CSR

SSR 似乎已经解决了 CSR 带来的问题,那是不是 CSR 完全没有用武之地呢?其实并不是。使用 CSR 时,页面切换无需刷新,无需重新请求整个 HTML 的内容。既然如此,可以各取所长,各补其短,于是就有 SSR + CSR 的方案:

  • 首次加载页面走 SSR:保证首屏加载速度的同时,并且满足 SEO 的诉求
  • 页面切换走 CSR:Next.js 会发起一次网络请求,执行 getServerSideProps 函数,拿到它返回的数据后,进行页面渲染。

二者的有机结合,大大减少后端服务器的压力和成本的同时,也能提高页面切换的速度,进一步提升用户的体验。

# SSG + CSR

SSR 需要较高的服务器运维成本。对于某些静态网站或者实时性要求较低的网站来说,是没有必要使用 SSR 的。假如用 SSG 代替 SSR,使用 SSG + CSR 方案,是不是会更好:

  • 静态内容走 SSG:对于页面中较为静态的内容,比如导航栏、布局等,可以在编译构建时预先渲染静态 HTML
  • 动态内容走 CSR:一般会在 useEffect 中请求接口获取动态数据,然后进行页面重新渲染

虽然从体验来说,动态内容需要页面重新渲染后才能出现,体验上没有 SSR 好,但是避免 SSR 带来的高额服务器成本的同时,也能保证首屏渲染时间不会太长,相比纯 CSR 来说,还是提升了用户体验。

# SSG + SSR

在上面介绍的 ISR 方案时提及过,ISR 的实质是 SSG + SSR:

  • 静态内容走 SSG:编译构建时把相对静态的页面预先渲染生成 HTML,浏览器请求时直接返回静态 HTML
  • 动态内容走 SSR:浏览器请求未预先渲染的页面,在运行时通过 SSR 渲染生成页面,然后返回到浏览器,并缓存静态 HTML,下次命中缓存时直接返回

ISR 相比于 SSG + CSR 来说,动态内容可以直接直出,进一步提升了首次访问页面时的体验;相比于 SSR + CSR 来说,减少没必要的静态页面渲染,节省了一部分后端服务器成本。

# 同构

上述知道了 SSR 的优缺点,并不是所有的 WEB 应用都必须使用 SSR,这需要开发者自己来权衡,因为服务端渲染会带来以下问题:

  • 代码复杂度增加。为了实现服务端渲染,应用代码中需要兼容服务端和客户端两种运行情况,而一部分依赖的外部扩展库却只能在客户端运行,需要对其进行特殊处理,才能在服务器渲染应用程序中运行。
  • 需要更多的服务器负载均衡。由于服务器增加了渲染HTML的需求,使得原本只需要输出静态资源文件的 node 服务,新增了数据获取的 IO 和渲染 HTML 的 CPU 占用,如果流量突然暴增,有可能导致服务器down机,因此需要使用响应的缓存策略和准备相应的服务器负载。
  • 涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。

假如我们需要在项目中使用服务端渲染,我们需要做什么呢?那就是同构我们的项目。

# 同构定义

在服务端渲染中,有两种页面渲染的方式:

  • 前端服务器通过请求后端服务器获取数据并组装HTML返回给浏览器,浏览器直接解析HTML后渲染页面
  • 浏览器在交互过程中,请求新的数据并动态更新渲染页面

这两种渲染方式有一个不同点就是,一个是在服务端中组装html的,一个是在客户端中组装html的,运行环境是不一样的。所谓同构,就是让一份代码,既可以在服务端中执行,也可以在客户端中执行,并且执行的效果都是一样的,都是完成这个html的组装,正确的显示页面。也就是说,一份代码,既可以客户端渲染,也可以服务端渲染。

# 同构条件

为了实现同构,我们需要满足什么条件呢?首先,我们思考一个应用中一个页面的组成,假如我们使用的是Vue.js,当我们打开一个页面时,首先是打开这个页面的URL,这个URL,可以通过应用的路由匹配,找到具体的页面,不同的页面有不同的视图,那么,视图是什么?从应用的角度来看,视图 = 模板 + 数据,那么在 Vue.js 中, 模板可以理解成组件,数据可以理解为数据模型,即响应式数据。所以,对于同构应用来说,我们必须实现客户端与服务端的路由、模型组件、数据模型的共享。

# 小结

CSR、SSR、SSG 并不是渲染技术的最新趋势。虽然 SSR 和 SSG 在几年前开启了性能优化趋势,但增量静态再生 (ISR) 和流式 SSR 等更细分的渲染技术开始活跃起来。前者推进了 SSG,因为它允许在每个页面的基础上静态重新构建网站(例如,每 60 秒重新构建页面 X)而不是重新构建整个网站。按需 ISR,也称为按需重新验证,可用于通过应用公开的 API 触发重新构建(例如,当 CMS 数据更新时)。

另一方面,Streaming SSR 优化了服务端渲染的单线程瓶颈。普通 SSR 必须在服务器上等待数据将渲染的内容立即发送到客户端,而流式 SSR 允许开发人员将应用分成块,这些块可以逐步从服务器并行发送到客户端。

在过去几年中,SPA/MPA 中的 SSG 和 SSR 渲染模式非常简单。然而,如今更细分的版本正在流行,除了 ISR 和流式 SSR,部分水合(例如 React 服务端组件)允许仅在客户端上水合某些组件,渐进式水合可以对水合顺序进行更细粒度的控制,Island 用于 MPA 中的隔离应用或组件的架构(例如 Astro )以及使用可恢复性而不是水合作用(例如 Qwik)。

新一代Web技术栈的演进:SSR/SSG/ISR/DPR都在做什么 (opens new window)

Next.js 是怎么做预渲染的 (opens new window)

从头开始,彻底理解服务端渲染原理 (opens new window)

从头到尾彻底理解服务端渲染SSR原理 (opens new window)