深入理解函数组件与useEffect

4,262 阅读8分钟

Preface

React Hooks 发布已经一年多了,越来越多的工程实践也开始拥抱Hooks。相信对于大多数早已读完文档的同学来说,使用Hooks已经是一件很简单的事,它使得我们可以将许多小的,不必要使用类来写的组件,轻松地使用useState和useEffect来实现。

在大多数情况下,将类组件重构为函数组件不会产生任何问题,但是你有没有遇到这样的问题:

  • 函数组件获取到的是之前的props,而你想要最新的

  • 一些effect没有按照你的预期产生

  • 不知道如何正确在effect中fetch数据

而这些问题,都源于函数组件和类组件的区别。

每个人在刚开始使用Hooks的时候,都一定下意识地在使用Class模型思考,甚至在React官方文档中也这样写道:

image
image

但是当我们下意识地使用相同的模型思考并使用Hooks编程的时候,总是会出现一些不符合我们预期的情况。

这篇文章会通过函数组件与类组件的区别入手,来理解Hooks中最有趣useEffect,来回答上面的几个问题。

本文参考:

  • Dan Abramov - A complete guide to useEffect - https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/
  • React Hooks Docs - https://reactjs.org/docs/hooks-intro.html

functional与class组件的区别

状态捕获

首先这里有两个概念:

  • props是immutable的
  • class的this是mutable的

这能让我们很好地理解下面的一些差异。

看下面一个组件:

funcional写法

function ProfilePage(props) {

const showMessage = () => {

alert('Followed ' + props.user);

};

const handleClick = () => {

setTimeout(showMessage, 3000);

};

return (

<button onClick={handleClick}>Follow</button>

);

}

Class写法

class ProfilePage extends React.Component {

showMessage = () => {

alert('Followed ' + this.props.user);

};

handleClick = () => {

setTimeout(this.showMessage3000);

};

render() {

return <button onClick={this.handleClick}>Follow</button>;

}

}

乍一看他们似乎一模一样,但看看下面的例子你就会发现他们的不同。

这个例子来自Dan的博客

分别使用class组件和functional组件的follow按钮,点击follow之后的三秒内,改变父级的user值。

看看有什么不同?

difference
difference

当我们点击Follow的时候,选择的用户是Dan,然后快速换成Sophie,最后弹出的是Sophie。

这是由于在user变化后,this被mutable了,理所当然地:this.props.user 取到的就是最新的Sophie。

如果从this中取,那props就永远会是最新的。

如何避免这种情况呢?比如这个例子中来说,我们肯定是希望点击fellow的时候,能锁定user为Dan。

答案是:闭包

class ProfilePage extends React.Component {

render() {

const { props } = this

const showMessage = () => {

alert('Followed ' + props.user);

};

const handleClick = () => {

setTimeout(showMessage, 3000);

};

return <button onClick={handleClick}>Follow</button>;

}

}

这样的话,我们就能拿到每次的props,并且相应的处理函数也只会使用这一次渲染的props。

这样将函数都定义到render中的写法简直太蛋疼了,完全失去了类的优势。

但是在functional 组件中就不一样了。

function ProfilePage(props) {

const showMessage = () => {

alert('Followed ' + props.user);

};

const handleClick = () => {

setTimeout(showMessage, 3000);

};

return (

<button onClick={handleClick}>Follow</button>

);

}

props作为函数的参数传进来,而且是immutable的,所以在这次渲染中,props是不会变的。所以使用functional 组件在上面的例子中,是这样的:

component
component

尽管在follow点击之后,user由Sophie更新为了Sunil,但仍然弹出的是Sophie。

函数式组件捕获了每次渲染所用的值(Capture Value)

再加深一下理解,下面这两个组件,是完全相等的:

// 组件1

function Example(props) {

useEffect(() => {

setTimeout(() => {

console.log(props.counter);

}, 1000);

});

// ...

}

// 组件二

function Example(props) {

const counter = props.counter;

useEffect(() => {

setTimeout(() => {

console.log(counter);

}, 1000);

});

// ...

}

用同步取代生命周期

要将函数式组件的行为理解为一个函数,每当状态更新时,就会运行这个函数对页面中的状态进行同步(effect只是函数运行过程中的一部分,它并不独立);而类组件,每当状态更新时,会依次调用生命周期函数进行响应。

获取上一次渲染的props

前面已经说了,function组件每次都会捕获当次渲染的props与state。而class组件有getDerivedStateFromProps可以获取上一次的state,function 组件该怎么办呢?

使用useRef,封装一个usePrev

function App() {

const [value, setValue] = useState(2)

const prevValue = usePrev(count)

return (

<p>Last: {prevValue}, now: {value}</p>

)

}

function usePrev(val) {

const ref = useRef()

useEffect(() => {

ref.current = val

})

return ref.current

}

这里使用一个ref,在副作用中保存了当次渲染时的prop。

useEffect

useEffect是Hooks里面最有趣的,尽管我们在讨论effect的时候总是会不由自主地用生命周期来解释,但它根本不是生命周期,我们更喜欢称它为“副作用(Side Effect)”。

用法

useEffect可以让你在当前渲染结束后执行一些副作用

function App() {

const [count, setCount] = useState(0)

useEffect(() => {

console.log('clicked!')

})

return <div>

{count}

<Button onClick={() => setCount(count+1)}>Click!</Button>

</div>

}

正如React官方Docs中所说的,上面代码中的副作用会看起来像类组件中的componentDidMount和componentDidUpdate,也就是在每一次组件挂载与更新的时候都会运行这段副作用。

如果只想让它在第一次挂载的时候执行副作用,给useEffect的第二个参数传空数组即可(至于第二个参数到底是什么东西,后面会提到)。

useEffect(() => {

// side effect

}, [])

第二个参数

要理解第二个参数是做什么用的,要从React的基本模型说起:对于下面两个组件

{/* component 1 */}

<div className="foo">Bill</div>

{/* component 2 */}

<div className="bar">Gates</div>

在React看来就是两个对象

const component1 = { className: 'foo', children: 'Bill' }

const component2 = { className: 'bar', children: 'Gates' }

当组件的状态发生变化时,React会使用之前的对象和现在的对象比较来确定是否更新。

如果我们也要求副作用也能在它依赖的状态发生变化的时候才运行,那么我们就需要比较不同的副作用。

所以Effect需要第二个参数:依赖

当我们注册这样一个Effect

const [count, setCount] = useState(0)

const [value, setValue] = useState(2)

useEffect(() => {

document.title = `You clicked ${count} times`
}, [count])

的时候,effect就可以根据它的依赖count的值是否变化来进行对比,进而确定是否产生该副作用。所以在这个例子中,value的改变不会触发该副作用,只有count的改变会触发该副作用。

理解了第二个参数是为了diff,那么它的用法就很简单了:声明你的副作用所依赖的变量。

但千万不要将第二个参数理解为:当依赖的变量发生变化的时候,就会产生副作用。

还记得前面的一句话吗:当前渲染结束后执行一些副作用

也就是说,产生副作用有一个大前提:页面render/rerender ,其次才是diff依赖。

很多人用class的思维模型去使用hooks的时候,会觉得副作用会单独运行,这是错误的。

我们可能都尝试过类似下面的例子:

example
example

它有一个错误:

error
error

“像window.a这样外部的变量不是有效的依赖,因为他们的mutate不会引起组件的rerender”,其潜台词就是“只有render/rerender才可能会产生副作用”。

所以我们也就理解为啥称它为副作用了,因为副作用总是伴(render/rerender)生的

使用函数作为第二个参数

当你在effect中进行一些例如fetchData的操作时,可能会这样写:

const fetchData = async () => {

fetch('...', {

state1,

state2,

}).then(() => {

// setState

})

}

useEffect(() => {

fetchData()

}, [])

这经常让我们忘记了给effect的依赖添加state1与state2,有时候我们会使用eslint来约束,将fetchData写在effect里面。但其实还有更好的方法:

将函数直接作为effect的依赖。

你可能想问:你前面不是说了,只有导致rerender的变量才能作为依赖项,函数怎么能触发rerender呢?

Right,所以可以把这个fetchData包装一下:

const fetchData = useCallback(async () => {

fetch('...', {

state1,

state2,

}).then(() => {

// setState

})

}, [state1, state2])

useEffect(() => {

fetchData()

}, [fetchData])

总结

本文先是比较了函数组件与类组件的不同,主要是:

  • 类组件如果从this取状态,将会总取到最新的;但函数组件总是会捕获当次渲染的【所有】
  • 函数组件的rerender,可以看作是执行函数来进行一次同步;而类组件是使用各个周期函数响应状态修改

之后,我们主要围绕useEffect,解释了一些使用类组件思维模型(完全不建议在函数组件中使用这种思维模型)导致的非预期情况。并且,对于一些函数组件中的特殊情况,给出了较好的解决方案。