需求:使用 React 实现一个简单的待办事项列表:用户可以添加、编辑和删除待办事项。
样式参照 todomvc 实现。
(样式来自:todomvc.com )
创建项目
本项目通过 vite 搭建 React 并使用 TSX 编写,不依赖第三方组件库,唯一依赖是 use-immer。你需要创建项目之后通过 npm i use-immer 引入。
npm create vite@latest
样式则是使用 todomvc 提供的样式,只需要在 index.css 添加如下代码即可。
@import "https://unpkg.com/todomvc-app-css@2.4.1/index.css";
数据结构
本项目的数据结构比较简单,代办事项 Todo 由有个三个字段:id,title 和 completed。代办事项列表 Todo[] 不需要单独声明类型。用 Java 的术语,它们都是 POJO,不需要任何复杂方法。
export default interface Todo {
id: number;
title: string;
completed: boolean;
}
UI 树与组件
除开最顶部的文字“todos”,可以将 UI 分成三个部分:
- Header:包括输入框和全选按钮;
- TodoList:由待办事项组成,待办事项可以显示标题和选中框,选中的待办事项会被划线。
- Footer:状态栏及过滤选择器。
stateDiagram-v2
APP --> Header
APP --> TodoList
APP --> Footer
TodoList --> Todo1
TodoList --> ...
TodoList --> Todon
组件
根据图片还原 UI 比较容易,难度较大的是状态的管理及事件处理。本例中状态实现了五个组件,尽可能提高各组件的内聚性:
APP
APP 负责拼装其他组件及状态管理。正如之前所说,本项目使用 useImmerReducer 管理状态,当用户触发响应事件之后通过 dispatch 到相应的 action 处理。
function App() {
const [todoList, dispatch] =
useImmerReducer<Todo[], TodoAction>(todoListReducer, []);
const [filter, setFilter] = useState<FilterEnum>(FilterEnum.ALL);
function handleChange(id: number, updates: Partial<Todo>) {
dispatch({
type: 'update',
payload: {
id,
...updates
}
});
}
// ...
}
每个 action 都有 type 字段表示当前操作类型。可选参数 payload 的类型是 Partial<Todo> 及任何可选的 Todo 字段组成的对象,方便不同操作传参。
export type TodoAction = {
type: 'add' | 'update' | 'delete' | 'clearCompleted' | 'toggleAll';
payload?: Partial<Todo>;
}
我们都知道修改 React 中的状态是不能直接修改的,需要 set 函数传入新的值。这对于修改对象或数组就比较麻烦了。本项目使用 immer 是 React 中比较常用的库,它通过代理降低了修改状态的心智负担。使用 reducer 则是为了统一管理修改复杂状态的代码,提高了可读性也方便调试。同时,如果后期需要修改或添加新功能,也只需要修改 reducer,而不用修改 App.tsx。
filter 是过滤类型枚举,分别筛选全部、未完成和已完成代办事项。Filters 则是提供了相应的过滤器。
enum FilterEnum {
ALL = 'all',
ACTIVE = 'active',
COMPLETED = 'completed',
}
const Filters = {
[FilterEnum.ALL]: (items: Todo[]) => items,
[FilterEnum.ACTIVE]: (items: Todo[]) =>
items.filter((todo) => !todo.completed),
[FilterEnum.COMPLETED]: (items: Todo[]) =>
items.filter((todo) => todo.completed),
}
useMemo Hook 需要两个参数:函数和依赖数组。useMemo 会记住函数的返回值,在依赖数组不变的情况下,并不会重复调用过滤函数,这能提升存在大量待办事项时的性能。但当 filter 或 todoList 改变时,待办事项列表展示的内容也相应地改变。
const filteredList =
useMemo(() => Filters[filter](todoList), [filter, todoList]);
<TodoList
todoList={filteredList}
// ...
/>
另外就是切换过滤器时,比如,用户可能在点击 “active” 后改变主意了,立刻又点击了 “completed”。此时如果处于 “active” 的待办事项比较多的话,用户得等待 “active” 得待办事项渲染完成之后才能切换到 “completed”。使用 startTransition 可以在不阻塞 UI 的情况下更新过滤器。
function handleChangeFilter(filter: FilterEnum) {
startTransition(() => {
setFilter(filter);
});
}
Header
Header 负责接受用户输入的内容,并通过事件传递给 App 处理。同时还负责提供全选按钮。
type HeaderProps = {
onAdd: (title: string) => void;
onToggleAll: () => void;
}
export function Header(props: HeaderProps) {
// ...
return (
<header className="header">
<input
ref={inuptRef}
className="new-todo"
placeholder="What needs to be done?"
onKeyDown={(e) => {
if (e.key === 'Enter') {
onAdd(inuptRef.current!.value);
inuptRef.current!.value = '';
}
}}
/>
// ...
</header>
);
}
TodoList 和 TodoItem
TodoItem 用于响应用户对代办事项的操作,TodoList 将这些操作代理到 App。但也不是所有状态都由 App 管理,为了提高内聚性,与修改相关状态只存在 TodoList,只有修改完成之后才上传到 App。
export default function TodoList(props: TodoListProps) {
const { todoList, onChange, onDestroy } = props;
const [editingId, setEditingId] = useState<number>(-1);
return (
<ul className="todo-list">
{todoList.map((todo: Todo) => (
<TodoItem
key={todo.id}
todo={todo}
isEditing={todo.id === editingId}
onEdit={(id) => setEditingId(id)}
onCancel={() => setEditingId(-1)}
onToggle={(id) => onChange(id, { completed: true })}
onSave={(id, title) => {
onChange(id, { title });
setEditingId(-1);
}}
onDestroy={onDestroy}
/>
))}
</ul>
)
}
Footer
Footer 的职责是显示当前状态及过滤选择器,响应用户点击,并将选择器交由 App 处理。
总结
本文简单实现了一个代办事项列表,可以新增、删除、修改和过滤待办事项。本项目使用 useImmerReducer 简化代办事项状态的管理,进一步可以引入 Redux,提供更灵活的状态管理方式。同时本项目还使用 useMemo 缓存过滤出的待办事项,以及使用 startTransition 实现更快速地切换过滤器。
本项目不足之处在于未实现数据的持久化,未来可以通过 localStorage 或 IndexedDB 实现本地存储,或者连接后端实现。另一个不足是项目样式是直接使用 todomvc 提供的样式,虽然也挺好看的,但最终项目的组织受限于其样式的定义。可以使用 tailwind 提供的样式,或者使用 Context 自定义和管理主题。