JavaScript 练习题(18 题)
学了那么多语法,得动手写才算真的会。这一页给 18 道精心挑选的 JavaScript 练习题,按难度分三级——基础 8 题(字符串 / 数字 / 数组 / 对象 / 闭包 / Promise / 正则)、进阶 6 题(map/reduce / 解构 / 深拷贝 / 事件委托 / localStorage / 防抖)、实战 4 题(Todo / 事件委托组件 / SPA / fetch)。每题都含题目描述、提示、可折叠的参考答案。
使用建议:先别急着展开答案。读题后自己打开浏览器 F12 控制台(或 PlayCode)试着写,卡住了再看提示,实在写不出再对照参考答案。JS 灵活但坑也多——== 和 ===、var 和 let、this 指向——能跑通只是第一步,理解为什么才是关键。
基础(8 题):字符串 / 数字 / 数组 / 对象 / 闭包 / Promise / 正则
这一组覆盖 JS 最基础的语言元素和常用 API。注意 JS 是动态弱类型,let / const 替代 var、用 === 严格比较。
第 1 题:翻转字符串
题目:写一个函数 reverseString(s),把字符串翻转过来。比如 "hello" 翻转成 "olleh"。
提示:JS 没有 string.reverse(),但数组有——s.split("").reverse().join("") 三步走;也可用 for 循环从后往前拼。
参考答案
// 方法 1:split + reverse + join(最常用)
function reverseString(s) {
return s.split("").reverse().join("");
}
// 方法 2:for 循环从后往前拼(理解原理)
function reverseString2(s) {
let result = "";
for (let i = s.length - 1; i >= 0; i--) {
result += s[i];
}
return result;
}
console.log(reverseString("hello")); // olleh
console.log(reverseString2("JavaScript")); // tpircSavaJ
console.log(reverseString("你好")); // 好你第 2 题:判断素数
题目:写一个函数 isPrime(n),判断正整数 n 是否为素数。
提示:小于 2 都不是素数;只需检查到 sqrt(n)。JS 数字都是双精度浮点,i*i <= n 在 i 不大时安全。
参考答案
function isPrime(n) {
if (n < 2) return false;
// 只需检查到 sqrt(n),用 i*i <= n 避免浮点
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false;
}
return true;
}
console.log(isPrime(2)); // true
console.log(isPrime(7)); // true
console.log(isPrime(10)); // false
console.log(isPrime(1)); // false第 3 题:FizzBuzz
题目:打印 1 到 15。3 的倍数打印 Fizz,5 的倍数打印 Buzz,同时是 3 和 5 的倍数打印 FizzBuzz,其余打印数字。
提示:if / else if / else 的顺序很关键——必须先判断 15 的倍数,否则被前面的分支截胡。注意用 === 严格相等。
参考答案
for (let i = 1; i <= 15; i++) {
if (i % 15 === 0) { // 必须先判断 15(否则被 3 或 5 截胡)
console.log("FizzBuzz");
} else if (i % 3 === 0) {
console.log("Fizz");
} else if (i % 5 === 0) {
console.log("Buzz");
} else {
console.log(i);
}
}
// 输出:1 / 2 / Fizz / 4 / Buzz / Fizz / 7 / 8 / Fizz / Buzz
// / 11 / Fizz / 13 / 14 / FizzBuzz第 4 题:数组最大值
题目:写一个函数 findMax(arr),返回数组中的最大值;空数组返回 null。不使用 Math.max。
提示:打擂台算法——初始化 max = arr[0],遍历遇到更大的就更新。简便写法 Math.max(...arr) 用展开运算符把数组元素作为参数传入。
参考答案
// 方法 1:打擂台,不使用 Math.max
function findMax(arr) {
if (arr.length === 0) return null;
let max = arr[0];
for (const n of arr) {
if (n > max) max = n;
}
return max;
}
console.log(findMax([3, 1, 4, 1, 5, 9, 2, 6])); // 9
console.log(findMax([-5, -2, -9])); // -2
console.log(findMax([])); // null
// 方法 2:简便写法,展开后传给 Math.max
console.log(Math.max(...[3, 1, 4, 1, 5, 9])); // 9第 5 题:对象遍历
题目:给定一个对象 { name: "小明", age: 20, city: "北京" },用三种方式遍历并打印所有 key/value。
提示:for...in 遍历可枚举属性(含原型链);Object.entries 配合解构最推荐(只遍历自身属性);Object.keys/values 只要键或值。
参考答案
const user = { name: "小明", age: 20, city: "北京" };
// 方法 1:for...in(遍历可枚举属性,含原型链上的)
for (const key in user) {
console.log(key + ":", user[key]);
}
// 方法 2:Object.entries(推荐,只遍历自身属性)
for (const [key, value] of Object.entries(user)) {
console.log(key + " => " + value);
}
// 输出:
// name => 小明
// age => 20
// city => 北京
// 方法 3:只要 key 或只要 value
Object.keys(user).forEach(k => console.log(k));
Object.values(user).forEach(v => console.log(v));第 6 题:闭包计数器
题目:写一个函数 makeCounter(),每次调用返回的函数,计数器加 1 并返回当前值。要求外部无法直接访问计数变量。
提示:外层函数定义 let count = 0,内层函数捕获并修改它——这就是闭包(函数 + 它引用的外层变量)。JS 没有真正的私有属性,闭包是实现私有的经典手段。
参考答案
function makeCounter() {
let count = 0; // 这个变量被下面的函数"封存"
return function () {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// 外部无法直接访问 count,只能通过返回的函数操作——这就是闭包
// 闭包 = 函数 + 它引用的外层变量第 7 题:Promise 链
题目:用 Promise 链实现:1 秒后打印 1 → 把值加 1 传下去 → 再乘 2 → 最后打印结果。任意一步出错都要被 catch。
提示:new Promise(resolve => setTimeout(...)) 起步;每个 .then(n) 返回的值会传给下一个 then;.catch 接住链中任意一步的异常。返回 Promise 会自动等待(扁平化异步)。
参考答案
// 模拟异步链:1 秒后开始,每步传给下一步处理
new Promise(resolve => {
setTimeout(() => resolve(1), 1000);
})
.then(n => {
console.log("第 1 步:", n); // 1
return n + 1; // 传给下一个 then
})
.then(n => {
console.log("第 2 步:", n); // 2
return n * 2;
})
.then(n => {
console.log("第 3 步:", n); // 4
})
.catch(err => {
console.error("链中任意一步出错都会到这里:", err);
});
// 提示:then 返回的值会传给下一个 then;返回 Promise 会自动等待第 8 题:正则提取
题目:从字符串 "联系我:phone 138-0013-8000 或 email hello@example.com" 中提取手机号、邮箱和所有数字段。
提示:String.match(/pattern/) 不带 g 返回第一个匹配;带 g 返回全部匹配数组。\\d 匹配数字,[\\w.]+@... 匹配邮箱,.test() 返回布尔。
参考答案
const text = "联系我:phone 138-0013-8000 或 email hello@example.com";
// 提取手机号(match 不带 g 返回第一个匹配 + 捕获组)
const phone = text.match(/\d{3}-\d{4}-\d{4}/);
console.log(phone[0]); // 138-0013-8000
// 提取邮箱
const email = text.match(/[\w.]+@[\w.]+/);
console.log(email[0]); // hello@example.com
// 提取所有数字(带 g 标志返回全部匹配数组)
const nums = text.match(/\d+/g);
console.log(nums); // ["138", "0013", "8000"]
// 测试是否匹配(返回布尔)
console.log(/\d{4}/.test("abc1234")); // true进阶(6 题):map/reduce / 解构 / 深拷贝 / 事件委托 / localStorage / 防抖
这一组是现代 JS(ES6+)的核心武器——函数式数组操作、解构语法、引用类型拷贝、DOM 性能优化、本地存储、高频事件控制。掌握它们,你写的就是"现代 JS"而非"老 JS"。
第 9 题:数组 map / reduce
题目:给定商品列表 { name, price, count }:(a) 用 map 算每项小计;(b) 用 reduce 汇总总价;(c) 用 filter + reduce 只算 count >= 3 的总价。
提示:map 一对一映射、filter 筛选、reduce((acc, cur) => ..., init) 归约。reduce 第二个参数是初始值(这里给 0,否则空数组会报错)。
参考答案
const products = [
{ name: "苹果", price: 5, count: 2 },
{ name: "香蕉", price: 3, count: 5 },
{ name: "橙子", price: 4, count: 3 },
];
// map:把每个元素映射成新结构(算每项小计)
const subtotals = products.map(p => ({
name: p.name,
total: p.price * p.count,
}));
console.log(subtotals);
// [{ name: "苹果", total: 10 }, { name: "香蕉", total: 15 }, { name: "橙子", total: 12 }]
// reduce:把整个数组归约成一个值(汇总总价)
const grandTotal = products.reduce((sum, p) => sum + p.price * p.count, 0);
console.log("总价:", grandTotal); // 10 + 15 + 12 = 37
// filter + map + reduce 链式:只算 count >= 3 的商品总价
const hot = products
.filter(p => p.count >= 3)
.reduce((s, p) => s + p.price * p.count, 0);
console.log("热销总价:", hot); // 15 + 12 = 27第 10 题:解构赋值
题目:演示四种解构:(a) 数组解构交换两个变量;(b) 对象解构改名 + 默认值;(c) 函数参数解构;(d) 嵌套解构取值。
提示:[a, b] = [b, a] 交换;const { name: userName, age = 18 } = obj 改名 + 默认值;函数参数 function f({ name, lang = "zh" }) 直接解构传入对象。
参考答案
// 1. 数组解构:一行交换两个变量
let a = 1, b = 2;
[a, b] = [b, a];
console.log(a, b); // 2 1
// 2. 对象解构:改名 + 默认值
const user = { name: "小明", age: 20 };
const { name: userName, age = 18, city = "未知" } = user;
console.log(userName, age, city); // 小明 20 未知
// 3. 函数参数解构(常用配置项)
function greet({ name, lang = "zh" }) {
console.log(lang === "zh" ? "你好, " + name : "Hello, " + name);
}
greet({ name: "小明" }); // 你好, 小明
greet({ name: "Tom", lang: "en" }); // Hello, Tom
// 4. 嵌套解构
const { name } = { name: "小红", score: { math: 90 } };
console.log(name); // 小红第 11 题:深拷贝对象
题目:写一个 deepClone(obj),实现对象/数组的深拷贝(修改副本不影响原对象)。要求能处理循环引用。
提示:JSON.parse(JSON.stringify(obj)) 最简但丢函数/Date/循环引用;递归版本用 Map 记录已访问对象处理循环引用。生产环境推荐内置 structuredClone()。
参考答案
// 方法 1:JSON 序列化(最简单但有局限)
function deepClone1(obj) {
return JSON.parse(JSON.stringify(obj));
}
// 缺点:丢失函数、undefined、Symbol、Date 会变字符串、循环引用会报错
// 方法 2:递归深拷贝(处理对象/数组/循环引用)
function deepClone(obj, map = new Map()) {
if (obj === null || typeof obj !== "object") return obj;
if (map.has(obj)) return map.get(obj); // 处理循环引用
const clone = Array.isArray(obj) ? [] : {};
map.set(obj, clone);
for (const key of Reflect.ownKeys(obj)) {
clone[key] = deepClone(obj[key], map);
}
return clone;
}
const original = { name: "小明", scores: [90, 80], meta: { age: 20 } };
const copy = deepClone(original);
copy.scores.push(100);
copy.meta.age = 21;
console.log(original.scores); // [90, 80] 深拷贝互不影响
console.log(original.meta.age); // 20
console.log(copy.scores); // [90, 80, 100]
// 提示:生产环境推荐用 structuredClone(obj),浏览器和 Node 17+ 内置第 12 题:事件委托(基础版)
题目:一个 <ul> 里有若干 <li data-id="...">,点击任意 li 打印它的 data-id。不要给每个 li 单独绑监听器,且动态新增的 li 也能响应。
提示:在父元素 ul 上绑一个监听器(事件冒泡会把 li 的 click 冒到 ul);用 e.target.closest("li") 找到被点击的 li。这就是事件委托,大量子元素时性能优势明显。
参考答案
<!-- HTML:<ul id="list"><li data-id="1">项目 1</li><li data-id="2">项目 2</li></ul> -->
const list = document.getElementById("list");
// 不给每个 li 单独绑监听器,而是在父元素 ul 上绑一个
// 原理:事件冒泡,点 li 会冒到 ul
list.addEventListener("click", e => {
const li = e.target.closest("li");
if (!li) return; // 点的不是 li 就忽略
console.log("点击了 id =", li.dataset.id);
});
// 后来动态新增的 li 也能响应——这就是委托的最大优势
list.insertAdjacentHTML("beforeend", "<li data-id=\"3\">项目 3</li>");
// 点"项目 3"也会触发,因为它在 ul 里,冒泡到 ul第 13 题:localStorage CRUD
题目:封装四个函数 readAll / create / remove / update,用 localStorage 管理一个用户列表(数组里存对象)。
提示:localStorage 只能存字符串,对象必须 JSON.stringify 写入、JSON.parse 读出;读写都要先取出全部、改完再整体覆盖。getItem(key) || "[]" 防止首次为 null。
参考答案
const KEY = "users";
// 读(查)
function readAll() {
return JSON.parse(localStorage.getItem(KEY) || "[]");
}
// 写(整体覆盖)
function writeAll(arr) {
localStorage.setItem(KEY, JSON.stringify(arr));
}
// 增
function create(user) {
const arr = readAll();
arr.push(user);
writeAll(arr);
}
// 删(按下标)
function remove(index) {
const arr = readAll();
arr.splice(index, 1);
writeAll(arr);
}
// 改
function update(index, user) {
const arr = readAll();
arr[index] = user;
writeAll(arr);
}
// 演示
create({ name: "小明", age: 20 });
create({ name: "小红", age: 22 });
console.log(readAll()); // [{name:"小明",...}, {name:"小红",...}]
remove(0);
console.log(readAll()); // [{name:"小红",...}]
// 提示:localStorage 只能存字符串,对象必须先 JSON.stringify第 14 题:防抖(debounce)
题目:写一个 debounce(fn, delay) 函数,让被包裹的函数在连续触发停止 delay 毫秒后才执行最后一次。再写一个对比的 throttle(节流)。
提示:维护一个 timer,每次调用 clearTimeout 清掉之前的,重新计时——只有最后一次能撑过 delay 毫秒。节流则记录上次执行时间,固定间隔执行一次。
参考答案
// 防抖:连续触发期间不执行,停止后 delay 毫秒才执行最后一次
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer); // 每次进来都清掉之前的定时器
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 用法:输入框停止输入 300ms 后才搜索(避免每按一键就发请求)
const handleInput = debounce(value => {
console.log("搜索:", value);
}, 300);
window.addEventListener("input", e => handleInput(e.target.value));
// 对比节流(throttle):固定间隔执行一次,适合滚动/拖拽
function throttle(fn, gap) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= gap) {
last = now;
fn.apply(this, args);
}
};
}实战(4 题):综合应用
这一组每题都会综合用到多个知识点——DOM 操作、事件、localStorage、异步、错误处理——做出一个浏览器端可运行的小程序。建议每题都新建 HTML 文件完整跑一遍。
第 15 题:Todo List CRUD
题目:实现一个待办列表应用:输入框 + 添加按钮 + 列表;支持增加、删除;用 localStorage 持久化(刷新不丢)。删除按钮用事件委托处理。
提示:用数组 todos 存数据,每次改动后 save() + render();render 用模板字符串拼每项的 HTML(含 data-index);在 ul 上监听 click,用 closest("[data-action='del']") 定位删除按钮。
参考答案
<!-- HTML:
<input id="input" placeholder="输入待办...">
<button id="add">添加</button>
<ul id="list"></ul>
-->
const KEY = "todos";
const input = document.getElementById("input");
const list = document.getElementById("list");
let todos = JSON.parse(localStorage.getItem(KEY) || "[]");
function save() {
localStorage.setItem(KEY, JSON.stringify(todos));
}
function render() {
list.innerHTML = "";
todos.forEach((todo, i) => {
const li = document.createElement("li");
// 模板字符串拼装每项的 HTML
li.innerHTML = `<span>${todo}</span>
<button data-action="del" data-index="${i}">删除</button>`;
list.appendChild(li);
});
}
// 事件委托:list 上一个监听器处理所有删除按钮(动态新增也生效)
list.addEventListener("click", e => {
const btn = e.target.closest("[data-action='del']");
if (!btn) return;
todos.splice(+btn.dataset.index, 1);
save();
render();
});
document.getElementById("add").addEventListener("click", () => {
const v = input.value.trim();
if (!v) return;
todos.push(v);
input.value = "";
save();
render();
});
render(); // 首次渲染第 16 题:DOM 事件委托(选项卡组件)
题目:实现一个选项卡(Tab)切换组件:三个按钮 + 三个面板,点按钮切换对应面板显示。要求用一个事件监听器处理所有按钮。
提示:在按钮容器 .tabs 上监听 click(事件委托);e.target.closest(".tab") 定位被点的按钮;切换 .active 类高亮,按 data-tab 切换面板 hidden 属性。
参考答案
<!-- HTML:
<div class="tabs">
<button class="tab active" data-tab="home">首页</button>
<button class="tab" data-tab="about">关于</button>
<button class="tab" data-tab="contact">联系</button>
</div>
<div class="panels">
<div data-panel="home">首页内容</div>
<div data-panel="about" hidden>关于内容</div>
<div data-panel="contact" hidden>联系:hello@example.com</div>
</div>
-->
const tabBar = document.querySelector(".tabs");
const panels = document.querySelectorAll("[data-panel]");
// 在父容器 .tabs 上监听一次,而不是给每个按钮各绑一个
// 这样按钮再多也只需一个监听器,新增按钮自动生效
tabBar.addEventListener("click", e => {
const btn = e.target.closest(".tab");
if (!btn) return; // 点的不是按钮就忽略
// 1. 切换按钮高亮
tabBar.querySelectorAll(".tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
// 2. 按 data-tab 切换对应面板显示
const name = btn.dataset.tab;
panels.forEach(p => {
p.hidden = p.dataset.panel !== name;
});
});第 17 题:简单 SPA(哈希路由)
题目:用 hash 路由实现一个极简 SPA——三个链接(#/ / #/about / #/contact),点链接切换 #app 里的内容,页面不刷新。未知 hash 显示 404。
提示:维护一个 routes 路由表(hash → 渲染函数);监听 hashchange 事件重新渲染;location.hash 读当前路由;首次 load 也要手动触发一次。这就是 React/Vue Router 的简化原理。
参考答案
<!-- HTML:
<nav><a href="#/">首页</a> | <a href="#/about">关于</a> | <a href="#/contact">联系</a></nav>
<main id="app"></main>
-->
// 路由表:hash -> 渲染函数(返回 HTML 字符串)
const routes = {
"#/": () => "<h2>首页</h2><p>欢迎来到首页</p>",
"#/about": () => "<h2>关于</h2><p>这是一个极简 SPA 演示</p>",
"#/contact": () => "<h2>联系</h2><p>邮箱:hello@example.com</p>",
};
function router() {
const hash = location.hash || "#/"; // 默认首页
const view = routes[hash] || (() => "<h2>404</h2><p>" + hash + " 不存在</p>");
document.getElementById("app").innerHTML = view();
}
// 监听 hash 变化重新渲染;首次加载也要手动触发一次
window.addEventListener("hashchange", router);
window.addEventListener("load", router);
// 切换路由不发请求、不刷新页面,这就是 SPA 的核心原理第 18 题:fetch 错误处理
题目:封装一个 fetchJson(url) 函数,统一处理:(a) HTTP 4xx/5xx(检查 res.ok);(b) 网络错误 / CORS(是 TypeError);(c) 返回解析后的 JSON,失败返回 null。再加一个带超时控制的版本。
提示:fetch 只在网络层错误时 reject,HTTP 4xx/5xx 仍然 resolve——必须手动检查 res.ok;用 try/catch 包裹 await;AbortController 配合 setTimeout 实现超时取消。
参考答案
async function fetchJson(url, options = {}) {
try {
const res = await fetch(url, options);
// 注意:HTTP 4xx/5xx 不会让 fetch 抛错,要手动检查 res.ok
if (!res.ok) {
throw new Error("HTTP " + res.status + " " + res.statusText);
}
return await res.json();
} catch (err) {
// fetch 的网络层错误(断网、CORS 被拦)是 TypeError
if (err instanceof TypeError) {
console.error("网络错误(可能 CORS / 断网):", err.message);
} else {
console.error("请求失败:", err.message);
}
return null; // 统一失败返回 null,调用方好处理
}
}
// 用法(顶层 await 需在 ES Module 里;浏览器 script 标签要 type="module")
const data = await fetchJson("https://api.example.com/users");
if (data) {
console.log("拿到数据:", data);
} else {
console.log("请求失败,用兜底数据");
}
// 进阶:加超时控制(AbortController,5 秒没响应就取消)
async function fetchWithTimeout(url, ms = 5000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
return await fetch(url, { signal: ctrl.signal });
} finally {
clearTimeout(timer);
}
}练习建议
不要只看答案。把每道题在浏览器控制台或新建 HTML 文件亲手敲一遍,改一改输入试试边界情况(空数组、null、超大数字、异步并发)。JS 的"诡异行为"(0.1 + 0.2、[] == ![]、typeof null)多敲几次就见怪不怪了。
试着扩展:做完第 15 题 Todo,能不能加上"完成状态切换""双击编辑"?做完第 17 题 SPA,能不能加上"路由参数 #/user/123"?做完第 18 题 fetch,能不能加上"请求重试"和"请求取消"?每个扩展都是一次小型的真实开发。
更多练习资源:刷题去 LeetCode / Codewars;做小项目去 GitHub 找灵感;遇到不懂的先查 MDN 文档(有中文版,最权威)。
← 上一篇 JavaScript 实战项目