JavaScript 实现 Tic Tac Toe (2)
3. 渲染模块
渲染模块的功能设计
我们把 displayController 单独作为一个模块。这个模块完成了视图的工作、并提前做好一些绑定工作。
绑定器
我们可以把一些固定的、不依赖特定情况的 DOM 操作先写好,这样就不需要在主文件 script.js
中写重复的代码了,主文件只需要负责把这些模块初始化、连接好即可。
我们把需要做绑定的组件传进来作为参数:
function init({boardEl, startBtn, playerXInput, playerOInput, turnEl, resultEl})
然后提前做好绑定的工作,首先是棋盘的相关逻辑,我们先把棋盘划分好:
boardEl.innerHTML = '';
for (let i = 0; i < 9; i ++) {
const cell = document.createElement('div');
cell.className = 'cell';
cell.dataset.index = i;
boardEl.appendChild(cell);
}
然后实现对 startBtn
和每个 cell 被点击的操作的绑定。这里我们采用依赖注入的设计方式,让主文件传入回调函数:
bindCellClick: (handler) => {
boardEl.addEventListener('click', e => {
const cell = e.target.closest('.cell');
if (!cell) return;
const idx = Number(cell.dataset.index);
handler(idx);
});
},
bindStart: (handler) => {
startBtn.addEventListener('click', ()=> {
handler(playerXInput.value, playerOInput.value);
})
},
主文件只需要传入 handler
逻辑,DOM 相关操作都在绑定器中封装好了。
视图
在视图的笔记中,我们有数据驱动视图,即:
UI = f(state)
我们也使用这个设计,返回一个渲染函数,这个渲染函数接收数据、然后渲染收到的数据:
render: (board, state) => {
board.forEach((v, i) => {
const cell = boardEl.querySelector(`[data-index="${i}"]`);
if (!cell) return; // defensive
cell.textContent = v;
cell.classList.toggle('taken', v !== '');
});
turnEl.textContent = state.isOver? "Game Over" : `Turn: ${state.current}`;
resultEl.textContent = state.winner ? (state.winner === 'tie' ? 'Tie!' : `${state.winner} wins!`) : '';
}
我们还可以添加两个用于消息渲染的,当我们需要渲染诸如 “X赢得了比赛” 的消息的时候,直接调用即可、不需要再去手动操作 DOM:
showMessage: (msg) => {resultEl.textContent = msg;},
clearMessage: () => {resultEl.textContent = '';}
4. 事件总线设计
发布——订阅模式概述
在这个井字棋项目中,我们就可以用到之前讲解的发布——订阅模式了。
使用发布——订阅模式,我们可以让需要渲染的东西订阅“棋盘发生变化”这个事件、在收到这个事件后调用回调函数自动触发渲染,而不是手动地调用 render
。这同时也很好地做到了模块间的解耦。
具体设计
首先我们先看看这个事件应该在什么时候被触发。我们希望在井字棋状态发生变化后发送这些事件,然后触发完渲染后执行后面的逻辑(比如其他的回调函数)。这正是JavaScript的微任务的执行顺序!于是我们可以创建一个微任务队列来存放我们希望在接收到事件后执行的逻辑。
然后,我们可以把管理发布——订阅流程的代码抽离成一个组件,也就是事件总线。它实现了对对应事件的回调函数的注册以及发送。这样我们就不需要在 gameboard 模块和 gameController 模块包含和它们职能不符合的发布——订阅功能,具体的事件管理可以让主文件 script.js
来连接。
下面是具体的代码实现:
- 我们用一个
Map
来存储事件对应的回调函数。在注册完回调函数后返回一个取消注册的方法。 - 在发布一个事件时,我们创建一个微任务队列,把这个事件对应的回调函数以微任务的形式执行。
// Simple event bus with microtask-delivered emits
const handlers = new Map();
function on(event, fn) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event).add(fn);
return () => handlers.get(event).delete(fn);
}
function emit(event, payload) {
const set = handlers.get(event);
if (!set || set.size === 0) return;
const snapshot = payload;
// deliver asynchronously (microtask) so callers can finish sync work first
queueMicrotask(() => {
set.forEach(fn => {
try { fn(snapshot); } catch (e) { console.error('eventBus handler error', e); }
});
});
}
5. 主文件整合
总体架构概览
在实现 script.js
文件前,我们有了如下文件:
├── ./displayController.js
├── ./eventBus.js
├── ./gameboard.js
├── ./gameController.js
├── ./index.htm
└── ./style.css
我们的 js 文件是以 MVC 模式架构的:
gameboard.js
用于实现井字棋的模型。它只负责操作数据、管理棋盘的状态。gameController.js
用于实现井字棋的逻辑,它只处理下棋的具体过程的逻辑,并返回过程中的一些状态。displayController.js
负责 DOM 的创建、渲染以及事件绑定。它返回了一些方法,这些方法把用户传入的动作应用到 DOM 上、完成渲染。eventBus.js
负责广播事件,我们可以把它视为 Controller 发布 state-change 的官方渠道。
并且每个模块都是独立的、没有依赖其他的模块,这很好地满足了单一职责原则。
流程整合
最后我们在 script.js
中把各个模块连接起来:
- 导入模块和依赖。
- 初始化视图层,把 DOM 元素注入视图模块。
// Selectors
const boardEl = document.getElementById('board');
const startBtn = document.getElementById('startBtn');
const turnIndicator = document.getElementById('turnIndicator');
const resultEl = document.getElementById('result');
const inputX = document.getElementById('playerX');
const inputO = document.getElementById('playerO');
const display = DisplayController.init({
boardEl,
startBtn,
playerXInput: inputX,
playerOInput: inputO,
turnEl: turnIndicator,
resultEl
});
- 订阅事件总线,协调渲染过程,决定什么时候调用 render 渲染。
// subscribe display to board changes via central event bus
let unsubBoard = null;
let unsubGameStart = null;
function bindSubscriptions() {
// cleanup previous
if (typeof unsubBoard === 'function') { unsubBoard(); unsubBoard = null; }
if (typeof unsubGameStart === 'function') { unsubGameStart(); unsubGameStart = null; }
unsubBoard = eventBus.on('board.change', (payload) => {
display.render(payload.board, GameController.getState());
});
unsubGameStart = eventBus.on('game.start', (payload) => {
display.render(payload.board, GameController.getState());
});
}
bindSubscriptions();
- 将 UI 事件转成控制器调用,并根据返回值显示临时消息
display.bindStart((nameX, nameO) => {
GameController.start(nameX, nameO);
display.clearMessage();
// ensure subscriptions are bound (in case eventBus was re-initialized)
bindSubscriptions();
});
display.bindCellClick((idx) => {
const res = GameController.playAt(idx);
if (!res.success) {
display.showMessage(res.reason === 'taken'? 'The cell is used!' : 'Invalid operation!');
setTimeout(() => display.clearMessage(), 1000);
} else if (res.winner) {
display.showMessage(res.winner === 'tie'? 'Tie!' : `${res.winner} wins!`);
}
});
这里由于 DOM 操作已经在 displayController 中封装好了,我们只需要调用不同模块的方法就可以实现按钮对应的 DOM 操作。
- 在页面即将离开时清理(解绑)所有订阅和视图资源,防止内存泄漏、重复绑定或残留的异步任务:
// cleanup subscription on page unload
window.addEventListener('beforeunload', () => {
if (typeof unsubBoard === 'function') unsubBoard();
if (typeof unsubGameStart === 'function') unsubGameStart();
if (display && typeof display.destroy === 'function') display.destroy();
});