我用 AsyncGenerator 重写了那个疯狂缓存的前端搜索,终于不用半夜背锅了

上个月接了个活儿——优化一个内部管理系统的搜索框。这搜索框看着简单,就是用户在输入框里打字,下面实时出一个列表给你选。结果点开源码我心里一凉:好家伙,里面用 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* 来重构。那种从源头保证顺序的感觉,怎么说呢,能让你少加很多班,也少长很多白头发。

我用 AsyncGenerator 重写了那个疯狂缓存的前端搜索,终于不用半夜背锅了
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript 我用 AsyncGenerator 重写了那个疯狂缓存的前端搜索,终于不用半夜背锅了 https://www.taomawang.com/web/javascript/2716.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务