我们来介绍一下,dva出自于暴雪出品的一款游戏《守望先锋》,援引官方的角色介绍:
D.Va拥有一部强大的机甲,它具有两台全自动的近距离聚变机炮、可以使机甲飞跃敌人或障碍物的推进器、还有可以抵御来自正面的远程攻击的防御矩阵。
然后呢,蚂蚁金服的一位架构师sorrycc很迷这位美女,正巧刚开发了一款前端框架没有名字,作为一个向女神献礼的项目,dva框架就此诞生。
我们先看看React 没有解决的问题
React 没有解决的问题 |
React 本身只是一个 DOM 的抽象层,使用组件构建虚拟 DOM。
如果开发大应用,还需要解决一个问题。
- 通信:组件之间如何通信?
- 数据流:数据如何和视图串联起来?路由和数据如何绑定?如何编写异步逻辑?等等
通信问题 |
组件会发生三种通信。
- 向子组件发消息
- 向父组件发消息
- 向其他组件发消息
React 只提供了一种通信手段:传参。对于大应用,很不方便。
数据流问题 |
目前流行的数据流方案有:
- Flux,单向数据流方案,以 Redux 为代表
- Reactive,响应式数据流方案,以 Mobx 为代表
- 其他,比如 rxjs 等
到底哪一种架构最合适 React ?
目前最流行的数据流方案:
- 路由: React-Router
- 架构: Redux
- 异步操作: Redux-saga
缺点:要引入多个库,项目结构复杂。
何为 dva |
dva 是体验技术部开发的 React 应用框架,将上面三个 React 工具库包装在一起,简化了 API,让开发 React 应用更加方便和快捷。
dva = React-Router + Redux + Redux-saga
dva 首先是一个基于 redux 和 redux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了react-router 和 fetch。
实际上,dva只是基于现有开源框架的一层轻量封装,并没有引入任何新概念:
- React:管理View
- react-router:管理路由
- Redux:管理Model
- redux-saga:管理异步调用(副作用)
dva 应用的最简结构 |
import dva from 'dva';
const App = () => <div>Hello dva</div>;
// 创建应用
const app = dva();
// 注册视图
app.router(() => <App />);
// 启动应用
app.start('#root');
dva 应用的最简结构(带 model) |
// 创建应用
const app = dva();
// 注册 Model
app.model({
namespace: 'count',
state: 0,
reducers: {
add(state) { return state + 1 },
},
effects: {
*addAfter1Second(action, { call, put }) {
yield call(delay, 1000);
yield put({ type: 'add' });
},
},
});
// 注册视图
app.router(() => <ConnectedApp />);
// 启动应用
app.start('#root');
Dva 概念 |
数据流图 |
数据的改变发生通常是通过用户交互行为或者浏览器行为(如路由跳转等)触发的,当此类行为会改变数据的时候可以通过 dispatch 发起一个 action,如果是同步行为会直接通过 Reducers 改变 State ,如果是异步行为(副作用)会先触发 Effects 通过server交互获取数据然后流向 Reducers 最终改变 State,同样state通过connect将model、状态数据与组件相连
总的数据流图:
数据流图1:最简单 |
- State:一个对象,保存整个应用状态
- View:React 组件构成的视图层
- Action:一个对象,描述事件
- connect 方法:一个函数,绑定 State 到 View
- dispatch 方法:一个函数,发送 Action 到 State
数据流图2:使用中间件 |
数据流图3:使用中间件 副作用 |
View |
View 就是 React 组件构成的 UI 层,从 State 取数据后,渲染成 HTML 代码。只要 State 有变化,View 就会自动更新。
Models |
- namespace: 当前 Model 的名称。整个应用的 State,由多个小的 Model 的 State 以 namespace 为 key 合成
- state: 该 Model 当前的状态。数据保存在这里,直接决定了视图层的输出
- reducers: Action 处理器,处理同步动作,用来算出最新的 State
- effects:Action 处理器,处理异步动作
namespace |
model 的命名空间,同时也是他在全局 state 上的属性,只能用字符串,不支持通过 .
的方式创建多层命名空间。
当前 Model 的名称。整个应用的 State,由多个小的 Model 的 State 以 namespace 为 key 合成。
在组件里面,通过connect将这个key引入到相应的model中。
State |
初始值,优先级低于传给 dva() 的 opts.initialState。
State 是储存数据的地方,收到 Action 以后,会更新数据。
State 表示 Model 的状态数据,通常表现为一个 javascript 对象(当然它可以是任何值);
const app = dva({
initialState: { count: 1 },
});
app.model({
namespace: 'count',
state: 0,
});
此时,在 app.start()
后 state.count 为 1 。
Reducer |
Action 处理器,处理同步动作,用来算出最新的 State
type Reducer<S, A> = (state: S, action: A) => S
以 key/value 格式定义 reducer。用于处理同步操作,唯一可以修改 state 的地方。由 action 触发。
格式为 (state, action) => newState 或 [(state, action) => newState, enhancer]
reducers: {
save(state, action) {
return { ...state, ...action.payload };
},
},
📌Action
- Action 是用来描述 UI 层事件的一个 javascript 对象
- 它是改变 State 的唯一途径。
- 通过 dispatch 函数调用一个 action
- 必须包含type字段
{
type: 'click-submit-button',
payload: this.form.data
}
Effect |
Action 处理器,处理异步动作,不直接修改 state。Effect 指的是副作用。根据函数式编程,计算以外的操作都属于 Effect,典型的就是 I/O 操作、数据库读写。副作用是因为它使函数变得不纯,同样的输入不一定获得同样的输出。
effects: {
*fetch({ payload }, { call, put }) { // eslint-disable-line
yield put({ type: 'save' });
},
},
格式为 *(action, effects) => void 或 [*(action, effects) => void, { type }]
。
type 类型有:
- takeEvery:监听action的每次变化执行(默认)
- takeLatest:监听action最近一次的变化
- throttle:防抖
- watcher:观察者
如:
['setQuery']: [function*() {}, { type: 'takeEvery'}],
📌Generator 函数
Effect 是一个 Generator 函数,内部使用 yield 关键字,标识每一步的操作(不管是异步或同步)。
dva 提供多个 effect 函数内部的处理函数,比较常用的是 call 和 putz,当然还有takeEvery、takeLatest、take
- call:执行异步函数
- put:发出一个 Action,类似于 dispatch
Subscription |
subscription 是订阅,用于订阅一个数据源,然后根据需要 dispatch 相应的 action。在 app.start()
时被执行,全局范围生效。数据源可以是当前的时间、服务器的 websocket 连接、keyboard 输入、geolocation 变化、history 路由变化等等。
注意:如果要使用 app.unmodel(),subscription 必须返回 unlisten 方法,用于取消数据订阅。
({ dispatch, history }, done) => unlistenFunction
以 key/value 格式定义 subscription。
subscriptions: {
setup ({ dispatch, history }) {
history.listen(location => {
if (location.pathname === '/video/logvideo') {
dispatch({type: 'projectTree', payload: {}})
}
})
},
onClick(){
document.addEventListener('click',()=>console.log('click'))
}
},
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>
);
connect & dispatch |
需要注意的是 dispatch 是在组件 connect Models以后,通过 props 传入的
connect 方法 |
connect 是一个函数,绑定 State 到 View
import { connect } from 'dva';
function mapStateToProps(state) {
return { todos: state.todos };
}
connect(mapStateToProps)(App);
connect 方法返回的也是一个 React 组件,通常称为容器组件。因为它是原始 UI 组件的容器,即在外面包了一层 State。
connect 方法传入的第一个参数是 mapStateToProps 函数,mapStateToProps 函数会返回一个对象,用于建立 State 到 Props 的映射关系。
dispatch 方法 |
dispatch 是一个函数方法,用来将 Action 发送给 State。
dispatch({
type: 'click-submit-button',
payload: this.form.data
})
dispatch 方法从哪里来?被 connect 的 Component 会自动在 props 中拥有 dispatch 方法。
dva API |
import dva from 'dva';
import './index.css';
// 1. Initialize
const app = dva();
// 2. Plugins
// app.use({});
// 3. Model
// app.model(require('./models/example').default);
// 4. Router
app.router(require('./router').default);
// 5. Start
app.start('#root');
app(opts) |
创建应用,返回 dva 实例。(注:dva 支持多实例)。
opts 包含:
- history:指定给路由用的 history,默认是 hashHistory
- initialState:指定初始数据,优先级高于 model 中的 state,默认是 {}
如果要配置 history 为 browserHistory,可以这样:
import createBrowserHistory from 'history/createBrowserHistory';
const app = dva({
history: createBrowserHistory(),
});
另外,出于易用性的考虑,opts 里也可以配所有的 hooks ,下面包含全部的可配属性:
const app = dva({
history, // 指定给路由用的 history,默认是 hashHistory
initialState, // 指定初始数据,优先级高于 model 中的 state
onError, // effect 执行错误或 subscription 通过 done 主动抛错时触发,可用于管理全局出错状态。
onAction, // 在 action 被 dispatch 时触发
onStateChange, // state 改变时触发,可用于同步 state 到 localStorage,服务器端等
onReducer, // 封装 reducer 执行。比如借助 redux-undo 实现 redo/undo
onEffect, // 封装 effect
onHmr, // 热替换相关
extraReducers, // 指定额外的 reducer,比如 redux-form 需要指定额外的 form reducer
extraEnhancers, // 指定额外的 StoreEnhancer ,比如结合 redux-persist 的使用
});
如:我们用 antd,那么最简单的全局错误处理通常会这么做:
import { message } from 'antd';
const app = dva({
onError(e) {
message.error(e.message, /* duration */3);
},
});
例如我们要通过 redux-logger 打印日志:
import createLogger from 'redux-logger';
const app = dva({
onAction: createLogger(opts),
});
其他hook使用示例看这
app.use(hooks) |
配置 hooks 或者注册插件。(插件最终返回的是 hooks )
import createLoading from 'dva-loading';
...
app.use(createLoading(opts));
app.model(model) |
dva 提供 app.model 这个对象,所有的应用逻辑都定义在它上面。
在普通的react-redux+redux-saga的项目中,我们首先会建4个文件夹,分别是actions,reducer,saga,组件,还有获取请求数据的services文件夹,同样在入口文件那要引入很多中间件、provider、connect等去将这几个文件夹联系起来,在这里的model以下就将这些集成在了一起,大大减小了开发工作量。
const app = dva();
// 新增这一行
app.model({ /**/ });
app.router(() => <App />);
app.start('#root');
Model 对象的例子:
{
namespace: 'count',
state: 0,
reducers: {
add(state) { return state + 1 },
},
effects: {
*addAfter1Second(action, { call, put }) {
yield call(delay, 1000);
yield put({ type: 'add' });
},
},
}
app.replaceModel(model) |
取消 model 注册,清理 reducers, effects 和 subscriptions。subscription 如果没有返回 unlisten 函数,使用 app.unmodel 会给予警告⚠️。
app.unmodel(namespace) |
替换model为新model,清理旧model的reducers, effects 和 subscriptions,但会保留旧的state状态,对于HMR非常有用。subscription 如果没有返回 unlisten 函数,使用 app.unmodel 会给予警告⚠️。
如果原来不存在相同namespace的model,那么执行app.model操作
app.router(({ history, app }) => RouterConfig) |
注册路由表。
通常是这样的:
import { Router, Route } from 'dva/router';
app.router(({ history }) => {
return (
<Router history={history}>
<Route path="/" component={App} />
</Router>
);
});
推荐把路由信息抽成一个单独的文件,这样结合 babel-plugin-dva-hmr 可实现路由和组件的热加载,比如:
app.router(require('./router'));
而有些场景可能不使用路由,比如多页应用,所以也可以传入返回 JSX 元素的函数。比如
app.router(() => <App />);
app.start(selector?) |
启动应用。selector 可选,如果没有 selector 参数,会返回一个返回 JSX 元素的函数。
app.start('#root');
那么什么时候不加 selector?常见场景有测试、node 端、react-native 和 i18n 国际化支持。
比如通过 react-intl 支持国际化的例子:
import { IntlProvider } from 'react-intl';
...
const App = app.start();
ReactDOM.render(<IntlProvider><App /></IntlProvider>, htmlElement);
Hello dva |
安装 dva-cli |
虽说官方已经说通过dva-cli创建新项目已经过期了,但是依然是可以使用的,为了降低学习成本,还是继续使用dva-cli创建新项目先。后面的一篇文章将会介绍umi创建新项目。
$ npm install dva-cli -g
创建新应用 |
通过 dva new
创建新应用。
dva new dva-quickstart
cd dva-quickstart
启动项目
npm start
当前项目结构和页面效果:
使用 antd |
通过 npm 安装 antd 和 babel-plugin-import 。babel-plugin-import 是用来按需加载 antd 的脚本和样式的,不需要手动引入相关组件样式,配置文件将自动引用相关antd的样式,从less文件转成css样式,作为行内样式插入元素中。
npm i antd babel-plugin-import
编辑 .webpackrc,使 babel-plugin-import 插件生效。
"extraBabelPlugins": [
["import", { "libraryName": "antd", "libraryDirectory": "es", "style": "css" }]
]
以下我们写一个应用来先显示产品列表,展示完整的应用构建流程
定义路由 |
新建 route 组件,routes/Products.js,内容如下:
import React from "react";
const Products=()=><h2>List of Products</h2>
export default Products;
添加路由信息到路由表,编辑 router.js :
+ import Products from './routes/Products';
...
+ <Route path="/products" exact component={Products} />
目前已经可以实现页面跳转了,我们继续:
编写 UI 组件 |
为何不将这部分列表呈现加在Products组件,而独立拿出来?
为了在在多个页面分享 UI 元素 (或在一个页面使用多次),这样就能在不同的地方显示产品列表了。
新建 components/ProductList.js 文件:
import React from 'react';
import PropTypes from 'prop-types';
import { Table, Popconfirm, Button } from 'antd';
const ProductList = ({ onDelete, products }) => {
const columns = [{
title: 'Name',
dataIndex: 'name',
}, {
title: 'Actions',
render: (text, record) => {
return (
<Popconfirm title="Delete?" onConfirm={() => onDelete(record.id)}>
<Button>Delete</Button>
</Popconfirm>
);
},
}];
return (
<Table
dataSource={products}
columns={columns}
/>
);
};
ProductList.propTypes = {
onDelete: PropTypes.func.isRequired,
products: PropTypes.array.isRequired,
};
export default ProductList;
定义 Model |
完成 UI 后,现在开始处理数据和逻辑。
dva 通过 model 的概念把一个领域的模型管理起来,包含
- 同步更新 state 的 reducers
- 处理异步逻辑的 effects
- 订阅数据源的 subscriptions 。
新建 model models/products.js :
export default{
namespace:'products',
state:[],
reducers:{
'delete'(state,{payload:id}){
return state.filter(item => item.id !== id);
}
}
}
这个 model 里:
- namespace 表示在全局 state 上的 key
- state 是初始值,在这里是空数组
- reducers 等同于 redux 里的 reducer,接收 action,同步更新 state
为什么没有effect?这里没有异步操作。
在 index.js 里载入Model
+ app.model(require('./models/products').default);
connect 起来 |
到这里,我们已经单独完成了 model 和 component,那么他们如何串联起来呢?
dva 提供了 connect 方法。如果你熟悉 redux,这个 connect 就是 react-redux 的 connect 。
编辑 routes/Products.js,替换为以下内容:
import React from 'react';
import { connect } from 'dva';
import ProductList from '../components/ProductList';
const Products = ({ dispatch, products }) => {
function handleDelete(id) {
dispatch({
type: 'products/delete',
payload: id,
});
}
return (
<div>
<h2>List of Products</h2>
<ProductList onDelete={handleDelete} products={products} />
</div>
);
};
// export default Products;
export default connect(products=>products)(Products);
最后,我们还需要一些初始数据让这个应用 run 起来。编辑 index.js:
- const app = dva();
+ const app = dva({
+ initialState: {
+ products: [
+ { name: 'dva', id: 1 },
+ { name: 'antd', id: 2 },
+ ],
+ },
+ });
使用 Dva 开发复杂 SPA(单页面应用) |
动态加载model |
dva1.0 手动 |
多个model并不需要在应用启动的时候就全部加载,如果每个功能页面是通过路由切换,互相之间没有关系的话,通常会使用webpack的require.ensure来做代码模块的懒加载。
function RouterConfig({ history, app }) {
const routes = [
{
path: '/',
name: 'IndexPage',
getComponent(nextState, cb) {
require.ensure([], (require) => {
registerModel(app, require('./models/dashboard'));
cb(null, require('./routes/IndexPage'));
});
},
},
{
path: '/users',
name: 'UsersPage',
getComponent(nextState, cb) {
require.ensure([], (require) => {
registerModel(app, require('./models/users'));
cb(null, require('./routes/Users'));
});
},
},
];
return <Router history={history} routes={routes} />;
}
这样,在视图切换到这个路由的时候,对应的model就会被加载。
拓展:require.ensure() |
require.ensure(
dependencies: String[],
callback: function(require),
errorCallback: function(error),
chunkName: String
)
-
dependencies:依赖
这是一个字符串数组,通过这个参数,在所有的回调函数代码被执行前,我们可以将所有需要用到的模块进行声明。 -
callback:回调
当所有的依赖都加载完成后,webpack会执行这个回调函数。require 对象的一个实现会作为一个参数传递给这个回调函数。因此,我们可以进一步 require() 依赖和其它模块提供下一步的执行。 -
errorCallback:错误回调
-
chunkName:chunk名称
chunkName 是提供给这个特定的 require.ensure() 的 chunk 的名称。通过提供 require.ensure() 不同执行点相同的名称,我们可以保证所有的依赖都会一起放进相同的文件束(bundle)。
dva2.0 dva/dynamic |
import dynamic from 'dva/dynamic';
const UserPageComponent = dynamic({
app,
models: () => [
import('./models/users'),
],
component: () => import('./routes/UserPage'),
});
opts 包含:
- app: 为挂载的对象,就是你要将这个router挂载到哪个实例上。dva 实例,加载 models 时需要用到。
- models: 为这个router所需要的model。返回 Promise 数组的函数,Promise 返回 dva model
- component:为这个router的组件。返回 Promise 的函数,Promise 返回 React Component
工厂函数来生成model |
注意到dva中的每个model,实际上都是普通的JavaScript对象,包含
- namespace
- state
- reducers
- effects
- subscriptions
function createModel(options) {
const { namespace, param } = options;
return {
namespace: `demo${namespace}`,
states: {},
reducers: {},
effects: {
*foo() {
// 这里可以根据param来确定下面这个call的参数
yield call()
}
}
};
}
const modelA = createModel({ namespace: 'A', param: { type: 'A' } });
const modelB = createModel({ namespace: 'A', param: { type: 'B' } });
多任务调度 |
- 串行,若干个任务之间存在依赖关系,并且后续操作对它们的结果有依赖
- 并行,若干个任务之间不存在依赖关系,并且后续操作对它们的结果无依赖
- 竞争,若干个任务之间,只要有一个执行完成,就进入下一个环节
- 子任务,若干个任务,并行执行,但必须全部做完之后,下一个环节才继续执行
串行 |
逐个yield call
* doSome({payload}, {put, call}) {
const res = yield call(service1, param1),
yield call(service2, res.data)
},
const [result1, result2] = yield* all([
call(service1, param1),
call(service2, param2)
])
并行 |
const [result1, result2] = yield all([
call(service1, param1),
call(service2, param2)
])
把多个要并行执行的东西放在一个数组里,就可以并行执行,等所有的都结束之后,进入下个环节,类似promise.all的操作。一般有一些集成界面,比如dashboard,其中各组件之间业务关联较小,就可以用这种方式去分别加载数据,此时,整体加载时间只取决于时间最长的那个。
yield [];
不要写成:
yield* [];
这两者含义是不同的,后者会顺序执行。
竞争 |
如果多个任务之间存在竞争关系,可以通过下面这种方式:
const { data, timeout } = yield race({
data: call(service, 'some data'),
timeout: call(delay, 1000)
});
if (data)
put({type: 'DATA_RECEIVED', data});
else
put({type: 'TIMEOUT_ERROR'});
这个例子比较巧妙地用一个延时一秒的空操作来跟一个网络请求竞争,如果到了一秒,请求还没结束,就让它超时。
========================================================================
参考文档:
DvaJS官方文档
浅析dva (史上最全的dva用法及分析)
前端技术栈(五):dva,美貌与智慧并存