Jotai极致性能体验优化策略实战

1,103 阅读6分钟

Jotai 作为原子化的状态管理库真是越来越火了,并且随着另一个原子化状态管理库 Recoil 的成员被 Facebook 裁员之后,Jotai 似乎也成为了原子化方案的最佳选择,接下来就让我们来看一下如何在 Jotai 之上达到更加极致的性能/用户体验。

本文会围绕 jotai-scheduler 库进行讲解,jotai-scheduler 库项目地址:github.com/jotaijs/jot… 可以给该项目点一个 Star 🌟 方便项目中使用时可以快速找到该优化方案。

传统 Jotai 或者其它状态管理库有什么问题?

假设我们现在有这样一个页面:

demo.png

它包含了一个应用中通用的元素 —— Header、Footer、Sidebar、Content。每一个元素代表了一个组件,在这些组件中可能会复用同一个全局状态。

对应代码可能是这样的:

const anAtom = atom(0);

const Header = () => {
  const num = useAtomValue(anAtom);
  return <div className="header">Header-{num}</div>;
};

const Footer = () => {
  const num = useAtomValue(anAtom);
  return <div className="footer">Footer-{num}</div>;
};

const Sidebar = () => {
  const num = useAtomValue(anAtom);
  return <div className="sidebar">Sidebar-{num}</div>;
};

const Content = () => {
  const [num, setNum] = useAtom(anAtom);
  return (
    <div className="content">
      <div>Content-{num}</div>
      <button onClick={() => setNum((num) => ++num)}>+1</button>
    </div>
  );
};

即当我们点击按钮时会更新全局状态,并触发所有的组件 re-render,为了模拟更真实的场景,我们手动来模拟繁重的渲染计算过程:

const simulateHeavyRender = () => {
  const start = performance.now();
  while (performance.now() - start < 500) {}
};

并在组件中进行调用:

const anAtom = atom(0);

const Header = () => {
  simulateHeavyRender();
  const num = useAtomValue(anAtom);
  return <div className="header">Header-{num}</div>;
};

const Footer = () => {
  simulateHeavyRender();
  const num = useAtomValue(anAtom);
  return <div className="footer">Footer-{num}</div>;
};

const Sidebar = () => {
  simulateHeavyRender();
  const num = useAtomValue(anAtom);
  return <div className="sidebar">Sidebar-{num}</div>;
};

const Content = () => {
  simulateHeavyRender();
  const [num, setNum] = useAtom(anAtom);
  return (
    <div className="content">
      <div>Content-{num}</div>
      <button onClick={() => setNum((num) => ++num)}>+1</button>
    </div>
  );
};

也就是说在渲染每一个组件时都会包含了 500ms 的延迟,这时候看看当我们点击按钮时的效果:

before-optimization.gif

可以看到,当我们点击按钮时,需要等较长的时间才能看到状态的变化,并且 <Header /><Footer /><Sidebar /><Content /> 引用的状态同一时间发生了变化。

对应打开 Chrome Performance:

image.png

可以看到此时包含了 3 个 Long Task,对应点击三次按钮,这无疑对于用户体验是非常差的。

可能现在有的小伙伴会说 React18 不是有并发更新吗?为什么不开启并发更新来解决这个问题!

好!现在我们使用 useTransition 来开启并发更新:

const Content = () => {
  simulateHeavyRender();
  const [num, setNum] = useAtom(anAtom);
  const [isPending, startTransition] = useTransition();
  return (
    <div className="content">
      <div>Content-{num}</div>
      <button
        onClick={() => {
          startTransition(() => {
            setNum((num) => ++num);
          });
        }}
      >
        +1
      </button>
    </div>
  );
};

此时效果:

20240822221114_rec_.gif

对应 Chrome Performance:

image.png

可以看到,即使开启了并发更新,用户仍然需要等待同样的时间来看到状态的更新,和上面的区别仅仅是一个长任务被拆分为了数个子渲染任务。由于每个组件渲染时间是固定的,因此无论何种方式都无法减少总的渲染时长,我们唯一能做的,就是让每个组件渲染完立刻呈现给用户,而区分组件渲染先后顺序我们称之为“渲染优先级”。

通常来说用户更关心内容区域,因此我们可以假设在现在的页面中渲染优先级为:Content > Sidebar > Header = Footer,也就是说我们应该先把 <Content /> 组件渲染出来之后立即呈现给用户,这样就大大提前关键内容展示给用户的时机,带来更好的性能以及用户体验!

那我们怎么能做到这一点呢?答案就是 jotai-scheduler。

基于 jotai-scheduler 库进行优化

jotai-scheduler API 和 原生 Jotai 极为相似,对应关系为:

  • useAtom --> useAtomWithSchedule
  • useAtomValue --> useAtomValueWithSchedule
  • useSetAtom --> useSetAtomWithSchedule

使用上唯一和 Jotai 的一点区别是可以额外传入 priority 代表渲染优先级:

import { LowPriority, useAtomValueWithSchedule } from 'jotai-scheduler'

const [num, setNum] = useAtomWithScheduleanAtom, {
  priority: LowPriority,
});

const num = useAtomValueWithSchedule(anAtom, {
  priority: LowPriority,
});

priority 可以传入三个值 —— ImmediatePriority, NormalPriority, LowPrioritypriority 是一个可选项,如果不传入默认为 NormalPriority,此时的行为等同于原生 Jotai API。因此,现在你完全可以使用这些 API 替换掉原生 Jotai API,即使你不使用渲染优先级的能力。

好,现在让我们来看一下效果:

const anAtom = atom(0);

const Header = () => {
  const num = useAtomValueWithSchedule(anAtom, {
    priority: LowPriority,
  });
  return <div className="header">Header-{num}</div>;
};

const Footer = () => {
  const num = useAtomValueWithSchedule(anAtom, {
    priority: LowPriority,
  });
  return <div className="footer">Footer-{num}</div>;
};

const Sidebar = () => {
  const num = useAtomValueWithSchedule(anAtom);
  return <div className="sidebar">Sidebar-{num}</div>;
};

const Content = () => {
  const [num, setNum] = useAtomWithSchedule(anAtom, {
    priority: ImmediatePriority,
  });
  return (
    <div className="content">
      <div>Content-{num}</div>
      <button onClick={() => setNum((num) => ++num)}>+1</button>
    </div>
  );
};

此时效果:

after-optimization.gif

对应 Chrome Performance:

image.png

可以看到,重要的内容更早的展现给了用户(时间缩短 75%),这也意味着带来了更好的用户体验。

更多案例

再举一个通用的实际场景,我们经常会看到瀑布流布局的页面,并且当我们点击某个 Item 时候会展开这个卡片的详情:

20240823220123_rec_.gif

我们对展开的卡片做一些操作其实对应也会反映在 Item 上,比如当我点赞的时候,退出展开的卡片后在 Item 上也可以看到点赞的效果:

20240823220440_rec_.gif

也就是说,这些信息的状态是共享的,当状态发生变化时需要触发组件 re-render,但是 Item 在卡片下面,因此我们无需立刻 re-render 下层的组件,也就是说上层的渲染优先级 > 下层的元素。或者当我们有非常多的 Item 时,屏幕内的元素优先级 > 屏幕外的元素。

我们可以模拟一下这种情况,点击查看 Demo:codesandbox.io/p/sandbox/j…

首先我们写一个 Hook 借助 IntersectionObserver API 用来判断组件是否位于屏幕内部:

function useIsVisible() {
  const ref = useRef(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsVisible(entry.isIntersecting);
      }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current);
      }
    };
  }, [ref]);

  return [ref, isVisible];
}

最后我们来编写一下 <App /> 组件与 <Item /> 组件:

const Item = () => {
  simulateHeavyRender();
  const [ref, isVisible] = useIsVisible();
  const [num, setNum] = useAtom(anAtom);
  return (
    <div
      ref={ref}
      style={{
        height: "50px",
        width: "50%",
        margin: "10px",
        textAlign: "center",
        border: "2px solid black",
      }}
    >
      <div>
        {num} {isVisible ? "visible" : "not visible"}
      </div>
      <button
        onClick={() => {
          setNum(num + 1);
        }}
      >
        +1
      </button>
    </div>
  );
};

const App = () => {
  const items = new Array(100).fill(0);

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
      }}
    >
      {items.map(() => (
        <Item />
      ))}
    </div>
  );
};

同样为了模拟真实的场景给每个 <Item /> 组件增加了渲染延迟,同时每个 <Item /> 组件都引用了同一个全局状态,并且当点击按钮时会更新这个状态:

image.png

在原先我们使用 Jotai API useAtomValue 时,当我们点击按钮时效果:

20240823223936_rec_.gif

可以看到非常的卡顿,这是因为 React 需要将全部 Item 都渲染出来。按照我们前面的理论,在屏幕内的组件应该是更重要的,并且我们可以通过 useIsVisible Hook 判断出哪个组件位于屏幕内,从而赋予不同的优先级,从而优先去渲染屏幕内的组件:

const [num, setNum] = useAtomWithSchedule(anAtom, {
  priority: isVisible ? ImmediatePriority : NormalPriority,
});

最终效果:

20240823224306_rec_.gif

可以看到非常丝滑!