Hooks 是一个新的概念,它允许你组成状态和副作用。它们允许你在组件之间重用有状态逻辑。

如果你已经使用 Preact工作了一段时间,你可能会熟悉 render propshigher order components等模式,这些模式试图解决这些挑战。这些解决方案往往会使代码更难遵循,更抽象。钩子API 使得整齐地提取状态和副作用的逻辑成为可能,同时也简化了独立于依赖该逻辑的组件的单元测试。

钩子可以用在任何组件中,并且避免了类组件 API 所依赖的这个关键字的许多陷阱。钩子不是从组件实例中访问属性,而是依赖于闭包。这使得它们具有值约束,并消除了在处理异步状态更新时可能出现的一些陈旧数据问题。

有两种方式导入钩子:从preact/hookspreact/compat

简介

理解钩子的最简单的方法是将它们与同等的基于类的Components进行比较。

我们将使用一个简单的计数器组件作为我们的例子,它渲染了一个数字和一个将其增加一的按钮。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Counter extends Component {
state = {
value: 0
};

increment = () => {
this.setState(prev => ({ value: prev.value +1 }));
};

render(props, state) {
return (
<div>
Counter: {state.value}
<button onClick={this.increment}>Increment</button>
</div>
);
}
}

现在,这里有一个用钩子构建的等价函数组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Counter() {
const [value, setValue] = useState(0);
const increment = useCallback(() => {
setValue(value + 1);
}, [value]);

return (
<div>
Counter: {value}
<button onClick={increment}>Increment</button>
</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
29
30
function useCounter() {
const [value, setValue] = useState(0);
const increment = useCallback(() => {
setValue(value + 1);
}, [value]);
return { value, increment };
}

// 第一个计数器
function CounterA() {
const { value, increment } = useCounter();
return (
<div>
Counter A: {value}
<button onClick={increment}>Increment</button>
</div>
);
}

// 第二个计数器,它呈现出不同的输出
function CounterB() {
const { value, increment } = useCounter();
return (
<div>
<h1>Counter B: {value}</h1>
<p>I'm a nice counter</p>
<button onClick={increment}>Increment</button>
</div>
);
}

注意:CounterACounterB 都是完全独立的。它们都使用useCounter()自定义钩子,但每个钩子都有自己的钩子相关状态的实例。

觉得这看起来有点奇怪?你并不孤单!
我们很多人花了一段时间才习惯了这种做法。

依赖性参数

许多钩子都接受一个参数,这个参数可以用来限制钩子的更新时间。Preact 会检查依赖关系数组中的每个值,并检查它是否在上次调用钩子后发生了变化。当没有指定依赖性参数时,钩子总是被执行。

在上面的useCounter()实现中,我们向useCallback()传递了一个依赖关系数组。

1
2
3
4
5
6
7
function useCounter() {
const [value, setValue] = useState(0);
const increment = useCallback(() => {
setValue(value + 1);
}, [value]); // <-- the dependency array
return { value, increment };
}

在这里传递value 会导致每当 value 改变时,useCallback 都会返回一个新的函数引用。这对于避免 “陈旧的闭包”是必要的,因为在这种情况下,回调总是会引用第一个渲染的 value 变量,当它被创建时,导致增量总是设置一个值为1

这样每次 value 发生变化时都会创建一个新的 increment 回调。出于性能方面的考虑,使用useState来更新状态值通常比使用依赖关系保留当前值要好。

有状态钩子

下面我们就来看看如何在功能组件中引入状态逻辑。

在引入钩子之前,任何需要状态的地方都需要类组件。

useState

这个钩子接受一个参数,这将是初始状态。当调用这个钩子时,会返回一个由两个变量组成的数组,第一个是当前状态,第二个是我们的状态设置器。

我们的setter的行为类似于经典状态的setter,它接受一个以currentState为参数的值或函数。当你调用setter而状态不同时,它将从使用了该useState的组件开始重新渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { h } from 'preact';
import { useState } from 'preact/hooks';

const Counter = () => {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
// 你也可以将回调传递给 setter
const decrement = () => setCount((currentCount) => currentCount - 1);

return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
)
}

当我们的初始状态开销很大时,最好传递一个函数而不是一个值。

useReducer

useReducer 钩子与 redux 有很相似的地方。与 useState 相比,当你有复杂的状态逻辑,下一个状态取决于上一个状态时,它更容易使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const initialState = 0;
const reducer = (state, action) => {
switch (action) {
case 'increment': return state + 1;
case 'decrement': return state - 1;
case 'reset': return 0;
default: throw new Error('Unexpected action');
}
};

function Counter() {
// Returns the current state and a dispatch function to
// trigger an action
const [count, dispatch] = useReducer(reducer, initialState);
return (
<div>
{count}
<button onClick={() => dispatch('increment')}>+1</button>
<button onClick={() => dispatch('decrement')}>-1</button>
<button onClick={() => dispatch('reset')}>reset</button>
</div>
);
}

记忆化

在 UI 编程中,经常会有一些状态或结果需要耗时的计算。Memoization可以缓存该计算结果,使其在使用相同的输入时可以重复使用。

useMemo

通过useMemo钩子,我们可以记住其计算结果,只有当其中一个依赖关系发生变化时才重新计算。

1
2
3
4
5
const memoized = useMemo(
() => expensive(a, b),
// 只有当依赖关系发生变化时,才重新运行expensive函数。
[a, b]
);

不要在useMemo里面运行任何effectful代码。副作用属于useEffect

useCallback

useCallback 钩子可以用来确保只要没有依赖关系发生变化,返回的函数就会保持引用平等。当子组件依赖引用平等来跳过更新时,这可以用来优化子组件的更新(例如 shouldComponentUpdate)。

1
2
3
4
const onClick = useCallback(
() => console.log(a, b),
[a, b]
);

有趣的是:useCallback(fn,deps) 相当于useMemo(()=>fn,deps)

使用引用

要在一个功能组件中获取一个 DOM 节点的引用,有一个 useRef 钩子。它的工作原理类似于 createRef

1
2
3
4
5
6
7
8
9
10
11
12
function Foo() {
// Initialize useRef with an initial value of `null`
const input = useRef(null);
const onClick = () => input.current && input.current.focus();

return (
<>
<input ref={input} />
<button onClick={onClick}>Focus input</button>
</>
);
}

注意不要把 useRefcreateRef 混淆。

使用上下文

要在功能组件中访问上下文,我们可以使用 useContext 钩子,而不需要任何高阶或包装组件。

第一个参数必须是由 createContext 调用创建的上下文对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Theme = createContext('light');

function DisplayTheme() {
const theme = useContext(Theme);
return <p>Active theme: {theme}</p>;
}

// ...later
function App() {
return (
<Theme.Provider value="light">
<OtherComponent>
<DisplayTheme />
</OtherComponent>
</Theme.Provider>
)
}

副作用

副作用是许多现代 Apps 的核心。无论你是想从 API 中获取一些数据,还是想在文档上触发一个效果,你会发现 useEffect 几乎可以满足你所有的需求。这也是 hooks API 的主要优势之一,它重新塑造了你的思维,让你以效果而不是组件的生命周期来思考。

useEffect

顾名思义,useEffect 是触发各种副作用的主要方式。如果需要的话,你甚至可以从你的effect中返回一个清理函数。

1
2
3
4
5
6
useEffect(() => {
// Trigger your effect
return () => {
// Optional: Any cleanup code
};
}, []);

我们先从 Title 组件开始,它应该反映出文档的标题,这样我们就可以在浏览器标签页的地址栏中看到它。

1
2
3
4
5
6
7
function PageTitle(props) {
useEffect(() => {
document.title = props.title;
}, [props.title]);

return <h1>{props.title}</h1>;
}

useEffect 的第一个参数是一个触发效果的无参数回调。在我们的例子中,我们只想触发它,当标题真的发生了变化。当它保持不变时,更新它就没有意义了。这就是为什么我们要用第二个参数来指定我们的依赖关系数组。

但有时我们有一个更复杂的用例。想象一下一个组件,当它挂载时需要订阅一些数据,而当它卸载时需要取消订阅。这也可以通过 useEffect 来完成。要运行任何清理代码,我们只需要在回调中返回一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Component that will always display the current window width
function WindowWidth(props) {
const [width, setWidth] = useState(0);

function onResize() {
setWidth(window.innerWidth);
}

useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);

return <div>Window width: {width}</div>;
}

清理函数是可选的。如果你不需要运行任何清理代码,你不需要在传递给 useEffect 的回调中返回任何东西。

useLayoutEffect

该签名与 useEffect 相同,但它将在组件被扩散且浏览器有机会绘制时立即启动。

useErrorBoundary

每当一个子组件抛出一个错误时,你可以使用这个钩子来捕捉它并向用户显示一个自定义的错误 UI。

1
2
3
4
5
// error = The error that was caught or `undefined` if nothing errored.
// resetError = Call this function to mark an error as resolved. It's
// up to your app to decide what that means and if it is possible
// to recover from errors.
const [error, resetError] = useErrorBoundary();

出于监控的目的,任何错误的通知服务都是非常有用的。为此,我们可以利用一个可选的回调,并将其作为 useErrorBoundary 的第一个参数。

1
const [error] = useErrorBoundary(error => callMyApi(error.message));

一个完整的使用例子可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const App = props => {
const [error, resetError] = useErrorBoundary(
error => callMyApi(error.message)
);

// Display a nice error message
if (error) {
return (
<div>
<p>{error.message}</p>
<button onClick={resetError}>Try again</button>
</div>
);
} else {
return <div>{props.children}</div>
}
};

如果你过去一直在使用基于类的组件 API,那么这个 hook 本质上是 componentDidCatch 生命周期方法的替代品。这个钩子是在 Preact 10.2.0 中引入的。