第十五章 数据获取 与 竞态条件 【下】

处理 竞态条件:强制重新挂载

从本质上讲,第一个解决方案甚至算不上真正的解决方案,它更像是一种解释,说明了为什么这些竞态条件实际上并不常发生,以及为什么我们在常规的页面导航过程中通常不会遇到它们。

先忘记之前的示例代码,我们现在看到了这样的代码:

js 复制代码
const App = () => {
    const [page, setPage] = useState('issue');
    
    return (
        <>
            {page === 'issue' && <Issue />}
            {page === 'about' && <About />}
        </>
    )
}

这个App组件没有传递属性给子组件,而IssueAbout组件有其获取数据的、独特的URL。而在useEffect里获取数据的逻辑,和之前是一样的:

js 复制代码
const About = () => {
    const [about, setAbout] = useState();
    
    useEffect(() => {
        fetch("/some-url-for-about-page")
            .then((r) => r.json())
            .then((r) => setAbout(r));
    }, []);
    ...
}

这个应用没有出现 竞态条件 问题。无论你切换导航多快,这个导航功能都是正常运行的。

代码示例: advanced-react.com/examples/15...

为什么?

答案在{page === 'issue' && <Issue />}IssueAboutpage值变动时,并不是被重新渲染,而是被重新挂载了。当pageissue变为aboutIssue 组件被卸载了,而About组件挂载了。

而从数据获取的角度,发生了:

  • App组件首先被渲染,加载了Issue,之后开启了数据获取。
  • 之后我点击了About组件,此时Issuefetch还在进行中,而App组件卸载了About组件,开始挂载Issue组件,此时,Issuefetch被终止了。

当React卸载一个组件时,意味着这个组件消失了。当一个组件消失时,人们无法访问它,关于这个组件的内的事务也消失了,包括状态。而这个代码与之前的代码是这样的,<Page id={page} />。这个Page不会被卸载。

而使用{page === 'about' && <About />}时,当我被导航到About页面时,Issue组件的fetch 请求结束了,要调用setIssue来改变状态了。但是这个组件已经消失了。所以从React的视角看,它不存在了。所以这个promise请求也没了。

顺便问一下,你还记得那个吓人的警告 "无法对已卸载的组件执行 React 状态更新" 吗?以前在这类情况中就会出现这个警告:当类似数据获取这样的异步操作在组件已经被卸载之后才完成。说 "以前" 是因为这个警告现在也没了。它是最近才被移除的。

w不管怎样,从理论上讲,这种行为可以用来解决原始应用程序中的竞态条件问题:我们所需要做的就是在导航时强制页面(Page)组件重新挂载。为此,我们可以使用 "key" 属性:

js 复制代码
<Page id={page} key={page} />

我们从第六章的diff原理可知,当一个元素的"key"发生变化时,React会卸载旧的元素,再挂载新的元素,即使这两个元素类型相同。

然而,这个方法并不是解决 竞态条件 问题的 最优解。使用这个方法有太多事情要顾虑了:应用的性能被影响了,会有很多意外的bugs,等等。

处理 竞态条件:跳过错误的结果

有一个更轻量级的解决方法(不用再卸载组件了):校验.then回调里的结果与当前"生效"的id是否一致。

如果结果返回的是用于生成该网址的那个 ID,我们就可以直接对它们进行比较。而如果它们不匹配,就忽略这些结果。这里的诀窍在于绕开 React 的生命周期以及函数中局部作用域的数据,并且在 useEffect 的所有迭代中,即便那些 "过时" 的迭代,也能获取到 "最新的" ID。这又是一个关于 Refs 的应用场景,我们在第 9 章 "Refs:从存储数据到命令式 API" 中讨论过 Refs。

js 复制代码
const Page = ({ id }) => {
    // create ref
    const ref = useRef(id);

    useEffect(() => {
        // update ref value with the latest id
        ref.current = id;
        
        fetch(`/some-data-url/${id}`)
            .then((r) => r.json())
            .then((r) => {
                // compare the latest id with the result
                // only update state if the result actually belongs to that id
        if (ref.current === r.id) {
                setData(r);
            }
        });
    }, [id]);
};

代码示例: advanced-react.com/examples/15...

或者这样:

js 复制代码
const Page = ({ id }) => {
    // create ref
    const ref = useRef(id);
    
    useEffect(() => {
        // update ref value with the latest url
        ref.current = url;
        
        fetch(`/some-data-url/${id}`).then((result) => {
            // compare the latest url with the result's url
            // only update state if the result actually belongs to that url

            if (result.url === ref.current) {
                result.json().then((r) => {
                    setData(r);
                });
            }
        });
    }, [url]);
};

代码示例: advanced-react.com/examples/15...

处理 竞态条件:跳过所有以前的结果

不喜欢之前的解法,或者觉得使用ref很奇怪?没问题,还有一个方法。useEffect内部有一个"清除"函数,它可以用来清除订阅。

这个"清除"函数的语法是这样的:

js 复制代码
// normal useEffect
useEffect(() => {
    // "cleanup" function - function that is returned in useEffect
    return () => {
    // clean something up here
    };
// dependency - useEffect will be triggered every time url has changed
}, [url]);

这个清楚函数会在组件被卸载时运行,或者在每次依赖变化导致的重新渲染之前。所以,在重新渲染期间,是按照这个顺序进行的:

  • url变化
  • "清除函数"被触发
  • useEfffect被触发了

JavaScript的函数和闭包,我们可以这样:

js 复制代码
useEffect(() => {
    // local variable for useEffect's run
    let isActive = true;
    
    // do fetch here
    
    return () => {
        // local variable from above
        isActive = false;
    };
}, [url]);

我们引入了一个本地的布尔值isActive,在useEffct运行时,令isActive为true,在"清除"函数执行时,令isActive为false。useEffct里的函数会在每次重新渲染时重新创建,所以对于最新执行的useEffctisActive永远是true。但是,之前运行的"清除"函数,依然有权限访问之前执行的函数的上下文,它会把isActive设置为false。这是JavaScript闭包的工作原理。

虽然fetchPromise是异步运行的,它依然有一个闭包,也有权限去访问启动它的useEffect里的函数。所以我们在检查.then回调里的isActive时,只有最新的运行并没有启动"清除"函数,也只有最新的运行里isActive为true。所以我们只要通过isActive为真来判定是否在最新的闭包里。如果在最新的闭包,我们就调用setData函数。如果不是最新的,就什么都不做,而这些数据也会消失在虚空中。

js 复制代码
useEffect(() => {
    // set this closure to "active"
    let isActive = true;
    
    fetch(`/some-data-url/${id}`)
        .then((r) => r.json())
        .then((r) => {
            // if the closure is active - update state
            if (isActive) {
            setData(r);
        }
    });
    
    return () => {
        // set this closure to not active before next re-render
        isActive = false;
    };
}, [id]);

代码示例: advanced-react.com/examples/15...

处理 竞态条件:取消之前的所有请求

如果你也觉得处理JavaScript的闭包问题很烧脑,那我们还有另一个解法。

除了调用"清除"函数或者比较是否是正确的结果外,我们还可以清除所有之前的数据请求。如果之前的请求没有完成,那么.then回调函数里的setData方法将永远不会生效,这个问题也就不复存在了。我们可以使用AbortController来实现这个效果。

useEffect里创建AbortController和使用它,也是非常简单的。

js 复制代码
useEffect(() => {
    // create controller here
    const controller = new AbortController();
    
    // pass controller as signal to fetch
    fetch(url, { signal: controller.signal })
        .then((r) => r.json())
        .then((r) => {
            setData(r);
    });
    return () => {
        // abort the request here
        controller.abort();
    };
}, [url]);

所有,每次重新渲染时,原来还在进行中的请求会被停止,只有当前渲染所触发的useEffct钩子会被执行,并调用setData

中止一个正在进行的请求会导致 Promise 被拒绝,所以你需要捕获错误,以消除控制台中那些吓人的警告信息。但无论是否使用AbortController,正确处理 Promise 的拒绝情况都是个好主意,所以这是你在任何策略中都应该做的事情。因AbortController而导致的拒绝会产生一种特定类型的错误,这使得将其从常规错误处理中排除变得很容易。

js 复制代码
fetch(url, { signal: controller.signal })
    .then((r) => r.json())
    .then((r) => {
        setData(r);
    })
    .catch((error) => {
        // error because of AbortController
        if (error.name === 'AbortError') {
        // do nothing
    } else {
        // do something, it's a real error!
    }
});

代码示例: advanced-react.com/examples/15...

Async/await 改变了什么吗

并没有。Async/await 就是把promise换了个写法。它就是把异步执行的promise变得看起来像同步执行的代码。

以前是这样的:

js 复制代码
fetch('/some-url')
    .then((r) => r.json())
    .then((r) => setData(r));

用了 Async/await后:

js 复制代码
const response = await fetch('/some-url');
const result = await response.json();
setData(result);

代码示例: advanced-react.com/examples/15...

除了写法不一样,处理 竞态条件 的逻辑都是一样的。

知识概要

  • 竞态条件 问题,可能在一个promise完成后,多次调用React的修改状态函数导致的
js 复制代码
useEffect(() => {
    fetch(url)
        .then((r) => r.json())
        .then((r) => {
            // this is vulnerable to the race conditions
            setData(r);
    });
}, [url]);
  • 我们可以这样修复:
    • 卸载使用旧数据的组件,重新挂载使用新数据的组件。
    • 将返回的结果与触发该 Promise 的变量进行比较,如果它们不匹配,就不设置状态。
    • 使用"清除"函数来确保当前的promise是最新的promise
    • 使用AbortController来取消之前的请求。
相关推荐
祈澈菇凉23 分钟前
Vue 中如何实现自定义指令?
前端·javascript·vue.js
sorryhc1 小时前
解读Ant Design X API流式响应和流式渲染的原理
前端·react.js·ai 编程
1024小神1 小时前
vue/react前端项目打包的时候加上时间,防止后端扯皮
前端·vue.js·react.js
拉不动的猪1 小时前
刷刷题35(uniapp中级实际项目问题-2)
前端·javascript·面试
bigcarp1 小时前
理解langchain langgraph 官方文档示例代码中的MemorySaver
java·前端·langchain
FreeCultureBoy1 小时前
从 VS Code 的插件市场下载扩展插件
前端
前端菜鸟日常1 小时前
Webpack 和 Vite 的主要区别
前端·webpack·node.js
Clockwiseee2 小时前
js原型链污染
开发语言·javascript·原型模式
仙魁XAN2 小时前
Flutter 学习之旅 之 flutter 在设备上进行 全面屏 设置/隐藏状态栏/隐藏导航栏 设置
前端·学习·flutter