一 前言
目前在移动端 app 应用中,有很多使用到活动页面的场景,因为这些活动页面更新频繁,迭代快,所以都是采用 webview h5 的方式。而这些 h5 页面很多都是采用服务端渲染 SSR 加载的。
提到了服务端渲染 SSR ,就会引出一个问题 为什么要用服务端渲染?
首先,在传统客户端渲染模式中,数据的请求和数据的渲染本质上都是通过浏览器来完成的。像基于 React 构建的 SPA 单页面应用中,在首次加载的时候,只是返回了只有一个挂载节点的 html,类似如下的样子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="vendors~main.js"></script>
<script type="text/javascript" src="main.js"></script>
</body>
</html>
如上整个应用中,就只有一个 app 根节点,那么整个页面的数据,首先需要通过 JS 向服务器请求数据,然后需要插入并渲染大量的元素节点。在这其中会浪费很多时间,那么这段时间内,页面是没有响应的,给用户直观的感受就是‘白屏’时间过长,这是非常不友好的用户体验。
尤其是一些手机端 h5 活动页面,白屏时间长就可能让用户失去等待的耐心,从而导致转化率和留存率降低。
为了解决这个问题,服务端渲染就应运而生了,服务端渲染在首次加载中,本质上请求一个服务端,服务端去请求数据,得到数据后,渲染数据得到一个有数据的 html 文件,浏览器收到完整的 html ,可以直接用来渲染。
服务端渲染和客户端渲染相比,由于少了初始化请求数据和通过 JS 向 html 中填充 DOM 的环节,所以会一定程度上缩短首屏时间,也就是减少白屏时间。
还有一些网站,想要获取流量,那么就要通过搜索引擎来曝光,这个时候,就需要 SEO,但是我们都知道,像 React 这种单页面应用,初始化的时候只有一个 app 节点,不能被爬虫爬取关健的信息,所以也就对 SEO 不够友好,但是服务端渲染初始化的时候,是能够返回含有关健信息的 html 文件的,重要信息能够被获取,所以服务端渲染这种方式也就更加利于 SEO 。
讲到了服务端渲染的优点之后,我们来看一下 React 中的服务端渲染 SSR。
二 React SSR 流程分析
React SSR 既保证了单页面 SPA 的特性,有解决了客户端渲染带来的白屏时间长 ,SEO 等问题。
React SSR 的流程和传统的客户端渲染有什么区别呢?
转成 html
当我们通过浏览器的 path 去跳转对应的页面的时候,首先访问的是一个 Node 服务器,Node 服务器会根据路径信息进行路由匹配,找到路由对应的组件。
接下来需要请求组件需要的初始化数据,这里记得一点就是,此时请求的数据是在服务端完成的。
请求数据之后,就可以通过 props 等方式把数据传递给组件,这里有的同学可能会有一些疑问,就是此时的运行时明明在服务端,那么组件怎么运行的呢?
在 React 中,组件本身就是一个函数,函数返回的是 React Element 对象,如果脱离 DOM 层级,React Element 是可以存在在任何环境下的,包括服务端 Node.js。
有了 element 结构, React 就可以向页面组件中注入数据,但是在服务端不能形成真正的 DOM ,不过只需要形成 html 模版就可以了,接下来交给浏览器,就会快速绘制静态 html 页面。如下就是组件转成 html 模块的方式:
import {
renderToString } from 'react-dom/server'
import Home from '../home'
import express from "express";
const app = express();
app.get('/home', (req, res) => {
/* 模拟请求数据 */
const dataSource = Home.fetchData()
/* 产生 html */
const homeString = renderToString(<Home dataSource={
dataSource} />)
const html = `
<html>
<body>${
homeString}</body>
</html>
`
/* express 提供的 render 方法 */
res.render(html)
});
app.listen(8080)
如上就是大致流程,这里要说的是 React 提供了 renderToString 方法,可以直接将注入数据的组件,转成 html 结构。
React 提供了两种方式将数据组件转成初始化页面结构,除了上面的 renderToString 还有一个就是 renderToNodeStream 。
两者的区别如下:
renderToString :将 React 组件转换成 html 字符串,renderToString生成的产物中会包含一些额外生成特殊标记,代码体积会有所增大,这是为了后面通过 hydrate 复用 html 节点做的准备,至于 hydrate 是干什么用的,下文中会讲到。
renderToNodeStream:通过名字就可以猜出来,这个 api 是转化成‘流’的方式传递给 response 对象的。 也就是说浏览器不用等待所有 html 结构的返回。
接下来我们做个实验,看一下经过 renderToString 处理后,到底变成了什么样子。
function Home({
name }){
return <div onClick={
()=>console.log('hello,React!')} >
name:{
name}
</div>
}
console.log(
renderToString(<Home name={
'React'} />)
)
如上的打印结果是:
<div>name:<!-- -->React</div>
可以看出 renderToString 就是转成了字符窜 DOM 元素结构,不过有特殊的标记,对于一些事件,renderToString 的处理逻辑是直接过滤。
Hydrate注水流程
经过上面的流程,已经能够返回给浏览器静态的 html 结构了,浏览器可以直接渲染 html 模版,解决了白屏时间过长 和 SEO 的问题,那么接下来面临的问题就是:
返回的只是静态的 html 结构,那么如何把视图数据,同步到客户端,因为我们都知道 React 框架是基于数据驱动视图的,现在页面上只是写死的 html 结构,数据和视图是怎么交给 React 客户端应用的。
怎么完成事件交互的,因为 html 模版返回的 DOM 元素是没有绑定任何事件的。
如何解决上面两个问题,让整个 React SSR 应用变活了呢?首先当完成初始化渲染之后,服务端渲染的使命就已经完成了,接下来的事情都是客户端也就是浏览器处理的,那么就需要在浏览器中真正的运行 React 的应用。
那么接下来的 React 应用,需要重新执行一遍,包括通过 JS 的方式来向服务端请求真正的数据,接管页面,接管页面上已经存在的 DOM 元素,这个过程叫“注水”(Hydrate),完成数据和视图的统一。
在纯浏览器中,构建的应用中,传统 legacy 模式下是通过 ReactDOM.render 构建整个 React 应用的。在传统模式下,是没有 DOM 元素的,而在服务端渲染模式下,是有 DOM 元素的,所以在初始化构建 React 应用的时候,要使用 ReactDOM 提供的 hydrate 方法,具体使用如下所示:
import React from 'react';
import ReactDOM from 'react-dom';
import Home from '../home'
ReactDOM.hydrate(<Home />, document.getElementById('app'));
如上 ReactDOM.hydrate 会复用服务端返回的 DOM 节点,然后就会走一遍浏览器的流程,包括事件绑定,那么接下来就能进行正常的用户交互了。
Reac 服务端渲染整个流程如下图所示:
React SSR 技术处理细节
在 React SSR 中还有一些细节需要注意,在 React 构建的 SPA 应用中,会存在多个页面,那么就需要 react-router 来注册多个页面,那么现在的问题就是在服务端是如何通过对应的路径,找到对应的路由组件呢?
有一个经典的处理方案,就是 react-router-config,在浏览器端,通过 react-router-config 提供的 renderRoutes 去渲染路由结构。
具体如下所示:
import {
BrowserRouter } from 'react-router-dom'
import {
renderRoutes } from 'react-router-config'
import Home from './Home'
import List from './List'
import Detail from './Detail'
export const routes = [
{
path: '/home',
component: Home,
},
{
path: '/list',
component: List,
},
{
path: '/detail',
component: Detail,
}
]
const Routers = <BrowserRouter>
{
renderRoutes(routes)}
<BrowserRouter/>
如上一共有 Home,List 和 Detail 三个页面,那么当初始化的时候路由为 /home 的时候,在服务端,同样需要 react-router-config 中提供的 matchRoutes 去找到对应的路由,如下所示:
import express from 'express'
import {
matchRoutes } from 'react-router-config'
import {
routes } from '../routes'
app.get('/home',()=>{
/* 查找对应的组件 */
const branch = matchRoutes(routes,'/home');
const Component = branch[0].route.component;
/* 得到 html 字符串 */
const html = renderToString(<Component />);
/* 返回浏览器渲染 */
res.end(html);
})
如上就是通过 matchRoutes 来找到对应的组件,转换成 html 字符串,并渲染的。
三 React 18 SSR 新特性
在 React v18 中 对服务端渲染 SSR 增加了流式渲染的特性 New Suspense SSR Architecture in React 18 , 那么这个特性是什么呢?我们来看一下:
刚开始的时候,因为服务端渲染,只会渲染 html 结构,此时还没注入 js 逻辑,所以我们把它用灰色不能交互的模块表示。(如上灰色的模块不能做用户交互,比如点击事件之类的。)
js 加载之后,此时的模块可以正常交互,所以用绿色的模块展示,我们可以把视图注入 js 逻辑的过程叫做 hydrate (注水)。
但是如果其中一个模块,服务端请求数据,数据量比较大,耗费时间长,我们不期望在服务端完全形成 html 之后在渲染,那么 React 18 给了一个新的可能性。可以使用 Suspense 包装页面的一部分,然后让这一部分的内容先挂起。
接下来会通过 script 加载 js 的方式 流式注入 html 代码的片段,来补充整个页面。接下来的流程如下所示:
- 页面 A B 是初始化渲染的,C 是 Suspense 处理的组件,在开始的时候 C 没有加载,C 通过流式渲染的方式优先注入 html 片段。
- 接下来 A B 注入逻辑,C 并没有注水。
- A B 注入逻辑之后,接下来 C 注入逻辑,这个时候整个页面就可以交互了。
在这个原理基础之上, React 个特性叫 Selective Hydration,可以根据用户交互改变 hydrate 的顺序。
比如有两个模块都是通过 Suspense 挂起的,当两个模块发生交互逻辑时,会根据交互来选择性地改变 hydrate 的顺序。
我们来看一下如上 hydrate 流程,在 SSR 上的流程如下:
- 初始化的渲染 A B 组件,C 和 D 通过 Suspense 的方式挂起。
- 接下来会优先注水 A B 的组件逻辑,流式渲染 C D 组件,此时 C D 并没有注入逻辑。
- 如果此时 D 发生交互,比如触发一次点击事件,那么 D 会优先注入逻辑。
- 接下来才是 C 注入逻辑,整个页面 hydrate 完毕。
四 React SSR 框架 Next.js
Next.js 是一个轻量级的 React 服务端渲染应用框架。Next.js 的上手也非常简单。
安装 next:
npm install --save next react react-dom
将下面脚本添加到 package.json 中:
{
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
}
我们看一下用 Next 编写的 demo 组件:
import App, {
Container} from 'next/app'
import React from 'react'
export default class MyApp extends App {
static async getInitialProps ({
Component, router, ctx }) {
let pageProps = {
}
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx)
}
return {
pageProps}
}
render () {
const {
Component, pageProps} = this.props
return <Container>
<Component {
...pageProps} />
</Container>
}
}
如上就是用 Next 编写的组件,在 Next 中提供了一个钩子就是 getInitialProps ,getInitialProps 会在服务端执行,一般用于请求初始化的数据。
五 总结
本章节介绍了 React 做 webview 开发的另外一种模式——SSR ,感兴趣的读者可以写一个 Next.js 的项目练练手,官方文档也比较清晰,比较容易上手。