React 服務端渲染在跨端領域中的新視界
一 前言
目前在移動端 app 應用中,有很多使用到活動頁面的場景,因爲這些活動頁面更新頻繁,迭代快,所以都是採用 webview h5 的方式。而這些 h5 頁面很多都是採用服務端渲染 SSR 加載的。
提到了服務端渲染 SSR ,就會引出一個問題 爲什麼要用服務端渲染?
首先,在傳統客戶端渲染模式中,數據的請求和數據的渲染本質上都是通過瀏覽器來完成的。像基於 React 構建的 SPA 單頁面應用中,在首次加載的時候,只是返回了只有一個掛載節點的 html,類似如下的樣子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta />
<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 的流程和傳統的客戶端渲染有什麼區別呢?
轉成 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 的項目練練手,官方文檔也比較清晰,比較容易上手。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/kvJqo1LRMF-Dg1elugHC7g