CSR -> SSR -> 流式SSR

CSR -> SSR -> Stream SSR

简单介绍了前端页面开发中的CSR进化到SSR,再进化到流式SSR的过程。

1. CSR

使用create-react-app脚手架可以方便的创建一个React工程。

 npx create-react-app my-ssr-app --template typescript

创建后安装依赖后直接运行即可看到效果

npm install
npm run start

create-react-app 脚手架帮我们完成了webpack的配置,将很多工程问题隐藏起来了。

方便使用的同时,也带来了限制,如果想要深度定制webpack打包配置,可以使用

npx react-scripts eject

将webpack配置暴露出来,注意该操作是不可逆的,执行后会将所有的工程配置生成到当前工程目录。

如下图所示:

之后就可以按需修改webpack配置了。

同时可以发现,package.json 中scripts部分有如下变化:

-    "start:client": "react-scripts start",
-    "build:client": "react-scripts build",
+    "start:client": "node scripts/start.js",
+    "build:client": "node scripts/build.js",

思考:react-scripts start 是怎么运行的?

node_modules的.bin目录中有可以找到react-scripts文件,内容如下:

#!/usr/bin/env node
/**
 * Copyright (c) 2015-present, Facebook, Inc.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

'use strict';

...

可以看到第一行指明了使用node运行该脚本,感兴趣的了解看看。

2. SSR

2.1 为什么需要SSR?

传统的CSR页面加载流程:用户请求网页,返回一个模板HTML,渲染HTML时遇到js标签,开始加载js文件,js文件加载完成后开始执行js逻辑,最终完成页面内容的渲染。

可以看到从请求到页面渲染出真实内容最少要经过两次请求(html + js各一次),之后执行js渲染内容。

这样的流程有啥缺点?

  • SEO不友好(因为返回的只有框架HTML)

有没有好的方案可以解决以上问题呢?

核心思路:服务端能直接给出渲染后的html

一些异构的服务端,会使用模版语法直接生成最终的html,搭配JS实现页面逻辑。

现在前段页面开发大部分使用了React、Vue等框架,要是在服务端能执行render就好了,幸运的是框架提供了renderToString的方法。伴随而来另一个问题,服务端直接下发了渲染后的标签,事件处理逻辑怎么和这些下发的标签绑定呢?

框架设计者也考虑到了这个问题,并给出了配套的解决方案:hydrateRoot

2.2 SSR 改造

为了方便验证,可以在当前工程中基于Express框架实现同构的Server,具体做法如下:

根目录下新建一个Server目录,新建一个server.tsx

// server/index.tsx
import path from 'path';
import express from 'express';
import { renderToString, renderToPipeableStream } from 'react-dom/server';
import App from "../src/App";
import {html} from "./html";
import * as fs from "node:fs";

const assetMap: any = JSON.parse(fs.readFileSync(path.join(__dirname, '../build', 'asset-manifest.json'), 'utf8'));
console.log('assetMap: ', typeof assetMap, assetMap,);

const app = express();
app.use('/static', express.static(path.join(__dirname, '../build/static')));

const htmlContent = html();

app.get('/', async (req: any, res: any) =>  {
  const appRenderStr = renderToString(<App/>);
  console.log('**** App:', appRenderStr);

  console.log('**** html:', htmlContent);
  res.send(`${htmlContent.headPart}${appRenderStr}${htmlContent.tailPart}`);
});

app.listen(3333, () => console.log('✅ Server started'));

可以看到server.ts 里面用到了React tsx的代码,所以肯定是需要webpack打包之后才能运行的。根目录下添加 webpack.server.config.js文件:

const path = require('path');
const nodeExternals = require('webpack-node-externals');
const webpack = require('webpack');

module.exports = {
    mode: 'development', // 明确设置模式
    target: 'node',
    externals: [nodeExternals()],
    entry: './server/index.tsx',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'server.js',
        libraryTarget: 'commonjs2',
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: [
                    {
                        loader: 'ts-loader',
                        options: {
                            // 关键配置:强制生成输出
                            compilerOptions: {
                                noEmit: false,
                                declaration: false
                            }
                        }
                    }
                ],
                exclude: /node_modules/,
            },
            {
                test: /\.svg$/,
                use: {
                    loader: 'file-loader',
                    options: {
                        emitFile: false,
                        publicPath: '/static/media/',
                        name: '[name].[contenthash].[ext]',
                    }
                }
            },
            {
                test: /\.css$/,
                use: ['style-loader', 'css-loader']
            }
        ]
    },
    plugins: [
        new webpack.DefinePlugin({
            'process.env.ASSET_MANIFEST': JSON.stringify(
                require('./build/asset-manifest.json')
            )
        })
    ],
    resolve: {
        extensions: ['.tsx', '.ts', '.js'],
    },
};

最后在package.json 的scripts中加上以下配置即可:

"build:server": "webpack --config webpack.server.config.js",
"start:server": "npm run build:server && node ./dist/server.js",

至此服务端的代码写完了,客户端的代码也要做一点儿改动,从原来的render改成hydrateRoot,如下:

// const root = ReactDOM.createRoot(
//   document.getElementById('root') as HTMLElement
// );
// root.render(
//   <React.StrictMode>
//     <App />
//   </React.StrictMode>
// );

import { hydrateRoot } from 'react-dom/client';
hydrateRoot(
    document.getElementById('root') as HTMLElement,
    <App />
);

改造完成,直接运行

npm run build:client
npm run start:server

浏览器中访问 http://127.0.0.1:3333/ 后,检查可以发现服务端直接返回了渲染后的html,如下图

SSR渲染注意事项:

  1. 因为renderToString是在服务端执行,没有浏览器环境,所以获取不到window等BOM变量。因此需要React组件中访问此类对象时需要判定当前运行环境。
  2. useEffect逻辑不会执行,所以渲染出来的会是初始值。

hydrateRoot 原理:

接管服务端SSR渲染返回的DOM节点的后续管理(事件处理、后续组件刷新等)。

3. Stream SSR

有了SSR了,体验感觉已经比CSR好很多了,还要流式SSR干什么?

上面SSR部分的demo没有依赖远程数据,如果页面渲染强依赖远程数据,那么需要服务端渲染的时候也需要等待数据拉取完成后才能开始渲染。这势必会导致主文档请求响应时间增加(ttfb过长),用户看到较长的白屏时间,流失SSR则是专门解决这个问题的。

流失SSR的思路:服务端返回数据时使用HTTP/1.1 的分块传输编码(Chunked Transfer Encoding)能力,设置response header为transfer-encoding:

chunked,分块传输,先返回无需依赖数据的部分,等拿到数据后再返回其余部分。现代浏览器首页HTML就可以直接开始渲染(无需等待)所有html加载完成。

使用流失SSR可以显著的降低TTFB,让用户无需经历长时间的白屏,第一时间看到页面的部分内容,之后页面逐渐加载完成。

React框架 18以后的版本,结合 Suspense 组件和 renderToPipeableStream API,即可实现按需延迟加载某些组件,并优先渲染关键内容。

具体改造如下:

import React, {Suspense} from 'react';
import logo from './logo.svg';
import './App.css';
import Posts from "./components/Posts";

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <Suspense fallback={<div>Loading...</div>}>
          <Posts />
        </Suspense>
      </header>
    </div>
  );
}

export default App;

export function fetchPosts() {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(
                { id: 0, text: 'Post 1' }
            )
        }, 4000);
    })
}

export default function Posts (props: any) {
    const content: any = use(fetchPosts());

    return (
        <div>
            Post Content 2:
            <div>
                {JSON.stringify(content)}
            </div>
        </div>
    )
}

Suspense 组件限制:只有启用了Suspense的数据源才会激活Suspense数据源,React框架中Suspense数据源包括:

  1. 使用 lazy的延迟加载组件。
  2. 使用 use从Promise中读取数据。

上述Demo中演示的是使用use加载Pomise数据源的方式。

适用流式SSR的常见场景:

  1. 内容密集型页面
    • 长页面(如新闻、社交信息流)可分块加载,优先展示首屏内容。
  2. 动态数据依赖多
    • 不同模块依赖多个 API,流式 SSR 可避免最慢的 API 拖累整体渲染。
  3. 高并发或资源敏感环境
    • 减少服务器内存占用,提升吞吐量。

4. 思考&参考资料

4.1 思考

为什么同样都要执行React框架渲染出来html,为什么编译出来的server.js 会比 main.js(未采取分包)要小?

检查构建产物发现,server.js 大小:37kb,而main.js大小:186kb,同样都要使用React框架,为什么相差那么多,通过react-script eject 之后对比client和server的webpack配置找到了答案,如下所示:

// 服务端配置示例(webpack.server.js)
{
  target: 'node',
  externals: [nodeExternals()], // 排除 node_modules
  entry: './src/server.js',
  
  ...
}

// 客户端配置示例(webpack.client.js)
{
  target: 'web',
  entry: './src/client.js',
  
  ...
}

client.js 运行环境复杂,因此它必须包含所有浏览器运行所需的代码(React、ReactDOM、Polyfill、业务逻辑等)。

server.js 运行环境可控,通过执行nodeExternals将React等框架作为外部依赖(无需打入server.js),因此体积更小。实际运行时,服务端代码会直接使用项目的 node_modules 中的依赖。

4.2 参考资料

posted on 2025-03-23 16:02  一片-枫叶  阅读(155)  评论(0)    收藏  举报