几乎每个前端开发者都写过 TodoList,几乎每个团队也都有过因为类型不明确、字段拼写错误、接口变动导致的线上小 bug。
今天我们就从一个非常常见的 TodoList 实现出发,用 TypeScript 把它的各个角落都加上类型约束,看看能带来哪些实际的、可量化的好处。
1. 先看看「纯 JS 写法」最容易翻车的地方
假设我们用纯 JavaScript + React + localStorage 写一个 TodoList,通常会长这样(只列关键易错点):
JavaScript
// 极易出错的几个点(纯 JS 版)
const STORAGE_KEY = 'todos'
function getTodos() {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [] // 可能是 null、可能是乱七八糟的结构
}
function saveTodos(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
}
function addTodo(title) {
const todos = getTodos()
todos.push({ id: Date.now(), title, completed: false, createAt: new Date() }) // 手滑多加字段怎么办?
saveTodos(todos)
}
function toggleTodo(id) {
const todos = getTodos()
const todo = todos.find(t => t.id == id) // == 宽松比较 + 类型不匹配都可能通过
if (todo) todo.completed = !todo.completed // 拼错成 complated 编译器不管
saveTodos(todos)
}
最常见的血泪史包括但不限于:
- 拼错字段名:complated、isComplete、complete 混用
- id 变成字符串 vs 数字比较问题
- localStorage 被其他 tab/extension 污染,读到 {}、null、undefined、123 等
- 别人在 todo 上加了新字段,你不知道,然后 todo.xxx 狂打 undefined
- 重构时把 title 改成 text,全项目搜索替换漏掉几个地方
TypeScript 能比较彻底地解决上面绝大多数问题。
2. 先定义核心数据结构(这是最重要的一步)
TypeScript
// types/todo.ts
export interface Todo {
id: number
title: string
completed: boolean
// 如果以后要加字段,在这里加一次,全局感知
// createAt?: number
// priority?: 'low' | 'medium' | 'high'
}
建议:把所有核心业务数据结构都集中放在 types/ 或 interfaces/ 目录下,并统一导出,这是大型项目最常见的做法。
3. 类型安全的 localStorage 工具函数
TypeScript
// utils/storage.ts
export function getStorage<T>(key: string, defaultValue: T): T {
const value = localStorage.getItem(key)
if (value === null) return defaultValue
try {
return JSON.parse(value) as T
} catch {
console.warn(`localStorage 解析失败,key: ${key},使用默认值`)
return defaultValue
}
}
export function setStorage<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value))
}
这里用泛型 让调用方自己决定要什么类型,同时做最基本的错误兜底。
4. 核心业务逻辑 Hook —— useTodos
TypeScript
import { useState, useEffect } from 'react'
import type { Todo } from '../types/todo'
import { getStorage, setStorage } from '../utils/storage'
const STORAGE_KEY = 'todos-v2' // 建议带版本号,避免历史数据格式不兼容
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>(() =>
getStorage<Todo[]>(STORAGE_KEY, [])
)
// 每次 todos 变化都自动保存
useEffect(() => {
setStorage(STORAGE_KEY, todos)
}, [todos])
const addTodo = (title: string) => {
if (!title.trim()) return
const newTodo: Todo = {
id: Date.now(), // 生产环境建议用 uuid
title: title.trim(),
completed: false,
}
setTodos(prev => [...prev, newTodo])
}
const toggleTodo = (id: number) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
)
}
const removeTodo = (id: number) => {
setTodos(prev => prev.filter(todo => todo.id !== id))
}
return {
todos,
addTodo,
toggleTodo,
removeTodo,
}
}
TypeScript 带来的主要安全点:
- todos 一定是 Todo[],永远不会是 any[] 或其他奇怪的东西
- newTodo 必须符合 Todo 接口,少写/多写字段都会报错
- title: string 约束了输入参数类型
- 所有操作函数参数类型明确,调用方很难传错
5. 组件 Props 接口化(重中之重)
// components/TodoList.tsx
import type { Todo } from '../types/todo'
import TodoItem from './TodoItem'
interface TodoListProps {
todos: Todo[]
onToggle: (id: number) => void
onRemove: (id: number) => void
}
const TodoList: React.FC<TodoListProps> = ({ todos, onToggle, onRemove }) => {
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</ul>
)
}
export default TodoList
// components/TodoItem.tsx
import type { Todo } from '../types/todo'
interface TodoItemProps {
todo: Todo
onToggle: (id: number) => void
onRemove: (id: number) => void
}
const TodoItem: React.FC<TodoItemProps> = ({ todo, onToggle, onRemove }) => {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'undefined' }}>
{todo.title}
</span>
<button onClick={() => onRemove(todo.id)}>删除</button>
</li>
)
}
这里只要你漏传/传错类型,编辑器立刻红线,比写完跑才发现 props 漏传要早得多。
最后:
TypeScript 真正的价值不是让代码「多打几个冒号」,而是让「数据的形状」变成项目中可追踪、可搜索、可重构的第一等公民。
当一个中后台项目、组件库、或者多人协作超过 6 个月以上的业务项目开始出现「不知道这个字段到底叫什么」「改了个字段全项目爆红」「这个数据到底有没有可能是 null」等问题的时候, 往往已经错过了引入 TypeScript 最好的时机。
不如从下一个 TodoList 开始,就让它成为类型安全的起点吧。