dva理论到实践——帮你扫清dva的知识盲点

4,723 阅读8分钟

本文会介绍一下 dva 的相应知识点,使用 dva 的流程,以及使用 dva 中的坑。

并会通过 dva-cli 和 create-umi 写一个 倒计时计数器 的例子。

希望通过这篇文章能让你大致了解 dva 的使用流程。

 

Dva 简介

  1. 借鉴 elm 的概念,Reducer,Effect 和 Subscription

  2. 框架,而非类库

  3. 基于 redux,react-router,redux-saga 的轻量级封装

 

Dva 的特性

  1. 仅有 5 个 API,其用法我们会在之后详细介绍。

  2. 支持 HMR,支持模块的热更新。

  3. 支持 SSR (ServerSideRender),支持服务器端渲染。

  4. 支持 Mobile/ReactNative,支持移动手机端的代码编写。

  5. 支持 TypeScript,支持 TypeScript,毋庸置疑这个会是 Javascript 的一个趋势。

  6. 支持路由和 Model 的动态加载。

  7. …...

 

Dva 的 5 个API

app = dva(Opts)

创建应用,返回 dva 实例(注:dva 支持多实例)。

在 opts 可以配置所有的 hooks

const app = dva({
  history,
  initialState,
  onError,
  onAction,
  onStateChange,
  onReducer,
  onEffect,
  onHmr,
  extraReducers,
  extraEnhancers,
});

这里比较常用的是,history 的配置,一般默认的是 hashHistory,如果要配置 history 为 browserHistory,可以这样:

import createHistory from 'history/createBrowserHistory';

const app = dva({
  history: createHistory(),
});
  • 关于 react-router 中的 hashHistory 和 browserHistory 的区别大家可以看:react-router。
  • initialState:指定初始数据,优先级高于 model 中的 state,默认是 {},但是基本上都在 modal 里面设置对应应的 state。

app.use(Hooks)

配置 hooks 或者注册插件。

这里最常见的就是 dva-loading 插件的配置,

import createLoading from 'dva-loading';

// ...

app.use(createLoading(opts));

但是一般对于全局的 loading 我们会根据业务的不同来显示相应不同的 loading 图标,我们可以根据自己的需要来选择注册相应的插件。

app.model(ModelObject)

这个是你数据逻辑处理,数据流动的地方。

modal 是 dva 里面与我们真正进行项目开发,逻辑处理,数据流动的地方。

这里面涉及到的 namespace、Modal、effects 和 reducer 等概念都很重要,我们会在接下来的部分详细讲解。

app.router(Function)

注册路由表,我们做路由跳转的地方。

一般都是通过下面的方式编写:

import { Router, Route } from 'dva/router';

app.router(({ history }) => {
  return (
    <Router history={history}>
      <Route path="/" component={App} />
    <Router>
  );
});

但是如果你的项目特别的庞大,我们就要考虑到相应的性能的问题,但是入门可以先看一下这个。

解决性能问题,其实就是对路由进行按需加载,实现起来也比较简单,就是使用了 require.ensure 这个方法来帮我们实现,同时我们也可以使用 ES6 module 的动态 import 来实现按需加载。不过动态 import 这个方法还在提案中,我们需要接着 babel 插件来帮我们实现,具体笔者就不展开了。

app.start([HTMLElement], opts)

启动应用,将我们的应用跑起来。

 

Dva 九个概念

State(状态)

初始值,我们在 dva() 初始化的时候和在 modal 里面的 state 对其两处进行定义,其中 modal 中的优先级低于传给 dva() 的 opts.initialState。

如下:

// dva()初始化
const app = dva({
  initialState: { count: 1 },
});

// modal()定义事件
app.model({
  namespace: 'count',
  state: 0,
});

Action

表示操作事件,可以是同步,也可以是异步

action 的格式如下,它需要有一个 type ,表示这个 action 要触发什么操作;payload 则表示这个 action 将要传递的数据

{
  type: String,
  payload: data,
}

我们通过 dispatch 方法来发送一个 action

// Action
// Action 表示操作事件,可以是同步,也可以是异步
{
  type: String,
  payload: data
}

// 格式

dispatch(Action);
dispatch({ type: 'todos/add', payload: 'Learn Dva' });

其实我们可以构建一个 Action 创建函数,如下

function addTodo(text) {
  return {
    type: ADD_TODO,
    text
  }
}
	
//我们直接dispatch(addTodo()),就发送了一个action。
dispatch(addTodo())

action 更多内容可以查看文档:redux——action

Model

model 是 dva 中最重要的概念,Model 非 MVC 中的 M,而是领域模型,用于把数据相关的逻辑聚合到一起,几乎所有的数据,逻辑都在这边进行处理分发

state

这里的 state 跟我们刚刚讲的 state 的概念是一样的,只不过它的优先级比初始化的低,但是基本上项目中的 state 都是在这里定义的。

namespace

model 的命名空间,同时也是他在全局 state 上的属性,只能用字符串,我们在发送 action 到相应的 reducer 时,就会需要用到 namespace 。

Reducer

以 key/value 格式定义 reducer,用于处理同步操作,唯一可以修改 state 的地方。由 action 触发。其实一个纯函数。

Effect

用于处理异步操作和业务逻辑,不直接修改 state,简单的来说,就是获取从服务端获取数据,并且发起一个 action 交给 reducer 的地方。

其中它用到了redux-saga,里面有几个常用的函数。

*add(action, { call, put }) {
  yield call(delay, 1000);
  yield put({ type: 'minus' });
},

在项目中最主要的会用到的是 put 与 call。

Subscription

subscription 是订阅,用于订阅一个数据源,然后根据需要 dispatch 相应的 action。

在 app.start() 时被执行,数据源可以是当前的时间、当前页面的 url、服务器的 websocket 连接、history 路由变化、键盘事件等等。

Router

Router 表示路由配置信息,项目中的 router.js。

export default function({ history }){
  return(
    <Router history={history}>
      <Route path="/" component={App} />
    </Router>
  );
}

RouteComponent

RouteComponent 表示 Router 里匹配路径的 Component,通常会绑定 model 的数据。如下:

import { connect } from 'dva';

function App() {
  return <div>App</div>;
}

function mapStateToProps(state) {
  return { todos: state.todos };
}

export default connect(mapStateToProps)(App);

 

整体架构

我简单的分析一下这个图:

首先我们根据 url 访问相关的 Route-Component,在组件中我们通过 dispatch 发送 action 到 model 里面的 effect 或者直接 Reducer。

当我们将 action 发送给 Effect,基本上是取服务器上面请求数据的,服务器返回数据之后,effect 会发送相应的 action 给 reducer,由唯一能改变 state 的 reducer 改变 state ,然后通过 connect 重新渲染组件。

当我们将 action 发送给 reducer,那直接由 reducer 改变 state,然后通过 connect 重新渲染组件。

这样我们就能走完一个流程了。

 

项目案例

这一节我们会根据 dva 的快速搭建一个计数器。

官方的例子是都把所有的逻辑写在了入口文件 HomePage.js 里,我会在下面的 demo 中,把例子中的各个模块抽出来,放在相应的文件夹中。让大家能更加清楚每一个模块的作用。

安装与初始化

首先全局安装 dva-cli,我的操作在桌面进行的,大家可以自行选择项目目录。

$ npm install -g dva-cli

安装完成后,我们可以通过以下命令查看当前版本号:

$ dva -v // dva-cli version 0.10.1

接着使用 dva-cli 创建我们的项目文件夹

$ dva new myapp

这个时候 dva-cli 会提示我们 dva-cli 已经过期了,让我们使用 create-umi 代替:

从这里也可以看出前端技术的更新迭代是很快的,我们必须不断的学习才能跟上社区的步伐。

这个是题外话,我们还是先使用 dva-cli 来创建项目,在接下来我们也会讲一下 umi。

进入 myapp 目录,安装依赖,执行如下操作。

$ cd myapp
$ npm start

浏览器会自动打开一个窗口,如下图。

 

目录结构

.
├── mock    // mock数据文件夹
├── node_modules // 第三方的依赖
├── public  // 存放公共public文件的文件夹
│   ├── index.html // 项目模版文件
├── src  // 最重要的文件夹,编写代码都在这个文件夹下
│   ├── assets // 可以放图片等公共资源
│   ├── components // 就是react中的木偶组件
│   ├── models // dva最重要的文件夹,所有的数据交互及逻辑都写在这里
│   ├── routes // 就是react中的智能组件,不要被文件夹名字误导。
│   ├── services // 放请求借口方法的文件夹
│   ├── utils // 自己的工具方法可以放在这边
│   ├── index.css // 入口文件样式
│   ├── index.js // 入口文件
│   └── router.js // 项目的路由文件
├── .eslintrc // bower安装目录的配置
├── .editorconfig // 保证代码在不同编辑器可视化的工具
├── .gitignore // git上传时忽略的文件
├── .webpackrc // 项目的配置文件,配置接口转发,css_module等都在这边。
├── .roadhogrc.mock.js // 项目的配置文件
└── package.json // 当前整一个项目的依赖

编写代码

前端页面

在 dva-cli 初始化的项目中 react 的版本是 16.2.0,但现在众所周知,react 的版本已经到了 16.13.0 了,而且 react hooks 的写法得到了大范围的推广,官方也说这是未来编写 react 的趋势。

但是我们在例子中还是暂时使用 class 形式来创建组件,对 react hooks 有兴趣的大家可以自行查阅相关文档。

我们先修改前端页面:route/IndexPage.js

import React from 'react';
import { connect } from 'dva';
import styles from './IndexPage.css';

class IndexPage extends React.Component {
  render() {
    const { dispatch } = this.props;

    return (
      <div className={styles.normal}>
        <div className={styles.record}>Highest Record: 1</div>
        <div className={styles.current}>2</div>
        <div className={styles.button}>
          <button onClick={() => {}}>
            +
          </button>
        </div>
      </div>
   );
 }
}

export default connect()(IndexPage);

同时修改样式 routes/IndexPage.css

.normal {
  width: 200px;
  margin: 100px auto;
  padding: 20px;
  border: 1px solid #ccc;
  box-shadow: 0 0 20px #ccc;
}

.record {
  border-bottom: 1px solid #ccc;
  padding-bottom: 8px;
  color: #ccc;
}

.current {
  text-align: center;
  font-size: 40px;
  padding: 40px 0;
}

.button {
  text-align: center;
}

button {
  width: 100px;
  height: 40px;
  background: #aaa;
  color: #fff;
}

此时你的页面应该是如下图所示

更改 state

在 model 处理 state ,在页面里面输出 model 中的 state。

首先我们在 index.js 中将 models/example.js,即将 model 下一行的的注释打开,使用 es6 的形式导入:

import dva from 'dva';
//使用import导入。
import countModel from './models/example';
import router from './router';

import './index.css';

// 1. Initialize
const app = dva();

// 2. Plugins
// app.use({});

// 3. Model
app.model(countModel);

// 4. Router
app.router(router);

// 5. Start
app.start('#root');

接下来我们进入 models/example.js,将 namespace 名字改为 count,state 对象加上 record 与 current 属性。如下:

export default {
  namespace: 'count',
  state: {
    record: 0,
    current: 0,
  },
  // ...
};

接着我们来到 routes/indexpage.js 页面,通过的 mapStateToProps 引入相关的 state。

import React from 'react';
import { connect } from 'dva';
import styles from './IndexPage.css';

class IndexPage extends React.Component {
  render() {
    const { dispatch, count } = this.props;

    return (
      <div className={styles.normal}>
        <div className={styles.record}>
          {/*将count的record输出*/}
          Highest Record: {count.record}
        </div>

        <div className={styles.current}>
          {count.current}
        </div>

        <div className={styles.button}>
          <button onClick={() => {} } >
            +
          </button>
        </div>
      </div>
    );
  }
}

function mapStateToProps(state) {
  return { count: state.count };
} // 获取state

export default connect(mapStateToProps)(IndexPage);

打开网页:你应该能看到下图:

使用 action 与 reducer

通过 + 发送 action,通过 reducer 改变相应的 state

首先我们在 models/example.js,写相应的 reducer。

export default {
  reducers: {
    add1(state) {
      const newCurrent = state.current + 1;

      return { ...state,
        record: newCurrent > state.record ? newCurrent : state.record,
        current: newCurrent,
      };
    },
    minus(state) {
      return { ...state, current: state.current - 1 };
    },
  },
};

在页面的模板 routes/IndexPage.js 中 + 号点击的时候,dispatch 一个 action

// ...

class IndexPage extends React.Component {
  render() {
    // ...
    <button
+     onClick={
+       () => {
+         dispatch({ type: 'count/add1' });
+       }
+     }
    >
      +
    </button>
    // ...
  }
}

// ...

效果如下图:

使用 effect

接下来我们来使用 effect 模拟一个数据接口请求,返回之后,通过 yield put() 改变相应的 state

首先我们替换相应的 models/example.js 的 effect

effects: {
  *add(action, { call, put }) {
    yield call(delay, 1000);
    yield put({ type: 'minus' });
  },
},

这里的 delay,是一个延时的函数,我们在 utils 里面新建一个 utils.js ,一般请求接口的函数都会写在 serveices 文件夹中。

export function delay(timeout) {
  return new Promise((resolve) => {
    setTimeout(resolve, timeout);
  });
}

接着我们在 models/example.js 导入这个 utils.js

import { delay } from '../utils/utils';

使用 subscriptions

订阅订阅键盘事件,使用 subscriptions,当用户按住 command + up 时候触发添加数字的 action。

在 models/example.js 中作如下修改

+ import key from 'keymaster';
// ...
app.model({
  namespace: 'count',
  // ...
+ subscriptions: {
+   keyboardWatcher({ dispatch }) {
+     key('⌘+up, ctrl+up', () => { dispatch({type:'add'}) });
+   },
+ },
});

在这里你需要安装 keymaster 这个依赖

npm install keymaster -D

现在你可以按住 command + up 就可以使 current 加 1 了。

实现 gif 的最终效果

例子中我们看到当我们不断点击 + 按钮之后,我们会看到current会不断加一,但是 1s 过后,他会自动减到零。

官方的 demo 的代码没有实现 gif 图里面的效果,因为官方代码中没有 dispatch 一个 minus 的 action,大家看下图:

要做到 gif 里面的效果,我们应该在 effect 中发送一个关于添加的 action,但是我们在 effect 中不能直接这么写:

effects: {
  *add(action, { call, put }) {
    yield put({ type: 'add' });
    yield call(delay, 1000);
    yield put({ type: 'minus' });
  },
},

因为如果这样的话,effect 与 reducers 中的 add 方法重合了,这里会陷入一个死循环,因为当组件发送一个 dispatch 的时候,model 会首先去找 effect 里面的方法,当又找到 add 的时候,就又会去请求 effect 里面的方法。

我们应该更改 reducers 里面的方法,使它不与 effect 的方法一样,将 reducers 中的 add 改为 add1,如下:

// ...
effects: {
  *add(action, { call, put }) {
    yield put({ type: 'add1' });
    yield call( delay, 1000 );
    yield put({ type: 'minus' });
  },
},
  
reducers: {
  add1(state) {
    const newCurrent = state.current + 1;
    
    return {
      ...state,
      record: newCurrent > state.record ? newCurrent : state.record,
      current: newCurrent,
    };
  },
  minus(state) {
    return {
      ...state,
      current: state.current - 1
    };
  },
},

// ...

这样我们就实现了 gif 图中的效果:

至此我们的简单的 demo 就结束了,通过这个例子大家可以基本上了解 dva 的基本概念。

如果还想深入了解 dva 的各个文件夹中文件的特性,大家可以看 快速上手dva的一个简单demo,这里面会很详细的讲到我们该怎么写 model、怎么使用 effect 请求接口数据等等。

 

使用 umi

umi,中文可发音为乌米,是一个可插拔的企业级 react 应用框架,安装了 umi 就相当于安装了 roadhog + 路由 + HTML 生成 + 完善的插件机制,能在提升开发者效率方面发挥出更大的价值。

业界流行的 Ant Design Pro 就是基于 umi 搭建起来的。

与 dva 和 doadhog 的关系

以下比较引自官网:

  • roadhog 是基于 webpack 的封装工具,目的是简化 webpack 的配置
  • umi 可以简单地理解为 roadhog + 路由,思路类似 next.js/nuxt.js,辅以一套插件机制,目的是通过框架的方式简化 React 开发
  • dva 目前是纯粹的数据流,和 umi 以及 roadhog 之间并没有相互的依赖关系,可以分开使用也可以一起使用,更多可以参考:使用 umi 改进 dva 项目开发

安装 umi

我们可以通过一下 create-umi 命令,来快速新建一个初始化项目:

$ mkdir umi-demo && cd umi-demo
$ yarn create umi
$ npm start // 启动项目

启动项目之后我们可以看到下图:

 

目录

.
├── dist/                  // 默认的 build 输出目录
├── mock/                  // mock 文件所在目录,基于 express
└── src/                   // 源码目录,可选
  ├── layouts/index.js     // 全局布局
  ├── pages/               // 页面目录,里面的文件即路由\
    ├── .umi/              // dev 临时目录,需添加到 .gitignore
    ├── document.ejs       // HTML 模板
    ├── 404.js             // 404 页面
    ├── page1.js           // 页面 1,任意命名,导出 react 组件
    ├── page1.test.js      // 用例文件,umi test 会匹配所有 .test.js 和 .e2e.js 结尾的文件
      └── page2.js         // 页面 2,任意命名
  ├── global.css           // 约定的全局样式文件,自动引入,也可以用 global.less
  ├── global.js            // 可以在这里加入 polyfill
  ├── app.js               // 运行时配置文件
├── .umirc.js              // umi 配置,同 config/config.js,二选一
├── .env                   // 环境变量
└── package.json

umi 路由

我们使用 umi + dva 来快速写一个上面的计数器例子,我们假设要将这个计数器页面显示在 http://localhost:8000/home 这个路由下面,我们可以通过两种方式来做:

  • 配置式路由

直接在 .umirc.js 下面新增一个 home 配置即可

// ...
export default {
  // ...
  routes: [
    {
      path: '/',
      component: '../layouts/index',
      routes: [
        { path: '/', component: '../pages/index' }
      ]
    }
  ],
  // ...
}

  • 约定式路由

第二种就是 umi 采用约定式路由,会根据 pages 目录自动生成路由配置。就我们只有在 page 目录下新增一个 home 文件夹就行了。

+ pages/
  + home/
    - index.js
    - index.css
  - index.js

这种情况下,umi 会自动生成路由配置如下:

[
  { path: '/', component: './pages/index.js' },
  { path: '/home/', component: './pages/home/index.js' },
]

当采用了配置式路由路由之后,约定式路由就会失效。看使用者更偏向于哪一个。不过 umi 是推荐我们使用约定式路由的。

更多的笔者在这里就不细讲了,大家可以参考:约定式路由。

使用 umi 写一个计数器

页面

我们在 pages 目录下新增一个 home 目录,同时新建 index.js 和 index.css 文件,将我们之前将 dva 中写的 indexPage.js 和 indexPage.css 文件拷贝过来。

model

同时我们在 model 目录下新建 count.js,这个是我们之前在 dva 中提到的 model/example.js,直接拷贝过来

utils

接着我们还是以 delay 这个方法来模拟接口请求,所以我们在在 src 下新建一个 utils 目录,并创建 utils.js。

最后我们访问 http://localhost:8000/home,页面也成功运行了:

总结

文章中我们主要讲了一下 dva 的概念以及用法,同时还讲了通过 dva-cli 和 create-umi 快速创建一个简单项目,最后通过一个小例子 倒计时计数器 来大致过了一下开发的流程。

这篇文章其实是想起一个 抛砖引玉 的作用,希望能帮大家了解这一块的技术,激发起对这方面的兴趣,那么文章的效果也就达到了,剩下的更多更深层次的技术还是需要大家自己不断去探索的。

 

参考链接

 

示例代码