手把手实现一个高效的AutoComplete组件
项目初期的技术选型
这个需求其实挺简单:用户在搜索框里输入地址,自动补全候选列表。原本想用现成的第三方组件库,比如 Ant Design 的 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);
`>
<p>同时设了个最大缓存数(比如 10 条),避免内存膨胀。这一招下来,体验顺滑多了。</p>
<h2>又踩坑了:点击建议项失焦丢失</h2>
<p>另一个问题是,用户点建议列表的时候,输入框会先失去焦点,导致某些场景下弹窗直接收起了,根本点不到。这个问题我在 Chrome 上没复现,结果 QA 在 Safari 和部分安卓机上稳定出现。</p>
<p>查了一圈才发现是事件执行顺序的问题:
input blur 触发比 li click 还快。解决办法是在隐藏列表前加个微任务延迟判断是否真的要关闭:</p></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 的完整讲解,有更优的实现方式欢迎评论区交流
这个技巧的拓展用法还有很多,比如结合地理位置权限自动填充附近地址,后续可能会继续分享这类实战案例。目前这套代码已经在生产环境跑了两周,没出过大问题,算是勉强过关了。前端就是这样,看着简单的功能,背后全是细节堆出来的。

暂无评论