ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用 JavaScript 作用域与闭包实战:从零构建浏览器端井字棋项目

用 JavaScript 作用域与闭包实战:从零构建浏览器端井字棋项目 用 JavaScript 作用域与闭包实战从零构建浏览器端井字棋项目【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum本篇技术指南围绕本仓库旧版 JavaScript 课程中的经典实战作业project_scopes_closures展开其核心任务是在浏览器中构建一款支持两名玩家对战的井字棋Tic Tac Toe游戏借此系统检验你对 JavaScript 作用域Scope、闭包Closure与this关键字的掌握程度。读完本文你将掌握棋盘对象建模、render()渲染函数、基于事件监听的主游戏逻辑与胜负判定等完整实现路径并能进一步用工厂函数与 IIFE 模块模式重构代码把全局变量污染降到最低。项目背景为什么用井字棋来检验作用域与闭包原作业文档开篇即点明设计意图如果你此前在 Ruby 面向对象课程中构建过命令行版井字棋来练习 OOP 技能那么这次把它搬到浏览器里会有天然的先发优势——同样的游戏规则、同样的棋盘建模思路但运行环境换成了浏览器 DOM 与 JavaScript 的事件模型。井字棋这个项目之所以被选为作用域与闭包知识的练兵场是因为它的实现天然涉及 JavaScript 最棘手的三类问题跨函数共享可变状态棋盘格子内容、当前轮到谁需要被多个函数读写稍不留神就会陷入变量该放在哪层作用域的纠结事件回调中的this点击事件回调何时执行、以何种方式被调用直接决定了回调内部this的指向见 作用域与闭包课程 的学习目标状态封装如何把棋盘玩家游戏流程各自的内部状态藏起来只暴露必要接口这正是闭包与模块模式的用武之地。前置知识速览作用域、闭包与this在动手写代码前先快速梳理本作业要检验的核心知识点。仓库中的 作用域与闭包课程 列出了一份学习目标清单可作为自我检视的标准this等于什么这从来不是一个简单问题……如何用bind把变量绑定到某个作用域为什么要定义一个that变量来保存this为什么随意修改或引用作用域之外的变量是不好的为什么私有变量其实并不真正私有函数是否应该总是返回同样的东西函数的调用方式函数式调用、方法式调用……如何影响其作用域以及this作用域变量在哪里可用工厂函数与模块模式课程 对作用域给出了精确定义作用域问的是某个变量在当前位置是否可用。未声明在任何函数或花括号内的变量处于全局作用域处处可用声明在函数或{ }块内的变量属于局部作用域。ES6 之前var声明的变量是函数作用域仅在其所在函数内可用ES6 引入的let/const则是块级作用域仅在最近的{ }内可用这个块可以是for循环、if-else分支等。该课程给出了一个三种作用域同框的示例非常值得对照理解// 这是一个全局变量 let globalAge 23; // 这是一个函数——花括号即块 function printAge(age) { // 这是函数作用域变量 var varAge 34; // 这是又一个花括号即一个块 if (age 0) { // 块级作用域变量仅存在于最近的块if 的块内 const constAge age * 2; console.log(constAge); } // 报错在块外访问了块级作用域变量 console.log(constAge); } printAge(globalAge); // 报错在函数外访问了函数作用域变量 console.log(varAge);在井字棋项目中你至少会用到三层作用域存放游戏状态对象的全局作用域、工厂/构造函数内部的作用域、以及事件回调创建的新一层作用域。每一层边界都值得用上面的思维去审视。闭包函数捕获其词法环境闭包的定义是函数与其声明时的周边状态词法环境的组合。一个关键行为是外部函数执行完毕后其内部变量并不会被清理只要返回的内部函数仍然引用着它们。课程中的经典例子function makeAddingFunction(firstNumber) { // firstNumber 在 makeAddingFunction 内任意位置可见包括 returnedFunction 内 return function returnedFunction(secondNumber) { // secondNumber 仅在 returnedFunction 内可见 return firstNumber secondNumber; } } const add5 makeAddingFunction(5); console.log(add5(2)); // 7add5是对makeAddingFunction(5)返回函数的引用。makeAddingFunction执行完毕后5并不会被回收因为返回的函数仍然需要访问它所在的词法环境其中包含firstNumber参数。函数可以关联数据并在外层函数之外操作这些数据这一特性正是后续工厂函数与模块模式的基石也是你在井字棋中封装玩家分数、棋盘状态时依赖的底层机制。项目任务分解九个步骤完整实现原作业给出了完整的任务清单Assignment下面逐条展开并补充实现细节。开始前请先按作业要求为本项目创建一个 GitHub 仓库把版本控制从第一天就建立起来参考本仓库 git 基础课程 完成初始化与首次提交创建一个空白的 HTML 文档作为游戏的宿主页面先思考如何组织游戏中的不同元素需要哪些对象和函数花几分钟的头脑风暴能帮你避免浪费一小时的编码时间——这是整个作业中最重要的一条建议用 JavaScript 创建一个基本的 3×3 棋盘对象列出棋盘每个位置当前的内容例如X、O或 创建render()函数遍历棋盘对象并生成相应的 HTML 元素把它们样式化成真正的井字棋棋盘构建主游戏逻辑作业刻意不给太多提示用户点击空白方格时显示当前玩家的符号。这可以通过在对应 div 上监听 click 事件触发。建议给每个 div 设置基于其位置的data属性或id方便在 JS 代码中定位游戏逻辑随后检查游戏是否结束是否有一方获胜或是否已无剩余回合。若分出胜负在屏幕上显示相应消息可选用合适或有趣的图片替换X和O字母把解决方案推送到 GitHub并提交链接。棋盘建模与render()渲染函数第一步用对象保存棋盘状态最简单的建模方式是让棋盘对象持有 9 个格子3×3的当前内容。作业要求用对象而非散落各处的变量来组织这正是为后续作用域管理打基础。一个最小实现const gameBoard { // 0 到 8对应 3×3 棋盘的九个位置 spaces: [ , , , , , , , , ], currentPlayer: X };这里spaces数组就是棋盘内容的单一事实来源后续所有读写都应围绕它进行避免在不同作用域里各存一份副本导致状态不同步。第二步render()遍历棋盘生成 DOMrender()的职责是单向数据流读取棋盘对象 → 生成对应 HTML → 挂载到页面。它不应该包含任何游戏规则逻辑保持纯粹function render() { const container document.querySelector(#board); container.innerHTML ; // 清空旧内容 gameBoard.spaces.forEach((space, index) { const square document.createElement(div); square.classList.add(square); square.textContent space; // 用 data 属性记录格子在棋盘中的位置便于事件处理时定位 square.dataset.index index; container.appendChild(square); }); }关于 DOM 操作的具体手法querySelector、createElement、textContent等可参阅 DOM 操纵与事件课程其中详细介绍了选择器定位、节点增删改等基础方法。主游戏逻辑事件监听、回合切换与胜负判定用事件回调处理点击作业明确要求用户点击空白方格时显示当前玩家符号这需要为每个方格监听click事件。仓库的 DOM 操纵与事件课程 推荐使用addEventListener方式方法三因为它既能保持关注点分离又允许为一个元素挂多个监听器const btn document.querySelector(#btn); btn.addEventListener(click, () { alert(Hello World); });对井字棋而言更贴近实战的写法是先通过querySelectorAll拿到所有方格再用forEach为每个方格分别绑定监听器这正是该课程为节点组挂载监听器一节的示范模式document.querySelectorAll(.square).forEach((square) { square.addEventListener(click, (e) { const index e.target.dataset.index; // 这里需要结合闭包与作用域访问棋盘与当前玩家状态 }); });注意事件回调接收的e参数它包含事件本身的对象其中e.target指向被点击的 DOM 节点正是我们用它来读取data-index定位格子的原因。用闭包管理回合状态这是作用域与闭包知识最直接的用武之地。回合切换逻辑需要记住当前轮到谁且这个记忆必须在多次点击之间持续存在。如果你把currentPlayer声明在事件回调内部每次点击都会重新初始化为X声明在全局又容易污染全局作用域。闭包提供了中间路线——把状态封装在外部函数的作用域里function createGame() { let currentPlayer X; function playTurn(index) { // 通过闭包读写 currentPlayer currentPlayer currentPlayer X ? O : X; } return { playTurn }; }这里playTurn形成对currentPlayer的闭包createGame执行结束后currentPlayer依然存活且仅能通过返回的playTurn接口访问——外部代码无法直接把它改成非法值。胜负判定作业要求检查任意玩家是否达成三连或已无剩余回合。3×3 棋盘共有 8 条获胜线3 行、3 列、2 条对角线判定逻辑可以硬编码为索引组合const WINNING_LINES [ [0, 1, 2], [3, 4, 5], [6, 7, 8], // 行 [0, 3, 6], [1, 4, 7], [2, 5, 8], // 列 [0, 4, 8], [2, 4, 6] // 对角线 ]; function checkWinner(spaces) { for (const line of WINNING_LINES) { const [a, b, c] line; if (spaces[a] ! spaces[a] spaces[b] spaces[b] spaces[c]) { return spaces[a]; // 返回获胜方符号 } } return null; }平局判定则是没有获胜者且棋盘已无空白格。这两种情况都应在屏幕上给出明确提示X 获胜平局等。进阶重构用工厂函数与 IIFE 模块模式收敛全局代码原作业的任务到此已可交付但仓库中同一主题的新版井字棋项目project_tic_tac_toe.md提出了更高的代码组织要求恰好是旧版作业的天然延伸建议在完成基础版后继续精进把棋盘存储为Gameboard对象内的数组玩家与游戏流程也各自放入对象主目标是让全局代码尽可能少能放进工厂函数的逻辑尽量放进工厂如果某类对象只需要单一实例如棋盘gameboard、显示控制器displayController就用 IIFE 把工厂包起来模块模式使其无法被复用来创建额外实例认真思考每段逻辑的归属每小块功能都应能放进 game、player 或 gameboard 对象中合理的位置先在控制台跑通完整游戏优先实现包含全部三连与平局判定的游戏逻辑在考虑 DOM 与 HTML/CSS 之前用函数调用 传参的方式自己扮演玩家验证逻辑正确性控制台版跑通后再创建负责显示/DOM 逻辑的对象写一个把gameboard数组内容渲染到网页的函数初期可以手动往数组里填X/O来观察渲染效果编写让玩家通过与 DOM 元素交互点击棋盘格落子的函数——别忘了已被占用的格子不能再次落子的校验打磨界面允许玩家输入名字、提供开始/重开按钮、在游戏结束时显示结果。为什么需要 IIFE 模块模式旧版作业让状态直接暴露在全局而新版作业用模块模式解决全局污染问题。核心动机在 工厂函数与模块模式课程 中有透彻解释对象的所有属性无论如何都是公开的。若直接写对象字面量lastResult之类的内部状态就允许被外部直接改写如calculator.lastResult 111100105110。唯一能真正隐藏状态的方式是把变量放进函数内部脱离外部作用域再在同一作用域内创建对象并返回它——这就是模块模式const calculator (() { let lastResult; const add (a, b) { lastResult a b; return lastResult; }; const getLastResult () lastResult; return { add, getLastResult }; })(); console.log(calculator.add(3, 5)); // 8 console.log(calculator.getLastResult()); // 8IIFE立即调用函数表达式在此的价值是工厂函数只需要被调用一次用 IIFE 包裹后无需给它命名因而它既不在所在作用域占用名字也不可能被再次调用代码被打包成一个模块仅向外暴露calculator对象。这一过程体现的正是封装Encapsulation把数据与代码捆绑为单一单元并选择性开放内部访问权限。私有变量的威力同样来自该课程工厂函数可以利用闭包制造真正意义上外部不可直接触碰的私有变量——这对井字棋的玩家数据管理极具借鉴意义function createUser(name) { const discordName name; let reputation 0; const getReputation () reputation; const giveReputation () { reputation; }; return { name, discordName, getReputation, giveReputation }; } const josh createUser(josh); josh.giveReputation(); josh.giveReputation(); // logs { discordName: josh, reputation: 2 } console.log({ discordName: josh.discordName, reputation: josh.getReputation() });注意返回对象不包含reputation变量本身也不含其值副本只包含读取与递增它的两个闭包函数。外部代码无法把reputation误设为-18000只能通过受控接口操作——把井字棋中的当前回合数玩家得分等状态按此模式封装可有效规避跨作用域误改的隐患。何时改用 ES6 模块如果项目文件逐渐变大ES6 模块课程 还提供了一条更现代的路径每个文件默认拥有私有作用域用export/import显式选择暴露与引入的内容。其动机与 IIFE 模块模式一脉相承——课程明确指出加载多个普通script时它们共享同一全局作用域顶层变量并不安全IIFE 是 ES6 之前的模块模式而 ESM 则是语言原生的解决方案。浏览器端使用时只需把入口脚本声明为模块script srcgame.js typemodule/script知识自检完成项目后请回答这些问题对照 作用域与闭包课程 的学习目标逐条确认你是否能结合自己的井字棋代码给出答案this在事件回调、方法调用、函数调用、箭头函数中分别等于什么如何用bind把this或参数固定到回调上什么时候该用箭头函数天然捕获外层this什么时候该用function () {}配合that变量你的游戏状态是否被封装在工厂/模块内部而非散落在全局私有变量为何在 JavaScript 中其实是通过闭包实现的作用域隔离同样的函数用函数式调用与方法式调用执行时this的差异在哪里小结本项目以浏览器井字棋为媒介把 JavaScript 最易踩坑的三块知识——作用域边界、闭包状态保持、this动态绑定——串联进一次完整的实战。从空白的 HTML 文档到可对战的完整游戏核心路径是对象化棋盘状态 →render()单向渲染 → 事件回调驱动回合 → 胜负/平局判定 → 用工厂函数与 IIFE 模块模式收敛全局代码。完成基础版并完成进阶重构后你就同时掌握了能用与会用两个层次为后续深入学习 ES6 模块与 React 等现代框架中的状态管理打下坚实基础。【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表