本項目github地址 react-koa2-ssr
所用到技術棧 react16.x + react-router4.x + koa2.x
前言
前段時間業余做了一個簡單的古文網 ,但是項目是使用React SPA 渲染的,不利于SEO,便有了服務端渲染這個需求。后面就想寫個demo把整個過程總結一下,同時也加深自己對其的理解,期間由于工作,過程是斷斷續續 。總之后來就有了這個項目吧。關于服務端渲染的優缺點,vue服務端渲染官方文檔講的最清楚。講的最清楚。 對于大部分場景最主要還是兩點 提高首屏加載速度 和方便SEO.為了快速構建開發環境,這里直接使用create-react-app 和koa2.x生成一個基礎項目 。整個項目便是以此作為基點進行開發的,目前也只是完成了最基本的需求, 還有很多Bug 和可以優化的地方, 歡迎交流。
服務端渲染最基本的理論知識梳理
首先前后端分別使用create-react-app 和koa2的腳手架快速生成, 然后再將兩個項目合并到一起。這樣我們省去了webpack的一些繁瑣配置 ,同時服務端使用了babel編譯。看這個之前 默認已經掌握webpack 和 koa2.x,babel的相關知識。
我們直切重要的步驟吧。我覺得搭建一個react-ssr環境主要只有三點
第一是react服務端提供的渲染API,二是前后端路由的同構,三則是初始化異步數據的同構。因此這個簡單的demo主要從這三方面入手。
- react 服務端渲染的條件
- react-router4.x 與koa2.x 路由實現同構
- redux 初始數據同構
react 服務端渲染的條件
其實可以看 《深入React技術棧》的第七章, 介紹的非常詳細。
概括來說 React 之所以可以做到服務端渲染 是因為ReactDOM提供了服務端渲染的API
- renderToString 把一個react 元素轉換成帶reactid的html字符串。
- renderToStaticMarkup 轉換成不帶reactid的html字符串,如果是靜態文本,用這個方法會減少大批的reactid.
這兩個方法的存在 ,實際上可以把react看做是一個模板引擎。解析jsx語法變成普通的html字符串。
我們可以調用這兩個API 實現傳入ReactComponent 返回對應的html字符串到客戶端。瀏覽器端接收到這段html以后不會重新去渲染DOM樹,只是去做事件綁定等操作。這樣就提高了首屏加載的性能。
react-router4.x 和 服務端的路由實現同構。
react-router4.x 相對于之前的版本,做了較大的改動。 整個路由變得組件化了。
可以著重看這里 官方給出了詳細的例子和文檔可以作為基本思想的和標準參考。
服務端渲染與客戶端渲染的不同之處在于其路由是沒有狀態的,所以我們需要通過一個無狀態的router組件 來包裹APP,通過服務端請求的url來匹配到具體的路由數組和其相關屬性。
所以我們在客戶端使用 BrowserRouter,服務端則使用無狀態的 StaticRouter。
- BrowserRouter 使用 HTML5 提供的 history API (pushState, replaceState 和 popstate 事件) 來保持 UI 和 URL 的同步。
- StaticRouter 是一個不會改變地址的router組件 。
參考代碼如下所示:
// 服務端路由配置
import { createServer } from 'http'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router'
import App from './App'
createServer((req, res) => {
const context = {}
const html = ReactDOMServer.renderToString(
<StaticRouter
location={req.url}
context={context}
>
<App/>
</StaticRouter>
)
if (context.url) {
res.writeHead(301, {
Location: context.url
})
res.end()
} else {
res.write(`
<!doctype html>
<div id="app">${html}</div>
`)
res.end()
}
}).listen(3000)
And then the client:import ReactDOM from 'react-dom'
// 客戶端路由配置
import { BrowserRouter } from 'react-router-dom'
import App from './App'
ReactDOM.render((
<BrowserRouter>
<App/>
</BrowserRouter>
), document.getElementById('app'))
我們把koa的路由url傳入 <StaticRouter /> ,后者會根據url 自動匹配對應的React組件,這樣我們就能實現,刷新頁面,服務端返回的對應路由組件與客戶端一致。
到這一步我們已經可以實現頁面刷新 服務端和客戶端保持一致了。
Redux 服務端同構
首先下官方文檔做了簡單的介紹介紹http://cn.redux.js.org/docs/recipes/ServerRendering.html.
其處理步驟如下:
- 1 我們根據對應的服務端請求API 得到對應的異步方法獲取到異步數據。
- 2 使用異步數據生成一個初始化的store
const store = createStore(counterApp, preloadedState)
, - 3 然后調用
const finalState = store.getState()
方法獲取到store的初始化state. - 4 將初始的initState 作為參數傳遞到客戶端
- 5 客戶端初始化的時候回去判斷 window.INITIAL_STATE 下面是否有數據,如果有則作為初始數據重新生成一個客戶端的store.
如下面代碼所示。
服務端
<html>
<head>
<title>Redux Universal Example</title>
</head>
<body>
<div id="root">${html}</div>
<script>
window.__INITIAL_STATE__ = ${JSON.stringify(finalState)}
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
客戶端
...
// 通過服務端注入的全局變量得到初始 state
const preloadedState = window.__INITIAL_STATE__
// 使用初始 state 創建 Redux store
const store = createStore(counterApp, preloadedState)
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
這個基本上就是一個標準的redux同構流程, 其實更多的官方是在給我們提供一種標準化的思路,我們可以順著這個做更多的優化。
首先我們并不需要直接通過API作為映射 服務端和客戶端各搞一套異步加載的方法,這樣顯得非常冗余。
react-router 包里面提供了react-router-config主要用于靜態路由配置。
提供的 matchRoutes API可以根據傳入的url 返回對應的路由數組。我們可以通過這個方法在服務端直接訪問到對應的React組件。 如果要從路由中直接獲取異步方法,我看了很多類似的同構方案,
- 主要有兩種方式一種是直接在路由中增加一個thunk方法,通過這個方法直接去獲取初始化的異步數據,
我覺得優點是比較明確直觀,直接在路由層就把這個事情解決了。 - 第二種是利用class 的靜態方法。我們可以通過路由訪問到組件的類下面的static方法。 這樣我們就直接可以在容器組件內部同時聲明服務端初始化方法和客戶端初始化方法了 這樣處理的層級放到了組件里面我自己覺得更能體現組件的獨立性吧。
本項目采用了第二種方案,先看一下代碼:
/**
* 渲染服務端路由
*/
module.exports.render = async(ctx,next) =>{
const { store ,history} = getCreateStore(ctx);
const branch = matchRoutes(router, ctx.req.url);
const promises = branch.map(({route}) => {
const fetch = route.component.fetch;
return fetch instanceof Function ? fetch(store) : Promise.resolve(null)
});
await Promise.all(promises).catch((err)=>{
console.log(err);
});
const html = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter
location={ctx.url}
context={{}}>
<App/>
</StaticRouter>
</Provider>
)
let initState=store.getState();
const body = layout(html,initState);
ctx.body =body;
}
對應容器組件提供了一個靜態的fetch方法
class Home extends Component {
...
static fetch(store){
return store.dispatch(fetchBookList({page:1,size:20}))
}
這是我們的 actions
/**
* 獲取書籍目錄
* @param {*} param
*/
export const fetchBookList = (params) => {
return async (dispatch, getState) => {
await axios.get(api.url.booklist, {
params: params
}).then((res) => {
dispatch(booklist(res.data.result));
}).catch((err) => {
})
}
}
首先我們通過 matchRoutes 拿到當前路由下所有的路由,再對其遍歷得到有關一個異步方法的Promise數組,這里我們所謂的異步方法就是actions中的異步方法。由于我們在服務端也初始化的store所以我們可以直接在服務端調用actions,這里我們需要給容器組件的static方法傳入store ,這樣我們就可以通過store.dispatch(fetchBookList({page:1,size:20}))
調用actions了。上面的方法我們得到了一個Promise 數組。我們使用 Promise.all將異步全部執行。這個時候實際上 store的運行跟客戶端是一樣的。 我們在異步的過程中 將初始數據全部寫入了 store中。所以我們通過store.getState()
就可以拿到初始化數據了。客戶端的初始化跟Redux官方例子是一樣的。直接判斷是否傳入初始化state,如果傳入就做為初始化數據。我們服務端的初始化異步和客戶端的初始化異步 如何避免重復。 這里我們直接先獲取store中的對應初始數據 ,看是否存在,如果不存在我們再進行加載。
到這一步我們已經可以實現刷新頁面異步數據服務端處理,不刷新頁面前端處理,一個基本的同構方案主體就出來了,剩下的就是一些優化項和一些項目定制性的東西了。
服務端頁面分發
對于服務器而言不僅會收到前端路由的請求還會收到各種其他靜態資源的請求 import {matchPath} from 'react-router-dom';
我們這里使用react-router-dom包里面的 matchPath API 來匹配當前請求路由是否與我們客戶端的路由配置相同如果不同我們默認為請求的是靜態資源或其他。如果不匹配當前路由我們直接執行 next() 進入到下一個中間件 。因為我們這個項目實際上還是是一個前后端分離的項目 只不過增加了服務端渲染的方式而已。 如果服務端還要處理其他請求,那么其實我們也可以在通過服務端 增加其他路由 ,通過映射來匹配對應的渲染頁面和API。
其他
寫這個demo看了很多的github項目以及相關文章,這些資料對本項目有很大的啟發
https://github.com/joeyguo
...
總結
我們知道服務端渲染的
優勢在于可以極快的首屏優化 ,支持SEO,與傳統的SPA相比多了一種數據的處理方式。
缺點也非常明顯,服務端渲染相當于是把客戶端的處理流程部分移植到了服務端,這樣就增加了服務端的負載。因此要做一個好的SSR方案,緩存是必不可少的。與此同時工程化方面也是有很多值得優化的地方。這里只是淺嘗輒止,并沒有做相關的處理,估計后面有時間會做一些優化歡迎大家關注。
本項目github地址 https://github.com/yangfan0095/react-koa2-ssr
以上です