全文关键词查找(关键词高亮) vue

1,603 阅读7分钟

1、痛点

  • 开发一些业务后,突然新增一个需求(后台根据法律法规配置一系列的敏感词汇,对于web页面中的需要呈现并做一些提示或则规则处理)
  • 做这个之前也找了网上的一系列插件,比如:findAndReplaceDOMText,但是用起来后发现比较多的问题,如:1、支持的查找关键词只能单一化,不能支持数组化的查找;2、对于查找后的词组不方便做后续的处理(比如tip,事件的注册)。

2、功能点

  • 支持多个关键词查找(关键词支持Regexp,字符串,返回前两种格式的函数)
  • 搜索dom支持纯字符串文本,querySelector可查找字符串,Node节点及数组
  • 支持配置化过滤节点(指定一些区域是否属于查找范围)
  • 支持替换后还原
  • 支持与Vue等框架结合(不破坏框架的响应式)

3、实现方案

V1

  • 在此版本前,前面还有很多的版本;第一个版本V1也是最简单的版本,字符串的递归替换,使用一个正则表达式就可以轻松完成,以下是核心代码:
    /**
    * text:待替换文本
    * keywords:关键词列表
    * tag:关键词包裹标签
    **/
    function getReplaceText(text, keywords, tag) {
      return keywords.reduce((v, keyword) => {
        let { sensitiveWord, description, wordType } = keyword
        let _wordType = wordType.toLowerCase()
        let _regexp = getRegexp(sensitiveWord, 'g')
        return v.replace(_regexp, (_, n) => {
          let _tag = document.createElement(tag)
          let _icon = document.createElement('i')
          let _textNode = document.createTextNode(n)
          ICON_MAP[wordType] && _icon.classList.add(ICON_MAP[wordType])
          _tag.setAttribute(`${CONTEXT}-seed`, `${SEED++}`)
          _tag.setAttribute(`${CONTEXT}-desc`, description)
          _tag.setAttribute(`${CONTEXT}-type`, _wordType)
          _tag.classList.add(CONTEXT)
          _tag.classList.add(`${CONTEXT}-${_wordType}`)
          _tag.appendChild(_icon)
          _tag.appendChild(_textNode)
          return _tag.outerHTML
        })
      }, text)
    }
    
    
    此版本缺点:1、无法进行dom的回滚还原;2、对于包裹标签属性存在的内容如果也命中关键词,会出问题;3、事件的绑定比较恶心

V2

  • 遇到问题后,就要解决问题V1的主要问题是包裹标签属性内容也会命中,那我们就先不要生成属性值,而是用一个惟一值进行占位,查找完后再进行填空式替换,以上包裹标签的属性内容先用值的hash值进行占位:
    function getHashCode(str, caseSensitive) {
      if (!caseSensitive) {
        str = str.toLowerCase();
      }
      let _hash = 1315423911, _i, _ch;
      for (_i = str.length - 1; _i >= 0; _i--) {
        _ch = str.charCodeAt(_i);
        _hash ^= ((_hash << 5) + _ch + (_hash >> 2));
      }
      return (_hash & 0x7FFFFFFF);
    }
    
    跟V1其实还是差不多,扩展性跟可用性都比较差。(针对例如在Vue中使用会遇到破坏了其原有的响应式,无解!!!)
  • 越到问题就解决问题,如何才能优雅的替换且不破坏Vue等框架的响应式呢,后面就产生的当前的版本V3。版本的优点就是前两个版本的痛点,扩展性也相对较高(使用es6 Class写法)。

4、V3讲解

实例化插件

import { Keyword } from '@qiangkun/sdk'
let keyword=new Keyword(el,{options})

参数支持

static DEF_OPTIONS = {
   tag: 'bs',//包裹标签
   list: [],//关键词列表{word,desc,type}
   filter: () => true,//过滤函数
   icons: ICON_MAP,//根据关键词类型生成图标(比如电灯泡)
   wrapClass: () => '',//根据关键词获取customClass
   walkHitDom: loop,//遍历哪一个查找到的关键词(可做tip,事件绑定等操作)
   validator: loop,//返回是否查找到关键词
   isReplace: true,//是否做替换操作
   immediate:false//是否立即查找
 }

运行

实例化后执行实例的run方法即可开始查找并替换,(此时也支持指定参数,覆盖之前的配置运行)

/**
  * 执行一次查找(重置上一次状态)
  * @param {Object} options 执行本次查找参数
  */
 run(options = {}) {
   let _targets = []
   this.__hasRun__ = true
   this.__domMap__ = new Map()
   this.__revertFns__ = []
   this.__aimWord__ = new Set()
   this.options = merge(deepClone(Keyword.DEF_OPTIONS), options)
   // 过滤器
   this.filters = this._getFilters(this.options.filter)
   if (this.options.list && this.options.list.length) {
     let _textNodes = this._getTextNodes()
     _log('原始关键词标签-->', _textNodes)
     if (_textNodes && _textNodes.length) {
       _targets = this._replaceFns(_textNodes)
       if (this.options.isReplace) {
         _log('替换标签-->', _targets)
         for (let _node of _targets) {
           this.options.walkHitDom(_node)
         }
       }
     }
   }
   if (!this.options.isReplace) {
     this.options.validator(!!_targets.length, this.getAimWord())
   } else {
     this.options.validator(!!_targets.length, _targets, this.getAimWord())
   }
   return _targets
 }

恢复还原

在搭配vue等框架使用时,替换原先的dom会破坏掉其原有的响应式机制,所有在vue state变化之后dom更新之前我们需要将web页面恢复至未替换之前的页面;需要恢复dom我们在查找替换的过程中就需要记录替换之前的dom及注册相应的恢复函数;如下:

/**
  *  遍历dom替换关键词
  * @param {Array} doms 目标搜索dom节点列表
  * @returns {Array,Function} 已替换元素列表,回滚函数
  */
 _replaceFns(doms) {
   let _replaceDomsSet = new Set()
   doms.forEach(dom => {
     let _splitDoms = this._getMatched(dom.nodeValue)
     _splitDoms = _splitDoms.filter(node => !!node)
     if (_splitDoms.length) {
       let _parentNode = dom.parentNode
       let _fragment = document.createDocumentFragment()
       _splitDoms.forEach(node => {
         if (node.nodeName.toLowerCase() === this.options.tag) {
           _replaceDomsSet.add(node)
         }
         _fragment.appendChild(node)
       })
       // 只查找不做替换
       if (this.options.isReplace) {
         // 存储被替换的dom
         this.__domMap__.set(dom, _splitDoms)
         // 临时节点(用于text节点移除钩子)
         let _tempFragment = document.createDocumentFragment()
         _parentNode.insertBefore(_fragment, dom)
         _tempFragment.appendChild(dom)
         // 添加revert函数
         this.__revertFns__.push(() => {
           if (_tempFragment.contains(dom)) {
             _parentNode.insertBefore(dom, _splitDoms[0])
           }
           _splitDoms.forEach((ele, i) => {
             _parentNode.removeChild(ele)
           })
         })
       }
     }
   })
   return [..._replaceDomsSet]
 }

其中__revertFns__存储着每次替换后的回滚函数;有了这个页面的恢复就简单多了,在数据变化后我们执行一次revert方法即可:

/**
  * 重置所有dom
  */
 revert() {
   if (!this.__hasRun__ || !this.__revertFns__.length) {
     return
   }
   this.__revertFns__.forEach(fn => fn())
   this.__revertFns__ = []
   this.__domMap__.clear()
 }

这样在每次替换之前相当于页面都是干净的。 这边还有一个功能点就是根据源文本节点生成替换后的节点集合,这边实现方案是:根据关键词分割源文本内容然后再拼装生成节点集合,实现如下:

 /**
  * 查找关键字替换返回关键词分割的dom数列
  * @param {String} text 匹配的文本
  * @returns {Array} 生成的dom列表
  */
 _getMatched(text, keywords = []) {
   if (!text) {
     return []
   }
   let _keyword = null
   let _keywords = keywords.slice(0)
   while (_keyword = _keywords.shift()) {
     let { word, desc, type, ...other } = _keyword
     let _regexp = this._getMatchRegExp(word)
     let _texts = text.split(_regexp)
     if (_texts.length > 1) {
       this.__aimWord__.add(_regexp.source)
       let _matchs = text.match(_regexp)
       return _texts.reduce((prevDoms, text, index) => {
         let _nextDoms = [...prevDoms, ...this._getMatched(text, _keywords)]
         if (_matchs[index]) {
           _nextDoms.push(this._createWrapDom(other.tag || this.options.tag, { word: _matchs[index], type, desc, ...other }))
         }
         return _nextDoms
       }, [])
     }
   }
   return [document.createTextNode(text)]
 }

这里的__aimWord__是记录匹配的关键词列表。

搭配vue使用

这里是对dom的操作,所以很快就想到使用vue指令的方式进行使用(使用方式如下,具体业务可自行修改使用),这边直接贴代码:

import { Keyword,ToolTip } from '@qiangkun/sdk'
import '@qiangkun/sdk/theme/tooltip.css'
import '@qiangkun/sdk/theme/keyword.css'
import { getOptions, CONTEXT, getByPath, isDefined } from './utils'
function setTooltip(dom) {
 let _dataset = dom[CONTEXT]
 new ToolTip(dom, { type: _dataset.type, content: _dataset.desc })
}

// 关键词适配器
function wordDataAdapt(el, vm) {
 let { list, ...others } = getOptions(el, vm)
 list = list.map(lis => {
   return {
     ...lis,
     word: lis.sensitiveWord,
     desc: lis.description,
     type: lis.wordType.toLowerCase()
   }
 })
 return { ...others, list }
}
export default {
 inserted: (el, binding, vnode) => {
   let _keyword = new Keyword(el)
   let _vm = vnode.context
   el[CONTEXT] = {
     keyword: _keyword
   }
   _vm.$watch(binding.expression, (n, o) => {
     _vm.$nextTick(() => {
       _keyword.revert()
       _keyword.run({
       <!--数据适配,请根据具体场景-->
         ...wordDataAdapt(el, _vm),
         walkHitDom: setTooltip.bind(_vm)
       })
     })
   }, {
     deep: true,
     immediate: true
   })
 },
 unbind: (el) => {
   if (el[CONTEXT] && el[CONTEXT].keyword)
     el[CONTEXT].keyword.revert()
 }
}

DEMO

关键词demo

代码

由于当前代码还未发到我的github,这边先贴代码:

import { CONTEXT, isDocumentFrament, merge, deepClone, loop, getType, isHtmlElement } from '../../utils'
import './index.scss'
// 关键词图标map
const ICON_MAP = {
 worn: 'el-icon-tishi1',
 forbid: 'el-icon-tishi1'
}
// dom seed
let SEED = 0

/**
* dev LOG
* @param  {...any} args 打印数据
*/
const _log = (...args) => {
 if (process.env.NODE_ENV === 'development') {
   console.log('🦄:', ...args)
 }
}


class Keyword {
 static DEF_OPTIONS = {
   tag: 'bs',//包裹标签
   list: [],//关键词列表{word,desc,type}
   filter: () => true,//过滤函数
   icons: ICON_MAP,//
   wrapClass: () => '',
   walkHitDom: loop,
   validator: loop,
   isReplace: true,
   immediate: false
 }
 constructor(el, options = {}) {
   this.el = this._getContainer(el)
   this.options = merge(deepClone(Keyword.DEF_OPTIONS), options)
   this.__hasRun__ = false
   if (this.options.immediate) {
     this.run()
   }
 }
 /**
  * 执行一次查找(重置上一次状态)
  * @param {Object} options 执行本次查找参数
  */
 run(options = {}) {
   let _targets = []
   this.__hasRun__ = true
   this.__domMap__ = new Map()
   this.__revertFns__ = []
   this.__aimWord__ = new Set()
   this.options = merge(deepClone(Keyword.DEF_OPTIONS), options)
   // 过滤器
   this.filters = this._getFilters(this.options.filter)
   if (this.options.list && this.options.list.length) {
     let _textNodes = this._getTextNodes()
     _log('原始关键词标签-->', _textNodes)
     if (_textNodes && _textNodes.length) {
       _targets = this._replaceFns(_textNodes)
       if (this.options.isReplace) {
         _log('替换标签-->', _targets)
         for (let _node of _targets) {
           this.options.walkHitDom(_node)
         }
       }
     }
   }
   if (!this.options.isReplace) {
     this.options.validator(!!_targets.length, this.getAimWord())
   } else {
     this.options.validator(!!_targets.length, _targets, this.getAimWord())
   }
   return _targets
 }
 /**
  * 获取匹配的源列表
  */
 getAimWord() {
   return [...this.__aimWord__]
 }
 /**
  * 重置所有dom
  */
 revert() {
   if (!this.__hasRun__ || !this.__revertFns__.length) {
     return
   }
   this.__revertFns__.forEach(fn => fn())
   this.__revertFns__ = []
   this.__domMap__.clear()
 }
 /**
  *  遍历dom替换关键词
  * @param {Array} doms 目标搜索dom节点列表
  * @returns {Array,Function} 已替换元素列表,回滚函数
  */
 _replaceFns(doms) {
   let _replaceDomsSet = new Set()
   doms.forEach(dom => {
     let _splitDoms = this._getMatched(dom.nodeValue, this.options.list)
     _splitDoms = _splitDoms.filter(node => !!node)
     if (_splitDoms.length) {
       let _parentNode = dom.parentNode
       let _fragment = document.createDocumentFragment()
       _splitDoms.forEach(node => {
         if (node.nodeName.toLowerCase() === this.options.tag) {
           _replaceDomsSet.add(node)
         }
         _fragment.appendChild(node)
       })
       // 只查找不做替换
       if (this.options.isReplace) {
         // 存储被替换的dom
         this.__domMap__.set(dom, _splitDoms)
         // 临时节点(用于text节点移除钩子)
         let _tempFragment = document.createDocumentFragment()
         _parentNode.insertBefore(_fragment, dom)
         _tempFragment.appendChild(dom)
         // 添加revert函数
         this.__revertFns__.push(() => {
           if (_tempFragment.contains(dom)) {
             _parentNode.insertBefore(dom, _splitDoms[0])
           }
           _splitDoms.forEach((ele, i) => {
             _parentNode.removeChild(ele)
           })
         })
       }
     }
   })
   return [..._replaceDomsSet]
 }
 /**
  * 构造关键词包裹元素
  * @param {String} tag 包裹标签
  * @param {Object} param1 额外参数
  * @returns {Element} 包裹元素
  */
 _createWrapDom(tag, { word, type, desc, ...other }) {
   let _tag = document.createElement(tag)
   let _textNode = document.createTextNode(word)
   let _wrapClass = this.options.wrapClass({ word, type, desc, ...other }, _tag)
   let _hasIcon = this.options.icons[type]
   if (_hasIcon) {
     let _icon = document.createElement('i')
     _icon.classList.add(this.options.icons[type])
     _tag.appendChild(_icon)
   }
   _tag.setAttribute(`${CONTEXT}-seed`, `${SEED++}`)
   _tag.setAttribute(`${CONTEXT}-desc`, desc)
   _tag.setAttribute(`${CONTEXT}-type`, type)
   _tag.classList.add(CONTEXT)
   _wrapClass && _tag.classList.add(_wrapClass)
   _tag.classList.add(`${CONTEXT}-${type}`)
   _tag.appendChild(_textNode)
   _tag[CONTEXT] = { word, type, desc, ...other }
   return _tag
 }
 /**
  * 查找关键字替换返回关键词分割的dom数列
  * @param {String} text 匹配的文本
  * @returns {Array} 生成的dom列表
  */
 _getMatched(text, keywords = []) {
   if (!text) {
     return []
   }
   let _keyword = null
   let _keywords = keywords.slice(0)
   while (_keyword = _keywords.shift()) {
     let { word, desc, type, ...other } = _keyword
     let _regexp = this._getMatchRegExp(word)
     let _texts = text.split(_regexp)
     if (_texts.length > 1) {
       this.__aimWord__.add(_regexp.source)
       let _matchs = text.match(_regexp)
       return _texts.reduce((prevDoms, text, index) => {
         let _nextDoms = [...prevDoms, ...this._getMatched(text, _keywords)]
         if (_matchs[index]) {
           _nextDoms.push(this._createWrapDom(other.tag || this.options.tag, { word: _matchs[index], type, desc, ...other }))
         }
         return _nextDoms
       }, [])
     }
   }
   return [document.createTextNode(text)]
 }
 /**
  * 构建正则
  * @param {String} word 匹配目标字符串
  * @param {String} flag 正则修饰符
  * @returns {RegExp} 正则表达式
  */
 _getRegexp(word, flag) {
   if (flag) {
     return new RegExp(`${word}`, flag)
   } else {
     return new RegExp(`${word}`)
   }
 }
 /**
  * 根据输入串获取正则表达式
  * @param {String} word 输入字符串
  * @returns {RegExp} 匹配表达式
  */
 _getMatchRegExp(word) {
   let _regexp = null
   let _type = getType(word)
   switch (_type) {
     case 'String':
       _regexp = this._getRegexp(word, 'g')
       break;
     case 'RegExp':
       _regexp = word
       break;
     case 'Function':
       _regexp = getMatchRegExp(word())
       break;
     default:
       _regexp = this._getRegexp(word.toString(), 'g')
       break;
   }
   return _regexp
 }
 /**
  * 获取text节点过滤器
  * @param {Function|Array} filter 自定义过滤器
  */
 _getFilters(filter) {
   let _keywords = this.options.list.map(lis => this._getMatchRegExp(lis.word).source)
   let _regexp = this._getRegexp(_keywords.join('|'))
   let _filterTag = dom => dom.parentNode.tagName.toLowerCase() !== this.options.tag
   let _filterKeyword = dom => _regexp.test(dom.nodeValue)
   let _filterValue = dom => !!dom.nodeValue.replace(/[\s\r\n]*/g, '')
   let _filters = [_filterValue, _filterTag, _filterKeyword]
   let _type = getType(filter)
   if (_type === 'Array') {
     _filters.push(...filter)
   } else if (_type === 'Function') {
     _filters.push(filter)
   }
   return _filters
 }
 /**
  * 获取查找容器
  * @param {Element} el 查找容器
  * @returns {Element} 容器
  */
 _getContainer(el) {
   let _type = getType(el)
   if (_type === 'String') {
     try {
       return document.querySelector(el)
     } catch (error) {
       let _div = document.createElement('div')
       _div.innerHTML = el
       if (_div.childNodes.length > 1) {
         return this._getContainer(_div.childNodes)
       } else {
         return _div.childNodes[0]
       }
     }
   }
   if (_type === 'Array') {
     let _fragment = document.createDocumentFragment()
     el.forEach(e => {
       _fragment.appendChild(this._getContainer(e))
     })
     return _fragment
   }
   if (el instanceof Node) {
     return el
   }
 }
 /**
  * 过滤dom是否符合条件
  * @param {Element} dom dom元素
  * @param {Array} allDoms 之前符合的dom
  * @returns {Boolean} 是否符合
  */
 _isValidDom(dom, allDoms) {
   for (let filter of this.filters) {
     if (!filter(dom, allDoms)) {
       return false
     }
   }
   return true
 }
 /**
  * 递归获取节点下所有文本节点
  * @returns {Array} 符合条件的dom节点
  */
 _getTextNodes() {
   let _allDoms = []
   const _loopFn = (el) => {
     if (el.nodeType === Node.TEXT_NODE && this._isValidDom(el, _allDoms)) {
       _allDoms.push(el)
     }
     if (!isHtmlElement(el) && !isDocumentFrament(el)) {
       return
     }
     let _childs = el.childNodes
     for (let _index = 0; _index < _childs.length; _index++) {
       _loopFn(_childs[_index])
     }
   }
   _loopFn(this.el)
   return _allDoms
 }
}

export default Keyword

结束语

npm地址

GitHub地址

发现开发简单的例子啥的,记录一遍收获也满多的,关键是文档写的蛋疼阿(可以去补充以下MD的语法了)。