介绍
返回一个 state,以及更新 state 的函数。
const [state, setState] = useState(initialState);
useState 实现原理
去了解原理的最好方式就是去实现它 useMyState 🐶
我们先声明一下 useMyState 函数,返回一个数组,接收初始值,定义一个state变量存储初始值。注意useState方法只能执行一次,所以我们还需要处理一下,将state放在外面,如果有值不设置默认值,如果没有值再设置默认值。如何可以把一个变量保护起来不让外届可以随便修改?相信大家的第一想法就是「闭包」了。
function useMyState (initialState) {
const state = initialState;
function setState(newState) {
state = newState;
// render
}
return [state, setState]
}
把 从 react 中引入的 useState 替换成自己实现的,查看在线 demo
import React from 'react'
import { render } from 'react-dom'
function useState(initialValue) {
let state = initialValue
function dispatch(newState) {
state = newState
render(<App />, document.getElementById('root'))
}
return [state, dispatch]
}
const App: React.FC = () => {
const [count, setCount] = useState(0)
const [name, setName] = useState('airing')
const [age, setAge] = useState(18)
return (
<>
<p>You clicked {count} times</p>
<p>Your age is {age}</p>
<p>Your name is {name}</p>
<button onClick={() => {
setCount(count + 1)
setAge(age + 1)
}}>
Click me
</button>
</>
)
}
export default App
这个时候我们发现点击按钮不会有任何响应,count 和 age 都没有变化。因为我们实现的 useState 并不具备存储功能,每次重新渲染上一次的 state 就重置了。这里想到可以在外部用个变量来存储之前的值。基于此,我们优化一下刚才实现的 useMyState:
let state
function useMyState(initialValue) {
state = state || initialValue
function setState(newState) {
state = newState
// render
}
return [state, setState]
}
虽然按钮点击有变化了,但是效果不太对。如果我们删掉 age 和 name 这两个 useState 会发现效果是正常的。这是因为我们只用了单个变量去储存,那自然只能存储一个 useState 的值。那我们想到可以用数组,去储存所有的 state,但同时我们需要维护好数组的索引。再次优化 useMyState:
let memoizedState = [] // hooks 的值存放在这个数组里
let cursor = 0 // 当前 memoizedState 的索引
function useMyState(initialValue) {
memoizedState[cursor] = memoizedState[cursor] || initialValue
const currentCursor = cursor
function setState(newState) {
memoizedState[currentCursor] = newState
cursor = 0 // 每次 setState 后会重新触发 render,需要重置下标
// render
}
return [memoizedState[cursor++], setState] // 返回当前 state,并把 cursor 加 1
}
看起来已经可以很好的运行起来了,这里是使用了数组来存储所有的 state,实际上 React 是使用「链表」的数据结构来存储 hooks,有兴趣的同学也可以去阅读一下相关的源码。这也是为什么在不能用 if...else...,while 等条件语句包住 hook,因为只要顺序不对,读写就会开始错乱。
setState 是异步的还是同步的?
一道 React 很经典的面试题~众所周知,如果没有合并更新,在每次执行 setState 的时候,组件都要重新 render 一次,会造成很多无效渲染(因为最后一次渲染会覆盖掉前面所有的渲染效果)。 所以 react 会把一些可以一起更新的 useState/setState 放在一起,进行合并更新(batch update)。因为无法在 setState 后马上从 state 上获取更新后的值,所以也会把这种情况说 setState 是异步的。在线 Demo
来个🌰
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
// 有很多次 setState
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
};
// 点击按钮后只会打印一次
console.log('render', count);
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
但也会存在一些特殊情况的
1. setTimeout / setInterval
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
})
};
// 点击按钮后只会打印 4 次
console.log('render', count);
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
2. Promise / Fetch 的回调中
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
Promise.resolve().then(() => {
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
setCount(preCount => preCount + 1);
})
};
// 点击按钮后只会打印 4 次
console.log('render', count);
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
3. 手动绑定原生事件
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const buttonNode = document.getElementById('button');
buttonNode.addEventListener('click', handleClick);
return () => {
buttonNode.removeEventListener('click', handleClick);
};
}, []);
const handleClick = () => {
setCount((preCount) => preCount + 1);
setCount((preCount) => preCount + 1);
setCount((preCount) => preCount + 1);
setCount((preCount) => preCount + 1);
};
// 点击按钮后只会打印 4 次
console.log('render', count);
return (
<div className="App">
<button id="button">点击</button>
</div>
);
}
WHY ?
为什么会有不同的情况发生呢?这里就涉及到了 batch update 是怎么运作的了,可以先看下官方的图
* wrappers (injected at creation time)
* + +
* | |
* +-----------------|--------|--------------+
* | v | |
* | +---------------+ | |
* | +--| wrapper1 |---|----+ |
* | | +---------------+ v | |
* | | +-------------+ | |
* | | +----| wrapper2 |--------+ |
* | | | +-------------+ | | |
* | | | | | |
* | v v v v | wrapper
* | +---+ +---+ +---------+ +---+ +---+ | invariants
* perform(anyMethod) | | | | | | | | | | | | maintained
* +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | +---+ +---+ +---------+ +---+ +---+ |
* | initialize close |
* +-----------------------------------------+
用大白话将就是:
- 在 react的生命周期和合成事件中, react仍然处于他的更新机制中,这时 isBranchUpdate 为 true。按照上述过程,这时无论调用多少次 setState,都会不会执行更新,而是将要更新的 state 存入 _pendingStateQueue,将要更新的组件存入 dirtyComponent。当上一次更新机制执行完毕,以生命周期为例,所有组件,即最顶层组件 didmount 后会将 isBranchUpdate 设置为 false。这时将执行之前累积的 setState。
- 由执行机制看, setState 本身并不是异步的,而是如果在调用 setState 时,如果 react正处于更新过程,当前更新会被暂存(EventLoop),等上一次更新执行后在执行,这个过程给人一种异步的假象。在生命周期,根据JS的事件循环机制,会将微任务和宏任务放到任务队列中,等所有同步代码执行完毕后再依次执行,这时上一次更新过程已经执行完毕, isBranchUpdate 被设置为 false,根据上面的流程,这时再调用 setState 由于不处理更新过程即可立即执行更新。
如何马上获取 state 最新的值?
在 class component 中,可以通过 setState 的第二个参数,或者在同步模式中可以直接获取到最新 state 的值,但在 function component 中的表现却不一样。
来个🌰
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
func();
};
const func = () => {
// 点击后打印 0
console.log(count);
};
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
- 我们的组件第一次渲染的时候,从useState()拿到count的初始值0。当我们调用setCount(1),React会再次渲染组件,这一次count是1。
- 我们发现count在每一次函数调用中都是一个常量值。值得强调的是 — 我们的组件函数每次渲染都会被调用,但是每一次调用中 count 值都是常量,并且它被赋予了当前渲染中的状态值。
1. 直接把值传给下个函数
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
const newCount = count + 1
setCount(newCount);
// 将最新的值当作函数参数进行传递
func(newCount);
};
const func = (newCount) => {
console.log(newCount);
};
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
2. 使用useEffect 监听 state 的变化
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
useEffect(() => {
console.log(count)
function act (){
...
}
act()
}, [count])
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
3. 使用 useRef 将值保存起来
function App() {
const [count, setCount] = useState(0);
const lastCount = useRef()
const handleClick = () => {
const newCount = count + 1
setCount(newCount);
lastCount.current = newCount;
func()
};
const func = () => {
console.log(lastCount.current);
};
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
将通用的部分抽成一个 hook
const useSyncState = (initState) => {
const [state, setStateValue] = useState(initState)
const stateRef = useRef(state)
const setState = (value) => {
stateRef.current = value
setStateValue(value)
}
const getSyncState = useCallback(() => stateRef.current, [])
return [state, setState, getSyncState]
}
function App() {
const [count, setCount] = useSyncState(0);
const handleClick = () => {
setCount(count.current + 1);
func()
};
const func = () => {
console.log(getSyncState());
};
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
4. 等值变化后在调用 func
因为useEffect是在react组件render之后才会执行,所以在useEffect获取的状态一定是最新的,所以利用这一点,把我们写的函数放到useEffect执行,函数里获取的状态就一定是最新的。
首先,在useSyncCallback中创建一个标示proxyState,初始的时候会把proxyState的current值赋成false,在callback执行之前会先判断current是否为true,如果为true就允许callback执行,若果为false,就跳过不执行,因为useEffect在组件render之后,只要依赖项有变化就会执行,所以我们无法掌控我们的函数执行,在useSyncCallback中创建一个新的函数Func,并返回,通过这个Func来模拟函数调用。
const useSyncCallback = callback => {
const [proxyState, setProxyState] = useState({ current: false });
const Func = useCallback(() => {
setProxyState({ current: true });
}, [proxyState])
useEffect(() => {
if (proxyState.current === true) setProxyState({ current: false });
}, [proxyState])
useEffect(() => {
proxyState.current && callback();
})
return Func
}
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
func()
};
const func = useSyncCallback(() => {
console.log(count);
});
return (
<div className="App">
<button onClick={handleClick}>点击</button>
</div>
);
}
5. 模拟实现 setState 的回调函数
import { useEffect, useRef, SetStateAction } from 'react';
import { useSafeState } from 'ahooks';
type DispatchWithCallback<A> = (value: SetStateAction<A>, callback?: Callback<A>) => void;
type Callback<S = any> = (state: S) => void | (() => void | undefined);
const useStateWithCallbackLazy = <S>(initialValue: S): [S, DispatchWithCallback<S>] => {
const callbacksRef = useRef<Callback<S>[]>([]);
const [value, setValue] = useSafeState<S>(initialValue);
useEffect(() => {
if (callbacksRef.current.length > 0) {
callbacksRef.current.forEach(refItem => {
refItem.call(null, value);
});
// callback依次调用之后,清空callbackRef
callbacksRef.current = [];
}
}, [value]);
const setValueWithCallback = (newValue: SetStateAction<S>, callback?: Callback<S>) => {
// 过滤处理 1. callback 为空 2. callback为重复传入(浅比较去重)
if (
callback &&
!callbacksRef.current.some(refItem => {
if (refItem === callback) {
console.warn('WARNING: 此次传入的 callback 已存在与回调队列,会进行过滤处理,请勿传入重复 callback');
return true;
} else {
return false;
}
})
) {
callbacksRef.current.push(callback);
}
return setValue(newValue);
};
return [value, setValueWithCallback];
};
export default useStateWithCallbackLazy;
batch update 成长史
在 react 18 新特性已经实现了 Automatic batching,即不在 react 控制范围内也可以实现 batch update,请看对比的例子
那么 为什么 react 18 原理又和 17 有什么区别呢?
v18实现**「自动批处理」**的关键在于两点:
- 增加调度的流程
- 不以全局变量「executionContext」为批处理依据,而是以更新的**「优先级」**为依据
在组件对应fiber挂载update后,就会进入**「调度流程」**。
试想,一个大型应用,在某一时刻,应用的不同组件都触发了更新。
那么在不同组件对应的 fiber 中会存在不同优先级的 update。
**「调度流程」**的作用就是:选出这些 update 中优先级最高的那个,以该优先级进入更新流程。
- 获取当前所有优先级中最高的优先级
- 将步骤1的优先级作为本次调度的优先级
- 看是否已经存在一个调度
- 如果已经存在调度,且和当前要调度的优先级一致,则 return
- 不一致的话就进入调度流程
由于每次执行 setState 都会创建 update 并挂载在 fiber 上。并且每个 setState 的优先级都是一样的
所以即使只执行一次更新流程,还是能将状态更新到最新。
总结
- useState 最底层的原理是使用了链表结构并把对应的值存储在 fiber 节点上,对顺序有要求,所以不可以在条件语句 & 循环语句中使用 hook
- React 17 及之前 setState 在 react 控制范围内是 异步的,不在 react 控制范围内是 同步的。
-
- 以一个全局变量 isBranchUpdate 来判断是否需要合并
- React 18 之后无论是否在 react 的控制范围内都实现了 batchUpdate
-
- 不再以 全局变量来判断是否需要合并了,而是以「优先级」来判断是否需要进行合并