从一个颜色选择器说起:我终于整明白了 React+TS 里的 model 与 api 分层

上周摸鱼写个小 demo,要做个 RGB 颜色选择器,再加个成员信息列表。上来啥也没想,组件咔咔一顿写,写着写着就不对劲了 ------ 同一个 Color 类型,我在 App 里写了一遍,ColorPicker 里又写了一遍,改个字段名改了三处还漏了一处,运行半天颜色显示不对。折腾了一下午,从堆代码到慢慢拆分层,才算把这事捋明白。

先从那个堆出来的颜色选择器说起

最开始想法很简单:三个滑动条,分别控制红、绿、蓝三个通道,取值 0 到 255,左边放个方块实时显示颜色。这不就是最基础的受控组件嘛,我闭着眼都能写。

第一版:所有东西都塞在一个文件里

第一版我直接把所有逻辑都堆在 App.tsx 里,类型也当场定义,组件也当场写。大概长这样:

tsx 复制代码
import { useState } from "react";

// 颜色类型,随手就写在这了
interface Color {
  red: number;
  green: number;
  blue: number;
}

function App() {
  const [color, setColor] = useState<Color>({
    red: 20,
    green: 40,
    blue: 180,
  });

  return (
    <div>
      {/* 色块直接写在这 */}
      <div style={{
        width: '11rem',
        height: '7rem',
        backgroundColor: `rgb(${color.red}, ${color.green}, ${color.blue})`
      }}></div>

      {/* 三个滑块也写在这 */}
      <input type="range" min="0" max="255" value={color.red}
        onChange={e => setColor({...color, red: +e.target.value})}
      />
      {/* 剩下两个滑块同理,就不重复贴了 */}
    </div>
  )
}

写完跑起来一看,没问题,滑块拖一拖,颜色跟着变。我当时还想,这有啥难的,还分层?纯纯脱裤子放屁。

第一个麻烦:类型到处复制,改一个漏三个

后来我想把代码拆得像样点,把色块拆成 ColorBrowser 组件,滑块拆成 ColorPicker 组件。拆完我就傻了:两个组件都要用到 Color 类型,怎么办?

我当时的第一反应是,复制粘贴呗。ColorBrowser 里写一份 interface Color,ColorPicker 里再写一份,App 里再留一份。反正也就三行代码的事。

结果当天晚上我想加个透明度通道,把 RGB 改成 RGBA。改完 App 里的类型,去改 ColorBrowser,改完 ColorBrowser 转头就忘了 ColorPicker 里还有一份。运行起来直接报错,我对着控制台看了十分钟,才反应过来是类型没改全。

说实话那一刻我突然就懂了,为什么大项目里没人把类型散着写。你写的时候是省事,改的时候就是灾难。

把类型收进 model 层,世界终于清净了

想起之前看别人的 React+TS 项目,根目录下总有个 model 文件夹,以前不知道是干嘛的,那天突然就通了。我照着样子建了个 model 目录,把所有数据类型都扔了进去。

model 层到底是干嘛的?我给你打个比方

很多教程会说 "model 是数据模型层",听着挺玄乎。其实说白了特别好理解:model 就是你项目里的 "数据字典"。所有你会用到的数据长什么样、有哪些字段、是什么类型,全在这统一登记。

就像奶茶店的配料表,珍珠多大颗、糖度分几档、茶底有哪几种,全写在一张表里贴在后厨。每个做奶茶的员工都看这张表,不用自己瞎定义,就不会出现你做的三分糖比别人五分糖还甜的情况。

放到我们这个小项目里,颜色对象有 red、green、blue 三个数字字段,成员对象有 id、login、avatar_url 三个字段,这些都是 "数据规格",都应该放进 model 里统一管理。

抽完之后的代码长啥样

我建了两个文件,分别放颜色和成员的类型定义。

先是 model/color.ts

typescript 复制代码
    // 颜色数据结构,全项目共用
    // 以后要加alpha通道,改这一处就行
    export interface Color {
        red: number;
        green: number;
        blue: number;
    }

然后是 model/member.ts

typescript 复制代码
    // 成员信息数据结构
    // 接口返回、组件props里用的member,全遵循这个定义
    export interface Member {
        id: number;
        login: string;
        avatar_url: string;
    }

就这么简单?就这么简单。但效果立竿见影。

拆完之后,ColorBrowser 组件里不用自己写类型了,直接从 model 引入:

tsx 复制代码
    import * as React from 'react';
    import type { Color } from '../model/color';

    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}></div>
    }

    export default ColorBrowser;

ColorPicker 也是一样,props 里的 color 和回调函数的参数,全用统一的 Color 类型。

tsx 复制代码
    import * as React from 'react';
    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, // 注意这行,坑了我半小时
                })}
                ></input>
                {props.color.red}
                <br />
                {/* 绿、蓝通道同理 */}
            </div>
        )
    }

    export default ColorPicker;

小提醒如果项目大、业务模块多,可以在 model 里再按模块拆分子文件夹,比如 model/user、model/order,避免一个文件堆几十上百个接口,找起来费劲。

这时候再改需求就舒服了:想加 alpha 通道?打开 model/color.ts 加一行 alpha: number,剩下的地方 TS 会自动给你提示哪里缺了字段,根本不用担心漏改。

跑起来的效果就是这样,左边色块,右边三个滑块,数值分别对应 RGB 三个通道:

成员列表又给我上了一课:接口别散在组件里

颜色选择器搞定了,接着写成员列表。本来以为更简单,不就是发个请求拿数据,渲染成表格嘛。结果写着写着,又觉得别扭了。

一开始我把请求直接写在 useEffect 里

最开始我图省事,直接在 MemberTable 组件的 useEffect 里写请求,连模拟数据都塞在组件里面:

tsx 复制代码
    // 反面教材:接口逻辑直接写在组件里
    const MemberTable: React.FC = () => {
        const [memberCollection, setMemberCollection] = React.useState<Member[]>([]);

        React.useEffect(() => {
            // 直接在组件里模拟接口请求
            setTimeout(() => {
                setMemberCollection([
                    {
                        id: 1457912,
                        login: "brauliodiez",
                        avatar_url: "https://xxx/avatar.webp"
                    },
                    // 第二条数据...
                ])
            }, 500)
        }, [])

        return <table>{/* 渲染表格 */}</table>
    }

写完我就皱眉头了。这写法问题太大了:

  • 要是别的组件也要用这份成员数据,我就得把这段 setTimeout 再复制一遍;
  • 以后要接真实后端接口,我得钻进组件里去改逻辑;
  • 要是接口要加统一的错误处理、loading 状态,每个组件都得写一遍。

这不就是和之前散着写类型一样的毛病吗?类型可以抽成 model,那接口请求是不是也能抽一层?

抽成 api 层之后,改接口再也不用翻组件了

答案当然是可以。这就是很多项目里都会有的 api 层 ------所有和后端打交道的方法,都统一放在 api 目录下管理。组件只负责调用方法拿数据,不用关心数据是模拟的、还是从真实接口来的,也不用管请求地址、超时时间这些细节。

我建了个 api/memberApi.ts,把成员相关的接口都放进去:

typescript 复制代码
    import { type Member } from '../model/member';

    // 获取成员列表的接口方法
    // 组件只需要调用这个方法,不用管内部实现
    export const getMemberCollection = (): Promise<Member[]> => {
        return new Promise((resolve, reject) => {
            // 现在是用setTimeout模拟数据
            // 以后接真实接口,直接换成axios.get就行
            setTimeout(() => {
                resolve([
                    {
                        id: 1457912,
                        login: "brauliodiez",
                        avatar_url: "https://p6-passport.byteacctimg.com/img/user-avatar/1980675dbdbf6a122febac01a4c5ab4f~70x70.awebp"
                    },
                    {
                        id: 4374977,
                        login: "Nasdan",
                        avatar_url: "https://p6-passport.byteacctimg.com/img/user-avatar/1980675dbdbf6a122febac01a4c5ab4f~70x70.awebp"
                    }
                ])
            }, 500)
        })
    }

你看,方法返回的是 Promise<Member \[\]>,直接复用了 model 里定义的 Member 类型,连返回值类型都不用自己再写一遍。这就是分层配合的好处。

然后组件里就干净了,只管调用:

tsx 复制代码
    import * as React from 'react';
    import { type Member } from '../model/member';
    import { getMemberCollection } from '../api/memberApi';

    const MemberTable: React.FC = () => {
        const [memberCollection, setMemberCollection] = React.useState<Member[]>([]);

        React.useEffect(() => {
            // 挂载后请求接口,组件不关心接口内部怎么实现
            (async () => {
                const members = await getMemberCollection();
                setMemberCollection(members);
            })()
        }, [])

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

组件里再也没有乱七八糟的请求逻辑了,只负责 "拿到数据→渲染页面" 这一件事。以后接口地址变了、返回格式改了、要加请求拦截,都去 api 层处理,组件代码纹丝不动。

踩过的几个坑,你们别再踩了

整个过程踩了好几个不大不小的坑,说出来给大家避避雷,都是我实打实踩过的。

坑 1:滑块的 value 是字符串,直接塞给 number 类型会炸

就是 ColorPicker 里那行 red: + event.target.value,前面的加号我一开始忘了写。

当时我想当然觉得,input 的 min 和 max 都是数字,value 肯定也是数字啊。结果一写,TS 直接飘红,说 string 不能赋值给 number。我还愣了一下,去打印了一下 typeof event.target.value,果然是 string。

注意所有表单元素的 value,哪怕你设置的是数字范围,取出来的也都是字符串类型。用在 number 类型的字段上,一定要记得转类型,要么用 + 号,要么用 Number ()。

别问我为什么印象这么深,一开始我还吐槽 TS 事儿多,后来想想要是没 TS,运行的时候颜色显示不对,我指不定得对着 DOM 找多久 bug。

坑 2:子组件 props 不加类型,TS 等于白用

写 MemberRow 子组件的时候,我图省事直接写成了:

tsx 复制代码
    // 反面教材:props不写类型
    const MemberRow = (props) => {
        const { member } = props;
        return (
            <tr>
                <td><img src={member.avatar_url} style={{maxWidth:'70px'}}></img></td>
                <td><span>{member.id}</span></td>
                <td><span>{member.name}</span></td> {/* 这里写错了! */}
            </tr>
        )
    }

我把字段写成了 member.name,但实际接口返回的是 login。因为 props 没加类型,TS 啥也没提示,运行起来 Name 列全是空的。我对着接口数据看了半天,才发现字段名写错了。

后来老老实实加上类型:

tsx 复制代码
    interface MemberRowProps {
        member: Member;
    }

    const MemberRow = (props: MemberRowProps) => {
        // ...
    }

刚敲完 member.name,TS 直接就标红了,说 Member 类型上不存在 name 属性。你看,这就是 TS 的价值 ------ 不是为了让你多写几行代码,是帮你在写代码的时候就把低级错误掐死。

改完之后跑起来,表格就正常了:

坑 3:想当然去解析图片内容,闹了个乌龙

说个特别丢人的事。最开始拿到头像的 url,我脑子不知道哪根弦搭错了,想着要不要先把图片内容拉下来解析一下再渲染,结果直接报了个错:

文件内容是纯图片,目前无法解析出文本信息

我盯着报错愣了半分钟,才拍了下脑袋 ------img 标签直接 src 不就完了,我搁这解析啥呢?

说白了就是想复杂了。前端渲染图片,老老实实交给 img 标签就行,别总想着自己去处理文件内容。原生标签能搞定的事,就别瞎折腾。

聊点实在的:分层到底值不值?

折腾完这一圈,我估计很多人会问:就这么个小 demo,还要建 model 和 api 两个文件夹,是不是过度设计了?

说实话,我一开始也是这么想的。

什么时候该拆,什么时候别瞎拆

分层这东西,从来不是 "有就比没有好"。

  • 如果你的项目就是个单页面小工具,两三个组件,写完就扔,那完全没必要分层,所有东西写一块反而快。
  • 但如果是团队协作的长期项目,组件会越来越多,数据结构会反复复用,接口会经常调整,那分层就非常有必要。

我自己的判断标准很简单:当你开始复制粘贴同一段类型定义、同一段接口代码的时候,就该考虑抽一层了。

别上来就套架构,把简单的事情搞复杂。也别死活不拆,最后代码乱成一锅粥,改都没法改。架构是用来解决问题的,不是用来炫技的。

我自己的三点小收获

这次写 demo 看着简单,折腾完还是挺有感触的。

第一,TS 的威力要靠统一的类型定义才能发挥出来。类型散在各个组件里,你只会觉得 TS 又麻烦又啰嗦;把类型收到 model 层统一管理,类型提示、错误校验、自动补全才真正能帮你提效。

第二,api 层本质上是把 "变化" 隔离起来。业务需求会变,后端接口会变,网络请求方式会变,但组件的核心逻辑 ------ 拿数据、渲染页面 ------ 是稳定的。把容易变的部分抽出去,稳定的部分就不容易被影响。

第三,最好的架构都是慢慢长出来的。不用一开始就设计得完美无缺,先写起来,觉得哪里别扭了、重复了,再去拆、去重构。写代码和收拾屋子一样,不是一次收拾完就一劳永逸,是边用边整理,慢慢就顺了。

最后说两句

其实最开始我看别人的 React+TS 项目,一堆文件夹,model、api、components、utils,头都大。总觉得这些都是 "大项目才用的东西",小 demo 用不着。真自己动手写一遍,踩一遍复制粘贴的坑,才明白每一层存在的意义。

很多技术点都是这样,看别人讲十遍原理,不如自己亲手踩一遍坑印象深。

要是你也刚接触 TS 项目的分层,不妨自己写个小 demo 试试,从堆代码开始,拆成 model 和 api 两层,感受一下变化。搞懂了或者有别的想法,记得回来留个言,我也想看看你们平时是怎么组织项目结构的。

相关推荐
AlienZHOU8 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc11 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师13 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen13 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒13 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow14 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81814 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007814 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明14 小时前
第 6 章 组件进阶
前端·vue.js
江华森14 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端