1、痛点
- 开发一些业务后,突然新增一个需求(后台根据法律法规配置一系列的敏感词汇,对于web页面中的需要呈现并做一些提示或则规则处理)
- 做这个之前也找了网上的一系列插件,比如:findAndReplaceDOMText,但是用起来后发现比较多的问题,如:1、支持的查找关键词只能单一化,不能支持数组化的查找;2、对于查找后的词组不方便做后续的处理(比如tip,事件的注册)。
2、功能点
- 支持多个关键词查找(关键词支持Regexp,字符串,返回前两种格式的函数)
- 搜索dom支持纯字符串文本,querySelector可查找字符串,Node节点及数组
- 支持配置化过滤节点(指定一些区域是否属于查找范围)
- 支持替换后还原
- 支持与Vue等框架结合(不破坏框架的响应式)
3、实现方案
V1
- 在此版本前,前面还有很多的版本;第一个版本V1也是最简单的版本,字符串的递归替换,使用一个正则表达式就可以轻松完成,以下是核心代码:
此版本缺点:1、无法进行dom的回滚还原;2、对于包裹标签属性存在的内容如果也命中关键词,会出问题;3、事件的绑定比较恶心/** * 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) }
V2
- 遇到问题后,就要解决问题V1的主要问题是包裹标签属性内容也会命中,那我们就先不要生成属性值,而是用一个惟一值进行占位,查找完后再进行填空式替换,以上包裹标签的属性内容先用值的hash值进行占位:
跟V1其实还是差不多,扩展性跟可用性都比较差。(针对例如在Vue中使用会遇到破坏了其原有的响应式,无解!!!)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); } - 越到问题就解决问题,如何才能优雅的替换且不破坏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
代码
由于当前代码还未发到我的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
结束语
发现开发简单的例子啥的,记录一遍收获也满多的,关键是文档写的蛋疼阿(可以去补充以下MD的语法了)。