用 TypeScript 把一个看似普通的 TodoList 做得更安全、更可维护

11 阅读3分钟

几乎每个前端开发者都写过 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 开始,就让它成为类型安全的起点吧。