使用 TypeScript、Zustand 和 Typed Services 构建可预测的 React 基线
一个简单的 React、TypeScript 和 Zustand 框架,实现了存储层、带类型标注的 API 服务与组件的分离,并提供了关于结构设计、异步状态处理及测试方面的规范。
新项目的第一个界面虽然很小,但其中关于状态存储位置、数据获取方式以及类型约束的决策,会决定后续所有内容的走向。一个简单的“Hello App”正是确定这些要素的理想场所。本指南将使用 React、TypeScript 和 Zustand 来构建这样一个应用,解释框架各组成部分的职责,并将这些基础规范转化为随着代码库扩展时可应用的规则。
为何选择 React、TypeScript 和 Zustand 结合使用
每种工具都负责不同的方面:
- React 提供声明式 UI、成熟的生态系统和强大的工具支持,而组件组合方式则提升了代码的可读性。
- TypeScript 能在编译时发现错误,让代码重构更加安全,并将组件的属性和状态结构转化为具有自解释功能的契约。
先从这三个要素加上一个路由器开始,只有在出现具体需求时再添加相应的库。
核心结构:存储层、服务层和组件
下面的示例虽以一个列表形式呈现,但实际上包含三个文件,每个文件负责一项功能。store/counterStore.ts定义了一个带类型的Zustand存储,其中包含count、loading标志、同步的increment操作以及异步的loadInitial操作。services/counterApi.ts将HTTP请求封装在带类型的函数中,当收到非正常响应时会抛出异常。App.tsx通过选择器读取各个值,并在效应函数中触发初始数据加载。
注意三点。该商店从不直接调用 fetch,而是将其委托给服务层,这样网络层就可以独立地被替换或模拟。finally 块确保即使请求失败,loading 状态也会被重置。此外,该组件通过各自的选择器来监听每个字段,因此只有当其实际使用的值发生变化时才会重新渲染。
// store/counterStore.ts
import { create } from "zustand"
type CounterState = {
count: number
loading: boolean
increment: () => void
loadInitial: () => Promise<void>
}
export const useCounter = create<CounterState>((set, get) => ({
count: 0,
loading: false,
increment: () => set({ count: get().count + 1 }),
loadInitial: async () => {
set({ loading: true })
try {
const value = await fetchInitialCount()
set({ count: value })
} finally {
set({ loading: false })
}
},
}))
// services/counterApi.ts
export type CounterResponse = { value: number }
export async function fetchInitialCount(): Promise<number> {
const res = await fetch("/api/counter")
if (!res.ok) throw new Error("Failed to load")
const data = (await res.json()) as CounterResponse
return data.value
}
// App.tsx
import React, { useEffect } from "react"
import { useCounter } from "./store/counterStore"
export default function App() {
const count = useCounter(s => s.count)
const loading = useCounter(s => s.loading)
const increment = useCounter(s => s.increment)
const loadInitial = useCounter(s => s.loadInitial)
useEffect(() => {
void loadInitial()
}, [loadInitial])
return (
<main>
<h1>Hello App</h1>
<p>{loading ? "Loading..." : `Count: ${count}`}</p>
<button onClick={increment} disabled={loading}>
Increment
</button>
</main>
)
}
在将此代码应用到实际项目中之前,还有几处细节需要完善。作为独立文件时,store 需要明确从服务模块导入 fetchInitialCount 函数。increment 方法通过 get() 读取当前值;而函数式写法 set((s) => ({ count: s.count + 1 })) 能表达相同的功能,且是更常见的实现方式。此外,当前请求失败时只会终止加载并重新抛出错误,由于相关逻辑使用 void 弃掉了承诺对象,导致错误无法被处理;若在 store 中添加 error 字段并在那里捕获错误,就能让界面显示真实的错误信息。
随着规模扩大仍保持清晰的结构
应按功能而非文件类型对代码进行分组。为每个功能创建一个文件夹,其中包含该功能的存储逻辑、类型定义及用户界面,这样的结构比那些每次修改都需涉及的顶层 components、utils 和 services 目录更易于管理。共享的实用工具和设计系统则应拥有独立的模块。如需进一步了解各种布局的对比,可参阅选择 React 文件夹结构一文。
TypeScript 作为契约层
将类型视为每个模块公共 API 的一部分。导出使用者所需的类型,将内部类型保留为私有。从一开始就启用 strict(其中包含 noImplicitAny)以及严格的 JSX 设置;事后再添加严格性约束会困难得多。使用 Pick、Omit 和 ReturnType 等工具类型,以确保派生类型保持一致,并为选择器添加类型注解。
无缝使用 Zustand
按领域将状态拆分为多个小型存储,例如 authStore 和 todosStore,每个都通过 create 方法创建。请保持选择器范围较小:仅选择单个字段可避免无关字段变化时引发重新渲染,而每次调用都选择新构建的对象则可能导致额外渲染,除非使用浅层相等性检测工具。Zustand 不要求使用 reducer,因此只要通过生成新值而非直接修改状态,更新操作就能保持简洁且可预测。
组件与表单模式
将容器与展示组件分开。容器负责与存储交互并处理逻辑;展示组件则接收类型化的属性,保持纯粹性且易于测试。对于简单表单,受控输入即可;而对于复杂表单,使用如 react-hook-form 这样的轻量级库并结合支持 TypeScript 的架构,可以确保验证逻辑与类型的一致性。只有在性能分析显示有提升空间时才使用 React.memo、useMemo 和 useCallback。
异步操作与副作用
将每个 API 调用放入类型化的服务中,让存储层仅保留界面所需的状态并调用这些服务。在存储层中追踪请求的状态、加载情况以及错误与成功信息,这样界面就能始终反映实际发生的情况。使用 AbortController 取消过期的长时间请求,并在存储层中保留请求 ID 或新鲜度标记,以防止旧响应覆盖新响应。
测试与代码质量
针对核心业务逻辑和存储选择器的单元测试成本较低且能快速见到成效。将 ESLint 与支持 TypeScript 的规则以及 Prettier 结合使用,并在提交前的钩子中自动运行它们。通过类型化的测试数据生成工具创建测试用例和 Storybook 数据,这样当模型发生变化时就能在编译阶段发现错误。
无需重写即可扩展
垂直扩展功能:每新增一项特性就需要创建一个新的文件夹,用于存储和路由处理,而本地化与主题功能则属于独立的模块。当 API 或数据模型发生变化时,让编译器指出所有违反契约的地方。只有在真正有需求时才引入缓存、数据规范化及乐观更新机制;如果服务器状态开始占据主导地位,使用专门的数据获取库往往比 Zustand 更为合适。
核心要点
- 即便在最小的应用中,也应将状态管理、服务逻辑和组件分别放在独立的专用模块中。
- 通过精确的选择器读取状态,并明确处理数据加载与错误状态。
- 尽早启用严格的 TypeScript,利用类型来界定和约束模块边界。
- 按功能进行组织,仅在必要时添加依赖项,并在经过测量后进行优化。