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

2,497 阅读11分钟

本文正在参加「金石计划」

需求

日志服务平台是提供 一站式的日志解决方案的日志服务。

具体提供日志接入数据采集日志管理日志告警日志检索图表分析链路追踪等。   日志检索是我们日志服务平台必备的核心能力,同时也是用户使用频率最高的一个模块,所以针对该模块提供更优质的体验、更稳定的服务就成为我们首要任务。

Kibana ES7 版本提供了 KQL 检索,这个功能可以提供模糊匹配,智能提示语法等能力。可帮助用户快速的、便捷的进行日志检索。

目前给用户开放的是 Kibana ES6 版本,ES7 版本 和 ES6 版本底层之间有差异,不能直接连通使用。

正因为 ES6 版本不支持,加上日志服务平台是一站式的日志服务,所以我们日志服务平台需要实现语句 KQL模式检索

那么就有了一个前端需求:需要一个输入框,支持 KQL 语法,支持智能匹配,前提条件纯前端实现。

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

image.png

如果让大家做这么一个需求?大家会有什么思路?会怎么做?请先思考。

需求分析

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

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

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

我的方案

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

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

image.png

image.png

image.png

源码分析

Kibana ES7 版本的 KQL 查询的输入框智能匹配前端代码逻辑可以分为以下几个部分:

  1. 监听用户输入:前端代码会监听用户在搜索框中输入的内容。

  2. 发送请求获取匹配项:当用户输入内容时,前端代码会向 Elasticsearch 服务器发送请求,获取与用户输入内容匹配的查询项。

  3. 处理匹配项:前端代码会将 Elasticsearch 返回的匹配项进行处理,以便在搜索框中展示给用户。

  4. 展示匹配项:前端代码会将处理后的匹配项展示在搜索框下拉列表中,供用户选择。

  5. 监听用户选择:当用户选择某个匹配项时,前端代码会将该项的值填充到搜索框中,并触发查询操作。

总的来说,这些功能可以帮助用户快速输入正确的查询语句,提高查询效率。

方案一、

Q:我先研究源码,再把研究好的源码全部转成 vue 版本输出?

A:由于源码部分也是非常复杂,并没有第一时间内全部消化,只梳理了大致的流程。同时该方案短时间内看不到效果,需要好好梳理其源码。是一个 0 或者 1 的问题,如果研究好了并实现转化出来,那就是 1,如果期间遇到问题阻塞了,那就是短时间看不到产出效果。不敢冒险。

方案二、

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

A:因为我们的业务也不是全部一样,存在差异。同时不知道在拆分代码和组装代码的过程中会遇到什么问题?未知,不敢冒险去耽误时间,也是一个 0 或者 1 的问题。

方案三、

Q:了解了源码大致流程,加上自研自己研究 KQL 语法,自己摸索规则,自己实现其逻辑?

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

  我最后选择的是方案三:自研(梳理了源码大致逻辑 + 自研语法分析器)

难点就在没有人告诉我规则,需要一个“门外汉”来自行摸索,深入业务使用,自己研究。

针对自研方案,我们就开干吧!撸起袖子加油干!😄

准备

首先,我们需要一些准备工作,我需要了解 KQL 语法是什么?然后使用它,研究其规则,梳理其逻辑。

Kibana 查询语言 (Kibana Query Language、简称 KQL) 是一种使用自由文本搜索或基于字段的搜索过滤 Elasticsearch 数据的简单语法。 KQL 仅用于过滤数据,并没有对数据进行排序或聚合的作用。

KQL 能够在您键入时建议字段名称、值和运算符。

KQL 能够查询嵌套字段和脚本字段。 KQL 不支持正则表达式或使用模糊术语进行搜索。

更为详细的可以看官方文档 Kibana Query Language

Kibana的KQL(Kibana Query Language)是一种基于Lucene查询语法的查询语言,用于在Kibana中进行数据查询和分析。以下是KQL的语法规则:

  1. 查询语句以查询关键字(如queryfiltermustshould等)开头,后面跟着查询条件。

  2. 查询条件由字段名和查询值组成,中间用冒号分隔。例如:field_name:query_value

  3. 多个查询条件可以通过逻辑运算符(如ANDORNOT)组合起来。例如:field_name1:query_value1 AND field_name2:query_value2

  4. 查询值可以使用通配符(如*?)进行模糊匹配。例如:field_name:query_*

  5. 查询值可以使用引号将多个单词组合成一个短语进行精确匹配。例如:field_name:"query phrase"

  6. 查询值可以使用布尔运算符(如>, <, >=, <=, =, !=)进行比较。例如:field_name > query_value

  7. 查询语句可以使用括号来控制运算符的优先级。例如:(field_name1:query_value1 OR field_name2:query_value2) AND field_name3:query_value3

  8. 查询语句可以使用特殊字符(如+-()[]{}等)进行高级查询。例如:+field_name:query_value -field_name2:query_value2

  9. 查询语句可以使用聚合函数(如countsumavgminmax等)进行数据分析。例如:count(field_name)

总的来说,KQL的语法规则与Lucene查询语法类似,但是KQL更加简洁易懂,适合非专业人士使用。

简单总结:

  • 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 …. 不标准多个语句
  • (key method value OR/AND key method value) OR/AND key method value …. 优先级
  • key method value OR/AND (key method value 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。

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

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