人人都要理解的-react闭包陷阱

问题

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第一次执行:

  1. useState返回 count,值是0
  2. 创建fun函数,作用域指向当前MyComp函数内的count值是0(闭包)。
  3. useRef创建timeHandle方法,current赋值此次fun函数。
  4. 初始化执行useEffect函数,创建定时器,每秒执行fun方法
  5. fun方法获取作用域内的count,打印0

click点击后,导致count更新。 MyComp第二次执行:

  1. useState返回 count,值是1,指向新的内存地址。
  2. 创建fun函数,作用域指向当前的MyComp函数内的count值是1(闭包)。
  3. useRef获取timeHandle方法,由于react的传递存储timeHandle,timeHandle对象还是上一次执行的对象。
  4. 修改timeHandle.current 为新创建的fun函数。
  5. 定时器回调的时候由于timeHandle对象指向的地址和第一次执行的时候相同,current的引用关系变成新创建的fun函数。
  6. 新创建的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执行:

  1. useState返回 count,值是0
  2. useRef创建countRef对象,current赋值0。
  3. 第一个useEffect函数执行赋值countRef.current的值为0
  4. 第二个useEffect函数执行,创建定时器,每秒打印countRef对象上的current值,0。

click点击后,导致count更新。 MyComp第二次执行:

  1. useState返回 count,值是1,指向新的内存地址。
  2. useRef返回上一次执行的countRef对象,也就是同一个内存地址的对象。
  3. 第一个useEffect函数执行赋值countRef.current的值为1
  4. 由于计时器内的countRef对象和第二次执行useRef返回的countRef对象是同一个,所以其指向的current的值也变成了1,至此计时器打印的值就变成了1。
相关推荐
Myli_ing8 分钟前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风11 分钟前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟20 分钟前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾41 分钟前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧1 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm1 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7011 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王2 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
疯狂的沙粒2 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript