什么是hooks?
hooks 是 react 在16.8版本开始引入的一个新功能,它扩展了函数组件的功能,使得函数组件也能实现状态、生命周期等复杂逻辑。
import React, { useState } from 'react';
function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
上面是 react 官方提供的 hooks 示例,使用了内置hookuseState
,对应到<u>Class Component</u>应该这么实现
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}
简而言之,hooks 就是钩子,让你能更方便地使用react相关功能。
hooks解决了什么问题?
看完上面一段,你可能会觉得除了代码块精简了点,没看出什么好处。别急,继续往下看。
过去,我们习惯于使用Class Component,但是它存在几个问题:
-
状态逻辑复用困难
- 组件的状态相关的逻辑通常会耦合在组件的实现中,如果另一个组件需要相同的状态逻辑,只能借助 render props 和 high-order components,然而这会破坏原有的组件结构,带来 JSX wrapper hell 问题。
-
side effect 复用和组织困难
- 我们经常会在组件中做一些有 side effect 的操作,比如请求、定时器、打点、监听等,代码组织方式如下
class FriendStatusWithCounter extends React.Component { constructor(props) { super(props); this.state = { count: 0, isOnline: null }; this.handleStatusChange = this.handleStatusChange.bind(this); } componentDidMount() { document.title = `You clicked ${this.state.count} times`; ChatAPI.subscribeToFriendStatus( this.props.friend.id, this.handleStatusChange ); } componentDidUpdate() { document.title = `You clicked ${this.state.count} times`; } componentWillUnmount() { ChatAPI.unsubscribeFromFriendStatus( this.props.friend.id, this.handleStatusChange ); } handleStatusChange(status) { this.setState({ isOnline: status.isOnline }); } render() { return ( <div> <p>You clicked {this.state.count} times</p> <p>Friend {this.props.friend.id} status: {this.state.isOnline}</p> <button onClick={() => this.setState({ count: this.state.count + 1 })}> Click me </button> </div> ); } }
复用的问题就不说了,跟状态逻辑一样,主要说下代码组织的问题。1. 为了在组件刷新的时候更新文档的标题,我们在
componentDidMount
和componentDidUpdate
中各写了一遍更新逻辑; 2. 绑定朋友状态更新和解绑的逻辑,分散在componentDidMount
和componentWillUnmount
中,实际上这是一对有关联的逻辑,如果能写在一起最好;3.componentDidMount
中包含了更新文档标题和绑定事件监听,这2个操作本身没有关联,如果能分开到不同的代码块中更利于维护。 -
Javascript Class 天生缺陷
- 开发者需要理解
this
的指向问题,需要记得手动 bind 事件处理函数,这样代码看起来很繁琐,除非引入@babel/plugin-proposal-class-properties
(这个提案目前还不稳定)。 - 现代工具无法很好地压缩 class 代码,导致代码体积偏大,hot reloading效果也不太稳定。
- 开发者需要理解
为了解决上述问题,hooks 应运而生。让我们使用 hooks 改造下上面的例子
import React, { useState, useEffect } from 'react';
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
return isOnline;
}
function FriendStatusWithCounter(props) {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
const isOnline = useFriendStatus(props.friend.id);
return (
<div>
<p>You clicked {count} times</p>
<p>Friend {props.friend.id} status: {isOnline}</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
function FriendStatus(props) {
// 通过自定义hook复用逻辑
const isOnline = useFriendStatus(props.friend.id);
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
看,问题都解决了!
怎么使用?
hooks 一般配合 Function Components 使用,也可以在内置 hooks 的基础上封装自定义 hook。
先介绍下 react 提供的内置 hooks。
useState
const [count, setCount] = useState(0);
useState
接收一个参数作为初始值,返回一个数组,数组的第一个元素是表示当前状态值的变量,第二个参数是修改状态的函数,执行的操作类似于this.setState({ count: someValue })
,当然内部的实现并非如此,这里仅为了帮助理解。
useState
可以多次调用,每次当你需要声明一个state时,就调用一次。
function ExampleWithManyStates() {
// Declare multiple state variables!
const [age, setAge] = useState(42);
const [fruit, setFruit] = useState('banana');
const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
需要更新某个具体状态时,调用对应的 setXXX 函数即可。
useEffect
useEffect的作用是让你在 Function Components 里面可以执行一些 side effects,比如设置监听、操作dom、定时器、请求等。
- 普通side effect
useEffect(() => {
document.title = `You clicked ${count} times`;
});
- 需要清理的effect,回调函数的返回值作为清理函数
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
需要注意,上面这种写法,每次组件更新都会执行 effect 的回调函数和清理函数,顺序如下:
// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // Run first effect
// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // Run next effect
// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // Run next effect
// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // Clean up last effect
这个效果等同于在componentDidMount
、componentDidUpdate
和componentWillUnmount
实现了事件绑定和解绑。如果只是组件的 state 变化导致重新渲染,同样会重新调用 cleanup 和 effect,这时候就显得没有必要了,所以 useEffect 支持用第2个参数来声明依赖
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
第2个参数是一个数组,在数组中传入依赖的 state 或者 props,如果依赖没有更新,就不会重新执行 cleanup 和 effect。
如果你需要的是只在初次渲染的时候执行一次 effect,组件卸载的时候执行一次 cleanup,那么可以传一个空数组[]
作为依赖。
useContext
context
这个概念大家应该不陌生,一般用于比较简单的共享数据的场景。useContext
就是用于实现context
功能的 hook。
来看下官方提供的示例
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};
const ThemeContext = React.createContext(themes.light);
function App() {
return (
<ThemeContext.Provider value={themes.dark}>
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar(props) {
return (
<div>
<ThemedButton />
</div>
);
}
function ThemedButton() {
const theme = useContext(ThemeContext);
return (
<button style={{ background: theme.background, color: theme.foreground }}>
I am styled by theme context!
</button>
);
}
代码挺长,但是一眼就能看懂了。把 context 对象传入useContext
,就可以拿到最新的 context value。
需要注意的是,只要使用了useContext
的组件,在 context value 改变后,一定会触发组件的更新,哪怕他使用了React.memo
或是shouldComponentUpdate
。
useReducer
useReducer(reducer, initialArg)
返回[state, dispatch]
,跟 redux 很像。
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>
</>
);
}
除此之外,react 内置的 hooks 还包括useCallback
、useMemo
、useRef
、useImperativeHandle
、useLayoutEffect
和useDebugValue
,这里就不再赘述了,可以直接参考官方文档。
自定义 hook
基于内置 hook,我们可以封装自定义的 hook,上面的示例中已经出现过useFriendStatus
这样的自定义 hook,它能帮我们抽离公共的组件逻辑,方便复用。注意,自定义 hook 也需要以use
开头。
我们可以根据需要创建各种场景的自定义 hook,如表单处理、计时器等。后面实战场景的章节中我会具体介绍几个例子。
实现原理
hooks 的使用需要遵循几个规则:
- 必须在顶层调用,不能包裹在条件判断、循环等逻辑中
- 必须在 Function Components 或者自定义 hook 中调用
之所以有这些规则限制,是跟 hooks 的实现原理有关。
这里我们尝试实现一个简单的版本的useState
和useEffect
用来说明。
const memoHooks = [];
let cursor = 0;
function useState(initialValue) {
const current = cursor;
const state = memoHooks[current] || initialValue;
function setState(val) {
memoHooks[current] = val;
// 执行re-render操作
}
cursor++;
return [state, setState];
}
function useEffect(cb, deps) {
const hasDep = !!deps;
const currentDeps = memoHooks[cursor];
const hasChanged = currentDeps ? !deps.every((val, i) => val === currentDeps[i]) : true;
if (!hasDep || hasChanged) {
cb();
memoHooks[cursor] = deps;
}
cursor++;
}
此时我们需要构造一个函数组件来使用这2个 hooks
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
const [name, setName] = useState('Joe');
useEffect(() => {
console.log(`Your name is ${name}`);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
- 渲染前:memoHooks 为
[]
,cursor 为0
- 第一次渲染
- 执行
const [count, setCount] = useState(0);
,memoHooks 为[0]
,cursor 为0
- 执行
useEffect(() => { document.title =
You clicked ${count} times; }, [count]);
,memoHooks 为[0, [0]]
,cursor 为1
- 执行
const [name, setName] = useState('Joe');
,memoHooks 为[0, [0], 'Joe']
,cursor 为2
- 执行
useEffect(() => { console.log(
Your name is ${name}); });
,memoHooks 为[0, [0], 'Joe', undefined]
,cursor 为3
- 执行
- 点击按钮
- 执行
setCount(count + 1)
,memoHooks 为[1, [0], 'Joe', undefined]
,cursor 为0
- 执行 re-render
- 执行
- re-render
- 执行
const [count, setCount] = useState(0);
,memoHooks 为[1, [0], 'Joe', undefined]
,cursor 为0
- 执行
useEffect(() => { document.title =
You clicked ${count} times; }, [count]);
,memoHooks 为[1, [1], 'Joe', undefined]
,cursor 为1
。这里由于hooks[1]
的值变化,会导致 cb 再次执行。 - 执行
const [name, setName] = useState('Joe');
,memoHooks 为[1, [1], 'Joe', undefined]
,cursor 为2
- 执行
useEffect(() => { console.log(
Your name is ${name}); });
,memoHooks 为[1, [1], 'Joe', undefined]
,cursor 为3
。这里由于依赖为 undefined,导致 cb 再次执行。
- 执行
通过上述示例,应该可以解答为什么 hooks 要有这样的使用规则了。
- 必须在顶层调用,不能包裹在条件判断、循环等逻辑中:hooks 的执行对于顺序有强依赖,必须要保证每次渲染组件调用的 hooks 顺序一致。
- 必须在 Function Components 或者自定义 hook 中调用:不管是内置 hook,还是自定义 hook,最终都需要在 Function Components 中调用,因为内部的
memoHooks
和cursor
其实都跟当前渲染的组件实例绑定,脱离 Function Components,hooks 也无法正确执行。
当然,这些只是为了方便理解做的一个简单demo,react 内部实际上是通过一个单向链表来实现,并非 array,有兴趣可以自行翻阅源码。
实战场景
操作表单
实现一个hook,支持自动获取输入框的内容。
function useInput(initial) {
const [value, setValue] = useState(initial);
const onChange = useCallback(function(event) {
setValue(event.currentTarget.value);
}, []);
return {
value,
onChange
};
}
// 使用示例
function Example() {
const inputProps = useInput('Joe');
return <input {...inputProps} />
}
网络请求
实现一个网络请求hook,能够支持初次渲染后自动发请求,也可以手动请求。参数传入一个请求函数即可。
function useRequest(reqFn) {
const initialStatus = {
loading: true,
result: null,
err: null
};
const [status, setStatus] = useState(initialStatus);
function run() {
reqFn().then(result => {
setStatus({
loading: false,
result,
err: null
})
}).catch(err => {
setStatus({
loading: false,
result: null,
err
});
});
}
// didMount后执行一次
useEffect(run, []);
return {
...status,
run
};
}
// 使用示例
function req() {
// 发送请求,返回promise
return fetch('http://example.com/movies.json');
}
function Example() {
const {
loading,
result,
err,
run
} = useRequest(req);
return (
<div>
<p>
The result is {loading ? 'loading' : JSON.stringify(result || err)}
</p>
<button onClick={run}>Reload</button>
</div>
);
}
上面2个例子只是实战场景中很小的一部分,却足以看出 hooks 的强大,当我们有丰富的封装好的 hooks 时,业务逻辑代码会变得很简洁。推荐一个github repo,这里罗列了很多社区产出的 hooks lib,有需要自取。
使用建议
根据官方的说法,在可见的未来 react team 并不会停止对 class component 的支持,因为现在绝大多数 react 组件都是以 class 形式存在的,要全部改造并不现实,而且 hooks 目前还不能完全取代 class,比如getSnapshotBeforeUpdate
和componentDidCatch
这2个生命周期,hooks还没有对等的实现办法。建议大家可以在新开发的组件中尝试使用 hooks。如果经过长时间的迭代后 function components + hooks 成为主流,且 hooks 从功能上可以完全替代 class,那么 react team 应该就可以考虑把 class component 移除,毕竟没有必要维护2套实现,这样不仅增加了维护成本,对开发者来说也多一份学习负担。