学习 React Hooks

React Hook 或许是 React 中最有趣的玩意了,相较于隔壁 vue 的“竞品” setup,hook 的行为和普通函数的行为非常相像(我觉得这是它最棒的一点——对函数的心智模型可以被继续沿用),且更容易学习(vue 里一万个概念能够把你绕晕十次甚至九次),更容易避免引用泄漏相关的 bug。最近进行了很多学习,是时候该进行一个笔记的做了。

perspective

理解 Hook 的运行原理的关键在于:对于使用 Hook 的函数式组件,它的每一次执行都有着自己的 props 和 state,且对于每一次执行,它的 props 和 state 对这一趟而言都是常量。这意味着什么呢?这意味着我们在思考其运行机制时,可以使用所谓的代换模型——把 props 和 state 用它的值去替代

容易发现,这和传递值类型给函数的行为是一致的

代换模型实际上只对 props,state 等生效,对于 setState,useRef,dispatch 等是无效的——它们在多次执行中保持的都是同一个引用,但我认为它仍有使用的价值。

考虑一个最经典的例子。

1
2
3
4
5
6
7
8
9
10
11
12
const [counter, setCounter] = useState(0)

useEffect(() => {
const interval = setInterval(() => {
setCounter(counter + 1)
}, 1000)
return () => clearInterval(interval) // 清理函数的执行时机也是一个有趣的话题
}, []) // 这里 eslint 会警告一波,这也证明这么写不太行

return (
<p>counter: {counter}</p>
)

会发生什么呢?其实即使没学过 hook,只要学过闭包就能得到答案了——counter 将始终为 1,这是因为对于这一趟的执行,它看到的 counter 为常量 0,我们可以用代换模型去描述:

1
2
3
4
5
6
7
8
9
10
11
12
const [0, setCounter] = useState(0)

useEffect(() => {
const interval = setInterval(() => {
setCounter(0 + 1)
}, 1000)
return () => clearInterval(interval)
}, [])

return (
<p>counter: {0}</p>
)

That’s it!这个组件在之后或许还会被渲染数次(代码中没体现),但这个 interval 函数看到的永远是这一趟的 counter,即 0。这个问题有数种解决方案,比如在依赖数组中加入 counter(这样 counter 每次改变时旧的 interval 会被销毁,新的计数器会被创建,看着有点怪,但确实有用),或者使用传递函数的 setState,即setCounter(counter => counter + 1),也可以使用 useRef(虽然它并没意义用于此,且会引入新的复杂度,因为 useRef 的值的改变不会自动触发重渲染)。

另外,对于 useEffect,不应当考虑其为类组件生命周期的模拟,而是认为它是一种将 props 和 state 和 DOM 之外的事物进行同步的手段。下面该挨个过堂。

setState 的函数式更新,即setCounter(counter => counter + 1)是同步的,这意味着可以通过某些方式把这时候的 counter 取出来,从而避免上面的问题:

1
2
3
4
5
let c
setCounter(counter => {
c = counter
return counter
})

但在实践中绝不应该使用这个操作:它是“未定义”的,在不同的版本中可能会有不同的表现,这里只是随便提一嘴。

useState

useState 即创建一个状态,它其实并没有什么可说的。下面是一个示例,这里将 useState 的第一个返回值称为 state,第二个称为 setState。

1
const [count, setCount] = useState<number>(0)

useState 的行为是被明确说明了的:第一次渲染时,其会被赋予初始值即 0,之后通过 setCount 去修改它的值。

这里有一个有趣的地方在于,函数式组件的每一次渲染都是一次普通的函数调用,因此这里的useState<number>(0)也只是一次普通的函数调用(其它 hook 亦然)。

既然是普通的函数调用,那它的计算模型当然和普通的函数一致——先计算参数值,再传给函数去求值,因此这里的 0 每次都会被求值。在这里这并非是个问题,但倘若求初始值是一个很昂贵的操作呢?比如从 localStorage 中取一个值?倘若仍旧按原来的写法,那每一次渲染都会去拿一次 localStorage。性能被无意义地损耗了。

React 显然知道这个问题,因此它允许通过一个函数去创建初始值,这个函数显然只会被 React 调用一次,这显然运用了惰性求值/传名调用(call by name)——随你怎么说——的模式:

1
2
3
4
const [data, setData] = useState(() => {
// some expensive operation
return 42
})

useState 另一个需要关注的地方在于,state 在每一趟调用中是不同的——每一趟渲染中看到的 state 都是这一趟看到的,而 setState 在每一趟中都是相同的,因此 setState 不需要放在 useEffect 等的依赖数组中。

useEffect

useEffect,给予函数式组件制造副作用的能力。

useEffect 的行为和 useState 的很类似:每一趟渲染时,这一趟的 effect 看到的是这一趟的 props 和 state,这等于是说,每次渲染都有它自己的 Effects,考虑下面的代码:

1
2
3
4
5
6
7
8
9
const [count, setCount] = useState(0)

useEffect(() => {
setTimeout(() => {
console.log(count)
}, 5000)
}, [])

return <button onClick={e => setCount(count + 1)}>inc</button>

在这 5 秒内,无论我们点击多少次按钮,最后输出的永远是 0,因为这一次 effect 的执行看到的仅是这一次的 count。

同步,而非生命周期

这篇文章的这一节《同步,而非生命周期》 我认为很好地诠释了对 Effect 的心智模型:React 根据把组件当前的 props 和 state 同步给 DOM,而 Effect 则是根据 props 和 state 同步 DOM 以外的东西。在这个心智模型中,不存在所谓的生命周期,只有 props 和 state 到特定事物的一个映射,我们关注目的,而非过程。容易类比,这也是声明式编程相较于命令式编程,react 相较于 jquery 的不同:关注要做什么(目的),而非如何做(过程)

比如,我们可以把网页的 title 和组件的一个 state 去同步:

1
2
3
4
5
const [title, setTitle] = useState('你的名字')
useEffect(() => {
document.title = title // synchronize document.title with state title
})
// ...

但这种同步并非是单向的,React 也需要接受来自 DOM(以及其他地方)的事件去更新 state,Effect(以及事件处理器)有时候也是依据 DOM 之外的其它东西去修改 state,比如 HTTP 请求,下面是来自于 这篇文章 的一个比较复杂的例子,它通过请求来更新 state。

1
2
3
4
5
6
7
8
9
const [data, setData] = useState({});
const [query, setQuery] = useState('redux');
useEffect(() => {
(async () => {
const result = await axios(`http://hn.algolia.com/api/v1/search?query=${query}`);
setData(result.data);
})()
}, [query]);
// ...

这段代码用这里的心智模型怎么理解呢?我们关注目的,所以我们说我们将这个 http 接口(以及 query 这个状态)和 data 这个 state 进行同步。

显然,这种同步和这个接口的状态以及 query 状态相关,每当 query 改变,或者接口状态改变,data 这个 state 也应当进行改变;但后者显然是 react 监视不到的,或者说,后者的变化无法触发组件的重新渲染(似乎只有 setState,setReducer 以及某些不可名状的其它情况会触发重新渲染)。

这里会有另一个有趣的问题——从远程获取数据时,是在事件监听器去直接获取,还是去修改相应状态,在 useEffect 中去获取?显然后者是更加接近这里的心智模型的,但究竟孰优孰劣?亦或是两者都有适用场景?

关于清理函数

要理解清理函数的行为,必须理解 Effect 的执行流程。Effect 在每次渲染之后执行,在这一的 Effect 执行之前,会先执行上一 Effect 的清理函数,顺序为:

  1. 渲染完成
  2. 上一次的清理函数执行
  3. 这一次的 Effect 执行

这里说的是“次”而非“趟”——只有这个Effect确实执行了才算数,比如下面这个 Effect 的清理函数只在组件 unmount 时才执行

1
2
3
4
5
6
useEffect(() => {
console.log("来了来了")
return () => {
console.log("溜了溜了")
}
}, [])

比如下面这个 Effect 的清理函数只在重渲染且 data 这个变量改变时才执行:

1
2
3
4
5
6
useEffect(() => {
console.log("来了来了", data)
return () => {
console.log("溜了溜了", data)
}
}, [data])

考虑这样一个场景,组件有一个 id prop,我们希望用这个 id 去订阅某个聊天室,每次 id 改变时,我们都希望取消之前的订阅,再订阅新的聊天室,这个怎么实现呢?这个问题来自于 React FAQ这篇文章 也有描述。

实际上什么都不需要做:

1
2
3
4
useEffect(() => {
ChatAPI.subscribe(props.id);
return () => ChatAPI.unsubscribe(props.id);
}, [props.userId]);

这仍旧可以用代换模型来解决,比如 id 从 10 变成 20,两次的代码是这样:

1
2
3
4
5
6
7
8
9
10
11
// 上一次
useEffect(() => {
ChatAPI.subscribe(10);
return () => ChatAPI.unsubscribe(10);
}, [10]);

// 这一次
useEffect(() => {
ChatAPI.subscribe(20);
return () => ChatAPI.unsubscribe(20);
}, [20]);

上一趟的清理函数看到的是上一趟的 id,这一趟看到的是这一趟的 id。

这里有另一个有趣的场景,假设在某个 Effect 里执行某个异步操作,而且这个 Effect 连续执行了两次,而且旧的请求到达的更晚……如果没有任何操作的话,则新的请求的结果会被旧的请求的结果去覆盖掉,这不是我们想要的,为此我们要么去在执行新的请求的时候销毁旧的请求,要么让它“失能”,后者的操作更加通用,可以归结为一个模式:

1
2
3
4
5
6
7
8
9
10
11
useEffect(() => {
let ignore = false
(async () => {
const res = getUser(id)
if (ignore) return
// ...
})()
return () => {
ignore = true
}
}, [id])

这个 Effect 若执行了下一次,则这一次的清理函数会被调用,ignore 会设置为 true,因此这个请求的结果会被忽略。

关于依赖数组

useEffect 以及其它 hook 的依赖数组也是非常有趣的部分,它汗 useCallback,useMemo 等都可以认为都是对上面所说的同步的优化

依赖数组的原理十分简单——每次重新渲染的时候,React 会比较当前依赖数组中的值和上一次的值是否有修改(使用Object.is,行为类似===),若有修改才去触发 Effect。

React 的官方文档以及其它文章都会警告你,说你应当在依赖数组中包含你依赖的所有 props,state 以及其它可能会有修改的量(包括在函数组件顶级作用域中定义的变量,函数),否则这是不安全的,这在某些情景下会让人感觉很奇怪,但其实他们的意思是说,它的行为将可能会和你的预期不符

这里相关的具体细节还是直接看相关的 文章 更合适,但无论如何,持续维护依赖数组(并且安装 react 提供的 lint 插件)总是个好主意。

useReducer

倘若对一个状态的更新的逻辑依赖其他状态的值,这可能就是用 useReducer 的时候了。useReducer 以纯函数的形式统一了更新一系列状态的接口,并枚举出所有对这一系列状态所做的动作(action),以方便管理和维护复杂状态。

在 Typescript 中使用 reducer 是非常舒服的,下面是一个简单的实例,其中 Action 中的 FIELD 事件表示对特定状态进行更新,这个操作过于通用,因此不应当使用(倘若某个 reducer 提供这个 action 还能正常作用,那估计根本没必要去使用 reducer):

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
type CounterState = {
counter: number,
enabled: boolean
}
const initializeState : CounterState = {
counter: 0,
enabled: true
}
type CounterAction =
{ type: "SET_COUNTER", payload?: number } |
{ type: "ENABLE_COUNTER" } |
{ type: "DISABLE_COUNTER" } |
{ type: "INC" } |
{ type: "FIELD", payload: { // this thing is really bad!
[k in keyof CounterState]?: CounterState[k]
}
}
// Reducer<S, A> = (prevState: S, action: A) => S
const counterReducer: Reducer<CounterState, CounterAction> = (state, action) => {
switch (action.type) {
case 'SET_COUNTER': return state.enabled ? {
...state,
counter: action.payload ? action.payload : 0
} : { ...state }
case 'ENABLE_COUNTER': return { ...state, enabled: true }
case 'DISABLE_COUNTER': return { ...state, enabled: false }
case 'INC': return { ...state, counter: state.counter + 1 }
case 'FIELD': return { ...state, ...action.payload }
default: throw new Error()
}
}

// in component
const [{counter, enabled}, dispatch] = useReducer(counterReducer, initializeState)
dispatch({type: 'SET_COUNTER', payload: 42})

顺带一提,useReducer 还能这么玩:

1
const [counter, inc] = useReducer(s => s + 1, 0)

我对 useReducer 了解不多,就这样了。十分好奇为何 useReducer 的初始化函数放到第三个参数,没有和 useState 去统一。

useCallback & useMemo

有时候,组件的状态粒度过细,无法直接利用,我们就会尝试编写函数和定义变量来先进行一些操作,比如下面的实例:

1
2
3
4
5
6
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')

const fullName = firstName + lastName
// or
const getFullName = () => firstName + lastName

第一印象还好,但有如下几个问题:

  1. 它们每次重新渲染时都会被执行,而执行过程可能是昂贵的
  2. 倘若定义的是复杂对象或者函数,则它们每次执行时都会改变(使用 Object.is 不相等),因此无法加到依赖数组中

为此,React 提供了 useMemo 和 useCallback 两个 hook 来解决此问题。

useMemo 的性质就像 vue 的计算属性(但是是只读的),它会缓存计算值,并只在它的依赖有改变的时候重新计算,未重新计算时,拿到的值总是同一个,即用 Object.is 能比较相等。

1
2
3
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const fullName = useMemo(() => firstName + lastName, [firstName, lastName])

useCallback 不会缓存计算值,而是缓存计算的函数,以保证在依赖项未改变时仍旧为同一个函数,或者说使函数本身只在需要的时候才改变。若想在 Effect 中去引用定义在组件顶级作用域的函数且不违背依赖数组,必须使用 useCallback,见 这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function SearchResults() {
// ✅ Preserves identity when its own deps are the same
const getFetchUrl = useCallback(query => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, []); // ✅ Callback deps are OK

useEffect(() => {
const url = getFetchUrl('react');
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Effect deps are OK

useEffect(() => {
const url = getFetchUrl('redux');
// ... Fetch data and do something ...
}, [getFetchUrl]); // ✅ Effect deps are OK
// ...
}

useRef & useImperactiveHandle

ref 在 React 中有两种功能:

  1. 用于操作 DOM 元素
  2. 子组件暴露给父组件操作/值的方式,常用于自定义不受管组件时

函数式组件中则有第三种功能:

  1. 实现函数式组件的“实例变量”——对于每一次渲染,其值都引用同一个对象,这使历史的渲染也能够影响当前的该对象

操作 DOM 是我们都比较熟悉的,想要暴露给父组件操作的话子组件需要使用 forwardRef 函数(HOC?)包装,并且使用 useImperativeHandle 去注册操作/值,下面是一个简单的示例,子组件 Counter 暴露给父组件操作以清空它的值:

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
type CounterProp = {}
type CounterOpt = {
inc(): void
clear(): void
}
const Counter = forwardRef<CounterOpt, CounterProp>((prop, ref) => {
const [counter, setCounter] = useState(0)
useImperativeHandle(ref, () => ({
clear() {
setCounter(0)
},
inc() {
setCounter(counter + 1)
}
})) // useImperativeHandle 同样有依赖数组,但懒得加
return (
<>
<p>counter: {counter}</p>
</>
)
})

const Father = () => {
const counterRef = useRef<CounterOpt>(null)
return (
<>
<button onClick={e => counterRef.current?.inc()}>inc</button>
<button onClick={e => counterRef.current?.clear()}>clear</button>
<Counter ref={counterRef} />
</>
)
}

参考资料