Jotai 作为原子化的状态管理库真是越来越火了,并且随着另一个原子化状态管理库 Recoil 的成员被 Facebook 裁员之后,Jotai 似乎也成为了原子化方案的最佳选择,接下来就让我们来看一下如何在 Jotai 之上达到更加极致的性能/用户体验。
本文会围绕 jotai-scheduler 库进行讲解,jotai-scheduler 库项目地址:github.com/jotaijs/jot… 可以给该项目点一个 Star 🌟 方便项目中使用时可以快速找到该优化方案。
传统 Jotai 或者其它状态管理库有什么问题?
假设我们现在有这样一个页面:
它包含了一个应用中通用的元素 —— 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 的延迟,这时候看看当我们点击按钮时的效果:
可以看到,当我们点击按钮时,需要等较长的时间才能看到状态的变化,并且 <Header />、<Footer />、<Sidebar />、<Content /> 引用的状态同一时间发生了变化。
对应打开 Chrome Performance:
可以看到此时包含了 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>
);
};
此时效果:
对应 Chrome Performance:
可以看到,即使开启了并发更新,用户仍然需要等待同样的时间来看到状态的更新,和上面的区别仅仅是一个长任务被拆分为了数个子渲染任务。由于每个组件渲染时间是固定的,因此无论何种方式都无法减少总的渲染时长,我们唯一能做的,就是让每个组件渲染完立刻呈现给用户,而区分组件渲染先后顺序我们称之为“渲染优先级”。
通常来说用户更关心内容区域,因此我们可以假设在现在的页面中渲染优先级为:Content > Sidebar > Header = Footer,也就是说我们应该先把 <Content /> 组件渲染出来之后立即呈现给用户,这样就大大提前关键内容展示给用户的时机,带来更好的性能以及用户体验!
那我们怎么能做到这一点呢?答案就是 jotai-scheduler。
基于 jotai-scheduler 库进行优化
jotai-scheduler API 和 原生 Jotai 极为相似,对应关系为:
useAtom-->useAtomWithScheduleuseAtomValue-->useAtomValueWithScheduleuseSetAtom-->useSetAtomWithSchedule
使用上唯一和 Jotai 的一点区别是可以额外传入 priority 代表渲染优先级:
import { LowPriority, useAtomValueWithSchedule } from 'jotai-scheduler'
const [num, setNum] = useAtomWithScheduleanAtom, {
priority: LowPriority,
});
const num = useAtomValueWithSchedule(anAtom, {
priority: LowPriority,
});
priority 可以传入三个值 —— ImmediatePriority, NormalPriority, LowPriority。priority 是一个可选项,如果不传入默认为 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>
);
};
此时效果:
对应 Chrome Performance:
可以看到,重要的内容更早的展现给了用户(时间缩短 75%),这也意味着带来了更好的用户体验。
更多案例
再举一个通用的实际场景,我们经常会看到瀑布流布局的页面,并且当我们点击某个 Item 时候会展开这个卡片的详情:
我们对展开的卡片做一些操作其实对应也会反映在 Item 上,比如当我点赞的时候,退出展开的卡片后在 Item 上也可以看到点赞的效果:
也就是说,这些信息的状态是共享的,当状态发生变化时需要触发组件 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 /> 组件都引用了同一个全局状态,并且当点击按钮时会更新这个状态:
在原先我们使用 Jotai API useAtomValue 时,当我们点击按钮时效果:
可以看到非常的卡顿,这是因为 React 需要将全部 Item 都渲染出来。按照我们前面的理论,在屏幕内的组件应该是更重要的,并且我们可以通过 useIsVisible Hook 判断出哪个组件位于屏幕内,从而赋予不同的优先级,从而优先去渲染屏幕内的组件:
const [num, setNum] = useAtomWithSchedule(anAtom, {
priority: isVisible ? ImmediatePriority : NormalPriority,
});
最终效果:
可以看到非常丝滑!