AI-powered knowledge, Q & A community
- 前端:
- React
- react-router-dom(npm install react-router-dom)
- zustand(npm i zustand)
- react-activation
- tailwindcss
- typescript
- axios
- shadcn(pnpm i shadcn@latest, npx shadcn@latest init, npx shadcn@latest add button)
- 后端:
- Node.js + TypeScript
- NestJS (nest new nest-test-demo)
- Prisma ORM(pnpm i prisma@6.19.2, npx prisma init, npx prisma migrate dev --name add_posts)
- PostgreSQL
- JWT
- docker
- 查看所有运行中的容器:
docker ps - 查看所有容器(包括未运行的):
docker ps -a - 进入默认数据库:
docker exec -it pgvector-db psql -U postgres -d postgres
- 查看所有运行中的容器:
- AI
支持 11 位手机号注册登录,以及用户间的粉丝关注逻辑。
| 字段名 | 类型 | 约束 | 描述 |
|---|---|---|---|
id |
Int | Primary Key, AutoInc | 用户唯一内部 ID |
phone |
VarChar(11) | Unique, Not Null | 核心凭证:11位手机号用于注册登录 |
nickname |
VarChar(50) | Not Null | 用户展示用的昵称 |
password |
VarChar(255) | Not Null | 哈希加密后的密码 |
avatar |
VarChar(255) | Nullable | 头像 URL |
created_at |
Timestamptz | Default: now() | 账号注册时间 |
updated_at |
Timestamptz | Default: now() | 资料更新时间 |
| 字段名 | 类型 | 约束 | 描述 |
|---|---|---|---|
followerId |
Int | Composite PK, FK | 发起关注的用户 ID (粉丝) |
followingId |
Int | Composite PK, FK | 被关注的用户 ID (目标) |
created_at |
Timestamptz | Default: now() | 建立关注的时间 |
系统区分长文和短提问,两表物理分离。
| 字段名 | 类型 | 约束 | 描述 |
|---|---|---|---|
id |
Int | Primary Key, AutoInc | 文章 ID |
title |
VarChar(255) | Not Null | 文章标题 |
content |
Text | Not Null | 文章正文内容 |
userId |
Int | FK, Index, SetNull | 作者 ID |
created_at |
Timestamptz | Default: now() | 发布时间 |
| 字段名 | 类型 | 约束 | 描述 |
|---|---|---|---|
id |
Int | Primary Key, AutoInc | 问题 ID |
title |
VarChar(255) | Not Null | 问题标题 |
userId |
Int | FK, Index, SetNull | 提问者 ID |
created_at |
Timestamptz | Default: now() | 提问时间 |
| 注 | - | - | 无正文设计,保持提问简短 |
通过多态外键和中间表实现逻辑共用。
- 多态关联:由
postId和questionId决定评论所属。 - 无限级回复:通过
parentId实现评论嵌套。
| 字段名 | 类型 | 约束 | 描述 |
|---|---|---|---|
id |
Int | Primary Key, AutoInc | 评论 ID |
content |
Text | Not Null | 评论文本或回答内容 |
userId |
Int | FK, Index, Cascade | 评论者 ID |
postId |
Int | FK, Nullable, Index | 关联文章 (为空表示不是文章评论) |
questionId |
Int | FK, Nullable, Index | 关联问题 (为空表示不是提问回答) |
parentId |
Int | FK, Nullable, Index | 回复的父评论 ID |
created_at |
Timestamptz | Default: now() | 发布时间 |
-
标签库 (
tags):存储唯一标签名。 -
文章关联 (
post_tags):多对多中间表。 -
问题关联 (
question_tags):多对多中间表。 -
业务逻辑:后端 Service 需校验发布内容时关联的标签数
$\le 10$ 。
| 模块 | 表名 | 复合主键字段 | 描述 |
|---|---|---|---|
| 文章点赞 | user_like_posts |
userId, postId |
用户点赞的文章记录 |
| 问题点赞 | user_like_questions |
userId, questionId |
用户点赞的问题记录 |
| 文章收藏 | user_favorite_posts |
userId, postId |
用户收藏的文章记录 |
| 问题收藏 | user_favorite_questions |
userId, questionId |
用户收藏的问题记录 |
- 回答逻辑:向
comments表插入数据,仅填充questionId。 - 回复逻辑:向
comments表插入数据,填充parentId。 - 查询逻辑:
- 查询“全站 Node.js 标签内容”时,需通过
tags表连接post_tags和question_tags。 - 查询“我收藏的所有内容”时,需并集查询
user_favorite_posts和user_favorite_questions。
- 查询“全站 Node.js 标签内容”时,需通过
- 级联规则:用户/文章/问题删除时,其关联的评论、点赞、标签记录必须 Cascade (级联删除)。
touch-none
- 避免首页不断地挂载,卸载,导致性能问题 使用 KeepAlive 组件,缓存首页,避免重复挂载卸载
- npm install react-activation
- home 不能卸载
- react-activation cache 缓存 home,界面和数据都保持 display:none 离开文档流 KeepAlive + AliveScope
- 通过手机号,昵称,密码注册
- 在后端
user下增加register dto
import {
IsNotEmpty,
IsString,
MinLength,
Length,
} from 'class-validator';
export class RegisterDto {
@IsNotEmpty({ message: '手机号不能为空' })
@IsString()
@Length(11, 11, { message: '手机号必须是11位' })
phone: string;
@IsNotEmpty({ message: '密码不能为空' })
@IsString()
@MinLength(6, { message: '密码长度不能小于6位' })
password: string;
@IsNotEmpty({ message: '昵称不能为空' })
@IsString()
nickname: string;
}并且通过 bcrypt 进行加密
import {
Injectable,
BadRequestException, // 错误处理
} from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { RegisterDto } from './dto/user-register.dto'
import * as bcrypt from 'bcrypt';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService){
}
async register(registerDto: RegisterDto) {
const { phone, password, nickname } = registerDto;
const existingUser = await this.prisma.user.findUnique({
where: {
phone
}
});
if (existingUser) {
// 抛出异常
// nest 企业级框架 捕获并返回给用户错误信息
// node:弱类型,单线程,出错可能灾难性
throw new BadRequestException("用户名已存在");
}
// 10:加密算法的强度
const hashedPassword = await bcrypt.hash(password, 10);
// console.log(hashedPassword, hashedPassword.length)
const user = await this.prisma.user.create({
data: {
phone,
password: hashedPassword,
nickname
},
select: {
id: true,
phone: true,
nickname: true,
}
})
return user;
}
}- refreshToken 和 accessToken 双 token 机制
- 前端 config.ts 根据后端返回的响应,如果响应失败则判断是否是 token 过期,如果是则刷新 token 并将失败的请求放进请求队列中,token 刷新后重新发送请求,否则返回错误信息。
axios 请求,get请求接收两个参数,post接收三个参数
axios.get(url, config): 只有 2 个参数。 axios.post(url, data, config): 有 3 个参数。
config 配置对象: Axios 会根据字段名决定它的去向:
| 字段名 | 是否发给后端 | 说明 |
|---|---|---|
| params | 会 | 最终变成 URL 后面的 ?key=value。 |
| headers | 会 | 变成 HTTP 请求头(后端从 Header 获取)。 |
| signal | 不会 | 内部控制开关,只存在于浏览器内存中。 |
| timeout | 不会 | Axios 内部计时器,时间到了自动断开请求。adapter不会决定是用浏览器 XHR 还是 Node.js http 模块发送请求。 |
import axios from 'axios';
import { useUserStore } from '@/store/user';
// instance 拦截器
// instance 即为 axios 实例
const instance = axios.create({
baseURL: 'http://localhost:3001/api',
})
// 请求拦截器,在请求发送前添加 token
instance.interceptors.request.use(config => {
const token = useUserStore.getState().accessToken;
if (token) {
// 将 token 添加到请求头
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
})
// 实现无感刷新token
// 是否在刷新token
let isRefreshing = false;
// 请求队列,refresh 中,在并发的请求再去发送没有意义
// 保存下来,存到一个队列中,无缝地将之前的所有失败的请求,再请求,带上新的token 就会成功
let requestQueue: any[] = [];
instance.interceptors.response.use(res => {
// console.log('////[][][]');
// console.log("|||||||", res);
// if (res.status != 200) {
// console.log("出错了");
// return;
// }
// 来到这里说明成功响应
// 直接返回 res.data ,那么别的 api 下直接返回 res 即可
return res.data;
}, async (err) => {
// 说明响应不成功,需要去刷新token
const { config, response } = err;
// config:原始请求的配置对象,包括 url、method、headers、data,自己加的token 等
// 鉴权不成功返回 401 Unauthorized。
if (response?.stauts === 401 && !config._retry) {
// 401 就是token过期,如果token 过期了
if (isRefreshing) {
// 刷新了一次 token
// 当 access_token 过期(401)时,你只想 刷新一次 token,而不希望并发的其他请求也去刷新。
// 所以使用了一个队列 requestQueue 来存放这些请求的回调。
// 刷新 token 完成后,会依次执行队列里所有请求,带上新的 token。
return new Promise((resolve) => { // 这就意味着当前请求被 挂起,不会继续执行后面的刷新逻辑
// requestQueue 里存的是 (token: string) => void 类型的函数。
// token刷新完成后再依次拿到队列中的回调,然后再带上新的token 发送新的请求
requestQueue.push((token: string) => {
config.headers.Authorization = `Bearer ${token}`
// resolve 是 Promise 的成功回调函数,也就是用来告诉外部 “这个 Promise 已经完成,并返回结果了”。
// instance(config) 发送请求
// config 闭包,每个回调函数都会带上自己的config(原始请求对象)
resolve(instance(config));
});
})
}
// retry 是每个请求自己的,如果一个请求进来了并且 isRefreshing 为 true,那么这个请求就会进入队列
// 并且 retry 为 true 那么再请求就不会来到这
config.retry = true; // retry 开关 防止同一个请求无限循环刷新 token
isRefreshing = true; // 刷新一次 token
try {
const { refreshToken } = useUserStore.getState(); // 拿到前端存储的 refreshtoken 去刷新
if (refreshToken) {
// 拿到刷新的 token
const { access_token, refresh_token } = await instance.post('/auth/refresh', {
refresh_token: refreshToken
});
// 存到前端本地存储
useUserStore.setState({
accessToken: access_token,
refreshToken: refresh_token,
isLogin: true,
});
// 重新对之前刷新时的网络请求带上新的token进行请求
// 队列存储的都是回调函数
// (callback) => callback(access_token) 去调用存储的
requestQueue.forEach((callback) => callback(access_token));
requestQueue = [];
config.headers.Authorization = `Bearer ${access_token}`
// 原始请求的请求头带上新的 token
// 若 refreshtoken 还有效 那么就会后续就会触发回调
return instance(config); // 触发刷新 token 的第一个请求重试
}
} catch (err) {
window.location.href = '/login';
return Promise.reject(err); // 出错
} finally {
isRefreshing = false;
}
}
// refreshtoken 也失效了,那么第一个失败的请求也就失败了
// 第一个请求就失败了,队列里的请求也不会被执行
// reject 是 Promise 的失败回调
return Promise.reject(err); // 外层 async 函数的默认返回
})
export default instance;- 后端使用 jwt 进行鉴权和颁发token
import {
Injectable,
UnauthorizedException, // UnauthorizedException 是 NestJS 内置的一个异常类
} from '@nestjs/common';
import { LoginDto } from './dto/login.dto';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcrypt';
import {
PrismaService,
} from '../prisma/prisma.service';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async login(loginDto: LoginDto) {
const { phone, password } = loginDto;
// 根据手机号查询数据库中的用户
const user = await this.prisma.user.findUnique({
where: {
phone,
},
});
// hashed password 比对
if(!user || !(await bcrypt.compare(password, user.password))) {
throw new UnauthorizedException('用户名或密码错误')
}
// 颁发token
const tokens = await this.generateTokens(user.id.toString(), user.phone);
// generateTokens 返回 access_token 和 refresh_token
return {
...tokens, // 使用 ... 把 generateTokens 返回的 { access_token, refresh_token } 和新的 user 对象组合成一个最终返回对象。
user:{
id: user.id.toString(),
nickname: user.nickname,
phone: user.phone
}
}
}
// 刷新 token
async refreshToken(rt: string) {
try {
// verifyAsync:验证 JWT 的方法。
// 如果 token 过期或篡改,verifyAsync 会抛异常,你在 catch 里捕获,返回 401 错误。
const payload = await this.jwtService.verifyAsync(rt, {
secret:process.env.TOKEN_SECRET
});
// console.log(payload, "--------()()()")
// 没有过期,那么生成新的 token
return this.generateTokens(payload.sub, payload.name);
} catch(err) {
throw new UnauthorizedException("Refresh Token 已失效,请重新登录");
}
}
// 生成 token
private async generateTokens(userId: string, phone: string) {
const payload = {
sub: userId, // sub:用来唯一标识 token 所代表的主体,刚好可以用 userID
name: phone // name:自定义字段,可以随便放你想让 token 携带的信息
};
const [at, rt] = await Promise.all([
// 颁发了两个token access_token
this.jwtService.signAsync(payload, {
expiresIn: '15m', // 有效期 15分钟 更安全 被中间人攻击
// TOKNE_SECRET:JWT 的签名密钥
secret: process.env.TOKEN_SECRET
}),
// refresh_token 刷新
// 7d 服务器接受我们,用于refresh
// 服务器再次生成两个token 给我们
// 依然使用 15m token 请求
this.jwtService.signAsync(payload, {
expiresIn: '7d',
secret: process.env.TOKEN_SECRET
}),
])
return {
access_token: at,
refresh_token: rt
}
}
}- 查看所有运行中的容器:
docker ps - 查看所有容器(包括未运行的):
docker ps -a - 进入默认数据库:
docker exec -it pgvector-db psql -U postgres -d postgres
- 使用通义万象生成
async avatar(nickname: string) {
const prompt = `你是一位头像设计师,请你根据用户的姓名${nickname},设计一个专业的头像,风格卡通、时尚且好看。`;
try {
// 1. 提交绘图任务到通义万相
const response = await fetch('https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.DASHSCOPE_API_KEY}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable'
},
body: JSON.stringify({
model: 'wanx-v1',
input: { prompt },
parameters: {
n: 1,
size: '1024*1024'
}
})
});
const submitResult: any = await response.json();
const taskId = submitResult.output?.task_id;
if (!taskId) {
throw new Error(`Failed to submit task: ${submitResult.message || 'Unknown error'}`);
}
// 2. 带限制保护的轮询
const imgUrl = await this.pollTaskResult(taskId);
console.log(imgUrl);
return imgUrl;
} catch (error) {
console.error("生成头像失败", error);
throw error;
}
}
// 轮询检查任务状态
private async pollTaskResult(taskId: string): Promise<string> {
const checkUrl = `https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`;
const headers = { 'Authorization': `Bearer ${process.env.DASHSCOPE_API_KEY}` };
const MAX_ATTEMPTS = 30; // 最大尝试 30 次
const INTERVAL = 2000; // 每次间隔 2 秒
let attempts = 0;
while (attempts < MAX_ATTEMPTS) {
attempts++;
const res = await fetch(checkUrl, { headers });
const statusResult: any = await res.json();
// 容错处理:如果接口报错但没拿到 output
if (!statusResult.output) {
throw new Error('Invalid response from DashScope API');
}
const status = statusResult.output.task_status;
if (status === 'SUCCEEDED') {
// 成功:返回第一张图片的 URL
return statusResult.output.results[0].url;
}
if (status === 'FAILED' || status === 'UNKNOWN') {
// 失败:抛出 API 返回的具体错误信息
throw new Error(`Image generation failed: ${statusResult.output.message || 'Internal error'}`);
}
// 还在处理中,等待后重试
await new Promise(resolve => setTimeout(resolve, INTERVAL));
}
// 超过 60 秒(30次 * 2秒)仍未完成,强制断开
throw new Error('Image generation timed out after 60 seconds');
}- store 可以实现用户在发布页面的草稿功能
- 发布文章和问题时,都需要对用户输入的标题和标签进行限制
- 使用权重计算,中文标签权重为2分,英文标签权重为1分
// --- 配置区 ---
// const TITLE_TOTAL_SCORE = 30; // 总权重分
// const MAX_TAG_COUNT = 5;
// const TAG_CN_LIMIT = 7;
// const TAG_EN_LIMIT = 16;
// 获取字符串的权重分 (中文2分,其他1分)
export const getWeightScore = (str: string = ''): number => {
let score = 0;
for (const char of str) {
score += /[\u4e00-\u9fa5]/.test(char) ? 2 : 1;
}
return score;
};
// 根据权重分限额截断字符串
export const truncateByWeight = (str: string, limit: number): string => {
let score = 0;
let result = '';
for (const char of str) {
const charWeight = /[\u4e00-\u9fa5]/.test(char) ? 2 : 1;
if (score + charWeight <= limit) {
score += charWeight;
result += char;
} else {
break;
}
}
return result;
};变量永远是组件挂载时的状态
import { useRef, useEffect } from 'react'
interface InfiniteScrollProps {
hasMore: boolean; // 是否还有更多数据
isLoading: boolean; // 是否正在加载数据
onLoadMore: () => void; // 加载更多数据
children: React.ReactNode; // InfiniteScroll 通用的滚动功能,滚动过的具体内容 接受自定义
}
const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
hasMore,
onLoadMore,
isLoading = false,
children,
}) => {
const sentinelRef = useRef<HTMLDivElement>(null);
// react 不建议直接访问 dom ,使用 useRef 获取真实 DOM
useEffect(() => {
// IntersectionObserver:浏览器原生 Web API
// 作用:监听某个 DOM 元素是否进入视口
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
// isIntersecting:是否进入视口
// 只有满足:
// 进入视口
// 当前不在 loading
// 还有更多数据
// 才触发加载
if (
entry.isIntersecting &&
!isLoading &&
hasMore
) {
onLoadMore(); // 调用加载更多数据函数
}
},
{
threshold: 0,
// 0 表示:哨兵元素只要有 1px 进入视口就触发
}
);
const current = sentinelRef.current;
// current:哨兵 div 的真实 DOM 节点
if (current) {
observer.observe(current);
// 让 IntersectionObserver 开始观察这个 DOM 元素是否进入视口
}
// 卸载(路由切换)或组件销毁时
return () => {
if (current) {
observer.unobserve(current);
// 组件卸载时,取消观察哨兵元素
}
};
// 只在组件挂载时创建一次 observer
// 不依赖 isLoading / hasMore
// 避免 loading 变化导致 observer 反复创建
}, []); // 没有依赖项,变量永远是组件挂载时的状态
return (
<>
{children}
{/* Intersection Observer 哨兵元素 */}
{/* 页面滚动到底部时,它会进入视口,从而触发 observer */}
<div ref={sentinelRef} className="h-4" />
{
isLoading && (
<div className="text-center py-4 text-sm text-muted-forgound">
加载中...
</div>
)
}
{
!hasMore && !isLoading && (
<div className="text-center text-sm text-muted-foreground">
已经到底啦~
</div>
)
}
</>
);
}
export default InfiniteScroll;修复1:添加依赖项,但是,这将导致 observer 在 isLoading 或 hasMore 每次变化时都被重新创建,这可能会带来性能问题,并且注释中也提到了“避免 loading 变化导致 observer 反复创建”。
最佳实践:使用 useRef 来存储 isLoading 和 hasMore 的最新值。 这样, useEffect 的依赖数组仍然可以是空的,但 observer 的回调函数可以通过 ref 访问到最新的值。这种方法在需要避免 useEffect 频繁重新运行,但又需要访问最新 prop 值时非常有用。避免了 Observer 对象的重复装卸。
import { useRef, useEffect } from 'react'
interface InfiniteScrollProps {
hasMore: boolean; // 是否还有更多数据
isLoading: boolean; // 是否正在加载数据
onLoadMore: () => void; // 加载更多数据
children: React.ReactNode; // InfiniteScroll 通用的滚动功能,滚动过的具体内容 接受自定义
}
const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
hasMore,
onLoadMore,
isLoading = false,
children,
}) => {
const sentinelRef = useRef<HTMLDivElement>(null);
// react 不建议直接访问 dom ,使用 useRef 获取真实 DOM
// 使用 useRef 保存最新的 props 值,避免闭包陷阱
// 闭包陷阱:IntersectionObserver 回调函数会捕获创建时的变量值
// 使用 ref 可以让回调函数始终访问到最新值
const hasMoreRef = useRef(hasMore);
const isLoadingRef = useRef(isLoading);
const onLoadMoreRef = useRef(onLoadMore);
// 每次渲染时同步更新 ref 的值
// 无依赖项的 useEffect 会在每次渲染后执行useEffect
// 保证了赋值动作发生在 Commit 阶段。
// 含义:只有当 React 确定“这次渲染成功了,屏幕已经更新了”,它才会去跑 useEffect 里的赋值。
// 结果:这保证了 isLoadingRef.current 里的值,永远与当前屏幕上正在显示的那个 isLoading 状态保持一致。
useEffect(() => {
hasMoreRef.current = hasMore;
isLoadingRef.current = isLoading;
onLoadMoreRef.current = onLoadMore;
});
useEffect(() => {
// IntersectionObserver:浏览器原生 Web API
// 作用:监听某个 DOM 元素是否进入视口
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
// isIntersecting:是否进入视口
// 只有满足:
// 进入视口
// 当前不在 loading
// 还有更多数据
// 才触发加载
if (
entry.isIntersecting &&
!isLoadingRef.current &&
hasMoreRef.current
) {
onLoadMoreRef.current(); // 调用加载更多数据函数
}
},
{
threshold: 0,
// 0 表示:哨兵元素只要有 1px 进入视口就触发
}
);
const current = sentinelRef.current;
// current:哨兵 div 的真实 DOM 节点
if (current) {
observer.observe(current);
// 让 IntersectionObserver 开始观察这个 DOM 元素是否进入视口
}
// 卸载(路由切换)或组件销毁时
return () => {
if (current) {
observer.unobserve(current);
// 组件卸载时,取消观察哨兵元素
}
};
// 依赖项为空数组,observer 只在组件挂载时创建一次
// 避免 loading 变化导致 observer 反复创建
// 通过 ref.current 访问最新的 props 值,避免闭包陷阱
}, []);
return (
<>
{children}
{/* Intersection Observer 哨兵元素 */}
{/* 页面滚动到底部时,它会进入视口,从而触发 observer */}
<div ref={sentinelRef} className="h-4" />
{
isLoading && (
<div className="text-center py-4 text-sm text-muted-forgound">
加载中...
</div>
)
}
{
!hasMore && !isLoading && (
<div className="text-center text-sm text-muted-foreground">
已经到底啦~
</div>
)
}
</>
);
}
export default InfiniteScroll;pnpm add @tanstack/react-virtual
非虚拟列表下,页面性能随数据量呈线性衰减:由于 DOM 节点随滚动不断堆积,导致浏览器在执行样式计算(Recalculate Style)和布局(Layout)时耗时指数级增长,最终在触发路由跳转或长列表滚动时,因主线程被秒级长任务(Long Task)阻塞而产生严重掉帧和交互假死
可以参考性能图片:没有虚拟列表前的性能
通过 api 层和 store 层进行配置 config 来解决
signal 是什么?
signal 是 AbortController 实例上的一个属性(类型是 AbortSignal)。 你可以把它理解为一根引爆线。
你把这根“引爆线” (signal) 传给 Axios。
Axios 内部会一直盯着这根线。
当你在外面调用 currentAbortController.abort() 时,就相当于按下了起爆器。
Axios 看到引爆线被触发,就会立刻在底层(XMLHttpRequest 或 Fetch API 层面)切断网络连接,并抛出一个 CanceledError 。
signal 是闭包在每个请求里的吗?
是的,这是最精妙的地方!
currentAbortController = new AbortController();
const signal = currentAbortController.signal; // 这一步形成了闭包当输入 "a" 时,产生了一个局部的 signal_A。这个 signal_A 被传给了第一个 Axios 请求,并且在它的 finally 块里被引用。
当输入 "ab" 时,全局的 currentAbortController 被替换成了新的,产生了一个局部的 signal_B。 此时,"a" 的请求被 abort 抛出异常,进入了 "a" 请求自己的 catch 和 finally。
在 "a" 请求的 finally 里,它检查的是自己闭包里的 signal_A。因为 signal_A 已经被 abort 了(signal_A.aborted === true),所以它不会执行 set({ loading: false })。 这就完美避免了旧请求的结束导致新请求的 loading 圈提前消失。