前端体验优化之异步数据
在单页应用中,往往需要请求异步数据来渲染页面,开发者需要管理各种数据的加载、loading和异常处理,本文整理了这些场景下常见的问题,以及一些解决方案。
参考
- Vue Suspense — Everything You Need to Know
- Vue Suspense 官方文档
- 理解 React 的下一步:Concurrent Mode 與 Suspense,写的非常好
- [译] 为什么 React Suspense 将会逆转 Web 应用开发的游戏规则 ?
依赖异步数据
背景
在业务中,有的组件在初始化时就需要加载数据,等待数据加载完毕后才会渲染,常规的做法是通过状态来驱动 UI 渲染,在组件首次渲染的同时去请求数据,这种做法也被称为Fetch-on-Render
, componentDidMount
或是 useEffect
均是如此
function Page(){
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser().then(data => setUser(data))
}, [])
if(!user) {
return (<div>loading user...</div>)
}
return (<div>
<h1>Hi, {user.name}</h1>
<List ></List>
</div>)
}
function List(){
const [list, setList] = useState([])
useEffect(() => {
fetchList().then(data => setList(data))
}, [])
if(!list.length) {
return (<div>loading list...</div>)
}
return (<div>
{{list.length}}
</div>)
}
这段代码存在一些很明显的问题,在userstate
正在加载的时候,会阻塞List
组件的渲染,整个流程类似于
fetchUser().then(fetchList);
但实际上这两个数据状态是可以并行去请求的,这样可以加快List组件的渲染。
将串联请求改成并行请求,可以用Promise.all
,于是可以用到状态提升
function Page() {
const [user, setUser] = useState(null);
const [list, setList] = useState([]);
useEffect(() => {
Promise.all([fetchUser(), fetchList()]).then(([user, list]) => {
setUser(user);
setList(list);
});
}, []);
return (
<div>
<UserInfo user={user} />
<List list={list}></List>
</div>
);
}
function UserInfo({ user }) {
if (!user) {
return <div>loading user...</div>
}
return <h1>Hi, {user.name}</h1>
}
function List({list}){
if(!list.length) {
return (<div>loading list...</div>)
}
return (<div>
{{list.length}}
</div>)
}
Promise.all 存在的问题是必须要等到两个请求都完成才能渲染数据,在某些希望尽快渲染各自状态的组件中可能不太好,可以再改一下
function Page() {
const [user, setUser] = useState(null);
const [list, setList] = useState([]);
useEffect(() => {
fetchUser().then(data => setUser(data))
fetchList().then(data => setList(data))
}, []);
// ...
}
我们的项目里面可能充斥着各种类似的代码,
- 设置一个state
- 请求接口,根据接口返回值setState,
- 根据state的值渲染加载中、加载成功或失败的UI
- 存在多个接口返回不同的state时,需要手动处理各个state之间的加载顺序和渲染状态
那么有没有一种通用的解决方案呢?让我们来看看Suspense
吧!
解决方案
Suspense是一种通信机制,相当于告诉框架等待异步请求完成之后自动渲染对应的UI。
其原理是将异步资源对应的promise通过throw
抛出,框架会通过ErrorBoundary
向上找到最近的Suspense,然后根据这个promise对应的状态,展示fallback或者resolve的内容。
直接看代码
function createFetchApi(promise) {
let data;
promise.then((ans) => {
data = ans;
});
return function fetchData() {
if (data) return data;
// 注意这里抛出的是一个Promise
throw promise;
};
}
const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
async function fetchUser() {
await sleep(400);
return "data user";
}
async function fetchList() {
await sleep(1000);
return "data list";
}
function User({ api }) {
const data = api();
return <p>user :{data}</p>;
}
function List({ api }) {
const data = api();
return <p>list: {data}</p>;
}
function App() {
const userApi = useMemo(() => createFetchApi(fetchUser()), []);
const listApi = useMemo(() => createFetchApi(fetchList()), []);
return (
<Suspense fallback={"loading user"}>
<User api={userApi} />
<Suspense fallback={"loading list"}>
<List api={listApi} />
</Suspense>
</Suspense>
);
}
代码里面有两个请求,但是没有任何跟网络请求响应相关的setState
了!
通过Suspense
的嵌套,我们可以很轻松的管理各个组件依赖的响应顺序
观察一下网络请求,是在初始化应用的时候就同时发送出去了。
如果fetchUser先响应,则展示顺序为 loading user -> user组件、loading list -> user组件、list组件
如果fetchList先响应,则会先等待fetchUser响应完成,展示顺序为 loading user -> user组件、list组件
这种写法最本质的改动是我们将网络请求的发送和响应完全分开了:请求可以在最优先的时机就发送出去,而响应的处理由Suspense
嵌套解决了,我们只需要关注UI的渲染,无需再关心响应后的数据处理。
小结
Suspense可以让网络请求尽可能提前,避免Fetch-on-Render
的问题。
处理数据竞态
数据竞态 Race Condition
是业务网络请求中另外一个比较常见的问题。这个名词可能比较陌生,但下面这个场景大家应该都比较熟悉
背景
建设存在一个组件,有一个props为id,在创建组件时,会根据id请求数据并渲染
const ItemDetail = ({ id }) => {
const [data, setData] = useState();
useEffect(() => {
fetchItemDetail(id).then((data) => {
setData(data);
});
}, [id]);
if (!data) {
return <div>loading</div>;
}
return <div>data: {data}</div>;
};
看起来一切顺利,但当id切换时就可能发生一些异常现象。
比如从id 1切换到id2,但fetchItemDetail(2)
比fetchItemDetail(1)
先响应,展示的UI就错误了,如果是通过redux等托管的全局状态,则整个应用的state都会出现异常。
由于存在这些异步等待接口响应之后再渲染的场景(在前端中非常常见),为了解决这些”竞态“问题,我们不得不编写额外的代码来保证代码的健壮性。
比如使用一个额外的变量
const ItemDetail = ({ id }) => {
const [data, setData] = useState();
const idRef = useRef()
useEffect(() => {
idRef.current = id
fetchItemDetail(id).then((data) => {
setData(data);
});
}, [id]);
// 确保本次渲染的数据是符合预期的
if (!data || idRef.current !== id) {
return <div>loading</div>;
}
return <div>data: {data}</div>;
};
或者直接取消更新操作
const ItemDetail = ({ id }) => {
const [data, setData] = useState();
useEffect(() => {
let cancel = false
fetchItemDetail(id).then((data) => {
if(!cancel) {
setData(data);
}
});
// 组件卸载时取消setState
return ()=>{
cancel = true
}
}, [id]);
if (!data) {
return <div>loading</div>;
}
return <div>data: {data}</div>;
};
还有一种比较简单的方式:通过不同的key来避免组件复用,虽然这会导致重新渲染组件的一些性能消耗。
<ItemDetail id={id} key={id}></ItemDetail>
还有一些其他的黑魔法,比如节流、cancelToken取消上一次网络请求等操作,这里就不再展开了。
思考一下竞态问题出现的本质,实际上是数据请求的promise和UI渲染是完全无感知导致的,他们之间唯一的联系是promise完成之后的setState。如果在前一个promise还未完成之前,错误的执行了下一次setState导致的render,就会出现渲染错误的情况。
那么有没有其他更优雅的解决方案呢?答案也是suspense
解决方案
Suspense是通过监听promise的完成再渲染对应的children组件,相当于promise和UI渲染是绑定在一起的,从本质上就避免了竞态问题出现的条件。
直接看代码
const ItemDetail = ({ api }) => {
const data = api()
return <div>data: {data}</div>;
};
const initApi = ()=>createFetchApi(fetchItemDetail(1))
const RaceDemo = () => {
const [api, setApi] = useState(initApi);
const toggleItemId = (id) => {
const _api = ()=>createFetchApi(fetchItemDetail(id))
setApi(_api)
};
return (
<div>
<div>
<button onClick={toggleItemId.bind(null, 1)}>id 1</button>
<button onClick={toggleItemId.bind(null, 2)}>id 2</button>
</div>
<div>
<Suspense fallback="loading">
<ItemDetail api={api}></ItemDetail>
</Suspense>
</div>
</div>
);
};
这样,ItemDetail现在只承担渲染数据的功能,没有state,也就不用在考虑数据竞态等问题了~
小结
Suspense将promise与UI render关联在一起,避免了数据竞态的问题。
loading管理
参考
数据加载时的loading
在发送请求至数据返回之前,我们希望给用户展示一个加载动画。
但如果用户的网络速度比较快,只展示一个一闪而过的loading,从体验上来看比不展示loading更差。
与loading问题相似的场景是状态切换:比如一个卡片默认展示状态1,然后会根据接口响应展示不同的状态,如果接口返回的状态与状态1不一致,卡片就会出现状态闪烁切换,比如从【立即购买】变成【已购买】,用户体验也不太好。
这些问题常见的解决办法是通过一个标志位,当接口请求完成后再修改数据,再此之前均展示loading动画
然后通过Promise.all
来控制loading展示的最短时间。
// 这表示至少会展示一个500ms的加载动画
const task = await Promise.all([
sleep(500),
fetchData()
])
很显然,这并不是最好的实现方式,如果接口只需要100ms就请求完了,用户需要额外花费400ms的等待时间来观赏loading动画~
不展示loading动画好像也不太行,我们没法预知接口实际耗时,如果用户长时间看见白屏,体验也比较差。
可以通过Promise.race
来控制超过某个时间才展示loading动画
const task = await Promise.race([
timeout(100),
fetchData()
])
如果fetchData在100ms内返回,就不会展示动画。
于是可以考虑一下折中方案:比如希望在接口响应大于100ms之后,展示一个至少400ms的loading动画
export const sleep = (delay) =>
new Promise((resolve) => setTimeout(resolve, delay));
export const timeout = (ms) =>
new Promise((_, reject) => setTimeout(() => reject("timeout"), ms));
export async function requestWithLoading(promise, min = 100, max = 400) {
Promise.race([timeout(min), promise]).catch((e) => {
if (e === "timeout") {
showLoaidng();
return Promise.all([sleep(max), promise]);
}
throw e;
});
}
相较于单纯的loading动画而言,骨架屏更能够让用户感觉到”真实的“数据已经提前加载完成,这样的视觉体验要更好一些。
页面切换时的loading
在SPA中,router-view往往会作为页面全局的根节点,根据不同的url渲染出不同的View。从设计上是没有问题的,但从体验上,就存在一些问题了。
最常见的就是从页面A点击按钮跳转到页面B之后,页面A渲染的所有视图都被销毁,而页面B如果还需要等待异步数据完成之后才能渲染,用户在此期间看见的就是空白页面,为了弥补用户体验,又得加上一个loading动画。
也许保留A页面,直到B页面依赖的数据加载完成,跳过中间那个过渡的loading动画是不是更好一点?
async function fetchNextPageData(){
setLoading(true)
await nextPage.fetchData()
setLoading(false)
jumpToPage(nextPage)
}
// 通过isLoading变量在当前页面展示loading状态,下一个页面渲染完成后直接跳过去
<button onClick={fetchNextPageData} spin={isLoading}> to nextpage </button>
这个思路是美好的,但在组件化开发中,每个组件依赖的数据是放在组件内部去管理的,也就是前面提到的Fetch-on-Render
。要实现提前请求数据,就需要打破这种习惯,比如将数据请求提升至路由组件公共的父组件,通过更新 route view的方式来加载数据也渲染页面。
React提供了useTransition
来处理类似的问题,大概使用方式是
const [resource, setResource] = useState(initialResource);
const [startTransition, isPending] = useTransition({
timeoutMs: 3000,
});
<button
onClick={() => {
startTransition(() => {
const nextUserId = getNextId(resource.userId);
setResource(fetchProfileData(nextUserId));
});
}}
>
请求合并
多个组件发送了相似的请求,可以进行合并。
举个例子,现在需要展示一个卡片列表,每个卡片组件自己负责请求其依赖的数据,拿上面的ItemDetail
举例
const ItemDetail = ({ id }) => {
const [data, setData] = useState();
useEffect(() => {
let cancel = false
fetchItemDetail(id).then((data) => {
if(!cancel) {
setData(data);
}
});
// 组件卸载时取消setState
return ()=>{
cancel = true
}
}, [id]);
if (!data) {
return <div>loading</div>;
}
return <div>data: {data}</div>;
};
如果后台提供了一个根据id列表批量查询数据的方法,就可以将这些请求合并起来了,统一在父组件请求列表,然后分别渲染对应的数据。
但如果有某些特殊的原因,无法使用状态提升,必须由组件自己发送网络请求,还能够利用这个批量接口合并请求吗。有一些取巧的方法
export function createMergeRequest(api, mergeParams, filterResult) {
let timer
let cacheList = []
return function getData(params) {
return new Promise(resolve => {
clearTimeout(timer)
timer = setTimeout(() => {
const params = mergeParams(cacheList.map(row => row.params))
api(params).then(list => {
cacheList.forEach(({ resolve, params }) => {
const res = filterResult(list, params)
resolve(res)
})
cacheList = []
timer = null
})
}, 50)
cacheList.push({ resolve, params })
})
}
}
// 测试
const fetchItemDetailApi = createMergeRequest(
fetchItemDetail,
paramsList => {
return { names: paramsList.join(',') }
},
(res, params) => {
const { data } = res
return data.find(row => row.name === params)
}
)
然后将组件里面的fetchItemDetail
替换成新的fetchItemDetailApi
就可以了,原理也很简单,通过一个定时器,拦截某个时间段内发送的所有请求,合并成统一的请求。
根据这个思路,也可以合并参数完全相同的请求,比如一个页面,侧边栏、顶部菜单栏等组件都调用了获取用户信息接口的场景,就可以通过这种方式减少一次没必要的网络请求。
小结
本文主要整理了前端业务场景中常见的使用异步数据的场景问题
- 管理组件之间的数据依赖
- 数据竞态导致的渲染错误
- 数据加载和页面切换时的loading
- 如何合并重复或相似的请求
其中很多解决方案都涉及到了Suspense
,是React、Vue都尚未完全stable的特性,其中关于关联promise和UI的思想,还是很值得我们去研究一下的。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。