純js實現高度可擴展關鍵詞高亮方案詳解

關鍵詞高亮

日常需求開發中常見需要高亮的場景,本文主要記錄字符串渲染時多個關鍵詞同時高亮的實現方法,目的是實現高度可擴展的多關鍵詞高亮方案。

1. 實現的主要功能:

  • 關鍵詞提取和高亮
  • 多個關鍵詞同時高亮
  • 關鍵詞支持正則匹配
  • 每個關鍵字支持獨立樣式配置,支持高度定制化
    • 不同標簽使用不同顏色區分開
    • 使用不同標簽名
    • 使用定制化CSSStyle樣式
    • 自定義渲染函數,渲染成任何樣式
  • 擴展性較好,可以根據解析數據自定義渲染,能很好的兼容復雜的場景

2. 效果演示

體驗地址:鏈接

高級定制用法

  • 自定義渲染,例如可以將文本變成鏈接

用法

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>
  );
};

以上就是純js實現高度可擴展關鍵詞高亮方案詳解的詳細內容,更多關於js高度可擴展關鍵詞高亮的資料請關註WalkonNet其它相關文章!

推薦閱讀: