注册
web

上千行代码的输入框的逻辑是什么?


需求


我们要做一个前端需求:需要一个输入框,支持 KQL 语法,支持智能匹配,前提条件纯前端实现。


该功能详见 kibana es7版本。有条件的可以去使用一下,感受一番。


image.png


需求分析


使用了一下该功能,感觉还是挺复杂的。不好实现啊,我,我,我。。。


不过因为 kibana 是开源的,我就去 github 上看了一下源码。



  • 首先人家是 React 版本,我的项目是 Vue 版本,我不能行使拿来主义。
  • 一个 input 框的核心代码写了一千多行,不包括一些工具函数,公共组件之类。

方案




  1. 我先研究源码,再把研究好的源码转成 vue 版本输出?


    该方案短时间内看不到效果,需要好好梳理其源码。是一个 0 或者 1 的问题,如果研究好了并实现转化出来,那就是 1,如果期间遇到问题阻塞了,那就是短时间看不到产出效果。不敢冒险。




  2. 创建一个 React 项目,把相关的这部分代码拆分出来,以微前端的方式内嵌到我的项目中?


    不知道在拆分代码和组装代码的过程中会遇到什么问题?未知,不敢冒险去耽误时间,也是一个 0 或者 1 的问题。




  3. 自己研究 KQL 语法,自己摸索规则,自己实现其逻辑?


    由于项目排期紧张,不敢太过冒险,就选择了自研。起码 ld 能看到进度。😁




image.png


image.png


image.png


我最后选择的是方案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

下拉面板


image.png


下拉框是左右布局,右侧是检索语法说明的静态文案,可忽略。左侧是语句提示内容。


语句提示内容经过研究其实有四种:


key(字段名称)、method(运算符)、value(值)、connectionSymbol(连接符)


由于可能会有多个语句,其实我们是只对当前语句进行提示的,所以我们只分析当前语句的情况。


// 当前语句详情
{
cur_fields: '', // 当前 key
cur_methods: '', // 当前 method
cur_values: '', // 当前 value
cur_input: '' // 当前用户输入内容,可模糊匹配检索
}

有四部分,肯定就是需要在符合条件的情况下分别展示对应的 options 面板内容。


那判断条件就是如下图,其中后续需要注意的就是这几个判断条件的值赋值场景要准确。


image.png


语法分析器


想处理输入内容,做一个语法分析器,首先需要去监听用户的输入,那么就用 vue 提供的 watch。


watch: {
input: debounce(function(newValue, oldValue) {
if (newValue !== oldValue) this.dealInputShow(newValue)
}, 500)
}

基本大概思路如下:


KQL语法分析器.png


其中获取输入框的光标位置的方法如下:


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 根据定义分别存在以下几种情况:



  1. 元素自身有 fixed 定位,父元素不存在定位,则 offsetParent 的结果为 null(firefox 中为:body,其他浏览器返回为 null)
  2. 元素自身无 fixed 定位,且父元素也不存在定位,offsetParent 为 <body> 元素
  3. 元素自身无 fixed 定位,且父元素存在定位,offsetParent 为离自身最近且经过定位的父元素
  4. <body>元素的 offsetParent 是 null

offsetTop:元素到 offsetParent 顶部的距离


image.png


offsetHeight:是一个只读属性,它返回该元素的像素高度,高度包含内边距(padding)和边框(border),不包含外边距(margin),是一个整数,单位是像素 px。


通常,元素的 offsetHeight 是一种元素 CSS 高度的衡量标准,包括元素的边框、内边距和元素的水平滚动条(如果存在且渲染的话),不包含 :before或 :after 等伪类元素的高度。


image.png


scrollTop:可以获取或设置一个元素的内容垂直滚动的像素数。


一个元素的 scrollTop 值是这个元素的内容顶部(卷起来的)到它的视口可见内容(的顶部)的距离的度量。当一个元素的内容没有产生垂直方向的滚动条,那么它的 scrollTop 值为0。


clientHeight:是一个只读属性,它返回该元素的像素高度,高度包含内边距(padding),不包含边框(border),外边距(margin)和滚动条,是一个整数,单位是像素 px。


clientHeight 可以通过 CSS height + CSS padding - 水平滚动条高度 (如果存在)来计算。


image.png


最全各个属性相关图如下:


image.png


效果


效果怎么说呢,也算顺利上线生产环境了,在此截图几张,给大家看看效果。


image.png


image.png


image.png


image.png


小结


做这个需求,最难的点是要求自己去研究 KQL 的语法规则,以及使用方式,然后总结规则,写出自己的词法分析器。


其中有什么技术难点吗?似乎并没有,都是各种判断条件,最简单的 if-else。


所以想告诉大家的是,不要一心只钻研技术,在做业务的时候也需要好好梳理业务,做一个懂业务的技术人。业务和技术互相成就!


最后,如果感到本文还可以,请给予支持!来个点赞、评论、收藏三连,万分感谢!😄🙏


作者:Bigger
来源:juejin.cn/post/7210593177820676154

0 个评论

要回复文章请先登录注册