- FetchQueue 自动管理loading等的请求控制容器
- Task 轮训请求的控制
- makeAsyncIter 分页api的迭代管理
- useInfiniteScrolling 无限滚动
- useAntdListPagination / GeneralPagination 翻页管理
desc: 输入输出,网络请求相关的
请求容器,用于控制多个请求的并发,重试,意外处理,自动控制loading,可以大量减少了try catch finally
等代码的使用
//最大并发数量, -1为不限制
maxConcurrencyCount = -1,
// 最大重试次数
maxRetryCount = 3,
// 重试间隔ms
retryInterval = 3_000,
// 错误处理方法,retry | throw
errorHandleMethod: ErrorHandleMethod = 'retry'
class {
/**
* 获取队列配置参数
*/
conf: {
maxConcurrencyCount: number;
maxRetryCount: number;
retryInterval: number;
errorHandleMethod: ErrorHandleMethod;
};
/**
* 等待直到当前的队列为空
*/
waitUntilEmpty(): Promise<void>;
/**
* 添加队列监听器
*/
on (name: EventName, cb: Fn) : void;
/**
* 是否空闲
*/
isIdle: boolean;
/**
* 获取当前正在运行的任务
*/
tasks: ExportFetchTask<R>[];
/**
* 压入一个任务到资源获取队列,如果有提示两个任务的元和任务函数一次则这两次函数的运行会是同一个结果
* @param meta 元标识,且将作为action函数的实参传入
* @param action 资源获取函数
*/
pushAction<R> (action: () => Promise<R>): ExportFetchTask<R>;
/**
* 添加全局监听器
*/
static on (name: EventName, cb: (target: FetchQueue, ...args: any[]) => any) ;
}
type ExportFetchTask<Res> = {
// 正在运行的是哪个任务
readonly action: () => Promise<Res>;
// 运行结果
readonly res: Promise<Res>;
// 任务是否正在运行
readonly running: boolean;
// 取消当前任务
readonly cancel: () => void;
}
const queue = new FetchQueue()
const task = queue.pushAction(fetchUser)
const user = await task.res
const queue = new FetchQueue(1, -1, 0) // 不并发, 不限制重试数量,重试间隔0
queue.pushAction(action0)
queue.pushAction(action1)
queue.pushAction(action2)
queue.pushAction(action3)
await queue.waitUntilEmpty() // 将会按顺序执行所有任务,某个任务失败,会不断尝试直至完成
取消任务很简单直接cancel就行
const task = queue.pushAction(action)
task.cancel()
监听任务被取消,有两种办法,可以直接catch错误判断,或者添加监听器
queue.pushAction(act => {
act.events.on('cancel', handleCancel) // 对于一些上传很有用,可以直接将cancel写在里面,避免往外传变量
})
const task = queue.pushAction(action)
try {
await task.res
} catch (err) {
if (err instanceof FetchTaskCancel) {
handleCancel()
}
}
对于FetchQueue返回的结果类型可以是不固定的,但标识的类型是固定的
const q = new FetchQueue<string>()
const act = q.push(action, 'task-1') // 在第二个参数填入标识
q.push(action, 'task-2')
act.extra === 'task-1' // true
const q = new FetchQueue<{ type: 'upload' } | { type: 'download' }>()
// 标识在区分一大堆任务时很有用
q.task.filter(predicate) // 获取你想查看的任务
想要FetchQueue具有响应式除了借助那个监听事件外还可以直接
const queue = reactive(new FetchQueue())
const queue = ref(new FetchQueue())
,这在vue中很有用
在vue内的话更推荐使用包装过的几个hook,而不是裸FetchQueue。具体的文档hooks部分
- useFetchQueueHelper, 增加了更多有用的函数, 包括vue ref风格的loading。需要传入一个队列实例
- useRetryableQueue, useFetchQueueHelper的可重试参数包装
- useStrictQueue, useFetchQueueHelper的严格参数包装
Task是针对轮训请求的一个封装,主要还是用于各类分析结果的轮训获取。在之前是Task还支持定时在某个时刻去执行action,后来用不到就删除了。
/**
* 任务函数,支持异步
*/
action: () => T | Promise<T>;
/**
* 立即执行还是等下次轮训间隔后再再执行
*/
immediately?: boolean;
/**
* 验证器,action结束后调用,为true时结束当前任务
*/
validator?: (r: T) => boolean;
/**
* 发生错误的错误方法,忽略还是立即停止
*/
errorHandleMethod? : 'stop'|'ignore'
/**
* 轮询间隔,ms
*/
pollInterval: number
返回一个对象,可以通过解构获取以下
clearTask: () => void
completedTask: Promise<T>
task: TaskInst<T>
const { completedTask } = Task.run({
pollInterval: 5_000, // 轮训间隔5000ms
action: getFunnelRes, // 获取漏斗分析结果
validator: v => v.status === 'completed' // 判断是否可以结束轮训,v是action执行完的返回值
})
completedTask.then(res => {
console.log(res) // 这是可用的数据
})
将基于游标分页的请求转成异步迭代资源,旨在提供更高程度的抽象,逻辑层只通过next()和reset()即可完成所有操作。
从jarvis的Pagination到spam的useCursorControl再到lanfan-dashboard的makeAsyncIter对于分页资源控制的探索一直有在尝试,整体是呈现一个类型推导逐渐完善,手动管理的变量逐渐变少,不再需要手动处理意外的趋势。
在makeAsyncIter这里分页资源的控制已经趋近完善,它的思想和前面两个有着较大的差别。前面两个只能说是负责帮你管理cursor,而makeAsyncIter则是让你定义资源获取的方式再暴露给你一个next()函数和一个获取到的资源的引用,让你可以通过next()的调用来进行资源的迭代
interface R {
load: Ref<boolean> // 所有资源是否已加载完成
async next(): void // 向前迭代
res: Ref<T> // 当前迭代到资源
abort(): void // 中断当前请求
loading: Ref<boolean> // 当前是否在加载中
cursorStack: string[] // 保存使用的所有cursor
// 重置内部状态,多资源管理时用得到,如果当前处于迭代中,直接重置会失败,考虑使用force
reset (reFetch: boolean | { force: boolean, reFetch: boolean }): Promise<void>
[Symbol.asyncIterator]: ES2018AsyncIter // for await of 语法
iter: {
[Symbol.asyncIterator]: ES2018AsyncIter
}
}
const { next, res } = makeAsyncIter(cur => fetchRes(cur), resp => resp.val)
await next()
res.value // 第一页的值
await next()
res.value // 第二页的值
await next(0)
res.value // 第一页的值
典型场景tab,keyword...改变。
例如/recipe/search?keyword=xxxx
在tab,keyword...变化后,需要对迭代器的内部状态进行重置,因为对应的cursor增长不一样
const keyword = ref('')
const iter = makeAsyncIter(
cursor => axios.get('/recipe/search', { params: { cursor, keyword: keyword.value }}),
resp => resp.recipes
)
watch([keyword], () => iter.reset(true)) // keyword改变后,重置并重新获取
典型场景例如
- tab切换,在加载还未完成时继续切换
- 获取远程的搜索建议,持续的输入
和上面的一样,这两种场景都是需要reset()
,但是这个是应对请求时间较长的情况,如果你直接reset
会引发断言错误,可以先abort
中断掉之前的请求,或者直接reset({ force: true })
。
但不一定需要上面那种情况,如果觉得某次迭代时间过长,也可以abort
返回之前的状态再重新next
。
makeAsyncIter是针对基于游标分页的请求,为了要获取到cursor的信息,使用了对返回类型进行约束,必须满足以下类型,next,next_cursor存在一个就行,prev同样
export interface PageCursor {
has_next: boolean
has_prev: boolean
next_cursor: string
prev_cursor: string
next: string
prev: string
}
type Response = { cursor: PageCursor }
如果对应的接口不满足,可以参考下面尝试写个转换
const apiCursorNormalizer = <T extends (...args: any[]) => { cursor: customCursor }>(api: T) => {
return (...args: Parameters<T>) => api(...args).then(resp => ({ ...resp, curosr: customCursor2PageCursor(resp.cursor) }))
}
const iter = makeAsyncIter(
apiCursorNormalizer(fetchRecipesCustomCursor),
resp => resp.recipes
)
makeAsyncIter是使用的composition api的风格写法,只不过因为没有钩子和useXXX所以不需要强制与setup同步运行才没有以use开头,这种写法对options api不友好,不能适合直接用需要使用reactive包一层。 其他的基本一致
参考下图
- 在js中
源码位置, 实现和本库的有点微小差别。
源码位置,由于小程序和vue完全不同的响应式系统,使用起来有点差别,同时也抛弃了composition api的风格写法改用了class。
Page({
data: {},
iter: new AsyncIterator(
cursor => pagedCollectedBoards({ cursor, size: 10 }),
resp => resp.content.cells,
{ dataUpdateStrategy: 'merge' } // 无限滚动要保留之前获取的资源所以选择merge
),
onLoad () {
this.iter.bindPage(this) // 绑定页面,为了在迭代器状态变化时通知页面
this.iter.next() // 进行首次加载
},
onReachBottom () {
this.iter.next() // 滚到底部时继续加载
}
})
// next(), reset() , abort() 与vue3版本一致,用法参考vue3版本
模板内存在res,loading,completed3个没写明在data中的变量,在后续部分会介绍
<view class="container">
<fav-item wx:for="{{res}}" wx:key="id" cell="{{item}}"></fav-item>
<view class="loading-bar" wx:if="{{loading}}">
<image src="https://s.chuimg.com/upload/fe7c0b86-2e97-11e5-a56d-e0db5512b208.gif" />
</view>
</view>
<view wx:if="{{completed}}" class="end-hint">
-- 到底了 --
</view>
直接通过this.iter.state
来获取,有足够完善的类型推导
小程序并没有类似vue的响应式值,所有要如何去通知页面更新这块需要单独写,这边使用最简单的回调实现。
asyncIter
内部有个值stateUpdatedCallback
,在asyncIter
状态变化后将会调用它。
asyncIter
的状态包括3种,任意一个改变都会触发回调
- loading 迭代器是否处于加载中
- completed 迭代器是否加载完成,对应主版本中的load
- res 迭代后获取到资源
this.iter.setStateUpdatedCallback(() => {
this.setData(this.iter.state) // 将会把loading,completed,res隐式的更新到this.data上。即使你没在page.data里面写
}) // 在模板中 {{res}} {{completed}} 使用
// 不嫌麻烦也可以
this.iter.setStateUpdatedCallback(() => {
const { res, laoding, completed } = this.iter.state
this.setData({ list: res, pending: loading, hasMore: !completed }) // list, pending,hasMore为data中写明
}) // 在模板中 {{list}} {{hasMore}} 使用
是setStateUpdatedCallback
的进一步简化,需要注意的是setStateUpdatedCallback
和bingPage
同时只生效一个
this.iter.bindPage(this) // 在模板中 {{res}} {{completed}} 使用
this.iter.bindPage(this, 'recommend') // 在模板中 {{recommend.res}} {{recommend.completed}} 使用
然后无论是使用setStateUpdatedCallback
还是bingPage
,在脚本文件中我都不推荐使用this.data.res
取获取状态,而是应该this.iter.state.res
。
在vue3,vue2中我们直接
await iter.next()
await nextTick()
// 现在就已经界面更新完成
而在小程序中不是nextTick可以通过setData的回调来实现,因此可以这样
this.iter.setStateUpdatedCallback(() => {
this.setData(this.iter.state, () => {
// do something
})
})
直接使用的makeAsyncIter的场景并不多,makeAsyncIter是对分页资源的一种可迭代的抽象。 日常中更多的是使用针对不同场景使用不同的适配,makeAsyncIter与这些的关系有点类似zrender和echarts
useInfiniteScrolling是针对无限滚动做的一个适配,包含了两种触发模式,探底触发和交叉触发。
探底触发适用于整个页面向下滚动,页面滚动到底部达到一定阈值是进行资源迭代,场景例如厨房装备页的滚动到底部加载。
const { loading, res, observe, reset } = useInfiniteScrolling(
cursor => getPagedRecipe({ cursor }),
resp => resp.recipes, { type: 'reach-bottom', threshold: 300 } // threshold 触发阈值,可空默认500
)
<ul>
<li v-for="item in res ?? []" :key="recipe.id">{{item.data}}</li>
</ul>
交叉触发适用于只是页面中的一部分进行滚动,当监听目标dom与根dom交叉时进行资源迭代,我们的后台项目大多用的这种,例如lanfan-dashboard的菜谱搜索组件和定制餐单的历史列表页。
const root = ref<HtmlDivElement>()
const { loading, res, observe, reset, load } = useInfiniteScrolling(
cursor => getPagedRecipe({ cursor }),
resp => resp.recipes, { type: 'intersection', root } // root可空,默认使用文档视口
)
<div ref="root">
<ul>
<li v-for="item in res ?? []" :key="recipe.id">{{item.data}}</li>
</ul>
<div :ref="observe" style="text-align:center"> {{ load ? '结束' : '加载中...'}} </div>
</div>
如果说需要在获取到的前后做一些事情,可以实现通过传一个hooks的对象
interface InfiniteScrollingHooks {
iterationPre?: () => Promise<void>
iterationPost?: () => Promise<void>
}
// 再
await hooks.iterationPre?.()
await iter.next()
await hooks.iterationPost?.()
useAntdListPagination是makeAsyncIter针对翻页做的一个适配,与GeneralPagation组件搭配使用,可以很容易写的出来一个翻页的组件
const { loading, pagination, res, reset } = useAntdListPagination(
cursor => PlatformProjectClient.paged({ cursor, keyword: keyword.value }),
resp => resp.projects
)
<a-table :data-source="res ?? []" row-key="id" :pagination="false" />
<general-pagination :option="pagination" />