一開始想學學服務端渲染,腦海中第一個浮現出來的就是next.js這種成熟的方案。看了一兩天,有趣,優雅,但是封裝好了,原理不甚清楚,也感覺無法靈活嵌合到老專案上去。於是看各種資料,想整理出同構的線索,一步一步地實現自己的同構模板。相關程式碼可檢視我的GitHub。感謝閱讀!!
TODO List
- 資料:如何保持前後端應用狀態一致
- 路由:路由在服務端和客戶端中的匹配方案
- 程式碼:同構,哪些地方可以共享,哪些地方需要差異化
- 靜態資源:服務端如何引入css/圖片等
- ssr直出資源:服務端在渲染路由頁面時如何匹配css/chunks資源
- 打包方案:服務端和瀏覽器端如何寫各自的webpack配置檔案
- SEO: head頭處理方案
同構的基礎
正常的網頁執行,需要生成dom,在dom樹loaded之後由js繫結相關的dom事件,監聽頁面的互動。服務端並不具備dom的執行環境,因而所有的服務端渲染其實都是返回了一個填充了初始資料的靜態文字。在react中,除了常用的render
這個用於生成dom的方法,還提供了renderToString
,renderToStaticMarkup
方法用來生成字串,由於VitualDOM的存在,結合這些方法就可以像以前的字串模板那樣生成普通的字串,返回給客戶端接管,再接著進行事件相關的繫結。最新的React v16+使用hydrate
和ssr
配套,能讓客戶端把服務端的VitualDOM渲染出來後得以複用,客戶端載入js後不會重刷一邊,減小了開銷,也避免瀏覽器重刷dom時帶來的閃屏體驗。而react的元件,還是和往常寫spa一樣編寫,前後端共享。不同的只是入口的渲染方法換了名字,且客戶端會掛載dom而已。
// clinet.js
ReactDom.hydrate(<App />, document.getElementById('app'))
// server.js
const html = ReactDom.renderToString(<App />)
複製程式碼
同構後網站執行流程圖
盜用一張圖,來自阿里前端。乍一看,ssr
與csr
的區別就在於2 3 4 5
,spa
模式簡單粗暴地返回一個空白的html頁面,然後在11
裡才去載入資料進行頁面填充,在此之前,頁面都處於空白狀態。而ssr
則會根據路由資訊,提前獲取該路由頁面的初始資料,返回頁面時已經有了初步的內容,不至於空白,也便於搜尋引擎收錄。
路由匹配
瀏覽器端的路由匹配還是照著spa
來做應該無需費心。略過了...
服務端的路由需要關注的,一個是後端服務的路由(如koa-router
)匹配的問題,一個是匹配到react應用後react-router
路由表的匹配問題。
- 服務端路由,可通過
/react
字首來和api介面
等其他區別開來,這種路由匹配方式甚至能讓服務端渲染能同時支援老專案諸如ejs
等的模板渲染方式,在系統升級改造方面可實現漸進式地升級。
// app.js檔案(後端入口)
import reactController from './controllers/react-controller'
// API路由
app.use(apiController.routes())
// ejs頁面路由
app.use(ejsController.routes())
// react頁面路由
app.use(reactController.routes())
// react-controller.js檔案
import Router from 'koa-router'
const router = new Router({
prefix: '/react'
})
router.all('/', async (ctx, next) => {
const html = await render(ctx)
ctx.body = html
})
export default router
複製程式碼
- 服務端的
react-router
react-router
專供了給ssr
使用的StaticRouter
介面,稱之為靜態的路由。誠然,服務端不像客戶端,對應於一次網路請求,路由就是當前的請求url
,是唯一的,不變的。在返回ssr直出的頁面後,頁面互動造成位址列的變化,只要用的是react-router
提供的方法,無論是hash
方式,還是history
方式,都屬於瀏覽器端react-router
的工作了,於是完美繼承了spa
的優勢。只有在輸入欄敲擊Enter
,才會發起新一輪的後臺請求。
import { StaticRouter } from 'react-router-dom'
const App = () => {
return (
<Provider store={store}>
<StaticRouter
location={ctx.url}
context={context}>
<Layout />
</StaticRouter>
</Provider>
)
}
複製程式碼
應用狀態資料管理
以往的服務端渲染,需要在客戶端網頁下載後馬上能看到的資料就放在伺服器提前準備好,可延遲展示,通過ajax
請求的資料的互動邏輯放在頁面載入的js
檔案中去。
換成了react
,其實套路也是一樣一樣的。但是區別在於:
傳統的字串模板,元件模板是彼此分離的,可各自單獨引入資料,再拼裝起來形成一份
html
。而在react
的ssr
裡,頁面只能通過defaultValue
和defaultProps
一次性render
,無法rerender
。
不能寫死defaultValude
,所以只能使用props
的資料方案。在執行renderToString
之前,提前準備好整個應用狀態的所有資料。全域性的資料管理方案可考慮redux
和mobx
等。
需要準備初始渲染資料,所以要精準獲取當前地址將要渲染哪些元件。react-router-config
和react-router
同源配套,是個支援靜態路由表配置的工具,提供了matchRoutes
方法,可獲得匹配的路由陣列。
import { matchRoutes } from 'react-router-config'
import loadable from '@loadable/component'
const Root = loadable((props) => import('./pages/Root'))
const Index = loadable(() => import("./pages/Index"))
const Home = loadable(() => import("./pages/Home"))
const routes = [
{
path: '/',
component: Root,
routes: [
{
path: '/index',
component: Index,
},
{
path: '/home',
component: Home,
syncData () => {}
routes: []
}
]
}
]
router.all('/', async (url, next) => {
const branch = matchRoutes(routes, url)
})
複製程式碼
元件的初始資料介面請求,最美的辦法當然是定義在各自的class元件的靜態方法中去,但是前提是元件不能被懶載入,不然獲取不到元件class,當然也無法獲取class static method
了,很多使用@loadable/component
(一個code split方案)庫的開發者多次提issue,作者也明示無法支援。不支援懶載入是絕對不可能的了。所以委屈一下程式碼了,在需要的route物件中定義一個asyncData方法。
- 服務端
// routes.js
{
path: '/home',
component: Home,
asyncData (store, query) {
const city = (query || '').split('=')[1]
let promise = store.dispatch(fetchCityListAndTemperature(city || undefined))
let promise2 = store.dispatch(setRefetchFlag(false))
return Promise.all([promise, promise2])
return promise
}
}
// render.js
import { matchRoutes } from 'react-router-config'
import createStore from '../store/redux/index'
const store = createStore()
const branch = matchRoutes(routes, url)
const promises = branch.map(({ route }) => {
// 遍歷所有匹配路由,預載入資料
return route.asyncData
? route.asyncData(store, query)
: Promise.resolve(null)
})
// 完成store的預載入資料初始化工作
await Promise.all(promises)
// 獲取最新的store
const preloadedState = store.getState()
const App = (props) => {
return (
<Provider store={store}>
<StaticRouter
location={ctx.url}
context={context}>
<Layout />
</StaticRouter>
</Provider>
)
}
// 資料準備好後,render整個應用
const html = renderToString(<App />)
// 把預載入的資料掛載在`window`下返回,客戶端自己去取
return `
<html>
<head></head>
<body>
<div id="app">${html}</div>
<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)};
</script>
</body>
</html>
`
複製程式碼
- 客戶端
- 為保證兩端的應用資料一致,客戶端也要使用同一份資料初始化一次redux的store,再生成應用。如果兩者的
dom/資料
不一致,導致瀏覽器接管的時候dom重新生成了一次,在開發模式下的時候,控制檯會輸出錯誤資訊,開發體驗完美。後續ajax
的資料,在componentDidMount
和事件中去執行,和服務端的邏輯天然剝離。
// 獲取服務端提供的初始化資料
const preloadedState = window.__PRELOADED_STATE__ || undefined
delete window.__PRELOADED_STATE__
// 客戶端store初始化
const store = createStore(preloadedState)
const App = () => {
return (
<Provider store={store}>
<BrowserRouter>
<Layout />
</BrowserRouter>
</Provider>
)
}
// loadableReady由@loadabel/component提供,在code split模式下使用
loadableReady().then(() => {
ReactDom.hydrate(<App />, document.getElementById('app'))
})
複製程式碼
- 服務端呼叫的介面客戶端也必須有。這就帶來了如何避免重複請求的問題。我們知道
componentDidMount
方法只執行一次,如果伺服器已經請求的資料帶有一個標識,就可以根據這個標識決定是否在客戶端需要發起一個新的請求了,需要注意的是判斷完成後重置該標識。
import { connect } from 'react-redux'
@connect(
state => ({
refetchFlag: state.weather.refetchFlag,
quality: state.weather.quality
}),
dispatch => ({
fetchCityListAndQuality: () => dispatch(fetchCityListAndQuality()),
setRefetchFlag : () => dispatch(setRefetchFlag(true))
})
)
export default class Quality extends Component {
componentDidMount () {
const {
location: { search },
refetchFlag,
fetchCityListAndQuality,
setRefetchFlag
} = this.props
const { location: city } = queryString.parse(search)
refetchFlag
? fetchCityListAndQuality(city || undefined)
: setRefetchFlag()
}
}
複製程式碼
打包方案
- 客戶端打包
我想說的是“照舊”。因為在瀏覽器端執行的還是spa
。入門級的具體見github,至於如何配置得賞心悅目,用起來得心應手,根據專案要求各顯神通吧。
- 服務端打包
和客戶端的異同:
同:
- 需要bable相容不同版本的js語法
webpack v4+/babel v7+ ... 真香
- ... 留白
異:
- 入口檔案不一樣,出口檔案不一樣
這裡既可以把整個服務端入口
app.js
作為打包入口,也可以把react路由
的起點檔案作為打包入口,配置輸出為umd
模組,再由app.js
去require
。以後者為例(好處在於升級改造專案時儘可能地降低對原系統的影響,排查問題也方便,斷點除錯什麼的也方便):
// webpack.server.js
const webpackConfig = {
entry: {
server: './src/server/index.js'
},
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js',
libraryTarget: 'umd'
}
}
// app.js
const reactKoaRouter = require('./build/server').default
app.use(reactKoaRouter.routes())
複製程式碼
- css、image資源正常來說服務端無需處理,如何繞開
偷懶,還沒開始研究,佔個坑
- require的是node自帶的模組時避免被webpack打包
const serverConfig = { ... target: 'node' }
- require第三方模組時如何避免被打包
const serverConfig = { ... externals: [ require('webpack-node-externals')() ]
- 生產環境程式碼無需做混淆壓縮
- ... 留白
服務端直出時資源的蒐集
服務端輸出html
時,需要定義好css
資源、js
資源,讓客戶端接管後下載使用。如果沒啥追求,可以直接把客戶端的輸出檔案全加上去,暴力穩妥,簡單方便。但是上面提到的@loadable/component
庫,實現了路由元件懶載入/code split功能後,也提供了全套服務,配套套裝的webpack工具,ssr工具,幫助我們做蒐集資源的工作。
// webpack.base.js
const webpackConfig = {
plugins: [ ..., new LoadablePlugin() ]
}
// render.js
import { ChunkExtractor } from '@loadable/server'
const App = () => {
return (
<Provider store={store}>
<StaticRouter
location={ctx.url}
context={context}>
<Layout />
</StaticRouter>
</Provider>
)
}
const webStats = path.resolve(
__dirname,
'../public/loadable-stats.json', // 該檔案由webpack外掛自動生成
)
const webExtractor = new ChunkExtractor({
entrypoints: ['client'], // 為入口檔名
statsFile: webStats
})
const jsx = webExtractor.collectChunks(<App />)
const html = renderToString(jsx)
const scriptTags = webExtractor.getScriptTags()
const linkTags = webExtractor.getLinkTags()
const styleTags = webExtractor.getStyleTags()
const preloadedState = store.getState()
const helmet = Helmet.renderStatic()
return `
<html>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
${linkTags}
${styleTags}
</head>
<body>
<div id="app">${html}</div>
<script>
window.STORE = 'love';
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)};
</script>
${scriptTags}
</body>
</html>
`
複製程式碼
SEO資訊
上面已經透露了。使用了一個react-helmet
庫。具體用法可檢視官方倉庫,資訊可直接寫在元件上,最後根據優先順序提升到head
頭部。