从一个颜色选择器说起:我终于整明白了 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 两层,感受一下变化。搞懂了或者有别的想法,记得回来留个言,我也想看看你们平时是怎么组织项目结构的。

相关推荐
赵大仁1 小时前
浏览器端 RAG:Transformers.js + WebGPU 本地检索入门
前端·ai·react·webgpu·rag
木叶丸1 小时前
从 Loop 到 Graph:AI 智能体协作系统工程指南
前端·后端·架构
吴懿不在 不负信仰3 小时前
从jQuery谈库与框架的设计之优劣
前端·javascript·jquery
灵析表格3 小时前
灵析表格手机号处理函数深度分析报告
前端·网络·json·wps·灵析表格·excel公式盒子
智海深蓝3 小时前
智慧渔业海上养殖数字孪生实践方向与难点拆解分析
java·前端·网络
丁引3 小时前
《数据清洗的艺术:如何用20行核心逻辑优雅地删除无标签图片》
前端·数据库·python
Bigger3 小时前
Han:一个让 AI Agent 也能做出高级中国风页面的 CSS 设计系统
前端·ai编程·设计
梦想的旅途23 小时前
企业微信自动化:自动发送文本、图片、文件
前端·数据库·microsoft
MartinYeung54 小时前
npm爆发大规模供应链攻击 蠕虫污染 2000+ 个包版本: 深度技术剖析
前端·npm·node.js