Hook 改变的 React Component 写法思路
React hook 在酝酿近一年后,终于在16.8稳定版中重磅推出。在此前后FB的Dan大神劳心劳力地几乎每天在Twitter上给大家洗脑宣传。
那它究竟跟之前的React应用相比有什么改变?
前提是先要理解Memoize
Memoize是贯穿hook用法的一个非常重要的概念,理解它是正确使用hook的基石。
Memoize基本上就是把一些程序中一些不需要反复计算的值和上下文(context)保存在内存中,起到类似缓存的作用,下次运行计算时发现已经有计算并保存过这个值就直接从内存中读取而不再重新计算。
Javascript中比较常见的做法可参考lodash.memoize源代码,它通过给function设置一个Map的属性,将function的传参作为key,运行结果存为这个key的value值。下次调用这个function时,它就先去查看key是否存在,存在的话就直接将对应的值返回,跳过运行方法里的代码。
这在functional programming中非常的实用。不过也就是说,只有所谓纯粹的function才能适用这种方式,输入和输出是一一对应的关系。
React hooks都是被Memoize的,绑定在使用的component中,只有指定的值发生了变化,这个hook中的代码和代码上下文才会被更新和触发。让我在下文中一步步说明。
用useState替换this.setState
在hook之前,用function写的Component是无法拥有自己的状态值的。想要拥有自己的状态,只能痛苦地将function改成Class Component。
Class Component中使用this.setState来设置一个部件的状态:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 1class MyComponent extends React.Component{
2 constructor(props) {
3 super(props);
4 // 初始化state值
5 this.state= {
6 myState: 1
7 };
8 this.toggleState = this.toggleState.bind(this);
9 }
10
11 // 将myState在0和1之间切换
12 toggleState() {
13 this.setState(prevState => {
14 return { myState: 1 - prevState.myState };
15 });
16 }
17
18 render() {
19 return <button onClick={toggleState}>Toggle State</Button>;
20 }
21}
22
23
24
使用React hook之后这就可以在function component中实现,并且更为简洁:
1
2
3
4
5
6
7
8
9
10 1function MyComponent (props) {
2 const [myState, setMyState] = useState(1);
3
4 // 这里应该用useCallback, 会在后面说明
5 const toggleState = () => setMyState(1 - myState);
6
7 return <button onClick={toggleState}>Toggle State</button>;
8}
9
10
代码行数精简为原来的三分之一之外,使用起来也更加地直观。
useState接收一个值作为一个state的初始值,返回一个数组。这个数组由两个成员组成,第一个成员是这个状态的当前值(如上例中的myState),第二个成员是改变这个状态值的方法,即一个专属的setState(如上例中的setMyState)。
During the initial render, the returned state (state) is the same as the value passed as the first argument (initialState).
注意这个初始值只在初始渲染中才被赋值给对应变量,也就是只有在component第一次挂载时,渲染之前才做了一次初始化。后来更新引发的重新渲染都不会让初始值对状态产生影响。
useEffect替换Component生命周期函数
useEffect并不能等同或替代原有的component生命周期函数,他们设计的思路完全不同。对于长期习惯使用原有生命周期的人来说,可能需要从“替换”的角度来转换写react代码的思维方式。
简单来说,钩子的设计更符合“react”这个名字。它完全是通过对数据和状态变化的检测,来“反馈”更新。
在前端程序里,我们习惯了一种我称为“事件思维”的方式,就是说发生某件事,就调用某段代码。运用钩子,就是把某些数据的变化作为事情发生的标志。
在此之前,我们回顾一下类component中几个主要的生命周期。
刚挂载:
- constructor()
- UNSAFE_componentWillMount()
- getDerivedStateFromProps()
- render()
- componentDidMount()
属性或状态更新:
- getDerivedStateFromProps()
- shouldComponentUpdate()
- UNSAFE_componentWillUpdate()
- UNSAFE_componentWillReceiveProps()
- render()
- getSnapshotBeforeUpdate()
- componentDidUpdate()
取消挂载:
- componentWillUnmount()
componentDidUpdate & shouldComponentUpdate
如上可以看到componentDidUpdate是只在:
- 属性值或状态值发生变化
- 并且shouldComponentUpdate()返回为true(默认为true)
时才会被触发的。
useEffect传入的函数,则会在:
- component 挂载后触发一次
- 渲染完成后才触发
- 第二个参数里传入的需要检测比较的数据有变化时才触发
- 如果没有第二个参数,则每次渲染都会触发
那就很明显useEffect不能简单地替代componentDidUpdate。
但在实际使用过程中,我们通常会:
- 在componentDidMount时调用API加载数据
- componentDidUpdate里比较一些条件(比如传入的数据id发生变化)后可能再次调用同样API重新加载数据
- 并且用shouldComponentUpdate来避免不必要的重新渲染(比如id没变化,redux store里这个id对应的数据也没变化的时候)
如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 1class MyComponent extends React.Component {
2 componentDidMount() {
3 this.loadData();
4 }
5 componentDidUpdate(prevProps) {
6 if (prevProps.id !== this.props.id) {
7 this.loadData();
8 }
9 }
10 shouldComponentUpdate(nextProps) {
11 return nextProps.id !== this.props.id ||
12 nextProps.data !== this.props.data;
13 }
14 loadData() {
15 this.props.requestAPI(this.props.id);
16 }
17 render() {
18 return <div>{this.props.data}</div>;
19 }
20}
21
22
useEffect就合并并且大大地简化了这一过程:
1
2
3
4
5
6
7
8
9 1function MyComponent(props) {
2 React.useEffect(() => {
3 props.requestAPI(props.id);
4 }, [props.id, props.requestAPI);
5 return <div>{props.data}</div>;
6}
7export default React.memo(MyComponent);
8
9
上述例子中实际上是将componentDidMount和componentDidUpdate合并了。在shouldComponentUpdate中比较props.data是否变化这一步,借由React.memo来完成。注意它只做每个props值的浅比较。
componentDidMount
那你说componentDidUpdate也许经常做compinentDidMount里会做的事情,那么componentDidMount呢?它是必须的。
虽然react大牛们已经提议了一些concurrent的方法,但react发了那么多版依旧没被加进来。所以在钩子的官方文档里,我们找到这一段:
If you pass an empty array ([]), the props and state as inside the effect will always have their initial values. While passing [] as the second argument is closer to the familiar componentDidMount and componentWillUnmount mental model, there are usually bettersolutions to avoid re-running effects too often. Also, don’t forget that React defers running useEffect until after the browser has painted, so doing extra work is less of a problem.
也就是说可以给useEffect第二个参数传一个空数组,来暂代componentDidMount。虽然官方不推荐,但现在没办法只能这么干。。。
so,上面已经说了,第二个参数是用来将某些数据变化作为效果触发的依据。那空数组,首先就防止了第二个参数为空时每次render都会触发的场景,然后每次渲染都没有数据可以比较变化,那就只有component挂载时才能被触发了。
1
2
3
4
5
6
7
8 1function MyComponent() {
2 React.useEffect(() => {
3 console.log('MyComponent is mounted!');
4 }, []);
5 return null;
6}
7
8
getDerivedStateFromProps
useState与useEffect不同,它不会检测数据的变化,它只接收一个参数 – 它的初始值。初始化过后,所有的状态更新,都需要我们自己调用useState所返回的 ”setState“方法来完成。
这是因为我们已经有useEffect了。getDerivedStateFromProps的效果等同于:
1
2
3
4
5
6
7
8
9 1function MyComponent(props) {
2 const [intValue, setIntValue] = React.useState(props.value);
3 React.useEffect(() => {
4 setIntValue(parseInt(props.value, 10));
5 }, [props.value, setIntValue]);
6 return <div>{intValue}</div>;
7}
8
9
比起getDerivedStateFromProps,这种方式还有效防止了不必要的多次计算。
componentWillUnmount
componentWillUnmount是又一个非常重要常用的生命周期。我们通常用它来解绑一些DOM事件,清理一些会造成内存泄漏的东西。
这就要说到useEffect里第一个参数的回调函数,是可以返回一个函数用来做这种清洁工作的:
1
2
3
4
5
6
7
8
9
10
11 1React.useEffect(
2 () => {
3 const subscription = props.source.subscribe();
4 return () => {
5 subscription.unsubscribe();
6 };
7 },
8 [props.source],
9);
10
11
以上是官方文档中的一个例子,它等同于:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 1class MyComponent extends React.Component {
2 componentDidMount() {
3 this.props.source.subscribe();
4 }
5 componentDidUpdate(prevProps) {
6 if (prevProps.source !== this.props.source) {
7 prevProps.source.unsubscribe();
8 this.props.source.subscribe();
9 }
10 }
11 componentWillUnmount() {
12 this.props.source.unsubscribe();
13 }
14 render() {
15 // ...
16 }
17}
18
19
简单来说,这个返回的清洁函数,会在下一次该效果被触发时首先被调用。