阅读视图

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

【模板】二分查找(Python/Java/C++/Go)

视频讲解二分查找 红蓝染色法【基础算法精讲 04】,包含闭区间、半闭半开、开区间三种写法。

相关题目34. 在排序数组中查找元素的第一个和最后一个位置,推荐阅读 题解 中的答疑。

手写二分

推荐写开区间二分,简单好记。

class Solution:
    def nextGreatestLetter(self, letters: List[str], target: str) -> str:
        n = len(letters)
        left, right = -1, n
        while left + 1 < right:
            mid = (left + right) // 2
            if letters[mid] > target:
                right = mid
            else:
                left = mid
        return letters[right] if right < n else letters[0]
class Solution {
    public char nextGreatestLetter(char[] letters, char target) {
        int n = letters.length;
        int left = -1;
        int right = n;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (letters[mid] > target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right < n ? letters[right] : letters[0];
    }
}
class Solution {
public:
    char nextGreatestLetter(vector<char>& letters, char target) {
        int n = letters.size();
        int left = -1, right = n;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            (letters[mid] > target ? right : left) = mid;
        }
        return right < n ? letters[right] : letters[0];
    }
};
func nextGreatestLetter(letters []byte, target byte) byte {
n := len(letters)
left, right := -1, n
for left+1 < right {
mid := left + (right-left)/2
if letters[mid] > target {
right = mid
} else {
left = mid
}
}
if right == n {
return letters[0]
}
return letters[right]
}

库函数写法

class Solution:
    def nextGreatestLetter(self, letters: List[str], target: str) -> str:
        i = bisect_right(letters, target)
        return letters[i] if i < len(letters) else letters[0]
class Solution {
    public char nextGreatestLetter(char[] letters, char target) {
        int i = Arrays.binarySearch(letters, (char) (target + 1));
        if (i < 0) { // letters 中没有 target + 1
            i = ~i; // 根据 Arrays.binarySearch 的文档,~i 是第一个大于 target 的字母的下标
        }
        return i < letters.length ? letters[i] : letters[0];
    }
}
class Solution {
public:
    char nextGreatestLetter(vector<char>& letters, char target) {
        auto it = ranges::upper_bound(letters, target);
        return it < letters.end() ? *it : letters[0];
    }
};
func nextGreatestLetter(letters []byte, target byte) byte {
i, _ := slices.BinarySearch(letters, target+1)
if i == len(letters) {
return letters[0]
}
return letters[i]
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\log n)$,其中 $n$ 是 $\textit{letters}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

附:二分查找常用转化

需求 写法 如果不存在
$\ge x$ 的第一个元素的下标 $\texttt{lowerBound}(\textit{nums},x)$ 结果为 $n$
$> x$ 的第一个元素的下标 $\texttt{lowerBound}(\textit{nums},x+1)$ 结果为 $n$
$< x$ 的最后一个元素的下标 $\texttt{lowerBound}(\textit{nums},x)-1$ 结果为 $-1$
$\le x$ 的最后一个元素的下标 $\texttt{lowerBound}(\textit{nums},x+1)-1$ 结果为 $-1$
需求 写法
$< x$ 的元素个数 $\texttt{lowerBound}(\textit{nums},x)$
$\le x$ 的元素个数 $\texttt{lowerBound}(\textit{nums},x+1)$
$\ge x$ 的元素个数 $n - \texttt{lowerBound}(\textit{nums},x)$
$> x$ 的元素个数 $n - \texttt{lowerBound}(\textit{nums},x+1)$

注意 $< x$ 和 $\ge x$ 互为补集,元素个数之和为 $n$。$\le x$ 和 $> x$ 同理。

分类题单

如何科学刷题?

  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站@灵茶山艾府

每日一题-寻找比目标字母大的最小字母🟢

给你一个字符数组 letters,该数组按非递减顺序排序,以及一个字符 targetletters 里至少有两个不同的字符。

返回 letters 中大于 target 的最小的字符。如果不存在这样的字符,则返回 letters 的第一个字符。

 

示例 1:

输入: letters = ['c', 'f', 'j'],target = 'a'
输出: 'c'
解释:letters 中字典上比 'a' 大的最小字符是 'c'。

示例 2:

输入: letters = ['c','f','j'], target = 'c'
输出: 'f'
解释:letters 中字典顺序上大于 'c' 的最小字符是 'f'。

示例 3:

输入: letters = ['x','x','y','y'], target = 'z'
输出: 'x'
解释:letters 中没有一个字符在字典上大于 'z',所以我们返回 letters[0]。

 

提示:

  • 2 <= letters.length <= 104
  • letters[i] 是一个小写字母
  • letters非递减顺序排序
  • letters 最少包含两个不同的字母
  • target 是一个小写字母

【宫水三叶】简单二分运用题

二分

给定的数组「有序」,找到比 $target$ 大的最小字母,容易想到二分。

唯一需要注意的是,二分结束后需要再次 check,如果不满足,则取数组首位元素。

代码:

###Java

class Solution {
    public char nextGreatestLetter(char[] letters, char target) {
        int n = letters.length;
        int l = 0, r = n - 1;
        while (l < r) {
            int mid = l + r >> 1;
            if (letters[mid] > target) r = mid;
            else l = mid + 1;
        }
        return letters[r] > target ? letters[r] : letters[0];
    }
}
  • 时间复杂度:$O(\log{n})$
  • 空间复杂度:$O(1)$

加餐 & 其他「二分」

今日份加餐 【综合笔试题】两种强有力的字符串处理方式 🎉🎉

或者继续学习「二分」相关内容 🍭🍭🍭

不太清楚 check 的大于小于怎么确定的可以重点看看这篇:

题目 题解 难度 推荐指数
4. 寻找两个正序数组的中位数 LeetCode 题解链接 困难 🤩🤩🤩🤩
29. 两数相除 LeetCode 题解链接 中等 🤩🤩🤩
74. 搜索二维矩阵 LeetCode 题解链接 中等 🤩🤩🤩🤩
220. 存在重复元素 III LeetCode 题解链接 中等 🤩🤩🤩
354. 俄罗斯套娃信封问题 LeetCode 题解链接 困难 🤩🤩🤩
363. 矩形区域不超过 K 的最大数值和 LeetCode 题解链接 困难 🤩🤩🤩
852. 山脉数组的峰顶索引 LeetCode 题解链接 简单 🤩🤩🤩🤩🤩
1004. 最大连续1的个数 III LeetCode 题解链接 中等 🤩🤩🤩
1208. 尽可能使字符串相等 LeetCode 题解链接 中等 🤩🤩🤩
1482. 制作 m 束花所需的最少天数 LeetCode 题解链接 中等 🤩🤩🤩
1707. 与数组中元素的最大异或值 LeetCode 题解链接 困难 🤩🤩🤩
1751. 最多可以参加的会议数目 II LeetCode 题解链接 困难 🤩🤩🤩

注:以上目录整理来自 wiki,任何形式的转载引用请保留出处。


最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

寻找比目标字母大的最小字母

方法一:线性查找

由于给定的列表已经按照递增顺序排序,因此可以从左到右遍历列表,找到第一个比目标字母大的字母,即为比目标字母大的最小字母。

如果目标字母小于列表中的最后一个字母,则一定可以在列表中找到比目标字母大的最小字母。如果目标字母大于或等于列表中的最后一个字母,则列表中不存在比目标字母大的字母,根据循环出现的顺序,列表的首个字母是比目标字母大的最小字母。

###Python

class Solution:
    def nextGreatestLetter(self, letters: List[str], target: str) -> str:
        return next((letter for letter in letters if letter > target), letters[0])

###Java

class Solution {
    public char nextGreatestLetter(char[] letters, char target) {
        int length = letters.length;
        char nextGreater = letters[0];
        for (int i = 0; i < length; i++) {
            if (letters[i] > target) {
                nextGreater = letters[i];
                break;
            }
        }
        return nextGreater;
    }
}

###C#

public class Solution {
    public char NextGreatestLetter(char[] letters, char target) {
        int length = letters.Length;
        char nextGreater = letters[0];
        for (int i = 0; i < length; i++) {
            if (letters[i] > target) {
                nextGreater = letters[i];
                break;
            }
        }
        return nextGreater;
    }
}

###C++

class Solution {
public:
    char nextGreatestLetter(vector<char>& letters, char target) {
        for (char letter : letters) {
            if (letter > target) {
                return letter;
            }
        }
        return letters[0];
    }
};

###C

char nextGreatestLetter(char* letters, int lettersSize, char target){
    for (int i = 0; i < lettersSize; i++) {
        if (letters[i] > target) {
            return letters[i];
        }
    }
    return letters[0];
}

###JavaScript

var nextGreatestLetter = function(letters, target) {
    const length = letters.length;
    let nextGreater = letters[0];
    for (let i = 0; i < length; i++) {
        if (letters[i] > target) {
            nextGreater = letters[i];
            break;
        }
    }
    return nextGreater;
};

###go

func nextGreatestLetter(letters []byte, target byte) byte {
    for _, letter := range letters {
        if letter > target {
            return letter
        }
    }
    return letters[0]
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是列表 $\textit{letters}$ 的长度。需要遍历列表一次寻找比目标字母大的最小字母。

  • 空间复杂度:$O(1)$。

方法二:二分查找

利用列表有序的特点,可以使用二分查找降低时间复杂度。

首先比较目标字母和列表中的最后一个字母,当目标字母大于或等于列表中的最后一个字母时,答案是列表的首个字母。当目标字母小于列表中的最后一个字母时,列表中一定存在比目标字母大的字母,可以使用二分查找得到比目标字母大的最小字母。

初始时,二分查找的范围是整个列表的下标范围。每次比较当前下标处的字母和目标字母,如果当前下标处的字母大于目标字母,则在当前下标以及当前下标的左侧继续查找,否则在当前下标的右侧继续查找。

###Python

class Solution:
    def nextGreatestLetter(self, letters: List[str], target: str) -> str:
        return letters[bisect_right(letters, target)] if target < letters[-1] else letters[0]

###Java

class Solution {
    public char nextGreatestLetter(char[] letters, char target) {
        int length = letters.length;
        if (target >= letters[length - 1]) {
            return letters[0];
        }
        int low = 0, high = length - 1;
        while (low < high) {
            int mid = (high - low) / 2 + low;
            if (letters[mid] > target) {
                high = mid;
            } else {
                low = mid + 1;
            }
        }
        return letters[low];
    }
}

###C#

public class Solution {
    public char NextGreatestLetter(char[] letters, char target) {
        int length = letters.Length;
        if (target >= letters[length - 1]) {
            return letters[0];
        }
        int low = 0, high = length - 1;
        while (low < high) {
            int mid = (high - low) / 2 + low;
            if (letters[mid] > target) {
                high = mid;
            } else {
                low = mid + 1;
            }
        }
        return letters[low];
    }
}

###C++

class Solution {
public:
    char nextGreatestLetter(vector<char> &letters, char target) {
        return target < letters.back() ? *upper_bound(letters.begin(), letters.end() - 1, target) : letters[0];
    }
};

###C

char nextGreatestLetter(char* letters, int lettersSize, char target){
    if (target >= letters[lettersSize - 1]) {
        return letters[0];
    }
    int low = 0, high = lettersSize - 1;
    while (low < high) {
        int mid = (high - low) / 2 + low;
        if (letters[mid] > target) {
            high = mid;
        } else {
            low = mid + 1;
        }
    }
    return letters[low];
}

###JavaScript

var nextGreatestLetter = function(letters, target) {
    const length = letters.length;
    if (target >= letters[length - 1]) {
        return letters[0];
    }
    let low = 0, high = length - 1;
    while (low < high) {
        const mid = Math.floor((high - low) / 2) + low;
        if (letters[mid] > target) {
            high = mid;
        } else {
            low = mid + 1;
        }
    }
    return letters[low];
};

###go

func nextGreatestLetter(letters []byte, target byte) byte {
    if target >= letters[len(letters)-1] {
        return letters[0]
    }
    i := sort.Search(len(letters)-1, func(i int) bool { return letters[i] > target })
    return letters[i]
}

复杂度分析

  • 时间复杂度:$O(\log n)$,其中 $n$ 是列表 $\textit{letters}$ 的长度。二分查找的时间复杂度是 $O(\log n)$。

  • 空间复杂度:$O(1)$。

hexo-rs:玩 Vibe Coding

月底升级了 Copilot Pro+,月初额度重置,这几天可以放开用,想到什么就 vibe 一把。

我的博客跑在 Hexo 上很多年了。其实没什么大问题,就是每次看到那几百 MB 的 node_modules,心里总有点膈应——生成几百个静态 HTML,真的需要这么多依赖吗?但迁移到别的博客系统又懒得折腾,所以一直拖着。

这次干脆试试:能不能用 AI 一个下午撸一个 Rust 版的 Hexo?我的目标比较简单:生成跟原来一样的静态文件,兼容我现在用的主题就行。

我用的是 OpenCode + Opus 4.5。陆陆续续聊了一下午,产出了 hexo-rs。能用,但还有些边边角角的问题。

Vibe Coding 的工具和体会以后再写,这篇主要聊 hexo-rs 的实现和踩过的坑。

技术选型

EJS 模板引擎

Hexo 主题基本都用 EJS 模板——就是把 JavaScript 嵌到 HTML 里,跟 PHP 差不多。

QuickJS 跑 JS,通过 quick-js crate 调用。好处是不用依赖 Node.js,坏处是 Windows 上编不过(libquickjs-sys 挂了),所以暂时只支持 Linux 和 macOS。

其他

Markdown 用 pulldown-cmark,代码高亮用 syntect,本地服务器用 axum。都是常规选择,没什么特别的。

踩过的坑

HashMap 的坑

这个 bug 藏得很深。生成 tag 和 category 页面时,一开始用 HashMap 存文章分组:

let mut tags: HashMap<String, Vec<&Post>> = HashMap::new();

HashMap 迭代顺序不确定,每次生成的 HTML 可能不一样。页面看着没问题,但 diff 一下就发现乱了。改成 BTreeMap 就好了:

let mut tags: BTreeMap<String, Vec<&Post>> = BTreeMap::new();

Helper 函数

Hexo 有一堆 helper 函数:url_forcssjsdate 之类的。都得在 Rust 里实现一遍,然后塞进 QuickJS。

最烦的是 date。Hexo 用 Moment.js 的格式(YYYY-MM-DD),Rust 的 chrono 用 strftime(%Y-%m-%d)。得写个转换函数,挺无聊的活。

Partial 嵌套

EJS 的 partial 可以套娃,A 引用 B,B 又引用 C,变量还得一层层传下去。搞了个作用域栈,进 partial 压栈,出来弹栈。不难,但容易写错。

Vibe Coding 体感

代码 100% 是 AI 写的。我干的事:描述需求、review 代码、把报错贴给它让它改、偶尔拍板选方案。

像 EJS 模板引擎这种东西,自己从头写估计得半天,AI 几分钟就吐出来了。

但 AI 也挺蠢的:

  • HashMap 那个 bug 它就没注意到,我提出界面上的变化它也没反应过来
  • 一开始它写的 EJS parser 全是字符串 hardcode,丑得不行,我让它按 lexer -> AST 的套路重写了一遍
  • 代码多了以后它会忘事,前面写过的逻辑后面又写一遍

但 AI 又确实非常强,我想到应该使用现在线上的 catcoding.me 来和新生成的内容一一对比,然后它就呼啦啦地一通操作把问题都找出来了,自己修改完。

使用

cargo binstall hexo-rs  # 或 cargo install hexo-rs

hexo-rs generate  # 生成静态文件
hexo-rs server    # 本地预览
hexo-rs clean     # 清理
hexo-rs new "标题"

局限

不支持 Hexo 插件,不支持 Stylus 编译(.styl 文件得先用 Node 编译好),Windows 也不行。

简单的博客应该够用。复杂主题可能会有兼容问题。


代码在这:github.com/chenyukang/hexo-rs

用 Hexo 的可以试试。有问题提 issue,我让 AI 来修 :)

这篇文章到底是人写的,还是 AI 写的?

type-challenges(ts类型体操): 11 - 元组转换为对象

11 - 元组转换为对象

by sinoon (@sinoon) #简单 #object-keys

题目

将一个元组类型转换为对象类型,这个对象类型的键/值和元组中的元素对应。

例如:

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const

type result = TupleToObject<typeof tuple> // expected { 'tesla': 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y'}

在 Github 上查看:tsch.js.org/11/zh-CN

代码

/* _____________ 你的代码 _____________ */

type TupleToObject<T extends readonly PropertyKey[]> = {
  [P in T[number]]: P
}

关键解释:

  • type PropertyKey = string | number | symbol
  • T extends readonly PropertyKey[] 用于限制 T 必须是一个只读的属性键元组。
  • [P in T[number]] 用于遍历元组中的每个元素,将其作为对象的键。
  • P 是元组中的元素类型,通过 T[number] 来获取。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

readonly

  • 核心作用:标记后,目标(属性 / 数组 / 元组)只能在初始化阶段赋值(比如接口实例化、类构造函数、变量声明时),后续任何修改运算都会被 TS 编译器拦截报错;
  • 运行时特性:readonly 仅做编译时检查,不会生成任何额外 JS 代码,也无法真正阻止运行时的修改(比如通过类型断言绕开的话,运行时仍能改);
  • const 的区别:const 是变量层面的不可重新赋值(但变量指向的对象 / 数组内部属性仍可改),readonly 是属性 / 类型层面的不可修改(变量本身可重新赋值,除非变量也用 const)。

常用使用场景:

  1. 作用于接口 / 类型别名的属性(最基础)
// 定义带只读属性的接口
interface User {
  readonly id: number; // 只读属性:只能初始化赋值,后续不可改
  name: string; // 普通属性:可修改
}

// 初始化时赋值(合法)
const user: User = { id: 1, name: "张三" };

// 尝试修改只读属性(报错)
user.id = 2; // ❌ 报错:无法分配到 "id",因为它是只读属性
// 修改普通属性(合法)
user.name = "李四"; // ✅ 合法
  1. 作用于类的属性: 类中使用 readonly 标记属性,只能在声明时构造函数中赋值,后续无法修改
class Person {
  readonly id: number; // 只读属性
  name: string;

  // 构造函数中给 readonly 属性赋值(唯一合法的后续赋值方式)
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  updateInfo() {
    this.id = 100; // ❌ 报错:id 是只读属性
    this.name = "王五"; // ✅ 合法
  }
}

const person = new Person(1, "赵六");
person.id = 2; // ❌ 报错:只读属性不可修改
  1. 作用于数组 / 元组(只读数组): readonly 可标记数组为 “只读数组”,禁止修改数组元素、调用 push/pop 等修改方法
// 方式1:使用 readonly 修饰数组类型
const arr1: readonly number[] = [1, 2, 3];
arr1.push(4); // ❌ 报错:readonly 数组不存在 push 方法
arr1[0] = 10; // ❌ 报错:无法修改只读数组的元素

// 方式2:使用 ReadonlyArray<T> 类型(等价于 readonly T[])
const arr2: ReadonlyArray<string> = ["a", "b"];
arr2.pop(); // ❌ 报错

// 作用于元组(只读元组)
type Point = readonly [number, number];
const point: Point = [10, 20];
point[0] = 30; // ❌ 报错:只读元组元素不可修改
  1. 结合 keyof + in 批量创建只读类型(映射类型)
interface Product {
  name: string;
  price: number;
  stock: number;
}

// 批量创建只读版本的 Product(TS 内置的 Readonly<T> 就是这么实现的)
type ReadonlyProduct = {
  readonly [K in keyof Product]: Product[K];
};

const product: ReadonlyProduct = { name: "手机", price: 2999, stock: 100 };
product.price = 3999; // ❌ 报错:price 是只读属性

// TS 内置了 Readonly<T>,可直接使用(无需手动写映射类型)
const product2: Readonly<Product> = { name: "电脑", price: 5999, stock: 50 };
product2.stock = 60; // ❌ 报错
  1. 只读索引签名:如果类型使用索引签名,也可以标记为 readonly,禁止通过索引修改属性
// 只读索引签名:只能读取,不能修改
type ReadonlyDict = {
  readonly [key: string]: number;
};

const dict: ReadonlyDict = { a: 1, b: 2 };
dict["a"] = 3; // ❌ 报错:索引签名是只读的
console.log(dict["b"]); // ✅ 合法:仅读取

in

in 运算符用于遍历联合类型中的每个成员,将其转换为映射类型的属性名。

例如:

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoKeys = 'title' | 'description'

type TodoPreview = {
  [P in TodoKeys]: Todo[P]
}
// TodoPreview 类型为:
// {
//   title: string
//   completed: boolean
// }

T[number]

T[number] 索引访问类型 用于 从数组类型 / 元组类型中提取所有元素的类型,最终得到一个联合类型。

  1. 普通数组类型
// 定义普通数组类型
type StringArr = string[];
type NumberArr = number[];
type BoolArr = boolean[];

// T[number] 提取元素类型
type Str = StringArr[number]; // 结果:string
type Num = NumberArr[number]; // 结果:number
type Bool = BoolArr[number]; // 结果:boolean

// 等价于直接注解类型
let s: Str = "hello"; // 等同于 let s: string
let n: Num = 123;    // 等同于 let n: number
let b: Bool = true;  // 等同于 let b: boolean
  1. 元组类型
// 定义一个多类型的元组类型
type Tuple = [123, "TS", true, null];

// T[number] 提取所有元素的联合类型
type TupleUnion = Tuple[number]; // 结果:123 | "TS" | true | null

// 变量注解:可以是联合类型中的任意一种
let val: TupleUnion;
val = 123;    // 合法
val = "TS";   // 合法
val = true;   // 合法
val = null;   // 合法
val = false;  // ❌ 报错:不在联合类型中
  1. 字面量元组
// 字面量元组:元素是数字/字符串字面量
type StatusTuple = [200, 404, 500];
type EnvTuple = ["dev", "test", "prod"];

// 转字面量联合类型(开发中常用的枚举式类型)
type Status = StatusTuple[number]; // 结果:200 | 404 | 500
type Env = EnvTuple[number];       // 结果:"dev" | "test" | "prod"

// 严格限制变量值,避免手写错误
let code: Status = 200; // 合法
code = 404;             // 合法
code = 403;             // ❌ 报错:403 不在 200|404|500 中

let env: Env = "dev";   // 合法
env = "prod";           // 合法
env = "production";     // ❌ 报错:不在联合类型中
  1. as const + 数组 + T[number]

同时拥有数组的可遍历性 + 联合类型的严格类型约束。

// 步骤1:用 as const 断言数组为「只读字面量元组」
// 作用:让 TS 保留每个元素的字面量类型,且把数组转为只读元组(不可修改)
const EnvArr = ["dev", "test", "prod"] as const;
const StatusArr = [200, 404, 500] as const;

// 步骤2:用 typeof 获取数组的类型(只读字面量元组类型)
// 补充:typeof 是 TS 关键字,用于「从变量中提取其类型」
type EnvTuple = typeof EnvArr; // 类型:readonly ["dev", "test", "prod"]
type StatusTuple = typeof StatusArr; // 类型:readonly [200, 404, 500]

// 步骤3:用 T[number] 转成字面量联合类型
type Env = EnvTuple[number]; // 结果:"dev" | "test" | "prod"
type Status = StatusTuple[number]; // 结果:200 | 404 | 500

// 简化写法(开发中常用,省略中间元组类型)
type EnvSimplify = typeof EnvArr[number];
type StatusSimplify = typeof StatusArr[number];
  1. 泛型中使用 T[number]
// 泛型 T 约束为「只读数组」(兼容 as const 断言的数组)
function getUnionType<T extends readonly any[]>(arr: T): T[number] {
  return arr[Math.floor(Math.random() * arr.length)];
}

// 传入 as const 断言的数组,返回值自动推导为字面量联合类型
const res1 = getUnionType(["dev", "test", "prod"] as const); // res1 类型:"dev" | "test" | "prod"
const res2 = getUnionType([1, 2, 3] as const); // res2 类型:1 | 2 | 3

// 传入普通数组,返回值推导为基础类型
const res3 = getUnionType([1, 2, 3]); // res3 类型:number
  1. 支持嵌套数组 / 元组
const NestedArr = [[1, "a"], [2, "b"]] as const;
type NestedUnion = typeof NestedArr[number]; // 结果:readonly [1, "a"] | readonly [2, "b"]
type DeepUnion = typeof NestedArr[number][number]; // 结果:1 | "a" | 2 | "b"

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const
const tupleNumber = [1, 2, 3, 4] as const
const sym1 = Symbol(1)
const sym2 = Symbol(2)
const tupleSymbol = [sym1, sym2] as const
const tupleMix = [1, '2', 3, '4', sym1] as const

type cases = [
  Expect<Equal<TupleToObject<typeof tuple>, { 'tesla': 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y' }>>,
  Expect<Equal<TupleToObject<typeof tupleNumber>, { 1: 1, 2: 2, 3: 3, 4: 4 }>>,
  Expect<Equal<TupleToObject<typeof tupleSymbol>, { [sym1]: typeof sym1, [sym2]: typeof sym2 }>>,
  Expect<Equal<TupleToObject<typeof tupleMix>, { 1: 1, '2': '2', 3: 3, '4': '4', [sym1]: typeof sym1 }>>,
]

// @ts-expect-error
type error = TupleToObject<[[1, 2], {}]>

相关链接

分享你的解答:tsch.js.org/11/answer/z… 查看解答:tsch.js.org/11/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

type-challenges(ts类型体操): 10 - 元组转合集

10 - 元组转合集

by Anthony Fu (@antfu) #中等 #infer #tuple #union

题目

实现泛型TupleToUnion<T>,它返回元组所有值的合集。

例如

type Arr = ['1', '2', '3']

type Test = TupleToUnion<Arr> // expected to be '1' | '2' | '3'

在 Github 上查看:tsch.js.org/10/zh-CN

代码

/* _____________ 你的代码 _____________ */

type TupleToUnion<T> = T extends [infer F, ...infer R] ? F | TupleToUnion<R> : never

关键解释:

  • T extends [infer F, ...infer R] 用于判断元组是否为空。
  • F | TupleToUnion<R> 用于递归处理元组的剩余部分。
  • never 用于处理空元组的情况。

相关知识点

extends

使用维度 核心作用 示例场景
类型维度 做类型约束或条件判断(类型编程核心) 限定泛型范围、判断类型是否兼容、提取类型片段
语法维度 做继承(复用已有结构) 接口继承、类继承
extends 做类型约束或条件判断
  1. 泛型约束:限定泛型的取值范围
// 约束 T 必须是「拥有 length 属性」的类型(比如 string/数组)
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

// 合法调用(符合约束)
getLength("hello"); // ✅ string 有 length,返回 5
getLength([1, 2, 3]); // ✅ 数组有 length,返回 3

// 非法调用(超出约束)
getLength(123); // ❌ 报错:number 没有 length 属性
  1. 条件类型:类型版 三元运算符
// 基础示例:判断类型是否为字符串
type IsString<T> = T extends string ? true : false;

type A = IsString<"test">; // true(符合)
type B = IsString<123>; // false(不符合)

分布式条件类型(联合类型专用): 当 T 是联合类型时,extends 会自动拆分联合类型的每个成员,逐个判断后再合并结果。

type Union = string | number | boolean;

// 拆分逻辑:string→string,number→never,boolean→never → 合并为 string
type OnlyString<T> = T extends string ? T : never;
type Result = OnlyString<Union>; // Result = string

注意:只有泛型参数是 裸类型(没有被 []/{} 包裹)时,才会触发分布式判断:

// 包裹后不触发分布式,整体判断 [string|number] 是否兼容 [string]
type NoDist<T> = [T] extends [string] ? T : never;
type Result2 = NoDist<Union>; // never(整体不兼容)
  1. 配合 infer:提取类型片段(黄金组合)
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;

type C = UnwrapPromise<Promise<string>>; // string(提取成功)
type D = UnwrapPromise<number>; // number(不满足条件,返回原类型)
extends 做继承(复用已有结构)
  1. 接口继承:复用 + 扩展属性
// 基础接口
interface User {
  id: number;
  name: string;
}

// 继承 User,并扩展新属性
interface Admin extends User {
  role: "admin" | "super_admin"; // 新增权限属性
}

// 必须包含继承的 + 扩展的所有属性
const admin: Admin = {
  id: 1,
  name: "张三",
  role: "admin"
};

// 多接口继承
interface HasAge { age: number; }
interface Student extends User, HasAge {
  className: string; // 同时继承 User + HasAge
}
  1. 类继承:复用父类的属性 / 方法
class Parent {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  sayHi() {
    console.log(`Hi, ${this.name}`);
  }
}

// 继承 Parent 类
class Child extends Parent {
  age: number;
  constructor(name: string, age: number) {
    super(name); // 必须调用父类构造函数(初始化父类属性)
    this.age = age;
  }
  // 重写父类方法
  sayHi() {
    super.sayHi(); // 调用父类原方法
    console.log(`I'm ${this.age} years old`);
  }
}

const child = new Child("李四", 10);
child.sayHi(); // 输出:Hi, 李四 → I'm 10 years old

补充:类实现接口用 implements(不是 extends

// 定义接口(契约:规定必须有 id、name 属性,以及 greet 方法)
interface Person {
  id: number;
  name: string;
  greet(): void; // 仅定义方法签名,无实现
}

// 类实现接口(必须严格遵守契约)
class Employee implements Person {
  // 必须实现接口的所有属性
  id: number;
  name: string;

  // 构造函数初始化属性
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 必须实现接口的 greet 方法(具体实现由类自己定义)
  greet() {
    console.log(`Hi, I'm ${this.name}, ID: ${this.id}`);
  }
}

// 实例化使用
const emp = new Employee(1, "张三");
emp.greet(); // 输出:Hi, I'm 张三, ID: 1


// 接口1:基础信息
interface Identifiable {
  id: number;
  getId(): number;
}

// 接口2:可打印
interface Printable {
  printInfo(): void;
}

// 类同时实现两个接口(必须实现所有接口的成员)
class Product implements Identifiable, Printable {
  id: number;
  name: string; // 类可扩展接口外的属性

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }

  // 实现 Identifiable 的方法
  getId(): number {
    return this.id;
  }

  // 实现 Printable 的方法
  printInfo() {
    console.log(`Product: ${this.name}, ID: ${this.getId()}`);
  }
}

const product = new Product(100, "手机");
console.log(product.getId()); // 100
product.printInfo(); // Product: 手机, ID: 100

infer

infer 是 TypeScript 在条件类型中提供的关键字,用于声明一个 待推导的类型变量(类似给类型起一个临时名字),只能在 extends 子句中使用。它的核心作用是:从已有类型中提取 / 推导我们需要的部分,而无需手动硬编码类型。

infer 必须配合条件类型使用,语法结构如下:

// 基础结构:推导 T 的类型为 U,若能推导则返回 U,否则返回 never
type InferType<T> = T extends infer U ? U : never;

type Example = InferType<string>; // Example 类型为 string
type Example2 = InferType<number[]>; // Example2 类型为 number[]

高频使用场景:

1. 提取函数的返回值类型
// 定义类型工具:提取函数的返回值类型
type GetReturnType<Fn> = Fn extends (...args: any[]) => infer R ? R : never;

// 测试用函数
const add = (a: number, b: number): number => a + b;
const getUser = () => ({ name: "张三", age: 20 });

// 使用类型工具
type AddReturn = GetReturnType<typeof add>; // AddReturn 类型为 number
type UserReturn = GetReturnType<typeof getUser>; // UserReturn 类型为 { name: string; age: number }
2. 提取数组的元素类型
// 定义类型工具:提取数组元素类型
type GetArrayItem<T> = T extends (infer Item)[] ? Item : never;

// 测试
type NumberArray = GetArrayItem<number[]>; // NumberArray 类型为 number
type StringArray = GetArrayItem<string[]>; // StringArray 类型为 string
type MixedArray = GetArrayItem<[string, number]>; // MixedArray 类型为 string | number
3. 提取 Promise 的泛型参数类型
// 定义类型工具:提取 Promise 的泛型类型
type GetPromiseValue<T> = T extends Promise<infer Value> ? Value : never;

// 测试
type PromiseString = GetPromiseValue<Promise<string>>; // PromiseString 类型为 string
type PromiseUser = GetPromiseValue<Promise<{ id: number }>>; // PromiseUser 类型为 { id: number }
4. 提取函数的参数类型
// 定义类型工具:提取函数参数类型
type GetFunctionParams<Fn> = Fn extends (...args: infer Params) => any ? Params : never;

// 测试
const fn = (name: string, age: number): void => {};
type FnParams = GetFunctionParams<typeof fn>; // FnParams 类型为 [string, number]

// 进一步:提取第一个参数的类型
type FirstParam = GetFunctionParams<typeof fn>[0]; // FirstParam 类型为 string

|

| 运算符用于表示联合类型,即一个值可以是多个类型中的任意一个。

  1. 变量的联合类型注解
// 变量 a 可以是字符串 OR 数字
let a: string | number;

// 合法赋值(符合任意一种类型)
a = "TS";
a = 123;

// 非法赋值(不属于联合类型中的任何一种),TS 直接报错
a = true; // ❌ 类型 'boolean' 不能赋值给类型 'string | number'
  1. 函数参数的联合类型
// 函数接收 string 或 number 类型的参数
function printValue(val: string | number) {
  console.log(val);
}

// 合法调用
printValue("hello");
printValue(666);

// 非法调用,TS 报错
printValue(null); // ❌
  1. 数组的联合类型(注意两种写法的区别)
// 写法1:(A | B)[] —— 数组的「每个元素」可以是 A 或 B(混合数组)
let arr1: (string | number)[] = [1, "2", 3, "4"]; // 合法

// 写法2:A[] | B[] —— 「整个数组」要么全是 A 类型,要么全是 B 类型(纯数组)
let arr2: string[] | number[] = [1, 2, 3]; // 合法(全数字)
arr2 = ["1", "2", "3"]; // 合法(全字符串)
arr2 = [1, "2"]; // ❌ 报错:混合类型不符合要求

当使用联合类型的时候,访问某一个子类型的专属属性 / 方法时,需要进行类型守卫,可用的方法有 typeofinswitchinstanceof

  1. typeof
function getLength(val: string | number) {
  // 类型窄化:判断 val 是 string 类型
  if (typeof val === "string") {
    // 此分支中,TS 确定 val 是 string,可安全使用 length
    return val.length;
  } else {
    // 此分支中,TS 确定 val 是 number,执行数字相关逻辑
    return val.toString().length;
  }
}

console.log(getLength("TS")); // 2
console.log(getLength(1234)); // 4
  1. in
function printUserInfo(user: { name: string } | { age: number }) {
  // 类型窄化:判断 user 是否有 name 属性(即是否是 { name: string } 类型)
  if ("name" in user) {
    console.log(`Name: ${user.name}`);
  } else {
    // 此分支中,TS 确定 user 是 { age: number } 类型
    console.log(`Age: ${user.age}`);
  }
}
  1. switch
interface User {
  type: "user";
  name: string;
  age: number;
}
interface Admin {
  type: "admin";
  name: string;
  permission: string[];
}
// 联合类型:可以是 User 或 Admin
type Person = User | Admin;
function printPerson(p: Person) {
  switch (p.type) {
    case "user":
      console.log(p.age); // 确定是 User
      break;
    case "admin":
      console.log(p.permission); // 确定是 Admin
      break;
  }
}
  1. instanceof
// 定义两个类
class Dog {
  bark() { console.log("汪汪"); }
}
class Cat {
  meow() { console.log("喵喵"); }
}

// 联合类型:Dog 或 Cat 实例
type Animal = Dog | Cat;

// instanceof 类型守卫(针对类实例)
function animalCall(animal: Animal) {
  if (animal instanceof Dog) {
    animal.bark();
  } else {
    animal.meow();
  }
}

animalCall(new Dog()); // 汪汪
animalCall(new Cat()); // 喵喵

测试用例

/* _____________ 测试用例 _____________ */
import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<TupleToUnion<[123, '456', true]>, 123 | '456' | true>>,
  Expect<Equal<TupleToUnion<[123]>, 123>>,
]

相关链接

分享你的解答:tsch.js.org/10/answer/z… 查看解答:tsch.js.org/10/solution… 更多题目:tsch.js.org/zh-CN

下面是我的公众号,欢迎关注。关注后有新的功能点会及时收到推送。

实战为王!专注于汇总各种功能点,致力于打造一系列能够帮助工程师实现各种功能的想法思路的优质文章。

前端功能点

为网页注入灵魂:Live2D Widget看板娘,打造会动的互动伙伴!

厌倦了静态网页的冰冷与单调?Live2D Widget 能将一个生动、可爱的看板娘轻松带入你的网站。只需一行代码,这个由 TypeScript 驱动的开源项目即可为博客、个人主页或任何网页赋予灵动的生命。她不仅会眨眼、转头,还能与访客进行简单的互动,瞬间提升网站的趣味性与亲和力。无论是技术极客追求的可定制性,还是普通用户向往的轻松集成,Live2D Widget 都能满足。跟随本文,一分钟唤醒你的网页,让数字世界多一位温暖陪伴。

🎯项目介绍

  • • 页面互动:在网页中添加 Live2D 看板娘
  • • 易于集成: 核心代码由 TypeScript 编写,易于集成,只需一行代码,即可为网站添加看板娘
  • • 轻量级设计:除Live2D核心库外无额外依赖,加载迅速
  • • 高度可定制:支持多种配置选项,完美适配你的网站风格

github地址:github.com/stevenjoezh…

官网地址:www.live2d.com/en

该项目目前在github上已有 10.4k ⭐️ star

⚡快速开始:一分钟集成

对于大多数用户来说,集成过程简单得令人惊喜:

<!-- 只需在页面中添加这行代码 -->
<script src="https://fastly.jsdelivr.net/npm/live2d-widgets@1.0.0/dist/autoload.js"></script>

我的博客页面 xiuji008.github.io/ 已经集成了,家人们可以移步过去看看效果,以下是一些效果图

🎖️进阶

如果你是小白,我们通过上边介绍的那行代码就已经把看板娘集成进来了。但是如果你想让看板娘更适合你的网站,你可以通过进一步的配置及开发来完成,感兴趣的家人们可以自行研究。

相关技术博文:www.fghrsh.net/post/123.ht…

📝 总结:让网页活起来

Live2D Widget不仅仅是一个技术项目,它代表了网页交互的新可能。在这个数字化的时代,一个灵动的看板娘能够:

  • • 提升用户体验和停留时间
  • • 增加网站的特色和记忆点
  • • 展示技术实力和创意精神

无论你是技术爱好者、博主还是网站开发者,Live2D Widget都能为你的项目增添独特的魅力。立即尝试,让你的网站拥有一个随时陪伴访客的可爱伙伴吧!

让技术更有温度,让网页更有生命!🚀

大模型发展史-01

前言

2017年,一篇论文悄然发表,题为《Attention Is All You Need》。

当时没人预料到,这篇论文中提出的 Transformer 架构,会在短短几年内彻底改变人工智能的格局。

五年后的2022年11月30日,ChatGPT 发布。五天内,用户突破100万。两个月内,用户突破1亿。

这是互联网历史上增长最快的应用,也是人工智能发展史上的重要里程碑。

从默默无闻到席卷全球,大语言模型经历了怎样的进化之路?让我们一起回顾这段激动人心的技术演进史。


1. 什么是 Transformer

Transformer 是一种完全基于注意力机制的神经网络架构,于2017年由 Google 团队提出。

核心创新

特点 说明
Self-Attention 自注意力机制,捕捉长距离依赖
并行计算 可并行训练,大幅提升效率
可扩展性 为后续大模型奠定基础

核心思想

// Transformer 的核心:Self-Attention
class Transformer {
  attention(Q, K, V) {
    // Q (Query)、K (Key)、V (Value)
    const scores = Q @ K.T / Math.sqrt(d_k);  // 计算注意力分数
    const weights = softmax(scores);           // 归一化
    return weights @ V;                        // 加权求和
  }
}

重要术语

术语 解释
预训练 用大量无标注数据训练基础模型
微调 针对特定任务用小数据集优化模型
RLHF 人类反馈强化学习,对齐人类偏好
少样本学习 只需几个例子就能学会新任务

2. 案例

案例 1:GPT 系列的进化之路

让我们看看 GPT 系列是如何一步步进化的:

代际 发布时间 参数量 能力突破
GPT-1 2018.06 117M 预训练范式
GPT-2 2019.02 1.5B 零样本生成
GPT-3 2020.05 175B 少样本学习
GPT-3.5 2022.11 未知 对话能力
GPT-4 2023.03 ~1.7T 多模态+推理
GPT-4o 2024.05 未知 原生多模态

关键突破:GPT-3 的少样本学习

const prompt = `
翻译以下句子成中文:
Example 1: Hello world -> 你好世界
Example 2: How are you -> 你好吗
Input: Good morning -> ?
`;
// GPT-3: 早上好
// 没有专门训练,就能学会翻译任务

案例 2:ChatGPT 的 AI iPhone 时刻

发布时间:2022年11月30日

突破性改进

训练流程:
1. 预训练(学习知识)
   ↓
2. 有监督微调(学习指令)
   ↓
3. 奖奖模型(学习人类偏好)
   ↓
4. 强化学习(优化输出)

成果

  • 对话能力大幅提升
  • 指令遵循能力强
  • 多轮对话流畅
  • 5天用户破100万

案例 3:2023年百花齐放

闭源模型三强鼎立

模型 公司 核心优势
GPT-4 OpenAI 多模态、推理能力强
Claude 3 Anthropic 超长上下文(200K)
Gemini Google 原生多模态

开源模型快速追赶

模型 组织 参数 特点
Llama 3 Meta 8B/70B 性能强劲
Qwen 阿里云 7B/14B/72B 中文优秀
Mistral Mistral AI 7B 效率之王

中国大模型崛起

模型 公司 特色
文心一言 百度 知识图谱增强
通义千问 阿里云 开源友好
讯飞星火 科大讯飞 语音能力强
DeepSeek 幻方量化 性价比高

案例 4:2024年的三大趋势

趋势1:开源模型追平闭源

2024年初:Llama 2 70B  GPT-3.5
2024年中:Llama 3 70B 接近 GPT-4
2024年底:Qwen 2.5、DeepSeek V3 追平闭源

趋势2:多模态成为标配

  • GPT-4o:原生多模态
  • Claude 3.5:强大的视觉能力
  • Gemini:从一开始就是多模态

趋势3:智能体技术成熟

// Agent 能力的进化
2022:简单对话
2023:工具调用
2024:
  ├── 复杂任务规划
  ├── 多智能体协作
  ├── 自主学习和改进
  └── 真正的"AI 员工"

总结

  1. 规模即质量——更大的模型通常表现更好
  2. 数据是关键——高质量训练数据至关重要
  3. 架构创新——Transformer 是核心突破
  4. 开源加速——开源模型推动技术普及

什么是大语言模型-00

前言

你有没有想过,当你问 ChatGPT 一个问题时,它是如何"思考"并给出回答的?

今天天气怎么样?——抱歉,我无法获取实时天气信息。 请用 JavaScript 写一个快速排序——几秒钟内,代码就出现在屏幕上。

同样是 AI,为什么能写代码却不能查天气?大语言模型的"知识"从哪里来?它是真的"理解"我们的话吗?

这些问题,正是我们探索大语言模型(Large Language Model,LLM)世界的起点。


1. 什么是大语言模型

大语言模型(LLM) 是一种经过海量文本数据训练的深度学习模型,能够理解和生成人类语言。

关键特征

特征 说明 例子
大规模训练 使用 TB 级文本数据 GPT-4 训练了约 1 万亿 tokens
深度神经网络 数十亿到数万亿参数 GPT-3 有 1750 亿参数
通用能力 不需要专门训练就能完成多种任务 翻译、写作、编程、推理

通俗理解

想象一下:

  • 你阅读了互联网上几乎所有的文本
  • 你记住了其中的模式、规律和知识
  • 当有人问你问题时,你能根据记忆生成回答

这就是大语言模型做的事情!

核心工作原理

LLM 的本质是一个文字接龙机器

输入: "今天天气"
LLM 预测下一个词可能是:
- "真好"    (概率 30%)
- "很热"    (概率 25%)
- "怎么样"  (概率 20%)

训练流程

┌─────────────────────────────────────────┐
│            LLM 训练流程                   │
├─────────────────────────────────────────┤
│                                         │
│  1. 数据收集                             │
│     ├── 网页文本                         │
│     ├── 书籍文章                         │
│     └── 代码库                           │
│                                         │
│  2. 预训练                               │
│     ├── 学习语言模式                     │
│     ├── 学习世界知识                     │
│     └── 学习逻辑推理                     │
│                                         │
│  3. 微调                                 │
│     ├── 对齐人类偏好                     │
│     ├── 遵循指令                         │
│     └── 安全性训练                       │
│                                         │
└─────────────────────────────────────────┘

四大核心能力

1. 语言理解

  • 理解文本含义
  • 识别情感倾向
  • 提取关键信息

2. 语言生成

  • 写文章、写代码
  • 创意写作
  • 总结提炼

3. 逻辑推理

  • 数学计算
  • 逻辑推理
  • 问题解决

4. 少样本学习

  • 看几个例子就能学会新任务
  • 不需要重新训练

2. 案例

案例 1:少样本学习的神奇之处

让我们看看 LLM 如何通过几个例子学会新任务:

const prompt = `
例子1:
输入:苹果
输出:水果

例子2:
输入:胡萝卜
输出:蔬菜

输入:香蕉
输出:?
`;
// LLM 能推断:香蕉 → 水果

image.png

解析:无需重新训练,只需几个示例,LLM 就能理解分类规律并应用到新问题。

案例 2:代码生成能力

输入:"请用 JavaScript 写一个快速排序"

LLM 输出

function quickSort(arr) {
  if (arr.length <= 1) {
    return arr;
  }
  const pivot = arr[Math.floor(arr.length / 2)];
  const left = arr.filter(x => x < pivot);
  const middle = arr.filter(x => x === pivot);
  const right = arr.filter(x => x > pivot);
  return [...quickSort(left), ...middle, ...quickSort(right)];
}

解析:LLM 从训练数据中学会了编程模式和算法逻辑,能够生成可运行的代码。

案例 3:发现 LLM 的局限性

测试 1:实时信息

用户: "今天天气怎么样?"
LLM: "抱歉,我无法获取实时天气信息。"

测试 2:精确计算

用户: "12345 × 67890 = ?"
LLM: "大约是 83,000,000 左右"
实际: 838,102,050

测试 3:知识截止

用户: "谁赢得了2024年奥运会?"
LLM: "抱歉,我的知识截止到2023年..."

解析:这些测试揭示了 LLM 的三大局限——知识截止、幻觉问题、无法访问实时信息。

案例 4:实际项目中的调用

在本项目的后端代码中,LLM 调用是这样实现的:

async chat(request: {
  question: string;    // 用户的问题
  model: string;       // 使用的模型(如 qwen-plus)
  apiKey: string;      // API 密钥
}) {
  // 调用阿里云百炼的 LLM
  const response = await axios.post(
    'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
    {
      model: request.model,
      messages: [{ role: 'user', content: request.question }]
    }
  );

  return response.data.choices[0].message.content;
}

解析:通过 HTTP API 调用,将用户问题发送给 LLM,获取生成的回复。


总结

  1. LLM 是文字接龙机器——核心原理是预测下一个词
  2. LLM 有强大但有限的能力——理解、生成、推理、学习都很强,但并非万能
  3. LLM 的知识来自训练数据——它学习的是模式和规律,而非简单记忆
  4. LLM 会犯错——幻觉、知识截止、计算不精确是常见问题

Flutter最佳实践:Sliver族网络刷新组件NCustomScrollView

一、需求来源

最近需要实现嵌套和吸顶Header滚动下的下拉刷新及上拉加载。最终实现基于 CustomScrollView 的刷新视图组件。

simulator_screenshot_5C4883E4-F919-4FFD-BE3D-97E0BCD5C40D.png

二、使用示例

Widget buildBodyNew() {
  return NCustomScrollView<String>(
    onRequest: (bool isRefresh, int page, int pageSize, pres) async {
      final length = isRefresh ? 0 : pres.length;
      final list = List<String>.generate(pageSize, (i) => "item${length + i}");
      DLog.d([isRefresh, list.length]);
      return list;
    },
    headerSliverBuilder: (context, bool innerBoxIsScrolled) {
      return [
        buildPersistentHeader(),
      ];
    },
    itemBuilder: (_, i, e) {
      return ListTile(
        title: Text('Item $i'),
      );
    },
  );
}

三、源码

//
//  NCustomScrollView.dart
//  projects
//
//  Created by shang on 2026/1/28 14:41.
//  Copyright © 2026/1/28 shang. All rights reserved.
//

import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/n_placeholder.dart';
import 'package:flutter_templet_project/basicWidget/n_sliver_decorated.dart';
import 'package:flutter_templet_project/basicWidget/refresh/easy_refresh_mixin.dart';
import 'package:flutter_templet_project/basicWidget/refresh/n_refresh_view.dart';

/// 基于 CustomScrollView 的下拉刷新,上拉加载更多的滚动列表
class NCustomScrollView<T> extends StatefulWidget {
  const NCustomScrollView({
    super.key,
    this.title,
    this.placeholder = const NPlaceholder(),
    this.contentDecoration = const BoxDecoration(),
    this.contentPadding = const EdgeInsets.all(0),
    required this.onRequest,
    required this.headerSliverBuilder,
    required this.itemBuilder,
    this.separatorBuilder,
    this.headerBuilder,
    this.footerBuilder,
    this.builder,
  });

  final String? title;

  final Widget? placeholder;

  final Decoration contentDecoration;

  final EdgeInsets contentPadding;

  /// 请求方法
  final RequestListCallback<T> onRequest;

  /// 列表表头
  final NestedScrollViewHeaderSliversBuilder? headerSliverBuilder;

  /// ListView 的 itemBuilder
  final ValueIndexedWidgetBuilder<T> itemBuilder;

  final IndexedWidgetBuilder? separatorBuilder;

  /// 列表表头
  final List<Widget> Function(int count)? headerBuilder;

  /// 列表表尾
  final List<Widget> Function(int count)? footerBuilder;

  final Widget Function(List<T> items)? builder;

  @override
  State<NCustomScrollView<T>> createState() => _NCustomScrollViewState<T>();
}

class _NCustomScrollViewState<T> extends State<NCustomScrollView<T>>
    with AutomaticKeepAliveClientMixin, EasyRefreshMixin<NCustomScrollView<T>, T> {
  @override
  bool get wantKeepAlive => true;

  final scrollController = ScrollController();

  @override
  late RequestListCallback<T> onRequest = widget.onRequest;

  @override
  List<T> items = <T>[];

  @override
  void didUpdateWidget(covariant NCustomScrollView<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.title != oldWidget.title ||
        widget.placeholder != oldWidget.placeholder ||
        widget.contentDecoration != oldWidget.contentDecoration ||
        widget.contentPadding != oldWidget.contentPadding ||
        widget.onRequest != oldWidget.onRequest ||
        widget.itemBuilder != oldWidget.itemBuilder ||
        widget.separatorBuilder != oldWidget.separatorBuilder) {
      setState(() {});
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    if (items.isEmpty) {
      return GestureDetector(onTap: onRefresh, child: Center(child: widget.placeholder));
    }

    final child = EasyRefresh.builder(
      controller: refreshController,
      onRefresh: onRefresh,
      onLoad: onLoad,
      childBuilder: (_, physics) {
        return CustomScrollView(
          physics: physics,
          slivers: [
            ...(widget.headerBuilder?.call(items.length) ?? []),
            buildContent(),
            ...(widget.footerBuilder?.call(items.length) ?? []),
          ],
        );
      },
    );
    if (widget.headerSliverBuilder == null) {
      return child;
    }

    return NestedScrollView(
      headerSliverBuilder: widget.headerSliverBuilder!,
      body: child,
    );
  }

  Widget buildContent() {
    if (items.isEmpty) {
      return SliverToBoxAdapter(child: widget.placeholder);
    }

    return NSliverDecorated(
      decoration: widget.contentDecoration,
      sliver: SliverPadding(
        padding: widget.contentPadding,
        sliver: widget.builder?.call(items) ?? buildSliverList(),
      ),
    );
  }

  Widget buildSliverList() {
    return SliverList.separated(
      itemBuilder: (_, i) => widget.itemBuilder(context, i, items[i]),
      separatorBuilder: (_, i) => widget.separatorBuilder?.call(context, i) ?? const SizedBox(),
      itemCount: items.length,
    );
  }
}

源码:EasyRefreshMixin.dart

//
//  EasyRefreshMixin.dart
//  projects
//
//  Created by shang on 2026/1/28 14:37.
//  Copyright © 2026/1/28 shang. All rights reserved.
//

import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/refresh/n_refresh_view.dart';

/// EasyRefresh刷新 mixin
mixin EasyRefreshMixin<W extends StatefulWidget, T> on State<W> {
  late final refreshController = EasyRefreshController(
    controlFinishRefresh: true,
    controlFinishLoad: true,
  );


  /// 请求方式
  late RequestListCallback<T> _onRequest;
  RequestListCallback<T> get onRequest => _onRequest;
  set onRequest(RequestListCallback<T> value) {
    _onRequest = value;
  }

  // 数据列表
  List<T> _items = [];
  List<T> get items => _items;
  set items(List<T> value) {
    _items = value;
  }

  int page = 1;
  final int pageSize = 20;
  var indicator = IndicatorResult.success;

  @override
  void dispose() {
    refreshController.dispose();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      // DLog.d([widget.title, widget.key, hashCode]);
      if (items.isEmpty) {
        onRefresh();
      }
    });
  }

  Future<void> onRefresh() async {
    try {
      page = 1;
      final list = await onRequest(true, page, pageSize, <T>[]);
      items.replaceRange(0, items.length, list);
      page++;

      final noMore = list.length < pageSize;
      if (noMore) {
        indicator = IndicatorResult.noMore;
      }
      refreshController.finishRefresh();
      refreshController.resetFooter();
    } catch (e) {
      refreshController.finishRefresh(IndicatorResult.fail);
    }
    setState(() {});
  }

  Future<void> onLoad() async {
    if (indicator == IndicatorResult.noMore) {
      refreshController.finishLoad();
      return;
    }

    try {
      final start = (items.length - pageSize).clamp(0, pageSize);
      final prePages = items.sublist(start);
      final list = await onRequest(false, page, pageSize, prePages);
      items.addAll(list);
      page++;

      final noMore = list.length < pageSize;
      if (noMore) {
        indicator = IndicatorResult.noMore;
      }
      refreshController.finishLoad(indicator);
    } catch (e) {
      refreshController.finishLoad(IndicatorResult.fail);
    }
    setState(() {});
  }
}

最后、总结

1、当页面比较复杂,需要吸顶或者嵌套滚动时就必须使用 Sliver 相关组件,否则会有滚动行文冲突。

2、NCustomScrollView 支持顶部吸顶组件自定义;底部列表头,列表尾设置,支持sliver 设置 Decoration。

3、支持下拉刷新,上拉加载更多,代码极简,使用方便。

4、刷新逻辑封装在 EasyRefreshMixin 混入里,方便多组件可共用。

github

flutter添加间隙gap源码解析

Flutter 小部件,可轻松在 Flex 小部件(如列和行)或滚动视图中添加间隙。

Gap的核心原理是使用RenderObject自定义实现布局。

Gap


class Gap extends StatelessWidget {
  const Gap(
    this.mainAxisExtent, {
    Key? key,
    this.crossAxisExtent,
    this.color,
  })  : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity),
        assert(crossAxisExtent == null || crossAxisExtent >= 0),
        super(key: key);

  const Gap.expand(
    double mainAxisExtent, {
    Key? key,
    Color? color,
  }) : this(
          mainAxisExtent,
          key: key,
          crossAxisExtent: double.infinity,
          color: color,
        );

  final double mainAxisExtent;

  final double? crossAxisExtent;

  final Color? color;

  @override
  Widget build(BuildContext context) {
    final scrollableState = Scrollable.maybeOf(context);
    final AxisDirection? axisDirection = scrollableState?.axisDirection;
    final Axis? fallbackDirection =
        axisDirection == null ? null : axisDirectionToAxis(axisDirection);

    return _RawGap(
      mainAxisExtent,
      crossAxisExtent: crossAxisExtent,
      color: color,
      fallbackDirection: fallbackDirection,
    );
  }
}

从源码看, 它提供了两个构造方法, GapGap.expand方便用户按需使用。

_RawGap

_RawGap是核心类, 它继承了LeafRenderObjectWidget.

class _RawGap extends LeafRenderObjectWidget {
  const _RawGap(
    this.mainAxisExtent, {
    Key? key,
    this.crossAxisExtent,
    this.color,
    this.fallbackDirection,
  })  : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity),
        assert(crossAxisExtent == null || crossAxisExtent >= 0),
        super(key: key);

  final double mainAxisExtent;

  final double? crossAxisExtent;

  final Color? color;

  final Axis? fallbackDirection;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderGap(
      mainAxisExtent: mainAxisExtent,
      crossAxisExtent: crossAxisExtent ?? 0,
      color: color,
      fallbackDirection: fallbackDirection,
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderGap renderObject) {
    if (kDebugMode) {
      debugPrint(
        '[Gap] updateRenderObject '
        'mainAxisExtent=$mainAxisExtent '
        'crossAxisExtent=${crossAxisExtent ?? 0} '
        'color=$color '
        'fallbackDirection=$fallbackDirection',
      );
    }
    renderObject
      ..mainAxisExtent = mainAxisExtent
      ..crossAxisExtent = crossAxisExtent ?? 0
      ..color = color
      ..fallbackDirection = fallbackDirection;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DoubleProperty('mainAxisExtent', mainAxisExtent));
    properties.add(
        DoubleProperty('crossAxisExtent', crossAxisExtent, defaultValue: 0));
    properties.add(ColorProperty('color', color));
    properties.add(EnumProperty<Axis>('fallbackDirection', fallbackDirection));
  }
}

RenderGap

class RenderGap extends RenderBox {
  RenderGap({
    required double mainAxisExtent,
    double? crossAxisExtent,
    Axis? fallbackDirection,
    Color? color,
  })  : _mainAxisExtent = mainAxisExtent,
        _crossAxisExtent = crossAxisExtent,
        _color = color,
        _fallbackDirection = fallbackDirection {
    if (kDebugMode) {
      debugPrint(
        '🆕 RenderGap<init> '
        'mainAxisExtent=$mainAxisExtent '
        'crossAxisExtent=$crossAxisExtent '
        'color=$color '
        'fallbackDirection=$fallbackDirection',
      );
    }
  }

  double get mainAxisExtent => _mainAxisExtent;
  double _mainAxisExtent;
  set mainAxisExtent(double value) {
    if (_mainAxisExtent != value) {
      if (kDebugMode) {
        debugPrint('📏 mainAxisExtent set: $_mainAxisExtent -> $value');
      }
      _mainAxisExtent = value;
      markNeedsLayout();
    }
  }

  double? get crossAxisExtent => _crossAxisExtent;
  double? _crossAxisExtent;
  set crossAxisExtent(double? value) {
    if (_crossAxisExtent != value) {
      if (kDebugMode) {
        debugPrint('📐 crossAxisExtent set: $_crossAxisExtent -> $value');
      }
      _crossAxisExtent = value;
      markNeedsLayout();
    }
  }

  Axis? get fallbackDirection => _fallbackDirection;
  Axis? _fallbackDirection;
  set fallbackDirection(Axis? value) {
    if (_fallbackDirection != value) {
      if (kDebugMode) {
        debugPrint('🧭 fallbackDirection set: $_fallbackDirection -> $value');
      }
      _fallbackDirection = value;
      markNeedsLayout();
    }
  }

  Axis? get _direction {
    final parentNode = parent;
    if (parentNode is RenderFlex) {
      return parentNode.direction;
    } else {
      return fallbackDirection;
    }
  }

  Color? get color => _color;
  Color? _color;
  set color(Color? value) {
    if (_color != value) {
      if (kDebugMode) {
        debugPrint('🎨 color set: $_color -> $value');
      }
      _color = value;
      markNeedsPaint();
    }
  }

  @override
  double computeMinIntrinsicWidth(double height) {
    final result = _computeIntrinsicExtent(
      Axis.horizontal,
      () => super.computeMinIntrinsicWidth(height),
    )!;
    if (kDebugMode) {
      debugPrint('🔹 computeMinIntrinsicWidth(height=$height) => $result');
    }
    return result;
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    final result = _computeIntrinsicExtent(
      Axis.horizontal,
      () => super.computeMaxIntrinsicWidth(height),
    )!;
    if (kDebugMode) {
      debugPrint('🔷 computeMaxIntrinsicWidth(height=$height) => $result');
    }
    return result;
  }

  @override
  double computeMinIntrinsicHeight(double width) {
    final result = _computeIntrinsicExtent(
      Axis.vertical,
      () => super.computeMinIntrinsicHeight(width),
    )!;
    if (kDebugMode) {
      debugPrint('🔸 computeMinIntrinsicHeight(width=$width) => $result');
    }
    return result;
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    final result = _computeIntrinsicExtent(
      Axis.vertical,
      () => super.computeMaxIntrinsicHeight(width),
    )!;
    if (kDebugMode) {
      debugPrint('🔶 computeMaxIntrinsicHeight(width=$width) => $result');
    }
    return result;
  }

  double? _computeIntrinsicExtent(Axis axis, double Function() compute) {
    final Axis? direction = _direction;
    if (direction == axis) {
      final result = _mainAxisExtent;
      if (kDebugMode) {
        debugPrint(
          '📐 _computeIntrinsicExtent(axis=$axis, direction=$direction) => $result',
        );
      }
      return result;
    } else {
      if (_crossAxisExtent!.isFinite) {
        final result = _crossAxisExtent;
        if (kDebugMode) {
          debugPrint(
            '📐 _computeIntrinsicExtent(axis=$axis, direction=$direction) => $result',
          );
        }
        return result;
      } else {
        final result = compute();
        if (kDebugMode) {
          debugPrint(
            '📐 _computeIntrinsicExtent(axis=$axis, direction=$direction) => $result',
          );
        }
        return result;
      }
    }
  }

  @override
  Size computeDryLayout(BoxConstraints constraints) {
    final Axis? direction = _direction;

    if (direction != null) {
      if (direction == Axis.horizontal) {
        final s =
            constraints.constrain(Size(mainAxisExtent, crossAxisExtent!));
        if (kDebugMode) {
          debugPrint(
            '💧 computeDryLayout(constraints=$constraints, direction=$direction) => $s',
          );
        }
        return s;
      } else {
        final s =
            constraints.constrain(Size(crossAxisExtent!, mainAxisExtent));
        if (kDebugMode) {
          debugPrint(
            '💧 computeDryLayout(constraints=$constraints, direction=$direction) => $s',
          );
        }
        return s;
      }
    } else {
      throw FlutterError(
        'A Gap widget must be placed directly inside a Flex widget '
        'or its fallbackDirection must not be null',
      );
    }
  }

  @override
  void performLayout() {
    size = computeDryLayout(constraints);
    if (kDebugMode) {
      debugPrint('🛠 performLayout(constraints=$constraints) size=$size');
    }
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (color != null) {
      final Paint paint = Paint()..color = color!;
      context.canvas.drawRect(offset & size, paint);
      if (kDebugMode) {
        debugPrint('🎨 paint(offset=$offset, size=$size, color=$color)');
      }
    } else {
      if (kDebugMode) {
        debugPrint('🎨 paint(offset=$offset, size=$size, color=null)');
      }
    }
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    if (kDebugMode) {
      debugPrint('🧾 debugFillProperties()');
    }
    properties.add(DoubleProperty('mainAxisExtent', mainAxisExtent));
    properties.add(DoubleProperty('crossAxisExtent', crossAxisExtent));
    properties.add(ColorProperty('color', color));
    properties.add(EnumProperty<Axis>('fallbackDirection', fallbackDirection));
  }
}

截屏2026-01-30 19.30.39.png

真正绘制原理, RenderGapRenderBox的子类, 不需要子类, 绘制时, 只与自身sizecolor有关。

mainAxisExtentcrossAxisExtent属性set方法触发后, 会执行markNeedsLayout, 标记该渲染对象需要重新布局, 并请求(requestVisualUpdate)调度下一帧执行布局。

布局阶段,会执行computeDryLayoutperformLayout方法,更新size

绘制阶段paint,在 offset & size 的矩形内填充颜色(color 为 null 时不绘制)。

  • 矩形范围:offset & size 等价于 Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height),保证绘制严格位于本组件区域。
  • 无子节点与图层:RenderGap 不 push 额外 Layer,也不绘制子内容;仅把一个矩形指令提交到当前画布。

markNeedsLayout布局阶段不同的是, markNeedsPaint绘制阶段不参与尺寸计算, 它在size确定后才执行。

与标记方法的关系:

  • markNeedsPaint:当 color 变更时由属性 setter 调用,标记本节点需要在下一帧重绘;不会触发布局。
  • markNeedsLayout:当 mainAxisExtent/crossAxisExtent/fallbackDirection 变更引起尺寸或方向变化时调用;下一帧会重新布局,布局完成后若绘制区域或内容也需更新才会出现 paint。
  • 执行链路示例:属性变更 → 标记(layout/paint)→ 布局(computeDryLayout/performLayout)→ 绘制(paint)。

React 手写实现的 KeepAlive 组件 🚀

React 手写实现的 KeepAlive 组件 🚀

引言 📝

在 React 开发中,你是否遇到过这样的场景:切换 Tab 页面后,返回之前的页面,输入的内容、计数状态却 “消失不见” 了?🤔 这是因为 React 组件默认在卸载时会销毁状态,重新渲染时会创建新的实例。而 KeepAlive 组件就像一个 “状态保鲜盒”,能让组件在隐藏时不卸载,保持原有状态,再次显示时直接复用。今天我们就结合实战代码,从零拆解 KeepAlive 组件的实现逻辑,带你吃透这一实用技能!

一、什么是 Keep-Alive? 🧩

Keep-Alive 源于 Vue 的内置组件,在 React 中并没有原生支持,但提供了组件缓存能力的第三方库react-activation,我们可以通过import {KeepAlive} from 'react-activation'; 导入KeepAlive获得状态保存能力。

现在我们来手动实现其核心功能,它本质是一个组件缓存容器,核心特性如下:

  • 缓存组件实例,避免组件频繁挂载 / 卸载,减少性能开销;
  • 保持组件状态(如 useState 数据、表单输入值等),提升用户体验;
  • 通过 “显隐控制” 替代 “挂载 / 卸载”,组件始终存在于 DOM 中,并未卸载,只是通过样式隐藏;
  • 支持以唯一标识(如 activeId)管理多个组件的缓存与切换。

简单说,Keep-Alive 就像给组件 “冬眠” 的能力 —— 不用时休眠(隐藏),需要时唤醒(显示),状态始终不变 ✨。

二、为什么需要 Keep-Alive?(作用 + 场景 + 使用)🌟

1. 核心作用

  • 状态保留:避免组件切换时丢失临时状态(如表单输入、计数、滚动位置);
  • 性能优化:减少重复渲染和生命周期函数执行(如 useEffect 中的接口请求);
  • 体验提升:切换组件时无加载延迟,操作连贯性更强。

2. 适用场景

  • Tab 切换页面:如后台管理系统的多标签页、移动端的底部导航切换;
  • 路由跳转:列表页跳转详情页后返回,保留列表筛选条件和滚动位置;
  • 高频切换组件:如表单分步填写、弹窗与页面的切换;
  • 资源密集型组件:如包含大量图表、视频的组件,避免重复初始化。

3. 基础使用方式

在我们的实战代码中,Keep-Alive 的使用非常简洁:

jsx

// 父组件中包裹需要缓存的组件,传入 activeId 控制激活状态
<KeepAlive activeId={activeTab}>
  {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>
  • activeId:唯一标识,用于区分当前激活的组件;
  • children:需要缓存的组件实例,支持动态切换不同组件。

三、手写 KeepAlive 组件的实现思路 🔍

1. 核心需求分析

要实现一个通用的 Keep-Alive 组件,需满足以下条件:

  • 支持多组件缓存:能同时缓存多个组件,通过 activeId 区分;
  • 自动更新缓存:新组件首次激活时自动存入缓存,已缓存组件直接复用;
  • 灵活控制显隐:只显示当前激活的组件,其余组件隐藏;
  • 兼容性强:不侵入子组件逻辑,子组件无需修改即可使用;
  • 状态稳定:缓存的组件状态不丢失,生命周期不重复执行。

2. 实现步骤拆解(结合代码讲解)

初始化一个React项目,选择JavaScript语言。

我们的 KeepAlive 组件代码位于 src/components/KeepAlive.jsx,核心分为 3 个步骤,一步步拆解如下:

步骤一:定义缓存容器 📦

核心思路:用 React 的 useState 定义一个缓存对象 cache,以 activeId 为 key,缓存对应的组件实例(children)。

jsx

import { useState, useEffect } from 'react';

const KeepAlive = ({ activeId, children }) => {
  // 定义缓存容器:key 是 activeId,value 是对应的组件实例(children)
  // 初始值为空对象,保证首次渲染时无缓存组件
  const [cache, setCache] = useState({}); 

  // 后续逻辑...
};
  • 为什么用对象作为缓存容器?对象的 key 支持字符串类型的 activeId,查询和修改效率高(O (1)),且配合 Object.entries 方便遍历;
  • Map 也可作为缓存容器(key 可支持对象类型),但本例中 activeId 是字符串,对象足够满足需求,更简洁。
步骤二:监听依赖,更新缓存 🔄

核心思路:通过 useEffect 监听 activeIdchildren 的变化,当切换组件时,若当前 activeId 对应的组件未被缓存,则存入缓存。

jsx

useEffect(() => {
  // 逻辑:如果当前 activeId 对应的组件未在缓存中,就添加到缓存
  if (!cache[activeId]) { 
    // 利用函数式更新,确保拿到最新的缓存状态(prev 是上一次的 cache)
    setCache((prev) => ({
      ...prev, // 保留已有的缓存组件
      [activeId]: children // 新增当前 activeId 对应的组件到缓存
    }))
  }
}, [activeId, children, cache]); // 依赖项:activeId 变了、组件变了、缓存变了,都要重新检查
  • 依赖项说明:

    • activeId:切换标签时触发,检查新标签对应的组件是否已缓存;
    • children:若传入的组件实例变化(如 props 改变),需要更新缓存中的组件;
    • cache:确保获取最新的缓存状态,避免覆盖已有缓存;
  • 为什么不直接 setCache({...cache, [activeId]: children})? 因为 cache 是状态,直接使用可能拿到旧值,函数式更新(prev => {...})能保证拿到最新的状态,避免缓存丢失。

步骤三:遍历缓存,控制组件显隐 🎭

核心思路:通过 Object.entries 将缓存对象转为 [key, value] 二维数组,遍历渲染所有缓存组件,通过 display 样式控制显隐(激活的组件显示,其余隐藏)。

jsx

return (
  <>
    {
      // Object.entries(cache):将缓存对象转为二维数组,格式如 [[id1, component1], [id2, component2]]
      Object.entries(cache).map(([id, component]) => (
        <div 
          key={id} // 用缓存的 id 作为 key,确保 React 正确识别组件
          // 显隐控制:当前 id 等于 activeId 时显示(block),否则隐藏(none)
          style={{ display: id === activeId ? 'block' : 'none' }}
        >
          {component} {/* 渲染缓存的组件实例 */}
        </div>
      ))
    }
  </>
);
  • 关键逻辑:所有缓存的组件都会被渲染到 DOM 中,但通过 display: none 隐藏未激活的组件,这样组件不会卸载,状态得以保留;
  • key 的作用:必须用 id 作为 key,避免 React 误判组件身份,导致状态丢失。

3.关键逻辑拆解

四、完整代码及效果演示 📸

1. 完整 KeepAlive 组件(src/components/KeepAlive.jsx

jsx

import { useState, useEffect } from 'react';

/**
 * KeepAlive 组件:缓存 React 组件,避免卸载,保持状态
 * @param {string} activeId - 当前激活的组件标识(唯一key)
 * @param {React.ReactNode} children - 需要缓存的组件实例
 * @returns {JSX.Element} 渲染所有缓存组件,控制显隐
 */
const KeepAlive = ({ activeId, children }) => {
  // 缓存容器:key 为 activeId,value 为对应的组件实例
  const [cache, setCache] = useState({});

  // 监听 activeId、children、cache 变化,更新缓存
  useEffect(() => {
    // 若当前 activeId 对应的组件未缓存,则添加到缓存
    if (!cache[activeId]) {
      // 函数式更新,确保拿到最新的缓存状态
      setCache((prevCache) => ({
        ...prevCache, // 保留已有缓存
        [activeId]: children // 新增当前组件到缓存
      }));
    }
  }, [activeId, children, cache]);

  // 遍历缓存,渲染所有组件,通过 display 控制显隐
  return (
    <>
      {Object.entries(cache).map(([id, component]) => (
        <div
          key={id}
          style={{
            display: id === activeId ? 'block' : 'none',
          }}
        >
          {component}
        </div>
      ))}
    </>
  );
};

export default KeepAlive;

2. 模拟 Tab 切换场景(src/App.jsx

jsx

import { useState, useEffect } from 'react';
import KeepAlive from './components/KeepAlive.jsx';

// 计数组件 A:演示状态保留
const Counter = ({ name }) => {
  const [count, setCount] = useState(0);

  // 模拟组件挂载/卸载生命周期
  useEffect(() => {
    console.log(`✨ 组件 ${name} 挂载完成`);
    return () => {
      console.log(`❌ 组件 ${name} 卸载完成`);
    };
  }, [name]);

  return (
    <div style={{ padding: '20px', border: '1px solid #646cff', borderRadius: '8px', margin: '10px 0' }}>
      <h3 style={{ color: '#646cff' }}>{name} 视图</h3>
      <p>当前计数:{count} 🎯</p>
      <button onClick={() => setCount(count + 1)} style={{ marginRight: '10px' }}>+1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
};

// 计数组件 B:与 A 功能一致,用于模拟切换
const OtherCounter = ({ name }) => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`✨ 组件 ${name} 挂载完成`);
    return () => {
      console.log(`❌ 组件 ${name} 卸载完成`);
    };
  }, [name]);

  return (
    <div style={{ padding: '20px', border: '1px solid #535bf2', borderRadius: '8px', margin: '10px 0' }}>
      <h3 style={{ color: '#535bf2' }}>{name} 视图</h3>
      <p>当前计数:{count} 🎯</p>
      <button onClick={() => setCount(count + 1)} style={{ marginRight: '10px' }}>+1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
};

const App = () => {
  // 控制当前激活的 Tab,默认激活 A 组件
  const [activeTab, setActiveTab] = useState('A');

  return (
    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
      <h1 style={{ textAlign: 'center', marginBottom: '2rem', color: '#242424' }}>
        React KeepAlive 组件实战 🚀
      </h1>

      {/* Tab 切换按钮 */}
      <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
        <button
          onClick={() => setActiveTab('A')}
          style={{
            marginRight: '1rem',
            padding: '0.8rem 1.5rem',
            backgroundColor: activeTab === 'A' ? '#646cff' : '#f9f9f9',
            color: activeTab === 'A' ? 'white' : '#242424'
          }}
        >
          显示 A 组件
        </button>
        <button
          onClick={() => setActiveTab('B')}
          style={{
            padding: '0.8rem 1.5rem',
            backgroundColor: activeTab === 'B' ? '#535bf2' : '#f9f9f9',
            color: activeTab === 'B' ? 'white' : '#242424'
          }}
        >
          显示 B 组件
        </button>
      </div>

      {/* 用 KeepAlive 包裹需要缓存的组件 */}
      <KeepAlive activeId={activeTab}>
        {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
      </KeepAlive>

      <div style={{ marginTop: '2rem', textAlign: 'center', color: '#888' }}>
        👉 切换 Tab 试试,组件状态不会丢失哦!
      </div>
    </div>
  );
};

export default App;

3. 效果展示

(1)功能效果
  • 首次进入页面:显示 A 组件,计数为 0;
  • 点击 A 组件 “+1” 按钮,计数变为 7;
  • 切换到 B 组件:B 组件计数为 0,A 组件隐藏(未卸载);
  • 点击 B 组件 “+1” 按钮,计数变为 5;
  • 切换回 A 组件:A 组件计数依然是 7,无需重新初始化;
  • 控制台日志:只有组件挂载日志,无卸载日志,证明组件始终存在。
(2)用户体验
  • 切换无延迟,状态无缝衔接;
  • 避免重复执行 useEffect 中的逻辑(如接口请求),提升性能;

QQ20260130-172541.gif

五、核心知识点梳理 📚

通过手写 KeepAlive 组件,我们掌握了这些关键知识点:

  1. React Hooks 实战useState 管理缓存状态,useEffect 监听依赖更新,函数式更新避免状态覆盖;
  2. 组件生命周期控制:通过 display 样式控制组件显隐,替代挂载 / 卸载,从而保留状态;
  3. 数据结构应用:对象作为缓存容器,Object.entries 实现对象遍历;
  4. Props 传递与复用children props 让 KeepAlive 组件通用化,支持任意子组件缓存;
  5. 状态管理思路:以唯一标识(activeId)关联组件,确保缓存的准确性和唯一性;
  6. 性能优化技巧:避免组件频繁挂载 / 卸载,减少 DOM 操作和资源消耗;
  7. 组件设计原则:通用、低侵入、易扩展,不修改子组件逻辑即可实现缓存功能。

补充: Map 与 JSON 的区别 ——Map 可以直接存储对象作为 key,而 JSON 只能存储字符串。如果需要缓存以对象为标识的组件,可将 cache 改为 Map 类型,优化如下:

jsx

// 用 Map 替代对象作为缓存容器
const [cache, setCache] = useState(new Map());

// 更新缓存
useEffect(() => {
  if (!cache.has(activeId)) {
    setCache((prev) => new Map(prev).set(activeId, children));
  }
}, [activeId, children, cache]);

// 遍历缓存
return (
  <>
    {Array.from(cache.entries()).map(([id, component]) => (
      <div key={id} style={{ display: id === activeId ? 'block' : 'none' }}>
        {component}
      </div>
    ))}
  </>
);

六、结语 🎉

手写 Keep-Alive 组件看似简单,却涵盖了 React 组件设计、状态管理、性能优化等多个核心知识点。它的核心思想是 “缓存 + 显隐控制”,通过巧妙的状态管理避免组件卸载,从而保留状态。

在实际开发中,我们可以基于这个基础版本扩展更多功能:比如设置缓存上限(避免内存溢出)、手动清除缓存、支持路由级缓存等。掌握了这个组件的实现逻辑,你不仅能解决实际开发中的状态保留问题,还能更深入理解 React 组件的渲染机制和生命周期。

希望这篇文章能带你吃透 Keep-Alive 组件的核心原理,下次遇到类似需求时,也能从容手写实现!如果觉得有收获,欢迎点赞收藏,一起探索 React 的更多实战技巧吧~ 🚀

React-Hooks逻辑复用艺术

前言

在 React 开发中,Hooks 的出现彻底改变了逻辑复用的方式。它让我们能够将复杂的、可复用的逻辑从 UI 组件中抽离,实现真正的“关注点分离”。本文将分享 Hooks 的核心原则,并提供 4 个在真实业务场景中封装的实战案例。

一、 Hooks 核心

1. 概念理解

Hooks 本质上是将组件间共享的逻辑抽离并封装成的特殊函数

2. 使用“红线”:规则与原理

  • 命名规范:必须以 use 开头(如 useChat),这不仅是约定,也是静态检查工具(ESLint)识别 Hook 的依据。
  • 调用位置严禁在循环、条件判断或嵌套函数中调用 Hook

底层原理: React 内部并不是通过“变量名”来记录 Hook 状态的,而是通过链表 。每次渲染时,React 严格依赖 Hook 的调用顺序来查找对应的状态。

注意: 如果在 if 语句中调用 Hook,一旦条件不成立导致某次渲染跳过了该 Hook,整个链表的指针就会错位,导致状态读取异常。

二、 实战:自定义 Hooks 封装

1. AI 场景:消息点赞/点踩逻辑 (useChatEvaluate)

在 AI 对话系统中,消息评价是通用功能。我们需要处理:状态切换(点赞 -> 取消点赞)、单选逻辑、以及异步接口调用。

import React, { useState } from 'react';

// 模拟接口
const public_evaluateMessage = async (params: any) => ({ data: true });

type EvaluateType = "GOOD" | "BAD" | "NONE";

export const useChatEvaluate = (initialType: EvaluateType = "NONE") => {
  const [ratingType, setRatingType] = useState<EvaluateType>(initialType);

  const evaluateMessage = async (contentId: number, type: "GOOD" | "BAD") => {
    let newEvaluateType: EvaluateType;

    // 逻辑:如果点击已选中的类型,则取消选中(NONE);否则切换到新类型
    if (type === "GOOD") {
      newEvaluateType = ratingType === "GOOD" ? "NONE" : "GOOD";
    } else {
      newEvaluateType = ratingType === "BAD" ? "NONE" : "BAD";
    }

    try {
      const res = await public_evaluateMessage({
        contentId,
        ratingType: newEvaluateType,
        content: "",
      });

      if (res.data === true) {
        setRatingType(newEvaluateType);
      }
    } catch (error) {
      console.error("评价失败:", error);
    }
  };

  return { ratingType, evaluateMessage };
};

// 使用示例
const ChatMessage: React.FC<{ id: number }> = ({ id }) => {
  const { ratingType, evaluateMessage } = useChatEvaluate();
  return (
    <button onClick={() => evaluateMessage(id, "GOOD")}>
      {ratingType === "GOOD" ? "👍 已点赞" : "👍 点赞"}
    </button>
  );
};

2. 响应式布局:屏幕尺寸监听 (useMediaSize)

在响应式系统中,封装一个能根据窗口宽度自动切换“设备类型”的 Hook,可以极大地简化响应式开发。

import { useState, useEffect, useMemo } from 'react';

export enum MediaType {
  mobile = 'mobile',
  tablet = 'tablet',
  pc = 'pc',
}

const useMediaSize = (): MediaType => {
  const [width, setWidth] = useState<number>(globalThis.innerWidth);

  useEffect(() => {
    const handleWindowResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleWindowResize);
    // 记得清理事件监听
    return () => window.removeEventListener('resize', handleWindowResize);
  }, []);

  // 使用 useMemo 避免每次渲染都重新运行计算逻辑
  const media = useMemo(() => {
    if (width <= 640) return MediaType.mobile;
    if (width <= 768) return MediaType.tablet;
    return MediaType.pc;
  }, [width]);

  return media;
};

export default useMediaSize;

3. 性能优化:防抖与节流 Hook

A. 防抖 Hook (useDebounce)

常用于搜索框,防止用户快速输入时频繁触发请求。

import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // 关键:在下一次 useEffect 执行前清理上一次的定时器
    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

B. 节流 Hook (useThrottle)

常用于滚动加载、窗口缩放,确保在规定时间内只执行一次。

import { useState, useEffect, useRef } from 'react';

function useThrottle<T>(value: T, delay: number): T {
  const [throttledValue, setThrottledValue] = useState<T>(value);
  const lastExecuted = useRef<number>(Date.now());

  useEffect(() => {
    const now = Date.now();
    const remainingTime = delay - (now - lastExecuted.current);

    if (remainingTime <= 0) {
      // 立即执行
      setThrottledValue(value);
      lastExecuted.current = now;
    } else {
      // 设置定时器处理剩余时间
      const timer = setTimeout(() => {
        setThrottledValue(value);
        lastExecuted.current = Date.now();
      }, remainingTime);

      return () => clearTimeout(timer);
    }
  }, [value, delay]);

  return throttledValue;
}

export default useThrottle;

三、 总结:封装自定义 Hook 的心法

  1. 抽离状态而非仅逻辑:如果一段逻辑只涉及纯函数计算,不需要 Hook;只有涉及 useStateuseEffect 等状态管理时,才有必要封装 Hook。
  2. 保持纯净:自定义 Hook 应该只关心逻辑,而不应该直接操作 DOM。
  3. TS 类型保护:利用泛型 <T> 增强 Hook 的兼容性,让它能适配各种数据类型。

一个Vite插件实现PC到移动端项目的高效迁移方案

当PC端项目需要迁移到移动端时,你是否还在手动复制粘贴代码?今天分享一个我们团队自研的Vite插件,帮你实现跨仓库、跨技术栈的代码高效复用!

背景:从PC到移动端的迁移之痛

最近我们团队遇到了一个典型的企业级需求:将一个成熟的PC端医疗管理系统(MDM)迁移到移动端。听起来简单,但实际上却面临诸多挑战:

面临的挑战

  1. 技术栈差异:PC端使用Element Plus,移动端需要Vant
  2. 仓库隔离:PC端和移动端在不同的Git仓库
  3. 代码复用:希望复用80%的业务逻辑,但UI组件完全不同
  4. 维护同步:业务逻辑更新需要在两端同步

传统的解决方案要么是完全重写(成本高),要么是复制粘贴(维护噩梦),或者是Monorepo (代码和依赖放一起)。我们需要的是一种既能复用核心逻辑,代码在不同仓库维护,又能灵活定制UI的解决方案。

解决方案:vite-plugin-code-reuse

基于这个需求,我们开发了 vite-plugin-code-reuse 插件,它的核心思想是:多仓库代码复用、智能替换、无缝集成

插件核心能力

// 插件的核心配置结构
interface PluginConfig {
  repositories: RepositoryConfig[];     // 代码仓库配置
  repoImportMappings: RepoImportMappings[]; // 路径映射,主要是规避不同仓库路径别名冲突问题
  importReplacements: ImportReplacementConfig[]; // 导入替换
}

实战案例:医疗管理系统迁移

让我通过实际案例展示这个插件的效果。

项目背景

  • PC端项目xlian-web-mdm(Vue 3 + Element Plus + TypeScript)
  • 移动端项目:新建的H5项目(Vue 3 + Vant + TypeScript)
  • 目标:复用PC端80%的业务逻辑,100%替换UI组件

第一步:安装配置插件

npm install vite-plugin-code-reuse --save-dev
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import codeReusePlugin from 'vite-plugin-code-reuse'
import path from 'path'

export default defineConfig({
  plugins: [
    vue(),
    codeReusePlugin({
      // 1. 引入PC端代码仓库
      repositories: [
        {
          url: 'http://gitlab.xinglianhealth.com/web/mdm/xlian-web-mdm.git',
          branch: 'master',
          dir: 'mdm-pc'  // 本地存放目录
        }
      ],
      
      // 2. 修正路径别名映射,pc和移动都配置了“@”别名,需要各自引用正确的路径
      repoImportMappings: [
        {
          alias: '@',
          repoDir: 'mdm-pc',
          // PC端中的“@”别名还是引用pc端目录下的代码,但是需要排除路由、状态管理等路径,引用移动端的,因为路由和store不能存在两个
          ignorePatterns: ['src/router', 'src/store', 'src/axios']
        }
      ],
      
      // 3. 智能导入替换,pc端的部分组件无法复用时,可以做替换
      importReplacements: [
        // 场景1:组件替换(Element Plus → Vant)
        {
          pattern: /\/views\/MasterData\/components\/UploadFile\.vue$/,
          file: 'src/components/MbUpload.vue' // 移动端专用上传组件
        },
        {
          pattern: /\/views\/MasterData\/components\/TextInput\.vue$/,
          file: 'src/components/MbTextInput.vue' // 移动端输入框
        },
        
        // 场景2:复杂表单组件替换
        {
          pattern: /\/views\/MasterData\/components\/MasterForm\.vue$/,
          file: 'src/components/MbForm.vue' // 移动端表单封装
        },
        
        // 场景3:PC端特有功能在移动端隐藏
        {
          pattern: /\/views\/MasterData\/components\/HeaderFilter\/HeaderFilter\.vue$/,
          code: '<template></template>' // 移动端不需要复杂的表头筛选
        },
        
        // 场景4:UI库替换
        {
          pattern: /^@?element-plus/,
          resolve: path.resolve('src/components/ElementPlus.ts') // 重定向到兼容层
        }
      ]
    })
  ]
})

第二步:UI组件替换

// src/components/ElementPlus.ts - UI库兼容层
import { Button, Input, Select } from './ui-adaptor'

// 创建Element Plus到Vant的映射
export {
  ElButton: Button,
  ElInput: Input,
  ElSelect: Select,
  // ... 更多映射
}

// src/components/ui-adaptor.ts - UI库适配层,以Button组件为例

<template><van-button v-bind="$attrs" /></template>
<script>
const props = defineProps({
// 对element-plus Button Props做适配,比如size不支持"medium",可以处理成其他的,相同的属性不用处理
})
</script>

第三步:复用业务逻辑组件

<!-- src/views/MasterData/PatientList.vue -->
<template>
  <!-- 复用PC端的模板结构,但使用移动端组件 -->
  <div class="patient-list">
    <!-- 搜索框(PC端是ElInput,自动替换为Vant Field) -->
    <van-field 
      v-model="searchKey"
      placeholder="搜索患者"
      @input="handleSearch"
    />
    
    <!-- 患者列表 -->
    <van-list 
      v-model:loading="loading"
      :finished="finished"
      @load="loadPatients"
    >
      <patient-item 
        v-for="patient in patients"
        :key="patient.id"
        :patient="patient"
        @click="handlePatientClick"
      />
    </van-list>
  </div>
</template>

<script setup lang="ts">
// 这里直接复用PC端的业务逻辑!
import { usePatientList } from '@/mdm-pc/src/views/MasterData/composables/usePatientList'

// 完全复用PC端的逻辑,包括:
// 1. 数据获取逻辑
// 2. 搜索过滤逻辑  
// 3. 分页逻辑
// 4. 事件处理逻辑
const {
  patients,
  loading,
  finished,
  searchKey,
  loadPatients,
  handleSearch,
  handlePatientClick
} = usePatientList()
</script>

<style scoped>
/* 移动端特有的样式 */
.patient-list {
  padding: 12px;
}
</style>

插件的核心实现原理

1. 代码仓库管理

vite build start开始时,自动拉取PC端仓库代码,如无更新不会重复拉取,默认只拉取一个commit,速度极快。

{
  url: 'http://gitlab.xinglianhealth.com/web/mdm/xlian-web-mdm.git', //仓库地址
  branch: 'master', // 仓库分支
  dir: 'mdm-pc'  // 本地存放目录
}

image.png


### 2. 智能导入替换,支持三种替换方式,任意选择

```typescript
interface ImportReplacementConfig {
    /**
     * 匹配模式(正则表达式字符串)
     */
    pattern: RegExp;
    /**
     * 替换方式:代码字符串
     */
    code?: string;
    /**
     * 替换方式:文件路径
     */
    file?: string;
    /**
     * 替换方式:重定向路径
     */
    resolve?: string;
}

3. 别名路径冲突映射修正,将被引用的仓库内部别名指向仓库内部,规避和外层别名的冲突

interface RepoImportMappings {
    /**
     * 路径别名
     */
    alias: string;
    /**
     * 冲突的仓库目录
     */
    repoDir: string;
    /**
     * 忽略列表(路径匹配时跳过),使用外出仓库别名解析
     */
    ignorePatterns?: string[];
}

实际效果对比

迁移前(传统方式)

├── 移动端项目
│   ├── src
│   │   ├── views
│   │   │   ├── PatientList.vue    ← 需要重写
│   │   │   ├── DoctorList.vue     ← 需要重写
│   │   │   └── ... (20+个页面)
│   │   └── components
│   │       ├── MbForm.vue         ← 需要重写
│   │       └── ... (50+个组件)
└── 工时:3-4人月

迁移后(使用插件)

├── 移动端项目
│   ├── mdm-pc/                    ← 自动引入的PC端代码
│   ├── src
│   │   ├── views
│   │   │   ├── PatientList.vue    ← 直接引用PC端PatientList组件,只对样式做适配覆盖
│   │   │   ├── DoctorList.vue     ← 直接引用PC端DoctorList组件,只对样式做适配覆
│   │   │   └── ... (复用80%)
│   │   └── components
│   │       ├── MbUploader.vue     ← 无法复用的组件,用移动端专用文件组件做替换
│   │       └── ... (只需写30%的组件)
└── 工时:1-1.5人月(节省60%+)

高级应用场景

场景1:多项目共享工具库

// 多个项目共享工具函数
codeReusePlugin({
  repositories: [
    {
      url: 'git@github.com:company/shared-utils.git',
      branch: 'main',
      dir: 'shared-utils'
    },
    ...
  ],
  importReplacements: [
    {
      pattern: /^@shared\/utils/,
      resolve: path.resolve('shared-utils/src')
    },
    ...
  ]
})

场景2:主题系统切换

// 根据环境切换主题
codeReusePlugin({
  importReplacements: [
    {
      pattern: /\/themes\/default/,
      file: process.env.NODE_ENV === 'mobile' 
        ? 'src/themes/mobile.vue'
        : 'src/themes/pc.vue'
    }
  ]
})

场景3:A/B测试版本

// A/B测试不同版本的组件
codeReusePlugin({
  importReplacements: [
    {
      pattern: /\/components\/CheckoutButton\.vue$/,
      file: Math.random() > 0.5 
        ? 'src/components/CheckoutButtonVariantA.vue'
        : 'src/components/CheckoutButtonVariantB.vue'
    }
  ]
})

总结

vite-plugin-code-reuse 插件为我们解决了跨项目代码复用的核心痛点:

🎯 核心价值

  1. 大幅提升开发效率:节省60%+的开发时间
  2. 保证代码一致性:业务逻辑完全一致,减少BUG
  3. 降低维护成本:一处修改,多处生效
  4. 灵活定制:UI层完全可定制,不影响核心逻辑

🚀 适用场景

  • PC端到移动端的项目迁移
  • 多项目共享组件库
  • 微前端架构中的模块复用
  • A/B测试和实验性功能

如果你也面临类似的多项目代码复用问题,不妨试试这个插件。项目已在GitHub开源,欢迎Star和贡献代码!

GitHub地址vite-plugin-code-reuse

让代码复用变得简单,让开发效率飞起来! 🚀

《打造高效的前端工程师终端:一份可复制的 Zsh + Powerlevel10k 配置实践》

目标很简单:快、稳、爽

快:打开终端不拖泥带水; 稳:补全、历史、插件不互相打架; 爽:git commit 这种高频命令,肌肉记忆级体验。

这篇文章基于一份可直接使用的 .zshrc 配置,完整讲清楚每一段配置解决了什么问题、为什么要这样放、以及它带来的实际体验提升。

一、为什么要“认真配置”终端?

对于前端工程师来说,终端并不是偶尔用一下的工具,而是:

  • 高频 Git 操作(git commit / rebase / checkout
  • 包管理(pnpm / npm / yarn
  • 本地调试、构建、脚手架

如果终端具备下面这些能力:

  • ↑ / ↓ 只在“相关历史”里循环
  • 自动联想但不抢键
  • 补全稳定、无卡顿

每天节省的不是几秒,而是注意力和心智负担

二、整体结构设计原则

这份配置遵循几个明确的原则:

  1. 启动相关的配置尽量靠前(避免闪屏、卡顿)
  2. 功能性模块分区清晰(补全、历史、联想、高亮各司其职)
  3. 顺序严格(这是很多问题的根源)

下面按模块逐一拆解。

三、Powerlevel10k Instant Prompt:启动速度的关键

if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
  source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
fi

这是 Powerlevel10k 官方推荐的 Instant Prompt 写法,作用是:

  • 在 Zsh 完全初始化之前,先渲染 Prompt
  • 避免终端启动时的“白屏等待”

四、NVM 与主题加载:稳定优先

export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && . "/opt/homebrew/opt/nvm/nvm.sh"

source /opt/homebrew/share/powerlevel10k/powerlevel10k.zsh-theme
[[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh

这里选择的是最稳妥、最不容易出坑的方式:

  • 不做过度魔改
  • 不提前优化启动时间

如果后续需要追求极致启动速度,再对 NVM 做 lazy load 即可。

五、PATH 管理:去重是隐藏的工程师细节

# 示例:将自定义工具加入 PATH(请按需修改或删除)
# export PATH="$HOME/.comate/bin:$PATH"

typeset -U PATH path

typeset -U PATH path 的作用是:

  • 自动去重 PATH
  • 保留第一次出现的顺序

这能避免:

  • PATH 无限增长
  • 同一个二进制被多次扫描

属于“看不见,但很专业”的配置。

六、补全系统:一切体验的地基

autoload -Uz compinit
compinit -d ~/.zcompdump

补全系统是很多插件的基础(包括自动联想)。

显式指定 .zcompdump 文件可以:

  • 避免偶发的补全重建
  • 提高稳定性

顺序要求

compinit 必须在 autosuggestions 之前。

七、历史行为优化:让“↑”变聪明

setopt HIST_IGNORE_ALL_DUPS
setopt HIST_REDUCE_BLANKS
setopt INC_APPEND_HISTORY
setopt SHARE_HISTORY

这些选项共同解决了几个痛点:

  • 同一条命令不会无限刷历史
  • 多余空格不会影响匹配
  • 多个终端窗口共享历史

结果是:

历史记录更干净,搜索更精准。

八、↑ / ↓ 前缀历史搜索(核心体验)

bindkey -M emacs '^[[A' history-beginning-search-backward
bindkey -M emacs '^[[B' history-beginning-search-forward

这是整份配置的灵魂部分

行为对比

历史中有:

git commit -m "init"
git commit --amend
git checkout main
pnpm install

当你输入:

git commit

按 ↑ / ↓:

  • ✅ 只会出现 git commit ...
  • ❌ 不会混入 checkout / pnpm

这是严格的前缀匹配,非常适合 Git 这类命令族。

九、自动联想:辅助而不是干扰

ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8'
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
  • 灰色提示足够克制
  • 只做“提示”,不抢控制权

并且通过 -M emacs 的 bindkey 写法,确保它不会和 ↑ / ↓ 行为冲突。

十、语法高亮:为什么必须放最后?

source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

这是官方和社区反复强调的一点:

zsh-syntax-highlighting 必须是最后加载的插件

否则会出现:

  • 高亮失效
  • 与其他插件冲突

十一、最终配置(可直接使用)

下面是一份完整、可直接复制使用的 .zshrc 最终配置

说明:

  • 已包含 Powerlevel10k Instant Prompt
  • 支持 git commit 等命令的 ↑ / ↓ 严格前缀历史搜索
  • 自动联想、高亮、补全顺序全部正确
  • 适合长期作为主力终端配置使用
########################################
# Powerlevel10k instant prompt(必须靠前)
########################################
if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
  source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
fi

########################################
# 基础环境
########################################
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && . "/opt/homebrew/opt/nvm/nvm.sh"

# Powerlevel10k 主题
source /opt/homebrew/share/powerlevel10k/powerlevel10k.zsh-theme
[[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh

# PATH 去重
typeset -U PATH path

########################################
# Zsh 补全系统
########################################
autoload -Uz compinit
compinit -d ~/.zcompdump

########################################
# 历史行为优化
########################################
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_REDUCE_BLANKS
setopt INC_APPEND_HISTORY
setopt SHARE_HISTORY

########################################
# 自动联想(灰色提示)
########################################
ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8'
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh

########################################
# ↑ / ↓ 前缀历史搜索(git commit 友好)
########################################
bindkey -M emacs '^[[A' history-beginning-search-backward
bindkey -M emacs '^[[B' history-beginning-search-forward

########################################
# 语法高亮(必须放最后)
########################################
source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

十二、最终效果总结

使用这份配置,你会得到:

  • 启动快、不闪屏(Powerlevel10k Instant Prompt)
  • 补全稳定(compinit 顺序正确)
  • 历史搜索精准(前缀匹配)
  • 自动联想不干扰操作

这不是“炫技型配置”,而是:

适合长期使用的工程师终端基建。

结语

终端配置的价值,不在于你用了多少插件,而在于:

  • 是否减少了无意义的操作
  • 是否让高频行为变成肌肉记忆

如果你每天要敲几十次 git commit, 那么让 ↑ 键“只做正确的事”,本身就已经非常值得了。

后续可以继续进阶的方向:

  • NVM lazy load(秒开终端)
  • Git / pnpm 专用快捷键
  • ZLE Widget 深度定制

这些,都可以在这份配置之上自然演进。

React-Scheduler 调度器如何掌控主线程?

前言

在 React 18 的并发时代,Scheduler(调度器) 是实现非阻塞渲染的幕后英雄。它不只是 React 的一个模块,更是一个通用的、高性能的 JavaScript 任务调度库。它不仅让 React 任务可以“插队”,还让“长任务”不再阻塞浏览器 UI 渲染。

一、 核心概念:什么是 Scheduler?

Scheduler 是一个独立的包,它通过与 React 协调过程(Reconciliation)的紧密配合,实现了任务的可中断、可恢复、带优先级执行。

主要职责

  1. 优先级管理:根据任务紧急程度(如用户点击 vs 数据预取)安排执行顺序。
  2. 空闲时间利用:在浏览器每一帧的空闲时间处理不紧急的任务。
  3. 防止主线程阻塞:通过“时间片(Time Slicing)”机制,避免长任务导致页面假死。

二、 Scheduler 的完整调度链路

当一个 setState 触发后,Scheduler 内部会经历以下精密流程:

1. 任务创建与通知

当状态更新时,React 不会立即执行 Render。它首先会创建一个 Update对象来记录这次变更,这个对象中包含这次更新所需的全部信息,例如更新后的状态值,Lane车道模型分配的任务优先级.

2. 优先级排序与队列维护

  • 任务优先级排序: 创建更新后,react会调用scheduleUpdateOnFiber函数通知scheduler调度器有个一个新的任务需要调度,这时scheduler会对该任务确定一个优先级,以及过期时间(优先级越高,过期时间越短,表示越紧急)

  • 队列维护: 接着scheduler会将该任务放入到循环调度中,scheduler对于任务循环调度在内部维护着两个队列,一个是立即执行队列taskQueue和延迟任务队列timeQueue,新任务会根据优先级进入到相应对列

    • timerQueue(延时任务队列) :存放还未到开始时间的任务,按开始时间排序。
    • taskQueue(立即任务队列) :存放已经就绪的任务,按过期时间排序。优先级越高,过期时间越短。

3. 时间片的开启:MessageChannel

将任务放入队列后,scheduler会调用requetHostCallback函数去请求浏览器在合适的时机去执行调度,该函数通过 MessageChannel对象中的port.postMessage 方法创建一个宏任务,浏览器在下一个宏任务时机触发 port.onmessage,并在这宏任务回调中启动 workLoop函数。

补充:Scheduler 会调用 requestHostCallback 请求浏览器调度。它没有选择 setTimeout,而是选择了 MessageChannel

为什么选 MessageChannel? setTimeout(fn, 0) 在浏览器中通常有 4ms 的最小延迟,且属于宏任务中执行时机较晚的。MessageChannelport.postMessage 产生的宏任务执行时机更早,且能更精准地在浏览器渲染帧之间切入。

4. 工作循环:workLoop

  • 在宏任务回调中,调度器会进入 workLoop。它会调用performUnitOfWork函数循环地处理Fiber节点,对比新旧节点的props、state,并从队列中取出最紧急的任务交给 React 执行。

  • workLopp中会包含一个shouldYield函数中断检查函数,用于检查当前时间片是否耗尽以及是否有更高优先级的任务执行,如果有的话则会将主线程控制权交还给浏览器,以保证高优先级任务(如用户输入、动画)能及时响应。


5. 中断与恢复:shouldYield 的魔力

workLoop 执行过程中,每一项单元工作完成后,都会调用 shouldYield() 函数进行“路况检查”。

  • 中断条件:如果当前时间片(通常为 5ms)耗尽,或者检测到有更紧急的用户交互(高优任务插队),shouldYield 返回 true
  • 状态保存:此时 React 会记录当前 workInProgress 树的位置,将控制权交还给浏览器。
  • 任务恢复:Scheduler 会在下一个时间片通过 MessageChannel 再次触发,从记录的位置继续执行,从而实现可恢复。

6. 任务插队

如果在执行一个低优先级任务时,有高优先级任务加入(如用户突然点击按钮),Scheduler会中断当前的低优任务并记录该位置,先执行高优任务。等高优任务完成后,再重新执行或继续之前的低优任务


三、 补充

  1. 执行时机对比MessageChannel 确实在宏任务中非常快,但在某些极其特殊的情况下(如没有 MessageChannel 的旧环境),它会回退到 setTimeout
  2. 饥饿现象防止:如果一个低优先级任务一直被插队怎么办?Scheduler 通过过期时间解决。一旦任务过期,它会从 taskQueue 中被提升为同步任务,强制执行。

如何监控qiankun中子应用的内存使用情况

希望监控 qiankun 中子应用的内存使用情况(包括 JS 堆内存、DOM 占用、资源泄漏等),核心思路是「主应用统一监控 + 子应用自主上报 + 浏览器原生 API 采集 + 异常告警」,结合 qiankun 的生命周期和通信机制,实现对每个子应用内存的精准、可追溯监控

核心原理
  1. 浏览器原生 API:使用 window.performance.memory(Chrome 特有,最核心)获取 JS 堆内存,document.querySelectorAll('*').length 统计 DOM 节点数;
  2. qiankun 通信机制:主应用通过「全局状态 / 自定义事件」接收子应用上报的内存数据;
  3. 生命周期绑定:在子应用的 mount/unmount/「激活 / 失活」时机采集内存数据,确保数据和子应用状态关联;
  4. 数据汇总:主应用汇总所有子应用的内存数据,形成「子应用 - 内存 - 时间」的维度表,便于分析泄漏趋势。
步骤 1:搭建监控基础框架(主应用)

主应用负责「接收子应用数据、存储监控日志、阈值告警、可视化展示」,先定义监控数据结构和核心方法。

// 主应用 src/utils/memoryMonitor.ts
interface SubAppMemoryData {
  appName: string; // 子应用名称
  timestamp: number; // 采集时间戳
  // 核心内存指标(Chrome 特有)
  usedJSHeapSize: number; // 已使用 JS 堆内存(字节)
  totalJSHeapSize: number; // 总 JS 堆内存(字节)
  jsHeapSizeLimit: number; // JS 堆内存上限(字节)
  // 辅助指标(判断泄漏)
  domCount: number; // DOM 节点数
  timerCount: number; // 定时器数量
  listenerCount: number; // 全局事件监听数
  status: 'mounted' | 'active' | 'inactive' | 'unmounted'; // 子应用状态
}

// 存储所有子应用的内存监控日志
const memoryLogs: Record<string, SubAppMemoryData[]> = {};
// 内存阈值(超出则告警,单位:MB)
const MEMORY_THRESHOLD = 200;
// 告警回调(可对接企业微信/钉钉)
let alarmCallback: (data: SubAppMemoryData) => void = () => {};

/**
 * 初始化内存监控
 * @param callback 告警回调
 */
export function initMemoryMonitor(callback: typeof alarmCallback) {
  alarmCallback = callback;
  // 初始化每个子应用的日志容器
  ['sub-app-a', 'sub-app-b', 'sub-app-c'].forEach(appName => {
    memoryLogs[appName] = [];
  });
}

/**
 * 接收子应用上报的内存数据
 */
export function receiveSubAppMemoryData(data: SubAppMemoryData) {
  // 转换为 MB 便于阅读
  const usedMB = (data.usedJSHeapSize / 1024 / 1024).toFixed(2);
  // 存储日志
  memoryLogs[data.appName].push({ ...data, usedJSHeapSize: Number(usedMB) });
  // 保留最近 100 条日志,避免主应用内存溢出
  if (memoryLogs[data.appName].length > 100) {
    memoryLogs[data.appName].shift();
  }
  // 阈值告警
  if (Number(usedMB) > MEMORY_THRESHOLD) {
    alarmCallback({ ...data, usedJSHeapSize: Number(usedMB) });
  }
}

/**
 * 获取指定子应用的内存日志
 */
export function getSubAppMemoryLogs(appName: string) {
  return memoryLogs[appName] || [];
}

步骤 2:子应用内存采集 & 上报

子应用内部实现「内存数据采集函数」,并通过 qiankun 的「全局通信」上报给主应用

// 子应用 src/utils/reportMemory.ts
/**
 * 采集当前子应用的内存数据
 * @param appName 子应用名称
 * @param status 子应用状态
 */
export function collectMemoryData(appName: string, status: string) {
  // 1. 获取 JS 堆内存(Chrome 特有,需做兼容)
  const memory = window.performance.memory || {
    usedJSHeapSize: 0,
    totalJSHeapSize: 0,
    jsHeapSizeLimit: 0
  };

  // 2. 统计 DOM 节点数(当前子应用容器内的 DOM)
  const container = document.querySelector('#app') || document.body;
  const domCount = container.querySelectorAll('*').length;

  // 3. 统计定时器数量(检测未清理的定时器)
  const timerCount = countTimers();

  // 4. 统计全局事件监听数(检测未移除的监听)
  const listenerCount = countGlobalListeners();

  // 5. 构造上报数据
  return {
    appName,
    timestamp: Date.now(),
    usedJSHeapSize: memory.usedJSHeapSize,
    totalJSHeapSize: memory.totalJSHeapSize,
    jsHeapSizeLimit: memory.jsHeapSizeLimit,
    domCount,
    timerCount,
    listenerCount,
    status
  };
}

// 辅助:统计定时器数量(简化版,生产可扩展)
function countTimers() {
  const maxTimerId = setTimeout(() => {}, 0);
  let count = 0;
  // 遍历所有定时器 ID(仅作参考,精准统计需子应用手动记录)
  for (let i = 1; i <= maxTimerId; i++) {
    if (window.clearTimeout(i)) count++;
  }
  clearTimeout(maxTimerId);
  return count;
}

// 辅助:统计全局事件监听数(如 resize/scroll 等)
function countGlobalListeners() {
  const events = ['resize', 'scroll', 'click', 'mousemove'];
  return events.reduce((total, event) => {
    return total + (window.eventListenerCount?.(window, event) || 0);
  }, 0);
}

/**
 * 上报内存数据给主应用
 * @param appName 子应用名称
 * @param status 子应用状态
 */
export function reportMemoryToMain(appName: string, status: string) {
  const data = collectMemoryData(appName, status);
  // 方式1:通过 qiankun 全局状态通信(推荐)
  if (window.__POWERED_BY_QIANKUN__) {
    window.qiankunGlobalState?.setState({
      memoryReport: data
    });
  }
  // 方式2:兼容低版本 qiankun,用自定义事件
  window.parent.postMessage(
    { type: 'MEMORY_REPORT', data },
    '*' // 生产环境替换为主应用域名
  );
}

子应用生命周期绑定上报(以 Vue 子应用为例)

// 子应用A main.ts
import { reportMemoryToMain } from './utils/reportMemory';

const appName = 'sub-app-a';
let app: any = null;
let isFirstMount = true;

// 1. 初始化时监听主应用的「激活/失活」通知
function initGlobalListener() {
  if (window.__POWERED_BY_QIANKUN__) {
    // 监听主应用的全局状态变化
    window.qiankunGlobalState?.onGlobalStateChange((state) => {
      if (state.appStatus === appName) {
        // 子应用被激活:上报激活状态的内存
        reportMemoryToMain(appName, 'active');
      } else if (state.appStatus) {
        // 子应用被失活:上报失活状态的内存
        reportMemoryToMain(appName, 'inactive');
      }
    });
  }
}

function bootstrap() {
  console.log(`${appName} bootstrap`);
  initGlobalListener();
}

function mount(props: any) {
  if (isFirstMount) {
    // 首次挂载:初始化应用
    const { createApp } = window.Vue;
    const pinia = window.pinia;
    const container = props.container.querySelector('#app') || '#app';
    app = createApp((await import('./App.vue')).default);
    app.use(pinia);
    app.mount(container);
    isFirstMount = false;
  }
  // 挂载完成:上报内存
  reportMemoryToMain(appName, 'mounted');
}

function unmount(props: any) {
  if (!props.isKeepAlive) {
    app.unmount();
    app = null;
    isFirstMount = true;
  }
  // 卸载/保活失活:上报内存
  reportMemoryToMain(appName, props.isKeepAlive ? 'inactive' : 'unmounted');
}

export { bootstrap, mount, unmount };
步骤 3:主应用接收 & 处理上报数据

主应用在初始化时绑定「内存上报」的接收逻辑,结合 qiankun 的子应用生命周期触发监控。

// 主应用 main.ts
import { initMemoryMonitor, receiveSubAppMemoryData } from './utils/memoryMonitor';
import { registerMicroApps, start, initGlobalState } from 'qiankun';

// 1. 初始化 qiankun 全局状态(用于通信)
const qiankunGlobalState = initGlobalState({
  appStatus: '', // 当前激活的子应用
  memoryReport: null // 子应用内存上报数据
});
window.qiankunGlobalState = qiankunGlobalState;

// 2. 初始化内存监控,设置告警回调
initMemoryMonitor((data) => {
  // 告警逻辑:控制台提示 + 对接企业微信/钉钉
  console.error(`【内存告警】${data.appName} 内存占用 ${data.usedJSHeapSize}MB,超出阈值 ${MEMORY_THRESHOLD}MB`);
  // 示例:调用钉钉告警接口
  // fetch('https://oapi.dingtalk.com/robot/send', {
  //   method: 'POST',
  //   body: JSON.stringify({ msgtype: 'text', text: { content: `【内存告警】${data.appName} 内存占用 ${data.usedJSHeapSize}MB` } })
  // });
});

// 3. 监听子应用的内存上报
qiankunGlobalState.onGlobalStateChange((state) => {
  if (state.memoryReport) {
    receiveSubAppMemoryData(state.memoryReport);
    // 清空上报数据,避免重复处理
    qiankunGlobalState.setState({ memoryReport: null });
  }
});

// 4. 监听自定义事件(兼容低版本子应用)
window.addEventListener('message', (e) => {
  if (e.data.type === 'MEMORY_REPORT') {
    receiveSubAppMemoryData(e.data.data);
  }
});

// 5. 注册子应用 + 切换时通知状态
const apps = [/* 子应用配置 */];
registerMicroApps(apps);
start({
  singular: false,
  sandbox: { strictStyleIsolation: true }
});

// 6. 切换子应用时,更新全局状态(触发子应用上报)
function switchApp(appName: string) {
  qiankunGlobalState.setState({ appStatus: appName });
  // 原有切换逻辑...
}

步骤 4:可视化监控面板(主应用)

主应用可开发一个简易的监控面板,展示各子应用的内存趋势(用 ECharts 实现),便于运维 / 开发实时查看。

<!-- 主应用 src/components/MemoryMonitor.vue -->
<template>
  <div class="memory-monitor">
    <h3>子应用内存监控</h3>
    <select v-model="selectedApp" @change="renderChart">
      <option value="sub-app-a">子应用A</option>
      <option value="sub-app-b">子应用B</option>
    </select>
    <div id="memory-chart" style="width: 800px; height: 400px;"></div>
    <div class="data-table">
      <table>
        <thead>
          <tr>
            <th>时间</th>
            <th>内存占用(MB)</th>
            <th>DOM节点数</th>
            <th>状态</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="item in logs" :key="item.timestamp">
            <td>{{ new Date(item.timestamp).toLocaleString() }}</td>
            <td :style="{ color: item.usedJSHeapSize > 200 ? 'red' : 'black' }">
              {{ item.usedJSHeapSize }}
            </td>
            <td>{{ item.domCount }}</td>
            <td>{{ item.status }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import * as echarts from 'echarts';
import { getSubAppMemoryLogs } from '../utils/memoryMonitor';

const selectedApp = ref('sub-app-a');
const logs = ref([]);
let chart: echarts.ECharts | null = null;

// 渲染内存趋势图
const renderChart = () => {
  logs.value = getSubAppMemoryLogs(selectedApp.value);
  if (!chart) {
    chart = echarts.init(document.getElementById('memory-chart'));
  }
  chart.setOption({
    title: { text: `${selectedApp.value} 内存趋势` },
    xAxis: {
      type: 'category',
      data: logs.value.map(item => new Date(item.timestamp).toLocaleTimeString())
    },
    yAxis: { type: 'value', name: '内存(MB)' },
    series: [{
      name: 'JS堆内存',
      type: 'line',
      data: logs.value.map(item => item.usedJSHeapSize),
      markLine: { // 阈值线
        data: [{ type: 'line', yAxis: 200, name: '阈值' }]
      }
    }]
  });
};

onMounted(() => {
  renderChart();
  // 每30秒刷新一次
  setInterval(renderChart, 30 * 1000);
});
</script>

步骤 5:线下深度分析(定位内存泄漏)

线上监控发现异常后,需用 Chrome DevTools Memory 面板做内存泄漏分析,选择快照观察半个小时的内存变化,定位是否是误报内存泄漏,如果停留在页面一段时间内,内存依然持续增长,基本可以断定发生内存泄漏,然后快照分析对象引用链,分析内存泄漏的可能性

注意事项:

  • 子应用不要高频上报(如每秒上报),建议「挂载 / 卸载 / 激活 / 失活」时上报 + 每 5 分钟定时上报,避免主应用被大量上报数据阻塞;
  • 主应用保留的日志数量不宜过多(如最多 100 条),避免主应用自身内存溢出。
  • 子应用的定时器 / 事件监听统计是「简化版」,生产环境建议子应用手动维护「定时器列表 / 事件列表」(如 const timers = []; timers.push(setInterval(...))),卸载时统一清理并上报准确数量;
  • 沙箱隔离场景下,子应用的 window 是代理对象,统计 eventListenerCount 时需指向真实 windowwindow.parent
❌