React组件开发实战-文字高亮组件

需求背景

产品: 我输入关键字搜索时候, 返回的结果能不能把关键字给我高亮起来?
开发: 可以, 安排.
产品: 我输入字母的时候, 希望不区分大小写.
开发: 可以, 安排.
产品: 如果有多个关键字, 都需要给我高亮起来. 还有还有, 如果我输入的关键字在结果中不是连续的, 也给我高亮出来.
开发: 可以.
产品: 我想起来了, 我们还有另外一个地方要用到这个功能, 但是高亮的颜色不大一样, 懂我的意思吧.
开发: ..., 我整理一份需求给你吧.

(半个小时后)

开发: 这个高亮功能包含这些功能, 你看满不满足要求:

  • 没有匹配到关键字显示正常文本
  • 支持高亮多组关键字
  • 可忽略大小写
  • 可自定义高亮样式
  • 支持简单的多层级文本高亮
  • 可以匹配特殊字符

产品: (点赞)

设计API

<HighlightText keywords={['1', 'a']}>匹配不到文本的情况正常显示</HighlightText>
<HighlightText keywords="关键字">匹配单个关键字</HighlightText>
<HighlightText keywords={['foo', 'bar']}>匹配多关键字, 例如 xxxfooxxxbar</HighlightText>
<HighlightText keywords={['a', 'c']}>忽略大小写, 例如 ABCDabcd</HighlightText>
<HighlightText keywords={['^', ']']}>正则测试: ^\$.\*+-?=!:|\/()[]{}</HighlightText>
<HighlightText keywords="样式" highlightStyle={{ color: '#f55', backgroundColor: 'rgba(0,0,0,.1)' }}>
    自定义高亮样式
</HighlightText>
<HighlightText keywords="ron">
    多层级
    <div>
        div
        <strong>strong</strong>
    </div>
</HighlightText>
复制代码

这个是我期望的样子. 来吧, 撸起袖子开始干

实现

高亮文字比较好解决, 方法也比较多. 我这里用正则的方式切割字符串, 并把分割后的字符串与匹配到的字符串做个拼接, 把需要高亮的文字加个 mark 标签. 多个关键字的情况加个遍历就好.
我写个 util 还专门处理字符串, 因为是用正则的方式匹配字符串, 所以要对正则关键字做特殊处理.

// utils.tsx

const regAtom = '^\\$.\\*+-?=!:|\\/()[]{}';

// 关键词高亮
export const highlightText = (
  text: string,
  keywords: string | string[],
  highlightStyle?: CSSProperties,
  ignoreCase?: boolean
): string | [] | ReactNode => {
  let keywordRegExp;
  if (!text) {
    return '';
  }
  // 把字符串类型的关键字转换成正则
  if (keywords) {
    if (keywords instanceof Array) {
      if (keywords.length === 0) {
        return text;
      }
      keywordRegExp = new RegExp(
        (keywords as string[])
          .filter(item => !!item)
          .map(item => (regAtom.includes(item) ? '\\' + item : item))
          .join('|'),
        ignoreCase ? 'ig' : 'g'
      );
    } else if (typeof keywords === 'string') {
      keywordRegExp = new RegExp(keywords, ignoreCase ? 'ig' : 'g');
    }
  }
  if (text && keywordRegExp) {
    const newData = text.split(keywordRegExp); //  通过关键字的位置开始截取,结果为一个数组
    // eslint-disable-next-line
    const matchWords = text.match(keywordRegExp); // 获取匹配的文本
    const len = newData.length;

    return (
      <>
        {newData.map((item, index) => (
          // eslint-disable-next-line react/no-array-index-key
          <React.Fragment key={index}>
            {item}
            {index !== len - 1 && <mark style={highlightStyle}>{matchWords?.[index]}</mark>}
          </React.Fragment>
        ))}
      </>
    );
  }
  return text;
};
复制代码

单个文本的话, 这段代码基本能解决问题了, 但是实际情况中可能会出现嵌套 html 的情况, 表现形式就不单单是字符串了, 需要特殊处理下.

// utils.tsx

export type PropsIncludeChildren = {
  props: {
    children: [] | string | ReactNode;
  };
};

// 递归子组件
export const highlightChildComponent = (
  item: PropsIncludeChildren,
  keywords: string | [],
  highlightStyle: CSSProperties,
  ignoreCase: boolean
) => {
  if (typeof item === 'string') {
    return highlightText(item, keywords, highlightStyle, ignoreCase);
  }
  // children 如果是文本, item.props.children 会等于 'string'
  if (item.props?.children && typeof item.props?.children === 'string') {
    const newItem = { ...item };
    newItem.props = {
      ...newItem.props,
      children: highlightText(newItem.props.children as string, keywords, highlightStyle, ignoreCase)
    };
    return newItem;
  }
  // 如果还有其他元素, 会返回一个数组, 遍历做判断
  if (item.props?.children && item.props?.children instanceof Array) {
    const newItem = { ...item };
    newItem.props = {
      ...newItem.props,
      children: item.props?.children.map((child, index) => (
        // eslint-disable-next-line react/no-array-index-key
        <React.Fragment key={index}>
          {highlightChildComponent(child as PropsIncludeChildren, keywords, highlightStyle, ignoreCase)}
        </React.Fragment>
      ))
    };
    return newItem;
  }
  return item;
};
复制代码

处理嵌套 html, React 组件还有可能存在多个同级的子组件, 这时候我们可以用 React.Children 的 API 来遍历子组件, 然后使用我们工具函数来遍历所有子组件.

export interface HighlightTextProp {
  keywords: [] | string | null;
  highlightStyle?: CSSProperties;
  ignoreCase?: boolean;
  children?: ReactElement;
}

const HighlightText: FC<HighlightTextProp> = ({
  keywords,
  highlightStyle = { color: '#ffa22d', backgroundColor: 'transparent', padding: 0 },
  ignoreCase = true,
  children
}) => (
  <>
    {children
      ? React.Children.map(children, item =>
          highlightChildComponent(item as PropsIncludeChildren, keywords || '', highlightStyle, ignoreCase)
        )
      : ''}
  </>
);

HighlightText.displayName = 'HighlightText';

export default HighlightText;
复制代码

正常这样就基本实现需求了. 但是写 React 常常容易犯的一个性能错误是这样的:

<HighlightText keywords={['1', 'a']}>匹配不到文本的情况正常显示</HighlightText>
复制代码

keywords 这个属性我们往往直接传了一个数组, 这样写相当于每次组件在更新的时候都告诉它, 这个是一个新的数组, 这个数组的引用地址是不一样的, React 默认比较的规则是浅比较. 所以如果不做任何处理, 这个组件的父组件每次刷新都会导致子组件刷新, 这是没有必要的.

// 测试代码, HighlightText 组件可以自己输出个日志看下
export default function Basic() {
  const [, forceUpdate] = useState();
  const clickFn = useCallback(() => {
    forceUpdate({});
  }, [])
  return (
    <>
      <button onClick={clickFn}>强制刷新页面</button>
      <HighlightText keywords={['1', 'a']}>匹配不到文本的情况正常显示</HighlightText>
    </>
  );
}
复制代码

我们可以在结果这里加个 memo, 来做性能优化.

export default memo(HighlightText, (prevProps, nextProps) => isEqual(prevProps, nextProps));
复制代码

总结

高亮组件在实际项目中是很频繁的一个功能需求. 实现这个组件用到了几个相关知识:

  • React.Children API 来遍及子组件
  • 递归处理嵌套 html
  • 正则匹配高亮高亮文字

这些技巧熟悉起来, 在以后的组件开发中经常会用到.

期待后续的React组件开发分享吧.

猜你喜欢

转载自juejin.im/post/7096846838233301005