🎨 React + TypeScript + Vite 实战:从零构建 Color Picker 应用 --- 完整学习日志
阅读时间 :约 15 分钟 | 难度 :入门 ~ 进阶 | 技术栈:React 19 · TypeScript 6 · Vite 8
📖 前言
最近在学习 React + TypeScript 的工程化开发,于是动手实践了一个小项目 ------ Color Picker(颜色选择器)。这个项目虽然不大,但五脏俱全,涵盖了现代 React 开发的诸多核心概念:
- ✅ TypeScript 类型系统与接口设计
- ✅ 组件化开发与 Props 通信
- ✅
useState状态管理与不可变更新 - ✅
useEffect副作用与异步数据获取 - ✅ CSS 变量 + 暗黑模式适配
- ✅ Vite 构建工具链配置
- ✅ 项目目录结构规范(model / api / components)
本文将 逐文件、逐行 地拆解整个项目,力求让每一位读者都能"看得懂、学得会、用得上"。废话不多说,我们开始!
🏗️ 一、项目架构总览
csharp
color-picker/
├── index.html # 入口 HTML
├── package.json # 依赖与脚本
├── vite.config.ts # Vite 构建配置
├── tsconfig.json # TypeScript 配置入口
├── tsconfig.app.json # 应用 TS 配置
├── tsconfig.node.json # Node 端 TS 配置
├── eslint.config.js # ESLint 代码规范
├── public/
│ ├── favicon.svg # 网站图标
│ └── icons.svg # SVG 图标集
└── src/
├── main.tsx # React 应用挂载入口
├── App.tsx # 根组件
├── App.css # 根组件样式
├── index.css # 全局样式 + CSS 变量
├── model/
│ ├── color.ts # Color 数据模型
│ └── member.ts # MemberEntity 数据模型
├── api/
│ └── memberApi.ts # 模拟 API 接口
└── components/
├── ColorBrowser.tsx # 颜色预览组件
├── ColorPicker.tsx # 颜色选择器组件
└── MemberTable.tsx # 成员列表组件
🎯 核心设计思想 :按职责分层 ------
model定义数据形状,api封装数据获取,components负责 UI 渲染。这种"数据-逻辑-视图"三层分离的架构,是中大型项目的标配。
⚙️ 二、工程化配置解读
2.1 package.json ------ 项目的"身份证"
json
{
"name": "color-picker",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
🔍 关键知识点解读:
| 字段 | 说明 |
|---|---|
"type": "module" |
启用 ES Module 规范,允许使用 import/export 语法,告别 require() |
"private": true |
防止意外发布到 npm 仓库 |
"dev": "vite" |
启动 Vite 开发服务器,享受毫秒级热更新(HMR) |
"build": "tsc -b && vite build" |
先 TypeScript 类型检查,再 Vite 打包。注意顺序:类型检查失败则不会打包,保证产物类型安全 |
react: ^19.2.6 |
本项目使用的是 React 19!带来了更强的服务端组件、更好的 Suspense 等特性 |
typescript: ~6.0.2 |
TypeScript 6.0!这是最新主版本,~ 表示只接受补丁更新 |
📌 学习要点 :
dependenciesvsdevDependencies的区别 ------ 前者是运行时需要的包(react、react-dom),后者是开发/构建时需要的包(TypeScript、Vite、ESLint),不会被打包进最终产物。
2.2 vite.config.ts ------ 构建利器
ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
极其简洁!这得益于 Vite 的"约定优于配置"理念:
@vitejs/plugin-react:提供 React Fast Refresh(热更新时不丢失组件状态)、JSX 编译等能力defineConfig:提供完整的 TypeScript 类型提示,写配置时 IDE 会自动补全
2.3 tsconfig.json ------ TypeScript 编译配置
主配置文件通过 Project References 拆分为两个子配置:
json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
tsconfig.app.json 中值得关注的关键配置:
| 配置项 | 值 | 含义 |
|---|---|---|
target |
es2023 |
编译目标为 ES2023,现代浏览器已全面支持 |
module |
esnext |
使用最新的 ES Module 标准 |
jsx |
react-jsx |
使用 React 17+ 的自动 JSX 运行时 ,无需手动 import React |
moduleResolution |
bundler |
专为打包器(Vite/webpack)设计的模块解析策略 |
verbatimModuleSyntax |
true |
强制使用 import type 导入纯类型,确保类型导入在编译后被完全擦除 |
noUnusedLocals |
true |
未使用的局部变量报错 ------ 保持代码干净 |
noUnusedParameters |
true |
未使用的参数报错 ------ 同上 |
erasableSyntaxOnly |
true |
TS 6.0 新特性 :只允许使用可被擦除的语法(如 enum 不可用,推荐用 const 对象替代) |
💡
"jsx": "react-jsx"这个配置非常实用。在 React 17 之前,每个.tsx文件顶部都需要import React from 'react',有了它之后,编译器会自动注入 JSX 运行时的导入,代码更简洁!
🧱 三、数据模型层(Model)
好的架构从数据建模开始。本项目定义了两个核心模型:
3.1 Color 模型
typescript
// src/model/color.ts
export interface Color {
red: number;
green: number;
blue: number;
}
极简却精妙:
- 使用 RGB 色彩空间 ,每个通道 0~255,与 CSS
rgb()函数天然对应 interface(而非type)更利于后续扩展(可以声明合并)- 三个字段精确描述一种颜色,无冗余、无歧义
3.2 MemberEntity 模型
typescript
// src/model/member.ts
export interface MemberEntity {
id: number;
login: string;
avatar_url: string;
}
模拟的是 GitHub 用户数据:
id:用户唯一标识(数字类型,注意 GitHub API 返回的 id 是number)login:GitHub 用户名avatar_url:头像链接
🎓 最佳实践 :Model 文件只定义类型,不包含任何逻辑。这是"单一职责原则(SRP)"在类型系统上的体现。当项目扩展到几十上百个接口时,你会发现这种分离带来的可维护性优势。
🌐 四、API 层 ------ 模拟异步数据获取
typescript
// src/api/memberApi.ts
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);
})
}
🔍 设计精要:
| 技巧 | 说明 |
|---|---|
import type |
纯类型导入,编译后完全消失,对运行时零影响 |
Promise<MemberEntity[]> |
返回值类型明确,调用方知道会拿到什么 |
setTimeout 500ms |
模拟网络延迟,让 UI 有 loading 感知 |
| 返回完整数据 | 模拟真实 API 的返回形状,方便后期替换为真实 fetch 调用 |
📌 替换为真实 API 只需改一行 :将
getMembersCollection内部改为return fetch('https://api.github.com/orgs/lemoncode/members').then(res => res.json()),其余代码完全不变 ------ 这就是接口抽象的魅力!
🧩 五、组件层 ------ 逐行拆解
5.1 入口文件 main.tsx
tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
🔑 关键点:
createRoot(React 18+ API) :取代旧版的ReactDOM.render,是 Concurrent Mode(并发模式) 的入口!非空断言 :document.getElementById('root')!告诉 TypeScript "这个元素一定存在",避免返回HTMLElement | null的类型守卫<StrictMode>:开发环境下的严格模式,会故意双重调用某些生命周期方法,帮助你发现副作用问题./index.css:全局样式的导入,Vite 会将其注入到<head>中
5.2 根组件 App.tsx ------ 状态管理的核心
tsx
import { useState } from 'react'
import ColorBrowser from './components/ColorBrowser';
import ColorPicker from './components/ColorPicker';
import type { Color } from './model/color';
import MemberTable from './components/MemberTable';
function App() {
const [color, setColor] = useState<Color>({
red: 20,
green: 0,
blue: 10,
})
return (
<>
<ColorBrowser color={color} />
<ColorPicker color={color} onColorUpdated={setColor} />
<MemberTable />
</>
)
}
export default App
🧠 深入理解:
状态提升(Lifting State Up):
scss
App (状态持有者)
├── ColorBrowser ← 接收 color,只读展示
├── ColorPicker ← 接收 color + onColorUpdated,读写
└── MemberTable ← 独立管理自己的状态
color状态定义在App(单一数据源 ),确保ColorBrowser和ColorPicker看到的是同一份颜色数据ColorPicker通过onColorUpdated={setColor}回调通知父组件更新状态 ------ 这是 React 单向数据流的经典实现
泛型 useState<Color> :TypeScript 自动推断出 color 的类型为 Color,setColor 的参数类型也被约束为 Color。如果你尝试 setColor({ red: "hello" }),编辑器会立刻报错!
5.3 ColorBrowser 组件 ------ 颜色预览
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;
📝 知识点解析:
| 技术点 | 详解 |
|---|---|
React.FC<Props> |
函数组件类型标注,FC = FunctionComponent,自动包含 children 属性 |
React.CSSProperties |
CSS 属性的 TypeScript 类型,写错属性名 IDE 会报错 |
| 模板字符串拼接 | rgb(${r}, ${g}, ${b}) 动态生成 CSS 颜色值 |
| 纯展示组件 | 无自身状态,无副作用 ------ 纯函数式组件的典范 |
🎨
React.CSSProperties是项目中最容易被忽视却非常实用的类型。它能防止你写出backgrounColor(拼写错误)这样的 bug,因为 TypeScript 会立刻提醒!
5.4 ColorPicker 组件 ------ 滑块交互
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={(e) =>
props.onColorUpdated({ ...props.color, red: +e.target.value })
}
/>
{props.color.red}
<br />
<input
type="range"
min={0}
max={255}
value={props.color.green}
onChange={(e) =>
props.onColorUpdated({ ...props.color, green: +e.target.value })
}
/>
{props.color.green}
<br />
<input
type="range"
min={0}
max={255}
value={props.color.blue}
onChange={(e) =>
props.onColorUpdated({ ...props.color, blue: +e.target.value })
}
/>
{props.color.blue}
</div>
)
}
export default ColorPicker;
🧬 核心技巧剖析:
1. 不可变更新(Immutable Update)
typescript
{ ...props.color, red: +e.target.value }
// ↑ ↑
// 展开旧对象 覆盖 red 字段,创建一个全新对象
这是 React 状态更新的黄金法则 ------ 永远不要直接修改原对象 !React 通过 Object.is() 对比新旧状态来决定是否重新渲染。如果你直接 props.color.red = 100,React 无法检测到变化。
2. +e.target.value 的一元加号技巧
typescript
e.target.value // "128" --- 字符串类型
+e.target.value // 128 --- 数字类型(number)
<input> 的 value 始终是 string,但 Color 接口要求 number。+ 运算符是一种简洁的类型转换方式,等价于 Number(e.target.value)。
3. <input type="range"> 滑块控件
原生 HTML5 的滑块输入:
| 属性 | 含义 |
|---|---|
min={0} |
最小值 0 |
max={255} |
最大值 255(RGB 单通道上限) |
value={...} |
受控组件的当前值 |
onChange={...} |
滑块拖动时的回调 |
5.5 MemberTable 组件 ------ 异步数据 + 列表渲染
tsx
import * as React from 'react';
import { getMembersCollection } from '../api/memberApi';
import type { MemberEntity } from '../model/member';
const MemberRow = (props) => {
const member = props.member
return (
<tr>
<td>
<img src={member.avatar_url} style={{ maxWidth: '10rem' }} />
</td>
<td><span>{member.id}</span></td>
<td><span>{member.login}</span></td>
</tr>
)
}
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>
</>
)
}
export default MemberTable;
🔬 深度剖析:
1. useEffect + IIFE(立即执行的异步函数表达式)
typescript
React.useEffect(() => {
(async () => {
const members = await getMembersCollection();
setMemberCollection(members);
})()
}, []);
这个模式非常经典!原因在于:
useEffect的回调不能直接是async函数(因为 async 函数返回 Promise,而 useEffect 期望返回 void 或 cleanup 函数)- 解决之道:在回调内部定义一个
async的 IIFE(Immediately Invoked Function Expression),立即调用它
2. 空依赖数组 [] 的含义
表示 "只在组件首次挂载 时执行一次"。相当于类组件中的 componentDidMount。
⚠️ 这意味着如果 API 数据更新了,组件不会 自动重新获取。对于真实场景,可能需要加入
[refreshFlag]依赖或使用 React Query 等方案。
3. key 属性的重要性
tsx
memberCollection.map((member) => (
<MemberRow key={member.id} member={member} />
))
key 是 React 列表渲染的唯一标识符,用于:
- 追踪列表中的每个元素
- 优化 diff 算法,避免不必要的 DOM 操作
- 永远不要用数组索引
index作为 key(除非列表是静态的、不会增删改的)
4. 子组件 MemberRow 的分离
将 <tr> 抽成独立组件 MemberRow,遵循单一职责原则。当行逻辑变复杂(如点击事件、条件样式)时,修改不会影响表格整体结构。
🎨 六、样式系统设计
6.1 CSS 变量 + 暗黑模式
css
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
font: 18px/145% var(--sans);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
}
}
✨ 设计亮点:
| 技巧 | 说明 |
|---|---|
| CSS 自定义属性(变量) | 定义全局设计令牌(Design Tokens),统一管理颜色、间距、字体 |
prefers-color-scheme: dark |
系统级暗黑模式媒体查询,自动适配用户系统偏好 |
font-synthesis: none |
禁止浏览器合成加粗/倾斜字体,避免字体失真 |
text-rendering: optimizeLegibility |
优化文本渲染清晰度(移动端建议换成 optimizeSpeed) |
-webkit-font-smoothing: antialiased |
macOS/iOS 字体抗锯齿,渲染更细腻 |
🎯 核心思想 :CSS 变量本质是"一处修改,全局生效"。如果要换主题色,只需改
--accent一个变量,所有依赖它的组件样式自动更新。
6.2 CSS 嵌套语法
css
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
/* ... */
&:hover {
box-shadow: var(--shadow);
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
}
}
项目使用了 CSS 原生嵌套(CSS Nesting) 语法,这是现代 CSS 的重大升级:
.logo嵌套在ul内,编译为#next-steps ul .logo&:hover引用父选择器,编译为#next-steps ul a:hover@media写在选择器内部,作用域更清晰
📌 目前 CSS 嵌套在 Vite 中开箱即用,无需 Sass/Less 预处理器。
6.3 CSS Modules 替代方案?
本项目使用全局 CSS 方式组织样式。在大型项目中,更推荐使用 CSS Modules (.module.css)或 CSS-in-JS 方案(如 Tailwind CSS、styled-components)来避免样式冲突。不过对于学习项目,全局 CSS 更直观,理解成本更低。
🔄 七、数据流全景图
scss
┌──────────────────────────────────────────────────┐
│ App (状态中心) │
│ │
│ const [color, setColor] = useState<Color>({ │
│ red: 20, green: 0, blue: 10 │
│ }) │
│ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ ColorBrowser │ │ ColorPicker │ │
│ │ │ │ │ │
│ │ color → 显示 │ │ color → range 值 │ │
│ │ RGB 色块 │ │ onChange → setColor │ │
│ │ (只读, 纯展示) │ │ (读写, 修改状态) │ │
│ └─────────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ MemberTable │ │
│ │ │ │
│ │ useState<MemberEntity[]> (独立状态) │ │
│ │ useEffect → getMembersCollection() │ │
│ │ .map() → <MemberRow /> │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
🔑 关键原则总结:
- 单向数据流:数据从父组件流向子组件(通过 Props),子组件通过回调函数通知父组件更新
- 状态提升:共享状态放在最近的公共祖先组件中
- 组件自治 :
MemberTable的状态是私有的,与color状态互不干扰
📊 八、技术要点速查表
| 技术点 | 具体实现 | 所在文件 |
|---|---|---|
| TypeScript 接口 | interface Color { red, green, blue } |
model/color.ts |
| 类型导入 | import type { Color } from '...' |
多个文件 |
| 泛型 useState | useState<Color>({...}) |
App.tsx |
| Props 类型标注 | React.FC<Props> + interface Props |
各组件 |
| CSS 对象类型 | React.CSSProperties |
ColorBrowser.tsx |
| 不可变更新 | { ...obj, field: newVal } |
ColorPicker.tsx |
| 字符串转数字 | +e.target.value |
ColorPicker.tsx |
| useEffect + async | IIFE 模式 (async () => {...})() |
MemberTable.tsx |
| 受控组件 | <input value={...} onChange={...} /> |
ColorPicker.tsx |
| 列表 key | <MemberRow key={member.id} /> |
MemberTable.tsx |
| CSS 变量 | --accent, --bg, --border |
index.css |
| 暗黑模式 | prefers-color-scheme: dark |
index.css |
| CSS 嵌套 | &:hover, & > div |
App.css |
🧪 九、运行与验证
bash
# 1. 进入项目目录
cd color-picker
# 2. 安装依赖
npm install
# 3. 启动开发服务器
npm run dev
# → Vite 启动在 http://localhost:5173
# 4. 生产构建
npm run build
# → 先 tsc 类型检查,再 vite 打包到 dist/
# 5. 预览生产构建
npm run preview
# 6. 代码质量检查
npm run lint
💡 十、学习心得与最佳实践
✅ 做得好的地方
- 类型系统充分利用 :每个 Props 都有明确的
interface定义,函数返回值都有类型标注,让代码即文档 - 组件职责清晰:展示(ColorBrowser)、交互(ColorPicker)、数据(MemberTable)三类组件各司其职
- API 层抽象:mock 数据和真实 API 共用相同的接口签名,随时可以切换
- 现代化工具链:Vite 8 + React 19 + TypeScript 6,全部是最新版本
🔧 可以改进的地方
| 改进方向 | 具体方案 |
|---|---|
| Loading 状态 | MemberTable 在数据加载完成前展示骨架屏或 Spinner |
| Error 状态 | API 请求失败时的错误处理和重试机制 |
| CSS Modules | 将全局 CSS 迁移为 .module.css,避免样式冲突 |
| 使用 React 19 新特性 | 尝试 use() Hook、useOptimistic 等新 API |
| 自定义 Hook | 将 MemberTable 中的 useEffect + useState 抽取为 useMembers() Hook |
| 单元测试 | 为组件和 API 添加 Vitest + React Testing Library 测试 |
| a11y 无障碍 | 为滑块添加 aria-label,为颜色块添加合适的语义标签 |
📚 延伸学习路径
vbnet
本项目的知识点 → 下一步学习方向
────────────────────────────────────
TypeScript 接口 → 泛型、工具类型(Partial/Pick/Omit)、条件类型
React 状态管理 → useReducer、Context API、Zustand、Redux Toolkit
组件通信 → Context、Event Bus、状态管理库
CSS 方案 → CSS Modules、Tailwind CSS、styled-components
异步数据 → React Query (TanStack Query)、SWR、RTK Query
构建工具 → Vite 插件开发、esbuild、Rolldown
测试 → Vitest、React Testing Library、Playwright
🎯 十一、总结
这个 Color Picker 项目虽然代码量不大(约 150 行核心代码),但它完整地展示了一个现代 React + TypeScript 项目的标准开发范式:
- 类型先行 ------ 先定义数据模型,再写组件
- 状态驱动 ------ UI 是状态的函数,
state → render - 单向数据流 ------ Props 向下,回调向上
- 关注点分离 ------ Model / API / Components 分层清晰
- 工程化完备 ------ Vite 构建 + TypeScript 类型检查 + ESLint 代码规范
🧩 代码虽小,架构不小。 理解了这个项目的每一行代码和背后的设计思想,你就掌握了 React 日常开发的 80% 核心技能。剩下的 20%,是在真实项目中不断打磨的工程经验。