0%

手写模拟实现 React Hooks

会使用 React Hook,还要学会简单手写 React Hook

前言

首先使用create-react-app新建个项目,然后在index.js写我们的代码,阅读本文前需要知道常用 React Hooks 的基本用法。

useState

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
import React from 'react'
import ReactDOM from 'react-dom'

let memorizedState
const useState = initialState => {
memorizedState = memorizedState || initialState // 初始化
const setState = newState => {
memorizedState = newState
render() // setState 之后触发重新渲染
}
return [memorizedState, setState]
}

const App = () => {
const [count1, setCount1] = useState(0)

return (
<div>
<div>
<h2>useState: {count1}</h2>
<button
onClick={() => {
setCount1(count1 + 1)
}}
>
添加count1
</button>
</div>
</div>
)
}

const render = () => {
ReactDOM.render(<App />, document.getElementById('root'))
}
render()

但到这里会有一个问题,就是当增加第二个 useState 的时候会发现改变两个 state 都是改同一个值,同步变化,于是,我们通过使用数组的方式下标来方式来区分。

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
import React from 'react'
import ReactDOM from 'react-dom'

let memorizedState = [] // 通过数组形式存储有关使用hook的值
let index = 0 // 通过下标记录 state 的值
const useState = initialState => {
let currentIndex = index
memorizedState[currentIndex] = memorizedState[index] || initialState
const setState = newState => {
memorizedState[currentIndex] = newState
render() // setState 之后触发重新渲染
}
return [memorizedState[index++], setState]
}

const App = () => {
const [count1, setCount1] = useState(0)
const [count2, setCount2] = useState(10)

return (
<div>
<div>
<h2>
useState: {count1}--{count2}
</h2>
<button
onClick={() => {
setCount1(count1 + 1)
}}
>
添加count1
</button>
<button
onClick={() => {
setCount2(count2 + 10)
}}
>
添加count2
</button>
</div>
</div>
)
}

const render = () => {
index = 0
ReactDOM.render(<App />, document.getElementById('root'))
}
render()

这样就实现效果了:

分析:

  • 第一次页面渲染的时候,根据 useState 顺序,声明了 count1 和 count2 两个 state,并按下标顺序依次存入数组中
  • 当调用setState的时候,更新 count1/count2 的值,触发重新渲染的时候,index 被重置为 0。然后又重新按 useState 的声明顺序,依次拿出最新 state 的值;由此也可见为什么当我们使用 hook 时,要注意点 hook 不能在循环、判断语句内部使用,要声明在组件顶部。

memorizedState这个数组我们下面实现部分 hook 都会用到,现在memorizedState数组长度为 2,依次存放着两个使用useState后返回的 state 值;

1
2
0: 0
1: 10

每次更改数据后,调用render方法,App函数组件重新渲染,又重新调用useState,但外部变量memorizedState之前已经依次下标记录下了 state 的值,故重新渲染是直接赋值之前的 state 值做初始值。知道这个做法,下面的useEffect,useCallback,useMemo也是这个原理。

当然实际源码,useState是用链表记录顺序的,这里我们只是模拟效果。

useReducer

useReducer 接受 Reducer 函数和状态的初始值作为参数,返回一个数组。数组的第一个成员是状态的当前值,第二个成员是发送 action 的 dispatch 函数。

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
let reducerState

const useReducer = (reducer, initialArg, init) => {
let initialState
if (init !== undefined) {
initialState = init(initialArg) // 初始化函数赋值
} else {
initialState = initialArg
}

const dispatch = action => {
reducerState = reducer(reducerState, action)
render()
}
reducerState = reducerState || initialState
return [reducerState, dispatch]
}

const init = initialNum => {
return { num: initialNum }
}

const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return { num: state.num + 1 }
case 'decrement':
return { num: state.num - 1 }
default:
throw new Error()
}
}

const App = () => {
const [state, dispatch] = useReducer(reducer, 20, init)

return (
<div>
<div>
<h2>useReducer:{state.num}</h2>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</div>
</div>
)
}

useEffect

对于 useEffect 钩子,当没有依赖值的时候,很容易想到雏形代码:

1
2
3
4
5
6
const useEffect = (callback, dependencies) => {
if (!dependencies) {
// 没有添加依赖项则每次执行,添加依赖项为空数组
callback()
}
}

但如果有依赖 state 值,即是我们使用 useState 后返回的值,这部分我们就需要用上方定义的数组 memorizedState 来记录

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
let memorizedState = [] // 存放 hook
let index = 0 // hook 数组下标位置
/**
* useState 实现
*/
const useState = initialState => {
let currentIndex = index
memorizedState[currentIndex] = memorizedState[index] || initialState
const setState = newState => {
memorizedState[currentIndex] = newState
render() // setState 之后触发重新渲染
}
return [memorizedState[index++], setState]
}

/**
* useEffect 实现
*/
const useEffect = (callback, dependencies) => {
if (memorizedState[index]) {
// 不是第一次执行
let lastDependencies = memorizedState[index] // 依赖项数组
let hasChanged = !dependencies.every((item, index) => item === lastDependencies[index]) // 循环遍历依赖项是否与上次的值相同
if (hasChanged) {
// 依赖项有改变就执行 callback 函数
memorizedState[index++] = dependencies
setTimeout(callback) // 设置宏任务,在组件render之后再执行
} else {
index++ // 每个hook占据一个下标位置,防止顺序错乱
}
} else {
// 第一次执行
memorizedState[index++] = dependencies
setTimeout(callback)
}
}

const App = () => {
const [count1, setCount1] = useState(0)
const [count2, setCount2] = useState(10)

useEffect(() => {
console.log('useEffect1')
}, [count1, count2])

useEffect(() => {
console.log('useEffect2')
}, [count1])

return (
<div>
<div>
<h2>
useState: {count1}--{count2}
</h2>
<button
onClick={() => {
setCount1(count1 + 1)
}}
>
添加count1
</button>
<button
onClick={() => {
setCount2(count2 + 10)
}}
>
添加count2
</button>
</div>
</div>
)
}

const render = () => {
index = 0
ReactDOM.render(<App />, document.getElementById('root'))
}
render()

程序第一次执行完毕后,memorizedState 数组值如下

1
2
3
4
0: 0
1: 10
2: [0, 10]
3: [0]

上述代码回调函数执行,本来我们可以用callback()执行即可,但因为useEffect在渲染时是异步执行,并且要等到浏览器将所有变化渲染到屏幕后才会被执行;

因为是异步且等页面渲染完毕才执行,根据对 JS 事件循环的理解,我们想要它异步执行任务,就在此创建一个宏任务setTimeout(callback)让它进入宏任务队列等待执行,当然这其中具体的渲染过程我这里就不细说了。

还有一个 hook 是useLayoutEffect,除了执行回调的两处地方代码实现不同,其他代码相同,callback这里我用微任务Promise.resolve().then(callback),把函数执行加入微任务队列。

因为useLayoutEffect在渲染时是同步执行,会在所有的 DOM 变更之后同步调用,一般可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 将被同步刷新。

怎么证明呢?如果你在useLayoutEffect加了死循环,然后重新打开网页,你会发现看不到页面渲染的内容就进入死循环了;而如果是useEffect的话,会看到页面渲染完成后才进入死循环。

1
2
3
useLayoutEffect(() => {
while (true) {}
}, [])

useCallback

useCallbackuseMemo会在组件第一次渲染的时候执行,之后会在其依赖的变量发生改变时再次执行;并且这两个 hooks 都返回缓存的值,useMemo 返回缓存计算数据的值,useCallback返回缓存函数的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const useCallback = (callback, dependencies) => {
if (memorizedState[index]) {
// 不是第一次执行
let [lastCallback, lastDependencies] = memorizedState[index]

let hasChanged = !dependencies.every((item, index) => item === lastDependencies[index]) // 判断依赖值是否发生改变
if (hasChanged) {
memorizedState[index++] = [callback, dependencies]
return callback
} else {
index++
return lastCallback // 依赖值不变,返回上次缓存的函数
}
} else {
// 第一次执行
memorizedState[index++] = [callback, dependencies]
return callback
}
}

useMemo

useMemo实现与useCallback也很类似,只不过它返回的函数执行后的计算返回值,直接把函数执行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const useMemo = (memoFn, dependencies) => {
if (memorizedState[index]) {
// 不是第一次执行
let [lastMemo, lastDependencies] = memorizedState[index]

let hasChanged = !dependencies.every((item, index) => item === lastDependencies[index])
if (hasChanged) {
memorizedState[index++] = [memoFn(), dependencies]
return memoFn()
} else {
index++
return lastMemo
}
} else {
// 第一次执行
memorizedState[index++] = [memoFn(), dependencies]
return memoFn()
}
}

useContext

代码出乎意料的少吧…

1
2
3
const useContext = context => {
return context._currentValue
}

useRef

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。

1
2
3
4
5
let lastRef
const useRef = value => {
lastRef = lastRef || { current: value }
return lastRef
}

后面几个例子我就没展示出 Demo 了,附上 Github 地址:上述 hooks 实现和案例

-------------本文结束感谢您的阅读-------------