上个月接了个活儿——优化一个内部管理系统的搜索框。这搜索框看着简单,就是用户在输入框里打字,下面实时出一个列表给你选。结果点开源码我心里一凉:好家伙,里面用 setTimeout 手动实现防抖、又搞了个数组当缓存,还得手动处理竞态,隔三差五就出现“输入了A结果弹出来B的列表”的诡异bug。
当时我就想,与其继续在这个“回调泥潭”里打补丁,不如把整个请求脑回路换成 async generator。折腾了半天后我只想说:用 AsyncGenerator 之后,我这个外行终于写出能面向同事的代码了。 这篇文章就当一次踩坑笔记,把核心的玩法都记录下来。
别嫌烦,先看看原来是怎么写崩的
原来的代码逻辑我简化一下大概是这样:
// 原来的事故现场
let timer = null;
let cache = [];
let currentText = '';
let requestCount = 0;
input.addEventListener('input', function(e) {
const keyword = e.target.value.trim();
clearTimeout(timer);
timer = setTimeout(() => {
// 缓存命中直接显示上一次结果,没命中就发新请求
const hit = cache.find(item => item.keyword === keyword);
if (hit) {
renderList(hit.data);
return;
}
// 每次请求都在累加计数器,但是响应才不管最新旧
const myRequest = ++requestCount;
fetch('/api/search?q=' + keyword)
.then(r => r.json())
.then(data => {
// 这里只允许一次响应,但只判断数字相等有点憨
if (myRequest !== requestCount) return;
cache.push({ keyword, data });
renderList(data);
});
}, 300);
});
这段代码最大的问题就是:缓存数组 + 计时器 + 计数器不统一。每个新请求发出之前,老的响应没被取消,只是靠一个全局 requestCount 来判断“要不要丢弃”。如果你狂打字,一堆请求发出去了,数组缓存越来越长,搜索框还会偶尔闪现旧结果。开个四五个页面,内存直接爆表。
为什么想用 AsyncGenerator
AsyncGenerator 是 JavaScript 里那种一开始你看不懂,看懂后直接拍大腿的东西。它让你的函数可以像播放器一样,“一次返回一个数据,你消费完了我再接着给你下一个”。
放到搜索场景里,特别适合处理“请求序列”:用户不断输入,你不断产出一个请求/响应对,直到输入结束。并且 generator 有个天然属性——如果你在外部停止迭代,generator 内部的代码还能自动帮你做清理,比如把还没发出去的请求直接掐掉。
为了让你快速体会,我先写个最简单的异步生成器:
// 用 async 函数直接创建异步生成器
async function* countUp() {
let n = 0;
while (n < 3) {
await new Promise(resolve => setTimeout(resolve, 1000));
yield n++;
}
}
// 消费的时候用 for await
(async() => {
for await (const value of countUp()) {
console.log(value); // 依次输出 0,1,2,每隔一秒
}
})();
看到没?它把“异步取数据”和“控制取几条”解耦了,比 forEach 或者 while + Promise 优雅太多。你不会再为“到底哪次请求是最新的”而心力交瘁——因为旧请求在循环里根本不会被你消费。
用 AsyncGenerator 改造搜索框
我重新设计后的思路是这样:写一个异步生成器 fetchSuggestions,它不断接收用户输入的关键词,并且通过 AbortController 把旧请求弃掉,然后只产出最新一次的结果。
由于 async generator 天然每次调用 next() 只会执行一段,你完全可以通过 return() 来终止上一次的迭代,一石二鸟。
代码如下:
// 一个真正的异步生成器,专门处理搜索请求
async function* searchFlow(fetchFn, duration = 300) {
let timer = null;
let controller = null;
let pendingKeyword = '';
// 通过外部在 next() 传值进来,就能知道用户最新输入的关键词
while (true) {
const keyword = yield; // 第一次 yield 用来暂停,等待外面的关键词
pendingKeyword = keyword;
// 清理上一次的计时器和请求
if (timer) {
clearTimeout(timer);
timer = null;
}
if (controller) {
controller.abort();
}
// 生成器内部也可以自己做防抖
timer = setTimeout(() => {
if (!pendingKeyword) return;
controller = new AbortController();
const signal = controller.signal;
fetchFn(pendingKeyword, signal).then(data => {
// 如果这个生成器还在迭代,说明我们可以继续走
iterator.next({ data: data, keyword: pendingKeyword });
}).catch((err) => {
// 如果是abort错误,直接给一个特殊标记,让外层知道没了
iterator.next({ error: err });
});
}, duration);
// 这时候让出控制权,设置一个空的yield一直等到上面的下一个next传入
yield new Promise(resolve => {});
}
}
上面代码中我利用 generator 的 next() 来双向通信,但老实说这个写法还不够最佳,反而有点绕。因为 generator 内部还要维护一个 iterator 的跳转,把它搞复杂了。我还是想一个更直接、更不烧脑的。所以我又改成下面的版本:
// 用 async generator 做异步序列:每个传入的关键词,产生一个搜索结果
async function* createSearchSequence(searchFn, delay = 300) {
let inputQueue = [];
let notify = null;
// 每次生成器恢复执行时,把最新的input取出来
const flush = () => { notify && notify(); };
async function pushKeyword(text) {
inputQueue.push(text);
flush();
return;
}
while (true) {
// 等待有输入进去,并且没有正在跑的请求
while (inputQueue.length === 0) {
await new Promise(resolve => { notify = resolve; });
}
const keyword = inputQueue.shift();
// 每次query,都先取消之前的旧的(实际上只能取消上一个,所以很好用)
const controller = new AbortController();
// 把数据返回给消费方
yield await searchFn(keyword, controller.signal);
}
}
这段代码还是觉得概念太重。后来我干脆放弃“频繁 yield”,直接利用 for await 循环,每次生成器只负责一个“debounce + 请求 + 返回结果”的周期。我把核心逻辑简化成了能让大家理解的第二个例子:
可取消的实时搜索(最终版)
// 这段可以放到你的 utils 里
async function* createLatestRequest(fetcher, wait = 300) {
let timer = null;
let abort = null;
let input = '';
let resolveNext = null;
// 在内部定义一个触发器
function enqueueSearch(value) {
input = value;
if (timer) clearTimeout(timer);
timer = setTimeout(doFetch, wait);
// 立即把控制权交还到 yield 让出点
resolver && resolver({ type: 'pending' });
}
async function doFetch() {
if (abort) abort.abort();
const controller = new AbortController();
abort = controller;
try {
const data = await fetcher(input, controller.signal);
resolver && resolver({ type: 'success', data, input });
} catch (err) {
resolver && resolver({ type: 'error', error: err });
}
}
while (true) {
const keyword = yield; // 从外部得到一个字符串
enqueueSearch(keyword);
let res;
res = await new Promise(r => { resolver = r; });
// 如果外部的生成器 next() 已经推进,则可跳转
if (res.type === 'success') {
yield { data: res.data, keyword: input };
} else if (res.type === 'error') {
yield { error: res.error };
} else {
yield { data: [], cancelled: true };
}
}
}
这套代码看起来有点绕,但“难点”其实是异步的相互调用。建议你直接复制下面的完整消费示例,能跑起来。
下面是组件里使用的完整逻辑:
// 实际搜索示例
async function* searchSequence() {
const gen = createLatestRequest(async (keyword, signal) => {
const res = await fetch('/api/search?keyword=' + keyword, { signal });
return res.json();
});
// 这层循环用于把外部传进来的keyword给gen,同时把gen输出的结果透传出去
let nextValue;
while (true) {
const keyword = yield nextValue;
const resultPromise = gen.next(keyword);
// 需要等待 gen 内部完成并 yield 出 result
// 所以这里再调用一次 resultPromise
const { value } = await resultPromise;
// 把内部的生成器输出给到外层
nextValue = value;
// 关键:让外层继续迭代,直到它收到了这个 value
yield value;
}
}
我发现自己又把事情搞复杂了,于是愤而重构,放弃封装成“高大上”的生成器,直接用 AbortController + async 一行行写,反而更清楚。但是为了这篇文章,我还是把那个花了两小时整理好的天才方案发出来——正因为很绕,才会让你少走弯路。
一个不再有严重 Bug 的实时搜索:混合方案
最后我采用的方法是:把 async generator 当作一个串行调度器,每次循环会等待用户输入,然后创建 AbortController 发起请求;如果上一次请求没有返回就直接取消。不搞花活。
// 优化后的终极函数
async function runSearch(fetcher, inputEl, listEl) {
let operateController = null;
async function* searchGenerator() {
while (true) {
// 等待输入框内容稳定下来,防抖时间由外层配合定时器
const text = yield;
// 旧请求取消
if (operateController) operateController.abort();
const controller = new AbortController();
operateController = controller;
try {
const result = await fetcher(text, controller.signal);
if (!controller.signal.aborted) {
yield result;
} else {
yield { cancelled: true };
}
} catch (err) {
if (err.name === 'AbortError') {
yield { cancelled: true };
} else {
yield { error: err };
}
}
}
}
const itr = searchGenerator();
let timeoutId = null;
inputEl.addEventListener('input', () => {
clearTimeout(timeoutId);
const keyword = inputEl.value.trim();
timeoutId = setTimeout(async () => {
// 让生成器执行到下一个yield前,传递关键词
// 这里调用 next 后,生成器内部的fetcher不会立刻执行,因为它在等待yield
// 但只要我们把 keyword 传进去,它就会继续执行,之后返回一个promise
const resultPromise = itr.next(keyword);
const { value } = await resultPromise;
if (value && value.error) {
console.error('搜索出错', value.error);
return;
}
if (value && value.cancelled) return;
// 正常结果渲染到列表
listEl.innerHTML = value.map(item => `<li>${item.name}</li>`).join('');
// 别忘记把生成器指针继续推进,让它再次等待输入
itr.next(); // 这个next会让它进入下一次while等待新关键字
}, 300);
});
}
这个方案确实极大缓解了竞态问题,因为每创建一个新请求,我们可以取消旧请求,而且由于 async generator 一次只处理一个请求,根本没机会出现“旧响应覆盖新响应”的场景——这是它最香的地方。
把 async generator 当作“响应式总线”来用
很多时候我们并不需要分页抓取,而是要处理一系列UI事件。搜索框、无限滚动、即时同步这些场景,本质上是“外部不断产生意图,内部不断消耗”。async generator 给你一种能力,让你把意图序列化,再配合 for await...of 一个萝卜一个坑地消费,不会因为并发原因导致错乱。
比如无限滚动列表,你完全可以这样:
// 无限加载更多,用异步生成器做分页拉取
async function* pagedFetcher(pageSize = 20) {
let page = 1;
while (true) {
const list = await fetch('/api/list?page=' + page + '&size=' + pageSize).then(r=>r.json());
yield* list;
page++;
}
}
(async() => {
let count = 0;
for await (const item of pagedFetcher()) {
console.log(item);
if (count++ >= 10) break;
}
})();
看到没有?这里我用了 yield*,它会把一个数组拆成一条条产出,外部像使用普通数组一样。这个玩法在大量分页场景下很够味。
最后总结一下我自己踩过的小坑
有些坑必须你写过才长记性:
- 外部调用生成器的 next() 和 async generator 内部的 yield 必须配对。不要丢掉生成器连续返回的 Promise,不然它会停在半路。
- AbortController 有兼容性门槛,但主流浏览器都支持了。当你中止请求时,fetch 的 catch 里能收到 AbortError,记得别把你的整体逻辑也给中断了。
- async generator + for await 无法像普通 for 循环一样随便 break,一旦 break 会立即执行 generator 的 return,这倒是好事,自动释放。
- 不要试图用 async generator 去处理严格顺序的同步数组,直接用 for of 就得了。
后来的代码复查同事说:“这搜索框终于像个正经人写的了。”虽然我觉得 async generator 不是解决问题的唯一方式,但它真的很适合这种“流式事件处理”的场景。每当我想把多个输入事件串起来而不互相污染,每次第一个想到的就是它。
你可以把这篇文章当作一个引子,以后碰到像 WebSocket、音视频流、队列任务消费之类的东西,都可以亲手试试用 async function* 来重构。那种从源头保证顺序的感觉,怎么说呢,能让你少加很多班,也少长很多白头发。

