React Hook的历史以及实战中的坑

Hook 的发展历程

React 团队从一开始就很注重 React 的代码复用性

他们对代码复用性的解决方案历经:Mixin, HOC, Render Prop,直到现在的 Custom Hook

所以 Custom Hook 并不是一拍脑门横空出世的产物,即使是很多对 Custom Hook 有丰富开发经验的开发者,也不了解 Hook 到底是怎么来的,以及在 React 里扮演什么角色

不理解这段设计思路是无法深刻的理解 Custom Hook 的,今天我们就一起来学习一下

1. Mixin

这种方式在Vue2中很常见,也是其源码中的重要组成部分,比如父子组件生命周期函数的merge

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
var SetIntervalMixin = {
componentWillMount: function() {
this.intervals = [];
},
setInterval: function() {
this.intervals.push(setInterval.apply(null, arguments));
},
componentWillUnmount: function() {
this.intervals.forEach(clearInterval);
}
};

var createReactClass = require('create-react-class');

var TickTock = createReactClass({
mixins: [SetIntervalMixin], // 使用 mixin
getInitialState: function() {
return {seconds: 0};
},
componentDidMount: function() {
this.setInterval(this.tick, 1000); // 调用 mixin 上的方法
},
tick: function() {
this.setState({seconds: this.state.seconds + 1});
},
render: function() {
return (
<p>
React has been running for {this.state.seconds} seconds.
</p>
);
}
});

ReactDOM.render(
<TickTock />,
document.getElementById('example')
);

优点:

  1. 确实起到了重用代码的作用

缺点:

  1. 它是隐式依赖,隐式依赖被认为在 React 中是不好的
  2. 名字冲突问题
  3. 只能在 React.createClass里工作,不支持 ES6 的 Class Component
  4. 实践下来发现:难以维护

在 React 官网中已经被标记为 ‘不推荐使用’,官方吐槽点这里

2. HOC

2015 年开始,React 团队宣布不推荐使用 Mixin,推荐大家使用 HOC 模式

HOC 采用了 ‘装饰器模式’ 来复用代码

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
function withWindowWidth(BaseComponent) {
class DerivedClass extends React.Component {
state = {
windowWidth: window.innerWidth,
}

onResize = () => {
this.setState({
windowWidth: window.innerWidth,
})
}

componentDidMount() {
window.addEventListener('resize', this.onResize)
}

componentWillUnmount() {
window.removeEventListener('resize', this.onResize);
}

render() {
return <BaseComponent {...this.props} {...this.state}/>
}
}
return DerivedClass;
}

const MyComponent = (props) => {
return <div>Window width is: {props.windowWidth}</div>
};

经典的 容器组件与展示组件分离 (separation of container presidential) 就是从这里开始的

下面是最最经典的 HOC 容器组件与展示组件分离 案例 - Redux中的connect 的实例代码

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
export const createInfoScreen = (ChildComponent, fetchData, dataName) => {
class HOComponent extends Component {
state = { counter: 0 }
handleIncrementCounter = () => {
this.setState({ counter: this.state.counter + 1 });
}
componentDidMount() {
this.props.fetchData();
}

render() {
const { data = {}, isFetching, error } = this.props[dataName];
if (isFetching) {
return (
<div>Loading</div>
);
}

if (error) {
return (
<div>Something is wrong. Please try again!</div>
);
}

if (isEmpty(data)) {
return (
<div>No Data!</div>
);
}

return (
<ChildComponent
counter={this.state.counter}
onIncrementCounterClick={this.handleIncrementCounter}
{...this.props}
/>
);
}
}

const dataSelector = state => state[dataName];
const getData = () => createSelector(dataSelector, data => data);
const mapStateToProps = state => {
const data = getData();
return {
[dataName]: data(state),
};
};

HOComponent.propTypes = {
fetchData: PropTypes.func.isRequired,
};

HOComponent.displayName = `createInfoScreen(${getDisplayName(HOComponent)})`;

return connect(
mapStateToProps,
{ fetchData },
)(HOComponent);
};

优点:

  1. 可以在任何组件包括 Class Component 中工作
  2. 它所倡导的 容器组件与展示组件分离 原则做到了:关注点分离

缺点:

  1. 不直观,难以阅读
  2. 名字冲突
  3. 组件层层层层层层嵌套

3. Render Prop

2017 年开始,Render Prop 流行了起来

Render Prop 采用了 ‘代理模式’ 来复用代码

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
class WindowWidth extends React.Component {
propTypes = {
children: PropTypes.func.isRequired
}

state = {
windowWidth: window.innerWidth,
}

onResize = () => {
this.setState({
windowWidth: window.innerWidth,
})
}

componentDidMount() {
window.addEventListener('resize', this.onResize)
}

componentWillUnmount() {
window.removeEventListener('resize', this.onResize);
}

render() {
return this.props.children(this.state.windowWidth);
}
}

const MyComponent = () => {
return (
<WindowWidth>
{width => <div>Window width is: {width}</div>}
</WindowWidth>
)
}

React Router 也采用了这样的API设计:

1
<Route path = "/about" render= { (props) => <About {...props} />}>

优点:

  1. 灵活

缺点:

  1. 难以阅读,难以理解

4. Hook

2018 年,React 团队宣布推出一种全新的重用代码的方式 - React Hook

它的核心改变是:允许函数式组件存储自己的状态,在这之前函数式组件是不能有自己的状态的

这个改变使我们可以像抽象一个普通函数一样抽象React组件中的逻辑

实现的原理:闭包

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
import { useState, useEffect } from "react";

const useWindowsWidth = () => {
const [isScreenSmall, setIsScreenSmall] = useState(false);

let checkScreenSize = () => {
setIsScreenSmall(window.innerWidth < 600);
};
useEffect(() => {
checkScreenSize();
window.addEventListener("resize", checkScreenSize);

return () => window.removeEventListener("resize", checkScreenSize);
}, []);

return isScreenSmall;
};

export default useWindowsWidth;
import React from 'react'
import useWindowWidth from './useWindowWidth.js'

const MyComponent = () => {
const onSmallScreen = useWindowWidth();

return (
// Return some elements
)
}

优点:

  1. 提取逻辑出来非常容易
  2. 非常易于组合
  3. 可读性非常强
  4. 没有名字冲突问题

缺点:

  1. Hook有自身的用法限制: 只能在组件顶层使用,只能在组件中使用
  2. 由于原理为闭包,所以极少数情况下会出现难以理解的问题

Hook使用过程中需要注意的几个点

闭包问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function WatchCount() {
const [count, setCount] = useState(0);

useEffect(function() {
setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
}, []);

return (
<div>
{count}
<button onClick={() => setCount(count + 1) }>
加1
</button>
</div>
);
}

这段代码大家认为会打印什么?
没错,每次打印出来的都是“Count is 0”

因为我们设置定时器的时候传入的是个函数闭包,这个闭包中已经缓存了count的值,所以每次调用的是同一个log函数。

解决方式也很简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function WatchCount() {
const [count, setCount] = useState(0);

useEffect(function() {
const id = setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
return function() {
clearInterval(id);
}
}, [count]); // 看这里,这行是重点

return (
<div>
{count}
<button onClick={() => setCount(count + 1) }>
Increase
</button>
</div>
);
}

这就告诉useEffect,每当count变化是,重新创建副作用函数。

闭包问题不仅仅是useEffect的问题,而是所有可能用到函数闭包的hook的通用问题。

useState第二个修改state的参数是异步的,需要在下一次渲染才生效

Hook的无限循环渲染问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function App() {
// 当obj是基本类型的时候,就不会无限循环
// 当 obj是对象的时候,就会无限循环
// 当 obj 是对象的state时,不会无限循环
const [obj, setObj] = useState({ name: "Jack" });
// const obj = 1;
// const obj = {name: 'Jack'}
const [num, setNum] = useState(0);

useEffect(() => {
console.log("effect");
setNum(num + 1);
}, [obj]);

return (
<div className="App">
{num}
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
</div>
);
}

​ 我们分析下为什么obj是普通对象时会触发无限渲染

第一次渲染时,执行useEffect的副作用,也就是会调用setNum去更新num

调用了setNum会更新num,导致组件重新渲染,这个时候obj已经是另一个对象了(虽然属性和值完全相同,但是地址已经变了,而react比较useEffect的方式很粗暴,应该也是为了性能的原因,只是简单的判等),所以又会重新执行setNum,又会出发重新渲染,所以进入了无限循环。

明白了obj是对象的时候为什么会无限渲染,我们就可以理解obj是基础类型的时候为什么不会进入无限循环了。

而当obj是个state时,除非手动调用对应的setState,react不会认为state发生了改变

useState惰性初始化

useState如果第一个参数传入一个函数,作用并不是以这个函数作为state的初始值,而是这个函数的返回值作为初始值,这叫做惰性初始化。所以尽量不要用useState保存函数。

useRef返回值并不是组件状态,所以它的改变不会触发重新渲染,而且它每次渲染会返回同一个值

本质上,useRef 就像是可以在其 .current 属性中保存一个可变值的“盒子”。

你应该熟悉 ref 这一种访问 DOM 的主要方式。如果你将 ref 对象以 <div ref={myRef} /> 形式传入组件,则无论该节点如何改变,React 都会将 ref 对象的 .current 属性设置为相应的 DOM 节点。

然而,useRef()ref 属性更有用。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。

这是因为它创建的是一个普通 Javascript 对象。而 useRef() 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。

请记住,当 ref 对象内容发生变化时,useRef不会通知你。变更 .current 属性不会引发组件重新渲染。如果想要在 React 绑定或解绑 DOM 节点的 ref 时运行某些代码,则需要使用回调 ref 来实现。

也就是说这段代码即使你点击了setCallback也不会触发重新渲染,所以点击call callback调用的还是一开始的callback

可以将call callback的onClick改为 () => callbackRef.current()

注意,改成onclick={callbackRef.current}无效,这个和图上的形式没什么区别

自定义hook中如果要返回函数,可以用useCallback包裹

1
2
3
4
5
6
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);

返回一个 memoized 回调函数。

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

注意

依赖项数组不会作为参数传给回调函数。虽然从概念上来说它表现为:所有回调函数中引用的值都应该出现在依赖项数组中。未来编译器会更加智能,届时自动创建数组将成为可能。

我们推荐启用 eslint-plugin-react-hooks 中的 exhaustive-deps 规则。此规则会在添加错误依赖时发出警告并给出修复建议。

参考链接:https://www.notion.so/10-7-React-Hook-14e8a28e607f45e2a4787ad9cebbbe66