未央的技术岛

React State的使用及原理

本博客适用于React初学者。

React现在更推荐使用函数组件,React设计之初就基于函数式编程思想,数据与视图单向绑定,输入相同的数据永远输出相同的视图。

函数组件更符合函数式思想,我们将从函数组件的角度来了解React State,并会与Class组件中的State进行简单对比。

声明一个简单的状态

在声明状态前,我们需要了解一个概念:钩子(Hook)

React 钩子是一种在React组件中处理状态和生命周期的方法。它不依赖于Class组件,可以降低组件的复杂性,且能封装和复用组件逻辑。

我们通过useState钩子,可以轻松地声明并使用一个状态。

引用React官方的例子:

1
2
3
4
5
6
7
8
9
10
11
function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
return (
<>
Count: {count}
<button onClick={() => setCount(initialCount)}>Reset</button>
<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
</>
);
}

在该官方例子中,为了维持count的值,让其能在整个页面运行期间可用,使用useState来声明count,并且用该钩子返回的set方法就可以用来更新状态。

在React中,一个组件相当于一个函数,更确切的说,是一个纯函数。纯函数是指:

  1. 输入相同的值永远输出相同的值;
  2. 无副作用,即不会更改输出以外的任何东西。

有副作用的函数举例:

1
2
3
4
5
6
7
function add3(list) {
list.push(3);
return list;
}
const array = [1, 2];
add3(array); // -> [1, 2, 3]
add3(array); // -> [1, 2, 3, 3]

把上面的add3改成无副作用版本:

1
2
3
4
5
6
function add3(list) {
return [...list, 3];
}
const array = [1, 2];
add3(array); // -> [1, 2, 3]
add3(array); // -> [1, 2, 3]

我们知道,函数在执行完毕后,内部的上下文就会被销毁。而有时候我们又需要持续保存某些值,这就需要函数式编程中的另一个东西:闭包

useState会创建一个闭包,从而产生一个能在函数执行完后依然保留的值。如果调用set方法更新该值,会触发函数重新执行,从而渲染出新的视图。

我们可以通过下面这个简单的例子来了解闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function createCount() {
let count = 0;
function increase() {
count++;
}
function log() {
console.log(count);
}

return [increase, log];
}

const [increase, log] = createCount();

log(); // 0
increase();
log(); // 1

声明一个函数createCount,当函数执行完毕了,返回两个引用了count的函数increaselog。由于count被其他函数引用,因此即便createCount执行完了,count依然不会销毁,这就形成了闭包。

闭包有很多用途,除了React state的实现以外,还经常用于模拟面向对象编程中的私有属性和私有方法。

useState无法在函数组件外使用,且需要声明在函数组件的开头,在概念上相当于静态声明,不会因为props的变化而改变。

使用了useState后的函数组件,依然可以理解为纯函数,useState只相当于静态声明,相当于使用一个外部wrapper创建的闭包来维持状态,并把状态传给函数组件。

很多初学者会有疑问,为什么不能直接用let,而是用useState

要知道,在纯函数里,输入相同的值永远输出相同的值,函数内部并无状态,因此let声明的值在组件每次执行时都会重新初始化,执行完就被销毁,也就意味着无法将其持久保存。

还有一个误区,一些初学者会给state直接赋值,这样即便state更新了,也无法通知到视图更新,这就涉及到Flex架构思想,后面我们会简单进行了解。

状态的提升

一个组件并不能实现所有需求,组件需要合理划分,不同开发人员需要分工合作。

这个时候如果不同组件之间需要相互通信,就需要把状态提升到二者的父组件中。

常见的通信场景有:

  1. 父组件 ➱ 子组件:props;
  2. 子组件 ➱ 父组件:callback;
  3. 兄弟组件:状态提升到父组件中。

常见的方式有两种:

  1. 将State声明在父组件中;
  2. 使用Context

前者把状态声明在父组件中,通过props把状态和set方法传递给子组件。后者常见于兄弟组件需要通信但不在同一层级、层级差大,或是全局需要用到的状态。

参考官方文档:使用Context之前的考虑。如果只是因为层级比较多,想避免逐级传递,则更推荐组件组合的方式。

为了了解如何将State声明在父组件中,还是用React官方例子,我们稍加重构,增加两个组件Content和ButtonGroup,Content负责展示,ButtonGroup负责对count进行加、减或重置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Content({ count }) {
return <>Count: {count}</>;
}

function ButtonGroup({ initialCount, setCount }) {
return <>
<button onClick={() => setCount(initialCount)}>Reset</button>
<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
</>
}

function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
return (
<>
<Content count={count} />
<ButtonGroup initialCount={initialCount} setCount={setCount} />
</>
);
}

count状态声明在Counter组件中,通过props把值和set方法分别传递给Content组件和ButtonGroup组件,以此实现了Content和ButtonGroup之间的通信。

State的延伸探索

React State和Redux很像,都基于Flux架构,一个Flux应用包含四个部分:

  • Dispatcher,处理动作的分发;
  • Store,负责数据处理和存储;
  • Action,驱动Dipatcher的对象;
  • View,展示数据的视图。

我们可以参考Redux的数据流。

Redux数据流

React提供useState的完整版:useReducer。实际上,前者就是基于后者实现的,后者基本上可以替代Redux使用,更适合用于管理包含多个子值的state对象。

以下是React官方例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const initialState = { count: 0 };

function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error();
}
}

function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</>
);
}

reducer是纯函数,负责计算新状态,里面定义了两个action:

  1. increment:增加值;
  2. decrement:减少值。

不同的action有不同的计算逻辑。dispatch是一个一个event handler,当它被触发时,他会把action传递给reducerreducer再依据不同的action计算并返回新值,触发State更新,最终导致View的更新。

函数式VS面向对象

如何在类组件中使用状态?

我们改写一下Counter组件,将其转成类组件看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Counter extends Component {
constructor(props) {
super(props);
const { initialCount } = props;

this.state = {
count: initialCount,
};
}

render() {
const { initialCount } = this.props;
const { count } = this.state;
return (
<>
Count: {count}
<button onClick={() => this.setState({ count: initialCount })}>Reset</button>
<button onClick={() => this.setState({ count: count - 1 })}>-</button>
<button onClick={() => this.setState({ count: count + 1 })}>+</button>
</>
);
}
}

Class组件是面向对象思维,状态即是为一个对象的属性,只要对象不被析构,状态就一直存在,reader方法则用来输出View。

Class组件中有许多概念,在函数组件中都被抽象和封装了。诸如Class的生命周期,在函数组件中被封装为Hook API,通过useEffect引入函数副作用来实现。

函数组件和类组件是两种不同的编程范式,而JS本身作为一门具有函数式思想的语言,或许更宜向函数组件靠拢,以发挥其优势。

函数式组件的优点也是十分显著的:

  1. 函数组件更符合函数式编程思想,而函数式编程是React的思想之一;
  2. Hooks是比高阶组件Render Props更优雅的逻辑复用方式;
  3. Hooks中的useEffect是生命周期的概念在函数组件中的实现方式,用起来更简单。

更多关于React和其哲学的,可以参考官方文档