状況
- A のロードが終わったら B をロードする
- B のロードが終わったら C をロードする
- C のロードが終わったら end を表示する
こういうものをSSRしたい。
問題
ReactDOMServer.renderToString() は同期しかとらないので、普通にやるだけだと A のロード待ちで止まってしまう。
この目的で使っていた https://github.com/recruit-tech/redux-async-loader が react-router v3 に依存していて、更新できていない。
解決策
- componentWillMount まで実行されるので、その action を収集する
- C のロード完了の action が実行されまで、store を更新しながら何度も render する
対象とするコード
/* @flow */
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { connect, Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux'
import promiseMiddleware from 'redux-promise'
import { compose, lifecycle } from 'recompose'
const initialState = {
aReady: false,
bReady: false,
cReady: false
}
const reducer = (state = initialState, action) => {
if (action.type === 'aReady') {
return { ...state, aReady: true }
} else if (action.type === 'bReady') {
return { ...state, bReady: true }
} else if (action.type === 'cReady') {
return { ...state, cReady: true }
}
return state
}
const A = compose(
connect(i => i),
lifecycle({
componentWillMount() {
if (!this.props.aReady) {
this.props.dispatch({ type: 'aReady' })
}
}
})
)(props => {
return (
<div>
A
{props.aReady && props.children}
</div>
)
})
const B = compose(
connect(i => i),
lifecycle({
componentWillMount(props) {
if (!this.props.bReady) {
this.props.dispatch(Promise.resolve({ type: 'bReady' }))
}
}
})
)(props => {
return (
<div>
B
{props.bReady && props.children}
</div>
)
})
const C = compose(
connect(i => i),
lifecycle({
componentWillMount(props) {
if (!this.props.cReady) {
this.props.dispatch({ type: 'cReady' })
}
}
})
)(props => {
return (
<div>
C
{props.cReady && props.children}
</div>
)
})
const App = () => (
<Provider store={store}>
<A>
<B>
<C>end</C>
</B>
</A>
</Provider>
)
やや冗長だが、自分自身に紐づく state が ready でなければ、それを読み出すという挙動。
b だけ Promise になっている。
どう実装するか
- 事前条件として action は 非同期な Promise を取りうるとする。(redux-promise準拠)
- store.dispatch を stub して action を収集する
- 収集した非同期アクションを resolve して、 reducer に流す
- 更新された store を使って再renderToString
実装した
let actionQueue = []
const hydrateQueue = async store => {
let results = []
for (const action of actionQueue) {
console.log('>>>', action)
const a = await store.dispatch(action)
results.push(a)
}
actionQueue = []
return results
}
const stubDispatch = store => ({
...store,
dispatch(action) {
console.log('<<<', action)
actionQueue.push(action)
}
})
const renderAsync = async (
store,
C,
opts: { maxDepth: number, endAction?: string }
) => {
let depth = opts.maxDepth
let endFlag = false
const endAction = opts.endAction
let ret
while (depth--) {
ret = ReactDOMServer.renderToString(
<Provider store={stubDispatch(store)}>
<C />
</Provider>
)
if (endFlag) {
break
}
if (actionQueue.length === 0) {
break
} else {
const execs = await hydrateQueue(store)
if (endAction && execs.map(a => a.type).includes(endAction)) {
endFlag = true
}
}
}
return ret
}
注意: ちょっと雑な実装なので、複数の非同期を扱う場合、スレッドセーフ(というかJSなので非同期セーフ)ではない。ちゃんとやるなら cationQueue をシングルトンにせずに引数で受け取って新しいキューを返すように引き回す。
使う
;(async () => {
const store = createStore(
reducer,
undefined,
applyMiddleware(promiseMiddleware)
)
const ret = await renderAsync(
store,
() => (
<A>
<B>
<C>end</C>
</B>
</A>
),
{
maxDepth: 5,
endAction: 'cReady'
}
)
console.log(ret)
})()
endAction がくるか、maxDepth回 render して返却する。
<div data-reactroot="">A<div>B<div>C<!-- -->end</div></div></div>
が入手できるので勝利。
動くコード全文はここ https://gist.github.com/mizchi/ffe35ba2ad753a449b8159ed1121ce6b
TODO
あとでライブラリにする or やってみたけど既にこういう実装ありそうなので調べる