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,'&').replace(/</g,'<').replace(/>/g,'>');
}
// 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 持久化)
- 点删除 → 立即移除
-->要点讲解:
- 状态驱动视图:用一个数组
todos当唯一数据源,所有操作先改数组,再save()+render(),视图永远是状态的投影——这是现代前端框架的核心思想,先用原生 JS 体会一遍。 localStorage:浏览器内置的键值存储,只能存字符串。JSON.stringify / parse配合存对象数组;容量约 5MB,关闭浏览器数据仍在。- 事件委托:列表里的复选框和删除按钮是动态生成的,不一个个绑事件,而是在父元素
#todo-list上绑一个监听器,用e.target.dataset.act判断点了啥——性能更好,新增项也自动生效。 escapeHtml:用户输入的内容直接拼进innerHTML会有 XSS 风险(比如输入<script>),先转义&<>三个字符。- 过滤逻辑:状态不变,只改变"显示哪些"的
filter变量,render时按它过滤——这是"数据 + 视图分层"的简单实践。 Date.now()当 id:时间戳几乎不重复,简单粗暴的 id 生成法(高并发场景才需要 UUID)。
实战 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}¤t=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 隐藏)
-->要点讲解:
fetch:现代浏览器发 HTTP 请求的标准 API,返回 Promise。fetch只在网络错误时 reject,4xx / 5xx 状态码不会自动抛错,所以要手动if (!resp.ok) throw。async / await:让异步代码看起来像同步,告别.then().then()链。await只能在async函数里用,它会暂停函数直到 Promise 完成。try / catch / finally:异步错误处理的标配。try包住可能失败的请求,catch显示错误信息,finally隐藏 loading——无论成功失败都要执行。encodeURIComponent:URL 不能直接含中文 / 空格 / 特殊字符,城市名拼进 URL 前必须编码(如"北京"变成%E5%8C%97%E4%BA%AC),否则会请求失败。- 模板字符串拼 URL:用反引号 +
\${变量}把经纬度参数嵌进 URL,比字符串拼接清晰得多。 - WMO weather_code:Open-Meteo 用 WMO 标准天气代码(0=晴,1-3=多云,45-48=雾,51-67=雨…),项目里做了简化映射,完整对照表见 官方文档。
- UI 状态管理:
loading/error/card三个元素的显示互斥,用一个display: none / block切换——比框架的状态管理原始,但能让你体会"为什么需要 React"。
实战 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 个彩色粒子缓慢飘动,附近粒子之间有半透明连线
- 移动鼠标 → 粒子被推开,形成"空洞"
- 按住鼠标 → 粒子被吸引过来,聚成一团
- 松开鼠标 → 恢复推开模式
- 粒子有拖影(半透明清屏实现)
-->要点讲解:
requestAnimationFrame:浏览器原生的动画调度 API,在每次重绘前调用回调,帧率自动匹配显示器(60Hz / 120Hz)。比setInterval流畅得多,且页面切到后台时自动暂停省电。- Canvas 2D 基本流程:每次画图都是
beginPath()→ 画路径(arc画圆) →fill()/stroke()。fillStyle/strokeStyle设颜色。 - 半透明清屏 = 拖影:不清屏用
clearRect,而是用fillRect盖一层半透明黑色(rgba(10,10,26,0.2)),旧帧慢慢淡出,粒子留下漂亮的拖尾——这是 Canvas 动画经典技巧。 - 粒子类:用
class Particle封装位置、速度、半径、颜色,以及update/draw方法。面向对象思维,每个粒子自己管自己的状态。 - 鼠标交互的力:计算粒子到鼠标的距离
dist,越近力越大(force = (120 - dist) / 120),力的方向沿dx / dist单位向量。mode控制 repel(推开) / attract(吸引),只差一个正负号。 - 摩擦力
*= 0.96:每帧速度衰减 4%,粒子不会一直加速下去;不加这个,鼠标一推粒子会飞出屏幕。 - 边界反弹:碰到画布边缘速度取反,并把位置 clamp 到边界内,避免粒子卡在墙外。
- 连线效果:双重循环两两计算距离,小于阈值就画一条半透明线,透明度随距离衰减——粒子网络背景就是这么做的(复杂度 O(n²),粒子数别超过几百个)。
devicePixelRatio适配:高分屏(Retina)物理像素是 CSS 像素的 2-3 倍,Canvas 不缩放会糊;把 canvas 实际尺寸 ×dpr 再ctx.scale(dpr, dpr),绘制清晰锐利。
小结
三个项目分别对应 JS 浏览器端三大场景:UI 交互(Todo)、异步数据(天气)、图形渲染(粒子)。它们都用到了前面学的基础——变量、函数、对象、数组、DOM、事件、Promise、async——只是组合起来解决真实问题。建议你至少改造其中一个:
- Todo:加"编辑"、加"优先级"、加"截止日期"、改成多个列表(工作 / 生活)。
- 天气:加 7 天预报、加图标映射、加城市收藏(localStorage)、加单位切换(°C / °F)。
- 粒子:改成"粒子跟随鼠标轨迹"、加引力点、改成 3D(Three.js)、把粒子替换成小图片。
每个改造方向都是练手好题目。做过一遍比读十遍更扎实——把代码跑起来、改坏它、再修好它,这才叫"学会"了。
恭喜!JavaScript 系列到这里就真正完结了。接下来可以挑一个方向深入——前端框架(React / Vue)、Node.js 后端、TypeScript、可视化(D3 / Three.js)、小游戏(Phaser)——边做项目边学,进步最快。
← 返回 JavaScript 教程目录