AI 时代前端框架选型:React 核心原理与 SocialVibe 项目实战解析

文章目录

概要

AI出现之前,技术选型(如Vue、React、Svelte之间的选择)是极为严谨的事情,核心考量围绕开发体验以及团队招聘难度------因为当时代码几乎全由人工编写,人工开发的舒适度和效率是关键,这也是Vue(尤其Vue2)、Svelte曾经的核心优势。

但进入AI时代后,代码编写的主体发生了本质变化,越来越多的代码由AI生成,此前的选型逻辑被颠覆:代码本身的复杂程度不再重要,因为无需人工逐字敲击,Vue、Svelte的"开发舒适"优势被大幅削弱。

AI编写代码的核心 诉求并非"开发体验",而是"能否理解该技术 "。实际体验中,AI编写Vue(尤其Vue2与Vue3混用)时易卡壳,编写Svelte时因生态过小、需大量手动补充内容而容易胡写,反观React,因使用人数多、项目量大、写法稳定且无破坏性大升级,AI对其理解度最高,生成代码的成功率也远高于前两者

如今多数AI平台默认生成React代码 ,本质是统计意义上最安全的选择。AI时代的技术选型核心逻辑已转变:不再选择开发者最喜欢的,而是选择AI最懂的;优先选用最流行、生态最庞大、范式最稳定的技术,并非因为这类技术最优秀,而是因为AI能精准理解、稳定生成,且不易出错------技术本身未变,变的是写代码的主体,从人变成了AI。

Vue还是React?

现在已经是AI时代了,选择框架还有必要吗?答案是肯定的。

举个通俗的例子:如果你不会技术开发,只依赖AI写代码,就像走钢丝没有安全绳,一旦AI出问题,项目就会一落千丈、无法挽回;但如果你掌握了技术,就相当于有了安全绳,即便AI出错,你也能及时挽救、重新调整。

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

为什么Vue在国内这么受欢迎?这和它的出身有关。Vue早期是站在AngularJS的肩膀上发展起来的,它凭借"渐进式"的特点,收割了大量用户。渐进式的核心优势就是上手简单------哪怕你不懂JS,只要照着Vue的文档、按指令填写,就能使用这个渲染框架。

但优点同时也是缺点:Vue提供了大量内置指令,比如v-for、v-show,用起来很方便,但这些指令也像一堵墙,把开发者圈在固定的模式里,不够灵活。而React恰恰相反,它采用函数式编程,完全基于JS,不用记额外的指令,灵活性极高,但也因此失去了"渐进式"的特点,对JS基础有一定要求。


生态方面,两者的差异也很明显:

  • React的生态完全是社区驱动,不管是状态管理库(比如Redux、JUSTYLE),还是SSR渲染、表单处理等需求,都有大量社区贡献的工具,大家相互竞争、彼此优化,形成了良性循环,最终优秀的工具会脱颖而出------这就是开源生态的优势,评判标准很公平,全看用户的认可和GitHub的star数量。

  • 而Vue的生态虽然也有社区参与,但主要依赖官方库。好处是,打开Vue文档,官方推荐的工具基本都很好用,不用纠结选择;但缺点也很明显,缺少了社区竞争的活力,就像上学时的铅笔盒,Vue里只有一根铅笔,你只能老老实实使用;而React的生态里有钢笔、铅笔、圆珠笔等多种选择,反而容易让人出现选择困难,但也正因如此,它的生态才更加繁荣。

这一点在AI时代尤为突出。很多优秀的AI产品,比如画图、画产品原型的Pixel、Figma,都只支持React,能根据UI库直接生成可复制使用的组件,非常便捷,但遗憾的是,它们都不支持Vue。这也是AI时代React的一大优势------AI对React的理解度更高,生成的代码更准确

还有一点,Vue能在国内占据大量市场份额,离不开小程序生态的推动,尤其是uni-app。uni-app之所以选择Vue作为渲染框架,大概率是因为当时国内Vue的用户更多,或者开发团队更熟悉Vue,两者相辅相成。现在很多中小型公司、外包公司,都会用uni-app加快多端开发速度,这也间接带动了Vue的使用。

如果你JS基础还可以,在AI时代我推荐使用React------它生态完善、工具优秀,灵活性高,适配多端(H5、桌面端、手机APP),而且AI生成React代码的准确率更高;但如果你是新手,或者主要做小程序开发(尤其是小公司、外包项目),可以先学Vue,它上手简单、指令清晰,能快速完成开发需求。

基础概念

组件

组件是 React 应用的最小构建单元 ,用来渲染页面上所有可见内容(按钮、输入框、页面等)。像乐高积木一样,可复用、可组合。本质就是一个返回 UI 标记的 JavaScript 函数

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 规则:

  1. 属性用驼峰命名: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('点击了')} />
  1. 用 {} 插入动态 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>
);
  1. 组件只能返回一个根元素,多元素可以用 < 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

  1. 传递 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>
  );
}
  1. 接收 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
};
  1. 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 算法" 找草稿的变化,再通过 "协调机制" 只修改真实页面中变化的部分,最终实现 "把代码变成浏览器能显示的页面" 这个目标(渲染)。

  1. 渲染(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」。

  1. 虚拟 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 倍以上。

  1. Diff 算法:找 "草稿" 的变化

Diff 算法是 React 内置的 "对比算法",核心作用是:对比 "旧虚拟 DOM" 和 "新虚拟 DOM",找出两者的差异(哪些节点新增 / 删除 / 修改)

Diff 算法的核心规则

  • 同层对比:只对比虚拟 DOM 树中 "同一层级" 的节点,不会跨层级对比(比如只对比根节点的子节点,不对比根节点和孙子节点);
  • 同 key 对比:列表节点通过 key 识别身份,只有 key 相同的节点才会对比内容,key 不同直接判定为 "新增 / 删除";
  • 同类型对比:如果两个节点的类型相同(比如都是 div),则对比它们的属性(比如 className/style);如果类型不同(比如 div 变成 p),则直接销毁旧节点,创建新节点。
  1. 协调 (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);
  • 初始值:状态的默认值(只在组件首次渲染时生效)。
  1. 直接传新值(适用于状态不依赖旧值)
python 复制代码
// 初始值:const [count, setCount] = useState(0);
setCount(1); // 直接把 count 改成 1
setInputValue("React 学习"); // 直接修改输入框值
  1. 传回调函数(适用于状态依赖旧值)

如果新状态需要基于旧状态计算(比如计数、累加),必须用回调函数 ------ 避免 "状态更新异步导致的取值错误"。

python 复制代码
// 正确:用回调函数获取最新的旧值
setCount(prevCount => prevCount + 1);

// 反例:异步场景下可能出错
// handleClick 执行时,count 可能还是旧值,导致更新不准确
setCount(count + 1);
  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,写入副作用会让组件失去 "相同输入→相同输出" 的纯特性,行为不可预测。
  1. 由用户操作触发的副作用 → 放在「事件处理函数」中:

副作用只在用户主动操作(点击、输入、提交)时执行,而非组件渲染时自动执行。

  • 点击按钮提交表单、触发查询;
  • 输入框回车触发搜索;
  • 点击按钮修改浏览器标题。
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>
  );
}
  1. 组件生命周期触发的副作用 → 放在「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 原生验证(邮箱格式、必填、密码长度)+ 异步错误处理,兼顾简单性和健壮性。

  1. 依赖导入与类型定义
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 用法,用于跨组件共享认证状态)
  1. 组件初始化与状态管理
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:控制提交按钮的禁用状态和加载文案,避免用户重复点击提交
  1. 动态文案定义
python 复制代码
  // 提交按钮文案(根据模式切换)
  const submitLabel = mode === "signin" ? "Sign In" : "Sign Up";
  // 切换模式按钮文案(根据模式切换)
  const switchLabel = mode === "signin" ? "Create account" : "Have an account? Sign in";

这两个变量根据当前 mode 动态生成按钮文案,避免重复写条件判断,让代码更简洁。

  1. 核心:表单提交处理函数
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);
    }
  };
  1. 组件渲染部分
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 订阅,防止内存泄漏。

  1. 依赖导入与类型定义
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;
}

类型约束:确保路由状态的类型安全,避免使用时出现类型错误。

  1. 工具函数:时间格式化
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)格式化为用户友好的本地时间。处理无效时间字符串,避免页面报错。

  1. 组件初始化与状态管理
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。

  1. 核心函数:刷新消息列表
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 执行。

  1. 副作用:初始化加载消息
python 复制代码
  useEffect(() => {
    // 组件挂载/refreshMessages 变化时,加载消息列表
    refreshMessages().catch(() => undefined);
  }, [refreshMessages]);

组件首次渲染时,自动调用 refreshMessages 加载历史消息;refreshMessages 函数变化时(即 conversationId 变化)也会重新加载。

  1. 核心: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 匹配)的消息,避免接收其他会话的消息。

回调函数:收到新消息后,更新本地消息列表(先判断是否已存在,避免重复)。

清理函数:组件卸载时取消订阅,防止内存泄漏和无效监听。

  1. 缓存计算:判断是否可发送消息
python 复制代码
  // useMemo 缓存计算结果:避免每次渲染都重新计算
  const canSend = useMemo(() => content.trim().length > 0 && Boolean(conversationId), [content, conversationId]);

计算逻辑:输入框内容非空 且 有会话 ID 时,才允许发送。

useMemo 作用:缓存布尔值,仅当 content 或 conversationId 变化时重新计算。

  1. 核心函数:发送消息
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 : "发送失败");
    }
  };

发送成功后:清空输入框,即时将新消息添加到列表(无需等待实时监听),提升用户体验。

  1. 兜底渲染:无会话 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 路径(无会话信息)时,显示友好提示,避免页面报错或空白。

  1. 主渲染:聊天页面 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、时间格式化容错,提升鲁棒性。

  1. 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. 未登录时跳过数据加载,避免无效请求
  1. 类型定义(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 组件。

拆分不同场景的类型(创建输入、筛选条件、上下文接口):每个类型只服务于特定场景,职责单一,易于维护。

  1. 工具函数(纯函数)

这部分是「数据转换器」和「辅助工具」,无副作用,只做数据处理。

默认常量(兜底值)

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 导致的错误。

  1. 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 设计规范

  1. 依赖导入(基础支撑)
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 是当前页面路径) 用于判断当前页面是否匹配导航项,实现激活态高亮
  1. 组件初始化与核心工具函数
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:极简工具函数,返回布尔值,是实现导航项「激活态高亮」的核心逻辑。

  1. 导航栏隐藏逻辑(边界处理)
python 复制代码
  // 定义需要隐藏导航栏的路径列表(全屏子页面)
  const hideNavPaths = ["/create", "/chat", "/detail", "/review", "/checkin", "/settings"];
  // 检查当前路径是否以列表中任意路径开头(支持子路径,如 /chat/123 也会匹配 /chat)
  if (hideNavPaths.some((path) => location.pathname.startsWith(path))) {
    return null; // 返回 null 表示组件不渲染,实现导航栏隐藏
  }

在「发布页、聊天页、详情页」等全屏子页面,不需要显示底部导航,因此直接返回 null 隐藏组件。

  1. 核心渲染:导航栏 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)」三类核心数据的结构:

  • 强类型约束:避免开发中出现「字段名写错、类型不匹配、可选字段未处理」等低级错误;
  • 统一数据格式:让前后端、组件间的数据交互有明确的规范;
  • 提升可维护性:通过类型注释 / 语义化字段名,让代码可读性更高。
  1. 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 活动待审核(主办方创建后未通过审核)
  1. User 接口(基础类型:用户)
python 复制代码
export interface User {
  name: string; // 用户名
  avatar: string; // 用户头像 URL
}
  • 极简设计:仅包含 UI 展示所需的核心字段(姓名 + 头像),适合「列表展示、参与者头像」等轻量场景;
  • 复用性:可用于活动发起人(Activity.host 可兼容此类型)、参与者、当前登录用户等场景,避免重复定义相似类型;
  • 扩展建议:若需完整用户信息,可继承此接口(如 interface FullUser extends User { id: string; email: string; })。
  1. 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 设计规范,区分「查询、创建、详情、参与」等业务操作。

  1. 依赖导入(核心模块 / 工具)
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) 权限控制:所有活动接口强制登录,避免未授权访问
  1. 创建路由实例
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);

作用:实现路由的「模块化管理」,避免所有接口都写在主应用中,提升代码可维护性。

  1. 全局中间件:统一认证校验
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,无需在每个接口单独写认证逻辑。

  1. 接口路径与处理函数映射(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 的参数校验、权限验证、业务逻辑调用、统一错误处理,是典型的「中间层」代码:

  1. 依赖导入与类型基础
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 方法 业务服务层,封装与数据库 / 第三方交互的核心逻辑 控制器与业务解耦(控制器只做参数 / 响应处理,服务层做核心逻辑),符合「单一职责」
  1. 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) // 经度:必传 + 范围限制
});
  1. 控制器函数(核心业务流程)

所有控制器函数遵循统一流程:

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 并行获取数据,提升接口性能。
  1. 依赖导入与基础定义
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 档案服务层:确保用户档案存在(无则创建) 统一用户档案管理,避免重复创建 / 查询逻辑
  1. 数据转换工具函数: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)转换为「活动列表」场景的精简数据结构;

  1. 核心服务函数解析

所有服务函数遵循「参数校验 → 依赖数据获取 → 业务规则校验 → 数据操作 → 跨模块联动 → 返回结果」的流程。

查询活动列表: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 错误并抛出标准化异常,服务层可统一处理。
  1. 基础依赖与类型定义
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 表字段对齐,仅包含档案核心字段 用于关联查询活动主办方信息
  1. 地理距离计算工具(核心业务工具)
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; // 距离 = 地球半径 × 圆心角
}

根据用户的地理坐标(纬度 / 经度)和活动的坐标,计算两者之间的直线距离,实现「按距离筛选活动」;

  1. 核心仓储方法解析

所有仓储方法遵循「获取 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 错误响应,避免接口级重复处理。
  1. 基础类型与依赖导入
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 / 邮箱)
  1. 核心工具函数解析

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 的签名、有效期、是否被吊销;

  1. 核心中间件: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)设置默认值,提升开发体验。
  1. 基础依赖与类型定义
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);
}
  1. 环境变量解析主函数: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/ 用于环境验证测试。

相关推荐
王者引擎2 小时前
CozeLoop简化AI代理开发和运营
人工智能
星爷AG I2 小时前
15-3 前庭觉(AGI基础理论)
人工智能·agi
Ahtacca2 小时前
实测分享:AI 生成 Python 科技海报时,如何避免文字乱码问题
人工智能·科技
杨超越luckly2 小时前
AI Agent应用指南 :自动化构建品牌数据库:提示词 + API + 结构化输出
大数据·数据库·人工智能·自动化·ai agent
Takoony2 小时前
Nanobot 源码深度剖析:一个轻量级 AI Agent 框架的架构设计与实现原理
人工智能
早點睡3902 小时前
ReactNative项目Openharmony三方库集成实战:react-native-safe-area-context
javascript·react native·react.js
物联网软硬件开发-轨物科技2 小时前
【轨物洞见】定义“视觉语音时代”:轨物科技重塑人机交互新范式
人工智能·科技·人机交互
DX_水位流量监测2 小时前
德希科技供水水质多参数 PLC 一体机
网络·人工智能·深度学习·水质监测·水质传感器·水质厂家·供水水质监测
艾莉丝努力练剑2 小时前
System V IPC底层原理详解
linux·运维·服务器·网络·c++·人工智能·学习