Vue中的异步数据处理的探索 - useAsyncData

787 阅读9分钟

引言

useAsyncData 函数提供了一种简便的方式来处理 Vue 组件中的异步数据获取和状态管理。通过组合式 API 和响应式引用,开发者可以轻松地在 Vue 组件中使用这个函数来管理异步数据。

1. 为什么需要这个工具函数

在日常的开发中,我们对接服务端展示数据是最经常写的代码,我们会经常写如下的代码

const data = ref([])
cosnt params = reactive({
    page: 1,
    pageSize: 20
})

const fetchData = async () => {
  const res = await API.fetchData(params))
  data = res.list
}

watchEffect(fetchData)

上述代码逻辑上可分为:

  1. 定义接受数据的ref
  2. 写一个fetchData去服务端获取数据并赋值给ref
  3. 最后使用watchEffect去触发请求,当依赖变更时候也重新触发请求更新数据

既然是这么通用的逻辑,那么应该能够用一定的方式去简化,而且上述代码有一个问题,就是即便fetchData返回的数据时有类型的,我们还需要再定义data时候去显式声明类型,这也是让我觉得不太合理的地方,所以就想着写一个方法去把这个逻辑封装起来,那就是useAsyncData

有了useAsyncData的代码如下

cosnt params = reactive({
    page: 1,
    pageSize: 20
})

const { data } = useAsyncData(async () => {
  const res = await API.fetchData(params))
  return res.list 
}, { init: [] })

这段代码和上面的功能是等价的,useAsyncData返回一个Ref类型的data,当依赖params发生变化时候useAsyncData会重新执行,请求最新的数据赋值给data这个ref

有了这个工具,就可以一步到位将异步数据变成ref。如果用一句话来描述这个useAsyncData,那它就是一个支持异步的computed

2. useAsyncData的基本实现

useAsyncData需要接受一个异步函数,告诉它数据是怎么去获取的,最基本的实现如下

export const useAsyncData = <T>(
  fetchFunc: () => Promise<T>,   // 将如何获取数据的异步函数传入
  options?: {
    init?: T; // 传入初始化的值
  }
) => {
  const { init } = options || {};

  // 初始化ref
  const sourceData = ref<T>(init);
  
  // 调用获取数据函数请求函数,将结果对ref赋值
  const fetchData = async () => {
    let data = await fetchFunc();
    sourceData.value = data;
  };
  watchEffect(() => fetchData());
  
  return {
    data: sourceData,
  };
};

3. useAsyncData的拓展功能

在实际工作中,我们面临的问题不只是请求数据后展示,还有其他问题

对返回数据进行二次处理的enhancer

小明在新的需求中,又把上述代码改了改

const activeTab = ref<1 | 2>(1)

const { data } = useAsyncData(async () => {
  const res = await API.fetchData(params))
  const { tab1data, tab2data } = res
  // 通过判断当前的activeTab是多少去选择目标数据
  return activeTab.value === 1 ? tab1data : tab2data
}, { init: [] })

但是我们从上一部分的简单实现中可以发现问题,如果定义数据是在await之后,那其实状态是订阅不到的,这个时候,如果activeTab发生变化,实际是不会触发data的改变的,所以小明改成了下面的写法,先获取activeTab的值,这样我们的useAsyncData里面的watchEffect就能订阅到状态变化了

const activeTab = ref<1 | 2>(1)

const { data } = useAsyncData(async () => {
  const targetIndex = activeTab.value
  const res = await API.fetchData(params))
  const { tab1data, tab2data } = res
  return targetIndex === 1 ? tab1data : tab2data
}, { init: [] })

但是这样出现了一个新的问题,如果params不变,只是activeTab改变,也会发起请求,这次请求其实是没必要的,所以小明又改成了下面的写法

cosnt params = reactive({
    page: 1,
    pageSize: 20
})
const activeTab = ref<1 | 2>(1)

const { data } = useAsyncData(() => {
  return API.fetchData(params))
}, { init: { tab1data: [], tab2data: [] } })

const targetData = computed(() => {
  const { tab1data, tab2data } = data.value
  return activeTab.value === 1 ? tab1data : tab2data
})

这个时候小明又觉得这种情况很常见,觉得需要给useAsyncData加一个enhancer,之后希望,让其能在获取完数据后对数据进行二次处理

cosnt params = reactive({
    page: 1,
    pageSize: 20
})
const activeTab = ref<1 | 2>(1)

const { data } = useAsyncData(() => {
  return API.fetchData(params))
}, { 
  init: { tab1data: [], tab2data: [] },
  enhancer: res =>  activeTab.value === 1 ? res.tab1data : res.tab2data,
  // enhancer2,enhancer3也是支持的,支持多个enhancer共同对原数据进行处理
})

小明写完这个后觉得,这样写组件内要展示的数据状态就只有一个data,把没必要的操作内聚于useAsyncData之中,十分的满意

请求数据的状态

在实际工作中,小明又被提了个需求,页面加载太慢,需要加个loading,小明又觉得这是一个通用的功能,把该功能内聚到useAsyncData之中,实现就是在请求前后加个状态记录,这里就不赘述了,只列出了如何使用

cosnt params = reactive({
    page: 1,
    pageSize: 20
})

const { data, isLoading } = useAsyncData(() => {
  return API.fetchData(params))
}, { 
  init: { tab1data: [], tab2data: [] }
})

useAsyncData的状态管理功能

问题很多的小明,在某一天的工作中,又遇到了一些问题,有一份数据需要在多个组件中用到,发现这是个老生常谈的组件间状态通讯的问题,心想着要是useAsyncData能直接写在外层,而不是写在某一个组件中就好了,之后就兴致勃勃地试了下,确实是可以的

./src/state/index.ts

const { data: userData } = useAsyncData(() => {
  return API.fetchUserData())
}, { init: {} })

得益于vue的响应式数据格式ref、reactive、computed等不是强依赖于组件,就是在组件外面也可以直接使用,所以机遇这些响应式数据的useAsyncData,也可以直接在js中使用,在需要用该状态的组件中直接使用就可以,就像下面,直接在两个不同的页面使用该响应式数据,当数据有更新时,这两个页面也会触发更新

./src/views/page1.vue

<template>
    <div>用户名称:{{userData.userName}}</div>
</template>

./src/views/page2.vue

<template>
    <div>页面2</div>
    <div>用户姓名:{{userData.userName}}</div>
</template>

但这样使用的话,按照一开始的设计,useAsyncData一初始化完就会触发请求,虽然对于用户信息这个数据,在页面加载时候,全局请求一份是没问题的,但更多的数据都是在页面加载时候才会使用到,所以想法很多的小明想优化一下useAsyncData,让数据只有在用到的时候才去请求。小明挠头思考,突然想起之前看vue源码时候,computed是惰性的,只有使用到的时候才会去计算值,这不,灵感立马来了,优化如下

export const useAsyncData = <T>(
  fetchFunc: () => Promise<T>,
  options?: {
    init?: T;
  }
) => {
  const { init } = options || {};
  const sourceData = ref<T>(init);
  let curRequestTime = 0;
  // 多个了状态标记是有组件使用过
  const hasUseIt = ref(false);

  const computedData = computed<Y>(() => {
    // 使用computed对原状态做一次代理,当被使用过时候,把hasUseIt变为true
    hasUseIt.value = true;
    return sourceData.value as Y;
  });

  const fetchData = async () => {
    // hasUseIt不是为true,这时候就不请求了
    if (!hasUseIt.value) return;
    const requestTime = curRequestTime + 1;
    curRequestTime = requestTime;
    let data = await fetchFunc();
    if (curRequestTime !== requestTime) return;
    sourceData.value = data;
  };

  watchEffect(() => fetchData());

  return {
    data: computedData,
  };
};

刷新数据和获取实时数据

又是一个阳光明媚的早晨,小明接到一个给用户报名获取的需求,那页面就是一个按钮,用户点击时候先触发登录,上面的文案根据用户是否报名显示立即报名或者已报名成功

这是一个用户登录相关的文件

export const isLogin = ref(false)

export const userLogin = () => {
    // 一个用户登录的方法,此处省略1000行
    // 用户登陆完会把 isLogin 变成 true
}
<template>
    <button @click=“handleSignUp”>{{ hasSignUp ? "查看活动" : "立即报名" }}</button>
</template>
<script>
import { islogin, userLogin } from '@/src/core/login' // 就是上面的代码

// 获取用户是否已经报名的状态
const { data: hasSignUp, getRealTimeData: getRealTimeHasSignUp, refresh: refreshHasSignUp } = useAsyncData(async () => {
    if(isLogin.value === false) return;
    const res = await API.fetchUserHasSignUp()
    return res.isSignUp;
})

const handleSignUp = () => {
    userLogin() // 当我们调用用户登录,登录成功时候会把islogin这个ref变量变成true,此时上面hasSignUp的useAsyncData会触发请求
    // 下面如果我们直接调用hasSignUp.value, 获取到一定是false,这时候API.fetchUserHasSignUp正在请求呢
    // 所以我们要调用 getRealTimeHasSignUp, 这个方法就是一个promise,如果状态正在请求,会等待状态请求完毕,返回一个最新的状态
    const _hasSignUp = await getRealTimeHasSignUp()
    if(_hasSignUp) rerurn // 已经报名就无需进行任何操作
    // 没报名我们就触发报名
    await API.handleSignUp()
    // 报名后更新报名状态, 这样页面的按钮状态就回变成“查看活动”
    refreshHasSignUp()
}
</script>

通过上面的实践,小明又给useAsyncData新增了两个方法,一个是能获取实时状态的getRealTimeData,该方法会等待请求完毕才返回最新数据;另一个是refresh,能够触发useAsyncData去重新请求,更新最新的状态

竞态问题的控制

小明在某一个愉快的早晨,被测试同学告知,他写的代码在页面切换过快的时候,会偶现内容不对的情况,有着多年代码经验的小明,立马想到网络请求返回的时间不好控制,应该是后面一次改变page触发的请求反而先返回,前一次page改变触发的请求晚返回,旧内容反而是覆盖了新的内容,他里面对useAsyncData做了一次优化

export const useAsyncData = <T>(
  fetchFunc: () => Promise<T>,
  options?: {
    init?: T;
  }
) => {
  const { init } = options || {};
  const sourceData = ref<T>(init);
  let curRequestTime = 0;  // 使用curRequestTime来标志当前是第几次触发请求

  const fetchData = async () => {
    const requestTime = curRequestTime + 1; // 将curRequestTime+1并保存到局部变量
    curRequestTime = requestTime;
    let data = await fetchFunc();
    if (curRequestTime !== requestTime) return; // 如果requestTime和curRequestTime不一致则无需变更数据
    sourceData.value = data;
  };
  watchEffect(() => fetchData());
  return {
    data: sourceData,
  };
};

4. 功能总结

小明经过九九八十一难,不断完善这个函数,功能不断丰富,迫不及待跟大家介绍一下

入参

参数功能描述
fetchFunc异步数据获取的方式
options.enhancer数据处理器,还有enhancer2,enhancer3总共三个数据处理器,没能合成一个数组是因为这样写才有类型,写成数组加上enhancer后类型没有了,三个也够用了
options.init初始化值
options.editable是否生成一份可编辑的副本,用于表单操作,默认false
options.lazy是否懒加载,在vue组件setup中默认为false,其它默认为true,只有当数据使用时候才触发fetchFunc获取

出参

返回值功能描述
data计算属性,返回经过一系列增强器处理后的数据
editableData可编辑的数据副本,用于表单操作等
isLoading计算属性,表示当前是否正在加载数据
isError计算属性,表示当前是否出错
refresh刷新数据的方法,重新调用fetchFunc获取最新数据
getRealTimeData异步获取实时数据的方法。如果数据正在加载,将等待加载完成后再返回数据
getInitState获取初始化状态的方法

最后,我发了个简单实现的npm包, 直接安装就可以使用,有问题可与我反馈哈

npm i vue-use-async-data

实现代码在 github.com/jackeryjam/… 有需要的小伙伴可以自取