垃圾站 网站优化 纯js实现高度可扩展关键词高亮方案详解

纯js实现高度可扩展关键词高亮方案详解

日常需求开发中常见需要高亮的场景,本文主要记录字符串渲染时多个关键词同时高亮的实现方法,目的是实现高度可扩展的多关键词高亮方案。

1. 实现的主要功能:

关键词提取和高亮多个关键词同时高亮关键词支持正则匹配每个关键字支持独立样式配置,支持高度定制化不同标签使用不同颜色区分开使用不同标签名使用定制化CSSStyle样式自定义渲染函数,渲染成任何样式扩展性较好,可以根据解析数据自定义渲染,能很好的兼容复杂的场景

2. 效果演示

体验地址:链接

纯js实现高度可扩展关键词高亮方案详解插图

高级定制用法

自定义渲染,例如可以将文本变成链接

纯js实现高度可扩展关键词高亮方案详解插图1

用法

1. react中使用

export default () => {
const text = `123432123424r2`;
const keywords = ['123'];
return (
<HighlightKeyword content={text} keywords=js高度可扩展关键词高亮,js关键词高亮 />
);
};

2. 原生js使用innerHTML

const div = document.querySelector('#div');
div.innerHTML = getHighlightKeywordsHtml(templateStr, [keyword]);

源码

核心源码

// 关键词配置
export interface IKeywordOption {
  keyword: string | RegExp;
  color?: string;
  bgColor?: string;
  style?: Record<string, any>;
  // 高亮标签名
  tagName?: string;
  // 忽略大小写
  caseSensitive?: boolean;
  // 自定义渲染高亮html
  renderHighlightKeyword?: (content: string) => any;
}
export type IKeyword = string | IKeywordOption;
export interface IMatchIndex {
  index: number;
  subString: string;
}
// 关键词索引
export interface IKeywordParseIndex {
  keyword: string | RegExp;
  indexList: IMatchIndex[];
  option?: IKeywordOption;
}
// 关键词
export interface IKeywordParseResult {
  start: number;
  end: number;
  subString?: string;
  option?: IKeywordOption;
}
/** ***** 以上是类型,以下是代码 ********************************************************/
/**
 * 多关键词的边界情况一览:
 *1. 关键词之间存在包含关系,如: '12345' 和 '234'
 *2. 关键词之间存在交叉关系,如: '1234' 和 '3456'
 */
// 计算
const getKeywordIndexList = (
  content: string,
  keyword: string | RegExp,
  flags = 'ig',
) => {
  const reg = new RegExp(keyword, flags);
  const res = (content as any).matchAll(reg);
  const arr = [...res];
  const allIndexArr: IMatchIndex[] = arr.map(e => ({
index: e.index,
subString: e['0'],
  }));
  return allIndexArr;
};
// 解析关键词为索引
const parseHighlightIndex = (content: string, keywords: IKeyword[]) => {
  const result: IKeywordParseIndex[] = [];
  keywords.forEach((keywordOption: IKeyword) => {
let option: IKeywordOption = { keyword: '' };
if (typeof keywordOption === 'string') {
  option = { keyword: keywordOption };
} else {
  option = keywordOption;
}
const { keyword, caseSensitive = true } = option;
const indexList = getKeywordIndexList(
  content,
  keyword,
  caseSensitive ? 'g' : 'gi',
);
const res = {
  keyword,
  indexList,
  option,
};
result.push(res);
  });
  return result;
};
// 解析关键词为数据
export const parseHighlightString = (content: string, keywords: IKeyword[]) => {
  const result = parseHighlightIndex(content, keywords);
  const splitList: IKeywordParseResult[] = [];
  const findSplitIndex = (index: number, len: number) => {
for (let i = 0; i < splitList.length; i++) {
  const cur = splitList[i];
  // 有交集
  if (
(index > cur.start && index < cur.end) ||
(index + len > cur.start && index + len < cur.end) ||
(cur.start > index && cur.start < index + len) ||
(cur.end > index && cur.end < index + len) ||
(index === cur.start && index + len === cur.end)
  ) {
return -1;
  }
  // 没有交集,且在当前的前面
  if (index + len <= cur.start) {
return i;
  }
  // 没有交集,且在当前的后面的,放在下个迭代处理
}
return splitList.length;
  };
  result.forEach(({ indexList, option }: IKeywordParseIndex) => {
indexList.forEach(e => {
  const { index, subString } = e;
  const item = {
start: index,
end: index + subString.length,
option,
  };
  const splitIndex = findSplitIndex(index, subString.length);
  if (splitIndex !== -1) {
splitList.splice(splitIndex, 0, item);
  }
});
  });
  // 补上没有匹配关键词的部分
  const list: IKeywordParseResult[] = [];
  splitList.forEach((cur, i) => {
const { start, end } = cur;
const next = splitList[i + 1];
// 第一个前面补一个
if (i === 0 && start > 0) {
  list.push({ start: 0, end: start, subString: content.slice(0, start) });
}
list.push({ ...cur, subString: content.slice(start, end) });
// 当前和下一个中间补一个
if (next?.start > end) {
  list.push({
start: end,
end: next.start,
subString: content.slice(end, next.start),
  });
}
// 最后一个后面补一个
if (i === splitList.length - 1 && end < content.length - 1) {
  list.push({
start: end,
end: content.length - 1,
subString: content.slice(end, content.length - 1),
  });
}
  });
  console.log('list:', keywords, list);
  return list;
};

渲染方案

1. react组件渲染

// react组件
const HighlightKeyword = ({
  content,
  keywords,
}: {
  content: string;
  keywords: IKeywordOption[];
}): any => {
  const renderList = useMemo(() => {
if (keywords.length === 0) {
  return <>{content}</>;
}
const splitList = parseHighlightString(content, keywords);
if (splitList.length === 0) {
  return <>{content}</>;
}
return splitList.map((item: IKeywordParseResult, i: number) => {
  const { subString, option = {} } = item;
  const {
color,
bgColor,
style = {},
tagName = 'mark',
renderHighlightKeyword,
  } = option as IKeywordOption;
  if (typeof renderHighlightKeyword === 'function') {
return renderHighlightKeyword(subString as string);
  }
  if (!item.option) {
return <>{subString}</>;
  }
  const TagName: any = tagName;
  return (
<TagName
  key={`${subString}_${i}`}
  style={{
...style,
backgroundColor: bgColor || style.backgroundColor,
color: color || style.color,
  }}>
  {subString}
</TagName>
  );
});
  }, [content, keywords]);
  return renderList;
};

2. innerHTML渲染

/** ***** 以上是核心代码部分,以下渲染部分 ********************************************************/
// 驼峰转换横线
function humpToLine(name: string) {
  return name.replace(/([A-Z])/g, '-$1').toLowerCase();
}
const renderNodeTag = (subStr: string, option: IKeywordOption) => {
  const s = subStr;
  if (!option) {
return s;
  }
  const {
tagName = 'mark',
bgColor,
color,
style = {},
renderHighlightKeyword,
  } = option;
  if (typeof renderHighlightKeyword === 'function') {
return renderHighlightKeyword(subStr);
  }
  style.backgroundColor = bgColor;
  style.color = color;
  const styleContent = Object.keys(style)
.map(k => `${humpToLine(k)}:${style[k]}`)
.join(';');
  const styleStr = `style="${styleContent}"`;
  return `<${tagName} ${styleStr}>${s}</${tagName}>`;
};
const renderHighlightHtml = (content: string, list: any[]) => {
  let str = '';
  list.forEach(item => {
const { start, end, option } = item;
const s = content.slice(start, end);
const subStr = renderNodeTag(s, option);
str += subStr;
item.subString = subStr;
  });
  return str;
};
// 生成关键词高亮的html字符串
export const getHighlightKeywordsHtml = (
  content: string,
  keywords: IKeyword[],
) => {
  // const keyword = keywords[0] as string;
  // return content.split(keyword).join(`<mark>${keyword}</mark>`);
  const splitList = parseHighlightString(content, keywords);
  const html = renderHighlightHtml(content, splitList);
  return html;
};

showcase演示组件

/* eslint-disable @typescript-eslint/no-shadow */
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
  Card,
  Tag,
  Button,
  Tooltip,
  Popover,
  Form,
  Input,
  Switch,
} from '@arco-design/web-react';
import { IconPlus } from '@arco-design/web-react/icon';
import ColorBlock from './color-block';
import {
  parseHighlightString,
  IKeywordOption,
  IKeywordParseResult,
} from './core';
import './index.less';
import { docStr, shortStr } from './data';
const HighlightContainer = ({ children, ...rest }: any) => <pre {...rest} className="highlight-container">
  {children}
</pre>;
const HighlightKeyword = ({
  content,
  keywords,
}: {
  content: string;
  keywords: IKeywordOption[];
}): any => {
  const renderList = useMemo(() => {
if (keywords.length === 0) {
  return <>{content}</>;
}
const splitList = parseHighlightString(content, keywords);
if (splitList.length === 0) {
  return <>{content}</>;
}
return splitList.map((item: IKeywordParseResult, i: number) => {
  const { subString, option = {} } = item;
  const {
color,
bgColor,
style = {},
tagName = 'mark',
renderHighlightKeyword,
  } = option as IKeywordOption;
  if (typeof renderHighlightKeyword === 'function') {
return renderHighlightKeyword(subString as string);
  }
  if (!item.option) {
return <>{subString}</>;
  }
  const TagName: any = tagName;
  return (
<TagName
  key={`${subString}_${i}`}
  style={{
...style,
backgroundColor: bgColor || style.backgroundColor,
color: color || style.color,
  }}>
  {subString}
</TagName>
  );
});
  }, [content, keywords]);
  return renderList;
};
const TabForm = ({ keyword, onChange, onCancel, onSubmit }: any) => {
  const formRef: any = useRef();
  useEffect(() => {
formRef.current?.setFieldsValue(keyword);
  }, [keyword]);
  return (
<Form
  ref={formRef}
  style={{ width: 300 }}
  onChange={(_, values) => {
onChange(values);
  }}>
  <h2>编辑标签</h2>
  <Form.Item field="keyword" label="标签">
<Input />
  </Form.Item>
  <Form.Item field="color" label="颜色">
<Input
  prefix={
<ColorBlock
  color={keyword.color}
  onChange={(color: string) =>
onChange({
  ...keyword,
  color,
})
  }
/>
  }
/>
  </Form.Item>
  <Form.Item field="bgColor" label="背景色">
<Input
  prefix={
<ColorBlock
  color={keyword.bgColor}
  onChange={(color: string) =>
onChange({
  ...keyword,
  bgColor: color,
})
  }
/>
  }
/>
  </Form.Item>
  <Form.Item field="tagName" label="标签名">
<Input />
  </Form.Item>
  <Form.Item label="大小写敏感">
<Switch
  checked={keyword.caseSensitive}
  onChange={(v: boolean) =>
onChange({
  ...keyword,
  caseSensitive: v,
})
  }
/>
  </Form.Item>
  <Form.Item>
<Button onClick={onCancel} style={{ margin: '0 10px 0 100px' }}>
  取消
</Button>
<Button onClick={onSubmit} type="primary">
  确定
</Button>
  </Form.Item>
</Form>
  );
};
export default () => {
  const [text, setText] = useState(docStr);
  const [editKeyword, setEditKeyword] = useState<IKeywordOption>({
keyword: '',
  });
  const [editTagIndex, setEditTagIndex] = useState(-1);
  const [keywords, setKeywords] = useState<IKeywordOption[]>([
{ keyword: 'antd', bgColor: 'yellow', color: '#000' },
{
  keyword: '文件',
  bgColor: '#8600FF',
  color: '#fff',
  style: { padding: '0 4px' },
},
{ keyword: '文件' },
// eslint-disable-next-line no-octal-escape
// { keyword: '\\d+' },
{
  keyword: 'react',
  caseSensitive: false,
  renderHighlightKeyword: (str: string) => (
<Tooltip content="点击访问链接">
  <a
href={'https://zh-hans.reactjs.org'}
target="_blank"
style={{
  textDecoration: 'underline',
  fontStyle: 'italic',
  color: 'blue',
}}>
{str}
  </a>
</Tooltip>
  ),
},
  ]);
  return (
<div style={{ width: 800, margin: '0 auto' }}>
  <div style={{ display: 'flex', alignItems: 'center' }}>
<h1>关键词高亮</h1>
<Popover
  popupVisible={editTagIndex !== -1}
  position="left"
  content={
<TabForm
  keyword={editKeyword}
  onChange={(values: any) => {
setEditKeyword(values);
  }}
  onCancel={() => {
setEditTagIndex(-1);
setEditKeyword({ keyword: '' });
  }}
  onSubmit={() => {
setKeywords((_keywords: IKeywordOption[]) => {
  const newKeywords = [..._keywords];
  newKeywords[editTagIndex] = { ...editKeyword };
  return newKeywords;
});
setEditTagIndex(-1);
setEditKeyword({ keyword: '' });
  }}
/>
  }>
  <Tooltip content="添加标签">
<Button
  type="primary"
  icon={<IconPlus />}
  style={{ marginLeft: 'auto' }}
  onClick={() => {
setEditTagIndex(keywords.length);
  }}>
  添加标签
</Button>
  </Tooltip>
</Popover>
  </div>
  <div style={{ display: 'flex', padding: '15px 0' }}></div>
  {keywords.map((keyword, i) => (
<Tooltip key={JSON.stringify(keyword)} content="双击编辑标签">
  <Tag
closable={true}
style={{
  margin: '0 16px 16px 0 ',
  backgroundColor: keyword.bgColor,
  color: keyword.color,
}}
onClose={() => {
  setKeywords((_keywords: IKeywordOption[]) => {
const newKeywords = [..._keywords];
newKeywords.splice(i, 1);
return newKeywords;
  });
}}
onDoubleClick={() => {
  setEditTagIndex(i);
  setEditKeyword({ ...keywords[i] });
}}>
{typeof keyword.keyword === 'string'
  ? keyword.keyword
  : keyword.keyword.toString()}
  </Tag>
</Tooltip>
  ))}
  <Card title="内容区">
<HighlightContainer>
  <HighlightKeyword content={text} keywords=js高度可扩展关键词高亮,js关键词高亮 />
</HighlightContainer>
  </Card>
</div>
  );
};
上一篇
下一篇
联系我们

联系我们

返回顶部