如何给字符串中的数字添加样式?

背景

有时候,我们需要对一段文字中的数字添加一些特殊的样式,以突出展示。例如下面这个例子:

如何给字符串中的数字添加样式?

这时候,你可能会想:“呵呵,小菜一碟,难不倒我”。于是写出了一段如下的代码:

<span>
   距离高考还有
   <span style={{ color: 'blue', fontSize: '18px', fontWeight: 'bold' }}>{day}</span>
   天,加油呀!
</span>

虽然上面的代码达到了目的,但是没法处理一些场景,举两个例子:

  • 整个句子都是动态生成的
    如展示的内容是请求接口获取的,但是也需要高亮展示其中的数字,这种情况没法像上面一样把句子写死。

  • 国际化
    这种 “断句式” 的写法很难进行国际化。如上面的例子,国际化时需要把中文部分拆分成两部分: 距离高考还有天,加油呀!。不同的语言,句子结构会有所不同,单独翻译句子中的一部分,最后拼接成一句话,很有可能导致整个句子语意不通,难以理解。翻译、使用过程都变得更为复杂:

    // zh-cn.json
    {
        "days-left-prefix": ”距离高考还有“,
        "day-left-suffix": "天,加油呀!"
    }
    
    // 使用
    <span>
      {t('days-left-prefix')}
      <span style={{ 
          color: 'blue', 
          fontSize: '18px', 
          fontWeight: 'bold' 
       }}>{day}</span>
       {t('days-left-suffix')}
    </span>
    

    理想情况下,我们在国际化时会把数字部分提取成一个参数。以 react-i18next 为例:提取文字时,会把中文句子提取为 ”距离高考还有 {{day}} 天,加油呀!“, 使用时通过参数传入具体的天数:

      ```json
      // zh-cn.json
      {
          "days-left": ”距离高考还有 {{day}} 天,加油呀!“
      }
    
      // 使用
      <span>{t('days-left', { day: 8 })}</span>
      ```
    

    这样,翻译时你需要翻译的是 ”距离高考还有 {{day}} 天,加油呀!“ 这句话,有完整的语意。但是,因为 t 函数返回的是一段字符串,所以无法给数字部分添加样式。

    那么,该如何操作,才既能加亮句子中的数字,又能保持句子的完整性呢?

思路

想要保留句子的完整性,同时也加亮数字部分,我们需要封装一个组件,把整个句子作为一个属性传给组件,然后在组件内部匹配句子中的数字并添加样式。例如这样

<Component content=”距离高考还有8天,加油呀!“ />

我们可以使用正则匹配字符串中的数字,但是匹配数字之后,又该怎么给数字添加样式呢?

我们需要使用到 JS 中的 Range 接口。Range是什么?简单来说,Range 表示文档中的一个范围。如下图中我们用鼠标选中的文字部分就是一个 Range

如何给字符串中的数字添加样式?

MDN 上对于 Range 的定义:

Range 接口表示一个包含节点与文本节点的一部分的文档片段。

可以使用 Document.createRange 方法创建 Range。也可以用 Selection 对象的 getRangeAt() 方法或者 Document 对象的 caretRangeFromPoint() 方法获取 Range 对象。

还可以用 Range() 构造函数。

我们可以使用构造函数 Range() 创建一个 Range 对象:

const range = new Range()

range 对象上有一些操作 DOM 的方法,可以进行提取、删除、插入节点等等操作。此处罗列出我们将会用到的几个方法:

  1. setStart – 设置 Range 的起点
  2. setEnd – 设置 Range 的终点
  3. surroundContents – 将 Range 中的内容移到一个新的节点

先说 setStartsetEnd, 这两个方法的参数相同。调用时需传入两个参数:

  • node: 开始/结束位置所属于的节点
  • offset: 开始/结束位置在所属节点中的偏移量

举个简单的例子, 我们想选中下方文字中的数字 8

<p id="p">距离高考还有8天,加油呀</p>

数字 8 在所属的文本节点中 index 为 6,所以我们可以创建一个 range 并设置开始和结束:

// 创建 range
const range = new Range();

// 设置开始结束
const textNode = p.firstChild;
range.setStart(textNode, 6);
range.setEnd(textNode, 7);

// 实现鼠标选中的效果
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);

然后,借助 surroundContents 方法, 我们可以对选中的区域添加一些样式效果。该方法只有一个参数—— 一个可以包裹 range 的父节点。此处,我们用 <b> 标签包裹数字已达到加粗的效果。

let newNode = document.createElement('b');
range.surroundContents(newNode);

效果如下:

如何给字符串中的数字添加样式?

实现

看到这里,整体思路已经比较清楚了,我们只需要匹配字符串中的数字,然后用一个带样式的 <span> 标签包裹数字,就可以达到加亮数字的效果了。

如何给字符串中的数字添加样式?

我们以 react 为例,封装一个可复用的组件 Hightlight, 首先定义下组件的属性

export type HightlightProps = {
  // 输入的字符串 
  content: string;
  
  // 正则,用于匹配想要加亮的内容
  pattern: RegExp;
  
  // 匹配内容的样式
  style?: CSSProperties;
  
  // 匹配内容点击时调用
  onTargetClick?: (args: { target: string; index: number }) => void;
}

首先,我们创建一个 Hightlight 组件, 该组件把输入的字符串嵌入一个 span 里, 并给该节点绑定了一个 ref

const Highlight = (props: HightlightProps) => {
  const { content, pattern, style = {}, onTargetClick } = props;
  const ref = useRef<HTMLSpanElement>(null);

  return <span ref={ref}>{content}</span>;
};

接下来,我们需要监听 content 的变化,每次输入的字符串发生变化时,都需要重新处理 span 的子节点

useEffect(() => {
    // 找到文本节点
    let target: ChildNode | null | undefined = ref.current?.firstChild;
    if (!target) return;

    let match = pattern.exec(target.textContent!);
    let index = 0;
    while (target && match != null) {
      const start = match.index;
      const end = pattern.lastIndex;
      
      // 创建 range 并设置范围
      const range = new Range();
      range.setStart(target, start!);
      range.setEnd(target, end);
      
      // 创建 span 节点并添加样式
      const span = document.createElement('span');
      Object.entries(style).forEach(([key, value]) => {
        Reflect.set(span.style, key, value);
      });
      
      // 处理匹配部分的点击事件,事件处理函数有两个参数:
      // 1. 匹配的内容
      // 2. 匹配内容的 index, 从0开始, 当字符串中有多个子串被匹配到时,可以通过该值判断点击了哪个
      if (onTargetClick) {
        const content = match![0];
        const _index = index;
        span.onclick = () => {
          onTargetClick({
            target: content,
            index: _index
          });
        };
      }
      
      // 用带样式的节点包裹匹配
      range.surroundContents(span);

      index++;
      pattern.lastIndex = 0; // 重置正则表达式的 lastIndex
      target = target.nextSibling?.nextSibling;
      match = pattern.exec(target?.textContent!);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
 }, [content]);

代码有些长,梳理下流程。假设我们展示的内容为:“距离过年还有1天,距离开工还有30天“

<HightlightText
    content="距离过年还有1天,距离开工还有30天"
    pattern={/\d+/g}
    style={{ color: 'blue' }}
/>

第一次循环

target: 距离过年还有1天,距离开工还有30天
match: {[0]: 1, index: 6 }
pattern.lastIndex: 7

match.index 为匹配内容开始的 index, pattern.lastIndex 为匹配内容结束位置的 index, 新建 range 包裹第一次匹配到的数字,此时 target变为: 距离过年还有<span style="color: blue">1</span>天,距离开工还有30天

处理完第1个数字后,我们需要把剩下的部分 天,距离开工还有30天 设为新的 target, 因为我们刚在中间插入了一个新的 <span>, 所以得用 target.nextSibling?.nextSibling 取到目标文字。

此外,在设置了 global 或 sticky 标志位的情况下(如 /foo/g 或 /foo/y),JavaScript RegExp 对象是有状态的,上次匹配后的位置会被记录在 lastIndex 属性中,下次调用 exec() 方法时,会从 lastIndex 开始往后匹配。 因为我们把剩下的文字部分设为了新的匹配目标,需要从头匹配,所以也需要把 pattern.lastIndex 重置为 0。

第二次循环

target: 天,距离开工还有30天
match: {[0]: 30, index: 8 }
pattern.lastIndex: 9

因为剩下的文字里还有 30 这个数字,所以进入第二次循环,天,距离开工还有30天 被处理成 天,距离开工还有<span style="color: blue">30</span>天

第三次循环

target: 天
match: null
pattern.lastIndex: 0

两次循环过后,剩余部分只剩下 , 没有剩余的数字了,程序跳出循环。至此,给数字加样式的流程结束,看下效果:

稍微变种下,也可以实现这种点击链接跳转的效果

结语

最后,再来看下国际化的场景,可以看到,这样提取文字会使翻译更简单、准确。

// zh-cn.json
{
   days-left: "距离过年还有{{daysBeforeNewYear}}天,距离开工还有{{daysBeforeBack}}天。"
}

// en-us.json
{
   days-left: "{{daysBeforeNewYear}} days left before the New Year, {{daysBeforeBack}} days left before caming back."
}

// 使用
<HightlightText 
    content={t('days-left', {
        daysBeforeNewYear: 1,
        daysBeforeBack: 30
    })} 
    pattern={/\d+/g} 
    style={{ color: 'blue' }} 
/>

在实际使用过程中,我们可以基于此组件二次封装,例如封装一个 HightlightNumber 组件,统一预设一个样式,等等。当然,这只是一种实现方式,如果你有其他的实现方式, 欢迎一起讨论分享。

源码在这,觉得有点意思的话求个 star 🌟

原文链接:https://juejin.cn/post/7332388389945802788 作者:JackLiR8

(0)
上一篇 2024年2月8日 下午4:48
下一篇 2024年2月8日 下午5:01

相关推荐

发表回复

登录后才能评论