这是一次突发奇想的感悟,感觉还挺神奇的,遂记录一下。

前言

作为一个React的开发者已经蛮久的了,大大小小的应用也开发了不少,除了一开始学习React时用过Redux以外,后来基本都不碰了,不管多么复杂的应用,我也简单的觉得使用Context就能够解决我所有的问题。说来惭愧,我基本没有思考过Redux存在的原因,可能是React真的做的太好了,又或者是我们现在的设备性能已经严重过剩了,让我完全不需要考虑应用优化的问题。

今天又冒出为什么这么多人用Redux的问题,所以又看了一下ReactRedux的文档,结果有蛮大的收获(每次看文档都有新收获,推荐大家没事多看看),突然让我回忆起曾经好多次使用useState更新数组时的别扭(虽然没什么问题,但是总觉得过于复杂了),今天我们就来聊聊这些。

随便推荐使用我写的理解例子examples/reducer-context-redux一起服用效果更佳哦,以下所有完整代码皆可在例子中找到。

需求

脱离真实需求聊一些技术的东西,总让人觉得比较虚,所以我们今天就来聊一下一个比较简单的需求,比较几种不同的方式演变的代码的区别来帮助我们理解RedcuerContextRedux这些概念。

简单的描述一下需求,一个可以创建todo的输入框,一个展示todo的列表,todo本身可以修改名称,标记为完成或者删除,具体看下图。

Base App

一般实现

从图片也可以看出来这是一个非常简单的需求,让我们来快速的实现一下,完整代码见examples/reducer-conetxt-redux/base

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
// 定义一个 Todos
const [todos, setTodos] = useState([]);

// 定义几个方法分别用来 创建,更新,删除 Todo
const handleAddTodo = (name) => {
setTodos([...todos, { id: nextId++, name, done: false }]);
};

const handleChangeTodo = (todo) => {
setTodos(
todos.map((t) => {
if (t.id === todo.id) {
return todo;
} else {
return t;
}
})
);
};

const handleDelTodo = (id) => {
setTodos(todos.filter((t) => t.id !== id));
};

为什么我只贴了一部分代码?因为这部分的代码将会进行第一步演化,我相信大部分的人应该都会这么写吧(如果不是的话,别喷我,至少我在大部分情况下都是这么写的)。

但是它有什么问题呢?其实没有什么问题,如果你还没有遇到问题的话,它的确没有问题,感觉自己在说废话呢,那我给几个你可能会遇到问题的情况吧:

  1. 如果 Todo 需要通过接口来完成创建、更新和删除,那当你同时进行多个操作时,会导致你的todos只完成了最后一次更新。
  2. 关于1有一个非常难受的地方,就是你不太能容易发现todos为什么没有正确更新成你期望的样子,这个排查是很痛苦的,不知道你们有没有遇到过?
  3. 虽然现在useState好像非常直观的展示了todos的更新机制,但如果我添加更多的功能,比如多状态的todo,这个时候你需要更多的setTodos来更新todos,这好像比较难理解,那我们可以将更新这个操作改为上一步或者下一步,这样你是不是就需要拆开handleChangeTodo这个方法了?

好了,差不多第一版就这些,那么如何来优化它?

Reducer

要用它,就得先知道它是什么吧?简单来说,就是把所有状态更新逻辑合并到一个函数中,就叫Reducer。它的定义已经出来了,我觉得这个时候你可能已经想到了如何用Reducer来更新上面的第一版了,完整代码见examples/reducer-context-redux/reducer

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
function todoReducer(todos, action) {
switch (action.type) {
case 'added': {
return [...todos, { id: nextId++, name: action.name, done: false }];
}
case 'changed':
return todos.map((t) => {
if (t.id === action.todo.id) {
return action.todo;
} else {
return t;
}
});
case 'deleted': {
return todos.filter((t) => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}

const [todos, dispatch] = useReducer(todoReducer, []);

const handleAddTodo = (name) => {
dispatch({
type: 'added',
name,
});
};

const handleChangeTodo = (todo) => {
dispatch({
type: 'changed',
todo,
});
};

const handleDelTodo = (id) => {
dispatch({
type: 'deleted',
id,
});
};

直观看,代码好像比上面更多了?这个例子确实是,但是就像我说的第一版中问题3那样,当你的状态越来越复杂,两种方式带来的代码增长将不会一样,也就是说,状态多到一定的程度后,这样写的代码会更少,不过这好像也不能成为一个这么写的充分理由。

那我在来补充几个这样写的好处吧:

  1. 所有的状态变更都收在了todoReducer函数里面,你可以方便的在这个函数里面console.log来感知状态的变化。 -> 方便调试
  2. todoReducer作为一个干净的函数,你可以轻易的写出它的测试用例。 -> 方便写测试用例,增强稳定性
  3. 状态的变化一目了然。 -> 增强可读性

当然这些都不是必要的,你完全可以按照你的喜好和场景来使用useState或者useReducer,不过你应该要知道它们的区别。

Context

上面的代码好像和Context并没有什么直接的联系,我又为什么要把它也拿来一起看看呢?是因为Redux就像是ReducerContext的结合体,所以你现在已经知道了Reducer是什么了,当然也要知道Context是什么,和上述一样完整代码见examples/reducer-conetxt-redux/context

那么Context又是什么呢?简单来讲,就是两个没有直接联系的组件共享状态。比如A -> B -> C -> DD想要接收到A的状态,需要经过BC,那如果你用Context就可以跳过BC,看起来是不是很好用?确实是,但是它有一个很大的缺点,也是我们不希望它被滥用的原因,因为你需要从A定义这个状态,那么如果这个状态发生了变化,A所有的子组件都会更新,那你要是在一个特别大的应用的根上定义了一个经常变化的状态,那这个应用就得经常更新,是一件比较可怕的事情。

再来看Context的主要目的是为了跨组件共享状态,它并不具有状态定义和管理的功能,也就是需要搭配useState或者useReducer来使用,这也是为什么我说Redux就像是ReducerContext的结合体,来看下代码演进吧。

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
// TodoContext.jsx
import { createContext } from 'react';

export function todoReducer(todos, action) {
switch (action.type) {
case 'added': {
return [...todos, { id: nextId++, name: action.name, done: false }];
}
case 'changed':
return todos.map((t) => {
if (t.id === action.todo.id) {
return action.todo;
} else {
return t;
}
});
case 'deleted': {
return todos.filter((t) => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}

export default createContext();

//
import TodoContext, { todoReducer } from './context';

const [todos, dispatch] = useReducer(todoReducer, []);

return (
<TodoContext.Provider value={{ todos, dispatch }}>
<ContextApp />
</TodoContext.Provider>
);

当然这里你依然可以用useState去取代useReducer,看你如何考虑吧!

Redux

终于到了最后一步了,其实你不用Redux,好像也不会阻塞你做这个需求,或者说任何别的需求,但是为什么要用它,这就是今天要探究的内容了,一样完整代码见examples/reducer-conetxt-redux/redux

先看代码改造吧,由于新的Redux使用Toolkit来组织代码,但是为了方便理解,我还是单纯的使用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
// store.jsx
import { createStore } from 'redux';

function todoReducer(todos = [], action) {
switch (action.type) {
case 'added': {
return [...todos, { id: nextId++, name: action.name, done: false }];
}
case 'changed':
return todos.map((t) => {
if (t.id === action.todo.id) {
return action.todo;
} else {
return t;
}
});
case 'deleted': {
return todos.filter((t) => t.id !== action.id);
}
default:
return todos;
}
}

export default createStore(todoReducer);

//
import { Provider } from 'react-redux';

export default () => (
<Provider store={store}>
<ReduxApp />
</Provider>
);

其实对比Context的版本来看,一眼就能明白我说的Redux就像是ReducerContext的结合体,那为什么要用它呢?直接上理由吧:

  1. 不知道你还记得Context的缺点吗?状态的变化会导致所有组件的变化,但是Redux只会影响订阅对应状态的组件。
  2. 不知道你还记得在最初版还有一个问题没有解决,就是异步请求后的状态更新,Redux有很多很好用的中间件来处理这些事情,比如redux-thunk,当然你也可以自己写,但是意义是什么呢?
  3. 浏览器插件Redux DevTools,让你清晰的看到各个状态的变化。
  4. 脱离于不同UI的状态管理,比如你同时有多个应用共享一套状态,或者说ReactVue写的两个应用共享一套状态。

这就是我理解Redux最为强大的优势吧,也就是当你没有这些问题的时候,你完全不需要它。

总结

就像我说的,当你还没有意识到你要不要用Redux时,你可能不太需要它,当你在思考如何组织你的状态,或者你已经被你的状态搞的焦头烂额了,你可能需要考虑它了,抛开需求谈技术都挺扯蛋的,你完全有足够的时间去不断的优化你的代码,而不是一开始就把所有的工具集成到一个应用里,不管它是不是真的需要,这样你永远也不会明白用它的意义是什么。