Preface
React Hooks 发布已经一年多了,越来越多的工程实践也开始拥抱Hooks。相信对于大多数早已读完文档的同学来说,使用Hooks已经是一件很简单的事,它使得我们可以将许多小的,不必要使用类来写的组件,轻松地使用useState和useEffect来实现。
在大多数情况下,将类组件重构为函数组件不会产生任何问题,但是你有没有遇到这样的问题:
函数组件获取到的是之前的props,而你想要最新的
一些effect没有按照你的预期产生
不知道如何正确在effect中fetch数据
而这些问题,都源于函数组件和类组件的区别。
每个人在刚开始使用Hooks的时候,都一定下意识地在使用Class模型思考,甚至在React官方文档中也这样写道:
但是当我们下意识地使用相同的模型思考并使用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.showMessage, 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
乍一看他们似乎一模一样,但看看下面的例子你就会发现他们的不同。
这个例子来自Dan的博客
分别使用class组件和functional组件的follow按钮,点击follow之后的三秒内,改变父级的user值。
看看有什么不同?
当我们点击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 组件在上面的例子中,是这样的:
尽管在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的时候,会觉得副作用会单独运行,这是错误的。
我们可能都尝试过类似下面的例子:
它有一个错误:
“像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,解释了一些使用类组件思维模型(完全不建议在函数组件中使用这种思维模型)导致的非预期情况。并且,对于一些函数组件中的特殊情况,给出了较好的解决方案。