Jansiel Notes

React 18 新特性(三):渐变更新

前言

在 React 18 新特性(一):自动批量更新 一文中提到:在 React 新版本中,更新会有优先级的顺序。

那如果希望更新时进行低优先级的处理,应该如何做呢,就是今天讲到的主题:渐变更新。

startTransition:渐变更新

  • startTransition 接受一个回调函数,可以将放入其中的 setState 更新推迟
  • 允许组件将速度较慢的更新延迟渲染,以便能够立即渲染更重要的更新

举个例子

先来看一个例子,在使用谷歌或者百度搜索时,都会遇到如下的场景:

Jansiel_Essay_1696960898068

这里的展示分为两部分

  • 一部分是输入框中的搜索内容
  • 另一部分是展示的联想内容。

从用户的角度进行分析:

  • 输入框中的内容是需要即时更新的
  • 而联想出来的内容是需要进行请求或者加载的,甚至于最开始的时候联想的不准确,用不到。所以用户可以接受这部分内容有一定延迟。

那在这种情况下,用户的输入就是高优先级操作,而联想区域的变化就属于低优先级的操作。

模拟代码实现这个例子

我们写一段代码来实现一下这个搜索框。

App.jsx

 1import React, { useEffect, useState, startTransition } from 'react';
 2import ReactDOM from 'react-dom';
 3
 4const App = () => {
 5    const [value, setValue] = useState('');
 6    const [keywords, setKeywords] = useState([]);
 7
 8    useEffect(() => {
 9        const getList = () => {
10            const list = value
11                ? Array.from({ length: 10000 }, (_, index) => ({
12                      id: index,
13                      keyword: `${value} -- ${index}`,
14                  }))
15                : [];
16            return Promise.resolve(list);
17        };
18        getList().then(res => setKeywords(res));
19    }, [value]);
20
21    return (
22        <>
23            <input value={value} onChange={e => setValue(e.target.value)} />
24            <ul>
25                {keywords.map(({ id, keyword }) => (
26                    <li key={id}>{keyword}</li>
27                ))}
28            </ul>
29        </>
30    );
31};
32
33// 使用 react 18 新的并发模式写法进行 dom render
34ReactDOM.createRoot(document.getElementById('root')).render(<App />);
35
36// legacy 旧模式
37// ReactDOM.render(<App />, document.getElementById('root')!)

然后我们先看一下现在的效果(这里暂时不讨论防抖或者节流):

Jansiel_Essay_1696960916101

可以看到,不仅联想区域的内容加载缓慢,甚至用户的交互内容也反应迟钝。

既然刚才说到了低优先级更新,那么此时,我们是否可以让联想区域的内容低优更新,以避免抢占用户操作的更新呢?

接下来主角登场,使用 startTransition 对代码进行改造。

启用渐变更新

App.jsx

 1import React, { useEffect, useState, startTransition } from 'react';
 2import ReactDOM from 'react-dom';
 3
 4const App = () => {
 5    const [value, setValue] = useState('');
 6    const [keywords, setKeywords] = useState([]);
 7
 8    useEffect(() => {
 9        const getList = () => {
10            const list = value
11                ? Array.from({ length: 10000 }, (_, index) => ({
12                      id: index,
13                      keyword: `${value} -- ${index}`,
14                  }))
15                : [];
16            return Promise.resolve(list);
17        };
18-        //getList().then(res => setKeywords(res));
19        // 仅仅只是将 setKeywords 用 startTransition 包裹一层,即可启用渐变更新
20+        getList().then(res => startTransition(() => setKeywords(res)));
21    }, [value]);
22
23    return (
24        <>
25            <input value={value} onChange={e => setValue(e.target.value)} />
26            <ul>
27                {keywords.map(({ id, keyword }) => (
28                    <li key={id}>{keyword}</li>
29                ))}
30            </ul>
31        </>
32    );
33};
34
35// 使用 react 18 新的并发模式写法进行 dom render
36ReactDOM.createRoot(document.getElementById('root')).render(<App />);
37
38// legacy 旧模式
39// ReactDOM.render(<App />, document.getElementById('root')!)

重新执行后,看看此时的效果:

Jansiel_Essay_1696960929491

可以看到,此时界面的响应速度比之前快了许多。

useDeferredValue:返回一个延迟响应的值

useDeferredValue 相当于是 startTransition(() => setState(xxx)) 的语法糖,在内部会调用一次 setState,但是此更新的优先级更低

那么我们用 useDeferredValue 改写一下上面的代码,看看是否有哪里不一样呢?

App.jsx

 1import React, { useEffect, useState, useDeferredValue } from 'react';
 2import ReactDOM from 'react-dom';
 3
 4const App = () => {
 5    const [value, setValue] = useState('');
 6    const [keywords, setKeywords] = useState([]);
 7+    const text = useDeferredValue(value);
 8
 9    useEffect(() => {
10        const getList = () => {
11            const list = value
12                ? Array.from({ length: 10000 }, (_, index) => ({
13                      id: index,
14                      keyword: `${value} -- ${index}`,
15                  }))
16                : [];
17            return Promise.resolve(list);
18        };
19        getList().then(res => setKeywords(res));
20        // 只是将依赖的值由 value 更新为 text
21+    }, [text]);
22
23    return (
24        <>
25            <input value={value} onChange={e => setValue(e.target.value)} />
26            <ul>
27                {keywords.map(({ id, keyword }) => (
28                    <li key={id}>{keyword}</li>
29                ))}
30            </ul>
31        </>
32    );
33};
34
35// 使用 react 18 新的并发模式写法进行 dom render
36ReactDOM.createRoot(document.getElementById('root')).render(<App />);
37
38// legacy 旧模式
39// ReactDOM.render(<App />, document.getElementById('root')!)

看看此时界面的响应速度:

Jansiel_Essay_1696960943167

可以看到此时的响应速度和使用 startTransition 时相差无几。

useTransition

还记得在 React 18 新特性(二):Suspense & SuspenseList 一文中使用过的 Suspense 组件以及 User 组件吗?我们在这两个组件的基础上,展示一下 useTransition 的用法和特性。

举个异步加载的例子

假设我们目前需要使用 Suspense 来包裹 User 组件,此时 User 组件内部会有网络请求等耗时操作。点击按钮,会触发 User 组件的更新,重新进行耗时操作获取数据

App.jsx

 1import React, { Suspense, useState } from 'react';
 2import ReactDOM from 'react-dom';
 3
 4// 对 promise 进行一层封装
 5function wrapPromise(promise) {
 6    let status = 'pending';
 7    let result;
 8    let suspender = promise.then(
 9        r => {
10            status = 'success';
11            result = r;
12        },
13        e => {
14            status = 'error';
15            result = e;
16        }
17    );
18    return {
19        read() {
20            if (status === 'pending') {
21                throw suspender;
22            } else if (status === 'error') {
23                throw result;
24            } else if (status === 'success') {
25                return result;
26            }
27        },
28    };
29}
30
31// 网络请求,获取 user 数据
32const requestUser = id =>
33    new Promise(resolve =>
34        setTimeout(() => resolve({ id, name: `用户${id}`, age: 10 + id }), id * 100)
35    );
36
37// User 组件
38const User = props => {
39    const user = props.resource.read();
40    return <div>当前用户是: {user.name}</div>;
41};
42
43// 通过 id 获取对应 resource
44const getResource = id => wrapPromise(requestUser(id));
45
46const App = () => {
47    const [resource, setResource] = useState(getResource(10));
48
49    return (
50        <>
51            <Suspense fallback={<div>Loading...</div>}>
52                <User resource={resource} />
53            </Suspense>
54            <button onClick={() => setResource(wrapPromise(requestUser(1)))}>切换用户</button>
55        </>
56    );
57};
58
59// 使用 react 18 新的并发模式写法进行 dom render
60ReactDOM.createRoot(document.getElementById('root')).render(<App />);
61
62// legacy 旧模式
63// ReactDOM.render(<App />, document.getElementById('root')!)

OK,那我们看一下此时的效果哈:

Jansiel_Essay_1696960959118

可以看到,第一次加载时,会出现 loading 效果,这是正常的,但是在点击按钮,切换用户时,依然会有 loading 效果的出现,这本来没有问题,但是当请求速度很快时,就会出现闪一下的问题。此时应该不需要 loading 的出现。

这个时候,useTransition 就派上用场了。

概念

  • useTransition 允许组件再切换到下一个界面之前等待内容加载,从而避免出现不必要的加载状态
  • 允许组件将速度较慢的数据获取更新推迟到随后渲染(低优先级更新),以便能够立即渲染更重要的更新
  • useTransition 返回包含两个元素的数组:
    • isPending: Boolean,通知我们是否正在等待过渡效果的完成
    • startTransition: Function,用它来包裹需要延迟更新的状态

使用 useTransition 修改上述的例子

使用 useTransition 中返回的 startTransition 包裹需要更新的 setState,就会降低更新的优先级,并且会对界面进行缓冲,等待下一个界面准备就绪后直接进行更新。

App.jsx

 1import React, { Suspense, useState, useTransition } from 'react';
 2import ReactDOM from 'react-dom';
 3
 4// 对 promise 进行一层封装
 5function wrapPromise(promise) {
 6    let status = 'pending';
 7    let result;
 8    let suspender = promise.then(
 9        r => {
10            status = 'success';
11            result = r;
12        },
13        e => {
14            status = 'error';
15            result = e;
16        }
17    );
18    return {
19        read() {
20            if (status === 'pending') {
21                throw suspender;
22            } else if (status === 'error') {
23                throw result;
24            } else if (status === 'success') {
25                return result;
26            }
27        },
28    };
29}
30
31// 网络请求,获取 user 数据
32const requestUser = id =>
33    new Promise(resolve =>
34        setTimeout(() => resolve({ id, name: `用户${id}`, age: 10 + id }), id * 100)
35    );
36
37// User 组件
38const User = props => {
39    const user = props.resource.read();
40    return <div>当前用户是: {user.name}</div>;
41};
42
43// 通过 id 获取对应 resource
44const getResource = id => wrapPromise(requestUser(id));
45
46const App = () => {
47    const [resource, setResource] = useState(getResource(10));
48+    const [isPending, startTransition] = useTransition();
49
50    return (
51        <>
52            <Suspense fallback={<div>Loading...</div>}>
53                <User resource={resource} />
54            </Suspense>
55+            <button onClick={() => startTransition(() => setResource(wrapPromise(requestUser(1))))}>
56                切换用户
57            </button>
58        </>
59    );
60};
61
62// 使用 react 18 新的并发模式写法进行 dom render
63ReactDOM.createRoot(document.getElementById('root')).render(<App />);
64
65// legacy 旧模式
66// ReactDOM.render(<App />, document.getElementById('root')!)

可以看到,加载状态的 loading 就不会出现了,闪一下的情况消失了:

Jansiel_Essay_1696960974398

那么问题来了,如果耗时操作确实会花费很久的时间,没有 loading 的话,对于用户来说就没有任何的反馈了呀。

别急,这个时候第一个元素 isPending 就可以用起来啦:

App.jsx

 1import React, { Suspense, useState, useTransition } from 'react';
 2import ReactDOM from 'react-dom';
 3
 4// 对 promise 进行一层封装
 5function wrapPromise(promise) {
 6    let status = 'pending';
 7    let result;
 8    let suspender = promise.then(
 9        r => {
10            status = 'success';
11            result = r;
12        },
13        e => {
14            status = 'error';
15            result = e;
16        }
17    );
18    return {
19        read() {
20            if (status === 'pending') {
21                throw suspender;
22            } else if (status === 'error') {
23                throw result;
24            } else if (status === 'success') {
25                return result;
26            }
27        },
28    };
29}
30
31// 网络请求,获取 user 数据
32const requestUser = id =>
33    new Promise(resolve =>
34        setTimeout(() => resolve({ id, name: `用户${id}`, age: 10 + id }), id * 100)
35    );
36
37// User 组件
38const User = props => {
39    const user = props.resource.read();
40    return <div>当前用户是: {user.name}</div>;
41};
42
43// 通过 id 获取对应 resource
44const getResource = id => wrapPromise(requestUser(id));
45
46const App = () => {
47    const [resource, setResource] = useState(getResource(10));
48    const [isPending, startTransition] = useTransition();
49
50    return (
51        <>
52            <Suspense fallback={<div>Loading...</div>}>
53                <User resource={resource} />
54            </Suspense>
55+            {isPending ? <div>Loading</div> : null}
56            <button
57                onClick={() => startTransition(() => setResource(wrapPromise(requestUser(20))))}
58            >
59                切换用户
60            </button>
61        </>
62    );
63};
64
65// 使用 react 18 新的并发模式写法进行 dom render
66ReactDOM.createRoot(document.getElementById('root')).render(<App />);
67
68// legacy 旧模式
69// ReactDOM.render(<App />, document.getElementById('root')!)

此时点击按钮切换用户,会有 2s 左右的等待时间,就可以展示出 loading 状态,用来提示用户:

Jansiel_Essay_1696960984526

所以,在使用 useTransition 时,一定要注意场景:

  • 在明确知道耗时操作速度极快的情况下,可以直接使用返回值中的 startTransition
  • 如果不能保证响应速度,还是需要使用 isPending 进行过渡状态的判断和展示
  • 如果对于更新的优先级有较高的要求,可以不使用 useTransition