如果你做过前端展示类的小工具,肯定会遇到这个需求:手头有一堆数据,想把它按某个规则分成两组,最好还能用动画把整个过程“演”出来。我最近用原生JS实现了一个分组排序动画——奇偶分组+排序可视化,效果挺惊艳,但中间踩的坑也不少。这篇文章把我从需求拆解、数据模型设计到动画步进控制的完整思路写出来,包含可直接复现的代码套路,给同样在折腾算法可视化的朋友做个参考。
1. 需求拆解:分组排序动画要解决的核心问题
1.1 一个“分组排序”项目的目标演示效果
先说清楚最终要达成的效果。页面上有一排随机高度的柱子,每个柱子上标着对应的数值。点击“奇偶分组”按钮后,原本站在同一排的柱子开始动起来:下标为偶数的柱子依次平滑移动到上方区域,下标为奇数的柱子依次移动到下方区域。两组柱子各就各位之后,组内立刻开始执行冒泡排序——每次比较的两根柱子会高亮,发生交换时也有明显的位移动画,排好序的柱子会逐渐改变颜色表示“已就位”。等上下两组都完全有序后,再执行一步合并动画:两组柱子交错飞回中央,最终组成一个完整的有序序列。
这个需求拆开来看其实包含三个核心能力:按奇偶规则分组、组内排序可视化、分组与合并的位移动画。这三个能力相互独立又彼此依赖,单独做任何一个都不难,难点在于要把它们塞进一个统一的动画流程里,让整个过程看起来一气呵成。
1.2 为什么坚持用原生JS
有人会问,这种可视化用现代框架不更省事吗?框架当然能实现,但我这里有三个必须用原生JS的理由。
第一,这个demo需要零依赖嵌入到任何页面里。我不想为了一个展示工具加载整套框架运行时,更不想让使用者在复制代码后还得配构建环境。纯HTML+CSS+JS三个文件拷过去就能跑,这才是最轻量的交付方式。
第二,动画可视化的核心诉求是数据与视图的解耦。框架的响应式更新在这种场景下反而是一种负担,因为你需要精确控制“每一步动画的节奏”,而不是让系统自动帮你批量更新DOM。我用原生JS手写渲染层,对每一步的DOM操作都有完全掌控权。
第三,算法可视化本质上是教学工具,用原生JS能把“排序算法的每一步”这个概念讲得更透。没有框架遮蔽,读者能清楚地看到数据模型怎么设计、渲染函数怎么工作、步进序列怎么生成,这些经验可以直接迁移到其他可视化项目里。
2. 数据模型与渲染解耦:先管好状态,再谈动画
2.1 千万别一上来就操作DOM
我最早一版实现犯过一个典型错误——每个柱子对应一个DOM元素,然后直接用querySelector找到它,再改style.top和style.left去移动。看起来直观,但很快就被打脸:当你需要同时维护“当前在哪一组”“在组内排第几个”“现在排到哪了”这些状态时,无状态地直接改DOM会导致逻辑彻底混乱。你根本不知道某个柱子此刻到底在哪,也无法回退重置。
正确做法是建立一个完整的数据模型,把所有柱子看作一个对象数组,DOM只是这个数组的“投影”。我定义的元素对象结构如下:
class SortItem { constructor(value, id) { this.value = value; // 柱子代表的数值 this.id = id; // 唯一标识,用于DOM复用 this.globalIndex = 0; // 当前在全局数组中的下标 this.zone = 'main'; // 所在区域:main / even / odd this.groupIndex = 0; // 所在区域内的顺序下标 this.status = 'default'; // default / comparing / swapping / sorted } }这里每个字段都有自己的职责。globalIndex和groupIndex是分离的:前者表明柱子在整个数据序列里的位置,后者表明它在所属分组里的位置。当分组发生和合并发生时,只需要更新这两个字段,视图层会自动响应变化。status字段负责驱动颜色变化,它和位置无关,专门用来表达算法执行过程中的语义状态。
2.2 渲染函数:唯一消费状态的地方
数据模型建好之后,下一步就是写渲染函数。我这里的渲染原则是:所有需要展示的状态都从数据模型读取,渲染函数不参与任何业务判断。这样做的直接好处是,你随时调用render(),界面都会和数据一致。
function render(items, container) { const barWidth = 36; const gap = 4; const mainY = 260; // 主区域柱子的底部Y const evenY = 120; // 偶数区域底部Y const oddY = 400; // 奇数区域底部Y items.forEach(item => { const el = document.getElementById('bar-' + item.id); let x, y; if (item.zone === 'main') { x = item.globalIndex * (barWidth + gap); y = mainY - item.value; // 数值越大柱子越高 } else if (item.zone === 'even') { x = item.groupIndex * (barWidth + gap); y = evenY - item.value; } else if (item.zone === 'odd') { x = item.groupIndex * (barWidth + gap); y = oddY - item.value; } el.style.transform = `translate(${x}px, ${y}px)`; el.style.height = item.value + 'px'; // 根据状态切颜色 const colorMap = { default: '#4f8ff7', comparing: '#f7d44f', swapping: '#f75f4f', sorted: '#41c47e' }; el.style.backgroundColor = colorMap[item.status]; }); }这段代码的核心是坐标计算。我为三个区域分别定义了基准Y坐标,柱子的横坐标由“区域内的顺序下标”决定,纵坐标由数值大小决定。所有柱子都用transform: translate()定位,而不是直接改left和top——这样做的原因后面会详细说。
2.3 为什么统一使用CSS transform
用transform而不是left/top,并不是炫技,而是有实实在在的性能和动画优势。
位移动画如果要顺滑,最理想的方式是浏览器合成器直接参与。CSS的transform属性不会触发layout(布局)和paint(绘制)阶段,只会触发composite(合成)阶段,性能开销最小。而你如果去修改left或top,浏览器每次都要重新计算布局,柱子一多就会出现明显的卡顿。
另一个关键点是动画过渡的触发方式。设置了transition: transform 0.4s ease-in-out之后,每次修改transform的值,浏览器都会自动补间,产生平滑移动效果。我不需要手动写任何requestAnimationFrame循环来驱动位置变化,只需要更新数据调一次render(),动画自动发生。
注意:这里的render()千万不能每次重建DOM元素。我在初始化时为每个柱子创建DOM,之后所有更新都只是修改style。频繁创建和销毁DOM会导致页面闪烁,也会干扰transition动画的启动。
3. 排序步进控制:把冒泡排序改造成可播放的生成器
3.1 为什么不能用普通函数跑排序
如果排序算法用普通的for循环跑,整个过程会在几毫秒内瞬间完成,你根本看不到中间的比较和交换过程。要实现可视化,就必须把排序过程“降速”,让每一步都变成可以被外部控制的离散事件。
一个可行的思路是每比较一次就sleep几百毫秒,但这样做有个隐藏问题:排序函数内部的状态完全被sleep逻辑污染,算法本身的逻辑和动画逻辑混成一团,代码难以维护。更严重的是,如果用户中途点击重置,你很难优雅地中断一个正在阻塞的循环。
这里我采用了一个前端可视化项目中比较成熟的方案:用生成器函数(Generator)改造排序算法。生成器可以在每次yield处暂停函数执行,把控制权交还给外部播放器,外部播放器决定什么时候继续执行下一步。
3.2 改造冒泡排序:每一步yield一个动作
我把冒泡排序改造成一个步进器,每一轮比较、交换都会向外抛出对应的事件对象。代码如下:
function* bubbleSortSteps(items) { const n = items.length; for (let i = 0; i < n - 1; i++) { for (let j = 0; j < n - 1 - i; j++) { // 抛出一个比较事件,外部可以高亮这两根柱子 yield { type: 'compare', indexes: [j, j + 1] }; if (items[j].value > items[j + 1].value) { // 交换数组位置前,抛出一个swap事件 yield { type: 'swap', indexes: [j, j + 1] }; [items[j], items[j + 1]] = [items[j + 1], items[j]]; // 更新每个item在数组中的globalIndex items[j].globalIndex = j; items[j + 1].globalIndex = j + 1; } } // 本轮结束,最后一位已经就位,抛出一个sorted事件 yield { type: 'sorted', index: n - 1 - i }; } yield { type: 'done' }; }每次yield之后,生成器函数会处于暂停状态。外部播放器拿到事件对象后,可以更新对应柱子的status状态并调用render(),等动画播完,再调用生成器的next()继续排序流程。这样排序算法和动画逻辑完全分离,算法的每一步依然清晰可读。
3.3 动画播放器:串起每一步的时间轴
有了生成器,还需要一个播放器来消费这些步骤。播放器的核心是一个异步循环:每次从生成器取一个step,根据step的类型更新数据状态,然后等待一个固定的时间间隔,再取下一步。
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function playSort(generator, items, container, speed = 200) { let result = generator.next(); while (!result.done) { const step = result.value; items.forEach(item => item.status = 'default'); if (step.type === 'compare') { const [a, b] = step.indexes; items[a].status = 'comparing'; items[b].status = 'comparing'; } else if (step.type === 'swap') { const [a, b] = step.indexes; items[a].status = 'swapping'; items[b].status = 'swapping'; } else if (step.type === 'sorted') { items[step.index].status = 'sorted'; } render(items, container); await sleep(speed); result = generator.next(); } items.forEach(item => item.status = 'sorted'); render(items, container); }这里的动画节奏比较朴素:间隔固定时间执行一步。如果需要更细腻的控制,可以用easing函数动态调整每一步的延时,比如比较时快一点、交换时慢一点,让视觉节奏更接近人工演示。这个优化点留给有需要的朋友自行扩展,基础的固定延时已经足够清楚。
4. 奇偶分组算法与合并回主数组的完整流程
4.1 奇偶分组:index % 2 是唯一判定标准
整个项目最核心的算法就是奇偶分组。这里的“奇偶”指的是元素当前下标的奇偶性,而不是元素值的奇偶性。很多人一开始会想当然地按值来分,那完全是另一种需求。我们的逻辑是:第一个元素(下标0)去偶数区,第二个元素(下标1)去奇数区,以此类推。
function groupByParity(items) { const evens = []; const odds = []; items.forEach((item, index) => { if (index % 2 === 0) { item.zone = 'even'; item.groupIndex = evens.length; evens.push(item); } else { item.zone = 'odd'; item.groupIndex = odds.length; odds.push(item); } }); return { evens, odds }; }这个函数做完之后,每个item的zone和groupIndex都被更新了,但数组里的相对顺序依然保持原样。接着调用render(),偶数下标的柱子就会移动到上方偶数区,奇数下标的柱子移动到下方奇数区。由于CSS transition的存在,位移动画自动发生,不需要额外写动画代码。
4.2 两组并发排序:交错合并步进事件
分组完成后,上下两个区域各自有一个数组,现在要让它们同时开始冒泡排序。这里有个小陷阱:如果分别调用两次playSort,它们会各自管理异步循环,视觉上两组虽然都在动,但节奏无法同步,而且代码要处理两个并发的Promise,控制难度陡增。
更稳的做法是把两组排序的步进事件交错合并成一条统一的步进序列。思路很简单:同时创建两个生成器对象,每次循环时先从生成器A取一步,再从生成器B取一步,交替执行,直到两个生成器都执行完毕。
async function playGroupSort(evens, odds, container, speed) { const genA = bubbleSortSteps(evens); const genB = bubbleSortSteps(odds); let resultA = genA.next(); let resultB = genB.next(); while (!resultA.done || !resultB.done) { evens.forEach(item => item.status = 'default'); odds.forEach(item => item.status = 'default'); if (!resultA.done) { applyStep(resultA.value, evens); resultA = genA.next(); } if (!resultB.done) { applyStep(resultB.value, odds); resultB = genB.next(); } render([...evens, ...odds], container); await sleep(speed); } }这样上下两组每轮各执行一次“比较或交换”,视觉上是完全同步的。这里每次渲染前把两组所有元素的状态重置为default,再根据当前step设置比较/交换状态,避免上一轮的高亮残留到下一轮。
4.3 分组后的归并:有序偶数区与奇数区交错合并
最后一幕是两组柱子飞回主区域,组成一个有序的整体。因为偶数下标区收集了原数组下标0、2、4...的元素,奇数区收集了1、3、5...的元素,归并回家时就应该按照“偶、奇、偶、奇”的顺序排列——这正是全局排序后的结果。
合并的算法非常直观:
function mergeZones(items) { const evens = items.filter(item => item.zone === 'even'); const odds = items.filter(item => item.zone === 'odd'); const sortedItems = []; for (let i = 0; i < Math.max(evens.length, odds.length); i++) { if (i < evens.length) { const item = evens[i]; item.zone = 'main'; item.globalIndex = sortedItems.length; sortedItems.push(item); } if (i < odds.length) { const item = odds[i]; item.zone = 'main'; item.globalIndex = sortedItems.length; sortedItems.push(item); } } return sortedItems; }这里不需要重新排序,因为两个组在上一阶段已经各自有序,交错合并的结果天然就是整体有序的。这正好呼应了标题里“分组排序”的意义:分组只是为了降低排序规模,最后还是要合回去的。
到了这一步,主数组的排序结果和初始状态下的排序结果是完全一致的,整个动画形成了一个闭环:随机排列 → 分组 → 组内排序 → 合并 → 整体有序。从用户体验看,这才是“完整”的分组排序动画。
5. 实例踩坑:动画时序、重复点击与性能优化细节
5.1 transitionend只触发一次,别依赖它做流程控制
我在第一版实现里想过一个方案:每次移动柱子后监听transitionend事件,等动画完成再执行下一步。听起来很合理,但实际操作时发现完全不是那么回事。
CSS动画的transitionend事件有几个坑:如果同时改变了transform和backgroundColor,会触发多次transitionend事件(每个过渡属性各一次);如果设置了transition属性之后立刻修改样式,浏览器可能会合并过渡,导致事件不触发;而且如果中途有人切换浏览器Tab页,动画会被暂停甚至取消,transitionend可能永远不触发。
最终我放弃了监听transitionend,改用固定延时控制——把动画时长固定为300ms,播放器每步等待320ms再走下一步。这样虽然少了“等动画完成再继续”的精确性,但胜在稳定可控,不会出现流程卡死的问题。在动画时长较短(300-500ms)的前提下,延时方案完全够用。
5.2 快速点击按钮导致动画错乱:播放锁与重置令牌
用户会手贱双击按钮,这是所有动画项目都逃不过的考验。我第一次写完demo,测试时连点两次“分组”,结果上下两区的柱子开始互相乱跑,排序状态彻底错乱。
解决办法是加一个播放锁,动画流程进行中禁止再次触发。更完整一点,还需要支持中途重置:用户可能在动画进行到一半时点击“重置”,此时应该取消当前所有进行中的动画,回到初始状态。
我用了“重置令牌”模式来解决这个问题:
let currentToken = 0; async function runFlow(token) { // 每一阶段都检查token是否失效 if (token !== currentToken) return; await groupAnimate(); if (token !== currentToken) return; await sortAnimate(); } document.getElementById('resetBtn').addEventListener('click', () => { currentToken++; // 所有旧流程的token失效 resetToInitialState(); });每次点击重置,currentToken自增,旧的异步流程在下一个恢复点发现自己持有的是旧token,就会立即终止。这个模式在异步动画流程里非常实用,比单纯用布尔锁要灵活得多。
5.3 性能优化:把重排控制在最小范围
当柱子数量到达50根以上时,我在第一次测试里明显感觉到了卡顿。排查后发现,问题出在render()函数里多次触发浏览器重排——每次设置style.height、style.transform时,浏览器都会重新计算布局。
优化措施有三招。
第一,批量更新样式。把对每个元素的所有样式修改放在同一个同步代码块里,让浏览器合并为一次重排。render()函数在同步循环里修改所有元素的style,然后让浏览器一次性执行layout。
第二,避免强制同步布局。不要在设置样式之后立刻获取某个元素的offsetWidth或offsetHeight来计算其他元素的位置,先把所有值计算好再统一写入。
第三,对柱子容器启用will-change: transform,提前告诉浏览器这些元素将发生位移,让浏览器预先分配合成层。但要注意,这个属性不能滥用,50个柱子的容器没问题,如果元素成千上万,大量合成层反而会消耗GPU内存。
.bar { will-change: transform; transition: transform 0.3s ease-in-out, background-color 0.2s; }还有一个容易被忽略的优化点:柱子初始化时应当连同高度一起生成好,而不是在render()里重新设置height。高度在项目过程中不会变化,只需要在创建DOM时设置一次,render()只负责位移和颜色,能省掉不少无用功。
5.4 扩展思考:从奇偶分组到任意分组
如果你理解了这套数据模型+步进控制+渲染解耦的组合方式,就会发现“奇偶分组”只是分组规则的一个特例。把groupByParity里的判断条件替换成任意规则,比如按数值范围、按首字母、按哈希取模,整个动画框架都能复用。
我后来在这个项目基础上做过一个扩展:支持用户自定义分组数量,可以把一组柱子分到三列、四列里分别排序。改动其实很小,只需要在数据模型里增加一个groupId字段替代zone,render()里根据groupId计算目标Y坐标,分组函数改成通用的分类器。这种扩展能力才是我选择数据驱动方案的根本原因——需求千变万化,但数据模型稳住了,上层怎么动都不虚。
这套原生JS实现方案,整体的结构就是“状态先行、生成器步进、render统一消费”。照着这个思路,你不仅能实现奇偶分组排序动画,后面想加选择排序可视化、插入排序可视化,也只是再写一个生成器函数的事情。做可视化项目最大的心得就是别急着让东西动起来,先把“动之前的状态”和“动之后的状态”定义清楚,动画本身反而是最简单的一环。