问题
javascript
function MyComp = () => {
const [count, setCount] = useState(0)
useEffect(() => {
setInterval(() => {
console.log(count, '----count-setInterval')
}, 1000)
}, [])
console.log(count, '----count')
return <div onClick={() => setCount((pre) => pre++)}>{count}</div>
}
初始化打印:
javascript
0 '----count'
0 '----count-setInterval'
当我们点击一次div导致 count
更新后打印:
javascript
1 '----count'
0 '----count-setInterval'
为什么会不一样? 问题聚焦:
- useState的渲染
- 初始化useEffect作用域
- 闭包问题
useState的渲染
我们在使用useState的时候都是用const
承接,也就是一个不可修改的常量。
javascript
function MyComp = () => {
const [count, setCount] = useState(0)
return <div>{count}</div>
}
每一次的执行 MyComp()
方法内部都是通过 useState
返回新的值来做到最终return值的更新。 useState
内部就类似一个阉割版的reducer
,通过链表将所有的hook串联一起。 每一次setState
会将此action添加到对应链表节点的queue
上并记录当前最新节点。 函数组件更新重新执行,再次执行useState,遍历对应节点的queue
,计算出最新的state
值,并存放在 memoizedState
上 ,从而让每一次执行都会返回更新的 count
值。 也就是说每一次执行生成的state值都是新计算生成的,和之前的变量不共用内存地址。
作用域问题
首先我们举例简单的例子来了解函数作用域:
javascript
const count = 0
const timeHandle = () => {
console.log(count, 'count--')
}
函数作用域链使得函数可以访问其外部的变量,当timeHandle
执行时,其会在函数内部查找count
,如果没有,会根据作用域链向上查找。 当在外部找到了count
变量,就可以执行打印出他的值0。 在来看下一个例子:
javascript
const count = 0
const timeHandle = {
current: () => {
console.log(count, 'count--')
}
}
function effect() {
const count = 1
timeHandle.current()
}
effect()
上面的代码打印结果是:
0 count--
为什么是0 而不是1 ? 因为函数的作用域是在函数创建的时候生成的,和后期函数的执行环境无关。 也就是如果我们把一个函数保存下来以后,覆值到其他地方调用,其作用域还是创建这个函数的时候的环境,不会变。 闭包例子:
javascript
function myCom1() {
let count = 0
return () => {
console.log(count, 'count=>')
count++
}
}
const count = 10
const countFun1 = myCom1()
countFun1() // 0 count=>
countFun1() // 1 count=>
const countFun2 = myCom1()
countFun2() // 0 count=>
闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。 也就是在函数myCom1
执行后,其内部就产生了一个闭合的作用域,只能其返回函数可以访问,也就可以打印出count
的值0,再次执行返回1。 但是如果重新执行myCom1
后,又会产生一个新的闭包,和之前 countFun1
相互隔绝,打印值也重新从0开始。
初始化useEffect作用域
我们可以把组件的更新导致函数组件的每一次重新执行,看作彼此是隔绝的,因为其每一次执行内部都是产生一个闭包,所有状态,方法都是只能访问此次执行的作用域内的,这样相互之间的变量值也都是隔绝的。 而将他们联系起来做到数据和状态的持久化的是react的hook。
useEffect
第一个参数传入的是一个方法,我们暂且叫它fun
,每一次组建更新的时候都会重新创建fun
并传入useEffect
,同时也就创建了 fun
的作用域为当前的 MyComp
方法执行环境。 比如MyComp
第一次执行时fun
可以访问到闭包内变量类似:
javascript
{
count: 0, // useState 返回
setCount: ...
}
第二次执行就是:
javascript
{
count: 1, // useState 返回
setCount: ...
}
最终问题定位:
我们的问题可以用下面的代码来模拟:
javascript
// 模拟持久化的state
let currData = null
const useState = (data) => {
if(!currData) {
currData = {
current: data
}
}
return currData.current
}
// 模拟只执行一次的 useEffect
let isInit = false
const useEffect = (fun) => {
if(!isInit) {
console.log('count')
fun()
}
isInit = true
}
function MyComp() {
const count = useState(0)
useEffect(() => {
setInterval(() => {
console.log(count, '----count-setInterval')
}, 1000)
})
}
MyComp() // 初始化
currData.current = 2 // 点击导致state更新
MyComp() // 重新执行函数
第一次执行的时候,useEffect
被执行,创建定时器,此时形成了一个闭包,定时器只访问第一次MyComp
执行环境内的count
变量0。 那如果state
值被改变,导致组建更新也就是MyComp()
方法被重新执行。 由于useState
返回的count
是一个常量导致其每一次指向的内存地址都不同。 上文的例子useEffect
的依赖是一个空数组,导致其只会执行一次。也就是初始化执行一次setInterval
方法。 那传入setInterval
的方法永远都是第一次useEffect
执行的时候创建的函数,也就是其作用域内的count
值指向的内存地址永远是MyComp()
第一次执闭包内部的值,0,这就是react的**闭包陷阱**
。
想要 useEffect
重新执行,创建新的计时器,并传入新的方法,同样获取新的count值也很简单比如:
javascript
function MyComp = () => {
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
console.log(count, '----count-setInterval')
}, 1000)
return () => clearInterval(timer)
}, [count]) // 加上依赖项
return <div onClick={() => setCount((pre) => pre++)}>{count}</div>
}
每一次count
值的更新,都会触发useEffect的重新执行,会重新生成新的计时器,也就会生成新的作用域。 但是这样很麻烦,要每一次都清除计时器,在重新创建这不是我们想要的,有没有最简单的什么办法?
解决思路
解决思路:
- 方法一:让方法执行的作用域是当前的function组建
- 方法二:让更新组建时闭包内引用的
count
指向的内存地址和useState返回值一直。
了解useRef
useRef方法返回值就是一个对象,也就是一个包裹了current的对象:
javascript
const refReturn = useRef(初始值)
// refReturn
{
current: 初始值
}
在useRef初始化创建时其会被react内部方法一路传递引用,最终保存在组件函数的内部作用域之上的上层作用域中(fiber 节点的 hook
对象上memoizedState)。 并且每一次function component执行的时候,useRef都会返回同一个内存指向地址的对象,也就是 oldRef === newRef
。 就如同下面代码:
javascript
let hook = null
const useRef = (data) => {
if (!hook) {
hook = {
memoizedState: {
current: data
}
}
}
return {
current: hook.memoizedState
}
}
当然在实际的函数执行时,所有的hook(useRef、useState、useEffect...)都是通过他们在函数组件内的执行顺序,用链表来连接,这边只是简单的模拟。
第一种思路
每一次执行MyComp
创建新的方法,新的作用域,用useRef
的current来保存新的函数,通过ref和来保证setInterval
内的对象为同一地址,来更新计时器执行的方法**。**
javascript
function MyComp = () => {
const [count, setCount] = useState(0)
const fun = () => {
console.log(count, '----count-setInterval')
}
const timeHandle = React.useRef(fun)
timeHandle.current = fun
useEffect(() => {
setInterval(() => timeHandle.current(), 1000)
}, [])
return <div>{count}</div>
}
步骤讲解: MyComp
第一次执行:
- useState返回 count,值是0
- 创建fun函数,作用域指向当前MyComp函数内的count值是0(闭包)。
- useRef创建timeHandle方法,current赋值此次fun函数。
- 初始化执行useEffect函数,创建定时器,每秒执行fun方法
- fun方法获取作用域内的count,打印0
click点击后,导致count更新。 MyComp
第二次执行:
- useState返回 count,值是1,指向新的内存地址。
- 创建fun函数,作用域指向当前的MyComp函数内的count值是1(闭包)。
- useRef获取timeHandle方法,由于react的传递存储timeHandle,timeHandle对象还是上一次执行的对象。
- 修改timeHandle.current 为新创建的fun函数。
- 定时器回调的时候由于timeHandle对象指向的地址和第一次执行的时候相同,current的引用关系变成新创建的fun函数。
- 新创建的fun函数执行,获取新闭包内的count,也就是1。
为什么不能直接这样呢:
javascript
useEffect(() => {
const timer = setInterval(timeHandle.current, 1000)
}, [])
这主要是一个内存引用关系的问题,如下图:
react useRef
缓存的是timeHandle
对象,也就是可以保证两次执行MyComp内的timeHandle
内存指向同一个地址。 MyComp第一次执行的时候current指向的是fun1
MyComp第二次执行的时候current指向的是fun2
上面那种错误写法主要是因为传入setInterval函数的第一个参数其实是fun1,其和current没有绑定关系,也就导致其和useRef返回值没有绑定关系,当current指向fun2函数的时候,计时器执行的的方法还是fun1。
第二种思路
用useRef来保存count值,保证每一次setInterval内方法执行调用的都是同一个内存地址变量引用的count值。
javascript
function MyComp = () => {
const [count, setCount] = useState(0)
const countRef = useRef(count)
useEffect(() => {
countRef.current = count
}, [count])
useEffect(() => {
const timer = setInterval(() => {
console.log(countRef.current, '----count-setInterval')
}, 1000)
}, []) // 加上依赖项
use
return <div onClick={() => setCount((pre) => pre++)}>{count}</div>
}
步骤讲解: 第一次MyComp执行:
- useState返回 count,值是0
- useRef创建countRef对象,current赋值0。
- 第一个useEffect函数执行赋值countRef.current的值为0
- 第二个useEffect函数执行,创建定时器,每秒打印countRef对象上的current值,0。
click点击后,导致count更新。 MyComp第二次执行:
- useState返回 count,值是1,指向新的内存地址。
- useRef返回上一次执行的countRef对象,也就是同一个内存地址的对象。
- 第一个useEffect函数执行赋值countRef.current的值为1
- 由于计时器内的countRef对象和第二次执行useRef返回的countRef对象是同一个,所以其指向的current的值也变成了1,至此计时器打印的值就变成了1。