前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >前端日志个性化渲染方案衍化与设计实现

前端日志个性化渲染方案衍化与设计实现

原创
作者头像
苏在
发布2023-11-21 14:52:29
2680
发布2023-11-21 14:52:29
举报
文章被收录于专栏:前端技术__xjsu前端技术__xjsu

目标功能

如下图所示的,日志文本多种高亮样式渲染,内容可分词进行点击以处理快速操作。

目标功能示例截图
目标功能示例截图

背景

随着智研日志汇的发展,用户对前台日志检索体验的需求不断增加。在发展的各个阶段中,为了满足用户快速定位问题日志的需求,而从零开始,一步步迭代前台日志呈现的功能。

迭代阶段摘要

#

需求 or 问题

处理 / 优化逻辑

0

需求:检索关键词高亮

通过关键词 split 日志原文后,关键词首尾加上高亮样式 span 标签

1

需求:兼容忽略关键词的大小写

拷贝一份关键词数据和日志原文数据,通过toLowerCase,来标记分割的位置,再根据标记的位置来操作原关键词、原日志

2

问题:v-html导致的特殊字符问题

日志原文、关键词,全文替换特殊字符

3

问题:多关键词时,插入的样式标签会导致不同关键词split时相互影响

以split字符串为宽,不同关键词为深,递归split、添加样式标签

4

需求:需要对日志原文分词,以支持对每个词进行点击操作

分词:根据分词符字符集分词,输入string,输出[{isWordLike:true, segment: “…”},…]; 兼容高亮逻辑:在原有的递归高亮逻辑上,对分割出来的数组中的每个字符串进行分词,关键词默认当作一个词

5

问题:高亮逻辑破坏了分词逻辑

对分词好后的分词数组进行高亮逻辑处理

6

问题:分词逻辑破坏了高亮逻辑,例如高亮字符串和多个分词有交集的场景

// TO BE CONTINUE…

方案设计

功能需求和技术难点

功能需求

  1. 能够高亮检索匹配到的关键词
  2. 能够高亮用户自定义的关键词
  3. 将原始日志进行分词操作,每个词支持点击快速添加到日志检索条件中
  4. 值为JsonString的日志字段内容,支持格式成结构化样式,格式化后的内容,需要兼容前面三个功能

技术难点

实现细节:

  1. 功能 1 和功能 2 可以合并为同一个功能,用相同的逻辑渲染不同的样式。
  2. 功能 3 的注意点在于,可点击的triger将会很多,需要注意性能优化问题;分词逻辑的设计。
  3. 功能 4 的麻烦点在于如何将开源社区的组建,和本项目非常个性化的功能相结合起来。
  4. 还需要注意,当单条日志长度超级长时的极端情况,所可能造成的前端性能问题。

整体整合难点:

大体功能可以分为两大模块:「高亮逻辑模块」和「分词模块」。而两个模块底层实现上,都是对原始日志的字符串内容进行操作——根据不同的需要,对目标子串(eg: 需要高亮的字符串、被分词逻辑分出来的字符串)包装上所需要的html标签,来实现对应的功能。而问题在于,这两个功能模块是很有可能被相互影响到的。

比如以下这个字符串:

代码语言:javascript
复制
Hello World!

首先,这个字符串将被分词为(先抛结果,具体算法先略过,只有当isWorldLike===true时,才是可操作的):

代码语言:javascript
复制
[
	{ "value": "Hello", "isWorldLike": true},
	{ "value": " ", "isWorldLike": false},
	{ "value": "World", "isWorldLike": true},
	{ "value": "!", "isWorldLike": false}
]

如果用户配置了高亮关键词:「lo w」。那么,高亮逻辑和分词逻辑将会同时产生交集和并集的情况。

功能设计

功能框架

首先,解决两大功能模块孰先孰后的方向问题。所谓孰先孰后,就是选择打断哪一个匹配的字符串,来保证另一个的字符串完整性的问题。语言文字描述比较抽象,按上面文本:「Hello World!」、高亮「lo w」的例子来讲,我们有两种解决方案:

代码语言:javascript
复制
// plan1:
<link>Hel<highlight>lo</highlight></link><highlight> </highlight><link><highlight>W</highlight>orld</link>!

// plan2:
<link>Hel</link><highlight><link>lo</link> <link>W</link></highlight><link>orld</link>!
  • plan1:是优先保证分词逻辑的完整性,把高亮内容打断
  • plan2:是优先保证高亮内容的完整性,把分词的内容打断

这就能很清楚的了解,分词的逻辑优先级是跟高的——因为打断分词会影响到分词功能的使用,而高亮仅作为渲染展示功能,被打断所受的影响更小。

高亮方案设计

其次,就是如何在高亮基础上做分词的问题。这里先简述下上表中,方案3的实现思路:

  1. 将高亮关键词由长到短进行排序(优先高亮更长的关键词,以此略过有交集、并集的情况)
  2. 以高亮关键词数组为纵深,进行递归:
    1. 递归参数:当前日志文本字符串、当前遍历的高亮关键词
    2. 处理逻辑:
      1. 用高亮关键词split分割日志文本字符串
      2. 将每个得到分割的数组,带上下一个高亮关键词进入新的递归
      3. 遍历边界:遍历完所有高亮关键词即退出

具体如下图所示:

高亮功能流程图
高亮功能流程图

这段旧的逻辑,可以复用到现在的需求当中来。区别在于:

  • 旧的逻辑:每层退出遍历前,会将高亮关键词包装上高亮的样式「<span class=”***”>highlight_keyword</span>」,作为参数,将split完、经历递归包装的日志文本字符串数组再join起来,最后返回一串innerHTML字符串
  • 新的逻辑:不再进行join操作,也不再返回一个innerHTML字符串。而是返回需要高亮的子串首位下标位置

最后,高亮功能模块输出了一个,需要高亮的子串首位下标的数组。

分词方案设计

初版分词,直接调用浏览器的Intl.Segmenter来进行分词。但由于浏览器的自然语义分词方案,和ElasticSearch可支持自定义分词符配置不能完全吻合,故放弃该方案。

现分词方案如下图所示:(比较简单,不再赘述)

分词功能流程图
分词功能流程图

最后,分词功能模块输出了一个,由「segment(存储词语文本或分词符)」和「isWordLike」两个字段组成的结构体的数组。

两大模块整合方案设计

简要思路,遍历一边日志文本,根据遍历到的节点,给分词包装上相应功能的HTML标签,给高亮关键词包装上渲染样式的HTML标签:

功能设计大致如下:

整体实现流程图
整体实现流程图

具体实现看下示代码(整体包装模块):

代码实现

整体包装模块

代码语言:javascript
复制
    wrapSegments(text, expand = true) {
      let remain = null
      // 性能优化:如果文本长度超长,则隐藏超长部分
      if (text?.length > this.foldLimit) {
        if (expand) {
          remain = text.slice(this.foldLimit, text.length)
        }
        text = text.slice(0, this.foldLimit)
        if (!expand) {
          text += '...'
        }
        this.expand = expand === true
      }
      // 获取高亮范围:
      const hlRange = this.getDecorateRanges((text + (remain || '')).toLowerCase(), 0, this.keyword.length - 1).sort((a, b) => a.start - b.start)
      // 获取分词数组:
      const segments = this.segmenter(text)
      if (remain) {
        segments.push({
          segment: remain,
          isWordLike: false
        })
      }

      let result = ''
      let hlIndex = 0 // 扫描到的高亮关键词下标
      let head = 0 // 记录扫描过的分词长度,高亮替换时减掉
      const spanClass = this.logConfig.segmenter ? 'class="quick-search-segment"' : ''
      for (const segment of segments) {
        let str = segment.segment
        let buffer = 0 // 每个分词当中,已经加上的HTML标签的总长度,用来记录偏移量
        let replaceEnd = 0 // replace end: 记录html关键字符转义结尾
        while (hlRange[hlIndex]?.start < head + segment.segment.length) {
          let before = ''
          switch (hlRange[hlIndex].type) {
            case ('bold'):
              before = `<span ${spanClass} style="font-weight: bold; color: red;">`
              break
            case ('keyword'):
              before = `<span ${spanClass} style="${styles[hlRange[hlIndex].index % styles.length]}">`
              break
            case ('query'):
              before = `<span ${spanClass} style="color: red;">`
          }
          const start = hlRange[hlIndex].start - head + buffer
          let end = hlRange[hlIndex].end - head + buffer
          let moveIndex = false
          if (end > buffer + segment.segment.length) {
            end = str.length
          } else {
            moveIndex = true
          }
          // replaceKeyChar:替换HTML关键字符(<、>、&、")
          const beforeStr = this.replaceKeyChar(str.slice(replaceEnd, start))
          const kwStr = this.replaceKeyChar(str.slice(start, end))

          // 连带包装好的关键词的 从头到当前扫描位置的 字符串
          const tmpStr = `${str.slice(0, replaceEnd)}${beforeStr}${before}${kwStr}</span>`

          // 字符转换、高亮标签增加的长度
          buffer += beforeStr.length - str.slice(replaceEnd, start).length + kwStr.length - str.slice(start, end).length + before.length + 7
          replaceEnd = tmpStr.length

          str = tmpStr + str.slice(end, str.length)
          if (moveIndex) {
            hlIndex++
          } else {
            hlRange[hlIndex].start = head + segment.segment.length
          }
        }
        if (replaceEnd < str.length) {
          str = `${str.slice(0, replaceEnd)}${this.replaceKeyChar(str.slice(replaceEnd, str.length))}`
        }
        if (segment.isWordLike) {
          result += `<span class="quick-search-segment" title="${this.keyValue}">${str}</span>`
        } else {
          result += str
        }
        head += segment.segment.length
      }
      return result
    },

高亮功能模块

代码语言:javascript
复制
    getDecorateRanges(text, head, hlIndex) {
      if (hlIndex < 0) {
        return []
      }
      const ranges = []
      const keyword = this.keywordMatcher[hlIndex]
      const arr = (text + '').split(keyword.text.toLowerCase())
      let front = 0
      for (let i = 0; i < arr.length; i++) {
        if (i < arr.length - 1) {
          ranges.push({
            start: head + front + arr[i].length,
            end: head + front + arr[i].length + keyword.text.length,
            type: keyword.type,
            index: hlIndex
          })
        }
        ranges.push(...this.getDecorateRanges(arr[i], head + front, hlIndex - 1))
        front += arr[i].length + keyword.text.length
      }
      return ranges
    },

分词功能模块

代码语言:javascript
复制
    segmenter(text) {
      if (!this.logConfig.segmenter) {
        return [{
          segment: text,
          isWordLike: false
        }]
      }
      if (this.fieldDataMapping?.isCls || this.keyValue === '@message') {
        return logSegmenter(text, this.fieldDataMapping?.isCls ? CLS_TOKENIZER : undefined)
      }
      const mapping = this.fieldDataMapping ? this.fieldDataMapping[this.keyValue] : null
      if (mapping?.type?.includes('text') && mapping.index === true) {
        return logSegmenter(text, mapping.analyzer?.pattern || undefined)
      } else {
        return [{
          segment: text,
          isWordLike: true
        }]
      }
    },

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目标功能
  • 背景
    • 迭代阶段摘要
    • 方案设计
      • 功能需求和技术难点
        • 功能需求
        • 技术难点
      • 功能设计
        • 功能框架
        • 高亮方案设计
        • 分词方案设计
        • 两大模块整合方案设计
    • 代码实现
      • 整体包装模块
        • 高亮功能模块
          • 分词功能模块
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档