前言
這篇文章是我自己在搭建個人網站的過程中,用到了服務端渲染,看了一些教程,踩了一些坑。想把這個過程分享出來。 我會盡力把每個步驟講明白,將我理解的全部講出來。
文中的示例程式碼來自於這個倉庫,也是我正在搭建的個人網站,大家可以一起交流一下。示例程式碼因為簡化,所以與倉庫程式碼有些許出入。
本文中用到的技術 React V16 | React-Router v4 | Redux | Redux-thunk | express
React 服務端渲染
服務端渲染的基本套路就是使用者請求過來的時候,在服務端生成一個我們希望看到的網頁內容的HTML字串,返回給瀏覽器去展示。 瀏覽器拿到了這個HTML之後,渲染出頁面,但是並沒有事件互動,這時候瀏覽器發現HTML中載入了一些js檔案(也就是瀏覽器端渲染的js),就直接去載入。 載入好並執行完以後,事件就會被繫結上了。這時候頁面被瀏覽器端接管了。也就是到了我們熟悉的js渲染頁面的過程。
需要實現的目標:
- React元件服務端渲染
- 路由的服務端渲染
- 保證服務端和瀏覽器的資料唯一
- css的服務端渲染(樣式直出)
一般的渲染方式
- 服務端渲染:服務端生成html字串,傳送給瀏覽器進行渲染。
- 瀏覽器端渲染:服務端返回空的html檔案,內部載入js,完全由js完成頁面的渲染
優點與缺點
服務端渲染解決了首屏載入速度慢以及seo不友好的缺點(Google已經可以檢索到瀏覽器渲染的網頁,但不是所有搜尋引擎都可以) 但增加了專案的複雜程度,提高維護成本。
如果非必須,儘量不要用服務端渲染
整體思路
需要兩個端:服務端、瀏覽器端(瀏覽器渲染的部分) 第一: 打包瀏覽器端程式碼 第二: 打包服務端程式碼並啟動服務 第三: 使用者訪問,服務端讀取瀏覽器端打包好的index.html檔案為字串,將渲染好的元件、樣式、資料塞入html字串,返回給瀏覽器 第四: 瀏覽器直接渲染接收到的html內容,並且載入打包好的瀏覽器端js檔案,進行事件繫結,初始化狀態資料,完成同構
React元件的服務端渲染
讓我們來看一個最簡單的React服務端渲染的過程。 要進行服務端渲染的話那必然得需要一個根元件,來負責生成HTML結構
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.hydrate(<Container />, document.getElementById('root'));
複製程式碼
當然這裡用ReactDOM.render也是可以的,只不過hydrate會盡量複用接收到的服務端返回的內容, 來補充事件繫結和瀏覽器端其他特有的過程
引入瀏覽器端需要渲染的根元件,利用react的 renderToString API進行渲染
import { renderToString } from 'react-dom/server'
import Container from '../containers'
// 產生html
const content = renderToString(<Container/>)
const html = `
<html>
<body>${content}</body>
</html>
`
res.send(html)
複製程式碼
在這裡,renderToString也可以替換成renderToNodeStream,區別在於前者是同步地產生HTML,也就是如果生成HTML用了1000毫秒, 那麼就會在1000毫秒之後才將內容返回給瀏覽器,顯然耗時過長。而後者則是以流的形式,將渲染結果塞給response物件,就是出來多少就 返回給瀏覽器多少,可以相對減少耗時
路由的服務端渲染
一般場景下,我們的應用不可能只有一個頁面,肯定會有路由跳轉。我們一般這麼用:
import { BrowserRouter, Route } from 'react-router-dom'
const App = () => (
<BrowserRouter>
{/*...Routes*/}
<BrowserRouter/>
)
複製程式碼
但這是瀏覽器端渲染時候的用法。在做服務端渲染時,需要使用將BrowserRouter 替換為 StaticRouter 區別在於,BrowserRouter 會通過HTML5 提供的 history API來保持頁面與URL的同步,而StaticRouter 則不會改變URL
import { createServer } from 'http'
import { StaticRouter } from 'react-router-dom'
createServer((req, res) => {
const html = renderToString(
<StaticRouter
location={req.url}
context={{}}
>
<Container />
<StaticRouter/>)
})
複製程式碼
這裡,StaticRouter要接收兩個屬性:
- location: StaticRouter 會根據這個屬性,自動匹配對應的React元件,所以才會實現重新整理頁面,服務端返回的對應路由的組與瀏覽器端保持一致
- context: 一般用來傳遞一些資料,相當於一個載體,之後講到樣式的服務端渲染的時候會用到
Redux同構
資料的預獲取以及脫水與注水我認為是服務端渲染的難點。
這是什麼意思呢?也就是說首屏渲染的網頁一般要去請求外部資料,我們希望在生成HTML之前,去獲取到這個頁面需要的所有資料, 然後塞到頁面中去,這個過程,叫做“脫水”(Dehydrate),生成HTML返回給瀏覽器。瀏覽器拿到帶著資料的HTML, 去請求瀏覽器端js,接管頁面,用這個資料來初始化元件。這個過程叫“注水”(Hydrate)。完成服務端與瀏覽器端資料的統一。
為什麼要這麼做呢?試想一下,假設沒有資料的預獲取,直接返回一個沒有資料,只有固定內容的HTML結構,會有什麼結果呢?
第一:由於頁面內沒有有效資訊,不利於SEO。
第二:由於返回的頁面沒有內容,但瀏覽器端JS接管頁面後回去請求資料、渲染資料,頁面會閃一下,使用者體驗不好。
我們使用Redux來管理狀態,因為有服務端程式碼和瀏覽器端程式碼,那麼就分別需要兩個store來管理服務端和瀏覽器端的資料。
元件的配置
元件要在服務端渲染的時候去請求資料,可以在元件上掛載一個專門發非同步請求的方法,這裡叫做loadData,接收服務端的store作為引數, 然後store.dispatch去擴充服務端的store。
class Home extends React.Component {
componentDidMount() {
this.props.callApi()
}
render() {
return <div>{this.props.state.name}</div>
}
}
Home.loadData = store => {
return store.dispatch(callApi())
}
const mapState = state => state
const mapDispatch = {callApi}
export default connect(mapState, mapDispatch)(Home)
複製程式碼
路由的改造
因為服務端要根據路由判斷當前渲染哪個元件,可以在這個時候傳送非同步請求。所以路由也需要配置一下來支援loadData方法。服務端渲染的時候, 路由的渲染可以使用react-router-config這個庫,用法如下(重點關注在路由上掛載loadData方法):
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import Home from './Home'
export const routes = [
{
path: '/',
component: Home,
loadData: Home.loadData,
exact: true,
}
]
const Routers = <BrowserRouter>
{renderRoutes(routes)}
<BrowserRouter/>
複製程式碼
服務端獲取資料
到了服務端,需要判斷匹配的路由內的所有元件各自都有沒有loadData方法,有就去呼叫, 傳入服務端的store,去擴充服務端的store。*同時還要注意到,一個頁面可能是由多個元件組成的,*會發各自的請求,也就意味著我們要等所有的請求都發完,再去返回HTML。
import express from 'express'
import serverRender from './render'
import { matchRoutes } from 'react-router-config'
import { routes } from '../routes'
import serverStore from "../store/serverStore"
const app = express()
app.get('*', (req, res) => {
const context = {css: []}
const store = serverStore()
// 用matchRoutes方法獲取匹配到的路由對應的元件陣列
const matchedRoutes = matchRoutes(routes, req.path)
const promises = []
for (const item of matchedRoutes) {
if (item.route.loadData) {
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve)
})
promises.push(promise)
}
}
// 所有請求響應完畢,將被HTML內容傳送給瀏覽器
Promise.all(promises).then(() => {
// 將生成html內容的邏輯封裝成了一個函式,接收req, store, context
res.send(serverRender(req, store, context))
})
})
複製程式碼
細心的同學可能注意到了上邊我把每個loadData都包了一個promise。
const promise = new Promise((resolve, reject) => {
item.route.loadData(store).then(resolve).catch(resolve)
console.log(item.route.loadData(store));
})
promises.push(promise)
複製程式碼
這是為了容錯,一旦有一個請求出錯,那麼下邊Promise.all方法則不會執行,所以包一層promise的目的是即使請求出錯,也會resolve,不會影響到Promise.all方法, 也就是說只有請求出錯的元件會沒資料,而其他元件不會受影響。
注入資料
我們請求已經發出去了,並且在元件的loadData方法中也擴充了服務端的store,那麼可以從服務端的資料取出來注入到要返回給瀏覽器的HTML中了。 來看 serverRender 方法
const serverRender = (req, store, context) => {
// 讀取客戶端生成的HTML
const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<Container/>
</StaticRouter>
</Provider>
)
// 注入資料
const initialState = `<script>
window.context = {
INITIAL_STATE: ${JSON.stringify(store.getState())}
}
</script>`
return template.replace('<!--app-->', content)
.replace('<!--initial-state-->', initialState)
}
複製程式碼
瀏覽器端用服務端獲取到的資料初始化store
經過上邊的過程,我們已經可以從window.context中拿到服務端預獲取的資料了,此時需要做的事就是用這份資料去初始化瀏覽器端的store。保證兩端資料的統一。
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from '../reducers'
const defaultStore = window.context && window.context.INITIAL_STATE
const clientStore = createStore(
rootReducer,
defaultStore,// 利用服務端的資料初始化瀏覽器端的store
compose(
applyMiddleware(thunk),
window.devToolsExtension ? window.devToolsExtension() : f=>f
)
)
複製程式碼
至此,服務端渲染的資料統一問題就解決了,再來回顧一下整個流程:
- 使用者訪問路由,服務端根據路由匹配出對應路由內的元件陣列
- 迴圈陣列,呼叫元件上掛載的loadData方法,傳送請求,擴充服務端store
- 所有請求完成後,通過store.getState,獲取到服務端預獲取的資料,注入到window.context中
- 瀏覽器渲染返回的HTML,載入瀏覽器端js,從window.context中取資料來初始化瀏覽器端的store,渲染元件
這裡還有個點,也就是當我們從路由進入到其他頁面的時候,元件內的loadData方法並不會執行,它只會在重新整理,服務端渲染路由的時候執行。 這時候會沒有資料。所以我們還需要在componentDidMount中去發請求,來解決這個問題。因為componentDidMount不會在服務端渲染執行, 所以不用擔心請求重複傳送。
樣式的服務端渲染
以上我們所做的事情只是讓網頁的內容經過了服務端的渲染,但是樣式要在瀏覽器載入css後才會加上,所以最開始返回的網頁內容沒有樣式,頁面依然會閃一下。為了解決這個問題,我們需要讓樣式也一併在服務端渲染的時候返回。
首先,服務端渲染的時候,解析css檔案,不能使用style-loader了,要使用isomorphic-style-loader。
{
test: /\.css$/,
use: [
'isomorphic-style-loader',
'css-loader',
'postcss-loader'
],
}
複製程式碼
但是,如何在服務端獲取到當前路由內的元件樣式呢?回想一下,我們在做路由的服務端渲染時,用到了StaticRouter,它會接收一個context物件,這個context物件可以作為一個載體來傳遞一些資訊。我們就用它!
思路就是在渲染元件的時候,在元件內接收context物件,獲取元件樣式,放到context中,服務端拿到樣式,插入到返回的HTML中的style標籤。
來看看元件是如何讀取樣式的吧:
import style from './style/index.css'
class Index extends React.Component {
componentWillMount() {
if (this.props.staticContext) {
const css = style._getCss()
this.props.staticContext.css.push(css)
}
}
}
複製程式碼
在路由內的元件可以在props裡接收到staticContext,也就是通過StaticRouter傳遞過來的context,
isomorphic-style-loader 提供了一個 _getCss() 方法,讓我們能讀取到css樣式,然後放到staticContext裡。
不在路由之內的元件,可以通過父級元件,傳遞props的方法,或者用react-router的withRouter包裹一下
其實這部分提取css的邏輯可以寫成高階元件,這樣就可以做到複用了
import React, { Component } from 'react'
export default (DecoratedComponent, styles) => {
return class NewComponent extends Component {
componentWillMount() {
if (this.props.staticContext) {
const css = styles._getCss()
this.props.staticContext.css.push(css)
}
}
render() {
return <DecoratedComponent {...this.props}/>
}
}
}
複製程式碼
在服務端,經過元件的渲染之後,context中已經有內容了,我們這時候把樣式處理一下,返回給瀏覽器,就可以做到樣式的服務端渲染了
const serverRender = (req, store) => {
const context = {css: []}
const template = fs.readFileSync(process.cwd() + '/public/static/index.html', 'utf8')
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<Container/>
</StaticRouter>
</Provider>
)
// 經過渲染之後,context.css內已經有了樣式
const cssStr = context.css.length ? context.css.join('\n') : ''
const initialState = `<script>
window.context = {
INITIAL_STATE: ${JSON.stringify(store.getState())}
}
</script>`
return template.replace('<!--app-->', content)
.replace('server-render-css', cssStr)
.replace('<!--initial-state-->', initialState)
}
複製程式碼
至此,服務端渲染就全部完成了。
總結
React的服務端渲染,最好的解決方案就是Next.js。如果你的應用沒有SEO優化的需求,又或者不太注重首屏渲染的速度,那麼儘量就不要用服務端渲染。 因為會讓專案變得複雜。此外,除了服務端渲染,SEO優化的辦法還有很多,比如預渲染(pre-render)。