手把手实现一个高效的AutoComplete组件

Air-莆泽 组件 阅读 2,966
赞 19 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这个需求其实挺简单:用户在搜索框里输入地址,自动补全候选列表。原本想用现成的第三方组件库,比如 Ant Design 的 AutoComplete,但产品提了个要求——需要接入我们自己的地理编码服务,而且要支持模糊拼音匹配。这下没法直接用了,只能自己搞一个轻量级的实现。

手把手实现一个高效的AutoComplete组件

我一开始还挺乐观,觉得不就是个输入框+下拉列表吗?监听 input 事件,防抖调接口,渲染 list 就完事了。结果后面才发现,UI 交互细节、性能优化、键盘操作这些地方全是坑。

核心代码就这几行

先说结构,HTML 很简单:

<div class="autocomplete-container">
  <input
    type="text"
    id="address-input"
    placeholder="请输入地址"
    class="autocomplete-input"
  />
  <ul id="suggestions-list" class="suggestions-list hidden"></ul>
</div>

CSS 用的是 Tailwind,主要是控制显示隐藏和 hover 样式:

.suggestions-list {
  @apply absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 max-h-60 overflow-y-auto z-50;
}
.suggestions-list.hidden {
  @apply hidden;
}
.suggestions-list li {
  @apply p-2 cursor-pointer hover:bg-blue-100;
}
.suggestions-list li.active {
  @apply bg-blue-200;
}

最关键的 JS 部分,我用原生写了一个类来封装逻辑:

class AutoComplete {
  constructor(inputElement, options = {}) {
    this.input = inputElement;
    this.suggestionsList = document.getElementById('suggestions-list');
    this.apiURL = options.apiURL || 'https://jztheme.com/api/geocode';
    this.debounceDelay = options.debounceDelay || 300;
    this.onSelect = options.onSelect || function () {};
    this.fetchCancel = null;

    this.selectedItemIndex = -1;
    this.suggestions = [];

    this.init();
  }

  init() {
    this.input.addEventListener('input', this.debounce((e) => {
      const query = e.target.value.trim();
      if (query.length === 0) {
        this.hideSuggestions();
        return;
      }
      this.fetchSuggestions(query);
    }, this.debounceDelay));

    this.input.addEventListener('keydown', this.handleKeydown.bind(this));
  }

  async fetchSuggestions(query) {
    // 取消防旧请求
    if (this.fetchCancel) {
      this.fetchCancel();
    }

    try {
      const controller = new AbortController();
      this.fetchCancel = () => controller.abort();

      const res = await fetch(${this.apiURL}?q=${encodeURIComponent(query)}, {
        signal: controller.signal
      });

      if (!res.ok) throw new Error('Network error');

      const data = await res.json();
      this.suggestions = Array.isArray(data.results) ? data.results : [];
      this.renderSuggestions();
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error('Fetch failed:', err);
      }
    }
  }

  renderSuggestions() {
    this.suggestionsList.innerHTML = '';
    this.selectedItemIndex = -1;

    if (this.suggestions.length === 0) {
      this.hideSuggestions();
      return;
    }

    this.suggestions.forEach((item, index) => {
      const li = document.createElement('li');
      li.textContent = item.name + ' - ' + item.address;
      li.dataset.index = index;
      li.addEventListener('click', () => {
        this.selectItem(index);
      });
      this.suggestionsList.appendChild(li);
    });

    this.showSuggestions();
  }

  selectItem(index) {
    const item = this.suggestions[index];
    if (item) {
      this.input.value = item.name;
      this.onSelect(item);
      this.hideSuggestions();
    }
  }

  handleKeydown(e) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        this.highlightNext();
        break;
      case 'ArrowUp':
        e.preventDefault();
        this.highlightPrev();
        break;
      case 'Enter':
        e.preventDefault();
        if (this.selectedItemIndex > -1) {
          this.selectItem(this.selectedItemIndex);
        }
        break;
      case 'Escape':
        this.hideSuggestions();
        break;
    }
  }

  highlightNext() {
    const len = this.suggestions.length;
    if (len === 0) return;

    this.selectedItemIndex = (this.selectedItemIndex + 1) % len;
    this.highlightItem();
  }

  highlightPrev() {
    const len = this.suggestions.length;
    if (len === 0) return;

    this.selectedItemIndex = this.selectedItemIndex <= 0 ? len - 1 : this.selectedItemIndex - 1;
    this.highlightItem();
  }

  highlightItem() {
    const items = this.suggestionsList.querySelectorAll('li');
    items.forEach((el, i) => {
      el.classList.toggle('active', i === this.selectedItemIndex);
    });

    // 滚动到可视区域
    if (items[this.selectedItemIndex]) {
      items[this.selectedItemIndex].scrollIntoView({ block: 'nearest' });
    }
  }

  showSuggestions() {
    this.suggestionsList.classList.remove('hidden');
  }

  hideSuggestions() {
    this.suggestionsList.classList.add('hidden');
  }

  debounce(func, delay) {
    let timer;
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => func.apply(this, args), delay);
    };
  }
}

// 初始化
const inputEl = document.getElementById('address-input');
new AutoComplete(inputEl, {
  apiURL: 'https://jztheme.com/api/geocode',
  onSelect: (selected) => {
    console.log('Selected:', selected);
  }
});

最大的坑:性能问题

开始测试的时候,发现只要快速打字,页面就卡得不行。一开始我以为是接口太慢,后来一查 Network 面板,发现同一时间发了十几个并发请求,虽然有 abort 控制,但 DOM 更新太频繁,尤其是 renderSuggestions 这个函数被高频触发。

折腾了半天发现,光靠防抖还不够。比如用户从“北京”改成“北京市”,虽然只多了一个字,但还是算两次不同请求,中间如果还有“北京x”这种无效输入,照样会渲染一次空列表。

后来加了个缓存机制,把之前请求过的关键词结果存起来,短时间内重复查询直接走缓存:

this.cache = new Map(); // 添加缓存
const cacheKey = query.toLowerCase();
if (this.cache.has(cacheKey)) {
this.suggestions = this.cache.get(cacheKey);
this.renderSuggestions();
return;
}
// 请求成功后
this.cache.set(cacheKey, data.results);
`&gt;
&lt;p&gt;同时设了个最大缓存数(比如 10 条),避免内存膨胀。这一招下来,体验顺滑多了。&lt;/p&gt;

&lt;h2&gt;又踩坑了:点击建议项失焦丢失&lt;/h2&gt;
&lt;p&gt;另一个问题是,用户点建议列表的时候,输入框会先失去焦点,导致某些场景下弹窗直接收起了,根本点不到。这个问题我在 Chrome 上没复现,结果 QA 在 Safari 和部分安卓机上稳定出现。&lt;/p&gt;
&lt;p&gt;查了一圈才发现是事件执行顺序的问题:
input blur 触发比 li click 还快。解决办法是在隐藏列表前加个微任务延迟判断是否真的要关闭:&lt;/p&gt;</code></pre>javascript
// 给 input 加 blur 监听
this.input.addEventListener('blur', () => {
setTimeout(() => {
// 检查当前是否有鼠标正在悬停建议项
const activeHover = document.querySelector('.suggestions-list li:hover');
if (!activeHover) {
this.hideSuggestions();
}
}, 150);
});
>
<p>虽然不够优雅,但亲测有效。也有同学建议用
pointer-events` 控制,但我懒得改结构了,就这样吧。

最终的解决方案

上线前最后做了一轮优化:

  • 接口返回加了字段过滤,只传 name 和 address,减小体积
  • 输入少于 2 个字符不触发请求
  • 移动端加了 touch 支持,防止滑动穿透
  • 给组件暴露 destroy 方法,方便 SPA 页面切换时清理事件

整体跑下来,首屏加载没受影响,交互也基本跟上了。唯一还有点小问题是拼音模糊匹配准确率不够高,但这属于后端能力范畴,前端也没法硬改。

回顾与反思

回过头看,这个组件花了比我预期多三倍的时间。本来以为两天搞定的事,硬生生拖了五天,主要耗在边界情况处理上。不过现在回头看,有几个点做得还算靠谱:

  • 用了 AbortController 控制请求生命周期,这点必须坚持
  • 键盘导航完整支持,无障碍体验达标
  • 缓存策略减轻服务器压力

不足的地方也有:

  • 样式耦合有点严重,Tailwind 类名散在 JS 里不太好维护
  • 没有做异步加载组件,首包体积增加了 2KB 左右
  • IE 兼容性直接放弃了,毕竟 AbortController 不支持

总的来说,不是最优解,但够用。有些问题比如点击失焦那种,我知道有更好的做法,但项目排期紧,能跑就行。

以上是我个人对这个 AutoComplete 的完整讲解,有更优的实现方式欢迎评论区交流

这个技巧的拓展用法还有很多,比如结合地理位置权限自动填充附近地址,后续可能会继续分享这类实战案例。目前这套代码已经在生产环境跑了两周,没出过大问题,算是勉强过关了。前端就是这样,看着简单的功能,背后全是细节堆出来的。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论