浅谈SSR服务端渲染——从定义到基于React的基本实现

lxf2023-05-05 23:05:02

大家好,我是忆白,最近在实习,很久没写文章了。上周leader说,从下周开始,以后每个月至少有一次周会要做一个分享,恰好这是八月的最后一周,又恰好这周轮到我主持周会,属实还是有点惨的哈哈。于是把最近了解学习的SSR服务端渲染做了一下整理。本文从服务端渲染的定义由来,到简单实现一个基于React的服务端渲染小Demo。

一、服务端渲染的定义

先回顾一下页面的渲染流程:

  1. 浏览器通过请求得到一个HTML文本
  2. 渲染进程解析HTML文本,构建DOM树
  3. 解析HTML的同时,如果遇到内联样式或者样式脚本,则下载并构建样式规则(stytle rules),若遇到JavaScript脚本,则会下载执行脚本。
  4. DOM树和样式规则构建完成之后,渲染进程将两者合并成渲染树(render tree)
  5. 渲染进程开始对渲染树进行布局,生成布局树(layout tree)
  6. 渲染进程对布局树进行绘制,生成绘制记录
  7. 渲染进程的对布局树进行分层,分别栅格化每一层,并得到合成帧
  8. 渲染进程将合成帧信息发送给GPU进程显示到页面中

浅谈SSR服务端渲染——从定义到基于React的基本实现

可以看到,页面的渲染其实就是浏览器将HTML文本转化为页面帧的过程。而如今我们大部分WEB应用都是使用 JavaScript 框架(Vue、React、Angular)进行页面渲染的,也就是说,在执行 JavaScript 脚本的时候,HTML页面已经开始解析并且构建DOM树了,JavaScript 脚本只是动态的改变 DOM 树的结构,使得页面成为希望成为的样子,这种渲染方式叫动态渲染,也可以叫客户端渲染(client side rende)。

来个直观点的例子:我们使用React脚手架创建一个项目,然后npm start运行起来,这时候可以展示一个基本的React项目的页面,此时我们查看网页代码,可以看到body标签中,除了一个对禁用script的浏览器做兼容的noscript标签,就只有一个id为root的div了,那么这些页面展现的数据是怎么来的呢,其实就是上面那个script标签引入的js文件,执行之后渲染出来的。这就是客户端渲染。

浅谈SSR服务端渲染——从定义到基于React的基本实现

什么是服务端渲染(server side render)?顾名思义,服务端渲染就是在浏览器请求页面URL的时候,服务端将我们需要的HTML文本组装好,并返回给浏览器,这个HTML文本被浏览器解析之后,不需要经过 JavaScript 脚本的执行,即可直接构建出希望的 DOM 树并展示到页面中。这个服务端组装HTML的过程,叫做服务端渲染。

浅谈SSR服务端渲染——从定义到基于React的基本实现

再来一个直观点的例子,我们起一个express服务,直接返回一个完整的html模版的字符串,如下:

var express = require("express");
var app = express();
​
app.get("/", (req, res) => {
  res.send(
    `
   <html>
     <head>
       <title>hello</title>
     </head>
     <body>
       <h1>hello</h1>
       <p>world</p>
     </body>
   </html>
 `
  );
});
​
app.listen(3001, () => {
  console.log("listen:3001");
});

然后我们在浏览器访问localhost:3001,可以看到页面显示了hello word,此时我们右键查看源代码,可以看到hello和world是直接在HTML源代码中存在的,这就是一个最基本的服务端渲染。

浅谈SSR服务端渲染——从定义到基于React的基本实现

二、服务端渲染的由来

Web1.0

在没有AJAX的时候,也就是web1.0时代,几乎所有应用都是服务端渲染(此时服务器渲染非现在的服务器渲染),那个时候的页面渲染大概是这样的,浏览器请求页面URL,然后服务器接收到请求之后,到数据库查询数据,将数据丢到后端的组件模板(php、asp、jsp等)中,并渲染成HTML片段,接着服务器在组装这些HTML片段,组成一个完整的HTML,最后返回给浏览器,这个时候,浏览器已经拿到了一个完整的被服务器动态组装出来的HTML文本,然后将HTML渲染到页面中,过程没有任何JavaScript代码的参与。

浅谈SSR服务端渲染——从定义到基于React的基本实现

客户端渲染

在WEB1.0时代,服务端渲染看起来是一个当时的最好的渲染方式,但是随着业务的日益复杂和后续AJAX的出现,也渐渐开始暴露出了WEB1.0服务器渲染的缺点。

  • 每次更新页面的一小的模块,都需要重新请求一次页面,重新查一次数据库,重新组装一次HTML
  • 前端JavaScript代码和后端(jsp、php、jsp)代码混杂在一起,使得日益复杂的WEB应用难以维护

而且那个时候,根本就没有前端工程师这一职位,前端js的活一般都由后端同学 jQuery 一把梭。但是随着前端页面渐渐地复杂了之后,后端开始发现js好麻烦,虽然很简单,但是坑太多了,于是让公司招聘了一些专门写js的人,也就是前端,这个时候,前后端的鄙视链就出现了,后端鄙视前端,因为后端觉得js太简单,无非就是写写页面的特效(JS),切切图(CSS),根本算不上是真正的程序员。

随之 nodejs 的出现,前端看到了翻身的契机,为了摆脱后端的指指点点,前端开启了一场前后端分离的运动,希望可以脱离后端独立发展。前后端分离,表面上看上去是代码分离,实际上是为了前后端人员分离,也就是前后端分家,前端不再归属于后端团队。

前后端分离之后,网页开始被当成了独立的应用程序(SPA,Single Page Application),前端团队接管了所有页面渲染的事,后端团队只负责提供所有数据查询与处理的API,大体流程是这样的:首先浏览器请求URL,前端服务器直接返回一个空的静态HTML文件(不需要任何查数据库和模板组装),这个HTML文件中加载了很多渲染页面需要的 JavaScript 脚本和 CSS 样式表,浏览器拿到 HTML 文件后开始加载脚本和样式表,并且执行脚本,这个时候脚本请求后端服务提供的API,获取数据,获取完成后将数据通过JavaScript脚本动态的将数据渲染到页面中,完成页面显示。

浅谈SSR服务端渲染——从定义到基于React的基本实现

这一个前后端分离的渲染模式,也就是客户端渲染(CSR)。

服务端渲染

随着单页应用(SPA)的发展,程序员们渐渐发现 SEO(Search Engine Optimazition,即搜索引擎优化)出了问题,而且随着应用的复杂化,JavaScript 脚本也不断的臃肿起来,使得首屏渲染相比于 Web1.0时候的服务端渲染,也慢了不少。

自己选的路,跪着也要走下去。于是前端团队选择了使用 nodejs 在服务器进行页面的渲染,进而再次出现了服务端渲染。大体流程与客户端渲染有些相似,首先是浏览器请求URL,前端服务器接收到URL请求之后,根据不同的URL,前端服务器向后端服务器请求数据,请求完成后,前端服务器会组装一个携带了具体数据的HTML文本,并且返回给浏览器,浏览器得到HTML之后开始渲染页面,同时,浏览器加载并执行 JavaScript 脚本,给页面上的元素绑定事件,让页面变得可交互,当用户与浏览器页面进行交互,如跳转到下一个页面时,浏览器会执行 JavaScript 脚本,向后端服务器请求数据,获取完数据之后再次执行 JavaScript 代码动态渲染页面。

浅谈SSR服务端渲染——从定义到基于React的基本实现

三、服务端渲染的利弊

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

利于SEO

有利于SEO,其实就是有利于爬虫来爬你的页面,然后在别人使用搜索引擎搜索相关的内容时,你的网页排行能靠得更前,这样你的流量就有越高。那为什么服务端渲染更利于爬虫爬你的页面呢?其实,爬虫也分低级爬虫和高级爬虫。

  • 低级爬虫:只请求URL,URL返回的HTML是什么内容就爬什么内容。
  • 高级爬虫:请求URL,加载并执行JavaScript脚本渲染页面,爬JavaScript渲染后的内容。

也就是说,低级爬虫对客户端渲染的页面来说,简直无能为力,因为返回的HTML是一个空壳,它需要执行 JavaScript 脚本之后才会渲染真正的页面。而目前像百度、谷歌、微软等公司,有一部分年代老旧的爬虫还属于低级爬虫,使用服务端渲染,对这些低级爬虫更加友好一些。

白屏时间更短

相对于客户端渲染,服务端渲染在浏览器请求URL之后已经得到了一个带有数据的HTML文本,浏览器只需要解析HTML,直接构建DOM树就可以。而客户端渲染,需要先得到一个空的HTML页面,这个时候页面已经进入白屏,之后还需要经过加载并执行 JavaScript、请求后端服务器获取数据、JavaScript 渲染页面几个过程才可以看到最后的页面。特别是在复杂应用中,由于需要加载 JavaScript 脚本,越是复杂的应用,需要加载的 JavaScript 脚本就越多、越大,这会导致应用的首屏加载时间非常长,进而降低了体验感。

浅谈SSR服务端渲染——从定义到基于React的基本实现

服务端渲染缺点

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

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

所以在使用服务端渲染SSR之前,需要开发者考虑投入产出比,比如大部分应用系统都不需要SEO,而且首屏时间并没有非常的慢,如果使用SSR反而小题大做了。

四、实践React服务端渲染

1. 简单组件服务端渲染

之前起的express服务返回的只是一个普通的html字符串,但我们讨论的是如何进行React的服务端渲染,那么怎么做呢? 首先写一个简单的React组件:

import React from 'react';
const Home = () => {
  return (
    <div>
      我是主页
    </div>
  )
}
export default Home

现在的任务就是将它转换为html代码返回给浏览器。 总所周知,JSX中的标签其实是基于虚拟DOM的,最终要通过一定的方法将其转换为真实DOM。虚拟DOM也就是JS对象,可以看出整个服务端的渲染流程就是通过虚拟DOM的编译来完成的,因此虚拟DOM巨大的表达力也可见一斑了。

而react-dom这个库中刚好实现了编译虚拟DOM的方法。做法如下:

import React from "react"; // import
import { renderToString } from "react-dom/server";
import Home from "./pages/Home";
​
const express = require("express");
const app = express();
app.get("*", (req, res) => {
  const content = renderToString(<Home />);
  res.writeHead(200, {
    'content-type': 'text/html;charset=utf8',
  });
  res.send(
    `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id="root">${content}</div>
      </body>
    </html>
   `
  );
});
​
app.listen(3000, () => {
  console.log("listen:3000");
});

启动express服务,再浏览器上打开对应端口,页面显示出"我是主页"。 到此,就初步实现了一个React组件是服务端渲染。 当然,这只是一个非常简陋的SSR,事实上对于复杂的项目而言是无能为力的.

2. 处理路由

写一个路由的配置文件,以及一个新的组件Personal,输出我是中心页:

import React from 'react';
import { Routes, Route, Link } from 'react-router-dom';
import Home from './pages/Home';
import Personal from './pages/Personal';
​
const RoutesList = () => {
  return (
    <div>
      <ul>
        <li>
          <Link to="/">首页</Link>
        </li>
        <li>
          <Link to="/personal">个人中心页</Link>
        </li>
      </ul>
      <Routes>
        <Route exact path="/" element={<Home />} />
        <Route path="/personal" element={<Personal />} />
      </Routes>
    </div>
  );
};
​
export default RoutesList;

在server.js中引入该文件,并且在react-router-dom/server中引入StaticRouter作为父容器包裹路由根组件

import React from "react"; // import
import { renderToString } from "react-dom/server";
import Home from "./pages/Home";
​
const express = require("express");
const app = express();
app.get("*", (req, res) => {
  const content = renderToString(
    <StaticRouter location={req.path}>
      <Routes />
    </StaticRouter>
  );
  res.writeHead(200, {
    "content-type": "text/html;charset=utf8",
  });
​
  const html = `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id="root">${content}</div>
      </body>
    </html>
  `;
  res.end(html);
});
​
app.listen(3000, () => {
  console.log("listen:3000");
});
​

服务运行起来之后,访问页面,可以看到路由跳转已经生效:

浅谈SSR服务端渲染——从定义到基于React的基本实现

查看网页源代码:

浅谈SSR服务端渲染——从定义到基于React的基本实现

3. 引入同构

其实前面的SSR依然是不完整的,平时在开发的过程中难免会有一些事件绑定,比如最基础的就是按钮点击时间,我们加一个button,绑定一个点击事件,点击之后输出“我被点击了”:

import React from 'react';
const Home = () => {
  return (
    <div>
      我是主页
      <button onClick={() => console.log('我被点击了')}>点击</button>
    </div>
  )
}
export default Home;

再试一下,就会发现,没有任何输出,事件绑定无效!那这是为什么呢?原因很简单,react-dom/server下的renderToString并没有做事件相关的处理,因此返回给浏览器的内容不会有事件绑定。

这就需要进行同构了。所谓同构,通俗的讲,就是一套React代码在服务器上运行一遍,到达浏览器又运行一遍。服务端渲染完成页面结构,浏览器端渲染完成事件绑定。

如何进行浏览器端的事件绑定呢?

唯一的方式就是让浏览器去拉取一个JS文件执行,让这个JS代码来接管服务端返回的这个页面。

写一个client.js用于给客户端使用,使用 ReactDOM.hydrate 让客户端接管页面:

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom'; // 注意
import Routes from './routes';
​
ReactDOM.hydrate( // 注水
  <BrowserRouter>
    <Routes />
  </BrowserRouter>,
  document.querySelector('#root') // 找到要接管的div
);

使用webpack把他打包到public目录下的bundle_client.js,并在express开启静态文件服务,然后在返回的html中,写一个script标签,用于客户端去请求这个client.js

app.use(express.static('dist/public'));
// 此时我们返回的html如下
<html>
  <head>
    <title>ssr</title>
  </head>
  <body>
    <div id="root">${content}</div>
    <script src="bundle_client.js"></script>
  </body>
</html>

这个时候,客户端拿到html模版之后,就会加载这个bundle_client.js,然后接管前端页面了

4. 集成redux

4.1 store、action、reducer的创建

store目录结构如下:

浅谈SSR服务端渲染——从定义到基于React的基本实现

  • actions,这里我们使用setTimeout模拟ajax请求:
// actions/home.js
export const FETCH_HOME_DATA = 'fetch_home_data';
​
export const fetchHomeData = async (dispath) => {
  const data = await new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        articles: [
          {
            id: 1,
            title: '文章标题1',
            content: '文章内容1',
          },
          {
            id: 2,
            title: '文章标题2',
            content: '文章内容2',
          },
        ],
      });
    }, 2000);
  });
​
  dispath({
    type: FETCH_HOME_DATA,
    payload: data,
  });
};
// actions/personal.js
export const FETCH_PERSONAL_DATA = 'fetch_personal_data';
​
export const fetchPersonalData = async (dispath) => {
  const data = await new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        userInfo: {
          username: 'YiBai',
          job: '前端工程师',
        },
      });
    }, 2000);
  });
​
  dispath({
    type: FETCH_PERSONAL_DATA,
    payload: data,
  });
};
  • reducers
// reducers/home.js
import { FETCH_HOME_DATA } from '../actions/home';
​
const initialState = {
  articles: [],
};
​
export default (state = initialState, action) => {
  switch (action?.type) {
    case FETCH_HOME_DATA:
      return action.payload;
    default:
      return state;
  }
};
// reducers/personal.js
import { FETCH_PERSONAL_DATA } from '../actions/personal';
​
const initialState = {
  userInfo: {},
};
​
export default (state = initialState, action) => {
  switch (action?.type) {
    case FETCH_PERSONAL_DATA:
      return action?.payload;
    default:
      return state;
  }
};
// reducers/index.js
import { combineReducers } from 'redux';
import homeReducer from './home';
import personalReducer from './personal';
​
export default combineReducers({
  home: homeReducer,
  personal: personalReducer,
});
  • 全局store
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import reducer from './reducers';
​
const store = createStore(reducer, applyMiddleware(thunk));
​
export default store;

4.2 组件连接全局store

对于store的连接操作,在同构项目中分两个部分,一个是与客户端store的连接,另一部分是与服务端store的连接。都是通过react-redux中的Provider来传递store的。

客户端:

import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { BrowserRouter } from "react-router-dom"; // 注意
import store from './store'
import Routes from "./routes";
​
ReactDOM.hydrate(
  <Provider store={store}>
    <BrowserRouter>
      <Routes />
    </BrowserRouter>
  </Provider>,
  document.querySelector("#root") // 找到要接管的div
);

服务端:

import React from "react"; // import
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { Provider } from "react-redux";
import store from "./store";
import Routes from "./routes";
​
const express = require("express");
const app = express();
app.get("*", (req, res) => {
  const content = renderToString(
    <Provider store={store}>
      <StaticRouter location={req.path}>
        <Routes />
      </StaticRouter>
    </Provider>
  );
  res.writeHead(200, {
    "content-type": "text/html;charset=utf8",
  });
​
  const html = `
    <html>
      <head>
        <title>ssr</title>
      </head>
      <body>
        <div id="root">${content}</div>
      </body>
    </html>
  `;
  res.end(html);
});
​
app.listen(3001, () => {
  console.log("listen:3001");
});

Home组件:

import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchHomeData } from '../store/actions/home';
​
const Home = () => {
  const dispatch = useDispatch(); // 获取dispatch
  const homeData = useSelector((state) => state.home); // 获取state中命名空间为home的
​
  useEffect(() => {
    dispatch(fetchHomeData); // 派发action初始化state数据
  }, []);
​
  return (
    <div>
      <h1>首页</h1>
      <ul>
        {homeData?.articles?.map((article) => (
          <li key={article?.id}>
            <p>文章标题:{article?.title}</p>
            <p>文章内容:{article?.content}</p>
          </li>
        ))}
      </ul>
      <button onClick={() => console.log('我被点击了')}>点我</button>
    </div>
  );
};
​
export default Home;

浅谈SSR服务端渲染——从定义到基于React的基本实现

4.3 一个潜在的坑

其实上面这样的store创建方式是存在问题的,什么原因呢?

上面的store是一个单例,当这个单例导出去后,所有的用户用的是同一份store,这是不应该的。那么这么解这个问题呢?

在全局的store/index.js下修改如下,导出一个工厂函数:

export default function createStoreInstance(preloadedState = {}) {
  return createStore(reducer, preloadedState, applyMiddleware(thunk));
}

这样在客户端和服务端的js文件引入时其实引入了一个函数,把这个函数执行就会拿到一个新的store,这样就能保证每个用户访问时都是用的一份新的store。

5.异步数据的服务端渲染

虽然现在页面能够正常渲染,但是打开网页源代码发现:

浅谈SSR服务端渲染——从定义到基于React的基本实现

源代码里面并没有这些列表数据的,那这是为什么呢?

分析一下客户端和服务端的运行流程,当浏览器发送请求时,服务器接受到请求,这时候服务器和客户端的store都是空的,紧接着客户端执行Home组件中useEffect中的函数,获取到数据并渲染到页面,然而服务器端始终不会执行useEffect,因此不会拿到数据,这也导致服务器端的store始终是空的。换而言之,关于异步数据的操作始终只是客户端渲染。

现在的工作就是让服务端将获得数据的操作也执行一遍,以达到真正的服务端渲染的效果。

5.1 在需要服务端事先请求数据的组件上挂载一个方法

以Home为例,为了让服务端知道要事先获取数据,我们在Home组件导出前,在身上挂载一个getInitialData的函数,在这个函数中执行获取数据的逻辑。

const Home = () => {
  // ......
}
// 在Home组件上挂载一个getInitialData函数
Home.getInitialData = async (store) => {
  return store.dispatch(fetchHomeData); // 在这个函数中派发action获取数据
};
export default Home;

5.2 改造路由根组件

导出一个路由表:

// src/routes.js
export const routesConfig = [
  {
    path: '/',
    component: Home,
  },
  {
    path: '/personal',
    component: Personal,
  },
];

5.3 服务端获取数据

服务端如何判断该页面是否需要提前获取数据?因为组件挂载一个getInitialData的函数,只要有这个函数服务端就执行获取数据。每次渲染一个组件获取异步数据时,都会调用相应组件的这个函数。

我们可以根据当前页面的请求路径req.url,去和routes路由表做匹配,拿到当前请求路径对应的组件。然后去判断这个组件是否挂载了getInitialData,如果挂载了这个函数,那么就在服务端去执行这个函数,去获取数据。因为组件上挂载的这个函数返回的是promise,一个页面可能同时渲染匹配到多个组件,我们可以就可以在promise.all中等所有promise都执行完之后,再去执行服务端的逻辑,返回html模版。

import React from "react"; // import
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { Provider } from "react-redux";
import store from "./store";
import Routes from "./routes";
​
const express = require("express");
const app = express();
app.get("*", (req, res) => {
  const promises = routesConfig?.map((route) => {
    const component = route?.component; // 拿到当前遍历的路由组件
    // 使用路由路径route.path和请求路径req.url去对应,并且判断组件是否挂载getInitialData函数
    if (route?.path === req?.url && component?.getInitialData) {
      return component?.getInitialData(store);
    } else {
      return null;
    }
  });
​
  // 等所有的一步操作都执行完,再渲染出content
  Promise.all(promises).then(() => {
    const content = renderToString(
      <Provider store={store}>
        <StaticRouter location={req.url}>
          <Routes />
        </StaticRouter>
      </Provider>
    );
​
    res.writeHead(200, {
      "content-type": "text/html;charset=utf8",
    });
​
    const html = `
      <html>
        <head>
          <title>ssr</title>
        </head>
        <body>
          <div id="root">${content}</div>
        </body>
      </html>
    `;
    res.end(html);
  });
});
​
app.listen(3001, () => {
  console.log("listen:3001");
});

根据这个思路,服务端渲染中异步数据的获取功能就完成了。

5.4 数据的注水和脱水

浅谈SSR服务端渲染——从定义到基于React的基本实现

此时在前端页面查看网页源代码,html结构中也是有数据的结构,但是发现,初始数据并没有渲染出来,数据仍然是2秒之后异步展示的,说明本质渲染还是CSR渲染出来的,这是为啥呢。

其实也很好理解。当服务端拿到store并获取数据后,客户端的js代码又执行一遍,在客户端代码执行的时候又创建了一个空的store,两个store的数据不能同步。

因此我们要让客户端知道已经有初始化数据了,就不需要再去请求一次了。这个时候,我们可以在返回的html模版中,加一段js代码,用于把服务端更新后的store中的数据,挂载到window上,这个属性就是服务端初始化好的数据。

首先,在服务端获取获取之后,在返回的html代码中加入这样一个script标签:

<script>
  window.__PRELOAD_STATE__=${JSON.stringify(preloadedState)}
</script>

这叫做数据的“注水”操作,即把服务端的store数据注入到window全局环境中。 接下来是“脱水”处理,换句话说也就是把window上绑定的数据给到客户端的store,可以在客户端store产生的源头进行,即在全局的store/index.js中进行。

// 创建store时,如果有window?.__PRELOAD_STATE__,就以它为初始state,否则为空对象{}
const store = createStoreInstance(window?.__PRELOAD_STATE__);

创建store的代码:

export default function createStoreInstance(preloadedState = {}) {
  return createStore(reducer, preloadedState, applyMiddleware(thunk));
}

服务端:

// server.js
import React from "react"; // import
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { Provider } from "react-redux";
import store from "./store";
import Routes from "./routes";
​
const express = require("express");
const app = express();
app.get("*", (req, res) => {
  const promises = routesConfig?.map((route) => {
    const component = route?.component; // 拿到当前遍历的路由组件
    // 使用路由路径route.path和请求路径req.url去对应,并且判断组件是否挂载getInitialData函数
    if (route?.path === req?.url && component?.getInitialData) {
      return component?.getInitialData(store);
    } else {
      return null;
    }
  });
​
  // 等所有的一步操作都执行完,再渲染出content
  Promise.all(promises).then(() => {
    const preloadedState = store.getState();
    const content = renderToString(
      <Provider store={store}>
        <StaticRouter location={req.url}>
          <Routes />
        </StaticRouter>
      </Provider>
    );
​
    const html = `
      <html>
        <head>
          <title>ssr</title>
        </head>
        <body>
          <div id="root">${content}</div>
          <script>
            window.__PRELOAD_STATE__=${JSON.stringify(preloadedState)}
          </script>
          <script src="bundle_client.js"></script>
        </body>
      </html>
    `;
    res.writeHead(200, {
      "content-type": "text/html;charset=utf8",
    });
    res.end(html);
  });
});
​
app.listen(3001, () => {
  console.log("listen:3001");
});

客户端:

// client.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import createStoreInstance from './store';
import Routes from './routes';
​
// 创建store时,如果有window?.__PRELOAD_STATE__,就以它为初始state,否则为空对象{}
const store = createStoreInstance(window?.__PRELOAD_STATE__);
​
ReactDOM.hydrate(
  <Provider store={store}>
    <BrowserRouter>
      <Routes />
    </BrowserRouter>
  </Provider>,
  document.querySelector('#root')
);
​

浅谈SSR服务端渲染——从定义到基于React的基本实现

至此,数据的脱水和注水操作完成。但是还是有一些瑕疵,其实当服务端获取数据之后,客户端并不需要再发送Ajax请求了,而客户端的React代码的useEffect中仍然存在这样的浪费性能的代码。怎么办呢?就是在useEffect中判断一下state是否有数据即可,没数据才派发action,否则,不做处理。

useEffect(() => {
  if(homeData?.articles?.length > 0) {
    dispatch(fetchHomeData);
  }
}, []);

五、总结

这里只是初步实现了一个React的SSR渲染框架,其实还有很多需要做的地方,比如引入css,多级路由渲染,引入node中间层优化等等。因此自己实现一个SSR的生产框架,还是比较复杂的。作为一个初学者,我也还有很多需要去了解的地方。当然现在已经也有一些成型的主流框架,比如对于React有Next.js,对于Vue有Nuxt.js,目前我还只接触过Next.js,感觉还是非常好用的,大家如果感兴趣的话也可以去了解和学习一下。

参考资料:

《从头开始,彻底理解服务端渲染原理(8千字汇总长文)》;

《彻底理解服务端渲染 - SSR原理》

慕课网《Next.js+React+Node系统实战,搞定SSR服务器渲染》