给公司写Vue组件时,需要实现一个下拉联想结果,这个下拉联想结果是根据用户输入内容实时调用API获取的,当用户输入内容时,下拉联想结果会自动弹出。自然地,如果用户点击了其他地方,下拉联想结果应该自动关闭,以保证用户体验。
其实一开始我用的是常用的onBlurSuggest实现,但是发现有时候点击外部没办法正确隐藏联想结果,分析了一下发现blur事件可能在focus事件之后触发,所以无法实现点击外部自动关闭。
所以我换了种方案,对于输入框和联想结果,我分别加了ref来标识两个组件,方便后面检查点击事件时进行定位。输入框:
1<!-- 联想输入框 -->
2<input
3 type="text"
4 :class="field.inputClass || 'default-input'"
5 v-model="searchValues[field.key]"
6 :placeholder="field.placeholder"
7 @input="onSuggestInput(field)"
8 @focus="onFocusSuggest(field)"
9 :ref="'input-' + field.key"
10/>
联想结果:
1<!-- 联想下拉 -->
2<div
3 v-show="showDropdown[field.key]"
4 class="search-dropdown"
5 :ref="'dropdown-' + field.key"
6>
7 <ul class="search-dropdown-list">
8 <li
9 v-for="(item, i2) in suggestResults[field.key] || []"
10 :key="i2"
11 class="search-dropdown-item"
12 @mousedown="onSelectSuggestItem(field, item)"
13 >
14 {{ item }}
15 </li>
16 </ul>
17</div>
两个 ref 属性的用来建立 DOM 元素与组件实例的关联。然后我们在methods中定义了handleClickOutside方法,用来处理点击外部事件。我们遍历所有的联想输入框,检查点击事件是否发生在输入框或下拉联想结果的外部,如果是,就关闭下拉联想结果。在方法中,我们通过this.$refs访问到对应的 DOM 元素。
1handleClickOutside(event) {
2 // 遍历所有联想输入框
3 this.fields.forEach(field => {
4 if (field.type === 'suggest') {
5 const dropdownRef = this.$refs['dropdown-' + field.key] // 跟踪联想结果
6 const inputRef = this.$refs['input-' + field.key] // 跟踪输入框
7
8 // 获取实际 DOM 元素
9 const dropdownEl = dropdownRef ? dropdownRef[0] : null
10 const inputEl = inputRef ? inputRef[0] : null
11
12 // 检查点击是否在下拉框或输入框内
13 const clickedInDropdown = dropdownEl && dropdownEl.contains(event.target)
14 const clickedInInput = inputEl && inputEl.contains(event.target)
15
16 if (!clickedInDropdown && !clickedInInput) {
17 // 点击不在下拉框和对应的输入框内,关闭下拉框
18 this.$set(this.showDropdown, field.key, false)
19 }
20 }
21 })
22},
在mounted和beforeDestroy钩子中分别添加事件监听和移除。注意别忘了在组件销毁前,要移除全局的点击事件监听器,避免内存泄漏。
1mounted() {
2 document.addEventListener('click', this.handleClickOutside)
3 // ...
4},
5
6beforeDestroy() {
7 document.removeEventListener('click', this.handleClickOutside)
8},
这样一来,可以精确地保证点击外部任意位置时,联想结果都会自动关闭。
那么再来说说这样全局监听click的方法相比onBlurSuggest的优点吧,首先我刚刚说了,通过全局click事件监听,可以判断点击事件是否发生在输入框或下拉列表内部,比简单的blur事件更灵活,能更好地处理复杂布局或嵌套组件的情况。(事实上,我前面用blur的时候就发现,有时候点击外部没办法正确隐藏联想结果)
比如:
1<!-- 当输入框包含子交互元素时 -->
2<div class="input-wrapper">
3 <input @blur="...">
4 <button @click="clear">×</button> <!-- blur 会误触发 -->
5</div>
这种情况下全局方案可以识别按钮的点击,但是blur方案会误判为离开输入区域。另一个例子(跨组件):
1<!-- 当下拉框使用 Portal 渲染到 body 时 -->
2<template>
3 <input>
4 <Teleport to="body">
5 <div class="dropdown"></div> <!-- blur 事件完全失效 -->
6 </Teleport>
7</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部分完整代码:(此组件已修改,下面为修改前的代码,非最终版本)
1<template>
2 <div class="search-panel">
3 <!-- 搜索条件区域 -->
4 <div class="search-conditions">
5 <template v-for="(field, idx) in fields" :key="idx">
6 <!-- 联想输入框 -->
7 <div v-if="field.type === 'suggest'" class="search-item">
8 <div class="search-item-title">{{ field.label }}:</div>
9 <div class="search-list-wrapper">
10 <input
11 type="text"
12 :class="field.inputClass || 'default-input'"
13 v-model="searchValues[field.key]"
14 :placeholder="field.placeholder"
15 @input="onSuggestInput(field)"
16 @focus="onFocusSuggest(field)"
17 :ref="'input-' + field.key"
18 />
19 <!-- 联想下拉 -->
20 <div
21 v-show="showDropdown[field.key]"
22 class="search-dropdown"
23 :ref="'dropdown-' + field.key"
24 >
25 <ul class="search-dropdown-list">
26 <li
27 v-for="(item, i2) in suggestResults[field.key] || []"
28 :key="i2"
29 class="search-dropdown-item"
30 @mousedown="onSelectSuggestItem(field, item)"
31 >
32 {{ item }}
33 </li>
34 </ul>
35 </div>
36 </div>
37 </div>
38
39 <!-- 日期输入框 -->
40 <div v-else-if="field.type === 'date'" class="search-item">
41 <div class="search-item-title">{{ field.label }}:</div>
42 <input
43 type="date"
44 :class="field.inputClass || 'default-input'"
45 v-model="searchValues[field.key]"
46 @change="onChangeField(field.key)"
47 />
48 </div>
49
50 <!-- 下拉选择框 -->
51 <div v-else-if="field.type === 'select'" class="search-item">
52 <div class="search-item-title">{{ field.label }}:</div>
53 <select
54 :class="field.inputClass || 'default-input'"
55 v-model="searchValues[field.key]"
56 @change="onChangeField(field.key)"
57 >
58 <option
59 v-for="(opt, i3) in field.options || []"
60 :key="i3"
61 :value="opt.value"
62 >
63 {{ opt.label }}
64 </option>
65 </select>
66 </div>
67
68 <!-- 单选组 (矩形块) -->
69 <div v-else-if="field.type === 'radioGroup'" class="search-item">
70 <div class="search-item-title">{{ field.label }}:</div>
71 <div class="radio-group">
72 <div
73 v-for="(opt, i4) in field.options || []"
74 :key="i4"
75 class="radio-item"
76 :class="{ 'radio-item-selected': searchValues[field.key] === opt.value }"
77 @click="onSelectRadioOption(field.key, opt.value)"
78 >
79 {{ opt.label }}
80 </div>
81 </div>
82 </div>
83
84 <!-- 默认文本输入 -->
85 <div v-else class="search-item">
86 <div class="search-item-title">{{ field.label }}:</div>
87 <input
88 type="text"
89 :class="field.inputClass || 'default-input'"
90 v-model="searchValues[field.key]"
91 :placeholder="field.placeholder"
92 @input="onChangeField(field.key)"
93 />
94 </div>
95 </template>
96 </div>
97
98 <!-- 操作按钮区域 -->
99 <div class="search-actions">
100 <template v-for="(action, idx) in actions" :key="idx">
101 <button
102 class="search-action-btn"
103 :class="action.btnClass"
104 @click="onActionClick(action.key)"
105 >
106 {{ action.label }}
107 </button>
108 </template>
109 </div>
110
111 <!-- 表格 + 分页 -->
112 <div v-if="showTable" class="search-result">
113 <table class="result-table">
114 <thead>
115 <tr>
116 <th
117 v-for="col in columns"
118 :key="col.key"
119 :style="col.style"
120 >
121 {{ col.label }}
122 </th>
123 </tr>
124 </thead>
125 <tbody>
126 <tr
127 v-for="(row, idx) in tableData"
128 :key="idx"
129 @click="onRowClick(row)"
130 >
131 <td v-for="col in columns" :key="col.key">
132 <component
133 v-if="col.render"
134 :is="col.render"
135 :data="row"
136 :field="col.key"
137 />
138 <template v-else>
139 {{ row[col.key] }}
140 </template>
141 </td>
142 </tr>
143 </tbody>
144 </table>
145
146 <!-- 分页器 -->
147 <div v-if="showPagination && totalPages > 1" class="pagination">
148 <div class="pagination-container">
149 <button
150 class="pagination-btn"
151 :disabled="currentPage <= 1"
152 @click="changePage(currentPage - 1)"
153 >
154 上一页
155 </button>
156 <span class="pagination-text">
157 第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
158 </span>
159 <button
160 class="pagination-btn"
161 :disabled="currentPage >= totalPages"
162 @click="changePage(currentPage + 1)"
163 >
164 下一页
165 </button>
166 </div>
167 </div>
168 </div>
169 </div>
170 </template>
171
172 <script>
173 import axios from 'axios'
174
175 export default {
176 name: 'SearchPanel',
177 props: {
178 // 搜索字段配置
179 fields: {
180 type: Array,
181 default: () => []
182 },
183 // 操作按钮
184 actions: {
185 type: Array,
186 default: () => []
187 },
188 // 表格列
189 columns: {
190 type: Array,
191 default: () => []
192 },
193 // 表格数据获取函数
194 // 形参: (searchValues, currentPage, pageSize) => Promise<{ list: Array, total: number }>
195 fetchTableFn: {
196 type: Function,
197 required: true
198 },
199 // 是否显示表格
200 showTable: {
201 type: Boolean,
202 default: true
203 },
204 // 是否显示分页器
205 showPagination: {
206 type: Boolean,
207 default: false
208 },
209 // 是否在字段变化时自动请求
210 autoFetch: {
211 type: Boolean,
212 default: false
213 },
214 // 每页数量
215 pageSize: {
216 type: Number,
217 default: 10
218 }
219 },
220
221 data() {
222 return {
223 // 内部记录搜索字段的值
224 searchValues: {},
225 // 联想结果
226 suggestResults: {},
227 // 联想下拉是否显示
228 showDropdown: {},
229 // 表格数据
230 tableData: [],
231 // 分页
232 currentPage: 1,
233 totalPages: 1,
234 // 是否正在请求
235 isFetching: false
236 }
237 },
238
239 watch: {
240 // 监控搜索值变化 => 若autoFetch,则更新表格,并把currentPage重置为1
241 searchValues: {
242 deep: true,
243 handler() {
244 if (this.autoFetch) {
245 this.currentPage = 1
246 this.fetchTableData()
247 }
248 }
249 },
250
251 // 监控currentPage变化 => 请求新数据
252 currentPage(val) {
253 if (this.autoFetch) {
254 this.fetchTableData()
255 }
256 }
257 },
258
259 created() {
260 // 初始化 searchValues
261 this.fields.forEach(f => {
262 this.$set(this.searchValues, f.key, f.defaultValue || '')
263 })
264 },
265
266 mounted() {
267 document.addEventListener('click', this.handleClickOutside)
268 // 开autoFetch了就直接来一次
269 if (this.autoFetch) {
270 this.fetchTableData()
271 }
272 },
273
274 beforeDestroy() {
275 document.removeEventListener('click', this.handleClickOutside)
276 },
277
278 methods: {
279 handleClickOutside(event) {
280 // 遍历所有联想输入框字段
281 this.fields.forEach(field => {
282 if (field.type === 'suggest') {
283 const dropdownRef = this.$refs['dropdown-' + field.key]
284 const inputRef = this.$refs['input-' + field.key]
285
286 // 获取实际 DOM 元素
287 const dropdownEl = dropdownRef ? dropdownRef[0] : null
288 const inputEl = inputRef ? inputRef[0] : null
289
290 // 检查点击有没有在下拉结果或输入框
291 const clickedInDropdown = dropdownEl && dropdownEl.contains(event.target)
292 const clickedInInput = inputEl && inputEl.contains(event.target)
293
294 if (!clickedInDropdown && !clickedInInput) {
295 // 不在,关闭下拉框
296 this.$set(this.showDropdown, field.key, false)
297 }
298 }
299 })
300 },
301 // -------------------------
302 // 通用字段变化逻辑
303 // -------------------------
304 onChangeField(fieldKey) {
305 const val = this.searchValues[fieldKey]
306 this.$emit('change', { key: fieldKey, value: val })
307 },
308
309 // -------------------------
310 // 单选组
311 // -------------------------
312 onSelectRadioOption(fieldKey, optionValue) {
313 this.searchValues[fieldKey] = optionValue
314 this.onChangeField(fieldKey)
315 },
316
317 // -------------------------
318 // 联想输入框处理
319 // -------------------------
320 async onSuggestInput(field) {
321 const val = this.searchValues[field.key]
322 if (typeof field.fetchSuggestFn !== 'function') return
323 try {
324 const results = await field.fetchSuggestFn(val)
325 // 注意必须使用this.$set更新响应式数据,不然某些情况检测不到更新!!
326 this.$set(this.suggestResults, field.key, results)
327 this.$set(this.showDropdown, field.key, true)
328 } catch (error) {
329 console.error('获取联想数据失败:', error)
330 // 确保下拉框隐藏
331 this.$set(this.showDropdown, field.key, false)
332 }
333 },
334 onFocusSuggest(field) {
335 // 显示下拉
336 if (
337 this.suggestResults[field.key] &&
338 this.suggestResults[field.key].length > 0
339 ) {
340 this.showDropdown[field.key] = true
341 }
342 },
343 // onBlurSuggest(field) {
344 // // 延迟,否则点击下拉时无法触发mousedown
345 // setTimeout(() => {
346 // this.showDropdown[field.key] = false
347 // }, 200)
348 // },
349 onSelectSuggestItem(field, item) {
350 this.searchValues[field.key] = item
351 this.showDropdown[field.key] = false
352 this.$emit('select', { key: field.key, value: item })
353 // 同时触发字段变化的事件
354 this.$emit('change', { key: field.key, value: item })
355 },
356
357 // -------------------------
358 // 表格数据请求
359 // -------------------------
360 async fetchTableData() {
361 if (this.isFetching) return
362 this.isFetching = true
363 try {
364 const { list, total } = await this.fetchTableFn(
365 { ...this.searchValues },
366 this.currentPage,
367 this.pageSize
368 )
369 this.tableData = list || []
370 const t = total || 0
371 this.totalPages = Math.ceil(t / this.pageSize)
372 } catch (e) {
373 console.error('fetchTableData error:', e)
374 }
375 this.isFetching = false
376 },
377
378 changePage(pageNum) {
379 if (pageNum < 1 || pageNum > this.totalPages) return
380 this.currentPage = pageNum
381 },
382
383 // -------------------------
384 // 操作按钮
385 // -------------------------
386 onActionClick(actionKey) {
387 this.$emit('action', actionKey)
388 },
389
390 // -------------------------
391 // 表格行点击
392 // -------------------------
393 onRowClick(rowData) {
394 this.$emit('row-click', rowData)
395 }
396 }
397 }
398 </script>