19.揭秘 React 真谛:数据状态管理

如果说组件是 React 应用的骨骼,那么数据就是 React 应用的血液。单向数据流就像血液在应用体中穿梭。处理数据向来不是一件简单的事情,良好的数据状态管理不仅需要经验的积累,更是设计能力的反应。目前来看 Redux 无疑能够将数据状态理清,与此同时 Vue 阵营模仿 Redux 的 Vuex 也起到了相同的效果。这一讲我们就来谈谈数据状态管理,了解 Redux 的真谛,并分析其利弊和上层解决方案。

如果说组件是 React 应用的骨骼,那么数据就是 React 应用的血液。单向数据流就像血液在应用体中穿梭。处理数据向来不是一件简单的事情,良好的数据状态管理不仅需要经验的积累,更是设计能力的反应。目前来看 Redux 无疑能够将数据状态理清,与此同时 Vue 阵营模仿 Redux 的 Vuex 也起到了相同的效果。这一讲我们就来谈谈数据状态管理,了解 Redux 的真谛,并分析其利弊和上层解决方案。」

相关知识点如下:

数据状态管理之痛
我们先思考一个问题,为什么需要数据状态管理,数据状态管理到底在解决什么样的问题。这其实是框架、组件化带来的概念,让我们回到最初的起点,还是那个简单的案例:


点击页面中一处「收藏」之后,页面里其他「收藏」按钮也需要切换为「已收藏」状态:

如果没有数据状态,也许我们需要:

const btnEle1 = $('#btn1')
const btnEle2 = $('#btn2')

btnEle1.on('click', () => {
   if (btnEle.textContent === '已收藏') {
       return
   }
   btnEle1.textContent = '已收藏'
   btnEle2.textContent = '已收藏'
})

btnEle2.on('click', () => {
   if (btnEle2.textContent === '已收藏') {
       return
   }
   btnEle1.textContent = '已收藏'
   btnEle2.textContent = '已收藏'

})

这只是两个按钮的情况,处理起来就非常混乱难以维护了,这种情况非常容易滋生 bugs。

现代化的框架解决这个问题的思路是组件化,组件依赖数据,对应这个场景数据状态就是简单的:

hasMarked: false / true

根据这个数据,所有的收藏组件都可以响应正确的视图操作。我们把面条式的代码转换成可维护的代码,重中之重就成了数据的管理,这就是数据状态的雏形。但是数据一旦庞大起来,如何和组件形成良好的交互就是一门学问了。比如我们要思考:

  • 一个组件需要和另一个组件共享状态
  • 一个组件需要改变另一个组件的状态

以 React 为例,其他框架类似,如果 React 或者 Vue 自己来维护这些数据,数据状态就是一个对象,并且这个对象在组件之间要互相修改,及其混乱。

接着我们衍生出这样的问题:hasMarked 这类数据到底是应该放在 state 中维护,还是借助数据状态管理类库,比如在 Redux 中维护呢?至少这样一来,数据源是单一的,数据状态和组件是解耦的,也更加方便开发者进行调试和扩展数据。

我们以 React state 和 Redux 为例,继续分析上面抛出的「数据谁来维护?」问题:

  • React 中 state 维护数据在组件内部,这样当某项 state 需要与其他组件共享时,我们可以通过 props 来完成组件间通讯。实践上来看,这就需要相对顶层的组件维护共享的 state 并提供修改此项 state 的方法,state 本身和修改方法都需要通过 props 传递给子孙组件。

  • 使用 Redux 的时候,state 维护在 Redux store 当中。任何需要访问并更新 state 的组件都需要感知或订阅 Redux store,这通常借助容器组件来完成。Redux 对于数据采用集中管理的方式。

我尝试从数据持久度、数据消费范围上来回答这个问题。

首先,数据持久度上,不同状态数据在持久度上大体可以分为三类:

  • 快速变更型
  • 中等持续型
  • 长远稳定型

快速变更型, 这类数据在应用中代表了某些原子级别的信息,且显著特点是变更频率最快。比如一个文本输入框数据值,可能随着用户输入在短时间内持续发生变化。这类数据显然更适合维护在 React 组件之内。

中等持续型数据, 在用户浏览或使用应用时,这类数据往往会在页面刷新前保持稳定。比如从异步请求接口通过 Ajax 方式得来的数据;又或者用户在个人中心页,编辑信息提交的数据。这类数据较为通用,也许会被不同组件所需求。在 Redux store 中维护,并通过 connect 方法进行连接,是不错的选择。

长远稳定型数据, 指在页面多次刷新或者多次访问期间都保持不变的数据。因为 Redux store 会在每次页面挂载后都重新生成一份,因此这种类型的数据显然应该存储在 Redux 以外其他地方,比如服务端数据库或者 local storage。

下面,我们从另一维度:数据消费范围来分析。数据特性体现在消费层面,即有多少组件需要使用。我们以此来区分 React 和 Redux 的不同分工。广义上,越多组件需要消费同一种数据,那么这种数据维护在 Redux store 当中就越合理;反之,如果某种数据隔离于其他数据,只服务于应用中某单一部分,那么由 React 维护更加合理。

具体来看,共享的数据在 React 当中,应该存在于高层组件,由此组件进行一层层传递。如果在 props 传递深度上,只需要一两个层级就能满足消费数据的组件需求,这样的跨度是可以接受的;反之,如果跨越层级很多,那么关联到的所有中间层级组件都需要进行接力赛式的传递,这样显然会增加很多乏味的传递代码,也破坏了中间组件的复用性。这个时候,使用 Redux 维护共享状态,合理设置容器组件,通过 connect 来打通数据,就是一种更好的方式。

一些完全不存在父子关系的组件,如果需要共享数据,比如前面提到过的一个页面需要多处展示用户头像。这往往会造成数据辐射分散的问题,对于 React 模式的状态管理十分不利。在这种场景下,使用 Redux 同样是更好的选择。

最后一点,如果你的应用有跟踪状态的功能,比如需要完成「重放」,「返回」或者「Redo/Undo」类似需求,那么 Redux 无疑是最佳选择。因为 Redux 天生擅长于此:每一个 action 都描述了数据状态的改变和更新,数据的集中管理非常方便进行记录。

最后,什么情况下该使用哪种数据管理方式,是 React 维护 state 还是 Redux 集中管理,这个讨论不会有唯一定论。这需要开发者对于 React、Redux 有深入理解,并结合场景需求完成选择。

上面的 Redux 可以被任何一个数据管理类库所取代,也就是说,适合放在 Redux 中的数据,如果开发者没有使用 Redux,而使用了 Mobx,那么也应该放在 Mobx store 中。

数据管理场景

我们来看一个场景来加深理解。

Redux 到底怎么用

某电商网站,应用页面骨架如下:


对应代码:

// 遍历渲染每一个商品

其中,ProductsContainer 组件负责渲染每一个商品条目:

import Product from './Product';

export default class ProductsContainer extends Component {
  constructor(props) {
    super(props);

    this.state = {
      products: ['商品 1', '商品 2', '商品 3'],
    };
  }

  renderProducts() {
    return this.state.products.map((product) => {
      return <Product name={product} />;
    });
  }

  render() {
    return <div>{this.renderProducts()}</div>;
  }
}

Product 组件作为 UI 组件/展示组件,负责接受数据、展现数据,Product 即可以用函数式/无状态组件完成:

import React, { Component } from 'react';

export default class Product extends Component {
  render() {
    return <div>{this.props.name}</div>;
  }
}

这样的设计,完全使用 React state 就可以完成,且合理高效。

但是,如果商品有「立即购买」按钮,点击购买之后加入商品到购物车(对应上面 Cart Info 部分)。这时候需要注意,购物车的商品信息会在更多页面被消费。比如:

当前页面右上角需要展示购物车里的商品数目

  • 购物车页面本身
  • 支付前 checkout 页面
  • 支付页面

这就是单页面应用需要对数据状态进行管理的信号:我们维护一个 cartList 数组,供应用消费使用,这个数组放在 Redux 或者 Mobx,或者 Vuex 当中都是可行的。

合理 connect 场景

在使用 Redux 时,我们搭配 React-redux 来对组件和数据进行联通(connect),一个常陷入的误区就是滥用 connect,而没有进行更合理的设计分析。也可能只在顶层进行了 connect 设计,然后再一层层进行数据传递。

比如在一个页面中存在 Profile、Feeds(信息流)、Images(图片)区域,如图所示。


这些区域构成了页面的主体,它们分别对应于 Profile、Feeds、Images 组件,共同作为 Page 组件的子组件而存在。

如果只对 Page 这个顶层组件进行 connect 设计,其他组件的数据依靠 Page 组件进行分发,则设计如图所示:


这样做存在的问题如下:

  • 当改动 Profile 组件中的用户头像时,由于数据变动整个 Page 组件都会重新渲染;
  • 当删除 Feeds 组件中的一条信息时,整个 Page 组件也都会重新渲染;
  • 当在 Images 组件中添加一张图片时,整个 Page 组件同样都会重新渲染。

因此,更好的做法是对 Profile、Feeds、Images 这三个组件分别进行 connect 设计,在 connect 方法中使用 mapStateToProps 筛选出不同组件关心的 state 部分,如图所示:


这样做的好处很明显:

  • 当改动 Profile 组件中的用户头像时,只有 Profile 组件重新渲染;
  • 当删除 Feeds 组件中的一条信息时,只有 Feed 组件重新渲染;
  • 当在 Images 组件中添加一张图片时,只有 Images 组件重新渲染。

扁平化数据状态

扁平化的数据结构是一个很有意义的概念,它不仅能够合理引导开发逻辑,同时也是性能优化的一种体现。请看这样的数据结构:

{
 articles: [{
   comments: [{
     authors: [{
     }]
   }]
 }],
 ...
}

不难想象这是一个文章列表加文章评论互动的场景,其对应于三个组件:Article、Comment 和 Author。这样的页面设计比比皆是,如图所示:


相关 reducer 的处理很棘手,如果 articles[2].comments[4].authors1 发生了变化,想要返回更新后的状态,并保证不可变性,操作起来不是那么简单的,我们需要对深层对象结构进行拷贝或递归。

因此,更好的数据结构设计一定是扁平化的,我们对 articles、comments、authors 进行扁平化处理。例如 comments 数组不再存储 authors 数据,而是记录 userId,需要时在 users 数组中进行提取即可:

{
 articles: [{
   ...
 }],

 comments: [{
   articleId: ..,
   userId: ...,
   ...
 }],

 users: [{
   ...
 }]
}

不同组件只需要关心不同的数据片段,比如 Comment 组件只关心 comments 数组;Author 组件只关心 users 数组。这样不仅操作更合理,而且有效减少了渲染压力。

Redux 的罪与罚

前文终点提到了 Redux,其实现原理较为简单,核心代码也不过几行,简要来说:Redux 是我们之前提到的发布订阅模式结合函数式编程的体现。这里不再过多赘述,我们主要来看看以 Redux 为首的数据状态管理类库的「缺陷」和发展点。

其实,Dan Abramov 很早就提到过 「You might not need Redux」,文中提到了 Redux 的限制。他也说过 「Try Mobx」 这种「打脸」行为。归纳一下,Redux 的限制主要体现在:

Redux 带来了函数式编程、不可变性思想等,为了配合这些理念,开发者必须要写很多「模式代码(boilerplate)」,繁琐以及重复是开发者不愿意容忍的。当然也有很多 hack 旨在减少 boilerplate,但目前阶段,可以说 Redux 天生就附着繁琐。

  • 使用 Redux,那么你的应用就要用 objects 或者 arrays 描述状态。
  • 使用 Redux,那么你的应用就要使用 plain objects 即 actions 来描述变化。
  • 使用 Redux,那么你的应用就要使用纯函数去处理变化。
  • 应用中,状态很多都要抽象到 store,不能痛痛快快地写业务,一个变化就要对应编写 action(action creator)、reducer 等。

这些「缺点」和响应式结合函数式的 Mobx 相比,编程体验被「打了折扣」。

Redux 上层解决方案

为了弥补这些缺点,社区开启了一轮又一轮的尝试,其中一个努力方向是基于 Redux 封装一整套上层解决方案,这个方向以 Redux-sage、dva、rematch 类库或框架为主。
我总结一下这些解决方案的特点和思路:

  • 简化初始化过程
    传统的 Redux 初始化充满了 hack,过于函数式,且较为繁琐:
import { createStore, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from './reducers'

const initialState = {
   // ...
}

const store = initialState => createStore(
   rootReducer,
   initialState,
   compose(
       applyMiddleware(thunk),
       // ...
   )
)

这其中我们只应用了一个中间件,还没有涉及到 devtool 的配置。而不论是 Dva 还是其他方案,都采用面向对象式的配置化初始。

  • 简化 reducers
    传统的 reducers 可能需要写恼人的 switch...case 或很多样板代码,而更上层的解决方案进行封装后,类似:
const reducer = {
   ACTIONTYPE1: (state, action) => newState,
   ACTIONTYPE2: (state, action) => newState,
}

更加清爽。

  • 带请求的副作用

处理网络请求,Redux 一般需要 thunk 中间件,它的原理是:首先 dispatch 一个 action,但是这个 action 不是 plain object 类型,而是一个函数;thunk 中间件发现 action type 为函数类型时,把 dispatch 和 getState 等方法作为参数,传递给函数进行副作用逻辑。

如果读者不是 React、Redux 开发者,也许很难看懂上一段描述,这也是 Redux 处理异步副作用的晦涩体现。更上层的解决方案 Redux-saga 采用 generator 的思想,或 async/await 处理副作用,无疑更加友好合理。

为了更好地配合生成器方案,上层方案将 action 分为普通 action 和副作用 action,开发者使用起来也更加清晰。

  • reducer 和 action 合并
    为了进一步减少模版代码,一个通用的做法是在 Redux 之上,将 reducer 和 action 声明合并,类似:
const store = {
   state: {
       count: 0,
       state1: {}
   },
   reduers: {
       action1: (state, action) => newState,
       action2: (state, action) => newState,
   }
}

这样的声明一步到位,我们定义了两个 action:

  • action1
  • action2

它们出自于 store.reducers 的键名,而对应键值即为 reducer 逻辑。

这些都是基于 Redux 封装上层解决方案的基本思想,了解了这些,Dva、Redux-saga 原理已经对读者不再陌生!

当然,理清了数据状态管理的意义,简化了数据管理的操作,我们还要分析到底应该如何组织数据。

我们到底需要怎样的数据状态管理

关于 Redux,这里不再过多讨论。我们试图脱离开 Redux 本身,思考到底需要什么样的数据状态管理方案。整理我们的核心诉求就是:方便地修改数据,方便地获取数据。

新的发展趋势:Mobx
从核心诉求出发,我们有两种做法:修改数据,Redux 提倡函数式、提倡不可变性、提倡数据扁平化,获取数据说到底是依赖发布订阅模式。相对地,Mobx 是面向对象和响应式的结合,它的数据源是可变的,对数据的观察是响应式的:

const foo = observable({
   a: 1,
   b: 2
})

autoRun(() => {
   console.log(foo.a)
})

foo.b = 3 // 没有任何输出
foo.a = 2 // 输出:2

这像不像我们前面课程提到的数据拦截/代理?没错,它们的原理都是完全一致的。尝试对上面的代码改为:

const state = observable({
   state1: {}
})

autoRun(() => {
   return ()
})

state.state1 = {}

当我们改动 state.state1 时,autoRun 的回调将会触发,引起了组件的重新渲染。不同于 Redux,这就是另一种流派 Mobx 的核心理念。

不管是 Redux 还是 Mobx,它们都做到了:组件可以读取 state,修改 state;有新 state 时更新。这个 state 是单一数据源,只不过修改 state 方式不同。更近一步地说,Mobx 通过包装对象和数组为可观察对象,隐藏了大部分的样板代码,比 Redux 更加简洁,也更加「魔幻」,更像是「双向绑定」。

对此我的建议是:在数据状态不太复杂的情况下,Mobx 也许更加简洁高效;如果数据状态非常复杂,或者你是函数式编程的粉丝,可以考虑 Redux,但是在 Redux 层上进行封装,使用类似 Dva 方案,是一个明智的选择。

如何做到 Redux free(context 和 hooks)

做到 Redux free,有两种选择:一个是拥抱 Mobx 或者 GraphQL,但还是没有脱离框架或者类库;另一个选择就是选择原生 React 方案,其中之一就是 context API,React 16.3 介绍了稳定版的 context 特性,它从某种程度上可以更方便地实现组件间通信,尤其是对于跨越多层父子组件的情况,更加高效。我们知道 Redux-react 就是基于 context 实现的,那么在一些简单的情况下,完全可以使用稳定的 context,而抛弃 Redux。

在 ReactConf 2018 会议中,React 团队发布了 React hooks。简单来看,hooks 给予了函数式组件像类组件工作的能力,函数式组件可以使用 state,并且在一些副作用后进行 update。useReducer hooks 搭配 context API 以及 useContext hook,完全可以模仿一个简单的 Redux。useReducer hooks 使我们可以像 reducer 的方式一样更新 state,useContext 可以隔层级传递数据,原生 React 似乎有了内置 Redux 的能力。当然这种能力是不全面的,比如对网络请求副作用的管理、时间旅行和调试等。

这不是一篇讲解 React 的课程,具体代码细节我们不再展开,感兴趣的读者可以参考:

总结

其实数据状态管理没有永恒的「最佳实践」。随着应用业务的发展,数据的复杂程度是不断扩张的,数据和组件是绑定在一起的概念,我们如何梳理好数据,如何对于特定的行为修改特定的数据,给予特定组件特定的数据,是一个非常有趣的话题,也是进阶路上的「必修课」。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容