原生JS实现奇偶分组排序动画:数据驱动DOM与异步协调实战

发布时间:2026/10/14 14:07:35
原生JS实现奇偶分组排序动画:数据驱动DOM与异步协调实战 奇偶分组排序动画这个点子是我在给一个算法可视化小工具做迭代时冒出来的。之前的排序演示基本都是一上来就交换冒泡、插入、快排柱子两两换位看久了确实有点腻。这次我加了一步前置分流先把数组里的偶数和奇数拆成两个阵营用不同颜色区分再让两组各自跑一遍冒泡排序的可视化。效果比单纯排序生动不少而且分组的过程本身就是一次清晰的视觉叙事。整个项目用原生JS完成不碰任何框架和动画库一个HTML文件双击就能跑。如果你正在找练手项目想搞清楚“数据变化如何驱动DOM动画”这件事或者想看看async/await在连续动画里怎么协调这篇内容应该能给你不少参考。1. 项目定位与核心设计思路1.1 奇偶分组排序动画到底在演示什么这个项目表面上是动画演示底层其实是一整套“数据状态 → DOM表现”的映射机制。一组随机生成的数字柱子点击“分组”后偶数柱子被标记成橙色并且整体向左侧靠拢奇数柱子标记成蓝色向右侧靠拢两组互不干扰地完成归位后点击“排序”两侧同时开始冒泡排序每一次比较都有高亮反馈每一次交换都有平滑的位移过渡。这里的“奇偶分组”有两个层次的意思。第一层是数据语义上的分组按数字本身是偶数还是奇数来划分这是项目明确实现的展示逻辑第二层是算法层面的“奇偶排序”Odd-Even Sort——它是一种冒泡排序的并行变体交替比较奇偶索引对和偶奇索引对。项目当前用的是第一种语义但设计时保留了向第二种语义扩展的余地后面章节我会单独说怎么延伸。整个演示过程解决了一个核心问题让抽象的分组逻辑和排序过程变得肉眼可见。对于刚接触排序算法的人来说直接看伪代码容易走神但看柱子移动几轮就天然理解了“比较”“交换”“冒泡”这些概念。它适合前端初学者练习DOM操作也适合教学中当作排序算法的辅助演示工具。1.2 交互流程与状态机设计交互流程我设计成了四步闭环洗牌、分组、排序、重置。每步严格遵循状态约束避免用户乱点导致动画错乱。状态机是这个项目的地基状态搞不清楚动画做得再好看也是一团乱麻。状态说明允许的操作idle初始态柱子已生成未分组洗牌、分组grouped分组完成偶左奇右洗牌、排序sorting排序进行中禁止任何操作只能等完成sorted排序完成洗牌、重置我把状态变量收敛在一个对象里所有操作都先检查状态再执行这样代码逻辑清晰也方便后续扩展。按钮的禁用状态也会根据当前状态实时更新比如排序中所有按钮置灰用户就不会在动画进行中误触。1.3 为什么坚持用原生JS CSS过渡市面上做动画有太多方案了动画库、虚拟DOM、canvas渲染随便挑一个都能实现。但这个项目我坚持原生JS理由很实在第一依赖越少运行环境越可控浏览器打开直接用不用考虑打包和部署第二原生DOM操作能让学习者更直观地理解浏览器的工作机制比如回流、重绘、transform合成第三这种矩形柱子的位移场景CSS transition天然就是最合适的选择短互动画交给过渡属性处理比用requestAnimationFrame手写插值要省心得多。其实用canvas也能做但canvas完全丧失DOM能力没法做按钮禁用、无法用浏览器开发者工具调试DOM交互回馈也得全部自己实现。原生JS加上CSS过渡的组合在项目规模小、动画线段短的时候是性价比最高的方案。2. 零依赖工程骨架与数据模型2.1 一个HTML文件搭建起来的项目结构整个项目就是一个单文件页面HTML结构、CSS样式、JavaScript逻辑全部写在一起。这一步看起来简单但结构分区要清晰否则后面代码一多就乱套。页面核心就三块可视化容器、控制按钮、状态文本。div classapp h3奇偶分组 · 排序可视化/h3 div idvisualizer/div div classcontrols button idbtnShuffle洗牌/button button idbtnGroup分组/button button idbtnSort排序/button span idstatus点击洗牌生成数据/span /div /div可视化容器是一个相对定位的区块每个柱子都在这个容器内用绝对定位摆放。绝对定位是这次选择的关键——只有让柱子完全脱离文档流才可以用left加transform的组合来精确控制落点和移动不会受到flex或常规流布局的干扰。CSS部分有几个核心参数柱宽44px、间距4px这样12根柱子总宽度在568px左右页面给到720px余量充足。柱子的高度直接映射数值大小等差数列的视觉呈现最直观。为了让数字标注看得清我给柱子加了等宽字体和白色文字。#visualizer { position: relative; height: 320px; border-bottom: 1px solid #eee; margin-bottom: 20px; } .bar { position: absolute; bottom: 0; width: 44px; background: #4a90d9; border-radius: 4px 4px 0 0; transition: left 0.4s ease, transform 0.4s ease, background 0.4s ease; } .bar.even { background: #e67e22; } .bar.odd { background: #3498db; } .bar.active { background: #e74c3c; }这里有个细节需要留意transition里只有left、transform、background三个属性height和透明度就没有参与过渡。为什么因为柱子的高度是数据值决定的不因位置交换而改变难点全在位移上没必要给高度也加上过渡干扰动画节奏。2.2 数组、柱子和落点坐标的三方映射项目里最核心的模型不是某个类而是数组、柱子DOM元素、落点坐标三者的对应关系。数组保存数据的顺序柱子是数据的可视化呈现落点坐标则决定了每一根柱子应该出现在容器的哪个位置。初始化时我遍历数组为每个数字创建一个div柱子把数字存进>function renderBars() { container.innerHTML ; const frag document.createDocumentFragment(); state.array.forEach((value, index) { const bar document.createElement(div); bar.className bar; bar.dataset.id String(index); bar.dataset.value String(value); bar.textContent value; bar.style.height value * 3 px; bar.style.left index * (BAR_WIDTH BAR_GAP) px; frag.appendChild(bar); }); container.appendChild(frag); }>btnShuffle.addEventListener(click, () { epoch; state.array randomArray(12); state.grouped false; state.sorting false; updateStatus(已生成新数据可点击分组); renderBars(); }); btnGroup.addEventListener(click, () { if (state.grouped || state.sorting) return; performGroup(); }); btnSort.addEventListener(click, () { if (!state.grouped || state.sorting) return; sortAll(); });状态守卫看着简单但极其重要。我在测试时发现如果去掉这些检查用户快速连点“分组”按钮动画会叠加排序中点击洗牌旧动画会和新数据打架。后来我干脆不只在回调里检查还结合按钮的disabled属性双保险。洗牌时epoch这个操作是为了让正在进行的排序任务主动失效这个“时代标记”方案我在第五章会详细拆解算是这个项目里最有复用价值的技巧之一。3. 分组动画与排序动画的核心实现3.1 奇偶分组从数据拆分到DOM重排分组动画分两步走第一步先让所有柱子通过transform平移到自己所属阵营的视觉位置第二步等动画结束后重新调整DOM顺序。先看数据拆分。遍历数组偶数放入evens数组奇数放入odds数组然后拼接成新顺序function performGroup() { const evens [], odds []; state.array.forEach((value, idx) { (value % 2 0 ? evens : odds).push({ value, idx }); }); const newOrder evens.concat(odds); newOrder.forEach((item, newIdx) { const bar bars.find(b Number(b.dataset.id) item.idx); const currentLeft parseFloat(bar.style.left); const targetLeft newIdx * (BAR_WIDTH BAR_GAP); bar.style.transform translateX(${targetLeft - currentLeft}px); bar.classList.add(item.value % 2 0 ? even : odd); }); setTimeout(() { newOrder.forEach(item { const bar bars.find(b Number(b.dataset.id) item.idx); container.appendChild(bar); }); // 同步左坐标并清空transform newOrder.forEach((item, newIdx) { const bar bars.find(b Number(b.dataset.id) item.idx); bar.style.transition none; bar.style.left newIdx * (BAR_WIDTH BAR_GAP) px; bar.style.transform ; void container.offsetWidth; bar.style.transition ; }); state.array newOrder.map(o o.value); state.grouped true; }, 400); }这里必须解释一个容易踩坑的点为什么动画结束要“改left 清transform 重排DOM”三步齐上因为CSS transition只能保证动画过程中的平滑动画结束后如果柱子的left还停留在原始位置transform还保留着位移量这就等于把最终位置交给了transform托管。后面一旦再次操作transform排序交换两个位移会叠加柱子位置就会彻底乱掉。所以每次位移动画结束都必须把最终结果固化到基准属性left里清空transform。重排DOM的作用是让container.children的顺序和state.array的顺序保持一致。如果不重排排序时拿到的bars数组还是原始顺序视觉上的分组阵营和逻辑上的分组就断裂了。3.2 排序可视化冒泡排序的状态推进排序可视化的本质是把冒泡排序的双重循环包装成异步状态机。每轮外层循环确定一个已排好的最大值内层循环负责相邻比较。我给每一组柱子单独维护一个值数组排序过程和动画过程交替推进async function bubbleSort(bars, values, myEpoch) { for (let i 0; i values.length - 1; i) { for (let j 0; j values.length - 1 - i; j) { if (epoch ! myEpoch) return; bars[j].classList.add(active); bars[j 1].classList.add(active); await sleep(300); bars[j].classList.remove(active); bars[j 1].classList.remove(active); if (values[j] values[j 1]) { await animateSwap(bars, j, j 1); [values[j], values[j 1]] [values[j 1], values[j]]; } } } }比较高亮和交换是两回事。比较时我只做颜色高亮让观看者知道这两个柱子正在被“审视”只有满足交换条件时才触发位移动画。这样区分开的好处是即使排到后半段很多比较都不产生交换动画节奏也不会变得呆板——红色高亮本身就在传递信息。sleep函数是控制动画节奏的关键我用一个Promise包住setTimeout来实现function sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)); }这个工具函数贯穿了整个项目的异步流程所有延时等待都靠它驱动。3.3 列交换动画的位移计算与无闪烁同步交换动画是整个项目最微妙的部分。两根柱子交换位置表面看是一个交叉换位的视觉动作但实现上要处理两件事一是让它们平滑越过彼此二是交换完成后视觉位置和DOM结构保持一致不能有跳变。我用getBoundingClientRect()获取两根柱子当前在视口中的精确坐标算出水平方向的位移差然后给两根柱子分别设置正负translateXasync function animateSwap(bars, i, j) { const a bars[i], b bars[j]; if (i j || !a || !b) return; const rectA a.getBoundingClientRect(); const rectB b.getBoundingClientRect(); const deltaX rectB.left - rectA.left; a.classList.add(active); b.classList.add(active); a.style.transform translateX(${deltaX}px); b.style.transform translateX(${-deltaX}px); await sleep(400); // DOM顺序交换 if (i j) container.insertBefore(b, a); else container.insertBefore(a, b); const tempLeft parseFloat(a.style.left); a.style.transition none; b.style.transition none; a.style.left parseFloat(b.style.left) px; b.style.left tempLeft px; a.style.transform ; b.style.transform ; void container.offsetWidth; a.style.transition ; b.style.transition ; a.classList.remove(active); b.classList.remove(active); [bars[i], bars[j]] [bars[j], bars[i]]; }这段代码的核心在于“无闪烁同步”那一段。如果只做insertBefore交换DOM柱子虽然顺序变了但left还是原来的值、transform还带着位移视觉位置依然是交换前的状态数据更新了画面却没动非常割裂。我的处理顺序是先交换left基准坐标再把transform清零同时临时关闭transition用void container.offsetWidth强制一次回流刷新样式最后恢复transition。这样浏览器在同一个动画帧内完成同步用户完全看不出重置的痕迹。getBoundingClientRect在这个场景里很有用但要注意它会强制回流。如果每帧都调用会影响性能我的做法是一次交换只在动画开始前调用一次后边的同步全部基于已经缓存的数值。3.4 两组并行的异步协调策略分组完成后偶数组在左侧、奇数组在右侧它们是两个互相独立的柱子集合。我用Promise.all让两组排序同时进行async function sortAll() { const myEpoch epoch; state.sorting true; updateStatus(排序中请稍候...); const bars Array.from(container.children); const evenCount bars.filter(b b.classList.contains(even)).length; const evenBars bars.slice(0, evenCount); const oddBars bars.slice(evenCount); const evenValues evenBars.map(b Number(b.dataset.value)); const oddValues oddBars.map(b Number(b.dataset.value)); await Promise.all([ bubbleSort(evenBars, evenValues, myEpoch), bubbleSort(oddBars, oddValues, myEpoch), ]); if (epoch myEpoch) { state.sorting false; updateStatus(排序完成可洗牌重新开始); } }Promise.all在这里是安全的因为两组操作的是完全不同的DOM节点互不干扰。evenBars和oddBars是从container.children里按顺序切的偶数在前奇数在后这个顺序由分组动画后的DOM重排保证。异步协调最大的敌人是竞态条件。举例来说用户点击排序后立刻点洗牌旧排序里的await已经让出了执行权新数据渲染完旧排序的下一次循环还惦记着操作已经脱离文档的旧节点。这时insertBefore会直接抛NotFoundError或者更隐蔽地操作了不属于自己阵营的节点。我的对策就是epoch时代标记每次洗牌时epoch加一所有异步流程在关键节点检查自己持有的myEpoch和全局epoch是否一致不一致就立即停止执行完全杜绝了僵尸任务对页面的干扰。4. 实操中的性能优化与体验打磨4.1 避免强制同步布局的连锁反应用getBoundingClientRect、offsetWidth这些API读取布局属性时如果之前有样式写入浏览器会被迫放弃“攒着一起处理”的优化策略立刻同步计算布局这就是强制同步布局Forced Synchronous Layout。它本身不是错误但频繁触发会导致页面卡顿尤其是在动画循环中。在这个项目里我严格控制布局读取的次数每根柱子只会在初始渲染时写一次left和height之后交换动画开始前统一读取一次坐标读取前避免穿插写入操作。中间所有的位移都靠transform完成而transform的变化不会触发layout只在合成阶段处理效率远高于修改left或者top。一个实用的原则是读取布局的API调用要集中到一起写完样式后不要立刻读读完整批数据后再做下一次写入。这样可以最大化利用浏览器的渲染优化。柱子数量少的时候差别不明显但如果你把数组扩容到30、50根这几个习惯就是天壤之别。4.2 动画节奏和视觉反馈的平衡动画节奏的调整我踩过不少坑。最初把每次sleep都调成500ms结果一组排序跑完接近半分钟观看耐心直接耗尽把交换动画缩到200ms又变得含糊不清根本看不清谁跟谁换了位。最终我定下来一套参数比较高亮300ms交换动画400ms交换后停留200ms。三段时间加起来每次交换约900ms12个数字的数组跑完整轮排序大约8到10秒节奏刚刚好。这个时长能保证观众看清楚每一次比较结果也不至于拖沓。另一个视觉反馈的细节是颜色语义。默认蓝色、偶数橙色、奇数蓝色调虽然区分明确但排序中高亮必须用反差最大的红色这样才能在两种阵营色中跳脱出来。我还在状态栏实时更新当前进度文本比如“排序中”“分组完成”让用户知道动画没卡死状态机还在推进。4.3 兼容性和无障碍的细节处理原生JS最让人放心的就是兼容性。CSS transform和transition这两个属性现在所有现代浏览器都原生支持不需要前缀。唯一要注意的是部分老版本浏览器的flex或者绝对定位在某些容器内的解析差异但本项目用的绝对定位方式非常基础兼容性完全没有压力。无障碍方面我做了两个小改进第一按钮文字不是简单的“分组”而是保留了完整的动词描述屏幕阅读器可以正确朗读第二柱子的文本内容直接显示了数字本身而不是完全依赖颜色传递信息这样色弱用户也能通过数字来判断奇偶分组。这个细节在很多数据可视化项目里容易被忽略但实际使用中体验差异很大。5. 常见问题与排查技巧实录5.1 动画结束柱子“跳一下”的根因和修复这是最容易遇到的问题现象是柱子明明已经平移到了新位置最后一瞬间却又闪回到旧位置然后才恢复正常。根因就是我前面提到的transform和left职责不清——动画交给transform没问题但动画结束后没有把最终的坐标固化到left也没有清空transform。浏览器在下一帧重新计算时发现left还在旧位置、transform位移还在视觉位置是两者叠加的结果一旦有人修改了其中一个位置就会突变。修复方案也已经嵌在代码里了动画结束后用transition: none临时关闭过渡交换left值清空transform强制一次回流恢复transition。这个“关闭过渡→改基准属性→清空临时位移→强制回流→恢复过渡”的五步套路是所有DOM位移动画里通用的最佳实践做任何拖拽、排序、旋转动画都能用上。5.2 连续点击导致动画状态彻底错乱在没有状态守卫的版本里快速点击分组按钮会发现柱子疯狂晃动最后阵营还完全错误。这是因为每次点击都重新发起一组独立的分组动画多组setTimeout回调叠加执行后面一组的计算基于前面一组尚未完成的状态。解决方法是任何可能触发连续动画的入口都要设置状态锁。我在performGroup开头检查state.grouped排序检查state.sorting同时在动画开始后立刻把对应的状态位置为true动态禁用按钮。记住动画操作的不仅是视觉也会改数据数据和动画是同一份状态的两面必须在入口处保证两者的边界清晰。5.3 快速重置导致“僵尸”排序这个问题的隐蔽程度很高。排序中的异步任务在sleep阶段被用户点击洗牌打断等sleep结束旧任务还会继续执行animateSwap操作那些已经从DOM中删除的柱子节点。结果要么报NotFoundError要么把新渲染的柱子莫名其妙地移动了位置。我用的epoch时代标记方案能从根本上解决全局epoch初始为0洗牌时加1排序任务开始前捕获当时的epoch在每一个循环节点和动画入口都检查当前epoch是否等于自己捕获的值不等就立即返回。这个方案本质上是给每个异步任务发一个“工牌”一旦页面状态被重置所有旧任务都会发现自己已经“过期”自觉退出。等你以后写更多的异步交互比如请求竞态、长轮询这个思路同样适用。5.4 数据与显示不同步的排查路径有时柱子的视觉位置是对的但数组里的顺序已经变了导致后续分组或排序逻辑出现匪夷所思的结果。这种问题我没少遇到排查思路其实就一条时刻确认state.array顺序、container.children顺序、视觉落点三者是否一致。检查有三步。第一步浏览器打开开发者工具在console里分别打印state.array和Array.from(container.children).map(n n.dataset.value)比对内容是否完全一致第二步看所有柱子的style.left是不是按0、1、2这样的顺序递增第三步看transform是否全部为空字符串。哪一步对不上就回到对应阶段排查。这个排查流程也同样适用于任何DOM列表和数据数组同步的场景。下面把我在实际开发和测试中遇到的高频问题整理成速查表方便你直接对照。问题现象可能原因快速修复动画结束柱子闪回原位transform未固化到left动画后同步left并清空transform点击分组后柱子乱飞分组动画被重复触发入口加状态守卫和disabled排序中洗牌后报NotFoundError异步任务操作了已删除的节点使用epoch时代标记让任务自检退出柱子位置正确但排序逻辑错array、children、视觉坐标三者不同步按5.4三步检查法定位差异20根以上柱子明显卡顿频繁触发强制同步布局集中读取布局值位移全走transform6. 扩展思路从分组动画到算法可视化工具箱6.1 奇偶分组再往前走一步就是奇偶排序这个项目做完后我认真想过能不能把“奇偶分组”升级成奇偶排序算法Odd-Even Sort的可视化奇偶排序的思路是交替处理相邻对第一轮比较交换所有(0,1)、(2,3)、(4,5)这些奇偶索引对第二轮比较交换(1,2)、(3,4)、(5,6)这些偶奇索引对如此反复直到数组完全有序。它是并行排序的经典教学例子在并行环境中特别友好——因为每一轮内的比较交换互不依赖可以分配给多个处理器同时执行。把它接入现在的项目非常自然。不需要修改柱子渲染逻辑只需要把bubbleSort函数替换成奇偶排序的推进逻辑动画部分完全复用。我在实际改动时只用了半天就完成了这个变体版本它是我最推荐的扩展方向因为视觉上让人看到“奇数轮”“偶数轮”交替推进比纯冒泡多一层节奏感。6.2 分组维度的更多玩法奇偶分组可以改造成很多其它维度。比如质数与合数分组、大于中位数与小于中位数分组、完全平方数与普通数分组。这些扩展只需要替换performGroup函数里的分类标准代码结构完全不用变。还有一个更有趣的方向是把分组从两类扩展到多类。桶排序的可视化就是一个天然延展——把数组按数值范围划分成几个桶柱子根据取值落进对应的桶展示完分组效果后再演示每个桶内单独排序的过程。这和当前项目的架构几乎完全兼容只是把“两组并行”改成“多组并行”。我试过改成三桶版本视觉冲击力更强。6.3 我的后续迭代规划我对这个小工具的下一步规划是会把它扩展成一个可配置的讲解工具顶部放一个算法选择器支持冒泡、插入、奇偶排序底下放一个数量滑块控制柱子数量从8到32再加一个速度滑块当前动画的三个时间参数全部由它统一控制。这样它就能作为一份完整的排序算法可视化教学素材来使用。数据展示上我还会考虑加一个步数计数器实时显示总的比较次数和交换次数。这个数据能直观反映不同排序算法的性能差异虽然有理论上的复杂度公式但实际动画跑一遍得到真实比较次数对初学者的冲击力是完全不同的。做这个项目的过程中我反复体会到一件事前端动画的难点不在“让东西动起来”而在“动完之后如何优雅地恢复静止状态”。每次位移结束要同步left、清transform、重排DOM、恢复transition这些看似繁琐的清理工作才是动画不闪回、不跳变的关键。如果你也想做一个类似的小工具我建议先把状态机画清楚把数据、DOM、视觉坐标三者的一致性当作第一原则再去优化动画效果。踩过这些坑之后你会发现原生JS动画的世界远比想象中友好。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询