|
| 1 | +import React from 'react'; |
| 2 | +import ReactDOM from 'react-dom'; |
| 3 | +import { Provider } from 'react-redux'; |
| 4 | +import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; |
| 5 | +import createSagaMiddleware, { takeEvery, takeLatest } from 'redux-saga'; |
| 6 | +import { handleActions } from 'redux-actions'; |
| 7 | +import { fork } from 'redux-saga/effects'; |
| 8 | +import isPlainObject from 'is-plain-object'; |
| 9 | +import assert from 'assert'; |
| 10 | +import Plugin from './plugin'; |
| 11 | + |
| 12 | +export default function createDva(createOpts) { |
| 13 | + const { |
| 14 | + mobile, |
| 15 | + initialReducer, |
| 16 | + defaultHistory, |
| 17 | + routerMiddleware, |
| 18 | + setupHistory, |
| 19 | + } = createOpts; |
| 20 | + |
| 21 | + return function dva(hooks = {}) { |
| 22 | + const plugin = new Plugin(); |
| 23 | + plugin.use(hooks); |
| 24 | + |
| 25 | + const app = { |
| 26 | + // properties |
| 27 | + _models: [], |
| 28 | + _router: null, |
| 29 | + _store: null, |
| 30 | + _history: null, |
| 31 | + _plugin: plugin, |
| 32 | + // methods |
| 33 | + use: plugin.use.bind(plugin), |
| 34 | + model, |
| 35 | + router, |
| 36 | + start, |
| 37 | + }; |
| 38 | + return app; |
| 39 | + |
| 40 | + //////////////////////////////////// |
| 41 | + // Methods |
| 42 | + |
| 43 | + function model(m) { |
| 44 | + checkModel(m, mobile); |
| 45 | + this._models.push(m); |
| 46 | + } |
| 47 | + |
| 48 | + // inject model dynamically |
| 49 | + function injectModel(createReducer, onError, m) { |
| 50 | + checkModel(m, mobile); |
| 51 | + const store = this._store; |
| 52 | + |
| 53 | + // reducers |
| 54 | + store.asyncReducers[m.namespace] = getReducer(m.reducers, m.state); |
| 55 | + store.replaceReducer(createReducer(store.asyncReducers)); |
| 56 | + // effects |
| 57 | + if (m.effects) { |
| 58 | + store.runSaga(getSaga(m.effects)); |
| 59 | + } |
| 60 | + // subscriptions |
| 61 | + if (m.subscriptions) { |
| 62 | + runSubscriptions(m.subscriptions, this, onError); |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + function router(router) { |
| 67 | + assert.equal(typeof router, 'function', 'app.router: router should be function'); |
| 68 | + this._router = router; |
| 69 | + } |
| 70 | + |
| 71 | + function start(container, opts = {}) { |
| 72 | + // support: app.start(opts); |
| 73 | + if (isPlainObject(container)) { |
| 74 | + opts = container; |
| 75 | + container = null; |
| 76 | + } |
| 77 | + |
| 78 | + // support selector |
| 79 | + if (typeof container === 'string') { |
| 80 | + container = document.querySelector(container); |
| 81 | + assert.ok(container, 'app.start: could not query selector: ' + container); |
| 82 | + } |
| 83 | + |
| 84 | + assert.ok(!container || isHTMLElement(container), 'app.start: container should be HTMLElement'); |
| 85 | + assert.ok(this._router, 'app.start: router should be defined'); |
| 86 | + |
| 87 | + // set history |
| 88 | + const history = opts.history || defaultHistory; |
| 89 | + |
| 90 | + // error wrapper |
| 91 | + const onError = plugin.apply('onError', function(err) { |
| 92 | + throw new Error(err.stack || err); |
| 93 | + }); |
| 94 | + const onErrorWrapper = (err) => { |
| 95 | + if (err) { |
| 96 | + if (typeof err === 'string') err = new Error(err); |
| 97 | + onError(err); |
| 98 | + } |
| 99 | + }; |
| 100 | + |
| 101 | + // get reducers and sagas from model |
| 102 | + let sagas = []; |
| 103 | + let reducers = { ...initialReducer }; |
| 104 | + for (const m of this._models) { |
| 105 | + reducers[m.namespace] = getReducer(m.reducers, m.state); |
| 106 | + if (m.effects) sagas.push(getSaga(m.effects, onErrorWrapper)); |
| 107 | + } |
| 108 | + |
| 109 | + // extra reducers |
| 110 | + const extraReducers = plugin.get('extraReducers'); |
| 111 | + assert.ok(Object.keys(extraReducers).every(key => !(key in reducers)), 'app.start: extraReducers is conflict with other reducers'); |
| 112 | + |
| 113 | + // create store |
| 114 | + const extraMiddlewares = plugin.get('onAction'); |
| 115 | + const reducerEnhancer = plugin.get('onReducer'); |
| 116 | + const sagaMiddleware = createSagaMiddleware(); |
| 117 | + let middlewares = [ |
| 118 | + sagaMiddleware, |
| 119 | + ...extraMiddlewares, |
| 120 | + ]; |
| 121 | + if (routerMiddleware) { |
| 122 | + middlewares = [routerMiddleware(history), ...middlewares]; |
| 123 | + } |
| 124 | + const devtools = window.devToolsExtension || (() => noop => noop); |
| 125 | + const enhancers = [ |
| 126 | + applyMiddleware(...middlewares), |
| 127 | + devtools(), |
| 128 | + ]; |
| 129 | + const store = this._store = createStore( |
| 130 | + createReducer(), |
| 131 | + opts.initialState || {}, |
| 132 | + compose(...enhancers) |
| 133 | + ); |
| 134 | + |
| 135 | + function createReducer(asyncReducers) { |
| 136 | + return reducerEnhancer(combineReducers({ |
| 137 | + ...reducers, |
| 138 | + ...extraReducers, |
| 139 | + ...asyncReducers, |
| 140 | + })); |
| 141 | + } |
| 142 | + |
| 143 | + // extend store |
| 144 | + store.runSaga = sagaMiddleware.run; |
| 145 | + store.asyncReducers = {}; |
| 146 | + |
| 147 | + // store change |
| 148 | + const listeners = plugin.get('onStateChange'); |
| 149 | + for (const listener of listeners) { |
| 150 | + store.subscribe(listener); |
| 151 | + } |
| 152 | + |
| 153 | + // start saga |
| 154 | + sagas.forEach(sagaMiddleware.run); |
| 155 | + |
| 156 | + // setup history |
| 157 | + if (setupHistory) setupHistory.bind(this, history); |
| 158 | + |
| 159 | + // run subscriptions |
| 160 | + const subs = this._models.reduce((ret, { subscriptions }) => { |
| 161 | + return [ ...ret, ...(subscriptions || [])]; |
| 162 | + }, []); |
| 163 | + runSubscriptions(subs, this, onError); |
| 164 | + |
| 165 | + // inject model after start |
| 166 | + this.model = injectModel.bind(this, createReducer, onError); |
| 167 | + |
| 168 | + // If has container, render; else, return react component |
| 169 | + if (container) { |
| 170 | + render(container, store, router, this); |
| 171 | + plugin.apply('onHmr')(render); |
| 172 | + } else { |
| 173 | + return getProvider(store, router, this); |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + //////////////////////////////////// |
| 178 | + // Helpers |
| 179 | + |
| 180 | + function getProvider(store, router, app) { |
| 181 | + return () => ( |
| 182 | + <Provider store={store}> |
| 183 | + <router app={app} history={app._history} /> |
| 184 | + </Provider> |
| 185 | + ); |
| 186 | + } |
| 187 | + |
| 188 | + function render(container, store, router, app) { |
| 189 | + ReactDOM.render(React.createElement(getProvider(store, router, app)), container); |
| 190 | + } |
| 191 | + |
| 192 | + function checkModel(model, mobile) { |
| 193 | + assert.ok(model.namespace, 'app.model: namespace should be defined'); |
| 194 | + assert.ok(mobile || model.namespace !== 'routing', 'app.model: namespace should not be routing, it\'s used by react-redux-router'); |
| 195 | + assert.ok(!model.subscriptions || Array.isArray(model.subscriptions), 'app.model: subscriptions should be Array'); |
| 196 | + assert.ok(!model.reducers || typeof model.reducers === 'object' || Array.isArray(model.reducers), 'app.model: reducers should be Object or array'); |
| 197 | + assert.ok(!Array.isArray(model.reducers) || (typeof model.reducers[0] === 'object' && typeof model.reducers[1] === 'function'), 'app.model: reducers with array should be app.model({ reducers: [object, function] })') |
| 198 | + assert.ok(!model.effects || typeof model.effects === 'object', 'app.model: effects should be Object'); |
| 199 | + } |
| 200 | + |
| 201 | + function isHTMLElement(node) { |
| 202 | + return typeof node === 'object' && node !== null && node.nodeType && node.nodeName; |
| 203 | + } |
| 204 | + |
| 205 | + function getReducer(reducers, state) { |
| 206 | + if (Array.isArray(reducers)) { |
| 207 | + return reducers[1](handleActions(reducers[0], state)); |
| 208 | + } else { |
| 209 | + return handleActions(reducers || {}, state); |
| 210 | + } |
| 211 | + } |
| 212 | + |
| 213 | + function getSaga(effects, onError) { |
| 214 | + return function *() { |
| 215 | + for (const key in effects) { |
| 216 | + const watcher = getWatcher(key, effects[key], onError); |
| 217 | + yield fork(watcher); |
| 218 | + } |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + function getWatcher(key, _effect, onError) { |
| 223 | + let effect = _effect; |
| 224 | + let type = 'takeEvery'; |
| 225 | + if (Array.isArray(_effect)) { |
| 226 | + effect = _effect[0]; |
| 227 | + const opts = _effect[1]; |
| 228 | + if (opts && opts.type) { |
| 229 | + type = opts.type; |
| 230 | + } |
| 231 | + assert.ok(['watcher', 'takeEvery', 'takeLatest'].indexOf(type) > -1, 'app.start: effect type should be takeEvery, takeLatest or watcher') |
| 232 | + } |
| 233 | + |
| 234 | + function *sagaWithCatch(...args) { |
| 235 | + try { |
| 236 | + yield effect(...args); |
| 237 | + } catch(e) { |
| 238 | + onError(e); |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + switch (type) { |
| 243 | + case 'watcher': |
| 244 | + return sagaWithCatch; |
| 245 | + case 'takeEvery': |
| 246 | + return function*() { |
| 247 | + yield takeEvery(key, sagaWithCatch); |
| 248 | + }; |
| 249 | + case 'takeLatest': |
| 250 | + return function*() { |
| 251 | + yield takeLatest(key, sagaWithCatch); |
| 252 | + }; |
| 253 | + default: |
| 254 | + throw new Error(`app.start: unsupport effect type ${type}`); |
| 255 | + } |
| 256 | + } |
| 257 | + |
| 258 | + function runSubscriptions(subs, app, onError) { |
| 259 | + for (const sub of subs) { |
| 260 | + assert.ok(typeof sub === 'function', 'app.start: subscription should be function'); |
| 261 | + sub({ dispatch: app._store.dispatch, history:app._history }, onError); |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + }; |
| 266 | +} |
0 commit comments