
split dance 不是某个框架的官方 API它是这个前端示例的项目代号。这个示例要解决的问题很小但很典型界面上有一行文字点击按钮之后文字里的每个字符都被拆开各自向上、向下、旋转、缩放形成一种类似跳舞的错峰动画。拆分字符串本身是入门级的操作但一旦拆分对象从字符串变成屏幕上的 DOM 节点就会牵扯到 Unicode 编码、元素宽度、事件重复触发、动画生命周期管理等一系列细节。这篇文章会带你把一个这样的效果从零写出来并解释每一步为什么要这样做以及实际项目里最容易被忽略的坑。这个 demo 不需要任何前端框架不需要 npm 包也不需要后端接口。保存为一个 HTML 文件用浏览器打开就能复现。适合刚接触原生 JavaScript 的开发者也适合给团队做活动页、个人主页、课程宣传页时参考。最终你会得到一个可以自由改文字、改运动幅度、改播放节奏的单文件组件。1. 先把“拆分”和“动画”两个概念对齐很多人第一次看到 split dance 这个名字第一反应是“用 split 分割字符串”。这个方向并不错但仅仅调用 split 并不能让文字动起来。真正让文字从静态变为动态的是拆完之后的 DOM 节点结构发生了变化。1.1 为什么要拆分文本节点和元素节点的区别在 HTML 中下面这段代码里的文字虽然可见但浏览器只会把它当成一个文本节点psplit dance 文字动画/p文本节点本身不能拥有 transform、opacity、transition 这类视觉动画属性。你只能对整个 p 元素设置样式动画发生时整行文字会作为一个整体移动。想让一行文字呈现出每个字符各自飞散、跳动、回弹的效果必须先把一个字符串切分成很多个字符然后为每个字符分别创建一个独立的元素节点。常见做法是让每个字符都待在一个 span 里p spans/spanspanp/spanspanl/spanspani/spanspant/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 classstage p classtext-line idtextLine/p button typebutton idplayBtn开始跳舞/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.fromconst 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 classtext-line idtextLine aria-labelsplit dance 文字动画 span classchar-item aria-hiddentrues/span span classchar-item aria-hiddentruep/span span classchar-item aria-hiddentruel/span span classchar-item aria-hiddentruei/span span classchar-item aria-hiddentruet/span span classchar-item char-space aria-hiddentruenbsp;/span span classchar-item aria-hiddentrued/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 langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titlesplit 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 classstage p classtext-line idtextLine/p button typebutton idplayBtn开始跳舞/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 面板遍历数组逐个创建 spanemoji 显示异常使用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让文字滚动到可视区域后再开始跳舞三是按词语或短句分组拆分让动画节奏更有语义而不是单纯逐字炫技。