# startTransition和Suspense对异步组件的优化
Suspense用于包裹懒加载(即异步)的组件,以便在组件还未完全加载时显示中间态
而在 startTransition 内部更新状态也可以触发懒加载
那么,Suspense内部包裹lazy懒加载组件,可以平稳的过渡并显示中间态fallback,而startTransition却不可以,那么startTransition存在的意义何在?
startTransition可以告诉 React 哪些更新是低优先级的,从而在处理这些更新时不阻塞高优先级的用户输入(如点击、输入框内容等)
export default function App() {
const [text,setText] = useState('Initial Text')
const [items,setItems] = useState<number[]>([])
const [isPending,setIsPending]= useState(false)
const handleClick = () => {
setIsPending(true)
setText("Text updated immediately!");
startTransition(()=> {
updateItems()
setIsPending(false);
})
}
const updateItems = () => {
const newItems = Array.from({ length: 10000 }, (_, i) => i + 1);
setItems(newItems);
}
return (
<div>
<h3>{text}</h3>
<StyledAntdButton ref={ref} type='primary' onClick={handleClick}>click Me</StyledAntdButton>
{isPending && <p>Updating list...</p>}
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
使用React-Window提供的虚拟渲染实现虚拟列表
import { FixedSizeList as List } from 'react-window';
export default function App() {
const [text,setText] = useState('Initial Text')
const [items,setItems] = useState<number[]>([])
const [isPending,setIsPending]= useState(false)
const handleClick = () => {
setIsPending(true)
setText("Text updated immediately!");
startTransition(()=> {
updateItems()
setIsPending(false)
})
}
const updateItems = () => {
const newItems = Array.from({ length: 10000 }, (_, i) => i + 1);
setItems(newItems);
}
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>Item {items[index]}</div>
)
return (
<div>
<StyledButton css={appClass} onClick={() => setCount(count + 1)}>{count}</StyledButton>
<h3>{text}</h3>
<StyledAntdButton ref={ref} type='primary' onClick={handleClick}>click Me</StyledAntdButton>
{isPending && <p>Updating list...</p>}
<List height={400} itemCount={items.length} itemSize={30} width={300}>{Row}</List>
</div>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
如果lazy懒加载的组件没有通过Suspense包裹或startTransition处理,渲染时则会抛出错误:
React Router caught the following error during render Error: A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.
# ReactRouter loader使用
传统的权限控制我们可能是这样做的:在useEffect中获取权限,有权限则渲染没有则返回null,这种一般有个问题就是页面结构会先渲染一遍
从接口获取数据并渲染到页面上我们可能是这样做的:在useEffect中获取数据,并setState触发页面更新
// 接口
export const useNumber = async () => {
const response = await fetch('http://127.0.0.1:3000/test/getNumber')
if (!response.ok) {
throw new Error('Network response was not ok')
}
const data = await response.json()
return data.number
}
2
3
4
5
6
7
8
页面
import { useEffect, useState } from 'react'
import { useNumber } from './useNumber'
const About1: React.FC = () => {
const [number, setNumber] = useState(0)
const getNumber = async () => {
const number = await useNumber()
setNumber(number)
}
useEffect(() => {
getNumber()
}, [])
console.log('render')
return <div style={{ color: 'red' }}>Here is the number: {number}</div>
}
export default About1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
很明显,上面的render会输出两次
我们来看下Waterfall中的输出:
这里我使用了按需加载,所以显示请求页面的js文件再是调接口,可以发现是按顺序排队执行,共计耗时3s+200ms
# 使用loader
在 React Router 6 中,loader 是用于在页面加载前获取数据的机制。通过 loader,你可以在渲染组件之前异步获取数据,并将其传递给你的路由组件。
// loader.ts
import { useNumber } from './useNumber'
export const userLoader = async () => {
const number = await useNumber()
return { number }
}
2
3
4
5
6
7
在路由表中添加loader
{ path: "/about", element: <About />, loader: userLoader }
import { useLoaderData } from "react-router-dom";
const About: React.FC = () => {
const userLoaderData = useLoaderData() as UserData
console.log('userLoaderData: ', userLoaderData)
console.log('render')
return (
<main>
<h1>Let's loading some data</h1>
<div style={{ color: 'red' }}>
<h3>About: {userLoaderData.number}</h3>
</div>
</main>
)
}
export default About;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
查看Waterfall中发现请求在前,js文件在后
这样就会导致页面在请求接口过程中出现长时间空白,如果接口请求时间过长的话。
继续优化
# 1.使用defer
import { defer } from 'react-router-dom'
import { useNumber } from './useNumber'
export const userLoader = async () => {
const number = useNumber()
return defer({ number })
}
2
3
4
5
6
7
使用 defer 的优势:
defer允许你将异步数据加载的 Promise 包装在一个对象中。这种方式可以确保Suspense和Await组件能够正确处理异步加载状态和数据渲染。
defer 的目的是将异步数据加载的操作推迟到实际需要渲染数据的时候。这意味着你可以直接将 Promise 对象返回,而不是等待 Promise 解析完成。
# 2.使用Suspense提供fallback UI,并和 Await 组件配合
Await 组件用于处理从 defer 返回的 Promise,并在数据加载完成后渲染实际内容。
import { Suspense } from 'react'
import { Await, useLoaderData } from 'react-router-dom'
interface UserData {
number: boolean
}
const About: React.FC = () => {
const userLoaderData = useLoaderData() as UserData
console.log('userLoaderData: ', userLoaderData)
console.log('render')
return (
<main>
<h1>Let's loading some data</h1>
<Suspense fallback={<div>Loading number...</div>}>
<Await resolve={userLoaderData.number}>
{(data) => {
return (
<div style={{ color: 'red' }}>
<h3>About: {data}</h3>
</div>
)
}}
</Await>
</Suspense>
</main>
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
页面表现为先加载Suspense内提供的fallback内容,当接口请求完毕后再将数据更新到对应位置,而组件的render只打印了一遍
Await 组件会等到 Promise 解析完成后再渲染实际内容。
我们来看Waterfall:
可以很明显的看到接口请求和页面资源文件并行执行
# 结论
- 条件渲染:适用于简单的场景,当数据加载逻辑相对简单时,直接在组件内部处理数据加载和条件渲染可以更直接、易于理解。
Suspense和Await:适用于更复杂的场景,特别是当你希望将数据加载和组件渲染逻辑分离,并且需要处理并行数据加载时。这种方式可以让组件在数据加载期间渲染fallback内容,改善用户体验,同时支持更复杂的异步数据处理逻辑。
# 实际应用
PieChart组件
import * as echarts from "echarts";
import { useEffect, useRef } from "react";
interface PieChartProps {
data: any[] | undefined;
}
var chart: echarts.ECharts;
const PieChart: React.FC<PieChartProps> = ({ data }) => {
const node = useRef<HTMLDivElement>(null);
const option: echarts.EChartsOption = {
tooltip: {
trigger: "item",
},
legend: {
top: "5%",
left: "center",
},
series: [
{
name: "Access From",
type: "pie",
radius: ["40%", "70%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
position: "center",
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: "bold",
},
},
labelLine: {
show: false,
},
data,
},
],
};
useEffect(() => {
chart = echarts.init(node.current);
const listener = () => {
chart.resize();
};
window.addEventListener("resize", listener);
return () => {
chart.dispose();
window.removeEventListener("resize", listener);
};
}, []);
useEffect(() => {
if (data) {
chart.setOption(option);
}
}, [data]);
return <div style={{ width: "100%", height: "100%" }} ref={node}></div>;
};
export default PieChart;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
DashBoard
const BAVDashBoard = () => {
const [appData, setAppData] = useState<AppDataType>();
const fetchAppData = async () => {
let res = await queryApp();
if (res.body) {
setAppData(res.body);
}
};
return (
<div style={{ flex: 1, height: 230 }}>
<PieChart data={appData?.apps} />
</div>
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这里就可以用Await和loader来进行优化
const { data: number, error, loading, run } = useAbortRequest(useNumber);
export const userLoader = async () => {
const abortController = new AbortController()
const { signal } = abortController
const number = useNumber(signal)
const name = useName(signal)
const data = useFetchPieData()
return defer({ number, name, data, abortController })
}
<Suspense fallback={<Loading />}>
<Await resolve={pieData}>
{(data) => {
return <PieChart data={data} />
}}
</Await>
</Suspense>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这样即美化了页面空白时的显示,又减少了请求时间
# 原理分析
# 1.loader函数并发执行
loader 是 React Router 6 中的一个新功能,允许你在路由级别加载数据。loader 函数会在路由渲染之前执行,用于获取页面渲染所需的异步数据。loader 返回的通常是一个 Promise,这个 Promise 可以包含从接口请求数据或其他异步资源(例如本地存储的数据等)。
当页面加载时,loader 函数与组件的 JavaScript 资源文件是并行加载的。具体原理是:
- 并行执行:
loader会异步运行,它不会阻塞浏览器去加载页面的静态资源文件(例如 JavaScript 文件、CSS 文件等)。因此,接口请求和页面资源文件加载会并行进行。 - 延迟渲染: 页面会等到
loader返回的数据就绪后,才真正开始渲染组件,避免因缺少数据而导致页面错误或内容不完整。
# 2.defer的并行加载
defer返回一个 Promise,并且可以分阶段返回数据
defer将数据请求的结果包装成一个 Promise,而页面的静态资源可以先行加载。
export async function loader() {
return defer({
number: fetch('/api/number'), // 延迟加载的接口数据
pieData: fetch('/api/pieData') // 另一个延迟加载的数据
});
}
2
3
4
5
6
Suspense 和 Await 的作用是确保页面资源和接口数据能够并行加载,并且不会阻塞组件渲染的过程。
# 使用Vue实现并发渲染
我们可以用vue-router的beforeEnter来实现类似的效果
{
path: 'about1',
component: () => import('@/pages/About1.vue'),
beforeEnter: (to, from, next) => {
const { number, name, abortController } = useFetchData()
to.meta.number = number
to.meta.name = name
to.meta.abortController = abortController
next()
}
},
2
3
4
5
6
7
8
9
10
11
这里的 number 和 name 都是promise
在页面中
const number = ref()
const name = ref()
const route = useRoute()
const abortController = route.meta.abortController
const getLoader = async () => {
const numberVal = await route.meta.number
number.value = numberVal
const nameVal = await route.meta.name
name.value = nameVal
}
getLoader()
onBeforeUnmount(() => {
abortController.abort('请求取消')
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这段代码中,如果numberVal执行的时间比nameVal长,那么页面会在numberVal 执行完后一次性渲染更新所有内容
使用Promise.all
const getLoader = async () => {
const [numberVal2, nameVal2] = await Promise.all([route.meta.number, route.meta.name])
number.value = numberVal2
name.value = nameVal2
}
2
3
4
5
与上面不同的是,Promise.all会等待所有promise都完成后才会进行页面更新。
使用then
const getLoader = async () => {
const numberVal = route.meta.number
const nameVal = route.meta.name
numberVal.then((n) => {number.value = n})
nameVal.then((n) => {name.value = n})
}
2
3
4
5
6
这段代码的效果是先执行完的先更新,后执行完的后更新,不用考虑谁比较快。
# 在Vue中封装一个Await组件
Await组件
<template>
<slot v-if="error" name="error"></slot>
<slot v-else name="default" :data="data"></slot>
</template>
<script setup lang="ts">
import { defineOptions, ref } from 'vue'
defineOptions({ name: 'AwaitComponent' })
const props = defineProps<{ resolve: any }>()
defineSlots<{ default: (props: { data: any }) => any; error: () => any }>()
const data = ref(null)
const error = ref<boolean>(false)
await props.resolve
.then((res) => (data.value = res))
.catch((e) => {
console.error(e)
error.value = true
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
不能采用下面这种写法:
因为只有Await组件的渲染被阻塞了,外部的Suspense组件检测到阻塞,会认为内容尚未准备好,于是Suspense到fallback部分才会显示。所以必须在setup顶层等待resolve的执行完成
const init = async () => {
try {
data.value = await props.resolve
} catch (e) {
console.error(e)
error.value = true
}
}
await init()
2
3
4
5
6
7
8
9
10
接收两个插槽
组件的props resolve接收一个promise,这个promise在组件内fullfilled后被通过data传递给父组件(跟render props有点像,也是传递给父组件,不过是通过回调函数)
父组件配合Suspense使用
<template>
<Suspense>
<template #fallback>Please wait...</template>
<Await :resolve="number">
<template #default="slotProps">
<div :style="{ color: 'red' }">Number: {{ slotProps.data }}</div>
</template>
<template #error>
<div>Error</div>
</template>
</Await>
</template>
</Suspense>
</template>
<script setup lang="ts">
const Await: (typeof import('../components/Await.vue'))['default'] = defineAsyncComponent(
() => import('../components/Await.vue')
)
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19