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>