一開始想學學服務端渲染,腦海中第一個浮現出來的就是next.js這種成熟的方案。看了一兩天,有趣,優雅,可是封裝好了,原理不甚清楚,也感受沒法靈活嵌合到老項目上去。因而看各類資料,想整理出同構的線索,一步一步地實現本身的同構模板。相關代碼可查看個人GitHub。感謝閱讀!!javascript
正常的網頁運行,須要生成dom,在dom樹loaded以後由js綁定相關的dom事件,監聽頁面的交互。服務端並不具有dom的執行環境,於是全部的服務端渲染其實都是返回了一個填充了初始數據的靜態文本。在react中,除了經常使用的render
這個用於生成dom的方法,還提供了renderToString
,renderToStaticMarkup
方法用來生成字符串,因爲VitualDOM的存在,結合這些方法就能夠像之前的字符串模板那樣生成普通的字符串,返回給客戶端接管,再接着進行事件相關的綁定。最新的React v16+使用hydrate
和ssr
配套,能讓客戶端把服務端的VitualDOM渲染出來後得以複用,客戶端加載js後不會重刷一邊,減少了開銷,也避免瀏覽器重刷dom時帶來的閃屏體驗。而react的組件,仍是和往常寫spa同樣編寫,先後端共享。不一樣的只是入口的渲染方法換了名字,且客戶端會掛載dom而已。css
// 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
則會根據路由信息,提早獲取該路由頁面的初始數據,返回頁面時已經有了初步的內容,不至於空白,也便於搜索引擎收錄。html
瀏覽器端的路由匹配仍是照着spa
來作應該無需費心。略過了...前端
服務端的路由須要關注的,一個是後端服務的路由(如koa-router
)匹配的問題,一個是匹配到react應用後react-router
路由表的匹配問題。java
/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
文件中去。node
換成了react
,其實套路也是同樣同樣的。可是區別在於:react
傳統的字符串模板,組件模板是彼此分離的,可各自單獨引入數據,再拼裝起來造成一份
html
。而在react
的ssr
裏,頁面只能經過defaultValue
和defaultProps
一次性render
,沒法rerender
。webpack
不能寫死defaultValude
,因此只能使用props
的數據方案。在執行renderToString
以前,提早準備好整個應用狀態的全部數據。全局的數據管理方案可考慮redux
和mobx
等。git
須要準備初始渲染數據,因此要精準獲取當前地址將要渲染哪些組件。react-router-config
和react-router
同源配套,是個支持靜態路由表配置的工具,提供了matchRoutes
方法,可得到匹配的路由數組。github
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> ` 複製代碼
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,至於如何配置得賞心悅目,用起來駕輕就熟,根據項目要求各顯神通吧。
和客戶端的異同:
同:
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())
複製代碼
偷懶,還沒開始研究,佔個坑
const serverConfig = { ... target: 'node' }
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> ` 複製代碼
上面已經透露了。使用了一個react-helmet
庫。具體用法可查看官方倉庫,信息可直接寫在組件上,最後根據優先級提高到head
頭部。