最近看了下 React SSR
相關的東西,這裏記錄一下相關內容css
本文實例代碼已經上傳到 github,感興趣的可參見 Basic | SplitChunkVhtml
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
中常見的渲染代碼,即 jsx
,node
是不認識的,必需要編譯一次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
了
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/')
複製代碼
有了 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
元素,又在客戶端運行了一次,主要是將事件等進行正確地註冊,兩者結合,就整合出了一個可正常交互的頁面,這種服務器端和客戶端運行同一套代碼的操做,也稱爲 同構
解決了事件等 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
當項目比較大的時候,一般咱們會使用 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