上千行代码的输入框的逻辑是什么?
需求
我们要做一个前端需求:需要一个输入框,支持 KQL 语法,支持智能匹配,前提条件纯前端实现。
该功能详见 kibana es7版本。有条件的可以去使用一下,感受一番。
需求分析
使用了一下该功能,感觉还是挺复杂的。不好实现啊,我,我,我。。。
不过因为 kibana 是开源的,我就去 github 上看了一下源码。
- 首先人家是 React 版本,我的项目是 Vue 版本,我不能行使拿来主义。
- 一个 input 框的核心代码写了一千多行,不包括一些工具函数,公共组件之类。
方案
我先研究源码,再把研究好的源码转成 vue 版本输出?
该方案短时间内看不到效果,需要好好梳理其源码。是一个 0 或者 1 的问题,如果研究好了并实现转化出来,那就是 1,如果期间遇到问题阻塞了,那就是短时间看不到产出效果。不敢冒险。
创建一个 React 项目,把相关的这部分代码拆分出来,以微前端的方式内嵌到我的项目中?
不知道在拆分代码和组装代码的过程中会遇到什么问题?未知,不敢冒险去耽误时间,也是一个 0 或者 1 的问题。
自己研究 KQL 语法,自己摸索规则,自己实现其逻辑?
由于项目排期紧张,不敢太过冒险,就选择了自研。起码 ld 能看到进度。😁
我最后选择的是方案3:自研。但是如果有时间,我更倾向的想去尝试方案1 和方案2。
针对自研方案,我们就开干吧!撸起袖子加油干!😄
准备
首先,我们需要一些准备工作,我需要了解 KQL 语法是什么?然后使用它,研究其规则,梳理其逻辑。
Kibana 查询语言 (Kibana Query Language、简称 KQL) 是一种使用自由文本搜索或基于字段的搜索过滤 Elasticsearch 数据的简单语法。 KQL 仅用于过滤数据,并没有对数据进行排序或聚合的作用。
KQL 能够在您键入时建议字段名称、值和运算符。 建议的性能由 Kibana 设置控制。
KQL 能够查询嵌套字段和脚本字段。 KQL 不支持正则表达式或使用模糊术语进行搜索。
更为详细的可以看官方文档 Kibana Query Language
- key method value 标准单个语句
- key method value OR/AND key method value OR/AND key method value .... 标准多个语句
- key OR/AND key OR/AND key OR/AND key method value .... 不标准多个语句
- ......
Tips:key(字段名称)、method(运算符)、value(值)
实现
textarea
- 由于用户可以输入多行文本信息,所以需要 textarea。type="textarea"
- 为了用户能清楚看到输入内容,以及input 的美观,初始行数 :rows="2"
- 因为我们能支持关键字和KQL两种情况,所以 placeholder="KQL/关键字"
- 获取焦点需要打开下拉展示框 @focus="dropStatus = true"
- 失去焦点且没有操作下拉选项则关闭下拉框 @blur="changeDrop"
- 由于下拉框的位置需要跟着 textarea 高度变化,所以 v-resizeHeight="inputResizeHeight"
<el-input
v-resizeHeight="inputResizeHeight"
id="searchInputID"
ref="searchInputRef"
v-model="input"
:rows="2"
type="textarea"
placeholder="KQL/关键字"
class="searchInput"
@blur="changeDrop"
@focus="dropStatus = true"
>
</el-input>
changeDrop 需要判断用户是否正在操作下拉框内容,如果是,就不要关闭。这块你会怎么实现呢?可以先思考自己的实现方式,再看下边是我个人的实现方式。
其实理论就是给下拉框操作的时候增加标记,在失去焦点要关闭的时候,判断是否有这个标记,如果有,就不要关闭,否则就关闭。但这个标记又不能影响真正的失焦状态下关闭动作。
我想到的就是定时器,定时器能增加一个变量,同时还能自动销毁。具体的实现方式:
// 不关闭下拉框标记
noCloseInput() {
this.$refs.searchInputRef.focus()
if (this.timer) clearInterval(this.timer)
let time = 500
this.timer = setInterval(() => {
time -= 100
if (time === 0) {
clearInterval(this.timer)
this.timer = null
}
}, 100)
}
// 失焦操作
changeDrop() {
setTimeout(() => {
if (!this.timer) this.dropStatus = false
}, 200)
}
这么做需要有以下几点注意:
- 失焦操作因为需要切换到下拉框有一定延迟需要定时器,而定时器的时间必须小于标记里边的定时器时间
- 定时器 this.timer = setInterval() 中 this.timer 是定时器的 id
- clearInterval(this.timer) 只会清除定时器,不会清空 this.timer
v-resizeHeight="inputResizeHeight" 这个是我写的一个自定义指令来检测元素的高度变化的,不知道你有什么好的方法吗?有的话请请共享一下,😍
const resizeHeight = {
// 绑定时调用
bind(el, binding) {
let height = ''
function isResize() {
// 可根据需求,调整内部代码,利用 binding.value 返回即可
const style = document.defaultView.getComputedStyle(el)
if (height !== style.height) {
// 此处关键代码,通过此处代码将数据进行返回,从而做到自适应
binding.value({ height: style.height })
}
height = style.height
}
// 设置调用函数的延时,间隔过短会消耗过多资源
el.__vueSetInterval__ = setInterval(isResize, 100)
},
unbind(el) {
clearInterval(el.__vueSetInterval__)
}
}
export default resizeHeight
下拉面板
下拉框是左右布局,右侧是检索语法说明的静态文案,可忽略。左侧是语句提示内容。
语句提示内容经过研究其实有四种:
key(字段名称)、method(运算符)、value(值)、connectionSymbol(连接符)
由于可能会有多个语句,其实我们是只对当前语句进行提示的,所以我们只分析当前语句的情况。
// 当前语句详情
{
cur_fields: '', // 当前 key
cur_methods: '', // 当前 method
cur_values: '', // 当前 value
cur_input: '' // 当前用户输入内容,可模糊匹配检索
}
有四部分,肯定就是需要在符合条件的情况下分别展示对应的 options 面板内容。
那判断条件就是如下图,其中后续需要注意的就是这几个判断条件的值赋值场景要准确。
语法分析器
想处理输入内容,做一个语法分析器,首先需要去监听用户的输入,那么就用 vue 提供的 watch。
watch: {
input: debounce(function(newValue, oldValue) {
if (newValue !== oldValue) this.dealInputShow(newValue)
}, 500)
}
基本大概思路如下:
其中获取输入框的光标位置的方法如下:
const selectionStart = this.$refs.searchInputRef.$el.children[0].selectionStart
修改完了之后,光标会自动跑的最后,这样有点违反用户操作逻辑,所以需要设置一下光标位置:
if (this.endValue) {
this.$nextTick(() => {
const dom = this.$refs.searchInputRef.$el.children[0]
dom.setSelectionRange(this.input.length, this.input.length)
this.input += this.endValue
})
}
还有面板里边有四项内容,那每一项内容选择都可以通过鼠标点击选择,点击选择后,就需要按照规则处理一下,进行最终的字符串 this.input 拼接,得到最终结果。
// 当前 key 点击选择
curFieldClick(str) {},
// 当前 method 点击选择
curMethodClick(str) {},
// 当前 value 点击选择
curValueClick(str) {},
// 当前 链接符 点击选择
curConnectClick(str) {},
这部分需要注意的就是点击面板 input 会失去焦点,就加上前边说到的 noCloseInput()
不关闭下拉面板标记。
键盘快捷操作
必备的目前就 3 个事件 enter、up、down,其他算是锦上添花,由于排期紧张,暂时只做了必备的 3 个 事件:
<el-input
v-resizeHeight="inputResizeHeight"
id="searchInputID"
ref="searchInputRef"
v-model="input"
:rows="2"
type="textarea"
placeholder="KQL/关键字"
class="searchInput"
@blur="changeDrop"
@focus="dropStatus = true"
@keydown.enter.native.capture.prevent="getSearchEnter($event)"
@keydown.up.native.capture.prevent="getSearchUp($event)"
@keydown.down.native.capture.prevent="getSearchDown($event)"
>
</el-input>
那么,我们的这几个键盘事件都需要怎么处理呢??接下来就直接上代码简单分析一下:
// 键盘 enter 事件,有两种情况
// 一种就是 选择内容,第二种就是 相当于回车事件直接触发接口
getSearchEnter(event) {
event.preventDefault()
// 当前下拉面板的展示的 options
const suggestions = this.get_suggestions()
// 满足可以选的条件
if (this.dropStatus && this.dropIndex !== null && suggestions[this.dropIndex]) {
// 光标之后是否有内容,有就需要截取处理
// ......
// 当前项是否是手动输入的,需要做截取处理
// .......
// 拼接 enter 键选择的选项
this.input += suggestions[this.dropIndex] + ' '
// 光标之后是否有内容,就需要设置光标在当前操作位置,并拼接之前截取掉的光标后的内容
// .......
// 设置当前语法区域的各个当前项 cur_fields、cur_methods、cur_values
// ......
// 恢复键盘 up、down 选择初始值
this.dropIndex = 0
} else {
// 不满足选的条件,就关闭选择面板,并触发检索查询接口
this.dropStatus = false
this.$emit('getSearchData', 2)
}
},
// 键盘 up 事件
getSearchUp(event) {
event.preventDefault()
// 满足上移,就做 dropIndex 减法
if (this.dropStatus && this.dropIndex !== null) {
this.decrementIndex(this.dropIndex)
}
},
// 键盘 down 事件
getSearchDown(event) {
event.preventDefault()
// 满足下移,就做 dropIndex 加法
if (this.dropStatus && this.dropIndex !== null) {
this.incrementIndex(this.dropIndex)
}
},
// 加法,注意边界问题
incrementIndex(currentIndex) {
let nextIndex = currentIndex + 1
const suggestions = this.get_suggestions()
// 到最后边,重置到第一个,形成循环
if (currentIndex === null || nextIndex >= suggestions.length) {
nextIndex = 0
}
this.dropIndex = nextIndex
// 被选择的选项如果不在可视范围之内,需要滚动到可视区
this.$nextTick(() => this.scrollToOption())
},
// 减法,注意边界问题
decrementIndex(currentIndex) {
const previousIndex = currentIndex - 1
const suggestions = this.get_suggestions()
// 到最前边,重置到最后,形成循环
if (previousIndex < 0) {
this.dropIndex = suggestions.length - 1
} else {
this.dropIndex = previousIndex
}
// 被选择的选项如果不在可视范围之内,需要滚动到可视区
this.$nextTick(() => this.scrollToOption())
},
键盘事件的核心逻辑上述基本说清楚了,那么其中需要注意的一个点,那就是被选择的选项如果不在可视范围之内,需要滚动到可视区
,这样可提高用户体验。那这块到底怎么做呢?其实实现起来还挺有意思的。
import scrollIntoView from './scroll-into-view'
// 滚动 optiosns 区域,保持在可视区域
scrollToOption() {
if (this.dropStatus === true) {
const target = document.getElementsByClassName('drop-active')[0]
const menu = document.getElementsByClassName('search-drop__left')[0]
scrollIntoView(menu, target)
}
},
scroll-into-view.js 内容如下:
export default function scrollIntoView(container, selected) {
// 如果当前激活 active 元素不存在
if (!selected) {
container.scrollTop = 0
return
}
const offsetParents = []
let pointer = selected.offsetParent
while (pointer && container !== pointer && container.contains(pointer)) {
offsetParents.push(pointer)
pointer = pointer.offsetParent
}
const top = selected.offsetTop + offsetParents.reduce((prev, curr) => (prev + curr.offsetTop), 0)
const bottom = top + selected.offsetHeight
const viewRectTop = container.scrollTop
const viewRectBottom = viewRectTop + container.clientHeight
if (top < viewRectTop) {
container.scrollTop = top
} else if (bottom > viewRectBottom) {
container.scrollTop = bottom - container.clientHeight
}
}
针对上述内容几个技术点做出简单解释:
offsetParent:就是距离该子元素最近的进行过定位的父元素,如果其父元素中不存在定位则 offsetParent为:body元素。
offsetParent 根据定义分别存在以下几种情况:
- 元素自身有 fixed 定位,父元素不存在定位,则 offsetParent 的结果为 null(firefox 中为:body,其他浏览器返回为 null)
- 元素自身无 fixed 定位,且父元素也不存在定位,offsetParent 为
<body>
元素 - 元素自身无 fixed 定位,且父元素存在定位,offsetParent 为离自身最近且经过定位的父元素
<body>
元素的 offsetParent 是 null
offsetTop:元素到 offsetParent 顶部的距离
offsetHeight:是一个只读属性,它返回该元素的像素高度,高度包含内边距(padding)和边框(border),不包含外边距(margin),是一个整数,单位是像素 px。
通常,元素的 offsetHeight 是一种元素 CSS 高度的衡量标准,包括元素的边框、内边距和元素的水平滚动条(如果存在且渲染的话),不包含 :before或 :after 等伪类元素的高度。
scrollTop:可以获取或设置一个元素的内容垂直滚动的像素数。
一个元素的 scrollTop 值是这个元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。当一个元素的内容没有产生垂直方向的滚动条,那么它的 scrollTop 值为0。
clientHeight:是一个只读属性,它返回该元素的像素高度,高度包含内边距(padding),不包含边框(border),外边距(margin)和滚动条,是一个整数,单位是像素 px。
clientHeight 可以通过 CSS height + CSS padding - 水平滚动条高度 (如果存在)来计算。
最全各个属性相关图如下:
效果
效果怎么说呢,也算顺利上线生产环境了,在此截图几张,给大家看看效果。
小结
做这个需求,最难的点是要求自己去研究 KQL 的语法规则,以及使用方式,然后总结规则,写出自己的词法分析器。
其中有什么技术难点吗?似乎并没有,都是各种判断条件,最简单的 if-else。
所以想告诉大家的是,不要一心只钻研技术,在做业务的时候也需要好好梳理业务,做一个懂业务的技术人。业务和技术互相成就!
最后,如果感到本文还可以,请给予支持!来个点赞、评论、收藏三连,万分感谢!😄🙏
来源:juejin.cn/post/7210593177820676154