返回文章
阅读时间 14 分钟

深入服务端渲染(上)

如果你还不了解服务端渲染,那么我先做个简单的介绍。

早期的 Web 应用,页面内容主要是在服务端生成。例如,在 PHP 中,服务端可以生成内容完整的 HTML 文件,再返回给客户端。

<?php
// 模拟动态数据
$hobby = ['🎤', '💃', '🤟', '🏀'];
?>
<!DOCTYPE html>
<html>
<head>
<title>SSR</title>
</head>
<body>
<div id="root">
<ul>
<?php foreach ($hobby as $item): ?>
<li>
<?php echo htmlspecialchars($item); ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<script src="/main.js" async></script>
</body>
</html>
<?php
// 模拟动态数据
$hobby = ['🎤', '💃', '🤟', '🏀'];
?>
<!DOCTYPE html>
<html>
<head>
<title>SSR</title>
</head>
<body>
<div id="root">
<ul>
<?php foreach ($hobby as $item): ?>
<li>
<?php echo htmlspecialchars($item); ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<script src="/main.js" async></script>
</body>
</html>

客户端接收到的 HTML 内容,大致如下:

<!DOCTYPE html>
<html>
<head>
<title>SSR</title>
</head>
<body>
<div id="root">
<ul>
<li>🎤</li>
<li>💃</li>
<li>🤟</li>
<li>🏀</li>
</ul>
</div>
<script src="/main.js" async></script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>SSR</title>
</head>
<body>
<div id="root">
<ul>
<li>🎤</li>
<li>💃</li>
<li>🤟</li>
<li>🏀</li>
</ul>
</div>
<script src="/main.js" async></script>
</body>
</html>

客户端只需要解析完 HTML,就能将内容渲染出来。

这种主要依靠在服务端上执行代码生成页面内容的方式,我们称为服务端渲染(SSR)。

客户端渲染

当前开发 Web 应用,采用更多的渲染方式是客户端渲染(CSR),页面内容主要在客户端生成。客户端接收到的只是一个空的 HTML 文件。内容大致如下:

<!DOCTYPE html>
<html>
<head>
<title>CSR</title>
</head>
<body>
<div id="root"></div>
<script src="/main.js"></script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>CSR</title>
</head>
<body>
<div id="root"></div>
<script src="/main.js"></script>
</body>
</html>

接收到 HTML 文件后,客户端还需要加载执行完一个 JavaScript 文件,内容才能被渲染出来。

我们可以通过一张动态图来理解这一过程:

csr

我们可以看到,客户端渲染需要依赖 JavaScript 生成页面内容,因此在首屏渲染时可能会遇到较长的白屏时间,并且由于服务器返回的 HTML 内容为空,对站点的 SEO 也不友好。

相反,服务端渲染能返回内容完整的 HTML,提供更快的首屏渲染速度和更好的 SEO 优化。但是,当前开发 Web 应用仍然更倾向于使用客户端渲染,这是为什么?

我们来对比看一下服务端渲染与客户端渲染各自存在的优点与缺点。

服务端渲染 vs 客户端渲染

服务端渲染

优点
  • 首屏渲染快: 服务端可以生成页面内容完整的 HTML,客户端直接加载渲染,用户能更快看到内容。
  • SEO 友好: 页面内容完整的 HTML,搜索引擎更容易抓取和索引内容。
  • 低网络带宽设备的性能好: 服务端渲染能减轻网络带宽低、处理能力弱的设备的渲染负担,缩短加载和显示内容的时间。
缺点
  • 服务端压力大: 服务端需要接收请求生成相应的 HTML 页面内容,因此在高并发的情况下,会导致负载过重,影响响应速度和稳定性。
  • 交互性差: 不能动态更新页面内容。切换页面往往需要重新加载整个页面的资源,导致页面加载慢,影响用户的交互体验,并且难以实现流畅的切换效果。
  • 前后端不分离: 服务端要同时处理前端和后端的逻辑,不能做到分离,后期维护的难度较高。

客户端渲染

优点
  • 服务端压力小: 生成页面内容交给了客户端处理,服务端只负责提供数据,减轻了服务器的压力。
  • 交互性强: 能动态的更新页面内容,页面切换时不用重新加载整个页面的资源,加载速度快,而且很容易实现流畅的切换效果。
  • 前后端分离: 可以做到前后端分离,降低后期维护的难度。
缺点
  • 首屏渲染慢: 页面内容需要客户端加载执行完 JavaScript 后才能生成,因此首屏渲染速度较慢,尤其在网络环境差的情况下。
  • SEO 不友好: HTML 内容在客户端执行 JavaScript 后生成,低级的搜索引擎爬虫难以抓取页面内容。
  • JavaScript 依赖: 用户禁用了 JavaScript 或者加载失败,页面就无法正常显示。

因为,服务端渲染存在交互性差、维护复杂度高的问题难以解决,而客户端渲染的交互性更强、维护复杂度更低,再加上 React 等数据驱动框架的流行,所以客户端渲染的方案逐渐成为了主流。

但是,服务端渲染的优点也是显而易见的,那么有没有办法结合两者的优点,规避两者的缺点呢?

答案是:有。

这就是接下来我们要介绍的:同构渲染。

同构渲染

要理解同构渲染,关键在于理解同构的含义。

在软件开发,特别是 Web 开发中,同构表示同一套代码既在服务端运行,也在客户端运行,两者相互配合共同实现功能,并且要求在不同环境中能保持一致的行为和结果。

同构渲染,就是将服务端渲染与客户端渲染结合的一种方式。其核心思想是:服务端负责生成页面内容,客户端负责处理页面交互。

因此,在同构渲染中:

  1. 首次请求: 由服务端生成页面内容完整的 HTML,返回给客户端,以加快首屏渲染速度并提高 SEO 效果。
  2. 后续交互: 由客户端接管,通过执行 JavaScript 来管理后续的页面更新、事件响应等交互操作。

简单点理解就是,同构渲染中只有首屏是由服务端渲染,后续页面都是由客户端渲染。这样就既能保证首屏渲染速度,又能保证交互性。

但问题来了,服务端返回首屏后,客户端要如何才能接管后续的页面渲染呢?

这里就要引入一个新的概念:水合(Hydration)

如何理解水合?

你可以想一下,服务器返回的 HTML 内容像是一个脱水的三体人(服务端在做渲染时,真的会有脱水这个步骤),没有生机不具备交互的能力。浏览器接收到后,通过使用 JavaScript 为其浸泡,使其恢复生机和交互的能力,这个恢复的过程就叫水合。

水合完成后,客户端就完全接管了页面,可以进行后续的渲染和交互了。

理解水合后,我们再来看一下同构渲染的具体工作流程。

同构渲染的工作流程

同构渲染通常会经历以下几个流程:

  1. 客户端访问 URL,发送请求到对应的服务器。
  2. 服务端接收到请求后,根据请求的 URL 匹配相应的处理函数。
  3. 服务端从数据库或其他外部 API 获取必要的数据,用于后续生成 HTML 页面内容。
  4. 服务端生成 HTML 页面,并将其返回给客户端。
  5. 客户端接收到 HTML 后,开始解析渲染页面内容。
  6. 客户端加载执行 JavaScript,水合页面。
  7. 客户端接管页面,处理后续的交互。

我们可以通过一张动态图来理解这一过程:

ssr

可以看到,在初次请求完成后,客户端就已经能渲染出页面内容了,不过此时的页面还不能进行交互,需要等到客户端加载执行 JavaScript 完成水合后,才能进行交互。

同构渲染与服务端渲染

如今我们在谈论 Web 应用的服务端渲染方案时,一般都是指同构渲染。而早期的服务端渲染,我们称为传统服务端渲染。

与传统服务端渲染不同的是,同构渲染的服务端一般只负责生成 HTML 页面内容,逻辑部分会交给其他服务去处理。

同构渲染存在的问题

虽然同构渲染结合了服务端与客户端渲染的优点,也规避了一些缺点,但是自身仍然存在一些要解决的问题:

  • 服务器压力大: 很遗憾同构渲染并没能规避这个问题,即使服务端只负责生成 HTML 页面内容,但在高并发的情况下,依然会负载过重。
  • 交互延迟: 虽然能更快的看到页面内容,但需要等待客户端完成水合后才能进行交互,因此交互上可能存在延迟。
  • 学习成本高: 同构渲染需要开发人员同时掌握前端与后端的知识,学习成本较高。

以上就是同构渲染的核心内容。在下一篇文章中,我们将通过具体代码来实现一个同构渲染,帮助我们深入的去了解它背后的工作原理。