浅析组件化时代的前端状态管理(四):Redux与Immutable

Redux

React + Redux是目前React技术栈下一个比较常见的组合, 其解决的问题就是上一节我们提到的 如何玩转 “组件化+数据驱动”,
简单的理解就是 React 负责管理视图, Redux提供一个可预测化的状态管理

在Redux中, 通过一个全局 Store保存整个页面的所有模型数据, 再通过 dispatch(action) 的方式更新store中的数据, 再通过 connect 的方式, 把 store的数据注入到组件中去.
也就是说, store每一个数据的改变, 都是通过 action触发的, 即 UI渲染的每一步也是和 action对应的, 假如我们能把这些action记录下来, 逆序 dispatch这些action, 甚至可以实现 “时光倒流的” 的效果,

如果没有接触过Redux可以简单的浏览一下 redux文档

Redux的简单介绍:

Action 是把数据从应用传到 store 的载体。它是 store 数据的唯一来源。一般来说你会通过 store.dispatch() 将 action 传到 store。

Reducers 指定了应用状态的变化如何响应 actions 并发送到 store 的,记住 actions 只是描述了有事情发生了这一事实,并没有描述应用如何更新 state。

Store 就是把它们联系到一起的对象。Store 有以下职责:
维持应用的 state;
提供 getState() 方法获取 state;
提供 dispatch(action) 方法更新 state;

Redux是如何驱动UI更新的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

_________ ____________ ___________
| | | | | |
| Action |------------▶| Dispatcher |------------▶| callbacks |
|_________| |____________| |___________|
▲ |
| |
| |
_________ ____|_____ ____▼____
| |◀----| Action | | |
| Web API | | Creators | | Store |
|_________|----▶|__________| |_________|
▲ |
| |
____|________ ____________ ____▼____
| User | | React | | Change |
| interactions |◀--------| Views |◀-------------| events |
|______________| |___________| |_________|


component(渲染UI) -> action(定义用户操作动作) -> reducer(处理action动作) -> store(处理reducer绑定state和dispatch) -> component(Provider注入数据, connect连接组件, 驱动UI更新

图片来源

看一个简单的 React-redux 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import React, { PureComponent, Component } from "react";
import { createStore } from 'redux'
import { connect, Provider } from 'react-redux'

// reducer/index.js
// reduce里面都是纯函数, 不依赖其他的全局状态. 具有幂等性, 简单的理解就是传入 store 和 action, 函数结束时返回新的 store, 然后触发页面UI的更新
const reducer = (state = { str: '✒️write something: ', placeholder: 'here?' }, action) => {
switch (action.type) {
case 'INPUTCHANGE':
return {
str: action.value
};
default:
return state;
}
};


// actions/index.js
const onChangeAction = (e) => (
{
type: 'INPUTCHANGE',
value: e.target.value
}
);




// component/Input.js
// 纯UI组件, stateless, 不维护组件的状态, 只做纯渲染操作
const Input = (props) => (
<div>
<h2>{props.str}</h2>
<input onChange={props.onChange} placeholder={props.placeholder} />
</div>
);



// container/index.js
const mapStateToProps = (state) => {
return ({
str: state.str,
placeholder: state.placeholder
});
};
const mapDispatchToProps = (dispatch) => {
return ({
onChange: (e) => { return dispatch(onChangeAction(e)) }
});
};
// 在这里把store的数据和 修改数据的dispatch方法注入到组件中
const InputWithState = connect(mapStateToProps, mapDispatchToProps)(Input);



// app.js 应用入口
const store = createStore(reducer);
const App = () => (
<Provider store={store}>
<InputWithState />
</Provider>
);

export default App

看完例子可以再返回上面重新回顾一下redux的概念, 也许会有不一样的理解
Edit front_end_state_manage_demo

PureComponent 与 Component的使用

看到上面的例子, 可能回想, 如果每次触发一个action, 就从 Provider开始渲染整个页面的, 是否会导致页面所有的组件的重复渲染?

假如页面的组件大量使用 PureComponent, 会自带一个浅对比的功能, 如果传入新的props对象 与旧的props对象第一层数据是一样的, 那么这个组件就不会重新执行render函数. 效果类似于 Component 中 shouldUpdateCompnent 自行实现浅对比是一样的. 对于大部分场景都推崇使用PureComponent

浅对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const a = {
"value1": {
"value2": {
"value3": 12345
}
}
}

// 如果是嵌套的结构, 直接修改第一层以下的数据节点, 浅对比的结果是 相等的
const b = Object.assign({}, a)
b.value1.value2.value3 = 54321
shallowEual(a, b) // true

// 如果直接修改第一层的数据节点
const c = Object.assign({}, a)
c.value1 = 54321
shallowEual(a, b) // false

Immutable

假如我们大规模使用PureComponent, 就会强依赖于 PureComponent自带的shallowEqual(数据浅对比), 而开发者在更新组件的过程中, 由于各种人为的不可控原因, 会直接修改数据, 这就违背了数据不可变的 原则了, 导致的后果最常见的就是数据变了, UI不更新, 因为直接修改数据, 数据的引用没有改变, 浅对比的时候就认为数据没有改变, 自然就不重新渲染组件

Immutable即数据不可变, 当更新store中的数据后, 不能在原来的指针下更新数据对象, 正确的做法应该是直接用一个新的对象替换掉之前的对象.
Facebook也提供对应的 Immutable.js 工具来帮我们解决这个问题.

Immutable.js解决问题的思路是: 既然开发者喜欢直接修改源数据, 那我就把这条路堵死, 只能通过我提供的 api 来操作数据对象, 我只要保证开发者每次调用api修改数据的时候, 能返回一个新的对象回去就好了. Immutable.js底层实现并非使用cloneDeep这么简单粗暴低效的方式, Immutable 实现的原理是 Persistent Data Structure(持久化数据结构), 也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免 deepCopy 把所有节点都复制一遍带来的性能损耗,Immutable 使用了Structural Sharing(结构共享),即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。

当然使用 Immutable.js对于开发者还是有挺高的成本的, 对于项目的侵入是非常大的, 所以很多开发者也没有勇气直接把Immutable.js在自己的大型项目中使用起来.

  1. 只能通过调用 api 来更新数据
  2. 在jsx渲染的时候, 只能通过 api 获取 Immutable对象中的数据
  3. 不能用es6的结构赋值了

广大开发者肯定都是又想要 Immutable的特性, 又想用js原生的对象操作方法, immer就帮我们实现了这个梦想

immer

immer的简单使用场景:

1
2
3
4
5
6
7
8
9
10
11
12
const produce = require('immer')

const state = {
done: false,
}

const newState = produce(state, (draft) => {
draft.done = true
})

console.log(state.done) // false
console.log(newState.done) // true

简单的来说就是 immer提供一个 produce 函数, 这个函数接受两个参数: 一个是原始的数据对象, 另外一个是一个纯函数, 在这个函数里面, 用户可以任意修改 draft, 如果用户修改了 draft, 最终 produce 返回的是一个新的对象. 在produce第二个参数的函数中, 可以使用 原生js的”.” 操作符进行对象的数据修改, 和原生的修改数据对象的写法几乎一致.

1
2
3
4
5
6
// 通过 immer, 可以更加方便地像修改原生对象一样修改state, 同时保证数据的不可变性
this.setState(
produce(draft => {
draft.user.age += 1
})
)

immer的实现

immer的底层实现理念和 mobx 和 vue的一些底层的理念非常相似.

先来思考一下, 假如要让我们自己设计这个 produce函数, 我们会怎么做, 才能保证数据的不可变性, 并保证运行效率. (需要更Immutable.js一样 使用了Structural Sharing, 即只有改动了, 才生成新对象)

Object.definePropertyProxy这两个方法很多人可能觉得有点陌生,
简单的来说就是在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写

immer官网有个图很直接的展示了 immer的原理:

immer
具体的实现参考下面的简化代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Store {
constructor(state) {
this.modified = false
this.source = state
this.copy = null
}
get(key) {
// 如果没有修改, 就直接返回原来的对象
if (!this.modified) {
return this.source[key]
} else {
return this.copy[key]
}
}
set(key, value) {
// 如果之前未修改过数据, 就把modified标识设置为 true, 并copy一份source数据到 this.copy上, 后续的修改都是在 this.copy 上修改, this.copy 就是 前面demo中的 draft
if (!this.modified) {
this.modified = true
this.copy = Array.isArray(this.source) ? this.source.slice() : { ...this.source }
}
return this.copy[key] = value
}
}



export const produce = (state, producer) => {

// 第一步: 把 state转换为 store对象
const store = new Store(state)

// 第二步: 添加给 store 添加一个 Proxy,劫持 数据的 get set方法
const PROXY_FLAG = '@@SYMBOL_PROXY_FLAG'
const handler = {
get(target, key) {
if (key === PROXY_FLAG) return target
return target.get(key)
},
set(target, key, value) {
return target.set(key, value)
},
}
const proxy = new Proxy(store, handler)

// 第三步: 执行用户修改数据的逻辑
producer(proxy)

// 第四步骤: 判断是否修改了数据, 如果没有, 就返回原数据, 如果有, 返回新的对象.
const newState = proxy[PROXY_FLAG]
if (newState.modified) return newState.copy
return newState.source
}

函数式编程 与 数据不可变性

从上面的简写版本的immer可以看出来, 对象的劫持是实现 “数据不可变性” 的关键的步骤.

现在我们反过来再思考一下, 我们为什么需要 “数据不可变性”这个概念呢?

这得从函数式编程开始讨论起来, React核心核心开发团队算是函数式编程的优秀布道者, 函数式编程对于很多人来说很陌生, 新手接触的时候, 一大堆系统的概念, 很多人在网上搜索一大堆, 往往看完几篇博文之后, 连Monad的定义还是搞不清楚, 相比于一上来就是各种灌输各种概念, React团队可谓是不知道高到哪里去了, 在React Redux框架中, 无处不体现函数式的思维, 同时也不会给开发者灌输很多函数概念, 讲究一个循序渐进的方式, 很多开发者都觉得自己离函数式编程非常远, 其实不然, 我们写的每一个 组件, 都是函数式的一个体现.

函数 是函数式编程中的 “一等公民”, 函数可以与其他数据一样,作为参数传递,或作为返回值返回

1
2
3
4
5
6
const add = (x, y) => x + y;
const multiply= (x, y) = > x * y;
const subtract = (x, y) => x - y;


let result = subtract(multiply(add(a, b), c), d);

上面这段代码 等效于中学数学中的函数: y = (a + b) * c - d
同理很多数据公式都可以用函数式编程的方式表达出来, 那这里我们回忆一下, 以前我们上中学的时候, 列的那些数学函数, 里面那些 x, y, a , b, c 等等, 是否跟我们平时定义的变量一样, 会在运行的过程中, 发生改变?

从上面的例子我们看到, 数学函数的运算, 就是一个函数计算完再把结果传递到下一个函数, 直到没有后续的运算. 根本是不存在变量这个说法, 只有 函数 + 输入输出.

在编程语言中, 我们很难做到不使用变量去完全模拟 数学中的函数运算, 在很多函数中, 还是会写一些变量, 方便编码(计算机的底层就是 寄存器 + 逻辑运算单元), 比如在JS中, 函数的参数是放在一个叫做 arguments的数组中的, 我们在函数内部可以直接通过 arguments[1] 获取第一个参数, 设想一下, 假如这里的 arguments[1] 是可以被改变的, a函数在执行的过程中直接修改了 arguments[1], 等b函数用到 arguments[1]时, 已经是改变过的值, arguments[1]在这里是一个引用的值, 类似一个指针. 这个显然就有问题的, 比如 y = (a + b) * c - d这个函数, 在执行的时候, a变量被其他的函数修改了, 那这里 a + b 就不等于期望的值了, 也就是说 a + b这个函数有副作用了, 它无法实现幂等的特性, 这个现象在函数式编程的世界里必然会造成一篇混乱.

在React编程中的具体表现就是 “浅对比” 认为数据没有改变, 导致UI没有更新, 可以认为React在设计上, 默认数据不可变的, 利用这个特性, 使用 “浅对比” 来减少重复渲染的次数