React Hooks简要介绍

HooksReact v16.7.0-alpha中引入的新特性,目前(2018年10月底)还在讨论之中

关于这次改动,官网里特地表明这不是Breaking Changes,并且向前兼容,大家可以放心地使用。在动机上:

  • 使用Hooks将便于开发者拆分和复用state管理的逻辑(而不是state本身)
  • 使用Hooks将把Class组件中的React生命周期方法抽象成effects,根据需要插入
  • 除了state和生命周期方法,React还将class提供的更多features拆分出来作为额外功能,按需使用

下面将从上面几点分别展开介绍,并给出一些使用须知。

State Hook

根据上面所述,用来拆分和复用state管理逻辑。通常情况下,class组件中的state更新逻辑比较简单。和官网给的例子本质上没什么差别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { 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>
);
}

useState是State Hook提供的API方法,它只需1个入参,表示状态的初始值。返回一个pair:

  • 第一个元素,状态本身,类似this.state.xxx
  • 第二个元素,设置状态方法,类似this.setState({ xxx: 'foo' })

需要注意的是,第二个元素,设置状态的方法不是增量更新,而是直接替换,这点和setState有区别。

在下面的渲染部分,直接使用状态名即可。当然这里只声明了一个需要的状态变量,需要新的状态变量(比如:[fruit, setFruit])时,需要用同样的方法获得返回,像下面这样:

1
2
3
4
// Declare multiple state variables!
const [age, setAge] = useState(42);
const [fruit, setFruit] = useState('banana');
const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);

可以看到,使用State Hook时,如何拆分state到各useState中是需要考虑的事情。

Effect Hook

Effect Hook用来处理React每次渲染完成后的副作用。它等同于componentDidMount, componentDidUpdate, 再加上componentWillUnmount。副作用分两种,需要cleanup和不需要cleanup的。

不需要Cleanup

通常的副作用包括数据请求、DOM修改等等。这些操作不需要清理占用的资源。使用时类似下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

useEffect(() => {
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

使用useEffect方法,传入一个函数作为唯一入参。这样,在每次render完成后(包含第一次render),都会执行这个函数,去完成副作用的部分。

你可能有些疑惑,如果我有某个副作用,只在componentDidMount使用一次,比如获取DOM ref这种呢?另外,每次重新渲染后,如果副作用依赖于当前的状态值,难道还需要写if语句判断状态有没有变化吗?接着,往下看。

userEffect这个方法可以有第二个入参,这个入参是数组类型,表示这个effects所依赖的内部状态。(注意:这个状态必须是上面用useState声明的)只有数组内的状态变化时,React才会去执行第一个入参的函数。

另外,数组为空时,表示函数没有依赖,即只在componentDidMount时执行一次即可。

1
2
3
4
5
6
7
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if count changes

useEffect(() => {
console.log('This component has been rendered.');
}, []); // Only re-run at the first rendering

最后,useEffect是异步完成的,即不会block浏览器更新屏幕内容,以节省资源。在一些不常见的场景,如需要测量当前布局时,需要用同步的useLayoutEffect

需要Cleanup

有的副作用以添加事件监听、设置定时器等等的subscription的形式进行,这些在组件销毁后需要释放掉占用的资源,避免内存泄漏。类似你之前在componentWillUnmount里写的逻辑。

React用useEffect表示这个副作用的清除操作。用法类似setTimeout,用返回值作为handler。

1
2
3
4
5
6
7
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// Specify how to clean up after this effect:
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});

在实际运行时,一个Effect在组件re-render时都会被重新销毁再重建,以便于在componentDidUpdate时,也能跟踪到副作用内使用的状态的最新值。上面那段代码可能会遇到下面这样的实际运行情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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

为了避免这样的频繁操作影响性能,可以通过上面介绍的传第二个参数的方式优化性能。官方文档在最后还补充了一句:

In the future, the second argument might get added automatically by a build-time transformation.

一些使用准则

  • 在函数内部的最外层使用,别在块语句内使用,以保证正确的内部状态
  • 只在函数组件和自定义Hooks中使用Hooks API,以保证可读性
  • 这个eslint-plugin能帮助你检查代码风格

为什么会有看起来比较别扭的上面两条规则呢?

useStateuseEffect看到,API本身是没有状态的,并不知道API的返回赋值给了哪个变量名。所以,就像介绍里说的:

React relies on the order in which Hooks are called.

React依赖于Hooks的调用顺序,因此在每次render时,Hooks方法的调用顺序一定要保持一致

(猜测内部用类似数组的结构保存了一个函数组件内的多个Hooks)

从而,所有导致Hooks可能不按一致顺序执行的写法都不建议使用。为了保证Hooks执行顺序所见即所得,又有了第二条准则。

组合 - 自定义Hooks

Hooks除了或多或少基于React提供的Hooks外,只是再普通不过的JavaScript function而已。可以将组件中共用的状态逻辑拆分出来作为自定义Hooks。类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);

function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

useEffect(() => {
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});

return isOnline;
}

强烈建议用户自定义的Hooks函数也以use开头。在使用时,就像使用正常的函数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id);

if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}

function FriendListItem(props) {
const isOnline = useFriendStatus(props.friend.id);

return (
<li style={{ color: isOnline ? 'green' : 'black' }}>
{props.friend.name}
</li>
);
}

Hooks共用的是状态逻辑,使用同一个自定义Hooks创建的状态是相互隔离的

你可以发挥你的想象力,抽象共用的状态逻辑,使用组合的方式(在函数中组合,React并不建议在class组件中使用mixin)构建新组件,减少组件代码长度。

官网举了个非常简单却普遍的useReducer的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState);

function dispatch(action) {
const nextState = reducer(state, action);
setState(nextState);
}

return [state, dispatch];
}

function Todos() {
const [todos, dispatch] = useReducer(todosReducer, []);

function handleAddClick(text) {
dispatch({ type: 'add', text });
}

// ...
}

还有什么Hooks

  • useContext,接受React.createContext作为入参,在每次provider更新后,自动用最新的context重渲染。
  • useReducer,组件状态逻辑很复杂时,代替useState使用
  • useCallback,保存一个和当前状态相关的函数,只有状态变化时,函数才会更新,避免重复创建函数。
    1
    2
    3
    4
    5
    6
    const memoizedCallback = useCallback(
    () => {
    doSomething(a, b);
    },
    [a, b]
    );
  • useMemo,保存一个和当前状态相关的值,只有状态变化时,值才会重新计算。不提供数组代表每次渲染都会更新。
  • useRef,获取DOM ref
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function TextInputWithFocusButton() {
    const inputEl = useRef(null);
    const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
    };
    return (
    <>
    <input ref={inputEl} type="text" />
    <button onClick={onButtonClick}>Focus the input</button>
    </>
    );
    }
  • useImperativeMethods,暴露当前组件Ref给父组件
  • useMutationEffectuseLayoutEffect,类似useEffect只是同步执行,而且执行时机有区别

更多参考文档介绍

还有问题?

实践

刚好最近在某管理后台需要有用户列表页的展示,除了获取数据的副作用,只有渲染的功能,用Hooks实现起来就很自然,而在原来的范式下,因为一个额外的网络请求,就需要把functional组件转成class,随之而来的又是一系列的模板代码和声明周期函数。

使用Hooks之后的代码像下面这样:

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
// ...

function UserList() {
const [users, setUsers] = useState([]);
async function fetchData() {
const res = await getUsers();
if (res.code === 200) {
setUsers(res.data);
}
}
useEffect(() => {
fetchData();
}, []);

return (
<div className="admin-users">
<h2>用户信息</h2>
<Table
columns={columns}
dataSource={users}
pagination={false}
/>
</div>
);
}

使用原来的模式时,大概像下面这样:

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
class UserList extends Component {
constructor(props) {
super(props);
this.state = {
users: []
};
}

async componentDidMount() {
const res = await getUsers();
if (res.code === 200) {
this.setState({ users: res.data });
}
}

render() {
return (
<div className="admin-users">
<h2>用户信息</h2>
<Table
columns={columns}
dataSource={this.state.users}
pagination={false}
/>
</div>
);
}
}

虽然代码行数类似,但是代码信噪比和可拓展性明显上面更优。

感受与展望

我理解Hooks的目的并不是强行把class组件里的功能硬塞到functional组件里(虽然从用法上确实有这种感觉),推荐使用funcational组件的形式。而是一次新的复用组件逻辑方式的尝试。毕竟组合函数比组合class简单多了(React并不建议mixin)。同时通过上面的简单实践可以发现,使用Hooks之后,少了许多Spaghetti code,看上去清爽了许多,可读性也随着提高。

不过另一方面,Hooks的API初看上去挺美,挺简洁好用,那是因为最开始举例的场景简单,不需要hack。由于使用Hooks就意味着用全盘用function的形式写组件,原来用class写法写的复杂的业务组件,如果都用Hooks的方式写,也需要开发者具有一定的设计模式意识。同时在有些场景(比如上面说的prevState,prevProps)要使用比较反直觉的操作才能完成。期待后面Hooks API不断优化后的结果。

在逐渐使用Hooks的方式写组件后,业务中会有一些共用的Hooks抽象出来,整个项目目录结构也会发生变化,Hooks文件的管理方式还要再实践。期待Hooks能让每个模块代码都能小于200行的一天更早到来😌。