useEffect 浅析

132 阅读10分钟

这篇文章来说一下 Hooks 中的 useEffect 原理以及在 useEffect 的实践

抛出问题

在讲之前先想想我们在使用 useEffect 的时候是否出现过以下几个问题?

  1. 如何用 useEffect 模拟生命周期?
  2. 如何在 useEffect 里请求数据?
  3. 函数可不可以当 useEffect 的依赖?
  4. 为什么使用 useEffect 的时候会出现死循环?
  5. 为什么有时候在 useEffect 中拿到的 state 或 props 是旧的?

常量的 state 和 props

看例子: codesandbox.io/s/old-frog-…

在 Hooks 中, 每次 render 的时候 state 和 props 都会是一个常量, 换句话说就是每次 render 中的状态都有属于自己的 state 和 props

为什么经过定时器后的 alert 还是当时的 count 值, 因为每次点击按钮重新触发 render 的 count 在会变为一个常量, 所以 alert 还是当时的 3

同理,每次 render 的时候 alertCount 函数也会是一个独立的函数, 每个函数都会记住属于它的 count

// 第一次 render 
const App = ()=> {
const count = 0
const alertCount = ()=>{
 setTimeout(()=>{
   alert('当前点击的count值为:' + 0)
 },2000)
}
}
// 第二次 render
const App = ()=> {
const count = 1
const alertCount = ()=>{
 setTimeout(()=>{
   alert('当前点击的count值为:' + 1)
 },2000)
}
}
// 第三次 render
const App = ()=> {
const count = 2
const alertCount = ()=>{
 setTimeout(()=>{
   alert('当前点击的count值为:' + 2)
 },2000)
}
}

为什么这样设计官方也给出了答案: 就刚才的案例给大家解释一下, 比如有按钮, 一个是 +1 一个是 -1, 那么点击+1 在点击一次 -1, 如果不这样设计那么就会alert两次0, 这样 react 可能就认为数据没变, 但其实用户已经进行了操作

小结: 在这个 Demo 示例中想告诉大家的是在每次 render 的渲染中 , props 和 state 是保持不变的, 包括事件处理函数也是独立的, 他们都属于一次渲染, 也可以理解为一次性筷子, 大家在用完一次性筷子就会扔掉, 这个也是同一个道理, 在一次渲染中只用一次, 下次渲染就会有新的一次性筷子

每次渲染都有独立的 useEffect

与事件处理函数相同, 每一次 render 都会有自己的 useEffect, 每个 useEffect 都会得到属于那次渲染的 count 常量, 然后在进行回调函数中的处理, React 会记住提供的 effect 函数, 并且会在每次浏览器绘制屏幕后调用

看例子: codesandbox.io/s/brave-tdd…

点击按钮后会一步一步的显示那次render时候的值, 每次渲染的时候useEffect 都会拿到属于自己的 count 值, 而在 class 组件中与之相反, this.setState 总会拿到最新的值

看例子: codesandbox.io/s/frosty-ra…

这是因为 class 组件中用的是 this.state.xxx 来访问 state, 他们一直挂载在一个引用上面, 所以每次在可以拿到最新的值, 而 hooks 上面也说了, 每次都会拿到那次 render 的 state 值, 所以每次函数运用闭包就访问到旧值, 可以近似理解为 for 循环下 var 和 let 的区别

这里推荐一篇文章: React Hook useState 与 this.setState 细节使用和差异

如何在 Hooks 里访问到最新值

使用 ref 可以帮助在 Hooks 里完成 class 的效果

看例子: codesandbox.io/s/solitary-…

ref 作为整个组件内的一个不变引用, 在每次更新后就会把最新的 count 传入 ref.current, 达到了模拟 class 的效果

非生命周期

我们理解 useEffect 总是把他和 class 的一些生命周期比较, 但其实这样的想法是不对的, 我们思考的正确方式应该是基于状态修改而非生命周期, 当 state 和 props 改变时 DOM 就会改变, 生命周期的 mount 和 update 和 unmount 的服务对象就是需要改变的 state 和 props, 其实最终目的就是为了在状态修改时做出一些操作, 这是一种心智模式, 我们在学习 Hooks 的时候不要被以前 React 的组件知识所拘束, 也就是官方提倡的学会忘记以前的知识

所以我的观点是: 正是因为这种心智模式才让 useEffect 可以模拟一些生命周期并把这些生命周期杂糅到 useEffect 中, Hooks 关注数据改变时干什么, 而生命周期关注某个时候对数据进行什么操作, 两个心智模式不一样, 所以设计API的方法也不一样

清理Effect

我们通常在生命周期 componentWillUnmount 会进行一些清理工作以避免内存泄漏, 在 useEffect 中也需要清理工作, 比如说下面代码:

const [count, setCount] = useState(0);
  useEffect(()=>{
 const id = setInterval(()=>{
        setCount(count + 1)
    },1000)
 return ()=>{
        cosole.log(count)
        clearInterval(id)
    }
 })
return <div>{count}</div>;

在这段代码中按照正常的思维应该是下面的逻辑:

  1. React 清除了 count 为 0 时的计时器
  2. React 渲染 count 为 1 的 UI
  3. React 运行 count 为 1 的计时器

但其实Effect的清除顺序不是那样, 而是这样

  1. React 渲染 count 为 1 的 UI

  2. 浏览器把 React 渲染出的 UI 挂载到真实 DOM 上

  3. React 清除 count 为 0 的计时器

  4. React 运行 count 为 1 的计时器

这时候会有一个疑问, 那如果是这样的清除顺序, 在清除部分 count 变为 1 的情况下还能访问到旧的 count 为 0?

这个答案其实在上面已经讲过, 因为每次 render 都有属于那次渲染的 state 或 props, 所以 Effect 在清除时只能读取到属于他那次的 state 或 props

我听说热力学有三大定律, 能量守恒, 温度不能自发由低到高传递, 绝对零度达不到, 也就是说我们的宇宙最终会变成死寂. 但 Effects 在 count 为 0 的清除函数只能看到 count 为 0, 就算宇宙毁灭也只能看到这个状态

所以如果你在想要访问旧的 state 或 props 可以在 Effect 清除时候访问

看例子: codesandbox.io/s/condescen…

这个例子还有一个问题, 我们留在下面解释

为什么要比对 Effects

其实如何比对和 React DOM Diff 道理是一样的, 有必要改变的就去更新, 没必要改变的就避免使用 Effects 就好了

const Hello = () => {
 const [name,setName] = useState('meng')
 const [age,setAge] = useState(20)
    useEffect(()=>{
 console.log(`我名字叫${name}`)
    })
 return (
        <div>
             <div>我今年{age}岁</div>
             <button onClick={()=>setAge(age+1)}>show age</button>
        </div>
    )
}

当我点击按钮, 年龄会上升, 但是名字也被打印了好几次, 但这种场景下 name 并没有改变, 那么这时候就可以给第二个参数加一个数组参数 name

useEffect(()=>{
 console.log(`我名字叫${name}`)
},[name])

这样只有在 name 变化的情况下才会调用 useEffect

关于Effect的依赖项

关于依赖项的原则: 如果设置了依赖项, Effect 中用到的所有组件内的值都要包含在依赖中, 包括 state, props, 函数等等

如果没有依赖项, 那么第二个参数为一个空数组的话, 那么这个 useEffect 就只会执行一次, 所以可以模拟 componentDidMount 这个生命周期

错误的依赖

看例子: codesandbox.io/s/interesti…

这个例子的想法是在 useEffect 初次调用时注册一个计时器, 本来按心中所期待的效果这个计时器注册后 count 就会一直累加下去, 然后例子上只是显示了 1, 根据上面分析的每次 render 都会有一个独立的 count, 那在第一次 useEffect 里内容为:

const [count, setCount] = useState(0);
useEffect(() => {
 const id = setInterval(() => {
 // count 为 0 
 // setCount(0+1)
    setCount(count + 1);
  }, 1000);
 return () => clearInterval(id);
},[]);
return <div>{count}</div>;

因为只调用一次, 那么这时候的 count 一直是 0, 也就是说 setCount 一直为 0 + 1, 所以我们一直看到的是 1

为什么? 因为我们的想法是重新触发 Effect 的时候才需要设置依赖, 而定时器触发一次就够, 但我们忽略了 Effect 其实已经依赖了 count

正确的依赖

如何修改上面的 bug, 有两种方法

  • 法一: 在依赖项加入 count
const [count, setCount] = useState(0);
useEffect(() => {
 const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
 return () => clearInterval(id);
},[count]);
return <div>{count}</div>;

法一的虽然已经依赖了 count, 使结果符合我们的预期, 但在每次调用 useEffect 的时候会注册一次计时器, 然后注销计时器, 这样显然不是我们想要的

  • 法二: 修改 Effect 内部代码, 不想添加依赖项, 那就只能把 Effect 里的依赖项移除掉
const [count, setCount] = useState(0);
useEffect(() => {
 const id = setInterval(() => {
 setCount(c=>c+1);
  }, 1000);
 return () => clearInterval(id);
},[]);
return <div>{count}</div>;

我们使用 setCount 的函数形式, 当然现在结论下了, 这种形式就是最佳的解决方法

现在我们来想一下是怎么转换为函数形式的, 我们为什么要用 count, 可以看到我们 setCount 中调用了 count, 这个场景中我们其实并不需要在 Effect 中使用 count, 细想我们只是想把 count 改变后的状态让 React 知道, 而 React 本身已经知道 count 的初始值, 所以我们只需要告知 React 的是 count 是以递增状态改变的即可

回答问题

  1. 如何用 useEffect 模拟生命周期?
useEffect(()=>{},[]) // 模拟 componentDidMount
useEffect(()=>{},[xxx]) // 模拟 componentDidUpdate
  1. 如何在 useEffect 里请求数据?

如果数据请求需要依赖 state 或 props, 最好把数据请求代码写道 useEffect 里(最佳方案)

如果数据请求只是一个单纯的请求, 那么就可以写到 useEffect 外边

如果万不得已想把含有依赖的数据请求写在 useEffect 外边, 那么就用 useCallback 把数据请求函数做一个缓存, 当里面数据依赖变化时才变化

这是一篇讲如何正确的在 useEffect 里请求数据的文章: How to fetch data with React Hooks?

  1. 函数可不可以当 useEffect 的依赖?

可以当, 但是得在函数外包一层 useCallback, 这样函数就被缓存, 只有内部依赖改变时才会变化函数

官方 FAQ : 在依赖列表中省略函数是否安全?

  1. 为什么使用 useEffect 的时候会出现死循环?

如果是无限循环请求, 则代表你依赖的数据一直在变化, 比如说请求数据时一直设置引用类变量或者利用函数作为依赖

2,3,4解答看例子: codesandbox.io/s/amazing-s…

  1. 为什么有时候在 useEffect 中拿到的 state 或 props 是旧的?

因为每次 render 在属于他特定的那次渲染, 这时 state 和 props 都是常量, 所以利用函数闭包的形式会拿到那次渲染的值

官方 FAQ : 为什么我会在我的函数中看到陈旧的 props 和 state ?

其他问题:

  1. 我应该使用 Hook,class,还是两者混用?

写新需求时使用 Hooks, 原有的 class 可以不变除非打算重构代码

  1. Hook 能否覆盖 class 的所有使用场景?

目前为止不能, 不过官方正在努力完善, 设定的目标是尽早覆盖 class 的所有使用场景

  1. Hook 会替代 render props 和高阶组件吗?

不会替代, 但大部分场景用 Hooks 就足够 官方FAQ

  1. Hook 和 Class 的区别

书写代码量的多少, 设计思想上不同

useEffect 更倾向于同步状态, 在回调中拿到的还是原来的 state 和 props lifeCycle 更倾向于在整个数据流中的某些时机做一些事情

最后推荐一个自定义 Hooks 的网站, 大家可以看看相应源码, 对我们在项目中自定义 Hooks 提供思路

Umi Hooks: github.com/alibaba/hoo…

本文是 React 核心创作者 Dan 的文章的个人总结版, 如有兴趣请看原文(比较长)

useEffect 完整指南