讨论还请到原 github issue 下:https://github.com/LeuisKen/l...
什么是异步迭代器
关注tc39或者通过其他渠道关注JavaScript发展的同学应该早已注意到了一个新的草案:proposal-async-iteration。该草案在本文成文时,已经进入了ECMAScript® 2019规范,也就是说,成为了JavaScript语言本身的一部分。这项草案就是我本文中,我将要提到的异步迭代器(Asynchronous Iterators)
。
这个新的语法,为之前的生成器函数(generator function)提供了异步的能力。举个例子,就是下面这样。
// 之前的生成器函数
function* sampleGenerator(array) {
for (let i = 0; i < array.length; i++) {
yield array[i];
}
}
// 现在的异步生成器函数,让我们可以在生成器函数前面加上 async 关键字
async function* sampleAsyncGenerator(getItemByPageNumber, totalPages) {
for (let i = 0; i < totalPages; i++) {
// 这样我们就能在里面使用 await 了
yield await getItemByPageNumber(i);
}
}
业务场景
我们学习新的东西,必然是要伴随着业务价值的。因此我去学习异步迭代器,自然也是为了解决我在业务中所遇到的问题。接下来我来分享一个场景:
在移动端,经常会有滑到页面底部,加载更多的场景。比如,我们在浏览新闻的时候,选择一个分类,就能看到对应分类的很多新闻,这些新闻通常是新的在前,旧的在后,顺序的排列下来。例如,百度新闻:https://news.baidu.com/news#/
本质上,这是一个分页器。通常的实现是,前端向服务端发送一个带有指定类别、指定页码(或者时间戳)的数据请求,服务端返回一个数据列表,该列表长度通常是固定的。然后前端在拿到这部分数据后,将数据渲染到视图上。值得我们注意的是,在这个场景下,因为是用户滑动到底部,触发对下一页的加载,所以是不存在从第一页跳到第五页这种跳页的需求的。
我们也许会用这样的代码来实现这个需求:
let page = 1; // 从第一页开始
let isLastPage = false;
function getPage(type) {
$.ajax({
url: '/api/list',
data: {
page,
type
},
success(res) {
isLastPage = res.isLastPage; // 是否为最后页
// 根据 res 更新视图
page++;
}
})
}
// 用户触发加载的事件处理函数
function handleLoadEvent() {
if (isLastPage) {
return;
}
getPage('推荐');
}
不去管一些其他的实现细节(如,throttle、异步竞态),这段代码虽然不甚优雅,但是足够实现我们的业务需求了。
需求总是会变的
假设不久之后,我们接到了一个新的需求,我们业务中的某两个(或者三个、四个)类别的列表需要在同一个页面上展示。也就是说,数据的映射关系,发生了如下改变:
方案设计
让我们先思考一下:如何去合并列表数据,让我们的列表还能像之前一样保证有序?为了方便讨论,我在这里抽象出两个数据源A、B,他们里面的内容是两个有序数组,如下所示:
A ---> [1, 3, 5, 7, 9, 11, …]
B ---> [0, 2, 4, 6, 8, …]
那么我们预期的合并后列表就是:
merged ---> [0, 1, 2, 3, 4, 5, 6, …]
假设我们每次分页去取数据,预期的数据长度(记为:pickNumber)是3,那么我们在第一次取数据后,回调中预期请求到的值就是[0, 1, 2]
。那么如果我们从A中拿3个,B中也拿3个,那么排序后,从排序的结果中取3个,就拿到了我们想要的[0, 1, 2]
。要取出合并后列表中有序的pickNumber
个数据,就先从各个数据源中取pickNumber
个数据,然后对结果排序,取出前pickNumber
个数据,这就是我所选择的保证数据有序的策略。
这个策略,在一些极限情况下,比如合并后列表的前几页都是A等等,都是可以保证顺序的。
实现设计
方案确定后,我们来设计下我们要实现的函数,很自然的,我们会想到这样的实现:
/**
* 从多个 type 列表中获取数据
*
* @param {Array} types 需要合并的 type 列表
* @param {Function} sortFn 排序函数
* @param {number} pickNumber 每页需要的数据
* @param {Function} callback 返回页数据的回调函数
*/
function getListFromMultiTypes(types, sortFn, pickNumber, callback) {
}
这样的实现,做出来其实也是可以满足业务需求的,但是他不是我想要的。因为type
这个东西和业务耦合的太严重了。当然,我可以把types
改成urls
,但是这种程度的抽象,还是需要我们把$.ajax
这个东西内置到我们的函数里,而我想要的仅仅只是一个merge
。所以,我们还是需要去追求更好的形式来抽象这个业务。
追求更好的抽象
下面我把前面的A和B换一种形式组织起来,如果我们忽略掉他们其实是异步的东西的话,其实他们可以被抽象为二维数组:
// A
[
[1, 3, 5],
[7, 9, 11],
…
]
// B
[
[0, 2, 4],
[6, 8, 10],
…
]
抽象成了二维数组,我们可以发现只要去迭代A、B,我们就可以获得想要的数据了。也就是说,A和B其实就是两个不同的迭代器。加上异步的话,那么一个分页的服务端列表数据源,在前端可以抽象成一个异步的迭代器,这样抽象后,我的需求,就变成了把两个数组merge
一下就ok了~
使用异步生成器函数抽象分页逻辑
我们可以用Promise
将$.ajax
的逻辑封装一下:
/**
* 请求数据,返回 Promise
*
* @param {string} url 请求的 url
* @param {Object} data 请求所带的 query 参数
* @return {Promise} 用于处理请求的 Promise 对象
*/
function getData(url, data) {
return new Promise(function (resolve, reject) {
$.ajax({
url,
type: 'GET',
data,
success: resolve
});
});
}
这样,一个分页器的异步生成器函数就可以用如下代码实现:
/**
* 获取 github 某仓库的 issue 列表
*
* @param {string} location 仓库路径,如:facebook/react
*/
async function* getRepoIssue(location) {
let page = 1;
let isLastPage = false;
while (!isLastPage) {
let lastRes = await getData(
'/api/issues',
{location, page}
);
isLastPage = lastRes.length < PAGE_SIZE;
page++;
yield lastRes;
}
}
使用起来可以说是非常简单了:
const list = getRepoIssue('facebook/react');
btn.addEventListener('click', async function () {
const {value, done} = await list.next();
if (done) {
return;
}
container.innerHTML += value.reduce((cur, next) =>
cur + `<li><div>Repo: ${next.repository_url}</div>`
+ `<div>Title: ${next.title}</div>`
+ `<div>Time: ${next.created_at}</div>`, '');
});
再设计
有了异步迭代器的抽象,我们重新来看看我们的设计,相信大家心中都有了答案:
/**
* 合并多个异步迭代器,返回一个新的异步迭代器
* 该迭代器每次返回 pickNumber 个数据
* 数据按照 sortFn 排序
*
* @param {Array} iterators 异步迭代器数组对象
* @param {Function} sortFn 对请求结果进行排序的函数
* @param {number} pickNumber 迭代器每次返回的元素数量
* @return {Iterator} 合并后的异步迭代器
*/
export default async function* mixLoader(iterators, sortFn, pickNumber) {
}
实现
mixLoader
取意是混合的加载器(老实说,并不是一个非常合适的名字),这个函数我做了一版最简单的实现,后续 @STLighter 帮我从算法层面上进行了多次优化,在此非常感谢~~
- github仓库地址:https://github.com/LeuisKen/m...
- 第一版实现(虽然实现的不好但是好在原理简单):https://github.com/LeuisKen/m...
- @STLighter 优化后的实现:https://github.com/LeuisKen/m...
结语
- 还请注意,如果是有跳页需求的话,就不能这么封装了
- 除了更好的抽象带来的可读性,代码也变得更加容易测试了
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。