在发布了高性能 Web 动画引擎 Velocity.js 之后,我希望能利用其强大的功能进行排版操作。很快便出现了一个问题:如何在不使用包装元素膨胀 HTML 的情况下,一次动画一个字母、一个单词或一个句子?
如果我能解决这个问题,就能创建漂亮的排版动画序列(比如电影片头字幕中看到的类型),并执行实时文本分析。
在研究了底层的 DOM 方法并磨练了我的正则表达式技能后,我构建了 Blast.js:一个 jQuery/Zepto 插件,它可以分解文本以实现轻松的文本操作。点击该链接查看一些演示。
让我们直接进入代码示例。如果我们使用以下语法对某个元素进行 Blast 操作…
$("div").blast({ delimiter: "word" });
…并且如果我们的元素最初看起来像这样…
Hello World
…那么我们的元素现在将看起来像这样
Hello
World
div 的文本使用指定的单词分隔符被分解成单独的 span 元素。我们也可以使用字符、句子或元素分隔符。
有关 Blast API 的详细说明,请参阅 其文档。
本文旨在探讨使 Blast 变得通用且准确的技术方面:我们将了解功能强大但鲜为人知的 DOM 遍历技术,以及如何最大限度地利用正则表达式实现语言准确性。
如果您对丰富的运动设计或文本操作的技术方面感兴趣,那么本文适合您。
通用性
大多数 DOM 元素都由后代文本节点组成。Blast 会遍历其目标 HTML 元素的全部内容,递归下降直到找到每个后代文本节点。
例如,如果您对以下 HTML 进行 Blast 操作
Hello World
包含的div是一个元素节点。此元素节点包含两个子节点:1) 一个文本节点(“Hello ”)和 2) 一个span元素节点。span元素节点包含一个子节点:它自己的一个文本节点(“World”)。
对于 Blast 找到的每个文本节点,它都会执行与所选分隔符类型(例如,字符、单词或句子)关联的正则表达式查询以查找子匹配项。例如,使用字符分隔符对“World”进行 Blast 操作的文本节点将生成五个子匹配项:“w”、“o”、“r”、“l”和“d”。Blast 会围绕这些子匹配项中的每一个包装一个用户定义类型的新元素节点(span 是默认值)。
通过以这种方式遍历 DOM,Blast 可以安全地应用于元素的全部内容,而无需担心破坏其任何后代 HTML 或其关联的事件处理程序。(事件处理程序从不绑定到文本节点,而是绑定到包含的元素节点。)
实际上,让我们就这样做——实时!点击此处查看在 CodePen 页面上使用单词分隔符的 Blast 操作。请注意,生成的包装元素是如何使用交替颜色进行过滤的。接下来,点击周围。您会看到页面继续完美工作;所有按钮、事件处理程序和交互都保持完整。没有任何内容受到影响。
这种通用性在对用户生成的内容进行 Blast 操作时至关重要,因为用户生成的内容本质上不一定具有可预测的结构。它可能包含杂乱的 HTML。
反转
当 Blast 在每个文本节点的子匹配项周围生成包装器时,它会为每个包装器分配一个“blast”类。稍后在反转 Blast 时会引用此类。
通过将false作为 Blast 的唯一参数传入来触发 Blast 反转。反转过程的工作原理如下:遍历 DOM,但它查找的元素是已分配“blast”类的元素节点(而不是文本节点)。然后,将每个匹配的元素节点替换为其内部 HTML。
例如,对以下 HTML 进行 Blast 反转…
Hello
World
… 使用以下语法…
$("#helloWorld").blast(false);
…将导致 Blast 下降到 #helloWorld,分别匹配每个元素节点,然后用它们包含的文本节点(分别为“Hello”和“World”)替换这些元素节点。
在此过程之后,我们的 DOM 将恢复到我们在对其进行 Blast 操作之前的状态。这种能够干净地进行反转的能力使我们能够跳转到任意结构的 HTML,将其分解成 Blast,运行一系列排版动画,然后在完成后反转 Blast,以便我们的标记保持干净和结构化,如最初预期的那样。
让我们就这样做
查看 CodePen 上 Julian Shapiro(@julianshapiro)的笔 Blast.js – 命令:反转。
准确性
我们已经确定 Blast 通过仅触及相关节点(文本节点)来保留 HTML。现在让我们探索 Blast 如何实现下一个技巧
查看 CodePen 上 Julian Shapiro(@julianshapiro)的笔 Blast.js TypeKit 文章 – 准确性。
请记住,当在 Blast 目标元素节点中找到后代文本节点时,会针对其执行所选分隔符的正则表达式。让我们检查每个分隔符,从最简单的开始:字符。
(请注意,您可以通过点击 Blast 的 文档中“健壮性库”部分下的演示按钮来了解这些示例。您还可以访问 RegEx101.com以针对您自己的文本主体测试以下正则表达式查询。)
字符分隔符的正则表达式很简单,即/(S)/
,它将每个非空格字符视为子匹配项(子匹配项是被新生成的元素包装的文本节点的一部分)。很简单。
接下来,单词分隔符使用此正则表达式:/s*(S+)s*/
。这匹配任何由空格或空字符(空字符是单词出现在文本节点开头或结尾的边缘情况)包围的非空格字符。具体来说,s*表示“可选匹配空格字符”,中间的S+表示“匹配尽可能多的非空格字符”。请注意,单词分隔符匹配将包括任何与单词相连的标点符号,例如,“Hey!”将是一个完整的匹配。对于绝大多数用例,这比将每个相连的标点符号视为其自身的单词更可取。
现在事情开始变得复杂起来。匹配字符和空格分隔的单词很简单,但要稳健地匹配句子则很棘手——尤其是在多语言环境下。Blast 的句子分隔符分隔以拉丁字母标点符号结尾的短语(换行符不被视为标点符号)或位于文本主体末尾的短语。句子分隔符的正则表达式如下所示
(?=S)(([.]{2,})?[^!?]+?([.…!?]+|(?=s+$)|$)(s*[′’'”″“")»]+)*)
以下是为提高可读性而扩展的视图(带空格)
(?=S) ( ([.]{2,})? [^!?]+? ([.…!?]+|(?=s+$)|$) (s*[′’'”″“")»]+)* )
让我们将其分解成其组成部分
(?=S)
句子必须包含一个非空格字符。([.]{2,})?
句子可能以一组句点开头,例如“… that was a bad idea, Tom!”[^!?]+?
获取不是明确终止标点符号的任何内容,但在达到以下条件时停止…([.…!?]+|(?=s+$)|$)
…匹配句末标点符号或文本末尾的最后一次出现(可选地带尾随空格)。(s*[′’'”″“")»]+)*
在匹配最终标点符号后,还包括任何和所有成对的(可选空格分隔)引号和括号。
这需要仔细消化,但如果您在重新查看本节开头的句子匹配行为(为方便起见,已重新嵌入在下面)时参考这些正则表达式组件,您将开始了解较大的部分是如何组合在一起的。
查看 CodePen 上 Julian Shapiro(@julianshapiro)的笔 Blast.js TypeKit 文章 – 准确性。
(点击 HTML 选项卡以修改 HTML 并查看 Blast 在不同文本主体上的行为。)
我们还没有解释为什么嵌入式演示中的错误句点没有错误地触发句末匹配:诀窍是在主要正则表达式执行之前对每个文本节点执行预处理过程——其中可能的误报通过将其临时编码为不匹配的字符串而变得无效。然后,在执行句子正则表达式后,将可能的误报解码回其原始字符。
误报编码过程包括将标点符号替换为其 ASCII 等效项,并将其放在双花括号内。例如,可能的误报句点(例如,在标题“Mr. Johnson”中找到的句点)将变成“Mr{{46}} Johnson”。然后,在执行句子分隔符的正则表达式时,它会跳过{{46}}块,因为花括号不被视为拉丁字母标点符号。
以下是此过程背后的逻辑
text
/* Escape the following Latin abbreviations and English
titles: e.g., i.e., Mr., Mrs., Ms., Dr., Sr., and Jr. */
.replace(RegEx.abbreviations, function(match) {
return match.replace(/./g, "{{46}}");
})
/* Escape inner-word (non-space-delimited) periods.
For example, the period inside "Blast.js". */
.replace(RegEx.innerWordPeriod, function(match) {
return match.replace(/./g, "{{46}}");
});
所以现在您对 Blast 的行为有了概述,但您并没有学到太多。不用担心,接下来的两节将深入探讨技术细节。
深入探讨:正则表达式
本节可选。这是对 Blast 正则表达式查询设计方式的技术性深入探讨。
这是您可以在 Blast 的源代码顶部找到的正则表达式代码块
var characterRanges = {
latinLetters: "\u0041-\u005A\u0061-\u007A\u00C0-\u017F\u0100-\u01FF\u0180-\u027F",
},
Reg = {
abbreviations: new RegExp("[^" + characterRanges.latinLetters + "](e\.g\.)|(i\.e\.)|(mr\.)|(mrs\.)|(ms\.)|(dr\.)|(prof\.)|(esq\.)|(sr\.)|(jr\.)[^" + characterRanges.latinLetters + "]", "ig"),
innerWordPeriod: new RegExp("[" + characterRanges.latinLetters + "].[" + characterRanges.latinLetters + "]", "ig"),
};
第一步是定义包含所有拉丁字母语言使用的字母的 UTF8 字符范围。如果您觉得该字符串完全是胡言乱语,请不要担心:字符表示系统为其每个可显示字符分配一个 ID。正则表达式只是允许我们定义一个 ID 范围(在第一个字符的 ID 和最后一个字符的 ID 之间放置一个“ - ”)。我们通过将一堆 ID 范围组合在一起利用了这一点,以便跳过包含日常语言中不使用的字符的范围(例如,表情符号、箭头符号等)。
一旦我们知道所有可接受的字符是什么,我们就可以使用它们来创建正则表达式查询
缩写正则表达式查找不区分大小写的白名单缩写(例如 Mr.、Dr. Jr.),这些缩写前面不紧跟着已接受的字符之一。换句话说,它希望找到这些缩写前面是空字符、空格或非字母字符的位置。例如,我们不想在“grams.”中匹配“ms.”,但我们想在“→Ms. Piggy”中匹配“ms.”。同样,正则表达式查询确保缩写也不紧跟着字母。例如,我们不想在公司名称缩写(如“E.G.G.S.”)中匹配“e.g.”。但是,我们确实想在“… farm animals, e.g. cows, bigs, etc.”中匹配“e.g.”。
单词内部句点正则表达式查找任何夹在两侧的白名单拉丁字母之间。因此,“Blast.js”中的句点成功匹配,但“This is is a short sentence.”末尾的句点则不匹配。
深入探讨:DOM 遍历
本节可选。这是对文本节点遍历工作原理的深入探讨。
让我们看一下递归 DOM 遍历代码
if (node.nodeType === 1 && node.hasChildNodes()
&& !Reg.skippedElements.test(node.tagName)
&& !Reg.hasPluginClass.test(node.className)) {
/* Note: We don't cache childNodes' length since it's a live nodeList (which changes dynamically with the use of splitText() above). */
for (var i = 0; i < node.childNodes.length; i++) {
Element.nodeBeginning = true;
i += traverseDOM(node.childNodes[i], opts);
}
}
在上面,我们检查节点是否…
- 具有 1 的 nodeType(与元素节点关联的 ID)。
- 有我们可以爬行的子节点。
- 不是黑名单元素节点标签(script、textarea 和 select)之一,这些标签包含文本节点,但不是用户可能希望进行 Blast 操作的那种典型文本节点。
- 尚未分配“blast”类,Blast 使用此类来跟踪当前正在使用的元素。
如果上述条件不成立,并且 nodeType 返回的值为 3,那么我们就知道我们已经遇到了一个实际的文本节点。在这种情况下,我们将继续进行子匹配和元素包装逻辑。请参阅内联注释以获取完整的演练
/* Find what position in the text node that our
delimiter's RegEx returns a match. */
matchPosition = textNode.data.search(delimiterRegex);
/* If there's a RegEx match in this text node, proceed
with element wrapping. */
if (matchPosition !== -1) {
/* Return the match. */
var match = node.data.match(delimiterRegex),
/* Get the node's full text. */
matchText = match[0],
/* Get only the match's text. */
subMatchText = match[1] || false;
/* RegEx queries that can return empty strings (e.g ".*")
produce an empty matchText which throws the entire
traversal process into an infinite loop due to the
position index not incrementing. Thus, we bump up
the position index manually, resulting in a zero-width
split at this location followed by the continuation
of the traversal process. */
if (matchText === "") {
matchPosition++;
/* If a RegEx submatch is produced that is not
identical to the full string match, use the submatch's
index position and text. This technique allows us to
avoid writing multi-part RegEx queries for submatch finding. */
} else if (subMatchText && subMatchText !== matchText) {
matchPosition += matchText.indexOf(subMatchText);
matchText = subMatchText;
}
/* Split this text node into two separate nodes at the
position of the match, returning the node that begins
after the match position. */
var middleBit = node.splitText(matchPosition);
/* Split the newly-produced text node at the end of the
match's text so that middleBit is a text node that
consists solely of the matched text. The other
newly-created text node, which begins at the end
of the match's text, is what will be traversed in
the subsequent loop (in order to find additional
matches in the containing text node). */
middleBit.splitText(matchText.length);
/* Over-increment the loop counter so that we skip
the extra node (middleBit) that we've just created
(and already processed). */
skipNodeBit = 1;
/* Create the wrapped node. Note: wrapNode code
is not shown, but it simply consists of creating
a new element and assigning it an innerText value. */
var wrappedNode = wrapNode(middleBit);
/* Then replace the middleBit text node with its
wrapped version. */
middleBit.parentNode.replaceChild(wrappedNode, middleBit);
}
当对包含大量文本的大型文本主体使用会产生许多小匹配项(即字符分隔符)的分隔符时,此过程的性能并不出色,但它非常健壮且可靠。
总结
继续前进,尽情地使用 Blast 吧 ;-) 如果您创建了一些很酷的东西,请将其发布在 CodePen 上并在下面的评论中分享。
关注 我在 Twitter 上的动态,了解有关 UI 操作的推文。
关于 Julian Shapiro
Julian Shapiro 是一位技术创始人。他的第一个初创公司 NameLayer.com 被 Techstars 收购。他目前专注于 UI 动画。他正在努力让我们向《少数派报告》更近一步。在 Julian.com 上了解更多信息。
关于 Robert Nyman [荣誉编辑]
技术布道师及 Mozilla Hacks 编辑。发表演讲和博客文章,内容涉及 HTML5、JavaScript 和开放网络。Robert 是 HTML5 和开放网络的坚定支持者,自 1999 年起就在从事网页前端开发工作——分别在瑞典和纽约。他还会定期在 http://robertnyman.com 发布博客文章,并且热爱旅行和结识新朋友。
6 条评论