🎬 从「写崩三个项目」到「TypeScript 真香」:一个 Todo 列表的血泪救赎史
前言
你以为你在写 JavaScript,其实你在玩一场名为**「大家来找茬」**的生存游戏。
只不过,找茬的是运行时的用户,而被"公开处刑"的,是深夜加班的你。
直到有一天,一位带着蓝色徽章的骑士拍了拍你的肩膀:
「兄弟,别等了。编译时就能把错揪出来,不香吗?」
🌪️ 第一幕:崩溃之夜,undefined 的狂欢
时间 :凌晨 02:30
地点 :某互联网公司只剩一盏灯的工位
人物:小码(资深"背锅"工程师)
屏幕上,红色的报错信息像瀑布一样刷屏。小码盯着控制台,眼神空洞:
"为什么?!我明明写了
todo.title,为什么用户看到的全是undefined?我的 Todo 应用变成了'未定义'应用了吗?!"
🎞️ 闪回镜头:
- 同事 A (在群里):"我觉得字段叫
text更直观,我就改啦!" - 同事 B (在后端):"哦,数据库里存的是
completed,不是done,顺手改了下返回结构。" - 小码(天真地敲键盘):"没事没事,JS 嘛,灵活一点,大家都懂的~"
💥 现实暴击: 当用户点击「完成」按钮的那一刻,由于字段名对不上,整个页面瞬间崩塌。控制台里,Cannot read property 'length' of undefined 像在嘲笑小码的天真。
小码瘫坐在椅子上,内心 OS:
"要是......要是写代码的时候,就有个严厉的老师拿着红笔,在我写下
title而不是text的瞬间狠狠打我的手,该多好啊......"
(图注:那一刻,小码觉得自己的职业生涯也要 undefined 了)
🦸♂️ 第二幕:蓝衣骑士降临,TypeScript 的真香现场
✨ 特效音 :叮!

一道柔和的蓝光闪过,TypeScript 君(戴着单片眼镜,手持类型检查权杖)凭空出现在显示器旁。
TypeScript 君 : "兄弟,别哭了。JavaScript 是自由的,但自由过了头就是灾难。让我来帮你建立秩序吧。在我的世界里,Bug 在代码'出厂'前就会被拦截,不用等用户投诉,你自己先'社死'一遍,好过上线后'社会性死亡'。"
小码半信半疑:"你......真的能拦住那些手滑的错误?"
TypeScript 君推了推眼镜:"不信?看好了!这是你的新武器------Interface(接口),也就是我们数据结构的'宪法'。"
typescript
// 📜 数据结构宪法:types/todo.ts
export interface Todo {
id: number;
text: string; // ⚠️ 统一叫 text,谁敢写 title 就报错!
completed: boolean; // ⚠️ 必须是布尔值,字符串 "false" 也不行!
}
🔥 高潮时刻: 小码试着像以前一样手滑,写下了这段代码:
typescript
const badTodo: Todo = {
id: 1,
title: "买菜", // ❌ TypeScript 君立刻亮出红牌:属性 'title' 不存在!
done: false // ❌ TypeScript 君再次亮牌:属性 'done' 不存在,应该是 'completed'!
};
编辑器反应 :瞬间出现红色波浪线,鼠标悬停提示:"对象字面量只能指定已知属性,并且 'title' 不在类型 'Todo' 中。"
小码瞪大眼睛:"卧槽!还没运行就报错了?!"
TypeScript 君得意地笑:"这就叫静态类型检查。怎么样,香不香?"

(图注:TypeScript 君:有我在,undefined 休想靠近你的用户!)
🗺️ 第三幕:全景地图 ------ 一图看懂 TS 如何守护数据流
在 TypeScript 的加持下,我们的 Todo 项目不再是散沙,而是一座精密的堡垒。
text
src/
├── types/todo.ts 👑【宪法】:定义 Todo 长什么样(唯一真理)
├── utils/storage.ts 🛡️【盾牌】:泛型封装 localStorage,拒绝 any
├── hooks/useTodos.ts 🧠【大脑】:状态核心,增删改查 + 类型贯穿
├── components/
│ ├── TodoInput.tsx 📥【入口】:Props 强约束,传错直接编译失败
│ ├── TodoList.tsx 📋【列表】:数组类型安全,子组件传参校验
│ └── TodoItem.tsx 🧩【单元】:最后一公里的数据守护
├── App.tsx 🏗️【总装】:类型闭环,完美组装
└── main.tsx 🚀【启动】:非空断言,自信挂载
💡 核心逻辑 : 数据从 TodoInput 流入,经过 useTodos 处理,存入 localStorage,最后由 TodoList 渲染。每一步都有类型契约,哪一环敢"越狱",编译器直接熔断!
🛠️ 第四幕:实战拆解 ------ 代码里的"真香"细节
4.1 📜 立法者:types/todo.ts
"一锤定音,从此再无歧义"
typescript
// 数据状态是应用的心脏,TS 是心脏的保镖
export interface Todo {
id: number;
text: string;
completed: boolean;
}
- 真香点 :这是全项目的"单一事实来源"。以后谁敢加个
createdAt字段却不更新这里?编译器第一个不答应!所有引用处都会提示你补全,重构从未如此丝滑。
4.2 🧙♂️ 魔法师:utils/storage.ts
"泛型 <T>:一套逻辑,通吃万物"
typescript
export function getStorage<T>(key: string, defaultValue: T): T {
const value = localStorage.getItem(key);
// 哪怕读出来是字符串,我也能把它变成你想要的 T
return value ? JSON.parse(value) : defaultValue;
}
export function setStorage<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
}
- 真香点 :
getStorage<Todo[]>('todos', []):告诉 TS"我读的是 Todo 数组",返回值自动推断为Todo[],彻底告别any黑盒。- 如果没有泛型,你得为 User、Config、Order 各写一套 get/set,现在?一套搞定!
4.3 🧠 指挥官:hooks/useTodos.ts
"类型贯穿始终,逻辑稳如老狗"
typescript
import { useState, useEffect } from 'react';
import type { Todo } from '../types/todo'; // 👈 只导类型,打包更轻
import { getStorage, setStorage } from '../utils/storage';
const STORAGE_KEY = 'todos';
export function useTodos() {
// 初始值从 localStorage 读,类型明确为 Todo[]
const [todos, setTodos] = useState<Todo[]>(
() => getStorage<Todo[]>(STORAGE_KEY, [])
);
// 只要 todos 变了,自动存回去,类型安全
useEffect(() => {
setStorage<Todo[]>(STORAGE_KEY, todos);
}, [todos]);
const addTodo = (text: string) => { // 👈 参数锁死为 string
const newTodo: Todo = {
id: +new Date(),
text,
completed: false
};
setTodos([...todos, newTodo]);
};
const toggleTodo = (id: number) => {
// TS 能推断出 map 后的结果仍是 Todo[],因为结构符合 interface
const newTodos = todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
setTodos(newTodos);
};
// ... removeTodo 略
return { todos, addTodo, toggleTodo, removeTodo };
}
- 真香点 :
import type让构建产物更纯净;useState<Todo[]>让状态管理不再靠猜。
4.4 📥 守门员:TodoInput.tsx
"Props 即文档,传错即报错"
typescript
interface TodoInputProps {
onAddTodo: (text: string) => void; // 👈 契约:我只要这个函数
}
export default function TodoInput({ onAddTodo }: TodoInputProps) {
const [text, setText] = useState('');
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = text.trim();
if (trimmed) {
onAddTodo(trimmed); // ✅ 类型匹配,安全调用
setText('');
}
};
// ... JSX 略
}
- 真香点 :父组件如果手滑传了
(id) => ...,或者忘了传onAddTodo,编译器直接红牌罚下,根本轮不到用户发现。
4.5 🧩 组装车间:App.tsx & main.tsx
"类型闭环与非空断言"
typescript
// App.tsx:类型在顶层完美闭环
export default function App() {
const { todos, addTodo, toggleTodo, removeTodo } = useTodos();
return (
<div>
<TodoInput onAddTodo={addTodo} />
{/* addTodo 的类型自动匹配 onAddTodo 的要求 */}
<TodoList todos={todos} onToggleTodo={toggleTodo} onRemove={removeTodo} />
</div>
);
}
// main.tsx:自信的非空断言
createRoot(document.getElementById('root')!).render(<App />);
// 👆 '!' 告诉 TS:我发誓 root 一定存在,别啰嗦了!
🏆 第五幕:TS 护城河 ------ 功能与安全的完美映射
| 功能 | 实现位置 | 🛡️ TypeScript 如何保护你 |
|---|---|---|
| 添加待办 | addTodo |
参数锁死 string,想传数字?门都没有。 |
| 切换状态 | toggleTodo |
map 返回类型自动推断,确保数组结构不变。 |
| 持久化 | storage.ts |
泛型保证读写类型一致,杜绝 JSON.parse 后的类型丢失。 |
| 组件通信 | Props Interface | 漏传、错传、类型不匹配?编译期直接拦截。 |
| DOM 操作 | main.tsx |
非空断言 ! 或可选链 ?.,让 null 检查更可控。 |
🎓 第六幕:新手入坑指南(三步走)
别被篇幅吓到,入门其实只需三步:
- 请神上身 :
npm install -D typescript - 立下规矩 :在
types/目录下写出你的第一个interface。 - 处处设防:给变量、函数参数、Props 加上类型注解。
记住这三句真言:
- Interface 是宪法,全项目照着做。
- 泛型
<T>是魔法,一次编写处处用。- 编译检查 是保镖,Bug 不过夜。
🎬 终幕:再见,undefined;你好,未来
场景:又是深夜,但这次办公室里充满了轻松的氛围。小码从容地提交代码,CI/CD 流水线上一路绿灯。
小码喝着咖啡,对着屏幕微笑:
"以前总觉得 TS 麻烦,要写好多类型定义。现在才发现,那些多写的几行代码,是我深夜安睡的保障,是团队沟通的契约,是线上稳定的基石。"
TypeScript 君(渐渐隐去,留下最后一句话):
"记住,TypeScript 不是银弹,但它能让你少写很多'运行时才发现'的 Bug。 从 Todo 开始,从今天开始------真香,从来不晚。"
💡 彩蛋:那些年我们踩过的 TS 坑(避坑指南)
- Q:
todo.title还是todo.text?- A: 上
interface!一次定义,到处复用,谁改谁报错。
- A: 上
- Q:
localStorage读出来全是any?- A: 泛型
getStorage<Todo[]>安排上,类型瞬间清晰。
- A: 泛型
- Q:
document.getElementById为啥报错?- A: 它可能返回
null。要么用!断言(你确定有),要么先判空(更安全)。
- A: 它可能返回
👉 互动话题 : 你是在哪个瞬间决定"弃 JS 投 TS"的?是在修一个莫名其妙的 undefined 时,还是在接手一座"屎山"代码时? 欢迎在评论区分享你的**"真香"**故事!👇