React Hooks不完全解读

React Hooks不完全解读

什么是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解决了什么问题?

看完上面一段,你可能会觉得除了代码块精简了点,没看出什么好处。别急,继续往下看。

过去,我们习惯于使用<u>Class Component</u>,但是它存在几个问题:

  • 状态逻辑复用困难

    • 组件的状态相关的逻辑通常会耦合在组件的实现中,如果另一个组件需要相同的状态逻辑,只能借助<u>render props</u> 和 <u>high-order components</u>,然而这会破坏原有的组件结构,带来 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. 为了在组件刷新的时候更新文档的标题,我们在componentDidMountcomponentDidUpdate中各写了一遍更新逻辑; 2. 绑定朋友状态更新和解绑的逻辑,分散在componentDidMountcomponentWillUnmount中,实际上这是一对有关联的逻辑,如果能写在一起最好;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 一般配合<u>Function Components</u>使用,也可以在内置 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的作用是让你在<u>Function Components</u>里面可以执行一些 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

这个效果等同于在componentDidMountcomponentDidUpdatecomponentWillUnmount实现了事件绑定和解绑。如果只是组件的 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 还包括useCallbackuseMemouseRefuseImperativeHandleuseLayoutEffectuseDebugValue,这里就不再赘述了,可以直接参考官方文档。

自定义 hook

基于内置 hook,我们可以封装自定义的 hook,上面的示例中已经出现过useFriendStatus这样的自定义 hook,它能帮我们抽离公共的组件逻辑,方便复用。注意,自定义 hook 也需要以use开头。

我们可以根据需要创建各种场景的自定义 hook,如表单处理、计时器等。后面实战场景的章节中我会具体介绍几个例子。

实现原理

hooks 的使用需要遵循几个规则:

  • 必须在顶层调用,不能包裹在条件判断、循环等逻辑中
  • 必须在 <u>Function Components</u> 或者自定义 hook 中调用

之所以有这些规则限制,是跟 hooks 的实现原理有关。

这里我们尝试实现一个简单的版本的useStateuseEffect用来说明。

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>
);
}
  1. 渲染前:memoHooks 为[],cursor 为0
  2. 第一次渲染

    1. 执行const [count, setCount] = useState(0);,memoHooks 为[0],cursor 为0
    2. 执行useEffect(() => { document.title = You clicked ${count} times; }, [count]);,memoHooks 为[0, [0]],cursor 为1
    3. 执行const [name, setName] = useState('Joe');,memoHooks 为[0, [0], 'Joe'],cursor 为2
    4. 执行useEffect(() => { console.log(Your name is ${name}); });,memoHooks 为[0, [0], 'Joe', undefined],cursor 为3
  3. 点击按钮

    1. 执行setCount(count + 1),memoHooks 为[1, [0], 'Joe', undefined],cursor 为0
    2. 执行 re-render
  4. re-render

    1. 执行const [count, setCount] = useState(0);,memoHooks 为[1, [0], 'Joe', undefined],cursor 为0
    2. 执行useEffect(() => { document.title = You clicked ${count} times; }, [count]);,memoHooks 为[1, [1], 'Joe', undefined],cursor 为1。这里由于hooks[1]的值变化,会导致 cb 再次执行。
    3. 执行const [name, setName] = useState('Joe');,memoHooks 为[1, [1], 'Joe', undefined],cursor 为2
    4. 执行useEffect(() => { console.log(Your name is ${name}); });,memoHooks 为[1, [1], 'Joe', undefined],cursor 为3。这里由于依赖为 undefined,导致 cb 再次执行。

通过上述示例,应该可以解答为什么 hooks 要有这样的使用规则了。

  • 必须在顶层调用,不能包裹在条件判断、循环等逻辑中:hooks 的执行对于顺序有强依赖,必须要保证每次渲染组件调用的 hooks 顺序一致。
  • 必须在 <u>Function Components</u> 或者自定义 hook 中调用:不管是内置 hook,还是自定义 hook,最终都需要在 <u>Function Components</u> 中调用,因为内部的memoHookscursor其实都跟当前渲染的组件实例绑定,脱离了<u>Function Components</u>,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,比如getSnapshotBeforeUpdatecomponentDidCatch这2个生命周期,hooks还没有对等的实现办法。建议大家可以在新开发的组件中尝试使用 hooks。如果经过长时间的迭代后 function components + hooks 成为主流,且 hooks 从功能上可以完全替代 class,那么 react team 应该就可以考虑把 class component 移除,毕竟没有必要维护2套实现,这样不仅增加了维护成本,对开发者来说也多一份学习负担。

参考文章

  • Making Sense of React Hooks
  • Deep dive: How do React hooks really work?
  • 一篇看懂 react hooks