Write By CS 逍遥剑仙
我的主页: http://www.csxiaoyao.com/
- GitHub: https://github.com/csxiaoyaojianxian
- Email: sunjianfeng@csxiaoyao.com
- QQ:
1. 需求
最近的项目中, 需要实现在 vue 框架中动态渲染带提示框的单选 / 多选文本框, 具体的效果如下图所示, 在输入框聚焦时, 前端组件通过接收的 kv 参数渲染出选项, 用户点击选项, 可以将选择的选项的 key 拼装到输入框中, 同时允许用户自由输入.
由于项目中使用的 element-ui, 首选考虑使用组件的 input 和 select 组件, 然而实际使用中发现框架提供的组件不能很好满足此需求. 例如, 使用带输入建议的 input 组件, 能够实现提示框和单选, 但并不能方便地实现多选(重复选择会覆盖输入框内的内容).
而使用框架提供的 select 选择器的远程搜索功能, 能够实现提示框, 也能轻松实现单选与多选, 但 select 组件的内容只能通过用户选择(文本框内容必须包含于提示选项中), 不允许用户自由输入文本内容.
再加上设计稿需要实现三列布局, 最终的返回结果需要动态拼装选项 key 值, 若对现有的 element 组件进行改造成本过高, 因此, 尝试封装带提示框的单选 / 多选文本框组件, 记录下封装过程中组件交互方面遇到的问题.
2. 接口参数设计
组件支持传入 6 个参数, 分别为
- size (尺寸, String, medium / small / mini)
- value (输入值, String, 可以使用 sync 修饰符实现双向绑定)
- opt (选项列表, Array,kv 数组形如{
- key:1, value:xxx
- })
- seperator (分隔符, String, 如',','|','-')
- multiple (是否支持多选, Boolean)
- placeholder (提示, String)
调用方式如下:
- <cs-select
- size="mini" // 尺寸
- :value.sync="value" // value
- :opt="optParams.kv" // 选项
- seperator="," // 分隔符
- :multiple="true">
- </cs-select>
3. 提示框显示隐藏交互实现
细化上述需求, 需要在用户点击输入框 (获取焦点) 时, 显示提示框, 在用户点击空白区域时隐藏提示框, 点击组件自身时不做任何操作. 组件的模板结构如下, 通过 show 变量控制提示框的显示与隐藏, 在组件的输入框绑定聚焦和失焦事件: @focus="onfocus" 和 @blur="onblur", 在 focus 时设置 this.show 为 true,blur 时为 false, 由于点击了输入框外的选项元素必然导致输入框失焦从而自动关闭, 所有问题的关键在于如何实现点击提示选项而不隐藏提示框.
- <template>
- <div>
- <!-- 输入框 -->
- <el-input
- @focus="onfocus
- @blur="onblur>
- </el-input>
- <!-- 提示框 -->
- <div v-if="show && opt.length> 0">
- <el-row>
- <el-col :span="8" v-for="(item, index) in opt" :key="index">
- {{item.value}}
- </el-col>
- </el-row>
- </div>
- </div>
- </template>
3.1 尝试方案 1: click 事件主动聚焦
根据上述需求, 毫无疑问联想到可以为选项绑定 click 事件, 调用 el-input 的 focus()方法进行主动聚焦, 实现如下, 此处使用了 vue 的 ref, 通过 $ref 来查找 dom 元素.
- clickEvent () {
- this.show = true // 设置提示框显示
- this.$refs.input.$el.querySelector('input').focus() // 设置主动聚焦
- }
问题: 实际开发过程中发现, 每次点击提示选项后, 提示框会闪烁一次, 原因在于 JS 的事件机制, blur 事件先于 click 事件执行, 导致提示框隐藏后再显示, 造成闪烁.
3.2 尝试方案 2: blur 事件添加延时器 + 开关变量
由于方案 1blur 事件先于 click 事件执行, 因此考虑使用 settimeout 延时器来改变执行时间, 实现如下.
- blurEvent () {
- setTimeout(() => {
- this.show = false
- }, 200)
- }
问题: 实际开发过程中发现, 延时器延时执行关闭操作, 导致输入框获取焦点后, 主动关闭了提示框, 不再自动打开, 不满足需求, 因此考虑使用开关变量 canClose 判断当前是否需要执行关闭, 实现如下.
- focusEvent () {
- this.show = true
- this.canClose = true // 聚焦时打开开关
- },
- blurEvent () {
- if (this.canClose) {
- setTimeout(() => {
- this.show = false // 只有开关打开时才执行关闭
- }, 200)
- }
- },
- clickEvent (key) {
- this.canClose = false // 点击提示选项, 关闭开关
- this.show = true
- ...
- }
问题: 实际开发过程中发现, 大多数情况下, 提示框能够显示与隐藏, 但是当操作较快时, 会偶尔出现提示框不能关闭或提前关闭的情况, 分析原因在于, 延时器期间任何对开关的操作可能导致组件开关状态变化, 致使状态紊乱.
3.3 尝试方案 3: 不使用 blur, 关闭方法改为事件委托, 动态绑定 class
如果关闭不使用 blur, 而是通过点击事件触发, 则不会存在上述时序问题, 因此考虑在全局使用事件委托, 监听用户的点击事件, 通过判断节点特殊 class 实现提示框关闭, 实现如下.
- $('body').on('click', (event) => {
- this.show = false
- })
- $('body').on('click', className, (event) => {
- this.show = true
- })
问题 1: 事件委托, 使用固定的 class, 当同时渲染多个组件时, 无法实现单独管理提示框的开关, 因此无法渲染多组件, 因此 class 使用动态绑定, 每个组件使用不同的 class, 实现如下.
问题 2: 阻止冒泡, 如果组件的父容器阻止了冒泡, 则无法触发 body 上绑定的关闭方法, 需要针对父容器单独处理.
- let randId = Math.round(Math.random()*100000)
- this.className = `cs-select-${randId}`
- // 单独处理父容器, 在父容器上绑定关闭事件
- ...
改造后的组件表面看起来已经基本可用, 实际存在诸多问题:
问题 1: 组件中对父组件绑定了事件, 违反了设计模式的迪米特法则, 增加了组件间的耦合, 不利于后期维护.
问题 2: 上述操作只考虑了点击事件的关闭, 忽略了其他可能关闭的情况, 如使用 tab 按键切换输入框时也需要能正常显示隐藏提示框.
问题 3: 绑定事件过多会带来性能隐患甚至导致意想不到的问题发生.
3.4 尝试方案 4: onfocus + onblur + mousedown + 开关
由于 focus 事件先于 click 事件执行, 导致了上述方案 1 和方案 2 问题的产生, 通过查阅资料可知, mousedown 事件先于 focus 事件执行, 因此, 使用 onfocus + onblur + mousedown + 开关能够很好解决上述执行时序问题, 具体实现如下.
- focusEvent () {
- this.show = true
- this.canClose = true // 聚焦时打开开关
- },
- blurEvent () {
- if (this.canClose) {
- this.show = false // 只有开关打开时才执行关闭
- }
- },
- mousedownEvent (key) {
- this.canClose = false // 点击提示选项, 关闭开关
- this.show = true
- this.$refs.input.$el.querySelector('input').focus()
- ...
- }
问题: 实际开发中发现, 由于组件是动态渲染的, mousedownEvent 事件中无法直接获取到当前对象的 dom 元素 this.$refs.xxx, 导致自动聚焦失败.
3.5 实现方案
在方案 4 的基础上, 使用 nextTick 异步更新队列能够解决 dom 渲染时序问题, 具体实现针对方案 4 稍作修改即可.
$nextTick:
在 vue 官方深入响应式原理中说明了 vue 实现响应式并不是数据发生变化之后 DOM 立即变化, 而是在下次 DOM 更新循环结束之后执行延迟回调, 在修改数据之后使用 $nextTick, 则可以在回调中获取更新后的 DOM, 官方示例: focusEvent () {
- this.show = true
- this.canClose = true // 聚焦时打开开关
- },
- blurEvent () {
- if (this.canClose) {
- this.show = false // 只有开关打开时才执行关闭
- }
- },
- mousedownEvent (key) {
- this.canClose = false // 点击提示选项, 关闭开关
- this.show = true
- this.$nextTick(() => {
- this.$refs.input.$el.querySelector('input').focus()
- })
- ...
}4. 组件数据双向绑定
为了方便组件内数据的处理, 传入组件的输入值 value 会首先被 split 分解为 key 数组, 然后添加 watcher 观察器, 监听输入值的变化, 更新提示框的选中状态, 并通过 $emit 方法同步到父组件中, 实现数据的双向绑定, 输入值的 watch 如下所示:
- watch: {
- inputVal: {
- handler () {
- let selectArray = this.inputFilter()
- this.inputVal = selectArray.join(this.seperator)
- // 更新选中状态
- this.updateActive()
- // 同步数据
- this.$emit('update:value', this.inputVal) // 可改为 v-model
- },
- immediate: true
- }
- }
5. 组件应用与改进
带提示框的单选 / 多选文本框组件的应用场景较多, 典型的场景如封装企业联系人的选择器, 用户输入用户名关键词, 提示框显示相关联系人, 同时允许用户自由输入用户名.
组件还有不少可以改进的地方, 例如:
目前的设计通过监听 mousedown 来阻止提示框的关闭, 很明显不能兼容移动端, 可以考虑添加 touch 事件;
在 CSS 布局方面没有判断用户可见的友好性, 在极端情况下可能会超出屏幕范围;
还不支持 slot 插槽和动态设置 class 等.
随着整体项目的迭代可以逐步完善.
来源: https://www.qcloud.com/developer/article/1372866