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.json

1.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 版的三点升级:

  • idDate.now() 数字升级为 crypto.randomUUID() 字符串,杜绝撞 id
  • Filter 由裸字符串变为字面量联合,非法筛选值在编译期就被拒绝(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 版、框架版)的代码量与出错率,你会对”框架到底解决了什么”有第一手结论。