AI 时代前端框架选型:React 核心原理与 SocialVibe 项目实战解析
文章目录
概要
AI出现之前,技术选型(如Vue、React、Svelte之间的选择)是极为严谨的事情,核心考量围绕开发体验以及团队招聘难度——因为当时代码几乎全由人工编写,人工开发的舒适度和效率是关键,这也是Vue(尤其Vue2)、Svelte曾经的核心优势。
但进入AI时代后,代码编写的主体发生了本质变化,越来越多的代码由AI生成,此前的选型逻辑被颠覆:代码本身的复杂程度不再重要,因为无需人工逐字敲击,Vue、Svelte的“开发舒适”优势被大幅削弱。
AI编写代码的核心诉求并非“开发体验”,而是“能否理解该技术”。实际体验中,AI编写Vue(尤其Vue2与Vue3混用)时易卡壳,编写Svelte时因生态过小、需大量手动补充内容而容易胡写,反观React,因使用人数多、项目量大、写法稳定且无破坏性大升级,AI对其理解度最高,生成代码的成功率也远高于前两者。
如今多数AI平台默认生成React代码,本质是统计意义上最安全的选择。AI时代的技术选型核心逻辑已转变:不再选择开发者最喜欢的,而是选择AI最懂的;优先选用最流行、生态最庞大、范式最稳定的技术,并非因为这类技术最优秀,而是因为AI能精准理解、稳定生成,且不易出错——技术本身未变,变的是写代码的主体,从人变成了AI。
Vue还是React?
现在已经是AI时代了,选择框架还有必要吗?答案是肯定的。
举个通俗的例子:如果你不会技术开发,只依赖AI写代码,就像走钢丝没有安全绳,一旦AI出问题,项目就会一落千丈、无法挽回;但如果你掌握了技术,就相当于有了安全绳,即便AI出错,你也能及时挽救、重新调整。

首先从全球范围来看,React的使用率遥遥领先,远超Vue;而Vue能占据17%左右的市场份额,绝大多数集中在东南亚,尤其是在我国,Vue的市场份额达到55%,React则是30%。

为什么Vue在国内这么受欢迎?这和它的出身有关。Vue早期是站在AngularJS的肩膀上发展起来的,它凭借“渐进式”的特点,收割了大量用户。渐进式的核心优势就是上手简单——哪怕你不懂JS,只要照着Vue的文档、按指令填写,就能使用这个渲染框架。
但优点同时也是缺点:Vue提供了大量内置指令,比如v-for、v-show,用起来很方便,但这些指令也像一堵墙,把开发者圈在固定的模式里,不够灵活。而React恰恰相反,它采用函数式编程,完全基于JS,不用记额外的指令,灵活性极高,但也因此失去了“渐进式”的特点,对JS基础有一定要求。
生态方面,两者的差异也很明显:
- React的生态完全是社区驱动,不管是状态管理库(比如Redux、JUSTYLE),还是SSR渲染、表单处理等需求,都有大量社区贡献的工具,大家相互竞争、彼此优化,形成了良性循环,最终优秀的工具会脱颖而出——这就是开源生态的优势,评判标准很公平,全看用户的认可和GitHub的star数量。
- 而Vue的生态虽然也有社区参与,但主要依赖官方库。好处是,打开Vue文档,官方推荐的工具基本都很好用,不用纠结选择;但缺点也很明显,缺少了社区竞争的活力,就像上学时的铅笔盒,Vue里只有一根铅笔,你只能老老实实使用;而React的生态里有钢笔、铅笔、圆珠笔等多种选择,反而容易让人出现选择困难,但也正因如此,它的生态才更加繁荣。
这一点在AI时代尤为突出。很多优秀的AI产品,比如画图、画产品原型的Pixel、Figma,都只支持React,能根据UI库直接生成可复制使用的组件,非常便捷,但遗憾的是,它们都不支持Vue。这也是AI时代React的一大优势——AI对React的理解度更高,生成的代码更准确。
还有一点,Vue能在国内占据大量市场份额,离不开小程序生态的推动,尤其是uni-app。uni-app之所以选择Vue作为渲染框架,大概率是因为当时国内Vue的用户更多,或者开发团队更熟悉Vue,两者相辅相成。现在很多中小型公司、外包公司,都会用uni-app加快多端开发速度,这也间接带动了Vue的使用。
如果你JS基础还可以,在AI时代我推荐使用React——它生态完善、工具优秀,灵活性高,适配多端(H5、桌面端、手机APP),而且AI生成React代码的准确率更高;但如果你是新手,或者主要做小程序开发(尤其是小公司、外包项目),可以先学Vue,它上手简单、指令清晰,能快速完成开发需求。
基础概念
组件
组件是 React 应用的最小构建单元,用来渲染页面上所有可见内容(按钮、输入框、页面等)。像乐高积木一样,可复用、可组合。本质就是一个返回 UI 标记的 JavaScript 函数。
function WelcomeButton(){return(<button className="bg-blue-500 text-white p-2"> 点击我 </button>);}JSX
React 组件不直接返回 HTML,而是返回 JSX。JSX 是披着 HTML 外衣的 JavaScript。不用 JSX 也可以用 React.createElement(),但写法繁琐。
写起来像 HTML 的 JavaScript
JSX 规则:
- 属性用驼峰命名:className 代替 class
在 JSX 中,HTML 原生的属性名需要遵循 JavaScript 标识符的命名规则(驼峰命名),而不是 HTML 的短横线命名;其中最典型的就是把 HTML 的 class 属性改成 className。
JSX 的本质是 JavaScript 语法糖,最终会被编译成 React.createElement() 函数调用。class 是 JavaScript 的关键字(用于定义类),如果在 JSX 中直接写 class,会导致语法冲突、代码报错。
除了 class,其他 HTML 属性也遵循这个规则:
| HTML 属性 | JSX 写法 | 原因 |
|---|---|---|
| class | className | class 是 JS 关键字 |
| for(label 标签) | htmlFor | for 是 JS 关键字 |
| tabindex | tabIndex | 遵循驼峰命名规范 |
| onclick | onClick | 事件属性统一驼峰 |
// ❌ 错误写法:JSX 中用 class 会报错 <div class="container">Hello</div>// ✅ 正确写法:用 className <div className="container">Hello</div>// 其他示例 <label htmlFor="username">用户名:</label><input tabIndex={1} onClick={()=> console.log('点击了')}/>- 用 {} 插入动态 JavaScript 值(变量、表达式等)
JSX 允许你在标记中嵌入任意有效的 JavaScript 代码,只需用花括号 {} 包裹;花括号是 “JSX 静态内容” 和 “JavaScript 动态逻辑” 的分隔符。
- 花括号外:是 JSX 语法(类似 HTML),字符串值可以直接写(比如 < div > 静态文本< /div >)。
- 花括号内:是纯 JavaScript 语法,必须符合 JS 规则(比如字符串要加引号 < div >{‘动态文本’}< /div>)。
//1. 插入变量 const name ="React 学习者"; const jsx1 =<h1>你好,{name}</h1>;// 渲染:你好,React 学习者 //2. 插入表达式 const count =10; const jsx2 =<p>计数:{count +1}</p>;// 渲染:计数:11//3. 插入函数调用 const getTime =()=> new Date().getHours(); const jsx3 =<p>当前小时:{getTime()}</p>;//4. 插入对象(内联样式) const style ={ fontSize:'16px', color:'red'}; const jsx4 =<div style={style}>红色文字</div>;//5. 条件渲染(三元表达式) const isLogin = true; const jsx5 =<div>{isLogin ? '已登录':'请登录'}</div>;//6. 数组渲染(map 函数) const list=['苹果','香蕉']; const jsx6 =(<ul>{list.map(item =><li key={item}>{item}</li>)}</ul>);- 组件只能返回一个根元素,多元素可以用 < div> 包裹
React 组件的返回值必须是单个顶级元素(根元素),如果要返回多个元素,必须用一个 “容器” 包裹;<></> 是 React.Fragment 的简写,称为 “空标签 / 片段”,作用是包裹多元素但不生成多余的 DOM 节点。
React 组件最终会编译成 React.createElement() 调用,这个函数只能返回一个对象(对应一个 DOM 节点),如果返回多个元素,会导致 React 无法识别 “根”,从而报错。
DOM(Document Object Model,文档对象模型)是浏览器把 HTML/XML 文档解析后生成的树形结构,这个结构里的每一个「节点」(Node),对应页面上的一个元素、文本、属性等,其中我们最常说的「DOM 节点」特指 元素节点(比如 < div>、< p>、< ul> 等标签对应的节点)。
浏览器就是通过操作这些 DOM 节点来渲染页面、响应用户操作的(比如点击、修改内容)。
// ❌ 错误写法:返回多个根元素,会报错 function MyComponent(){return(<p>第一个元素</p><p>第二个元素</p>);}// ✅ 方式1:div 包裹(不推荐,多了无用 div) function MyComponent1(){return(<div><p>第一个元素</p><p>第二个元素</p></div>);}// ✅ 方式2:Fragment 简写(推荐) function MyComponent2(){return(<><p>第一个元素</p><p>第二个元素</p></>);}// ✅ 方式3:Fragment 完整写法(需导入 React) import React from'react'; function MyComponent3(){return(<React.Fragment><p>第一个元素</p><p>第二个元素</p></React.Fragment>);}如果在列表渲染中使用 Fragment,需要用完整写法并加 key(空标签 <></> 不支持加属性):
const items =[{id:1, text:'a'},{id:2, text:'b'}]; function List(){return({items.map(item =>(<React.Fragment key={item.id}><p>{item.id}</p><p>{item.text}</p></React.Fragment>))});}Props(组件间传数据)
Props 是 Properties 的缩写,你可以把它理解为:从父组件传递给子组件的 “只读数据”,是组件之间通信的最基础方式。
就像函数的 “参数”—— 组件是函数,props 就是函数的入参,输入不同的 props,组件输出不同的 UI
- 传递 Props(父组件侧)
用法和写 HTML 属性完全一致,分两种场景:
- 静态值(字符串):直接写,不用加花括号;
- 动态值(非字符串 / 变量 / 表达式):必须用 {} 包裹。
// 父组件 App.js import Button from'./Button'; function App(){// 准备要传递的各种类型数据 const buttonText ="确定";// 字符串 const count =10;// 数字 const isDisabled = false;// 布尔 const style ={ color:'blue'};// 对象 const handleClick =()=> alert('点击了按钮');// 函数 return(<div>{/*1. 静态字符串:直接写 */}<Button text="取消"/>{/*2. 动态值:用 {} 包裹 */}<Button text={buttonText} count={count} isDisabled={isDisabled} style={style} onClick={handleClick}/></div>);}- 接收 Props(子组件侧)
子组件是函数组件时,props 就是函数的第一个参数(一个对象),有两种接收方式:
- 方式 1:直接接收 props 对象,通过 . 访问属性;
- 方式 2:解构赋值(更推荐,代码更简洁)。
// 子组件 Button.js // 方式 1:接收完整 props 对象 function Button(props){// 访问 props 中的属性 return(<button disabled={props.isDisabled} style={props.style} onClick={props.onClick}>{props.text}- 计数:{props.count}</button>);}// 方式 2:解构赋值(推荐) function Button({ text, count, isDisabled, style, onClick }){return(<button disabled={isDisabled} style={style} onClick={onClick}>{text}- 计数:{count}</button>);} export default Button;给 Props 设置默认值(可选)----- 如果父组件没传递某个 props,子组件可以设置默认值,避免报错:
// 方式 1:用 ES6 默认参数 function Button({ text ="默认按钮", count =0}){return<button>{text}- 计数:{count}</button>;}// 方式 2:用 React 的 defaultProps(旧写法,不推荐) Button.defaultProps ={ text:"默认按钮", count:0};- children props:特殊的 “子内容” Props
children 是 React 内置的特殊 props—— 当你给组件写开始 / 结束标签时,标签中间的所有内容都会被 React 自动封装成 children 属性,传递给子组件。
// 父组件 function App(){return({/* 标签中间的 "确定" 就是 children */}<Button>确定</Button>);}// 子组件 Button.js function Button({ children }){// 直接渲染 children return<button>{children}</button>;}复杂场景:传递多个元素 / 其他组件 ------ children 可以是任意 JSX 内容,包括多个元素、其他组件,这也是 React “组合模式” 的核心:
// 父组件 function App(){return(<Card><h3>卡片标题</h3><p>卡片内容</p><Button>操作按钮</Button></Card>);}// 子组件 Card.js(布局组件) function Card({ children }){// 封装通用样式,渲染 children return(<div style={{ border:'1px solid #ccc', padding:20}}>{children}{/* 渲染父组件传入的所有内容 */}</div>);}children 实现了 React 的组合(Composition)思想—— 让组件更灵活、更易复用:
场景:你需要写一个 “通用布局组件”(比如 Card、Modal、Layout),希望这个组件只负责 “外壳”(样式 / 结构),内部内容由使用它的父组件决定;------- 不用给布局组件传递大量 props 来控制内部内容,而是直接把内容 “塞” 进去,代码更直观。
Key
React 的核心优势是高效更新 DOM,而列表渲染是最容易出现性能问题和渲染错误的场景,key 就是 React 解决这个问题的 “钥匙”。
列表渲染的“身份证”
没有 key 会发生什么?
// ❌ 无 key,React 会报警告,且更新列表时可能出错 function TodoList(){ const [todos, setTodos]= useState([{id:1, text:"吃饭"},{id:2, text:"睡觉"},]);// 给列表头部新增一项 const addTodo =()=>{ setTodos([{id:3, text:"打游戏"},...todos]);};return(<div><button onClick={addTodo}>新增</button><ul>{todos.map(todo =>(<li>{todo.text}</li>// 无 key ))}</ul></div>);}- 控制台警告:React 会明确提示 Warning: Each child in a list should have a unique “key” prop;
- 性能问题:React 无法区分列表项,更新时会销毁旧列表所有节点,重新创建新列表(而非只新增 / 修改变化的项),DOM 操作量暴增;
- 渲染错误:如果列表项包含输入框、复选框等 “有状态” 的组件,无 key 会导致状态错乱(比如输入框的值跑到错误的项上)。
key 是 React 识别列表项的唯一标识,作用是:
- 身份识别:让 React 知道 “哪个列表项是哪个”,建立 “虚拟 DOM 节点” 和 “真实 DOM 节点” 的一一对应关系;
- 高效更新:列表变化时(增 / 删 / 改 / 排序),React 只更新key 变化 / 新增 / 删除的项,复用未变化的项,最小化 DOM 操作;
- 避免状态错乱:保证列表项的 “状态”(比如输入框值、复选框选中状态)和对应的项绑定,不会错位。
列表数据通常来自接口 / 数据库,会有天然的唯一标识(id、uuid、手机号等),这是 key 的最佳选择。
// ✅ 正确:用数据的唯一 id 作为 key function TodoList(){ const [todos, setTodos]= useState([{id:1, text:"吃饭"},{id:2, text:"睡觉"},]);return(<ul>{todos.map(todo =>(<li key={todo.id}>{todo.text}</li>// 用唯一 id 做 key ))}</ul>);}渲染与虚拟 DOM
React 的核心机制
React 渲染页面的核心逻辑是:用 “虚拟 DOM” 做 “草稿”,通过 “Diff 算法” 找草稿的变化,再通过 “协调机制” 只修改真实页面中变化的部分,最终实现 “把代码变成浏览器能显示的页面” 这个目标(渲染)。
- 渲染(Render):从代码到页面的全过程
写的 JSX/React 代码,最终被转换成浏览器能识别的 HTML 并显示在页面上的过程,就是渲染。
- 首次渲染:页面第一次加载时,React 把所有代码转换成 DOM 并渲染到页面;
- 重新渲染:组件状态(state)/ 属性(props)变化时,React 重新计算 UI 并更新页面。
为什么直接操作真实 DOM 会慢?
如果不用 React,直接用原生 JS 操作 DOM 更新列表如下:
// 原生 JS 更新列表(低效) const ul = document.querySelector('ul');// 清空旧列表(销毁所有 DOM 节点) ul.innerHTML ='';// 重新创建所有 DOM 节点 todos.forEach(todo =>{ const li = document.createElement('li'); li.textContent = todo.text; ul.appendChild(li);});哪怕只新增 1 个列表项,也要销毁所有旧 DOM + 重建所有新 DOM—— 而真实 DOM 是浏览器中 “重量级” 的对象,创建 / 销毁 / 修改都会触发浏览器的 “重排 / 重绘”,非常消耗性能,页面会卡顿。
React 的核心目标就是减少真实 DOM 的操作,而实现这个目标的关键就是「虚拟 DOM」。
- 虚拟 DOM (VDOM):内存中的 “轻量草稿”
虚拟 DOM 是 React 用 JavaScript 对象 模拟的 “DOM 副本”,存在于内存中,完全脱离浏览器的 DOM 体系。
- 真实 DOM:浏览器提供的、表示页面元素的 “重量级对象”(包含大量属性和方法,操作慢);
- 虚拟 DOM:简单的 JS 对象(比如 { type: ‘div’, props: { className: ‘box’ }, children: [‘Hello’] }),操作速度极快。
// 你写的 JSX <div className="box"><p>Hello React</p></div>// 编译后变成 React 元素(虚拟 DOM 的核心形态) const vdom ={type:'div', props:{ className:'box'}, children:[{type:'p', props:{}, children:['Hello React']}]};这个 JS 对象就是虚拟 DOM—— 它和真实 DOM 结构一一对应,但只是内存中的普通对象,修改它不会触发浏览器的任何渲染行为,速度比操作真实 DOM 快 10 倍以上。
- Diff 算法:找 “草稿” 的变化
Diff 算法是 React 内置的 “对比算法”,核心作用是:对比 “旧虚拟 DOM” 和 “新虚拟 DOM”,找出两者的差异(哪些节点新增 / 删除 / 修改)。
Diff 算法的核心规则
- 同层对比:只对比虚拟 DOM 树中 “同一层级” 的节点,不会跨层级对比(比如只对比根节点的子节点,不对比根节点和孙子节点);
- 同 key 对比:列表节点通过 key 识别身份,只有 key 相同的节点才会对比内容,key 不同直接判定为 “新增 / 删除”;
- 同类型对比:如果两个节点的类型相同(比如都是 div),则对比它们的属性(比如 className/style);如果类型不同(比如 div 变成 p),则直接销毁旧节点,创建新节点。
- 协调 (Reconciliation):把差异更新到真实 DOM
协调是 React 的 “更新策略”,核心作用是:根据 Diff 算法找到的差异,只把变化的部分更新到真实 DOM 上,而非重建整个 DOM 树。
虚拟 DOM 一定更快吗?
虚拟 DOM 的优势是 “批量更新 + 最小化 DOM 操作”,如果只是修改单个 DOM 节点(比如改一个按钮的文字),原生 JS 可能比 React 更快(少了 VDOM/Diff/ 协调的开销);但复杂页面 / 频繁更新时,React 的优势会完全体现。
重新渲染 ≠ 重新创建真实 DOM
组件状态变化会触发 “重新渲染”(生成新 VDOM),但 React 只会通过协调机制更新真实 DOM 中变化的部分,大部分真实 DOM 节点会被复用。
React 会给更新任务分优先级(比如用户输入的优先级 > 数据请求的优先级),高优先级任务会打断低优先级任务,保证页面响应流畅(这是 React 18 并发渲染的核心)。
事件处理
React 事件处理的本质是:捕获用户在页面上的操作(点击、输入、提交等),并执行对应的 JavaScript 逻辑,是实现 “交互功能” 的核心(比如点击按钮计数、输入框打字、提交表单)。
和原生 HTML 事件相比,React 事件处理有两个关键差异:事件名命名规则 和 处理函数绑定方式。
为什么 React 事件名必须用「驼峰式」 ?
eact 事件不是原生 DOM 事件的直接映射,而是 React 封装的「合成事件(SyntheticEvent)」;
合成事件的命名遵循 JavaScript 标识符的驼峰规则,目的是统一 React/JSX 的语法风格,同时避开原生事件的兼容性问题。
| 原生 HTML 事件名(全小写) | React 合成事件名(驼峰式) | 用途 |
|---|---|---|
| onclick | onClick | 点击事件(按钮 / 元素) |
| onchange | onChange | 输入变化(输入框 / 下拉) |
| onsubmit | onSubmit | 表单提交 |
| onmouseover | onMouseOver | 鼠标悬浮 |
| onkeydown | onKeyDown | 键盘按下 |
// ❌ 错误:用原生 HTML 全小写事件名(React 不识别) <button onclick="alert('点击了')">按钮</button>// ✅ 正确:用 React 驼峰式事件名 <button onClick={()=> alert('点击了')}>按钮</button>绑定的是「函数本身」,而非字符串
这是 React 事件处理和原生 HTML 最核心的区别
| 原生 HTML 写法(字符串) | React 写法(函数本身) | 核心差异 |
|---|---|---|
| < button οnclick=“alert(‘点击’)”> | <button onClick={() => alert(‘点击’)}> | 原生传字符串,React 传函数引用 / 函数表达式 |
| < input οnchange=“handleChange()”> | < input onChange={handleChange}> | React 不传字符串,直接传函数本身 |
为什么不能传字符串?
- JSX 是 JavaScript 的语法糖,而非纯 HTML;在 JSX 中,onClick=“handleClick()” 会被解析为 “字符串字面量”,而非 “函数调用”,React 无法执行对应的逻辑;
- 传 “函数本身” 可以让 React 控制事件的执行时机(用户触发时才执行),还能避免原生字符串写法的安全风险(比如 XSS 攻击)和性能问题(每次渲染都解析字符串)。
内联箭头函数(简单场景)----- 直接在事件属性中写箭头函数,适合逻辑简单的场景(比如弹窗、简单提示)。
function Button(){return(// ✅ 箭头函数是“函数表达式”,传递的是函数本身 <button onClick={()=> alert('点击了按钮')}> 点击弹窗 </button>);}绑定预定义函数(推荐,逻辑复用)----- 先定义函数,再把函数名(不加括号)传给事件属性 —— 这是 “传递函数本身” 的核心体现。
function Counter(){//1. 预定义处理函数 const handleClick =()=>{ alert('点击了计数按钮');};return(//2. 传递函数本身(不加括号!加括号会立即执行) <button onClick={handleClick}> 计数按钮 </button>);}⚠️ 关键坑点:不要加括号!
带参数的处理函数(高频场景)---- 如果需要给处理函数传参数(比如列表项的 ID、输入值),需要用 “箭头函数包裹” 的方式。
function TodoList(){// 带参数的处理函数 const handleDelete =(todoId)=>{ alert(`删除 ID 为 ${todoId} 的待办`);}; const todos =[{id:1, text:"吃饭"},{id:2, text:"睡觉"}];return(<ul>{todos.map(todo =>(<li key={todo.id}>{todo.text}{/* 用箭头函数包裹,传递参数 */}<button onClick={()=> handleDelete(todo.id)}>删除</button></li>))}</ul>);}React 合成事件是对原生 DOM 事件的封装,目的是抹平不同浏览器的兼容性差异(比如 IE 和 Chrome 的事件对象差异);
合成事件的用法和原生事件几乎一致(比如 e.target 获取触发元素、e.preventDefault() 阻止默认行为):
// 阻止表单默认提交行为 function handleSubmit(e){ e.preventDefault();// 合成事件的 preventDefault 方法 console.log('表单提交了');}return(<form onSubmit={handleSubmit}><button type="submit">提交</button></form>);如果组件频繁重渲染(比如父组件状态变化),内联箭头函数会每次创建新函数,可能导致不必要的子组件重渲染,此时可以用 useCallback 缓存函数:
import{ useCallback }from'react'; function Button({ onClick }){return<button onClick={onClick}>优化后的按钮</button>;} function Parent(){ const [count, setCount]= useState(0);// 缓存函数,避免每次渲染创建新函数 const handleClick = useCallback(()=>{ setCount(count +1);},[count]);return(<div><Button onClick={handleClick}/></div>);}State
state(状态)是 React 组件私有的、可以被修改的内部数据,是实现 “页面动态交互” 的核心(比如计数变化、输入框内容、弹窗显隐、列表增删)。
- 私有性:状态只属于当前组件,其他组件无法直接访问(除非通过 props 主动传递);
- 可变性:状态可以通过 React 提供的方法修改(不能直接赋值);
- 驱动渲染:状态变化是 React 组件重新渲染的唯一核心触发条件。
把组件想象成一个 “动态网页卡片”:组件的结构 / 样式是 “固定模板”(比如卡片的边框、字体);State 是卡片上 “可以动态变化的内容”(比如点赞数、倒计时数字、开关按钮的状态);没有 State,组件就是 “静态的死页面”;有了 State,组件才能根据用户操作动态更新。
为什么普通 JS 变量不行?
“为什么我改了变量,页面却不更新?”
普通 JS 变量是 “原生的、脱离 React 管控” 的:你修改变量后,React 没有任何机制能 “监听” 到这个变化,自然不会触发页面更新;
// ❌ 错误:用普通变量,修改后页面不更新 function Counter(){// 普通 JS 变量 let count =0; const handleClick =()=>{ count +=1;// 变量值变了,但 React 不知道 console.log(count);// 控制台会打印 1、2、3... 但页面上的 count 始终是 0};return(<div><p>计数:{count}</p><button onClick={handleClick}>+1</button></div>);}// ✅ 正确:用 useState 状态,修改后页面更新 function Counter(){// React 状态变量 const [count, setCount]= useState(0); const handleClick =()=>{ setCount(count +1);// 用 React 提供的方法更新状态 console.log(count);// 页面会同步显示 1、2、3...};return(<div><p>计数:{count}</p><button onClick={handleClick}>+1</button></div>);}State 是 “被 React 托管” 的:useState 创建的状态,React 会建立 “状态 → 组件渲染” 的关联,一旦状态通过 setCount 等方法更新,React 会立即知道,并触发组件重新渲染。
useState 是 React 提供的状态钩子,专门用于在函数组件中创建和管理状态,是最基础、最常用的 React Hook。
// 语法:const [状态变量, 更新状态的函数]= useState(初始值); const [count, setCount]= useState(0); const [inputValue, setInputValue]= useState(""); const [isModalOpen, setIsModalOpen]= useState(false);- 状态变量(比如 count):读取当前状态的值;
- 更新状态的函数(比如 setCount):修改状态的唯一方式(不能直接写 count = 1);
- 初始值:状态的默认值(只在组件首次渲染时生效)。
- 直接传新值(适用于状态不依赖旧值)
// 初始值:const [count, setCount]= useState(0); setCount(1);// 直接把 count 改成 1 setInputValue("React 学习");// 直接修改输入框值 - 传回调函数(适用于状态依赖旧值)
如果新状态需要基于旧状态计算(比如计数、累加),必须用回调函数 —— 避免 “状态更新异步导致的取值错误”。
// 正确:用回调函数获取最新的旧值 setCount(prevCount => prevCount +1);// 反例:异步场景下可能出错 // handleClick 执行时,count 可能还是旧值,导致更新不准确 setCount(count +1);- 状态的 “不可变更新”(核心规则)
React 要求状态是不可变的—— 不能直接修改状态本身,必须通过更新函数创建 “新值” 替换 “旧值”:
// ❌ 错误:直接修改状态(对象/数组),React 无法感知 const [user, setUser]= useState({ name:"张三", age:20}); const handleUpdate =()=>{ user.age =21;// 直接修改对象属性,页面不更新 setUser(user);};// ✅ 正确:创建新对象/新数组 const handleUpdate =()=>{// 方式 1:对象展开运算符 setUser({...user, age:21});// 方式 2:数组(新增/删除/修改都要创建新数组) // const [list, setList]= useState([1,2,3]);// setList([...list,4]);// 新增项 };React 对比新旧状态时,是 “浅对比”—— 如果直接修改对象 / 数组的属性,新旧状态的引用地址相同,React 会认为 “状态没变化”,从而不触发渲染。
State 的常见适用场景
| 场景 | 状态类型 | useState 示例 |
|---|---|---|
| 计数 / 累加 | 数字 | const [count, setCount] = useState(0) |
| 输入框内容 | 字符串 | const [value, setValue] = useState(“”) |
| 开关 / 显隐 | 布尔 | const [isOpen, setIsOpen] = useState(false) |
| 列表数据 | 数组 | const [list, setList] = useState([]) |
| 复杂数据 | 对象 | const [user, setUser] = useState({ name: “”, age: 0 }) |
受控组件
受控组件(Controlled Component)是 React 处理表单元素(输入框、下拉框、复选框等)的标准模式,核心是:表单元素的 value(或 checked)完全由 React 状态(State)控制,表单的任何输入变化都会同步更新到 State,State 变化也会同步回显到表单。
把表单输入框想象成 “木偶”,React State 就是 “牵线的人”:非受控组件:木偶自己动(输入值存在 DOM 里,React 管不着);受控组件:木偶的所有动作都由牵线的人控制(输入值存在 State 里,React 说了算)。
| 类型 | 数据存储位置 | 取值方式 | 核心特点 |
|---|---|---|---|
| 非受控组件 | DOM 元素本身 | ref 读取 DOM | 像原生 HTML 表单,React 不接管 |
| 受控组件 | React State | 直接读 State | React 完全掌控,可实时控制 |
受控组件的实现机制:输入 → onChange → 更新 State → 同步表单
以最常见的 “文本输入框” 为例,实现一个基础的受控组件:
import{ useState }from'react'; function ControlledInput(){//1. 用 State 存储输入框的值(核心:数据在 React 里) const [inputValue, setInputValue]= useState("");//2. 定义 onChange 处理函数:同步输入值到 State const handleChange =(e)=>{// e.target 是触发事件的输入框 DOM 元素,e.target.value 是当前输入值 setInputValue(e.target.value);};return(<div>{/*3. 表单的 value 绑定 State,onChange 绑定处理函数 */}<inputtype="text"// 核心:value 由 State 控制,而非 DOM 自身 value={inputValue}// 核心:输入变化时触发函数,更新 State onChange={handleChange} placeholder="请输入内容"/>{/* 实时显示 State 的值,验证同步效果 */}<p>你输入的内容:{inputValue}</p></div>);}- value 绑定 State: 是 “受控” 的核心 —— 输入框显示的值完全由 inputValue 决定,哪怕用户输入,只要 inputValue 不变,输入框内容就不变;
- onChange 必绑:如果只绑 value 不绑 onChange,输入框会变成 “只读”(用户输入无法更新 State,value 不变,输入框内容也不变);
不同表单元素的适配:
- 复选框 / 单选框:用 checked 代替 value,绑定布尔类型的 State;
- 下拉框(select):value 绑在 < select> 上,而非 < option>;
// 示例 1:受控复选框 function ControlledCheckbox(){ const [isChecked, setIsChecked]= useState(false);return(<inputtype="checkbox" checked={isChecked} onChange={(e)=> setIsChecked(e.target.checked)}/>);}// 示例 2:受控下拉框 function ControlledSelect(){ const [selected, setSelected]= useState("apple");return(<select value={selected} onChange={(e)=> setSelected(e.target.value)}><option value="apple">苹果</option><option value="banana">香蕉</option></select>);}受控组件的核心优势:为什么推荐用?
受控组件的优势本质上是 “数据归一化”—— 所有表单数据都存在 State 里,React 能统一管理,具体体现在:
- 行为可预测:所有表单的输入、回显都由 State 驱动,不会出现 “DOM 值和 React 数据不一致” 的情况,调试时只需看 State 就能定位问题,而非去查 DOM。
- 实时校验 / 格式化(高频场景):因为输入值实时同步到 State,能轻松实现 “输入时实时校验”“自动格式化内容”:
// 示例:实时校验手机号(只能输入数字,且长度不超过11位) function PhoneInput(){ const [phone, setPhone]= useState(""); const [error, setError]= useState(""); const handleChange =(e)=>{//1. 格式化:只保留数字 const value = e.target.value.replace(/\D/g,"");//2. 校验:长度不超过11位 if(value.length >11){ setError("手机号不能超过11位");}else{ setError(""); setPhone(value);}};return(<div><inputtype="text" value={phone} onChange={handleChange} placeholder="请输入手机号"/>{error &&<p style={{ color:'red'}}>{error}</p>}</div>);}- 方便实现复杂交互:比如 “一键清空输入框”“表单重置”“多输入框联动”,只需修改 State 即可,无需操作 DOM:
// 示例:一键清空输入框 function ClearableInput(){ const [inputValue, setInputValue]= useState("");return(<div><input value={inputValue} onChange={(e)=> setInputValue(e.target.value)}/>{/* 清空只需把 State 设为空字符串 */}<button onClick={()=> setInputValue("")}>清空</button></div>);}- 表单提交更简单:提交表单时,无需遍历 DOM 收集所有输入值,直接读取 State 即可:
function LoginForm(){ const [username, setUsername]= useState(""); const [password, setPassword]= useState(""); const handleSubmit =(e)=>{ e.preventDefault();// 阻止默认提交 // 直接读取 State,无需操作 DOM console.log("提交数据:",{ username, password });// 发送请求、验证逻辑等 };return(<form onSubmit={handleSubmit}><inputtype="text" value={username} onChange={(e)=> setUsername(e.target.value)} placeholder="用户名"/><inputtype="password" value={password} onChange={(e)=> setPassword(e.target.value)} placeholder="密码"/><button type="submit">登录</button></form>);}受控 vs 非受控:什么时候用非受控?
虽然受控组件是主流,但少数场景下非受控组件更合适:
简单的表单(比如单个输入框,无需实时校验);依赖原生 DOM 特性的场景(比如文件上传 ,只能用 ref 读取);集成第三方 UI 库,且库本身封装了非受控逻辑。
Hooks (钩子)
Hooks 是 React 16.8 新增的特性,核心目的是让函数组件拥有类组件的核心能力(状态、生命周期、DOM 操作等),同时解决类组件 “代码复用难、this 指向混乱、逻辑分散” 的问题。
- 命名规则:所有 Hooks 都以 use 开头(比如 useState、useEffect),这是 React 强制的规范;
- 适用范围:只能在函数组件 / 自定义 Hooks 中使用,不能在普通函数、类组件中使用;
- 调用规则:只能在组件的顶层作用域调用(不能在 if/for/ 嵌套函数中调用),保证 Hooks 执行顺序稳定。
把函数组件想象成 “基础款手机”,Hooks 就是 “功能插件”:没有 Hooks:函数组件只能渲染静态 UI(基础款手机只能打电话);加 useState:拥有状态(加了微信,能聊天);加 useEffect:拥有生命周期(加了闹钟,能定时);加 useRef:能操作 DOM(加了数据线,能连电脑);组合 Hooks:函数组件就能实现类组件的所有功能,且代码更简洁。
useState管理组件的 “动态数据”,在函数组件中创建和管理可变化的内部数据,是实现交互的基础。上面已经阐述过了,下面介绍其他四种hooks。
上下文钩子useContext :解决 props drilling(props 层层传递)问题,让数据直接从 “祖先组件” 传给 “深层子组件”,无需中间组件转发。
| Hook 名称 | 适用场景 | 核心示例 |
|---|---|---|
| useContext | 全局数据(主题、用户信息、多语言)、深层组件传值 | 暗黑模式切换、登录用户信息共享 |
//1. 创建 Context const ThemeContext = React.createContext('light');//2. 父组件:用 Provider 提供数据 function App(){ const [theme, setTheme]= useState('light');return(<ThemeContext.Provider value={{ theme, setTheme }}><div><Child />{/* 子组件 */}</div></ThemeContext.Provider>);}//3. 深层子组件:用 useContext 直接取数据(无需 props 传递) function Child(){ const { theme, setTheme }= useContext(ThemeContext);return(<button onClick={()=> setTheme(theme ==='light' ? 'dark':'light')}> 切换{theme}主题 </button>);}引用钩子useRef :一是直接访问真实 DOM 元素,二是存储 “跨渲染持久化” 的变量(组件重新渲染时,变量值不重置)。
| Hook 名称 | 适用场景 | 核心示例 |
|---|---|---|
| useRef | 获取 DOM 元素、存储定时器 / 计时器、跨渲染保存值 | 输入框自动聚焦、统计组件渲染次数、保存定时器 ID |
获取DOM元素:
function InputFocus(){//1. 创建 ref 对象 const inputRef = useRef(null);//2. 点击按钮让输入框聚焦 const handleFocus =()=>{ inputRef.current.focus();// current 指向真实 DOM 元素 };return(<div>{/*3. 绑定 ref 到输入框 */}<input ref={inputRef}type="text"/><button onClick={handleFocus}>聚焦输入框</button></div>);}存储持久化变量:
function RenderCounter(){ const [count, setCount]= useState(0);// 存储渲染次数,组件重渲染时不会重置 const renderCountRef = useRef(0);// 每次渲染都会执行,更新 ref 值 renderCountRef.current +=1;return(<div><p>组件渲染了 {renderCountRef.current} 次</p><button onClick={()=> setCount(count +1)}>触发重渲染</button></div>);}副作用钩子useEffect :在函数组件中模拟类组件的生命周期(比如 componentDidMount、componentDidUpdate),处理副作用(异步请求、定时器、订阅、DOM 操作等)。
| Hook 名称 | 适用场景 | 核心示例 |
|---|---|---|
| useEffect | 数据请求、定时器 / 防抖节流、监听窗口大小、订阅 / 取消订阅 | 组件挂载时请求数据、监听滚动、自动取消定时器 |
核心语法:
// 语法:useEffect(副作用函数, 依赖数组); useEffect(()=>{// 执行副作用(请求、定时器等) const timer = setInterval(()=> console.log('计时'),1000);// 清理函数(组件卸载/依赖变化时执行) return()=>{ clearInterval(timer);// 取消定时器,避免内存泄漏 };},[]);// 依赖数组:空数组=只在挂载/卸载执行;传变量=变量变化时执行 组件挂载时请求数据:
function UserList(){ const [users, setUsers]= useState([]);// 组件首次挂载时请求数据 useEffect(()=>{// 异步请求 fetch('https://api.example.com/users').then(res => res.json()).then(data => setUsers(data)).catch(err => console.log(err));},[]);// 空依赖:只执行一次 return(<ul>{users.map(user =>(<li key={user.id}>{user.name}</li>))}</ul>);}性能钩子useMemo ,useCallback :避免不必要的重复计算 / 重复渲染,提升组件性能,仅在 “性能有问题时” 使用(不要过早优化)。
| Hook 名称 | 核心作用 | 适用场景 |
|---|---|---|
| useMemo | 缓存计算结果,避免每次渲染重复计算 | 复杂数据处理(排序、过滤)、大列表计算 |
| useCallback | 缓存函数引用,避免每次渲染创建新函数 | 传递给子组件的回调函数、依赖函数的 useEffect |
userMemo:缓存计算结果
function ExpensiveCalculation(){ const [count, setCount]= useState(0);// 复杂计算:只有 count 变化时才重新计算 const doubleCount = useMemo(()=>{ console.log('重新计算');// 只有 count 变才打印 return count *2;},[count]);// 依赖 count return(<div><p>计算结果:{doubleCount}</p><button onClick={()=> setCount(count +1)}>+1</button></div>);}useCallback:缓存函数引用
// 父组件 function Parent(){ const [count, setCount]= useState(0);// 缓存函数:只有 count 变化时才创建新函数 const handleClick = useCallback(()=>{ console.log('点击了', count);},[count]);return(<div>{/* 子组件:函数引用不变,避免不必要的重渲染 */}<Child onClick={handleClick}/><button onClick={()=> setCount(count +1)}>+1</button></div>);}// 子组件(用 React.memo 缓存) const Child = React.memo(({ onClick })=>{ console.log('子组件渲染');// 只有 onClick 变化时才渲染 return<button onClick={onClick}>子组件按钮</button>;});Hook常见误区和避坑点:
| 错误做法 | 问题 | 正确做法 |
|---|---|---|
| 在 if/for 中调用 Hooks | React 无法保证 Hooks 执行顺序,报错 | 只在组件顶层调用 Hooks |
| 依赖数组漏写变量 | useEffect 捕获旧值,逻辑出错 | 用 ESLint 插件(react-hooks/exhaustive-deps)自动补全依赖 |
| 滥用 useMemo/useCallback | 增加代码复杂度,缓存本身也有开销 | 只在性能有问题时使用 |
| 用 useRef 存储需要触发渲染的值 | ref 变化不触发渲染,页面不更新 | 需触发渲染的值用 useState,无需渲染的值用 useRef |
| 忘记清理 useEffect 的副作用 | 内存泄漏(定时器、订阅) | 一定要写清理函数(return 部分) |
纯组件 & 严格模式
纯组件是遵循「纯函数原则」的 React 组件,核心规则:
- 相同输入 → 相同输出:只要组件接收的 props 和自身的 state 不变,渲染出的 JSX 就一定不变;
- 无副作用:渲染过程中不修改外部变量、不操作 DOM、不发送请求、不改变 props/state(仅做 “计算和返回 JSX”);
- 幂等性:多次渲染同一组输入,结果完全一致,且不会产生任何意外影响。
把纯组件想象成 “数学函数”:函数 f(x) = x * 2 是纯函数:输入 2 永远返回 4,输入 3 永远返回 6,且不会修改外部值;纯组件同理:输入 props.count=2 永远渲染 < div>2< /div>,且渲染时不会偷偷改其他数据。
// 纯函数组件:仅接收 props,返回 JSX,无任何副作用 function PureComponent({ count }){// 仅做计算,不修改任何外部值 const doubleCount = count *2;// 只返回 JSX,无 DOM 操作、无数据修改 return<div>计数翻倍:{doubleCount}</div>;}// 使用时:父组件保证传入的 props 是不可变的 function Parent(){ const [count, setCount]= useState(0);return<PureComponent count={count}/>;}React 对纯组件的优化支持:用 React.memo 包裹普通函数组件,使其成为 “浅比较的纯组件”—— 只有 props 浅对比变化时,才重新渲染;
// React.memo 缓存组件,仅 props 变化时重渲染 const MemoizedComponent = React.memo(PureComponent);类组件:继承 React.PureComponent(而非 React.Component),内部会自动对 props 和 state 做浅比较,避免不必要的重渲染。
纯组件的核心价值
- 可预测性:组件行为完全由 props/state 决定,调试时只需关注输入,无需排查 “渲染时的副作用”;
- 性能优化:结合 React.memo/PureComponent 可避免不必要的重渲染,提升性能;
- 可测试性:纯组件的输出仅依赖输入,单元测试只需验证 “输入 - 输出” 对应关系,无需处理副作用。
用了 React.memo 就是纯组件吗?
React.memo 只是优化渲染的工具,若组件内部有副作用(比如渲染时改全局变量),仍是非纯组件;
严格模式 (Strict Mode):React.StrictMode 是 React 提供的开发环境专属特性(生产环境自动失效),核心作用:
- 强制双重渲染:在开发环境下,故意让组件的 “渲染函数 / 副作用函数” 执行两次,暴露 “非纯的渲染逻辑” 和 “未清理的副作用”;
如果你的组件渲染时发起请求,双重渲染会导致请求发两次,此时你就会意识到 “请求应该放在 useEffect 里,而非渲染函数中”;
- 提前报警告:检测到不规范的代码(比如使用过时 API、直接修改 state、未清理的定时器)时,在控制台输出明确警告;
- 无视觉影响:仅在后台执行检查,不会改变页面的渲染结果。
很多隐蔽的 bug 只在 “多次渲染 / 组件卸载” 时出现(比如内存泄漏、非纯渲染),严格模式通过 “刻意制造重复渲染”,让这些问题在开发阶段就暴露,而非上线后才发现。
基本用法(只需包裹组件树)
import React from'react';import ReactDOM from'react-dom/client';import App from'./App'; const root = ReactDOM.createRoot(document.getElementById('root'));// 用 <React.StrictMode> 包裹根组件,开启严格模式 root.render(<React.StrictMode><App /></React.StrictMode>);副作用 (Side Effects)
在 React 组件中,所有 “不属于渲染逻辑本身、会与外部系统交互、且可能改变外部状态 / 产生不可预测结果” 的操作,都称为副作用。
把组件渲染想象成 “做一道菜”:渲染逻辑 = 切菜、翻炒、调味(核心流程,只产出 “菜品(JSX)”);副作用 = 做菜时接电话、开窗通风、清理灶台(和 “做菜本身” 无关,但需要做的额外操作)。
核心特征:
- 脱离渲染本身:不是为了生成 JSX,而是和 “组件外部” 交互;
- 可能有副作用:执行后可能改变外部状态(比如修改全局变量、更新浏览器标题)、产生异步结果(比如 API 请求返回数据);
- 不可纯性:多次执行可能有不同结果(比如 API 请求可能成功 / 失败,定时器执行时间不确定)。
| 副作用类型 | 具体场景 | 核心特点 |
|---|---|---|
| 网络请求 | 调用 API 获取数据、提交表单 | 异步、依赖外部服务器、结果不可控 |
| 浏览器 API 操作 | 修改 document.title、操作 localStorage、监听滚动 / 窗口大小 | 与浏览器环境交互,修改外部状态 |
| 定时器 / 延时器 | setInterval 计时、setTimeout 防抖 | 异步执行、需要手动清理(否则内存泄漏) |
| 事件订阅 / 监听 | 订阅 WebSocket、监听 DOM 事件(如 window.resize) | 持续监听外部事件,需卸载时取消订阅 |
| 直接操作 DOM | 手动修改 DOM 样式、聚焦输入框(非 useRef 方式) | 绕过 React 虚拟 DOM,直接操作真实 DOM |
为什么不能把副作用放在 “渲染逻辑中”?
如果把副作用写在组件的 “顶层渲染逻辑” 中,会导致严重问题,先看反例:
function BadComponent(){ const [data, setData]= useState(null);// ❌ 错误:API 请求写在渲染逻辑中 // 问题1:组件每次渲染都会发请求(比如状态变化、父组件重渲染),导致重复请求; // 问题2:请求是异步的,返回数据时组件可能已卸载,引发警告; // 问题3:破坏纯组件原则,渲染结果不可预测。 fetch('https://api.example.com/data').then(res => res.json()).then(data => setData(data));return<div>{data?.name}</div>;}- 重复执行:组件每次重渲染(比如 state/props 变化)都会执行副作用,导致不必要的网络请求、定时器创建(比如多次创建定时器,导致计时加速);
- 不可控性:渲染逻辑是同步执行的,而异步副作用(如 API 请求)返回时,组件可能已卸载,引发 “更新已卸载组件状态” 的警告;
- 破坏纯组件:渲染逻辑本该只负责生成 JSX,写入副作用会让组件失去 “相同输入→相同输出” 的纯特性,行为不可预测。
- 由用户操作触发的副作用 → 放在「事件处理函数」中:
副作用只在用户主动操作(点击、输入、提交)时执行,而非组件渲染时自动执行。
- 点击按钮提交表单、触发查询;
- 输入框回车触发搜索;
- 点击按钮修改浏览器标题。
function UserForm(){ const [username, setUsername]= useState("");// ✅ 正确:点击提交按钮时发请求(用户操作触发) const handleSubmit =(e)=>{ e.preventDefault();// 副作用:提交表单的 API 请求 fetch('https://api.example.com/login',{ method:'POST', body: JSON.stringify({ username })}).then(res => res.json()).then(data => console.log('登录成功', data));};return(<form onSubmit={handleSubmit}><input value={username} onChange={(e)=> setUsername(e.target.value)} placeholder="用户名"/><button type="submit">登录</button></form>);}- 组件生命周期触发的副作用 → 放在「useEffect 钩子」中
副作用在组件 “挂载后、更新后、卸载前” 自动执行,由 React 生命周期管控,而非用户操作触发。
- 组件挂载后自动请求初始化数据;
- 状态 / Props 变化后同步更新浏览器标题;
- 组件挂载时创建定时器,卸载时清理;
- 监听窗口大小变化(组件挂载时监听,卸载时取消)。
function UserList(){ const [users, setUsers]= useState([]);// ✅ 正确:useEffect 中处理挂载后请求 useEffect(()=>{// 副作用:挂载后请求数据 const fetchUsers =async()=>{ const res =await fetch('https://api.example.com/users'); const data =await res.json(); setUsers(data);}; fetchUsers();// 可选:清理函数(组件卸载时执行,比如取消请求) return()=>{// 示例:取消未完成的请求(需结合 AbortController) const controller = new AbortController(); controller.abort();};},[]);// 空依赖:仅组件挂载时执行一次 return(<ul>{users.map(user =><li key={user.id}>{user.name}</li>)}</ul>);}Portal
Portal 是 React 提供的一种将组件渲染到父组件 DOM 层级之外的 DOM 节点的方式,组件的逻辑(Props、State、事件)仍属于原组件树,但渲染位置脱离了父组件的 DOM 结构。
Portal 是 React 提供的一种将组件渲染到父组件 DOM 层级之外的 DOM 节点的方式,组件的逻辑(Props、State、事件)仍属于原组件树,但渲染位置脱离了父组件的 DOM 结构。
- 渲染位置脱离父 DOM:组件的 DOM 节点不在父组件的 DOM 层级中,而是挂载到指定的外部 DOM 节点;
- 逻辑仍属于原组件树:Portal 组件的 State、Props、事件回调仍和原组件树联动,不受渲染位置影响;
- 不破坏 React 上下文:Portal 内的组件仍能访问父组件的 Context、Hooks 等。
为什么需要 Portal?(解决的核心问题)
默认情况下,React 组件的渲染位置和 DOM 层级强绑定,这会导致两个高频问题:
- 层叠上下文(z-index)被父组件限制
父组件如果有 overflow: hidden、z-index、position: relative 等样式,子组件(比如弹窗)会被父组件裁剪、遮挡,哪怕设置很高的 z-index 也无效。
- 事件冒泡被父组件拦截
父组件如果有事件阻止冒泡(e.stopPropagation()),子组件的事件可能无法正常触发;或父组件的样式(如 pointer-events: none)影响子组件交互。
无Portal的弹窗问题:
// 父组件:有 overflow: hidden,弹窗会被裁剪 function Parent(){ const [showModal, setShowModal]= useState(false);return(<div style={{ width:300, height:300, border:'1px solid #ccc', overflow:'hidden',// 裁剪超出内容 position:'relative', zIndex:1}}><button onClick={()=> setShowModal(true)}>打开弹窗</button>{/* 弹窗被父组件裁剪,无法全屏显示 */}{showModal &&(<div style={{ position:'fixed', top:0, left:0, width:'100%', height:'100%', background:'rgba(0,0,0,0.5)', zIndex:9999// 哪怕z-index再高,仍被父组件裁剪 }}> 弹窗内容 </div>)}</div>);}弹窗只能在父组件的 300x300 区域内显示,超出部分被 overflow: hidden 裁剪,无法实现全屏遮罩。
Portal 的基本用法:createPortal 核心 API:
步骤 1:在 HTML 中定义外部 DOM 节点:在 public/index.html 中,在 root 之外新增一个空节点(用于挂载 Portal 内容):
<!DOCTYPE html><html lang="en"><body><!-- React 根节点 --><div id="root"></div><!-- Portal 专用节点(渲染弹窗/模态框) --><div id="portal-root"></div></body></html>步骤 2:封装 Portal 组件
import{ useState }from'react';import{ createPortal }from'react-dom';//1. 封装 Modal 组件(使用 Portal) function Modal({ isOpen, onClose, children }){if(!isOpen)return null;//2. 获取 Portal 目标 DOM 节点 const portalRoot = document.getElementById('portal-root');if(!portalRoot)return null;//3. 使用 createPortal 渲染到外部节点 return createPortal(// 弹窗内容(逻辑仍属于原组件树) <div style={{ position:'fixed', top:0, left:0, width:'100vw', height:'100vh', background:'rgba(0,0,0,0.5)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:9999// 此时z-index生效,不会被父组件限制 }} onClick={onClose}><div style={{ width:400, padding:20, background:'white', onClick:(e)=> e.stopPropagation()// 阻止点击内容关闭弹窗 }}>{children}<button onClick={onClose}>关闭</button></div></div>, portalRoot // 渲染到外部 DOM 节点 );}//4. 使用 Modal 组件 function Parent(){ const [isModalOpen, setIsModalOpen]= useState(false);return(<div style={{ width:300, height:300, border:'1px solid #ccc', overflow:'hidden'// 父组件仍有裁剪,但弹窗不受影响 }}><button onClick={()=> setIsModalOpen(true)}>打开弹窗</button><Modal isOpen={isModalOpen} onClose={()=> setIsModalOpen(false)}><h3>Portal 弹窗内容</h3><p>我脱离了父组件的 DOM 层级,但仍受父组件控制</p></Modal></div>);} export default Parent;- 渲染位置:Modal 的 DOM 节点会被挂载到 #portal-root 下,而非 Parent 组件的 DOM 层级中,因此不受 Parent 的 overflow: hidden 限制;
- 逻辑联动:Modal 的 isOpen、onClose 仍由 Parent 组件的 State 控制,点击 “关闭” 按钮能正常修改 Parent 的 State,事件冒泡也能正常传回原组件树;
- 兼容性:createPortal 是 React DOM 包的 API(react-dom),需单独导入,React Native 中无此 API(因为无 DOM 概念)。
Portal 主要用于 “需要脱离父组件 DOM 层级,但仍需和原组件树交互” 的元素,核心场景如下:
| 场景 | 核心需求 | 为什么用 Portal |
|---|---|---|
| 全局模态框(Modal) | 全屏遮罩、不被父组件裁剪、最高层显示 | 父组件的 overflow/z-index 无法限制,保证弹窗全屏显示 |
| 悬浮提示框(Tooltip) | 超出父组件范围显示、避免被裁剪 | 比如表格单元格内的 Tooltip,需显示在单元格外 |
| 下拉菜单(Dropdown) | 超出父组件容器显示(比如导航栏下拉) | 导航栏高度有限,下拉菜单需显示在导航栏外 |
| 通知提示(Notification/Toast) | 全局显示、不受任何父组件限制 | 提示框需在页面最顶层,不被其他元素遮挡 |
| 拖拽组件(Drag & Drop) | 拖拽时脱离原容器,避免被裁剪 | 拖拽过程中组件需悬浮在页面最上层 |
Suspense
Suspense 是 React 提供的组件,用于声明 “当子组件 / 数据还未准备好时(如懒加载组件未加载完成、异步数据未获取到),显示指定的占位 UI,准备完成后再渲染目标内容”。
把 Suspense 想象成 “餐厅的服务员”:目标内容 = 你点的牛排(需要时间烹饪);占位 UI = 免费的餐前小面包(牛排没好时先提供,避免你空等);Suspense = 服务员:负责盯着牛排的进度,没好时上小面包,做好了再换牛排。
- 声明式加载状态:无需手动写 isLoading 状态判断,直接通过 Suspense 声明 “加载中显示什么”;
- 等待 “可暂停的操作”:仅对两种场景生效 ——① React.lazy 懒加载组件 ② 支持 Suspense 的异步数据获取(如 React Query、SWR 或 React 内置的 use () 钩子);
- 优雅降级:等待过程中显示占位 UI,避免页面空白或闪烁,提升用户体验。
在 Suspense 出现前,处理 “加载状态” 需要手动维护 isLoading 状态,代码繁琐且易出错:
// 懒加载组件(传统方式) import{ useState, useEffect }from'react';// 手动导入懒加载组件 let LazyComponent = null; function Parent(){ const [isLoading, setIsLoading]= useState(true); const [Component, setComponent]= useState(null);// 手动加载组件 + 维护 loading 状态 useEffect(()=>{import('./LazyComponent').then(module =>{ setComponent(module.default); setIsLoading(false);}).catch(err => console.log('加载失败', err));},[]);// 手动判断加载状态 if(isLoading)return<div>Loading...</div>;return Component ? <Component />:<div>加载失败</div>;}代码冗余(每个懒加载组件都要写 isLoading)、逻辑分散(加载状态和业务逻辑混在一起)、无法统一管理多个异步操作的加载状态。
Suspense 把 “加载状态判断” 和 “占位 UI 显示” 封装成组件,代码简洁且统一:
import{ Suspense, lazy }from'react';//1. React.lazy 懒加载组件(返回 Promise) const LazyComponent = lazy(()=>import('./LazyComponent')); function Parent(){//2. Suspense 包裹懒加载组件,声明占位 UI return(<Suspense fallback={<div>Loading...</div>}><LazyComponent /></Suspense>);}项目实操
项目地址:github-socialvibe
SocialVibe 是一个以移动端优先的社交平台,用于发现、创建和参与本地活动。是一个三层 Monorepo,其中每一层 —— 前端、后端和数据基础设施 —— 都用 TypeScript 编写。

对于刚起步的开发者来说,SocialVibe 是一个具有指导意义的代码库:
- 后端强制执行严格的路由 → 控制器 → 服务 → 存储库分离 。每一层都有单一的职责,使得追踪从 HTTP 入口点到数据库查询的任何 API 请求都变得简单直接。
- 五次迁移的历史记录展示了生产模式如何随着时间的推移而增长 。
- 端到端使用相同的语言(以及共享的概念,如 Zod 验证)消除了困扰多语言技术栈的前端和后端之间的认知上下文切换。
Zod 是一个基于 TypeScript 的类型校验库(Schema Validation Library),核心作用是「在运行时校验数据的合法性」,同时能自动推导 TypeScript 类型,完美解决「TypeScript 编译时类型检查」和「运行时数据校验」的割裂问题。 ---- 运行时抛出详细错误,可自定义。
- 因为 Supabase 在本地 Docker 中运行,你可以运行整个应用程序 —— 包括认证、实时 WebSocket 广播和数据库 —— 而不需要任何云账户。这使得可以安全地进行实验、破坏和学习。
后端的 server.ts 默认使用端口 4000,前端 Vite 开发服务器使用端口 3000。如果你更改了其中任何一个,请记得相应地更新前端 .env.local 中的 VITE_API_PROXY_TARGET。
socialvibe/ ├── frontend/ │ ├── pages/# 11 个页面组件 + 测试文件 │ ├── components/# 共享 UI (BottomNav, RequireAuth) │ ├── context/# React Context 提供者 (Auth, User, Activity) │ ├── App.tsx # 带有 auth guard 的根路由器 │ └── package.json # React 19, Vite 6, react-router-dom 7 │ ├── backend/# Express BFF (TypeScript) │ ├── src/ │ │ ├── routes/# 5 个路由模块 │ │ ├── controllers/# 请求验证 + 响应塑形 │ │ ├── services/# 业务逻辑 (5 个领域服务) │ │ ├── repositories/# Supabase 数据访问层 │ │ ├── middleware/# Auth JWT 验证等 │ │ ├── config/# Zod 验证的环境解析, Supabase 客户端初始化 │ │ ├── types/# Express 请求扩充 │ │ ├── app.ts # Express 应用设置 + CORS + 路由挂载 │ │ └── server.ts # HTTP 服务器入口 (端口 4000) │ ├── tests/# API、服务、数据库和配置测试 │ ├── db/migrations/# 5 个编号的 SQL 迁移 │ └── package.json # Express 4, Zod 3, Supabase JS SDK │ ├── supabase-project/# 自托管 Supabase (Docker) │ ├── docker-compose.yml # 完整的 Supabase 服务栈 │ ├── dev/data.sql # 种子数据 │ └── volumes/# 持久化数据卷 │ ├── docs/# 计划、部署、测试 │ ├── plans/# 6 份设计 + 实现文档 │ ├── deployment/# Railway 部署操作手册 │ └── testing/# MVP 冒烟检查清单 │ ├── openspec/# 变更规范和任务跟踪 ├── AGENTS.md # 编码风格、测试和提交指南 └── README.md # 快速入门和常用命令
| 层级 | 技术 | 用途 |
|---|---|---|
| 前端 | React 19, Vite 6, TypeScript 5.8, React Router 7, Lucide Icons | 具有客户端路由的移动端优先 SPA |
| 后端 (BFF) | Express 4, TypeScript 5.8, Zod 3, Supabase JS SDK | 具有分层架构的 API 网关 |
| 数据与认证 | 自托管 Supabase (PostgreSQL, PostgREST, Auth, Realtime, Storage, Studio) | 所有持久化数据的单一真实来源 |
| 测试 | Vitest 3, Testing Library (前端), Supertest (后端) | 跨两个应用的共享测试框架 |
通过 Docker 自托管的 Supabase 而不是托管云服务。supabase-project/ 目录包含完整的 Docker Compose 栈,其中包含所有 Supabase 服务 —— 包括 Kong API 网关、PostgreSQL、PostgREST、Realtime (Elixir)、Auth、Storage 和 Studio 仪表板 —— 在本地 http://127.0.0.1:28000 运行。这意味着整个栈可以通过单个 git clone 和 docker compose up 复现。
什么是Monorepo ?
Monorepo(单体仓库)组织形式——即一个 Git 仓库承载了应用程序的所有部分:React 前端、Express 后端 API、本地 Supabase 数据库栈、项目文档以及变更规范。
对于不熟悉 Monorepo 的开发者来说,核心理念很简单:不再需要管理多个仓库(一个用于 UI,一个用于服务端,一个用于基础设施配置),所有内容都存放在同一个屋檐下。这意味着只需一次 git clone 即可获得完整的代码库,且单个 Pull Request 即可同时协调前端、后端和数据库的变更。
快速上手
前置要求:
| 工具 | 最低版本 | 用途 | 验证 |
|---|---|---|---|
| Node.js | 18+ (推荐 LTS) | 前端和后端的运行时 | node --version |
| npm | 9+ | 所有三个子项目的包管理器 | npm --version |
| Docker Desktop | 当前稳定版 | 运行自托管 Supabase 栈(Postgres, GoTrue, Kong, Realtime, Studio, Storage 等) | docker --version 和 docker compose version |
| Git | 任意近期版本 | 克隆代码仓库 | git --version |
第一步:克隆代码仓库
git clone https://github.com/liuhaha1111/socialvibe.git cd socialvibe 第二步:安装依赖
从代码仓库根目录运行以下两条命令。它们会分别安装到 frontend/node_modules 和 backend/node_modules 中:
npm --prefix frontend install npm --prefix backend install
| 子项目 | 主要运行时依赖 | 主要开发依赖 |
|---|---|---|
| frontend/ | react@19, react-router-dom@7, @supabase/supabase-js, lucide-react | vite@6, @vitejs/plugin-react, vitest, @testing-library/react, jsdom |
| backend/ | express@4, @supabase/supabase-js, zod@3 | tsx, [email protected], vitest, supertest |
两个子项目都在其 package.json 中使用了 “type”: “module”,因此所有导入都遵循 ESM 语法。
第三步:启动本地Supabase栈
SocialVibe 使用自托管 Supabase 配置,而非托管的云服务。整个栈位于 supabase-project/ 目录中,并由 Docker Compose 编排。
cd supabase-project cp .env.example .env docker compose pull docker compose up -d 这将启动大约十个容器:Studio、Kong (API 网关)、GoTrue (身份验证)、PostgREST (自动 REST API)、Realtime (WebSocket)、Storage、imgproxy、postgres-meta、Edge Runtime 和 Logflare。
第四步:配置环境文件
后端和前端都需要环境文件指向你的本地 Supabase 实例。复制示例文件,并从你的 Supabase .env 中填写值。
后端 (backend/.env)
cp backend/.env.example backend/.env
| 变量 | 值 | 来源 |
|---|---|---|
| PORT | 4000 | 你的选择(匹配 Vite 代理目标) |
| SUPABASE_URL | http://127.0.0.1:28000 | 来自 Supabase .env 的 Kong 网关端口 (KONG_HTTP_PORT) |
| SUPABASE_SERVICE_ROLE_KEY | (来自 supabase .env) | SERVICE_ROLE_KEY 值——授予完全数据库访问权限 |
前端 (frontend/.env.local)
cp frontend/.env.example frontend/.env.local
| 变量 | 值 | 备注 |
|---|---|---|
| VITE_API_PROXY_TARGET | http://127.0.0.1:4000 | 必须匹配后端的 PORT |
| VITE_API_BASE_URL | (本地开发中为空) | 仅在已部署的分离式架构设置中需要 |
| VITE_SUPABASE_URL | http://127.0.0.1:28000 | 与后端相同的 Supabase 网关 |
| VITE_SUPABASE_ANON_KEY | (来自 supabase .env) | ANON_KEY 值——用于身份验证和实时功能 |
第五步:启动后端和前端
启动后端和前端
从代码仓库根目录打开两个终端窗口(或使用进程管理器):
npm --prefix backend run dev # 输出:API listening on http://localhost:4000这将运行 tsx watch src/server.ts,它会在每次文件更改时编译并重启。Express 应用在 /api/v1/ 下注册了活动、个人资料、收藏、好友和聊天的路由,以及一个健康检查端点。
npm --prefix frontend run dev # 输出:VITE v6.x.x ready in xxx ms# ➜ Local: http://127.0.0.1:3000/Vite 使用热模块替换 (HMR) 提供 React 应用。由于 Vite 代理配置,所有对 /api/* 的请求都会被透明地代理到 http://127.0.0.1:4000。
前端代码
frontend/ 目录遵循清晰的、基于约定的布局,根据职责对代码进行分组。了解每个子文件夹的用途将帮助你快速导航任何 React 功能:
pages/
应用程序中每个屏幕对应一个文件。目前有 13 个页面组件:Home、Detail、Create、Chat、ChatList、Friends、Profile、Settings、Review、CheckIn、Saved 和 Auth。每个页面都是一个渲染完整屏幕的 React 函数组件。测试文件与其对应的页面放在一起,使用 PageName.test.tsx 或 PageName.text.test.tsx(用于文本专注的测试)的命名模式。
下面以Auth.tex和Chat.tsx为例:
- Auth.tsx(一体化的登录/注册表单组件)
采用「单组件双模式」设计,通过 mode 状态切换登录 / 注册,避免写两个重复的表单组件。用 useState 管理表单输入、提示信息、加载状态,逻辑清晰且类型安全。
加载状态禁用按钮,防止重复提交;友好的错误 / 成功提示;登录成功后替换路由,避免返回登录页;注册成功后自动切换到登录模式并清空密码。
表单验证:结合 HTML5 原生验证(邮箱格式、必填、密码长度)+ 异步错误处理,兼顾简单性和健壮性。
- 依赖导入与类型定义
import React,{ useState }from"react";import{ useNavigate }from"react-router-dom";import{ useAuth }from"../context/AuthContext";- React, { useState }:React 核心库 + 状态管理钩子(用于管理表单状态、模式切换等)
- useNavigate:React Router 钩子,用于登录成功后跳转到首页
- useAuth:自定义的认证上下文钩子,提供 signIn(登录)和 signUp(注册)方法(这是典型的 React - Context 用法,用于跨组件共享认证状态)
- 组件初始化与状态管理
export const Auth: React.FC =()=>{ const navigate = useNavigate(); const { signIn, signUp }= useAuth();// 登录/注册模式切换(默认登录) const [mode, setMode]= useState<"signin"|"signup">("signin");// 表单输入状态 const [email, setEmail]= useState(""); const [password, setPassword]= useState("");// 提示信息状态 const [error, setError]= useState<string | null>(null); const [notice, setNotice]= useState<string | null>(null);// 提交加载状态(防止重复提交) const [isSubmitting, setIsSubmitting]= useState(false);React.FC 表示这是一个无 props 的 React 函数式组件(Functional Component),TypeScript 强类型约束。
- mode:限定为 “signin” 或 “signup” 的联合类型,确保模式切换的类型安全
- email/password:存储用户输入的表单值
- error/notice:分别存储错误提示(红色)和成功提示(绿色)
- isSubmitting:控制提交按钮的禁用状态和加载文案,避免用户重复点击提交
- 动态文案定义
// 提交按钮文案(根据模式切换) const submitLabel = mode ==="signin" ? "Sign In":"Sign Up";// 切换模式按钮文案(根据模式切换) const switchLabel = mode ==="signin" ? "Create account":"Have an account? Sign in";这两个变量根据当前 mode 动态生成按钮文案,避免重复写条件判断,让代码更简洁。
- 核心:表单提交处理函数
const handleSubmit =async(event: React.FormEvent)=>{// 阻止表单默认提交行为(避免页面刷新) event.preventDefault();// 重置提示信息,开始提交 setError(null); setNotice(null); setIsSubmitting(true);try{// 根据模式调用不同的认证方法 if(mode ==="signin"){// 登录:调用 signIn 方法,成功后跳转到首页 await signIn(email, password); navigate("/",{ replace: true });// replace: true 避免返回登录页 }else{// 注册:调用 signUp 方法,成功后切换到登录模式 await signUp(email, password); setMode("signin");// 切换到登录 setPassword("");// 清空密码输入框 setNotice("Registration successful. Please sign in.");// 注册成功提示 }} catch (submitError){// 错误处理:兼容不同的错误格式,确保提示文案友好 const message = submitError instanceof Error ? submitError.message.trim():"";// 处理空错误、无效错误对象的情况 if(!message || message ==="{}"|| message ==="[object Object]"){ setError(mode ==="signup" ? "Registration failed. Please try again.":"Sign in failed. Please try again.");}else{// 显示具体的错误信息 setError(message);}}finally{// 无论成功/失败,都结束加载状态 setIsSubmitting(false);}};- 组件渲染部分
return(<div className="min-h-screen w-full flex justify-center bg-gray-100">{/* 表单容器:适配移动端,最大宽度 400px 左右 */}<div className="w-full max-w-md bg-white min-h-screen px-6 py-12">{/* 标题和说明 */}<h1 className="text-2xl font-bold text-slate-900">{mode ==="signin" ? "Welcome back":"Create account"}</h1><p className="text-sm text-slate-500 mt-2">Use your email and password to continue.</p>{/* 表单主体 */}<form className="mt-8 space-y-4" onSubmit={handleSubmit}>{/* 邮箱输入框 */}<div><label className="block text-sm font-medium text-slate-700 mb-1" htmlFor="auth-email"> Email </label><inputid="auth-email"type="email"// 浏览器原生邮箱格式验证 required // 必填项验证 value={email}// 受控组件:值绑定到状态 onChange={(event)=> setEmail(event.target.value)}// 输入时更新状态 className="w-full rounded-xl border border-slate-200 px-4 py-3"/></div>{/* 密码输入框 */}<div><label className="block text-sm font-medium text-slate-700 mb-1" htmlFor="auth-password"> Password </label><inputid="auth-password"type="password"// 密码隐藏显示 minLength={6}// 密码最小长度验证 required // 必填项验证 value={password}// 受控组件 onChange={(event)=> setPassword(event.target.value)} className="w-full rounded-xl border border-slate-200 px-4 py-3"/></div>{/* 错误提示(红色) */}{error ? <p className="text-sm text-red-600">{error}</p>: null}{/* 成功提示(绿色) */}{notice ? <p className="text-sm text-emerald-700">{notice}</p>: null}{/* 提交按钮 */}<button type="submit" disabled={isSubmitting}// 加载中禁用按钮 className="w-full rounded-xl bg-primary text-white py-3 font-semibold disabled:opacity-70">{/* 加载中显示 "Please wait...",否则显示对应文案 */}{isSubmitting ? "Please wait...": submitLabel}</button></form>{/* 切换登录/注册模式按钮 */}<button type="button" onClick={()=>{ setMode((prev)=>(prev ==="signin" ? "signup":"signin")); setError(null);// 切换模式时清空错误 setNotice(null);// 切换模式时清空提示 }} className="mt-4 text-sm text-primary font-medium">{switchLabel}</button></div></div>);};- Chat.tsx(实时聊天页面组件)
基于 Supabase Realtime 实现消息实时推送,无需刷新页面即可接收新消息;完整的消息生命周期:加载历史消息 → 发送新消息 → 实时接收消息;完善的边界处理:无会话、加载失败、空消息等场景都有友好提示。
useCallback 缓存消息加载函数,避免重复创建;useMemo 缓存发送状态计算结果,减少重复计算;组件卸载时取消 Supabase 订阅,防止内存泄漏。
- 依赖导入与类型定义
import React,{ useCallback, useEffect, useMemo, useState }from"react";import{ ArrowLeft, Send }from"lucide-react";import{ useLocation, useNavigate }from"react-router-dom";import{ listMessages, sendMessage,type ChatMessage,type ConversationSummary }from"../lib/chatApi";import{ supabase }from"../lib/supabase";import{ useUser }from"../context/UserContext";- React 钩子:useCallback(缓存函数)、useEffect(副作用)、useMemo(缓存计算值)、useState(状态管理)
- 图标:ArrowLeft(返回)、Send(发送)
- 路由:useLocation(获取路由状态)、useNavigate(页面跳转)
- 业务逻辑:chatApi 提供消息列表 / 发送方法,以及 ChatMessage/ConversationSummary 类型
- 实时通信:supabase 客户端(实现实时消息推送)
- 用户上下文:useUser 获取当前登录用户信息
// 路由状态类型:定义从消息列表页跳转过来时携带的会话信息 interface ChatLocationState { conversation?: ConversationSummary;}类型约束:确保路由状态的类型安全,避免使用时出现类型错误。
- 工具函数:时间格式化
function formatTime(iso: string): string { const date = new Date(iso);// 处理无效的时间字符串 if(Number.isNaN(date.getTime())){return"";}// 格式化为 24小时制的 "时:分"(如 14:30) return date.toLocaleTimeString("zh-CN",{ hour:"2-digit", minute:"2-digit", hour12: false });}将数据库返回的 ISO 时间字符串(如 2026-03-18T14:30:00Z)格式化为用户友好的本地时间。处理无效时间字符串,避免页面报错。
- 组件初始化与状态管理
export const Chat: React.FC =()=>{ const navigate = useNavigate(); const location = useLocation(); const { user }= useUser();// 获取路由跳转时携带的会话信息(类型断言) const state = location.state as ChatLocationState | null; const conversation = state?.conversation;// 核心状态 const [messages, setMessages]= useState<ChatMessage[]>([]);// 聊天消息列表 const [loading, setLoading]= useState(false);// 消息加载状态 const [error, setError]= useState<string | null>(null);// 错误提示 const [content, setContent]= useState("");// 输入框内容 const conversationId = conversation?.id;// 当前会话ID location.state:React Router 中,从其他页面通过 navigate(‘/chat’, { state: { conversation } }) 传递的参数,这里用于接收当前要打开的会话信息。
conversationId:会话唯一标识,所有消息操作(加载 / 发送 / 实时监听)都依赖这个 ID。
- 核心函数:刷新消息列表
// useCallback 缓存函数:避免依赖变化导致重复创建 const refreshMessages = useCallback(async()=>{// 无会话ID时直接返回 if(!conversationId){return;} setLoading(true);try{// 调用 API 获取该会话的所有消息 const data =await listMessages(conversationId); setMessages(data);// 更新消息列表 setError(null);// 清空错误 } catch (err){// 错误处理:友好提示 setError(err instanceof Error ? err.message :"消息加载失败");}finally{ setLoading(false);// 结束加载状态 }},[conversationId]);// 依赖:仅当 conversationId 变化时重新创建函数 useCallback 作用:缓存函数引用,避免因组件重渲染导致函数重新创建,进而影响依赖它的 useEffect 执行。
- 副作用:初始化加载消息
useEffect(()=>{// 组件挂载/refreshMessages 变化时,加载消息列表 refreshMessages().catch(()=> undefined);},[refreshMessages]);组件首次渲染时,自动调用 refreshMessages 加载历史消息;refreshMessages 函数变化时(即 conversationId 变化)也会重新加载。
- 核心:Supabase 实时消息监听
useEffect(()=>{if(!conversationId){return;}// 创建 Supabase 实时通道:监听指定会话的消息新增 const channel = supabase .channel(`chat-${conversationId}`)// 通道名称:唯一标识该会话的监听通道 .on("postgres_changes",// 监听 PostgreSQL 数据库变化 { event:"INSERT",// 只监听新增事件(发送新消息时会插入数据) schema:"public",// 数据库模式 table:"messages",// 监听的表名 filter: `conversation_id=eq.${conversationId}` // 过滤条件:仅当前会话的消息 },(payload)=>{// 收到新消息时的回调 const incoming = payload.new as ChatMessage;// 更新消息列表:避免重复添加(防止重复监听导致重复渲染) setMessages((prev)=>(prev.some((item)=> item.id=== incoming.id) ? prev :[...prev, incoming]));}).subscribe();// 订阅通道 // 组件卸载时取消订阅:防止内存泄漏 return()=>{if(typeof (channel as{ unsubscribe?:()=> void }).unsubscribe ==="function"){(channel as{ unsubscribe:()=> void }).unsubscribe();}};},[conversationId]);supabase.channel():创建一个实时通道,名称 chat-${conversationId} 确保每个会话的通道唯一。
on(“postgres_changes”):监听数据库表的变化,这里只监听 messages 表的 INSERT 事件(新消息插入)。
filter:只处理当前会话(conversation_id 匹配)的消息,避免接收其他会话的消息。
回调函数:收到新消息后,更新本地消息列表(先判断是否已存在,避免重复)。
清理函数:组件卸载时取消订阅,防止内存泄漏和无效监听。
- 缓存计算:判断是否可发送消息
// useMemo 缓存计算结果:避免每次渲染都重新计算 const canSend = useMemo(()=> content.trim().length >0&& Boolean(conversationId),[content, conversationId]);计算逻辑:输入框内容非空 且 有会话 ID 时,才允许发送。
useMemo 作用:缓存布尔值,仅当 content 或 conversationId 变化时重新计算。
- 核心函数:发送消息
const handleSend =async()=>{// 前置校验:无会话ID 或 输入框为空,直接返回 if(!conversationId || !content.trim()){return;}try{// 调用 API 发送消息 const created =await sendMessage(conversationId, content.trim());// 更新消息列表:避免重复添加 setMessages((prev)=>(prev.some((item)=> item.id=== created.id) ? prev :[...prev, created])); setContent("");// 清空输入框 setError(null);// 清空错误 } catch (err){// 错误提示 setError(err instanceof Error ? err.message :"发送失败");}};发送成功后:清空输入框,即时将新消息添加到列表(无需等待实时监听),提升用户体验。
- 兜底渲染:无会话 ID 时的提示
// 无会话ID/会话信息时,显示提示并返回消息列表页 if(!conversationId || !conversation){return(<div className="bg-background-light min-h-screen flex items-center justify-center px-6"><div className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 text-center max-w-sm w-full"><h1 className="text-xl font-bold text-slate-900">未选择会话</h1><p className="text-slate-500 text-sm mt-2">请先在消息列表中选择一个会话。</p><button onClick={()=> navigate("/chat-list")} className="mt-5 w-full h-11 rounded-full bg-primary text-white font-semibold"> 返回消息列表 </button></div></div>);}边界处理:用户直接访问 /chat 路径(无会话信息)时,显示友好提示,避免页面报错或空白。
- 主渲染:聊天页面 UI
return(<div className="bg-background-light h-screen flex flex-col justify-center overflow-hidden">{/* 聊天容器:适配移动端,最大宽度 400px 居中 */}<div className="relative w-full max-w-md h-full bg-white shadow-2xl flex flex-col overflow-hidden mx-auto">{/* 顶部导航栏 */}<header className="flex-none bg-white border-b border-slate-100 z-20"><div className="h-10 w-full bg-white"/>{/* 适配移动端安全区 */}<div className="flex items-center justify-between px-4 py-3">{/* 返回按钮:跳回消息列表 */}<button onClick={()=> navigate("/chat-list")} className="text-slate-900 flex items-center justify-center p-2 -ml-2 rounded-full hover:bg-slate-100 transition-colors"><ArrowLeft size={24}/></button>{/* 会话标题和类型 */}<div className="flex flex-col items-center flex-1 mx-2"><h2 className="text-slate-900 text-lg font-bold leading-tight tracking-tight">{conversation.title}</h2><p className="text-xs text-slate-500">{conversation.type==="activity_group" ? "活动群聊":"好友私聊"}</p></div><div className="w-10"/>{/* 占位:让标题居中 */}</div></header>{/* 消息列表区域 */}<main className="flex-1 overflow-y-auto bg-background-light p-4 flex flex-col gap-3">{/* 加载状态提示 */}{loading &&<p className="text-xs text-slate-500 text-center">加载中...</p>}{/* 错误提示 */}{error &&<p className="text-xs text-red-500 text-center">{error}</p>}{/* 消息列表渲染 */}{!loading && messages.map((message)=>{// 判断是否是当前用户发送的消息 const isMine = user.id&& message.sender_profile_id === user.id;return(<div key={message.id} className={`flex ${isMine ? "justify-end":"justify-start"}`}><div className={`max-w-[78%] rounded-2xl px-4 py-3 ${// 自己的消息:主题色背景、白色文字、右下角无圆角 isMine ? "bg-primary text-white rounded-br-none":// 对方的消息:白色背景、灰色文字、左下角无圆角、边框 "bg-white text-slate-900 rounded-bl-none border border-slate-100"}`}>{/* 消息内容 */}<p className="text-sm leading-relaxed break-words">{message.content}</p>{/* 消息时间 */}<p className={`text-[10px] mt-1 ${isMine ? "text-white/80":"text-slate-400"}`}>{formatTime(message.created_at)}</p></div></div>);})}{/* 空消息提示 */}{!loading && messages.length ===0&&<p className="text-sm text-slate-500 text-center py-8">还没有消息,发一条开始聊天吧。</p>}</main>{/* 底部输入框区域 */}<footer className="flex-none bg-white px-4 py-3 pb-8 border-t border-slate-100 z-20"><div className="flex items-end gap-2">{/* 输入框 */}<div className="flex-1 bg-slate-100 rounded-3xl flex items-center px-4 py-2 min-h-[48px]"><input className="w-full bg-transparent border-none p-0 text-slate-900 placeholder-slate-400 focus:ring-0 text-[16px]" placeholder="输入消息..."type="text" value={content}// 受控组件:值绑定到 state onChange={(event)=> setContent(event.target.value)}// 输入时更新 state onKeyDown={(event)=>{// 回车发送消息(阻止默认换行行为) if(event.key ==="Enter"){ event.preventDefault(); handleSend().catch(()=> undefined);}}}/></div>{/* 发送按钮 */}<button onClick={()=> handleSend().catch(()=> undefined)} disabled={!canSend}// 不可发送时禁用 className="flex-none bg-primary text-white p-3 rounded-full disabled:opacity-50 disabled:cursor-not-allowed h-[48px] w-[48px] flex items-center justify-center"><Send size={20} className="ml-0.5"/></button></div></footer></div></div>);};context/
三个 React Context 提供器(AuthContext.tsx、UserContext.tsx、ActivityContext.tsx),用于管理全局应用状态。App.tsx 将经过认证的路由包裹在 < UserProvider> 和 < ActivityProvider> 中,创建了清晰的提供器层级。
下面以ActivityContext.tsx为例:
- ActivityContext(基于 React Context 实现的活动数据管理上下文)
区分后端数据(ActivityApi)和前端数据(Activity),解耦数据层和 UI 层。
状态管理:基于 Context + useState 实现全局状态共享,无需第三方状态库(如 Redux),轻量高效。
性能优化:大量使用 useCallback/useMemo 缓存函数 / 计算结果,Set 优化查找性能,避免不必要的重渲染。
用户体验:收藏功能采用「乐观更新」,先更本地状态再同步后端,减少等待感;失败时回滚状态,保证数据一致性。
容错设计:默认图片 / 头像兜底、API 失败不崩溃 UI、时间格式化容错,提升鲁棒性。
- React 核心及钩子导入
import React,{ createContext, useCallback, useContext, useEffect, useMemo, useState,type ReactNode }from"react";import{ apiDelete, apiGet, apiPost }from"../lib/api";import{ useAuth }from"./AuthContext";
| 导入项 | 核心作用 | 在本组件中的具体用途 | 工程化考量 |
|---|---|---|---|
| React | React 核心库 | 所有 React 组件的基础(如 React.FC 类型、JSX 解析依赖) | 必须导入,即使代码中未显式使用(JSX 编译后会依赖 React.createElement) |
| createContext | 创建 React Context | 定义 ActivityContext,实现全局状态共享 | 替代 Props 透传,适合跨层级组件共享状态 |
| useCallback | 缓存函数引用 | 包装 refreshActivities/createActivity/toggleFavorite,避免组件重渲染时函数重复创建,进而防止依赖这些函数的 useEffect 重复执行 | 优化性能,减少不必要的重渲染 |
| useContext | 消费 Context | 在 useActivity 钩子中获取 ActivityContext 的值 | 是 Context 模式的核心消费方式 |
| useEffect | 处理副作用 | 1. 组件挂载时自动加载活动列表;2. 依赖变化时重新加载 | 处理异步数据请求、订阅 / 取消订阅等副作用,是 React 生命周期的核心替代方案 |
| useMemo | 缓存计算结果 | 包装 isFavorite 函数,将收藏列表转为 Set 提升查找性能,且仅在 favorites 变化时重新计算 | 避免频繁重复计算(如 isFavorite 可能被列表中每个活动调用) |
| useState | 管理组件状态 | 定义 activities(活动列表)、favorites(收藏列表)两个核心状态 | 函数式组件的基础状态管理方式 |
| type ReactNode | TypeScript 类型 | 定义 ActivityProvider 的 children 属性类型({ children: ReactNode }) | 强类型约束,确保 children 是合法的 React 节点(元素、文本、null 等) |
| 导入项 | 核心作用 | 在本组件中的具体用途 | 封装价值 |
|---|---|---|---|
| apiGet | 封装 GET 请求 | 调用 /api/v1/activities 获取活动列表 | 1. 统一请求头(如携带 Token);2. 统一错误处理;3. 类型泛型支持(apiGet<ActivityApi[]>);4. 统一 baseURL |
| apiPost | 封装 POST 请求 | 1. 创建活动(/api/v1/activities);2. 收藏活动(/api/v1/me/favorites/${id}) | 同上,且自动处理 JSON 数据序列化 |
| apiDelete | 封装 DELETE 请求 | 取消收藏活动(/api/v1/me/favorites/${id}) | 统一 DELETE 请求的参数传递方式(如 URL 参数 / 请求体) |
| useAuth | 消费认证上下文 | 获取 isAuthenticated(是否登录)、isLoading(认证状态加载中)、getAccessToken(获取 Token) | 1. 仅当用户登录后才加载活动列表;2. API 请求需要 Token 鉴权;3. 未登录时跳过数据加载,避免无效请求 |
- 类型定义(TypeScript)
这部分是代码的「骨架」,确保类型安全:
// 前端展示用的活动类型(UI 层) export interface Activity {id: string; title: string; image: string; location: string; date: string;// 格式化后的日期(如 "03-18 周二 14:30") fullDate?: string;// 原始 ISO 时间 time?: string;// 格式化后的时间(如 "14:30") participants: number;// 已参与人数 needed: number;// 还需人数 tag: string;// 分类标签 avatars: string[];// 参与者头像(展示用) full?: boolean;// 是否满员 description?: string;// 活动描述 isUserCreated?: boolean;// 是否是当前用户创建 latitude?: number | null;// 纬度 longitude?: number | null;// 经度 distanceKm?: number;// 距离当前用户的公里数 }// 创建活动时的输入参数类型(表单提交用) interface CreateActivityInput { title: string; image_url?: string; location: string; start_time: string;// ISO 时间 category: string; description?: string; max_participants: number; latitude: number; longitude: number;}// 活动列表筛选条件类型 interface ActivityListFilters { q?: string;// 关键词 category?: string;// 分类 latitude?: number;// 纬度(用于附近活动) longitude?: number;// 经度 radius_km?: number;// 距离半径(公里) }// 后端 API 返回的原始活动类型(数据层) interface ActivityApi {id: string; title: string; image_url: string | null; location: string; start_time: string; category: string; description: string | null; participant_count: number;// 后端字段名:参与人数 max_participants: number;// 后端字段名:最大人数 is_favorite?: boolean;// 是否收藏 latitude: number | null; longitude: number | null; distance_km?: number;}// 上下文提供的方法/状态类型(核心接口) interface ActivityContextType { activities: Activity[];// 活动列表 createActivity:(payload: CreateActivityInput)=> Promise<void>;// 创建活动 favorites: string[];// 收藏的活动 ID 列表 toggleFavorite:(id: string)=> Promise<void>;// 切换收藏状态 isFavorite:(id: string)=> boolean;// 判断是否收藏 refreshActivities:(filters?: ActivityListFilters)=> Promise<void>;// 刷新活动列表 }区分 ActivityApi(后端原始数据)和 Activity(前端展示数据):解耦后端数据结构和前端 UI 展示,即使后端字段变化,只需修改映射函数,无需改动 UI 组件。
拆分不同场景的类型(创建输入、筛选条件、上下文接口):每个类型只服务于特定场景,职责单一,易于维护。
- 工具函数(纯函数)
这部分是「数据转换器」和「辅助工具」,无副作用,只做数据处理。
默认常量(兜底值)
const DEFAULT_IMAGE ="https://xxx";// 活动默认封面 const DEFAULT_AVATAR ="https://xxx";// 默认头像 作用:后端返回 image_url 为 null 时,使用默认图片,避免 UI 出现空白。
时间格式化函数
function formatDateAndTime(iso: string):{ date: string; time: string }{ const d = new Date(iso);// 格式化示例:返回 { date:"03-18 周二 14:30", time:"14:30"} const date = d.toLocaleDateString("zh-CN",{ month:"2-digit", day:"2-digit", weekday:"short"}); const time = d.toLocaleTimeString("zh-CN",{ hour:"2-digit", minute:"2-digit", hour12: false });return{ date: `${date} ${time}`, time };}作用:将后端返回的 ISO 时间字符串(如 2026-03-18T14:30:00Z)转换为用户友好的中文格式。
后端数据 → 前端数据映射函数
function mapApiToActivity(api: ActivityApi): Activity { const participants = api.participant_count; const needed = Math.max(api.max_participants - participants,0);// 还需人数(最小为 0) const { date, time }= formatDateAndTime(api.start_time); const avatarCount = Math.min(Math.max(participants,1),3);// 头像数量:1-3 个 return{id: api.id, title: api.title, image: api.image_url || DEFAULT_IMAGE,// 兜底默认图片 location: api.location, date, fullDate: api.start_time, time, participants, needed, tag: api.category, avatars: Array.from({ length: avatarCount },()=> DEFAULT_AVATAR),// 生成默认头像数组 full: needed ===0,// 满员判断 description: api.description ||"", isUserCreated: true, latitude: api.latitude, longitude: api.longitude, distanceKm: api.distance_km };}字段映射:后端 participant_count → 前端 participants,后端 category → 前端 tag。
业务计算:needed(还需人数)、full(是否满员)、avatarCount(展示的头像数量)。
兜底处理:图片 / 描述为空时使用默认值,避免 UI 报错。
构建带筛选参数的 API 路径
function buildActivitiesPath(filters?: ActivityListFilters): string {if(!filters)return"/api/v1/activities"; const params = new URLSearchParams();// 拼接筛选参数 if(filters.q) params.set("q", filters.q);if(filters.category) params.set("category", filters.category);if(filters.latitude !== undefined) params.set("latitude", filters.latitude.toString());if(filters.longitude !== undefined) params.set("longitude", filters.longitude.toString());if(filters.radius_km !== undefined) params.set("radius_km", filters.radius_km.toString()); const query = params.toString();return query ? `/api/v1/activities?${query}` :"/api/v1/activities";}作用:根据筛选条件动态生成 API 请求路径(如 /api/v1/activities?q=跑步&category=运动&radius_km=5),避免手动拼接 URL 导致的错误。
- Context 核心实现
这部分是「状态管理核心」,包含 Provider 组件和自定义 Hook。
创建 Context
const ActivityContext = createContext<ActivityContextType | undefined>(undefined);初始值设为 undefined:强制要求组件必须在 Provider 内部使用,否则抛出错误(见 useActivity 钩子)。
Provider 组件(核心)
export const ActivityProvider: React.FC<{ children: ReactNode }>=({ children })=>{// 从 AuthContext 获取认证状态和 Token const { isAuthenticated, isLoading, getAccessToken }= useAuth();// 核心状态 const [activities, setActivities]= useState<Activity[]>([]);// 活动列表 const [favorites, setFavorites]= useState<string[]>([]);// 收藏的活动 ID 列表 //1. 刷新活动列表(带筛选) const refreshActivities = useCallback(async(filters?: ActivityListFilters)=>{// 调用 API 获取后端原始数据 const remote =await apiGet<ActivityApi[]>(buildActivitiesPath(filters));// 转换为前端展示数据 setActivities(remote.map(mapApiToActivity));// 提取收藏的活动 ID 列表 setFavorites(remote.filter((x)=> x.is_favorite).map((x)=> x.id));},[]);// 无依赖:始终使用同一个函数引用 //2. 副作用:初始化加载活动列表 useEffect(()=>{// 未认证/加载中/无 Token 时,不加载数据 if(isLoading || !isAuthenticated || !getAccessToken()){return;}// 加载数据(失败时只打印日志,不崩溃 UI) refreshActivities().catch((error)=>{ console.error("Failed to load activities:", error);});},[getAccessToken, isAuthenticated, isLoading, refreshActivities]);//3. 创建新活动 const createActivity = useCallback(async(payload: CreateActivityInput)=>{// 调用 API 创建活动 const created =await apiPost<ActivityApi>("/api/v1/activities", payload);// 将新活动添加到列表头部(最新的在最前面) setActivities((prev)=>[mapApiToActivity(created),...prev]);},[]);//4. 切换收藏状态(收藏/取消收藏) const toggleFavorite = useCallback(async(id: string)=>{// 先更新本地状态(乐观更新:提升用户体验) const prev = favorites; const currentlyFavorite = prev.includes(id); const next= currentlyFavorite ? prev.filter((x)=> x !==id):[...prev,id]; setFavorites(next);try{// 调用后端 API 同步状态 if(currentlyFavorite){await apiDelete(`/api/v1/me/favorites/${id}`);// 取消收藏 }else{await apiPost<null>(`/api/v1/me/favorites/${id}`);// 收藏 }} catch (error){// API 失败时,回滚本地状态 setFavorites(prev); throw error;// 抛出错误,让调用方处理 }},[favorites]// 依赖:收藏列表变化时重新创建函数 );//5. 判断是否收藏(缓存函数,提升性能) const isFavorite = useMemo(()=>{// 转换为 Set:查找效率 O(1)(数组查找是 O(n)) const set= new Set(favorites);return(id: string)=>set.has(id);},[favorites]);// 仅当收藏列表变化时重新创建函数 // 提供上下文值给子组件 return(<ActivityContext.Provider value={{ activities, createActivity, favorites, toggleFavorite, isFavorite, refreshActivities }}>{children}</ActivityContext.Provider>);};
| 函数 / 副作用 | 核心作用 | 设计亮点 |
|---|---|---|
| refreshActivities | 加载 / 刷新活动列表 | 1. useCallback 缓存函数;2. 自动转换后端数据为前端格式;3. 同步收藏列表 |
| 初始化 useEffect | 自动加载数据 | 1. 仅在认证通过后加载;2. 失败时只打日志,保证 UI 可用;3. 依赖精准,避免重复加载 |
| createActivity | 创建新活动 | 1. 创建后立即添加到列表头部;2. 数据转换后再更新状态 |
| toggleFavorite | 切换收藏 | 1. 乐观更新:先更本地状态,再调 API(用户无感知延迟);2. API 失败时回滚状态;3. 抛出错误让调用方处理 |
| isFavorite | 判断收藏状态 | 1. useMemo 缓存函数;2. 转换为 Set 提升查找性能(适合频繁调用) |
自定义 Hook(便捷访问上下文)
export const useActivity =()=>{ const context = useContext(ActivityContext);if(context === undefined){ throw new Error("useActivity must be used within an ActivityProvider");}return context;};简化上下文访问:子组件只需 const { activities, toggleFavorite } = useActivity() 即可使用。
强制约束:如果组件不在 ActivityProvider 内使用,直接抛出明确错误,便于调试。
components/
跨页面使用的共享 UI 组件。目前包含 BottomNav.tsx(移动端标签栏)和 RequireAuth.tsx(将未认证用户重定向到 /auth 的路由守卫)。
下面以BottomNav.tsx为例:
- BottomNav.tsx(移动端标签栏)
基于 React Router 实现路由跳转和激活态高亮;支持在指定页面自动隐藏导航栏;包含「首页、收藏、发布、消息、我的」5 个导航项,其中「发布」为突出显示的核心功能按钮;丰富的交互动效(hover/active 状态、缩放动画、毛玻璃效果);适配移动端安全区、响应式布局,符合现代 UI 设计规范
- 依赖导入(基础支撑)
import React from"react";import{ Home, Heart, MessageSquare, User, Plus }from"lucide-react";import{ useNavigate, useLocation }from"react-router-dom";
| 导入项 | 核心作用 | 工程化考量 |
|---|---|---|
| React | React 核心库,支持 JSX 解析和组件创建 | 函数式组件的基础依赖 |
| lucide-react 图标 | 轻量级、可定制的 SVG 图标库,提供导航所需的首页 / 收藏 / 消息等图标 | 替代图片图标,支持尺寸 / 颜色 / 填充等动态调整,体积更小 |
| useNavigate | React Router 钩子,实现编程式路由跳转(点击按钮跳转到指定页面) | 替代传统 a 标签,避免页面刷新,符合 SPA 设计 |
| useLocation | React Router 钩子,获取当前路由信息(如 location.pathname 是当前页面路径) | 用于判断当前页面是否匹配导航项,实现激活态高亮 |
- 组件初始化与核心工具函数
export const BottomNav: React.FC =()=>{// 初始化路由导航和位置钩子 const navigate = useNavigate(); const location = useLocation();// 工具函数:判断当前路由是否与目标路径完全匹配(用于激活态判断) const isActive =(path: string)=> location.pathname === path;navigate:调用 navigate(‘/path’) 即可跳转到对应路由(如 navigate(‘/’) 跳首页);
location.pathname:当前页面的路径(如 /chat-list、/profile);
isActive:极简工具函数,返回布尔值,是实现导航项「激活态高亮」的核心逻辑。
- 导航栏隐藏逻辑(边界处理)
// 定义需要隐藏导航栏的路径列表(全屏子页面) const hideNavPaths =["/create","/chat","/detail","/review","/checkin","/settings"];// 检查当前路径是否以列表中任意路径开头(支持子路径,如 /chat/123 也会匹配 /chat) if(hideNavPaths.some((path)=> location.pathname.startsWith(path))){return null;// 返回 null 表示组件不渲染,实现导航栏隐藏 }在「发布页、聊天页、详情页」等全屏子页面,不需要显示底部导航,因此直接返回 null 隐藏组件。
- 核心渲染:导航栏 UI 结构
return({/* 导航栏外层容器:固定在底部,适配移动端 */}<nav className="fixed bottom-0 left-0 right-0 bg-white/90 backdrop-blur-lg border-t border-slate-100 pb-safe pt-2 px-6 z-50 max-w-md mx-auto">{/* 导航项容器:flex 布局,5 个导航项均分宽度 */}<div className="flex justify-between items-end pb-4">{/*1. 首页导航项 */}<button onClick={()=> navigate("/")}// 点击跳首页 {/* 动态 className:激活态文字为主题色,否则为灰色;w-1/5 表示占 1/5 宽度(5 个项均分) */} className={`flex flex-col items-center gap-1 group w-1/5 ${isActive("/") ? "text-primary":"text-slate-400"}`}>{/* 图标容器:圆角矩形,hover/激活态背景变化 */}<div className={`rounded-2xl w-12 h-8 flex items-center justify-center transition-all ${ isActive("/") ? "bg-primary/10":"group-hover:bg-primary/5"}`}>{/* 首页图标:激活态时填充颜色(fill-current 表示填充当前文字颜色) */}<Home size={24} className={isActive("/") ? "fill-current":""}/></div>{/* 导航文字:极小字号,加粗,符合移动端设计 */}<span className="text-[10px] font-bold">首页</span></button>{/*2. 收藏导航项(逻辑与首页一致,仅路径/图标/文字不同) */}<button onClick={()=> navigate("/saved")} className={`flex flex-col items-center gap-1 group w-1/5 ${isActive("/saved") ? "text-primary":"text-slate-400"}`}><div className="rounded-2xl w-12 h-8 flex items-center justify-center transition-all group-hover:bg-primary/5"><Heart size={24}/></div><span className="text-[10px] font-bold">收藏</span></button>{/*3. 发布按钮(核心突出按钮,特殊设计) */}<div className="w-1/5 flex justify-center relative z-10"><button onClick={()=> navigate("/create")}{/* 核心样式:绝对定位向上偏移(-top-10),圆形,主题色背景,阴影增强立体感 */} className="absolute -top-10 w-14 h-14 bg-primary text-white rounded-full flex items-center justify-center shadow-xl shadow-primary/40 border-4 border-background-light transform transition-transform active:scale-90 hover:scale-105" aria-label="发布"// 无障碍标签,提升可访问性(屏幕阅读器识别) ><Plus size={32}/>{/* 加号图标,尺寸更大,突出发布功能 */}</button></div>{/*4. 消息导航项(带未读红点,特殊设计) */}<button onClick={()=> navigate("/chat-list")} className={`flex flex-col items-center gap-1 group w-1/5 ${ isActive("/chat-list") ? "text-primary":"text-slate-400"}`}>{/* 图标容器:relative 定位,用于放置未读红点 */}<div className="relative rounded-2xl w-12 h-8 flex items-center justify-center transition-all group-hover:bg-primary/5"><MessageSquare size={24}/>{/* 消息图标 */}{/* 未读红点:绝对定位在图标右上角,圆形,主题色,白色边框(避免和图标融合) */}<span className="absolute top-1 right-2 w-2 h-2 bg-primary rounded-full border border-white"/></div><span className="text-[10px] font-bold">消息</span></button>{/*5. 我的导航项(逻辑与首页一致) */}<button onClick={()=> navigate("/profile")} className={`flex flex-col items-center gap-1 group w-1/5 ${isActive("/profile") ? "text-primary":"text-slate-400"}`}><div className={`rounded-2xl w-12 h-8 flex items-center justify-center transition-all ${ isActive("/profile") ? "bg-primary/10":"group-hover:bg-primary/5"}`}><User size={24} className={isActive("/profile") ? "fill-current":""}/></div><span className="text-[10px] font-bold">我的</span></button></div></nav>);};types.ts
共享的 TypeScript 接口,如 Activity、User 和 Tab,供多个页面导入使用。
React + TypeScript 项目的核心业务类型定义,主要用于约束「活动(Activity)」、「用户(User)」、「导航标签(Tab)」三类核心数据的结构:
- 强类型约束:避免开发中出现「字段名写错、类型不匹配、可选字段未处理」等低级错误;
- 统一数据格式:让前后端、组件间的数据交互有明确的规范;
- 提升可维护性:通过类型注释 / 语义化字段名,让代码可读性更高。
- Activity 接口(核心业务类型:活动)
export interface Activity {id: string;// 活动唯一标识(主键) title: string;// 活动标题 image: string;// 活动封面图 URL location: string;// 活动地点(文字描述,如 "北京市朝阳区XX公园") date: string;// 活动日期(格式化后,如 "2026-03-20") time: string;// 活动时间(格式化后,如 "14:00-16:00") price?: number;// 活动价格(可选,无则为免费) host:{// 活动主办方/发起人(嵌套对象) name: string;// 发起人姓名 avatar: string;// 发起人头像 URL rating?: number;// 发起人评分(可选,如 4.8) }; tags: string[];// 活动标签(数组,如 ["户外","跑步","社交"]) participants: number;// 当前参与人数 maxParticipants: number;// 最大参与人数 participantAvatars: string[];// 参与者头像列表(展示用,如前3个参与者) status?:'active'|'full'|'finished'|'confirmed'|'waitlist'|'pending';// 活动状态(联合类型) category?: string;// 活动分类(如 "户外"、"美食"、"运动") description?: string;// 活动详情描述 }
| 字段 | 类型 | 必选 / 可选 | 业务含义 | 设计考量 |
|---|---|---|---|---|
| id | string | 必选 | 活动唯一 ID | 通常为后端数据库主键(UUID / 自增 ID),用于区分不同活动 |
| title/image | string | 必选 | 标题 / 封面图 | 核心展示字段,UI 中优先显示 |
| location | string | 必选 | 活动地点 | 文字描述型地址,适合移动端简洁展示 |
| date/time | string | 必选 | 日期 / 时间 | 格式化后的字符串(而非原始 ISO 时间),直接用于 UI 展示,避免前端重复格式化 |
| price | number | 可选 | 价格 | 可选字段(?),无则表示免费活动 |
| host | 嵌套对象 | 必选 | 活动发起人 | 聚合发起人相关信息,避免零散字段(如 h |
| tags | string[] | 必选 | 标签列表 | 数组类型,支持多个标签(如同时标记「户外」+「新手友好」) |
| participants/maxParticipants | number | 必选 | 参与人数 / 最大人数 | 用于计算「剩余名额」(maxParticipants - participants)、判断是否满员 |
| participantAvatars | string[] | 必选 | 参与者头像 | 仅存储展示用的少量头像(如前 3 个),减少数据传输量 |
| status | 联合类型 | 可选 | 活动状态 | 限定为固定枚举值,避免随意字符串导致的逻辑错误 |
| category/description | string | 可选 | 分类 / 详情 | 补充字段,分类用于筛选,描述用于活动详情页 |
可选字段(?):如 price?: number,表示该字段「可以不存在」,TypeScript 会强制开发者处理「字段不存在」的场景(如 activity.price ?? 0),避免 Cannot read property ‘price’ of undefined 错误;
嵌套对象:host 字段是嵌套接口,聚合相关字段,让数据结构更清晰,符合「单一职责」;
联合类型(Union Type):status?: ‘active’ | ‘full’ | …,限定 status 只能是指定的 6 个字符串之一,杜绝拼写错误(如把 ‘full’ 写成 ‘FULL’ 或 ‘fulled’),是 TypeScript 替代「枚举(enum)」的轻量方案。
状态值(status)业务含义补充
| 状态值 | 业务含义 |
|---|---|
| active | 活动正常招募中 |
| full | 名额已满 |
| finished | 活动已结束 |
| confirmed | 活动已确认(主办方确认举办) |
| waitlist | 满员后开启候补名单 |
| pending | 活动待审核(主办方创建后未通过审核) |
- User 接口(基础类型:用户)
export interface User { name: string;// 用户名 avatar: string;// 用户头像 URL }- 极简设计:仅包含 UI 展示所需的核心字段(姓名 + 头像),适合「列表展示、参与者头像」等轻量场景;
- 复用性:可用于活动发起人(Activity.host 可兼容此类型)、参与者、当前登录用户等场景,避免重复定义相似类型;
- 扩展建议:若需完整用户信息,可继承此接口(如 interface FullUser extends User { id: string; email: string; })。
- Tab 类型(枚举类型:导航标签)
export type Tab ='home'|'saved'|'chat'|'profile';TypeScript 类型别名(Type Alias):用 type 定义「字符串字面量联合类型」,替代传统的 enum,更轻量、更符合 React 生态习惯;
用于约束底部导航栏(BottomNav)的标签类型,例如:
// 导航栏组件中使用 const [activeTab, setActiveTab]= useState<Tab>('home');// 仅允许传入指定的 4 个值,否则 TypeScript 报错 const switchTab =(tab: Tab)=> setActiveTab(tab);index.html
标准的 React + TypeScript + Vite(或类似构建工具) 项目的入口 HTML 文件
定义网页的基础元信息(字符编码、视口适配、标题);提供 React 应用的挂载节点(div#root);加载并执行 React 应用的入口脚本(main.tsx);适配移动端、现代浏览器的模块化规范。
vite.config.ts
Vite 配置,包括将 /api 请求转发到后端 http://127.0.0.1:4000 的开发服务器代理,以及指向项目根目录的 @ 路径别名。
在 vite.config.ts 中定义的 @ 别名允许你使用 @/pages/Home 从项目根目录导入文件,而不是使用像 …/…/pages/Home 这样的相对路径。这在深度嵌套的组件文件中特别有用。
后端目录
backend/ 目录遵循分层架构模式,其中 src/ 下的每个子文件夹代表不同的职责层。这种分离意味着只要知道某段逻辑属于哪一层,你就能找到它:
routes/
Express 路由定义,将 HTTP 动词和 URL 路径映射到控制器函数。五个路由文件对应五个功能域:activities、chat、favorites、friends 和 profiles。所有路由都挂载在 app.ts 中的 /api/v1/ 前缀下。
- 以 activityRoutes.ts 为例:
基于 Express.js 框架的后端路由配置代码,专门处理「活动(Activity)」相关的 API 请求,核心作用是定义接口路径、绑定处理函数,并通过中间件实现权限校验。
创建 Express 路由实例,集中管理所有「活动」相关的 API 接口;为所有活动接口统一添加「登录认证」中间件(未登录用户无法访问);映射 HTTP 请求方法 + 路径到对应的业务处理函数(Controller);遵循 RESTful API 设计规范,区分「查询、创建、详情、参与」等业务操作。
- 依赖导入(核心模块 / 工具)
import{ Router }from"express";import{ handleCreateActivity, handleGetActivities, handleGetActivityById, handleJoinActivity }from"../controllers/activityController.js";import{ requireAuth }from"../middleware/auth.js";
| 导入项 | 核心作用 | 工程化考量 |
|---|---|---|
| Router(express) | Express 提供的「路由实例」构造函数,用于创建模块化的路由(而非直接挂载到 app 实例) | 模块化拆分:将「活动路由」与「用户路由 / 聊天路由」分离,降低代码耦合 |
| activityController | 中的处理函数 业务逻辑层:封装「查询活动、创建活动、活动详情、参与活动」的核心逻辑 | 路由与业务解耦:路由只负责「路径匹配」,业务逻辑放在 Controller 中,符合 MVC 架构 |
| requireAuth(中间件) | 认证中间件:校验请求是否携带有效的登录凭证(如 JWT Token) | 权限控制:所有活动接口强制登录,避免未授权访问 |
- 创建路由实例
export const activityRoutes = Router();- Router():创建一个独立的路由实例(类似「迷你版 Express app」),可挂载路径、中间件、处理函数;
- export:将路由实例导出,供项目入口文件(如 app.ts/server.ts)挂载到 Express 主应用:
// 示例:主应用挂载活动路由 import express from"express";import{ activityRoutes }from"./routes/activityRoutes.js"; const app = express();// 所有以 /api/activities 开头的请求,都交给 activityRoutes 处理 app.use("/api/activities", activityRoutes);作用:实现路由的「模块化管理」,避免所有接口都写在主应用中,提升代码可维护性。
- 全局中间件:统一认证校验
// Access policy for this change: activity reads/writes are authenticated endpoints. activityRoutes.use(requireAuth);- router.use(middleware):为当前路由实例的所有请求挂载中间件(无论 GET/POST,无论路径层级);
requireAuth 中间件的核心逻辑(补充理解):
//../middleware/auth.js 示例实现 export const requireAuth =(req, res,next)=>{//1. 从请求头/Cookie 中获取 Token const token = req.headers.authorization?.split(" ")[1];//2. 校验 Token 有效性(如 JWT 解密) if(!token || !verifyToken(token)){//3. 未认证:返回 401 错误 return res.status(401).json({ error:"Unauthorized: Please login first"});}//4. 已认证:将用户信息挂载到 req 上,继续执行后续逻辑 req.user = decodeToken(token);next();// 调用 next() 放行请求 };所有通过 activityRoutes 处理的请求,都会先经过 requireAuth 校验,未登录用户直接返回 401,无需在每个接口单独写认证逻辑。
- 接口路径与处理函数映射(RESTful 设计)
//1. 查询活动列表(支持筛选、分页等) activityRoutes.get("/", handleGetActivities);//2. 创建新活动 activityRoutes.post("/", handleCreateActivity);//3. 获取单个活动详情(:id 是路径参数) activityRoutes.get("/:id", handleGetActivityById);//4. 参与指定活动 activityRoutes.post("/:id/join", handleJoinActivity);
| HTTP 方法 | 路径 | 处理函数 | 接口功能 | 前端请求示例 |
|---|---|---|---|---|
| GET | / | handleGetActivities | 查询活动列表(支持筛选 / 分页) | GET /api/activities?category=户外&page=1 |
| POST | / | handleCreateActivity | 创建新活动 | POST /api/activities + JSON 请求体 |
| GET | /:id | handleGetActivityById | 获取单个活动详情 | GET /api/activities/123(123 是活动 ID) |
| POST | /:id/join | handleJoinActivity | 参与指定活动 | POST /api/activities/123/join |
补充:
- /:id 是 Express 的「动态路径参数」,表示匹配「任意字符串」作为活动 ID;
- 处理函数中可通过 req.params.id 获取该值:
// handleGetActivityById 示例 export const handleGetActivityById =async(req, res)=>{ const activityId = req.params.id;// 获取路径中的 ID const activity =await ActivityModel.findById(activityId); res.json(activity);};路由层(Routes):仅负责「路径匹配、中间件挂载」,不写业务逻辑;控制层(Controllers):封装业务逻辑(如操作数据库、参数校验);中间件层(Middleware):封装通用逻辑(认证、日志、参数校验);
模块化与可扩展
每个业务域(活动、用户、聊天)单独创建路由文件,便于扩展;新增接口时,只需在 activityRoutes 中添加一行映射,无需修改主应用;
示例:新增「取消参与活动」接口:
import{ handleCancelJoinActivity }from"../controllers/activityController.js";// 新增取消参与接口 activityRoutes.post("/:id/cancel-join", handleCancelJoinActivity);controllers/
请求处理器,从 Express Request 对象中提取参数,使用 Zod schema 验证输入,调用相应的服务,并返回 HTTP 响应。控制器很轻量——它们将业务逻辑委托给服务。
下面以activityController.ts文件为例:
基于 Express + Zod + TypeScript 实现的后端「活动模块」控制器(Controller)代码,核心职责是处理活动相关 API 的参数校验、权限验证、业务逻辑调用、统一错误处理,是典型的「中间层」代码:
- 依赖导入与类型基础
importtype{ Request, Response }from"express";import{ z }from"zod";import{ AppError, toErrorResponse }from"../lib/errors.js";import{ createActivityForUser, getActivities, getActivityDetail, joinActivityForUser }from"../services/activityService.js";
| 导入项 | 核心作用 | 工程化意义 |
|---|---|---|
| Request/Response(express 类型) | TypeScript 类型约束,限定 req/res 的类型,避免类型错误 | 强类型保障,IDE 自动提示 req/res 的属性(如 req.query/res.status) |
| z(Zod) | 类型校验库,用于定义校验规则并验证请求参数 | 替代手动 if-else 校验,支持复杂规则(如范围、格式、联合校验),且与 TypeScript 无缝集成 |
| AppError/toErrorResponse | 自定义错误类 + 错误转换工具,实现统一错误处理 | 区分「业务错误」和「系统错误」,返回标准化错误响应 |
| activityService | 方法 业务服务层,封装与数据库 / 第三方交互的核心逻辑 | 控制器与业务解耦(控制器只做参数 / 响应处理,服务层做核心逻辑),符合「单一职责」 |
- Zod 校验规则定义(核心:参数合法性保障)
Zod 是这段代码的核心亮点,通过 Schema 定义参数规则,parse 方法验证参数,验证失败会抛出异常,被后续的 catch 捕获。
列表查询参数校验:ListQuerySchema
const ListQuerySchema = z.object({ q: z.string().optional(),// 搜索关键词(可选) category: z.string().optional(),// 分类(可选) // 纬度:强制转换为数字 + 范围限制(-90~90) + 可选 latitude: z.coerce.number().min(-90).max(90).optional(),// 经度:强制转换为数字 + 范围限制(-180~180) + 可选 longitude: z.coerce.number().min(-180).max(180).optional(),// 距离半径:强制转换为数字 + 正数 + 最大200km + 可选 radius_km: z.coerce.number().positive().max(200).optional()})// 自定义校验:纬度和经度必须同时提供/同时缺失(不能只传一个) .refine((value)=>(value.latitude === undefined)===(value.longitude === undefined),{ message:"latitude and longitude must be provided together"});路径参数校验:ParamsSchema
const ParamsSchema = z.object({id: z.string().uuid()// 活动ID必须是合法的UUID格式 });创建活动请求体校验:CreateActivitySchema
const CreateActivitySchema = z.object({ title: z.string().min(1).max(80),// 标题非空 + 长度限制(避免超长标题) image_url: z.string().url().optional(),// 图片URL可选,但传了必须是合法URL location: z.string().min(1).max(120),// 地点非空 + 长度限制 start_time: z.string().datetime(),// 开始时间必须是合法的ISO datetime格式(如 2026-03-20T14:00:00Z) category: z.string().min(1),// 分类非空 description: z.string().max(1000).optional(),// 描述可选,最长1000字 max_participants: z.number().int().min(2).max(100),// 最大参与人数:整数 + 最少2人 + 最多100人 latitude: z.number().min(-90).max(90),// 纬度:必传 + 范围限制 longitude: z.number().min(-180).max(180)// 经度:必传 + 范围限制 });- 控制器函数(核心业务流程)
所有控制器函数遵循统一流程:
校验认证 → 校验参数 → 调用服务层 → 返回成功响应 ↓(异常) 捕获错误 → 转换为标准化错误响应 → 返回 查询活动列表:handleGetActivities
export async function handleGetActivities(req: Request, res: Response){try{//1. 兜底校验认证信息(路由层中间件可能被绕过,二次校验更安全) if(!req.auth){ throw new AppError(401,"UNAUTHORIZED","Missing bearer token");}//2. 校验查询参数(Zod.parse 失败会抛出异常) const filters = ListQuerySchema.parse(req.query);//3. 调用服务层获取数据(传入筛选条件 + 用户信息) const data =await getActivities(filters, req.auth.userId, req.auth.email);//4. 返回标准化成功响应 res.status(200).json({ code:"OK", message:"Activities fetched", data });} catch (error){//5. 异常处理:转换为标准化错误响应 const payload = toErrorResponse(error); res.status(payload.status).json(payload.body);}}获取活动详情:handleGetActivityById
export async function handleGetActivityById(req: Request, res: Response){try{if(!req.auth){ throw new AppError(401,"UNAUTHORIZED","Missing bearer token");}// 校验路径参数(活动ID必须是UUID) const params = ParamsSchema.parse(req.params);// 调用服务层获取单个活动详情 const data =await getActivityDetail(params.id); res.status(200).json({ code:"OK", message:"Activity fetched", data });} catch (error){ const payload = toErrorResponse(error); res.status(payload.status).json(payload.body);}}创建活动:handleCreateActivity
export async function handleCreateActivity(req: Request, res: Response){try{if(!req.auth){ throw new AppError(401,"UNAUTHORIZED","Missing bearer token");}// 校验请求体(创建活动的所有参数) const payload = CreateActivitySchema.parse(req.body);// 调用服务层创建活动(传入用户ID + 活动参数 + 用户邮箱) const data =await createActivityForUser(req.auth.userId, payload, req.auth.email);//201 状态码:表示资源创建成功(RESTful 规范) res.status(201).json({ code:"CREATED", message:"Activity created", data });} catch (error){ const payload = toErrorResponse(error); res.status(payload.status).json(payload.body);}}参与活动:handleJoinActivity
export async function handleJoinActivity(req: Request, res: Response){try{if(!req.auth){ throw new AppError(401,"UNAUTHORIZED","Missing bearer token");}// 校验活动ID(路径参数) const params = ParamsSchema.parse(req.params);// 调用服务层参与活动(传入用户ID + 活动ID + 用户邮箱) const data =await joinActivityForUser(req.auth.userId, params.id, req.auth.email); res.status(200).json({ code:"OK", message:"Activity joined", data });} catch (error){ const payload = toErrorResponse(error); res.status(payload.status).json(payload.body);}}services/
业务逻辑层。服务负责编排诸如创建活动、管理好友请求或发送聊天消息等操作。一个服务可能会调用一个或多个仓库。
下面以activityService.ts文件为例:
基于 TypeScript + 仓储模式(Repository) 实现的后端「活动模块」服务层(Service)代码,是业务逻辑的核心层 —— 承接控制器层的参数,调用仓储层操作数据,同时整合其他服务(聊天、用户档案)完成跨模块业务逻辑。
- 业务逻辑封装:将「查询活动、创建活动、参与活动」等核心业务规则(如「活动满员不能参与」「重复参与报错」)集中实现;
- 跨模块整合:联动「用户档案服务」「聊天服务」完成业务闭环(如创建活动时自动生成聊天群、参与活动时加入群聊);
- 数据转换:将仓储层的原始数据(ActivityRecord)转换为前端 / 控制器层可用的 DTO(数据传输对象);
- 业务规则校验:在数据持久化前校验业务规则(如活动是否满员、是否已参与),抛出标准化业务错误;
- 异步并发优化:使用 Promise.all 并行获取数据,提升接口性能。
- 依赖导入与基础定义
import{ AppError }from"../lib/errors.js";import{ addActivityMember, createActivity, getActivityById, getProfileById, incrementActivityParticipantCount, isActivityMember, listActivities, listFavoriteActivityIds,type ActivityRecord }from"../repositories/activityRepository.js";import{ addProfileToActivityGroup, ensureActivityGroupConversation }from"./chatService.js";import{ ensureProfileForAuthUser }from"./profileService.js";
| 导入项 | 核心作用 | 架构意义 |
|---|---|---|
| AppError | 自定义业务错误类,抛出标准化业务异常 | 统一错误类型,控制器层可识别并转换为 HTTP 响应 |
| activityRepository 方法 / 类型 | 仓储层:封装与数据库的直接交互(如查询 / 创建活动、操作参与者),屏蔽数据库细节 | 服务层与数据库解耦(仓储模式),更换数据库(如 MySQL → MongoDB)只需改仓储层,无需改服务层 |
| chatService | 聊天服务层:处理活动群聊相关逻辑(创建群聊、加入群聊) | 跨模块业务整合(活动与聊天联动),避免服务层代码臃肿 |
| profileService | 档案服务层:确保用户档案存在(无则创建) | 统一用户档案管理,避免重复创建 / 查询逻辑 |
- 数据转换工具函数:toListDto
function toListDto(record: ActivityRecord, favoriteIds: Set<string>){return{id: record.id, title: record.title, image_url: record.image_url, location: record.location, start_time: record.start_time, category: record.category, description: record.description, participant_count: record.participant_count, max_participants: record.max_participants, is_favorite: favoriteIds.has(record.id),// 标记是否收藏 latitude: record.latitude, longitude: record.longitude, distance_km: record.distance_km };}DTO(Data Transfer Object)转换函数,将仓储层的原始数据(ActivityRecord)转换为「活动列表」场景的精简数据结构;
- 核心服务函数解析
所有服务函数遵循「参数校验 → 依赖数据获取 → 业务规则校验 → 数据操作 → 跨模块联动 → 返回结果」的流程。
查询活动列表:getActivities
export async function getActivities( filters:{ q?: string; category?: string; latitude?: number; longitude?: number; radius_km?: number }, authUserId: string, email?: string ){//1. 确保用户档案存在(无则自动创建) const profile =await ensureProfileForAuthUser(authUserId, email);//2. 并行获取活动列表 + 用户收藏的活动ID(提升性能) const [activities, favoriteIds]=await Promise.all([ listActivities(filters),// 仓储层:查询符合筛选条件的活动 listFavoriteActivityIds(profile.id)// 仓储层:查询用户收藏的活动ID ]);//3. 转换为列表DTO(添加是否收藏标记) const favoriteSet = new Set(favoriteIds);return activities.map((record)=> toListDto(record, favoriteSet));}获取活动详情:getActivityDetail
export async function getActivityDetail(id: string){//1. 仓储层:查询活动原始数据 const activity =await getActivityById(id);//2. 业务校验:活动不存在则抛出404错误 if(!activity){ throw new AppError(404,"NOT_FOUND","Activity not found");}//3. 关联查询:获取活动主办方的档案信息 const host =await getProfileById(activity.host_profile_id);//4. 整合数据:返回活动 + 主办方信息 return{...activity, host };}创建活动:createActivityForUser
export async function createActivityForUser( authUserId: string,input:{/* 活动创建参数 */}, email?: string ){//1. 确保用户档案存在 const profile =await ensureProfileForAuthUser(authUserId, email);//2. 仓储层:创建活动(主办方为当前用户,初始参与人数为1) const activity =await createActivity({ title:input.title, image_url:input.image_url, location:input.location, start_time:input.start_time, category:input.category, description:input.description, host_profile_id: profile.id,// 关联用户档案ID participant_count:1,// 创建者默认参与,初始人数为1 max_participants:input.max_participants, latitude:input.latitude, longitude:input.longitude });//3. 仓储层:添加创建者为活动参与者 await addActivityMember(activity.id, profile.id);//4. 跨模块:创建活动对应的聊天群(确保群聊存在) await ensureActivityGroupConversation(activity.id, profile.id);//5. 返回创建的活动数据 return activity;}参与活动:joinActivityForUser(最复杂的业务逻辑)
export async function joinActivityForUser(authUserId: string, activityId: string, email?: string){//1. 确保用户档案存在 const profile =await ensureProfileForAuthUser(authUserId, email);//2. 查询活动并校验是否存在 const activity =await getActivityById(activityId);if(!activity){ throw new AppError(404,"NOT_FOUND","Activity not found");}//3. 业务校验1:是否已参与该活动(避免重复参与) if(await isActivityMember(activityId, profile.id)){ throw new AppError(409,"CONFLICT","Already joined this activity");}//4. 业务校验2:活动是否已满员(参与人数 ≥ 最大人数) if(activity.participant_count >= activity.max_participants){ throw new AppError(409,"CONFLICT","Activity is full");}//5. 仓储层:添加用户为活动参与者 await addActivityMember(activityId, profile.id);//6. 仓储层:递增活动参与人数(原子操作,避免并发问题) const updatedActivity =await incrementActivityParticipantCount(activityId);//7. 跨模块:将用户加入活动对应的聊天群 await addProfileToActivityGroup(activityId, profile.id);//8. 返回更新后的活动数据 return updatedActivity;}补充:
仓储模式(Repository Pattern):服务层不直接操作数据库,而是调用仓储层方法(如 listActivities/createActivity),屏蔽数据库细节;
- 可测试性:仓储层可 Mock,服务层单元测试无需连接真实数据库;
Mock是软件开发中一种核心的测试 / 开发技巧:用「模拟对象」替代真实的依赖(如数据库、第三方接口、硬件设备),让代码在不依赖真实环境的情况下运行。
- 可扩展性:更换数据库(如 PostgreSQL → Redis)只需修改仓储层实现,服务层代码不变;
- 单一职责:仓储层负责「数据存取」,服务层负责「业务逻辑」。
repositories/
数据访问层。每个仓库文件包含 SQL 查询(通过 Supabase 客户端执行),用于读取和写入 PostgreSQL 数据库。仓库是唯一直接与数据库对话的层。
下面以activityRepository.ts文件为例:
基于 Supabase(PostgreSQL) + TypeScript 实现的后端「活动模块」仓储层(Repository)代码,是直接与数据库交互的底层层 —— 封装了所有活动相关的数据库操作(查询、创建、更新、关联),同时实现了地理距离计算等核心工具逻辑。
- 数据库操作封装:所有 Supabase/PostgreSQL 操作都集中在这一层,服务层无需关注 SQL / 数据库语法,只需调用方法;
- 类型约束:通过 ActivityRecord/CreateActivityInput 等接口约束数据库数据结构,与 TypeScript 无缝集成;
- 业务工具封装:实现地理距离计算(Haversine 公式),支持按距离筛选活动;
- 数据安全与规范:处理空值、默认值、冲突处理(如 upsert),保证数据一致性;
- 标准化错误处理:捕获 Supabase 错误并抛出标准化异常,服务层可统一处理。
- 基础依赖与类型定义
import{ getSupabaseAdmin }from"../config/supabase.js";// 活动表原始数据结构(与数据库表字段一一对应) export interface ActivityRecord {id: string;// 主键(UUID) title: string; image_url: string | null;// 允许为空 location: string; start_time: string;// ISO datetime 字符串 category: string; description: string | null;// 允许为空 host_profile_id: string;// 关联profiles表的外键 participant_count: number;// 当前参与人数 max_participants: number;// 最大参与人数 latitude: number | null;// 纬度(允许为空) longitude: number | null;// 经度(允许为空) distance_km?: number;// 非数据库字段,计算得出的距离(可选) }// 创建活动的输入参数结构(适配服务层传入的参数) export interface CreateActivityInput { title: string; image_url?: string;// 可选(服务层传入) location: string; start_time: string; category: string; description?: string;// 可选 host_profile_id: string; participant_count: number; max_participants: number; latitude: number;// 必传(服务层已校验) longitude: number;// 必传 }// 用户档案表原始数据结构 export interface ProfileRecord {id: string; name: string; avatar_url: string;}
| 类型 | 核心作用 | 设计考量 |
|---|---|---|
| ActivityRecord | 与数据库 activities 表字段完全对齐,包含所有字段(含空值类型) | 确保从数据库查询的数据类型准确,避免「类型不匹配」错误 |
| CreateActivityInput | 服务层创建活动的入参结构,与 ActivityRecord 兼容但更灵活(如 image_url 可选) | 适配服务层的参数格式,无需服务层手动转换空值(如 undefined → null) |
| ProfileRecord | 与 profiles 表字段对齐,仅包含档案核心字段 | 用于关联查询活动主办方信息 |
- 地理距离计算工具(核心业务工具)
// 地球半径(千米) const EARTH_RADIUS_KM =6371;// 角度转弧度(Haversine公式依赖) function toRadians(deg: number): number {return(deg * Math.PI)/180;}// Haversine公式:计算两点间的球面距离(千米) function haversineDistanceKm(fromLat: number, fromLng: number, toLat: number, toLng: number): number { const dLat = toRadians(toLat - fromLat);// 纬度差(弧度) const dLng = toRadians(toLng - fromLng);// 经度差(弧度) // Haversine公式核心计算 const a = Math.sin(dLat /2)**2+ Math.cos(toRadians(fromLat))* Math.cos(toRadians(toLat))* Math.sin(dLng /2)**2; const c =2* Math.atan2(Math.sqrt(a), Math.sqrt(1- a));return EARTH_RADIUS_KM * c;// 距离 = 地球半径 × 圆心角 }根据用户的地理坐标(纬度 / 经度)和活动的坐标,计算两者之间的直线距离,实现「按距离筛选活动」;
- 核心仓储方法解析
所有仓储方法遵循「获取 Supabase 实例 → 构建查询 → 执行查询 → 处理结果 / 错误 → 返回数据」的流程,且都为异步函数(async/await)。
列表查询:listActivities(最复杂的查询逻辑)
export async function listActivities(filters:{ q?: string; category?: string; latitude?: number; longitude?: number; radius_km?: number;}): Promise<ActivityRecord[]>{//1. 获取Supabase管理员实例(拥有全权限) const supabase = getSupabaseAdmin();//2. 基础查询:查询activities表所有字段,按开始时间升序排序 let query = supabase.from("activities").select("*").order("start_time",{ ascending: true });//3. 筛选条件1:按分类筛选(精确匹配) if(filters.category){ query = query.eq("category", filters.category);}//4. 筛选条件2:关键词模糊搜索(标题/地点/分类) if(filters.q){ const pattern = `%${filters.q}%`;// ilike:PostgreSQL不区分大小写的模糊匹配 query = query.or(`title.ilike.${pattern},location.ilike.${pattern},category.ilike.${pattern}`);}//5. 执行查询,处理Supabase响应 const { data, error }=await query;if(error){ throw new Error(error.message);// 抛出错误,服务层捕获 }//6. 类型转换:确保data是ActivityRecord数组(空值转为空数组) const rows =(data ?? [])as ActivityRecord[]; const { latitude, longitude }= filters;//7. 无地理坐标筛选:直接返回结果 if(latitude === undefined || longitude === undefined){return rows;}//8. 有地理坐标:按距离筛选 + 计算距离 + 排序 const radiusKm = filters.radius_km ?? 20;// 默认半径20km return rows .reduce<ActivityRecord[]>((acc, row)=>{// 过滤无坐标的活动 if(row.latitude == null || row.longitude == null){return acc;}// 计算当前活动与用户坐标的距离 const distanceKm = haversineDistanceKm(latitude, longitude, row.latitude, row.longitude);// 过滤超出半径的活动 if(distanceKm > radiusKm){return acc;}// 添加距离字段,保留两位小数 acc.push({...row, distance_km: Number(distanceKm.toFixed(2))});return acc;},[])// 按距离升序排序(近的在前) .sort((a, b)=>(a.distance_km ?? Number.MAX_SAFE_INTEGER)-(b.distance_km ?? Number.MAX_SAFE_INTEGER));}单条查询:getActivityById
export async function getActivityById(id: string): Promise<ActivityRecord | null>{ const supabase = getSupabaseAdmin();// 查询指定ID的活动,maybeSingle():无结果返回null(而非报错) const { data, error }=await supabase.from("activities").select("*").eq("id",id).maybeSingle();if(error){ throw new Error(error.message);}// 类型转换 + 空值兜底 return(data as ActivityRecord | null) ?? null;}收藏查询:listFavoriteActivityIds
export async function listFavoriteActivityIds(profileId: string): Promise<string[]>{ const supabase = getSupabaseAdmin();// 查询用户收藏的所有活动ID(仅查activity_id字段,减少数据传输) const { data, error }=await supabase.from("favorites").select("activity_id").eq("profile_id", profileId);if(error){ throw new Error(error.message);}// 提取activity_id字段,返回字符串数组 return(data ?? []).map((row)=> row.activity_id as string);}档案查询:getProfileById
export async function getProfileById(id: string): Promise<ProfileRecord | null>{ const supabase = getSupabaseAdmin();// 仅查询档案的核心字段(id/name/avatar_url),避免冗余 const { data, error }=await supabase.from("profiles").select("id,name,avatar_url").eq("id",id).maybeSingle();if(error){ throw new Error(error.message);}return(data as ProfileRecord | null) ?? null;}只返回活动详情所需的档案字段,符合「按需查询」的性能优化原则
创建活动:createActivity
export async function createActivity(input: CreateActivityInput): Promise<ActivityRecord>{ const supabase = getSupabaseAdmin();// 插入数据 + 立即返回插入的完整记录(select("*").single()) const { data, error }=await supabase .from("activities").insert({ title:input.title, image_url:input.image_url ?? null,// 入参undefined转为数据库null location:input.location, start_time:input.start_time, category:input.category, description:input.description ?? null, host_profile_id:input.host_profile_id, participant_count:input.participant_count, max_participants:input.max_participants, latitude:input.latitude, longitude:input.longitude }).select("*").single();if(error){ throw new Error(error.message);}return data as ActivityRecord;}批量查询:listActivitiesByIds
export async function listActivitiesByIds(ids: string[]): Promise<ActivityRecord[]>{// 空数组直接返回空,避免数据库无效查询 if(ids.length ===0){return[];} const supabase = getSupabaseAdmin();//in:查询ID在指定数组中的活动 const { data, error }=await supabase.from("activities").select("*").in("id", ids).order("start_time",{ ascending: true });if(error){ throw new Error(error.message);}return(data ?? [])as ActivityRecord[];}添加活动参与者:addActivityMember
export async function addActivityMember(activityId: string, profileId: string): Promise<void>{ const supabase = getSupabaseAdmin();// upsert:存在则更新(无操作),不存在则插入,避免重复添加 const { error }=await supabase.from("activity_members").upsert({ activity_id: activityId, profile_id: profileId },{ onConflict:"activity_id,profile_id"}// 唯一冲突键:活动ID+用户ID );if(error){ throw new Error(error.message);}}校验参与者:isActivityMember
export async function isActivityMember(activityId: string, profileId: string): Promise<boolean>{ const supabase = getSupabaseAdmin();// 仅查询activity_id字段(无需全字段),判断是否存在 const { data, error }=await supabase .from("activity_members").select("activity_id").eq("activity_id", activityId).eq("profile_id", profileId).maybeSingle();if(error){ throw new Error(error.message);}// 存在则返回true,否则false return Boolean(data);}递增参与人数:incrementActivityParticipantCount
export async function incrementActivityParticipantCount(activityId: string): Promise<ActivityRecord>{// 先查询当前活动(确保存在) const current =await getActivityById(activityId);if(!current){ throw new Error("Activity not found");} const supabase = getSupabaseAdmin();// 更新参与人数(原子递增) + 更新时间 const { data, error }=await supabase .from("activities").update({ participant_count: current.participant_count +1, updated_at: new Date().toISOString()}).eq("id", activityId).select("*").single();if(error){ throw new Error(error.message);}return data as ActivityRecord;}middleware/
目前包含 auth.ts,用于在受保护的路由上验证 Supabase JWT 令牌,并将用户身份附加到 req.auth。
基于 Express + Supabase Auth + TypeScript 实现的后端「认证中间件」代码,核心职责是校验前端请求的身份凭证(Bearer Token)、解析用户身份信息并挂载到请求对象上,同时兼容测试环境的模拟认证逻辑,是后端权限控制的核心屏障
- Token 提取与校验:从请求头中提取 Bearer Token,校验格式合法性;
- 用户身份解析:通过 Supabase Auth 验证 Token 有效性,解析出用户 ID / 邮箱;
- 测试环境兼容:为单元测试提供模拟认证逻辑,无需依赖真实 Supabase 服务;
- 身份挂载:将解析后的用户信息挂载到 req.auth 上,供后续控制器 / 服务层使用;
- 统一错误处理:认证失败时返回标准化的 401 错误响应,避免接口级重复处理。
- 基础类型与依赖导入
importtype{ NextFunction, Request, Response }from"express";import{ getSupabaseAdmin }from"../config/supabase.js";import{ AppError, toErrorResponse }from"../lib/errors.js";// 解析后的认证用户信息结构 interface ResolvedAuthUser {id: string;// 用户唯一ID(Supabase Auth的user.id) email?: string;// 用户邮箱(可选,可能未验证/未设置) }
| 导入 / 类型 | 核心作用 | 设计考量 |
|---|---|---|
| NextFunction/Request/Response | Express 中间件的核心类型,约束中间件函数的参数类型 | 强类型保障,避免参数类型错误(如 next 函数调用方式错误) |
| getSupabaseAdmin | 获取 Supabase 管理员客户端(拥有 Auth 鉴权权限) | 管理员客户端可跳过 Row Level Security (RLS),直接验证 Token |
| AppError/toErrorResponse | 自定义错误类 + 错误转换工具 | 统一认证错误的格式,与其他业务错误保持一致 |
| ResolvedAuthUser | 标准化解析后的用户信息结构 | 隔离 Supabase Auth 的原始用户对象,只保留业务所需字段(ID / 邮箱) |
- 核心工具函数解析
Token 提取:extractBearerToken
function extractBearerToken(req: Request): string {//1. 从请求头获取 Authorization 字段 const raw = req.header("authorization");if(!raw){ throw new AppError(401,"UNAUTHORIZED","Missing bearer token");}//2. 拆分 Bearer 方案与 Token(格式:Bearer <token>) const [scheme, token]= raw.split(" ");//3. 校验格式:必须包含 scheme + token,且 scheme 为 Bearer(不区分大小写) if(!scheme || !token || scheme.toLowerCase()!=="bearer"){ throw new AppError(401,"UNAUTHORIZED","Missing bearer token");}//4. 返回纯 Token 字符串 return token;}严格遵循 OAuth 2.0 的 Bearer Token 格式规范(Authorization: Bearer );
用户身份解析:resolveAuthUser
async function resolveAuthUser(token: string): Promise<ResolvedAuthUser>{//1. 测试环境兼容:模拟认证逻辑(无需真实 Supabase) if(process.env.NODE_ENV ==="test"){// 测试 Token 格式:test-user:<userId>(如 test-user:123456) if(token.startsWith("test-user:")){ const userId = token.slice("test-user:".length);if(userId){// 返回模拟用户信息,邮箱为固定格式 return{id: userId, email: `${userId}@example.test` };}}// 测试环境无效 Token → 抛出 401 throw new AppError(401,"UNAUTHORIZED","Invalid or expired token");}//2. 生产环境:通过 Supabase Auth 验证 Token const supabase = getSupabaseAdmin();// 调用 Supabase Auth API 验证 Token 并获取用户信息 const { data, error }=await supabase.auth.getUser(token);//3. Token 无效/无用户信息 → 抛出 401if(error || !data.user){ throw new AppError(401,"UNAUTHORIZED","Invalid or expired token");}//4. 返回标准化用户信息(屏蔽 Supabase 原始字段) return{id: data.user.id, email: data.user.email ?? undefined // 邮箱为空则转为 undefined };}生产环境依赖 Supabase Auth 验证 Token,测试环境无需启动 Supabase 服务,只需传入 test-user: 格式的 Token 即可模拟认证;让后端单元测试 / 集成测试「确定性执行」,不依赖外部服务,提升测试效率;
supabase.auth.getUser(token):Supabase 官方提供的 Token 验证方法,会校验 Token 的签名、有效期、是否被吊销;
- 核心中间件:requireAuth
export async function requireAuth(req: Request, res: Response,next: NextFunction){try{//1. 提取并校验 Bearer Token const token = extractBearerToken(req);//2. 解析 Token 得到用户身份 const authUser =await resolveAuthUser(token);//3. 挂载用户信息到 req 对象(扩展 Request 类型) req.auth ={ userId: authUser.id, email: authUser.email };//4. 认证通过:调用 next() 放行请求到下一个中间件/控制器 next();} catch (error){//5. 认证失败:转换为标准化错误响应并返回 const payload = toErrorResponse(error); res.status(payload.status).json(payload.body);}}补充:
中间件使用方式;这个中间件需挂载到 Express 应用或路由上,示例:
import express from"express";import{ requireAuth }from"./middleware/auth.js";import{ activityRoutes }from"./routes/activityRoutes.js"; const app = express();// 全局挂载(所有接口都需要认证) // app.use(requireAuth);// 路由级挂载(仅活动接口需要认证) app.use("/api/activities", requireAuth, activityRoutes); app.listen(3000,()=> console.log("Server running on port 3000"));config/
env.ts 定义了环境变量的 Zod 验证 schema(确保 SUPABASE_URL 和 SUPABASE_SERVICE_ROLE_KEY 存在且类型正确),supabase.ts 初始化 Supabase 客户端单例。
下面以env.ts文件为例:
基于 Zod + Node.js 文件系统 实现的后端「环境变量解析与校验」代码,核心职责是标准化项目环境变量的加载、校验和类型推导,确保应用启动时所有必需的环境变量都符合格式要求,同时兼容本地开发的 .env 文件加载逻辑。
- 环境变量校验:通过 Zod 定义环境变量的规则(如 SUPABASE_URL 必须是合法 URL),启动时校验,避免因环境变量错误导致运行时异常;
- 本地 .env 文件加载:自动检测并加载本地 .env 文件(兼容项目根目录 /backend 目录),适配本地开发流程;
- 类型推导:通过 Zod 的 infer 自动生成环境变量的 TypeScript 类型,无需手动维护类型定义;
- 幂等加载:确保 .env 文件只加载一次,避免重复加载导致环境变量覆盖;
- 默认值处理:为非必需变量(如 PORT)设置默认值,提升开发体验。
- 基础依赖与类型定义
import{ existsSync }from"node:fs";// Node.js 文件系统:检查文件是否存在 import{ resolve }from"node:path";// Node.js 路径处理:解析绝对路径 import{ z }from"zod";// 类型校验库:定义环境变量规则 //1. Zod 环境变量校验规则 const EnvSchema = z.object({// 端口:字符串类型,默认值 4000(未配置时使用) PORT: z.string().default("4000"),// Supabase 地址:必须是合法的 URL 格式(如 https://xxx.supabase.co) SUPABASE_URL: z.string().url(),// Supabase 服务角色密钥:字符串类型,长度至少 20 位(避免无效密钥) SUPABASE_SERVICE_ROLE_KEY: z.string().min(20)});//2. 从 Zod Schema 自动推导 TypeScript 类型 export type AppEnv = z.infer<typeof EnvSchema>;
| 核心元素 | 作用 | 设计考量 |
|---|---|---|
| EnvSchema | 定义环境变量的校验规则 | 强制约束:- SUPABASE_URL 必须是 URL(避免配置成 IP / 错误域名);- SUPABASE_SERVICE_ROLE_KEY 长度≥20(Supabase 服务密钥固定格式,短于 20 位必为无效);- PORT 设默认值,无需手动配置即可启动。 |
| z.infer | 自动推导类型 | 无需手动写 interface AppEnv { PORT: string; … },Schema 变更时类型自动同步,避免「类型与规则不一致」。 |
| node:fs/node:path | 文件 / 路径处理 | 原生 Node.js 模块,无第三方依赖,适配所有 Node.js 环境。 |
2, .env 文件加载逻辑:loadLocalEnvFile
// 标记 .env 文件是否已加载(避免重复加载) let envFileLoaded = false; function loadLocalEnvFile(){//1. 幂等性检查:已加载则直接返回,避免重复加载 if(envFileLoaded){return;} envFileLoaded = true;// 标记为已加载 //2. 兼容 Vite 等工具的 loadEnvFile 方法(非 Node.js 原生) // 类型扩展:Process 增加 loadEnvFile 方法(避免 TypeScript 报错) const loadEnvFile =(process as NodeJS.Process &{ loadEnvFile?:(path?: string)=> void }).loadEnvFile;// 无 loadEnvFile 方法(如非 Vite 环境)则返回 if(typeof loadEnvFile !=="function"){return;}//3. 定义 .env 文件的候选路径(适配不同项目目录结构) const candidates =[ resolve(process.cwd(),".env"),// 项目根目录的 .env resolve(process.cwd(),"backend/.env")// backend 子目录的 .env ];// 找到第一个存在的 .env 文件路径 const envPath = candidates.find((path)=> existsSync(path));if(!envPath){return;// 无 .env 文件则返回 }//4. 加载指定路径的 .env 文件(将变量注入 process.env) loadEnvFile(envPath);}- 环境变量解析主函数:parseEnv
export function parseEnv(raw: NodeJS.ProcessEnv): AppEnv {//1. 加载本地 .env 文件(首次调用时执行) loadLocalEnvFile();//2. 校验并解析环境变量:不符合规则则抛出 ZodError return EnvSchema.parse(raw);}工程化使用示例
// backend/src/config/env.ts import{ parseEnv }from"./env-parser.js";// 解析并校验环境变量(全局唯一调用) export const env = parseEnv(process.env);// 后续业务代码中使用(带类型提示) console.log(env.PORT);//"4000"(默认值) console.log(env.SUPABASE_URL);// 如 "https://abc.supabase.co" console.log(env.SUPABASE_SERVICE_ROLE_KEY);// 长度≥20的字符串
| 场景 | 代码行为 | 结果 |
|---|---|---|
| 未配置 PORT | 使用 Zod 默认值 “4000” | 应用默认启动在 4000 端口 |
| SUPABASE_URL 是 127.0.0.1(非 URL) | Zod 抛出 invalid_url 错误 | 应用启动失败,提示 URL 格式错误 |
| SUPABASE_SERVICE_ROLE_KEY 长度 10 | Zod 抛出 too_small 错误 | 应用启动失败,提示密钥长度不足 |
| 项目根目录和 backend 目录都有 .env | 加载第一个存在的(根目录) | 优先使用根目录的配置 |
| 非 Vite 环境(无 loadEnvFile) | 跳过 .env 加载 | 直接使用系统环境变量 |
types/
TypeScript 声明文件。express.d.ts 文件扩展了全局 Express Request 接口,使其包含一个带有 userId 和可选 email 的 auth 属性。
export {}; declare global{ namespace Express { interface Request { auth?:{ userId: string; email?: string;};}}}db/migrations/
五个按顺序编号的 SQL 迁移文件,按顺序构建数据库架构:MVP 表 → 种子数据 → 认证配置文件 → 社交聊天 → 地理位置定位。
tests/
组织结构映像源代码结构:api/ 用于端点测试,services/ 用于业务逻辑测试,db/ 用于架构测试,config/ 用于环境验证测试。