State/Effect 分层

友好的 React Hooks

网络上对 react hooks 的评价负面大于正面,确实很容易写出性能有问题的代码,关键就在于:我们太喜欢用 useState 了。
在 vue-composition-api 中,reactivity 数据都有 wrapper,custom-vca 里不管产生多少个 reactivity 对象,不会直接产生 re-render。只有那些被 return 到外部跟 template 绑定的部分才会触发视图渲染。
而 react 的 reactivity 就是通过 re-render 实现的,useState 没有 wrapper,每次使用都会得到一个触发渲染的函数。在这种 reactivity 机制下,就需要特殊的方式编写 hooks —— State/Effect 分层

假设有个 useHeight:

1
const [ref, height] = useHeight()

高度变化时,被动 re-render,难以转换合并。大部分情况下,不提供 state,而提供 effect 可能会更好:

1
2
3
4
5
const [height, setHeight] = useState(0)
const ref = useHeight((height: number) => {
// do something
setHeight(height)
})

使用者在外部声明 state,然后在 callback 中按需 setState。使用者可以结合其他 state,做 dispatch 到 reducer 的一次整体更新,而不是被动 re-render。
根据 State/Effect 分层理念,尝试着给出友好地 react hooks 公式:

1
const handler = useProducer(consumer, options)

producer 接收 consumer callback 作为参数,返回 handler 控制函数,用于绑定到事件或其他位置。

React 实现 useHeight

给定一个 resizable 的 textarea,我们监听它的高度变化,并展示到文本里。同时给个 checkbox,用户决定是否继续监听。并且只监听一定范围内的尺寸变化。
代码实现按照 low-level -> high-level

首先实现一个 useResizeObserver,对 dom api 的 low-level 适配:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { useCallback, useLayoutEffect, useRef } from "react"

const useDispatch = <I extends any[], O>(f: (...args: I) => O): typeof f => {
const dispatchRef = useRef<typeof f>(f)
const callback = useCallback<typeof f>((...args) => {
return dispatchRef.current(...args)
}, [])

useLayoutEffect(() => {
dispatchRef.current = f
}, [f])

return callback
}

export const useResizeObserver = <T extends HTMLElement>(
callback: (target: T) => any
) => {
const ref = useRef<T | null>(null)
const observerRef = useRef<ResizeObserver | null>(null)
const dispatch = useDispatch(callback)

const trigger = (elem: T | null) => {
ref.current = elem

if (observerRef.current) {
observerRef.current.disconnect()
observerRef.current = null
}

if (!elem) {
return
}

const observer = new ResizeObserver(() => {
dispatch(elem)
})

observer.observe(elem)
observerRef.current = observer
}

const enable = () => {
if (ref.current) {
observerRef.current?.observe(ref.current)
}
}

const disable = () => {
if (ref.current) {
observerRef.current?.unobserve(ref.current)
}
}

return { trigger, enable, disable }
}

useResizeObserver 不返回 state 出去,而是暴露一个 resize effect。然后再使用 useResizeObserver 实现 useHeight:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const useHeight = <T extends HTMLElement>(
callback: (height: number) => any
) => {
const heightRef = useRef<number>(0)
const observer = useResizeObserver<T>((target) => {
const height = target.offsetHeight
heightRef.current = height
callback(height)
})

const getCurrentHeight = () => heightRef.current

return { ...observer, getCurrentHeight }
}

同样的思路还能实现其他监听。
最后在页面中使用:

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
36
37
export default function App() {
const [height, setHeight] = useState(0)
const [checked, setChecked] = useState(false)

const handler = (event: React.ChangeEvent<HTMLInputElement>) => {
setChecked(event.target.checked)
}

const observer =
useHeight <
HTMLTextAreaElement >
((currentHeight) => {
if (currentHeight > 300) {
if (currentHeight !== 300) {
setHeight(300)
}
} else {
setHeight(currentHeight)
}
})

useEffect(() => {
if (checked) {
observer.enable()
} else {
observer.disable
}
}, [checked, observer])

return (
<div>
<textarea ref={observer.trigger} />
<div>height is {height}</div>
<input type="checkbos" checked={checked} onChange={handler} />
</div>
)
}