blog icon indicating copy to clipboard operation
blog copied to clipboard

dva 入门:手把手教你写应用

Open sorrycc opened this issue 7 years ago • 38 comments

本文已迁移至 https://github.com/dvajs/dva-docs/blob/master/v1/zh-cn/getting-started.md

我们将基于 dva 完成一个简单 app,并熟悉他的所有概念。

最终效果:

这是一个测试鼠标点击速度的 App,记录 1 秒内用户能最多点几次。顶部的 Highest Record 纪录最高速度;中间的是当前速度,给予即时反馈,让用户更有参与感;下方是供点击的按钮。

看到这个需求,我们可能会想:

  1. 该如何创建应用?
  2. 创建完后,该如何一步步组织代码?
  3. 开发完后,该如何构建、部署和发布?

在代码组织部分,可能会想:

  1. 如何写 Component ?
  2. 如何写样式?
  3. 如何写 Model ?
  4. 如何 connect Model 和 Component ?
  5. 用户操作后,如何更新数据到 State ?
  6. 如何处理异步逻辑? (点击之后 +1,然后延迟一秒 -1)
  7. 如何处理路由?

以及:

  1. 不想每次刷新 Highest Record 清 0,想通过 localStorage 记录,这样刷新之后还能保留 Highest Record。该如何处理?
  2. 希望同时支持键盘的点击测速,又该如何处理?

我们可以带着这些问题来看这篇文章,但不必担心有多复杂,因为全部 JavaScript 代码只有 70 多行。

安装 dva-cli

你应该会更希望关注逻辑本身,而不是手动敲入一行行代码来构建初始的项目结构,以及配置开发环境。

那么,首先需要安装的是 dva-cli 。dva-cli 是 dva 的命令行工具,包含 init、new、generate 等功能,目前最重要的功能是可以快速生成项目以及你所需要的代码片段。

$ npm install -g dva-cli

安装完成后,可以通过 dva -v 查看版本,以及 dva -h 查看帮助信息。

创建新应用

安装完 dva-cli 后,我们用他来创建一个新应用,取名 myApp

$ dva new myApp --demo

注意:--demo 用于创建简单的 demo 级项目,正常项目初始化不加要这个参数。

然后进入项目目录,并启动。

$ cd myApp
$ npm start

几秒之后,会看到这样的输出:

          proxy: listened on 8989
     livereload: listening on 35729
📦  173/173 build modules
webpack: bundle build is now finished.

(如需关闭 server,请按 Ctrl-C.)

在浏览器里打开 http://localhost:8989/ ,正常情况下,你会看到一个 "Hello Dva" 页面。

定义 model

接到需求之后推荐的做法不是立刻编码,而是先以上帝模式做整体设计。

  1. 先设计 model
  2. 再设计 component
  3. 最后连接 model 和 component

这个需求里,我们定义 model 如下:

app.model({
  namespace: 'count',
  state: {
    record : 0,
    current: 0,
  },
});

namespace 是 model state 在全局 state 所用的 key,state 是默认数据。然后 state 里的 record 表示 highest recordcurrent 表示当前速度。

完成 component

完成 Model 之后,我们来编写 Component 。推荐尽量通过 stateless functions 的方式组织 Component,在 dva 的架构里我们基本上不需要用到 state 。

import styles from './index.less';
const CountApp = ({count, dispatch}) => {
  return (
    <div className={styles.normal}>
      <div className={styles.record}>Highest Record: {count.record}</div>
      <div className={styles.current}>{count.current}</div>
      <div className={styles.button}>
        <button onClick={() => { dispatch({type: 'count/add'}); }}>+</button>
      </div>
    </div>
  );
};

注意:

  1. 这里先 import styles from './index.less';,再通过 styles.xxx 的方式声明 css classname 是基于 css-modules 的方式,后面的样式部分会用上
  2. 通过 props 传入两个值,countdispatchcount 对应 model 上的 state,在后面 connect 的时候绑定,dispatch 用于分发 action
  3. dispatch({type: 'count/add'}) 表示分发了一个 {type: 'count/add'} 的 action,至于什么是 action,详见:[email protected]

更新 state

更新 state 是通过 reducers 处理的,详见 [email protected]

reducer 是唯一可以更新 state 的地方,这个唯一性让我们的 App 更具可预测性,所有的数据修改都有据可查。reducer 是 pure function,他接收参数 state 和 action,返回新的 state,通过语句表达即 (state, action) => newState

这个需求里,我们需要定义两个 reducer,count/addcount/minus,分别用于计数的增和减。值得注意的是 count/add 时 record 的逻辑,他只在有更高的记录时才会被记录。

app.model({
  namespace: 'count',
  state: {
    record: 0,
    current: 0,
  },
+ reducers: {
+   add(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};
+   },
+ },
});

注意:

  1. { ...state } 里的 ... 是对象扩展运算符,类似 Object.extend,详见:对象的扩展运算符
  2. add(state) {} 等同于 add: function(state) {}

绑定数据

还记得之前的 Component 里用到的 count 和 dispatch 吗? 会不会有疑问他们来自哪里?

在定义了 Model 和 Component 之后,我们需要把他们连接起来。这样 Component 里就能使用 Model 里定义的数据,而 Model 中也能接收到 Component 里 dispatch 的 action 。

这个需求里只要用到 count

function mapStateToProps(state) {
  return { count: state.count };
}
const HomePage = connect(mapStateToProps)(CountApp);

这里的 connect 来自 react-redux

定义路由

接收到 url 之后决定渲染哪些 Component,这是由路由决定的。

这个需求只有一个页面,路由的部分不需要修改。

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

注意:

  1. history 默认是 hashHistory 并且带有 _k 参数,可以换成 browserHistory,也可以通过配置去掉 _k 参数。

现在刷新浏览器,如果一切正常,应该能看到下面的效果:

添加样式

默认是通过 css modules 的方式来定义样式,这和普通的样式写法并没有太大区别,由于之前已经在 Component 里 hook 了 className,这里只需要在 index.less 里填入以下内容:

.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;
  }
}

效果如下:

异步处理

在此之前,我们所有的操作处理都是同步的,用户点击 + 按钮,数值加 1。

现在我们要开始处理异步任务,dva 通过对 model 增加 effects 属性来处理 side effect(异步任务),这是基于 redux-saga 实现的,语法为 generator。(但是,这里不需要我们理解 generator,知道用法就可以了)

在这个需求里,当用户点 + 按钮,数值加 1 之后,会额外触发一个 side effect,即延迟 1 秒之后数值 1 。

app.model({
  namespace: 'count',
+ effects: {
+   *add(action, { call, put }) {
+     yield call(delay, 1000);
+     yield put({ type: 'minus' });
+   },
+ },
...
+function delay(timeout){
+  return new Promise(resolve => {
+    setTimeout(resolve, timeout);
+  });
+}

注意:

  1. *add() {} 等同于 add: function*(){}
  2. call 和 put 都是 redux-saga 的 effects,call 表示调用异步函数,put 表示 dispatch action,其他的还有 select, take, fork, cancel 等,详见 redux-saga 文档
  3. 默认的 effect 触发规则是每次都触发(takeEvery),还可以选择 takeLatest,或者完全自定义 take 规则

刷新浏览器,正常的话,就应该已经实现了最开始需求图里的所有要求。

订阅键盘事件

在实现了鼠标测速之后,怎么实现键盘测速呢?

在 dva 里有个叫 subscriontions 的概念,他来自于 elm

Subscription 语义是订阅,用于订阅一个数据源,然后根据条件 dispatch 需要的 action。数据源可以是当前的时间、服务器的 websocket 连接、keyboard 输入、geolocation 变化、history 路由变化等等。

dva 中的 subscriptions 是和 model 绑定的。

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

这里我们不需要手动安装 keymaster 依赖,在我们敲入 import key from 'keymaster'; 并保存的时候,dva-cli 会为我们安装 keymaster 依赖并保存到 package.json 中。输出如下:

use npm: tnpm
Installing `keymaster`...
[keymaster@*] installed at node_modules/.npminstall/keymaster/1.6.2/keymaster (1 packages, use 745ms, speed 24.06kB/s, json 2.98kB, tarball 15.08kB)
All packages installed (1 packages installed from npm registry, use 755ms, speed 23.93kB/s, json 1(2.98kB), tarball 15.08kB)
📦  2/2 build modules
webpack: bundle build is now finished.

所有代码

index.js

import dva, { connect } from 'dva';
import { Router, Route } from 'dva/router';
import React from 'react';
import styles from './index.less';
import key from 'keymaster';

const app = dva();

app.model({
  namespace: 'count',
  state: {
    record: 0,
    current: 0,
  },
  reducers: {
    add(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};
    },
  },
  effects: {
    *add(action, { call, put }) {
      yield call(delay, 1000);
      yield put({ type: 'count/minus' });
    },
  },
  subscriptions: {
    keyboardWatcher(dispatch) {
      key('⌘+up, ctrl+up', () => { dispatch({type:'count/add'}) });
    },
  },
});

const CountApp = ({count, dispatch}) => {
  return (
    <div className={styles.normal}>
      <div className={styles.record}>Highest Record: {count.record}</div>
      <div className={styles.current}>{count.current}</div>
      <div className={styles.button}>
        <button onClick={() => { dispatch({type: 'count/add'}); }}>+</button>
      </div>
    </div>
  );
};

function mapStateToProps(state) {
  return { count: state.count };
}
const HomePage = connect(mapStateToProps)(CountApp);

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

app.start('#root');


// ---------
// Helpers

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

构建应用

我们已在开发环境下进行了验证,现在需要部署给用户使用。敲入以下命令:

$ npm run build

输出:

> @ build /private/tmp/dva-quickstart
> atool-build

Child
    Time: 6891ms
        Asset       Size  Chunks             Chunk Names
    common.js    1.18 kB       0  [emitted]  common
     index.js     281 kB    1, 0  [emitted]  index
    index.css  353 bytes    1, 0  [emitted]  index

该命令成功执行后,编译产物就在 dist 目录下。

下一步

通过完成这个简单的例子,大家前面的问题是否都已经有了答案? 以及是否熟悉了 dva 包含的概念:model, router, reducers, effects, subscriptions ?

还有其他问题?可以关注 dva repo 了解更多细节。

(完)

sorrycc avatar Jul 26 '16 06:07 sorrycc

如果定义了多个model,model之间如何通信?

jerexyz avatar Jul 26 '16 09:07 jerexyz

@jerexyz 通过 action。dva 封装自 redux,通信机制也等同于 redux 。

sorrycc avatar Jul 26 '16 09:07 sorrycc

问个题外话

dva-cli 会为我们安装 keymaster 依赖并保存到 package.json 中

这个怎么做到的?

codering avatar Jul 27 '16 07:07 codering

@codering https://github.com/ericclemmons/npm-install-webpack-plugin

sorrycc avatar Jul 27 '16 07:07 sorrycc

@sorrycc 源码没看到用到这个依赖

codering avatar Jul 27 '16 08:07 codering

我 fork 了一份的,因为要优先用内部的 tnpm 作为安装工具。

sorrycc avatar Jul 27 '16 08:07 sorrycc

  1. onClick 的 dispatch 放在 Component 里面,keyUp 的 dispatch 写在 model 里面: 事件订阅放在两个地方?
  2. namespace 已经声明了,还要在每个方法见面加前缀么?
  3. 如果想在页面加载时 load 一组数据该怎么写?
  4. effect 里面参数如何传递?
  5. 将 elm-lang 的 subscription,effect 的概念直接引入,是否有什么特殊考虑? 如果和大家已经熟知的 redux 概念差异较大,是否会增加理解和学习成本?

希望文档中能够补充一下。

ibigbug avatar Jul 31 '16 15:07 ibigbug

@ibigbug 都是很好的问题,说下我的理解。


问:onClick 的 dispatch 放在 Component 里面,keyUp 的 dispatch 写在 model 里面: 事件订阅放在两个地方?

keyUp 可以当做是事件,也可以当做是数据源。作为事件,放 Component 比较合适;作为数据源,放 model 的 subscription 则更合适。

Subscription 语义是订阅,用于订阅一个数据源,然后根据条件 dispatch 需要的 action。keyUp 的事件绑定可以把他理解成是订阅了键盘事件的数据源。

而如果把 keyUp 放在 React Component,按我理解,应该是要放在 componentWillMount 之类的生命周期里。我不喜欢这么做,因为:

  1. 现在的 React Component 都要求 stateless,不包含 state 和声明周期管理,只涉及 props 和 view
  2. 逻辑分散,散落在各个 Component 里

问:namespace 已经声明了,还要在每个方法见面加前缀么?

这是一个非常纠结的点。

我也希望把前缀去掉,但其实并不止需要去 key 里的前缀,reducers, effects 和 subscriptions 里 dispatch/put/take 的时候也要处理。

考虑一下原因,最终决定不去前缀:

  1. redux 生态圈的接受度,保留 namespace prefix 最主要是为了和 redux 的使用习惯保持一致
  2. 技术问题,effects 层(redux-saga) 里不好实现 namespace prefix 的功能

详见:https://github.com/dvajs/dva/pull/10#issuecomment-232247445

问:如果想在页面加载时 load 一组数据该怎么写?

在 subscriptions 里写,因为 component 已经是 stateless 了。可以参考:https://github.com/dvajs/dva/blob/master/examples/user-dashboard/src/models/users.js#L20-L31

问:effect 里面参数如何传递?

通过 action 传递数据,effects 里可以去到 action 对象。

比如:

const effects = {
  *['a'](action) {
    console.log(action.payload); // 1
  },
};

dispatch({type: 'a', payload: 1});

问:将 elm-lang 的 subscription,effect 的概念直接引入,是否有什么特殊考虑? 如果和大家已经熟知的 redux 概念差异较大,是否会增加理解和学习成本?

subscription、effect 和 redux 的 reducer 并不冲突,他们解决的是不同的问题。至于为何要引入,主要是认同 elm 的概念,前端的好多库(包括 redux)都是从 elm 里借的概念;另外,用 redux 我们通常不会仅用 redux 本身,还需要各种插件的配合,而 subscription + effect + reducer 则很好的解决了我目前能想到的所有项目问题。

dva 在设计上会尽量保证 redux 用户的低迁移/使用成本,对 redux 用户友好。比如 api 设计上没有做过度封装。

sorrycc avatar Aug 01 '16 03:08 sorrycc

subscriptions: [
    function(dispatch) {  //  error  dispatch 找不到
      key('⌘+up, ctrl+up', () => { dispatch({type:'count/add'}) });
    },
  ]

为啥 阿里都喜欢用 less 嘞

janjon avatar Aug 14 '16 08:08 janjon

@janjon 抱歉,api 变更了,这里换成 function({ dispatch }) {}

sorrycc avatar Aug 14 '16 11:08 sorrycc

有几个疑问,希望可以帮忙解答下

  1. 关于effect的归属问题

    如果有一个effect需要同时修改多个model,这个effect应该归属于哪个model呢?

  2. 关于subscription的归属问题

    同上,一个数据源修改多个model的情况

  3. subscription如何禁用

    假如监听了键盘事件,但在某个特定条件下想禁用这个subscription,相关逻辑是写在subscription内部,还是可以从外部禁用呢?比如当另一个model的某个字段变成true时,停止监听键盘事件,该如何实现?

fengzilong avatar Sep 27 '16 05:09 fengzilong

关于effect的归属问题

effect 只能归属一个 model,修改其他 model 要 dispatch 该 model 的 action

subscription如何禁用

用 model 内部变量,比如:

const isEnable = true;

export default {
  namespace: 'keyboard',
  subscriptions: {
    setup({ dispatch }) {
      key('ctrl+up', () => {
        if (isEnable) { /* do something with dispatch */ }
      });
    },
  },
  effects: {
    *enable() {
      isEnable = true;
    },
    *disable() {
      isEnable = false;
    },
  }
};

sorrycc avatar Sep 27 '16 05:09 sorrycc

@sorrycc 感谢~

fengzilong avatar Sep 27 '16 06:09 fengzilong

webpack打包工具怎么加载字体文件就报错。该怎么配置。可以出个教程讲讲你这个webpack跟我们的不一样

wangyinwei1 avatar Oct 07 '16 08:10 wangyinwei1

blog开头贴的地址404

Plortinus avatar Oct 18 '16 08:10 Plortinus

@Plortinus 感谢提醒,已更新。

sorrycc avatar Oct 18 '16 08:10 sorrycc

如果存在页面上存在一个列表,列表里面每一项都对应于一个相同的model(每一项对应之前定义好的一个组件实例,类似于TodoList里面的每一个Todo,但是这个Todo也是进行了封装了,并且使用了Todo model,不是Plain Object),此时,这样的情况如何处理?

ian4hu avatar Oct 21 '16 01:10 ian4hu

dva new myApp cd myApp npm start 后: err with request :Error: socket hang up / err with request :Error: socket hang up /favicon.ico

shenxiuqiang avatar Nov 09 '16 01:11 shenxiuqiang

访问 http://localhost:8000 呢?

On Wed, Nov 9, 2016 at 9:29 AM, shenxiuqiang [email protected] wrote:

dva new myApp cd myApp npm start 后: err with request :Error: socket hang up / err with request :Error: socket hang up /favicon.ico

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/sorrycc/blog/issues/8#issuecomment-259312229, or mute the thread https://github.com/notifications/unsubscribe-auth/AACJOGzQcFJLiYBI6_NyOvSFqstHFUn_ks5q8SIHgaJpZM4JU09Y .

sorrycc avatar Nov 09 '16 02:11 sorrycc

@sorrycc 请问,如果我想给.normal添加一个背景图片,直接在样式表里写background:url('xxx.png');报错,请问怎么解决?

spiroo avatar Nov 09 '16 08:11 spiroo

@spiroo 报什么错? https://github.com/dvajs/dva/issues 这里提 issue 吧。

sorrycc avatar Nov 09 '16 08:11 sorrycc

最终打包想按需加载模块,不全在一个脚本里,应如何处理才好

liyswei avatar Jan 05 '17 06:01 liyswei

按步骤写好了index.less,再index.js里也有写好import和className相关语句,但网页上没有出现样式。用浏览器开发工具查看页面代码,已看到css已出现,但在div里却没出现,求赐教

cyaolong avatar Mar 14 '17 16:03 cyaolong

同上,完成后,css无效~

yelgogogo avatar May 09 '17 03:05 yelgogogo

@yelgogogo .roadhogrc 里的配置要把CSSModules打开...

jiyifun avatar May 25 '17 02:05 jiyifun

请问写mock时用post方法如何获取到body传过来的参数

module.exports = {
  'POST /getList'(req, res) {

    const page = qs.parse(req.query);

    console.log(qs.parse(req.query))

    console.log(qs.parse(req.body))

  },

ershaoyelxw avatar Jun 06 '17 02:06 ershaoyelxw

貌似并不会自动安装 keymaster 啊。。

DrakeXiang avatar Jun 20 '17 06:06 DrakeXiang

如何实现嵌套的路由,像/products/edit/1234或者/products/view/1234,

   {   path: '/',
      component: App,
      getIndexRoute(nextState, cb) {
        require.ensure([], (require) => {
          cb(null, {component: require('./routes/Home/')});
        }, 'home');
      },
      childRoutes: [
{
          path: 'products',
          getComponent(nextState, cb) {
            require.ensure([], (require) => {
              cb(null, require('./routes/Product/index'));
            }, 'products');
          },
          childRoutes: [
            {
              path: 'edit',
              getComponent(nextState, cb){
                require.ensure([], (require) => {
                  cb(null, require('./routes/Product/edit'));
                }, 'productEdit');
              },
            },
          ],
        },
],
}

ANDYGE avatar Jun 27 '17 10:06 ANDYGE

我import key from 'keymaster'后,并没有自动安装啊,运行报错说Module not found: 'keymaster' in ...

koreadragon avatar Jun 30 '17 02:06 koreadragon

监听页面加载完成之后 立刻执行 函数?或者在render之后执行函数,如何做呢?

wangfengcool avatar Jul 10 '17 15:07 wangfengcool

reducers 和 effects 同名的action 方法,effects的动作会覆盖 reducers 的动作吗?我的本地测试显示只执行了 effects 里面的 add 方法

joshle avatar Sep 20 '17 02:09 joshle

@joshle 会,先执行reducers里面的方法,然后执行effects,俩个不能同名

kcshan avatar Oct 17 '17 02:10 kcshan

其中 “完成 component” 小节中 dispatch 方法是如何传递的

yinqiao avatar Dec 24 '17 13:12 yinqiao

请问,为什么component dispatch type 'count/add' 的actioner, 只执行了effects的add,而reducers的add确没有被执行呢?

Lifedance avatar Feb 11 '18 08:02 Lifedance

@Lifedance dva2.0还是某个版本后就不会同时触发effects和reducers了

DrakeXiang avatar Feb 11 '18 08:02 DrakeXiang

作为后端码农,我竟然仔细了阅读了每一个issues,感谢并膜拜~

cookiespiggy avatar Jul 25 '18 01:07 cookiespiggy

每次修改完代码后编译构建都很慢,请问如何做优化!

big-tutu avatar Aug 21 '18 03:08 big-tutu

model 什么时候会用到哇,感觉放在页面的state 里面好像就不需要model存放state了

Sessionking avatar Nov 08 '18 09:11 Sessionking