19 KiB
title | description | date | slug | categories | tags | lastmod | links | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
【SearchPanel组件】下拉联想结果的全局监听click事件技术实现 | 写SearchPanel组件时实现下拉联想结果点击外部自动关闭,同时用了全局click事件监听来代替常用的onBlurSuggest逻辑 | 2025-02-14T01:31:00+08:00 | 下拉联想 |
|
|
2025-02-14T02:58:00+08:00 |
|
给公司写Vue组件时,需要实现一个下拉联想结果,这个下拉联想结果是根据用户输入内容实时调用API获取的,当用户输入内容时,下拉联想结果会自动弹出。自然地,如果用户点击了其他地方,下拉联想结果应该自动关闭,以保证用户体验。
其实一开始我用的是常用的onBlurSuggest
实现,但是发现有时候点击外部没办法正确隐藏联想结果,分析了一下发现blur
事件可能在focus
事件之后触发,所以无法实现点击外部自动关闭。
所以我换了种方案,对于输入框和联想结果,我分别加了ref
来标识两个组件,方便后面检查点击事件时进行定位。输入框:
<!-- 联想输入框 -->
<input
type="text"
:class="field.inputClass || 'default-input'"
v-model="searchValues[field.key]"
:placeholder="field.placeholder"
@input="onSuggestInput(field)"
@focus="onFocusSuggest(field)"
:ref="'input-' + field.key"
/>
联想结果:
<!-- 联想下拉 -->
<div
v-show="showDropdown[field.key]"
class="search-dropdown"
:ref="'dropdown-' + field.key"
>
<ul class="search-dropdown-list">
<li
v-for="(item, i2) in suggestResults[field.key] || []"
:key="i2"
class="search-dropdown-item"
@mousedown="onSelectSuggestItem(field, item)"
>
{{ item }}
</li>
</ul>
</div>
两个 ref 属性的用来建立 DOM 元素与组件实例的关联。然后我们在methods
中定义了handleClickOutside
方法,用来处理点击外部事件。我们遍历所有的联想输入框,检查点击事件是否发生在输入框或下拉联想结果的外部,如果是,就关闭下拉联想结果。在方法中,我们通过this.$refs
访问到对应的 DOM 元素。
handleClickOutside(event) {
// 遍历所有联想输入框
this.fields.forEach(field => {
if (field.type === 'suggest') {
const dropdownRef = this.$refs['dropdown-' + field.key] // 跟踪联想结果
const inputRef = this.$refs['input-' + field.key] // 跟踪输入框
// 获取实际 DOM 元素
const dropdownEl = dropdownRef ? dropdownRef[0] : null
const inputEl = inputRef ? inputRef[0] : null
// 检查点击是否在下拉框或输入框内
const clickedInDropdown = dropdownEl && dropdownEl.contains(event.target)
const clickedInInput = inputEl && inputEl.contains(event.target)
if (!clickedInDropdown && !clickedInInput) {
// 点击不在下拉框和对应的输入框内,关闭下拉框
this.$set(this.showDropdown, field.key, false)
}
}
})
},
在mounted
和beforeDestroy
钩子中分别添加事件监听和移除。注意别忘了在组件销毁前,要移除全局的点击事件监听器,避免内存泄漏。
mounted() {
document.addEventListener('click', this.handleClickOutside)
// ...
},
beforeDestroy() {
document.removeEventListener('click', this.handleClickOutside)
},
这样一来,可以精确地保证点击外部任意位置时,联想结果都会自动关闭。
那么再来说说这样全局监听click
的方法相比onBlurSuggest
的优点吧,首先我刚刚说了,通过全局click
事件监听,可以判断点击事件是否发生在输入框或下拉列表内部,比简单的blur
事件更灵活,能更好地处理复杂布局或嵌套组件的情况。(事实上,我前面用blur
的时候就发现,有时候点击外部没办法正确隐藏联想结果)
比如:
<!-- 当输入框包含子交互元素时 -->
<div class="input-wrapper">
<input @blur="...">
<button @click="clear">×</button> <!-- blur 会误触发 -->
</div>
这种情况下全局方案可以识别按钮的点击,但是blur
方案会误判为离开输入区域。另一个例子(跨组件):
<!-- 当下拉框使用 Portal 渲染到 body 时 -->
<template>
<input>
<Teleport to="body">
<div class="dropdown"></div> <!-- blur 事件完全失效 -->
</Teleport>
</template>
使用blur
事件时,我们依赖的是输入框与下拉框之间的“焦点”关系来判断用户是否点击在下拉菜单外部。但是通过 Teleport 将下拉菜单渲染到 body 时,输入框和下拉菜单就不再处于同一 DOM 层级内。由于下拉菜单被 Teleport 到了 body,它与输入框在 DOM 树中完全分离。点击下拉菜单,输入框会失去焦点,从而触发 blur
事件,而这时我们其实希望下拉菜单保持显示状态。反之,如果不触发 blur
,又无法区分点击是否发生在下拉菜单上,所以单纯依赖 blur
很难准确判断点击位置。
换句话说,blur
事件本质上只是反映了焦点丢失,而不能判断新获得焦点的元素是否属于下拉菜单的范围。对于 Teleport 渲染的结构,浏览器没办法将下拉菜单与输入框关联起来,从而导致blur
事件失效,或者触发不符合预期。而全局的click
事件方案则不依赖于组件之间的DOM层级关系。它是直接监听整个文档的点击事件,然后通过contains
方法判断点击是否发生在输入框或下拉菜单内。无论下拉菜单渲染在哪,只要能获取到DOM引用(ref
),全局方案都能正确判断、控制下拉菜单的显示和隐藏。
另一方面,时序上,用户点击下拉建议时,输入框会先触发blur
失焦,导致下拉框提前关闭,就可能会阻止click
或mousedown
事件在下拉项上被正确触发。而全局click
事件判断可以确保在点击事件发生后再做判断,避免这种“先失焦后点击”带来的问题。
方案 | 事件触发顺序 | 典型问题场景 |
---|---|---|
blur 方案 | mousedown -> blur -> click |
点击下拉选项时,输入框先触发 blur 导致下拉关闭,无法触发选项的 click 事件,一般要设置延时(200ms左右) |
全局点击方案 | 直接捕获 click 事件 |
可通过 mousedown 提前处理选择逻辑,完美解决时序问题 |
另外,页面中有多个联想输入框时,使用全局click
事件就能统一管理所有输入框的失焦逻辑,避免为每个输入框单独绑定blur
事件可能带来的冗余代码,或者一些其他的不一致状态。
又熬到3点了,看来这辈子不可能早睡了。。。附组件的HTML和JS部分完整代码:(此组件已修改,下面为修改前的代码,非最终版本)
<template>
<div class="search-panel">
<!-- 搜索条件区域 -->
<div class="search-conditions">
<template v-for="(field, idx) in fields" :key="idx">
<!-- 联想输入框 -->
<div v-if="field.type === 'suggest'" class="search-item">
<div class="search-item-title">{{ field.label }}:</div>
<div class="search-list-wrapper">
<input
type="text"
:class="field.inputClass || 'default-input'"
v-model="searchValues[field.key]"
:placeholder="field.placeholder"
@input="onSuggestInput(field)"
@focus="onFocusSuggest(field)"
:ref="'input-' + field.key"
/>
<!-- 联想下拉 -->
<div
v-show="showDropdown[field.key]"
class="search-dropdown"
:ref="'dropdown-' + field.key"
>
<ul class="search-dropdown-list">
<li
v-for="(item, i2) in suggestResults[field.key] || []"
:key="i2"
class="search-dropdown-item"
@mousedown="onSelectSuggestItem(field, item)"
>
{{ item }}
</li>
</ul>
</div>
</div>
</div>
<!-- 日期输入框 -->
<div v-else-if="field.type === 'date'" class="search-item">
<div class="search-item-title">{{ field.label }}:</div>
<input
type="date"
:class="field.inputClass || 'default-input'"
v-model="searchValues[field.key]"
@change="onChangeField(field.key)"
/>
</div>
<!-- 下拉选择框 -->
<div v-else-if="field.type === 'select'" class="search-item">
<div class="search-item-title">{{ field.label }}:</div>
<select
:class="field.inputClass || 'default-input'"
v-model="searchValues[field.key]"
@change="onChangeField(field.key)"
>
<option
v-for="(opt, i3) in field.options || []"
:key="i3"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
<!-- 单选组 (矩形块) -->
<div v-else-if="field.type === 'radioGroup'" class="search-item">
<div class="search-item-title">{{ field.label }}:</div>
<div class="radio-group">
<div
v-for="(opt, i4) in field.options || []"
:key="i4"
class="radio-item"
:class="{ 'radio-item-selected': searchValues[field.key] === opt.value }"
@click="onSelectRadioOption(field.key, opt.value)"
>
{{ opt.label }}
</div>
</div>
</div>
<!-- 默认文本输入 -->
<div v-else class="search-item">
<div class="search-item-title">{{ field.label }}:</div>
<input
type="text"
:class="field.inputClass || 'default-input'"
v-model="searchValues[field.key]"
:placeholder="field.placeholder"
@input="onChangeField(field.key)"
/>
</div>
</template>
</div>
<!-- 操作按钮区域 -->
<div class="search-actions">
<template v-for="(action, idx) in actions" :key="idx">
<button
class="search-action-btn"
:class="action.btnClass"
@click="onActionClick(action.key)"
>
{{ action.label }}
</button>
</template>
</div>
<!-- 表格 + 分页 -->
<div v-if="showTable" class="search-result">
<table class="result-table">
<thead>
<tr>
<th
v-for="col in columns"
:key="col.key"
:style="col.style"
>
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, idx) in tableData"
:key="idx"
@click="onRowClick(row)"
>
<td v-for="col in columns" :key="col.key">
<component
v-if="col.render"
:is="col.render"
:data="row"
:field="col.key"
/>
<template v-else>
{{ row[col.key] }}
</template>
</td>
</tr>
</tbody>
</table>
<!-- 分页器 -->
<div v-if="showPagination && totalPages > 1" class="pagination">
<div class="pagination-container">
<button
class="pagination-btn"
:disabled="currentPage <= 1"
@click="changePage(currentPage - 1)"
>
上一页
</button>
<span class="pagination-text">
第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
</span>
<button
class="pagination-btn"
:disabled="currentPage >= totalPages"
@click="changePage(currentPage + 1)"
>
下一页
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'SearchPanel',
props: {
// 搜索字段配置
fields: {
type: Array,
default: () => []
},
// 操作按钮
actions: {
type: Array,
default: () => []
},
// 表格列
columns: {
type: Array,
default: () => []
},
// 表格数据获取函数
// 形参: (searchValues, currentPage, pageSize) => Promise<{ list: Array, total: number }>
fetchTableFn: {
type: Function,
required: true
},
// 是否显示表格
showTable: {
type: Boolean,
default: true
},
// 是否显示分页器
showPagination: {
type: Boolean,
default: false
},
// 是否在字段变化时自动请求
autoFetch: {
type: Boolean,
default: false
},
// 每页数量
pageSize: {
type: Number,
default: 10
}
},
data() {
return {
// 内部记录搜索字段的值
searchValues: {},
// 联想结果
suggestResults: {},
// 联想下拉是否显示
showDropdown: {},
// 表格数据
tableData: [],
// 分页
currentPage: 1,
totalPages: 1,
// 是否正在请求
isFetching: false
}
},
watch: {
// 监控搜索值变化 => 若autoFetch,则更新表格,并把currentPage重置为1
searchValues: {
deep: true,
handler() {
if (this.autoFetch) {
this.currentPage = 1
this.fetchTableData()
}
}
},
// 监控currentPage变化 => 请求新数据
currentPage(val) {
if (this.autoFetch) {
this.fetchTableData()
}
}
},
created() {
// 初始化 searchValues
this.fields.forEach(f => {
this.$set(this.searchValues, f.key, f.defaultValue || '')
})
},
mounted() {
document.addEventListener('click', this.handleClickOutside)
// 开autoFetch了就直接来一次
if (this.autoFetch) {
this.fetchTableData()
}
},
beforeDestroy() {
document.removeEventListener('click', this.handleClickOutside)
},
methods: {
handleClickOutside(event) {
// 遍历所有联想输入框字段
this.fields.forEach(field => {
if (field.type === 'suggest') {
const dropdownRef = this.$refs['dropdown-' + field.key]
const inputRef = this.$refs['input-' + field.key]
// 获取实际 DOM 元素
const dropdownEl = dropdownRef ? dropdownRef[0] : null
const inputEl = inputRef ? inputRef[0] : null
// 检查点击有没有在下拉结果或输入框
const clickedInDropdown = dropdownEl && dropdownEl.contains(event.target)
const clickedInInput = inputEl && inputEl.contains(event.target)
if (!clickedInDropdown && !clickedInInput) {
// 不在,关闭下拉框
this.$set(this.showDropdown, field.key, false)
}
}
})
},
// -------------------------
// 通用字段变化逻辑
// -------------------------
onChangeField(fieldKey) {
const val = this.searchValues[fieldKey]
this.$emit('change', { key: fieldKey, value: val })
},
// -------------------------
// 单选组
// -------------------------
onSelectRadioOption(fieldKey, optionValue) {
this.searchValues[fieldKey] = optionValue
this.onChangeField(fieldKey)
},
// -------------------------
// 联想输入框处理
// -------------------------
async onSuggestInput(field) {
const val = this.searchValues[field.key]
if (typeof field.fetchSuggestFn !== 'function') return
try {
const results = await field.fetchSuggestFn(val)
// 注意必须使用this.$set更新响应式数据,不然某些情况检测不到更新!!
this.$set(this.suggestResults, field.key, results)
this.$set(this.showDropdown, field.key, true)
} catch (error) {
console.error('获取联想数据失败:', error)
// 确保下拉框隐藏
this.$set(this.showDropdown, field.key, false)
}
},
onFocusSuggest(field) {
// 显示下拉
if (
this.suggestResults[field.key] &&
this.suggestResults[field.key].length > 0
) {
this.showDropdown[field.key] = true
}
},
// onBlurSuggest(field) {
// // 延迟,否则点击下拉时无法触发mousedown
// setTimeout(() => {
// this.showDropdown[field.key] = false
// }, 200)
// },
onSelectSuggestItem(field, item) {
this.searchValues[field.key] = item
this.showDropdown[field.key] = false
this.$emit('select', { key: field.key, value: item })
// 同时触发字段变化的事件
this.$emit('change', { key: field.key, value: item })
},
// -------------------------
// 表格数据请求
// -------------------------
async fetchTableData() {
if (this.isFetching) return
this.isFetching = true
try {
const { list, total } = await this.fetchTableFn(
{ ...this.searchValues },
this.currentPage,
this.pageSize
)
this.tableData = list || []
const t = total || 0
this.totalPages = Math.ceil(t / this.pageSize)
} catch (e) {
console.error('fetchTableData error:', e)
}
this.isFetching = false
},
changePage(pageNum) {
if (pageNum < 1 || pageNum > this.totalPages) return
this.currentPage = pageNum
},
// -------------------------
// 操作按钮
// -------------------------
onActionClick(actionKey) {
this.$emit('action', actionKey)
},
// -------------------------
// 表格行点击
// -------------------------
onRowClick(rowData) {
this.$emit('row-click', rowData)
}
}
}
</script>