Skip to content
应用蓝图:组件树与数据流
待办应用需要满足的功能:读取任务清单、添加任务、标记完成、删除任务、按状态筛选(全部/进行中/已完成)[1]。将这些能力映射到组件上,可以得到一棵简单的组件树,这也是后续代码组织的骨架。
App
├── 标题
├── 添加表单(输入框 + 按钮)
├── 筛选按钮组(全部 / 进行中 / 已完成)
├── 计数提示
└── 任务列表
└── 任务项(文本 + 完成切换 + 删除)数据从 App 开始向下分发:
tasks(任务数组)存放所有待办项,每一项包含id、name、completed。filter(筛选条件)控制当前展示哪些任务,取值'all'、'active'、'completed'。- 添加任务时通过表单的回调把新任务追加到
tasks末尾。 - 切换完成状态 / 删除任务由任务项触发,回调更新
tasks中的对应元素。 - 筛选按钮组改变
filter的值,列表组件根据filter过滤tasks后再渲染。
tasks 和 filter 都放在 App 内部,兄弟组件之间通过 props 获取。应用本身只有两层,通过 props 传递足够;后文也会展示如何用 Context 代替 props 传递,作为深度组件树场景的可选方案。
搭建项目文件结构
通过 Vite(或 create-react-app)生成 React + TypeScript 骨架后,先清理不需要的文件[3]:
src/App.test.tsxsrc/App.csssrc/logo.svgsrc/serviceWorker.ts(视模板而定)src/setupTests.ts
保留的核心文件:
src/
index.tsx // 入口
App.tsx // 根组件
index.css // 样式index.tsx 只挂载 <App />,index.css 存放基础样式(例如任务完成状态的删除线),不引入额外的 CSS 框架。
添加待办:受控表单与状态更新
表单包含一个文本输入框和一个提交按钮。输入框的值与 React 状态同步,以便在提交时拿到最新文本并在提交后清空。这是典型的受控组件模式:值由 state 控制,onChange 同步回写。
在 App.tsx 中定义任务列表和输入框的状态:
tsx
import { useState } from 'react';
interface Task {
id: string;
name: string;
completed: boolean;
}
export default function App() {
const [tasks, setTasks] = useState<Task[]>([]);
const [name, setName] = useState('');
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
const newTask: Task = {
id: crypto.randomUUID(),
name,
completed: false,
};
setTasks([...tasks, newTask]);
setName('');
}
return (
<div className="todoapp">
<h1>TodoMatic</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="new-todo-input">添加新任务</label>
<input
id="new-todo-input"
type="text"
value={name}
onChange={e => setName(e.target.value)}
/>
<button type="submit">添加</button>
</form>
</div>
);
}crypto.randomUUID() 为每个任务生成唯一 ID,稍后用做列表的 key。添加任务时通过展开原数组再追加新项来保持不可变更新——直接修改原数组不会触发 React 重新渲染。
待办列表:渲染、完成切换与删除
接下来把任务数组渲染为 <ul> 列表,每一项用 .map() 生成,key 使用任务自身的 id(避免使用索引可能带来的状态错位,参见《React 工作原理:Virtual DOM 与协调》中对 key 的分析)。
完成切换和删除的函数实现:
tsx
function toggleTaskCompleted(id: string) {
setTasks(
tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
)
);
}
function deleteTask(id: string) {
setTasks(tasks.filter(task => task.id !== id));
}在 JSX 中调用它们:
tsx
<ul>
{tasks.map(task => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTaskCompleted(task.id)}
/>
<span
style={{
textDecoration: task.completed ? 'line-through' : 'none',
}}
>
{task.name}
</span>
</label>
<button onClick={() => deleteTask(task.id)}>删除</button>
</li>
))}
</ul>注意:这里必须使用 checked={task.completed} 而非 defaultChecked。defaultChecked 只在组件首次挂载时生效;当任务数组通过 setTasks 更新,React 会根据相同的 key 复用已有 DOM 节点,此时复选框的选中状态并不会随 completed 变化而更新,导致标记完成功能失效。checked 配合 onChange 可以保证复选框始终反映数据中的 completed 字段。
完成切换通过对比 id 更新对应元素的 completed 字段;删除则用 filter 去掉对应项,同样返回新数组。
筛选状态:提升到父组件
筛选功能需要三个按钮:「全部」「进行中」「已完成」。筛选条件本身只是 App 组件里的一个字符串状态,被按钮点击事件修改。这个状态和 tasks 同层,不需要额外提升。
tsx
type Filter = 'all' | 'active' | 'completed';
const [filter, setFilter] = useState<Filter>('all');按钮组通过 props 接收当前 filter 和更新函数 setFilter,并高亮当前选中的按钮(aria-pressed 属性需要用字符串 'true' / 'false',而非布尔值[4]):
tsx
interface FilterButtonsProps {
filter: Filter;
setFilter: (f: Filter) => void;
}
function FilterButtons({ filter, setFilter }: FilterButtonsProps) {
const buttons: { label: string; value: Filter }[] = [
{ label: '全部', value: 'all' },
{ label: '进行中', value: 'active' },
{ label: '已完成', value: 'completed' },
];
return (
<div>
{buttons.map(btn => (
<button
key={btn.value}
type="button"
aria-pressed={filter === btn.value ? 'true' : 'false'}
onClick={() => setFilter(btn.value)}
>
{btn.label}
</button>
))}
</div>
);
}现在 App 把 filter / setFilter 传给 FilterButtons,稍后还会把 filter 传给列表渲染逻辑。
根据筛选条件动态渲染列表
渲染列表之前,先基于 filter 对 tasks 做一次过滤:
tsx
const visibleTasks = tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true; // 'all'
});然后把 <ul> 里的 tasks.map 替换为 visibleTasks.map,其余不变。这样点击筛选按钮时,filter 改变会引发 App 重新渲染,visibleTasks 随之变化,列表内容自动更新。
任务计数也可以基于过滤后的结果显示,无需单独的 state:
tsx
<h2>{visibleTasks.length} 个任务</h2>使用 Context 传递筛选条件
当前应用只有两层,筛选状态通过 props 传递已经足够。如果组件树更深,或者想避免逐层转发 props,可以把 filter 和 setFilter 放入 Context。
新建 FilterContext.ts:
tsx
import { createContext } from 'react';
export type Filter = 'all' | 'active' | 'completed';
export const FilterContext = createContext<{
filter: Filter;
setFilter: (f: Filter) => void;
}>({
filter: 'all',
setFilter: () => {},
});App 中用 FilterContext.Provider 包裹子组件,并传递当前筛选信息:
tsx
import { FilterContext } from './FilterContext';
// 在 App 中
<FilterContext.Provider value={{ filter, setFilter }}>
{/* 表单、列表、按钮组等 */}
</FilterContext.Provider>FilterButtons 改用 useContext(FilterContext) 获取筛选状态,不再依赖父组件通过 props 转发:
tsx
import { useContext } from 'react';
import { FilterContext, Filter } from './FilterContext';
export default function FilterButtons() {
const { filter, setFilter } = useContext(FilterContext);
const buttons: { label: string; value: Filter }[] = [
{ label: '全部', value: 'all' },
{ label: '进行中', value: 'active' },
{ label: '已完成', value: 'completed' },
];
return (
<div>
{buttons.map(btn => (
<button
key={btn.value}
type="button"
aria-pressed={filter === btn.value ? 'true' : 'false'}
onClick={() => setFilter(btn.value)}
>
{btn.label}
</button>
))}
</div>
);
}负责过滤 tasks 的逻辑部分也改为从 Context 消费 filter,省去了中间层级的数据搬运。
组合与收尾:构建完整应用
综合以上片段,最终 App.tsx 将状态、事件处理、列表渲染和筛选逻辑串联在一起:
tsx
import { useState } from 'react';
import FilterButtons from './FilterButtons';
import { FilterContext, Filter } from './FilterContext';
interface Task {
id: string;
name: string;
completed: boolean;
}
export default function App() {
const [tasks, setTasks] = useState<Task[]>([]);
const [name, setName] = useState('');
const [filter, setFilter] = useState<Filter>('all');
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setTasks([...tasks, { id: crypto.randomUUID(), name, completed: false }]);
setName('');
}
function toggleTaskCompleted(id: string) {
setTasks(
tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
)
);
}
function deleteTask(id: string) {
setTasks(tasks.filter(task => task.id !== id));
}
const visibleTasks = tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
return (
<div className="todoapp">
<h1>TodoMatic</h1>
<form onSubmit={handleSubmit}>
<label htmlFor="new-todo-input">添加新任务</label>
<input
id="new-todo-input"
type="text"
value={name}
onChange={e => setName(e.target.value)}
/>
<button type="submit">添加</button>
</form>
<FilterContext.Provider value={{ filter, setFilter }}>
<FilterButtons />
<h2>{visibleTasks.length} 个任务剩余</h2>
<ul>
{visibleTasks.map(task => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTaskCompleted(task.id)}
/>
<span
style={{
textDecoration: task.completed ? 'line-through' : 'none',
}}
>
{task.name}
</span>
</label>
<button onClick={() => deleteTask(task.id)}>删除</button>
</li>
))}
</ul>
</FilterContext.Provider>
</div>
);
}FilterButtons 文件按上一节的 Context 版本提供即可。运行 npm run dev(Vite)后,输入任务回车添加,点击复选框切换完成状态,删除按钮移除条目,筛选按钮切换视图——这些功能建立在组件、状态、事件和 Context 的组合之上,构成一个典型的单页面小应用。
