split dance 不是某个框架的官方 API,它是这个前端示例的项目代号。这个示例要解决的问题很小但很典型:界面上有一行文字,点击按钮之后,文字里的每个字符都被拆开,各自向上、向下、旋转、缩放,形成一种类似跳舞的错峰动画。拆分字符串本身是入门级的操作,但一旦拆分对象从字符串变成屏幕上的 DOM 节点,就会牵扯到 Unicode 编码、元素宽度、事件重复触发、动画生命周期管理等一系列细节。这篇文章会带你把一个这样的效果从零写出来,并解释每一步为什么要这样做,以及实际项目里最容易被忽略的坑。
这个 demo 不需要任何前端框架,不需要 npm 包,也不需要后端接口。保存为一个 HTML 文件,用浏览器打开就能复现。适合刚接触原生 JavaScript 的开发者,也适合给团队做活动页、个人主页、课程宣传页时参考。最终你会得到一个可以自由改文字、改运动幅度、改播放节奏的单文件组件。
1. 先把“拆分”和“动画”两个概念对齐
很多人第一次看到 split dance 这个名字,第一反应是“用 split 分割字符串”。这个方向并不错,但仅仅调用 split 并不能让文字动起来。真正让文字从静态变为动态的,是拆完之后的 DOM 节点结构发生了变化。
1.1 为什么要拆分:文本节点和元素节点的区别
在 HTML 中,下面这段代码里的文字虽然可见,但浏览器只会把它当成一个文本节点:
<p>split dance 文字动画</p>文本节点本身不能拥有 transform、opacity、transition 这类视觉动画属性。你只能对整个 p 元素设置样式,动画发生时,整行文字会作为一个整体移动。
想让一行文字呈现出每个字符各自飞散、跳动、回弹的效果,必须先把一个字符串切分成很多个字符,然后为每个字符分别创建一个独立的元素节点。常见做法是让每个字符都待在一个 span 里:
<p> <span>s</span><span>p</span><span>l</span><span>i</span><span>t</span> </p>这样每个 span 都是独立盒子,可以被 JavaScript 单独选中、单独设置动画。所谓 split dance,拆开的是字符串,搭建的是动画控制单元。
1.2 split 的数据结果:不是切词,而是切字符
在英文语境里,split 常被理解为“按空格切分成单词”。但做逐字动画时,希望拿到的是单个可见字符,比如把hello变成['h', 'e', 'l', 'l', 'o'],把你好世界变成['你', '好', '世', '界']。
这一点和传统字符串切分不同。传统切分逻辑关心分隔符,例如:
const text = 'split dance 文字动画'; console.log(text.split(' ')); // ['split', 'dance', '文字动画']这种结果对统计单词有用,但对逐字动画不够细。split dance 需要把粒度降到字符,而不是词语。
1.3 拆分后的字符节点如何成为动画的控制单元
当一个字符待在自己独立的 span 里,就可以给这个 span 设置transform和opacity。控制单元的最小粒度是“一个字符为一个 span”,而不是“一行文字一个 span”。
在实际视觉表现中,这个模型可以拆成三层:
- 容器层:负责框住整行文字,控制宽度和对齐。
- 字符层:每一个 span 对应一个可见字符,负责位移和旋转。
- 时间层:通过
delay让不同字符在不同时间启动,形成错峰感。
当这三层配合起来,视觉效果就不再是文字整体平移,而像一排小角色依次起跳。后面所有代码都会围绕这三层结构展开。
2. 用一个 HTML 文件搭建最小可运行页面
先不要想得太复杂。最小闭环需要四个内容:一个字符串来源、一个显示区域、一个触发按钮、一段 JavaScript 逻辑。
2.1 环境要求与文件保存方式
这个示例只需要现代浏览器。推荐使用 Chrome 或 Edge 的最新稳定版本,因为代码里用到了 Web Animations API,从 Chrome 36、Firefox 48、Safari 13.1 开始都已有较好支持。
在电脑上新建一个文件夹,比如split-dance-demo,在文件夹里新建文件index.html。用文本编辑器打开它,把 HTML 代码写进去,然后直接用浏览器打开这个文件即可。
如果要把效果放进已有前端项目,也可以把样式和脚本分别移到对应文件中。这里用单文件示例先保证逻辑可运行,后续再谈工程化拆分。
2.2 页面结构与样式
HTML 结构里,显示文字的区域使用p标签,按钮使用button。给它们设置好 id,方便 JavaScript 获取。
<main class="stage"> <p class="text-line" id="textLine"></p> <button type="button" id="playBtn">开始跳舞</button> </main>这里字符会被添加到textLine里。按钮点击后,重新拆分文字并启动动画。
样式上要特别注意.char-item的display。如果 span 保持默认的 inline 布局,设置 transform 时不会生效。常见做法是设置为inline-block,让它既能在文本流中排布,又能作为动画元素存在:
.char-item { display: inline-block; white-space: pre; will-change: transform; }white-space: pre是为了保留空格。逐字拆分后,空格也会被包进 span,如果样式表使用了默认的空格折叠规则,可能会让空格宽度消失。
2.3 初始状态下不让文字出现乱跳
页面首次加载时,一般希望看到一行正常的静态文字。只在点击按钮后才开始拆分和动画。所以在页面初始化阶段,调用一个把文字渲染到页面的函数,但不启动动画:
function renderText(str) { textLine.textContent = ''; const chars = Array.from(str); chars.forEach((ch) => { const span = document.createElement('span'); span.textContent = ch; textLine.appendChild(span); }); } renderText('split dance');这里先把文字显示出来。真正做拆分、做动画的代码,会在后面章节逐步加入。
3. 用 Array.from 完成 split dance 的第一步:逐字拆分
逐字拆分的实现看起来只是把字符串变成数组,但如果直接调用字符的split(''),会在包含 emoji、特殊符号时得到意料之外的结果。
3.1 使用 split('') 会踩到的 Unicode 问题
JavaScript 字符串底层使用 UTF-16 编码。很多常见字符正好是一个 UTF-16 代码单元,但 emoji 和部分生僻字会用两个代码单元表示。比如下面这个例子:
const text = 'split dance 😀'; console.log(text.split(''));在浏览器里运行时,emoji 可能变成两个乱码符号。这是因为split('')按 UTF-16 代码单元切分,没有理解人类眼中的“一个字符”。
更稳妥的做法是使用Array.from:
const chars = Array.from('split dance 😀'); console.log(chars);Array.from会按可迭代对象展开字符串,能识别码点,所以对 emoji 的切分要比split('')好很多。
但这里要补充一点:Array.from也不是万能方案。如果字符串包含组合字符,比如一个字母后面跟着音标符号,它仍然可能被拆成两个码点。视觉上看起来是一个字符,实际上由多个码点组成。更精细的做法需要借助Intl.Segmenter按可感知字符切分。对于大多数标题、口号、短句场景,Array.from已经足够。
3.2 创建 span 节点并保留空格
不要把拆分后的字符直接拼成 HTML 字符串,尤其是文字内容来自用户、接口或数据库时,直接拼 innerHTML 可能引入 XSS 风险。推荐用document.createElement创建节点,再用textContent设置内容。
function splitTextIntoChars(str) { textLine.textContent = ''; textLine.setAttribute('aria-label', str); const chars = Array.from(str); const elements = []; chars.forEach((ch) => { const span = document.createElement('span'); span.setAttribute('aria-hidden', 'true'); span.className = 'char-item'; if (ch === ' ') { span.textContent = '\u00A0'; span.classList.add('char-space'); } else { span.textContent = ch; } textLine.appendChild(span); elements.push(span); }); return elements; }这里做了两件额外的事:
- 空格字符不直接使用普通空格,而是使用不换行空格
\u00A0,并增加一个char-space类,避免空格被折叠后视觉上消失。 - 给 textLine 设置
aria-label,让辅助技术可以读整段文字,而不会把每个字符当成独立词语逐个朗读。
3.3 拆分阶段完成后应看到的节点结构
页面初始化后,打开浏览器开发者工具,Elements 面板里应该可以看到类似的层级:
<p class="text-line" id="textLine" aria-label="split dance 文字动画"> <span class="char-item" aria-hidden="true">s</span> <span class="char-item" aria-hidden="true">p</span> <span class="char-item" aria-hidden="true">l</span> <span class="char-item" aria-hidden="true">i</span> <span class="char-item" aria-hidden="true">t</span> <span class="char-item char-space" aria-hidden="true"> </span> <span class="char-item" aria-hidden="true">d</span> ... </p>如果页面上直接显示出了整个数组对象,比如s,p,l,i,t,说明代码里把数组赋给了 textContent,而不是把数组里的字符逐个创建成节点。
4. 让字符在屏幕上跳舞:Web Animations API 用法
字符已经拆到独立的 span 里,接下来要处理动画。这里不使用 setInterval 手写每一帧,而是采用浏览器提供的 Web Animations API。
4.1 为什么选 Web Animations API 而不是 setInterval
实现逐字运动有很多方式:
- CSS transition 配合 class:简单,但适合单次状态变化。
- CSS keyframes 配合动画类:适合通用节奏,但很难做到每个字符轨迹都不同。
- setInterval 或 requestAnimationFrame:可控性强,但要自己计算每一帧的位置。
- Web Animations API:可以直接在 JavaScript 里生成 keyframes,并且支持动态随机轨迹。
split dance 需要“每个字符都有一点自己的运动轨迹”,而不是所有字符都执行完全一样的动画。用 Web Animations API 最方便,因为可以为每个元素单独生成 keyframes 数组:
element.animate(keyframes, options);每次调用会返回一个 Animation 对象,可以对这个对象执行cancel()、pause()、play(),适合处理连续点击时的清理问题。
4.2 为每个字符生成随机轨迹
为了让视觉效果像舞蹈,而不是整齐划一的机械运动,需要给每个字符生成不完全相同的水平位移、垂直位移和旋转角度。
先写一个随机数工具函数:
function randomBetween(min, max) { return Math.random() * (max - min) + min; }在一次动画周期里,可以让字符经历起飞、高处旋转、回弹、归位几个阶段。每个阶段的坐标都用随机数生成:
const keyframes = [ { transform: 'translate(0, 0) rotate(0deg) scale(1)', offset: 0, }, { transform: `translate(${randomBetween(-28, 28)}px, ${randomBetween(-42, -18)}px) rotate(${randomBetween(-45, 45)}deg) scale(${randomBetween(0.9, 1.3)})`, offset: 0.2, }, { transform: `translate(${randomBetween(-38, 38)}px, ${randomBetween(12, 42)}px) rotate(${randomBetween(-60, 60)}deg) scale(${randomBetween(0.9, 1.15)})`, offset: 0.55, }, { transform: `translate(${randomBetween(-16, 16)}px, ${randomBetween(-10, 10)}px) rotate(${randomBetween(-18, 18)}deg) scale(1)`, offset: 0.8, }, { transform: 'translate(0, 0) rotate(0deg) scale(1)', offset: 1, }, ];offset 表示 keyframe 在时间轴上的位置。这里 0 对应起点,1 对应终点。中间加入不同阶段的控制点,能让动画看起来有节奏变化。
4.3 延迟与循环方向的取舍
逐字动画的错峰效果来自延迟。如果所有字符都在同一毫秒开始,它们会像一组整齐列队同时跳,缺少“舞蹈”的层次感。
常见做法是用字符序号乘以固定步进:
const options = { duration: randomBetween(800, 1400), easing: 'ease-in-out', delay: index * 18, iterations: Infinity, direction: 'alternate', };这里delay让每个字符比前一个晚 18 毫秒进入动画。使用iterations: Infinity可以让它反复播放,形成持续的舞蹈效果。direction: 'alternate'会让动画正向结束后再反向播放,避免每次归位时都出现生硬的跳变。
如果只想播放一次,只需要把iterations调成1或2。实际项目中,循环次数不要盲目设置成 Infinity,因为无限动画对当前页面功耗有影响。
4.4 完整可运行代码
下面是完整的单文件实现。可以直接复制保存为index.html后打开。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>split dance 文字动画示例</title> <style> :root { --font-size: 2rem; } body { margin: 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; background: #f6f7fb; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; } .stage { max-width: 720px; width: calc(100% - 32px); padding: 24px; background: #fff; border-radius: 16px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); } .text-line { font-size: var(--font-size); line-height: 1.8; min-height: calc(var(--font-size) * 2.2); user-select: none; border: 1px dashed #d8d8e0; border-radius: 8px; padding: 24px; overflow-wrap: break-word; } .char-item { display: inline-block; white-space: pre; will-change: transform; } .char-space { min-width: 0.5em; } #playBtn { margin-top: 16px; font-size: 16px; padding: 8px 20px; cursor: pointer; border: none; border-radius: 8px; background: #36b37e; color: #ffffff; } #playBtn:hover { opacity: 0.85; } </style> </head> <body> <main class="stage"> <p class="text-line" id="textLine"></p> <button type="button" id="playBtn">开始跳舞</button> </main> <script> const textLine = document.getElementById('textLine'); const playBtn = document.getElementById('playBtn'); const originalText = 'split dance 项目示例,符号和空格都会被保留'; let runningAnimations = []; function splitTextIntoChars(str) { textLine.textContent = ''; textLine.setAttribute('aria-label', str); const chars = Array.from(str); const elements = []; chars.forEach((ch) => { const span = document.createElement('span'); span.setAttribute('aria-hidden', 'true'); span.className = 'char-item'; if (ch === ' ') { span.textContent = '\u00A0'; span.classList.add('char-space'); } else { span.textContent = ch; } textLine.appendChild(span); elements.push(span); }); return elements; } function randomBetween(min, max) { return Math.random() * (max - min) + min; } function playDance(elements) { runningAnimations.forEach((animation) => animation.cancel()); runningAnimations = []; elements.forEach((element, index) => { const keyframes = [ { transform: 'translate(0, 0) rotate(0deg) scale(1)', offset: 0, }, { transform: `translate(${randomBetween(-28, 28)}px, ${randomBetween(-42, -18)}px) rotate(${randomBetween(-45, 45)}deg) scale(${randomBetween(0.9, 1.3)})`, offset: 0.2, }, { transform: `translate(${randomBetween(-38, 38)}px, ${randomBetween(12, 42)}px) rotate(${randomBetween(-60, 60)}deg) scale(${randomBetween(0.9, 1.15)})`, offset: 0.55, }, { transform: `translate(${randomBetween(-16, 16)}px, ${randomBetween(-10, 10)}px) rotate(${randomBetween(-18, 18)}deg) scale(1)`, offset: 0.8, }, { transform: 'translate(0, 0) rotate(0deg) scale(1)', offset: 1, }, ]; const animation = element.animate(keyframes, { duration: randomBetween(800, 1400), easing: 'ease-in-out', delay: index * 18, iterations: Infinity, direction: 'alternate', }); runningAnimations.push(animation); }); } playBtn.addEventListener('click', () => { const elements = splitTextIntoChars(originalText); playDance(elements); }); splitTextIntoChars(originalText); </script> </body> </html>这段代码的核心流程很清晰:点击按钮 -> 重新拆分文字 -> 取消上一次动画 -> 为每个字符生成随机 keyframes -> 启动无限循环动画。
这里要注意一个容易写错的地方:如果runningAnimations没有先执行cancel(),用户连续点击按钮时,旧的 Animation 对象仍然可能持有被删除的 DOM 引用,既浪费内存,又可能在后台继续执行无效动画。
5. 验证结果:如何确认 split dance 是按预期工作的
代码写完不等于效果正常。需要从页面表现、DOM 结构、异常输入三个角度验证。
5.1 手工观察的最低标准
打开页面后,应该先看到不带动画的静态文字。点击“开始跳舞”按钮后,字符应该逐个错峰运动。判断标准包含以下几点:
- 字符没有直接变成数组逗号分隔的样子。
- 字符之间的空格仍然可见,文字没有挤成一团。
- 每个字符都在独立运动,而不是整行文字一起平移。
- 动画反复播放时没有停止或闪烁。
- 连续点击按钮后,页面不会出现多个动画叠加造成的卡顿。
如果点击后页面没有变化,优先打开浏览器控制台检查 JavaScript 报错。
5.2 在 DevTools 里检查字符层级
按 F12 打开开发者工具,选择 Elements 面板,查看textLine下的子节点。见到的结构应当是一排 span,而不是一段纯文本节点。
如果看到的是纯文本,说明拆分函数没有被调用,或者textContent被设置成了数组本身。
在 Console 里还可以主动验证拆分逻辑:
const text = 'split dance 😀'; console.log(text.split('')); console.log(Array.from(text));两组输出对比后,能看到split('')对 emoji 的输出是异常的两个码点,而Array.from更接近视觉上的单字符。
5.3 验证异常输入和边界情况
推荐准备几组特殊输入来测试:
- 只有空格的字符串。
- 包含英文逗号、中文逗号、句号、引号的字符串。
- 包含 emoji 的字符串。
- 包含中英文混排的长句子。
分别修改originalText后刷新页面观察。最明显的风险是空格被吞掉、emoji 被拆成两半、长文本换行时字符被截断到下一行但动画仍然遮挡其他模块。
在真实项目中,如果要处理用户输入内容,还应该加入白名单校验或限制输入长度,避免把一段上千字的文本全部拆成几万个 span 导致页面卡死。
6. 常见问题与排查链路
写这个 demo 时最容易出问题的点,集中在拆分方式、空格处理、动画生命周期和重复点击这几个环节。
6.1 页面打开后只显示一整段静态文字
现象:页面能看到文字,但点击按钮没有任何反应。
可能原因:
playBtn的 click 事件没有正确绑定。splitTextIntoChars抛出了异常,动画没有执行到。textLine的 id 或脚本位置不对,获取元素时返回 null。
检查方式:打开控制台,查看是否有 TypeError。如果脚本放在页面 head 中,且没有使用DOMContentLoaded,执行时可能找不到按钮元素。
处理建议:把 script 标签放在 body 底部;确保 id 和代码里一致;在 click 回调第一行加console.log验证事件触发。
6.2 动画只闪一下就不再继续
现象:首次点击后字符动了一下,随后停下来。
可能原因:
iterations被设置成了1,动画只播放一次就结束。direction不是alternate,动画回到终点后直接停止。- 动画被后续代码立即取消。
检查方式:查看调用element.animate时传入的 options,确认iterations是否为Infinity。同时检查是否在循环中误调用了runningAnimations.forEach((animation) => animation.cancel())。
处理建议:如果希望循环播放,显式设置iterations: Infinity。如果希望播放固定次数,设置数字,并不要把取消逻辑放在每次循环内部。
6.3 空格消失或字符间距异常
现象:按钮点击后,原本单词之间的距离没了,或者中文文字之间出现明显多余间隔。
可能原因:
- 空格被包进 inline 元素后,受默认
white-space: normal影响被折叠。 - 把空格直接设置成空字符串。
- 对 span 设置了过大的 padding 或 margin。
检查方式:在 Elements 面板中查看空格对应的 span 内容。如果是空格字符,检查样式表里是否设置了white-space: pre;如果字符直接不存在,检查拆分函数是否正确处理了空格。
处理建议:将空格字符转成\u00A0,并为空格的 span 设置最小宽度。
6.4 连续点击按钮导致动画越来越卡
现象:多次点击按钮后,页面响应变慢,甚至越来越卡。
可能原因:
- 每次点击生成大量 span,旧节点没有真正释放。
- 每次点击都创建 Web Animations API 对象,但没有 cancel 旧对象。
will-change: transform被加到了大量长期元素上。
检查方式:在 Performance 面板里录制点击前后的性能数据,观察 JS 堆内存和动画节点数量。也可以在 Console 执行document.querySelectorAll('.char-item').length检查节点是否持续增加。
处理建议:点击时先清空容器,再 cancel 动画列表,并且把runningAnimations重新赋值为空数组。不要给成百上千个元素都加will-change。
6.5 排查顺序总表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 点击无反应 | 脚本获取不到按钮或事件未绑定 | 查看 Console 报错 | script 放 body 底部,检查 id |
| 文字被拆成逗号数组 | 把数组直接赋给了textContent | 查看 Elements 面板 | 遍历数组逐个创建 span |
| emoji 显示异常 | 使用split('')按 UTF-16 代码单元切分 | Console 运行Array.from对比 | 改用Array.from或Intl.Segmenter |
| 动画只播放一次 | options.iterations 被设为 1 | 查看 animate 配置 | 设置iterations: Infinity或需要的次数 |
| 文字间距异常 | 空格被折叠或 span 样式错误 | 检查空格 span 内容和 CSS | 使用 NBSP 并设置white-space: pre |
| 多次点击卡顿 | 旧动画未取消 | 查看 runningAnimations 数量 | 每次点击前 cancel 所有旧动画 |
7. 从 split dance 延伸出去的开发建议与最佳实践
动画项目很容易写完就不管了,但放到真实项目里,还需要考虑用户感受、性能边界和无障碍问题。
7.1 拆出来的不一定是字符,也可以是词语或 DOM 元素
split dance 的思路可以扩展到更多场景。如果做关键词强调,可以把一段话按词语拆开,只有重点词语参与动画。如果做列表展示,可以把列表项当成“字符”,让它们在进入可视区域时逐项错峰出现。
切分单位取决于需求,而不一定非是字符。单位越大,动画越容易控制,阅读干扰越小。
7.2 短文案展示比长段落更适合字符动画
逐字动画确实很生动,但不宜滥用。标题、按钮文案、短标语用起来效果较好,因为用户能一眼看出完整内容。正文、长段落、合同文案、数据表格这类内容如果逐字乱跳,用户几乎无法阅读。
实际项目中建议:
- 页面首屏最多一到两个逐字动画模块。
- 正文内容使用普通文本,最多做整体淡入。
- 带图标的标签不要把图标和文字同时拆散,视觉噪音会很大。
- 用户可以手动关闭动画时,体验会更好。
7.3 用浏览器原生能力减少动画卡顿
现代浏览器对 transform 和 opacity 做了合成器优化,动画过程中尽量避免改变left、top、width、height这类属性,否则会触发布局和重绘。
当前示例中的位移全程使用translate,旋转使用rotate,缩放使用scale,都属于合成器友好的属性。这是合适的做法。
另外,Web Animations API 会在元素被移除时释放动画对象,但为了稳妥,仍建议在创建新动画前统一 cancel。
7.4 发布前检查清单
把类似 split dance 的动画模块交付前,可以按下面这张清单逐项过一遍:
- 确认文字内容能按预期拆分成单字符,中文、英文、空格、标点都正确。
- 确认 emoji 不会被拆成乱码。
- 确认页面上至少保留一份原始完整文本供辅助技术读取。
- 确认动画是用户主动触发,而不是页面加载后强制播放。
- 确认短文字效果正常,长文字不会造成性能问题。
- 确认连续点击不会叠加多个动画。
- 确认按钮在触屏设备上有足够的点击区域。
- 确认弱网或低端机环境下,动画不会阻塞文本阅读。
这个项目的核心其实不是把字符串切开,而是把一个不可分割的文本节点,重构成一组可以独立控制、又能按统一秩序协作的动画单元。理解了这一点,你会发现 split 只是一个入口,后面接的舞台可以有无数种玩法。下一步可以继续练习的关键方向有三个:一是把动态 keyframes 抽成可配置参数,让设计师不用改代码就能调动作幅度;二是加入 IntersectionObserver,让文字滚动到可视区域后再开始跳舞;三是按词语或短句分组拆分,让动画节奏更有语义,而不是单纯逐字炫技。