背景
有时候,我们需要对一段文字中的数字添加一些特殊的样式,以突出展示。例如下面这个例子:
这时候,你可能会想:“呵呵,小菜一碟,难不倒我”。于是写出了一段如下的代码:
<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 并设置开始和结束:
// 创建 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