React Hooks是React团队在2018年10月的React Conf上宣布的新特性能让函数组件拥有状态和生命周期等类组件的能力不用再写class代码更简洁更优雅Hooks发布之后,在前端社区引起了巨大的反响大家都觉得这是React的未来能大大简化组件代码提升开发效率。

我作为一个React技术爱好者在Hooks发布alpha版本(16.7.0-alpha)之后,就迫不及待地在一个内部项目中试用了想提前体验Hooks的便利结果没想到遇到了一个严重的故障排查了很久才解决过程惊心动魄今天想复盘一下这次故障的经过原因以及解决方案希望能帮大家在使用React Hooks的时候,少踩坑避免类似的问题。

一、故障发生的经过

先说说故障发生的经过我们的这个内部项目是一个管理后台用React开发之前,一直用类组件的方式写运行很稳定我在Hooks发布alpha版本之后,觉得Hooks很有意思就想在项目里试用一下,因为是内部项目用户不多就算出问题影响也不大,所以我就把一个比较复杂的表单组件从类组件改写成了Hooks的函数组件想体验一下Hooks的写法。

改写的过程很顺利Hooks的写法确实很简洁原来类组件需要写很多代码(constructorstate生命周期方法等等)用Hooks之后,代码量减少了很多逻辑也更清晰了我用useState管理表单的状态用useEffect处理数据加载和副作用用useCallback优化函数引用用useMemo优化计算结果整体改写完之后,本地测试了一下功能都正常就提交代码部署到测试环境了。

一开始测试环境运行也很正常大家用了几天都没发现什么问题我还挺开心的觉得Hooks确实很好用准备在项目里推广更多组件用Hooks改写结果没想到过了几天测试同事反馈了一个奇怪的问题说表单页面有时候会出现数据错乱的情况,比如填写的表单数据突然变成了别人的数据,或者页面刷新之后,数据不对,而且这个问题不是必现的偶尔才出现一次很难复现。

我一开始以为是后端接口的问题,或者缓存的问题查了很久后端接口和缓存都没发现问题后来我自己反复测试终于复现了这个问题发现是在快速切换不同的表单数据的时候,会出现数据错乱的情况,而且问题只在我改写的那个Hooks组件里出现其他类组件的页面都没问题这时候我才意识到可能是Hooks的写法有问题导致了这个故障。

二、排查过程

发现可能是Hooks的问题之后,我就开始排查这个问题排查的过程很曲折,因为问题不是必现的,而且Hooks是新特性资料也不多很多东西都要自己摸索。

第一步:对比类组件和Hooks组件的代码

首先,我把改写前的类组件代码和改写后的Hooks组件代码做了对比看逻辑上有没有什么不一样的地方对比了很久发现整体逻辑是一样的状态管理数据加载表单提交等等逻辑都对应上了没有发现明显的逻辑错误。

但是我注意到一个细节类组件里数据加载是在componentDidMount生命周期方法里做的,而且有一个判断,如果组件已经卸载了就不更新状态而Hooks组件里数据加载是在useEffect里做的我当时图省事没有处理组件卸载的情况也没有处理请求竞态的问题我怀疑可能是这个地方有问题。

第二步:研究useEffect的执行机制

然后我开始仔细研究useEffect的执行机制,因为Hooks是新特性我对它的理解还不够深入我看了React官方文档里关于useEffect的说明也看了一些社区的文章和讨论终于搞清楚了useEffect的执行机制以及可能出问题的地方。

useEffect是在组件渲染完成之后,异步执行的相当于类组件的componentDidMount和componentDidUpdate的结合,而且useEffect可以返回一个清理函数(cleanup function)在组件卸载,或者下一次effect执行之前,调用用来清理副作用,比如取消订阅清除定时器取消请求等等。

我当时写useEffect的时候,没有返回清理函数也没有处理请求竞态的问题这就可能导致一个问题当快速切换表单数据的时候,会触发多次数据加载请求这些请求是异步的返回的顺序可能和发出的顺序不一致,比如先发出请求A再发出请求B但是请求B先返回请求A后返回这时候请求A返回的数据就会覆盖请求B的数据导致页面显示的是旧的数据也就是测试同事反馈的数据错乱的问题!

而且,还有一个问题,如果组件已经卸载了请求才返回这时候调用setState更新状态React会给出警告(Can't perform a React state update on an unmounted component)虽然不会导致功能问题,但是也说明代码写得不规范有潜在的问题。

第三步:验证猜想

搞清楚了可能的原因之后,我就开始验证这个猜想我在useEffect里加了日志打印请求发出和返回的顺序以及更新状态的时机,然后快速切换表单数据复现问题果然日志显示请求返回的顺序和发出的顺序不一致后发出的请求先返回先发出的请求后返回后返回的旧数据覆盖了新数据导致页面显示错乱这就验证了我的猜想问题确实是,因为useEffect里的异步请求竞态导致的。

而且我还发现了另一个问题,因为我用useCallback包裹了一些函数,但是依赖数组写得不对导致函数引用没有正确更新也导致了一些奇怪的问题,比如事件处理函数里拿到的状态是旧的等等这些问题加在一起导致了这次故障。

三、故障的根本原因

排查清楚之后,我总结了这次故障的根本原因主要有以下几个。

原因1:useEffect里的异步请求没有处理竞态

这是最主要的原因useEffect里的异步请求没有处理竞态条件当快速切换数据触发多次请求的时候,请求返回的顺序可能和发出的顺序不一致导致旧数据覆盖新数据页面显示错乱。

这个问题其实在类组件里也可能出现,但是类组件里我们一般会用一个标志位(比如this._isMounted)来判断组件是否还挂载,或者用请求ID来判断返回的数据是不是最新的而我在改写成Hooks的时候,忽略了这个问题没有处理竞态条件,所以导致了故障。

原因2:useEffect没有返回清理函数

第二个原因是useEffect没有返回清理函数没有在组件卸载,或者下一次effect执行之前,取消之前,的请求,或者清理副作用这,不仅可能导致请求竞态还可能导致组件卸载后还在更新状态出现React警告甚至内存泄漏。

useEffect的设计就是鼓励我们在effect里返回清理函数处理副作用的清理我当时,因为对useEffect的理解不够深入忽略了这个重要的部分导致了问题。

原因3:useCallback和useMemo的依赖数组不正确

第三个原因是useCallback和useMemo的依赖数组写得不正确导致函数引用和计算结果没有,正确更新出现闭包陷阱(closure trap)拿到的状态是旧的导致一些奇怪的问题。

Hooks的依赖数组是一个很容易出错的地方,因为JavaScript的闭包特性,如果依赖数组写得不对就会出现闭包陷阱拿到旧的状态和属性导致bug我当时就是,因为对这个问题认识不足依赖数组写得不全导致了问题。

原因4:在alpha版本上试用缺乏经验和资料

第四个原因是我在Hooks还是alpha版本的时候,就在项目中试用了当时Hooks还不稳定API也可能变化,而且社区的资料和最佳实践也不多很多坑都要自己踩自己摸索我对Hooks的理解也不够深入,所以容易出问题这也是这次故障的一个客观原因。

四、解决方案

找到根本原因之后,我就开始解决这个问题主要从以下几个方面入手。

方案1:处理useEffect里的请求竞态

首先,处理useEffect里的请求竞态问题我用了一个标志位的方式在useEffect里定义一个变量标记当前的请求是否有效当effect重新执行,或者组件卸载的时候,把之前,的请求标记为无效请求返回的时候,先判断请求是否有效有效才更新状态无效就忽略这样就能避免旧数据覆盖新数据的问题。

代码大概是这样的:

useEffect(() => {
    let didCancel = false; // 标记当前请求是否有效
    
    async function fetchData() {
        const result = await api.getData(id);
        if (!didCancel) { // 只有请求有效才更新状态
            setData(result);
        }
    }
    
    fetchData();
    
    return () => {
        didCancel = true; // 清理时标记请求无效
    };
}, [id]); // 依赖idid变化时重新执行effect

这样当id变化的时候,会先执行上一次effect的清理函数把didCancel设为true之前,的请求,即使返回了也不会更新状态,然后执行新的effect发起新的请求新的请求返回后更新状态这样就不会出现旧数据覆盖新数据的问题了。

另外也可以用AbortController来取消fetch请求这样更彻底,不仅不更新状态还能取消请求节省网络资源,但是AbortController的兼容性需要注意老浏览器可能不支持需要polyfill我当时用的是标志位的方式简单有效兼容性也好。

方案2:useEffect返回清理函数

然后给所有的useEffect都加上清理函数处理副作用的清理,比如异步请求的取消(或者标记无效)定时器的清除事件监听的取消订阅的取消等等确保组件卸载,或者effect重新执行前能正确清理之前,的副作用避免内存泄漏和不必要的状态更新。

这是使用useEffect的一个重要原则,只要effect里有副作用(异步请求定时器事件监听订阅等等)就应该返回清理函数处理清理不要忽略这个部分,否则很容易出问题。

方案3:正确设置useCallback和useMemo的依赖数组

然后检查所有的useCallback和useMemo的依赖数组确保依赖数组包含了所有在回调函数,或者计算函数里用到的外部变量(状态属性函数等等)避免闭包陷阱拿到旧的值。

这个问题说起来简单,但是实际写代码的时候,很容易遗漏特别是函数里用到的变量比较多的时候,很容易漏写某个依赖导致bug我当时就是漏写了几个依赖导致了问题后来我用了ESLint的react-hooks/exhaustive-deps规则来自动检查依赖数组是否完整这个规则能很好地帮我们发现遗漏的依赖避免闭包陷阱非常有用推荐大家都开启这个ESLint规则。

方案4:封装自定义Hook复用逻辑

解决了当前的问题之后,我还把数据加载的逻辑封装成了一个自定义Hook(useFetch)把请求竞态处理加载状态错误处理等等逻辑都封装在自定义Hook里这样其他组件需要数据加载的时候,直接用这个自定义Hook就可以了不用每个组件都重复写这些逻辑也避免了重复踩坑。

自定义Hook是Hooks的一大优势能把组件逻辑抽取出来复用非常方便我封装的useFetchHook大概是这样的:

function useFetch(url, dependencies = []) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    
    useEffect(() => {
        let didCancel = false;
        
        async function fetchData() {
            setLoading(true);
            setError(null);
            try {
                const result = await fetch(url);
                const json = await result.json();
                if (!didCancel) {
                    setData(json);
                }
            } catch (err) {
                if (!didCancel) {
                    setError(err);
                }
            } finally {
                if (!didCancel) {
                    setLoading(false);
                }
            }
        }
        
        fetchData();
        
        return () => {
            didCancel = true;
        };
    }, dependencies);
    
    return { data, loading, error };
}

这样组件里用这个Hook就很简单了:

const { data, loading, error } = useFetch(`/api/data/${id}`, [id]);

不用每个组件都写一堆请求和竞态处理的逻辑既简洁又可靠这就是自定义Hook的威力。

五、经验和教训

这次故障,虽然过程惊心动魄排查了很久,但是也让我学到了很多对React Hooks的理解也更深入了这里总结一些经验和教训分享给大家。

教训1:新特性不要急着在生产环境用

第一个教训是新特性不要急着在生产环境用特别是还在alpha或者beta阶段的新特性API可能还会变化也可能有未知的bug而且社区的资料和最佳实践也不多容易踩坑我这次就是,因为在alpha版本就试用了Hooks对它的理解不够深入导致了故障,如果等正式版本发布社区最佳实践成熟了再用可能就不会踩这个坑了。

当然不是说不能试用新特性而是要在合适的场景试用,比如内部项目小工具等等影响小的地方,而且要做好充分的测试和回滚准备不要直接在核心业务上用新特性避免出大问题。

教训2:useEffect一定要处理清理和竞态

第二个教训是useEffect一定要处理清理和竞态这是使用useEffect最容易出问题的地方也是最重要的地方,只要effect里有异步操作定时器事件监听订阅等等副作用就一定要返回清理函数处理清理也要处理竞态条件避免旧数据覆盖新数据,或者组件卸载后更新状态。

很多人刚用Hooks的时候,容易忽略这个问题,因为类组件里生命周期方法是分开的(componentDidMountcomponentDidUpdatecomponentWillUnmount)比较容易记住要在componentWillUnmount里清理而useEffect把这些都合在一起了清理函数是可选的容易被忽略,所以一定要养成习惯写useEffect的时候,就想清楚这个effect有没有副作用需不需要清理需不需要处理竞态不要忽略。

教训3:依赖数组要正确用ESLint检查

第三个教训是useCallbackuseMemouseEffect的依赖数组一定要正确包含所有用到的外部变量避免闭包陷阱这个问题说起来简单,但是实际写代码的时候,很容易遗漏,所以一定要用工具来辅助检查ESLint的react-hooks/exhaustive-deps规则非常好用能自动检查依赖数组是否完整帮我们发现遗漏的依赖避免闭包陷阱推荐大家都开启这个规则,并且不要随便禁用它。

另外也要理解闭包陷阱的原理知道为什么依赖数组不全会导致问题这样才能从根本上避免这个问题而不是只靠工具检查。

教训4:封装自定义Hook复用逻辑避免重复踩坑

第四个教训是要善于封装自定义Hook把通用的逻辑抽取出来复用这样,不仅代码更简洁,而且能避免重复踩坑,因为逻辑只写一次测试好之后,其他地方直接用就可以了不用每个地方都重复写重复踩坑自定义Hook是Hooks的一大优势一定要善于利用。

比如数据加载的逻辑表单的逻辑防抖节流的逻辑等等都可以封装成自定义Hook复用非常方便。

教训5:故障复盘很重要能帮我们成长

第五个教训是故障复盘很重要每次出了故障都要认真复盘总结经验教训这样才能避免以后再犯同样的错误也能帮我们成长这次故障,虽然排查了很久过程很辛苦,但是复盘之后,我对React Hooks的理解深入了很多也学到了很多最佳实践这些都是宝贵的经验对以后的开发很有帮助,所以不要害怕出故障出了故障认真复盘总结就能把故障变成成长的机会。

六、写在最后

以上就是我这次React Hooks故障复盘的全部内容包括故障发生的经过排查过程根本原因解决方案以及经验和教训希望能帮大家在使用React Hooks的时候,少踩坑避免类似的问题。

React Hooks确实是一个非常优秀的新特性能大大简化组件代码提升开发效率也能更好地复用逻辑,但是新特性也意味着有新的坑需要我们去学习和适应,只要我们理解了Hooks的原理和最佳实践避开这些坑就能很好地发挥Hooks的优势写出更简洁更优雅的代码。

现在React Hooks已经越来越成熟了社区的资料和最佳实践也越来越多相信大家用起来会比我当时顺利很多,但是还是要注意这些常见的坑特别是useEffect的清理和竞态依赖数组的正确性等等这些是最容易出问题的地方一定要重视。

最后用一句话结束这篇文章:"新特性带来新能力也带来新坑,只要我们认真学习善于总结就能避开坑发挥新特性的优势写出更好的代码。"

愿大家都能用好React Hooks写出简洁优雅稳定的前端代码享受技术进步带来的便利和,乐趣。