浅谈ssr,ssg, csr,rsc

浅谈 ssr,ssg,csr,rsc

前言

在文章正式开始之前我想澄清一个概念,我们所写的程序有开发,构建,生产等阶段,所谓的 ssr,ssg, csr,rsc,只是在这些特定的阶段做了特定的事情,进而形成的一种开发模式,澄清了这个概念,让我们正式进入正文

csr

csr(client side render),客户端渲染,它是前后端分离的经典产物,通过 spa 开发,实现在客户端的局部数据请求,让用户告别了点击刷新整页网页的时代,不仅优化了用户体验,同时对服务器的压力也更小,但伴随的是 seo 效果差,和首屏渲染速度过慢的问题

ssr

ssr(server side render),服务端渲染,它的出现解决了 seo 优化和首屏渲染过慢的问题,这里我通过 vitereact 实现 ssr 的过程简单讲讲为什么它可以解决上述问题。

- index.html
- server.js # main application server
- src/
  - main.js          # 导出环境无关的(通用的)应用代码
  - entry-client.js  # 将应用挂载到一个 DOM 元素上
  - entry-server.js  # 使用某框架的 SSR API 渲染该应用

这是 vite 官方实现的 ssr 的文件目录,我们 ssr 的主要逻辑其实都是在 server.js 中。

// entry-client.ts
import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App";
import { fetchData } from "./entry-server";
import axios from "axios";

const rootDOM = document.getElementById("root");

if (rootDOM) {
  hydrateRoot(
    rootDOM,
    <React.StrictMode>
      <App data={data} />
    </React.StrictMode>
  );
} else {
  throw new Error("hydrate error");
}
// entry-server.ts
import App from "./App";
import "./index.css";

export async function fetchData() {
  return {
    user: "xxx",
  };
}

export function ServerEntry(props: any) {
  return <App data={props.data} />;
}
// server.ts

async function createSsrMiddleware(app: Express): Promise<RequestHandler> {

  ...

  return async (req, res, next) => {
    // SSR 的逻辑
    // 1. 加载服务端入口模块
    // 2. 数据预取
    // 3. 「核心」渲染组件
    // 4. 拼接 HTML,返回响应
    try {

      ...

      const { ServerEntry, fetchData } = await loadSsrEntryModule(vite);
      const data = await fetchData();

      // 获取组件的html字符串
      const appHtml = renderToString(
        React.createElement(ServerEntry, { data })
      );

      // 解析template.html文件
      const templatePath = resolveTemplatePath();
      let template = await memoryFsRead(templatePath);

      const html = template
        .replace("<!-- SSR_APP -->", appHtml)
        // 注入数据标签,用于客户端 hydrate
        .replace(
          "<!-- SSR_DATA -->",
          `<script>window.__SSR_DATA__=${JSON.stringify(data)}</script>`
        );
      res.status(200).setHeader("Content-Type", "text/html").end(html);
    } catch (e: any) {
      vite?.ssrFixStacktrace(e);
      console.error(e);
      res.status(500).end(e.message);
    }
  };
}

async function createServer() {
  const app = express();
  // 加入 Vite SSR 中间件
  app.use(await createSsrMiddleware(app));

  app.listen(3000, () => {
    console.log("Node 服务器已启动~");
    console.log("http://localhost:3000");
  });

}

createServer();

这里就是对于 ssr 的一个简单实现,中间只包含了重要代码,具体实现的话可以参考源码,我们不难发现,其实 ssr 的实现就是在生产阶段,通过中间件来对我们返回到客户端端代码进行处理,动态生成 html 框架,同时将要水合的 js 代码发送到客户端进行水合(因为有些 js 运行在浏览器的代码是不能在 nodejs 的环境中运行的,所以需要发送到客户端水合),当然如果服务器出现问题,也可以在中间件中进行 csr 降级,这里就不展开讨论,其实对于 ssr 来说,它解决首屏渲染过慢的方式其实是将渲染压力放到了服务端,通过在服务端提前组装 html 来减少客户端通过 js 生成 dom 节点的压力,同时这样生成的 html 结构也有助于 seo 优化

ssg

ssg(static site generation),静态站点生成,它总结一下其实就是构建阶段的 ssr,它生成 html 的时机是在 build 打包阶段,进行 html 的拼接,这样能减少服务器的渲染压力,让服务器有更快的响应速度,但同时 ssg 开发的网站一般都是静态的,不能有大范围的页面更改,所以 ssg 一般用于博客网站,以下是 ssg 构建的核心代码

export async function build(root: string = process.cwd(), config: SiteConfig) {
  // 1. bundle - client 端 + server 端
  const [clientBundle] = await bundle(root, config);
  // 2. 引入 server-entry 模块
  const serverEntryPath = join(root, ".temp", "ssr-entry.js");
  const { render, routes } = await import(serverEntryPath);
  // 3. 服务端渲染,产出 HTML
  try {
    await renderPages(render, routes, root, clientBundle);
  } catch (e) {
    console.log("Render page error.\n", e);
  }
}

其实对于 ssg 来说,开发的重点主要是插件,通过对插件进行开发,对 build 阶段的各个模块进行定制化处理,来提 ssg 的性能,和功能的扩展,同样,这里的具体代码也能参考源码,在 ssg 框架中,有一个相对来说比较重要的概念,就是 island 架构,我们通过对在客户端需要进行水合的组件进行标记,在打包的阶段单独打包出来 bundle,在向客户端发送 js 代码的时候可以分包处理,达到渐进式增强的效果,而不是在客户端进行全量水合,不仅可以细粒度的控制水合,还可以减少 TTI 的时间,那么,ssr 有没有类似的升级呢,答案是有的,虽然没有 island 架构这样性能这么高,但是对比原始的 ssr 有了不少的提升。

rsc

rsc(react server component),服务端组件,这是 next.js 提出来的一个重要理念,相对于传统的 ssrnext.js 将组件区分为服务端组件和客户端组件,对于服务端组件来说,是不需要进行水合的组件,而对于客户端组件来说,则是需要水合的,next.js 会将需要水合的 js 流式传输,渐进式增强的水合,以下是对客户端发送请求之后客户端返回的部分代码

1d:["$","div",null,{"className":"note-preview","children":["$","div",null,{"className":"text-with-markdown","dangerouslySetInnerHTML":{"__html":"<p>et iusto sed quo iure</p>\n"}},"$1e"]},"$1e"]

这个就是 rsc payload 的一部分,对于 rsc 来说,服务端组件是在服务端直接生成的,所以在 payload 中直接包含,而客户端组件则是通过一个占位符"$"来表示,等到了客户端再进行水合,同时这里的 rsc payloadTransfer-Encodingchunked,通过分片上传来渐进式的水合客户端组件,细粒度的控制 js 水合的逻辑,当然,对于 next.js 来说,向服务端发送请求的时候还可以带上 next-router-state-tree 等参数,这样 rsc payload 可以记住之前的状态,达到状态保存的效果。

再谈 ssg

通过对比,我们不难发现,island 架构和 rsc 都是强调只为交互部分加载 js 文件,都是渐进式增强的水合,不同的是 rsc 是将客户端逻辑和服务端逻辑进行分离达到部分水合的效果,而 island 架构则是在框架层面通过特殊标识来控制水合效果

小结

好了,说了这么多,其实最重要的不是哪种开发模式最好,而是要看具体的使用场景,面对不同的业务使用不同的开发模式,甚至可以混合使用,例如 rsc 何尝不是 ssrcsr 的结合呢,我们在开发过程中也要有自己独立的思考,敢于创新,才是正解,而不是一味的遵循某种开发模式,一头走到黑。 😉