news 2026/10/14 21:39:53

原生JS实现奇偶分组排序动画:状态机驱动的可视化实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
原生JS实现奇偶分组排序动画:状态机驱动的可视化实践

做排序可视化的时候,很多人第一反应是选冒泡排序或者快速排序,因为它们最常被拿来当教学案例。但这次我偏偏选了条冷门路线:用原生JS实现奇偶分组排序动画,核心是「奇偶分组 + 排序可视化」。奇偶排序这个名字可能不如冒泡家喻户晓,但它有个天然优势——每一轮都会按数组索引的奇偶分成两组配对比较,奇偶交替形成两股波浪式的推进节奏,视觉节奏感非常强。我做的页面顶部是播放/暂停、速度滑块、重置按钮,下面是一排高度随数值变化的柱子。点击播放后,柱子按奇偶索引交替亮起,必要时交换位置,直到所有柱子从左到右按高度排好。它解决的核心问题是“算法过程看不见”的痛点,排序本身抽象,可视化能让每一轮比较、每一次交换都变成能看的画面。如果你是前端入门不久、想练原生JS和DOM动画,或者对算法可视化感兴趣,这篇文章的思路和代码可以直接拿去改。

1. 奇偶分组排序的可视化方案,为什么偏偏选它

1.1 算法原理:两队交替推进的排序节奏

奇偶排序全名是奇偶转置排序,也叫砖排序(Brick Sort)。它可以理解成冒泡排序的一种变体,区别在于不是每一趟都从头到尾扫完整条数组,而是把一趟拆成两个小轮次:

  • 偶轮:比较起点为偶数下标的相邻对,也就是 (0,1)、(2,3)、(4,5)……
  • 奇轮:比较起点为奇数下标的相邻对,也就是 (1,2)、(3,4)、(5,6)……

每轮内部如果前一个数大于后一个数,就交换位置。偶轮和奇轮合起来算完整的一趟。如果某一趟里完全没有发生任何交换,说明数组已经整体有序,算法提前终止。

举个很简单的例子,数组[5, 3, 8, 4, 2]:

  • 偶轮:比较(5,3),交换 ->[3,5,8,4,2];比较(8,4),交换 ->[3,5,4,8,2]
  • 奇轮:比较(5,4),交换 ->[3,4,5,8,2];比较(8,2),交换 ->[3,4,5,2,8]

下一趟继续从偶轮开始,这样反复多走几轮,最终得到[2,3,4,5,8]。

在最坏情况下,时间复杂度是O(n^2)。纯粹比拼排序效率,它不如快速排序,但做可视化演示、特别是元素数量控制在几十根柱子的场景,性能完全不是问题。更重要的是,它的每一步只比较和交换相邻两个元素,变化幅度小,观众视线能一直跟上,动画体验非常稳。

1.2 选型逻辑:小波动、易观察、教学感强

为什么选奇偶排序而不是快排或选择排序?我的考量和大多数算法可视化项目不太一样,不追求“展示最快算法”,而是追求“展示清晰过程”。

快速排序虽然快,但递归分区的跳跃性很强。一趟分区以后,基准值瞬间飞到中间,左右子数组接着递归,视觉上元素位置变动剧烈,初学者很容易看一会儿就迷路。选择排序倒是稳定,每轮从左到右扫描找最小值,但它的“扫描-交换”节奏比较平,缺少奇偶分组这种对称的错峰感。奇偶排序的偶轮和奇轮交替出现,像两队人接力把大元素往右推,视觉上会有一种波浪错峰的节奏,特别适合做让人愿意看完的动画演示。

在算法教学上,奇偶排序也有独特价值。它的偶轮内部各对比较相互独立,奇轮内部各对也相互独立,这种特性天然贴合并行处理的思想。就算是在单线程的JavaScript里,我们也能通过状态机把这种交错推进的顺序完整还原出来,给观众一种“好像多路同时处理”的感觉。

1.3 技术选型:原生JS、DOM渲染,为什么不碰框架和Canvas

这个项目我定了两条规矩:不引框架,不碰Canvas,只用最传统的DOM加原生JavaScript。理由有三点。

第一,演示规模小。单页展示的柱子数量在30到60根之间,DOM节点数量完全可控,操作DOM的性能开销可以忽略不计。Canvas更适合上千个粒子那种高密度绘制,但对于柱子数量不多的页面,用Canvas反而要在坐标转换、点击命中、帧绘制上额外写一堆代码,属于过度设计。

第二,CSS天然就是动画引擎。DOM元素有现成的transition和transform机制,柱子位置变换、高度变化、颜色高亮都可以交给CSS补间,不需要自己在Canvas里写插值函数。这让动画代码量大幅减少。

第三,方便调试。DOM渲染的每一根柱子都是真实元素,浏览器开发者工具里可以直接查看样式、检查transform值,出问题一盯一个准。换到Canvas里,你只能靠console.log去推测当前坐标是不是画错了。

好,技术路线确定后,真正难的部分来了:怎么把一次跑完的排序算法,拆成一个可以逐帧播放的动画?

2. 动画驱动的核心设计:状态机与渲染解耦

2.1 把排序函数改造成“可视化小步”

普通排序函数是连续执行,跑完整个算法才返回结果。可视化要的是“走一步,停一下,渲染一帧”。所以必须把排序过程改造成状态机:每次调用只推进一个比较动作,然后返回给动画循环一个描述对象,让渲染层知道这次比较了哪两个下标、是否需要交换。

我用一个对象保存排序进度:

const state = { pass: 0, // 当前已完成多少趟 phase: 'even', // 当前是偶轮还是奇轮 pairIndex: 0, // 当前轮内处理到第几对比较 swappedThisPass: false // 当前这一趟是否发生过交换 done: false, // 排序是否结束 };

nextStep每次被调用,就推进一对比较,或者完成偶轮/奇轮之间的切换。这里有个容易被绕晕的地方:排序逻辑里既有“轮”的概念,又有“趟”的概念。一趟由偶轮和奇轮组成,所以状态机里必须同时记住当前在哪个轮、这一轮走到第几对了。

2.2 核心状态机代码与边界处理

核心代码骨架如下,逐行注释我都写在里面:

function nextStep(state, arr) { if (state.done) return { done: true }; const n = arr.length; // 当前轮次剩余的比较对数量 // 偶轮:起跳点为 0,2,4... // 奇轮:起跳点为 1,3,5... const maxPairs = state.phase === 'even' ? Math.ceil((n - 1) / 2) : Math.floor((n - 1) / 2); if (state.pairIndex < maxPairs) { // 计算当前要比较的两个下标 const j = state.phase === 'even' ? state.pairIndex * 2 : state.pairIndex * 2 + 1; const needSwap = arr[j] > arr[j + 1]; if (needSwap) { [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; state.swappedThisPass = true; } state.pairIndex++; return { done: false, compare: [j, j + 1], swap: needSwap, arr: arr.slice(), // 这一步的数组快照,调试时很有用 }; } // 当前轮次结束,切换阶段 if (state.phase === 'even') { state.phase = 'odd'; state.pairIndex = 0; } else { // 奇轮结束,意味着完整的一趟结束 state.pass++; // 这一趟没有任何交换,说明已经有序 if (!state.swappedThisPass) { state.done = true; return { done: true }; } state.swappedThisPass = false; state.phase = 'even'; state.pairIndex = 0; } // 阶段切换后,马上进入下一阶段的第一步 return nextStep(state, arr); }

几个需要重点解释的细节:

maxPairs的计算是奇偶排序最容易写错的地方。偶轮比较对的起点是0、2、4……,每对比较的是(j, j+1),所以最后一对的 j 必须满足j + 1 < n,也就是 j 最大只能是 n-2 或 n-3 的偶数值。Math.ceil((n-1)/2)和Math.floor((n-1)/2)刚好覆盖了这个边界。比如 n=6 时,偶轮有3对,奇轮有2对;n=5时,偶轮有2对,奇轮有2对,正好都不越界。

swappedThisPass的更新位置非常关键。它是整个“趟”维度的交换标记,不是轮维度的,所以在偶轮里发生交换和奇轮里发生交换都会置为 true。只有在完成一整趟、准备进入下一趟时会检查并重置它。

我还在阶段切换后直接递归调用了nextStep。这样做的目的是避免“偶轮最后一对比较完、奇轮第一对还要等下一帧才开始”的空拍感。比如动画循环里调用一次 step,偶轮结束、奇轮开始的第一对会在同一个调用里返回,渲染层不会出现一帧什么都没有推进的空白状态。

从这套状态机可以看出来,排序数据和渲染状态是解耦的:状态机只管arr和state,它甚至完全不知道页面上有什么柱子。渲染层只需要读取{ done, compare, swap }里的信息去更新DOM即可。

2.3 渲染层与数据层的同步约定

可视化项目最常踩的坑,就是数据和视觉没有保持同步。这里我采用一套很朴素的约定,实测下来非常省心:

数据在nums数组里,DOM引用在bars数组里。nums[i]表示第 i 个位置现在放的数值,bars[i]表示第 i 个位置现在对应的 DOM 元素。交换时,两个数组同步交换对应位置的元素,始终保持下标语义一致。

位置变化用transform: translateX(i * stepX)表达,高度变化用height: value * scaleH表达。因为 bars 数组的下标语义和 nums 数组完全一致,所以我只需要在交换后把这两个柱子的 transform 和 height 更新到新位置即可。

打个比方,数据数组是“剧本”,bars 数组是“演员站位表”。每一次调度后,要同时更新剧本和站位表,不能只改一边。如果只改数据不改 bars 引用,下一次比较高亮时,页面上高亮的柱子和实际数据就对不上号,整个动画会像抽风了一样乱跳。

准备动手写完整页面之前,还有最后一个设计要确定:动画驱动到底用 setInterval 还是 requestAnimationFrame?这个问题放在下一节详细说。

3. 完整原生JS实现:从初始化到播放全流程

3.1 初始化页面:随机数据与柱子的生成

页面结构很简单,控制区和图表区两部分。

<div class="toolbar"> <button id="playBtn">播放</button> <button id="resetBtn">重置</button> <input type="range" id="speed" min="20" max="300" value="100"> </div> <div id="chart"></div>

生成随机数据时注意一个细节:直接用Array.sort(() => Math.random() - 0.5)洗牌不是均匀随机,因为浏览器排序算法并不保证对每个元素公平分布。我用 Fisher–Yates 洗牌,实现也简单:

function shuffledArray(len, max) { const arr = Array.from({ length: len }, (_, i) => { return Math.floor(Math.random() * max); }); for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; }

柱子的宽度和步距按容器宽度算出来:

const CHART_WIDTH = 800; const CHART_HEIGHT = 320; const GAP = 2; const barW = (CHART_WIDTH - GAP * (n - 1)) / n; const stepX = barW + GAP; const scaleH = CHART_HEIGHT / maxValue;

创建DOM时我统一用DocumentFragment先拼好再一次性插入,避免每次 append 都触发一次回流。每根柱子都是一个 div,初始位置通过transform: translateX(i * stepX)设置,高度通过style.height设置。这里不用 left 定位,是因为 transform 变化只触发合成层更新,性能更好,而且 CSS 过渡动画也接得住。

3.2 播放控制:RAF加时间间隔,而不是 setInterval

动画循环我选择了requestAnimationFrame加时间间隔控制。核心想法是:RAF 循环一只保持运行,但只有满足“时间到了”这个条件才会真正推进排序步骤。暂停只是把布尔开关关掉,不需要销毁和重建定时器。

let raf = null; let lastStepTime = 0; let paused = true; let intervalMs = 100; function loop(ts) { if (!paused && ts - lastStepTime >= intervalMs) { const r = nextStep(state, nums); applyStep(r); lastStepTime = ts; if (r.done) { paused = true; runDoneAnimation(); return; } } raf = requestAnimationFrame(loop); }

为什么不直接用 setInterval?因为 setInterval 在页面切后台再回来时,会积压一批回调在同一时间疯狂执行,看起来像突然开了加速。而且暂停/恢复时还得去 clearInterval 再重新创建,代码容易写出竞态问题。用 RAF 加时间戳差值的写法,天然规避了这两个问题。

速度滑块直接控制intervalMs。滑块值越大,间隔越长,速度越慢。这样用户从慢到快可以调出完全不同的观感:慢速模式下可以看到每一步比较的细节,快速模式下能感受到算法整体的波浪节奏。

3.3 视图更新:高亮、交换、完成动画

每次推进一个步骤后,渲染层要做的事很明确:清掉上一轮的高亮状态,给当前比较的两个柱子打上高亮样式;如果需要交换,就让两个柱子的 DOM 引用互换位置,并更新 transform 和 height。

function applyStep(r) { clearHighlight(); if (r.done) return; const [a, b] = r.compare; bars[a].classList.add('comparing'); bars[b].classList.add('comparing'); if (r.swap) { // 同步交换 bars 数组里的引用 [bars[a], bars[b]] = [bars[b], bars[a]]; // 位置要到目标下标 bars[a].classList.add('swapping'); bars[b].classList.add('swapping'); bars[a].style.transform = `translateX(${a * stepX}px)`; bars[b].style.transform = `translateX(${b * stepX}px)`; // 高度这里我选择显式更新,保证数值与DOM一定一致 bars[a].style.height = `${nums[a] * scaleH}px`; bars[b].style.height = `${nums[b] * scaleH}px`; } }

这里有一个容易被绕晕的点:交换完成后,bars[a]引用的是原来 b 位置的柱子,它的 transform 却被设置成了 a 的坐标。这看起来反直觉,但正是因为我们在 swap 里同时交换了 bars 数组的引用,bars[a] 现在的任务就是“站到 a 的位置上”,所以 transform 设置为 a 的坐标是正确的。高度同理,nums 数组已经交换,所以 nums[a] 正是这个柱子现在应该显示的高度。

排序完成后,我加了一个简单但很提气的完成动画:从左到右给每根柱子依次加上sorted样式,像绿色光带扫过去。

let sortedIndex = 0; function runDoneAnimation() { if (sortedIndex >= nums.length) return; bars[sortedIndex].classList.add('sorted'); sortedIndex++; setTimeout(runDoneAnimation, 40); }

3.4 CSS 关键样式:让动画自然发生

CSS 部分是动画效果的灵魂。柱子绝对定位在容器底部,宽高固定,背景色通过类名切换:

#chart { position: relative; width: 800px; height: 320px; border-bottom: 1px solid #ddd; } .bar { position: absolute; bottom: 0; width: 16px; background: linear-gradient(180deg, #4a90d9, #2c6bb0); border-radius: 2px 2px 0 0; transition: transform 0.2s ease, height 0.2s ease, background 0.1s ease; } .bar.comparing { background: #f2c94c; } .bar.swapping { background: #eb5757; } .bar.sorted { background: #27ae60; }

需要注意一个新手容易踩的坑:如果transition一开始就在基础样式里,那么初始化创建柱子时也会出现短暂的“从0长高到目标高度”的过渡,观感很怪。我的做法是初始化创建完柱子后,再往根节点加一个类控制过渡,或者直接用requestAnimationFrame在下一帧添加 transition,避免初始动画污染。

4. 排错实录:四个最容易翻车的细节

虽然状态机设计已经尽量简洁,但在实际编码调试过程中,我还是踩了几个坑,有些问题排查了好一阵才意识到根源。

4.1 状态机边界算错导致排序不完整

典型症状:动画跑完,数组基本有序,但总有个别相邻元素顺序是反的。

原因基本出在maxPairs的公式上。偶轮和奇轮的比较对数量不一样,如果两边都用了同一个Math.floor((n-1)/2),偶轮最后一对会比较不上。我的建议是:接入可视化之前,先把状态机函数单独放到 Node 或浏览器控制台里,跑100遍随机数据,断言排序结果。

function assertSorted(nums) { for (let i = 1; i < nums.length; i++) { if (nums[i - 1] > nums[i]) return false; } return true; } for (let t = 0; t < 100; t++) { const test = shuffledArray(20, 100); const state = initialSortState(); while (!nextStep(state, test).done) {} console.assert(assertSorted(test), '排序结果错误'); }

先把纯算法验证到稳定,再开始动 DOM,能省下大量调试时间。

4.2 “动静不一”:数据已经换位,视觉没跟上

症状是页面上高亮的柱子和它显示的数值对不上,甚至出现同一个柱子在两个位置同时出现似的错觉。

根源就是 bars 数组没有和 nums 数组同步交换。刚开始我图省事,交换时只改数据数组,让渲染层每次通过>const duration = Math.min(200, intervalMs * 0.8); document.documentElement.style.setProperty('--bar-transition', duration + 'ms');

基础 CSS 里写的 transition 时长改用 CSS 变量,快接近极限速度时,过渡自动缩短,看起来就会顺眼很多。

4.4 标签页切后台再回来,动画像开了快进

这是 RAF 的经典陷阱。浏览器在后台会冻结requestAnimationFrame,切回来时时间戳会突然跳一大截。如果循环里写成“当前帧距上次足够久就一直推进”,那就可能一个帧内执行几十步,排序瞬间完成,观众什么都没看到。

解决方法是限制追赶:如果间隔超过一定阈值,只重置时间基准,不进行任何推进。

if (ts - lastStepTime > 1000) { lastStepTime = ts; }

用户切走时根本看不到动画,回来时从当前状态继续慢慢演,完全不需要补帧。这个防护逻辑非常值得写进每个 RAF 动画页面里。

4.5 问题速查表

现象根因解法
排序结束仍有个别逆序maxPairs 边界公式写错先跑纯函数断言测试
高亮柱子和实际数值错位bars 引用没有和 nums 同步交换swap 时同步两个数组
快速模式下动画瞬移transition 和 step 间隔不匹配用 CSS 变量联动过渡时长
切后台回来突然完成RAF 追帧间隔过大时重置基准时间

5. 还能怎么玩:接口复用与体验增强

5.1 同一个动画引擎跑多种排序算法

这个状态机设计的好处是,动画循环只认{ done, compare, swap }这个返回结构,完全不关心底层跑的是奇偶排序还是冒泡排序。所以我顺手把算法抽象成了一个统一接口,想换成冒泡排序,只需要实现一个bubbleNextStep。

function bubbleNextStep(state, arr) { if (state.done) return { done: true }; // 冒泡排序每趟从左到右扫一遍 // state.idx 记录当前扫到的位置 // state.pass 记录完成趟数 // ... }

动画层完全不用改,切换算法等于换一个函数。这种解耦结构让代码的扩展性好了非常多。

5.2 加音效:排序听出节奏感

给排序加音效是很带感的操作。用 Web Audio API 生成短促的 beep,比较时音高可以随数值变化,交换时用更短促的提示音。

const audioCtx = new AudioContext(); function beep(freq, duration = 0.05) { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.frequency.value = freq; osc.connect(gain).connect(audioCtx.destination); gain.gain.setValueAtTime(0.05, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration); osc.start(); osc.stop(audioCtx.currentTime + duration); }

需要注意浏览器自动播放策略,必须等用户点击过页面后才创建或 resume AudioContext,否则刚打开页面就播放音频会被浏览器拦截。

5.3 显示进度与交换次数

如果用来做算法教学,建议再加一行信息面板:当前趟数、总交换次数、偶轮/奇轮标识。用户能直观看到奇偶排序每趟的交换统计,也更清楚整个算法的推进进度。我调试时最喜欢这种“过程可量化”的设计,能一眼看出算法是不是陷入了无效比较。

做完整套效果之后,我最大的体会是:可视化排序不难,难在让动画和算法状态始终处于同一套认知模型。只要坚持“数据是唯一真相、状态机只推数据、渲染层只画状态”这三条原则,后面无论换算法、加音效、改样式,代码都接得住。如果你也想写一个排序可视化页面,建议先从奇偶排序入门,先让排序函数在控制台里百分之百稳定,再开始写动画。跑通这套流程后,再回去看快速排序的分区动画,你会发现状态机的设计思路是一脉相承的。这篇分享就写到这里,祝你的柱子们排得又快又好看。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 21:39:13

JavaScript代理模式实战:缓存、权限与懒加载的工程化应用

1. 为什么需要代理模式&#xff1a;从一个登录组件说起做前端开发的朋友应该都遇到过这种场景&#xff1a;你给某后台系统写了一个上传头像的功能&#xff0c;用户每次点击头像区域&#xff0c;就要去后端拿一次用户的最新资料&#xff0c;然后重新渲染头像、昵称、等级这些信息…

作者头像 李华
网站建设 2026/10/14 21:38:46

微信跳一跳辅助:图像识别+ADB控制的端到端实践

简介&#xff1a;本资源是一款面向微信小程序‘跳一跳’玩家的轻量级自动化辅助工具包&#xff0c;适用于具备基础命令行操作与Java环境配置能力的编程初学者或自动化兴趣实践者&#xff0c;旨在解决手动跳跃精度低、重复操作疲劳等问题。压缩包共355个文件&#xff0c;主体为2…

作者头像 李华
网站建设 2026/10/14 21:38:43

Flutter图片加载优化:OpenHarmony适配实战与内存调优

一开始我没想到图片加载会成为一个大问题。某跨端适配项目里&#xff0c;Flutter 代码在 Android 那边跑得好好的&#xff0c;列表滚动顺滑&#xff0c;图片刷刷就出来了。结果同事把同一套代码部署到 OpenHarmony 开发板上&#xff0c;人直接懵了&#xff1a;列表快速滑动时白…

作者头像 李华
网站建设 2026/10/14 21:37:47

Microsoft Barcode Control 9.0:Windows原生COM条码控件实战指南

简介&#xff1a;本资源是基于 Microsoft Barcode Control 9.0 的条形码生成与打印完整开发示例&#xff0c;面向 .NET Framework 2.0 及以上版本的 Windows Forms 开发者&#xff0c;尤其适合需快速集成条形码功能的库存管理、物流追踪等业务系统项目。压缩包含 30 个文件&…

作者头像 李华
网站建设 2026/10/14 21:36:42

房屋租赁合同书模板:从条款拆解到避坑的完整指南

简介&#xff1a;这份房屋租赁合同书模板面向房东、租户及法律从业者&#xff0c;用于规范房屋出租过程中的权利义务约定&#xff0c;降低租赁纠纷风险。模板以商业租赁场景为主&#xff0c;涵盖甲乙双方基本信息、租赁房屋位置与面积、租赁用途、租赁期限及装修期、基础租金与…

作者头像 李华
网站建设 2026/10/14 21:36:38

勇敢开始:把“伟大”拆解成可执行的最小行动

很多人把这句话当作桌面壁纸和签名&#xff0c;但真正把它落进生活的人少之又少。我见过太多人对着这句话热泪盈眶&#xff0c;转身又走进同一个犹豫不决的循环里。问题不在“伟大”这个词太大&#xff0c;而在“开始”这件事被想得太重。作为一个常年和项目、故障、交付打交道…

作者头像 李华