React 服务端渲染在跨端领域中的新视界

5,477 阅读10分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

一 前言

目前在移动端 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 服务端渲染整个流程如下图所示:

7-3-1的副本.jpeg

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 , 那么这个特性是什么呢?我们来看一下:

WechatIMG6936.jpeg

刚开始的时候,因为服务端渲染,只会渲染 html 结构,此时还没注入 js 逻辑,所以我们把它用灰色不能交互的模块表示。(如上灰色的模块不能做用户交互,比如点击事件之类的。)

js 加载之后,此时的模块可以正常交互,所以用绿色的模块展示,我们可以把视图注入 js 逻辑的过程叫做 hydrate (注水)。

但是如果其中一个模块,服务端请求数据,数据量比较大,耗费时间长,我们不期望在服务端完全形成 html 之后在渲染,那么 React 18 给了一个新的可能性。可以使用 Suspense 包装页面的一部分,然后让这一部分的内容先挂起。

接下来会通过 script 加载 js 的方式 流式注入 html 代码的片段,来补充整个页面。接下来的流程如下所示:

d94d8ddb-bdcd-4be8-a851-4927c7966b99.png

  • 页面 A B 是初始化渲染的,C 是 Suspense 处理的组件,在开始的时候 C 没有加载,C 通过流式渲染的方式优先注入 html 片段。
  • 接下来 A B 注入逻辑,C 并没有注水。
  • A B 注入逻辑之后,接下来 C 注入逻辑,这个时候整个页面就可以交互了。

在这个原理基础之上, React 个特性叫 Selective Hydration,可以根据用户交互改变 hydrate 的顺序

比如有两个模块都是通过 Suspense 挂起的,当两个模块发生交互逻辑时,会根据交互来选择性地改变 hydrate 的顺序。

ede45613-9994-4e77-9f50-5b7c1faf1160.png

我们来看一下如上 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 的项目练练手,官方文档也比较清晰,比较容易上手。