React SSR 原理解析和实践

什么是 CSR & SSR ?

CSR(Client Side Rendering)就是在浏览器从服务器中获取到的只是一个带有空 div 标签的 html 文件,然后执行 js 文件生成 dom 和操作 dom,日常中开发的后台管理类的系统大多都是 CSR 的模式。

SSR(Server Side Rendering)是在服务端已经完成渲染工作,浏览器从服务器获得的是完整的网页的 dom 字符串。不同于以前通过后端模板等方案生成页面,现在的 React、Vue、Svelte 等优秀框架都有 SSR 的解决方案。

为什么要使用 SSR?

关于这个问题尤大在 Vue SSR 指南中也给出了答案。

SSR 对比 CSR 的优点:

  • 更好的 SEO:对 SSR 的应用搜索引擎可以直接获取完全渲染的页面,但是在 CSR 的应用中搜索引擎获取到的只是一个空标签。
  • 更快的内容到达时间。

当然 SSR 对比 CSR 也有一些缺点:

  • 引用成本高:SSR 还需要使用 Node.js 作为服务器,对于代码构建和部署也要求更高,加大了开发成本
  • 更多的服务器负载。
  • 传统开发思路受限:在开发的时候要区分是 Node.js 环境还是浏览器环境,部分生命周期在服务器端也不会生效。

所以在选择技术栈的时候可以根据项目需求去选择,如果对 SEO 和加载速度有要求的时候就可以使用 SSR。反之如果没有 SEO 或首屏加载优化等需求,使用 SSR 也可能是一种负担。

more:

如果单纯的想对个别数据变动不频繁的页面做 SEO,可以考虑预渲染的方案,毕竟 SSR 的成本是相对较高的。

  • 预渲染:预渲染就是把页面生成静态的 html 结构,然后进行静态部署。实现方案一般是通过一个无头浏览器打开系统,等到 js 执行完毕,dom 渲染完毕后通过XMLSerializer API 进行处理最后生成静态 html 字符串。

SSR 构建流程

下图来自 Vue 官网,详细展示了 SSR 项目的构建流程,虽然 React 和 Vue 的实现方式略有不同,但是整体思路也是如此:

image.png

  • Store、Router、Components、app.js 这些模块(以下简称web模块)是公共的,在 SSR 的情况下这些模块既要在客户端使用,也要在服务端使用。
  • web 模块被 Cliententry 引用通过 webpack 打包作为静态资源使用。
  • web 模块被 Serverentry 引用通过 webpack 打包被 Node.js 服务 调用,用于请求页面的时候,进行组件渲染返回 html 字符串。
  • 最终 Node.js Server 返回的 html 字符串和 js 加载生成的 html 在浏览器端进行同构。

由上图也可以初步新建如下的文件目录:

.
├── build
│   ├── base.config.js
│   ├── client.config.js
│   └── server.config.js
├── entry
│   ├── server-entry.jsx
│   └── client-entry.jsx
├── server
│   └── app.js
└── web
    ├── components
    │   └── Test.jsx
    ├── index.jsx
    └── pages
        └── Index.jsx
  • build:webpack 构建的目录
  • entry:入口文件的目录
  • server:Node.js 服务器的目录
  • web:前端代码和资源目录

webpack 构建服务端 bundle

webpack 构建主要是把 web 目录下的文件分别通过 client 和 server 的配置打包成两份代码,这里不再介绍客户端代码的打包,主要介绍一下服务端代码的打包。

const WebpackChain = require('webpack-chain');
const nodeExternals = require('webpack-node-externals');
const {
    resolvePath,
    isDev
} = require('./utils');
module.exports = {
    getServerConfig: function() {
        const chain = new WebpackChain();
        chain
            .entry('server')
                .add(resolvePath('entry/server-entry.js'))
                .end()
            .output
                .path(resolvePath('dist/server'))
                .filename('[name].js')
                .libraryTarget('commonjs2')
                .end()
            .when(isDev, function(chain) {
                chain.watch(true);
            })
            .target('node')
            .externals(nodeExternals({
                allowlist: [/.(css|less|sass|scss)$/]
            }));
        return chain.toConfig();
    }
}

可以看到,服务器端打包和客户端的打包有一点区别:

  • target: ‘node’,target 设置为 node,webpack 将在类 Node.js 环境编译代码。(使用 Node.js 的 require 加载 chunk,而不加载任何内置模块,如 fspath)。每个target都包含各种 deployment(部署)/environment(环境)特定的附加项,以满足其需求。
  • output 的 libraryTarget 设置为 ‘commonjs2’ 打包输出的代码将在 Node.js 环境下运行。
  • 使用 nodeExternals 后,代码将不会被 webpack 打包,因为服务端的代码自带 node_modules,可以直接去 node_modules 中获取。

React 服务端渲染 API

在开始编写代码之前,我们需要了解react实现服务端渲染必须的几个 API,参考 ReactDOMServer

  • renderToString 把 React 元素渲染 html 字符串。
function Comp () {
  return (
  	<div>123</div>
  )
}
renderToString(<Comp />); // 返回 <div>123</div>
  • renderToNodeStream 把 React 元素渲染成 html,和 renderToString 不同的是,该方法返回一个可输出 HTML 字符串的可读流
  • hydrate 如果您调用ReactDOM.hydrate()已经具有此服务器渲染标记的节点,React 将保留它并仅附加事件处理程序,从而使您获得非常出色的首次加载体验。在 SSR 应用中使用hydrate替代render
ReactDOM.hydrate(
  <App></App>,
  document.getElementById('root')
)

简单实现 SSR

  • 根组件
import React from 'react';
export const Index = () => {
  return (
    <div>123</div>
  )
}
  • client-entry.jsx

客户端入口实际上就是把组件挂载到 dom 中:

import React from 'react';
import ReactDOM from 'react-dom';
import {Index} from 'web/index'
ReactDOM.hydrate(
	<Index />,
  document.getElementById('app')
)
  • server-entry.jsx

服务端入口应该导出一个函数,该函数返回解析后的 html 字符串:

import {renderToString} from 'react-dom/server';
import {Index} from 'web/index';
import React from 'react';
export function serverRender() {
  return renderToString(
  	<Index />
  )
}
  • start.js

在启动 Node.js 服务器之前,我们需要先把客户端和服务端先打包好,然后供 Node.js 服务调用。start.js 暴露两个方法分别是客户端执行打包和服务端打包。在启动 Node.js 服务之前调用这两个函数。

const webpack = require('webpack');
const {getClientConfig} = require('./client.config');
const {getServerConfig} = require('./server.config');
exports.startClientServer = () => {
    return new Promise((resolve, reject) => {
        const config = getClientConfig();
        const compile = webpack(config);
        compile.run((err, stats) => {
            if (err || stats.hasErrors()) {
                console.log(err || stats.toString());
                reject();
            } else {
                resolve();
            }
        })
    })
}
exports.startServerBuild = () => {
    return new Promise((resolve, reject) => {
        webpack(getServerConfig(), (err, stats) => {
            if (err || stats.hasErrors()) {
                console.log(err || stats.toString());
                reject();
            } else {
                resolve();
            }
        })
    })
}
  • app.js

启动一个 Node.js 服务,服务端使用 express 框架

const express = require('express');
const path = require('path');
const {startClientServer, startServerBuild} = require('../build/start')
const app = express();
const PORT = 3000;
app.get('*', (req, res) => {
    const {serverRender} = require(path.join(__dirname, '../dist/server/server.js'));
    res.send(serverRender());
})
async function bootstrap () {
  	// 等待webpack打包完毕后启动服务
    await Promise.all([startClientServer(), startServerBuild()]);
    app.listen(PORT, () => {
        console.log('server running~~')
    })
}
bootstrap();

在浏览器访问http://localhost:3000就可以看到返回结果,但是还有一些问题等待解决:

  1. 访问服务器的时候实际上仅仅返回的是服务端返回的字符串,并没有同构的过程
  2. 开发环境的热更新
  3. 路由的同构
  4. 数据预取

下面我们将一一处理这些问题。

处理HTML

在上一节中提到访问服务器的时候实际上仅仅返回的是服务端返回的字符串,并没有同构的过程,实际上就是因为我们只处理了服务端渲染的字符串,并未把客户端打包的资源和浏览器整合到一起。

image.png

<!-- 现在访问localhost:3000返回给浏览器的结果为 -->
<div>
  123
</div>
<!-- 期望返回给浏览器的结果为 -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="style.css">
    <title>Document</title>
</head>
<body>
    <div>123</div>
</body>
<script src="script.js"></script>
</html>

对于静态文件我们可以用 Node.js 服务直接访问文件,但是每次打包完成后 js 和 css 的文件都是带有 hash 的,如何把这些文件注入到 html 中?在这里提供两个解决方案,一个是通过webpack-manifest-plugin插件,生成打包清单;另一种是通过html-webpack-plugin把资源注入到 html 中。下面详细介绍这两种方案:

  • webpack-manifest-plugin方案在 webpack 中使用该插件后,打包会额外生成一个清单文件
{
  "dist/a.js": "dist/a.1234567890.js",
  "dist/b.js": "dist/b.0987654321.js"
}

所以我们可以通过该清单直接获取打包后的 js 和 css

app.get('/', (req, res) => {
    const {serverRender} = require(serverBundlePath);
  	// 引入客户端清单文件
    const clientManifest = require(clientManifestPath);
    const html = `<!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta http-equiv="X-UA-Compatible" content="IE=edge">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Document</title>
        </head>
        <body>
            <div id="app">${serverRender()}</div>
        </body>
        <script src="${clientManifest['client-entry.js']}"></script>
        </html>
    `
    res.send(html);
})

通过这种方式就可以把 js 和 css 注入到 html 中。

  • html-webpack-plugin 方案

在客户端的打包过程中,html-webpack-plugin 插件会自动把 js 和 css 等注入到 html 中。

image.png

但是在服务端渲染中还需要对该模板进行处理把 html 加载进去。

image.png

app.get('/', async (req, res) => {
    const {serverRender} = require(serverBundlePath);
  	// 读取打包后已注入资源的 html 文件
    const html = (await fs.readFile(htmlPath)).toString().replace(
    	'ssr-placeholder',
      serverRender()
    )
    res.send(html);
})
处理 CSS

CSS 也是开发中必不可少的,在开发的时候我们一般使用 css-loader 和 style-loader 处理 css。

chain
    .module
    .rule('css')
    .test(/.css$/)
    .use('style')
    .loader('style-loader')
    .end()
    .use('css')
    .loader('css-loader')
    .end()

但是在启动时候却出现错误

image.png

让我们看一下 style-loader 的 insertStyleElement.js 文件中做了什么?

/* istanbul ignore next  */
function insertStyleElement(options) {
  const element = document.createElement("style");
  options.setAttributes(element, options.attributes);
  options.insert(element, options.options);
  return element;
}
module.exports = insertStyleElement;

style-loader 是在 js 运行的时候动态把代码注入到 html 中,因为在 Node.js 环境下是没有 document 的,所以抛了异常,这一点在服务端渲染中需要特别注意。既然不能动态加载,我们可以把 css 打包成单独的文件,然后在 html 引入即可。在这里需要借助 MiniCssExtractPlugin。

chain
    .module
    .rule('css')
    .test(/.css$/)
    .use('MiniCss')
    .loader(MiniCssExtractPlugin.loader)
    .end()
    .use('css')
    .loader('css-loader')
    .end()
chain
    .plugin('mini-css-extract-plugin')
    .use(MiniCssExtractPlugin, [
        {
            filename: '[name].[contenthash:6].css',
            chunkFilename: '[name].[contenthash:6].chunk.css'
        }
    ])
静态资源处理

image.png 上图是从访问浏览器到渲染的过程,上一节处理好 html 后还不能算完成了完整的 SSR,因为浏览器无法获取到 js、css 等资源,也无法完成 react 的 hydrate 过程,所以我们需要把 js 资源也返回给浏览器。

app.use(express.static(path.join(__dirname, '../dist/client')));

只需要把客户端打包的路径设置为静态资源路径即可。

至此,我们已经完成了一个完整的 SSR 流程!

开发环境热更新

webpack开发环境的热更新可以直接借助 DevServer 实现,但是我们使用的是 Node.js 服务做了资源返回,所以需要借助 webpack-dev-middleware + webpack-hot-middleware 实现热更新。

使用 webpack-dev-middleware + webpack-hot-middleware
const path = require('path');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const {getClientConfig} = require('./client.config');
const {getServerConfig} = require('./server.config');
const {cleanDist} = require('./utils');
exports.startClientServer = async (app) => {
    // 启动之前删除之前编译的代码
    await cleanDist(path.join(__dirname, '../dist'));
    const config = getClientConfig();
    const compile = webpack(config);
    // 使用webpackDevMiddleware
    app.use(webpackDevMiddleware(
        compile, {
            publicPath: config.output.publicPath,
            writeToDisk: true
        }
    ))
    // 热重载
    .use(webpackHotMiddleware(compile))
}

webpackDevMiddleware 的作用是把 webpack 打包的资源让 Node.js server 使用

webpackHotMiddleware 的作用是热重载

修改 webpack 的配置
exports.getClientConfig = () => {
    const chain = new WebpackChain();
    // some code...
    
    chain.entry('client-entry')
        .when(isDev, entry => {
            // 在开发环境下需要在入口出添加
            entry.add('webpack-hot-middleware/client')
                .add(resolvePath('../entry/client-entry.tsx'))
        }, entry => {
            entry
                .add(resolvePath('../entry/client-entry'))
        })
        .end()
    // some code...
    chain
        .plugin('HotModuleReplacementPlugin')
        .use(webpack.HotModuleReplacementPlugin)
        .end()
}
exports.getServerConfig = () => {
    // ...
    isDev && chain.watch(true)
    // ...
}
修改入口文件
// client-entry.jsx
// some code ...
if (module.hot) {
    module.hot.accept();
}
修改服务器代码
// app.js
// 因为 webpackDevMiddleware 已经对资源做了处理,所以不需要 express 再处理
// app.use(express.static(path.join(__dirname, '../dist/client')));
async function bootstrap () {
  	// 把app传入到 startClientServer 中
    await Promise.all([startClientServer(app), startServerBuild()]);
    app.listen(PORT, () => {
        console.log('server running~~')
    })
}

经过配置后,当修改前端代码的时候就可以热更新了。当修改服务端代码的时候可以使用 nodemon 等工具使服务重新启动,当服务重新启动的时候,热更新的 websocket 链接就会断开,需要重新刷新页面重新建立连接。

当然,上面的方案只是其中一种方案,webpack-dev-server 也是不错的选择。

路由同构

在 react 项目中,路由一般使用的是 react-router(本文使用的版本为5),react-router 同样也支持服务端渲染,在进行同构之前我们需要了解类似于 react-router 路由框架在切换路由的时候,是不会向服务器发送请求的。服务端渲染的时候流程如下:

image.png

  1. 当首次进入系统访问 localhost:3000/a 的时候会首先访问 Node.js 服务器
  2. Node.js 服务器收到请求后,会进行路由匹配,根据路由决定应该返回哪个页面(组件)的字符串给浏览器
  3. html 发送到浏览器后,执行 js 脚本,此时客户端的路由(react-router-dom)会再次执行,决定要渲染哪个页面(组件)
  4. 如果服务器生成的字符串和前端js脚本的字符串匹配则 hydrate 完成,否则失败。
  5. 后续的路由切换都是前端路由切换,不会产生请求。
  • 添加前端路由
// client-entry.js
import React from 'react';
import ReactDOM from 'react-dom';
import {BrowserRouter} from 'react-router-dom';
import {Index} from 'web/index';
ReactDOM.hydrate(
  <BrowserRouter>
	  <Index />
  </BrowserRouter>,
  document.getElementById('app')
)
// web/index.jsx
import React from 'react';
import {Switch, Route} from 'react-router-dom';
import {routerList} from './router';
export const Index = () => {
    return (
        <Switch>
            {
                routerList.map(Item => {
                    return (
                        <Route key={Item.path} path={Item.path} exact={Item.exact} component={Item.component}>
                        </Route>
                    )
                })
            }
        </Switch>
    )
}
// web/router
import {Index} from '../pages/Index';
import {About} from '../pages/About';
export const routerList = [
    {
        path: '/',
        component: Index,
        exact: true
    },
    {
        path: '/about',
        component: About
    }
]

添加前端路由和客户端渲染添加路由是一样的,因为最终在客户端运行的还是前端路由。

  • server 端路由的处理

在服务端需要根据请求判断请求的是哪个页面和组件,然后通过 renderToString 将其转换为字符串返回给浏览器。首先需要实现一个查找组件的方法,react-router 恰好也提供了 matchPath 方法。

import {routerList} from 'web/router'
import {matchPath} from 'react-router-dom'
export function findRoute(path) {
    return routerList.find(item => matchPath(path, item))
}

React-router 在服务端渲染中也提供了 StaticRouter 用来替代 BrowserRouter

import {renderToString} from 'react-dom/server';
import {StaticRouter} from 'react-router-dom';
import {Index} from 'web/index';
import React from 'react';
import {findRoute} from 'web/router/findRoute';
export function serverRender(path) {
	const router = findRoute(path);
	if (router) {
		return renderToString(
			<StaticRouter location={router.path}>
				<Index />
			</StaticRouter>
		);
	} else {
		return '404';
	}
}

服务端渲染的路由同构相对其他模块比较简单,关键在于如何匹配路由渲染相对应的路由。

数据预取

目前实现的功能依然存在一个比较严重的问题,服务端返回浏览器的 html 并无请求的数据,只有静态的 html 结构,所以我们要在服务端提前获取到数据发送给浏览器。前面也提到过,服务端返回的结构要和客户端渲染的 html 一致,这样客户端只需要完成事件绑定,否则会在客户端再进行一次解析渲染,所以我们需要解决以下问题:

  1. 如何在服务端预取数据
  2. 取到数据之后如何保证服务端和客户端渲染一致
服务端预取数据

比较明确的是预取数据的这个接口在服务端和客户端都会调用,所以请求的时候既要可以在浏览器环境成功请求到数据,又可以在 Node.js 环境请求到数据。这里推荐 axios,axios 对以上两个环境都有很好的支持。我们为每个页面都建一个 fetch 文件,fetch 文件内容就是一个数据请求预取的函数,下面用定时器模拟一下

// fetch.js
export function fetch() {
    return new Promise(r => {
        setTimeout(() => {
            r({
                name: 'jack',
                age: 18
            })
        }, 3000)
    })
}

把数据预取的函数和路由进行绑定:

import {Index} from '../pages/Index';
import {About} from '../pages/About';
import {fetch as IndexFetch} from '../pages/Index/fetch';
import {fetch as AboutFetch} from '../pages/About/fetch';
export const routerList = [
    {
        path: '/',
        component: Index,
        exact: true,
      	// 把fetch方法和路由绑定
        fetch: IndexFetch
    },
    {
        path: '/about',
        component: About,
        fetch: AboutFetch
    }
]

绑定完成后在服务端通过 findRoute 可以找到该方法调用并获取数据,在客户端可以直接把 fetch 传到组件上并调用。

// server-entry.js
import {renderToString} from 'react-dom/server';
import {StaticRouter} from 'react-router-dom';
import {Index} from 'web/index';
import React from 'react';
import {findRoute} from 'web/router/findRoute';
export async function serverRender(path) {
	const router = findRoute(path);
	const res = await router.fetch();
	if (router) {
		const content = renderToString(
			<StaticRouter location={router.path} context={{
				initData: res
			}}>
				<Index />
			</StaticRouter>
		);
		return {
			content,
			state: res
		}
	}
}

React-router 中StaticRouter支持 context 属性,可以把数据传到 props 中。

在组件中 props.staticContext.initData的值为 context 的值。

export const Index = (props) => {
    return (
        <div>
            {props.staticContext.initData.name} // jack
        </div>
    )
}
数据同构

数据预取的时候,Index 页面组件通过props.staticContext.initData获取预取到的数据,但是BrowserRouter并没有StaticRouter的 context 属性,所以要在客户端给 Index 组件的props添加staticContext.initData

routerList.map({path, exact, Component} => {
  return (
    <Route 
      key={path} 
      path={path} 
      exact={exact} 
      render={(props) => <Component {...props} staticContext={initData: data}  />}>
    </Route>
  )
})

通过这种方式使客户端和服务端有一致的数据,也就保证了双端渲染的一致性。

在服务端中通过每个页面的 fetch 获取到对应页面的数据,那么在浏览器端如何拿到这些数据呢。首先要避免在客户端再次 fetch,不仅仅是因为重复调用会浪费资源,还因为在初始化渲染的时候会造成同构失败。比较理想的方法是对服务端数据序列化后交由前端处理:

// app.js
const {content, state} = await serverRender(url);
const html = `<!DOCTYPE html>
  <html lang="en">
  <head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  </head>
  <body>
  <div id="app">${content}</div>
  </body>
  <script>
  window.__INIT_STATE__=${JSON.stringify(state)}
  </script>
  <script src="${clientManifest['client-entry.js']}"></script>
  </html>
`

在客户端可以使用window.__INIT_STATE__获取服务端预取到的数据。

routerList.map({path, exact, Component} => {
  return (
    <Route 
      key={path} 
      path={path} 
      exact={exact} 
      render={(props) => <Component {...props} staticContext={initData: window.__INIT_STATE__}  />}>
    </Route>
  )
})

到这里便完成了数据预取和同构,但是在前面我们提到,SSR 的模式只是初次请求的时候使用服务端渲染,后续切换页面都是客户端的路由切换行为,那么当前实现方式,在路由切换的时候就会出现问题,因为我们只有初次渲染的那个页面的数据,而且我们希望每次切换路由会请求新数据,所以在后续的切换的时候,需要先获取数据再传给页面组件,我们使用高阶组件实现。

// WrapperComponent
import React from "react";
import {withRouter} from 'react-router-dom';
// 标志是否是初次渲染
let hasRender = false;
export const WrapperComponent = (Component) => {
    return withRouter(class extends React.Component {
        constructor (props) {
            super(props);
            this.state = {
              	// 服务端渲染取props.staticContext,客户端初次渲染取window.__INIT_STATE__
                staticContext: props.staticContext || {
                    initData: window && !hasRender && window.__INIT_STATE__
                }
            }
        }
        componentDidMount () {
          // 如果是初次渲染,直接取window.__INIT_STATE__,不用再请求
            if (!hasRender) {
                hasRender = true;
            } else {
              // 后续切换路由的时候,请求数据并更新
                this.props.fetch().then(res => {
                    console.log('fetch')
                    this.setState({
                        staticContext: {
                            initData: res
                        }
                    });
                })
            }
        }
        render () {
            return (
                <Component {...this.props} staticContext={this.state.staticContext}></Component>
            )
        }
    })
}
routerList.map(Item => {
  const NewComponent = WrapperComponent(Item.component);
  return (
    <Route 
    	key={Item.path} 
        path={Item.path}
		exact={Item.exact}
		render={(props) => <NewComponent {...props} fetch={Item.fetch} />}
    >
    </Route>
	)
})

写在最后

在搭建 SSR 框架的时候不仅仅需要熟悉 react、vue 等前端框架,还需要对 webpack、Node.js 等有一定的了解,而且真实的线上环境会比文章描述要复杂的更多,所以在技术选型的时候请确定你真的需要 SSR,并已经做好了要解决诸多问题的准备。 (全文完)

免责声明:
1.本站所有内容由本站原创、网络转载、消息撰写、网友投稿等几部分组成。
2.本站原创文字内容若未经特别声明,则遵循协议CC3.0共享协议,转载请务必注明原文链接。
3.本站部分来源于网络转载的文章信息是出于传递更多信息之目的,不意味着赞同其观点。
4.本站所有源码与软件均为原作者提供,仅供学习和研究使用。
5.如您对本网站的相关版权有任何异议,或者认为侵犯了您的合法权益,请及时通知我们处理。
火焰兔 » React SSR 原理解析和实践