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

4,625 阅读8分钟

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

并会通过 dva-clicreate-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,如果要配置 historybrowserHistory,可以这样:

import createHistory from 'history/createBrowserHistory';

const app = dva({
  history: createHistory(),
});
  • 关于 react-router 中的 hashHistorybrowserHistory 的区别大家可以看: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)

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

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

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

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

modeldva 中最重要的概念,ModelMVC 中的 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' });
},

在项目中最主要的会用到的是 putcall

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 发送 actionmodel 里面的 effect 或者直接 Reducer

当我们将 action 发送给 Effect,基本上是取服务器上面请求数据的,服务器返回数据之后,effect 会发送相应的 actionreducer,由唯一能改变 statereducer 改变 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 名字改为 countstate 对象加上 recordcurrent 属性。如下:

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.jseffect

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 一个 minusaction,大家看下图:

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

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

因为如果这样的话,effectreducers 中的 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 搭建起来的。

dvadoadhog 的关系

以下比较引自官网:

  • 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.jsindex.css 文件,将我们之前将 dva 中写的 indexPage.jsindexPage.css 文件拷贝过来。

model

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

utils

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

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

总结

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

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

 

参考链接

 

示例代码