文章目录
-
- 概要
- Vue还是React?
- 基础概念
-
- 组件
- JSX
- Props(组件间传数据)
- Key
- [渲染与虚拟 DOM](#渲染与虚拟 DOM)
- 事件处理
- State
- 受控组件
- Hooks (钩子)
- [纯组件 & 严格模式](#纯组件 & 严格模式)
- 副作用 (Side Effects)
- Portal
- Suspense
- 项目实操
概要
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 函数。
python
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 | 事件属性统一驼峰 |
python
// ❌ 错误写法: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>)。
python
// 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 节点来渲染页面、响应用户操作的(比如点击、修改内容)。
python
// ❌ 错误写法:返回多个根元素,会报错
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(空标签 <></> 不支持加属性):
python
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 属性完全一致,分两种场景:
- 静态值(字符串):直接写,不用加花括号;
- 动态值(非字符串 / 变量 / 表达式):必须用 {} 包裹。
python
// 父组件 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:解构赋值(更推荐,代码更简洁)。
python
// 子组件 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,子组件可以设置默认值,避免报错:
python
// 方式 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 属性,传递给子组件。
python
// 父组件
function App() {
return (
{/* 标签中间的 "确定" 就是 children */}
<Button>确定</Button>
);
}
// 子组件 Button.js
function Button({ children }) {
// 直接渲染 children
return <button>{children}</button>;
}
复杂场景:传递多个元素 / 其他组件 ------ children 可以是任意 JSX 内容,包括多个元素、其他组件,这也是 React "组合模式" 的核心:
python
// 父组件
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 会发生什么?
python
// ❌ 无 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 的最佳选择。
python
// ✅ 正确:用数据的唯一 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 更新列表如下:
python
// 原生 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'] }),操作速度极快。
python
// 你写的 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 | 键盘按下 |
python
// ❌ 错误:用原生 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 攻击)和性能问题(每次渲染都解析字符串)。
内联箭头函数(简单场景)----- 直接在事件属性中写箭头函数,适合逻辑简单的场景(比如弹窗、简单提示)。
python
function Button() {
return (
// ✅ 箭头函数是"函数表达式",传递的是函数本身
<button onClick={() => alert('点击了按钮')}>
点击弹窗
</button>
);
}
绑定预定义函数(推荐,逻辑复用)----- 先定义函数,再把函数名(不加括号)传给事件属性 ------ 这是 "传递函数本身" 的核心体现。
python
function Counter() {
// 1. 预定义处理函数
const handleClick = () => {
alert('点击了计数按钮');
};
return (
// 2. 传递函数本身(不加括号!加括号会立即执行)
<button onClick={handleClick}>
计数按钮
</button>
);
}
⚠️ 关键坑点:不要加括号!
带参数的处理函数(高频场景)---- 如果需要给处理函数传参数(比如列表项的 ID、输入值),需要用 "箭头函数包裹" 的方式。
python
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() 阻止默认行为):
python
// 阻止表单默认提交行为
function handleSubmit(e) {
e.preventDefault(); // 合成事件的 preventDefault 方法
console.log('表单提交了');
}
return (
<form onSubmit={handleSubmit}>
<button type="submit">提交</button>
</form>
);
如果组件频繁重渲染 (比如父组件状态变化),内联箭头函数会每次创建新函数,可能导致不必要的子组件重渲染,此时可以用 useCallback 缓存函数:
python
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 没有任何机制能 "监听" 到这个变化,自然不会触发页面更新;
python
// ❌ 错误:用普通变量,修改后页面不更新
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。
python
// 语法:const [状态变量, 更新状态的函数] = useState(初始值);
const [count, setCount] = useState(0);
const [inputValue, setInputValue] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
- 状态变量(比如 count):读取当前状态的值;
- 更新状态的函数(比如 setCount):修改状态的唯一方式(不能直接写 count = 1);
- 初始值:状态的默认值(只在组件首次渲染时生效)。
- 直接传新值(适用于状态不依赖旧值)
python
// 初始值:const [count, setCount] = useState(0);
setCount(1); // 直接把 count 改成 1
setInputValue("React 学习"); // 直接修改输入框值
- 传回调函数(适用于状态依赖旧值)
如果新状态需要基于旧状态计算(比如计数、累加),必须用回调函数 ------ 避免 "状态更新异步导致的取值错误"。
python
// 正确:用回调函数获取最新的旧值
setCount(prevCount => prevCount + 1);
// 反例:异步场景下可能出错
// handleClick 执行时,count 可能还是旧值,导致更新不准确
setCount(count + 1);
- 状态的 "不可变更新"(核心规则)
React 要求状态是不可变的------ 不能直接修改状态本身,必须通过更新函数创建 "新值" 替换 "旧值":
python
// ❌ 错误:直接修改状态(对象/数组),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 → 同步表单
以最常见的 "文本输入框" 为例,实现一个基础的受控组件:
python
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 绑定处理函数 */}
<input
type="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>;
python
// 示例 1:受控复选框
function ControlledCheckbox() {
const [isChecked, setIsChecked] = useState(false);
return (
<input
type="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,能轻松实现 "输入时实时校验""自动格式化内容":
python
// 示例:实时校验手机号(只能输入数字,且长度不超过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>
<input
type="text"
value={phone}
onChange={handleChange}
placeholder="请输入手机号"
/>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
- 方便实现复杂交互:比如 "一键清空输入框""表单重置""多输入框联动",只需修改 State 即可,无需操作 DOM:
python
// 示例:一键清空输入框
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 即可:
python
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}>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
/>
<input
type="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 | 全局数据(主题、用户信息、多语言)、深层组件传值 | 暗黑模式切换、登录用户信息共享 |
python
// 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元素:
python
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>
);
}
存储持久化变量:
python
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 | 数据请求、定时器 / 防抖节流、监听窗口大小、订阅 / 取消订阅 | 组件挂载时请求数据、监听滚动、自动取消定时器 |
核心语法:
python
// 语法:useEffect(副作用函数, 依赖数组);
useEffect(() => {
// 执行副作用(请求、定时器等)
const timer = setInterval(() => console.log('计时'), 1000);
// 清理函数(组件卸载/依赖变化时执行)
return () => {
clearInterval(timer); // 取消定时器,避免内存泄漏
};
}, []); // 依赖数组:空数组=只在挂载/卸载执行;传变量=变量变化时执行
组件挂载时请求数据:
python
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:缓存计算结果
python
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:缓存函数引用
python
// 父组件
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>,且渲染时不会偷偷改其他数据。
python
// 纯函数组件:仅接收 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 浅对比变化时,才重新渲染;
python
// 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 只在 "多次渲染 / 组件卸载" 时出现(比如内存泄漏、非纯渲染),严格模式通过 "刻意制造重复渲染",让这些问题在开发阶段就暴露,而非上线后才发现。
基本用法(只需包裹组件树)
python
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 |
为什么不能把副作用放在 "渲染逻辑中"?
如果把副作用写在组件的 "顶层渲染逻辑" 中,会导致严重问题,先看反例:
python
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,写入副作用会让组件失去 "相同输入→相同输出" 的纯特性,行为不可预测。
- 由用户操作触发的副作用 → 放在「事件处理函数」中:
副作用只在用户主动操作(点击、输入、提交)时执行,而非组件渲染时自动执行。
- 点击按钮提交表单、触发查询;
- 输入框回车触发搜索;
- 点击按钮修改浏览器标题。
python
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 变化后同步更新浏览器标题;
- 组件挂载时创建定时器,卸载时清理;
- 监听窗口大小变化(组件挂载时监听,卸载时取消)。
python
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的弹窗问题:
python
// 父组件:有 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 内容):
python
<!DOCTYPE html>
<html lang="en">
<body>
<!-- React 根节点 -->
<div id="root"></div>
<!-- Portal 专用节点(渲染弹窗/模态框) -->
<div id="portal-root"></div>
</body>
</html>
步骤 2:封装 Portal 组件
python
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 状态,代码繁琐且易出错:
python
// 懒加载组件(传统方式)
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 显示" 封装成组件,代码简洁且统一:
python
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。
python
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 |
第一步:克隆代码仓库
python
git clone https://github.com/liuhaha1111/socialvibe.git
cd socialvibe
第二步:安装依赖
从代码仓库根目录运行以下两条命令。它们会分别安装到 frontend/node_modules 和 backend/node_modules 中:
python
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, typescript@5.8, vitest, supertest |
两个子项目都在其 package.json 中使用了 "type": "module",因此所有导入都遵循 ESM 语法。
第三步:启动本地Supabase栈
SocialVibe 使用自托管 Supabase 配置,而非托管的云服务。整个栈位于 supabase-project/ 目录中,并由 Docker Compose 编排。
python
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)
python
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)
python
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 值------用于身份验证和实时功能 |
第五步:启动后端和前端
启动后端和前端
从代码仓库根目录打开两个终端窗口(或使用进程管理器):
python
npm --prefix backend run dev
# 输出:API listening on http://localhost:4000
这将运行 tsx watch src/server.ts,它会在每次文件更改时编译并重启。Express 应用在 /api/v1/ 下注册了活动、个人资料、收藏、好友和聊天的路由,以及一个健康检查端点。
python
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 原生验证(邮箱格式、必填、密码长度)+ 异步错误处理,兼顾简单性和健壮性。
- 依赖导入与类型定义
python
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 用法,用于跨组件共享认证状态)
- 组件初始化与状态管理
python
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:控制提交按钮的禁用状态和加载文案,避免用户重复点击提交
- 动态文案定义
python
// 提交按钮文案(根据模式切换)
const submitLabel = mode === "signin" ? "Sign In" : "Sign Up";
// 切换模式按钮文案(根据模式切换)
const switchLabel = mode === "signin" ? "Create account" : "Have an account? Sign in";
这两个变量根据当前 mode 动态生成按钮文案,避免重复写条件判断,让代码更简洁。
- 核心:表单提交处理函数
python
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);
}
};
- 组件渲染部分
python
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>
<input
id="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>
<input
id="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 订阅,防止内存泄漏。
- 依赖导入与类型定义
python
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 获取当前登录用户信息
python
// 路由状态类型:定义从消息列表页跳转过来时携带的会话信息
interface ChatLocationState {
conversation?: ConversationSummary;
}
类型约束:确保路由状态的类型安全,避免使用时出现类型错误。
- 工具函数:时间格式化
python
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)格式化为用户友好的本地时间。处理无效时间字符串,避免页面报错。
- 组件初始化与状态管理
python
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。
- 核心函数:刷新消息列表
python
// 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 执行。
- 副作用:初始化加载消息
python
useEffect(() => {
// 组件挂载/refreshMessages 变化时,加载消息列表
refreshMessages().catch(() => undefined);
}, [refreshMessages]);
组件首次渲染时,自动调用 refreshMessages 加载历史消息;refreshMessages 函数变化时(即 conversationId 变化)也会重新加载。
- 核心:Supabase 实时消息监听
python
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 匹配)的消息,避免接收其他会话的消息。
回调函数:收到新消息后,更新本地消息列表(先判断是否已存在,避免重复)。
清理函数:组件卸载时取消订阅,防止内存泄漏和无效监听。
- 缓存计算:判断是否可发送消息
python
// useMemo 缓存计算结果:避免每次渲染都重新计算
const canSend = useMemo(() => content.trim().length > 0 && Boolean(conversationId), [content, conversationId]);
计算逻辑:输入框内容非空 且 有会话 ID 时,才允许发送。
useMemo 作用:缓存布尔值,仅当 content 或 conversationId 变化时重新计算。
- 核心函数:发送消息
python
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 时的提示
python
// 无会话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
python
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 核心及钩子导入
python
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)
这部分是代码的「骨架」,确保类型安全:
python
// 前端展示用的活动类型(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 组件。
拆分不同场景的类型(创建输入、筛选条件、上下文接口):每个类型只服务于特定场景,职责单一,易于维护。
- 工具函数(纯函数)
这部分是「数据转换器」和「辅助工具」,无副作用,只做数据处理。
默认常量(兜底值)
python
const DEFAULT_IMAGE = "https://xxx"; // 活动默认封面
const DEFAULT_AVATAR = "https://xxx"; // 默认头像
作用:后端返回 image_url 为 null 时,使用默认图片,避免 UI 出现空白。
时间格式化函数
python
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)转换为用户友好的中文格式。
后端数据 → 前端数据映射函数
python
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 路径
python
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
python
const ActivityContext = createContext<ActivityContextType | undefined>(undefined);
初始值设为 undefined:强制要求组件必须在 Provider 内部使用,否则抛出错误(见 useActivity 钩子)。
Provider 组件(核心)
python
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(便捷访问上下文)
python
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 设计规范
- 依赖导入(基础支撑)
python
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 是当前页面路径) | 用于判断当前页面是否匹配导航项,实现激活态高亮 |
- 组件初始化与核心工具函数
python
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:极简工具函数,返回布尔值,是实现导航项「激活态高亮」的核心逻辑。
- 导航栏隐藏逻辑(边界处理)
python
// 定义需要隐藏导航栏的路径列表(全屏子页面)
const hideNavPaths = ["/create", "/chat", "/detail", "/review", "/checkin", "/settings"];
// 检查当前路径是否以列表中任意路径开头(支持子路径,如 /chat/123 也会匹配 /chat)
if (hideNavPaths.some((path) => location.pathname.startsWith(path))) {
return null; // 返回 null 表示组件不渲染,实现导航栏隐藏
}
在「发布页、聊天页、详情页」等全屏子页面,不需要显示底部导航,因此直接返回 null 隐藏组件。
- 核心渲染:导航栏 UI 结构
python
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 接口(核心业务类型:活动)
python
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 接口(基础类型:用户)
python
export interface User {
name: string; // 用户名
avatar: string; // 用户头像 URL
}
- 极简设计:仅包含 UI 展示所需的核心字段(姓名 + 头像),适合「列表展示、参与者头像」等轻量场景;
- 复用性:可用于活动发起人(Activity.host 可兼容此类型)、参与者、当前登录用户等场景,避免重复定义相似类型;
- 扩展建议:若需完整用户信息,可继承此接口(如 interface FullUser extends User { id: string; email: string; })。
- Tab 类型(枚举类型:导航标签)
python
export type Tab = 'home' | 'saved' | 'chat' | 'profile';
TypeScript 类型别名(Type Alias):用 type 定义「字符串字面量联合类型」,替代传统的 enum,更轻量、更符合 React 生态习惯;
用于约束底部导航栏(BottomNav)的标签类型,例如:
python
// 导航栏组件中使用
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 设计规范,区分「查询、创建、详情、参与」等业务操作。
- 依赖导入(核心模块 / 工具)
python
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) | 权限控制:所有活动接口强制登录,避免未授权访问 |
- 创建路由实例
python
export const activityRoutes = Router();
- Router():创建一个独立的路由实例(类似「迷你版 Express app」),可挂载路径、中间件、处理函数;
- export:将路由实例导出,供项目入口文件(如 app.ts/server.ts)挂载到 Express 主应用:
python
// 示例:主应用挂载活动路由
import express from "express";
import { activityRoutes } from "./routes/activityRoutes.js";
const app = express();
// 所有以 /api/activities 开头的请求,都交给 activityRoutes 处理
app.use("/api/activities", activityRoutes);
作用:实现路由的「模块化管理」,避免所有接口都写在主应用中,提升代码可维护性。
- 全局中间件:统一认证校验
python
// Access policy for this change: activity reads/writes are authenticated endpoints.
activityRoutes.use(requireAuth);
- router.use(middleware):为当前路由实例的所有请求挂载中间件(无论 GET/POST,无论路径层级);
requireAuth 中间件的核心逻辑(补充理解):
python
// ../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 设计)
python
// 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 获取该值:
python
// 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 中添加一行映射,无需修改主应用;
示例:新增「取消参与活动」接口:
python
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 的参数校验、权限验证、业务逻辑调用、统一错误处理,是典型的「中间层」代码:
- 依赖导入与类型基础
python
import type { 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
python
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
python
const ParamsSchema = z.object({
id: z.string().uuid() // 活动ID必须是合法的UUID格式
});
创建活动请求体校验:CreateActivitySchema
python
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) // 经度:必传 + 范围限制
});
- 控制器函数(核心业务流程)
所有控制器函数遵循统一流程:
python
校验认证 → 校验参数 → 调用服务层 → 返回成功响应
↓(异常)
捕获错误 → 转换为标准化错误响应 → 返回
查询活动列表:handleGetActivities
python
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
python
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
python
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
python
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 并行获取数据,提升接口性能。
- 依赖导入与基础定义
python
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
python
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
python
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
python
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
python
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(最复杂的业务逻辑)
python
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 错误并抛出标准化异常,服务层可统一处理。
- 基础依赖与类型定义
python
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 表字段对齐,仅包含档案核心字段 | 用于关联查询活动主办方信息 |
- 地理距离计算工具(核心业务工具)
python
// 地球半径(千米)
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(最复杂的查询逻辑)
python
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
python
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
python
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
python
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
python
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
python
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
python
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
python
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
python
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 错误响应,避免接口级重复处理。
- 基础类型与依赖导入
python
import type { 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
python
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
python
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 无效/无用户信息 → 抛出 401
if (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
python
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 应用或路由上,示例:
python
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)设置默认值,提升开发体验。
- 基础依赖与类型定义
python
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
python
// 标记 .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
python
export function parseEnv(raw: NodeJS.ProcessEnv): AppEnv {
// 1. 加载本地 .env 文件(首次调用时执行)
loadLocalEnvFile();
// 2. 校验并解析环境变量:不符合规则则抛出 ZodError
return EnvSchema.parse(raw);
}
工程化使用示例
python
// 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 属性。
python
export {};
declare global {
namespace Express {
interface Request {
auth?: {
userId: string;
email?: string;
};
}
}
}
db/migrations/
五个按顺序编号的 SQL 迁移文件,按顺序构建数据库架构:MVP 表 → 种子数据 → 认证配置文件 → 社交聊天 → 地理位置定位。
tests/
组织结构映像源代码结构:api/ 用于端点测试,services/ 用于业务逻辑测试,db/ 用于架构测试,config/ 用于环境验证测试。