🎨 从零搭建 React + TypeScript + Vite 项目:以 Color Picker 为例,聊聊前端工程化的 model/api 分层架构

前言

最近在带团队做前端项目时,发现很多初级前端开发者有一个通病:拿到需求就写组件,写完组件就堆状态,堆完状态就调接口 。项目跑到后期,代码里到处散落着 any 类型、重复的接口定义、以及谁也看不懂的数据流。

这篇文章以一个看似简单的 RGB 拾色器(Color Picker) 项目为例,聊聊我在实际项目中沉淀下来的一套轻量级前端架构思路------model/api 分层 + TypeScript 类型约束

项目源码:GitHub | 技术栈:React 19 + TypeScript 6 + Vite 8


一、先看成品

我们的 Color Picker 长这样:

三个滑块分别控制 RGB 三个通道(0-255),上方色块实时预览当前颜色,下方还有一个模拟异步请求的成员列表。

功能虽简单,但代码结构一点都不"简单"------它是一套可复用到大型项目的工程模板。


二、为什么选 TypeScript?

如果你写过 10 个以上 React 组件的中型项目,大概率遇到过这种场景:

jsx 复制代码
// 😱 没有类型约束的噩梦
function ColorBrowser({ color }) {
  // color 是什么结构?red 是 number 还是 string?
  // 传错了怎么办?
  return <div style={{ backgroundColor: `rgb(${color.red}, ...)` }} />
}

TypeScript 解决的核心问题不是"语法检查",而是"团队协作中的认知对齐"。

当项目有 5 个以上的开发者、100 个以上的组件时,类型系统就是活文档------你不需要去翻 README、不需要问同事,IDE 会直接告诉你这个 props 长什么样。


三、model 层:前端数据模型的"单一真理来源"

3.1 什么是 model?

在传统前端架构中,我们习惯把"数据模型"放在后端。但现代前端应用越来越复杂,前端也需要自己的领域模型

在我的架构中,model/ 目录专门放 TypeScript 接口定义------它就是前端所有数据结构的"宪法"。

bash 复制代码
src/
├── model/
│   ├── color.ts      # 颜色数据模型
│   └── member.ts     # 成员数据模型
├── api/
│   └── memberApi.ts  # 接口层
├── components/
│   ├── ColorPicker.tsx
│   ├── ColorBrowser.tsx
│   └── MemberTable.tsx
└── App.tsx

3.2 定义 Color 模型

typescript 复制代码
// src/model/color.ts
// 数据接口------项目中被多处引用
// model 是项目架构的核心目录之一

export interface Color {
  red: number;
  green: number;
  blue: number;
}

就这么简单。 但它的价值在于:

  • 一处定义,全局引用 ------5 个组件、10 个组件都在用同一个 Color 类型
  • 改类型即改全局 ------如果将来 RGB 扩展为 RGBA,只需在这一个地方加 alpha?: number
  • IDE 自动补全和类型检查------拼错字段名?编译不过

3.3 定义 Member 模型

typescript 复制代码
// src/model/member.ts
export interface MemberEntity {
  id: number;
  login: string;
  avatar_url: string;
}

同样简洁,同样重要。这个接口既约束了 API 返回的数据格式,也约束了组件渲染需要的数据格式。

核心认知 :model 不是"后端接口返回什么我就定义什么",而是"前端需要什么数据,我就定义什么模型"。后端接口的字段名和前端模型可以不同------那是 api 层要做的事情(本文暂不展开)。


四、api 层:统一接口管理,告别散落的 fetch

4.1 为什么需要 api 层?

有多少项目是这么写接口的:

tsx 复制代码
// ❌ 散落在组件里的接口调用
function MemberTable() {
  useEffect(() => {
    fetch('/api/members')
      .then(res => res.json())
      .then(data => setMembers(data))  // data 是什么类型?不知道
  }, [])
}

问题很明显

  1. 接口地址散落在各个组件里,改一个 URL 要全局搜索
  2. 返回值没有类型约束,dataany
  3. 没法统一做错误处理、请求拦截、缓存策略

4.2 api 层的实践

typescript 复制代码
// src/api/memberApi.ts
// 接口文件------前端需要的接口都在这里模块化定义
// 统一接口声明,好管理(application interface)

import { type MemberEntity } from "../model/member";

export const getMembersCollection = (): Promise<MemberEntity[]> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        {
          id: 1457912,
          login: "brauliodiez",
          avatar_url: "https://avatars.githubusercontent.com/u/1457912?v=3"
        },
        {
          id: 4374977,
          login: "Nasdan",
          avatar_url: "https://avatars.githubusercontent.com/u/4374977?v=3"
        }
      ])
    }, 500)
  })
}

亮点:

  • 返回类型明确声明Promise<MemberEntity[]>------调用方不用猜
  • 模块化:一个 api 文件对应一个业务域
  • 可以随时切换实现 :现在是 mock 数据,将来换成 fetch() 调用,组件一行不改

五、组件层:类型安全的 Props 设计

5.1 ColorPicker------受控组件的正确打开方式

tsx 复制代码
// src/components/ColorPicker.tsx
import { type Color } from '../model/color';

interface Props {
  color: Color;
  onColorUpdated: (color: Color) => void;
}

const ColorPicker: React.FC<Props> = (props) => {
  return (
    <div>
      <input
        type="range"
        min="0"
        max="255"
        value={props.color.red}
        onChange={event => props.onColorUpdated({
          ...props.color,      // 展开旧状态
          red: +event.target.value,  // 只更新 red 通道
        })}
      />
      {props.color.red}
      {/* green 和 blue 同理 */}
    </div>
  )
}

设计要点

  1. 受控组件模式------状态在父组件,ColorPicker 只负责展示和通知
  2. 不可变更新 ------用展开运算符 ...props.color 拷贝旧值,再覆盖修改的字段
  3. +event.target.value ------+ 号将字符串转数字,简洁且类型安全

5.2 ColorBrowser------纯展示组件

tsx 复制代码
// src/components/ColorBrowser.tsx
interface Props {
  color: Color
}

const ColorBrowser: React.FC<Props> = (props) => {
  const divStyle: React.CSSProperties = {
    width: "11rem",
    height: "7rem",
    backgroundColor: `rgb(${props.color.red},${props.color.green},${props.color.blue})`
  }
  return <div style={divStyle} />
}

这里有个容易被忽略的细节divStyle 的类型是 React.CSSProperties。这不是多此一举------当你拼错 backgroundColorbackgroudColor 时,TypeScript 会直接报错。

5.3 MemberTable------异步数据加载的最佳实践

tsx 复制代码
const MemberTable: React.FC = () => {
  const [memberCollection, setMemberCollection] = React.useState<MemberEntity[]>([])

  React.useEffect(() => {
    // 挂载后请求接口,不会阻塞首屏渲染
    (async () => {
      const members = await getMembersCollection();
      setMemberCollection(members);
    })()
  }, [])

  return (
    <table>
      <thead>
        <tr>
          <th>Avatar</th>
          <th>Id</th>
          <th>Name</th>
        </tr>
      </thead>
      <tbody>
        {memberCollection.map((member: MemberEntity) => (
          <MemberRow key={member.id} member={member} />
        ))}
      </tbody>
    </table>
  )
}

关键技巧

  1. useEffect 中的 IIFE ------(async () => { ... })() 是处理异步 Effect 的经典模式(因为 useEffect 的回调不能直接是 async 函数)
  2. 空依赖数组 []------只在组件挂载时执行一次
  3. 初始值是空数组 [] ------渲染前不会报 map of undefined

六、App.tsx------状态的"中央厨房"

tsx 复制代码
function App() {
  // TypeScript 适合大型项目:代码量大、团队成员多
  const [color, setColor] = useState<Color>({
    red: 20,
    green: 240,
    blue: 180
  });

  return (
    <>
      <ColorBrowser color={color} />
      <ColorPicker color={color} onColorUpdated={setColor} />
      <MemberTable />
    </>
  )
}

架构意图

  • 状态提升 ------color 状态在 App 层,ColorBrowser 和 ColorPicker 通过 props 共享
  • 单向数据流 ------ColorPicker 不直接修改 color,而是通过 onColorUpdated 回调通知父组件
  • setColor 直接作为回调传递 ------因为签名完全匹配 (color: Color) => void

到这里,整个应用的数据流就非常清晰了:

scss 复制代码
App (状态持有者)
 ├── ColorBrowser  ← color (只读)
 ├── ColorPicker   ← color + onColorUpdated (读写)
 └── MemberTable   ← 自己管理自己的状态

七、这套架构的核心价值

回顾一下,我们这个"小"项目做到了哪些"大"项目才需要的东西:

能力 实现方式 收益
类型安全 TypeScript + model 层 编译时发现 90% 的字段拼写和类型错误
接口管理 api 层统一封装 改接口不影响组件,方便 mock/真实切换
数据流清晰 受控组件 + 状态提升 数据流向一目了然,debug 不头疼
可维护性 model/api/components 三层分离 新人接手只看目录结构就知道代码在哪

八、总结:好的架构不是"大项目的专利"

很多开发者觉得:

"我就写个小 demo,不需要 TypeScript 吧?" "才 3 个组件,分层干嘛?"

但我的观点是:好的架构习惯,恰恰要在小项目中刻意练习。

就像这个 Color Picker------功能可能你 30 分钟就能写完,但用上 model/api 分层 + TypeScript 后,它变成了一个可直接复用的项目模板。下次你接到一个新需求,直接在这个骨架上填代码就行:

  1. model/ 里定义新的数据模型
  2. api/ 里封装接口方法
  3. components/ 里写组件
  4. App.tsx 里组装

架构不是约束,是脚手架------它让你不用每次都从零思考"代码放哪"。


参考


如果这篇文章对你有帮助,欢迎点赞、收藏、评论 🎉 关于前端架构设计的更多实践,我会持续更新。

相关推荐
阿黎梨梨8 小时前
从“能用”到“好维护”:React + TypeScript 实战指南
react.js
无人生还8 小时前
从 Vue3 到 React · 快速上手系列第 11 篇:状态管理
前端·vue.js·react.js
GuWenyue8 小时前
90%前端写React TS都踩坑!一套父子组件+Hooks完整实战,彻底搞懂类型约束
前端·react.js
张元清8 小时前
React useMeasure Hook:用 ResizeObserver 测量 DOM 元素 (2026)
javascript·react.js
朝阳3910 小时前
react19【实战】配置化路由登录鉴权完整方案
前端·javascript·react.js
名字还没想好☜10 小时前
React 用 IntersectionObserver 实现图片懒加载与无限滚动:封装一个 useInView Hook
前端·javascript·vue.js·react.js·react
朝阳3910 小时前
react19【系列实用教程】setSearchParams
react.js
张元清1 天前
React useDisclosure Hook:管理模态框和抽屉的打开关闭状态 (2026)
javascript·react.js
学高数就犯困1 天前
React:常见的性能优化手段
前端·react.js