React并行模式(concurrent模式)

该模式尚处试验状态,不应在应用的生产环境使用

动机

阻塞VS可中断渲染

  • 用版本控制作为类比。在以前没有版本管理工具的时候,如果你想要编辑某个文件,你需要告知所有合作者,并且他们在你编辑完成之前不能修改这些文件,实际上这就造成了阻塞。

  • 组件渲染也存在这样的阻塞现象,当一个组件进行渲染的时候,它不能中断包括创建DOM和运行组件代码在内的工作,这被称为“阻塞渲染”

  • 在concurrent模式下,渲染不是阻塞的,是可以中断的

    • 比如在一个列表过滤器中,我们常常会遇到在键盘键入的过程中,发现列表组件在不断重复刷新,如果列表组件较多甚至电脑性能较低的话,就会感觉到明显的卡顿。

    • 卡顿的原因无非是列表组件在不断地创建与删除。

    • 我们可以通过防抖的方式让用户停止键入的时候才刷新列表,但这样使得列表不能实时刷新;我们也可以使用节流的方式限定一个最大刷新的频率,但是在低性能电脑上仍可能会卡顿。

    • 卡顿最根本的原因就是因为组件渲染无法中断。concurrent模式意味着可以让用户在浏览器刷新的时候键入时,中断刷新列表的过程并优先刷新输入框的内容,然后再更新列表组件,实现用户流畅地输入

有意的加载顺序

  • 如同一个分支上的代码可能需要经过足够长的时间才能好到足够用以展示,在React应用也存在类似的问题。在两个页面导航的时候加载一个新页面,由于网络等原因可能会有较长的一段时间白屏状态,而concurrent模式可以让应用在旧页面多停留一段时间,在展示新屏幕前跳过不够好的加载状态

不同的主题

Suspense 用于数据获取

Suspense 是什么

  1. React 16.6新增了一个Suspense组件,将组件包裹在内可以使得组件等待异步操作的返回值,它可以在数据尚未加载的时候告诉React某个组件暂不可用

    1
    const resource = fetchProfileData();
    2
    3
    function ProfilePage() {
    4
      return (
    5
        <Suspense fallback={<h1>Loading profile...</h1>}>
    6
          <ProfileDetails />
    7
          <Suspense fallback={<h1>Loading posts...</h1>}>
    8
            <ProfileTimeline />
    9
          </Suspense>
    10
        </Suspense>
    11
      );
    12
    }
    13
    14
    function ProfileDetails() {
    15
      // 尝试读取用户信息,尽管该数据可能尚未加载
    16
      const user = resource.user.read();
    17
      return <h1>{user.name}</h1>;
    18
    }
    19
    20
    function ProfileTimeline() {
    21
      // 尝试读取博文信息,尽管该部分数据可能尚未加载
    22
      const posts = resource.posts.read();
    23
      return (
    24
        <ul>
    25
          {posts.map(post => (
    26
            <li key={post.id}>{post.text}</li>
    27
          ))}
    28
        </ul>
    29
      );
    30
    }
  2. 它只是协助加载状态在UI上的展示,并不是请求数据的工具

传统实现方式 VS Suspense

  1. Fetch-on-render,渲染后数据请求,会导致瀑布问题

    1
    function ProfilePage() {
    2
      const [user, setUser] = useState(null);
    3
    4
      useEffect(() => {
    5
        fetchUser().then(u => setUser(u));
    6
      }, []);
    7
    8
      if (user === null) {
    9
        return <p>Loading profile...</p>;
    10
      }
    11
      return (
    12
        <>
    13
          <h1>{user.name}</h1>
    14
          <ProfileTimeline />
    15
        </>
    16
      );
    17
    }
    18
    19
    function ProfileTimeline() {
    20
      const [posts, setPosts] = useState(null);
    21
    22
      useEffect(() => {
    23
        fetchPosts().then(p => setPosts(p));
    24
      }, []);
    25
    26
      if (posts === null) {
    27
        return <h2>Loading posts...</h2>;
    28
      }
    29
      return (
    30
        <ul>
    31
          {posts.map(post => (
    32
            <li key={post.id}>{post.text}</li>
    33
          ))}
    34
        </ul>
    35
      );
    36
    }
  2. Fetch-then-render,集中进行数据获取。通常需要等待所有数据加载完成才进行渲染(如果不使用Promise.all,数据树中部分数据出现缺失,则很难写出健壮的组件)

    1
    const promise = fetchProfileData();
    2
    3
    function ProfilePage() {
    4
      const [user, setUser] = useState(null);
    5
      const [posts, setPosts] = useState(null);
    6
    7
      useEffect(() => {
    8
        promise.then(data => {
    9
          setUser(data.user);
    10
          setPosts(data.posts);
    11
        });
    12
      }, []);
    13
    14
      if (user === null) {
    15
        return <p>Loading profile...</p>;
    16
      }
    17
      return (
    18
        <>
    19
          <h1>{user.name}</h1>
    20
          <ProfileTimeline posts={posts} />
    21
        </>
    22
      );
    23
    }
    24
    25
    // 子组件不再触发数据请求
    26
    function ProfileTimeline({ posts }) {
    27
      if (posts === null) {
    28
        return <h2>Loading posts...</h2>;
    29
      }
    30
      return (
    31
        <ul>
    32
          {posts.map(post => (
    33
            <li key={post.id}>{post.text}</li>
    34
          ))}
    35
        </ul>
    36
      );
    37
    }
  3. Render-as-you-fetch(获取数据后渲染,使用Suspense)。有了Suspense,我们不必等到数据全部返回才渲染,实际上我们是一发送网络请求就开始渲染。如果一个组件渲染的时候所需数据还没有就绪,那么这个组件就被挂起,当组件树中已经没有其他组件要被渲染了且有组件有挂起,则显示距离挂起组件最近的fallback。每当一个数据就绪,就解锁对应的Suspense边界

    1
    // 这不是一个 Promise。这是一个支持 Suspense 的特殊对象。
    2
    const resource = fetchProfileData();
    3
    4
    function ProfilePage() {
    5
      return (
    6
        <Suspense fallback={<h1>Loading profile...</h1>}>
    7
          <ProfileDetails />
    8
          <Suspense fallback={<h1>Loading posts...</h1>}>
    9
            <ProfileTimeline />
    10
          </Suspense>
    11
        </Suspense>
    12
      );
    13
    }
    14
    15
    function ProfileDetails() {
    16
      // 尝试读取用户信息,尽管信息可能未加载完毕
    17
      const user = resource.user.read();
    18
      return <h1>{user.name}</h1>;
    19
    }
    20
    21
    function ProfileTimeline() {
    22
      // 尝试读取博文数据,尽管数据可能未加载完毕
    23
      const posts = resource.posts.read();
    24
      return (
    25
        <ul>
    26
          {posts.map(post => (
    27
            <li key={post.id}>{post.text}</li>
    28
          ))}
    29
        </ul>
    30
      );
    31
    }

Race Conditions

  1. 在原来的useEffect获取数据时,因为组件与异步请求有着各至的生命周期,如果组件状态切换的太快就会导致响应的请求与组件的状态不一致的问题,看如下代码,有时候在把profile页面切换成别的ID后,旧的profile请求会“返回”

    1
    function ProfilePage({ id }) {
    2
      const [user, setUser] = useState(null);
    3
    4
      useEffect(() => {
    5
        fetchUser(id).then(u => setUser(u));
    6
      }, [id]);
    7
    8
      if (user === null) {
    9
        return <p>Loading profile...</p>;
    10
      }
    11
      return (
    12
        <>
    13
          <h1>{user.name}</h1>
    14
          <ProfileTimeline id={id} />
    15
        </>
    16
      );
    17
    }
    18
    19
    function ProfileTimeline({ id }) {
    20
      const [posts, setPosts] = useState(null);
    21
    22
      useEffect(() => {
    23
        fetchPosts(id).then(p => setPosts(p));
    24
      }, [id]);
    25
    26
      if (posts === null) {
    27
        return <h2>Loading posts...</h2>;
    28
      }
    29
      return (
    30
        <ul>
    31
          {posts.map(post => (
    32
            <li key={post.id}>{post.text}</li>
    33
          ))}
    34
        </ul>
    35
      );
    36
    }
  2. 而使用Suspense,我们在发出请求的同时马上就设置状态,确保响应可用的时候总是正确的值

    1
    <>
    2
      <button onClick={() => {
    3
        const nextUserId = getNextId(resource.userId);
    4
        setResource(fetchProfileData(nextUserId));
    5
      }}>
    6
        Next
    7
      </button>
    8
      <ProfilePage resource={resource} />
    9
    </>
  3. 可以通过定义一个错误边界组件来处理错误信息(目前仅支持使用class组件定义

    1
    class ErrorBoundary extends React.Component {
    2
      state = { hasError: false, error: null };
    3
      static getDerivedStateFromError(error) {
    4
        return {
    5
          hasError: true,
    6
          error
    7
        };
    8
      }
    9
      render() {
    10
        if (this.state.hasError) {
    11
          return this.props.fallback;
    12
        }
    13
        return this.props.children;
    14
      }
    15
    }
    16
    17
    function ProfilePage() {
    18
      return (
    19
        <Suspense fallback={<h1>Loading profile...</h1>}>
    20
          <ProfileDetails />
    21
          <ErrorBoundary fallback={<h2>Could not fetch posts.</h2>}>
    22
            <Suspense fallback={<h1>Loading posts...</h1>}>
    23
              <ProfileTimeline />
    24
            </Suspense>
    25
          </ErrorBoundary>
    26
        </Suspense>
    27
      );
    28
    }

Concurrent UI模式

用Transition 包裹 setState

  1. 如果我们希望在页面加载的足够好之前停留在原来的页面,那可以使用useTransition
    1
    const [startTransition, isPending] = useTransition({
    2
      timeoutMs: 3000
    3
    });
    4
    return (
    5
      <>
    6
        <button
    7
          disabled={isPending}
    8
          onClick={() => {
    9
            startTransition(() => {
    10
              const nextUserId = getNextId(resource.userId);
    11
              setResource(fetchProfileData(nextUserId));
    12
            });
    13
          }}
    14
        >
    15
          Next
    16
        </button>
    17
        {isPending ? " Loading..." : null}
    18
        <ProfilePage resource={resource} />
    19
      </>
    20
    );
    这时候体验已经很不一样了,当点击时我们在前一个页面停留了一段时间,然后才切换到新的页面

3个阶段

  1. 默认方式是Receded(后退) -> Skeleton -> Complete

    1
    <Suspense fallback={...}>
    2
      {/* previous screen */}
    3
      <HomePage />
    4
    </Suspense>
    5
    6
    // 点击之后开始渲染下一个页面
    7
    <Suspense fallback={...}>
    8
      {/* next screen */}
    9
      <ProfilePage>
    10
        <ProfileDetails />
    11
        <Suspense fallback={...}>
    12
          <ProfileTimeline />
    13
        </Suspense>
    14
      </ProfilePage>
    15
    </Suspense>

    当组件被suspend时,React需要显示最近的降级页面。但是对<ProfileDetails>来说最近的降级页面就是顶层了,所以会给我们一个后退了一步的感觉

  2. 我们期望的方式是Pending -> Skeleton -> Compelete,即在页面跳转的时候让我们在前一个页面“停留一会”,这种的体验会比Receded状态好得多,而实现的方法就是使用useTransition定制<Button>

  3. 有时候可能下一个页面的resource需要大量的时间加载,这时候使用useTransition会长时间停留在上一个页面,这时一个折中的方案就是把不那么必要马上显示的组件用<Suspense>包裹,这样useTransition就不会等待该组件加载完再跳转,如下的<ProfileTrivia>

    1
    function ProfilePage({ resource }) {
    2
      return (
    3
        <>
    4
          <ProfileDetails resource={resource} />
    5
          <Suspense fallback={<h2>Loading posts...</h2>}>
    6
            <ProfileTimeline resource={resource} />
    7
          </Suspense>
    8
          <Suspense fallback={<h2>Loading fun facts...</h2>}>
    9
            <ProfileTrivia resource={resource} />
    10
          </Suspense>
    11
        </>
    12
      );
    13
    }

根据优先级分割 state

  1. 我们在设计state的时候,通常希望找到state的极小表示法。例如,与其在 state 中保存 firstName、lastName 和 fullName,不如只保存 firstName 和 lastName 这样通常会更好,然后在渲染时通过计算得到 fullName。然而在有时候我们希望某些state通过useTransition控制变化,而另一些实时变化,这时候就要把数据冗余到不同的state变量
    1
    const initialQuery = "Hello, world";
    2
    const initialResource = fetchTranslation(initialQuery);
    3
    4
    function App() {
    5
      const [query, setQuery] = useState(initialQuery);
    6
      const [resource, setResource] = useState(initialResource);
    7
    8
      function handleChange(e) {
    9
        const value = e.target.value;
    10
        setQuery(value);
    11
        setResource(fetchTranslation(value));
    12
      }
    13
    14
      return (
    15
        <>
    16
          <input
    17
            value={query}
    18
            onChange={handleChange}
    19
          />
    20
          <Suspense fallback={<p>Loading...</p>}>
    21
            <Translation resource={resource} />
    22
          </Suspense>
    23
        </>
    24
      );
    25
    }
    26
    27
    function Translation({ resource }) {
    28
      return (
    29
        <p>
    30
          <b>{resource.read()}</b>
    31
        </p>
    32
      );
    33
    }
    这是一个列表输入搜索的小应用,这时候我们希望resource是transition的,因为我们不想用户在输入的时候列表在不断loading而不显示数据,所以更希望让他显示已经渲染好的数据。另一方面,我们又不希望我们的输入也有延迟,要等到列表加载出来才刷新,所以query不能放在trainsition中,所以正确写法如下,这时state就有了冗余
    1
    function handleChange(e) {
    2
      const value = e.target.value;
    3
      
    4
      // Outside the transition (urgent)
    5
      setQuery(value);
    6
    7
      startTransition(() => {
    8
        // Inside the transition (may be delayed)
    9
        setResource(fetchTranslation(value));
    10
      });
    11
    }

延迟一个值

  1. 默认情况下 React 总是渲染一个一致的UI,如下

    1
    <>
    2
      <ProfileDetails user={user} />
    3
      <ProfileTimeline user={user} />
    4
    </>

    他们数据来自同一个user,所以他们会同时变化。

  2. 但有时候,我们可能希望数据是不一致的,对同一个resource,user信息需要300ms获取而文章却需要1000ms,如果我们要使其一致,则整个页面需要1000ms才能更新;但我们可以允许其不一致,这样使得局部刷新,牺牲了一致性,却换来了加载速度的提升

    1
    function ProfilePage({ resource }) {
    2
      const deferredResource = useDeferredValue(resource, {
    3
        timeoutMs: 1000
    4
      });
    5
      return (
    6
        <Suspense fallback={<h1>Loading profile...</h1>}>
    7
          <ProfileDetails resource={resource} />
    8
          <Suspense fallback={<h1>Loading posts...</h1>}>
    9
            <ProfileTimeline
    10
              resource={deferredResource}
    11
              isStale={deferredResource !== resource}
    12
            />
    13
          </Suspense>
    14
        </Suspense>
    15
      );
    16
    }
    17
    18
    function ProfileTimeline({ isStale, resource }) {
    19
      const posts = resource.posts.read();
    20
      return (
    21
        <ul style={{ opacity: isStale ? 0.7 : 1 }}>
    22
          {posts.map(post => (
    23
            <li key={post.id}>{post.text}</li>
    24
          ))}
    25
        </ul>
    26
      );
    27
    }
  3. 传统的防抖的方法,我们需要手动设置一个防抖时间,这个延迟是固定的,不论我们的电脑有多快。然而使用useDeferredValue的值只会在渲染耗费时间的情况下“滞后”。所以在较快的机器上,滞后很少,在较慢的机器上,它会变得更明显。但无论如何它总会引入最小的延迟。

SuspenseList

  1. 在大部分时间,API调用时长是随机的,所以Suspense边界去掉的顺序也是随机的

    1
    function ProfilePage({ resource }) {
    2
      return (
    3
        <>
    4
          <ProfileDetails resource={resource} />
    5
          <Suspense fallback={<h2>Loading posts...</h2>}>
    6
            <ProfileTimeline resource={resource} />
    7
          </Suspense>
    8
          <Suspense fallback={<h2>Loading fun facts...</h2>}>
    9
            <ProfileTrivia resource={resource} />
    10
          </Suspense>
    11
        </>
    12
      );
    13
    }

    这就带来了一个问题,如果趣闻数据先到达,我们可能会先开始阅读这些,但是随后文章列表的响应到达,把所有的趣闻推到下面。这就造成了布局的变化,感觉十分不友好

  2. 其中一种解决方法是把他们放在一个Suspense边界中,但这就带来了一个问题,有可能文章列表已经到达了但是趣闻没有到达,这样需要花费额外的时间等待趣闻,而用户本可以先阅读文章列表

  3. 为了解决这个问题引入了一个<SuspenseList>组件,这个组件协调它下面最近的<Suspense>节点的“展开顺序”

    1
    function ProfilePage({ resource }) {
    2
      return (
    3
        <SuspenseList revealOrder="forwards">
    4
          <ProfileDetails resource={resource} />
    5
          <Suspense fallback={<h2>Loading posts...</h2>}>
    6
            <ProfileTimeline resource={resource} />
    7
          </Suspense>
    8
          <Suspense fallback={<h2>Loading fun facts...</h2>}>
    9
            <ProfileTrivia resource={resource} />
    10
          </Suspense>
    11
        </SuspenseList>
    12
      );
    13
    }

    它的revealOrder属性用于控制<Suspense>的展开顺序

使用 Concurrent 模式

  1. 安装react实验版,npm install react@experimental react-dom@experimental

  2. 开启concurrent模式,在index.js中修改render函数

    1
    import ReactDOM from 'react-dom';
    2
    3
    // 如果你之前的代码是:
    4
    //
    5
    // ReactDOM.render(<App />, document.getElementById('root'));
    6
    //
    7
    // 你可以用下面的代码引入 concurrent 模式:
    8
    9
    // 这里官方文档写的是createRoot就可以了,但是实际实验发现需要加上unstable_前缀,大坑!!!
    10
    ReactDOM.unstable_createRoot(
    11
      document.getElementById('root')
    12
    ).render(<App />);
  3. 做个组件实验一下~

    1
    import React from "react";
    2
    import "./App.css";
    3
    4
    function SuspenseDemo() {
    5
      const [greetingResource, setGreetingResource] = React.useState(null);
    6
      function handleSubmit(e) {
    7
        e.preventDefault();
    8
        const name = e.target.elements.nameInput.value;
    9
        setGreetingResource(createGreetingResource(name));
    10
      }
    11
    12
      return (
    13
        <div>
    14
          <strong>Suspense Demo</strong>
    15
          <form onSubmit={handleSubmit}>
    16
            <label htmlFor="nameInput">Name</label>
    17
            <input id="nameInput" />
    18
            <button type="submit">Submit</button>
    19
          </form>
    20
            <React.Suspense fallback={<p>loading greeting</p>}>
    21
              <Greeting greetingResource={greetingResource}/>
    22
            </React.Suspense>
    23
        </div>
    24
      );
    25
    }
    26
    27
    function Greeting({ greetingResource }) {
    28
      return (
    29
        <p>
    30
          {greetingResource ? greetingResource.read() : "Please submit a name"}
    31
        </p>
    32
      );
    33
    }
    34
    35
    // 🐨 make this function do something else. Like an HTTP request or something
    36
    function getGreeting(name) {
    37
      return new Promise((resolve, reject) => {
    38
        setTimeout(() => {
    39
          resolve(`Hello ${name}!`);
    40
        }, 3000);
    41
      });
    42
    }
    43
    44
    45
    function createGreetingResource(name) {
    46
      let status = "pending";
    47
      let result;
    48
      let suspender = getGreeting(name).then(
    49
        (greeting) => {
    50
          status = "success";
    51
          result = greeting;
    52
        },
    53
        (error) => {
    54
          status = "error";
    55
          result = error;
    56
        }
    57
      );
    58
      return {
    59
        read() {
    60
          if (status === "pending") throw suspender;
    61
          if (status === "error") throw result;
    62
          if (status === "success") return result;
    63
        },
    64
      };
    65
    }
    66
    67
    export default function App() {
    68
      return <SuspenseDemo></SuspenseDemo>;
    69
    }
  4. 完整代码请参考这位老哥