如何优雅的设计Redux中的Store

用了几个月的redux,现在回过来总结一下。

刚开始用的时候遇到一个比较大的疑问,就是如何设计redux的store中的state树,这应该是我在使用redux中最大的一个疑问,阻挡了我前进的脚步,当时查阅了许多博客和官方文档,还询问了许多做react的同学,基本上讲的都不是很清楚,可能本身理解的就有问题或者是表达能力有限。我这里给大家用非常通俗易懂的方式说一说我的疑问,以及我是如何解决的。

当初主要的疑问是:

1.state树是按照页面划分

2.还是按照数据库中的表(users,events这种数据集)划分

第一种就是把每个页面当成一个模块,实际上这也是比较流行的一种做法,有很多公司是这么做的,比如你可以像下面这样初始化你的store

module.exports = function() {
  return {
    common: {
      isFetching: false
    },
   profile: {
   },
list: {
},
   edit: {
   },
home: {
}
}
}

这种方式的优点就是模块与模块之间互相独立,不会相互影响,非常清晰易懂,每个模块维护自己的reducer,并且只在store中存入页面展示或者变化的数据,数据量是最小化的,但这也是它的小缺点,假如一个页面的数据依赖于另一个页面的操作,比如,你有一个编辑页面,改变了数据,这时候数据库里面的数据已经被改变了,退回到列表展示页,这时候这个页面的state是独立的,并不会发生变化。

要让依赖的列表页面也产生同步的更新,这里有三种做法:

1.每次进入列表页面去数据库刷新store(如果你做的是一个用户端App显然不会这么干,只可能在第一次进入的时候才会载入数据,第二次应该就不会发请求了,立即从store中拿出缓存数据,否则性能和体验太差,PC端后台倒是无所谓)

2.编辑完保存后dispatch一个action,去改变你修改的那条数据(相对还是比较可靠的办法,但如果改动的数据牵连比较多,不建议这么做,容易造成缓存数据与真实数据的差异性,代码也不太好维护)

3.编辑完后除了更新数据库中的数据,在前端这边什么都不做,等到页面DidMount之后发一个请求,不要出loading图,检查一下数据是否被更新过,如果是,刷新当前页面,如果不是,那就不变,具体做法请先看一下协商缓存相关知识

你可能觉得还有第四种做法,比如为什么不把编辑页面的数据从列表页面的state读出来,共用一个state。首先,编辑页面通常是有表单的,如果你要使用controlled component,必须要维护react组件自己的state,这边可能压根就不会用到store,其次,列表页的展示数据可能非常少,而编辑页是非常多的字段需要编辑,如果共用一个state分支,显然不合理,先进入列表页,就已经把所有字段的数据都加载完了,说不定用户根本不会进入编辑页,这是一种性能浪费,而且每个页面就是一个独立的state分支,不要破坏这种设计原则。

  注意:没有人告诉过你,用了redux必须通篇要用store中的state,redux作者也没有这么讲过,比较好的做法是结合react组件自己的state和store中的state一起使用。

 

第二种就是按照数据库中的表划分,比如你可以像下面这样初始化你的store

module.exports = function() {
  return {
    common: {
      isFetching: false
    },
   companies: {
    }, 
    users: {
    },
    events: {
    }
  }
}

这种可能比较适合单页应用,比如(trello,coding.net),一下子把所有的数据都读出来,把store当成数据库使,增删改查都维护这一个store,作为数据源的存在,这种方式比较好理解,就不多介绍了,优点是无刷新,体验好,交互好,什么操作都是实时的,无需等待,把前端的展示完全和服务端剥离出来了,缺点就是启动应用耗时较长,并且前端人员更新数据需要更新两份,要发请求到后端操作数据库,也要更新store中的数据源。

结语:redux作者的意思大概就是,对于store中的state树,躺着用,跪着用,骑着用,想怎么用怎么用,并没有明确规定必须怎么用,只要能优雅的设计state树,解决你自己的特殊场景就可以了。

下面引一篇写的不错的博客,帮助大家排坑。

0x00 store为页面服务,以及遇到的坑

在前期使用这种方案真心不要太爽:

  1. 看着设计图想想所需要的数据
  2. 按照这些数据构建每个页面的state树节点
  3. 写可能的action处理每一个用户行为
  4. 构建好用户请求用到的异步action
  5. 写reducer处理请求到的数据
  6. 接入现实数据
  7. 测试

完美,action写法统一,管理方便,reducer写法统一,管理方便,数据流下来,刷新不用自己处理(setState)。但是,最终发现,自己的项目,单页面这样写写很不错,但是并不适合写一个完整的app或者前后端同构的web app。(遇到啥问题下面说,先看看这种store的组织方式怎么做)

0x01 什么是store为页面服务

即根节点往下是各个页面,页面中的组件对应着state子树中的节点。

在React中,一个页面是由若干个嵌套的组件构成,每个组件都有相应的数据输入,最终这些数据输入可以反映成一个树状结构,最后我们直观的使用这个树状结构到state树上,就如下图所示。


page based state.png

0x02 遇到了什么问题

  1. 重复页面倒退
  2. 数据同步

重复页面倒退是指如下这种情况:


wenti1.png

假设这种情况:在React Native和Redux构建的一个App中,我们从首页feed流进入某个id为1的文章的详情页,从详情页进入了某个推荐列表,然后又从这个推荐列表进入了另一个id为2的文章的详情页。

现在问题来了,无论是id为1的详情页,还是id为2的详情页,它们用的都是同一个state树的分支(可能叫state.detailPage),所以当页面浏览到id为2的详情页的时候,数据请求完毕之后设置到state.detailPage分支,id为1的数据就被替换掉了。如果这个时候要回退到id为1的详情页,就必须得重新获取数据。否则就还是id为2的数据。

在web应用里,用户比较习惯在白页中重新加载数据(当回退的时候),可是在app中,这是不符合习惯的,而且在app中页面发生了变化,只是向一个页面的堆栈中压入一个新的页面,之前的页面并不会释放调,我们习惯性将获取页面数据放入componentWillMount或者constructor或者任一个生命周期函数中,都不会执行(也就是说不会重新获取数据)。

第二个问题是数据同步。

还记得flux的引入是为了解决什么问题吗?

facebook右上角的消息提醒总是莫名其妙的出现,因为同是消息提醒的数据在不同的数据源中可能重复存有多份。使用flux可以让数据的源头只有一个,所有的展示都是通过一个源头流下来的数据产生的,某个页面中我读取了当前的所有消息,发送一个action告诉数据源,现在未读消息变为0了,然后所有地方的未读消息,受这个数据的改变都变成0了。

然而如果采用页面即数据的这种方案,即使数据源只有一个,但是同一种数据也有可能在多个地方存储过。例如上面的例子:列表页中可能有某个文章的标题(在列表页树分支的某个节点上),这个文章的详情页也有这个文章的标题,假设我在某个地方(假设是详情页)更改了这个树分支上的标题,其他树分支上标题并不会改变(例如列表页,因为是不同的数据),依然没有解决flux根本想解决的问题。

0x10 寻求解决方案

两个问题都有其各自单独的解决方案。

要解决相同页面会退的问题,就必须区分id为1和id为2的数据,例如,我们可以在state.detailPage[1]state.detailPage[2]中分别存放id为1的详情页的数据和id为2的详情页的数据,然而redux的combine并不能动态的增加分支,分之节点都是事先预置好的,要实现这种,我们只能自己写中间件,或者自己实现插入分支(我是这样做的)。

如果要解决数据同步的问题,有两种方案:第一种,使用事件机制,所有要跟着变动的地方,建一个变更的事件,当变更的时候触发这个事件,让所有相关的地方发生改变;第二种,不管是列表的标题还是详情的标题,都只存一次,存在一个地方,那么不同地方取的都是同一个数据,就可以自然同步了。

方案一让人感觉redux并没有帮上什么忙,第二种方法不太好实现,在实际中,我们混合使用了两种方案。

这两个问题看下来,让人第一感受是:数据(按照ID区分,会同时出现在多处的那种)和页面需要分离,数据以表的形式存在,并且只存一次。

如果解决这个问题呢?还是以上面可能的app为例:

我们建立一个叫文章的state下的子树,其是一个id, value的map,用id区分(当然,得自己实现),当然也会有一个叫首页的子树,但是首页只有一个,所以它可以正常来,但是首页的feed流list只是一个id的list,其并不包含具体数据,具体数据都在叫文章的子树里。

在reducer获取的时候,先将列表接口获取的已有的数据赋给文章map对应id的各个文章,然后向列表页(首页feed)返回一个id的列表。列表页要取详情,就去文章的map中自己取。到了详情页,向后端接口获取详情数据,再将文章map中,让正在访问的这条的信息更新的更完备。

0x11 另一个构建store的方法 - Mobx

其实总得来说flux应该是一套从后端到前端一路向下的数据解决方案,而不应该仅仅只是用在react的前端这块的数据处理,要是这样的话,可能它方便之处并不在于单一数据源。而应该在于前端开发和调试的时候,能规整代码结构,让数据可追溯,并且可以很方便的缓存数据。而如果用上面提交的方案来处理前端数据,首先id动态生成数据redux是天然支持的,我们得用其提供的方法自行实现。

另外,有很多reducers其实并没有做数据处理,只是简单的把数据做了转发,而获取数据由往往是通过异步的action来实现的,那这样的reducer是否有必要存在?

Mobx实际上是为了解决这样麻烦的reducer而产生的,直接让action改动数据,然后用双向绑定的方式将数据直接映射到界面中。用来简化前端的数据流程。他和MVVM的不同处在于数据是单独出来的作为store的存在。在react组件中绑定store中的数据,类似于以前打模板的方式,当store变化时自动就会映射到界面中,所有的数据操作都在action中进行。

https://mobxjs.github.io/mobx/


flow.png

如此,我们就不必在意页面取什么数据了,store就看成数据库,使用mobx提供的asMap生成按照id -> value的键值对来处理不同id的同种数据。

0x12 最佳实践给的灵感

官方文档给的给出了建议的构建store的方式:https://mobxjs.github.io/mobx/best/store.html

Most applications benefit from having at least two stores. One for the UI state and one or more for the domain state.

建议我们至少新建两个store(实际上应该是两种),一个UI state一个domain state:

  • UI state是指当前UI的状态,比如:窗口尺寸、当前展示的页面、渲染状态、网络状态等等
  • Domain state则主要包含页面所需的各种数据(一般是需要从后端获取的)。例如:
    • 文章详情(id为索引的数据表)
    • 首页feed(只有一个,不需要列表)
    • 推荐列表(推荐id索引的数据表,每一项的内容又是一个文章id的列表)

其新建store的方式也并不和redux一样,在mobx中,一个store是一个类,而具体的state则是它的实例。

另外,所有需要按照id区分,多处会用到或者修改的数据,应单独抽象成一个domain state。某store内部自己需要的,按照id区分的数据,可单独以map的形式存在某store内部。

在它官方给的例子中,只有一个domain state,就是TodoStore,用来存储todo list和相应的操作(这些操作可以声明成action)如果整个app中只有一个todo list的话,那整个state就是一个TodoStore的实例了。

官方代码略class TodoStore

这样抽象下来的话,todo也可以抽象成一个类,而每个todo item都是todo的实例,多个todo存储在todoStore中,也满足我们对整个数据的抽象。

官方代码略class Todo

假设我们以后要新增查看todo item的详情(例如:里面有具体计划之类的)。我们也都是对同一个todo的对象进行操作。而具体我们展现的是那个todo item的页面,我们可以放到ui state中。

不知道有没有比较好理解,可以留言反馈下,或者实际操作下Mobx

0x20 效果

还是以上面的例子来说明,页面有:首页feed、详情页、推荐列表

0x21 创建store

import {
    observable, action,
    asMap, extendObservable
} from 'mobx'

// ui state
export const ui = observable({
    pendingRequests: 0,
})

// 首页feed流数据
class HomeStore {
    @observable feed: string[] = []
    @action('获取feed流') async fetchFeed() {
        const data = await requestFromServer()
        // 请求接口并且获得了数据 data
        this.feed = data.list.map(item => {
            const id = item.id
            if(!detail.has(id))
                detail.set(id, new Detail(item))
            return id
        })
    }
}

// 需要是一个map的store,比如文章详情,推荐列表等等
class mapStore<T> {
    @observable data = asMap<T>()
    get(id: string) { return this.data.get(id) }
    set(id: string, value: Detail) { this.data.set(id, value) }
    has(id: string) { return this.data.has(id) }
}

// 文章详情
class Detail {
    id: string
    // ...其他属性 
    constructor(item: any) {
        extendObservable(this, item)
    }
    @action('获取详情') async fetch() {
        const data = await requestFromServer(this.id)
        extendObservable(this, data)
    }
    @action('保存编辑') async save(data) {
        extendObservable(this, data)
        await submitToServer(data)
        await this.fetch()
    } 
}

// 推荐列表
class Recommend {
    @observable id: string = null
    @observable list: any[] = []
    constructor (id: string) {
        this.id = id
        this.fetch()
    }
    @action('获取推荐列表') async fetch() {
        this.list = await requestFromServer()
    }
}

export const detail = new mapStore<Detail>()
export const home = new HomeStore
export const recommend = new mapStore<Recommend>()

0x22 页面取数据

就单独以一个首页为例子吧,现在首页的feed流中只有id,而具体数据都充detailStore中取

import * as React from 'react'
import { View, Text, ListView } from 'react-native'
import { observer } from 'mobx-react/native'
import { detail, home } from './stores'

const ds = new ListView.DataSource({
    rowHasChanged: (r1, r2) => r1 !== r2
})
export const Home = observer((props: any) => {
    const list = home.feed.map(id => detail.get(id))

    return <ListView
        dataSource={ds.cloneWithRows(list)}
        renderRow={(item) => <Text>{item.content}</Text>}
    />
})

假设我们现在进入详情页,修改了某个文章

import { detail } from './stores'

// ...

detail.get(id).save(changedData)

列表页会实时变动。

爱编程-编程爱好者经验分享平台

文章评论

  

版权所有 爱编程 © Copyright 2012. w2bc.com. All Rights Reserved.
闽ICP备12017094号-3