阅读视图

发现新文章,点击刷新页面。

【AI 编程实战】第 10 篇:让应用更健壮 - 错误处理与边界情况

功能做完了,但用户体验还差一步——当网络出错、接口超时、数据异常时,应用会不会崩溃?用户能不能看到友好的提示?这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,构建健壮、友好、可维护的错误处理体系。

系列专栏【AI 编程实战】专栏目录

本篇主题:让应用更健壮 - 错误处理与边界情况

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:错误处理的重要性

1.1 没有错误处理的代码

// ❌ 危险:没有任何错误处理
const handleGenerate = async () => {
  const response = await chatService.generateReply({ text: inputText.value });
  replies.value = response.data.replies;
};

可能出现的问题

  • 网络断开 → 应用白屏
  • 接口超时 → 无任何反馈
  • 返回数据为空 → Cannot read property 'replies' of undefined
  • 用户点击多次 → 重复请求

1.2 完善的错误处理

// ✅ 健壮:完整的错误处理
const handleGenerate = async () => {
  if (loading.value) return;  // 防重复点击

  loading.value = true;
  try {
    const response = await chatService.generateReply({ text: inputText.value });

    if (response.code !== 200) {
      throw new Error(response.msg || '生成失败');
    }

    replies.value = response.data?.replies ?? [];  // 空值保护
  } catch (error) {
    const errMsg = error?.message || '网络错误,请稍后再试';
    toast.error(errMsg);
    console.error('生成回复失败:', error);
  } finally {
    loading.value = false;
  }
};

1.3 本文要解决的问题

问题 解决方案
网络错误 统一错误提示 + 重试机制
接口错误码 分类处理(401/403/500)
空值异常 可选链 + 空值合并
重复点击 loading 状态锁定
用户取消 区分取消和真正的错误

二、统一错误提示:Toast 封装

2.1 我的需求

我:项目里很多地方要显示提示,有成功、失败、警告等类型。
    每次都写 uni.showToast 太繁琐,能不能封装一下?

2.2 AI 设计方案

AI:封装一个 toast 工具,提供链式调用:

📐 接口设计:

toast.success('操作成功')
toast.error('操作失败')
toast.warning('请注意')
toast.info('提示信息')

📐 配置选项:

interface ToastOptions {
  duration?: number;      // 显示时长,默认 2000ms
  position?: 'top' | 'middle' | 'bottom';  // 位置
  mask?: boolean;         // 是否显示遮罩防止穿透
}

2.3 AI 生成代码

// src/utils/toast.ts
type ToastType = 'success' | 'error' | 'warning' | 'info';

interface ToastOptions {
  type?: ToastType;
  duration?: number;
  position?: 'top' | 'middle' | 'bottom';
  message: string;
  mask?: boolean;
}

export function showToast(message: string): void;
export function showToast(options: ToastOptions): void;
export function showToast(options: ToastOptions | string): void {
  const defaultOptions: ToastOptions = {
    type: 'info',
    duration: 2000,
    position: 'middle',
    message: '',
    mask: false,
  };

  const mergedOptions = typeof options === 'string'
    ? { ...defaultOptions, message: options }
    : { ...defaultOptions, ...options };

  // 映射 position 到 UniApp 支持的格式
  const positionMap = {
    top: 'top',
    middle: 'center',
    bottom: 'bottom',
  };

  uni.showToast({
    title: mergedOptions.message,
    duration: mergedOptions.duration,
    position: positionMap[mergedOptions.position ?? 'middle'],
    icon: 'none',
    mask: mergedOptions.mask,
  });
}

// 便捷方法
type SimpleToastOptions = Omit<ToastOptions, 'type' | 'message'>;

export const toast = {
  success: (message: string, options?: SimpleToastOptions) =>
    showToast({ ...options, type: 'success', message }),
  error: (message: string, options?: SimpleToastOptions) =>
    showToast({ ...options, type: 'error', message }),
  warning: (message: string, options?: SimpleToastOptions) =>
    showToast({ ...options, type: 'warning', message }),
  info: (message: string, options?: SimpleToastOptions) =>
    showToast({ ...options, type: 'info', message }),
};

2.4 使用示例

import { toast } from '@/utils/toast';

// 简单用法
toast.success('保存成功');
toast.error('网络错误,请稍后再试');

// 带配置
toast.info('正在处理...', { duration: 3000, mask: true });

三、接口错误分类处理

3.1 我的需求

我:后端返回不同的错误码:
    - 401:未登录或 token 过期
    - 403:没有权限(如 VIP 功能)
    - 500:服务器错误

    不同错误码要有不同的处理逻辑。

3.2 AI 设计错误分类

AI:在 HTTP 层统一处理常见错误码,业务层处理特殊错误:

📐 错误分类:

【HTTP 层处理】(在 http.ts 中)
- 401 → 清除登录态,跳转登录
- 网络错误 → 显示"网络错误"提示

【业务层处理】(在页面中)
- 403 → 引导开通 VIP
- 业务错误 → 显示具体错误信息

3.3 HTTP 层错误处理

// src/http/http.ts
export function http<T>(options: CustomRequestOptions) {
  return new Promise<IResData<T>>((resolve, reject) => {
    uni.request({
      ...options,
      success(res) {
        // 2xx 状态码视为成功
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(res.data as IResData<T>);
        }
        // 401 未授权
        else if (res.statusCode === 401) {
          // 清除登录态
          uni.removeStorageSync('token');
          // 可以触发全局事件或跳转登录
          reject(res);
        }
        // 其他错误
        else {
          if (!options.hideErrorToast) {
            const data = res.data as IResData<T>;
            uni.showToast({
              icon: 'none',
              title: data.msg || '请求错误',
            });
          }
          reject(res);
        }
      },
      // 网络错误
      fail(err) {
        uni.showToast({
          icon: 'none',
          title: '网络错误,换个网络试试',
        });
        reject(err);
      },
    });
  });
}

3.4 业务层错误处理

// 页面中处理 403 等业务错误
const handleGenerate = async () => {
  loading.value = true;

  try {
    const response = await chatService.generateReply(params);

    if (response.code !== 200) {
      throw new Error(response.msg || '生成失败');
    }

    replies.value = response.data?.replies ?? [];
  } catch (error: any) {
    console.error('生成回复失败:', error);

    // 提取错误信息
    const errMsg = error?.data?.message
      || error?.msg
      || error?.message
      || '生成失败,请稍后再试';

    // 根据状态码分类处理
    const statusCode = error?.statusCode || error?.data?.code || error?.code;

    if (statusCode === 403) {
      // 权限不足,引导开通 VIP
      const message = errMsg || '免费次数已用完,请开通VIP';
      promptOpenVip(message);
    } else {
      // 其他错误,显示提示
      toast.error(errMsg);
    }
  } finally {
    loading.value = false;
  }
};

四、空值保护:防御性编程

4.1 常见的空值错误

// ❌ 危险:可能报错
const username = response.data.user.username;
// 如果 response.data 是 undefined,就会报错

const firstReply = replies[0].text;
// 如果 replies 是空数组,就会报错

4.2 AI 教我防御性编程

AI:用可选链(?.)和空值合并(??)来保护:

📐 防御性编程规则:

1. 访问对象属性 → 用 ?.
2. 提供默认值 → 用 ??
3. 数组访问 → 先检查长度或用 ?.[0]

4.3 实践示例

// ✅ 安全:可选链 + 空值合并
const username = response?.data?.user?.username ?? '未知用户';

// ✅ 安全:数组保护
const replies = response.data?.replies ?? [];
const firstReply = replies.length > 0 ? replies[0].text : '';

// ✅ 安全:解构时提供默认值
const { data = {} } = response ?? {};
const { replies = [], analysis = '' } = data;

// ✅ 安全:函数参数默认值
const updateUserInfo = (info: Partial<UserInfo> = {}) => {
  // info 一定是对象,不会是 undefined
};

4.4 实际代码中的应用

// 生成回复的完整错误处理
const handleGenerate = async () => {
  // ... 请求逻辑

  const data = response.data ?? {};

  // 安全提取分析结果
  analysisResult.value = data.analysis || '';

  // 安全提取回复列表,并添加 id
  const repliesFromApi = Array.isArray(data.replies)
    ? data.replies.slice(0, MEMORY_REPLY_COUNT)
    : [];

  replies.value = repliesFromApi.map((r, idx) => ({
    ...r,
    id: r.id ?? idx,  // 如果没有 id,用索引代替
  }));

  // 安全更新配额信息
  userStore.updateFreeQuota({
    free_reply_total: data.freeReplyTotal,
    free_reply_used: data.freeReplyUsed,
    free_reply_remaining: data.remainingTimes,
    vip_status: data.isVip ? 'vip' : 'none',
    vip_expire_time: data.vipExpireTime ?? null,
  });
};

五、用户取消 vs 真正的错误

5.1 问题场景

我:用户选择图片时点了"取消",会触发 fail 回调,
    显示"图片选择失败"的错误提示。
    但这不是真正的错误,不应该提示。

5.2 AI 分析区分方法

AI:通过错误信息来区分用户取消和真正的错误:

📊 错误信息分析:

【用户取消】
- chooseMedia:fail cancel
- chooseImage:fail cancel
- chooseImage:fail(code: 0)

【真正的错误】
- chooseImage:fail no permission
- chooseMedia:fail system error
- 其他错误信息

5.3 实现代码

const addImages = () => {
  const { run } = useUpload('', {}, {
    count: remainingCount,
    selectOnly: true,
    onSelect: (images) => {
      // 正常处理选中的图片
      images.forEach((img) => uploadChosenImage(img));
    },
    onError: (err) => {
      // 判断是否为用户主动取消
      const errMsg = typeof err === 'object' && err !== null && 'errMsg' in err
        ? (err as any).errMsg
        : '';
      const errCode = typeof err === 'object' && err !== null && 'code' in err
        ? (err as any).code
        : -1;

      const isUserCancel =
        errMsg === 'chooseMedia:fail cancel' ||
        errMsg === 'chooseImage:fail cancel' ||
        (errMsg === 'chooseImage:fail' && errCode === 0);

      if (!isUserCancel) {
        // 只有真正的错误才提示
        console.error('图片选择失败:', err);
        toast.error('图片选择失败');
      }
      // 用户取消时静默处理,不显示任何提示
    },
  });

  run();
};

六、Promise 封装:统一回调风格

6.1 我的需求

我:uni.login 等 API 用的是回调风格,写起来很繁琐。
    能不能封装成 Promise 风格?

6.2 AI 封装示例

// src/utils/wechat.ts
/**
 * 获取微信登录凭证
 * 将回调风格封装成 Promise
 */
export const requestWechatLoginCode = () =>
  new Promise<string>((resolve, reject) => {
    uni.login({
      provider: 'weixin',
      onlyAuthorize: true,
      success: (res) => {
        if (res?.code) {
          resolve(res.code);
        } else {
          reject(new Error('未获取到有效的登录凭证'));
        }
      },
      fail: (err) => {
        reject(err);
      },
    });
  });

// 使用时
try {
  const code = await requestWechatLoginCode();
  await userStore.wechatLogin(code);
} catch (error) {
  toast.error('登录失败');
}

6.3 剪贴板封装

// src/utils/clipboard.ts
import { isApp } from '@/utils/platform';

declare const plus: any;

/**
 * 跨平台复制文本到剪贴板
 */
export function copyText(text: string): Promise<void> {
  // App 端使用 plus API
  if (isApp && typeof plus !== 'undefined' && plus?.navigator) {
    return new Promise((resolve, reject) => {
      try {
        plus.navigator.setClipboard(text);
        resolve();
      } catch (error) {
        reject(error);
      }
    });
  }

  // 小程序/H5 使用 uni API
  return new Promise((resolve, reject) => {
    uni.setClipboardData({
      data: text,
      success: () => resolve(),
      fail: (err) => reject(err),
    });
  });
}

// 使用时
const handleCopy = async (text: string) => {
  try {
    await copyText(text);
    toast.success('已复制');
  } catch (error) {
    console.error('复制失败:', error);
    toast.error('复制失败,请稍后再试');
  }
};

七、Loading 状态管理

7.1 防止重复点击

const loading = ref(false);

const handleSubmit = async () => {
  // 防止重复点击
  if (loading.value) return;

  loading.value = true;
  try {
    await doSubmit();
  } finally {
    loading.value = false;
  }
};

7.2 按钮状态联动

<template>
  <XButton
    :text="loading ? '处理中...' : '提交'"
    :loading="loading"
    :disabled="loading || !canSubmit"
    @click="handleSubmit"
  />
</template>

7.3 多个 Loading 状态

// 页面有多个独立的加载状态
const isGenerating = ref(false);      // 生成回复
const isUploadingImages = ref(false); // 上传图片
const isSaving = ref(false);          // 保存数据

// 计算总体加载状态
const isLoading = computed(() =>
  isGenerating.value || isUploadingImages.value || isSaving.value
);

// 细粒度控制按钮状态
<XButton
  :disabled="isGenerating || isUploadingImages"
  @click="handleGenerate"
/>

八、错误处理最佳实践

8.1 错误处理清单

层级 处理内容 示例
HTTP 层 网络错误、401 http.ts 统一处理
业务层 403、业务错误码 页面 catch 块处理
UI 层 用户提示、状态恢复 toast + loading

8.2 代码模板

// 标准的异步操作模板
const handleAsyncAction = async () => {
  // 1. 防重复
  if (loading.value) return;

  // 2. 前置校验
  if (!canSubmit.value) {
    toast.warning('请填写完整信息');
    return;
  }

  loading.value = true;

  try {
    // 3. 执行操作
    const response = await doAction();

    // 4. 校验响应
    if (response.code !== 200) {
      throw new Error(response.msg || '操作失败');
    }

    // 5. 处理成功
    const data = response.data ?? {};
    processData(data);
    toast.success('操作成功');

  } catch (error: any) {
    // 6. 分类处理错误
    console.error('操作失败:', error);

    const statusCode = error?.statusCode || error?.code;
    const errMsg = error?.msg || error?.message || '操作失败';

    if (statusCode === 403) {
      handleNoPermission();
    } else {
      toast.error(errMsg);
    }

  } finally {
    // 7. 恢复状态
    loading.value = false;
  }
};

8.3 总结

技术 用途 示例
try-catch-finally 捕获和恢复 异步操作的标准模式
可选链 ?. 安全访问属性 response?.data?.user
空值合并 ?? 提供默认值 value ?? defaultValue
Promise 封装 统一回调风格 requestWechatLoginCode()
错误分类 差异化处理 401/403/500 不同处理
Loading 状态 防重复 + 用户反馈 if (loading) return

错误处理不是"事后补救",而是设计阶段就要考虑的架构问题

好的错误处理让用户感受到应用的专业和可靠。

如果这篇文章对你有帮助,请点赞、收藏、转发!

docker-compose k8s部署项目使用服务名称,后端服务发布后出现接口502问题

这里使用一个例子docker-compose.yml

// docker-compose.yml
# 确保所有服务在同一网络
version: '3.8'
services:
  app:
    networks:
      - mynetwork
    depends_on:
      - mysql
      - redis
  
  mysql:
    networks:
      - mynetwork
  
  redis:
    networks:
      - mynetwork
  imotor-ltq:
    image: phm-ltq:1.0.0
    container_name: imotor-ltq
    restart: always
    networks:
      - app_net
    deploy:
      resources:
        limits:
          cpus: '3.00'
          memory: 5G
        reservations:
          cpus: '3.00'
          memory: 2G
    ports:
      - "7953:8080"
spectral-ltq-web:
    image: spectral-ltq-web:1.0.0
    container_name: spectral-ltq-web
    restart: always
    networks:
      - app_net
    deploy:
      resources:
        limits:
          cpus: '1.00'
          memory: 1G
        reservations:
          cpus: '1.00'
          memory: 1G
    ports:
      - "7777:8080"
    depends_on:
      imotor-algo:
        condition: service_healthy
networks:
  mynetwork:
    driver: bridge

前端nginx 镜像容器配置:

    location /api/ {                # 使用变量强制每次解析                proxy_pass http://imotor-ltq:7953/;                proxy_set_header Host $proxy_host;                proxy_set_header X-Real-IP $remote_addr;                proxy_set_header remote_addr $remote_addr;                proxy_http_version 1.1;                proxy_connect_timeout 4s;                proxy_read_timeout 600s;                proxy_send_timeout 12s;            }

当后端开发新功能发版后偶现

发现服务不能使用了,每次出现这个问题时 都需要重启前端项目,

后面开始排查:查看docker 网络

docker network ls
docker network inspect <network_name>

多次重启后发现IP地址会发生变化,基本可以确定是使用服务名DNS解析时存在缓存
使用的是之前的服务IP地址,所以找不到

进一步确定问题:使用 docker-compose exec 前端应用 ping imotor-ltq
服务

解决方法:

server { 
         listen       8080;# 访问端口 
         server_name  localhost;
         resolver 127.0.0.11 valid=10s;  # Docker 内置 DNS
         location /api/ {
          set $algo_host imotor-algo;
          rewrite ^/api/(.*)$ /$1 break; //使用rewrite重置为后端需要的地址
          proxy_pass http://$algo_host:8080;
       }
    }

// 添加 resolver 127.0.0.11 valid=10s; # Docker 内置 DNS
使用变量

 set $algo_host imotor-algo;

rewrite ^/api/(.*)/ /1 break;

proxy_pass http://$algo_host:8080;

之前想省略  rewrite ^/api/(.*)/ /1 break;直接在后面加/

set $algo_host imotor-algo;
proxy_pass http://$algo_host:8080/;

发现找不到服务地址,不知道什么原因

HarmonyOS 官方的rules规则你还不知道吗(可以直接CV使用)

HarmonyOS 官方的rules规则你还不知道吗(可以直接CV使用)

万少:华为HDE、鸿蒙极客

个人主页:blog.zbztb.cn/

2025年参与孵化了20+鸿蒙应用、技术文章300+、鸿蒙知识库用户500+、鸿蒙免费课程2套。

如果你也喜欢交流AI和鸿蒙技术,欢迎扣我。

前言

2026年,也是AI 编程蓬勃发展的一年。

DevEco Studio 6.0.2 Beta1 开始也迎来了大的增强,这个能力的到来,

基本可以实现只在 DevEco Studio 中进行愉快的 AI 编程了。

增加的能力有:

  • 智能体
  • MCP
  • 模型
  • 规则

模型

先来聊一下模型,目前官方内置的大模型有 deepseek-v3.1

现在的日期是 2026年2月3日

说实在的,deepseek-v3.1 有点不够打了,目前国产模型欢呼声比较大的是 GLM4.7,以及 Kimi2.5

不过胜在可以接入三方的大模型。

万少这里接入了的有

  • 智谱官方的 GLM4.7 收费的
  • 英伟达的 GLM4.7 免费的

开发者可以自由选择,如果不知道如何配置的,欢迎扣我

智能体

你可以自定义智能体,理解为这个 AI 编程工具的角色是谁即可,文章末尾提供万少的智能体以供参考

规则

规则分成两种,一种是项目级别的,只在当前工程内有效

另一种是全局的,所有的工程都生效。

有意思的是 CodeGenie 中已经包含了一份内置的 HarmonyOS 规则,既然是内置的,那么就理解是官方的规则

一览。

文章末尾会提供,可以直接 CV

MCP

MCP 可以让我们的 AI 编辑器直接对接外部各种服务,这里我最常使用的是 context7

context7 是一个远程仓库,它上面有各种编程语言的资料,当你的 AI 编辑器生成的代码语法有误时,只要你配置了 context7,然后在对话中只需要这样:

帮我生成一段下载图片的代码 use context7

那么你的 AI 编辑器就会自动去搜索 context7 上最新的 HarmonyOS 代码,确保生成的语法无误了。

如果你也想要配置,可以参考:

json

{
  "mcpServers": {
    "context7": {
      "args": ["-y", "@upstash/context7-mcp@latest"],
      "command": "npx",
      "enabled": true,
      "env": {
        "DEFAULT_MINIMUM_TOKENS": "10000"
      }
    }
  }
}

规则模版

markdown

你正在为 HarmonyOS 应用开发相关功能。以下是你需要遵循的开发规则。

## ArkTS/ets 语法约束(违反条目将无法编译通过)

- 不支持索引访问类型。请改用类型名称。
- 不支持环境模块声明,因为它有自己的与 JavaScript 互操作的机制。请从原始模块中导入所需的内容。
- 不支持 anyunknown 类型。请显式指定类型。
- 不支持 as const 断言,因为在标准 TypeScript 中,as const 用于使用相应的字面量类型标注字面量,而 ArkTS 不支持字面量类型。请避免使用 as const 断言。请改用字面量的显式类型标注。
- 不支持对象类型中的调用签名。请改用 class(类)来实现。
- 不支持类字面量。请显式引入新的命名类类型。
- 不支持将类用作对象(将其赋值给变量等)。这是因为在 ArkTS 中,类声明引入的是一种新类型,而不是一个值。请勿将类用作对象;类声明引入的是一种新类型,而不是一个值。
- 仅在 for 循环中支持逗号运算符。在其他情况下,逗号运算符是无用的,因为它会使执行顺序更难理解。在 for 循环之外,请使用显式执行顺序而不是逗号运算符。
- 不支持条件类型别名。请显式引入带约束的新类型,或使用 Object 重写逻辑。不支持 infer 关键字。
- 不支持在构造函数中声明类字段。请在类声明内部声明类字段。
- 不支持使用构造函数类型。请改用 lambdas(匿名函数)。
- 不支持接口中的构造函数签名。请改用方法(methods)。
- 不支持对象类型中的构造函数签名。请改用 class(类)来实现。
- 不支持声明合并。请保持代码库中所有类和接口的定义紧凑。
- 不支持确定性赋值断言 let v!: T,因为它们被认为是过度的编译器提示。使用确定性赋值断言运算符(!)需要运行时类型检查,导致额外的运行时开销并生成此警告。请改用带初始化的声明。如果使用了!,请确保实例属性在使用前已赋值,并注意运行时开销和警告。
- 假定对象布局在编译时已知且运行时不可更改。因此,删除属性的操作没有意义。为了模拟原始语义,您可以声明一个可空类型并赋值为 null 以标记值的缺失。
- 不支持解构赋值。请改用其他惯用法(例如,在适用情况下使用临时变量)代替。
- 不支持解构变量声明。这是一个依赖于结构兼容性的动态特性。创建中间对象并逐字段操作,不受名称限制。
- 要求参数直接传递给函数,并手动分配局部名称。请将参数直接传递给函数,并手动分配局部名称,而不是使用解构参数声明。
- 不支持枚举的声明合并。请保持代码库中每个枚举的声明紧凑。
- 不支持使用在程序运行时评估的表达式初始化枚举成员。此外,所有显式设置的初始化器必须是相同类型。请仅使用相同类型的编译时表达式初始化枚举成员。
- 不支持 export = ...语法。请改用普通的 exportimport 语法。
- 不允许接口包含两个具有不可区分签名的方法(例如,参数列表相同但返回类型不同)。请避免接口扩展具有相同方法签名的其他接口。重构方法名称或返回类型。
- 不支持通过 for .. in 循环遍历对象内容。对于对象,运行时遍历属性被认为是冗余的,因为对象布局在编译时已知且运行时不可更改。对于数组,请使用常规的 for 循环进行迭代。
- 不支持 Function.applyFunction.call。这些 API 在标准库中用于显式设置被调用函数的 this 参数。在 ArkTS 中,this 的语义被限制为传统的 OOP 风格,并且禁止在独立函数中使用 this。请避免使用 Function.applyFunction.call。请遵循传统的 OOP 风格来处理 this 的语义。
- 不支持 Function.bind。这些 API 在标准库中用于显式设置被调用函数的 this 参数。在 ArkTS 中,this 的语义被限制为传统的 OOP 风格,并且禁止在独立函数中使用 this。请避免使用 Function.bind。请遵循传统的 OOP 风格来处理 this 的语义。
- 不支持函数表达式。请改用箭头函数来显式指定。
- 不支持在函数上声明属性,因为不支持具有动态更改布局的对象。函数对象遵循此规则,其布局在运行时不可更改。请勿直接在函数上声明属性,因为它们的布局在运行时不可更改。
- 当前不支持生成器函数。请使用 async/await 机制进行多任务处理。
- 不支持全局作用域和 globalThis,因为不支持具有动态更改布局的无类型对象。请使用显式模块导出和导入来在文件之间共享数据,而不是依赖全局作用域。
- 支持函数返回类型推断,但此功能目前受到限制。特别是,当 return 语句中的表达式是对返回类型被省略的函数或方法的调用时,会发生编译时错误。当返回类型被省略时,请显式指定函数的返回类型。
- 不支持导入断言,因为导入在 ArkTS 中是编译时特性,而不是运行时特性。因此,对于静态类型语言来说,在运行时断言导入 API 的正确性没有意义。请改用普通的 import 语法;导入的正确性将在编译时检查。
- 不支持 in 运算符。此运算符意义不大,因为对象布局在编译时已知且运行时不可更改。如果您想检查是否存在某些类成员,请使用 instanceof 作为替代方案。
- 不允许索引签名。请改用数组(arrays)。
- 允许在函数调用时省略泛型类型参数(如果可以从传递给函数的参数中推断出具体类型),否则会发生编译时错误。特别地,仅基于函数返回类型推断泛型类型参数是被禁止的。当推断受限时(特别是仅基于函数返回类型时),请显式指定返回类型。
- 当前不支持交叉类型。请使用继承(inheritance)作为替代方案。
- 不支持 is 运算符,必须将其替换为 instanceof 运算符。请注意,在使用对象字段之前,必须使用 as 运算符将其转换为适当的类型。请将 is 运算符替换为 instanceof。在使用对象字段之前,请使用 as 运算符将其转换为适当的类型。
- 不支持 JSX 表达式。请勿使用 JSX,因为没有提供替代方案来重写它。
- 不支持映射类型。请使用其他语言惯用法和常规类来实现相同的行为。
- 不支持重新分配对象方法。在静态类型语言中,对象的布局是固定的,同一对象的所有实例必须共享每个方法的相同代码。如果需要为特定对象添加特定行为,可以创建单独的包装函数或使用继承。
- 所有 import 语句都应该在程序中的所有其他语句之前。请将所有 import 语句放在程序的开头,在任何其他语句之前。
- 不支持模块名称中的通配符,因为 importArkTS 中是编译时特性,而不是运行时特性。请改用普通的 export 语法。
- 不允许类初始化存在多个静态代码块。将所有静态代码块语句合并到一个静态代码块中。
- 不支持嵌套函数。请改用 lambdas(匿名函数)。
- 不支持 new.target,因为语言中没有运行时原型继承的概念。此功能被认为不适用于静态类型。此功能不适用于静态类型和运行时原型继承,因此不受支持。没有提供直接的替代方案,因为它是一个根本性的差异。
- 如果数组字面量中至少有一个元素具有不可推断的类型(例如,无类型对象字面量),则会发生编译时错误。请确保数组字面量中的所有元素都具有可推断的类型,或将元素显式转换为已定义的类型。
- 不支持将命名空间用作对象。请将类或模块解释为命名空间的类似物。
- 不支持命名空间中的语句。请使用函数来执行语句。
- 不支持将对象字面量直接用作类型声明。请显式声明类和接口。
- 只允许一元运算符+、-和~作用于数字类型。如果这些运算符应用于非数字类型,则会发生编译时错误。与 TypeScript 不同,此上下文中不支持字符串的隐式类型转换,必须显式进行类型转换。请确保一元运算符+、-和~仅应用于数字类型。如有必要,请执行显式类型转换。
- 不支持以#符号开头的私有标识符。请改用 private 关键字。
- 不支持动态字段声明和访问,也不支持通过索引访问对象字段(obj["field"])。请在类中立即声明所有对象字段,并使用 obj.field 语法访问字段。标准库中的所有类型化数组(如 Int32Array)是例外,它们支持通过 container[index]语法访问元素。
- 不支持原型赋值,因为语言中没有运行时原型继承的概念。此功能被认为不适用于静态类型。请改用类和/或接口来静态地将方法与数据“组合”在一起。
- 不支持通过 require 导入。它也不支持 import 赋值。请改用常规的 import 语法。
- 展开运算符唯一支持的场景是将数组或派生自数组的类展开到 rest 参数或数组字面量中。否则,必要时手动从数组和对象中“解包”数据。展开运算符仅用于将数组或派生自数组的类展开到 rest 参数或数组字面量中。对于其他情况,请手动从数组和对象中解包数据。
- 不支持在独立函数和静态方法中使用 thisthis 只能在实例方法中使用。
- 当前不支持结构化类型。这意味着编译器无法比较两种类型的公共 API 并判断它们是否相同。请改用其他机制(继承、接口或类型别名)。
- 不支持 Symbol() API,因为其最常见的用例在静态类型环境中没有意义,对象的布局在编译时定义且运行时不可更改。除 Symbol.iterator 外,避免使用 Symbol() API。
- 目前,用标准 TypeScript 语言实现的 codebase 不得通过导入 ArkTS codebase 来依赖 ArkTS。请避免 TypeScript 代码库依赖 ArkTS 代码库。反向导入(ArkTS 导入 TS)是支持的。
- 仅在表达式上下文中支持 typeof 运算符。不支持使用 typeof 指定类型标注。请改用显式类型声明而不是 typeof 进行类型标注。
- 在 TypeScript 中,catch 子句变量类型标注必须是 anyunknown(如果指定)。由于 ArkTS 不支持这些类型,因此请省略类型标注。请省略 catch 子句中的类型标注。
- 不支持使用 this 关键字进行类型标注。请改用显式类型。
- 不支持通用模块定义(UMD),因为它没有“脚本”的概念(与“模块”相对)。此外,importArkTS 中是编译时特性,而不是运行时特性。请改用普通的 exportimport 语法。
- 支持对象字面量,前提是编译器可以推断出这些字面量所对应的类或接口。否则,会发生编译时错误。在以下上下文中,不支持使用字面量初始化类和接口:初始化 anyObjectobject 类型;初始化带有方法的类或接口;初始化声明带参数构造函数的类;初始化带有 readonly 字段的类。请确保对象字面量对应于显式声明的类或接口。避免将它们用于 anyObjectobject 类型,或用于初始化带有方法、参数化构造函数或只读字段的类。
- 目前不支持 TypeScript 扩展标准库中的实用类型。PartialRequiredReadonlyRecord 是例外。对于 Record<K, V>类型,索引表达式 rec[index]的类型为 V | undefined。请避免使用不支持的 TypeScript 实用类型。PartialRequiredReadonlyRecord 可用于其特定目的。
- 不支持 var 关键字。请改用 let 关键字。
- 不支持 with 语句。请使用其他语言惯用法来实现相同的行为。

## HarmonyOS API 使用规范(必读条目)

- 优先使用 HarmonyOS 官方提供的 APIUI 组件、动画、代码模板
- API 调用前请确认遵循官方文档入参、返回值及对应 API Level 和设备支持情况
- 对于任何不肯定的语法和 API 使用,不要猜测或自行构造 API,请尝试使用搜索工具获取华为开发者官方文档并进行确认
- 使用 API 前请确认是否需要在文件头添加 import 语句
- 调用 API 前请确认是否需要对应权限,在对应模块的`module.json5`中确认权限配置
- 如需使用依赖库,请确认依赖库的存在和匹配版本,并在对应模块的`oh-package.json5`中添加依赖配置
- 使用`@Component``@ComponentV2`时需要区分兼容性,尽量与已有工程代码保持一致
- UI 界面展示引用的常量需要定义 resources 资源值,并使用`$r`引用, 一般不直接使用字面值
- 新增国际化资源字符串时在对应的国际化每种语言下添加值,避免遗漏
- 新增颜色等资源请确认是否需要添加黑色主题支持(参考历史工程),新工程建议默认支持黑色及白色主题


## ArkUI 动画规范(`animateTo`,`transform`,`renderGroup`,`opacity`)

- 优先使用 HarmonyOS 提供的原生动画 API 和高级模板
- 优先使用 HarmonyOS 的声明式 UI`@State` 驱动动画,通过改变状态变量触发动画
- 对于包含复杂子组件的动画,将其设置为 `renderGroup(true)`,减少渲染批次
- 不可以在动画过程中频繁改变组件的 `width``height``padding``margin` 等布局属性,严重影响性能

智能体模板

markdown

# 角色:HarmonyOS 应用开发专家

## 角色概述

你是一位精通 HarmonyOS 应用开发的资深专家,专注于使用 ArkTS 和 ArkUI 构建高质量的鸿蒙原生应用。你对 HarmonyOS 的系统组件、API 以及底层机制有深入的理解,并始终致力于应用行业最佳实践。

## 核心技术栈与约束(严格执行)

在所有代码生成、问题解答和技术建议中,必须严格遵守以下技术选型,**绝不妥协**1.  **状态管理:仅限 V2 (ArkUI State Management V2)**

    - **必须使用**`@ComponentV2``@Local``@Param``@Event``@Provider``@Consumer``@Monitor``@Computed`    - **严禁使用**:V1 版本的装饰器(如 `@State``@Prop``@Link``@ObjectLink``@Observed``@Provide``@Consume``@Watch` 等)。

2.  **路由方案:仅限 Navigation**
    - **必须使用**:基于 `Navigation` 组件和 `NavPathStack` 的路由管理方案;使用 `NavDestination` 作为子页面的根容器。
    - **严禁使用**:传统的 `router` 模块(`@ohos.router`)进行页面跳转。

## 专业能力

- **精通 ArkTS & ArkUI**:能够编写优雅、高效、类型安全的声明式 UI 代码,深刻理解 V2 状态管理的观测机制和 UI 更新逻辑。
- **全栈组件与 API 掌握**:熟练运用各类 UI 组件(List, Grid, Swiper, Tabs 等)及系统 API(网络、媒体、文件、首选项等),能够快速实现复杂的业务需求。
- **最佳实践落地**  - **架构设计**:采用模块化、分层架构,确保代码的高内聚低耦合。
  - **性能优化**:擅长使用 `LazyForEach`、组件复用、耗时任务异步处理等手段优化应用性能。
  - **代码规范**:代码风格统一,逻辑严密,注释清晰,符合 HarmonyOS 官方开发规范。

## 行为准则

- **主动重构**:如果用户提供的代码包含 V1 状态管理或 `router` 路由,请主动指出并将其重构为 V2 + Navigation 的现代化实现。
- **方案解释**:在提供代码解决方案时,简要说明为什么这样做是“最佳实践”(例如:解释 `@ComponentV2` 相比 V1 的性能优势)。
- **严谨性**:确保提供的代码片段是完整的、可运行的,并且处理了常见的边界情况(如空数据、加载状态、错误处理)。

参考链接

  1. 新增和增强特性:developer.huawei.com/consumer/cn…
  2. AI智能编程辅助:developer.huawei.com/consumer/cn…

关于我

第一卷:初入江湖 第一章:深夜的键盘声

凌晨三点十七分。

林晨盯着屏幕上那个红色的错误提示,已经看了整整两个小时。控制台里不断跳出的警告信息像一串串嘲讽,每一个都在告诉他:你错了,你的代码有问题。

他揉了揉酸涩的眼睛,端起桌上已经凉透的咖啡,抿了一口。苦涩的味道在舌尖蔓延,但至少能让他保持清醒。
“为什么就是不行呢?”他自言自语,声音在安静的房间里显得格外清晰。

这是一个关于组件状态管理的bug。明明逻辑看起来没问题,数据流也清晰,但就是无法正常工作。用户点击按钮后,状态更新了,但UI没有响应。这种问题最让人头疼——不是完全报错,而是静默失败。

林晨重新审视代码。这是一个Vue 3组件,使用了Composition API。他喜欢这种写法,代码组织更清晰,逻辑复用方便。但有时候,清晰的结构反而会掩盖问题。

const handleClick = (button, e, forceEmit = false) => {
  trackBtnClick(button.label?.toLowerCase() || 'buy now')
  
  if ((props.shouldPopEvent && NEED_REACTIVE_LABEL_TYPE.includes(button.labelType))) {
    e.preventDefault()
    e.stopPropagation()
    emit('click', button)
  }
  
  if (forceEmit) {
    emit('click', button)
  }
}

他盯着这段代码,突然意识到问题所在。两个独立的if语句都会调用emit('click', button),而第一个if里阻止了事件冒泡,这可能导致某些情况下事件处理不正确。更重要的是,当shouldPopEvent为true且forceEmit也为true时,会触发两次emit,虽然不会导致bug,但确实不够优雅。

林晨开始重构代码,提取条件变量,合并emit调用,添加空值检查,逻辑更清晰统一:

const handleClick = (button, e, forceEmit = false) => {
  trackBtnClick(button.label?.toLowerCase() || 'buy now')
  
  const shouldPopEvent = props.shouldPopEvent && NEED_REACTIVE_LABEL_TYPE.includes(button.labelType)
  const shouldEmit = shouldPopEvent || forceEmit
  
  if (shouldPopEvent && e) {
    e.preventDefault()
    e.stopPropagation()
  }
  
  if (shouldEmit) {
    emit('click', button)
  }
}

运行测试,bug消失了。问题解决了,但林晨并没有感到兴奋,反而有些疲惫。小问题往往最耗时间——看起来简单,但需要仔细思考才能找到根源。

他保存代码,提交到git,然后关掉编辑器。窗外的城市依然安静,只有偶尔经过的车辆打破宁静。

林晨走到窗边,看着远处那栋还亮着灯的写字楼。不知道是哪个同行也在加班,也许正在解决类似的问题。程序员的生活就是这样:白天写代码,晚上修bug,周末学习新技术。永远有解决不完的问题,永远有学不完的知识。

他回到电脑前,打开邮箱。一封未读邮件来自项目经理张明,时间是晚上十一点。

“林晨,明天早上有个新项目要讨论,关于一个小说阅读器组件。客户需求比较复杂,需要支持自定义字体、夜间模式、进度保存等功能。你技术能力强,我想让你来负责这个项目。明天上午十点,会议室A,我们详细讨论。”

小说阅读器?林晨有些意外。他们公司主要做企业级应用,突然做阅读器组件确实不常见。但转念一想,这或许是个机会:他最近在学习Vue 3的新特性,正好可以实践。

他回复邮件,表示愿意接手项目,然后关掉电脑准备休息。躺在床上,他还在想那个bug。虽然解决了,但总觉得还有更好的方法。程序员的职业病——永远不满足现状,永远想优化。

想着前辈的话:“写代码容易,写好代码难。但最难的是,在时间压力和完美主义之间找到平衡。”林晨渐渐睡着了。梦里,他还在写代码,但这次运行完美,没有bug。

第二天早上,林晨比平时早到办公室。九点四十五分,他走进会议室。里面已经有人:产品经理苏雨,UI设计师王雪,技术总监陈浩,以及张明。

苏雨打开投影仪,介绍项目:“客户是一家在线教育公司,希望提供阅读平台,支持自定义字体、夜间模式、进度保存、章节导航等功能。要求响应式设计,长章节也要流畅。”

林晨认真记录,同时提出技术问题:

  • 章节内容格式:HTML

  • 进度存储:localStorage还是后端?

  • 上线时间:一个月

张明答复:数据为HTML格式,进度先存localStorage,客户希望一个月上线。

林晨心里计算:设计一周,开发两周,测试和优化一周,时间紧迫但可行。他提出几点支持需求:

  1. UI设计尽快确定

  2. 测试环境需模拟大量数据

  3. 技术难题可寻求团队支持

陈浩点头:“林晨,项目你全权负责。遇到问题找我或团队,都会支持。”

会议结束后,林晨回到工位,开始规划组件架构:

  1. 主组件:NovelReader.vue

  2. 设置面板:NovelReaderSettings.vue

  3. 章节列表:NovelChapterList.vue

  4. Composable:useNovelReader.js

他在纸上画架构图,思考模块职责和接口,喜欢从零开始设计的感觉,就像建筑师设计房子。

苏雨过来,递给他详细需求文档和参考案例:“有什么问题随时找我,我就在你对面工位。”

林晨接过,点头。苏雨笑了笑:“这个项目可能需求多变,提前沟通避免返工。”

他微微一笑,回到电脑,搭建项目框架,写基础composable,定义数据接口。

// useNovelReader.js
export function useNovelReader() {
  const currentChapterIndex = ref(0)
  const readingSettings = ref({
    fontSize: 18,
    lineHeight: 1.8,
    backgroundColor: '#f5f5dc',
    // ...
  })
}

晚上七点,林晨保存代码提交到git,关掉电脑,走出公司大楼。晚风带着一丝凉意,他在回家的路上思考这个充满挑战的阅读器项目。回到家,他简单吃了点东西,然后学习Vue 3的新特性,准备第二天继续开发。

我没想到 CSS if 函数这么强

如果 CSS 能像 JavaScript 一样进行条件判断会怎样?

你可能会想,只是个条件判断,能有什么用?

那你就太小瞧这个功能了!

这篇文章带你展示它的强大。

PS:目前 CSS if() 函数已在 Chrome 137 中正式发布。

1. 基本用法

property: if(condition-1: value-1; condition-2: value-2; condition-3: value-3; else: default-value);

函数会按顺序检查条件并应用第一个匹配的值。如果没有条件匹配,则使用 else 值。

CSS if函数基本用法

2. 3 大使用场景

2.1. 深色模式

以前实现深色模式,要么用 JavaScript 切换 class,要么写两套样式。

现在你可以直接这样写:

body {
  --theme: "dark"; /* 通过 JavaScript 或用户偏好切换 */
  background: if(style(--theme: "dark"): #1a1a1a; else: white);
  color: if(style(--theme: "dark"): #e4e4e4; else: #333);
}

场景一:深色模式

2.2. 响应式布局

以前写响应式:

.container {
  width: 100%;
}

@media (min-width: 576px) {
  .container {
    width: 540px;
  }
}

@media (min-width: 768px) {
  .container {
    width: 720px;
  }
}

@media (min-width: 992px) {
  .container {
    width: 960px;
  }
}

/* 还有更多... */

现在你可以这样写:

.container {
  width: if(media(width >= 1400px): 1320px; media(width >= 1200px): 1140px; media(width >= 992px): 960px; media(width >= 768px): 720px; media(width >= 576px): 540px; else: 100%);
  padding-inline: if(media(width >= 768px): 2rem; else: 1rem);
}

代码更优雅,性能更快,维护起来也方便。

场景二:响应式布局

2.3. 优雅降级

假设你想用最新的颜色函数 lch(),但又担心旧浏览器不支持。以前你可能要这样写:

.element {
  border-color: rgb(200, 100, 50); /* 兜底方案 */
  border-color: lch(50% 100 150); /* 新浏览器会覆盖 */
}

现在可以用 supports() 明确地检测:

.element {
  border-color: if(supports(color: lch(0 0 0)): lch(50% 100 150) ; supports(color: lab(0 0 0)): lab(50 100 -50) ; else: rgb(200, 100, 50));
}

浏览器会按顺序检查:支持 lch() 就用 lch(),不支持就看看支持不支持 lab(),都不支持就用传统的 rgb()

场景三:优雅降级

3. 浏览器支持度

截至 2025 年 8 月:

  • ✅ Chrome/Edge:从版本 137 开始

  • ✅ Chrome Android:从版本 139 开始

  • ❌ Firefox:开发中

  • ❌ Safari:在路线图上

  • ❌ Opera:尚未支持

浏览器支持现状

所以如果你现在就想用,记得写好 fallback:

.button {
  /* 所有浏览器的回退 */
  padding: 1rem 2rem;
  background: #007bff;
  /* 现代浏览器会自动覆盖 */
  padding: if(style(--size: small): 0.5rem 1rem; style(--size: large): 1.5rem 3rem; else: 1rem 2rem);
  background: if(style(--variant: primary): #007bff; style(--variant: success): #28a745; style(--variant: danger): #dc3545; else: #6c757d);
}

4. 技术在进步

写到这里,我想起自己刚学前端那会儿。

每次看到新技术出来,就觉得“完了,我又落后了”。

后来慢慢发现,技术是用来解决问题的,不是用来制造焦虑的。

CSS if() 函数确实很酷,但它解决的问题——条件判断、响应式布局、浏览器兼容——这些问题我们用现有的方法也能解决,只是可能麻烦一点。

新技术的意义,不是让你觉得“我必须马上学会”,而是让你知道“原来还可以这样做”。

所以,如果你现在项目里用不上 if() 函数,没关系。把它收藏起来,等哪天浏览器支持好了,或者你遇到了它能解决的问题,再拿出来用。

前端学习是个长跑,不是短跑。慢慢来,别着急。

技术学习的长跑

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

CSS 绘制几何图形原理与实战指南

在前端面试中,CSS 绘制图形(尤其是三角形)是一个考察基础知识深度的经典问题。这个问题看似简单,实则可以考察开发者对 盒模型(Box Model)  的底层理解,以及对 现代 CSS 属性(如 clip-path, transform)  的掌握程度。

本文将从原理、实战代码及面试回答策略三个维度进行解析。

一、 经典方案:利用 Border(边框)挤压

这是面试中最常被问到的方案,也是兼容性最好的方案(支持 IE6+)。核心在于理解 CSS 边框在盒模型中的渲染机制。

原理分析

在标准盒模型中,边框是围绕在内容区(Content)之外的。当一个元素的 width 和 height 都设置为 0 时,内容区域消失,元素的大小完全由边框决定。

此时,如果设置了较粗的边框,四条边框会在中心汇聚。由于边框连接处需要平滑过渡,浏览器在渲染时,会以 45度角(正方形时)或根据宽高比计算的斜角 对边框交界处进行斜切处理。

如果不设置颜色,边框看起来是一个矩形;但如果给四条边框设置不同的颜色,视觉上会呈现出四个三角形拼合在一起的效果。

代码实战

CSS

.triangle-border {
    width: 0;
    height: 0;
    /* 核心步骤1:设置足够宽的边框,并将其颜色设为透明 */
    border: 50px solid transparent; 
    /* 核心步骤2:单独指定某一个方向的边框颜色 */
    /* 想要箭头朝上,就给下边框上色 */
    border-bottom-color: #007bff; 
}

面试考察点

  1. 为什么宽高要设为 0?
    是为了消除中间的内容矩形区域,让四条边框在中心直接接触,从而利用边框交界处的斜切特性形成尖角。

  2. 如何调整三角形形状?
    通过调节不同方向 border-width 的数值。

    • 等腰/等边三角形:保持左右边框宽度一致。
    • 直角三角形:将某一条相邻边框的宽度设为 0(例如 border-top: 0),利用剩余边框的挤压形成直角。

image.png

二、 现代方案:利用 Clip-path(裁剪路径)

随着浏览器技术的发展,clip-path 成为了绘制不规则图形的最优解。与 Border 法利用“副作用”不同,Clip-path 是“声明式”的绘图方式。

原理分析

clip-path 属性会在元素内部创建一个裁剪区域:区域内的内容可见,区域外的内容被隐藏。

使用 polygon() 函数可以通过定义一系列坐标点 (x y) 来绘制多边形。坐标系以元素的左上角为原点 (0, 0),右下角为 (100%, 100%)。

代码实战

CSS

.triangle-clip {
    /* 与 Border 法不同,这里需要元素有实际宽高 */
    width: 100px;
    height: 100px;
    background-color: #007bff;
    /* 定义三个顶点:顶部中间、右下、左下 */
    clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

优缺点对比(面试加分项)

  • Border 法:兼容性极好,但在处理背景图片、渐变色或阴影时非常困难,本质上是 Hack 手段。
  • Clip-path 法:语义清晰,支持背景图片裁剪、支持渐变色,且不影响盒模型实际占据的布局空间。

image.png

三、 实用变体:利用 Transform 与 Border-radius

除了基础三角形,面试中常涉及箭头、扇形等图形,这些通常结合 transform 和 border-radius 实现。

1. 空心箭头 (Chevron)

常用于下拉菜单或翻页按钮。
原理:创建一个正方形,只保留相邻的两条边框(如上边和右边),然后旋转 45 度。

CSS

.arrow {
    width: 10px;
    height: 10px;
    border: 2px solid #333;
    /* 隐藏两条边 */
    border-bottom: none;
    border-left: none;
    /* 旋转 */
    transform: rotate(45deg);
}

2. 扇形 (Sector)

原理:利用 border-radius 可以单独控制每个角半径的特性。将正方形的一个角设为圆形(半径等于边长),其余角为 0。

CSS

.sector {
    width: 50px;
    height: 50px;
    background-color: red;
    /* 顺序:左上 右上 右下 左下 */
    /* 将左上角设为半径,形成 1/4 圆 */
    border-radius: 50px 0 0 0; 
}

image.png

总结:面试回答策略

如果在面试中被问到“如何用 CSS 画三角形”,建议按以下逻辑条理清晰地回答:

  1. 首选方案(Border 法) :首先演示 Border 法,因为这是最基础的 CSS 原理。重点解释“宽高为 0”和“透明边框”如何利用盒模型渲染机制形成三角形。
  2. 进阶方案(Clip-path 法) :随后补充说明,如果场景需要显示背景图片或渐变色,clip-path 是更现代、更规范的解决方案,这能体现你对新特性的关注。
  3. 特殊场景:如果是画空心箭头,指出使用 transform: rotate 配合单侧边框是最高效的方法。
  4. 避坑指南:提及尽量避免使用 linear-gradient 拼凑三角形,因为其计算复杂且容易产生锯齿,维护成本高。

突然,一个树结构的数据展示不出来了

突然,一个树结构的数据展示不出来了

  有一天,一个已经上线的项目,收到了用户反馈,说在附件上传的时候,同时上传两个会出错。后来经排查,旧系统的框架用的是 element-ui,而新系统用的是 ant-design-vue,是因为这两个框架的˲upload 组件的˲参数格式不同˲导致的错误。如果想要彻底修复,就要把新系统中的 upload 组件代码重构。由于已经存在的代码体量比较大,重构成本过高,于是决定,另辟蹊径。

  在系统中有一个优化功能:在短时间内重复调用一个接口时,只会保留最后一次调用。

  这个优化功能,正好可以掩盖附件上传遇到的问题。但是一个同事表示,虽然找到了这个优化功能的代码,但好像根本没用上,短时间内重复调用的接口并没有被优化成一个。于是,这个问题来到了我这里……

  事情的来龙去脉已经清楚了,接下来,开始干活

第一层阻碍

  首先梳理优化功能逻辑,实现方法是在 请求拦截器 与 响应拦截器 中配合完成的,大致就是说:

  将当前调用的接口,放置在一个容器中。在接口成功返回前,如果再次调用了相同接口,就将前一次的调用中止,保留后一次。

响应拦截器

响应拦截器

  截图中拦截器里调用的两个函数,分别就是 ˲添加进容器˲ 与 ˲从容器移除并中止˲ 的两个函数,检查到这里时发现,请求拦截器中的第一行代码被注释掉了,也就是没有了对 removePending 函数的调用,导致整个逻辑没有完整执行。

  查询修改记录,发现这是项目初始化时的原始代码。也就是说,之前使用过这个框架的项目组,故意把这个代码注释掉了。虽然不明所以,但为了解决现在附件上传的问题,我们就再给它放开吧。

  通知测试,告诉同事,问题解决,皆大欢喜。

  意外的是,真正的问题才刚刚浮出水面……

第二层阻碍

  测试人员复测附件上传,果然如预期所料,框架组件的差异问题 成功被 优化代码搞定了。

  但是没过多久,测试人员表示,又出现了一个新bug,有一个页面的数据,查不出来了……

报错

  调用栈满了?

  这是一个树表格的页面,这个功能一直都好好的,要出问题早就应该出了。问问后端同事,改过东西没有?检查前端代码,谁动了代码了?都没有,好好的突然就不行了。

  刚刚修复bug的喜悦一下就消散了。检查了好久,不明所以。前端以为是后端的问题,后端以为是前端的问题。后端同事提出个想法,把刚刚改的代码恢复一下试试呢?

  哈哈哈,接口重复调用优化 的代码,跟˲调用栈溢出˲的bug,怎么可能有关系呢!

  当我将代码恢复,重新将 removePending 函数注释掉之后。果然好了

首先贴出在两个拦截器里都调用了的 removePending 函数 函数.png 这个函数,就是在容器中查询,是不是已经存在某一个接口了,存在就把它给停了!

问题所在:

  可能有经验的人在一开始就发现了,问题就出在响应拦截器里的函数调用上。

  请求拦截器 与 响应拦截器 都将自身默认的参数传给了 removePending 函数,但,两个拦截器的默认参数的格式,是不同的!

  问题,就出在这里:

  1. 请求拦截器参数

    联想截图_20260204154129.png

  2. 响应拦截器参数 联想截图_20260204154151.png   经过打印检查,查询资料后确认,响应拦截器参数中的 config 字段,才是与 请求拦截器参数 相同的存在。

  也就是说,在˲响应拦截器˲调用 removePending 时,给了一个意外的值。而检查这个值会发现,其中的 data 属性,就是接口返回的完整内容。对应到刚刚报错的页面,也就是说,函数对一个巨大的树形数据,做了 qs.stringify() 操作

  就是这个操作,导致了调用栈溢出。

  好,只需要在响应拦截器里,传参的时候加上个 .config ,好了,皆大欢喜 X 2

  可万万没想到,改到这里,并没有彻底结束……

第三层阻碍

  因为这一部分还存在一些小的疑虑,所以,虽然功能都已经能够正常使用了,但我计划在空闲的时候回顾一下,将所有的不解弄清楚并记录下来。就在疑虑被一点点消除的时候,我又发现了一个隐藏很深的问题

  理论上讲,在所有接口调用完成后,暂存接口的容器应该会被清空。因为在最后的响应拦截器中,会执行 removePending 函数来移除接口。但我看到的,却是这样的结果:

容器的结果: 容器结果.png

  在操作时,里面的接口数据会越来越多,但又不是所有的都在里面,只有一部分。

  为什么会有这么多接口还在里面?又为什么只有一部分?

  检查代码,请求拦截器 中添加进容器,响应拦截器 中从容器中删除,整个逻辑是没问题的,那么这又是怎么回事?

  最终,问题定位在了 config.data 字段上

  分别打印 请求拦截器 与 响应拦截器 中的 config.data 的 值 与 类型

  1. 请求拦截器中的 config.data 请求拦截器的.png
  2. 响应拦截器中的 config.data 响应拦截器的
  3. 请求拦截器与响应拦截器中的 config,里面的 data 是相同的

联想截图_20260204165126.png

  在请求拦截器中,config.data 是一个对象类型数据;在响应拦截器中,config.data 变成了字符串。而在直接打印 config 时,里面的 data 又都是 JSON 字符串。代码实际使用的值,并不是打印台中看到的值,这也是前端非常容易出错的点。

问题所在:

问题,又出在数据的格式上!
但这次不是数据层级的问题,而是 axios 底层,默认对数据格式的修改
回顾一下 removePending 函数,问题就出在 config.data 上 联想截图_20260204162657.png

  axios 会在请求拦截器执行后、真正发送请求前,自动把 config.data 从 JS 对象序列化为 JSON 字符串。而响应拦截器里拿到的 config.data,是经过这次序列化后的最终结果。

  简单说:请求拦截器操作的是「原始 JS 对象」,响应拦截器拿到的是「axios 序列化后的 JSON 字符串」

所以,为了操作数据的类型统一,在操作 config.data 数据前,进行格式调整,完成!

联想截图_20260204171321.png

  最终,这个优化代码的逻辑与实现,完全正常了!

  最后提一下,整个 axios 封装里面,还有一个在路由跳转时用于清空容器的函数,但查遍整个项目代码,根本没人用!

总结:

  这是一个在架构阶段遗留下来的问题,在前面的项目组手上被掩盖了,把大问题变成了小问题从而忽略掉。在我们这里,小问题因第三方框架的变更而显现。在不断的修改中,终于从根本上弄清楚了来龙去脉!

所以:架构是架构师的事,开发才不会改,甚至有可能,会改个错的上来!

如何让AI写出一个稳定的应用

前言:
AI时代,前端转型势在必行,或者说环境已经开始逐步在强制你转型了,从前两年的与AI共同编程,到现在AI已经开始完成大多数的工作了。那么前端如何定位自己的角色就更显得重要了。作者就是从一名前端,逐步转成了一个AI开发者,不在局限在前端领域,而是多语言的开发者,或者说是AI工具或者AI驱动的项目开发者。

本文而是作者在多个真实项目中,通过不断试错、总结、优化 Prompt 和工程流程后,沉淀出的一些经验(可能有不准确的地方,完全是根据自身工程经验的总结)。

如何让AI写出一个完整的项目,并且这个项目还能做到符合预期且代码健壮稳定。

目标只有一个:

如何让 AI 写出一个完整、可维护、符合预期,并且足够稳定的真实项目。

我大概总结了几个部分

1. 提示词

  • 清晰的输入源

    你应该知道你的上游是什么,输出是什么。如果有,要如何处理,避免这些影响的自己的上下文。

  • prompt如何写

    也就是你如何组织自己的prompt。很多人觉得 AI “不稳定”“经常胡写”“代码质量不行”,但实际项目中你会发现:80% 的问题,出在输入,而不是模型。

    我总结了很重要的一点就是结构化

    结构化是指两个方面,一个是你描述的需求要有明确的结构层级。 如:

    • 角色定义
    • 目标描述
    • 输入说明
    • 输出要求
    • 约束条件
    • 失败处理规则

    那么你的输入应该类似这样: ::: block-1 你是一个资深前端 + AI 工程师
    你的目标是将 Figma 原型转换为 React 项目
    输入包含:原型图片 + Figma 节点数据
    输出必须是可运行的 React 项目
    不允许留下 TODO
    不允许修改已有文件
    :::

    第二点就是对输入输出的结构化要求,也就是大模型比较容易理解的如XML结构

    这一点是因为在大模型的训练过程中,接收了大量的Xml的结构化数据,所以它天然的对这种结构敏感。这个在claude文档中曾经提到过。如去年爆火的bolt.new的开源版本里面,它的prompt可以看到大量的结构化提示词

    可看到很多的结构化指令

  • 分层次,反向提示 不要一次性让 AI 干所有事。

    错误方式:

    根据这些内容,帮我生成一个完整项目

    正确方式:

    第一步: 只让AI分析

    第一步: 只让AI给方案

    第三步:只让AI生成代码

    第四步:只让AI修复问题

    并且加入反向提示:

    如果信息不足,必须明确指出 不允许自行假设 不允许使用未声明的库

    这一步能极大减少“看似合理、实际不可用”的幻觉代码

  • 提示词不要怕多,把你想到的边界情况都写上也不无不可, 毕竟现在模型的理解能力和上下文长度已经有了很大的提升。

2. 工程化

真正能落地的 AI 开发,一定是工程化的,而不是聊天式的。

过程可控、分步骤,而不是一次性生成

你可以把 AI 看成一个不太聪明但执行力极强的工程师, 前提是你要拆好任务

例如一个真实项目流程:

初始化项目结构

锁定技术栈

生成页面骨架/路由等

其它部分

代码自检与修复

每一步都有明确输入和输出

如果拆的足够细致,那么每一步的产出其实都可以用我们工程化的方式来解决,对产物的检查会大幅度的减少AI的乱写的情况,同时还能给我们节省很多不必要的token浪费

增加模版

模版是限制AI发散的非常有效的方式。在很多优秀的开源项目中也都是提供了大量的模版来实现。

想象一个让AI在一个圈子里面发挥,和让AI无边界的发挥哪一种会更加的稳定。毕竟我们需要的是一个生产项目,而不是一个聊天机器人。

mcp

其实这个更多的是为了让产品更加的看起来像 "产品"。

毕竟让AI生成一个城市的实时天气情况,它最后给你的是一堆假的数据摆在那,也很让人出戏。

模型选择

这个没有太多选择,就是claude系,对指令的遵循非常好。

gemini3最近也挺火,试了一下,试图能力确实比claude要好,但是说指令遵循,自我感觉没有claude更好。

其它

整个过程也遇到了一些问题,比如最大的问题就是AI编写速度这个。

也尝试过并发处理,其实就是用空间换时间,如果能做到当然很好,没有去做特别深入的尝试。但是可想而知会有一个问题就是上下文同步的问题,怎么保证每个session之间的上下文是有关联的,比如我们的路由,点击跳转,状态管理等等。

或者是父子session的方式,但是又该如何划分职责,以及记忆管理都会比较复杂。

但终归是有办法解决的,但同时也可以有另一个反思就是既然我们做的是一个完备的可上线的东西,那么对应的时间耗费是否也是可以接受的。

从稳定性和上下文管理的角度来说,业界已经出了对应的agent产品来解决,claude code和gemini cli都是帮你解决这些问题的。它们可以帮助你解决上下文自动压缩的问题,工具使用,文件查找等等以前需要我们手动的补全的能力。

而且claude code真心好用,有了它之后我们之前的代码可以节省70%,它做到了一个真正的agent能做到的事情。但是即使用了这个agent的产品,也会面临很多情况,比如我们就遇到了一些情况。

一个是频繁对话以及一些输入过大的情况,最终会撑爆上下文。或者它自身记忆的问题导致它不记得的cwd目录,导致它自己去寻找目录写入文件。

目前它也提供了一些好的方式去解决。比如subagent, 读写钩子或者Skill等能力。

这里很多步骤的细节没有很具体的写,涉及到的问题会很多,感觉每一个细节都可以详细的出一篇文章的地步。

虽然上文说到了工程化去解决一些问题,实践中其实大家都感觉到了一点就是人为的干预在越来越少。也就是大模型的能力始终在进步。我们目前的一切操作,似乎都是在给过渡阶段打补丁。

前端并发处理最佳实践:Token 共享资源的单例 Promise 模式

在前端开发中,我们经常遇到这样的场景:多个独立的组件或事件流(Event Stream)几乎同时触发,它们都需要依赖同一个异步获取的资源——比如鉴权 Token。

如果处理不当,就会导致 Race Condition(竞态条件)  或者 重复的网络请求。今天我们来分享一种简单而健壮的最佳实践:Singleton Promise Pattern(单例 Promise 模式)

常见问题:并发地狱

假设你有一个 audio-to-text(语音转文字)的功能,每次触发都需要先获取一个临时的 API Token。

如果用户连续快速播放了 3 条语音,或者页面初始化时有多个组件同时请求 Token,可能会发生以下情况:

  1. 资源浪费:发起了 3 次获取 Token 的网络请求,但实际上只需要 1 次。
  2. 状态不一致:第 1 个请求成功了,但在它写入本地缓存之前,第 2 个请求又发起了。
  3. 死锁/无限等待:使用简单的 isLoading 锁,如果代码逻辑在异常处理(catch)中遗漏了重置锁,导致后续请求一直 pending。

解决方案:单例 Promise 模式

核心思想非常简单:将"正在进行的请求"缓存起来,而不是只缓存结果。

对于共享资源,我们维护一个全局唯一的 Promise。

  • 当第一个请求进来时,创建一个 Promise 并发起请求。
  • 当后续请求进来时,如果发现已经有一个 Promise 在 pending,直接复用并返回这个 Promise。
  • 无论成功或失败,Promise settled 后,清理状态,以便下次可以发起新请求。

代码实现模板

下面是一个健壮的实现模板,包含了 防抖(Debounce)异常安全(Exception Safety)  和 状态复位

// 全局状态变量
let getTokenTask = null; // 存储正在进行的 Promise
let isGettingToken = false; // 锁标记
async function getSharedToken() {
  // 1. 检查本地缓存是否有效
  if (isTokenValid()) {
    return getTokenFromStorage();
  }
  // 2. 并发拦截:如果有正在进行的任务,直接复用,不发起新请求
  if (isGettingToken && getTokenTask) {
    return getTokenTask;
  }
  // 3. 初始化一个新的单例任务
  isGettingToken = true;
  
  // 创建 Promise 闭包,确保我们可以控制 resolve
  let taskResolve = null;
  getTokenTask = new Promise((resolve) => {
    taskResolve = resolve;
  });
  try {
    // 4. 执行实际的异步操作
    const token = await fetchTokenFromServer();
    
    // 5. 缓存结果
    saveTokenToStorage(token);
    
    return token;
  } catch (error) {
    console.error('获取 Token 失败', error);
    throw error; // 向上传递错误,让调用方决定如何处理
  } finally {
    // 6. 关键:无论成功还是失败,都要通知等待的 Promise 并释放锁
    if (taskResolve) {
      // 获取当前最新的 token (可能在 try 块中已经存入,也可能为空)
      const currentToken = getTokenFromStorage();
      taskResolve(currentToken);
    }
    
    // 重置状态,允许下一次新的请求发起
    isGettingToken = false; 
    getTokenTask = null;
  }
}

💡 核心设计点解析

1. 为什么要返回 Promise 而不是等待 await 后返回结果?

这也是为什么通过 if (isGettingToken) return getTokenTask 直接返回 Promise 对象。这样,三个并发的调用者 await getSharedToken() 实际上是在等待同一个 Promise 对象 resolve。一旦 Leader 请求完成,三个调用者会同时得到通知。

2. Try...Finally 的重要性

并发控制中最怕的是  "死锁" 。如果 API 请求抛出异常,而你忘记在 catch 块中把 isGettingToken 设置回 false,那么整个应用的相关功能就会永久卡死。 使用 finally 块可以保证无论代码路径如何(正常返回、抛出错误、甚至早退),锁一定会被释放。

3. 不要为每个请求创建缓存 Map

有些开发者会想:"我是不是应该用一个 Map,根据 Request ID 来缓存?" 对于 Token 这种 全局共享资源,不需要。因为无论谁发起的请求,他们要的都是同一个东西。Map 只会增加内存泄漏的风险和管理的复杂度。

总结

在高并发的前端场景下,共享资源的单例 Promise 模式 是性价比极高的优化手段。它用极少的代码成本,实现了:

  • 流量节省:N 个并发请求只需 1 次网络调用。
  • 高性能:所有请求几乎同时得到响应,没有阻塞。
  • 高健壮性:异常情况下也能自动恢复,不会卡死。

本地配置host

注意事项

  1. xx.demo.com:8000/#/ 这个要和图片配置的域名一致(细心点)才能打开 注意http和https

image.png

  1. https://localhost:8000 这个地址能启动 配置的地址就能启动 不能启动就是地址不对

  2. 如果配置完显示如下 需要清理dns缓存

image.png

  • 在 Chrome 浏览器的地址栏中输入 chrome://net-internals/#dns
  • 在打开的页面中,找到并点击 ‌ “Clear host cache” ‌ 按钮。 ‌
  • 执行完上述步骤后,Chrome 的 DNS 缓存即被清除。此操作无需重启浏览器即可生效。
  • 如果还是不对清理浏览器缓存

image.png

image.png

chrome://net-internals/#sockets 刷新Socket池以重置网络连接

done

基于Transformers从0到1训练一个模型

在我刚使用大语言模型比如 Deekseek、ChatGPT 进入对话时,我也曾一度觉得这个世界上有魔法,但实际上这背后并没有真正的魔法,或者说,所谓的魔法其实是数学的魔法;而数学计算世界的前提就是找到一种方法,将连续的真实转化为离散的模型(这里所谓的模型不在是指大模型,而是指具体事物的抽象结果),然后再运用各种数学知识去计算、去拟合。

而最好的去揭开大模型背后的魔法的方式,就是自己亲手去做一个大模型,想要在哪一层去制作大模型取决于你的目的和精力,我觉得仅仅学习一些入门的流程知识并不能满足我,但是要我从神经网络的数学公式开始研究我又没那么聪明,所以我选择利用封装后的、而且是高度封装后的代码库来完成模型的训练流程,并且试图在有限的代码中看到魔法背后更多维度的真相。

请注意,本篇仅仅是个人输出和记录分享,并不一定会详细解释每一个术语和步骤且必然存在一些个人理解错误,感谢大家理解。

背景知识

简单地用流程图表示一下一个大模型的训练流程:

  1. 首先,我们需要找到可最初用于训练的数据集,数据集看着很简单,但实际上它的质量会直接影响最后的大模型,所以最开始这一步也是最为关键的一步

  2. 有了数据集之后,我们便需要用分词算法对其进行tokenization(分词),这一步其实就是将连续的语言信息转化成离散的信息单元

  3. 将分词后的结构及表格同原数据集一起丢给transformer算法,耗费非常巨大的人力物力算力,最终我们可以得到一个 base/foundation model,但是目前这个模型并不会进行智能问答,因为它还没有学会如何「chat」,所以它只会基于你的话进行接龙,接龙的依据就是向量空间中各分词的相似度概率

  4. 如果想要基础模型变得会说话,那就需要进行 SFT(监督微调),通过喂给模型一定体量的问答结构的数据,让模型学会怎么基于 user-assistant 的模式去回答人类或进行「chat」

  5. 很多问题或许没有标准答案,并且人类偏好的交流模式和风格也都是有一定范围的,所以此时需要通过 RL(增强学习),或者通过更新的技术 DPO(直接偏好优化),来训练模型尽量输出更符合人类偏好和审美的回答

本次训练的模型将会按照 pretraining-SFT-DPO 这一路径来完成。

准备工作

首先技术选型具体如下:

由于我的个人电脑 GPU 非常弱,而且使用 GPU 来进行模型训练非常容易出现 OOM(内存溢出),所以我在代码中强制规定了只使用 CPU 进行各种运算;

而 CPU 运算确实特别慢,所以模型的参数、层数我都不能设置太多,以下是我的模型的一些重要参数:

其实这个参数量完全不够用,因为目前市场上成熟大模型的参数量基本都是 B 数量级起步的,所以我这顶多算做是「小」模型了,但是仅仅就 256 Vectors + 4 layers 就在我的电脑上花了很久的运算时间:

接下来我将较为详细地演示每一步骤,如果有代码需求对应的 GitHub 地址在这里:

github.com/Chacha-Bing…

训练模型

数据集

上面也提到过,数据集作为最原始的喂给模型训练的原油非常重要,数据集的大小与质量将会直接决定成品模型的质量。

目前大部分最先进模型的数据集都是非公开的(即使他们本身已经开源),但是我们已知的是,这些语言大模型的训练数据集的数量级已经来到了 TB 级别,而且在不远的未来几年或许就会把人类互联网已有的文字资料给训练殆尽。

但是有了各种网络数据以后并不可以直接拿来使用,因为这些网络信息中可能包含大量的重复文章、广告信息、有毒有害信息等,这些文字将会严重影响模型的偏好与训练成果,所以如何进行数据清洗和结构化是数据集中更为至关重要的一步。

但是现在优秀数据集并不难得到,比如 Huggingface 上的 FineWeb 项目(huggingface.co/datasets/Hu… 18.5T tokens,它们经由爬虫(Common Crawl)爬取数据并且做了一定的清洗,你可以很容易地就下载到其中的某些数据分片甚至是全量的数据,但真正的问题在于大多数人根本没有对应的算力资源去对如此海量的数据进行计算。

我的个人电脑也没有这么大的算力,虽然可以使用诸如 colab 之类的平台争取一些免费计算资源,但是我还是决定先在本地先行跑一个流程,所以我选取了一个非常小的关于医疗方面的数据子集(shibing624/medical,源自Huggingface:huggingface.co/datasets/sh…

预训练 [ pre-training ]

预训练是指在大规模的海量数据(通常是未标注的互联网文本)上,使用自监督学习 (Self-supervised Learning) 的方式训练模型,使其掌握通用的知识和语言规律。

预训练是大模型万丈高楼平地起的第一步,同时预训练也是 LLM 训练中最耗钱、耗力的阶段,因为这一阶段的数据集和所需算力都是最大的:比如 GPT-4 的预训练就需要数万颗 GPU 连续跑数月,消耗的电费和算力成本高达数千万美元。

预训练并不要求模型完成特定任务如翻译或分类,它的目标只有一个:猜下一个字,比如

  • 输入: “床前明月光,”

  • 目标: 模型预测下一个字是“疑”。

为了猜得准,模型必须学会语法、逻辑、事实知识甚至是常识,所以这也就是为什么预训练阶段需要大量的数据。

这些数据会经过分词,变成一个个有语义的 token,经过模型的训练后,这些词表中的 token 就会在 transformer 后映射成内部向量空间的高维数组,当用户输入后,这些输入也会经过内部 transformer 的各种层得到一个同维度的数组向量,而模型的下一步就是在预训练好后的向量空间内寻找与输入向量相似度「最适配」的向量,注意这里不一定是「最接近」而是「最适配」,因为这取决于我们的模型运行参数和内部使用这些参数的算法。那么找到「最适配」的向量后,我们通过逆向的办法即 unembedding 来解码出找出的这个向量是对应着什么字作为输出,然后循环往复一直「接龙」。

分词 [ tokenization ]

在我们训练语言模型之前,我们需要对数据进行分词,tokenization 是将我们的字符串转换为可做数值运算的数字的过程:我们的原始数据集仅仅是大段大段文字的集合,算法本身并不知道要如何去摄入并且训练自己理解这些长篇大论,我们必须对其进行离散化、数学化。

这里我们不展开分词的逻辑和各种算法,我们可以简单理解为分词相当于是将我们所有的语料库进行了语义切割,比如将「我爱月亮」这句话分为「我」、「爱」、「月亮」这 3 个tokens,虽然不同的算法会对同样的文字进行不同类型的分词,但是我们目前只需要知道,经过分词以后,所有的语料库就会被切割为非常大量的 tokens,其中每个 token 都会对应着自己的 tokenid,每个 tokenid 下都会有一个代表着这个 token 的向量,向量的初始值可以是随机的,向量的维度是我们自己定的,之后预训练的很大一部分工作,就是对于这些向量中的每一个参数进行微调。

我在这里使用了基于 BPE 算法的 tokenizer 即 ByteLevelBPETokenizer,用它来对我们 630MB (内含 370000 条医疗知识)的预训练数据集进行分词:

经过10min后,我们的分词结果已经完成,同时我们写一个脚本简单测试一下我们的分词成果:从上图可以看出,对于我构造的文本「患者诊断为心肌梗塞,建议服用阿司匹林。」—— 这句话基于我们的分词成果被拆分成了 8 个 tokens,将这些 tokens 做 decoded 还原没有丢失任何信息,将这些 tokens 逐个打印出来可以发现这些 token 都是有语义性的,比如「阿司匹林」被视为了一个 token 而不是视为「阿」、「司」、「匹」、「林」四个 token。

开始训练

我们调用 transformers 库,设置好合适的参数后,即开始进入漫长的预训练过程。

由于训练过程涉及到非常多的专业和数学知识,我感觉我也讲不好,所以大家想要了解还是专门去搜索相关资料比较好,或者可以进这个网站看一看:bbycroft.net/llm(这是一个用 three.js 制作的讲解 LLM 内部的动画流,对于形成概念还是挺有用的)

大概 8.4M 的参数量,我从下午3点开始训练,到半夜近2点才训练完。

在这 10h+ 的训练过程中,模型需要进行 92474 步才能将这次训练任务跑完:

可以从监视器里看到 Python 的执行已经占据了大部分 CPU 运算,同时为了不功亏一篑,所以每隔 500 步我都会让模型保留一次 checkpoint 作为快照存档。

另一方面,每隔50步我就会让模型以「我今天有点头疼,我需要」开头让它进行续写,以便能够实时看到训练成果:

刚开始训练时,模型几乎无法吐出完整的句子,loss 值为8+;

模型跑到2万步时,输出从破碎结构接近有一定逻辑,Loss降到了 5+ ;

模型 9万+ 轮全部跑完时平均 Loss 在4.7(一般来说,Loss 在2-4是比较优秀,对于我们训练小模型来说 Loss 在4-6也算够用啦)

监督微调 [ SFT ]

经过了十个半小时的集中训练,我们终于完成了这个9万步的训练过程🎉。

现在我们拥有了一个基础模型(Base/Foundation Model),但是目前的这个模型只会进行数理统计意义的词语接龙,虽然我们可以调整各种参数让它每次的输出结果不一样,但是它目前还学不会问答这套逻辑,而且只能基于医疗数据回答,让我们写个脚本测试一下:

可以看出其实它已经有了比较强的逻辑性,接下来我们就需要通过监督微调(SFT)来让这个“GPT”变成“Chat GPT”。

我们可以大致看一看用来做 SFT 的数据集样例:

{"instruction": "请描述口腔黏膜吸收的历史", "input": "", "output": "1847年,阿斯坎尼欧·索布雷罗等首先报导了硝酸甘油可以经口腔黏膜吸收进入人体血液循环系统。1879年,硝酸甘油舌下药成功地用于临床,之后陆续出现许多药物的口腔黏膜吸收相关报导。"}{"instruction": "我妈70岁,CT部位L3—S1椎间盘,结果L,无", "input": "", "output": "腰椎间盘突出症引起的症状就是有髓亥脱出刺激神经引起的痛庝,下肢反射性的痛庝,治疗的方法就是需要注意休息牵引,按摩针灸的方法治疗,口服芬必得,腰息痛胶囊。腰腿痛丸治疗,无效时射频和针刀的方法治疗一定要自己锻炼的,增强肌肉的韧性"}

这份数据是符合 Alpaca 格式的数据集,这也是目前开源社区最通用的 SFT 数据标准。

其中**instruction** (指令)是用户给模型下达的任务命令,****input **(上下文/输入):**是执行指令时所需要的额外背景信息,这个数据集没那么复杂所以 input 基本为空,而 output 就是模型需要学习的输出。

这一份数据集明显有更强烈的格式化,它能够教会模型怎样去做更符合问答结构的输出而不是去做字词接龙。

同时这一部分的数据集不用太多,正如其名称「微调」一样,我们只是想要去改变模型的一些输出模式和语气,并不想动到之前已经训练好的知识的关联,所以不需要过多的数据,否则模型可能会产生灾难性遗忘。

我从这个 SFT 数据集中随机抽取了5000条数据基于上面那个基础模型做 SFT 训练:

SFT完成截图:

从日志看,最终 train_loss 定格在 4.517。虽然数字上看起来和预训练差不多,但因为 SFT 改变了模型的输出分布,所以它现在的对话逻辑会比之前更强。

现在让我们测试一下这个经过了 SFT 的可以进行 Chat 的模型:

现在这个经过 SFT 的模型的确超越了字词接龙的门槛,但是对于「我想知道什么是口腔溃疡,你可以告诉我吗」这同一输入模型的不同表现,我们也可以看出在SFT后的答案没有SFT前的好:

这里的原因大概率就是出现了我们上面提到的「遗忘」,或者说灾难性遗忘 (Catastrophic Forgetting),这里的核心原因是因为我的模型太小了:为了迎合 SFT 的「对话格式」,而强制压制了预训练时学到的「百科知识」。

由于参数量太小(8M),模型可能无法同时兼顾「深刻的知识」和「礼貌的格式」,而最终选择了格式,丢掉了逻辑:这种「过拟合到模版」的操作挤占了原有知识的存储空间,导致它在回答时显得非常呆滞,甚至不管之前学到的逻辑而拼命输出礼貌的「你好」。

除了通过比较难实现的提高参数量以外,我们也可以通过一些措施来修正,比如

  • 混合训练 (Mixed Training):在我们 SFT 的 5000 条问答里,掺入 1000 条预训练时的纯百科文本。这被称为「重演策略」(Replay),能有效缓解遗忘

  • 更温和的微调:进一步降低学习率(比如到 1e-5),并减少 Epoch(轮次)数。

我这里就不去做更多的复杂调整了,同时,对于我的这个case,直接使用更精准一些的Prompt是个更迅速的捷径:

直接偏好优化 [ DPO ]

当模型做完微调以后,比较传统和在复杂场景下更为优越的方法其实是 RLHF(人类反馈强化学习)。

首先我们需要知道,在模型做完 SFT 后,模型的确能像个正常对话者一样,可以去和我们进行一来一回的对话了,但是这还不够,因为对于很多回答,此时的模型还并不能做出一个符合多数人预期的「好回答」,比如涉及到不同地区的习俗宗教法律的问题,又比如这个世界上大多数问题都是开放性问题,我们需要解决这些问题让模型变得更加「像人」。

比较典型的做法 RLHF 大体上分为2步:

首先我们要建立一个奖励模型 (Reward Modeling),虽然最理想的情况是根据一个指令让模型生成不同的回答然后让人类直接来标记打分,但是想想也知道这是多么大的工作量,但是我们可以通过一些人类打分的样本,去训练一个奖励模型,这个“小裁判”模型会学习人类的喜好,在后续的海量工作中它将模拟替代人类、给我们的训练模型的多个回答打分或者标记好差评。我记得之前在用 ChatGPT 时,有些时候它能生成两种回答并且会让你选择更喜欢的风格或逻辑,其实和上述的流程类似。

其次就是进行强化学习 (PPO, Proximal Policy Optimization),在这个算法中,我们让模型不断生成回答,然后让刚刚生成的“小裁判”即奖励模型打分,模型根据分数调整自己,这种算法其实也是“概率偏好优化”的一种具体实现。

但是我打算用一种更现代更简化的方式,也即 DPO:直接偏好优化 (DPO, Direct Preference Optimization)。

它直接跳过了训练“裁判”的步骤,直接用「好回答 / 坏回答」的对比对来训练模型,相对更简单、高效、省显存;比如说如果用 RLHF 的方法,那么在内存中需要同时运行数个模型:一个奖励模型、被训练的模型、基准模型、还有一些其他防止模型跑偏的附加模型,那我的电脑肯定会不堪重负,而 DPO 其实只用加载两个模型,即被训练的模型和基准模型,这个基准模型其实就是上面我们结束 SFT 后的模型,它存在的意义是用来作为某种距离计算的基准,可以防止我们的模型在做完 DPO 后离原来太远而产生劣化。

我们可以大致看一看用来做 DPO 的数据集样例:

{"question": "术后肌痛的高危因素有些什么?", "response_chosen": "琥珀胆碱", "response_rejected": "手术后疼痛是常见的并发症之一。"}{"question": "高血脂的就诊科室是什么?", "response_chosen": "内科;心内科", "response_rejected": "高血压、糖尿病等疾病患者在治疗过程中常常需要进行血液检查。"}{"question": "老年环状混合痔的并发症是什么?", "response_chosen": "冠心病;高血压病;糖尿病", "response_rejected": "老年环状混合痔的常见并发症包括:肛门狭窄、直肠脱垂和大便习惯改变。"}

这个数据结构中包含成对的数据即:同一个问题,附加一个正确的、更好答案和一个错误的、不太好的但看起来很像正确的答案,把这些数据丢给模型去训练,模型可以被训练出使回答更偏更好的答案的形态。

DPO 流程其实特别耗时,DPO 时我的电脑会承受比 SFT 阶段重 3-4 倍的计算压力

DPO 时电脑内存中发生的一些事:

  • 双模型同步前向传播:在 SFT 时,电脑只需要跑一个模型。而在 DPO 的每一单步里,电脑要同时跑 Policy Model(训练模型)和 Reference Model(参考模型)。计算两次 Logits

  • chosen(好回答)喂给两个模型,算概率差。

  • rejected(坏回答)喂给两个模型,再算概率差。

  • 对数几率比(Log Odds Ratio)计算:模型需要根据这两组概率差,计算出一个损失值(Loss),这个过程涉及大量的浮点运算。

Rewards/accuracies (奖励准确率)在30%轮次时就已经高达 **0.9375,**这意味着在当前的每一个处理采样里,模型有 93% 的把握认为 chosen 确实比 rejected 更好。

看相关的参数我觉得我们这个小模型对好坏的认知已经基本定型。所以我最后决定不继续等待,而是拿最新的第**141步的checkpoint**文件作为 DPO 的训练结果,也作为我们这个「医疗大模型」的最终成果。

结果分析

我们采取最直观的方式来分析结果:写一个脚本,它用于加载不同步骤生成后的共计3个模型:预训练生成后的模型、SFT后生成的模型、DPO后生成的模型,我们只需要在命令行打一个问题,下面就会自动出现三个模型生成的回复以便进行比较:

意外但合理的结果是,我发现效果最好的反而是只经过预训练的 Base Model。

下面我们来简单分析一下,为什么 SFT 和 DPO 反而变差了?其实还是上面提到过的**「灾难性遗忘」**。

在截图里,可以清晰看到微调带来的副作用:

  • SFT(指令微调):内容变得像是在“背清单”(1...2...3...)。这是因为 SFT 数据集里有很多分条列点的格式,模型学会了这种“说话的调调”,却在模仿过程中把 Base 模型里深层的医学知识给挤掉了。

  • DPO(偏好对齐):结尾出现了大面积的“祝您早日康复!祝您早日康复!”。这是很典型的「过拟合」或「模式崩塌」:因为 DPO 强迫模型去迎合某个特定的审美,比如更有礼貌或者更像医生,结果它那仅有的 8M 「脑子」承载不了这种复杂的转变,导致它最后只学会了反复输出这种高分金句,因为这个句子模式在训练集中被认为是更被人类偏好的。

我们模型的核心矛盾就是其 8M 的大脑容量实在是有限,我们会说模型规模决定了微调的上限,你可以理解为因为参数太小所以我训练的模型在预训练阶段可能就已经没有什么冗余了,所以后续继续训练时,影响的参数绝大多数都是之前的关键参数,虽然我们可以设置学习率、梯度等参数来缓解这种改变,但是参数变了就是变了,它可能就是会对之前习得的向量空间里各个 token 的位置产生影响。

最后,我们的模型其实在预训练后成为基础模型的表现会更好,这也证明这份 Huggingface 上的医疗预训练语料质量很高; 而 SFT 和 DPO 表现出的退步,说明对于我们的 8M 模型 来说,过度的 Alignment(对齐)反而劣化了。

既然如此,我们后续更好的路径其实是:

  1. 使用 Base 模型作为核心。后续我们还可以做简单少量的 SFT 仅仅让模型学会一些回答的格式,但DPO可以先不考虑

  2. 可以继续考虑做 RAG (检索增强):我们可以让 Base 模型根据搜索到的实时专业文档进行总结,这样它就不需要继续消耗本就不多的宝贵参数量去“背诵”知识盲区和新知识了

后续我就暂不优化这个模型了,因为主要流程和核心都跑过了,RAG也在其他项目中做过了,所以这个项目至此完成。

结语

GitHub 地址:github.com/Chacha-Bing…

我在前两周还是一个对人工智能、对以上知识均一无所知的小白,到现在已经能够简单地跑通一个模型的训练流程,可以说是算已经入门了,对此还是感到非常有趣和开心的。

写这篇文章也是权把自己的一些学习心得和实验流程记录下来供大家参考,或许也有许多小白和我在各个阶段有相同的疑惑,那么就感谢各位的阅读啦,后续如果我继续做了什么有趣的小东西我会继续输出的~

前端 er 速码!TinyVue 全局动效实践指南,干货拉满

本文由TinyVue贡献者程锴原创。

一、前言:为什么要统一管理动效

在前端开发中,动画不仅是锦上添花的“视觉糖”,更是交互体验的重要组成部分: 它能引导用户关注、反馈操作结果、缓解等待焦虑、提升产品质感。

但当项目变大、组件增多后,你可能遇到这些问题:

  • 同样的淡入淡出,在不同组件中表现不一致
  • 想调整动画速度,却要修改多个文件
  • 动画样式难以复用、维护困难

这些问题的根源在于:动画定义分散、缺乏统一管理。 为此,TinyVue 引入了一套全新的 全局动效体系,基于 LESS + CSS 变量 实现集中配置与动态控制。

二、为什么选择 LESS + CSS 变量

常见的动画实现方式有两种:

方式 优点 缺点
1️⃣ 直接在组件中定义@keyframes 简单直观,局部可定制 无法统一、修改麻烦
2️⃣ 全局管理动画 可复用、风格一致 静态,难以动态调整

TinyVue 采用 LESS + CSS 变量结合方案,兼顾两者优势:

变量化控制 所有动效的时长、透明度、位移量都由 CSS 变量控制

可局部覆盖 组件可根据需求覆盖变量,灵活调整动画参数

主题可切换 只需在不同主题文件中修改变量,即可快速切换全局动效风格

三、环境搭建与示例预览

1. 拉取 TinyVue 仓库:

git clone https://github.com/opentiny/tiny-vue.git
cd tiny-vue
pnpm i

1.PNG

2. 启动TinyVue项目

pnpm dev

浏览器访问:http://localhost:7130

2.png

3. 打开配置文件:

/packages/theme/src/base/vars.less

3.png

1). 修改变量即可实时生效:

--tv-motion-slide-speed: 1.2s;

刷新页面后,可在抽屉(Drawer)组件中观察滑动动效速度变化。

4.gif

同样地:

--tv-motion-fade-offset-y: 100px;

会影响对话框(DialogBox)的淡入位移动画。

5.gif

四、全局动效的设计思路

1. 统一变量管理

所有动画相关参数集中在 /packages/theme/src/base/vars.less

:root {
  /* 淡入淡出 */
  --tv-motion-fade-speed: 0.3s;

  /* 滑动类 */
  --tv-motion-slide-speed: 0.4s;
  --tv-motion-slide-offset-left: -30px;
  --tv-motion-slide-offset-left-mid: -10px;
  --tv-motion-slide-opacity-mid: 0.5;

  /* 蚂蚁线 */
  --tv-motion-ants-shift: 8px;
  --tv-motion-ants-speed: 0.8s;
}

修改任意变量即可影响全局动效表现。

2. 按类型分类管理

为方便维护和扩展,动效按类型拆分为多个 LESS 文件:

motion/
  fade.less       // 淡入淡出
  slide.less      // 滑动
  zoom.less       // 缩放
  rotate.less     // 旋转
  bounce.less     // 弹跳
  ants.less       // 蚂蚁线
  ...
  index.less      // 汇总引入

每个文件独立维护一类动效,结构清晰,修改成本低。

3. 动效命名规范

统一命名规则: {type}-{direction}-{state}

示例:

  • fade-in:淡入
  • slide-left-in:从左滑入
  • zoom-in:放大进入
  • ants-x-rev:蚂蚁线反向滚动

保证语义清晰、全局唯一,方便引用与调试。

五、动效实现示例

1️⃣ 淡入淡出动效

@keyframes fade-in {
  0% { opacity: 0; }
  100% { opacity: 1; }
}
@keyframes fade-out {
  0% { opacity: 1; }
  100% { opacity: 0; }
}

调用方式:

.fade-enter-active {
  animation: fade-in var(--tv-motion-fade-speed) ease-out both;
}
.fade-leave-active {
  animation: fade-out var(--tv-motion-fade-speed) ease-in both;
}

2️⃣ 滑动动效

@keyframes slide-left-in {
  0% {
    opacity: 0;
    transform: translateX(var(--tv-motion-slide-offset-left));
  }
  50% {
    opacity: var(--tv-motion-slide-opacity-mid);
    transform: translateX(var(--tv-motion-slide-offset-left-mid));
  }
  100% {
    opacity: 1;
    transform: translateX(0);
  }
}

通过变量可灵活调整动画节奏和距离。

3️⃣ 蚂蚁线动画(Ants)

@keyframes ants-x {
  0% { background-position: 0 0; }
  100% { background-position: var(--tv-motion-ants-shift, 8px) 0; }
}

在组件中调用:

.copyed-borders {
  --tv-motion-ants-shift: 13px;
  .border-top {
    animation: ants-x var(--tv-motion-ants-speed) linear infinite;
  }
}

六、组件集成方式

方式 描述
全局引入 motion/index.less 统一引入所有动效,确保全局可用
局部调用 组件通过类名或 animation 属性使用对应动效
变量覆盖 通过覆盖 CSS 变量实现不同组件动效差异化

七、实践经验与优化建议

保持命名规范:保证语义清晰、避免重复
文件分类明确:不同类型动效分文件管理
加注释和示例:便于团队协作与复用

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyVue、TinyEngine、TinyPro、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

React学习-虚拟DOM

虚拟DOM

一、什么是虚拟 DOM?

虚拟 DOM 是对浏览器中真实 DOM(Real DOM) 的一种轻量级 JavaScript 对象表示。它本质上是一个纯 JavaScript 对象树,结构与真实 DOM 树一致,但不直接操作浏览器的渲染引擎。

例如,真实 DOM 元素:

<div id="container">
  <p>Hello, world!</p>
</div>

对应的虚拟 DOM 可能是:

{
  type: 'div',
  props: {
    id: 'container',
    children: [
      {
        type: 'p',
        props: {
          children: 'Hello, world!'
        }
      }
    ]
  }
}

注意:React 内部使用的是 Fiber 节点(自 React 16 起),它是对虚拟 DOM 的进一步抽象,支持增量渲染、优先级调度等高级特性,但开发者通常仍将其统称为“虚拟 DOM”。

JSX 编译为 虚拟DOM

在 React 中,JSX 代码会被编译为对 React.createElement() 的函数调用,而 React.createElement() 的返回值就是 虚拟 DOM(即 React 元素) 。这个过程主要依赖 构建工具(如 Babel、TypeScript)的编译转换

JSX本质上是 React.createElement() 函数调用的语法糖

JSX 约等于(=) React.createElement() 调用

二、React 16之前,虚拟 DOM 的工作流程

React 使用虚拟 DOM 实现高效的 UI 更新,主要分为三步:

1. Render 阶段:生成新的虚拟 DOM 树

  • 当组件状态(state)或属性(props)发生变化时,React 会重新调用组件的 render() 方法(函数组件即重新执行函数)。
  • 生成一棵全新的虚拟 DOM 树(称为“新 VDOM”)。

2. Reconciliation(协调)阶段:Diff 算法比对新旧虚拟 DOM

  • React 将新 VDOM 与上一次渲染保存的旧 VDOM 进行差异比较(Diffing)
  • 使用高效的 启发式 Diff 算法(如基于 key 的列表 diff、同层比较等),找出最小变更集。
  • 这个过程发生在内存中,速度极快。

3. Commit 阶段:批量更新真实 DOM

  • 将计算出的差异(patches)批量应用到真实 DOM 上。
  • 避免频繁、零散地操作真实 DOM,从而减少重排(reflow)和重绘(repaint)。

💡 整个过程可概括为:State/Props 变化 → 生成新 VDOM → Diff 新旧 VDOM → 批量更新真实 DOM

三、为什么需要虚拟 DOM?

  1. 性能优化

    真实 DOM 操作的代价高:

    • 浏览器的 DOM 操作涉及布局(Layout)、绘制(Paint)、合成(Composite)等多个昂贵步骤。
    • 频繁操作会导致页面卡顿。

    在 JS 层面做 diff,避免不必要的 DOM 操作

  2. 跨平台能力:虚拟 DOM 是平台无关的,可用于 Web、React Native(映射到原生组件)等

  3. 声明式编程:开发者只需描述“UI 应该是什么样子”,无需手动管理 DOM 更新逻辑

四、虚拟 DOM 的局限性

  • 内存开销:需要维护两棵 DOM 树(新 + 旧),在大型应用中可能占用较多内存。
  • 并非总是最快:对于简单、静态的 UI,直接操作 DOM 可能更快(如 jQuery)。

⚠️ 提示:合理使用 key(尤其是列表渲染)可极大提升 diff 效率。

React 16+ 的 Fiber 架构:全新工作流

Fiber 引入后,整个协调过程被重构为:

  1. JSX → React 元素(输入不变)

  2. 但协调过程不再直接操作 React 元素树,而是:

    • 构建/更新 Fiber 节点树(内部工作单元)
    • 使用 可中断的循环(而非递归)  遍历 Fiber 树
    • 支持 时间切片(Time Slicing) :每执行一小段工作就检查是否需要让出主线程
    • 基于 优先级调度(Scheduler) 决定哪些更新先执行
  3. 最终在 Commit 阶段 批量更新真实 DOM

关键变化:

方面 React 15(旧) React 16+(Fiber)
协调算法 递归 diff 虚拟 DOM 树 迭代式处理 Fiber 树
执行方式 同步、不可中断 异步、可中断、可恢复
数据结构 简单的 React 元素 复杂的 Fiber 节点(带状态、副作用、指针等)
渲染模型 立即渲染 支持并发渲染(Concurrent Rendering)
性能瓶颈 大更新卡死 UI 可拆分任务,保持响应性

Fiber 完全接管了“如何从虚拟描述生成真实 UI”的全过程

一、Fiber 架构下的工作流程(渲染循环)

Fiber 将渲染过程分为两个主要阶段:

阶段 1:Reconciliation(协调阶段)

  • 目标:对比新旧 UI,计算出需要执行的变更。

  • 可中断、可恢复、支持优先级

  • 分为两个子树:

    • current tree:当前已提交到屏幕的 Fiber 树。
    • work-in-progress (WIP) tree:正在构建的新 Fiber 树。
关键步骤:
  1. 从根开始遍历 JSX 生成的 React 元素树

  2. 为每个元素创建或复用 Fiber 节点(通过 beginWork)。

  3. 执行 diff(协调)

    • 比较 typekey
    • 若相同,复用现有 Fiber(避免重建 DOM)。
    • 若不同,标记为删除或替换。
  4. 完成子树后,执行 completeWork

    • 创建/更新 DOM 节点(但不挂载)。
    • 收集副作用(如需要插入、更新、删除的节点)。
  5. 整个 WIP 树构建完成后,进入 Commit 阶段。

🔄 此阶段运行在 render phase,可能被高优先级任务(如用户输入)打断并丢弃。


阶段 2:Commit(提交阶段)

  • 目标:将变更应用到真实 DOM。

  • 不可中断(必须一次性完成,否则 UI 不一致)。

  • 分为三个子阶段:

    1. before mutation:调用 getSnapshotBeforeUpdate 等。
    2. mutation:执行 DOM 操作(插入、更新、删除)。
    3. layout:调用 useLayoutEffectcomponentDidMount/Update

✅ 此阶段操作的是 真实 DOM,并触发生命周期和副作用。

二、那“虚拟 DOM”还存在吗?

存在,但角色变了

  • React 元素(即 JSX 编译结果)仍然是“虚拟 DOM”的表现形式,作为输入描述

  • 协调和 diff 不再直接在 React 元素上进行,而是在 Fiber 节点 上进行。

  • Fiber 节点可以看作是 增强版、可工作的虚拟 DOM,它:

    • 持有 React 元素的信息(type, props
    • 还包含状态(memoizedState)、副作用(effectTag)、父子指针等
    • 支持复用、中断、优先级等

📌 所以: “虚拟 DOM”作为概念仍然存在,但其实现和工作机制已被 Fiber 架构全面覆盖和升级

三、开发者感知的变化

对大多数开发者来说,API 层面几乎没有变化

function App() {
  return <div>Hello</div>; // 写法不变
}

但底层:

  • 不再是简单的 React.createElement → 递归 diff
  • 而是 jsx() → 创建 Fiber 节点 → 协调 → 提交

总结:

Fiber 架构完全覆盖并取代了 React 15 中传统的虚拟 DOM 工作流

  • 它不是“在虚拟 DOM 上加了个调度器”,而是用一套全新的、基于 Fiber 节点的协调系统替代了旧的递归 diff 机制
  • “虚拟 DOM”作为UI 描述的抽象依然存在(即 React 元素),但协调、diff、更新的执行载体已变为 Fiber 树
  • 这一变革使得 React 能够支持并发渲染、自动批处理、Suspense、Transition 等现代特性。

简单说:Fiber 是新一代的“虚拟 DOM 引擎” ,它让 React 流畅。

最长连续序列:我的哈希集合解法与优化思路

最长连续序列:哈希集合的巧妙运用

在力扣上面刷到的这道题目,提交了一直超时,才发现遍历了原数组,没有去重,这道题目的思路还是很重要的,所以,写一篇文章分享分享

问题分析

给定一个未排序的整数数组 nums,需要找出其中数字连续的最长序列的长度,并且要求时间复杂度为 O(n)。

举个例子:

输入: [100, 4, 200, 1, 3, 2]
输出: 4
解释: 最长连续序列是 [1, 2, 3, 4]

我的解题思路

第一步:建立哈希集合去重

首先我想到要用哈希集合来存储数组的所有元素,这样做有两个好处:

  1. 快速查询某个数是否存在(O(1)时间复杂度)
  2. 自动去重,避免重复数字干扰
let cnt = new Set(nums);  // 去重 + 快速查询

第二步:关键优化——只从序列起点开始查找

这是我算法的核心优化点。如果直接遍历每个数都向后查找,会有大量重复计算。

我的观察:一个连续序列只需要从它的最小数开始查找就行了。
比如序列 [1, 2, 3, 4],从1开始查找可以得到整个序列,但如果从2、3、4开始查找,只会得到部分结果,是重复计算。

如何判断一个数是不是序列起点呢?
很简单:如果 num-1 不存在于集合中,那么 num 就是某个连续序列的起点

// 只有当前数是序列起点时才进行处理
if(!cnt.has(num-1)) {
    // 从起点开始向后扩展
}

第三步:从起点向后扩展计算长度

确定了起点后,就从这个数开始,不断检查 num+1num+2... 是否存在,直到遇到不存在的数字为止。

let sum = num;  // 从当前数开始
while(cnt.has(sum)) {
    sum++;  // 向后查找连续的数
}

第四步:计算并更新最大长度

当while循环结束时,sum 指向的是连续序列最后一个数的下一个值。
所以序列长度就是 sum - num

maxlen = Math.max(maxlen, sum - num);

完整代码实现

/**
 * @param {number[]} nums
 * @return {number}
 */
var longestConsecutive = function(nums) {
    // 1. 将数组转为哈希集合,自动去重
    let cnt = new Set(nums);
    let maxlen = 0;
    let sum = 0;
    
    // 2. 遍历集合(遍历集合而不是原数组,避免处理重复数)
    for(const num of cnt) {
        sum = num;
        
        // 关键优化:只有当前数是连续序列的起点时才处理,这样我们就避免了重复的计算
        // 判断标准:num-1 不在集合中
        if(!cnt.has(num-1)) {
            // 从起点开始向后查找连续的数
            while(cnt.has(sum)) {
                sum++;
            }
        }
        
        // 3. 更新最大长度
        // 此时 sum 是最后一个连续数的下一个值,所以长度 = sum - num
        maxlen = Math.max(maxlen, sum - num);
    }
    
    return maxlen;
};

算法执行示例

让我用 nums = [100, 4, 200, 1, 3, 2, 2] 来演示:

  1. 建立集合{100, 4, 200, 1, 3, 2}(去掉了重复的2)

  2. 遍历集合

    • 遇到100:99不存在 → 是起点
      向后找:101不存在 → 序列 [100],长度 = 1

    • 遇到4:3存在 → 不是起点 → 跳过

    • 遇到200:199不存在 → 是起点
      向后找:201不存在 → 序列 [200],长度 = 1

    • 遇到1:0不存在 → 是起点
      向后找:2存在,3存在,4存在,5不存在
      序列 [1, 2, 3, 4],长度 = 4

    • 遇到3:2存在 → 不是起点 → 跳过

    • 遇到2:1存在 → 不是起点 → 跳过

  3. 最终结果:4

时间复杂度分析

  • 每个元素最多被访问两次(一次在外层循环,一次在内层while)
  • 所有操作都是哈希集合的 O(1) 操作
  • 总时间复杂度:O(n)

为什么这个方法高效?

  1. 避免排序:排序需要 O(n log n),我们直接用 O(n)
  2. 避免重复计算:通过"只从起点开始"的优化,确保每个连续序列只被完整遍历一次
  3. 空间换时间:使用 O(n) 的额外空间(哈希集合)来获得 O(n) 的时间复杂度

总结

这道题的核心在于理解如何高效地找到连续序列。我的思路是:

  1. 用哈希集合去重 + 快速查询
  2. 只从序列的起点开始向后扩展
  3. 确保每个序列只被计算一次

这种"空间换时间"的思想在算法面试中非常常见,记住这个模式,可以解决很多类似的问题。

Vue-深度解读代理技术:Object.defineProperty 与 Proxy

前言

在 Vue 的进化史中,从 Vue 2 到 Vue 3 的跨越,最核心的变革莫之过于响应式系统的重构。而这场重构的主角,正是 Object.definePropertyProxy。本文将带你从底层描述符到 Reflect 陷阱,深度拆解这两大对象代理技术。

一、 ES5 时代的功臣:Object.defineProperty

Object.defineProperty 用于在一个对象上定义或修改属性。Vue 2 的响应式基础正是建立在其“存取描述符”之上的。

1. 基础语法

Object.defineProperty(obj, prop, descriptor);

  • obj:目标对象
  • prop:要定义或修改的属性名(字符串或 Symbol)
  • descriptor:属性描述符,是一个配置对象(包含数据描述符与存取描述符)

2. descriptor描述符分类

它可分为两类,一类为数据描述符、一类为存取描述符

属性描述符不能同时包含 value/writable(数据描述符)和 get/set(存取描述符)。

  • 数据描述符

    字段 类型 默认值 说明
    value any undefined 属性的值
    writable boolean false 是否可写(能否被重新赋值)
    enumerable boolean false 是否可枚举(能否在 for...inObject.keys 中出现)
    configurable boolean false 是否可配置(能否被删除或修改描述符)
  • 存取描述符:

    字段 类型 说明
    get function 读取属性时调用的函数
    set function 设置属性时调用的函数

注意❗:一个描述符不能同时包含 value/writableget/set,否则会报错。

3. 局限性分析(Vue 2 的痛点)

  • 无法监听新增/删除:必须预先定义好属性,动态添加的属性(data.b = 2)无法响应。

  • 数组支持差:无法拦截索引修改(arr[0] = x)及 length 变更。

  • 性能开销:必须通过递归遍历对象的所有属性进行拦截。

4. 使用示例:

// 封装一个劫持对象所有属性的函数
function observe(obj) {
  // 遍历对象的自有属性
  Object.keys(obj).forEach((prop) => {
    let value = obj[prop]; // 存储原始值
    Object.defineProperty(obj, prop, {
      enumerable: true,
      configurable: true,
      get() {
        console.log(`读取 ${prop} 属性:${value}`);
        return value;
      },
      set(newValue) {
        console.log(`给 ${prop} 赋值:${newValue}`);
        value = newValue;
      },
    });
  });
}

// 测试
const person = { name: "张三", gender: "男" };
observe(person);

person.name = "李四"; // 输出:给 name 赋值:李四
console.log(person.gender); // 输出:读取 gender 属性:男 → 男

二、 ES6 时代的巅峰:Proxy

Proxy 是ES6引入的一个新对象,用于创建一个对象的代理,从而拦截并自定义这个对象的基本操作(比如属性读取、赋值、删除、遍历等)。它是 Vue 3 实现高效响应式的基石。

1. 基本语法

  • 语法:const proxy = new Proxy(target, handler);

    • target:要代理的目标对象(可以是普通对象、数组、函数,甚至是另一个 Proxy)。

    • handler:一个配置对象,包含多个陷阱函数(traps),每个陷阱函数对应一种对目标对象的操作(比如读取属性对应get陷阱,赋值对应set陷阱)

    • proxy:返回的代理对象,后续操作都通过这个代理对象进行,而非直接操作原对象。

1. 常见陷阱函数 (Traps)

Proxy 的强大在于它能拦截多种底层操作。

Trap 触发时机 示例
get(target, prop, receiver) 读取属性时 obj.foo
set(target, prop, value, receiver) 设置属性时 obj.foo = 'bar'
has(target, prop) 使用in 操作符时 'foo' in obj
deleteProperty(target, prop) 删除属性时 delete obj.foo
ownKeys(target) 获取自身属性名时 Object.keys(obj)
apply(target, thisArg, args) 调用函数时(仅当 target 是函数) fn()
construct(target, args) 使用new操作符时 new Obejct()

2. 使用示例

    // 1. 定义原始用户对象
    const user = {
      name: '张三',
      age: 20,
    };

    // 2. 创建 Proxy 代理对象
    const userProxy = new Proxy(user, {
      // 拦截属性读取操作(比如 userProxy.name)
      get(target, prop, receiver) {
        console.log(`读取属性${prop}`);
        // 核心逻辑:属性不存在时返回默认提示
        if (!Reflect.has(target, prop)) {
          return `属性${prop}不存在`;
        }
        return Reflect.get(target, prop, receiver); // 用 Reflect 保证 this 指向正确
      },

      // 拦截属性赋值操作(比如 userProxy.age = 25)
      set(target, prop, value, receiver) {
        console.log(`给属性${prop}赋值:${value}`);
        // 核心逻辑:属性合法性校验
        switch (prop) {
          case 'age':
            if (typeof value !== 'number' || value <= 0) {
              console.error(' 年龄必须是大于0的数字!');
              return false; // 返回 false 表示赋值失败
            }
            break;
          case 'name':
            if (typeof value !== 'string' || value.trim() === '') {
              console.error(' 姓名不能为空字符串!');
              return false;
            }
            break;
        }
        return Reflect.set(target, prop, value, receiver); // 合法则执行赋值,返回 true 表示成功
      },
    });

    // 3. 测试代理功能
    console.log('===== 测试属性读取 =====');
    console.log(userProxy.name); // 读取存在的属性
    console.log(userProxy.age); // 读取存在的属性
    console.log(userProxy.gender); // 读取不存在的属性

    console.log('\n===== 测试合法赋值 =====');
    userProxy.age = 25; // 合法的年龄赋值
    userProxy.name = '李四'; // 合法的姓名赋值
    console.log('赋值后 name:', userProxy.name);
    console.log('赋值后 age:', userProxy.age);

    console.log('\n===== 测试非法赋值 =====');
    userProxy.age = -5; // 非法的年龄(负数)
    userProxy.name = ''; // 非法的姓名(空字符串)
    console.log('非法赋值后 age:', userProxy.age); // 年龄仍为 25
   
// 打印结果:  
===== 测试属性读取 =====
 读取属性name
 张三
 读取属性age
 20
 读取属性gender
 属性gender不存在
===== 测试合法赋值 =====
 给属性age赋值:25
 给属性name赋值:李四
 读取属性name
 赋值后 name: 李四
 读取属性age
 赋值后 age: 25
 114 
===== 测试非法赋值 =====
 给属性age赋值:-5
 年龄必须是大于0的数字!
 给属性name赋值:
 姓名不能为空字符串!
 读取属性age
 非法赋值后 age: 25



三、 Reflect:Proxy 的最佳拍档

Reflect 是 ES6 引入的内置全局对象,不能通过 new 实例化(不是构造函数)。它的核心作用是把原本属于 Object 对象的底层操作(比如属性赋值、删除)提炼成独立的函数方法,同时能保证操作的 “正确性”—— 比如转发操作时保留正确的 this 指向。

1. 为什么一定要配合 Reflect?

核心原因:处理 this 指向问题。

当对象内部存在 getter 并依赖 this 时,如果直接使用 target[prop]this 将指向原始对象而非代理对象,导致后续的属性读取无法被 Proxy 拦截。

2. Reflect使用对比

const person = {
      _name: '张三',
      get name() {
        console.log('getter 被调用,this:', this === person ? 'person' : this);
        return this._name;
      },

      introduce() {
        console.log('this', this)
        return `我叫${this.name}`;
      },
    };

    // 错误代理
    const badProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          const original = target[prop]; // 错误:直接获取
          return function () {
            return original(); // this 指向 badProxy
          };
        }
        return target[prop];
      },
    });

    // 正确代理
    const goodProxy = new Proxy(person, {
      get(target, prop, receiver) {
        console.log(`拦截: ${prop}`);
        if (prop === 'introduce') {
          return function () {
            return Reflect.apply(target[prop], receiver, arguments); // 正确
          };
        }
        return Reflect.get(target, prop, receiver);
      },
    });

    console.log('=== 测试错误代理 ===');
    console.log(badProxy.introduce());

    console.log('\n=== 测试正确代理 ===');
    console.log(goodProxy.introduce()

3. 打印结果分析

  1. 首先执行console.log(badProxy.introduce())

    • 它会读取badProxy.introduce属性,触发badProxyget 陷阱,参数target = personprop = 'introduce'receiver = badProxy
  2. 接着进入badProxyget陷阱函数,此时返回的新函数被赋值给badProxy.introduce,然后执行这个新函数。

    console.log(`拦截: ${prop}`);  // 输出:拦截: introduce
    if (prop === 'introduce') {
      const original = target[prop]; // 拿到 person.introduce 函数
      return function () { // 返回一个新函数
        return original(); // 关键错误:裸调用 original
      };
    }
    
  3. 执行返回的新函数original()(即person.introduce()

    • original是裸调用(没有对象前缀),所以introduce方法里的this指向window(非严格模式);
    • 输出:this window
    • 执行this.namewindow.name,不会触发personnamegetter(因为this不是person/badProxy),所以window._name不存在,返回undefined
    • 最终返回我叫undefined,控制台输出:我叫

  4. 执行console.log(goodProxy.introduce())

    • 它会读取goodProxy.introduce属性,触发goodProxyget 陷阱,参数:
    • target = personprop = 'introduce'receiver = goodProxy
  5. 第一次触发get陷阱(拦截introduce),此时返回的新函数被赋值给goodProxy.introduce,然后执行这个新函数

    console.log(`拦截: ${prop}`); // 输出:拦截: introduce → 第一次拦截
    if (prop === 'introduce') {
      return function () { // 返回一个新函数
        return Reflect.apply(target[prop], receiver, arguments); // 正确绑定 this
      };
    }
    
  6. 执行返回的新函数,Reflect.apply(target[prop], receiver, arguments),其中

    • target[prop]=person.introduce 函数;
    • receiver=goodProxy(把introduce方法的this绑定到goodProxy);
    • 执行person.introduce方法,此时方法内的this = goodProxy
  7. 执行 introduce 方法内部代码

    console.log('this', this); // 输出:this Proxy(Object) { _name: '张三' }(即 goodProxy)
    return `我叫${this.name}`; // 关键:读取 this.name → goodProxy.name
    
  8. 第二次触发get陷阱(拦截name),因为this = goodProxy,所以this.name等价于goodProxy.name,需要读取goodProxy.name属性,再次触发goodProxyget 陷阱,参数:

    • target = personprop = 'name'receiver = goodProxy
    • 进入get陷进函数
console.log(`拦截: ${prop}`); // 输出:拦截: name → 第二次拦截
if (prop === 'introduce') { /* 不执行 */ }
return Reflect.get(target, prop, receiver); // 调用 Reflect.get 读取 person.name

9. 调用Reflect.get(target, prop, receiver),触发person.name的 getter,此时 getter 里的thisreceiver绑定为goodProxy

get name() {
  console.log('getter 被调用,this:', this === person ? 'person' : this); 
  // 输出:getter 被调用,this: Proxy(Object) { _name: '张三' }
  return this._name; // this = goodProxy → 读取 goodProxy._name
}

10. 返回this._name(不是name!),这时会第三次触发goodProxy的get陷阱(prop = '_name'

console.log(`拦截: ${prop}`); // 输出:拦截: _name
return Reflect.get(target, '_name', receiver); // 返回 person._name = '张三'

11. 最终返回结果 我叫张三

![](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/cb44628ee3904c759428efdadbba9e90~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y-R546w5LiA5Y-q5aSn5ZGG55Oc:q75.awebp?rk3s=f64ab15b&x-expires=1770714393&x-signature=VN5mF0OKtlLwwfknHvfBPYIqpVE%3D)

四、 总结:Proxy 的降维打击

  1. 全方位拦截:不仅能拦截读写,还能拦截删除、函数调用、new 操作等。
  2. 性能优势:无需遍历属性,直接代理整个对象。
  3. 原生支持数组:完美解决 Vue 2 中数组监听的各种奇技淫巧(如重写数组原型方法)。
  4. 配合 Reflect:通过 receiver 参数完美转发 this 绑定,保证了响应式系统的严密性。

Dart - 内存管理与垃圾回收(GC)深度解析

Flutter 性能底层(一):内存世界的版图 —— Dart 分代模型

引言:透过现象看本质

作为 Flutter 开发者,我们每天都在与 Widget 打交道。无论是简单的 Text 还是复杂的 CustomPaint,我们习惯了通过不断地 new 对象来构建界面。

但在这些代码背后,你是否曾有过一丝隐忧:Dart 虚拟机(VM)到底把这些对象存在了哪里?它是怎么管理这一大堆数据的?

要回答这个问题,解决我们对“频繁创建对象”的恐惧,我们不能一上来就谈枯燥的 GC 算法。我们首先需要拿出一张地图,看懂 Dart 内存世界的版图结构

因为 Dart 所有的内存策略,都建立在两个核心设计之上:Isolate 的独立性分代假说


一、 宏观格局:Isolate 的分户式管理

在 Java 或 C++ 等传统语言中,多线程通常共享同一块堆内存(Shared Heap)。这就像一群人住在一个大通铺里:

  • 优点:我想给你个东西,直接放在桌子上就行。
  • 缺点:打扫卫生(GC)时非常麻烦。为了防止“我刚扫完地,你又扔了纸团”,GC 往往需要暂停所有人的活动(Stop The World),给整个屋子上锁。

Dart 选择了完全不同的路线:Isolate(隔离区)

在 Dart 中,每个线程(Isolate)都有自己独立的堆内存(Heap)。Isolate A 的内存,Isolate B 根本看不见,更摸不着。

Gemini_Generated_Image_mh86o8mh86o8mh86.png

这种“分户式管理”带来了两个巨大的优势:

  1. 无锁分配:因为只有我自己用这块内存,创建对象时不需要加锁,速度极快。
  2. 局部 GC:当主 UI 线程需要扫垃圾时,完全不影响后台做繁重计算的 Isolate。App 不会因为后台任务的 GC 而卡顿。

二、 核心理论:分代假说 (Generational Hypothesis)

搞清楚了“谁管谁家”的问题,接下来我们看单各 Isolate 内部,这块内存(Heap)是如何规划的。

Dart 的内存规划并非拍脑门决定的,而是基于计算机科学中一个著名的观察结论 —— 分代假说 (The Generational Hypothesis)

研究人员发现,在几乎所有的面向对象程序(特别是像 Flutter 这样构建 UI 的程序)中,对象的生命周期呈现出极端的两极分化

  1. 绝大多数对象是“朝生夕死”的: 比如我们在 build() 方法里创建的 Container, Padding, EdgeInsets,它们存在的意义就是为了渲染这一帧画面。下一帧来了,它们就没用了。这些对象占了总量的 90% 以上。
  2. 极少数对象是“长命百岁”的: 比如全局的 UserSession, HttpClient, 图片缓存。它们一旦创建,可能会伴随 App 运行很久。

基于这个事实,Dart 决定不再“一视同仁”地管理所有内存,而是将堆内存物理划分为两个世界:新生代老年代


三、 版图划分:两室一厅的智慧

Dart VM 将堆内存(Heap)切分成了两块截然不同的区域,每一块都有自己的“性格”和“规则”。

Gemini_Generated_Image_4n0wye4n0wye4n0w.png

1. 新生代 (Young Generation / New Space)

  • 定位:对象的“生产室”与“育儿所”。
  • 住户所有刚被 new 出来的对象,必须先在这里出生。没有例外。
  • 特点
    • 空间较小:因为大部分对象马上就会死,不需要太大空间。
    • 内存连续:为了追求极致的分配速度(下一章会讲的指针碰撞)。
    • 流转极快:这里的 GC 频率非常高,策略是“快进快出”。
    • 双空间:方便快速复制整理,涉及Cheney 算法内容(下一章节会讲)。

2. 老年代 (Old Generation / Old Space)

  • 定位:对象的“退休所”或“档案馆”。
  • 住户:只有在新生代经历过“大风大浪”(GC)还没死的对象,才有资格拿到这里的“绿卡”。
  • 特点
    • 空间巨大:用于存放长期数据,甚至可以动态扩容。
    • 管理沉稳:这里的 GC 频率很低,因为大家都很稳定,但这并不意味着它不重要(涉及内存碎片整理)。

四、 为什么这么设计?

试想一下,如果没有分代,所有对象都在一个大池子里: 每次 GC,我们都要遍历几万个 Widget 和几个全局单例。为了清理那 90% 的垃圾 Widget,我们不得不把 10% 的长寿对象也扫描一遍。这就像为了扔掉门口的垃圾袋,却把家里的保险柜也翻了一遍,效率极低。

有了分代模型,Dart 就可以因材施教:

  • 新生代(满地垃圾):采用一种“只捡宝贝,剩下全扔”的激进算法。
  • 老年代(满地宝贝):采用一种“小心核对,整理归档”的保守算法。

小结

这一章我们建立了两条核心认知:

  1. Isolate 让我们拥有了独立的内存空间,互不干扰。
  2. 分代模型 将内存划分为“快”与“慢”两个区域,完美契合了 Flutter UI “高频刷新”的特性。

这就解释了那个经典问题:“为什么 Flutter 官方建议我们将大 Widget 拆分成小 Widget?”

因为拆分出来的小 Widget 都在 新生代。只要我们能证明新生代的创建和销毁是“免费”的,那么拆分 Widget 就只有好处(代码清晰、局部刷新),没有坏处(性能损耗)。

那么,新生代到底凭什么做到“创建极速”且“销毁免费”呢?

下一章,我们将拿上放大镜,深入 新生代 的内部,揭秘那个反直觉的 Scavenge 算法指针碰撞 技术。


Flutter 性能底层(二):新生代的极速魔法 —— 为什么创建对象不需要勇气?

引言:被误解的 new

在上一章的地图中,我们要聚焦在左边那个繁忙的区域——新生代 (New Space)

还记得我们在 build() 方法里随手写下的 new Container(), new Padding() 吗? 很多直觉告诉我们:“申请内存”应该是一个很重的操作。就像去图书馆占座,你得先找管理员,查查哪里有空位,登记名字,然后坐下。如果频繁这么做,肯定会累死。

但在 Dart 的新生代里,**“占座”**这件事被简化到了极致。它不需要查找,甚至不需要管理员介入。

今天,我们将揭秘两项核心技术:让分配快如闪电的 “指针碰撞”,以及让销毁成本归零的 “Scavenge 算法”


一、 分配的艺术:指针碰撞 (Bump Pointer)

还记得我们在第一章图中看到的新生代吗?它的内存空间不仅小,还有一个关键特性:连续

正因为内存是连续的,Dart VM 不需要像老式内存管理那样,去维护一个复杂的“空闲列表(Free List)”来记录哪里有缝隙。

Dart 只需要维护一个简单的指针:Top Pointer(顶部指针)。这个指针永远指向当前内存使用量的末尾。

当你执行 new Widget() 时,VM 内部的对话是这样的:

  1. Widget: “我需要 32 字节的空间。”
  2. VM: “Top 指针现在的地址是 1000。1000 + 32 = 1032。没超出边界。给,你拿去用吧。”
  3. VM: (默默把 Top 指针移到 1032)。

结束。

没有查找,没有遍历,没有碎片整理。整个过程仅仅是几条 CPU 指令(一次加法运算)。

这就像是老式的磁带录音。 你想录一段新歌,根本不需要去整盘磁带里寻找哪里有空白片段。你只需要在当前磁头的位置,按下录音键,顺着往下录就行了。

所以,在 Dart 新生代里分配内存,在时间复杂度上几乎等同于赋值操作。 这就是你敢在 Flutter 每一帧里疯狂 new 对象的底气。


二、 布局的秘密:两个半区 (Semi-Space)

但是,“指针碰撞”有一个致命问题:只能往后录,不能回头。 如果内存填满了(Top 指针撞墙了)怎么办?这时候,GC (垃圾回收) 就必须登场了。

为了解决这个问题,Dart 采用了 Cheney 算法 的变种。它把新生代物理切分成了两个大小完全一样的半区:

  1. 活跃半区 (Active / To Space):当前的“工作车间”,所有新对象都在这里分配。
  2. 空闲半区 (Inactive / From Space):完全是空的,留作备用。

这就解释了第一章那张图中,为什么新生代里总有一半是“灰白色”的空闲状态。这是为了接下来的“乾坤大挪移”做准备。


三、 清理的艺术:Scavenge (搬家算法)

当活跃半区被填满时,GC 哨声吹响。Dart VM 开始执行 Scavenge 操作。

这里的核心逻辑极其反直觉: 传统的清洁工是“找垃圾 -> 扔掉”。 Dart 的 Scavenge 是“找活人 -> 救走”。

过程如下:

  1. 标记存活:GC 从根节点(栈变量)出发,瞬间找出那些还活着的对象(比如还在用的 State,还没销毁的 Widget)。
  2. 复制 (Copying):把这些活着的“幸存者”,从 活跃半区 复制到 空闲半区
  • 注意:复制过去的时候,它们是紧紧挨着排队的。这意味着,原来可能存在的内存碎片,在复制的一瞬间被自动整理好了。
  1. 无视垃圾:那些没人引用的垃圾对象呢?GC 看都不看它们一眼。

Gemini_Generated_Image_f2catbf2catbf2ca.png

四、 最大的反直觉:O(0) 的销毁成本

现在,活对象都搬到了另一边。 原来的活跃半区里,剩下了成吨的垃圾。Dart 需要一个个去销毁它们吗?

不需要。

Dart 只需要做一个动作:身份互换 (Swap)。

  • 原来的空闲半区(现在装满了整齐的活对象) 变成新的 活跃半区
  • 原来的活跃半区(满地垃圾) 变成新的 空闲半区

变成空闲半区意味着什么?意味着 指针归零 (Reset Top Pointer)。 那块内存里的数据(垃圾)还在吗?在。 但在逻辑上,它们已经被视为“空白”了。下次分配新对象时,直接覆盖在它们尸体上。

这就是 O(0) 销毁的奥义。

我们可以得出一个震撼的结论: Dart 清理新生代的耗时,只取决于“活对象”的数量,而与“垃圾”的数量完全无关。

  • 如果你产生了 10 个垃圾,GC 耗时为 T。
  • 如果你产生了 10,000 个垃圾,只要活下来的还是那几个,GC 耗时依然是 T。

这就是为什么 Flutter 官方敢建议我们将大 Widget 拆分成小 Widget。因为那些中间产生的临时小 Widget,对 GC 来说,清理它们的成本是零。


小结

这一章我们见证了新生代的两大魔法:

  1. 进得快:指针碰撞,无需查表。
  2. 死得快:半区复制,无视垃圾。

Dart 的新生代就像一个高效的**“一次性用品流水线”**,完美契合了 Flutter 声明式 UI “用完即扔”的特性。

但是,如果一个对象命很大,在这一轮“大清洗”中活下来了,甚至活了好几轮,该怎么办? 它不能永远在两个半区之间搬来搬去,那样太累了。

下一章,我们将讲述这些幸存者的故事——晋升 (Promotion),以及它们在 老年代 (Old Generation) 将面临的残酷生存法则。


Flutter 性能底层(三):老年代的沉稳智慧 —— 晋升与并发的艺术

引言:幸存者的烦恼

在上一章,我们见识了新生代 Scavenge 算法的“神速”。通过把活对象在两个半区之间搬来搬去,Dart 实现了 O(0) 的垃圾清理。

但是,请设想这样一个场景: 你有一个全局的 UserSession 对象,或者一张缓存的大图。它们在第 1 帧被创建,并且会一直存活到 App 关闭。 如果它们一直留在新生代,就会出现一个很滑稽的现象: GC 每跑一次,这些长寿对象就要被“搬运”一次。 它们像钉子户一样,在两个半区之间反复横跳,不仅浪费了宝贵的 CPU 搬运时间,还长期占据了新生代本就不大的地盘。

为了解决这个问题,Dart 引入了 晋升机制 (Promotion)


一、 晋升:从幼儿园到社会

Dart 的规则很简单:“新生代是留给短命鬼的,只有经历过风浪的对象才配去老年代。”

当一次 Scavenge (新生代 GC) 发生时,Dart 会检查那些幸存的对象:

  • 规则:如果一个对象在上一轮 GC 中已经活下来过(或者其生命周期显式地被标记为长),它就不再会被复制到新生代的另一个半区,而是直接被打包,晋升(Promote)老年代 (Old Generation)

一旦进入老年代,它就安全了。它不会再被频繁地搬来搬去,而是安稳地定居下来。

但是,随着越来越多的对象晋升进来,老年代终究也会满。这时候,老年代的 GC 就必须出手了。


二、 策略突变:标记-清除 (Mark-Sweep)

在老年代,Dart 放弃了新生代那种高效的“半区复制”算法。为什么?

  1. 空间浪费:复制算法需要把内存一分为二。老年代通常很大(几百 MB),如果为了 GC 浪费一半内存,手机内存根本不够吃。
  2. 复制太慢:老年代里 90% 可能都是活对象。要把几百 MB 的数据搬一遍,CPU 绝对会发烧,App 也会卡死。

所以,老年代采用了更成熟、更节省空间的 标记-清除 (Mark-Sweep) 策略。

  • Mark (标记):找出所有活的对象。
  • Sweep (清除):回收死掉对象的内存地址,记录在“空闲列表”里,供下次使用。

其中最核心的难点在于:如何在几百兆内存中快速找出活对象,而且不让 UI 卡顿?


三、 并发标记:三色魔法 (Tri-Color Marking)

如果 Dart 像传统 GC 那样,在扫描老年代时暂停整个 App(Stop The World),用户就会感觉到明显的 掉帧(Jank)

为了解决这个问题,Dart 采用了源自 Dijkstra 的经典理论 —— 三色标记算法 (Tri-Color Marking)。这允许 GC 线程在后台默默工作,而 UI 线程可以同时继续运行。

Dart 将内存中的对象逻辑上分为三种颜色:

  1. 白色 (White)未访问。初始状态下所有对象都是白色。如果扫描结束还是白色,说明是垃圾。
  2. 灰色 (Grey)进行中。对象本身被访问了(活着),但它引用的子对象还没扫描完。这是扫描的“波浪前沿”。
  3. 黑色 (Black)已完成。对象本身和它引用的所有子对象都已扫描完毕。黑色对象是绝对安全的。

魔法过程(边跑边标):

  1. 根节点扫描:GC 把从栈上直接能访问到的对象瞬间染成 灰色
  2. 并发推进:GC 线程从灰色对象出发,把它的子对象染灰,把自己染黑。灰色像一道波浪,推着白色(未知区域)向黑色(安全区域)转化。

Gemini_Generated_Image_cgzgt7cgzgt7cgzg.png

如果 UI 捣乱怎么办?(写屏障 Write Barrier) 既然是并发,就可能出事。比如 GC 刚把一个对象标记为黑色(扫完了),UI 线程突然把一个白色的新对象塞给了这个黑色对象。GC 会以为黑色对象已经没事了,结果漏掉了那个白色对象。

Dart 的对策是 写屏障

UI 线程: “大哥(GC),我对这个黑色对象做了修改,给它指了个新引用。” GC: “收到。把你修改的这个黑色对象重新标灰,我稍后会重新扫描它。”

正是这套机制,让 Flutter 在进行大规模内存扫描时,依然能保持流畅。


四、 整理:治愈“瑞士奶酪” (Compaction)

当标记完成后,所有白色的对象就是垃圾。Dart 会把它们的地址回收。 但是,随着时间推移,老年代会变成一块 “瑞士奶酪” —— 到处都是小的空洞(内存碎片)。

如果你突然想存一张高清大图(需要连续的大块内存),虽然空洞加起来总和够大,但没有一块连续区域能放得下。这就叫 OOM (Out Of Memory)

这时候,Dart 会祭出最后的大招:整理 (Compaction)

动作: GC 会暂停世界,把所有活着的(黑色)对象,强行往内存的一端推移,把它们挤在一起,把所有的空隙都挤到另一端去。

Gemini_Generated_Image_aetoh4aetoh4aeto.png

代价: 这是一个非常昂贵的操作。所以,Dart 并不是每次 GC 都做整理,只有在碎片化非常严重,或者内存即将耗尽时才会触发。


小结

老年代的世界没有“唯快不破”的热血,只有“精打细算”的权衡。

  • 晋升:避免了长寿对象的无意义搬运。
  • 并发标记:利用三色模型,让 GC 和 UI 线程和谐共处。
  • 整理:解决内存碎片,防止 OOM。

至此,我们已经看懂了 Dart 内存管理的空间维度

但还有一个时间维度的谜题没有解开: Flutter 每一帧只有 16ms,Dart 到底是在这 16ms 的哪个时间缝隙里偷偷执行这些 GC 操作的?

下一章(终章),我们将揭秘 Flutter Engine 的调度机制 —— 看它如何利用 Idle Time(空闲时间) 做到“在用户眨眼的瞬间打扫完房间”。


Flutter 性能底层(四):终极调度 —— 时间缝隙里的生存游戏

引言:16 毫秒的生死线

做过动画或游戏开发的都知道,16.6ms 是一条不可逾越的生死线。 为了保持 60fps 的流畅体验,系统每隔 16.6ms 就会发出一个 VSync(垂直同步)信号。

Flutter 的渲染管线(Build -> Layout -> Paint)必须在这段时间内跑完。

  • 跑完了,屏幕刷新,画面丝般顺滑。
  • 跑不完,新的一帧无法生成,屏幕重复显示上一帧。这就是用户眼中的 “掉帧” (Jank)

这时候,一个终极矛盾出现了: GC(垃圾回收)也是要消耗 CPU 的。如果我的业务代码已经很重了,GC 还要横插一杠,岂不是必然导致卡顿?

Dart 的回答是:我会看脸色,也会在绝境中求生。


一、 顺风局:引擎与 VM 的“空闲密谋” (Idle-Time GC)

在大多数正常的场景下,Flutter 的每一帧并不会填满 16ms。也许你的代码很高效,只用了 10ms 就完成了所有渲染工作。

那么,剩下的 6.6ms 去哪了? 这段时间,CPU 处于空闲状态,等待下一个 VSync 信号。这就是 “空闲时间 (Idle Time)”

Flutter Engine(渲染管家)极其聪明,它利用这段时间与 Dart VM(内存管家)进行了一次完美的配合:

  1. 计算余额:Engine 发现:“当前帧已提交,距离下一帧还有 6ms。”
  2. 发送信号:Engine 通知 VM:“嘿,我有 6ms 空档,你要不要打扫卫生?”
  3. 见缝插针:VM 评估发现新生代 GC 只需要 2ms,于是果断执行 Scavenge

Gemini_Generated_Image_rmlt07rmlt07rmlt.png

结果:GC 在用户感知的“死角”里完成了。用户觉得 App 极其流畅,完全感觉不到内存被清理了。这是 Dart 最理想的工作状态。


二、 逆风局:当没有空闲时间时 (Forced GC & Jank)

但是(关键的转折来了),现实并不总是完美的。

假设你写了一个极其复杂的列表,或者正在进行大量的 3D 变换计算,导致 BuildLayout 阶段就已经消耗了 15ms。 这时候,Engine 根本没有多余的时间留给 VM。

更糟糕的是,在这个高强度的计算过程中,你的代码还在疯狂地 new 对象(比如在循环里创建临时变量)。

突然,新生代(New Space)满了。 指针指到了尽头,物理上无法再分配下一个对象的内存了。

这时候,Dart VM 别无选择,必须启动 “应急预案”。这个过程不再是协商,而是强制执行

强制 GC (Stop The World)

  1. 立即挂起:VM 必须强制暂停当前的 UI 线程。哪怕现在正画到一半,也得停下来,因为没有内存可用了。
  2. 原地清理:VM 立即执行 Scavenge 算法,清理新生代。
  3. 惨痛代价
  • 业务代码耗时:15ms。
  • 强制 GC 耗时:3ms。
  • 总耗时:18ms

Gemini_Generated_Image_8n5fsg8n5fsg8n5f.png

结局:总时间超过了 16.6ms 的红线。VSync 信号来了,但新画面没准备好。用户看到了明显的 卡顿


三、 绝境局:老年代的“内存换时间”策略

如果不是新生代满了,而是 老年代(Old Gen) 满了呢? 老年代的 GC(标记-整理)非常慢,可能耗时 100ms 以上。如果在 UI 运行过程中触发这个,App 会直接卡死一瞬间。

为了避免这种灾难性的体验,Dart 采用了一种 “能拖就拖” 的策略:

  1. 优先扩容:如果老年代满了,但当前没有空闲时间,Dart 通常不会立即触发老年代 GC。相反,它会向操作系统申请更多的物理内存,临时扩大老年代的容量(Hard Limit)。
  2. 扛过高峰:先把新晋升的对象塞进去,硬扛过这一波高负载的动画或计算。
  3. 秋后算账:等到后面终于有空闲时间了,或者内存占用实在太大触碰到了 OOM(内存溢出)的红线,才会强制执行大规模 GC。

这也解释了为什么 Flutter App 有时候内存占用会飙升: 因为它在用 空间(内存) 换取 时间(流畅度)。它宁愿多吃点内存,也不想让用户感到卡顿。


四、 总结:开发者的责任

看完这整个系列,我们可以得出一个最终结论:

Dart 的内存管理机制是“为 UI 而生”的,它构筑了三道防线:

  1. 极速分配(微观):指针碰撞,快如闪电。
  2. 并发标记(宏观):三色算法,减少阻塞。
  3. 空闲调度(终极):见缝插针,隐藏成本。

但是,Dart 救不了“作死”的代码。 如果你的代码在每一帧里都塞满了繁重的计算,并且毫无节制地分配内存,导致“空闲时间”消失,那么 强制 GC掉帧 就是必然的物理结果。

所以,作为 Flutter 开发者,我们在享受便利的同时,依然要遵守基本法:

  1. **多用 const**const 对象不占新生代,不给 GC 添堵。
  2. **避免循环内 new**:这会瞬间填满新生代,触发强制 GC。
  3. 拆分耗时任务:别让 UI 线程过载,给 Engine 留一点喘息(和 GC)的时间。

React状态管理指南

文章顶部.png作者卡片

React状态管理指南

引言

在React开发中状态管理一直是开发者面临的核心挑战之一。你是否遇到过这些困惑:

  • 什么时候该用useState,什么时候该用useReducer?

  • Context会导致性能问题吗?什么场景下应该使用?

  • 面对Redux、Zustand、Jotai、Recoil等众多状态库,该如何选择?

  • 服务器数据应该用React Query还是SWR?

  • 如何避免"prop drilling"(属性透传地狱)?

2025年,React生态的状态管理方案已经相当成熟,但选择的多样性也带来了决策的困难。本指南将系统地梳理React状态管理的核心概念、最佳实践,以及不同场景下的技术选型,帮助你建立清晰的状态管理思维模型。

一、理解React状态

1.1 什么是状态?

在 React 中我们可以用一个公式来描述状态和 UI 的关系,即:UI = f(状态)

React 的一个核心概念是幂等性,也就是说在 React Component 中,相同的输入(propsstatecontext)总会得到相同的输出,那我们可以用一个公式来表达:UI = f(props, state, context)

因此在 React 中状态其实本质上只有三种:propsstatecontext,即使我们日常使用的各种状态管理库背后也是使用这些。

1.2 单向数据流原则

React状态管理的基石是单向数据流——状态只能从父组件通过props传递给子组件,子组件不可直接修改父组件状态。这种设计确保数据流向可预测。

错误示例:子组件直接修改props

// ❌ 错误:子组件直接修改props
const TodoItem = ({ todo }) => {
  const handleToggle = () => {
    todo.isCompleted = !todo.isCompleted; // 直接修改导致状态不可追踪
  };
  return <input type="checkbox" checked={todo.isCompleted} onChange={handleToggle} />;
};

正确示例:通过回调函数通知父组件更新

// ✅ 正确:子组件通过回调传递状态变更请求
const TodoItem = ({ todo, onToggle }) => {
  const handleToggle = () => {
    onToggle(todo.id); // 仅传递变更标识,由父组件统一处理状态
  };
  return <input type="checkbox" checked={todo.isCompleted} onChange={handleToggle} />;
};

// 父组件中实现状态更新逻辑
const TodoList = () => {
  const [todos, setTodos] = useState([{ id: 1, title: "学习React状态管理", isCompleted: false }]);
  
  const toggleTodo = (id) => {
    setTodos(prevTodos => 
      prevTodos.map(todo => 
        todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo
      )
    );
  };
  
  return todos.map(todo => <TodoItem key={todo.id} todo={todo} onToggle={toggleTodo} />);
};

1.3 状态的三大分类

根据使用范围和管理方式,状态可以划分为三类:

1. 局部状态 (Component State)

仅在单个组件内使用和维护的状态。

  • 使用场景: 表单输入、UI交互(展开/折叠)、临时计算结果

  • 管理方式useStateuseReducer

  • 特点:生命周期与组件绑定,组件销毁时状态也消失

const Counter = () => {
  const [count, setCount] = useState(0); // 局部状态
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
};

2. 全局状态 (Global State)

需要在多个组件间共享的状态。

  • 使用场景:用户信息、主题配置、语言设置、购物车

  • 管理方式:Context API、Redux、Zustand、Jotai等

  • 特点:独立于组件生命周期,多个组件可同时访问和修改

// 使用 Context 管理全局主题
const ThemeContext = createContext();

const App = () => {
  const [theme, setTheme] = useState('light');
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Header />
      <Main />
      <Footer />
    </ThemeContext.Provider>
  );
};

3. 服务器状态 (Server State)

从服务器获取并在前端展示的数据。

  • 使用场景:API数据、数据库查询结果、实时数据流

  • 管理方式:React Query (TanStack Query)、SWR、Apollo Client

  • 特点:需要处理加载、缓存、同步、过期、重新验证等问题

// 使用 React Query 管理服务器状态
const UserProfile = ({ userId }) => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
  });
  
  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  
  return <Profile user={data} />;
};

二、局部状态管理

局部状态是React中最基础也是最常用的状态类型。掌握好局部状态管理是一切的基础。

2.1 useState

基础用法

setState更新数据,reRender组件更新视图

import { useState } from 'react';

const Counter = () => {
  // 声明状态
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
};

复杂的状态初始化场景

使用函数式写法,这样只有第一次reRender的时候react才会去调用初始化函数,否则每次reRender都会造成无意义的函数调用,useState只会初始化一次

// ❌ 不好:每次渲染都会调用 expensiveCalculation
const [data, setData] = useState(expensiveCalculation());

// ✅ 好:只在初始化时调用一次
const [data, setData] = useState(() => expensiveCalculation());

// 实际例子
const [todos, setTodos] = useState(() => {
  const saved = localStorage.getItem('todos');
  return saved ? JSON.parse(saved) : [];
});

上面useState例子的错误写法也只是进行了多余的计算,对功能上没有实际影响,下面来看看笔者在工作中遇到的对业务造成影响的useRef的一个例子

useRef初始化直接new BusinessPoint,useBusinessEndPoint是一个hook,会跟随使用到的组件reRender而重新执行,这样会导致有多个BusinessPoint类的实例被创建,即使useRef只会被赋值第一次创建的BusinessPoint实例,但new BusinessPoint过程中进行了postMessage事件的监听,导致消息有了多个handler,接受消息的回调函数会被执行多次,可能对业务使用造成未知的影响

const useBusinessEndPoint = (config: Config) => {
  const business = useRef<BusinessPoint>(new BusinessPoint(config));
  useEffect(() => {
    return () => {
      business.current?.destroy();
      business.current = null;
    };
  }, []);
  const sendMessage = (params?: ReqData['params']) => {
    return business.current?.sendMessage(params);
  };
  return {
    sendMessage,
  };
};

那么如何解决呢,比较尴尬的是useRef不支持useState的函数式惰性初始化的写法,所以我们需要手动实现惰性初始化

import * as React from 'react';

export function useBusinessEndPoint(config: Config) {
  const businessRef = React.useRef<BusinessPoint | null>(null);

  // ✅ 懒初始化:只在第一次 render 时创建一次
  if (businessRef.current === null) {
    businessRef.current = new BusinessPoint(config);
  }

  const sendMessage = React.useCallback((params?: ReqData['params']) => {
    return businessRef.current?.sendMessage(params);
  }, []);

  return { sendMessage };
}


// 这样把初始化过程放在render流程中可能不是太好,可以放在useEffect中

export function useBusinessEndPoint(config: Config) {
  const ref = React.useRef<BusinessPoint | null>(null);

  React.useEffect(() => {
    ref.current = new BusinessPoint(config);

    return () => {
      ref.current?.destroy();
      ref.current = null;
    };
  }, []); 
  const sendMessage = React.useCallback((params?: ReqData['params']) => {
    return ref.current?.sendMessage(params);
  }, []);

  return { sendMessage };
}


或者使用ahooks提供useCreation,专门提到解决此类问题

image.png

函数式回调更新

setState 除了直接传新值,还可以传一个函数:setCount(prev => prev + 1);

这个函数会拿到最新的上一次状态prev),并返回下一次状态。

为什么会需要它:state 更新是“排队 + 批处理”的

React 更新 state 并不保证“立刻生效”。同一轮事件里多次 setState 往往会合并/批处理,这会导致你用外层变量计算新值时,读到的是同一个旧快照

// 购物车场景,正常cartTotal的值应该基于cart数据在函数渲染过程中派生出来,但cartTotal也完全可能是一个state的情况下,比如折扣券抵扣,直接setCartTotal,就可能出现下面的情况
const addToCart = (item) => {
  // 1. 更新商品列表
  setCart([...cart, item]);

  ❌ 错误写法(基于旧的 cart,更新丢失)
  // 2. 更新总价
  setCartTotal(cart.reduce((sum, p) => sum + p.price, 0));
};

✅ 正确写法(函数式更新)

const addToCart = (item) => {
  setCart(prevCart => {
    const exists = prevCart.find(p => p.id === item.id);
    let nextCart;

    if (exists) {
      nextCart = prevCart.map(p =>
        p.id === item.id ? { ...p, quantity: p.quantity + 1 } : p
      );
    } else {
      nextCart = [...prevCart, { ...item, quantity: 1 }];
    }

    // 这里甚至可以顺手更新总价
    setCartTotal(
      nextCart.reduce((sum, p) => sum + p.price * p.quantity, 0)
    );

    return nextCart;
  });
};

上面的例子中,可能会认为可以先暂存更新后的状态,然后进行更新,看起来完全不需要函数式写法拿到最新值,这在某些前提下是可以的,但它不能替代函数式更新,原因在于:这里的 cart 仍然来自当前这次 render 的快照。如果存在“同一时间可能有别的更新也在改 cart”(并发、批处理、异步回调、快速连点、订阅消息等),这个快照就可能是旧的,从而出现“丢更新”

const addToCart = (item) => {
  // 暂存更新后的结果
  const cur = [...cart, item]
  setCart(cur);

  setCartTotal(cur.reduce((sum, p) => sum + p.price, 0));
};

什么时候这种 temp 写法是安全的?

基本要满足这些条件(越多越安全):

  • 更新只会从这个 handler 触发(不会有别的地方同时 setCart)

  • 没有异步回调里也在改 cart(比如 SSE/WebSocket/定时器/Promise)

  • 不需要承受快速连点导致的“多次 enqueue 更新”

  • 逻辑是“直接替换成一个完全由当前 cart 推导出的新数组”,且不会有并发写入

更常见的坑:异步回调里的“闭包旧值”

函数组件每次reRender都会产生一套新的变量快照。异步回调(定时器、Promise、事件监听)里引用的 state,可能永远是当时那一帧的旧值

经典的计时器的例子

useEffect(() => {
  const id = setInterval(() => {
    // 引用的count永远是第一次渲染时count的值
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);



✅ 简单写法:
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

// 当然还有很多种写法能够解决这个闭包问题

//依赖更新,创建新的定时器,这时候的闭包中就能够拿到最新的count数据了
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);



// ref配合解决,不受闭包影响,不过代码逻辑实现不够优雅,冗余出了一个违反直觉的变量
const [count, setCount] = useState(0);
const countRef = useRef(count);

useEffect(() => {
  countRef.current = count; // 每次渲染同步最新值
}, [count]);

useEffect(() => {
  const id = setInterval(() => {
    setCount(countRef.current + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

下面是一个实际业务场景中相对不易被察觉的闭包问题,使用函数式更新解决

const [state, setState] = useSetState({
  accumulatedSchema: [] as any[],
});
const { accumulatedSchema } = state;

const renderTable = async (code: string) => {
  const result = await fileStructApi({ taskId, elementModule: code });

  // ❌ accumulatedSchema 为旧快照,startMarkdownRender函数内执行循环,内层函数始终不会随着reRender而生成新的函数引用,引用的外层state也始终是第一次的
  setState({
    accumulatedSchema: [...accumulatedSchema, result],
  });
  ✅ 正确写法
  // setState((prevState) => {
  //   const newSchema = [...prevState.accumulatedSchema, result];
  //   return {
  //     accumulatedSchema: newSchema,
  //   };
  // });
};


async function startMarkdownRender(tid: string) {
  for (const code of ['A', 'B', 'C']) {
    const { done } = await fetchMarkdown(tid, code);
    if (done) {
      await renderTable(code);
    }
  }
}

useEffect(() => {
  startMarkdownRender();
},[])


多字段&大嵌套对象

多字段:一个页面/组件同时维护 loading、分页、筛选、弹窗、列表数据……很快堆成一排 useState

常见写法:一堆 useState

以“列表页”举例:筛选条件、分页、加载状态、抽屉开关、选中行、数据源。

function ListPage() {
  const [loading, setLoading] = useState(false);
  const [pageNo, setPageNo] = useState(1);
  const [pageSize, setPageSize] = useState(20);
  const [keyword, setKeyword] = useState('');
  const [status, setStatus] = useState<'all' | 'open' | 'closed'>('all');
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
  const [dataSource, setDataSource] = useState<any[]>([]);

  const onSearch = async () => {
    setLoading(true);
    try {
      const data = await fetchList({ pageNo, pageSize, keyword, status });
      setDataSource(data);
    } finally {
      setLoading(false);
    }
  };

  const onReset = () => {
    setKeyword('');
    setStatus('all');
    setPageNo(1);
    setSelectedRowKeys([]);
  };

  // ...
}

这种写法没错,但问题是:

  • 状态分散在很多地方,重置/联动操作要写很多行

  • 很多更新是“更新一两个字段”,写起来啰嗦

  • 多个字段需要同时变更时,可读性下降(更容易漏改)

 useSetState:把同类字段收拢 + 支持“部分更新”

ahooks 的 useSetState 很适合这种“对象型 state、经常只改局部字段”的场景。

import { useSetState } from 'ahooks';

type Status = 'all' | 'open' | 'closed';

function ListPage() {
  const [state, setState] = useSetState({
    loading: false,
    pageNo: 1,
    pageSize: 20,
    keyword: '',
    status: 'all' as Status,
    drawerOpen: false,
    selectedRowKeys: [] as string[],
    dataSource: [] as any[],
  });

  const onSearch = async () => {
    setState({ loading: true });
    try {
      const data = await fetchList({
        pageNo: state.pageNo,
        pageSize: state.pageSize,
        keyword: state.keyword,
        status: state.status,
      });
      setState({ dataSource: data });
    } finally {
      setState({ loading: false });
    }
  };

  const onReset = () => {
    setState({
      keyword: '',
      status: 'all',
      pageNo: 1,
      selectedRowKeys: [],
    });
  };

  const onOpenDrawer = () => setState({ drawerOpen: true });
  const onCloseDrawer = () => setState({ drawerOpen: false });

  // ...
}

优点:

  • 同类状态集中管理,代码更聚合

  • setState({ a: 1 }) 是“局部 patch”,不用每次展开对象

  • 重置/联动特别舒服:一次 setState 改多个字段

什么时候不建议用?

  • 当某些字段更新非常频繁且彼此独立(比如高频输入 + 大对象),拆分 useState 可能更利于性能与可读性

  • 当 state 结构非常深:useSetState 的 patch 是浅合并,深层更新仍然麻烦(下一节解决)

大嵌套对象:不可变更新写法又长又容易写错

痛点例子:复杂表单草稿/配置对象

比如一个“提取配置”草稿,结构很深:

const [draft, setDraft] = useState({
  projectId: 'p1',
  rules: {
    buyer: { enabled: true, fields: { name: true, phone: false } },
    supplier: { enabled: false, fields: { company: true } },
  },
  ui: {
    drawer: { open: false, width: 520 },
  },
});

你要更新一个深层字段,比如把 buyer.fields.phone 设为 true

❌ 传统不可变更新(又长又绕)

setDraft(prev => ({
  ...prev,
  rules: {
    ...prev.rules,
    buyer: {
      ...prev.rules.buyer,
      fields: {
        ...prev.rules.buyer.fields,
        phone: true,
      },
    },
  },
}));

问题:

  • 写起来太长,业务代码被“展开语法”淹没

  • 很容易漏展开一层导致引用被复用,出现不可预期的 UI 不更新

用 Immer:像写“可变操作”,实际产生不可变新对象

Immer 的核心:给你一个 draft(草稿代理),你可以直接改它,但最终产出的是不可变的新 state。

有两种常用方式:

方式 A:直接用 immer 的 produce

import { produce } from 'immer';

setDraft(prev =>
  produce(prev, (draft) => {
    draft.rules.buyer.fields.phone = true;
  })
);

方式 B:用 useImmer(更顺手)

import { useImmer } from 'use-immer';

const [draft, updateDraft] = useImmer({
  projectId: 'p1',
  rules: {
    buyer: { enabled: true, fields: { name: true, phone: false } },
    supplier: { enabled: false, fields: { company: true } },
  },
  ui: {
    drawer: { open: false, width: 520 },
  },
});

// 更新深层字段:极其清爽
const enableBuyerPhone = () => {
  updateDraft(draft => {
    draft.rules.buyer.fields.phone = true;
  });
};

// 同时改多个深层字段也很自然
const openDrawerAndResize = () => {
  updateDraft(draft => {
    draft.ui.drawer.open = true;
    draft.ui.drawer.width = 640;
  });
};

优点:

  • 深层更新变得像“直接赋值”,可读性提升非常明显

  • 不用手动展开每一层对象

  • 同一个更新里可以改多个深层字段,代码很像业务描述

注意点:

  • Immer 适合“结构复杂的对象状态”。如果 state 非常简单(一个数字/字符串),没必要引入

  • draft 里尽量只做同步修改(不要在 producer 回调里写 await/异步)

总结:

  • 多字段但结构不深:优先 useSetState(局部 patch、重置联动舒服)

  • 结构深、嵌套多、需要频繁深层更新:用 Immer(produce/useImmer)

2.2 useReducer

useReducer 可以把它理解成:把状态更新规则集中到一个 reducer 里,用 dispatch 触发更新。它适合“状态复杂/更新逻辑多”的场景,比各处地方进行一堆 setState 更可维护。

核心概念

  • state:当前状态

  • action:一次“意图/事件”(比如 { type: 'add' }{ type: 'setKeyword', payload: 'xx' }

  • reducer(state, action):根据旧 state 和 action 计算新 state 的纯函数

  • dispatch(action):发起一次更新

一个贴合业务的例子:请求状态机

import React, { useEffect, useReducer } from 'react';

type State =
  | { status: 'idle'; data: null; error: null }
  | { status: 'loading'; data: null; error: null }
  | { status: 'success'; data: any[]; error: null }
  | { status: 'error'; data: null; error: string };

type Action =
  | { type: 'start' }
  | { type: 'success'; payload: any[] }
  | { type: 'error'; payload: string }
  | { type: 'reset' };

const initialState: State = { status: 'idle', data: null, error: null };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'start':
      return { status: 'loading', data: null, error: null };
    case 'success':
      return { status: 'success', data: action.payload, error: null };
    case 'error':
      return { status: 'error', data: null, error: action.payload };
    case 'reset':
      return initialState;
    default:
      return state;
  }
}

// 模拟请求:1s 后随机成功/失败
function fakeFetchList(): Promise<any[]> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const ok = Math.random() > 0.3;
      if (ok) resolve([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]);
      else reject(new Error('Network error'));
    }, 1000);
  });
}

export default function RequestStateMachineDemo() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const load = async () => {
    dispatch({ type: 'start' });
    try {
      const data = await fakeFetchList();
      dispatch({ type: 'success', payload: data });
    } catch (e: any) {
      dispatch({ type: 'error', payload: e?.message || 'Unknown error' });
    }
  };

  useEffect(() => {
    // 首次进入自动拉一次
    load();
  }, []);

  return (
    <div style={{ fontFamily: 'sans-serif', padding: 12 }}>
      <h3>useReducer 请求状态机</h3>

      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <button onClick={load} disabled={state.status === 'loading'}>
          {state.status === 'loading' ? 'Loading...' : 'Reload'}
        </button>
        <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
      </div>

      <div>
        <div>
          <b>Status:</b> {state.status}
        </div>

        {state.status === 'idle' && <div>点击 Reload 发起请求</div>}

        {state.status === 'loading' && <div>正在请求中…</div>}

        {state.status === 'success' && (
          <ul>
            {state.data.map((it) => (
              <li key={it.id}>
                {it.id} - {it.name}
              </li>
            ))}
          </ul>
        )}

        {state.status === 'error' && (
          <div style={{ color: 'crimson' }}>
            <b>Error:</b> {state.error}
          </div>
        )}
      </div>
    </div>
  );
}

什么时候用 useReducer 更合适

  • 状态是对象且字段多,并且更新路径多(表单、列表页、复杂 UI)

  • 更新需要“按事件”处理:比如 FETCH_START/FETCH_SUCCESS/FETCH_ERROR

  • 想把更新逻辑集中,避免散落在多个 setXxx 里

2.3 useMemo性能优化

从官方的文档中可以看到useMemo这个hooks的定义:它在每次重新渲染的时候能够进行缓存计算的结果。useCallback是useMemo的语法糖,用来方便缓存函数引用的

image.png

主要的应用场景如下

1.跳过昂贵的重新计算

2.跳过组件的重渲染

跳过昂贵的重新计算

假设有 2 万条数据,每次渲染都要做 filter + sort(O(n log n)),如果你在组件里直接写,任何 state 改动都会重复计算。

❌ 不用 useMemo:每次 render 都重新算

❌ 不用 useMemo:每次 render 都重新算
import React, { useState } from "react";

function expensiveFilterAndSort(list, keyword) {
  // 模拟昂贵计算:filter + sort + 额外 CPU
  const t0 = performance.now();

  const result = list
    .filter((x) => x.name.toLowerCase().includes(keyword.toLowerCase()))
    .sort((a, b) => a.score - b.score);

  // 模拟额外 CPU(别在生产这么写)
  let sum = 0;
  for (let i = 0; i < 3_000_000; i++) sum += i;

  const t1 = performance.now();
  console.log("计算耗时(ms):", (t1 - t0).toFixed(1));

  return result;
}

export default function Demo({ data }) {
  const [keyword, setKeyword] = useState("");
  const [count, setCount] = useState(0);

  // 🚨 任意 state 变化都会触发昂贵计算(包括 count++)
  const visible = expensiveFilterAndSort(data, keyword);

  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setCount((c) => c + 1)}>count++: {count}</button>

      <div>结果数:{visible.length}</div>
      <ul>
        {visible.slice(0, 20).map((x) => (
          <li key={x.id}>{x.name} - {x.score}</li>
        ))}
      </ul>
    </div>
  );
}

你点 count++ 会发现控制台也在打印“计算耗时”,说明它每次都在算(而这个计算跟 count 根本无关)。

✅ 用 useMemo:keyword 不变就复用上次结果

import React, { useMemo, useState } from "react";

function expensiveFilterAndSort(list, keyword) {
  const t0 = performance.now();

  const result = list
    .filter((x) => x.name.toLowerCase().includes(keyword.toLowerCase()))
    .sort((a, b) => a.score - b.score);

  let sum = 0;
  for (let i = 0; i < 3_000_000; i++) sum += i;

  const t1 = performance.now();
  console.log("计算耗时(ms):", (t1 - t0).toFixed(1));

  return result;
}

export default function Demo({ data }) {
  const [keyword, setKeyword] = useState("");
  const [count, setCount] = useState(0);

  // ✅ 只有 data / keyword 变才重算
  const visible = useMemo(() => {
    return expensiveFilterAndSort(data, keyword);
  }, [data, keyword]);

  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setCount((c) => c + 1)}>count++: {count}</button>

      <div>结果数:{visible.length}</div>
      <ul>
        {visible.slice(0, 20).map((x) => (
          <li key={x.id}>{x.name} - {x.score}</li>
        ))}
      </ul>
    </div>
  );
}

效果:

  • 首次渲染:照样会算一次(所以不会提升首次渲染速度)

  • 之后你点 count++不会再打印“计算耗时”(因为 data/keyword 没变)

  • 只有你改输入框 keyword 才会重新计算

跳过组件的重渲染

useMemo缓存props的值,需要配合React.memo才能防止组件的重渲染

import React, { useCallback, useState, memo } from "react";

const ExpensiveChild = memo(function ExpensiveChild({ title, onClick }) {
  console.log("子组件渲染了");

  return (
    <div style={{ border: "1px solid #ccc", padding: 8, marginTop: 8 }}>
      <div>{title}</div>
      <button onClick={onClick}>child button</button>
    </div>
  );
});

export default function Parent() {
  const [count, setCount] = useState(0);

  // ✅ 用 useCallback 保证函数引用稳定,否则每次 render 都是新函数 -> memo 失效
  const handleChildClick = useCallback(() => {
    console.log("child click");
  }, []);

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>
        count++: {count}
      </button>

      <ExpensiveChild title="我不会跟着 count 重新渲染" onClick={handleChildClick} />
    </div>
  );
}

效果:

  • 点击 count++:父组件会重渲染

  • 子组件不会再打印 “子组件渲染了”(因为 title 没变,onClick 引用也没变)

常见错误处理(重点)

当组件重渲染时,对于非组件的普通元素节点都会被重渲染,无视props

const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return <button onClick={onClick}>Click me</button>
};

这里子组件被memo包裹,onClick也被useCallback包裹,value并没有被包裹,这个时候,你的Component重渲染,你的MemoItem仍然会重渲染,此时useCallback还是什么都没做

const Item = () => <div> ... </div>
const MemoItem = React.memo(Item)
const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return <MemoItem onClick={onClick} value={[1,2,3]}/>
};

这次看上去似乎没有问题,onClickuseCallback包裹了,然后MemoItem也被memo

const Item = () => <div> ... </div>
const MemoItem = React.memo(Item)
const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return 
  <MemoItem onClick={onClick}>
    <div>something</div>
  </MemoItem>
};

然而还是会造成重渲染,因为实际也是通过props传递的

// 以下写法均等价,也就是说在props中传递children,和直接children嵌套是一致的
React.createElement('div',{
  children:'Hello World'
})

React.createElement('div',null,'Hello World')

<div>Hello World</div>
const Item = () => <div> ... </div>
const MemoItem = React.memo(Item) // useless
const Component = () => {
  const onClick = useCallback(() => { //useless
    /* do something */
  }, []);
  return 
  <MemoItem 
    onClick={onClick} 
    children={<div>something</div>}
  />
};

那么是不是把子组件也memo包裹就可以了呢

const Item = () => <div> ... </div>
const Child = () => <div>sth</div>

const MemoItem = React.memo(Item)
const MemoChild = React.memo(Child)

const Component = () => {
  const onClick = useCallback(() => { 
    /* do something */
  }, []);
  return (
    <MemoItem onClick={onClick}>
      <MemoChild />
    </MemoItem>
  )
};

答案还是没有阻止重渲染,为什么呢?来我们把MemoChild单独拿出来解析一下,它是怎么执行的:

const child = <MemoChild />;
// 只是MemoChild不变
const child = React.createElement(MemoChild,props,childen);
const child = {
  type: MemoChild,
  props: {}, // same props
  ... // same interval react stuff
}

前面的问题也迎刃而解,其实每次create的时候,React.createElement(MemoChild,props,childen);只是MemoChild被我们memo了,创建的child都是不一样的对象

如果你真的想要阻止重渲染,你应该memo的目标是Element本身,而不是ComponentuseMemo会缓存之前的值,如果memo的依赖项没有变化,则用缓存的数据返回。

const Child = () => <div>sth</div>

const MemoItem = React.memo(Item)

const Component = () => {
  const onClick = useCallback(() => { 
    /* do something */
  }, []);
  const child = useMemo(()=> <Child /> ,[])
  return (
    <MemoItem onClick={onClick}>
      {child}
    </MemoItem>
  )
};

所以实际上useMemo防止组件重渲染好用吗,你辛苦对每个属性的处理,接手项目的人只需要随手丢props,就回到了最初的起点。

你应该在所有地方加上 useMemo 吗?

总的来说,如果是基础的中后台应用,大多数交互都比较粗糙,通常不需要。如果你的应用类似图形编辑器,大多数交互是颗粒状的(即移动形状),那么此时使用Memo可能会带来很大的帮助。

使用useMemo进行优化仅在极少数情况下有价值:

  • 显然你知道这种计算非常昂贵,而且它的依赖关系很少改变。

  • 如果当前的计算结果将作为memo包裹组件的props提交。计算结果不改变,可以利用useMemo缓存结果,跳过重渲染。

  • 当前计算的结果作为某些hook的依赖项。比如其他的useMemo/useEffect依赖当前的计算结果。

三、全局状态管理

3.1 Context

使用

React Context 是我们在项目中所离不开的一种技术方案,常用来解决状态共享,特别是那些需要在多个不直接关联的组件间共享状态,以及 prop drilling(prop 下钻) 问题。

为什么要从 React Context 开始讲起呢?

诸如 React Context 与 Zustand、Jotai、Valtio 等等都是属于全局状态管理,看似它们都能解决组件之间共享状态的问题。而这些状态管理库的发明某种程度上是为了解决 React Context 的局限性以及性能问题。

在日常代码中经常可以看到数据在多个组件层级中进行传递,即数据会从顶层组件传递到深层子组件,这是一种非常不好的实践,如果你发现同事这么写代码请及时制止!例如,现在你有深度为 5 层的组件树,顶层 ComponentA维护了一个状态 count,在 ComponentE 中需要使用到这个状态,这时候通过一层层的传递来达到这个目的:

function ComponentA() {
  const count = 10;
  return <ComponentB count={count} />
}

function ComponentB({ count }) {
  return <ComponentC count={count} />
}

function ComponentC({ count }) {
  return <ComponentD count={count} />
}

function ComponentD({ count }) {
  return <ComponentE count={count} />
}

function ComponentE({ count }) {
  return <div>{count}</div>
}

image.png

可以看到,在上面的例子中,count 属性从 ComponentA -> ComponentB -> ComponentC -> ComponentD -> ComponentE 中逐层传递。这就是典型的 prop drilling(prop 下钻)。

因此,不难理解,prop drilling 会很容易引发以下几个问题。

  • 产生性能问题。当你在多个组件间传递同一个属性时,这个属性变化会导致所有组件发生 re-render,即使某些组件没有真正使用到该值,这会导致不必要的开销,从而造成性能问题

  • 减少可维护性。prop drilling 会导致你的代码可读性变差,应用变得难以维护,尤其是应用变得足够复杂时,你会发现很难增加新的功能或者改变现有的逻辑,并且容易滋生 Bug,定位和解决 Bug 也变得更加困难。

  • 增加心智负担。当跨多个组件传递同一个值时,你需要在每一个组件中添加额外的 props,即使这些组件并没有直接使用到。你会发现需要花费更多精力来追踪这个值的去向,以及这个值在哪个组件中真正被使用到,这无疑会在开发和维护中带来更多的心智负担。

而 React Context 就常用来解决这种状态共享(特别是需要在多个不直接关联的组件间共享的状态),以及 prop drilling 问题。

遗憾的是,虽然 Context 非常实用,帮助我们解决了很多问题,但是当 React Context 中任意属性发生变化时,会引起所有使用到该 Context 的组件发生 re-render,即重新渲染。但是我们希望当只有组件关心的值(或者说实际使用到的值)发生变化才会导致组件发生 re-render

关于这种重新渲染的性能问题,可以结合下面这个例子来看下,或许你在业务过程也曾遇到过

import { createContext, useContext, useState } from "react";

const context = createContext(null);

const Count1 = () => {
  const { count1, setCount1 } = useContext(context);
  console.log("Count1 render");
  return <div onClick={() => setCount1(count1 + 1)}>count1: {count1}</div>;
};

const Count2 = () => {
  const { count2 } = useContext(context);
  console.log("Count2 render");
  return <div>count2: {count2}</div>;
};

const StateProvider = ({ children }) => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    <context.Provider
      value={{
        count1,
        count2,
        setCount1,
        setCount2
      }}
    >
      {children}
    </context.Provider>
  );
};

const App = () => (
  <StateProvider>
    <Count1 />
    <Count2 />
  </StateProvider>
);

export default App;

可以看到在 context 中包含了 count1 和 count2 以及改变它们状态的方法,在 <Count1 /> 与 <Count2 /> 组件中分别引用了 count1 和 count2,当修改 count1 状态时可以发现 <Count2 /> 组件也会发生 re-render,也就是重新渲染。很显然这里有性能上的问题,我们希望当 count1 状态发生变化时,不依赖该状态的组件不发生 re-render。

那如何解决这个问题呢?

优化

拆分 context

将 context 进行拆分,将 “大” context 拆分为多个 “小” context,这样每个组件只关心自己所用到的 context。具体逻辑如下示意图:

image.png

import { createContext, useContext, useState } from "react";

const context1 = createContext(null);
const context2 = createContext(null);

const Count1 = () => {
  const { count1, setCount1 } = useContext(context1);
  console.log("Count1 render");
  return <div onClick={() => setCount1(count1 + 1)}>count1: {count1}</div>;
};

const Count2 = () => {
  const { count2 } = useContext(context2);
  console.log("Count2 render");
  return <div>count2: {count2}</div>;
};

const StateProvider = ({ children }) => {
  const [count1, setCount1] = useState(0);
  return (
    <context1.Provider
      value={{
        count1,
        setCount1
      }}
    >
      {children}
    </context1.Provider>
  );
};

const StateProvider2 = ({ children }) => {
  const [count2, setCount2] = useState(0);
  return (
    <context2.Provider
      value={{
        count2,
        setCount2
      }}
    >
      {children}
    </context2.Provider>
  );
};

const App = () => (
  <StateProvider>
    <StateProvider2>
      <Count1 />
      <Count2 />
    </StateProvider2>
  </StateProvider>
);

export default App;

借助 memo

将组件进行拆分,拆分出的子组件用 memo 包裹。

import { createContext, useContext, useState, memo } from "react";

const context = createContext(null);

const Count1 = () => {
  const { count1, setCount1 } = useContext(context);
  console.log("Count1 render");
  return <div onClick={() => setCount1(count1 + 1)}>count1: {count1}</div>;
};

const Count2 = memo(({ count2 }) => {
  console.log("Count2 render");
  return <div>count2: {count2}</div>;
});

const Count2Wrapper = () => {
  const { count2 } = useContext(context);
  return <Count2 count2={count2} />;
};

export default function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    <context.Provider
      value={{
        count1,
        count2,
        setCount1,
        setCount2
      }}
    >
      <Count1 />
      <Count2Wrapper />
    </context.Provider>
  );
}

借助 useMemo

在组件的 return 中,用 React.useMemo 包裹,将 Context 中消费的值,作为其依赖项。

import { createContext, useContext, useState, useMemo } from "react";

const context = createContext(null);

const Count1 = () => {
  const { count1, setCount1 } = useContext(context);
  console.log("Count1 render");
  return <div onClick={() => setCount1(count1 + 1)}>count1: {count1}</div>;
};

const Count2 = ({ count2 }) => {
- const { count2 } = useContext(context);
  console.log("Count2 render");
  return <div>count2: {count2}</div>;
};

+ const Count2Wrapper = () => {
+   const { count2 } = useContext(context);
+   return useMemo(() => <Count2 count2={count2} />, [count2]);
+ };

export default function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    <context.Provider
      value={{
        count1,
        count2,
        setCount1,
        setCount2
      }}
    >
      <Count1 />
      <Count2Wrapper />
    </context.Provider>
  );
}

总结

社区关于这个问题的解决方案分为了两派:

  • 不直接基于 Context 完成状态共享方案,比如我们耳熟能详的 Jotai、React Redux、Zustand 等等,这些库都不是直接基于 React Context 之上进行的改造,或者说是 React Context 的替代方案,本质上没有直接的关联,因此在状态共享的时候自然也就没有了 React Context 的性能问题。

  • 以 use-context-selector 为首的直接基于 Context 之上进行的优化:

use-context-selector 的用法非常简单,核心 API:createContext/useContextSelector 可以用来创建 context 和从 context 选取你需要的属性,如果这个属性没有发生变化则不会导致组件发生 re-render。

import React, { useState } from "react";
import { createContext, useContextSelector } from "use-context-selector";

const context = createContext(null);

const Count1 = () => {
  const { count1, setCount1 } = useContext(context);
  const count1 = useContextSelector(context, (state) => state.count1);
  const setCount1 = useContextSelector(context, (state) => state.setCount1);
  console.log("Count1 render");
  return <div onClick={() => setCount1(count1 + 1)}>count1: {count1}</div>;
};

const Count2 = () => {
  const { count2 } = useContext(context);
  const count2 = useContextSelector(context, (state) => state.count2);
  console.log("Count2 render");
  return <div>count2: {count2}</div>;
};

const StateProvider = ({ children }) => {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  return (
    <context.Provider
      value={{
        count1,
        count2,
        setCount1,
        setCount2
      }}
    >
      {children}
    </context.Provider>
  );
};

const App = () => (
  <StateProvider>
    <Count1 />
    <Count2 />
  </StateProvider>
);

export default App;

3.2 状态管理库

React 第三方状态管理库对比表格

特性 Redux Toolkit Zustand Jotai XState MobX Valtio
学习曲线 陡峭 平缓 平缓 较陡(概念多) 中等 平缓
样板代码 中等(已大幅减少) 极少 极少 中等(建模为主) 极少
包体积 ~12KB ~1KB ~3KB ~17KB ~16KB ~2KB
TypeScript 支持 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
DevTools ✅ 强大 ✅ 基础 ✅ 基础 ✅ 强(可视化) ✅ 适中 ✅ 基础
中间件/生态 ✅ 丰富 ✅ 适中 ❌ 较少 ✅ 适中 ✅ 丰富 ⚠️ 适中
异步处理 Redux Thunk / Saga 内置/自定义 内置/自定义 内置(invoke/actor) 自由(reaction/flow) 自由(普通 async)
适用场景 大型应用 中小型应用 原子化状态 复杂流程/状态机 复杂业务模型 轻量 proxy 状态

Redux Toolkit(RTK)

核心原理 :基于Redux的官方简化方案,通过createSlice自动生成action和reducer,内置Immer支持"直接修改"状态的写法。

官方标准 Redux 写法:用 createSlice + configureStore 把 Redux 的样板代码砍掉一大半,保持 reducer/状态流可预测,并且配套 DevTools 生态成熟。

适用场景

  • 中大型应用、多人协作、需要规范(可追溯、可调试、可测试)

  • 复杂业务逻辑、跨模块共享状态很重

  • 想要成熟生态:中间件、持久化、日志、回放、时间旅行调试

优点

  • 强一致性/可预测:状态更新路径清晰(action → reducer)

  • DevTools 非常强(时间旅行、action 追踪)

  • 生态完整:异步、缓存、持久化、调试工具多

  • TS 支持好(官方模板成熟)

缺点

  • 心智模型相对重(action/reducer/selector)

  • 对简单项目偏“重武器”

  • 写法虽然简化了,但仍比 Zustand/Jotai 更“工程化”

基本使用

// store.ts
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit'

type Todo = { id: number; title: string; done: boolean }

const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Todo[],
  reducers: {
    addTodo(state, action: PayloadAction<string>) {
      state.push({ id: Date.now(), title: action.payload, done: false })
    },
    toggleTodo(state, action: PayloadAction<number>) {
      const t = state.find(x => x.id === action.payload)
      if (t) t.done = !t.done
    },
  },
})

export const { addTodo, toggleTodo } = todosSlice.actions

export const store = configureStore({
  reducer: { todos: todosSlice.reducer },
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

react中使用

// App.tsx
import React, { useState } from 'react'
import { Provider, useDispatch, useSelector } from 'react-redux'
import { store, RootState, addTodo, toggleTodo } from './store'

function Todos() {
  const [text, setText] = useState('')
  const dispatch = useDispatch()
  const todos = useSelector((s: RootState) => s.todos)

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => { dispatch(addTodo(text)); setText('') }}>add</button>

      <ul>
        {todos.map(t => (
          <li key={t.id} onClick={() => dispatch(toggleTodo(t.id))}>
            {t.done ? '✅' : '⬜️'} {t.title}
          </li>
        ))}
      </ul>
    </div>
  )
}

export default function App() {
  return (
    <Provider store={store}>
      <Todos />
    </Provider>
  )
}

Jotai(Atom)

原子化状态:把状态拆成一个个 atom,组件只订阅自己用到的 atom;派生状态用“派生 atom”自然表达,依赖图很清晰。

适用场景

  • 中小项目、局部共享状态多

  • 状态天然可拆分(多个小状态组合、派生多)

  • 不想要 reducer 体系,但希望比 Context 更稳

优点

  • API 很小、心智轻

  • 细粒度更新:只更新用到的组件

  • 派生状态表达很顺(atom of atom / derived atom)

  • 很适合“局部全局”/组件间共享

缺点

  • 大规模工程规范需要自己定(命名、分层、atom 组织)

  • atom 太多时需要良好组织方式

  • DevTools/生态比 RTK 弱一些(但够用)

基本使用

// atoms.ts
import { atom } from 'jotai'

export const countAtom = atom(0)

export const doubleAtom = atom((get) => get(countAtom) * 2)

// 可写派生 atom(带 set)
export const incrementAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1)
})

// Counter.tsx
import React from 'react'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { countAtom, doubleAtom, incrementAtom } from './atoms'

export default function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const double = useAtomValue(doubleAtom)
  const inc = useSetAtom(incrementAtom)

  return (
    <div>
      <div>count: {count}</div>
      <div>double: {double}</div>

      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={inc}>inc(atom write)</button>
    </div>
  )
}

常见坑/建议

  • atom 文件按领域拆(userAtoms/todoAtoms/uiAtoms),别全堆一个文件

  • 派生 atom 里做昂贵计算时,必要时再考虑 memo(一般够用)

zustand

特点

轻量 store + selector 订阅:一个 store 就是一个 hook,组件用 selector 取自己关心的片段,简单、直觉、性能很好。

适用场景

  • 中小应用最舒服

  • 想要“比 Redux 轻、比 Context 强”

  • UI 状态 + 一些业务状态混用也很常见

优点

  • 极简(几行就能起 store)

  • selector 精确订阅,性能好

  • 写法直觉:set/get,不需要 action/reducer

  • 生态插件常用:persist、devtools、immer

缺点

  • 约束少:团队需要自己约定 store 分层/命名

  • 复杂业务如果写成“随处 set”,可能变难维护(建议封装 actions)

  • 异步/副作用没有“官方统一范式”(但也因此自由)

基本使用

// useTodoStore.ts
import { create } from 'zustand'

type Todo = { id: number; title: string; done: boolean }

type State = {
  todos: Todo[]
  add: (title: string) => void
  toggle: (id: number) => void
}

export const useTodoStore = create<State>((set, get) => ({
  todos: [],
  add: (title) =>
    set((s) => ({ todos: [...s.todos, { id: Date.now(), title, done: false }] })),
  toggle: (id) =>
    set((s) => ({
      todos: s.todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)),
    })),
}))

// Todos.tsx
import React, { useState } from 'react'
import { useTodoStore } from './useTodoStore'

export default function Todos() {
  const [text, setText] = useState('')
  const todos = useTodoStore(s => s.todos)      // ✅ selector
  const add = useTodoStore(s => s.add)
  const toggle = useTodoStore(s => s.toggle)

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => { add(text); setText('') }}>add</button>

      <ul>
        {todos.map(t => (
          <li key={t.id} onClick={() => toggle(t.id)}>
            {t.done ? '✅' : '⬜️'} {t.title}
          </li>
        ))}
      </ul>
    </div>
  )
}

常见坑/建议

  • selector 要稳定:避免 useStore(s => ({a:s.a})) 这种每次创建新对象导致重渲染(除非配 shallow)

  • 大型项目建议“一个领域一个 store”或“一个 store 分 slice”,别把所有东西塞一个 store

四、服务端状态管理

服务器状态与客户端状态有本质区别,需要专门的管理方案。

4.1 为什么需要专门的服务器状态管理?

传统的React应用中的数据获取依赖于useStateuseEffect钩子。这种方法最初看起来很简单,但随着应用变得更加复杂,基本限制开始显现,使这种模式越来越难以管理。

useEffect和useState对异步数据的限制

React团队官方建议在大多数情况下不要使用useEffect进行数据获取。这一建议源于几个影响应用性能和开发者体验的重要缺点。

useEffect中直接使用异步函数会立即产生问题。这个钩子期望返回的要么是空,要么是清理函数——而不是一个promise。尝试将useEffect回调标记为异步会导致意外行为,可能会扰乱React的渲染生命周期。

管理加载和错误状态需要多个必须仔细同步的状态变量:

// ❌ 问题示例:用 useState 管理服务器数据
const UserList = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);
  
  // 问题:
  // 1. 每次组件挂载都重新请求
  // 2. 没有缓存
  // 3. 其他组件无法共享这些数据
  // 4. 没有错误重试
  // 5. 没有自动刷新
  // 6. 代码重复
  
  if (loading) return <Spinner />;
  if (error) return <Error />;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
};

随着数据源数量的增加,这种模式变得冗长且容易出错。当组件在异步操作完成前卸载时,经常会发生内存泄漏。没有适当的清理,在已卸载的组件上设置状态会导致运行时错误。

Effect不在服务器上运行,这使服务器端渲染变得复杂。开发者必须为服务器和客户端渲染实现不同的数据获取策略,增加了开发过程的复杂性。

常见陷阱:竞态条件重复请求 竞态条件是使用useEffect获取数据时最有问题的问题之一。当多个异步操作以意外顺序完成时,这些情况会发生,可能会显示过时或不正确的信息。

考虑一个用户快速输入的搜索功能。如果第一个搜索查询比后续查询花费更长时间解析,UI可能会显示较早查询的结果,而不是最近的查询。正如一位开发者所描述的:"你搜索Macron,然后改变主意搜索Trump,最终你得到的是你想要的**(Trump)和你得到的(Macron)**之间的不匹配"。

服务器状态在几个关键方面与客户端状态不同:

  • 外部实体可能在您的应用不知情的情况下更新它

  • 它需要显式的获取和更新操作

  • 它涉及与网络相关的问题,如加载状态、错误处理和缓存 传统的状态管理工具如ReduxContext API主要是为客户端状态设计的。它们缺乏处理服务器状态特定挑战的内置功能。

随着应用规模的扩大,使用useStateuseEffect获取服务器数据的组合变得越来越复杂。开发者必须为每个数据源手动实现加载状态、错误处理、缓存、后台更新和过时数据管理。

React Query使开发者能够编写更易于维护的代码,同时避免与传统useState/useEffect方法相关的陷阱。这种关注点分离对于构建健壮、可扩展的React应用至关重要。

4.2 React Query (TanStack Query) - 推荐方案

TanStack Query(前身为React Query)代表了React应用中管理服务器状态的专门解决方案。它的核心功能是作为异步状态管理器,而不仅仅是数据获取库。这种区别很重要,因为它塑造了我们思考和与远程数据交互的方式。

使用useQuery()进行声明式数据获取

const { data, isLoading, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});

这种模式抽象了复杂性,同时提供了对加载状态、错误处理和获取数据的即时访问。queryKey作为查询的唯一标识符,而queryFn包含检索数据的返回promise的函数。

这种方法的强大之处在于它如何将异步代码从命令式转变为声明式。这种转变消除了通常使用useState/useEffect模式所需的大约50%的样板代码。

内置缓存和后台更新

React Query的复杂缓存机制解决了数据管理最具挑战性的方面之一。该库根据查询键自动缓存查询结果,使对同一端点的后续调用几乎即时。

以下是生命周期的工作原理:

  1. 当组件首次使用特定键调用useQuery时,React Query获取数据并缓存它

  2. 如果另一个组件请求相同的数据,它会立即接收缓存的结果,同时在后台进行刷新

  3. 在使用查询的所有组件卸载后,缓存会在默认期限(5分钟)内持续存在,然后进行垃圾回收

React Query通过配置选项提供精细控制:

  • staleTime:确定数据何时需要刷新(默认:立即)

  • gcTime:控制未使用的数据在内存中保留多长时间(默认:5分钟)

  • refetchOnMountrefetchOnWindowFocusrefetchOnReconnect:在适当时智能刷新数据 这些功能以最少的开发者干预自动化客户端和服务器之间的数据同步。

React Query如何简化异步状态管理

服务器状态由于其异步性质和远程持久性而面临独特挑战——传统状态管理工具不是为有效处理这些特性而设计的。

React Query通过几个关键机制解决了这种复杂性:

首先,它通过基于查询键而非组件实例存储状态来消除竞态条件。这防止了过时数据覆盖较新响应的情况,这是基于useEffect方法的常见问题。

其次,它提供自动后台获取指示器。虽然status === 'loading'表示初始加载,但isFetching布尔值表示后台刷新,允许更细微的加载状态:

{isFetching ? <div>刷新中...</div> : null}

第三,React Query有效地去重多个相同的请求,即使在React的StrictMode中也能防止冗余网络调用。这种优化节省了带宽并提高了性能,特别是在较大的应用中。

最后,该库擅长通过类型级别的区分联合管理异步状态转换,确保加载、错误和成功状态保持清晰分离。

React Query将缓存转变为服务器状态的事实上的数据存储,消除了仅为处理异步数据而需要复杂状态管理架构的需求。这种范式转变使开发者能够专注于业务逻辑,而不是数据同步的复杂机制。

项目中使用

快速上手
// main.jsx - 配置
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5分钟
      cacheTime: 1000 * 60 * 10, // 10分钟
      refetchOnWindowFocus: false
    }
  }
});

ReactDOM.createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);

// UserList.jsx - 使用
import { useQuery } from '@tanstack/react-query';

const fetchUsers = async () => {
  const response = await fetch('/api/users');
  if (!response.ok) throw new Error('Failed to fetch');
  return response.json();
};

const UserList = () => {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
  });
  
  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} onRetry={refetch} />;
  
  return (
    <ul>
      {data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
};
核心概念
  1. Query Keys - 缓存键

    // 简单的 key
    useQuery({ queryKey: ['users'], queryFn: fetchUsers });
    
    // 带参数的 key
    useQuery({ 
      queryKey: ['user', userId], 
      queryFn: () => fetchUser(userId) 
    });
    
    // 复杂的 key
    useQuery({
      queryKey: ['users', { page, filter, sort }],
      queryFn: () => fetchUsers({ page, filter, sort })
    });
    
    // Key 的作用:
    // 1. 缓存标识
    // 2. 自动重新请求(key 变化时)
    // 3. 手动失效(invalidateQueries)
    
  2. 缓存与失效策略

    import { useQuery, useQueryClient } from '@tanstack/react-query';
    
    const UserProfile = ({ userId }) => {
      const queryClient = useQueryClient();
      
      const { data } = useQuery({
        queryKey: ['user', userId],
        queryFn: () => fetchUser(userId),
        
        // 配置选项
        staleTime: 5 * 60 * 1000,     // 5分钟内不会重新请求
        cacheTime: 10 * 60 * 1000,    // 缓存保留10分钟
        retry: 3,                      // 失败重试3次
        retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
        refetchOnWindowFocus: true,    // 窗口聚焦时重新请求
        refetchOnReconnect: true,      // 重新连接时重新请求
        refetchInterval: 30000,        // 每30秒自动刷新
        enabled: !!userId              // 条件请求
      });
      
      // 手动失效缓存
      const handleUpdate = async () => {
        await updateUser(userId, newData);
        // 使该查询失效,触发重新请求
        queryClient.invalidateQueries({ queryKey: ['user', userId] });
      };
      
      return <div>{data?.name}</div>;
    };
    
  3. Mutations - 数据修改

    import { useMutation, useQueryClient } from '@tanstack/react-query';
    
    const CreateUser = () => {
      const queryClient = useQueryClient();
      
      const mutation = useMutation({
        mutationFn: (newUser) => {
          return fetch('/api/users', {
            method: 'POST',
            body: JSON.stringify(newUser)
          }).then(res => res.json());
        },
        
        // 成功后的回调
        onSuccess: (data) => {
          // 方式1: 使用户列表查询失效
          queryClient.invalidateQueries({ queryKey: ['users'] });
          
          // 方式2: 直接更新缓存
          queryClient.setQueryData(['users'], (old) => [...old, data]);
        },
        
        onError: (error) => {
          console.error('Failed to create user:', error);
        }
      });
      
      const handleSubmit = (formData) => {
        mutation.mutate(formData);
      };
      
      return (
        <form onSubmit={handleSubmit}>
          {mutation.isLoading && <Spinner />}
          {mutation.isError && <Error message={mutation.error.message} />}
          {mutation.isSuccess && <Success />}
          <button type="submit">Create</button>
        </form>
      );
    };
    
  4. 乐观更新

    const UpdateTodo = ({ todo }) => {
      const queryClient = useQueryClient();
      
      const mutation = useMutation({
        mutationFn: (updates) => updateTodo(todo.id, updates),
        
        // 乐观更新
        onMutate: async (newTodo) => {
          // 取消正在进行的查询
          await queryClient.cancelQueries({ queryKey: ['todos'] });
          
          // 保存之前的数据(用于回滚)
          const previousTodos = queryClient.getQueryData(['todos']);
          
          // 乐观地更新缓存
          queryClient.setQueryData(['todos'], (old) =>
            old.map(t => t.id === todo.id ? { ...t, ...newTodo } : t)
          );
          
          // 返回上下文(用于 onError)
          return { previousTodos };
        },
        
        // 如果失败,回滚
        onError: (err, newTodo, context) => {
          queryClient.setQueryData(['todos'], context.previousTodos);
        },
        
        // 成功后重新验证
        onSettled: () => {
          queryClient.invalidateQueries({ queryKey: ['todos'] });
        }
      });
      
      const handleToggle = () => {
        mutation.mutate({ completed: !todo.completed });
      };
      
      return (
        <input 
          type="checkbox" 
          checked={todo.completed}
          onChange={handleToggle}
        />
      );
    };
    
  5. 分页与无限滚动

// 分页
const UserList = () => {
  const [page, setPage] = useState(1);
  
  const { data, isLoading } = useQuery({
    queryKey: ['users', page],
    queryFn: () => fetchUsers(page),
    keepPreviousData: true // 保持之前的数据,避免闪烁
  });
  
  return (
    <>
      {data?.users.map(user => <UserCard key={user.id} user={user} />)}
      <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
        Previous
      </button>
      <button onClick={() => setPage(p => p + 1)}>Next</button>
    </>
  );
};

// 无限滚动
import { useInfiniteQuery } from '@tanstack/react-query';

const InfiniteUserList = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['users'],
    queryFn: ({ pageParam = 1 }) => fetchUsers(pageParam),
    getNextPageParam: (lastPage, pages) => {
      // 返回下一页的参数,或 undefined 表示没有更多
      return lastPage.hasMore ? pages.length + 1 : undefined;
    }
  });
  
  return (
    <>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.users.map(user => <UserCard key={user.id} user={user} />)}
        </div>
      ))}
      
      <button 
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : 'Load More'}
      </button>
    </>
  );
};

结论

传统的数据获取方法创造了不必要的复杂性。竞态条件、内存泄漏和冗长的状态管理代码困扰着仅依赖React内置钩子的应用。React Query通过其直观的useQuery和useMutation钩子以及自动工作的复杂缓存系统直接解决了这些问题

❌