React 体系下关于 Mobx 与 Redux 的一些思考

语言: CN / TW / HK

背景

无论是在 React 生态中,还是前端的大背景下,状态管理都是一个绕不过去的长久被谈论的话题。不管我们怎么评价,从现状来看,Redux 以及 Redux 的状态管理,确实已经成为了 第一首选 。(没办法,吹的人太多了,已经被吹晕乎了。。)

这其中,有的是社区发布的,如 Dva , Rematch 。有的是(Redux)官方发布的,如 redux-toolkit 。他们都有一个核心的共同点,即,是在 Redux 的理论基础上进行封装建模的。

除了 Redux 之外,以 Mobx , Mobx-state-tree 为代表。则是另一种不同思路的状态管理库。但是使用的人较少,差不多 10 个 React 开发者中,如果 9 个用过 Redux,差不多就只有 2 个用过 Mobx 了。

如果你要问(React)官方, 别问 ,问就是「Maybe you don't need State Management」,再问就是「 useState + useReducer + useContext 还不够吗?」。

然而, 稍微 做过业务开发的朋友应该都明白,这两句话可以归结为两个字—— 扯egg

由于这次的主要目标 不是对比所有 的状态管理库( Zustand , Recoil 等等)的异同,所以后面就不再提及这些库了。

Redux 和 Mobx,常见的说法是,它们对应了两种完全不同的设计思路,Immutable VS Mutable。不可变还是可变。可是这特么单词我都认识,是啥意思呢?虽然我们会有这样的疑问,或许还去知乎上看过某些专家头头是道的分析,看后不禁感叹——「 我悟了!」。

结果一到项目里,「怎么这组件不更新啊?」,「我都没改这个为啥会 re-render 啊?」,「这性能怎么这么差啊」。你不禁开始 疑惑到底是 React 辣 (当然在知乎上还是只能小声比比。。) 还是「咱真的没搞懂?」 呢。

果然,「打铁还需自身硬,还是要自己真的搞懂,才不会被专家忽悠」。我这样嘀咕道。

好了,因此,切入正题。

Redux VS Mbox(Immutable VS Mutable),这两种不同的思路会导致它们在与 React 这样的致力于 Immutable 数据的框架进行结合时,产生完全不同的结果。如果不去仔细区别, 很容易被经过包装之后的各种五花八门的库迷惑

什么意思呢,社区中有一种广为流传的 微信聊天记录 ,「都一样,Redux 也可以像 Vue 那样写(Vue 又被碰瓷了。。)」。

这句话 是什么意思呢 ,我想,大概是下面这样:

// 以前大家认为的 Redux 是(必须)这样写的
​
const UserReducer = (state, action) {
  switch (action.type) {
    case 'changeName': {
      return {
        ...state,
        userInfo: {
          ...userInfo,
          name: action.payload.newName,
        }
      };
    }
    default:
      return state;
  }
}
  
​
// 现在大家可以这样写了
​
import produce from 'immer';
  
const UserReducer = (draft, action) {
  switch (action.type) {
    case 'changeName': {
      draft.userInfo.name = action.payload.newName;
    }
  }
}
​
​
// 或者有更加系统的封装如 redux-toolkit 或者 Dva 
​
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
​
export const counterSlice = createSlice({
  name: 'user',
  initialState: { userInfo: {} },
  reducers: {
    changeName: (state, action) => {
      state.userInfo.name = action.payload.newName;
    },
  },
})
​
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer

于是很多人就认为,啊,Redux 原来可以写得这么舒(方)服(便),和 Vue(我也不知道为什么会扯到 Vue。。) 一样了,Immutable 不 Immutable 没区别 。尽管仍旧需要写 useSelector + useDispatch 让他们感到些许的烦恼。

真的没区别了吗?当然不是啦 。为了进行深入探索之前,我们先来 直观地 的感受下它们的区别(蓝色代表那些组件被 re-render 了):

Redux Demo with only number prop

Redux with only number prop https://www.zhihu.com/video/1469627550385790976

我们使用 Redux 跑一个应用,我们可以把里面的每个节点(Node)当做是我们日常项目中的任意一个组件。每一个组件都套了一层 React-Reduxconnect HOC。太 Redux 了。

// 数据层大概长这样
const root = {
  level: 1,
  id: '1-1',
  value: ' ',
  children: [
    {
      level: 2,
      id: '2-1',
      value: ' ',
      children: [
        {
          level: 3,
          id: '3-1',
          value: ' ',
          children: [],
        },
        {
          level: 3,
          id: '3-2',
          value: ' ',
          children: [],
        },
      ],
    },
  ]
};
​
// 然后把数据渲染出来
// ReduxNode 是一个经过 connect 包裹的组件,在 connect 的 mapStateToProps 中会通过 id 从 store 中找到这个 id 对应的数据
<ReduxNode id={ node.id } />

从视频里我们可以发现,当我们点击任意一个节点的时候,尽管我们只是修改这个节点自己的 value 值,但是 从这个组件到根组件的这条链路上的所有组件,都会 re-render。

这还算是比较好的情况,因为 <ReduxNode id={ child.id } /> 这里我们只传入了 id 这一个 prop,而这个 prop 还恰好是一个非引用类型。在真实项目中, 极有可能出现还传入了别的引用类型的 prop 。比如,像下面这样:

<ReduxNode id={ node.id } style={{ color: 'red' }} />

就会出现下面的情况:

Redux Demo with object prop

Redux with object prop https://www.zhihu.com/video/1469627861137559552

卧槽直接吓尿了!赶紧把之前吹 Redux 的评论删掉(不是。。

不管我们修改哪个节点 它自己value ,都会导致 整个应用所有的组件 re-render!

为什么会出现这种情况呢?这就和我们今天的主题,Immutable 有关系了,在 Immutable 中(Redux),由于我们不能直接修改一个引用(对象),只能通过 copy 改的方式 ,也就是俗称的点点点( { ...foo, bar: { ...foo.bar, x: newX } } )。

这种方式,会导致从你想要修改的那个数据的 object 的引用,到它的父 object 的引用,一直到根 object 的引用 都发生变化,变成一个新的引用。 当我们将数据与 UI 通过传递一个 prop 或者 props 的方式联系起来的时候,就会出现问题。

因为对于 React 而言,判断是否更新(re-render)的逻辑,恰恰就是看 props 的 引用 有没有发生变化,而不是看 props 的具体值有没有发生变化( { color: 'red' } 不等于 { color: 'red' } ,因为是不同的引用)。虽然我们可以通过浅比较,也就是传说中的 PureComponent 或者 React.memo 去包一层我们的组件( connect HOC 默认的作法),但是也仍然是杯水车薪,因为浅比较只是浅比较最外层的每个引用,没有办法一直 deep 然后去比较。

所以,我们可以 简单地 理解为,在 Redux 或者说 Immutable 的体系下。这里我们的组件是否 re-render, 并不是 我们期望的那样取决于依赖的值究竟变没变,而是取决于 依赖的值的引用 究竟变没变。

- 有数据变了 & 到底什么数据变了 VS 有组件需要更新 & 到底哪个组件需要更新

所以,在第一个例子( <ReduxNode id={ node.id } /> ) 的视频中,当我们点击最底层的节点的去修改 3-4 这个节点的 value 数据的时候,在 Reducer 里面经过点点点处理之后,最终会有三个数据的引用发生变化,分别是 :

  • 3-4 这个组件对应的 object 的引用
  • 3-4 这个组件的父组件( 2-2 )对应的 object 的引用
  • 3-4 这个组件的父组件的父组件( 1-1 )对应的 object 的引用。

到根 store 的 object ( 1-1 )的引用为止,一共有 三个 引用发生变化。所以,UI 上就有 三个组件 re-render。

为什么这个场景下不会所有的组件都更新呢?那是因为 connect 做了浅比较,而我们传给 ReduxNodeid 这个 prop 又刚好是一个 number 类型,所以在浅比较的时候就可以被判定为没有变化。

这也是为什么一旦我们传的 prop 是一个引用类型(如 style={{ color: 'red' }} ),且这个引用类型每次 render 都会变化(没有经过 useMemo 处理)的时候,会出现改过任意节点会导致所有组件 re-render。

因为无论修改哪个节点,按照上面说的,会导致这个节点到根数据的 object 的链路上的引用全部发生变化,也就是说,根组件依赖的数据的 object 的引用 一定会发生变化 ,那么, 根组件也一定会 re-render 。因此,从根组件开始,每个组件都会 re-render, 因为所有的组件都属于根组件的 children 。父组件(或者父组件的父组件)更新了, 要想自己不被更新 ,唯一的办法就是在想方设法加各种形式的「比较」了。

所有你会发现,这太难做到了,要做的比较类的工作太多了,在真实世界中,这种比较类的工作层出不穷,没有用 Redux 的时候,你需要用 PureComponent 或者 React.memo 甚至是自定义比较函数的 React.memo 来实现。但往往累人难维护之外,效果还很差。

在用了 Redux 之后,这类繁琐的工作又大部分转移(注意是转移不是消失)到了无穷无尽的 useSelector 或者其它类似 selector 的东西中。如 reselect , re-select .

所以,要想真的从根本上解决这个问题,无论是 React.memo 的方式,还是 selector 的方式,都是走不通的。因为 Immutable 的这种性质,就 决定了 它的使用形式和优化形式,你只能在这条路的限制下进一步看能不能再优化,而无法从根本上解决它面临的问题,因为那样会从底层上推翻它的建模基础。

那我们可以怎么做呢?

唯一能做的,就是尽一切努力, 让父组件(或者父组件的父组件)「能不渲染就不渲染」(因为我们根本就没改那些组件依赖的数据),而不是「先让它渲染,然后考虑怎么优化。」

所以要怎么做呢,我们回顾下,React 之所以会 re-render 我们的组件,是因为 React 发现了我们的组件的 props 发生了变化,所以,另外的一条路自然就是, 让 React「觉得」,props 没有变化

让 React 「觉得」是什么意思呢?

React 本身是一个 Immutable 的框架,我们无法改变这一点,更没有办法把 React 替换为别的框架,只能在这个基础上,实现我们想要的结果。

这就自然而然的引出我们今天的第二个主题,Mutable。同样,我们也先来 直观地 的感受下:

Mobx Demo with only number prop

<MobxNode id={ node.id } />
Mobx with only number prop https://www.zhihu.com/video/1469628334141714433

同样是只传 id 这样的 number prop 的例子,我们可以发现,使用 Mobx 之后,修改某个节点 自己 的 value 的时候, 无论 修改的节点位于组件树的 什么位置 ,都 只会 re-render 修改的那个组件 本身不会 re-render 任何其它的组件。

我们再来看看传其它的引用类型 prop 的话会怎么样:

Mobx Demo with object prop

<MobxNode id={ node.id } style={{ color: 'red' }} />
Mobx with object prop https://www.zhihu.com/video/1469628464509186049

即便我们现在传递的一个每次 render 都会变化的引用类型的 prop( style={{ color: 'red' }} ),在 Mobx 中,结果是不管我们修改哪个节点 它自己value ,都只会导致修改的那个组件,以及它的所有 children re-render,不会影响这个组件的兄弟组件(re-render),更不会影响这个组件的父组件(re-render)。

这是为什么呢?

这是因为,在 Mobx 体系中,数据天生就是 Mutable 的。Mutable 是什么意思呢,就是说,除非你自己手动把它赋值一个新的值或者引用, 没有谁,也没有任何约定或者法则会要求你改变其它不相干的东西。

也就是说,在上面的 MobxNode 组件中,我们通过传入的 props 里的 id 获取到 store 中这个组件对应的数据的 object。当我们在界面上点击 change 按钮的时候,实际上只做了一件事,即: object.value = newValue

而这里的 object.value = newValue ,是正儿八经的纯粹的 Javascript 赋值语句。 而不是像 Redux 中那样表面上是赋值语句,实际上只是帮你做了点点点的工作。

在 immer 或者 redux-toolkit 这类封装了状态管理的库中,常常在 action 里面也是执行 state.xx = newXX 就可以了。这在很大程度会让人形成「 Redux 和 Mobx 好像区别不大,用起来差不多」的错误印象。实际上,天壤之别呀。

也就是说,当我们执行完 object.value = newValue 之后,在 store 中, 没有任何 object 的引用会发生变化 。还记得之前的 Redux 例子中是怎样的吗? (会有三个 object 的引用发生变化,导致链路上的三个组件 re-render)。

正因为没有任何 object 的引用发生变化,所以在界面上 只有 我们点击的那个节点会重新渲染。

你可能会问,那如果 store 里没有任何 object 的引用发生变化,按理说任何组件都不应该渲染啊,为啥还能做到我们 change 的哪个就渲染哪个?

这就涉及到另一个核心问题,如何在一个 Immutable 的框架(React)中,使用 Mutable 的问题。而这个问题的核心又在于,当我们直接这样 object.value = newValue 修改了一个值而又没有任何引用发生变化, 如何让 React 知道,哪个组件需要 re-render 呢?

聪明的你肯定已经想到,当然只能是依赖收集啦!

只要我们能够知道点击 change 按钮的那个组件到底依赖了哪些数据(注意这里的重点是 依赖哪些数据而不是依赖哪些数据的引用 ),然后让这个组件去监听那些依赖的那些数据的变化,然后当它们变化的时候,就让组件重新 re-render 就好啦。

「当它们变化的时候,让组件重新 re-render 就好啦」这个简单,搞个 HOC,让需要响应变化的组件都套一层这个 HOC,在 HOC 里面监听,有变化就调用下 forceUpdate 就行了。

稍微麻烦的点在于「找出组件到底依赖了哪些数据」,其实也还好。在有模板的框架中,在模板里面用到的任何数据都会触发依赖收集,虽然 React 没有模板,不过 Class Component 里的 render 方法,或者 Functional Component 的函数体,其实差不多等价于模板。

什么意思呢,就是当我们在 JSX 里面写 <div>{ store.a.b }</div> 的时候,就代表了这个组件依赖 store.a.b 这个值,那么依赖收集就需要收集到这个信息,以便监听 store.a.b 的变化然后让这个组件 re-render。

和有模板的框架稍微不同的是,模板和 JS 层是天然隔离的,所以不会出现在 JS 层里去引用 store 的时候意外触发依赖收集。而在 React 的 Functional Component 中则不同,因为没有办法去区分一个函数的函数体里面哪部分属于「模板」,哪部分属于「JS 层」(这其实算是函数组件的一个很大的「缺点」。。)。

因此,只能做出一个「假设」,那就是,「函数体的最表层所有的对 store 的引用都会触发依赖收集」。什么叫最表层呢,就是直接执行一个函数会执行的那些步骤。其实可以近似地看作是人为的区分出函数体里的「模板」部分了。

而函数体里定义的各种 callback 或者 effect,由于在函数被执行期间并不会被调用,所以即使将来某个时间点里面会用到 store 里的数据,也不会触发依赖收集,当然,这正是我们期望的结果。因为依赖收集更准确的来说,是「针对 UI 里用到的数据的依赖收集」,而不是「任何用到了数据的地方都去当作依赖来收集」。

P.S 这也是为什么我们需要在 callback 或者 effect 里面 再去 「访问」callback 或者 effect 需要的数据,因为如果直接在函数体一开头就先访问然后在 callback 或者 effect 里去使用的话,那就相当于触发了组件的依赖收集,造成「明明只是希望在 callback 里拿到这个数据 最新的值 ,并不是组件真的依赖这个数据去渲染,然而这个数据变化却导致组件 re-render」的棘手问题。

所以只需要手动执行下 render 方法或者函数体,然后在这个执行期间所有触发的数据的 getter 就是这个组件依赖的所有数据了。这个在 Mobx 文档和 Youtube 讲演中有详细介绍,这里就不展开了。

因此,当我们在组件里根据 id 获取 store 中对应的数据的时候,不需要任何形式的 selector。因为「selector」早已经成为依赖收集的一部分。

这实际上只是另一种形式的 props 比较,可以算作是把 selector 进行「超级前置」,前置到早于组件 re-render 之前的依赖收集阶段去完成。当然,更准确的理解是,在 Mutable 的世界里,根本就不需要 selector。组件自己的 render 函数或者 JSX 里面就 已经在表达 (声明)自己依赖哪些数据了,如果 忽视掉 (比如纯 React 或者 Redux 项目)这些关键的信息, 当然就不得不 通过 selector 这样的东西 来重新「复现」这些关键的信息

这也是为什么在 Mobx 体系里,不需要任何 selector 或者 React.memo 的原因。

到这里我们基本就算明白了 Immutable VS Mutable 的来龙去脉了。虽然在真实的项目中不会每个组件都传了一个引用类型且未经过 memo 的 prop(比如这里的 style={ { color: 'red' } }),但是确实稍微一不小心就容易造成影响很大的 re-render。

下面我们来思(吹)考(比)下一些稍微抽象的问题。

Tip: 上面的 Demo 代码可以在 NE-SmallTown/redux-vs-mobx 仓库里找到。

小思考

1. 为什么在 Mobx 里面对数据的修改必须放到 action 里面?

const state = observable({ value: 0 })
​
// 1
const increment = action(state => {
    state.value++
    state.value++
})
​
increment(state);
​
// 2
runInAction(() => {
    state.value++
    state.value++
})

必须包装成 action 而不能直接在 callback 里面 state.value++ 的目的之一,应该是为了能够 batch(不能 batch 的话对性能影响非常大)。否则直接 state.value++ 完就更新的肯定是没办法做 batch 的了(因为没办法像 Concurrent Mode 那样把一个 Tick 甚至指定时间的聚合起来再 Flush)。

另一个目的是为了让在 action 里面的对 observeable 数据的读取不会触发依赖收集。

2. 为什么 Mobx 里的异步 action 需要声明为 generator 函数然后用 flow 方法包裹?

采用 generator 的目的,其实就是在「 JavaScript 的最小可「监控」单元必须是一个函数 」的基础上的一个黑魔法。 使得「最小可「监控」单元」从「函数」变成「一段语句」。

在一个 async await 函数中,Mbox 是 无法「知道」两个相邻的 await 语句之间的代码是何时被执行的 (至于为什么还要知道,第一条里说了,需要 batch),要想「知道」,只能是把每个 await 之间的对 observeable 数据的修改都包装在一个 action 里面。大约长这样:

// 从这样
async function callback() {
  await state.a++;
  
  XXXX
  
  await state.b++;
}
​
// 变成这样
// model.actions
actions = {
  incrementA() { state.a++; },
  incrementB() { state.b++; }
}
​
async function callback() {
  await model.incrementA();
  
  XXXX
  
  await model.incrementB();
}

这实在是太麻烦了。。。腱鞘炎就是这么来的呀!

但是用 flow + generator 的话,这个手动的过程就可以自动化。相当于 Mbox 可以通过 const gen = generator(); gen.next(); gen.next(); .... 的方式来人为构造一个 action (每次执行 gen.next() 就相当于执行一个 async await 里面的需要自己手动包装的 action )。

3. Immutable VS Mutable 另一种思考方式

前面提到的 Immutable VS Mutable 其实还可以从另一个角度来思(瞎)考(B)(吹)。

是什么呢?Redux 里面存在的问题,其实还可以看成是 store 本身是一个 object 树,而组件也是一个组件树导致的。如果 stote 本身是完全扁平的(没有任何层级关系),组件也是完成扁平的(没有任何层级关系),那 Redux 就不会有问题啦(不需要再点点点了,也不会再有引用发生变化的问题)。

所以,我们希望的是,在 UI 上我们还是能够按照一个(DOM)树形结构来组织我们的应用,但是在数据或者说数据流管理上,我们 却希望 我们能按照一个完全扁平的方式来组件我们的每一个组件,即,任何组件的父组件都是 document.body ,只有这样,我们才算是能够完全避免 「明明我只想或者只改了很叶子节点的一个组件的状态希望它重新渲染,但是它的父级以及父级的父级或者更父级的父级却不得不也重新渲染」 的问题(因为扁平之后就没有「父级」了」)。这是 props + Immutable 带来的根本问题。

但是很明显,这是很难实现的。。(CSS 和工程上都不太可能)

于是,在能够望见的可实现的范围内,更有希望的一种方式是, 「去 props 化」

什么意思呢,既然我们无法将每一个组件扁平化,也就是说,我们仍然不得不按照 树的方式 去组织我们的组件和应用代码。那么为了避免上面提供的问题,我们必须思考,造成这种现象的根本原因,是因为叶子节点(组件) 的状态是由外部 ,准确的来说, 是由 props 提供的 (当然,这里讨论的是业务组件而非纯 UI 库)。

因此,当我们想要更新界面的时候,必然只能通过 props 去修改,更具体的来说,在一个 Immutable 的环境下(如 Redux),只能通过 action 去修改。而 action 去修改 reducer 的时候,由于 reducer 本身 就是将界面用数据的方式 结构化(树形化)之后的体现 ,那么这个叶子节点在调用一个 action 的时候,由于 reducer 本身是从根 reducer 出发(也就是整个状态树的最顶层出发),那么自然从数据树的顶层找到与叶子节点对应的数据的过程,必然导致在 UI 链路或者说组件链路上也出现一个 对应的(re-render)过程 ,这是由于 props + 数据的 Immutable(叶子数据的修改会导致从叶子到顶层整个引用全部发生变化不再相等)带来的。

当一个组件使用 a.b.c.d 的时候,代表什么?Redux 说,这代表组件依赖 a , b , c 的引用,以及 d 的值。Mobx 说,这只代表组件依赖 d 的值。

所以,要避免这种问题,我们无路可走的只能想到两种方式:

  1. 「去 props 化」。(你这是要三大前端框架的命啊。。。不可能)
  2. 「Immutable -> Mutable」。

所以还是只能选 Mobx 这条路。这样之后,还是同样的场景,我们想要更新一个叶子节点的 UI。这时,我们只需要直接修改 store.foo[x].a.b = newState (当然具体而言这个肯定会被封装成一个类似 「action」的东西),然后 仅仅在这个叶子节点的组件的内部 调用 forceUpdate

是不是感觉像(仅仅是像。。)我们人为(Mobx)「接管」了整个应用的 diff/reconciler 过程哈哈哈。

4. 「请把我封装为组件时考虑下我的感受!」

在封装一个组件的时候,例如 :

function Foo() {
  return (
    ...
    
    <Bar xx={ xx } />
    
    <Baz bb={ bb } />
    
    ...
  )
}

我们需要考虑的问题应该是两个(但是往往 只考虑了下面的第一点 ):

  1. Foo 组件的性能(这样写会不会导致 Foo 组件不必要的 re-render)。
  2. Bar/Baz 组件的性能(这样写会不会导致调用 Bar/Baz 组件不必要的 re-render),这一点是很少被考虑或者关注到的。

对于 Foo 组件而言,需要确保它 return 里用到的那些子组件,应该是尽量隔离,尽量「互不影响」的。什么意思呢?

如果 Foo 组件传递给 Bar 组件的 props,和传递给 Baz 组件的 props 本身就没有什么关联或者说不太可能一起变化,那就应该尽可能让 BarBaz 组件自身去获取这些数据然后更新,而不是由 Foo 组件去获取然后更新再传给它们,因为这样必然造成不必要的 re-render(因为 BarBaz 永远是一起更新),特别是在 Redux 的情况下。

另外,对于 Bar/Baz 组件而言,需要确保传入的每个 prop 都是经过 memo 的。即,对于基础类型,不用管。 对于非基础类型 ,如 object, array, function 等, 一定要把这些 prop 通过 React.memo 包装 ,做到「引用复用」。 切忌,切忌,不能写对象/数组/函数字面量 。至少,在 JavaScript Records & Tuples Proposal 提案实现前不能写字面量。

5. 数据的自顶向下 VS 设计稿的自顶向下的冲突

设计稿
数据

在将设计稿通过组件复原成页面的过程中,我们的思维模式 需要是版面(排版)驱动的 (从上到下从左到右),但是对于具体的(代码)实现过程而言,我们的思维模式 却需要是数据 + 组件驱动的

设计稿看上去完全相同的 item ,到了数据(组件)层面, 却不得不受父级的引用和父级的组件的限制。

所以就会出现,你在 Foo1 里面改了 foo1.x ,并没有影响 foo2.x ,更没有影响 foo2 ,但是却无法避免的会改变 foo1foo2 共同的父级 Foos (对应数据 foos )使它变成一个新的引用。因为 foofoo2 它们在对象层面,在引用层面,在树这种数据结构层面,存在这种天然的特(限)性(制)。

在这样的情况下,页面的是否更新(re-render) 被强制捆绑成了一个整体 ,变成了一个牵一发而动「全身」的结局。

6. useMemo 的割裂

XXX, 所以,React Hooks 中类似 useMemo 这样的钩子的 deps 实际上并没有起到作用 。因为 deps 的(期望)目的是,如果 deps 没有变化,那么 组件本身 就不应该被渲染,类似于 Vue 的 computed。

但实际上 useMemo 之后的变量,React 并没有办法控制你到底用它来干嘛,即可能是用于 JSX 中(用于渲染),也可能是用于一个 effect 或者 callback 中,而 effect 和 callback 的 deps 如果依赖这个 useMemo 之后的变量,那么想要得到 最新的 callback, 必须通过使组件重新渲染这一手段来完成

所以就会出现这样一种情况,组件的 JSX 里只依赖一个 memo 后的变量,我们期望的是 「如果 memo 后的变量不变化,组件本身就不需要重新渲染」,而到了实际中,却变成了「即使 memo 后的变量不变化,组件也还是会重新渲染」。

function Foo() {
  const [ fuck, setFuck ] = useState({ foo: 1 });
  
  const computedObj = useMemo(
    () => ({ React: 'best', xiaochengxu: fuck }), 
    [ fuck ]
  );
  
  const callback = useCallback(() => {
    ...
    
    // at some time
    // 我们实际上并不关心 fuck 的值,只要 callback 被调用的那个时候能拿到最新的 fuck 就行了
    // 但是要实现这一点,没办法,只能在 deps 里面声明 computedObj 作为依赖
    // 而这样会导致 callback 的引用很容易变化,进而导致 Bar 组件不必要的渲染
    someLib({ param: computedObj })
  }, [ computedObj ]);
  
  return (
    ...
    <div>{ fuck }</div>
    
    <Bar onXX={ callback } />
    ...
  );
}

当然,我们可以用 useRef + useEffect 来解决这个问题,但这个问题本身的存在,实际上就有问题。。

在 Mobx 里,可以通过内置的 computed 这个 function 来避免这种情况。只要 computed 后的变量没有发生变化,组件就不会重新渲染。

到这里我们大概能够明白,React Hooks 实际上是把 Mbox 的 observer HOC 和 依赖收集/computed 分开了,或者说,只实现了后者( 依赖收集/computed )并把后者以 Hooks 的形式暴露给开发者。而前者( observer HOC ),并没有实现。或者说,只是在 React 源码内部进行了简单的实现。所以才会产生这样的割裂。

这里插一句, 其实,所以的函数类型的 prop,都应该是 ref。这样才能从根本上解决上面的问题,而不是说你用 Mobx 就没有这样的问题,用原生的 React 就有这样的问题。但是这个工作不能由开发者来做,太麻烦了,只能由框架来处理。(目前好像还没有这样的框架。。

7. useEffect 不可避免地被滥用

useEffect 这个东西,他(设计的本意或者说局限性)是和 render(视图,或者说组件的渲染) 强相关的,所以当我们面临的是视图相关的 effect 的时候,比如,更改某个状态,而这个状态会用在视图中,这个时候,我们会发现 useEffect 特别好用,合理,这是因为这个时候, useEffect 所表现出来的心智模型是和我们期望的是很 match 的。。

但是实际上组件里面,还有大量的与 UI 无关的逻辑和 effect。但是由于只有 useEffectuseLayoutEffect 也一样)这一个钩子,导致我们不得不,或者说, 只能是把这部分代码也放到 useEffect 里面去。

而当我们面临的不是视图相关的 effect,往往就会发现 很纠结和不知所措。 特别是涉及到嵌套的以及互相依赖的异步代码,由于 useEffet 只是和 deps 耦合 ,而 deps 只能是「数据」 。就会导致明明是逻辑上或者代码上的依赖,必须通过触发「数据」更新来进行一次二道贩子似的 中转来完成 。谁不讨厌黄牛呀!

这也为什么 useEffectdeps 经常会很麻烦,特别涉及到引用类型或者函数作为 deps 的时候,简直吐了。。

把一个变量放到 deps 里面去,其实有时候 希望的并不是这个变量变化的时候重新执行 effect (甚至这样做会导致错误)。而是,期望的只是在 effect 里面能读到这个变量最新的值,但是为了读到最新的值,我不得不把这个变量放到 deps 里面去,就成了一个死循环套娃问题了。。

归根结底,还是框架或者 JS 还没有想好或者说做不到怎么比较优雅的解决对引用类型的数据的依赖问题,Mobx 这种严格来说是换了一种完全不同的思路,而并不是真正意义上的解决了这个问题。这个问题本身,最终还是得由框架或者 JS 自己找到解决的办法。

8. 父组件与子组件的矛盾

目前的这种 父组件自己的 re-render 需要对子组件负责,即,会导致子组件也 re-render 的机制有时候实在很反人类 。倒不是这样的机制没法避免问题,因为目前我们可以通过 要求 子组件自己包一层 React.memo 来解决,但这在实际中很难做到,即:

function Parent({ children }) {
  const isLoading = useXXX();
  
  return (
    <div>
      <div className={ isLoading: 'mask' : '' }>...</div>     
      
      { children }
    </div>
  );
}
​
// 尤其是当这个 Parent 只是负责一个纯 UI 的跟 children 里面的要渲染的东西无关的组件时,这样的矛盾尤其明显
// 比如这里,Parent 只是一个纯布局组件,但是 isLoading 这个状态的变化导致的 Parent 的 re-render,将
// **同样导致** children 的 re-render。解决的办法也很简单,通常大家会把 children 用 React.memo 包一层来解决
// 但是这解决不了根本问题:
//  1.「把 children 用 React.memo 包一层」不是所有人都会注意要这么写,同时有可能没法包(第三方组件)
//  2. 目的是希望 Parent 在这种情况下不要影响,即,不要 re-render Child,而不是 re-render 了然后去优化

所以,更加可行的解决问题的方式是,当我们提供一个依赖 children 的组件的时候,不应该将逻辑直接置于组件本身,而应该 将逻辑封装到另一个组件中

function Parent({ children }) {
  return (
    <>
      <Logic />
      
      { children }
    </>
  );
}
​
function Logic() {
  const isLoading = useXXX();
​
  return (
     <div className={ isLoading: 'mask' : '' }>...</div>     
  );
}
​
// 现在,Parent 里面的 re-render,被转移到了 Logic 组件中,所以 Parent 的 re-render
// 不会导 children 也 re-render。所以,children 也不需要包一层 React.memo 了。

如果 children 不是直接放到 Parent 的直接子级,而是需要放到 Logic 里的某个地方怎么办呢?一样的,用同样的方式进行拆分即可。

其它状态管理库库

https:// github.com/pmndrs/zusta nd 或者 https:// recoiljs.org/docs/intro duction/getting-started 这样的多 store 的库,其实还是通过人工的方式去「优化」selector,使我们可以尽量缩小需要用到的 state 的范围来减少 re-render。但是这太累了不说,重点是 仍旧无法避免 只想用一个 store 里面的部分数据但是其它数据更新会导致自己被迫跟着更新,为了解决这个只能像 react-redux 那样手写完全具体的针对自己组件的 selector,又绕回去了。。

「其他文章」