最近看了下 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
中常見的渲染程式碼,即 jsx
,node
是不認識的,必須要編譯一次
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
了
renderToString
是 React
提供的用於將 React
程式碼轉換為瀏覽器可直接識別的 html
字串的 API
,可以認為此 API
提前將瀏覽器要做的事情做好了,直接在伺服器端將DOM
字串拼湊完成,交給 node
輸出到瀏覽器
上述程式碼中的變數 container
,其實就是如下的 html
字串:
<div data-reactroot="">home</div>
複製程式碼
所以,node
響應到瀏覽器端的就是一個正常的 html
字串了,瀏覽器直接展示即可,由於瀏覽器端不需要下載 react
程式碼,程式碼體積更小,也不需要實時拼接 DOM
字串,只是簡單地進行渲染頁面的動作,因而伺服器端渲染的速度會比較快
另外,除了 renderToString
之外,React v16.x
還提供了另外一個功能更加強大的 API
:renderToNodeStream
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
來觸發事件,並呼叫相應的方法,而伺服器端顯然是無法完成這個操作的,除此之外,一些與瀏覽器相關的操作也都是無法在伺服器端完成的
不過這些並不影響 SSR
,SSR
目的之一是為了能讓瀏覽器端更快地渲染出頁面,使用者互動操作的可執行性不必非要跟隨頁面 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.render
與 ReactDOM.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
程式碼中常見的非同步操作同步化,最常用的 Promise
或 async/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-loader
、style-loader
、extract-text-webpack-plugin / mini-css-extract-plugin
,如果使用了 css
後處理器的話,那麼可能還需要 sass-loader
或less-loader
等,這裡不考慮這些複雜情形,只針對最基本的 css
引入
內聯樣式
針對第一種使用內聯樣式,直接把樣式嵌入到頁面中,需要用到 css--loader
和 style-loader
, css-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-loader 的 README.md上已經說得很清楚了,主要就是 Context(isomorphic-style-loader@5.0.1
之前版本是舊版Context API
,5.0.1
及之後是新版Context API
)以及高階元件HOC
的使用
外聯樣式
一般在生產環境大部分都是用外聯樣式,使用 <link>
標籤在頁面上引入樣式檔案即可,這種其實和上面外聯引入 js
的做法是同一個處理邏輯,相比於內聯 引入CSS
更簡單易懂些,服務端和客戶端的處理流程也基本相同
mini-css-extract-plugin 是一個常用的抽取元件樣式的 webpack
外掛,由於此外掛本質上就是將樣式字串從各元件中抽取出來,整合到一個樣式檔案中,只是 JS Core
的操作,所以不存在伺服器端和瀏覽器端的說法,也就用不著進行同構,以前是如何在純客戶端使用這個外掛的,現在就怎麼在 SSR
中使用,這裡就不多說了
程式碼分割
SSR
的一個重要目的就是加速首屏渲染,因此原有客戶端渲染的優化措施,也應該在 SSR
上使用,其中一個關鍵點就是程式碼分割
React
的程式碼分割枯有很多,例如 babel-plugin-syntax-dynamic-import、react-loadable、loadable-components等
一般習慣於使用的庫是 react-loadable,但是我使用的時候遇到了一些問題,想查 issues
的時候,發現這個專案居然把 issues
給關了,於是棄之,改用更 modern
的 loadable-components,此庫文件齊全,而且也考慮到了 SSR的情況,並且支援以renderToNodeStream
的形式渲染頁面,只需照著文件做就 ok
了,很簡單上手,這裡也不多說了,具體可參見 SplitChunkV
總結
SSR
配置起來還是比較麻煩的一個東西,不只是前端層面上的配置,還需要考慮到後端程式相關的東西,例如登入態、高併發、負載均衡、記憶體管理等,Winter 曾表示對於 SSR
不太看好,其主要是用於 SEO
,不太建議用做服務端渲染,其能夠使用的場景不多,而且成本代價太大
所以對於實際開發來說,個人更建議直接使用業內相對成熟的輪子,例如 React
的 Next.js,Vue
的 Nuxt.js