使用 Intl.Segmenter
进行智能文本分割:告别简单的 split
update: GSAP已经面向所有人免费,包括 SplitText 插件,更新到 3.13.0
及以上就可以使用了,推荐使用成熟的方案它包含了许多边缘情况的处理
在 Web 开发中,我们经常需要将文本分割成更小的单元,例如为了实现逐字动画、文本分析或处理用户输入。传统的 JavaScript String.prototype.split('')
方法虽然简单,但它在处理复杂语言(如包含表情符号、组合字符的语言)时会遇到问题,因为它只是简单地按 Unicode 码位分割,而不是按用户感知的字符(字形)分割。
此外,市面上可能存在一些更高级的文本分割库或服务(有时称为 splitText
类功能),但它们可能存在以下缺点:
- 收费: 一些功能强大的库或 API 可能是商业产品,需要付费使用。
- 功能简单: 免费或简单的实现可能仍然停留在按固定字符(如空格)分割,或者像
split('')
一样进行简单的按码位切割,无法正确处理多语言环境下的词语或句子边界。
Intl.Segmenter
:原生且强大的解决方案
幸运的是,现代 JavaScript 提供了一个内置的解决方案:Intl.Segmenter
对象。这是一个强大的 API,属于 Intl
(Internationalization) 命名空间,专门用于进行语言敏感的文本分割。
Intl.Segmenter
的核心优势在于:
- 语言感知: 它理解不同语言的规则,能够根据特定语言的语法和习惯正确地分割文本。
- 多种分割粒度: 你可以指定分割的粒度,将字符串分割成有意义的片段:
grapheme
:按用户感知的字符(字形)分割,能正确处理表情符号和组合字符。word
:按单词分割,并能根据不同语言的规则识别单词边界(例如,泰语、日语等没有明显空格分隔的语言)。sentence
:按句子分割,同样能根据语言特性识别句子结束符。
- 原生支持: 无需引入外部库或支付费用,它是现代浏览器和 Node.js 环境的标准组成部分。
示例:使用 Intl.Segmenter
分割 DOM 文本
下面的 TypeScript 代码演示了如何使用 Intl.Segmenter
来递归地分割一个 HTML 元素内所有文本节点的内容,并将每个片段包裹在 <span>
标签中。这对于实现文本动画或样式化非常有用。
const splitText = (
container: Element,
granularity: Intl.SegmenterOptions["granularity"],
locale = ["zh-Hans-CN", "en", "fr"],
spanClass = `split-${granularity}`,
) => {
// 1. Initialize the Segmenter
// It's more efficient to create it once if processing many nodes.
const segmenter = new Intl.Segmenter(locale, { granularity });
// 2. Recursive function to process nodes
function processNode(node: Node, fragment: DocumentFragment) {
// Iterate over a *static copy* of the childNodes.
// Modifying the DOM while iterating over the live NodeList can cause issues.
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
if (child.nodeType === Node.TEXT_NODE) {
// 3. Process Text Nodes
const text = child.nodeValue;
if (text && text.trim() !== "") {
// Avoid processing empty/whitespace-only nodes if desired
const segments = segmenter.segment(text);
for (const { segment } of segments) {
if (segment.trim() === "") {
fragment.appendChild(document.createTextNode(segment));
} else {
const span = document.createElement("span");
span.textContent = segment;
span.classList.add(spanClass);
fragment.appendChild(span);
}
}
} else if (text !== null) {
// Keep whitespace nodes if needed for layout
fragment.appendChild(document.createTextNode(text));
// Or skip if you want to collapse whitespace:
// if (text && text.trim() !== '') { fragment.appendChild(document.createTextNode(text)); }
}
// The original text node is not added to the fragment,
// effectively replacing it with the new spans (or nothing).
} else if (child.nodeType === Node.ELEMENT_NODE) {
const childElement = child as Element;
// 4. Process Element Nodes (Recursively)
// Create a new element of the same type to append to the fragment
const newElement = document.createElement(childElement.tagName);
// Copy attributes
for (let i = 0; i < childElement.attributes.length; i++) {
const attr = childElement.attributes[i];
newElement.setAttribute(attr.name, attr.value);
}
// Create a sub-fragment for this element's children
const subFragment = document.createDocumentFragment();
// Recurse into the original child element's children
processNode(child, subFragment);
// Append the processed children to the new element
newElement.appendChild(subFragment);
// Append the fully processed new element to the main fragment
fragment.appendChild(newElement);
} else {
// 5. Preserve other node types (like comments)
// Clone them and append to the fragment
fragment.appendChild(child.cloneNode(true));
}
}
}
// 6. Main execution
// Create the main DocumentFragment to build the new content
const mainFragment = document.createDocumentFragment();
// Start the recursive processing from the container element
processNode(container, mainFragment);
// 7. Replace the container's original content with the new structure
// Using replaceChildren is generally preferred over innerHTML = '' + appendChild
container.replaceChildren(mainFragment);
};
// --- 使用示例 ---
/*
假设 HTML 结构如下:
<div id="myText">
这是 <span>一些</span> 示例文本,包含 English words 和 😊 表情。
<p>这是第二段。</p>
</div>
// 按字分割(正确处理表情和多语言)
const containerElement = document.querySelector('#myText');
if (containerElement) {
splitText(containerElement, 'grapheme');
}
// 按词分割
if (containerElement) {
splitText(containerElement, 'word', ['zh-Hans-CN', 'en']);
}
// 按句分割
if (containerElement) {
splitText(containerElement, 'sentence', ['zh-Hans-CN', 'en']);
}
*/
当需要进行超越简单字符分割的文本处理时,Intl.Segmenter
提供了一个强大、灵活且符合标准的本地化解决方案,是替代传统 split
方法或潜在付费库的理想选择。
// 例如使用 motion 让它们动起来
animate(
containerElement.querySelectorAll(".split-word"),
{ opacity: [0, 1], y: [10, 0] },
{
type: "spring",
duration: 2,
bounce: 0,
delay: stagger(0.05),
}
)