持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情
前面的文章我们已经了解了 react 的渲染更新机制,包括类组件和函数组件。从本小节开始会介绍下 react 内的 hooks 的实现,毕竟现在所有的新的 react 项目基本都是用 hook 形式开发了,要成长就不能光知道这么写,还得知道为什么这么写,怎么实现的。
useState 特点
useState是函数组件的状态管理器,它会返回一对值:当前状态和一个让你更新它的函数。它类似class组件的this.setState,但是它不会把新的state和旧的state进行合并,而是直接用新的值替换。useState唯一的参数就是初始state
使用
// src/index.js
function App() {
// 初始化状态
const [count, setCount] = React.useState(0)
const handleClick = () => {
// 修改状态
setCount(count + 1)
}
return <div>
<p>count: {count}</p>
<button onClick={handleClick}>+</button>
</div>
}
ReactDOM.render(<App />, document.getElementById('root'))
我们知道 useState 只会初始赋值一次,之后状态就会一直维持;执行状态改变函数,会重新执行函数组件进行渲染,页面显示新的状态。
实现
- 第一版本
// src/react.js
import { useState } from './react-dom'
// src/react-dom.js
export function useState(initialState) {
const state = initialState
function setState(newState) {}
return [state, setState]
}
这里我们大概实现了 useState 的格式,返回的状态也都对,但是如果每次都是初始值,与实际效果不符。我们需要有个变量存储起来,如果存储的有值,就返回该值,没有的话就用初始值。
引出一个问题,这里用什么存储呢?对于 js 来说,要么对象,要么数组存储。如果用 map,谁当 key 呢?一个组件中可以写多个 useState,也不可能初始值或文件名当 key;所以这里是用了数组进行存储,每执行一个 useState 对应一个索引,对于数组来说索引值是连贯的,这也就解释了为什么不能把 useStat 放到条件判断中,如果时而有该 state 时而没有,那么存储的数组索引对应关系就会乱,导致渲染异常。我们更新状态,实际上就是更新的数组中该 useState 对应的索引值对应的值而已。没理解的朋友可以多读几遍这里再往后看。
- 第二版
// 记录状态,多次渲染保持不变
let hookStates = []
let hookIndex = 0
functoin useState(initialState) {
// 解释了为什么第一次初始值,后面再更新就是新的状态值
hookStates[hookIndex] = hookStates[hookIndex] || initialState
const currentIndex = hookIndex
function setState(newState) {
hookStates[currentIndex] = newState
}
return [hookStates[hookIndex++], setState]
}
这里大家可能会有个疑问,
currentIndex是干什么的,为什么不直接用hookIndex?我们知道useState第二个返回值是修改状态的函数,根据上面分析,我们实际上改的是数组中索引值的位置。由于hookIndex对应的是全局的所有的状态索引,执行一次就会++操作指向下一个。而setState修改对应的是当前执行useState方法对应的索引值,所以这里使用了闭包,currentIndex与该setState与当前索引都是对应且不变的。如果我们这里是用hookIndex,那么他就是定死的值1,因为执行了一次++操作了,也丢失了索引对应关系。
从上面代码可以知道我们的组件状态实际上已经改变了,新问题又来了,页面怎么刷新呢?我们需要执行组件更新的逻辑,让函数重新执行,可以从根节点开始完成 diff 操作,实现重新渲染。
- 第三版
// 全局方法
let scheduleUpdate
function render() {
...
// 重新赋值
scheduleUpdate = () => {
// 这里重新赋值为 0,因为组件会重新执行,索引也需要重新开始,无节制的 ++ 操作,会溢出
hookIndex = 0
// 跟节点进行 diff 函数组件会重新执行,这时 hookStates 数组中的状态改变了,所以页面会改变
compareTwoVdom(container, vdom, vdom)
}
}
... useState
function setState(newState) {
hookStates[currentIndex] = newState
scheduleUpdate() // 触发刷新
}
...
切换到我们自己的库,可以实现同样的效果。大家可能有疑问,为什么 compareTwoVdom 的新旧 vdom 一样,因为我们这里只是改变函数组件的状态而已,为了重新执行渲染新值,对应函数组件的 vdom 是一样的,renderVdom 也会在递归对比中不同重新赋值,这不是 useState 考虑的事情了,我们前面已经完成了。
- 完整修改代码
let scheduleUpdate
// 记录状态,多次渲染保持不变
let hookStates = []
let hookIndex = 0
// 虚拟dom变成 真实dom,插入到父节点容器
function render(vdom, container) {
mount(vdom, container) // 可以自行把其他 render 方法换成 mount。否则 scheduleUpdate 会多次赋值,但是也可以忽略
scheduleUpdate = () => {
hookIndex = 0
// 跟节点进行 diff 函数组件会重新执行,这时 hookStates 数组中的状态改变了,所以页面会改变
compareTwoVdom(container, vdom, vdom)
}
}
function mount(vdom, container) {
// 1.
let newDOM = createDOM(vdom);
// 2.
container.appendChild(newDOM);
// 挂载完成
if (newDOM.componentDidMount) {
newDOM.componentDidMount();
}
}
export function useState(initialState) {
hookStates[hookIndex] = hookStates[hookIndex] || initialState
let currentIndex = hookIndex // 备份索引
// 闭包索引不会变 hookIndex 每次 ++ 会变
function setState(newState) {
hookStates[currentIndex] = newState
scheduleUpdate() // 触发刷新
}
return [hookStates[hookIndex++], setState]
}
到这里 useState 我们就实现了,文中可能有些判断不完美,大家可以自行完善,毕竟思路已经有了。如有错误欢迎指正!