轻棋局(四):前端 SPA 实战

引言

轻棋局的前端没有任何框架------没有 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);
    }
}

为什么用事件委托?

  1. 渲染后统一绑定 --- 不需要在每个 renderXxx() 中重复绑定
  2. 支持动态内容 --- 列表项等动态生成的内容也能响应事件
  3. 代码集中管理 --- 所有事件绑定在 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 的核心模式:

  1. 全局状态对象 --- 集中管理所有状态
  2. Hash 路由 --- 浏览器原生支持,无需服务端配合
  3. 状态驱动渲染 --- state → render() → innerHTML
  4. 事件委托 --- data-action 属性 + bindCommon()
  5. fetchJson --- 统一的 API 通信封装
  6. WebSocket --- 实时更新对局状态

这种模式适合交互简单、功能明确的工具型应用。如果需要复杂的表单、列表、状态管理,才考虑引入框架。


上一篇:(三)AI 引擎设计与实现

下一篇:(五)多棋种统一架构

相关推荐
不是山谷.:.2 小时前
前端性能优化全解析:从原理到落地,覆盖全领域与多技术栈
前端·笔记·性能优化·状态模式
sakana3 小时前
我开源了我的cgzskill,帮Claude装上长期记忆
前端
用户223586218203 小时前
如何在超大型的工程中使用 Claude Code?
前端·ios·claude
Amos_Web3 小时前
Rspack 源码解析 (2) —— 从 rspack build 到输出 dist,完整编译链路详解
前端·javascript
漓漾li3 小时前
每日面试题(2026-05-20)- 前端
前端·react.js
颯沓如流星3 小时前
前端 UI 组件专业术语科普指南
前端·ui
超*3 小时前
Bright Data Web Scraping指南 2026: 使用 MCP + Dify 自动采集海外社交媒体数据
前端·人工智能·媒体
洛宇3 小时前
(建议收藏)转型AI应用工程师之RAG:从入门到实战
前端·人工智能·面试
ID_180079054733 小时前
企业级淘宝评论 API最简说明,JSON 返回示例
java·服务器·前端