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

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

本文實例代碼已經上傳到 github,感興趣的可參見 Basic | SplitChunkVhtml

初識 React SSR

nodejs遵循 commonjs規範,文件的導入導出以下:前端

// 導出
module.exports = someModule
// 導入
const module = require('./someModule')
複製代碼

而咱們一般所寫的 react代碼是遵循 esModule規範的,文件的導入導出以下:node

// 導出
export default someModule
// 導入
import module from './someModule'
複製代碼

因此想要讓 react代碼兼容於服務器端,就必須先解決這兩種規範的兼容問題,實際上 react是能夠直接以 commonjs規範來書寫的,例如:react

const React = require('react')
複製代碼

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

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

客戶端編譯 react代碼用到最多的就是 webpack,服務器端一樣可使用,這裏使用 webpack的做用有兩個:git

  • jsx編譯爲 node認識的原生 js代碼
  • exModule代碼編譯成 commonjs

webpack示例配置文件以下:github

// 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']
            }
          }]]
        }
      }
    ]
  }
}
複製代碼

有了這份配置文件以後,就能夠愉快的寫代碼了web

首先是一份須要輸出到客戶端的 react代碼:redux

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-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)

而服務器端將狀態連同 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

相關文章
相關標籤/搜索