Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/TypeScript 深度教程/第10章

第10章:综合实战 —— Node 服务与前端框架类型设计

4 分钟 · 更新于 2026-09-01

第10章:综合实战 —— Node 服务与前端框架类型设计

本章用一个任务管理系统串起 Node.js、React、Vue 与共享协议。重点不是框架 API 大全,而是类型安全边界:请求进入时验证、领域内部保持可信、响应离开时保持稳定。


一、项目结构

text
task-workspace/
├── packages/
│   ├── contracts/
│   ├── server/
│   ├── react-app/
│   └── vue-app/
└── tsconfig.base.json

contracts 只放稳定协议和校验逻辑,不直接共享数据库模型或框架组件。

二、共享协议

ts
export const taskStatuses = ["todo", "doing", "done"] as const;
export type TaskStatus = (typeof taskStatuses)[number];

export interface TaskDto {
  id: string;
  title: string;
  status: TaskStatus;
  createdAt: string;
}

export interface CreateTaskInput {
  title: string;
}

日期在 JSON 中传字符串,而不是假装网络会传递 Date 实例。

三、运行时校验

ts
export function parseCreateTaskInput(value: unknown): CreateTaskInput {
  if (typeof value !== "object" || value === null) {
    throw new Error("请求体必须是对象");
  }

  const record = value as Record<string, unknown>;
  if (typeof record.title !== "string" || record.title.trim() === "") {
    throw new Error("title 不能为空");
  }

  return { title: record.title.trim() };
}

真实项目可用 schema 库减少重复。原则是:外部数据先是 unknown,通过校验后才进入领域模型。

四、Node.js 服务层

ts
interface TaskRepository {
  list(): Promise<Task[]>;
  save(task: Task): Promise<void>;
}

class TaskService {
  constructor(private readonly repository: TaskRepository) {}

  async create(input: CreateTaskInput): Promise<Task> {
    const task: Task = {
      id: crypto.randomUUID(),
      title: input.title,
      status: "todo",
      createdAt: new Date()
    };
    await this.repository.save(task);
    return task;
  }
}

领域模型可以使用 Date,但响应 DTO 负责序列化:

ts
function toTaskDto(task: Task): TaskDto {
  return {
    ...task,
    createdAt: task.createdAt.toISOString()
  };
}

五、HTTP 路由边界

伪代码:

ts
async function createTaskHandler(request: Request): Promise<Response> {
  try {
    const body: unknown = await request.json();
    const input = parseCreateTaskInput(body);
    const task = await taskService.create(input);
    return Response.json(toTaskDto(task), { status: 201 });
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : "未知错误";
    return Response.json({ code: "BAD_REQUEST", message }, { status: 400 });
  }
}

不要把框架的请求体类型直接相信为领域类型。

六、类型安全 API Client

ts
async function requestJson<T>(
  url: string,
  init: RequestInit,
  parse: (value: unknown) => T
): Promise<T> {
  const response = await fetch(url, init);
  const body: unknown = await response.json();

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return parse(body);
}

泛型 T 本身不会验证响应,因此把解析函数作为参数显式注入。

七、React Props 与状态

tsx
interface TaskItemProps {
  task: TaskDto;
  onStatusChange(taskId: string, status: TaskStatus): void;
}

function TaskItem({ task, onStatusChange }: TaskItemProps) {
  return (
    <button onClick={() => onStatusChange(task.id, "done")}>
      {task.title} - {task.status}
    </button>
  );
}

异步页面状态使用判别联合:

ts
type TasksState =
  | { status: "loading" }
  | { status: "success"; tasks: TaskDto[] }
  | { status: "error"; message: string };

避免 loading + data? + error? 的矛盾组合。

八、React 事件与 Hook

tsx
const [title, setTitle] = useState("");

function handleSubmit(event: React.FormEvent<HTMLFormElement>): void {
  event.preventDefault();
  // 提交
}

常见原则:

  • Props 是组件公开 API,应明确命名。
  • 能推断的 useState 不必重复写泛型。
  • 初始值为空且后续有值时,显式联合类型。
  • useRef 区分 DOM 引用与可变容器。
  • 自定义 Hook 返回对象通常比长元组更可读。

九、Vue 3 Props 与 emits

vue
<script setup lang="ts">
import type { TaskDto, TaskStatus } from "@workspace/contracts";

const props = defineProps<{
  task: TaskDto;
}>();

const emit = defineEmits<{
  change: [taskId: string, status: TaskStatus];
}>();
</script>

<template>
  <button @click="emit('change', props.task.id, 'done')">
    {{ props.task.title }}
  </button>
</template>

组合式 API 中:

  • ref 适合单值和需要整体替换的状态。
  • reactive 适合对象状态,但解构时注意响应性。
  • Props 不应由子组件直接修改。
  • emits 是组件输出契约,也应类型化。

十、前端状态机

提交任务时可能有:

ts
type SubmitState =
  | { status: "idle" }
  | { status: "submitting" }
  | { status: "success"; task: TaskDto }
  | { status: "error"; message: string };

这比三个独立状态变量更容易确保 UI 分支完整。复杂业务还可把事件也建模,并集中在 reducer 或状态机中转换。

十一、错误协议

ts
interface ApiErrorDto {
  code: "BAD_REQUEST" | "NOT_FOUND" | "INTERNAL_ERROR";
  message: string;
  requestId?: string;
}

服务端内部错误不应原样泄露给客户端。客户端根据稳定 code 决定交互,message 用于展示或诊断。

十二、测试策略

  • contracts:校验器和类型契约测试。
  • server:领域服务单测、仓储集成测试、HTTP 边界测试。
  • React/Vue:组件交互测试,不只测内部实现。
  • 端到端:创建任务、状态更新、错误提示。
  • 消费测试:确认前端使用的是发布后的协议包。

十三、常见误区

  1. 前后端共享数据库实体而非稳定 DTO。
  2. API Client 只写 <T>,没有运行时解析。
  3. 日期字段在 JSON 中被标成 Date
  4. React/Vue 页面用多个布尔值表达异步状态。
  5. 组件 Props 使用宽泛对象或 any
  6. 服务端把内部异常堆栈直接返回客户端。
  7. 把框架类型传入领域层,造成基础逻辑依赖框架。
  8. 所有包互相引用,contracts 逐渐变成杂物箱。

十四、实践练习

  1. 完成 Task 的运行时解析器和序列化器。
  2. 为服务层注入内存仓储并测试创建任务。
  3. 实现带解析函数的 API Client。
  4. 用判别联合实现 React 或 Vue 的列表加载状态。
  5. 为错误协议增加 requestId 并贯穿服务端和客户端。
  6. 将领域模型、DTO 和数据库模型拆开,说明各自职责。

十五、总结

  1. 类型安全系统的关键是边界,而不是共享最多类型。
  2. 网络数据先按 unknown 处理,验证后进入可信领域。
  3. DTO、领域模型和数据库模型可以不同。
  4. 泛型 API Client 不等于运行时校验。
  5. React Props、Vue Props/emits 和页面状态都应成为明确契约。
  6. 框架应依赖领域能力,领域核心不要依赖框架细节。

请继续阅读:第11章:兼容性与推断。


原始资料引用


  • 第9章:大型工程
  • 第11章:兼容性与推断

本章目录
一、项目结构二、共享协议三、运行时校验四、Node.js 服务层五、HTTP 路由边界六、类型安全 API Client七、React Props 与状态八、React 事件与 Hook九、Vue 3 Props 与 emits十、前端状态机十一、错误协议十二、测试策略十三、常见误区十四、实践练习十五、总结原始资料引用Related Documents
苏ICP备2025204887号-2