用 React 自定义 Hooks 写一个可持久化的 TodoList,从 0 到 1 全流程总结

140 阅读3分钟

前言

Todo List 这个东西虽然简单,但里面能覆盖 React 的大部分核心点:

  • 父子组件通信(props)
  • 单向数据流
  • 自定义 Hooks
  • 响应式状态管理
  • 本地持久化(localStorage)
  • 样式拆分、响应式设计

这篇我就直接把我的完整实现分享出来,顺便整理一些小经验,语言尽量通俗,不玩虚的。


🗂️ 项目需求

  • 新增任务
  • 切换任务的完成状态
  • 删除任务
  • 自动保存到浏览器的 localStorage,页面刷新任务也不会丢
  • 样式尽量简洁,支持基础响应式


核心功能代码

下面这套就是你之前的实现,包含完整注释。

hooks/useTodos.js - 自定义 Hook

import { useState, useEffect } from "react";

export const useTodos = () => {
  const [todos, setTodos] = useState(
    JSON.parse(localStorage.getItem("todos")) || []
  );

  useEffect(() => {
    // 数据变化就更新 localStorage
    localStorage.setItem("todos", JSON.stringify(todos));
  }, [todos]);

  // 新增
  const addTodo = (text) => {
    setTodos([
      ...todos,
      {
        id: Date.now(),
        text,
        isComplete: false
      }
    ]);
  };

  // 切换完成状态
  const onToggle = (id) => {
    setTodos(
      todos.map(todo =>
        todo.id === id ? { ...todo, isComplete: !todo.isComplete } : todo
      )
    );
  };

  // 删除
  const onDelete = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return {
    todos,
    setTodos,
    addTodo,
    onToggle,
    onDelete,
  };
};

components/Todos.jsx - 父组件

import TodoForm from './TodoForm'
import TodoList from './TodoList'
import { useTodos } from '@/hooks/useTodos';

const Todos = () => {
  const { todos, addTodo, onToggle, onDelete } = useTodos();

  console.log(todos, '-----------');

  return (
    <div className="app">
      {/* 自定义事件 */}
      <TodoForm onAddTodo={addTodo} />
      <TodoList todos={todos} onToggle={onToggle} onDelete={onDelete} />
    </div>
  );
};

export default Todos;

components/TodoForm.jsx - 新增任务

import { useState } from 'react';

const TodoForm = ({ onAddTodo }) => {
  const [text, setText] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    let result = text.trim();
    if (!result) return;
    onAddTodo(result);
    setText('');
  };

  return (
    <>
      <h1 className='header'>TodoList</h1>
      <form className='todo-input' onSubmit={handleSubmit}>
        <input 
          type="text"
          value={text}
          onChange={(e) => {
            setText(e.target.value);
          }}
          placeholder='Todo Text'
          required
        />
        <button type='submit'>Add</button>
      </form>
    </>
  );
};

export default TodoForm;

components/TodoList.jsx - 列表

import TodoItem from "./TodoItem";

const TodoList = (props) => {
  const { todos, onToggle, onDelete } = props;

  return (
    <ul className="todo-list">
      {todos.length > 0 ? (
        todos.map(todo =>
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={() => onToggle(todo.id)}
            onDelete={() => onDelete(todo.id)}
          />
        )
      ) : (
        <p>暂无待办事项</p>
      )}
    </ul>
  );
};

export default TodoList;

components/TodoItem.jsx - 单个任务项

const TodoItem = (props) => {
  const {
    text,
    isComplete,
  } = props.todo;
  const { onToggle, onDelete } = props;

  return (
    <div className="todo-item">
      <input type="checkbox" checked={isComplete} onChange={onToggle} />
      <span className={isComplete ? 'completed' : ''}>{text}</span>
      <button onClick={onDelete}>Delete</button>
    </div>
  );
};

export default TodoItem;

🎨 简易示例样式

配一套最基础的 CSS,直接放到 index.cssApp.css

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #f7f9fc;
  color: #333;
}

.app {
  max-width: 600px;
  margin: 2rem auto;
  padding: 2rem;
  background: #fff;
  border-radius: 8px;
}

.header {
  text-align: center;
  margin-bottom: 1rem;
}

.todo-input {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

.todo-input input {
  flex: 1;
  padding: 0.5rem 1rem;
}

.todo-input button {
  padding: 0 1rem;
  background: #007bff;
  border: none;
  color: #fff;
  cursor: pointer;
}

.todo-list {
  list-style: none;
  padding: 0;
}

.todo-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: 1px solid #eee;
  padding: 0.5rem 0;
}

.todo-item span.completed {
  text-decoration: line-through;
  color: #aaa;
}

⚡ 关键点小结

Stylus 和 Vite:Vite 脚手架自带对 Stylus 的支持,想要样式写得更舒服可以用 Stylus 做 CSS 超集,样式拆分清晰。

字体:用 -apple-systemSegoe UIRoboto 这类组合,前端负责用户体验,字体也很关键。

rem 单位:移动端尽量别死用 px,相对单位 rem / em / vw / vh 对适配友好,后期配上媒体查询一套走天下。

props 通信:组件通信核心就是 props,父组件通过 props 把状态和自定义事件传给子组件,子组件通知父组件更新,始终保持单向数据流。

localStorage 和 Cookie 的区别

  • localStorage 只在前端可读写,域名隔离,存储大(一般 5MB),不随请求发给后端。
  • Cookie 前后端都能设置,每次请求都会自动带上,但容量小(4KB 左右),大了影响性能。

路径优化:如果觉得 ../../ 很烦,可以在 vite.config.js 配置 alias,写 @/hooks@/components,省事。

 import path from 'path' //vite 工程化工具 node 
 alias: {
   '@': path.resolve(__dirname, './src'),
 },

useContext:如果组件嵌套深或者要全局共享状态,可以配 useContext 或 Zustand 做状态管理。

自定义 Hooks:现代 React 项目里,逻辑能提就提,组件只关注视图渲染,状态管理和副作用交给 Hooks,维护成本低。


🎯 总结

  • Todo List 虽小,麻雀虽小五脏俱全,能把 React 的状态流、通信、响应式、持久化都串起来跑一遍。
  • 自定义 Hooks 是函数式编程思想的体现,组件更聚焦渲染,逻辑更清晰。
  • 样式也别糊弄,系统字体、相对单位、样式拆分,前期做好,后期扩展省心。

如果这篇对你有帮助,欢迎收藏、点赞,有问题评论区咱们一起交流!🚀