本專案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傳入 ,後者會根據url 自動匹配對應的React元件,這樣我們就能實現,重新整理頁面,服務端返回的對應路由元件與客戶端一致。 到這一步我們已經可以實現頁面重新整理 服務端和客戶端保持一致了。
Redux 服務端同構
首先下官方文件做了簡單的介紹介紹cn.redux.js.org/docs/recipe…
其處理步驟如下:
- 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專案以及相關文章,這些資料對本專案有很大的啟發
總結
我們知道服務端渲染的 優勢在於可以極快的首屏優化 ,支援SEO,與傳統的SPA相比多了一種資料的處理方式。 缺點也非常明顯,服務端渲染相當於是把客戶端的處理流程部分移植到了服務端,這樣就增加了服務端的負載。因此要做一個好的SSR方案,快取是必不可少的。與此同時工程化方面也是有很多值得優化的地方。這裡只是淺嘗輒止,並沒有做相關的處理,估計後面有時間會做一些優化歡迎大家關注。
本專案github地址 github.com/yangfan0095…
以上です