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 实战项目

返回 JavaScript 教程目录

✈️💬