阅读视图

发现新文章,点击刷新页面。

告别割裂式学习:待办清单项目,一次性掌握数组、本地存储与事件委托

告别割裂式学习:待办清单项目,一次性掌握数组、本地存储与事件委托

你也许背过 mapfilter 的用法,也用过 localStorage,但一遇到真实项目就不知道怎么组合?
本文通过一个完整的待办清单应用,带你真正理解:数据驱动视图、状态持久化、如何优雅地操作数组。
重点:我会用最通俗的白话讲清楚「事件委托」——新手最头疼的概念之一。


为什么第二个项目必须是待办清单?

待办清单(TodoMVC)被称为“前端的力学题”。它看起来简单,却包含了现代 Web 应用的核心模式:

  • 数据模型:用数组存储对象,每个对象有 idtextcompleted
  • 增删改查(CRUD):添加、删除、更新完成状态
  • 数据持久化localStorage 保存数据,刷新页面不丢失
  • 事件委托:处理动态生成的 DOM 元素
  • 条件渲染:根据数据状态展示不同样式
  • 筛选过滤:全部/未完成/已完成

完成它之后,你会发现很多中大型项目的基础模块都长这样。


第一步:搭建界面(HTML + CSS)

我们先写好一个干净、现代的界面:输入框、添加按钮、待办列表、筛选栏和统计信息。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>待办清单|JavaScript 实战</title>
    <style>
        * { box-sizing: border-box; }
        body {
            background: #f1f5f9;
            font-family: system-ui, -apple-system, sans-serif;
            display: flex;
            justify-content: center;
            padding: 2rem;
        }
        .todo-app {
            max-width: 500px;
            width: 100%;
            background: white;
            border-radius: 1rem;
            box-shadow: 0 8px 20px rgba(0,0,0,0.1);
            padding: 1.5rem;
        }
        h1 { margin-top: 0; font-size: 1.8rem; color: #0f172a; }
        .add-form { display: flex; gap: 8px; margin-bottom: 1.5rem; }
        .add-form input {
            flex: 1; padding: 10px; border: 1px solid #cbd5e1;
            border-radius: 8px; font-size: 1rem;
        }
        .add-form button {
            background: #3b82f6; color: white; border: none;
            border-radius: 8px; padding: 0 16px; cursor: pointer;
        }
        .todo-list { list-style: none; padding: 0; margin: 0 0 1rem 0; }
        .todo-item {
            display: flex; align-items: center; gap: 12px;
            padding: 10px; border-bottom: 1px solid #e2e8f0;
        }
        .todo-item.completed span { text-decoration: line-through; color: #94a3b8; }
        .todo-item span { flex: 1; }
        .delete-btn {
            background: #ef4444; color: white; border: none;
            border-radius: 6px; padding: 4px 12px; cursor: pointer;
        }
        .filter-bar { display: flex; gap: 8px; margin-top: 1rem; }
        .filter-btn {
            background: #e2e8f0; border: none; border-radius: 20px;
            padding: 6px 12px; cursor: pointer;
        }
        .filter-btn.active { background: #3b82f6; color: white; }
        .stats { margin-top: 1rem; font-size: 0.9rem; color: #475569; text-align: center; }
    </style>
</head>
<body>
<div class="todo-app">
    <h1>✅ 待办清单</h1>
    <div class="add-form">
        <input type="text" id="todoInput" placeholder="写一个待办..." autocomplete="off">
        <button id="addBtn">添加</button>
    </div>

    <ul class="todo-list" id="todoList"></ul>

    <div class="filter-bar">
        <button class="filter-btn active" data-filter="all">全部</button>
        <button class="filter-btn" data-filter="active">未完成</button>
        <button class="filter-btn" data-filter="completed">已完成</button>
    </div>
    <div class="stats" id="stats"></div>
</div>

<script>
    // 所有 JavaScript 代码将放在这里
</script>
</body>
</html>

第二步:核心知识点拆解(重点:事件委托)

2.1 数据结构设计

每个待办项是一个对象:

{
  id: Date.now(),      // 唯一标识,不用下标避免删除错乱
  text: '学习JavaScript',
  completed: false
}

整个应用的数据就是一个数组 todos

2.2 从 localStorage 读取和保存

function loadTodos() {
  const stored = localStorage.getItem('todos');
  return stored ? JSON.parse(stored) : [];
}

function saveTodos(todos) {
  localStorage.setItem('todos', JSON.stringify(todos));
}

2.3 “数据 → 视图”的渲染函数

核心思想:只要数据 todos 变了,就彻底重新生成列表 HTML。不需要手动操作每一个 DOM 元素。

let currentFilter = 'all';

function render() {
  // 1. 根据筛选条件过滤
  let filtered = todos;
  if (currentFilter === 'active') {
    filtered = todos.filter(t => !t.completed);
  } else if (currentFilter === 'completed') {
    filtered = todos.filter(t => t.completed);
  }

  // 2. 生成 HTML 字符串
  const html = filtered.map(todo => `
    <li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
      <input type="checkbox" ${todo.completed ? 'checked' : ''}>
      <span>${escapeHtml(todo.text)}</span>
      <button class="delete-btn">删除</button>
    </li>
  `).join('');

  document.getElementById('todoList').innerHTML = html;

  // 3. 统计
  const total = todos.length;
  const completedCount = todos.filter(t => t.completed).length;
  document.getElementById('stats').innerHTML = `共 ${total} 项,已完成 ${completedCount} 项`;
}

2.4 🔥 事件委托(新手必看!)

问题:为什么需要事件委托?

我们所有的待办项(包括里面的“删除”按钮和复选框)都是通过 render() 动态生成的。如果直接写:

document.querySelector('.delete-btn').addEventListener('click', function() { ... })

这行代码执行时,页面上还没有 .delete-btn(因为列表是后来才渲染出来的),所以根本绑定不上。哪怕你写在 render() 之后,每次重新渲染旧 DOM 会被替换,之前绑定的监听器也会丢失。

传统解决方案:每次渲染后重新绑定事件——非常麻烦且容易出错。

解决方案:事件委托

利用 事件冒泡 机制:点击某个元素后,事件会一直向上传播到它的父元素、祖先元素。我们可以把监听器挂到已经存在的父容器上(比如 <ul id="todoList">),然后通过 e.target 判断实际点击的是哪个子元素,再做出相应的处理。

生活类比

  • 普通绑定 = 在每个员工身上安装一个专线电话(员工离职换人,电话就没了)。
  • 事件委托 = 只在部门经理那放一部总机,谁打来电话就通过分机号转给对应的人(员工换人,分机号不变,依然能找到)。
代码实现(逐行注释)
document.getElementById('todoList').addEventListener('click', (e) => {
  // e 是事件对象,e.target 是用户实际点击的最深层元素(可能是按钮、复选框、<span>等)
  const target = e.target;

  // 关键方法:closest('.todo-item')
  // 它会沿着祖先链向上查找,找到第一个匹配 '.todo-item' 选择器的元素。
  // 这样无论你点击的是按钮、复选框还是文字,都能拿到当前待办项所在的 <li>
  const li = target.closest('.todo-item');
  if (!li) return;   // 如果点击的不是待办项内部,直接忽略

  // 从 li 上读取 data-id 属性,这是我们在渲染时设置的
  const id = Number(li.dataset.id);

  // 判断点击的是“删除按钮”
  if (target.classList.contains('delete-btn')) {
    // 删除:过滤掉 id 匹配的项
    todos = todos.filter(t => t.id !== id);
    saveTodos(todos);
    render();   // 重新渲染列表
    return;
  }

  // 判断点击的是“复选框”(input type="checkbox")
  if (target.type === 'checkbox') {
    const todo = todos.find(t => t.id === id);
    if (todo) {
      todo.completed = target.checked;  // 根据复选框状态更新
      saveTodos(todos);
      render();
    }
  }
});

为什么这样就能工作?

  • 监听器挂载在 #todoList 上,而这个 <ul> 自始至终存在,不会被替换。
  • 每次点击,事件冒泡到 <ul>,我们检查 e.target,根据点击的元素类名或类型做出不同操作。
  • 对于新添加的待办项,不需要额外绑定任何事件——事件委托会自然处理。

这就是事件委托的威力:只需一个监听器,就能管理未来所有动态生成的子元素

2.5 添加待办

function addTodo() {
  const input = document.getElementById('todoInput');
  const text = input.value.trim();
  if (text === '') return;

  const newTodo = {
    id: Date.now(),
    text: text,
    completed: false
  };
  todos.push(newTodo);
  saveTodos(todos);
  input.value = '';
  render();
}

2.6 筛选功能

改变 currentFilter,然后重新调用 render() 即可。

function setFilter(filter) {
  currentFilter = filter;
  // 更新按钮高亮
  document.querySelectorAll('.filter-btn').forEach(btn => {
    if (btn.dataset.filter === filter) btn.classList.add('active');
    else btn.classList.remove('active');
  });
  render();
}

document.querySelectorAll('.filter-btn').forEach(btn => {
  btn.addEventListener('click', () => setFilter(btn.dataset.filter));
});

2.7 初始化

let todos = loadTodos();
currentFilter = 'all';
render();
// 绑定添加按钮和回车事件
document.getElementById('addBtn').addEventListener('click', addTodo);
document.getElementById('todoInput').addEventListener('keypress', (e) => {
  if (e.key === 'Enter') addTodo();
});
// 事件委托已经挂载在 ul 上了,不再需要额外绑定

第三步:完整代码(可以直接运行)

下面是把所有部分整合在一起,复制保存为 .html 即可体验。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>待办清单|JavaScript 实战</title>
    <style>
        * { box-sizing: border-box; }
        body {
            background: #f1f5f9;
            font-family: system-ui, -apple-system, sans-serif;
            display: flex;
            justify-content: center;
            padding: 2rem;
        }
        .todo-app {
            max-width: 500px;
            width: 100%;
            background: white;
            border-radius: 1rem;
            box-shadow: 0 8px 20px rgba(0,0,0,0.1);
            padding: 1.5rem;
        }
        h1 { margin-top: 0; font-size: 1.8rem; color: #0f172a; }
        .add-form { display: flex; gap: 8px; margin-bottom: 1.5rem; }
        .add-form input {
            flex: 1; padding: 10px; border: 1px solid #cbd5e1;
            border-radius: 8px; font-size: 1rem;
        }
        .add-form button {
            background: #3b82f6; color: white; border: none;
            border-radius: 8px; padding: 0 16px; cursor: pointer;
        }
        .todo-list { list-style: none; padding: 0; margin: 0 0 1rem 0; }
        .todo-item {
            display: flex; align-items: center; gap: 12px;
            padding: 10px; border-bottom: 1px solid #e2e8f0;
        }
        .todo-item.completed span { text-decoration: line-through; color: #94a3b8; }
        .todo-item span { flex: 1; }
        .delete-btn {
            background: #ef4444; color: white; border: none;
            border-radius: 6px; padding: 4px 12px; cursor: pointer;
        }
        .filter-bar { display: flex; gap: 8px; margin-top: 1rem; }
        .filter-btn {
            background: #e2e8f0; border: none; border-radius: 20px;
            padding: 6px 12px; cursor: pointer;
        }
        .filter-btn.active { background: #3b82f6; color: white; }
        .stats { margin-top: 1rem; font-size: 0.9rem; color: #475569; text-align: center; }
    </style>
</head>
<body>
<div class="todo-app">
    <h1>✅ 待办清单</h1>
    <div class="add-form">
        <input type="text" id="todoInput" placeholder="写一个待办..." autocomplete="off">
        <button id="addBtn">添加</button>
    </div>

    <ul class="todo-list" id="todoList"></ul>

    <div class="filter-bar">
        <button class="filter-btn active" data-filter="all">全部</button>
        <button class="filter-btn" data-filter="active">未完成</button>
        <button class="filter-btn" data-filter="completed">已完成</button>
    </div>
    <div class="stats" id="stats"></div>
</div>

<script>
    // ---------- 工具函数 ----------
    function escapeHtml(str) {
        if (!str) return '';
        return str.replace(/[&<>]/g, function(m) {
            if (m === '&') return '&amp;';
            if (m === '<') return '&lt;';
            if (m === '>') return '&gt;';
            return m;
        });
    }

    // ---------- 数据持久化 ----------
    function loadTodos() {
        const stored = localStorage.getItem('todos');
        return stored ? JSON.parse(stored) : [];
    }

    function saveTodos(todos) {
        localStorage.setItem('todos', JSON.stringify(todos));
    }

    // ---------- 全局状态 ----------
    let todos = loadTodos();
    let currentFilter = 'all';

    // ---------- 渲染(数据 → 视图) ----------
    function render() {
        // 过滤
        let filtered = todos;
        if (currentFilter === 'active') {
            filtered = todos.filter(t => !t.completed);
        } else if (currentFilter === 'completed') {
            filtered = todos.filter(t => t.completed);
        }

        // 生成 HTML
        const listHtml = filtered.map(todo => `
            <li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
                <input type="checkbox" ${todo.completed ? 'checked' : ''}>
                <span>${escapeHtml(todo.text)}</span>
                <button class="delete-btn">删除</button>
            </li>
        `).join('');

        document.getElementById('todoList').innerHTML = listHtml;

        // 统计
        const total = todos.length;
        const completedCount = todos.filter(t => t.completed).length;
        document.getElementById('stats').innerHTML = `共 ${total} 项,已完成 ${completedCount} 项`;
    }

    // ---------- 添加待办 ----------
    function addTodo() {
        const input = document.getElementById('todoInput');
        const text = input.value.trim();
        if (text === '') return;

        const newTodo = {
            id: Date.now(),
            text: text,
            completed: false
        };
        todos.push(newTodo);
        saveTodos(todos);
        input.value = '';
        render();
    }

    // ---------- 事件委托(核心) ----------
    function handleListClick(e) {
        const target = e.target;
        // 通过 closest 找到当前待办项所在的 <li>
        const li = target.closest('.todo-item');
        if (!li) return;
        const id = Number(li.dataset.id);

        // 删除按钮
        if (target.classList.contains('delete-btn')) {
            todos = todos.filter(t => t.id !== id);
            saveTodos(todos);
            render();
            return;
        }

        // 复选框(切换完成状态)
        if (target.type === 'checkbox') {
            const todo = todos.find(t => t.id === id);
            if (todo) {
                todo.completed = target.checked;
                saveTodos(todos);
                render();
            }
        }
    }

    // ---------- 筛选 ----------
    function setFilter(filter) {
        currentFilter = filter;
        document.querySelectorAll('.filter-btn').forEach(btn => {
            if (btn.dataset.filter === filter) {
                btn.classList.add('active');
            } else {
                btn.classList.remove('active');
            }
        });
        render();
    }

    // ---------- 初始化 ----------
    function init() {
        render();
        document.getElementById('addBtn').addEventListener('click', addTodo);
        document.getElementById('todoList').addEventListener('click', handleListClick);
        document.getElementById('todoInput').addEventListener('keypress', (e) => {
            if (e.key === 'Enter') addTodo();
        });
        document.querySelectorAll('.filter-btn').forEach(btn => {
            btn.addEventListener('click', () => setFilter(btn.dataset.filter));
        });
    }

    init();
</script>
</body>
</html>

第四步:你从中学到了什么?

通过这个项目,你不再只是孤立地知道数组方法、事件监听、本地存储,而是真正理解了它们如何协作构建一个真实应用。

  • 数据驱动视图:我们修改 todos,然后重新 render(),而不是手忙脚乱地操作 DOM。这是 React/Vue 等框架的思想源头。
  • 不可变数据:删除时用 filter 返回新数组(虽然添加用了 push,但删除和更新都是不可变或可追踪的)。
  • 🔥 事件委托:用最简单的方式处理动态元素,不再为绑定事件发愁。
  • 本地存储:前端数据持久化的基础。

下一步你可以扩展什么?

  • 编辑待办:双击文字变成可编辑的输入框(使用 contenteditable)。
  • 清空已完成:添加一个按钮,一次删除所有 completedtrue 的项。
  • 拖拽排序:使用 drag-and-drop API 重新排序待办。
  • 数据导出/导入:把 todos 导出为 .json 文件,也可以从文件导入。

每完成一个扩展,你都会对 JavaScript 更加自信。

如果你已经完成了猜数字和待办清单,下一个项目(记账本)将帮你熟练掌握 reduce、日期格式化、图表库的使用。

用猜数字游戏,一口气掌握 JavaScript 核心知识点(附完整代码)

用猜数字游戏,一口气掌握 JavaScript 核心知识点(附完整代码)

从一个简单小游戏开始,理解变量、函数、DOM 操作、事件监听、数组、本地状态管理……
这篇文章会带你亲手实现一个“猜数字”游戏,并拆解每一行代码背后的知识。


为什么第一个项目要做猜数字?

猜数字游戏麻雀虽小,五脏俱全。它包含了:

  • 变量声明与作用域const / let
  • 随机数生成Math.random
  • 函数拆分与职责分离
  • 输入处理与类型转换
  • 条件判断与早返回
  • 数组操作与状态更新
  • DOM 查询、修改、显隐、禁用
  • 事件监听(点击、键盘回车)
  • 模板字符串
  • 重置游戏与初始化

学完这个项目,你就能独立完成很多类似的交互小工具。


第一步:搭建界面(HTML + CSS)

我们先写好基础结构:一个数字输入框、两个按钮(“猜”和“新游戏”),以及用于显示提示、次数、历史记录的区域。样式使用简单的居中卡片,保证易读。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>猜数字游戏|学习 JavaScript</title>
    <style>
        * {
            box-sizing: border-box;
            user-select: none;
        }

        body {
            background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            font-family: system-ui, 'Segoe UI', 'Roboto', sans-serif;
            margin: 0;
            padding: 20px;
        }

        .game-container {
            background: rgba(255, 255, 255, 0.1);
            backdrop-filter: blur(8px);
            border-radius: 2rem;
            padding: 2rem;
            width: 100%;
            max-width: 500px;
            box-shadow: 0 25px 45px rgba(0, 0, 0, 0.3);
            border: 1px solid rgba(255, 255, 255, 0.2);
        }

        h1 {
            text-align: center;
            color: #facc15;
            margin-top: 0;
            font-size: 2rem;
        }

        .input-area {
            display: flex;
            gap: 12px;
            margin: 24px 0;
        }

        input {
            flex: 1;
            padding: 12px 16px;
            font-size: 1.2rem;
            border: none;
            border-radius: 60px;
            background: #1e293b;
            color: white;
            text-align: center;
            outline: none;
            transition: 0.2s;
        }

        input:focus {
            outline: 2px solid #facc15;
        }

        button {
            background: #facc15;
            border: none;
            padding: 0 24px;
            border-radius: 60px;
            font-weight: bold;
            font-size: 1rem;
            cursor: pointer;
            transition: 0.2s;
            color: #0f172a;
        }

        button:active {
            transform: scale(0.96);
        }

        .info-card {
            background: #0f172a80;
            border-radius: 1.5rem;
            padding: 1rem;
            margin: 20px 0;
            text-align: center;
        }

        .message {
            font-size: 1.2rem;
            font-weight: bold;
            color: #facc15;
            min-height: 3rem;
        }

        .stats {
            display: flex;
            justify-content: space-between;
            font-size: 0.9rem;
            color: #cbd5e1;
        }

        .history {
            background: #0f172a;
            border-radius: 1rem;
            padding: 8px 12px;
            font-family: monospace;
            word-break: break-all;
        }

        .new-game {
            width: 100%;
            margin-top: 12px;
            background: #3b82f6;
            color: white;
        }
    </style>
</head>
<body>
<div class="game-container">
    <h1>🔢 猜数字</h1>
    <p style="text-align:center; color:#94a3b8;">我已经想好了一个 1~100 之间的整数</p>

    <div class="input-area">
        <input type="number" id="guessInput" placeholder="输入你的猜测" min="1" max="100">
        <button id="guessBtn"></button>
    </div>

    <div class="info-card">
        <div class="message" id="message">✨ 点击「新游戏」开始</div>
        <div class="stats">
            <span>🎯 尝试次数:<span id="attemptCount">0</span></span>
            <span>📜 历史记录:<span id="historyList"></span></span>
        </div>
    </div>

    <button id="newGameBtn" class="new-game">🔄 新游戏</button>
</div>

<script>
    // ---------- 所有 JS 代码写在这里 ----------
    // 下文会完整展示
</script>
</body>
</html>

第二步:JavaScript 核心知识点逐项拆解

2.1 变量声明:constlet 的用法

在脚本开头,我们首先获取 DOM 元素。这些引用在游戏过程中不会改变,所以用 const 声明:

const guessInput = document.getElementById('guessInput');
const guessBtn = document.getElementById('guessBtn');
const newGameBtn = document.getElementById('newGameBtn');
const messageDiv = document.getElementById('message');
const attemptSpan = document.getElementById('attemptCount');
const historySpan = document.getElementById('historyList');

而游戏状态(目标数字、尝试次数、历史数组)会不断变化,因此使用 let

let targetNumber = 0;
let attempts = 0;
let guessHistory = [];

规则:DOM 引用用 const,业务状态用 let

2.2 随机数生成:Math.random() + Math.floor()

我们需要生成 1~100 的随机整数。公式是固定的:

function randomInt1To100() {
    return Math.floor(Math.random() * 100) + 1;
}
  • Math.random() → [0, 1)
  • * 100 → [0, 100)
  • Math.floor() → 向下取整,得到 0~99
  • + 1 → 1~100

2.3 函数拆分(单一职责)

我们把不同任务拆成独立函数:

  • randomInt1To100():只负责随机数生成。
  • resetGame():重置游戏状态、清空界面、启用控件。
  • validateGuess(rawValue):校验输入是否合法,返回错误文本或 null
  • handleGuess():完整的一次猜测流程。

这样主流程非常清晰,也便于测试和修改。

2.4 输入处理与类型转换

<input> 拿到的值是字符串,必须转成数字才能比较:

const rawValue = guessInput.value;
if (rawValue.trim() === '') {
    return '不能为空';
}
const value = Number(rawValue);
if (isNaN(value) || !Number.isInteger(value)) {
    return '请输入整数';
}
if (value < 1 || value > 100) {
    return '数字必须在 1~100 之间';
}

这里使用了 Number.isInteger() 确保不是小数。

2.5 条件判断与“早返回”

handleGuess 中,先检查游戏是否已结束(猜中后禁用按钮),再使用校验函数:

if (guessBtn.disabled) {
    messageDiv.textContent = '游戏已结束,请点击「新游戏」';
    return;
}
const error = validateGuess(guessInput.value);
if (error) {
    messageDiv.textContent = error;
    return;
}

这种“早返回”写法可以大幅减少嵌套 if,让代码更平直。

2.6 状态更新:计数与历史数组

每次有效猜测后:

attempts++;
guessHistory.push(guessNumber);
attemptSpan.textContent = attempts;
historySpan.textContent = guessHistory.join(', ');

join(', ') 把数组转成易读的字符串,没有显式使用循环,但内部已经遍历。

2.7 DOM 操作:修改内容、禁用/启用控件、显示/隐藏

  • 修改文本:.textContent
  • 禁用输入框 / 按钮:.disabled = true
  • 聚焦输入框:.focus()
  • 按钮显示/隐藏(本例使用了 .style.display 不过为了简洁可直接用禁用)

在猜中时,我们禁用猜测按钮和输入框;重置时再启用。

2.8 事件监听

绑定三个事件:

guessBtn.addEventListener('click', handleGuess);
newGameBtn.addEventListener('click', resetGame);
guessInput.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !guessBtn.disabled) {
        handleGuess();
    }
});

注意:回车触发前要检查游戏是否结束(!guessBtn.disabled),避免猜中后还能继续猜。

2.9 模板字符串(反引号)

动态拼接提示信息非常方便:

messageDiv.textContent = `🎉 恭喜!猜中了!共用 ${attempts} 次`;

2.10 初始化时机

页面加载后必须立即让游戏就绪,调用 resetGame() 生成随机数、清空界面。


第三步:完整代码(复制即用)

下面是整合后的完整 index.html(包含样式和所有 JS)。你可以保存为 .html 文件,用浏览器打开直接玩。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>猜数字游戏|JavaScript 核心练习</title>
    <style>
        * { box-sizing: border-box; user-select: none; }
        body {
            background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            font-family: system-ui, 'Segoe UI', sans-serif;
            margin: 0;
            padding: 20px;
        }
        .game-container {
            background: rgba(255, 255, 255, 0.1);
            backdrop-filter: blur(8px);
            border-radius: 2rem;
            padding: 2rem;
            width: 100%;
            max-width: 500px;
            box-shadow: 0 25px 45px rgba(0,0,0,0.3);
            border: 1px solid rgba(255,255,255,0.2);
        }
        h1 { text-align: center; color: #facc15; margin-top: 0; font-size: 2rem; }
        .input-area { display: flex; gap: 12px; margin: 24px 0; }
        input {
            flex: 1; padding: 12px 16px; font-size: 1.2rem;
            border: none; border-radius: 60px; background: #1e293b;
            color: white; text-align: center; outline: none;
        }
        input:focus { outline: 2px solid #facc15; }
        button {
            background: #facc15; border: none; padding: 0 24px;
            border-radius: 60px; font-weight: bold; font-size: 1rem;
            cursor: pointer; transition: 0.2s; color: #0f172a;
        }
        button:active { transform: scale(0.96); }
        button:disabled { opacity: 0.5; transform: none; cursor: not-allowed; }
        .info-card {
            background: #0f172a80; border-radius: 1.5rem; padding: 1rem;
            margin: 20px 0; text-align: center;
        }
        .message { font-size: 1.2rem; font-weight: bold; color: #facc15; min-height: 3rem; }
        .stats { display: flex; justify-content: space-between; font-size: 0.9rem; color: #cbd5e1; }
        .history {
            background: #0f172a; border-radius: 1rem; padding: 8px 12px;
            font-family: monospace; word-break: break-all;
        }
        .new-game { width: 100%; margin-top: 12px; background: #3b82f6; color: white; }
    </style>
</head>
<body>
<div class="game-container">
    <h1>🔢 猜数字</h1>
    <p style="text-align:center; color:#94a3b8;">我已经想好了一个 1~100 之间的整数</p>

    <div class="input-area">
        <input type="number" id="guessInput" placeholder="输入你的猜测" min="1" max="100">
        <button id="guessBtn"></button>
    </div>

    <div class="info-card">
        <div class="message" id="message">✨ 点击「新游戏」开始</div>
        <div class="stats">
            <span>🎯 尝试次数:<span id="attemptCount">0</span></span>
            <span>📜 历史记录:<span id="historyList"></span></span>
        </div>
    </div>

    <button id="newGameBtn" class="new-game">🔄 新游戏</button>
</div>

<script>
    // ---------- DOM 元素 ----------
    const guessInput = document.getElementById('guessInput');
    const guessBtn = document.getElementById('guessBtn');
    const newGameBtn = document.getElementById('newGameBtn');
    const messageDiv = document.getElementById('message');
    const attemptSpan = document.getElementById('attemptCount');
    const historySpan = document.getElementById('historyList');

    // ---------- 游戏状态 ----------
    let targetNumber = 0;
    let attempts = 0;
    let guessHistory = [];

    // ---------- 工具函数 ----------
    function randomInt1To100() {
        return Math.floor(Math.random() * 100) + 1;
    }

    // 校验输入,返回错误字符串或 null
    function validateGuess(rawValue) {
        const trimmed = rawValue.trim();
        if (trimmed === '') return '请输入数字';
        const num = Number(trimmed);
        if (isNaN(num)) return '必须是数字';
        if (!Number.isInteger(num)) return '请输入整数';
        if (num < 1 || num > 100) return '数字必须在 1~100 之间';
        return null; // 合法
    }

    // 更新界面:显示次数、历史记录
    function updateUI() {
        attemptSpan.textContent = attempts;
        if (guessHistory.length === 0) {
            historySpan.textContent = '—';
        } else {
            historySpan.textContent = guessHistory.join(', ');
        }
    }

    // 重置游戏(新游戏)
    function resetGame() {
        targetNumber = randomInt1To100();
        attempts = 0;
        guessHistory = [];
        updateUI();
        messageDiv.textContent = '✨ 新游戏开始!输入 1~100 的数字吧';
        guessInput.value = '';
        guessInput.disabled = false;
        guessBtn.disabled = false;
        guessInput.focus();
    }

    // 核心逻辑:处理一次猜测
    function handleGuess() {
        // 1. 游戏是否已结束(猜中后按钮被禁用)
        if (guessBtn.disabled) {
            messageDiv.textContent = '游戏已结束,请点击「新游戏」';
            return;
        }

        // 2. 校验输入
        const error = validateGuess(guessInput.value);
        if (error) {
            messageDiv.textContent = error;
            guessInput.value = '';
            guessInput.focus();
            return;
        }

        // 3. 转数字并记录
        const guessNumber = Number(guessInput.value.trim());
        attempts++;
        guessHistory.push(guessNumber);
        updateUI();

        // 4. 比较并反馈
        let feedback = '';
        if (guessNumber > targetNumber) {
            feedback = '📈 猜大了,再试试看!';
        } else if (guessNumber < targetNumber) {
            feedback = '📉 猜小了,再试试看!';
        } else {
            feedback = `🎉 恭喜!猜中了!共用 ${attempts} 次 🎉`;
            // 游戏胜利:禁用输入框和按钮
            guessInput.disabled = true;
            guessBtn.disabled = true;
            messageDiv.textContent = feedback;
            return;
        }

        messageDiv.textContent = feedback;
        guessInput.value = '';
        guessInput.focus();
    }

    // ---------- 事件绑定 ----------
    guessBtn.addEventListener('click', handleGuess);
    newGameBtn.addEventListener('click', resetGame);
    guessInput.addEventListener('keydown', (e) => {
        if (e.key === 'Enter' && !guessBtn.disabled) {
            handleGuess();
        }
    });

    // ---------- 页面初始化 ----------
    resetGame();
</script>
</body>
</html>

第四步:你能从这个项目学到什么?

完成这个项目后,你就不再是只懂语法的“纸上程序员”了。你已经能够:

  • 独立拆分函数,让代码可读、可维护。
  • 熟练使用 const / let 管理状态。
  • 自己写随机数、处理用户输入、校验边界。
  • 操作 DOM:修改文本、禁用控件、动态刷新数组数据。
  • 使用事件监听让页面拥有完整交互。

更重要的是,你学会了通过一个小项目把零散知识点串起来——这才是真正的“会用”。


下一步你可以做什么?

  • 增加难度选择(例如 1–50 / 1–200)
  • 加入“剩余机会”限制(最多 8 次,用完显示失败)
  • 把最佳成绩保存到 localStorage
  • 拆分 index.html 中的 JS 到独立的 main.js(模块化思维)

只要你完成了上面任何一个扩展,你的 JS 能力就会再上一个台阶。

❌