CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
前端入门实战/课时/综合练习
RawBlame
课时目录12 节

前端入门实战

  1. 01HTML
  2. 02CSS
  3. 03JavaScript
  4. 04Git
  5. 05GitHub
  6. 06npm
  7. 07React
  8. 08Tailwind
  9. 09Vitest
  10. 10综合练习
  11. 11部署预览
  12. 12路线图对照

综合练习

Todo 前端小项目。

40 分钟

综合练习:待办清单应用

这一课你将能做到

  1. 用 React + Tailwind CSS 从零搭建一个功能完整的待办清单应用
  2. 实现添加、删除、完成切换、本地存储持久化等核心交互
  3. 用 Vitest 编写测试,确保核心逻辑可靠

为什么要学

前面学的都是「知识点」,这一课把它们串成「项目」。真实工作不是做单选题,而是面对一个模糊需求,自己拆功能、选技术、写代码、调样式、修 bug。这个项目虽然小,但覆盖了 React 开发的核心闭环:组件拆分 → 状态管理 → 事件处理 → 副作用 → 测试。

做完这个项目,你就有了一个能写进简历的作品,更重要的是,建立了「我能独立做出东西」的信心。

先建立图像

┌─────────────────────────────┐
│      📝 待办清单             │
│  ┌───────────────────────┐  │
│  │ 输入任务...    [添加] │  │ ← 输入区
│  └───────────────────────┘  │
│  ☑ 学习 React        [删除] │  │
│  ☐ 写测试代码        [删除] │ ← 列表区
│  ☐ 部署到 Vercel     [删除] │  │
│                             │
│  共 3 项,已完成 1 项        │ ← 统计区
└─────────────────────────────┘

组件拆分:

App
├── TodoInput(输入框 + 按钮)
├── TodoList
│   └── TodoItem × N(单条任务)
└── TodoStats(统计信息)

跟着做

步骤 1:创建项目

npm create vite@latest todo-app -- --template react
cd todo-app
npm install

# 安装 Tailwind v4
npm install tailwindcss @tailwindcss/vite

# 安装测试工具
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vitest/coverage-v8

配置 vite.config.js:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
    plugins: [react(), tailwindcss()],
    test: {
        environment: "jsdom",
        globals: true,
        setupFiles: "./src/test/setup.js"
    }
});

src/index.css:

@import "tailwindcss";

src/test/setup.js:

import "@testing-library/jest-dom";

步骤 2:数据结构与状态设计

先想清楚数据长什么样:

// 单条待办
{
    id: 1,
    text: "学习 React",
    completed: false,
    createdAt: "2026-10-06T10:00:00.000Z"
}

App 组件的状态:

  • todos:所有待办数组
  • inputValue:输入框当前值

步骤 3:实现 TodoInput 组件

src/components/TodoInput.jsx:

import { useState } from "react";

export default function TodoInput({ onAdd }) {
    const [value, setValue] = useState("");

    const handleSubmit = (e) => {
        e.preventDefault();
        const text = value.trim();
        if (!text) return; // 空内容不添加
        onAdd(text);
        setValue(""); // 清空输入框
    };

    return (
        <form onSubmit={handleSubmit} className="flex gap-2 mb-6">
            <input
                type="text"
                value={value}
                onChange={(e) => setValue(e.target.value)}
                placeholder="输入新任务..."
                className="flex-1 px-4 py-2 border border-gray-300 rounded-lg
                           focus:outline-none focus:ring-2 focus:ring-blue-500
                           dark:bg-gray-800 dark:border-gray-600 dark:text-white"
            />
            <button
                type="submit"
                className="px-6 py-2 bg-blue-500 text-white rounded-lg
                           hover:bg-blue-600 transition-colors font-medium"
            >
                添加
            </button>
        </form>
    );
}

步骤 4:实现 TodoItem 组件

src/components/TodoItem.jsx:

export default function TodoItem({ todo, onToggle, onDelete }) {
    return (
        <div className="flex items-center gap-3 p-4 bg-white dark:bg-gray-800
                        rounded-lg shadow-sm border border-gray-200 dark:border-gray-700
                        hover:shadow-md transition-shadow">
            {/* 完成切换 */}
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
                className="w-5 h-5 text-blue-500 rounded focus:ring-blue-500 cursor-pointer"
            />

            {/* 任务文字,完成时加删除线 */}
            <span
                className={`flex-1 ${
                    todo.completed
                        ? "line-through text-gray-400 dark:text-gray-500"
                        : "text-gray-800 dark:text-gray-200"
                }`}
            >
                {todo.text}
            </span>

            {/* 删除按钮 */}
            <button
                onClick={() => onDelete(todo.id)}
                className="px-3 py-1 text-sm text-red-500 hover:bg-red-50
                           dark:hover:bg-red-900/20 rounded transition-colors"
            >
                删除
            </button>
        </div>
    );
}

步骤 5:实现 TodoStats 组件

src/components/TodoStats.jsx:

export default function TodoStats({ todos }) {
    const total = todos.length;
    const completed = todos.filter((t) => t.completed).length;

    if (total === 0) return null;

    return (
        <p className="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
            共 {total} 项,已完成 {completed} 项,未完成 {total - completed} 项
        </p>
    );
}

步骤 6:组装 App 组件

src/App.jsx:

import { useState, useEffect } from "react";
import TodoInput from "./components/TodoInput";
import TodoItem from "./components/TodoItem";
import TodoStats from "./components/TodoStats";

const STORAGE_KEY = "todo-app-todos";

function App() {
    // 从 localStorage 读取初始数据
    const [todos, setTodos] = useState(() => {
        const saved = localStorage.getItem(STORAGE_KEY);
        return saved ? JSON.parse(saved) : [];
    });

    // 数据变化时自动保存
    useEffect(() => {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
    }, [todos]);

    const addTodo = (text) => {
        const newTodo = {
            id: Date.now(),
            text,
            completed: false,
            createdAt: new Date().toISOString()
        };
        setTodos([...todos, newTodo]);
    };

    const toggleTodo = (id) => {
        setTodos(
            todos.map((todo) =>
                todo.id === id ? { ...todo, completed: !todo.completed } : todo
            )
        );
    };

    const deleteTodo = (id) => {
        setTodos(todos.filter((todo) => todo.id !== id));
    };

    return (
        <div className="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
            <div className="max-w-md mx-auto">
                <h1 className="text-3xl font-bold text-center mb-8 text-gray-800 dark:text-white">
                    📝 待办清单
                </h1>

                <TodoInput onAdd={addTodo} />

                <div className="space-y-3">
                    {todos.map((todo) => (
                        <TodoItem
                            key={todo.id}
                            todo={todo}
                            onToggle={toggleTodo}
                            onDelete={deleteTodo}
                        />
                    ))}
                </div>

                {todos.length === 0 && (
                    <p className="text-center text-gray-400 dark:text-gray-500 mt-8">
                        暂无任务,添加一个吧
                    </p>
                )}

                <TodoStats todos={todos} />
            </div>
        </div>
    );
}

export default App;

步骤 7:编写测试

src/App.test.jsx:

import { render, screen, fireEvent } from "@testing-library/react";
import App from "./App";

// 每个测试前清空 localStorage
beforeEach(() => {
    localStorage.clear();
});

describe("待办清单应用", () => {
    it("应该显示标题和输入框", () => {
        render(<App />);
        expect(screen.getByText("📝 待办清单")).toBeInTheDocument();
        expect(screen.getByPlaceholderText("输入新任务...")).toBeInTheDocument();
    });

    it("应该能添加新任务", () => {
        render(<App />);
        const input = screen.getByPlaceholderText("输入新任务...");
        const button = screen.getByText("添加");

        fireEvent.change(input, { target: { value: "学习 React" } });
        fireEvent.click(button);

        expect(screen.getByText("学习 React")).toBeInTheDocument();
        expect(input.value).toBe(""); // 输入框清空
    });

    it("应该能切换任务完成状态", () => {
        render(<App />);
        const input = screen.getByPlaceholderText("输入新任务...");

        fireEvent.change(input, { target: { value: "写测试" } });
        fireEvent.click(screen.getByText("添加"));

        const checkbox = screen.getByRole("checkbox");
        fireEvent.click(checkbox);

        expect(screen.getByText("写测试")).toHaveClass("line-through");
    });

    it("应该能删除任务", () => {
        render(<App />);
        const input = screen.getByPlaceholderText("输入新任务...");

        fireEvent.change(input, { target: { value: "要删除的任务" } });
        fireEvent.click(screen.getByText("添加"));

        fireEvent.click(screen.getByText("删除"));

        expect(screen.queryByText("要删除的任务")).not.toBeInTheDocument();
    });

    it("空输入不应该添加任务", () => {
        render(<App />);
        fireEvent.click(screen.getByText("添加"));
        expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
    });
});

运行测试:

npm run test:run

讲清楚要点

  1. 状态提升:todos 放在 App 组件(共同父组件),通过 props 把数据和回调传给子组件。这是 React 的标准模式。
  2. 不可变更新:添加用 [...todos, new],删除用 filter,修改用 map——始终返回新数组,不原地修改。
  3. localStorage 持久化:useState 的懒初始化读取初始值,useEffect 监听变化自动保存。刷新页面数据不丢。
  4. key 用唯一 ID:用 Date.now() 生成 id 做 key,不用数组索引——删除中间项时索引会变,导致状态错乱。
  5. 受控表单:输入框的 value 绑定 state,onChange 更新 state,提交后清空——这就是受控组件模式。

你可能会懵的地方

  • 为什么用 Date.now() 做 id? 简单场景够用。生产环境用 crypto.randomUUID() 或 nanoid,避免同一毫秒添加两条任务时 id 冲突。

  • localStorage 能存多少数据? 一般 5-10MB,只存字符串。存对象要 JSON.stringify,读取要 JSON.parse。敏感数据不要存这里(不加密)。

  • useState 的函数参数是什么? useState(() => ...) 是懒初始化:函数只在首次渲染时执行一次。直接从 localStorage 读数据时用这个,避免每次渲染都解析 JSON。

  • 为什么测试里要 localStorage.clear()? 测试之间要相互独立。前一个测试写入的数据会影响后一个测试,beforeEach 清空保证每个测试从干净状态开始。

  • 怎么加编辑功能? 给 TodoItem 加「编辑」按钮,点击进入编辑模式(input 替换 span),保存时调用 onUpdate(id, newText)。核心还是 map 更新数组。

错法对照

错法问题正解
todos.push(newTodo)原地修改,React 不重新渲染setTodos([...todos, newTodo])
用数组索引做 key删除中间项后状态错乱用唯一 id 做 key
输入框不写 value非受控,无法清空value + onChange 受控组件
useEffect 直接写 async不支持 async 回调内部定义 async 函数再调用
localStorage 存对象不序列化存成 "[object Object]"JSON.stringify / JSON.parse
测试间共享 localStorage测试互相污染beforeEach 清空
空字符串也添加任务产生空待办trim 后判空

卡点急救

  1. 测试报「localStorage is not defined」:jsdom 环境支持 localStorage,确认 vitest.config.js 里 environment: "jsdom"。
  2. 添加后页面没更新:检查是否直接修改了数组(push),必须用 setTodos 返回新数组。
  3. 刷新后数据丢失:检查 useEffect 依赖数组是否包含 todos;检查 localStorage 的 key 是否一致。
  4. dark 模式没生效:确认 html 标签有 dark 类,或系统偏好是深色。

面试常问

  • React 中如何管理列表数据? 状态放数组,操作用不可变方法(map/filter/展开运算符),key 用唯一 ID,状态提升到最近共同父组件。

  • localStorage 的优缺点? 优点:简单、同步 API、持久化。缺点:只存字符串、容量小、同步阻塞、无过期机制。大量数据用 IndexedDB,敏感数据不要存前端。

  • 怎么设计组件拆分? 单一职责:一个组件只做一件事。可复用、可测试、易理解。拆分过细会增加 props 传递成本,拆分太粗会导致组件臃肿——这个项目拆成 4 个组件是平衡点。

  • 如何优化大列表性能? 虚拟滚动(react-window)、React.memo 缓存列表项、useCallback 缓存回调函数。几百条以内不用优化。

自测三问

  1. 为什么 todos.map(t => t.id === id ? {...t, completed: !t.completed} : t) 能正确更新状态,而直接改 t.completed 不行?
  2. 这个项目中数据流向是怎样的?(谁持有 state,谁接收 props,谁触发回调)
  3. 如果要加「筛选:全部/未完成/已完成」功能,你会怎么设计?

小结一句话

这个项目把碎片知识串成了完整闭环:组件拆分定结构、状态驱动管数据、localStorage 做持久、测试保质量——这就是前端开发的标准工作流。

延伸

  • 挑战升级:给项目加编辑功能、拖拽排序、分类标签、截止日期
  • 下一课:部署预览——把项目发布到 Vercel,让全世界访问。
加载本节练习…
← 上一课Vitest下一课 →部署预览