引言
轻棋局的前端没有任何框架------没有 React、没有 Vue、没有 Angular,甚至没有构建工具。整个 SPA 由三个文件组成:app.js(主逻辑)、board.js(棋盘渲染)、app.css(样式)。
本篇讲解如何用原生 JavaScript 实现一个功能完整的单页应用。
1. 为什么不用框架
| 考量 | 框架方案 | 原生方案 |
|---|---|---|
| 构建步骤 | 需要 webpack/vite | 零构建,保存即生效 |
| 部署 | 需要 build → dist | 后端直接 serve 静态文件 |
| 依赖管理 | node_modules 100MB+ | 零依赖 |
| 首屏加载 | 框架运行时 50-200KB | 总 JS < 50KB |
| 适用场景 | 复杂交互、大型应用 | 棋盘游戏、工具型页面 |
对于棋盘游戏来说,核心交互是:点击棋子 → 选择目标 → 走棋。这不需要状态管理库、虚拟 DOM、组件系统。
2. 状态管理
全局状态对象
整个应用的状态集中在一个 state 对象中:
javascript
const state = {
bootstrap: null, // 初始化数据(棋种列表等)
me: null, // 当前登录用户
lobby: null, // 大厅数据
room: null, // 当前房间
game: null, // 当前对局
profile: null, // 用户资料
analysis: null, // 复盘分析
analysisStep: 0, // 分析步骤
learnContent: null, // 学习内容
learnProgress: null, // 学习进度
watchOverview: null, // 观战概览
communityLeaderboard: null, // 排行榜
authMode: 'login', // 认证模式:login/register
authError: '', // 认证错误信息
showAuthModal: false, // 是否显示登录弹窗
status: '', // 全局状态消息
selectedFrom: null, // 选中的棋子位置
soundEnabled: true, // 音效开关
ws: null, // WebSocket 连接
// ... 更多状态字段
};
状态驱动渲染
核心原则:状态变化 → 重新渲染。
javascript
// 任何状态更新后调用 render()
function render() {
const route = currentRoute();
app.innerHTML = renderPage(route);
bindCommon(); // 绑定事件
}
// 页面切换
window.addEventListener('hashchange', render);
window.addEventListener('load', boot);
这不是 React 的「状态变化自动 re-render」,而是手动调用 render()。简单直接,完全可控。
3. 路由系统
Hash 路由
使用 #/path 格式的路由,不需要服务端配合:
javascript
const routes = ['home', 'play', 'room', 'game', 'practice',
'analysis', 'learn', 'watch', 'community', 'me'];
function currentRoute() {
const raw = location.hash.replace(/^#\/?/, '');
if (!raw) return { page: 'home', id: '' };
const parts = raw.split('/');
const page = routes.includes(parts[0]) ? parts[0] : 'home';
const id = parts[1] || '';
return { page, id };
}
路由示例
| Hash | 含义 |
|---|---|
#/home |
首页 |
#/room/abc123 |
房间 abc123 |
#/game/xyz789 |
对局 xyz789 |
#/practice/xyz789 |
人机练习 |
#/learn/puzzles |
残局练习 |
#/watch |
观战大厅 |
#/community |
排行榜 |
SPA 导航
关键规则 :必须用 navTo(),不能用 window.location.href。
javascript
// ❌ 错误:会触发完整的页面请求,导致 404
on('[data-action="go-home"]', () => {
window.location.href = '/home';
});
// ✅ 正确:SPA 内部导航
on('[data-action="go-home"]', () => {
navTo('home');
});
function navTo(page, id) {
const hash = id ? `#/${page}/${id}` : `#/${page}`;
window.location.hash = hash;
}
4. 渲染模式
渲染函数
每个页面对应一个 renderXxx() 函数:
javascript
function renderPage(route) {
switch (route.page) {
case 'home': return renderHome();
case 'room': return renderRoom();
case 'game': return renderGame();
case 'practice': return renderPractice();
case 'learn': return renderLearn();
case 'watch': return renderWatch();
case 'community': return renderCommunity();
default: return renderHome();
}
}
模板字符串
用 ES6 模板字符串生成 HTML:
javascript
function renderHome() {
if (!state.bootstrap) {
return '<div class="loading">加载中...</div>';
}
return `
<div class="home-page">
<h1>轻棋局</h1>
<div class="game-types">
${state.bootstrap.gameTypes.map(type => `
<div class="game-card" data-action="select-game" data-game-type="${type.id}">
<img src="${type.icon}" alt="${type.name}">
<h3>${type.name}</h3>
<p>${type.description}</p>
</div>
`).join('')}
</div>
<div class="actions">
<button data-action="create-room">创建房间</button>
<button data-action="join-room">加入房间</button>
<button data-action="practice">人机练习</button>
</div>
</div>
`;
}
事件委托
不在渲染时绑定事件,而是用 data-action 属性做事件委托:
javascript
function bindCommon() {
// 使用事件委托,绑定在父元素上
on('[data-action="create-room"]', handleCreateRoom);
on('[data-action="join-room"]', handleJoinRoom);
on('[data-action="practice"]', handlePractice);
on('[data-action="select-game"]', handleSelectGame);
}
function on(selector, handler) {
const el = document.querySelector(selector);
if (el) {
el.addEventListener('click', handler);
}
}
为什么用事件委托?
- 渲染后统一绑定 --- 不需要在每个
renderXxx()中重复绑定 - 支持动态内容 --- 列表项等动态生成的内容也能响应事件
- 代码集中管理 --- 所有事件绑定在
bindCommon()中
5. API 通信
fetchJson 封装
所有 API 调用通过统一的 fetchJson() 函数:
javascript
async function fetchJson(url, options = {}) {
const response = await fetch(url, {
credentials: 'same-origin', // 携带 Cookie
headers: {
'Content-Type': 'application/json',
},
...options,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
数据加载模式
javascript
// 加载对局数据
async function loadGame(gameId) {
try {
state.game = await fetchJson(`/api/games/${gameId}`);
render();
} catch (err) {
state.status = '加载对局失败';
render();
}
}
// 走棋
async function makeMove(gameId, from, to) {
if (state.moveInFlight) return; // 防止重复提交
state.moveInFlight = true;
try {
const result = await fetchJson(`/api/games/${gameId}/move`, {
method: 'POST',
body: JSON.stringify({ fromRow: from.row, fromCol: from.col,
toRow: to.row, toCol: to.col }),
});
state.game = result.game;
render();
} catch (err) {
state.status = '走棋失败';
render();
} finally {
state.moveInFlight = false;
}
}
WebSocket 实时更新
javascript
function connectWebSocket(roomId) {
const ws = new WebSocket(`ws://${location.host}/online/ws`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'subscribe', roomId }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleWsMessage(msg);
};
state.ws = ws;
}
function handleWsMessage(msg) {
switch (msg.type) {
case 'move':
// 对手走棋
state.game = msg.game;
playMoveSound();
render();
break;
case 'game_over':
showEndGameModal(msg);
break;
case 'clock':
updateClock(msg);
break;
}
}
6. 棋盘渲染
Canvas 绘制
board.js 使用 Canvas API 绘制棋盘:
javascript
function drawXiangqiBoard(canvas, board, selectedFrom) {
const ctx = canvas.getContext('2d');
const cellSize = canvas.width / 10; // 10 行
// 绘制棋盘线
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
// 横线
for (let row = 0; row < 10; row++) {
ctx.beginPath();
ctx.moveTo(0, row * cellSize);
ctx.lineTo(8 * cellSize, row * cellSize);
ctx.stroke();
}
// 竖线
for (let col = 0; col < 9; col++) {
ctx.beginPath();
ctx.moveTo(col * cellSize, 0);
ctx.lineTo(col * cellSize, 9 * cellSize);
ctx.stroke();
}
// 绘制楚河汉界
drawRiver(ctx, cellSize);
// 绘制棋子
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 9; col++) {
const piece = board[row][col];
if (piece) {
drawPiece(ctx, row, col, piece, cellSize);
}
}
}
// 高亮选中的棋子
if (selectedFrom) {
highlightCell(ctx, selectedFrom.row, selectedFrom.col, cellSize);
}
}
响应式适配
棋盘大小随窗口自适应:
javascript
function fitBoardToViewport() {
const canvas = document.getElementById('board-canvas');
const container = canvas.parentElement;
const maxWidth = container.clientWidth - 32; // 边距
const maxHeight = window.innerHeight * 0.6; // 最多占 60% 高度
const size = Math.min(maxWidth, maxHeight);
canvas.width = size;
canvas.height = size * 10 / 9; // 象棋棋盘比例 9:10
render(); // 重新绘制
}
window.addEventListener('resize', fitBoardToViewport);
7. 音效系统
Web Audio API
javascript
let audioContext;
const sounds = {};
function initOnlineAudio() {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// 预加载音效
sounds.move = createOnlineAudio('/assets/audio/move.wav');
sounds.mate = createOnlineAudio('/assets/audio/mate.wav');
}
function createOnlineAudio(url) {
return { url, buffer: null, loaded: false };
}
async function playSound(name) {
if (!state.soundEnabled || !audioContext) return;
// 解锁 AudioContext(浏览器要求用户交互后才能播放)
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
const sound = sounds[name];
if (!sound) return;
// 加载音频文件
if (!sound.loaded) {
const response = await fetch(sound.url);
const arrayBuffer = await response.arrayBuffer();
sound.buffer = await audioContext.decodeAudioData(arrayBuffer);
sound.loaded = true;
}
// 播放
const source = audioContext.createBufferSource();
source.buffer = sound.buffer;
source.connect(audioContext.destination);
source.start(0);
}
音效解锁
浏览器要求用户交互后才能播放音频,所以需要在第一次点击时解锁:
javascript
function initOnlineAudio() {
document.addEventListener('click', () => {
if (audioContext && audioContext.state === 'suspended') {
audioContext.resume();
}
}, { once: true }); // 只触发一次
}
8. 认证弹窗
登录/注册切换
javascript
function renderAuthModal() {
if (!state.showAuthModal) return '';
return `
<div class="modal-overlay" data-action="close-auth-modal">
<div class="modal" onclick="event.stopPropagation()">
<h2>${state.authMode === 'login' ? '登录' : '注册'}</h2>
<form data-action="submit-auth">
<input type="text"
id="auth-username"
placeholder="用户名"
required>
<input type="password"
id="auth-password"
placeholder="密码"
required>
${state.authMode === 'register' ? `
<input type="password"
id="auth-password-confirm"
placeholder="确认密码"
required>
` : ''}
${state.authError ? `
<div class="error">${state.authError}</div>
` : ''}
<button type="submit">
${state.authMode === 'login' ? '登录' : '注册'}
</button>
</form>
<p>
${state.authMode === 'login'
? '没有账号?<a data-action="switch-auth-mode" data-mode="register">注册</a>'
: '已有账号?<a data-action="switch-auth-mode" data-mode="login">登录</a>'
}
</p>
</div>
</div>
`;
}
9. 计时器
实时更新
javascript
function tickLiveGameClock(route) {
if (route.page !== 'game' || !state.game) return;
const game = state.game;
const now = Date.now();
// 更新剩余时间
if (game.currentTurn === 'RED') {
game.firstRemaining = Math.max(0,
game.firstRemaining - 1);
} else {
game.secondRemaining = Math.max(0,
game.secondRemaining - 1);
}
// 更新显示
updateClockDisplay(game);
}
定时器
javascript
window.setInterval(() => {
const route = currentRoute();
tickLiveGameClock(route);
if (route.page === 'watch') {
maybePollWatchOverview();
}
}, 250); // 每 250ms 更新一次
10. 性能优化
避免不必要的渲染
javascript
let renderScheduled = false;
function scheduleRender() {
if (renderScheduled) return;
renderScheduled = true;
requestAnimationFrame(() => {
renderScheduled = false;
render();
});
}
棋盘增量更新
只在走棋后重绘棋盘,而不是整个页面:
javascript
function updateBoardOnly() {
const canvas = document.getElementById('board-canvas');
if (!canvas) return;
drawXiangqiBoard(canvas, state.game.board, state.selectedFrom);
}
WebSocket 消息合并
高频消息(如计时器)合并处理:
javascript
let pendingUpdates = [];
ws.onmessage = (event) => {
pendingUpdates.push(JSON.parse(event.data));
};
setInterval(() => {
if (pendingUpdates.length === 0) return;
// 只处理最新的状态
const latest = pendingUpdates[pendingUpdates.length - 1];
pendingUpdates = [];
handleWsMessage(latest);
}, 100); // 每 100ms 处理一次
小结
原生 JS 实现 SPA 的核心模式:
- 全局状态对象 --- 集中管理所有状态
- Hash 路由 --- 浏览器原生支持,无需服务端配合
- 状态驱动渲染 ---
state → render() → innerHTML - 事件委托 ---
data-action属性 +bindCommon() - fetchJson --- 统一的 API 通信封装
- WebSocket --- 实时更新对局状态
这种模式适合交互简单、功能明确的工具型应用。如果需要复杂的表单、列表、状态管理,才考虑引入框架。
上一篇:(三)AI 引擎设计与实现
下一篇:(五)多棋种统一架构