前端体验优化之异步数据

在单页应用中,往往需要请求异步数据来渲染页面,开发者需要管理各种数据的加载、loading和异常处理,本文整理了这些场景下常见的问题,以及一些解决方案。

<!--more-->

参考

1. 依赖异步数据

1.1. 背景

在业务中,有的组件在初始化时就需要加载数据,等待数据加载完毕后才会渲染,常规的做法是通过状态来驱动 UI 渲染,在组件首次渲染的同时去请求数据,这种做法也被称为Fetch-on-RendercomponentDidMount 或是 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吧!

1.2. 解决方案

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的渲染,无需再关心响应后的数据处理。

1.3. 小结

Suspense可以让网络请求尽可能提前,避免Fetch-on-Render的问题。

2. 处理数据竞态

数据竞态Race Condition是业务网络请求中另外一个比较常见的问题。这个名词可能比较陌生,但下面这个场景大家应该都比较熟悉

2.1. 背景

建设存在一个组件,有一个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

2.2. 解决方案

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,也就不用在考虑数据竞态等问题了~

2.3. 小结

Suspense将promise与UI render关联在一起,避免了数据竞态的问题。

3. loading管理

参考

3.1. 数据加载时的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动画而言,骨架屏更能够让用户感觉到”真实的“数据已经提前加载完成,这样的视觉体验要更好一些。

3.2. 页面切换时的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));
    });
  }}
>

4. 请求合并

多个组件发送了相似的请求,可以进行合并。

举个例子,现在需要展示一个卡片列表,每个卡片组件自己负责请求其依赖的数据,拿上面的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就可以了,原理也很简单,通过一个定时器,拦截某个时间段内发送的所有请求,合并成统一的请求。

根据这个思路,也可以合并参数完全相同的请求,比如一个页面,侧边栏、顶部菜单栏等组件都调用了获取用户信息接口的场景,就可以通过这种方式减少一次没必要的网络请求。

5. 小结

本文主要整理了前端业务场景中常见的使用异步数据的场景问题

  • 管理组件之间的数据依赖
  • 数据竞态导致的渲染错误
  • 数据加载和页面切换时的loading
  • 如何合并重复或相似的请求

其中很多解决方案都涉及到了Suspense,是React、Vue都尚未完全stable的特性,其中关于关联promise和UI的思想,还是很值得我们去研究一下的。