04 - TS 实战:类型安全组件
前置:TS 与面向对象、JS 实战:交互式页面。本章用 TypeScript 重写第 7 章的 TodoList,逐层展示”无框架项目里 TS 能提供什么”,并讨论它的价值边界。
1. 项目搭建
1.1 Vite 创建 vanilla-ts 项目
Vite 是当前主流的前端构建工具,原生支持 TS(编译由 esbuild 完成,毫秒级):
npm create vite@latest ts-todo -- --template vanilla-ts
cd ts-todo
npm install
npm run dev # 启动开发服务器,默认 http://localhost:5173生成的目录结构:
ts-todo/
index.html # 入口页面
src/
main.ts # TS 主逻辑
style.css
tsconfig.json # 脚手架已配置好 strict 模式
package.json1.2 工程要点
- 开发期 Vite 只做转译不检查类型;类型检查由
npm run build内部执行的tsc --noEmit兜底 - IDE(VS Code + 官方 TS 插件)会在编辑时实时标红——日常开发主要依赖它
strict: true保持开启,本章所有收益都建立在严格模式上
2. 类型建模:先设计后编码
2.1 Todo 接口与状态机
动手写渲染前,先把领域模型钉死:
// src/types.ts —— 集中管理所有类型定义
export interface Todo {
readonly id: string; // 用 UUID 字符串而非时间戳数字
title: string;
done: boolean;
createdAt: number; // 时间戳毫秒值
}
export type Filter = "all" | "active" | "done";
export type TodoMap = Record<string, Todo>; // id -> Todo 的字典形态对照 JS 版的三点升级:
id从Date.now()数字升级为crypto.randomUUID()字符串,杜绝撞 idFilter由裸字符串变为字面量联合,非法筛选值在编译期就被拒绝(TS 进阶类型 第 1 节)readonly id保证主键一旦生成就不会被误改
2.2 泛型存储层 LocalStore<T>
把 localStorage 读写抽象成可复用的泛型类(TS 与面向对象 泛型类的直接应用):
// src/localStore.ts
import type { Todo } from "./types";
class LocalStore<T> {
constructor(private key: string) {}
load(): T | null {
try {
const raw = localStorage.getItem(this.key);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch {
return null; // 数据损坏时静默降级
}
}
save(value: T): void {
localStorage.setItem(this.key, JSON.stringify(value));
}
clear(): void {
localStorage.removeItem(this.key);
}
}
export const todoStore = new LocalStore<Todo[]>("rootstack.todos.v2");new LocalStore<Todo[]>() 之后,load/save 的参数与返回值全程携带 Todo[] 类型——这个存储类可以被任何项目复用,换一个 T 就是另一种数据的仓库。
3. 应用核心:类型化的状态与渲染
3.1 状态模块
// src/state.ts
import type { Filter, Todo } from "./types";
import { todoStore } from "./localStore";
interface AppState {
todos: Todo[];
filter: Filter; // 永远只能是三个字面量之一
}
let state: AppState = {
todos: todoStore.load() ?? [],
filter: "all",
};
type Listener = (state: AppState) => void;
const listeners = new Set<Listener>();
function emit(): void {
todoStore.save(state.todos);
listeners.forEach(fn => fn(state)); // 通知所有订阅者重绘
}
// 对外暴露的受控更新接口
export function addTodo(title: string): void {
const trimmed = title.trim();
if (!trimmed) return;
state = {
...state,
todos: [{ id: crypto.randomUUID(), title: trimmed,
done: false, createdAt: Date.now() }, ...state.todos],
};
emit();
}
export function toggleTodo(id: string): void {
state = {
...state,
todos: state.todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
),
};
emit();
}
export function removeTodo(id: string): void {
state = { ...state, todos: state.todos.filter(t => t.id !== id) };
emit();
}
export function setFilter(filter: Filter): void {
state = { ...state, filter };
emit();
}
export function subscribe(fn: Listener): () => void {
listeners.add(fn);
return () => listeners.delete(fn); // 返回取消订阅函数
}
export function getState(): AppState {
return state;
}架构比 JS 版多走了一步:发布订阅替代了手动调 render。事件处理器只调 addTodo 等 action,action 改完状态自动广播,视图层订阅后自行刷新。函数签名即文档——setFilter(filter: Filter) 告诉你只能传哪三种值。
注意不可变更新风格(map/filter 展开而非 push/splice):保持引用变化可追踪,这正是 Vue/React 的响应系统所要求的习惯。
3.2 渲染模块
// src/view.ts
import { getState } from "./state";
import { escapeHtml as esc } from "./utils";
import type { Filter, Todo } from "./types";
const listEl = document.querySelector<HTMLUListElement>("#todo-list")!;
const countEl = document.querySelector<HTMLElement>("#count")!;
// querySelector 的泛型用法:告诉编译器返回什么元素类型,
// 于是 .value / .checked 等元素专属属性获得补全与检查
const inputEl = document.querySelector<HTMLInputElement>("#todo-input")!;
function visibleTodos(todos: Todo[], filter: Filter): Todo[] {
switch (filter) {
case "active": return todos.filter(t => !t.done);
case "done": return todos.filter(t => t.done);
case "all": return todos;
}
}
function renderTodo(t: Todo): string {
return `
<li data-id="${t.id}" class="${t.done ? "done" : ""}">
<input type="checkbox" ${t.done ? "checked" : ""} class="toggle">
<span class="label">${esc(t.title)}</span>
<button class="del">删除</button>
</li>`;
}
export function render(): void {
const { todos, filter } = getState();
listEl.innerHTML = visibleTodos(todos, filter).map(renderTodo).join("");
countEl.textContent =
`共 ${todos.length} 项,剩余 ${todos.filter(t => !t.done).length} 项`;
}细节亮点:
querySelector<HTMLInputElement>的泛型收窄让 DOM API 也享受类型保护,末尾!断言非空(HTML 里保证存在该节点,断言失败是部署事故而非运行常态)visibleTodos的 switch 三分支穷尽了 Filter 全部成员,没有 default——将来新增筛选档位时,这里会立刻报错提示补全
3.3 事件模块与入口
// src/main.ts
import { addTodo, toggleTodo, removeTodo, setFilter, subscribe } from "./state";
import { render, inputEl } from "./view";
// 键盘提交:event 参数的类型由 addEventListener 自动推断为 KeyboardEvent
inputEl.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
addTodo(inputEl.value);
inputEl.value = "";
});
document.querySelector("#add-btn")
?.addEventListener("click", () => {
addTodo(inputEl.value);
inputEl.value = "";
});
// 事件委托:e.target 先收窄为 Element 再操作
document.querySelector("#todo-list")
?.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const li = target.closest<HTMLElement>("li[data-id]");
if (!li) return;
const id = li.dataset.id!; // 选择器已含 data-id,断言安全
if (target.classList.contains("toggle")) toggleTodo(id);
else if (target.classList.contains("del")) removeTodo(id);
});
document.querySelector("#filters")
?.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
"button[data-filter]"
);
if (!btn) return;
const value = btn.dataset.filter;
// 运行时守卫:字符串来自 DOM,必须验证后才可信
if (value === "all" || value === "active" || value === "done") {
setFilter(value); // 此处 value 已被收窄为 Filter
}
});
subscribe(render);
render();最值得品味的是筛选那几行:dataset 读出的是 string | undefined,而 setFilter 要求 Filter 类型。TS 强迫你在中间插入一次显式校验,校验通过后变量自动收窄——运行时安全与编译期类型在同一处代码里闭环。JS 版本里这一步靠自觉,TS 版本靠编译器逼你完成。
4. 前后端契约:API 类型与守卫
4.1 定义响应类型
假设后端提供 /api/todos,前端先声明契约:
// src/api.ts
import type { Todo } from "./types";
interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
export async function fetchTodos(): Promise<Todo[]> {
const resp = await fetch("/api/todos");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const body = (await resp.json()) as ApiResponse<Todo[]>;
if (body.code !== 0) throw new Error(body.message);
return body.data;
}但这里的 as ApiResponse<Todo[]> 只是单方面宣称——后端实际返回的字段拼错了、少传了,编译器一概不知。
4.2 类型守卫 isTodo:把宣称变成验证
类型谓词函数(x is Type)在运行时检查形状,同时给编译器收窄信号:
// src/guards.ts
import type { Todo } from "./types";
export function isTodo(value: unknown): value is Todo {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === "string" &&
typeof v.title === "string" &&
typeof v.done === "boolean" &&
typeof v.createdAt === "number"
);
}
export function isTodoArray(value: unknown): value is Todo[] {
return Array.isArray(value) && value.every(isTodo);
}接入 API 层:
export async function fetchTodosSafe(): Promise<Todo[]> {
const resp = await fetch("/api/todos");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const body: unknown = await resp.json(); // 边界处一律 unknown,不预设形状
if (!isTodoArray(body)) {
throw new Error("响应数据不符合 Todo 契约");
}
return body; // 收窄为 Todo[]
}对比两种边界策略:
| 方案 | 编译期 | 运行时 | 适用 |
|---|---|---|---|
as 断言 | 有类型 | 无验证 | 内部可信数据 |
| unknown + 守卫 | 有类型 | 有验证 | 一切外部输入 |
铁律:信任边界上(网络、localStorage、URL 参数、postMessage)一律 unknown 进、守卫验、再放行。这与 Java 后端对入参做 Bean Validation 是同构思想。大型项目会用 zod 这类库实现”schema 即类型源”,避免手写守卫与接口两份维护。
5. 收获总结:TS 在无框架项目里的价值边界
回顾本次重构,真实获得的:
- 状态机约束:Filter 与未来的任何枚举状态都不可能被赋脏值
- DOM 类型:querySelector 泛型 + 事件对象自动推断,元素属性访问零猜测
- 契约强制:dataset 字符串必须显式转换校验才能进入业务流,一类 bug 直接绝迹
- 重构信心:给 Todo 加字段后,所有遗漏处理的代码位置全部标红
- 存储复用:LocalStore<T> 一处编写处处使用
同样要认清的边界:
- TS 不能替代测试:逻辑错误(该删 A 却删了 B)类型正确也照样发生
- 运行时数据仍是盲区:类型只在编译期存在,边界守卫必须自己写或引库
- 小型原型项目里,标注成本可能超过收益——价值随项目规模和协作人数增长
- 类型体操要克制:能推断的不手写,工具类型够用就不上条件类型
一句话定位:TS 把 JS 从”运行时试错”推进到”编译期防错”,但防的只是”形状错误”;行为正确性仍靠设计与测试。
6. 下一步:框架中的 TS 预告
今天手写的”状态 + 订阅 + 渲染”三件套,正是框架内置能力的朴素版。带着这套理解进入框架会非常顺畅:
- Vue3 单文件组件(SFC):
<script setup lang="ts">中 defineProps/defineEmits 全面泛型化,props 类型直达模板;响应式系统接管了手写的 subscribe - React + TSX:组件即函数,Props 接口即签名;事件处理器类型自动推导
- 两者的共同叙事:把你今天手动维护的类型闭环自动化
入口推荐 Vue3 基础,第一个练习就是把本项目再次重写为 Vue 组件版,对比三者(JS 版、TS 版、框架版)的代码量与出错率,你会对”框架到底解决了什么”有第一手结论。