阅读视图

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

每日一题-转换数组🟢

给你一个整数数组 nums,它表示一个循环数组。请你遵循以下规则创建一个大小 相同 的新数组 result :

对于每个下标 i(其中 0 <= i < nums.length),独立执行以下操作:
  • 如果 nums[i] > 0:从下标 i 开始,向 右 移动 nums[i] 步,在循环数组中落脚的下标对应的值赋给 result[i]
  • 如果 nums[i] < 0:从下标 i 开始,向 左 移动 abs(nums[i]) 步,在循环数组中落脚的下标对应的值赋给 result[i]
  • 如果 nums[i] == 0:将 nums[i] 的值赋给 result[i]

返回新数组 result

注意:由于 nums 是循环数组,向右移动超过最后一个元素时将回到开头,向左移动超过第一个元素时将回到末尾。

 

示例 1:

输入: nums = [3,-2,1,1]

输出: [1,1,1,3]

解释:

  • 对于 nums[0] 等于 3,向右移动 3 步到 nums[3],因此 result[0] 为 1。
  • 对于 nums[1] 等于 -2,向左移动 2 步到 nums[3],因此 result[1] 为 1。
  • 对于 nums[2] 等于 1,向右移动 1 步到 nums[3],因此 result[2] 为 1。
  • 对于 nums[3] 等于 1,向右移动 1 步到 nums[0],因此 result[3] 为 3。

示例 2:

输入: nums = [-1,4,-1]

输出: [-1,-1,4]

解释:

  • 对于 nums[0] 等于 -1,向左移动 1 步到 nums[2],因此 result[0] 为 -1。
  • 对于 nums[1] 等于 4,向右移动 4 步到 nums[2],因此 result[1] 为 -1。
  • 对于 nums[2] 等于 -1,向左移动 1 步到 nums[1],因此 result[2] 为 4。

 

提示:

  • 1 <= nums.length <= 100
  • -100 <= nums[i] <= 100

3379. 转换数组

解法

思路和算法

根据题意模拟,计算结果数组 $\textit{result}$ 即可。

用 $n$ 表示数组 $\textit{nums}$ 的长度。对于 $0 \le i < n$ 的每个下标 $i$,计算 $\textit{result}[i]$ 的方法如下。

  • 当 $\textit{nums}[i] > 0$ 时,$\textit{result}[i]$ 的值等于数组 $\textit{nums}$ 的下标 $i$ 向右移动 $\textit{nums}[i]$ 的下标处的值,即数组 $\textit{nums}[i]$ 的下标 $i + \textit{nums}[i]$ 对应的范围 $[0, n - 1]$ 中的下标。

  • 当 $\textit{nums}[i] < 0$ 时,$\textit{result}[i]$ 的值等于数组 $\textit{nums}$ 的下标 $i$ 向左移动 $-\textit{nums}[i]$ 的下标处的值,即数组 $\textit{nums}[i]$ 的下标 $i + \textit{nums}[i]$ 对应的范围 $[0, n - 1]$ 中的下标。

  • 当 $\textit{nums}[i] = 0$ 时,$\textit{result}[i]$ 的值等于数组 $\textit{nums}$ 的下标 $i$ 处的值。

上述情况可以统一表示成数组 $\textit{nums}[i]$ 的下标 $i + \textit{nums}[i]$ 对应的范围 $[0, n - 1]$ 中的下标。对于 $0 \le i < n$ 的每个下标 $i$,计算 $\textit{result}[i]$ 时为了确保得到范围 $[0, n - 1]$ 中的下标,应计算 $\textit{index} = ((i + \textit{nums}[i]) \bmod n + n) \bmod n$,则 $\textit{result}[i] = \textit{nums}[\textit{index}]$。

计算数组 $\textit{result}$ 中的所有元素之后,即可得到结果数组。

代码

###Java

class Solution {
    public int[] constructTransformedArray(int[] nums) {
        int n = nums.length;
        int[] result = new int[n];
        for (int i = 0; i < n; i++) {
            int index = ((i + nums[i]) % n + n) % n;
            result[i] = nums[index];
        }
        return result;
    }
}

###C#

public class Solution {
    public int[] ConstructTransformedArray(int[] nums) {
        int n = nums.Length;
        int[] result = new int[n];
        for (int i = 0; i < n; i++) {
            int index = ((i + nums[i]) % n + n) % n;
            result[i] = nums[index];
        }
        return result;
    }
}

###C++

class Solution {
public:
    vector<int> constructTransformedArray(vector<int>& nums) {
        int n = nums.size();
        vector<int> result(n);
        for (int i = 0; i < n; i++) {
            int index = ((i + nums[i]) % n + n) % n;
            result[i] = nums[index];
        }
        return result;
    }
};

###Python

class Solution:
    def constructTransformedArray(self, nums: List[int]) -> List[int]:
        n = len(nums)
        return [nums[(i + nums[i]) % n] for i in range(n)]

###C

int* constructTransformedArray(int* nums, int numsSize, int* returnSize) {
    int* result = (int*) malloc(sizeof(int) * numsSize);
    for (int i = 0; i < numsSize; i++) {
        int index = ((i + nums[i]) % numsSize + numsSize) % numsSize;
        result[i] = nums[index];
    }
    *returnSize = numsSize;
    return result;
}

###Go

func constructTransformedArray(nums []int) []int {
    n := len(nums)
    result := make([]int, n)
    for i := 0; i < n; i++ {
        index := ((i + nums[i]) % n + n) % n
        result[i] = nums[index]
    }
    return result
}

###JavaScript

var constructTransformedArray = function(nums) {
    let n = nums.length;
    let result = new Array(n);
    for (let i = 0; i < n; i++) {
        let index = ((i + nums[i]) % n + n) % n;
        result[i] = nums[index];
    }
    return result;
};

###TypeScript

function constructTransformedArray(nums: number[]): number[] {
    let n = nums.length;
    let result = new Array(n);
    for (let i = 0; i < n; i++) {
        let index = ((i + nums[i]) % n + n) % n;
        result[i] = nums[index];
    }
    return result;
};

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。结果数组的每个元素的计算时间都是 $O(1)$。

  • 空间复杂度:$O(1)$。注意返回值不计入空间复杂度。

模拟

解法:模拟

按题意模拟即可。复杂度 $\mathcal{O}(n)$。

参考代码(c++)

###cpp

class Solution {
public:
    vector<int> constructTransformedArray(vector<int>& nums) {
        int n = nums.size();
        vector<int> ans;
        for (int i = 0; i < n; i++) {
            int j = (i + nums[i] % n + n) % n;
            ans.push_back(nums[j]);
        }
        return ans;
    }
};

简洁写法(Python/Java/C++/C/Go/JS/Rust)

操作相当于从下标 $i$ 移动到下标 $i+\textit{nums}[i]$。

如果 $i+\textit{nums}[i]$ 下标越界呢?

需要把 $i+\textit{nums}[i]$ 调整到 $[0,n-1]$ 范围中。具体来说,把下标 $i+\textit{nums}[i]$ 模 $n$。比如 $n=4$,在循环数组中,正数下标 $5,9,13,\ldots$ 都是下标 $1$,负数下标 $-3,-7,-11,\ldots$ 也都是下标 $1$。

不了解取模的同学,请看 模运算的世界:当加减乘除遇上取模

本题视频讲解,欢迎点赞关注~

###py

class Solution:
    def constructTransformedArray(self, nums: List[int]) -> List[int]:
        n = len(nums)
        return [nums[(i + x) % n] for i, x in enumerate(nums)]

###java

class Solution {
    public int[] constructTransformedArray(int[] nums) {
        int n = nums.length;
        int[] result = new int[n];
        for (int i = 0; i < n; i++) {
            result[i] = nums[((i + nums[i]) % n + n) % n]; // 保证结果在 [0,n-1] 中
        }
        return result;
    }
}

###cpp

class Solution {
public:
    vector<int> constructTransformedArray(vector<int>& nums) {
        int n = nums.size();
        vector<int> result(n);
        for (int i = 0; i < n; i++) {
            result[i] = nums[((i + nums[i]) % n + n) % n]; // 保证结果在 [0,n-1] 中
        }
        return result;
    }
};

###c

int* constructTransformedArray(int* nums, int numsSize, int* returnSize) {
    int n = numsSize;
    int* result = malloc(n * sizeof(int));
    for (int i = 0; i < n; i++) {
        result[i] = nums[((i + nums[i]) % n + n) % n]; // 保证结果在 [0,n-1] 中
    }
    *returnSize = n;
    return result;
}

###go

func constructTransformedArray(nums []int) []int {
n := len(nums)
result := make([]int, n)
for i, x := range nums {
result[i] = nums[((i+x)%n+n)%n] // 保证结果在 [0,n-1] 中
}
return result
}

###js

var constructTransformedArray = function(nums) {
    const n = nums.length;
    const result = new Array(n);
    for (let i = 0; i < n; i++) {
        result[i] = nums[((i + nums[i]) % n + n) % n]; // 保证结果在 [0,n-1] 中
    }
    return result;
};

###rust

impl Solution {
    pub fn construct_transformed_array(nums: Vec<i32>) -> Vec<i32> {
        let n = nums.len();
        let m = n as i32;
        let mut result = vec![0; n];
        for i in 0..n {
            let j = ((i as i32 + nums[i]) % m + m) % m; // 保证结果在 [0,n-1] 中
            result[i] = nums[j as usize];
        }
        result
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。返回值不计入。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

log-lottery:不只是炫酷的3D抽奖,更是学习前端开发的最佳实践

文章简介:年关将至,年会抽奖如何玩出新意?log-lottery 开源项目将传统抽奖升级为炫酷的3D球体视觉盛宴,更是一款融合 Vue3、Three.js、IndexedDB 等前沿技术的完整学习案例。它不仅支持奖品人员管理、界面定制与音乐配置,还提供在线体验、本地部署与 Docker 容器化等多种使用方式。无论是打造现场亮点,还是深入学习现代前端工程实践,这个项目都能为你带来惊喜与收获。

年关将至,各家公司已陆续开始筹备年会。活动现场灯火璀璨,无论是庆典还是其他聚会,抽奖环节往往是最令人心动的亮点。然而,若只是简单地搬出一个抽奖箱随手抽取,尤其是对于软件公司而言——是否显得不高大上呢?

但你有没有想过,一个看似简单的抽奖系统背后,其实可以承载丰富的前端开发技术与工程实践?今天我要向大家介绍的 log-lottery,正是这样一个将趣味性与技术深度巧妙融合的开源项目。它不仅拥有引人注目的 3D 视觉效果,更堪称现代前端技术栈的完整示范案例。

f99efd1d4c35e9a7e295e318becbe469.png

🎉 什么 log-lottery?

log-lottery是一个可配置可定制化的抽奖应用,炫酷3D球体,可用于年会抽奖等活动,支持奖品、人员、界面、图片音乐配置。log-lottery 最吸引人的特点无疑是其华丽的3D球体抽奖界面。当参与者名单以3D球体形式旋转、跳动时,那种视觉冲击力是传统抽奖系统无法比拟的。无论是在公司年会、校园活动还是其他庆祝场合,这个功能都能瞬间点燃现场气氛。

但 log-lottery 的魅力远不止于此。它更是一个精心设计的学习项目,展示了如何将多种现代前端技术有机结合,构建一个功能完整、体验优秀的Web应用。

项目地址github.com/LOG1997/log…

在线体验lottery.to2026.xyz/log-lottery

该项目目前再github上已有 3k⭐️ star

🛠️ 技术栈亮点

log-lottery 采用了当前前端开发的主流技术栈:

  • • Vue3 - 最新版的Vue框架,展示组合式API的最佳实践
  • • Three.js - 业界领先的3D图形库,实现惊艳的视觉效果
  • • IndexedDB - 浏览器本地数据库,实现数据的持久化存储
  • • Pinia - Vue的现代状态管理库
  • • DaisyUI - Tailwind CSS组件库,提供美观的UI基础

这个技术组合非常实用,是学习者了解现代Web开发架构的绝佳项目。

🔧 快速开始

🌐在线体验

直接访问官方提供的两个地址之一即可体验:

🖥️本地开发

# 克隆项目
git clone https://github.com/LOG1997/log-lottery.git

# 安装依赖
pnpm i   或 npm install

# 启动开发服务器
pnpm dev   或 npm run dev

# 打包
pnpm build 或 npm run build

🐳Docker部署

  • • Docker run 运行

拉取镜像,从Docker Hub拉取镜像log-lottery

docker pull log1997/log-lottery:latest

运行容器

docker run -d --name log-lottery -p 9279:80 log1997/log-lottery:latest
  • • docker-compose 运行

创建docker-compose.yml文件

services:
  log-lottery:
    image: log1997/log-lottery:latest
    container_name: log-lottery
    ports:
      - "9279:80"
    restart: unless-stopped

在docker-compose.yml 同级目录下运行以下命令启动

docker-compose up -d 

启动之后访问 http://localhost:9279/log-lottery/ 即可使用。

df6cf7cecb81ee905c15ee1473607f62.png

23f5fdf0072f10d95ac454f3ab9b2d4a.png

28037b55654df1190c4a168abe9d57a3.png

📋 功能丰富且实用

1. 🧑‍🤝‍🧑完整的人员与奖品管理

  • • 通过Excel模板导入参与人员名单
  • • 自定义奖项设置(名称、人数、参与范围等)
  • • 抽奖结果导出到Excel,方便后续处理

2. 🎨高度可定制化界面

  • • 自定义标题、列数、卡片颜色
  • • 更换背景图片和首页图案
  • • 支持背景音乐上传和播放

3. 🚢多种部署方式

  • • 在线访问:直接通过提供的链接使用
  • • Docker部署:一键容器化部署
  • • 本地安装包:Windows平台可直接安装使用

🚀 学习价值

对于开发者来说,log-lottery 提供了多个学习维度:

1. 🎮3D Web应用开发

通过 Three.js 与 Vue3 的集成,你可以学习如何在Web应用中添加3D元素,这对于游戏开发、数据可视化等领域都有重要参考价值。

2. 💾本地数据持久化

项目使用 IndexedDB 存储配置和媒体文件,展示了如何在浏览器端实现复杂的数据管理,这对于离线应用和PWA开发非常有帮助。

3.🛠️ 完整的前端工程化实践

从开发、构建到部署,项目展示了完整的开发流程。特别是Docker支持,让你了解如何将前端应用容器化。

🌟结语

Log-Lottery 展示了一个看似简单的应用背后所蕴含的丰富技术内涵。它不仅是活跃年会气氛的实用工具,更是一件精心打磨的技术作品,一个极具学习价值的开源项目。

作为使用者,你可以:

  • • 获得一个免费、强大且高度可定制的抽奖系统
  • • 支持本地部署,确保活动数据完全自主可控
  • • 通过丰富的配置选项,灵活适应不同活动场景

作为学习者,你将能够:

  • • 深入剖析一个生产环境级的 Three.js 完整应用案例
  • • 掌握 IndexedDB 在前端复杂场景中的实战使用方法
  • • 理解基于 Vue 3 的现代化前端项目架构设计

无论你是为了下一次公司年会准备一个惊艳全场的抽奖环节,还是希望进一步提升个人前端技术实力,Log-Lottery 都是一个值得你投入时间深入探索的优秀项目。

写 React 写到凌晨,我才懂 RD280UG 彩纸编程模式有多香!

作为前端开发,其实我一直觉得显示器的影响,远比键盘鼠标更隐形。

因为我们每天面对的不是**“代码”**,而是:

  • 密密麻麻的 DOM 结构
  • 深色主题下的高频字符
  • 长时间滚动的调试面板
  • CSS 的层级缩进和细小符号
  • 浏览器 + IDE + 控制台三开

一天下来,眼睛累的不是工作量,而是屏幕一直在“消耗注意力”

最近我把主力屏换成了 明基 RD280UG

它是 RD280U 的升级款,但不是那种“换个名字加点参数”的升级,而是针对程序员(尤其是前端)做了三处关键强化:

RD280UG 相比 RD280U 的核心升级:

  • 专业编程模式升级:新增彩纸编程模式
  • 2000:1 高对比度 + 分区对比度功能
  • 120Hz 高刷升级,滚动与调试更顺滑

这三个点,恰好都戳在前端开发的高频场景里。

3:2 屏幕比例:写前端的人最懂“纵向空间”

前端开发最常见的界面是什么?

不是单文件,而是:

  • JSX/HTML 的嵌套结构
  • 组件层级
  • Tailwind 类名堆叠
  • 浏览器 DevToolsElements

你会发现,前端代码的痛点从来不是横向,而是纵向信息密度太大

RD280UG3:2 比例就是为这个场景准备的。

相比传统 16:9

  • 同样窗口能多显示更多行 DOM
  • 一个组件结构不用反复滚动
  • 调试时上下文更完整
  • “思路不断档”的感觉非常明显

尤其在读别人代码、看复杂页面结构时,提升特别直接。

抗反射面板:前端调色和长时间盯屏更友好

前端开发经常需要:

  • 对比 UI 色彩
  • 调整渐变阴影
  • 看细小字体的渲染效果

但普通屏幕在强光环境下很容易出现“泛白”反光

RD280UG 的抗反射面板属于那种:

  • 办公室顶灯不刺眼
  • 背后有光也不会晃
  • 屏幕内容始终干净稳定

这种体验不是“惊艳”,而是写一整天后才发现:眼睛真的更轻松。

专业编程模式:这次升级的重点(新增彩纸模式)

这一代 RD280UG 最核心的变化,就是编程模式做得更“专业化”

它不是简单调亮度,而是从代码识别效率出发做的显示优化。

RD280UG 的编程模式现在有三种:

1)暗黑编程模式:专为深色主题优化

前端开发者大多用 Dark Theme

但普通显示器在暗色背景下常见问题是:

  • 黑不够沉,灰蒙蒙
  • 字符边缘发虚
  • 注释和代码层级混在一起

暗黑编程模式的原理是:

  • 压低背景亮度
  • 提升字体边缘清晰度
  • 增强对比层级

效果就是:

  • 变量名更“跳出来”
  • 缩进结构更清楚
  • 长时间看暗色不累

特别适合 React/Vue 这种结构密集型代码。

2)明亮编程模式:适合白底与文档场景

很多时候我们不止写代码,还在:

  • MDN
  • 技术文档
  • 接口说明
  • 对照设计稿

明亮编程模式会优化:

  • 白底不刺眼
  • 字体不发灰
  • 页面信息更“干净”

适合白天办公和多窗口混合场景。

3)新增:彩纸编程模式(这次最大亮点)

彩纸模式是 RD280UG 新增的升级点。

它的思路非常像“护眼纸张阅读”:

  • 背景不是纯白纯黑
  • 而是低刺激的柔和纸张色
  • 减少高亮对比带来的疲劳

原理上,它通过调整:

  • 色温曲线
  • 背景亮度分布
  • 字符与底色的对比关系

让屏幕呈现一种“纸面代码”的感觉。

实际体验非常适合:

  • 长时间写业务代码
  • 深夜改 bug
  • 看别人项目源码
  • 前端调试一坐就是几个小时

2000:1 高对比度 + 分区对比度:前端调试更清晰

RD280UG 这次对比度升级到 2000:1,并新增了分区对比度功能。

这对前端开发尤其重要。

因为我们经常面对:

  • DevTools 的多层面板
  • 控制台输出
  • 代码区 + 浏览器预览同时开

分区对比度的作用是:屏幕不同区域可以动态优化对比度表现

结果就是:

  • 代码区文字更锐利
  • 调试面板层级更分明
  • 黑底控制台不会糊成一片
  • 小字号符号({}, (), ;)更容易识别

对于前端这种**“信息密度爆炸”**的工作流来说,这个升级非常实用。

120Hz 高刷:滚动代码和页面调试更顺

很多人觉得高刷是游戏需求。

但前端开发的高频动作其实是:

  • 滚动长页面
  • 拖动调试窗口
  • 浏览器上下切换
  • 查看动画与交互效果

120Hz 带来的提升是:

  • 滚动更跟手
  • 画面更稳定
  • 长时间盯屏不容易胀眼

属于“润物细无声”的升级。

MoonHalo + 夜间保护:深夜改 bug 的舒适感

前端开发最熟悉的场景是什么?

晚上十一点:

  • 屋里很暗
  • 屏幕很亮
  • 你还在修线上问题

RD280UG夜间保护模式 + MoonHalo 环境光非常适合这种场景:

  • 屏幕亮度更柔和
  • 色温更舒适
  • 背后环境光减少明暗反差
  • 不会像盯着一块刺眼光板

深夜编码体验明显更“松”。

软件协同升级:新增支持 Linux

RD280UG 依然支持 Display Pilot 2 软件调节显示器。

这次还有一个对程序员很重要的升级:

新增支持 Linux 系统

对于很多前端/全栈用户来说:

  • 主力机是 Mac
  • 开发机是 Linux
  • 测试环境也在 Linux

软件支持扩展后,调节显示器会更方便。

总结:RD280UG 更像“前端开发的第二代生产力屏”

RD280UG 的升级方向非常明确:

  • 彩纸编程模式 → 长时间写代码更耐看
  • 2000:1 + 分区对比度 → 调试信息更清晰
  • 120Hz 高刷 → 滚动与交互更顺滑

再加上:

  • 3:2 纵向空间
  • 抗反射面板
  • MoonHalo 夜间体验
  • 软件支持 Linux

它不是单纯参数升级,而是更贴近程序员真实工作流的迭代。

如果你是前端开发者,每天都在 IDE + 浏览器 + DevTools 之间切换,那 RD280UG 会是一块真正能提升效率和舒适度的显示器。

【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 流畅。

❌