Dva 概念
- https://dvajs.com/
- https://github.com/dvajs/dva
- redux docs
- redux docs 中文
- Mostly adequate guide to FP
- JS函数式编程指南
- choo docs
- elm
dva 首先是一个基于 redux 和 redux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了 react-router 和 fetch,所以也可以理解为一个轻量级的应用框架。
数据流向
数据的改变发生通常是通过用户交互行为或者浏览器行为(如路由跳转等)触发的,当此类行为会改变数据的时候可以通过dispatch发起一个action,如果是 同步行为会直接通过reducers 改变state,如果是异步行为(副作用)会先触发effects,然后流向reducers最终改变state,所以在dva中,数据流向非常清晰简明,并且思路基本跟开源社区保持一致(也是来自于开源社区)。
Models 介绍
State
State 表示 Model 的状态数据,通常表现为一个 javascript 对象(当然它可以是任何值);操作的时候每次都要当做不可变数据(immutable data)来对待,保证每次都是全新对象,没有引用关系,这样才能保证 State 的独立性,便于测试和追踪变化。
在 dva 中你可以通过 dva 的实例属性
_store看到顶部的 state 数据。
const app = dva();console.log(app._store); // 顶部的 state 数据
Action
Action 是一个普通 javascript 对象,它是改变 State 的唯一途径。无论是从UI事件、网络回调,还是 WebSocket 等数据源所获取的数据,最终都会通过 dispatch 函数调用一个 action,从而改变对应的数据。
action 必须带有 type 属性指明具体的行为,其它字段可以自定义,如果要发起一个 action 需要使用 dispatch
import {connect} from 'dva';function App(props){const action = {type: 'loginModel/userInfo', //action动作payload: {}, //额外参数}return (<div onClick={() => props.dispatch(action}></div>)}export default connect(mapStateToProps)(App)// 在 dva 中,通过 connect(mapStateToProps, dispatchActionToProps, ...)(App) 连接组件和Model// 如果dispatchActionToProps不传值时,connect Model 的组件通过 props 可以访问到 dispatch,// 可以调用 Model 中的 Reducer 或者 Effects
dispatch 函数
type dispatch = (a: Action) => Action
它是一个用于触发 action 的函数,action是改变 Satate 的唯一途径,但是它只描述一个行为,而 dispatch 可以看作是触发这个行为的方式,而 Reducer 则是描述如何改变数据的。
Reducer
type Reducer<S, A> = (state: S, action: A) => S
Reducer 函数接受2个参数:旧state对象、派发的action,返回新的state对象。
Reducer 必须是纯函数,所以同样的输入必然得到同样的输出,它们不应该产生任何副作用。并且每一次的计算都应该使用 immutable data,这种特性简单理解就是每次操作都是返回一个全新的数据(独立,纯净),所以热重载和时间旅行这些功能才能够使用。
Effect
Effect 被称为副作用,常见的就是异步操作。它来自于函数编程的概念,之所以叫副作用是因为它使得我们的函数变的不纯,同样的输入不一定获得同样的输出。
dva 为了控制副作用的操作,底层引入了redux-sagas做异步流程控制,由于采用了generator的相关概念,所以将异步转成同步写法,从而将effects转为纯函数。至于为什么我们这么纠结于 纯函数,如果你想了解更多可以阅读Mostly adequate guide to FP,或者它的中文译本JS函数式编程指南。
Subscription
Subscription 是一种从 源 获取数据的方法,它来自于 elm。
Subscription 语义是订阅,用于订阅一个数据源,然后根据条件 dispatch 需要的 action。数据源可以是当前的时间、服务器的 websocket 连接、keyboard 输入、geolocation 变化、history 路由变化等等。
import key from 'keymaster';...app.model({namespace: 'count',subscriptions: {// 键盘事件监听keyEvent({dispatch}) {key('⌘+up, ctrl+up', () => { dispatch({type:'add'}) });},// 路由变化监听setup({ dispatch, history }){history.listen(location => {});}}});
Router
这里的路由通常指的是前端路由,由于我们的应用现在通常是单页应用,所以需要前端代码来控制路由逻辑,通过浏览器提供的 History API 可以监听浏览器url的变化,从而控制路由相关操作。
dva 实例提供了 router 方法来控制路由,使用的是react-router。
import { Router, Route } from 'dva/router';app.router(({history}) =><Router history={history}><Route path="/" component={HomePage} /></Router>);
Route Components
在组件设计方法中,我们提到过 Container Components,在 dva 中我们通常将其约束为 Route Components,因为在 dva 中我们通常以页面维度来设计 Container Components。
所以在 dva 中,通常需要 connect Model的组件都是 Route Components,组织在/routes/目录下,而/components/目录下则是纯组件(Presentational Components)。
dva-cli(dva脚手架)
https://github.com/dvajs/dva-cli
npm install -g dva-cli # 全局安装dva-clidva -v # 查看dva版本号# 创建名字为demo的项目,包含项目初始化目录和文件,# 并提供开发服务器、构建脚本、数据 mock 服务、代理服务器等功能。# 创建好后,默认打开 http://localhost:8000dva new demo
初始化项目(以“基本计数器”为例介绍dva)
- history的最新版为5.0,而connect-react-router使用的history版本为4.7,不兼容
- 不显式安装history或者指定老版本history进行安装。否则自己安装的history是使用history5
npx create-react-app 8.dvacd 8.dvacnpm i -S dva redux react-redux redux-saga react-router-dom connected-react-router
reducers
src/index.js
import React from 'react';import dva from 'dva';import Counter1 from './components/Counter1.js';import Counter2 from './components/Counter2.js';// 创建dva实例const app = dva();// 定义全局model(可定义多个)app.model(require('./models/counter1').default);app.model(require('./models/counter2').default);// 定义路由规则// app.router 接收一个函数组件,回传参数是 {app, history}app.router(({app, history}) => {console.log(app, history);return (<div><Counter1 /><Counter2 /></div>)});// 启动app.start('#root');
src/models
src/models/counter1.js
// dva 通过 model 的概念把一个领域的模型管理起来,类似于redux的仓库。// 包含同步更新 state 的 reducers,处理异步逻辑的 effects,订阅数据源的 subscriptions 。export default {namespace: 'counter1', //命名空间,表示在全局 state 上的 keystate: { //初始值number: 5,},// reducers 等同于 redux 里的 reducer,接收 action,同步更新 statereducers: {add(state, action){return {...state, number: state.number+1};}}}
src/models/counter2.js
export default {namespace: 'counter2',state: {number: 10,},reducers: {add: (state, action) => ({...state, number: state.number+1}),}}
src/components
src/components/Counter1.js
// connect就是react-redux的connect,把model和component串联起来。import {connect} from 'dva';function Counter1(ps){return (<div><div>{ps.number}</div><div><button onClick={() => ps.dispatch({type: 'counter1/add'})}>同步+</button></div></div>)}let mapStateToProps = state => state.counter1;export default connect(mapStateToProps)(Counter1);
src/components/Counter1.js
import {connect} from 'dva';function Counter2(ps){return (<div><div>{ps.number}</div><div><button onClick={() => ps.dispatch({type: 'counter2/add'})}>同步+</button></div></div>)}export default connect(({counter2}) => counter2)(Counter2);
effects
src/models
src/models/delay.js
// 模拟延迟export const delay = (ms=1000, val=true) => new Promise((resolve, reject) => {setTimeout(resolve, ms, val);});// 模拟异步返回数据export const delayMock = (data, ms) => delay(ms, {result: 1,data,})
src/models/counter1.js
import {delayMock} from './delay';// dva 通过 model 的概念把一个领域的模型管理起来,类似于redux的仓库。// 包含同步更新 state 的 reducers,处理异步逻辑的 effects,订阅数据源的 subscriptions 。const counter1 = {namespace: 'counter1', //命名空间,表示在全局 state 上的 keystate: { //初始值number: 5,},// 异步(副作用)effects: {*asyncAdd(action, {call, put}){const data = yield call(delayMock, {id: 1, name: 'jack'});console.log('data=>', data);// 如果在effects里派发动作,如果是派发给自己的model的话,不需要加namespace前缀yield put({type: 'add'});// 如果派发给其它model,需要加前缀yield put({type: 'counter2/add'});}},// reducers 等同于 redux 里的 reducer,接收 action,同步更新 statereducers: {add(state, action){return {...state, number: state.number+1};}}}export default counter1;
src/models/counter2.js
import {delayMock} from './delay';const counter2 = {namespace: 'counter2',state: {number: 10,},effects: {*asyncAdd(action, {call, put}){yield call(delayMock);yield put({type: 'add'});}},reducers: {add: (state, action) => ({...state, number: state.number+1}),}}export default counter2;
src/components
src/components/Counter1.js
import {connect} from 'dva';function Counter1({dispatch, ...ps}){return (<div><div>{ps.number}</div><div><button onClick={() => dispatch({type: 'counter1/add'})}>同步+</button>+ <button onClick={() => dispatch({type: 'counter1/asyncAdd'})}>异步+</button></div></div>)}let mapStateToProps = state => state.counter1;export default connect(mapStateToProps)(Counter1);
src/components/Counter1.js
import {connect} from 'dva';function Counter2({dispatch, ...ps}){return (<div><div>{ps.number}</div><div><button onClick={() => dispatch({type: 'counter2/add'})}>同步+</button>+ <button onClick={() => dispatch({type: 'counter2/asyncAdd'})}>异步+</button></div></div>)}export default connect(({counter2}) => counter2)(Counter2);
路由使用 dva/route
默认输出 react-router 接口, react-router-redux 的接口通过属性 routerRedux 输出。
src/index.js
import React from 'react';import dva from 'dva';import history from './routes/history';import RouterConfig from './routes';// 创建dva实例const app = dva({history,});// 定义全局model(可定义多个)app.model(require('./models/counter1').default);app.model(require('./models/counter2').default);// 定义路由规则// app.router 接收一个函数组件,回传参数是 {app, history}// app.router(require('./routes').default);app.router(RouterConfig);// 启动app.start('#root');
src/routes/history.js
// import { createHashHistory } from 'history';import { createBrowserHistory } from 'history';const history = createBrowserHistory({basename: '/web', // 基链接forceRefresh: false, //是否强制刷新整个页面// keyLength: 6, //location.key的长度// getUserConfirmation: (message,callback) => callback(window.confirm(message)) // 跳转拦截函数});export default history;
src/routes/index.js
import React from "react";import {Router, Route, Switch, Link} from "dva/router";import Counter1 from '../components/Counter1.js';import Counter2 from '../components/Counter2.js';function RouterConfig({history, app}){console.log(history, app);return (<div><Router history={history}><><ul><li><Link to="counter1">page counter1</Link></li><li><Link to="counter2">page counter2</Link></li></ul><Switch><Route path="/counter1" component={props => <Counter1 {...props} />} /><Route path="/counter2" component={props => <Counter2 {...props} />} /></Switch></></Router></div>)}export default RouterConfig;
路径跳转
src/routes/index.js
import React from "react";+ import {Router, Route, Switch, Link, routerRedux} from "dva/router";import Counter1 from '../components/Counter1';import Counter2 from '../components/Counter2';+ const {ConnectedRouter} = routerRedux;function RouterConfig({history, app}){console.log(history, app);return (<div>+ <ConnectedRouter history={history}><><ul><li><Link to="counter1">page counter1</Link></li><li><Link to="counter2">page counter2</Link></li></ul><Switch><Route path="/counter1" component={props => <Counter1 {...props} />} /><Route path="/counter2" component={props => <Counter2 {...props} />} /></Switch></>+ </ConnectedRouter></div>)}export default RouterConfig;
src/models/counter1.js
import {delayMock} from './delay';+ import {routerRedux} from 'dva/router';const counter1 = {namespace: 'counter1',state: {number: 5,},effects: {*asyncAdd(action, {call, put}){yield call(delayMock);yield put({type: 'add'});},+ *goto({payload}, {put}){+ yield put(routerRedux.push(payload));+ }},reducers: {add(state, action){return {...state, number: state.number+1};}}}export default counter1;
src/components/Counter1.js
import {connect} from 'dva';import { routerRedux } from 'dva/router';function Counter1({dispatch, ...ps}){console.log('Counter1', ps);return (<div><div>{ps.number}</div><div><button onClick={() => dispatch({type: 'counter1/add'})}>同步+</button><button onClick={() => dispatch({type: 'counter1/asyncAdd'})}>异步+</button><button onClick={() => {dispatch({type: 'counter1/goto', payload: '/counter2'})}}>跳到/counter2</button><button onClick={() => {dispatch(routerRedux.push('/counter2'))}}>跳到/counter2</button></div></div>)}let mapStateToProps = ({routing, counter1}) => ({number: counter1.number,})export default connect(mapStateToProps)(Counter1);
dva/dynamic 按需加载model
import dynamic from 'dva/dynamic';const UserPageComponent = dynamic({app, //dva 实例,加载 models 时需要//返回 Promise 数组的函数,Promise 返回 dva modelmodels: () => [import('./models/users'),],//返回 Promise 的函数,Promise 返回 React Componentcomponent: () => import('./routes/UserPage'),});
基础使用
src/index.js
import dva from 'dva';import history from './routes/history';import RouterConfig from './routes';const app = dva({history,});app.router(RouterConfig);app.start('#root');
src/routes/core.js
import dynamic from 'dva/dynamic';/** 创建动态组件** @param {*} app dva 实例,加载 models 时需要* @param {*} models 该路由组件加载的model数组* @param {*} component 动态路由组件 () => import('@/pages/Home')* @returns*/export const dynamicWrapper = (app, models, component) => dynamic({app,models: () => models,component,});
src/routes/index.js
import React from "react";import {Route, Switch, Link, routerRedux} from "dva/router";import {dynamicWrapper} from './core';const {ConnectedRouter} = routerRedux;// 创建一级路由const routesData = (app) => {const Counter1 = dynamicWrapper(app,[import('../models/counter1'), import('../models/counter2')],() => import('../components/Counter1'),);const Counter2 = dynamicWrapper(app,[import('../models/counter2')],() => import('../components/Counter2'));return (<Switch><Route path="/counter1" component={Counter1} /><Route path="/counter2" render={props => (<Counter2 {...props} app={app} /> {/*传递app过去,用于创建二级路由时使用*/})}/></Switch>)}function RouterConfig({history, app}){return (<div><ConnectedRouter history={history}><><ul><li><Link to="/counter1">counter1</Link></li><li><Link to="/counter2">counter2</Link></li><li><Link to="/counter2/movie">counter2/movie</Link></li><li><Link to="/counter2/music">counter2/music</Link></li></ul>{routesData(app)}</></ConnectedRouter></div>)}export default RouterConfig;
src/models/movie.js
const movie = {namespace: 'movie',state: {movieName: '海上钢琴师',},}export default movie;
src/components/Counter2/index.js
import {connect} from 'dva';import {Route, Switch} from "dva/router";import {dynamicWrapper} from '../routes/core';// 创建二级路由const routesData = (app) => {const Movie = dynamicWrapper(app,[import('../models/movie')], //路由/counter2/movie 按需加载了 counter2 movie 2个model() => import('./Movie'),);const Music = dynamicWrapper(app,[], //路由/counter2/music 按需加载了 counter2 1个model() => import('./Music'));return (<Switch><Route path="/counter2/movie" component={Movie} /><Route path="/counter2/music" component={Music}/></Switch>)}function Counter2(ps){return (<div><div>{ps.number}</div><div><button onClick={() => ps.dispatch({type: 'counter2/add'})}>同步+</button><button onClick={() => ps.dispatch({type: 'counter2/asyncAdd'})}>异步+</button></div><div><h2>二级路由</h2><div>{routesData(ps.app)}</div></div></div>)}export default connect(({counter2}) => counter2)(Counter2);
src/components/Counter2/Movie.js
import {connect} from "dva";function App(ps){console.log('movie=>', ps);return <div>counter2/movie</div>}export default connect(({counter2, movie}) => ({counter2, movie}))(App);
src/componnets/Counter2/Music.js
function Music(){return <div>counter2/music</div>}export default Music;
路由配置简单封装
本例是不向下面组件传递 app,初始化的时候是一次性把所有路由创建好。
当然,你也可以把 app 向下传,在用到子路由的地方再创建子路由组。
src/routes/core.js
import {Route, Switch, Redirect} from "dva/router";import dynamic from 'dva/dynamic';// 设置页面标题export function PageTitle({title, children}){document.title = title;return children;}/** 创建动态组件** @param {*} app dva 实例,加载 models 时需要* @param {*} models 该路由组件加载的model数组* @param {*} component 动态路由组件 () => import('@/pages/Home')* @returns*/export const dynamicWrapper = (app, models, component) => dynamic({app,models: () => models,component});// 路由映射表window.dva_router_pathMap = {};/** 创建 组路由** @param {*} app* @param {*} routesFn 返回一组路由配置的函数* @param {*} isAddSwitch 是否添加 Switch 包裹* @param {*} opt.disabledNotFound 是否禁止添加 404 重定向*/export const createRoutes = (app, routesFn, isAddSwitch, opt={}) => {const RoutesComArr = routesFn(app).map(routeConfig => createRoute(app, (app) => routeConfig));// 是否禁止添加 404 重定向if (!opt.disabledNotFound){RoutesComArr.push(<Redirect key={`/not-found_redirect`} to="/not-found" />);}if (!isAddSwitch) return <Switch>{RoutesComArr}</Switch>;return RoutesComArr;};/** 创建单个路由** @param {*} app* @param {*} routeFn 返回一个路由配置的函数*/export const createRoute = (app, routeFn) => {const routeConfig = routeFn(app);const {component: Com, path, exact, indexRoute, title, ...extraProps} = routeConfig;if (path && path !== '/'){window.dva_router_pathMap[path] = { path, title, ...extraProps };}let baseTitle = '标题 - {title}';let outputTitle = title ? baseTitle.replace(/{.*}/gi, title) : baseTitle.slice(0, -10);let routeProps = Object.assign({key: path || Math.random(4),render: props => (<PageTitle title={outputTitle}><Com extraProps={extraProps} {...props} /></PageTitle>),},path && {path},exact && {exact},)if (indexRoute) {return [<Redirect key={path + "_redirect"} exact from={path} to={indexRoute} />,<Route {...routeProps} />];}return <Route {...routeProps} />;}
src/routes/index.js
import React from "react";import {Link, Switch, routerRedux} from "dva/router";import {createRoutes} from './core';import * as routers from './export';// 创建函数-返回路由配置数组const routesFn = (app) => {return Object.keys(routers).reduce((arr, item) => arr.concat(routers[item](app)), []);}const {ConnectedRouter} = routerRedux;function RouterConfig({history, app}){return (<div><ConnectedRouter history={history}><><ul><li><Link to="/counter1">counter1</Link></li><li><Link to="/counter2">counter2</Link></li><li><Link to="/counter2/movie">counter2/movie</Link></li><li><Link to="/counter2/music">counter2/music</Link></li></ul>{createRoutes(app, routesFn, true)}</></ConnectedRouter></div>)}export default RouterConfig;
src/routes/export.js
export {default as counter} from './counter';
src/routes/counter.js(一级路由)
import {dynamicWrapper, createRoutes} from './core';import counter2Routes from '../components/Counter2/routes';const routesCounter = app => {let result = [// 页面:/counter1{path: '/counter1',title: 'page counter1',//如果项目里全都是懒加载路由,可以只传component、model, 生成动态组件可以在createRoute里做。component: dynamicWrapper(app,[import('../models/counter1'), import('../models/counter2')],() => import('../components/Counter1'),),},// 页面:/counter2{path: '/counter2',indexRoute: '/counter2/movie',title: 'page counter2',component: dynamicWrapper(app,[import('../models/counter2')],() => import('../components/Counter2'),),// 二级路由引用:方式1(单独一条一条生成路由)// childRoutes: [// counter2Routes.movie(app),// counter2Routes.music(app),// ],// 二级路由引用:方式2(直接生成路由组)childRoutes: createRoutes(app, counter2Routes, true),},]return result;}export default routesCounter;
src/components/Counter2/routes.js (二级路由)
import { dynamicWrapper, createRoute } from "../../routes/core";// /counter2/movieconst movie = (app) => ({path: '/counter2/movie',title: "page counter2-movie",component: dynamicWrapper(app,[import("../../models/movie")],() => import("./Movie")),});// /counter2/musicconst music = (app) => ({path: '/counter2/music',title: "page counter2-music",component: dynamicWrapper(app,[],() => import("./Music")),});// 二级路由引用:方式1(单独一条一条生成路由)// const routesCounter2 = {// movie: (app) => createRoute(app, movie),// music: (app) => createRoute(app, music),// }// 二级路由引用:方式2(直接生成路由组)const routesCounter2 = app => ([movie(app),music(app),])export default routesCounter2;
src/components/Counter2/index.js
import {connect} from 'dva';import {Switch} from "dva/router";function Counter2(ps){console.log('Counter2=>', ps);return (<div><div>{ps.number}</div><div><button onClick={() => ps.dispatch({type: 'counter2/add'})}>同步+</button><button onClick={() => ps.dispatch({type: 'counter2/asyncAdd'})}>异步+</button></div><div><h2>二级路由</h2><div>{/* 二级路由引用:方式1(单独一条一条生成路由) */}{/* <Switch>{ps.extraProps.childRoutes}</Switch> */}{/* 二级路由引用:方式2(直接生成路由组) */}{ps.extraProps.childRoutes}</div></div></div>)}export default connect(({counter2}) => counter2)(Counter2);
dva-loading
src/index.js
import dva from 'dva';import history from './routes/history';import dynamic from "dva/dynamic";import createLoading from "dva-loading";import RouterConfig from './routes';// 创建dva实例const app = dva({history,// onError: error => message.error(error.message),});// 引用插件app.use(createLoading());function Loading(){return <div style={{position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 1000}}></div>}// -> loading 使用自定义loading组件dynamic.setDefaultLoadingComponent(() => <Loading />);// 定义根model(根model可应用于所有路由)(model可定义多个)// app.model(require('./models/counter1').default);// app.model(require('./models/counter2').default);// 定义路由规则// app.router 接收一个函数组件,回传参数是 {app, history}// app.router(require('./routes').default);app.router(RouterConfig);// 启动app.start('#root');
dva/fetch
异步请求库,输出 isomorphic-fetch 的接口。不和 dva 强绑定,可以选择任意的请求库。
import fetch from 'dva/fetch';function checkStatus(response) {if (response.status >= 200 && response.status < 300) {return response;}const error = new Error(response.statusText);error.response = response;throw error;}// 格式化数据const parseJSON = response => response.json();export async function get(url, params={}, onSuc, onErr, config={}) {params.platform = 'pc';let str = qsStringify(params, {prefix: '?'});let allUrl = url + str;return await fetch(allUrl, {credentials: 'include', //fetch 默认不带 cookie 如果你想在fetch请求里附带cookies之类的凭证信息,可以将 credentials参数设置成 “include” 值。headers: getHeaders(config),}).then(checkStatus).then(parseJSON).then((res) => successNetwork(allUrl, res, onSuc, onErr, config)).catch((res) => errorNetwork(allUrl, res, onErr));}export async function post(url, params={}, onSuc, onErr, config={}) {params.platform = 'pc';let formData = new FormData();if (params){for(let k in params){formData.append(k, params[k]);}}return await fetch(url, {method : 'POST',credentials: 'include',headers: getHeaders(config),body: config.isBodyJSONStringify ? JSON.stringify(params) : formData,}).then(checkStatus).then(parseJSON).then((res) => successNetwork(url, res, onSuc, onErr, config)).catch((res) => errorNetwork(url, res, onErr));}
dva/saga
输出 redux-saga 的接口,主要用于用例的编写。(用例中需要用到 effects)

