给公司写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>