聊聊服务端渲染(SSR)技术的前世今生

471 阅读7分钟

引言

最近在工作中,我发现一些同事不太了解前端的服务端渲染(SSR)技术。 作为一个接触开发过多个服务端渲染项目的开发者,我决定写这篇文章,跟大家介绍下服务端渲染技术的前世今生。 希望通过这篇文章,能帮助没有接触过的同学理解什么是 SSR、为什么要用 SSR,以及在实际项目中如何选择合适的技术方案。

1. 最早的服务端渲染 - JSP 时代

说到服务端渲染,我们得先聊聊最早的 JSP/PHP 时代。那时候还没有前后端分离的概念,前端代码直接写在后端页面里面。先看看它的工作流程:

sequenceDiagram
    participant Client as 浏览器
    participant Server as 服务器(JSP)
    participant Database as 数据库
    Client->>Server: 发送请求
    Server->>Database: 查询数据
    Database-->>Server: 返回数据
    Server->>Server: 生成 HTML
    Server-->>Client: 返回完整 HTML

那时候的代码是这样的:

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page import="java.sql.*" %>
<%@ taglib uri="<http://java.sun.com/jsp/jstl/core>" prefix="c" %>

<!DOCTYPE html>
<html>
<head>
    <title>商品列表</title>
</head>
<body>
    <h1>商品列表</h1>

    <%
        // 直接在页面里写数据库查询
        Class.forName("com.mysql.jdbc.Driver");
        Connection conn = DriverManager.getConnection(
            "jdbc:mysql://localhost:3306/store", "user", "password");

        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery("SELECT * FROM products");

        request.setAttribute("products", rs);
    %>

    <ul>
        <c:forEach var="product" items="${products}">
            <li>
                ${product.name} - ${product.price}元
                <form action="addToCart.jsp" method="POST">
                    <input type="hidden" name="productId" value="${product.id}">
                    <input type="submit" value="加入购物车">
                </form>
            </li>
        </c:forEach>
    </ul>
</body>
</html>

看到这段代码,有写过 JSP 的同学可能会感到亲切。没错,那时候的开发就是这样,HTML、Java 代码、数据库查询都混在一起。这种开发方式的好处是:

  1. 首屏速度快,用户打开页面就能看到内容
  2. 对搜索引擎特别友好,因为返回的是完整的 HTML
  3. 不需要写额外的前端代码,后端开发能直接上手

但问题也很明显:

  1. 前后端代码严重耦合,改个样式可能要找后端同学
  2. 每次点击链接或提交表单,整个页面都要刷新
  3. 开发效率低,前后端同学经常要改同一个文件
  4. 代码难以维护,一个文件要身兼数职

这种开发方式随着前端技术的发展,逐渐显露出了它的局限性。特别是在 Ajax 技术出现后,前端开发开始走向了一个新的时代。

2. 单页应用时代 - 前后端分离来了

随着 Ajax 技术的普及,以及 React、Vue 这些前端框架的出现,我们的开发方式发生了翻天覆地的变化。前端再也不用依赖后端模板,可以独立写业务逻辑了。我们先看看单页应用的工作流程:

sequenceDiagram
    participant Client as 浏览器
    participant Server as 服务器
    participant API as API 服务
    Client->>Server: 请求 HTML/JS/CSS
    Server-->>Client: 返回静态资源
    Client->>Client: 执行 JS,初始化应用
    Client->>API: AJAX 请求数据
    API-->>Client: 返回 JSON 数据
    Client->>Client: 更新页面内容
    Note over Client: 路由变化
    Client->>API: 请求新页面数据
    API-->>Client: 返回新数据
    Client->>Client: 更新视图

让我们看看现在最常见的单页应用是怎么写的:

// React 组件示例
function ProductList() {
  const [products, setProducts] = useState([]);

  // 页面加载时获取商品数据
  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => setProducts(data));
  }, []);

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          {product.name} - ¥{product.price}
          <button onClick={() => addToCart(product.id)}>
            加入购物车
          </button>
        </li>
      ))}
    </ul>
  );
}

对比一下之前的 JSP 代码,是不是清爽多了?前端只需要关注页面展示和交互,数据通过调用 API 接口获取。这种开发方式我们称之为 SPA(单页应用)或者 CSR(客户端渲染)。

这种模式最大的特点是前后端完全分离:

  • 前端写界面和交互,通过 API 获取数据
  • 后端专注写接口,不用管页面长啥样
  • 部署时前端静态文件和后端接口服务可以分开部署

用过 SPA 的同学都知道它的好处:

  1. 用户体验好,点击按钮直接更新数据,不用刷新整个页面
  2. 前后端并行开发,提高效率
  3. 代码结构清晰,方便维护
  4. 一套接口可以供多端使用(网页、手机 App 等)

但实际用下来,我们也发现了一些问题:

  1. 首屏加载速度慢,因为要等 JS 加载完并执行才能看到页面
  2. 搜索引擎不友好,爬虫抓取不到实际内容
  3. 如果用户网络差或者用的是低配手机,体验会比较糟糕

特别是第一个问题 —— 首屏加载慢,这个在电商项目里特别明显。用户打开页面要等好几秒才能看到商品列表,这显然是不能接受的。

3. 现代 SSR 的崛起

为了解决 SPA 的这些问题,现代服务端渲染技术应运而生。它的核心思想是:既然客户端渲染和服务端渲染各有优势,那么能不能把两者结合起来?

于是就有了现在的解决方案:

  1. 首次访问时使用服务端渲染,让用户快速看到内容
  2. 后续交互使用客户端渲染,保证体验流畅

让我们看看它是怎么工作的:

sequenceDiagram
    participant Client as 浏览器
    participant Server as SSR 服务器
    participant API as 后端 API
    Client->>Server: 访问页面
    Server->>API: 获取数据
    API-->>Server: 返回数据
    Server->>Server: 生成 HTML
    Server-->>Client: 返回 HTML 和 JS
    Note over Client: 页面立即显示
    Client->>Client: 加载 JS(水合过程)
    Note over Client: 页面可交互

这种模式引入了一个重要的概念 —— "水合"(Hydration)。它的意思是:

  1. 服务端生成 HTML 发给浏览器(就像给一具躯体)
  2. 浏览器加载并执行 JS(给躯体注入灵魂)
  3. 页面变得可交互(躯体活了过来)

举个简单的例子:

// 一个通用的 SSR 组件写法
function ProductList({ products }) {
  // 这段代码既能在服务端运行,也能在客户端运行
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          {product.name} - ¥{product.price}
          <button onClick={() => addToCart(product.id)}>
            加入购物车
          </button>
        </li>
      ))}
    </ul>
  );
}

现代 SSR 的优点是:

  1. 首屏渲染快,用户体验好
  2. SEO 友好,搜索引擎能抓取到内容
  3. 后续交互流畅,和 SPA 一样
  4. 可以同时兼顾性能和体验

但它也有一些缺点:

  1. 开发复杂度高
    • 要考虑服务端和客户端的代码兼容性
    • 有些浏览器 API 在服务端不能用
    • 调试起来比较麻烦
  2. 部署相对麻烦
    • 需要一个 Node.js 服务器来跑 SSR
    • 要考虑服务器缓存策略
    • 运维成本比纯静态文件高
  3. 服务器压力大
    • 每个页面请求都要在服务器执行渲染
    • 并发量大时需要考虑扩容
  4. 学习成本高
    • 要了解更多的概念(比如 hydration)
    • 框架规范比较多,限制比较多

4. 怎么选择适合自己的方案?

在实际项目中到底该怎么选择呢?我总结了一下经验:

适合用传统服务端渲染(JSP/PHP)的场景:

  • 小项目,功能简单,没什么交互
  • 团队以后端为主,不太懂前端技术
  • 对性能要求不高的内部系统

适合用 SPA 的场景:

  • 交互复杂的后台管理系统
  • 不需要考虑 SEO
  • 用户主要是电脑访问
  • 对首屏加载速度要求不高

适合用现代 SSR 的场景:

  • 电商网站、新闻网站等需要 SEO 的项目
  • 对首屏加载速度要求高的网站
  • 有一定的前端开发能力
  • 用户设备情况复杂,需要照顾低端设备

5. 未来的发展趋势

说到 SSR 的未来,最近几年又出现了一些新的概念和技术:

  1. Islands 架构 这是 Astro 框架带火的概念,它的想法是:页面中不是所有部分都需要交互,那为什么要给所有内容都加上 JavaScript 呢?它把页面分成:
  • 静态区域:纯 HTML,不需要 JS
  • 交互区域:需要 JS 的部分

这样可以大大减少加载的 JS 量,提升性能。

  1. 边缘渲染 随着云服务的发展,现在可以在离用户最近的节点进行渲染,这样可以大大减少响应时间。比如 Vercel 的边缘渲染就是这个思路。
  2. 混合渲染 不同页面用不同的渲染方式:
  • 经常变化的页面用 SSR
  • 较少变化的页面用静态生成
  • 纯静态的内容直接部署 HTML

结语

回顾服务端渲染技术的发展历程,我们可以看到它是在不断解决实际问题中演进的。从最早的 JSP 到现代 SSR,每种技术都有其适用场景,关键是要结合自己的项目需求和团队能力来选择。

随着前端技术的发展,我相信会有更多新的解决方案出现。作为开发者,我们需要:

  • 了解不同技术方案的优缺点
  • 保持学习新技术的热情
  • 在实践中不断总结经验