
先说说我自己的经历第一次正经写 2048 小游戏我干了件特别蠢的事——打开编辑器就开始画div网格忙着配圆角、配颜色、配过渡动画仿佛 UI 好看就万事大吉。结果呢四个方向的事件绑定好了却没有一行代码能告诉我们按下方向键之后棋盘应该变成什么样。憋到半夜才发现真正卡住我的不是界面而是那块棋盘背后的数组算法。标题这句话特别对别急着写 UI。2048 这个看似简单的数字拼图骨子里就是一组非常经典的二维数组操作把这层逻辑想透了UI 反而变成最轻松的一层。这篇文章想讲的就是把 2048 从数组算法到前端界面完整拆一遍重点放在数组、矩阵操作和逻辑与渲染的分离上。适合刚学完数组和前端基础、想拿个小项目练手的人也适合那些会用键盘滑 2048 但没搞懂内部原理的同学。你可以把它当一份从零实现的攻略也可以当成一道二维数组的专项练习题来看。1. 先写 UI 是最容易翻车的顺序一个新手翻车现场还原先还原一下那个经典翻车流程很多人应该有同感。1.1 打开编辑器先画格子然后呢新手打开编辑器第一件事基本都是建一个4x4的容器用 CSS Grid 或者 Flex 布局把十六个格子铺开然后给每个格子标上数字2、4、8……这一步很爽画面立刻就有模有样了。接着你开始处理键盘事件按下方向键之后做什么你下意识地想去操作 DOM把某个div里的文字改掉把某个数字挪到隔壁格子甚至想去计算某个格子应该移动到哪个格子。这就是问题的根源。你在把一个基于状态的游戏硬生生做成了手动操作界面元素的活。2048 的界面只是游戏状态的投影投影本身不应该是被操作的对象可一旦你从 UI 入手思维就会被 DOM 结构带跑偏你要考虑某个数字块怎么平移、边界在哪里、要按什么顺序更新十几个格子。这还没算上合并动画光是逻辑分支就能把你绕晕。我自己当时就是这么卡住的。写了大概两百行 DOM 操作代码后发现按下一次方向键只是把几个格子里的文字改对了但合并了几次、哪里有新块、什么时候游戏结束全都混乱了。于是推倒重来。那次的教训很直接棋盘在屏幕上是什么样完全不应该是逻辑层的责任。1.2 2048 真正的复杂度来自数组不是界面把游戏玩法抽象来看2048 只有一个核心概念一个4x4的棋盘上面散布着一些数字每次滑动时所有数字向滑动方向靠拢相同的数字碰到一起就合并成它们的和然后系统在空位随机生成一个新数字。这个过程从头到尾都是数据的变化跟界面没有半点关系。同一个逻辑你可以跑在浏览器里也可以跑在 Node 命令行里甚至可以打印成最原始的字符串逐行显示。真正有挑战性、有意思的是那 16 个格子怎么在代码里被正确地移动、合并、判断。如果能先把这部分写好UI 只是套上去的一件衣服。所以我的建议一直是先把 2048 当成一道纯算法题来做让它在控制台里跑通再碰任何样式和 DOM。标题说别急着写 UI核心就是这个意思。下面我就按这个顺序从数组建模开始一步步拆。2. 棋盘建模二维数组才是游戏的骨骼CSS 只是皮肉2048 棋盘在屏幕上是四行四列在代码里更简单一个四行四列的二维数组。每一个格子对应数组里的一个元素。2.1 4x4 棋盘到 4x4 数组的映射关系这是最直观的做法const board [ [0, 0, 0, 0], [0, 2, 0, 4], [0, 0, 0, 0], [2, 0, 0, 0], ];board[row][column]表示第row行第column列的值。0表示空格其余数字就是格子里显示的数字。比如上面的数组对应的棋盘就是第二行第二列有个2第二行第四列有个4第四行第一列有个2。这个映射关系是你以后所有操作的地基必须先走通。我见过有人用一维数组加索引运算的方式做比如board[r * 4 c]性能上其实也没问题但二维数组的可读性好得多尤其写按行处理的逻辑时直接board.forEach(row ...)非常自然。对新手来说二维数组就是 2048 的最佳建模方式。2.2 为什么用二维数组以及一个看着高级的扁平化方案二维数组之所以好用是因为 2048 的规则天然围绕行和列展开。向左滑动就是处理每一行向上滑动就是处理每一列。如果数据放在一维数组里每次都要手动算行列索引代码会变得很绕而且一旦棋盘尺寸变成5x5或者6x6所有索引操作都要跟着改。我并不是说一维数组方案不行。在一些追求极致性能的场景里一维数组因为内存连续、缓存友好确实更快。2048 这种只有 16 格的游戏性能需求低得可以忽略所以优先选可维护性更好的二维数组。就算以后想改用扁平数组核心的移动算法也得先在二维数组的思维下理清楚。用二维数组时有一个我自己习惯的小约束始终把不修改原数组作为默认原则移动函数应该返回一个新数组而不是在原数组上改。这样后面判断这次按键到底有没有产生变化会特别方便而且不容易被引用类型的问题坑到。这一步想清楚接下来每一段代码都顺。3. 左移算法拆成三刀压缩、合并、再压缩2048 里四个方向的移动本质上全是左移的变体。所以先把一行数据向左移动这个原语锤炼好游戏的核心就完成了 80%。这个说法不夸张我下面会演示。3.1 第一刀把零全部赶到行尾假设某一行现在是[2, 0, 2, 4]向左移动的第一步是让所有非零数字靠到左边也就是把0全部清除以后在行尾补上对应数量的0。这一步我习惯叫压缩function compact(row) { const nums row.filter((v) v ! 0); return nums.concat(Array(row.length - nums.length).fill(0)); }上面那行的压缩结果是[2, 2, 4, 0]这一步虽然简单但是很重要因为它把数字间可能有空格这个复杂问题直接消除了。压缩之后整行的数字都是紧挨着的后面做合并就只需要看相邻两个元素。我经常把这一步类比成排队的时候人挤一挤空位全甩到队尾后面再来处理两个人的组合。3.2 第二刀相邻相同合并注意一次滑动只合并一次的规则压缩完进入整道题最容易被绕进去的地方合并。2048 合并规则里有条隐藏细节——一次滑动中一个格子只能参与一次合并。比如这一行[4, 4, 8, 0]向左移动正确的合并结果应该是[8, 8, 0, 0]最左边的4和4合并成8原来的8保持不动。你可能会问合并出来的8和原来的8又挨着了为什么不再合并成16因为那个新生成的8已经是合并产物不能再参与本轮第二次合并。这个规则的另一个典型例子是[2, 2, 2, 0]结果是[4, 2, 0, 0]而不是[6, 0, 0, 0]更不是[8, 0, 0, 0]。我推荐的合并写法是在一个循环里从左往右扫描发现相邻两个相等就合并并把索引向后跳一位跳过那个已经合并过一次的块function mergeRow(row) { let arr compact(row); for (let i 0; i arr.length - 1; i) { if (arr[i] ! 0 arr[i] arr[i 1]) { arr[i] * 2; arr[i 1] 0; i; // 合并过一次跳过被合并的下一个 } } return compact(arr); }这里的i是整个合并过程的灵魂。因为arr[i 1]被置成0了如果不跳过下一轮循环会把arr[i 1]也就是 0抱到下一轮比较里虽然有时候碰巧结果对但碰到[2, 2, 2]这种情况就会出错。而跳过之后才能保证一个块本轮只合并一次。3.3 第三刀合并后留下的空位再做一次压缩合并完成后数组里又出现了0。比如[4, 4, 8, 0]合并后变成[8, 0, 8, 0]这显然不是最终形态需要再做一次压缩[4, 4, 8, 0] - 压缩 - [4, 4, 8, 0] - 合并 - [8, 0, 8, 0] - 压缩 - [8, 8, 0, 0]所以左移一行的完整过程就是压缩、合并、再压缩。这就是我说三刀的意思。其实有些更精简的实现把压缩和合并在一个 while 循环里直接做掉逻辑更快。比如我生产环境里更常用这种写法function mergeRow(row) { const nums row.filter((v) v ! 0); const result []; let gained 0; for (let i 0; i nums.length; i) { if (i 1 nums.length nums[i] nums[i 1]) { const merged nums[i] * 2; result.push(merged); gained merged; i; } else { result.push(nums[i]); } } while (result.length row.length) result.push(0); return { row: result, gained }; }这种写法直接基于过滤掉零后的数字序列进行合并天然满足相邻合并后跳过一格的规则。我把教学版和这个生产版都放出来是因为实际项目里你会想要计分数据gained这个返回值后面做总分统计时直接用得上。4. 四个方向收敛成一个原语倒序与转置的小技巧左移搞定后其余三个方向最笨的做法是把上面的逻辑各自重写一遍。但经典的 2048 算法题考的就是你能不能把所有方向都翻译回左移。翻译工具有两个数组倒序和矩阵转置。4.1 右移 倒序 左移 倒序先看右移。右移的规则和左移完全对称只是方向相反。如果你先把这行倒序那么从右往左合并就变成了从左往右合并处理完再倒序回来function reverseRow(row) { return row.slice().reverse(); } // 对每一行做右移 board board.map((row) reverseRow(row)); board board.map((row) mergeRow(row).row); board board.map((row) reverseRow(row));举例[2, 2, 4, 0]右移倒序为[0, 4, 2, 2]左移合并为[4, 4, 0, 0]再倒序为[0, 0, 4, 4]完全符合预期。注意reverseRow里我用了slice()拷贝不然会原地翻转原数组破坏不可变原则。4.2 上移/下移先转置再当左移处理上移和下移麻烦在它们是按列操作不是按行。但如果你把整个矩阵转置也就是把行变成列、列变成行那么按列向左移动就等价于按行向左移动处理完再转置回来。这个思路特别经典我当初第一次看到的时候拍了一下大腿。矩阵转置这个数组操作在很多二维数据处理的场景里都会用到不只是 2048。它的数学定义很简单原矩阵第i行第j列的元素放到新矩阵第j行第i列。function transpose(matrix) { return matrix[0].map((_, colIndex) matrix.map((row) row[colIndex])); }这段代码建议直接背下来它非常精炼。外层map遍历列索引内层map把每一行的第colIndex个元素抽出来组成新的一行效果就是行列互换。于是四个方向可以统一成这样左移直接对每一行做mergeRow右移每行倒序 - 左移 - 倒序上移转置 - 左移 - 转置下移转置 - 右移 - 转置4.3 转置函数怎么写不容易出错一个可以背下来的模板转置函数最大的坑是下标写反。新手很容易写出matrix[i][j]到result[i][j]的复制代码那只是复制了一份矩阵根本没有转置。我这里提供两个从原理出发的检查方法。第一个检查行列数的变化。一个4x4方阵转置完还是4x4看不出问题但如果是2x3的矩阵转置后应该是3x2。用非方阵去测试transpose函数如果行列数没互换基本就是写错了。第二个检查对角线。方阵转置之后主对角线上的元素保持不变比如board[1][2]应当等于转置后board[2][1]拿这个值手动验一次。下面是我推荐背下来的模板稳、短、可读function transpose(matrix) { return matrix[0].map((_, colIndex) matrix.map((row) row[colIndex]) ); }有了倒序和转置这两个工具移动方向的代码可以从之前那种四段复制粘贴收敛成一个非常干净的move函数function move(board, direction) { switch (direction) { case left: return board.map((row) mergeRow(row).row); case right: return board.map((row) reverseRow(row)) .map((row) mergeRow(row).row) .map((row) reverseRow(row)); case up: return transpose( transpose(board).map((row) mergeRow(row).row) ); case down: return transpose( transpose(board).map((row) reverseRow(row)) .map((row) mergeRow(row).row) .map((row) reverseRow(row)) ); } }transpose(board)得到的矩阵拿去做操作之后再transpose回来就是你想要的上移或下移结果。这套思路极好地体现了为什么二维数组算法是 2048 的灵魂如果你的代码里四个方向各写一套逻辑迟早会在某个边界条件上翻车而收敛成一个原语加两个矩阵变换几乎不需要单独测每个方向对不对只要测试左移和矩阵变换即可。5. 围绕数组的三个辅助逻辑随机块、有效移动、终局判定2048 除开移动合并还有三件事必须天天跟数组打交道随机生成新块、判断按键是否有效、判断游戏是否结束。它们单独拿出来都不难但是组合在一起会让游戏循环变得完整。5.1 随机生成新块统计空格子掷一次骰子每次有效移动之后棋盘上的某个空位要随机出现一个新数字。这个逻辑可以拆成两步遍历整个二维数组把所有值为0的位置收集起来。从这些空位里随机选一个填上2或4。经典规则是90%概率出210%概率出4。function addRandomTile(board) { const empty []; for (let r 0; r board.length; r) { for (let c 0; c board[r].length; c) { if (board[r][c] 0) empty.push([r, c]); } } if (empty.length 0) return false; const [r, c] empty[Math.floor(Math.random() * empty.length)]; board[r][c] Math.random() 0.9 ? 2 : 4; return true; }这个函数有个容易被忽略的前提它直接修改了传入的board。在我自己设计的代码里我倾向于只允许这个函数修改原数组其他移动、合并函数保持纯函数特性。这样职责分明不至于所有地方都偷偷改数组。5.2 判断这一次按键是否有效整个棋盘做一次快照对比没有产生任何变化的移动比如整行已经是[2, 0, 0, 0]还往左滑不应该生成新块。判断方法有很多种最高效的方法是移动之前先判断有没有空隙或者可合并的相邻对。但我个人强烈建议新手用最朴素的办法移动前后各留一份棋盘逐格对比。function boardsEqual(a, b) { for (let r 0; r a.length; r) { for (let c 0; c a[r].length; c) { if (a[r][c] ! b[r][c]) return false; } } return true; }你可能会觉得这样太笨了但注意 2048 只有 16 个格子逐格对比的开销完全可以忽略。用这种笨办法换回来的是正确性和可读性这恰恰是工程里更重要的东西。后面我还会讲直接比较数组引用是一个巨大的坑所以这里宁可写一个boardsEqual也不要偷懒。5.3 终局判定没有空格且没有相邻相等游戏结束的条件有两个同时满足才算结束棋盘上没有任何一个空格任意相邻的两个格子横向相邻或纵向相邻都不相等。第二个条件的原理可以这么理解如果存在两个相等的相邻格子那么朝它们所在的方向滑动一次这两个格子就能合并游戏还远没到结束。所以不需要真的对四个方向各跑一遍移动只要遍历一遍棋盘查相邻相等即可function isGameOver(board) { const size board.length; for (let r 0; r size; r) { for (let c 0; c size; c) { if (board[r][c] 0) return false; if (r 1 size board[r][c] board[r 1][c]) return false; if (c 1 size board[r][c] board[r][c 1]) return false; } } return true; }我之前见过有人真的把四个方向的移动各跑一遍然后看有没有变化来判断游戏是否结束。逻辑上没错但代码冗余得多而且还要小心移动函数是否会修改原数组。用是否存在相邻相等这个思路一次遍历就搞定了效率高理解起来也顺。6. 把算法和 UI 拆开让你的游戏在控制台里跑通再画界面到了这一步你手上已经有了一整套纯逻辑函数。现在可以正式回答标题里的问题了为什么别急着写 UI因为如果你一开始就把 UI 晾在一边先用这个纯函数内核在控制台里把游戏跑通后面做的事其实就只是把数组画出来。6.1 逻辑层只需要暴露 move、addRandomTile、isGameOver 三个能力我通常会用一个Game类把这些函数组装起来让外部只和它打交道class Game { constructor(size 4) { this.size size; this.score 0; this.board Array.from({ length: size }, () Array(size).fill(0)); addRandomTile(this.board); addRandomTile(this.board); } move(direction) { const newBoard move(this.board, direction); if (boardsEqual(this.board, newBoard)) { return { moved: false }; } this.board newBoard; this.score this.getLastMoveGained(direction); addRandomTile(this.board); return { moved: true }; } getLastMoveGained(direction) { // 可以在 move 函数里同时返回 gained这里简化为重新计算 // 更优雅的做法是让 move 返回 { board, gained } } isGameOver() { return isGameOver(this.board); } }这里的关键点是UI 层完全不 care 这些方法是基于什么数组操作实现的。UI 只需要知道三件事初始有棋盘、按键调用move、结束后提示Game Over。你在浏览器里按方向键本质上只是调用一个纯逻辑方法然后拿到一个新的二维数组。6.2 render 函数是唯一需要碰 DOM 的地方把界面想象成一句投影逻辑每次棋盘变化调用一个render把二维数组映射成格子。function render(game) { const container document.getElementById(board); container.innerHTML ; const board game.board; for (let r 0; r board.length; r) { for (let c 0; c board[r].length; c) { const cell document.createElement(div); cell.className cell; cell.textContent board[r][c] 0 ? : board[r][c]; container.appendChild(cell); } } }这个render是全项目里唯一允许碰 DOM 的代码。这样带来的好处是你可以在任何环境里测试、调试、甚至写自动化测试脚本去验证逻辑只要生成一个Game实例连续执行几个move然后打印game.board控制台里就能看到每一步的正确结果。我以前就是先写一个console.log版本的纯逻辑跑通所有边界用例之后才开始写 HTML 和 CSS整个开发过程极其安心。6.3 用同样的内核支持撤销和 5x5 变体因为逻辑和 UI 解耦了两个很酷的功能只需要几行代码就能加上。第一个是撤销。你只需要在每次有效移动前把当前棋盘JSON.parse(JSON.stringify(this.board))存进一个历史栈撤销时弹出一格就行this.history.push(this.board.map((row) row.slice()));第二个是变体尺寸。你会发现之前所有函数里几乎没有写死4这个数字——board.length就是尺寸。如果你想做一个5x5的 2048只需要new Game(5)UI 那边把网格布局改成五行五列算法一行都不用改。这就是数据结构先行UI 只是投影的实惠。7. 我在实战里反复踩的三个坑以及送给新手的建议再补充几个我实际开发中踩过、也经常看同行踩的坑。写这篇文的时候我特意回想了一下很多坑不是因为算法难而是因为 JavaScript 数组的某些特性太容易迷惑人了。7.1 坑一数组是引用类型直接比较棋盘永远无变化这个太经典了。新手在判断这次移动有没有效时很容易写出这样的代码if (this.board newBoard) { // 没有移动 }问题是两个不同的数组即使内容一模一样引用也不相等而如果你把一个数组赋给另一个变量它们又指向同一块内存怎么比都相等。在这个场景里直接比较引用几乎永远得不到你想要的结果。我自己的解决办法就是第 5 节里的boardsEqual函数老老实实逐格比较。你可能会觉得这个函数很土但它在 4x4 棋盘上性能完全够用而且可读性极强。但凡跟他人协作后来者看到boardsEqual(this.board, newBoard)也能秒懂。用土办法解决小规模问题本身就是一种优化。7.2 坑二合并方向写反4 4 8 一碗菜全煮糊合并逻辑里方向感和只合并一次这两件事特别容易一起出错。比如[4, 4, 8, 0]向左错误版本可能输出[16, 0, 0, 0]因为先合并了前两个 4 得到 8又继续把新 8 和原 8 合并成 16。这是很多教程代码都容易踩的隐藏 bug。要避免这个坑最好在写mergeRow的时候就补上跳过逻辑参考第 3 节的代码。我当时给自己定了一条死规矩合并完成后立即让下标加一永远不要用一个已经参与合并过的元素去参加本轮的后半段合并。另一个建议是写几个固定的测试用例跑一遍比如[2,2,2,0] - [4,2,0,0]、[4,4,8,0] - [8,8,0,0]、[2,0,0,2] - [4,0,0,0]。把这些用例先在控制台里跑通比在界面上肉眼观察稳得多。7.3 三条建议纯函数、先跑控制台、再做动画最后是我压箱底的几条经验不算什么高深理论但确实能帮你少走弯路。第一尽量让移动和合并函数保持纯函数特性不修改传入的数组而是返回新数组。这样做的直接好处是比较变化变得特别简单间接好处是以后你想做撤销、回放、测试成本都非常低。纯函数是数组操作里最值得习惯的一种思维方式。第二UI 是最后一步但也不能太晚。我个人的节奏是先在 Node 或浏览器控制台里把Game类跑通再写一个最丑的render函数哪怕只是把数字拼成字符串然后再开始美化。这样每一步都建立在逻辑正确的地基上而不是在一个界面半成品上反复猜 bug。如果你一上来就接各种动画库、花整套 CSS最后发现核心移动算法写错了返工成本会让你崩溃。第三动画一定放最后。2048 的滑动动画看起来很高级但它纯粹是视觉层的东西。用 CSS 过渡也好、用 requestAnimationFrame 也好都不该影响游戏逻辑的正确性。很多人做 2048 的时候把 80% 时间花在动画和样式上结果核心算法漏洞百出。我认为真正舒服的开发顺序是数组算法切入纯函数收尾渲染层贴上去动画随手点缀这才是一个练手项目该有的完成姿态。如果这篇能帮你把 2048 背后的数组操作理顺那我强烈建议你顺手再做一件事把同样的内核写一遍但棋盘改成 5x5。你会发现之前所有函数几乎不用动这其实就是数据与展示分离和通用数组算法给你带来的回报。2048 确实只是个小游戏但这个小游戏里藏着的二维数组技巧足够让你在日后面试、做小工具、处理表格数据时多一分从容。