React SSR(伺服器端渲染) 細微探究

清夜發表於2019-02-27

最近看了下 React SSR相關的東西,這裡記錄一下相關內容

本文例項程式碼已經上傳到 github,感興趣的可參見 Basic | SplitChunkV

初識 React SSR

nodejs遵循 commonjs規範,檔案的匯入匯出如下:

// 匯出
module.exports = someModule
// 匯入
const module = require('./someModule')
複製程式碼

而我們通常所寫的 react程式碼是遵循 esModule規範的,檔案的匯入匯出如下:

// 匯出
export default someModule
// 匯入
import module from './someModule'
複製程式碼

所以想要讓 react程式碼相容於伺服器端,就必須先解決這兩種規範的相容問題,實際上 react是可以直接以 commonjs規範來書寫的,例如:

const React = require('react')
複製程式碼

這樣一看似乎就是個寫法的轉換罷了,沒什麼問題,但實際上,這只是解決了其中一個問題而已,react中常見的渲染程式碼,即 jsxnode是不認識的,必須要編譯一次

render () {
  // node是不認識 jsx的
  return <div>home</div>
}
複製程式碼

客戶端編譯 react程式碼用到最多的就是 webpack,伺服器端同樣可以使用,這裡使用 webpack的作用有兩個:

  • jsx編譯為 node認識的原生 js程式碼
  • exModule程式碼編譯成 commonjs

webpack示例配置檔案如下:

// webpack.server.js
module.exports = {
  // 省略程式碼...
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          // 需要支援 react
          // 需要轉換 stage-0
          presets: ['react', 'stage-0', ['env', {
            targets: {
              browsers: ['last 2 versions']
            }
          }]]
        }
      }
    ]
  }
}
複製程式碼

有了這份配置檔案之後,就可以愉快的寫程式碼了

首先是一份需要輸出到客戶端的 react程式碼:

import React from 'react'

export default () => {
  return <div>home</div>
}
複製程式碼

這份程式碼很簡單,就是一個普通的 react stateless元件

然後是負責將這個元件輸出到客戶端的伺服器端程式碼:

// index.js
import http from 'http'
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from './containers/Home/index.js'

const container = renderToString(<Home />)

http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/html'})
  response.end(`
    <!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">
      <title>Document</title>
    </head>
    <body>
      <div id="root">${container}</div>
    </body>
    </html>
  `)
}).listen(8888)

console.log('Server running at http://127.0.0.1:8888/')
複製程式碼

上述程式碼就是啟動了一個 node http伺服器,響應了一個 html頁面原始碼,只不過相比於常見的 node伺服器端程式碼而言,這裡還引入了 react相關庫

我們通常所寫的 React程式碼,其渲染頁面的動作,其實是 react呼叫瀏覽器相關 API實時進行的,即頁面是由 js操縱瀏覽器DOM API組裝而成,伺服器端是無法呼叫瀏覽器 API的,所以這個過程無法進行,這個時候就需要藉助 renderToString

renderToStringReact提供的用於將 React程式碼轉換為瀏覽器可直接識別的 html字串的 API,可以認為此 API提前將瀏覽器要做的事情做好了,直接在伺服器端將DOM字串拼湊完成,交給 node輸出到瀏覽器

上述程式碼中的變數 container,其實就是如下的 html字串:

<div data-reactroot="">home</div>
複製程式碼

所以,node響應到瀏覽器端的就是一個正常的 html字串了,瀏覽器直接展示即可,由於瀏覽器端不需要下載 react程式碼,程式碼體積更小,也不需要實時拼接 DOM字串,只是簡單地進行渲染頁面的動作,因而伺服器端渲染的速度會比較快

另外,除了 renderToString之外,React v16.x還提供了另外一個功能更加強大的 APIrenderToNodeStream renderToNodeStream支援直接渲染到節點流。渲染到流可以減少你的內容的第一個位元組(TTFB)的時間,在文件的下一部分生成之前,將文件的開頭至結尾傳送到瀏覽器。 當內容從伺服器流式傳輸時,瀏覽器將開始解析HTML文件,有的文章稱此 API的渲染速度是 renderToString的三倍(到底幾倍我沒測過,不過一般情況下渲染速度會更快是真的)

所以,如果你使用的是 React v16.x,你還可以這麼寫:

import http from 'http'
import React from 'react'
// 這裡使用了 renderToNodeStream
import { renderToNodeStream } from 'react-dom/server'
import Home from './containers/Home/index.js'

http.createServer((request, response) => {
  response.writeHead(200, {'Content-Type': 'text/html'})
  response.write(`
    <!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">
      <title>Document</title>
    </head>
    <body>
      <div id="root">
  `)
  const container = renderToNodeStream(<Home />)
  // 這裡使用到了 資料流的概念,所以需要以流的形式進行傳送資料
  container.pipe(response, { end: false })
  container.on('end', () => {
    // 響應流結束
    response.end(`
      </div>
      </body>
      </html>
    `)
  })
}).listen(8888)

console.log('Server running at http://127.0.0.1:8888/')
複製程式碼

BOM / DOM 相關邏輯同構

有了 renderToString / renderToNodeStream之後,似乎伺服器端渲染觸手可及,但實際上還差得遠了,對於如下 react程式碼:

const Home = () => {
  return <button onClick={() => { alert(123) }}>home</button>
}
複製程式碼

期望是點選按鈕的時候,瀏覽器會彈出一個提示 123的彈窗,但是如果只是按照上述的流程,其實這個事件並不會被觸發,原因在於 renderToString只會解析基本的 html DOM元素,並不會解析元素上附加的事件,也就是會忽略掉 onClick這個事件

onClick是個事件,在我們通常所寫的程式碼中(即非 SSR), React是通過對元素進行 addEventListener來進行事件的註冊,也就是通過 js來觸發事件,並呼叫相應的方法,而伺服器端顯然是無法完成這個操作的,除此之外,一些與瀏覽器相關的操作也都是無法在伺服器端完成的

不過這些並不影響 SSRSSR目的之一是為了能讓瀏覽器端更快地渲染出頁面,使用者互動操作的可執行性不必非要跟隨頁面 DOM同時完成,所以,我們可以將這部分瀏覽器相關執行程式碼打包成一個 js檔案傳送到瀏覽器端,在瀏覽器端渲染出頁面後,再載入並執行這段 js,整個頁面自然也就擁有了可執行性

為了簡化操作,下面在伺服器端引入 Koa

既然瀏覽器端也需要執行一遍 Home元件,那麼就需要另外準備一份給瀏覽器端使用的Home打包檔案:

// client
import React from 'react'
import ReactDOM from 'react-dom'

import Home from '../containers/Home'

ReactDOM.render(<Home />, document.getElementById('root'))
複製程式碼

就是平常寫得瀏覽器端 React程式碼,把 Home元件又打包了一次,然後渲染到頁面節點上

另外,如果你用的是 React v16.x,上述程式碼的最後一句建議這麼寫:

// 省略程式碼...
ReactDOM.hydrate(<Home />, document.getElementById('root'))
複製程式碼

ReactDOM.renderReactDOM.hydrate 之間主要的區別就在於後者有更小的效能開銷(只用於伺服器端渲染),更多詳細可見 hydrate

需要將這份程式碼打包成一段 js程式碼,並傳送到瀏覽器端,所以這裡還需要對類似的客戶端同構程式碼進行 webpack的配置:

// webpack.client.js
const path = require('path')

module.exports = {
  // 入口檔案
  entry: './src/client/index.js',
  // 表示是開發環境還是生產環境的程式碼
  mode: 'development',
  // 輸出資訊
  output: {
    // 輸出檔名
    filename: 'index.js',
    // 輸出檔案路徑
    path: path.resolve(__dirname, 'public')
  },
  // ...
}
複製程式碼

這份配置檔案與伺服器端的配置檔案 webpack.server.js相差無幾,只是去除了伺服器端相關的一些配置罷了

此配置檔案宣告將 Home元件打包到 public目錄下,檔名為 index.js,所以我們只要在伺服器端輸出的 html頁面中,將這個檔案載入進去即可:

// server
// 省略無關程式碼...
app.use(ctx => {
  ctx.response.type = 'html'
  ctx.body = `
    <!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">
        <title>Document</title>
      </head>
      <body>
        <div id="root">${container}</div>
        <!-- 引入同構程式碼 -->
        <script src="/index.js"></script>
      </body>
    </html>
  `
})
app.listen(3000)
複製程式碼

對於 Home這個元件來說,它在伺服器端被執行了一次,主要是通過 renderToString/renderToNodeStream生成純淨的 html元素,又在客戶端執行了一次,主要是將事件等進行正確地註冊,二者結合,就整合出了一個可正常互動的頁面,這種伺服器端和客戶端執行同一套程式碼的操作,也稱為 同構

路由同構(Router)

解決了事件等 js相關的程式碼同構後,還需要對路由進行同構

一般情況下在 react程式碼中會使用 react-router進行路由的管理,這裡在伺服器端傳送給瀏覽器端的同構程式碼中,依舊按照通用做法即可(HashRouter/BrowserRouter),這裡以 BrowserRouter為例

路由的定義:

import React, { Fragment } from 'React'
import { Route } from 'react-router-dom'

import Home from './containers/Home'
import Login from './containers/Login'

export default (
  <Fragment>
    <Route path='/' exact component={Home}></Route>
    <Route path='/login' exact component={Login}></Route>
  </Fragment>
)
複製程式碼

瀏覽器端程式碼引入:

import React from 'react'
import ReactDOM from 'react-dom'
// 這裡以 BrowserRouter 為例,HashRouter也是可以的
import { BrowserRouter } from 'react-router-dom'
// 引入定義的路由
import Routes from '../Routes'
const App = () => {
  return (
    <BrowserRouter>
      {Routes}
    </BrowserRouter>
  )
}
ReactDOM.hydrate(<App />, document.getElementById('root'))
複製程式碼

主要在於伺服器端的路由引入:

// 使用 StaticRouter
import { StaticRouter } from 'react-router-dom'
import Routes from '../Routes'
// ...
app.use(ctx => {
  const container = renderToNodeStream(
    <StaticRouter location={ctx.request.path} context={{}}>
      {Routes}
    </StaticRouter>
  )
  // ...
})
複製程式碼

伺服器端的路由是無狀態的,也就是不會記錄一些路由的操作,無法自動獲知瀏覽器端的路由變化和路由狀態,因為這都是瀏覽器的東西,React-router 4.x為伺服器端提供了 StaticRouter用於路由的控制,此API通過傳入的 location引數來被動獲取當前請求的路由,從而進行路由的匹配與導航,更多詳細可見 StaticRouter

狀態同構(State)

當專案比較大的時候,通常我們會使用 redux來對專案進行資料狀態的管理,為了保證伺服器端的狀態與客戶端狀態的一致性,還需要對狀態進行同構

伺服器端的程式碼是給所有使用者使用的,必須要獨立開所有使用者的資料狀態,否則會導致所有使用者共用了同一個狀態

// 這種寫法在客戶端可取,但在伺服器端會導致所有使用者共用了同一個狀態
// export default createStore(reducer, applyMiddleware(thunk))
export default () => createStore(reducer, applyMiddleware(thunk))
複製程式碼

注意上述程式碼匯出的是一個函式而不是一個 store物件,想要獲取 store只需要執行這個函式即可:

import getStore from '../store'
// ...
<Provider store={getStore()}>
  <StaticRouter location={ctx.request.path} context={context}>
    {Routes}
  </StaticRouter>
</Provider>
複製程式碼

這樣一來就能保證伺服器端在每次接收到請求的時候,都重新生成一個新的 store,也就相當於每個請求都拿到了一個獨立的全新狀態

上面只是解決了狀態獨立性問題,但 SSR狀態同步的關鍵點在於非同步資料的同步,例如常見的資料介面的呼叫,這就是一個非同步操作,如果你像在客戶端中使用 redux來進行非同步操作那樣在伺服器端也這樣做,那麼雖然專案不會報錯,頁面也能正常渲染,但實際上,這部分非同步獲取的資料,在伺服器端渲染出的頁面中是缺失的

這很好理解,伺服器端雖然也可以進行資料介面的請求操作,但由於介面請求是非同步的,而頁面渲染是同步的,很可能在伺服器響應輸出頁面的時候,非同步請求的資料還沒有返回,那麼渲染出來的頁面自然就缺失資料了

既然是因為非同步獲取資料的問題導致資料狀態的丟失,那麼只要保證能在伺服器端響應頁面之前,就拿到頁面所需要的正確資料,問題也就解決了

這裡其實存在兩個問題:

  • 需要知道當前請求的是哪個頁面,因為不同的頁面所需要的資料一般都是不同的,所需要請求的介面和資料處理的邏輯也都是不同
  • 需要保證伺服器端在響應頁面之前就已經從介面拿到了資料,也就是拿到了處理好的狀態(store)

對於第一個問題,react-router 其實已經在 SSR方面給出瞭解決方案,即通過 配置路由/route-config 結合 matchPath,找到頁面上相關元件所需的請求介面的方法並執行:

React SSR(伺服器端渲染) 細微探究

另外,react-router提供的 matchPath只能識別一級路由,對於多級路由來說只能識別最頂級的那個而會忽略子級別路由,所以如果專案不存在多級路由或者所有的資料獲取和狀態處理都是在頂級路由中完成的,那麼使用 matchPath是沒有問題的,否則就可能出現子級路由下的頁面資料丟失問題

對於這個問題,react-router也給出了 解決方案,即由開發者自行使用 react-router-config中提供的 matchRoutes 來替代 matchPath

對於第二個問題,其實這就容易多了,就是 js程式碼中常見的非同步操作同步化,最常用的 Promiseasync/await都可以解決這個問題

const store = getStore()
const promises = []
// 匹配的路由
const mtRoutes = matchRoutes(routes, ctx.request.path)
mtRoutes.forEach(item => {
  if (item.route.loadData) {
    promises.push(item.route.loadData(store))
  }
})
// 這裡伺服器請求資料介面,獲取當前頁面所需的資料,填充到 store中用於渲染頁面
await Promise.all(promises)
// 伺服器端輸出頁面
await render(ctx, store, routes)
複製程式碼

然而,解決了這個問題之後,另一個問題又來了

前面說了,SSR的過程要保證伺服器端和客戶端頁面的資料狀態一致,根據上述流程,伺服器端最終會輸出一個帶有資料狀態的完整頁面,但是客戶端這邊的程式碼邏輯,是首先渲染出一個沒有資料狀態的頁面架子,之後才會在 componentDidMount之類的鉤子函式裡發起資料介面請求拿到資料,進行狀態處理,最後得到的頁面才和伺服器端輸出的一致

那麼在客戶端程式碼拿到資料之前的這段時間,客戶端的資料狀態其實是空的,而伺服器端的資料狀態是完整的,所以兩端資料狀態不一致,就會出問題

解決這個問題的流程,其實就是資料的 脫水注水

在伺服器端,當服務端請求介面拿到資料,並處理好資料狀態(例如 store的更新)後,保留住這個狀態,在伺服器端響應頁面HTML的時候,將這個狀態一併傳遞給瀏覽器,這個過程,叫做脫水(Dehydrate);在瀏覽器端,就直接拿這個脫水資料來初始化 React元件,也就是客戶端不需要自己發起請求獲取資料處理狀態了,因為伺服器端已經做好了這件事情,直接從伺服器端那裡獲取處理好的狀態即可,這個過程叫注水(Hydrate)

React SSR(伺服器端渲染) 細微探究

而伺服器端將狀態連同 html一併傳送給瀏覽器端的方式,一般都是通過全域性變數完成:

ctx.body = `
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta httpquiv="X-UA-Compatible" content="ie=edge">
      <title>Document</title>
    </head>
    <body>
      <div id="root">${data.toString()}</div>
      <!-- 從伺服器端拿到脫水的資料狀態 -->
      <script>
        window.context = {
          state: ${JSON.stringify(store.getState())}
        }
      </script>
      <!-- 引入同構程式碼 -->
      <script src="/index.js"></script>
    </body>
  </html>
`
複製程式碼

然後瀏覽器端在接收到伺服器端傳送來的頁面後,就可以直接從 window物件上獲取到狀態了,然後使用此狀態來更新瀏覽器端本身的狀態即可:

export const getClientStore = () => {
  // 從伺服器端輸出的頁面上拿到脫水的資料
  const defaultState = window.context.state
  // 當做 store的初始資料(即注水)
  return createStore(reducer, defaultState, applyMiddleware(thunk))
}
複製程式碼

引入樣式

樣式的引入就比較簡單了,可以從兩個角度來考慮:

  • 在伺服器端輸出 html文件的同時,在 html上加個 <style>標籤,此標籤內部寫入樣式字串,一同傳送到客戶端
  • 在伺服器端輸出 html文件的同時,在 html上加個 <link>標籤,此標籤的 href指向一份樣式檔案,此樣式檔案就是頁面的樣式檔案

這兩種操作大體思路差不多,而且和在客戶端渲染中引入樣式的流程也差不多,主要是藉助 webpack,通過 loader外掛將 react元件內寫入的樣式提取出來,與此相關的 loader外掛一般有 css-loaderstyle-loaderextract-text-webpack-plugin / mini-css-extract-plugin,如果使用了 css後處理器的話,那麼可能還需要 sass-loaderless-loader等,這裡不考慮這些複雜情形,只針對最基本的 css引入

內聯樣式

針對第一種使用內聯樣式,直接把樣式嵌入到頁面中,需要用到 css--loaderstyle-loadercss-loader可以繼續用,但是 style-loader由於存在一些跟瀏覽器相關的邏輯,所以無法在伺服器端繼續用了,但好在早就有了替代外掛,isomorphic-style-loader,此外掛用法跟 style-loader差不多,但是同時支援在伺服器端使用

isomorphic-style-loader 會將匯入 css檔案轉換成一個物件供元件使用,其中一部分屬性是類名,屬性的值是類對應的 css樣式,所以可以直接根據這些屬性在元件內引入樣式,除此之外,還包括幾個方法,SSR需要呼叫其中的 _getCss方法以獲取樣式字串,傳輸到客戶端

鑑於上述過程(即將 css樣式彙總及轉化為字串)是一個通用流程,所以此外掛專案內主動提供了一個用於簡化此流程的 HOC元件:withStyles.js

此元件所做的事情也很簡單,主要是為 isomorphic-style-loader中的兩個方法:__insertCss_getCss 提供了一個介面,以 Context 作為媒介,傳遞各個元件所引用的樣式,最後在服務端和客戶端進行彙總,這樣一來,就能夠在服務端和客戶端輸出樣式了

服務端:

import StyleContext from 'isomorphic-style-loader/StyleContext'
// ...
const css = new Set()
const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
const container = renderToNodeStream(
  <Provider store={store}>
    <StaticRouter location={ctx.request.path} context={context}>
      <StyleContext.Provider value={{ insertCss }}>
        {renderRoutes(routes)}
      </StyleContext.Provider>
    </StaticRouter>
  </Provider>
)
複製程式碼

客戶端:

import StyleContext from 'isomorphic-style-loader/StyleContext'
// ...
const insertCss = (...styles) => {
  const removeCss = styles.map(style => style._insertCss())
  return () => removeCss.forEach(dispose => dispose())
}

const App = () => {
  return (
    <Provider store={store}>
      <BrowserRouter>
        <StyleContext.Provider value={{ insertCss }}>
          {renderRoutes(routes)}
        </StyleContext.Provider>
      </BrowserRouter>
    </Provider>
  )
}
複製程式碼

此高階元件的用法,isomorphic-style-loaderREADME.md上已經說得很清楚了,主要就是 Contextisomorphic-style-loader@5.0.1之前版本是舊版Context API5.0.1及之後是新版Context API)以及高階元件HOC的使用

外聯樣式

一般在生產環境大部分都是用外聯樣式,使用 <link>標籤在頁面上引入樣式檔案即可,這種其實和上面外聯引入 js的做法是同一個處理邏輯,相比於內聯 引入CSS更簡單易懂些,服務端和客戶端的處理流程也基本相同

mini-css-extract-plugin 是一個常用的抽取元件樣式的 webpack外掛,由於此外掛本質上就是將樣式字串從各元件中抽取出來,整合到一個樣式檔案中,只是 JS Core的操作,所以不存在伺服器端和瀏覽器端的說法,也就用不著進行同構,以前是如何在純客戶端使用這個外掛的,現在就怎麼在 SSR中使用,這裡就不多說了

程式碼分割

SSR的一個重要目的就是加速首屏渲染,因此原有客戶端渲染的優化措施,也應該在 SSR上使用,其中一個關鍵點就是程式碼分割

React的程式碼分割枯有很多,例如 babel-plugin-syntax-dynamic-importreact-loadableloadable-components

一般習慣於使用的庫是 react-loadable,但是我使用的時候遇到了一些問題,想查 issues的時候,發現這個專案居然把 issues給關了,於是棄之,改用更 modernloadable-components,此庫文件齊全,而且也考慮到了 SSR的情況,並且支援以renderToNodeStream的形式渲染頁面,只需照著文件做就 ok了,很簡單上手,這裡也不多說了,具體可參見 SplitChunkV

總結

SSR配置起來還是比較麻煩的一個東西,不只是前端層面上的配置,還需要考慮到後端程式相關的東西,例如登入態、高併發、負載均衡、記憶體管理等,Winter 曾表示對於 SSR 不太看好,其主要是用於 SEO,不太建議用做服務端渲染,其能夠使用的場景不多,而且成本代價太大

所以對於實際開發來說,個人更建議直接使用業內相對成熟的輪子,例如 ReactNext.jsVueNuxt.js

相關文章