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

程序员他爱做梦 2024-05-07 22:04:57
背景

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

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

<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 的方法,可以进行提取、删除、插入节点等等操作。此处罗列出我们将会用到的几个方法:

setStart - 设置 Range 的起点setEnd - 设置 Range 的终点surroundContents - 将 Range 中的内容移到一个新的节点

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

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

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

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

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

// 创建 rangeconst 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: nullpattern.lastIndex: 0

复制代码

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

作者:OneMoreJack链接:https://juejin.cn/post/7332388389945802788

0 阅读:1

程序员他爱做梦

简介:感谢大家的关注