JavaScript 实战项目

学了 17 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可直接在浏览器运行的项目——Todo List 待办应用、天气查询、Canvas 粒子动画,每个都从需求出发,给出完整 HTML+JS 代码、效果说明和要点讲解。三个项目分别对应 JS 在浏览器端的三大主力场景:UI 交互、异步数据获取、图形渲染。

每个项目都是一个完整的 HTML 文件,把代码复制保存成 xxx.html,双击在浏览器打开就能直接用,不需要任何构建工具、依赖或服务器。建议你边读边敲、改一改需求,这才是真正"上手"的感觉。

实战 1:Todo List 待办应用(DOM + localStorage)

需求:做一个待办事项应用,支持添加、勾选完成、删除、按状态过滤(全部 / 未完成 / 已完成),刷新页面数据不丢。

涉及知识:querySelector / addEventListener / 事件委托 / localStorage 持久化 / 模板字符串渲染。这是新手必做的"Hello World 级"实战项目,把 DOM 操作和事件机制一次串起来。

<!-- todo.html — Todo List 待办应用:增删改查 + 完成/过滤 + localStorage 持久化 -->
<!-- 把整段保存为 todo.html,双击在浏览器打开即可 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Todo List</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; background: #f5f5f7; }
    h1 { text-align: center; color: #333; }
    .input-bar { display: flex; gap: 8px; margin-bottom: 16px; }
    #new-todo { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
    button { padding: 10px 14px; border: none; border-radius: 6px; background: #4f8cff; color: #fff; cursor: pointer; font-size: 14px; }
    button:hover { background: #3a78f0; }
    .filters { display: flex; gap: 8px; margin-bottom: 12px; justify-content: center; }
    .filters button { background: #fff; color: #4f8cff; border: 1px solid #4f8cff; }
    .filters button.active { background: #4f8cff; color: #fff; }
    ul { list-style: none; padding: 0; margin: 0; }
    li { display: flex; align-items: center; gap: 10px; padding: 10px; background: #fff; border-radius: 6px; margin-bottom: 8px; }
    li.done span { text-decoration: line-through; color: #aaa; }
    li span { flex: 1; font-size: 15px; }
    .del { background: #ff5c5c; padding: 6px 10px; font-size: 13px; }
    .del:hover { background: #e84545; }
    .empty { text-align: center; color: #aaa; padding: 20px; }
  </style>
</head>
<body>
  <h1>📝 Todo List</h1>

  <!-- 输入区:输入新待办,回车或点按钮添加 -->
  <div class="input-bar">
    <input id="new-todo" type="text" placeholder="今天要做什么?" autocomplete="off">
    <button id="add-btn">添加</button>
  </div>

  <!-- 过滤器:全部 / 未完成 / 已完成 -->
  <div class="filters">
    <button data-filter="all" class="active">全部</button>
    <button data-filter="active">未完成</button>
    <button data-filter="done">已完成</button>
  </div>

  <!-- 待办列表由 JS 动态渲染 -->
  <ul id="todo-list"></ul>

  <script>
    // 1. 状态:待办数组,首次从 localStorage 读取
    const STORAGE_KEY = 'todos-demo';
    let todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
    let filter = 'all'; // all / active / done

    // 2. 把状态写回 localStorage
    function save() {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
    }

    // 3. 渲染列表:根据 filter 过滤后生成 <li>
    function render() {
      const list = document.querySelector('#todo-list');
      const visible = todos.filter(t => {
        if (filter === 'active') return !t.done;
        if (filter === 'done') return t.done;
        return true;
      });

      if (visible.length === 0) {
        list.innerHTML = '<div class="empty">(空空如也,加一条试试)</div>';
        return;
      }

      list.innerHTML = visible.map(t => `
        <li class="${t.done ? 'done' : ''}" data-id="${t.id}">
          <input type="checkbox" ${t.done ? 'checked' : ''} data-act="toggle">
          <span>${escapeHtml(t.text)}</span>
          <button class="del" data-act="del">删除</button>
        </li>
      `).join('');
    }

    // 转义 HTML,防止用户输入 <script> 等
    function escapeHtml(s) {
      return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
    }

    // 4. 添加一条
    function addTodo() {
      const input = document.querySelector('#new-todo');
      const text = input.value.trim();
      if (!text) return;
      todos.push({ id: Date.now(), text: text, done: false });
      input.value = '';
      save();
      render();
    }

    // 5. 事件绑定:添加
    document.querySelector('#add-btn').addEventListener('click', addTodo);
    document.querySelector('#new-todo').addEventListener('keydown', e => {
      if (e.key === 'Enter') addTodo();
    });

    // 6. 事件委托:列表上的点击(切换完成 / 删除)
    document.querySelector('#todo-list').addEventListener('click', e => {
      const li = e.target.closest('li');
      if (!li) return;
      const id = Number(li.dataset.id);
      const todo = todos.find(t => t.id === id);
      if (!todo) return;

      if (e.target.dataset.act === 'toggle') {
        todo.done = e.target.checked;
      } else if (e.target.dataset.act === 'del') {
        todos = todos.filter(t => t.id !== id);
      }
      save();
      render();
    });

    // 7. 过滤器切换
    document.querySelector('.filters').addEventListener('click', e => {
      if (e.target.tagName !== 'BUTTON') return;
      filter = e.target.dataset.filter;
      document.querySelectorAll('.filters button').forEach(b => b.classList.remove('active'));
      e.target.classList.add('active');
      render();
    });

    // 8. 首次渲染
    render();
  </script>
</body>
</html>
<!-- 效果:
     - 输入"买牛奶"回车 → 列表新增一条未完成项
     - 勾选复选框 → 文字加删除线,切到"已完成"过滤才显示
     - 刷新页面 → 数据仍在(localStorage 持久化)
     - 点删除 → 立即移除
-->

要点讲解:

实战 2:天气查询(fetch + async/await)

需求:做一个天气查询应用,输入城市名,显示当前温度、天气、湿度、风速。用 Open-Meteo 免费 API(无需注册、无需 API key),开箱即用。

涉及知识:fetch / async / await / try / catch / finally 错误处理 / 模板字符串 / URL 查询参数。Open-Meteo 分两步:先用地理编码 API(城市名 → 经纬度),再用天气预报 API(经纬度 → 天气数据)。

<!-- weather.html — 天气查询:用 Open-Meteo 免费 API(无需 key) -->
<!-- 把整段保存为 weather.html,双击在浏览器打开即可 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>天气查询</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; background: #f0f4f8; }
    h1 { text-align: center; color: #333; }
    .search-bar { display: flex; gap: 8px; margin-bottom: 20px; }
    #city { flex: 1; padding: 10px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; }
    button { padding: 10px 16px; border: none; border-radius: 6px; background: #4f8cff; color: #fff; cursor: pointer; }
    button:hover { background: #3a78f0; }
    .card { background: #fff; padding: 20px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,.06); display: none; }
    .card .city { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
    .card .temp { font-size: 48px; font-weight: 300; color: #4f8cff; }
    .card .desc { color: #666; margin-top: 8px; }
    .card .meta { color: #999; font-size: 13px; margin-top: 12px; display: flex; gap: 16px; }
    .error { color: #ff5c5c; text-align: center; padding: 12px; display: none; }
    .loading { text-align: center; color: #999; padding: 12px; display: none; }
  </style>
</head>
<body>
  <h1>🌤️ 天气查询</h1>

  <!-- 搜索框:输入城市名 -->
  <div class="search-bar">
    <input id="city" type="text" placeholder="输入城市名,如 北京 / Shanghai / Tokyo" autocomplete="off">
    <button id="search-btn">查询</button>
  </div>

  <div id="loading" class="loading">加载中…</div>
  <div id="error" class="error"></div>

  <!-- 天气卡片:由 JS 渲染 -->
  <div id="card" class="card">
    <div class="city"></div>
    <div class="temp"></div>
    <div class="desc"></div>
    <div class="meta">
      <span>湿度 <b class="hum"></b>%</span>
      <span>风速 <b class="wind"></b> km/h</span>
    </div>
  </div>

  <script>
    const GEO_URL = 'https://geocoding-api.open-meteo.com/v1/search';
    const WEATHER_URL = 'https://api.open-meteo.com/v1/forecast';

    const $ = sel => document.querySelector(sel);
    const loading = $('#loading'), errorBox = $('#error'), card = $('#card');

    function showError(msg) {
      errorBox.textContent = msg;
      errorBox.style.display = 'block';
      card.style.display = 'none';
    }

    // 1. 城市名 → 经纬度(Open-Meteo 地理编码 API)
    async function geocode(city) {
      const url = `${GEO_URL}?name=${encodeURIComponent(city)}&count=1&language=zh`;
      const resp = await fetch(url);
      if (!resp.ok) throw new Error('地理编码请求失败');
      const data = await resp.json();
      if (!data.results || data.results.length === 0) {
        throw new Error('找不到城市: ' + city);
      }
      return data.results[0]; // { latitude, longitude, name, country }
    }

    // 2. 经纬度 → 当前天气
    async function getWeather(lat, lon) {
      const url = `${WEATHER_URL}?latitude=${lat}&longitude=${lon}&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code`;
      const resp = await fetch(url);
      if (!resp.ok) throw new Error('天气请求失败');
      const data = await resp.json();
      return data.current;
    }

    // 3. WMO weather_code → 中文描述(简化版)
    function describe(code) {
      if (code === 0) return '晴';
      if (code <= 3) return '多云';
      if (code <= 48) return '雾';
      if (code <= 67) return '雨';
      if (code <= 77) return '雪';
      if (code <= 82) return '阵雨';
      return '雷暴';
    }

    // 4. 主流程:async/await + try/catch 错误处理
    async function search() {
      const city = $('#city').value.trim();
      if (!city) return;

      loading.style.display = 'block';
      errorBox.style.display = 'none';
      card.style.display = 'none';

      try {
        const geo = await geocode(city);
        const w = await getWeather(geo.latitude, geo.longitude);

        // 渲染卡片
        card.querySelector('.city').textContent = `${geo.name}, ${geo.country}`;
        card.querySelector('.temp').textContent = `${Math.round(w.temperature_2m)}°C`;
        card.querySelector('.desc').textContent = describe(w.weather_code);
        card.querySelector('.hum').textContent = w.relative_humidity_2m;
        card.querySelector('.wind').textContent = w.wind_speed_10m;
        card.style.display = 'block';
      } catch (err) {
        showError(err.message);
      } finally {
        loading.style.display = 'none';
      }
    }

    // 5. 事件
    $('#search-btn').addEventListener('click', search);
    $('#city').addEventListener('keydown', e => {
      if (e.key === 'Enter') search();
    });
  </script>
</body>
</html>
<!-- 效果:
     - 输入"北京"回车 → 显示 北京, China,温度(实时)°C,天气描述,湿度,风速
     - 输入乱写的城市 → 显示红色错误"找不到城市: xxx"
     - 断网 → 显示"地理编码请求失败"(try/catch 捕获)
     - 加载中 → 显示"加载中…"(finally 隐藏)
-->

要点讲解:

实战 3:Canvas 粒子动画(requestAnimationFrame)

需求:在 Canvas 上画 120 个彩色粒子缓慢飘动,附近粒子之间画半透明连线;鼠标移动时粒子被推开,按住鼠标时粒子被吸引过来。

涉及知识:Canvas 2D API(arc / fill / stroke) / requestAnimationFrame / 类 / 物理模拟(力、摩擦、反弹)/ 高分屏适配。

<!-- particles.html — Canvas 2D 粒子系统:鼠标交互 -->
<!-- 把整段保存为 particles.html,双击在浏览器打开即可 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Canvas 粒子动画</title>
  <style>
    body { margin: 0; background: #0a0a1a; overflow: hidden; }
    canvas { display: block; }
    .hint { position: fixed; top: 16px; left: 16px; color: #888; font-family: sans-serif; font-size: 13px; pointer-events: none; }
  </style>
</head>
<body>
  <canvas id="c"></canvas>
  <div class="hint">移动鼠标推开粒子,点击吸引粒子</div>

  <script>
    const canvas = document.querySelector('#c');
    const ctx = canvas.getContext('2d');

    // 适配高分屏 + 全屏
    function resize() {
      canvas.width = innerWidth * devicePixelRatio;
      canvas.height = innerHeight * devicePixelRatio;
      canvas.style.width = innerWidth + 'px';
      canvas.style.height = innerHeight + 'px';
      ctx.scale(devicePixelRatio, devicePixelRatio);
    }
    resize();
    addEventListener('resize', resize);

    // 鼠标位置
    const mouse = { x: -9999, y: -9999, mode: 'repel' };
    canvas.addEventListener('mousemove', e => {
      mouse.x = e.clientX;
      mouse.y = e.clientY;
    });
    canvas.addEventListener('mouseleave', () => { mouse.x = mouse.y = -9999; });
    canvas.addEventListener('mousedown', () => { mouse.mode = 'attract'; });
    canvas.addEventListener('mouseup', () => { mouse.mode = 'repel'; });

    // 粒子类:位置、速度、半径、颜色
    const COLORS = ['#4f8cff', '#ff5ca8', '#5cffd1', '#ffd95c', '#b95cff'];
    const COUNT = 120;
    const particles = [];

    class Particle {
      constructor() {
        this.x = Math.random() * innerWidth;
        this.y = Math.random() * innerHeight;
        this.vx = (Math.random() - 0.5) * 0.6;
        this.vy = (Math.random() - 0.5) * 0.6;
        this.r = Math.random() * 2.5 + 1.5;
        this.color = COLORS[Math.floor(Math.random() * COLORS.length)];
      }

      update() {
        // 鼠标交互:在一定范围内推开 / 吸引
        const dx = this.x - mouse.x;
        const dy = this.y - mouse.y;
        const dist = Math.hypot(dx, dy);
        if (dist < 120 && dist > 0) {
          const force = (120 - dist) / 120;       // 越近力越大
          const dir = mouse.mode === 'repel' ? 1 : -1;
          this.vx += (dx / dist) * force * 0.5 * dir;
          this.vy += (dy / dist) * force * 0.5 * dir;
        }

        // 摩擦力,让粒子不会一直加速
        this.vx *= 0.96;
        this.vy *= 0.96;

        // 微小随机扰动,让粒子"活"起来
        this.vx += (Math.random() - 0.5) * 0.05;
        this.vy += (Math.random() - 0.5) * 0.05;

        this.x += this.vx;
        this.y += this.vy;

        // 边界反弹
        if (this.x < 0 || this.x > innerWidth) this.vx *= -1;
        if (this.y < 0 || this.y > innerHeight) this.vy *= -1;
        this.x = Math.max(0, Math.min(innerWidth, this.x));
        this.y = Math.max(0, Math.min(innerHeight, this.y));
      }

      draw() {
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
        ctx.fillStyle = this.color;
        ctx.fill();
      }
    }

    for (let i = 0; i < COUNT; i++) particles.push(new Particle());

    // 连线:附近粒子之间画半透明线
    function drawLinks() {
      for (let i = 0; i < particles.length; i++) {
        for (let j = i + 1; j < particles.length; j++) {
          const dx = particles[i].x - particles[j].x;
          const dy = particles[i].y - particles[j].y;
          const dist = Math.hypot(dx, dy);
          if (dist < 100) {
            ctx.strokeStyle = `rgba(255,255,255,${(1 - dist / 100) * 0.15})`;
            ctx.lineWidth = 1;
            ctx.beginPath();
            ctx.moveTo(particles[i].x, particles[i].y);
            ctx.lineTo(particles[j].x, particles[j].y);
            ctx.stroke();
          }
        }
      }
    }

    // 主循环:requestAnimationFrame 让浏览器自己调度帧率
    function loop() {
      // 半透明清屏,留下拖影效果
      ctx.fillStyle = 'rgba(10, 10, 26, 0.2)';
      ctx.fillRect(0, 0, innerWidth, innerHeight);

      drawLinks();
      for (const p of particles) {
        p.update();
        p.draw();
      }
      requestAnimationFrame(loop);
    }
    loop();
  </script>
</body>
</html>
<!-- 效果:
     - 打开页面 → 120 个彩色粒子缓慢飘动,附近粒子之间有半透明连线
     - 移动鼠标 → 粒子被推开,形成"空洞"
     - 按住鼠标 → 粒子被吸引过来,聚成一团
     - 松开鼠标 → 恢复推开模式
     - 粒子有拖影(半透明清屏实现)
-->

要点讲解:

小结

三个项目分别对应 JS 浏览器端三大场景:UI 交互(Todo)、异步数据(天气)、图形渲染(粒子)。它们都用到了前面学的基础——变量、函数、对象、数组、DOM、事件、Promise、async——只是组合起来解决真实问题。建议你至少改造其中一个:

每个改造方向都是练手好题目。做过一遍比读十遍更扎实——把代码跑起来、改坏它、再修好它,这才叫"学会"了。

恭喜!JavaScript 系列到这里就真正完结了。接下来可以挑一个方向深入——前端框架(React / Vue)、Node.js 后端、TypeScript、可视化(D3 / Three.js)、小游戏(Phaser)——边做项目边学,进步最快。

← 返回 JavaScript 教程目录

✈️💬