普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月13日技术

每日一题-移山所需的最少秒数🟡

2026年3月13日 00:00

给你一个整数 mountainHeight 表示山的高度。

同时给你一个整数数组 workerTimes,表示工人们的工作时间(单位:)。

工人们需要 同时 进行工作以 降低 山的高度。对于工人 i :

  • 山的高度降低 x,需要花费 workerTimes[i] + workerTimes[i] * 2 + ... + workerTimes[i] * x 秒。例如:
    • 山的高度降低 1,需要 workerTimes[i] 秒。
    • 山的高度降低 2,需要 workerTimes[i] + workerTimes[i] * 2 秒,依此类推。

返回一个整数,表示工人们使山的高度降低到 0 所需的 最少 秒数。

 

示例 1:

输入: mountainHeight = 4, workerTimes = [2,1,1]

输出: 3

解释:

将山的高度降低到 0 的一种方式是:

  • 工人 0 将高度降低 1,花费 workerTimes[0] = 2 秒。
  • 工人 1 将高度降低 2,花费 workerTimes[1] + workerTimes[1] * 2 = 3 秒。
  • 工人 2 将高度降低 1,花费 workerTimes[2] = 1 秒。

因为工人同时工作,所需的最少时间为 max(2, 3, 1) = 3 秒。

示例 2:

输入: mountainHeight = 10, workerTimes = [3,2,2,4]

输出: 12

解释:

  • 工人 0 将高度降低 2,花费 workerTimes[0] + workerTimes[0] * 2 = 9 秒。
  • 工人 1 将高度降低 3,花费 workerTimes[1] + workerTimes[1] * 2 + workerTimes[1] * 3 = 12 秒。
  • 工人 2 将高度降低 3,花费 workerTimes[2] + workerTimes[2] * 2 + workerTimes[2] * 3 = 12 秒。
  • 工人 3 将高度降低 2,花费 workerTimes[3] + workerTimes[3] * 2 = 12 秒。

所需的最少时间为 max(9, 12, 12, 12) = 12 秒。

示例 3:

输入: mountainHeight = 5, workerTimes = [1]

输出: 15

解释:

这个示例中只有一个工人,所以答案是 workerTimes[0] + workerTimes[0] * 2 + workerTimes[0] * 3 + workerTimes[0] * 4 + workerTimes[0] * 5 = 15 秒。

 

提示:

  • 1 <= mountainHeight <= 105
  • 1 <= workerTimes.length <= 104
  • 1 <= workerTimes[i] <= 106

二分

作者 tsreaper
2024年9月22日 12:15

解法:二分

二分答案,并计算每个工人在当前二分的值下能降低多少高度。这个计算也可以通过二分高度来进行。

最差情况是只有一个工人,且 workerTimes[0] = 1e6,所以二分的上界就是 $10^6 \times \frac{(1 + 10^5) \times 10^5}{2} \approx 10^{16}$。

复杂度 $\mathcal{O}(n\log h \log t)$,其中 $h = 10^5$ 是山的高度上限,$t = 10^{16}$ 是二分上限。

参考代码(c++)

###cpp

class Solution {
public:
    long long minNumberOfSeconds(int mountainHeight, vector<int>& workerTimes) {
        // 通过二分,计算每个工人在 lim 的时间内能降低多少高度
        auto calc = [&](long long lim) {
            int ret = 0;
            for (int t : workerTimes) {
                int head = 0, tail = mountainHeight;
                while (head < tail) {
                    int mid = (head + tail + 1) >> 1;
                    if (1LL * (1 + mid) * mid / 2 * t <= lim) head = mid;
                    else tail = mid - 1;
                }
                ret += head;
                // 提前退出,防止结果溢出
                if (ret >= mountainHeight) break;
            }
            return ret;
        };

        // 二分总用时
        long long head = 1, tail = 1e18;
        while (head < tail) {
            long long mid = (head + tail) >> 1;
            if (calc(mid) >= mountainHeight) tail = mid;
            else head = mid + 1;
        }
        return head;
    }
};

两种方法:最小堆模拟/二分答案(Python/Java/C++/Go)

作者 endlesscheng
2024年9月22日 12:08

方法一:最小堆模拟

循环 $\textit{mountainHeight}$ 次,每次选一个「工作后总用时」最短的工人,把山的高度降低 $1$。

具体请看 视频讲解,欢迎点赞关注~

###py

class Solution:
    def minNumberOfSeconds(self, mountainHeight: int, workerTimes: List[int]) -> int:
        h = [(t, t, t) for t in workerTimes]
        heapify(h)
        for _ in range(mountainHeight):
            # 工作后总用时,当前工作(山高度降低 1)用时,workerTimes[i]
            nxt, delta, base = h[0]
            heapreplace(h, (nxt + delta + base, delta + base, base))
        return nxt  # 最后一个出堆的 nxt 即为答案

###java

class Solution {
    public long minNumberOfSeconds(int mountainHeight, int[] workerTimes) {
        PriorityQueue<long[]> pq = new PriorityQueue<>((a, b) -> Long.compare(a[0], b[0]));
        for (int t : workerTimes) {
            pq.offer(new long[]{t, t, t});
        }
        long ans = 0;
        while (mountainHeight-- > 0) {
            // 工作后总用时,当前工作(山高度降低 1)用时,workerTimes[i]
            long[] w = pq.poll();
            long nxt = w[0], delta = w[1], base = w[2];
            ans = nxt; // 最后一个出堆的 nxt 即为答案
            pq.offer(new long[]{nxt + delta + base, delta + base, base});
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    long long minNumberOfSeconds(int mountainHeight, vector<int>& workerTimes) {
        priority_queue<tuple<long long, long long, int>, vector<tuple<long long, long long, int>>, greater<>> pq;
        for (int t : workerTimes) {
            pq.emplace(t, t, t);
        }
        long long ans = 0;
        while (mountainHeight--) {
            // 工作后总用时,当前工作(山高度降低 1)用时,workerTimes[i]
            auto [nxt, delta, base] = pq.top(); pq.pop();
            ans = nxt; // 最后一个出堆的 nxt 即为答案
            pq.emplace(nxt + delta + base, delta + base, base);
        }
        return ans;
    }
};

###go

func minNumberOfSeconds(mountainHeight int, workerTimes []int) int64 {
h := make(hp, len(workerTimes))
for i, t := range workerTimes {
h[i] = worker{t, t, t}
}
heap.Init(&h)

ans := 0
for ; mountainHeight > 0; mountainHeight-- {
ans = h[0].nxt // 最后一个出堆的 nxt 即为答案
h[0].delta += h[0].base
h[0].nxt += h[0].delta
heap.Fix(&h, 0)
}
return int64(ans)
}

// 工作后总用时,当前工作(山高度降低 1)用时,workerTimes[i]
type worker struct{ nxt, delta, base int }
type hp []worker
func (h hp) Len() int           { return len(h) }
func (h hp) Less(i, j int) bool { return h[i].nxt < h[j].nxt }
func (h hp) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (hp) Push(any)             {}
func (hp) Pop() (_ any)         { return }

复杂度分析

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

方法二:二分答案

由于花的时间越多,能够降低的高度也越多,所以有单调性,可以二分答案。

问题变成:

  • 每个工人至多花费 $m$ 秒,总共降低的高度是多少?能否大于等于 $\textit{mountainHeight}$?

遍历 $\textit{workerTimes}$,设 $t=\textit{workerTimes}[i]$,那么有

$$
t + 2t+ \cdots + xt = t\cdot \dfrac{x(x+1)}{2} \le m
$$

$$
\dfrac{x(x+1)}{2} \le \left\lfloor\dfrac{m}{t}\right\rfloor = k
$$

解得

$$
x \le \dfrac{-1 + \sqrt{1 + 8k}}{2}
$$

所以第 $i$ 名工人可以把山的高度降低

$$
\left\lfloor \dfrac{-1 + \sqrt{1 + 8k}}{2} \right\rfloor = \left\lfloor \dfrac{-1 + \lfloor\sqrt{1 + 8k}\rfloor}{2} \right\rfloor
$$

累加上式,如果和 $\ge \textit{mountainHeight}$,则说明答案 $\le m$,否则说明答案 $> m$。

最后,讨论二分的上下界。这里用开区间二分,其他二分写法也是可以的。

  • 开区间二分下界:$0$,无法把山的高度降低到 $0$。
  • 开区间二分上界:设 $\textit{maxT}$ 为 $\textit{workerTimes}$ 的最大值,假设每个工人都是最慢的 $\textit{maxT}$,那么单个工人要把山降低 $h=\left\lceil\dfrac{mountainHeight}{n}\right\rceil$,耗时 $\textit{maxT}\cdot(1+2+\cdots+h)=\textit{maxT}\cdot\dfrac{h(h+1)}{2}$,将其作为开区间的二分上界,一定可以把山的高度降低到 $\le 0$。

关于上取整的计算,当 $a$ 和 $b$ 均为正整数时,我们有

$$
\left\lceil\dfrac{a}{b}\right\rceil = \left\lfloor\dfrac{a-1}{b}\right\rfloor + 1
$$

证明见 上取整下取整转换公式的证明

###py

class Solution:
    def minNumberOfSeconds(self, mountainHeight: int, workerTimes: List[int]) -> int:
        def check(m: int) -> bool:
            left_h = mountainHeight
            for t in workerTimes:
                left_h -= (isqrt(m // t * 8 + 1) - 1) // 2
                if left_h <= 0:
                    return True
            return False

        max_t = max(workerTimes)
        h = (mountainHeight - 1) // len(workerTimes) + 1
        return bisect_left(range(max_t * h * (h + 1) // 2), True, 1, key=check)

###py

class Solution:
    def minNumberOfSeconds(self, mountainHeight: int, workerTimes: List[int]) -> int:
        f = lambda m: sum((isqrt(m // t * 8 + 1) - 1) // 2 for t in workerTimes)
        max_t = max(workerTimes)
        h = (mountainHeight - 1) // len(workerTimes) + 1
        return bisect_left(range(max_t * h * (h + 1) // 2), mountainHeight, 1, key=f)

###java

class Solution {
    public long minNumberOfSeconds(int mountainHeight, int[] workerTimes) {
        int maxT = 0;
        for (int t : workerTimes) {
            maxT = Math.max(maxT, t);
        }
        int h = (mountainHeight - 1) / workerTimes.length + 1;
        long left = 0;
        long right = (long) maxT * h * (h + 1) / 2;
        while (left + 1 < right) {
            long mid = (left + right) / 2;
            if (check(mid, mountainHeight, workerTimes)) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }

    private boolean check(long m, int leftH, int[] workerTimes) {
        for (int t : workerTimes) {
            leftH -= ((int) Math.sqrt(m / t * 8 + 1) - 1) / 2;
            if (leftH <= 0) {
                return true;
            }
        }
        return false;
    }
}

###cpp

class Solution {
public:
    long long minNumberOfSeconds(int mountainHeight, vector<int>& workerTimes) {
        auto check = [&](long long m) {
            int left_h = mountainHeight;
            for (int t : workerTimes) {
                left_h -= ((int) sqrt(m / t * 8 + 1) - 1) / 2;
                if (left_h <= 0) {
                    return true;
                }
            }
            return false;
        };

        int max_t = ranges::max(workerTimes);
        int h = (mountainHeight - 1) / workerTimes.size() + 1;
        long long left = 0, right = (long long) max_t * h * (h + 1) / 2;
        while (left + 1 < right) {
            long long mid = (left + right) / 2;
            (check(mid) ? right : left) = mid;
        }
        return right;
    }
};

###go

func minNumberOfSeconds(mountainHeight int, workerTimes []int) int64 {
maxT := slices.Max(workerTimes)
h := (mountainHeight-1)/len(workerTimes) + 1
ans := 1 + sort.Search(maxT*h*(h+1)/2-1, func(m int) bool {
m++
leftH := mountainHeight
for _, t := range workerTimes {
leftH -= (int(math.Sqrt(float64(m/t*8+1))) - 1) / 2
if leftH <= 0 {
return true
}
}
return false
})
return int64(ans)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log U)$,其中 $n$ 是 $\textit{workerTimes}$ 的长度,$U\le 5\cdot 10^{10}(10^5+1)$ 是二分上界。二分 $\mathcal{O}(\log U)$ 次,每次 $\mathcal{O}(n)$ 时间。开平方有专门的 CPU 指令,可以视作 $\mathcal{O}(1)$。
  • 空间复杂度:$\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站@灵茶山艾府

昨天 — 2026年3月12日技术

JavaScript 对象与属性描述符:从原理到实战

作者 swipe
2026年3月12日 22:50

背景:为什么要深入理解对象?

在日常开发中,我们经常会遇到这样的困惑:

  • 为什么有些对象属性用 for-in 遍历不出来?
  • 为什么 delete 有时能删除属性,有时却失效?
  • Vue2 的响应式原理到底是怎么"劫持"属性访问的?

这些问题的答案都指向同一个核心概念:属性描述符。它是 JavaScript 对象系统的底层机制,掌握它不仅能让你理解框架源码,还能写出更精准、更可控的代码。

本文将从面向对象的本质出发,逐步深入到属性描述符的细节,并结合实际场景帮你建立完整的知识体系。

你将收获:

  • 理解 JavaScript 面向对象的设计思想
  • 掌握属性描述符的 6 种特性及应用场景
  • 学会用 Object.defineProperty 精准控制对象行为
  • 具备阅读 MDN 文档和框架源码的基础能力

一、面向对象:用代码模拟现实世界

1.1 什么是面向对象?

面向对象编程(OOP)的核心思想是:用包含数据和行为的对象来模拟现实世界的实体

举个例子:

  • 一辆车(Car):有颜色、速度、品牌、价格等属性,有行驶、刹车等方法
  • 一个人(Person):有姓名、年龄、身高等属性,有吃饭、跑步等方法

这种抽象方式让代码结构更清晰,也更贴近人类的思维方式。在 JavaScript 中,面向对象主要体现在两个方面:

  1. 封装:把相关数据和方法组织在一起(函数、模块、对象都是封装)
  2. 继承:通过原型链实现代码复用(这是 JS 的重点,后续会详细讲解)

1.2 JavaScript 中的对象设计

JavaScript 支持多种编程范式,对象被设计成属性的无序集合,类似哈希表:

{
  key: value
}
  • key:标识符名称(字符串或 Symbol)
  • value:任意类型(基本类型、对象、函数等)
  • 如果 value 是函数,我们称之为方法

1.3 创建对象的两种方式

方式一:new Object()(构造函数方式)

var person1 = new Object();
person1.name = "小吴";
person1.age = 18;
person1.greet = function() {
  console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
};

person1.greet();  // 输出: Hello, my name is 小吴 and I am 18 years old.

适用场景:

  • 需要动态添加属性的复杂逻辑
  • 有 Java/C++ 等面向对象语言背景的开发者

历史背景: JavaScript 早期为了蹭 Java 的热度,在命名和语法上刻意模仿,导致很多 Java 开发者习惯用这种方式。

方式二:对象字面量(推荐)

var person2 = {
  name: "小吴",
  age: 18,
  greet: function() {
    console.log("Hello, my name is " + this.name + " and I am " + this.age + " years old.");
  }
};

person2.greet();  // 输出: Hello, my name is 小吴 and I am 18 years old.

优势:

  • 代码简洁,结构清晰
  • 属性和方法内聚性强
  • 性能略优(省略函数调用开销)

二、属性描述符:精准控制对象行为

2.1 为什么需要属性描述符?

通常我们直接定义属性:

var obj = {
  name: "小吴",
  age: 20,
  sex: "男"
};

// 获取属性
console.log(obj.name);  // 小吴

// 修改属性
obj.name = "XiaoWu";
console.log(obj.name);  // XiaoWu

// 删除属性
delete obj.name;
console.log(obj);  // { age: 20, sex: '男' }

但这种方式无法控制:

  • 这个属性能否被 delete 删除?
  • 这个属性能否被 for-in 遍历?
  • 这个属性能否被重新赋值?

属性描述符就是用来解决这些问题的工具。

2.2 Object.defineProperty 基础用法

Object.defineProperty(obj, prop, descriptor)

参数说明:

  • obj:目标对象
  • prop:属性名(字符串或 Symbol)
  • descriptor:属性描述符对象(核心)

返回值: 修改后的原对象(非纯函数)

示例:

var obj = {
  name: "XiaoWu",
  age: 20
};

Object.defineProperty(obj, "height", {
  value: 1.75
});

console.log(obj);  // Node 环境:{ name: 'XiaoWu', age: 20 }

疑问:为什么 height 没显示出来?

图 1:浏览器控制台显示了 height 属性

原因分析:

  • height 默认是不可枚举的(enumerable: false
  • Node.jsconsole.log 使用 util.inspect(),默认只显示可枚举属性(遵循 ECMAScript 标准)
  • 浏览器控制台 为了调试方便,会显示所有属性(包括不可枚举属性)

验证属性确实存在:

console.log(obj.height);  // 1.75(可以访问)

让属性可枚举:

Object.defineProperty(obj, "height", {
  value: 1.75,
  enumerable: true  // 设置为可枚举
});

console.log(obj);  // { name: 'XiaoWu', age: 20, height: 1.75 }

三、属性描述符的两种类型

属性描述符分为两大类,它们不能混用

类型 configurable enumerable value writable get set
数据描述符
存取描述符

记忆口诀: 2 共用 + 2 可选,同时生效最多 4 种

3.1 为什么不能混用?

本质原因: 它们代表了两种完全不同的属性管理方式

  • 数据描述符(静态):属性持有一个具体的值,可以直接读写
  • 存取描述符(动态):属性值通过函数动态计算,每次访问可能不同

如果同时定义,JavaScript 引擎无法判断应该直接操作值还是调用函数,因此规范禁止混用。

类比理解:

  • 数据描述符 = 名词(静态的"数据")
  • 存取描述符 = 动词(动态的"存取"操作)

四、数据描述符详解

4.1 四大特性

[[Configurable]]:可配置性

控制属性是否可以:

  • delete 删除
  • 修改其他描述符特性
  • 转换为存取描述符

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Enumerable]]:可枚举性

控制属性是否可以:

  • for-in 遍历
  • Object.keys() 返回

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Writable]]:可写性

控制属性值是否可以被修改。

默认值:

  • 直接定义:true
  • 通过描述符定义:false

[[Value]]:属性值

属性的实际值。

默认值: undefined

4.2 实战案例

var obj = {
  name: "XiaoWu",
  age: 18
};

// 定义一个受控属性
Object.defineProperty(obj, "address", {
  value: "福建省",
  configurable: false,  // 不可删除、不可重新配置
  enumerable: true,     // 可枚举
  writable: false       // 不可修改
});

// 测试 configurable
delete obj.name;
console.log(obj);  // { age: 18, address: '福建省' }(name 被删除)

delete obj.address;
console.log(obj.address);  // 福建省(删除失败)

// 测试 enumerable
console.log(Object.keys(obj));  // [ 'age', 'address' ]

for (var key in obj) {
  console.log(key);  // age, address
}

// 测试 writable
obj.address = "上海市";
console.log(obj.address);  // 福建省(修改失败)

关键点:

  • 直接定义的属性(nameage)默认所有特性都是 true
  • 通过描述符定义的属性(address)默认所有特性都是 false

五、存取描述符详解

5.1 四大特性

  • [[Configurable]]:同数据描述符
  • [[Enumerable]]:同数据描述符
  • [[Get]]:获取属性时执行的函数,默认 undefined
  • [[Set]]:设置属性时执行的函数,默认 undefined

5.2 应用场景

场景一:隐藏私有属性

var obj = {
  name: "小吴",
  age: 18,
  _address: "泉州市"  // _ 开头表示私有属性(约定俗成)
};

Object.defineProperty(obj, "address", {
  enumerable: true,
  configurable: true,
  get: function() {
    return this._address;  // 通过 address 访问 _address
  },
  set: function(value) {
    this._address = value;
  }
});

console.log(obj.address);  // 泉州市
obj.address = "厦门市";
console.log(obj.address);  // 厦门市

注意: ES6 后可以用 # 定义真正的私有属性(后续会讲)。

场景二:拦截属性访问(Vue2 响应式原理)

var obj = {
  name: "小吴",
  age: 18,
  _address: "泉州市"
};

Object.defineProperty(obj, "address", {
  enumerable: true,
  configurable: true,
  get: function() {
    console.log("获取了一次 address 的值");  // 拦截读取
    return this._address;
  },
  set: function(value) {
    console.log("设置了一次 address 的值");  // 拦截写入
    this._address = value;
  }
});

console.log(obj.address);
// 输出:获取了一次 address 的值
//      泉州市

obj.address = "why";
// 输出:设置了一次 address 的值

console.log(obj.address);
// 输出:获取了一次 address 的值
//      why

核心价值: 这就是 Vue2 响应式系统的底层原理——通过 get/set 拦截属性访问,实现依赖收集和派发更新。


六、学习属性描述符的实战意义

6.1 理解原生 API 的能力边界

所有原生对象的 API 都有属性描述符,这决定了它们的行为:

  • 为什么 Array.prototype 上的方法用 for-in 遍历不出来?(enumerable: false
  • 为什么 Object.prototype.toString 不能被删除?(configurable: false

6.2 读懂技术文档

MDN 文档中大量使用属性描述符来描述 API 特性:

图 2:MDN 文档对 API 能力边界的描述

掌握这些概念后,你能:

  • 快速理解 API 的使用限制
  • 预判代码的行为边界
  • 避免踩坑(比如误删不可配置的属性)

6.3 降低框架学习门槛

React、Vue 等框架文档中会用到这些术语:

图 3:React 文档中的专业术语

学完 JavaScript 高级后,这些词汇对你来说将不再陌生。


七、关键要点总结

  1. 属性描述符分两类:数据描述符(静态值)和存取描述符(动态函数),不能混用
  2. 默认值差异:直接定义的属性默认可配置/可枚举/可写,通过描述符定义的默认都是 false
  3. 核心应用场景
    • 隐藏私有属性(用 get/set 代理访问)
    • 拦截属性访问(实现响应式、日志、校验等)
    • 精准控制对象行为(防删除、防修改、防遍历)
  4. 实战价值:理解原生 API、读懂技术文档、掌握框架原理

八、下一步建议

团队落地建议:

  • 在工具函数库中封装常用的属性控制逻辑(如冻结对象、只读属性等)
  • Code Review 时关注属性描述符的使用是否合理
  • 在复杂对象设计中主动使用描述符提升代码健壮性

后续学习方向:

  • 批量定义属性描述符(Object.defineProperties
  • 对象方法补充(Object.freezeObject.seal 等)
  • 工厂函数与构造函数
  • 原型链与继承机制

下一篇我们将深入构造函数,探索更高效的对象创建方案。

【笔记】xxx 技术分享文档模板

作者 林小帅
2026年3月12日 18:56

纯模板,下次需要直接贴给 AI 然后替换内容就行

skills 技术分享文档

一、项目概述

1.1 项目定位

skills 是团队为 Claude Code 打造的通用技能工具集,通过 npm 包的形式进行分发和管理,旨在提升团队开发效率和代码质量。

1.2 核心价值

  1. 知识共享: 将团队最佳实践封装为可复用的技能
  2. 效率提升: 自动化重复性工作流程
  3. 规范统一: 确保团队操作流程一致性
  4. 降低门槛: 新成员快速上手团队工作方式

二、技术架构

2.1 整体架构

skills
├── bin/
│   └── cli.js          # CLI 工具入口
├── skills/             # 技能定义(自动触发或 /调用)
│   ├── brainstorming/
│   ├── merge-to-test/
│   ├── merge-to-develop/
│   └── requirement-to-spec/
├── commands/           # 命令定义(/调用)
│   └── create-branch.md
├── agents/             # 自定义 Agent 定义
└── rules/              # 全局规则约束

2.2 Claude Code 资源类型映射

类型 安装路径 触发方式 文件扩展 是否嵌套
skills ~/.claude/skills/ 自动或 /skill-name
commands ~/.claude/commands/ /command-name .md
agents ~/.claude/agents/ Agent 定义 .md
rules ~/.claude/rules/ 全局应用 .md

三、CLI 工具设计

3.1 核心功能

CLI 工具提供了完整的资源生命周期管理:

# 查看可用资源
skills list
skills list skills

# 查看已安装资源
skills installed
skills installed commands

# 安装资源
skills install --all                    # 安装所有
skills install skills brainstorming    # 安装单个

# 更新资源
skills update --all
skills update commands create-branch

# 卸载资源
skills remove skills brainstorming

3.2 技术实现要点

1. 路径解析

const CLAUDE_HOME = path.join(os.homedir(), '.claude')
const TYPES = {
  skills:   { src: 'skills',   dest: path.join(CLAUDE_HOME, 'skills'),   nested: true,  ext: '' },
  commands: { src: 'commands', dest: path.join(CLAUDE_HOME, 'commands'), nested: false, ext: '.md' },
  agents:   { src: 'agents',   dest: path.join(CLAUDE_HOME, 'agents'),   nested: false, ext: '.md' },
  rules:    { src: 'rules',    dest: path.join(CLAUDE_HOME, 'rules'),    nested: false, ext: '.md' },
}

2. 嵌套目录处理 Skills 需要递归复制整个目录,而其他类型仅需复制单个文件:

if (TYPES[type].nested) {
  fs.cpSync(src, dest, { recursive: true, force: true })
} else {
  fs.copyFileSync(src, dest)
}

3. 更新策略 更新时仅更新已安装的资源,跳过不存在的资源并给出警告:

const installed = listInstalled(type)
const available = new Set(listAvailable(type))
installed.forEach(name => {
  if (available.has(name)) { installOne(type, name); count++ }
  else console.warn(`  ! [${type}] "${name}" 不在包中,跳过`)
})

四、核心技能详解

4.1 brainstorming - 创意设计助手

使用场景: 在进行任何创造性工作之前(创建功能、构建组件、修改行为)

工作流程:

  1. 理解上下文: 检查项目状态(文件、文档、最近提交)
  2. 逐步提问: 每次只问一个问题,避免信息过载
  3. 方案探索: 提出 2-3 种方案并说明权衡
  4. 分段展示: 每段 200-300 字,逐步验证设计
  5. 文档输出: 将确认的设计写入 docs/plans/YYYY-MM-DD-<topic>-design.md

关键原则:

  • 一次只问一个问题
  • 优先使用多选题而非开放题
  • YAGNI 原则:移除不必要的功能
  • 始终提供多种方案

4.2 merge-to-test - 自动合并到测试环境

触发条件: 用户说"推送代码"、"可以推送"、"push"时

保护分支检测:

const protectedBranches = ['master', 'test', 'develop', 'release/*']
// 如果当前分支是保护分支,则不触发

合并流程:

branch=$(git rev-parse --abbrev-ref HEAD) && \
git checkout test && \
git pull && \
git merge $branch && \
git push && \
git checkout $branch

级联触发: 成功合并到 test 后,自动触发 merge-to-develop 询问是否部署到开发环境

4.3 merge-to-develop - 合并到开发环境

触发方式:

  1. merge-to-test 成功后自动触发
  2. 用户主动请求:"Merge to develop environment"、"Deploy to develop"

工作流程: 与 merge-to-test 类似,目标分支为 develop

部署完成摘要:

部署完成:
✅ 测试环境 (test)
✅ 开发环境 (develop)

4.4 requirement-to-spec - 需求转技术规格

核心功能: 通过自然对话收集需求信息,然后调用 /openspec:proposal 生成详细技术规格

分支保护检查 (关键安全机制):

// Step 0: 检查当前分支
branch=$(git branch --show-current)
if [[ "$branch" == "master" || "$branch" == "test" || "$branch" == "develop" ]]; then
  // 显示警告并询问是否切换分支
fi

信息收集流程:

  1. 需求名称(可直接从项目管理系统复制)
  2. 简要描述(做什么、预期什么结果)
  3. 技术变更点
  4. 影响范围(文件或模块)
  5. 接口信息(如有)
  6. 出入参(如有)

格式化输出:

/openspec:proposal 这里有个新需求需要创建提案:【<需求名称>】

简单描述:<做什么,要什么结果>

具体需求:<技术变更点>

影响范围:<文件或模块路径>

接口:<如果有则填写,没有则用占位符>

出入参:<如果有则填写,没有则用 {} 占位>

五、核心命令详解

5.1 create-branch - AI 辅助分支创建

功能: 从项目管理系统 Story 创建功能分支,AI 自动翻译分支名

分支命名规范: feat/<username>/<concise-english-name>-<storyID>

核心功能:

  1. 信息提取: 从项目管理系统 URL 提取 storyID
  2. AI 翻译: 将中文标题翻译为简洁的英文分支名(3-4 词)
  3. 用户名获取: 从 git config user.name 自动获取
  4. 创建模式选择: 支持普通分支和 Worktree 两种模式

Worktree 模式特殊处理:

  • 路径格式: <main-branch-parent>/<project>.worktrees/<branch-type>/<username>/<branch-name>
  • 共享 node_modules: 通过符号链接避免重复安装依赖
  • Windows 符号链接: powershell -Command "cmd /c 'mklink /D <target> <source>'"
  • 可选复制 route-dev.js 文件

AI 翻译规则:

  • 使用小写连字符格式(如 scrm-auto-tag
  • 移除冗余词汇("功能"、"优化"等)
  • 保留重要缩写(SCRM、CRM等)
  • 保持简洁,聚焦核心功能

执行流程:

# 1. 更新 master(必须)
git checkout master
git pull origin master

# 2. 创建分支
git checkout -b feat/<username>/<english-name>-<storyID>

# 3. Worktree 模式额外操作
git worktree add <worktree-path> -b <branch-name>
# 创建 node_modules 符号链接
# 可选复制 route-dev.js

六、集成与协作

6.1 技能间的协作

/create-branch
    ↓
/requirement-to-spec (分支保护检查)
    ↓
/openspec:proposal (生成技术规格)
    ↓
[开发实现]
    ↓
git push
    ↓
merge-to-test (自动触发)
    ↓ (成功后)
merge-to-develop (自动触发)

6.2 安全机制

  1. 分支保护: 所有涉及代码修改的技能都会检查当前分支
  2. 用户确认: 关键操作前都需要用户确认
  3. 错误处理: 提供清晰的错误信息和恢复指导
  4. 状态回滚: 合并失败时自动切换回原分支

七、最佳实践

7.1 发布流程

# 1. 更新版本号
npm version patch/minor/major

# 2. 发布到内部 npm
npm publish --registry http://npm.example.com/

# 3. 用户更新
npx --registry http://npm.example.com/ @company/skills update --all

7.2 本地开发

# 链接到全局
npm link

# 测试命令
skills list
skills install --all
skills installed

7.3 扩展开发指南

新增 Skill:

  1. 创建 skills/<skill-name>/SKILL.mdSkill.md
  2. 编写 frontmatter 元数据
  3. 编写技能逻辑文档
  4. 更新版本号并发布

新增 Command:

  1. 创建 commands/<command-name>.md
  2. 编写命令说明文档
  3. 更新版本号并发布

Skill Frontmatter 模板:

---
name: skill-name
description: 简短描述
category: 分类(可选)
tags: [tag1, tag2](可选)
---

八、常见问题

Q1: 如何调试技能执行过程?

A: Claude Code 会在执行过程中显示详细的日志输出,包括每个步骤的执行情况和结果。

Q2: Worktree 模式下符号链接创建失败?

A: Windows 需要管理员权限或开启开发者模式才能创建符号链接。可以:

  • 以管理员身份运行终端
  • 或启用 Windows 开发者模式

Q3: 如何自定义分支命名规则?

A: 修改 commands/create-branch.md 中的分支格式定义,然后更新发布。

Q4: 技能执行失败如何回滚?

A: 大多数技能都有内置的错误处理和状态恢复机制,会自动切换回原分支。


九、未来规划

  1. 更多技能: 持续添加团队常用的开发技能
  2. 配置化: 支持团队自定义配置(如分支命名规则)
  3. 测试覆盖: 为 CLI 工具添加自动化测试
  4. 文档完善: 提供更详细的使用示例和视频教程
  5. 性能优化: 优化安装和更新速度

十、总结

skills 通过以下方式提升开发效率:

  1. 标准化流程: 将团队最佳实践封装为可复用的技能
  2. 自动化操作: 减少重复性手工操作
  3. 智能辅助: AI 翻译、需求整理等智能功能
  4. 安全保护: 分支保护检查等安全机制
  5. 易于扩展: 清晰的架构便于添加新技能

通过这套工具集,新成员可以快速融入团队工作方式,老成员可以专注于核心业务逻辑,从而整体提升团队的开发效率和代码质量。

版权声明:
本文版权属于作者 林小帅,未经授权不得转载及二次修改。
转载或合作请在下方留言及联系方式。

里程碑4 - 基于Vue3完成动态组件库建设

作者 oo12138
2026年3月12日 18:15

一、目标

在之前的过程中,基于DSL完成了列表页面的领域模型搭建和页面的动态开发,接下来我们是要根据列表页展示的按钮,完成详情的 动态增改查 功能

二、配置项展示

1. xxxOption(字段级配置)

这部分设置的是某个字段在某个页面中的表现方式,用来控制每个字段如何渲染,比如是要隐藏或是禁用。

其中新增、修改页面的控件类型comType是必须要进行配置的,用于后续数据填写;而查看详情页面可以添加控件组件comType,和新增、修改采用同一种样式展示、也可以不配置控件组件,自定义展示样式。

以下是代码用例:

      xxxPageOption: {
        ...elComponentConfig, // 标准 el-component配置
        comType: "", // 控件类型 input/select/input-number
        visible: true, // 是否展示,默认为true
        disabled: false, // 是否禁用,默认为false
        default: "", // 默认值
        enumList: [], // 枚举列表,comType === select时生效
      },

2. componentConfig(组件级配置)

这部分配置是用来控制页面的行为;根据之前按钮配置的eventKey和eventOption中的comName来决定展示哪个页面

适合用来控制一些页面属性

        // 动态组件 相关配置
        componentConfig: {
          xxxPage: {
            title: '', // 表单标题
            saveBtnText: '', // 保存按钮文案
            mainKey: '', // 表单主键,用于唯一标识要修改的数据对象,修改和查看必须进行配置
          },
          // ... 支持用户动态扩展
        }

3. 两者之间的结构关系

        Schema
         ├─ properties (字段级)
         │   ├─ field1
         │   │   ├─ xxxPageOneOption
         │   │   ├─ xxxPageTwoOption
         │   │   └─ xxxPageThreeOption
         │   └─ field2
         │
        componentConfig (页面级)
             ├─ xxxPageOne
             ├─ xxxPageTwo
             └─ xxxPageThree

三、与代码配置页面的比对

代码配置页面 DSL领域模型生成
开发方式 写代码 写配置
页面数量 一个页面一套代码 一个组件根据配置可以生成多个页面
样式风格 难以统一 容易保持一致
  • DSL的核心优势

    一个schema可以生成,达到页面生成自动化,且UI样式统一,降低重复代码

四、最终DSL架构

经过不断的完善和扩展,我们最终的DSL架构样式为:

    schema: {
      properties: {
        key: {
          ...schema,
          label: '',
          type: '',
          searchOption: {
          },
          tableOption: {
          },
          xxxPageOneOption: {
          },
          xxxPageTwoOption: {
          },
        }
      },
      required: ["key"],
    }
    searchConfig: {},
    tableConfig: {},
    component: {
      xxxPageOne: {
      },
      xxxPageTwo: {
      },
    },

五、总结

为解决前端代码开发过程中遇到的 页面重复度高、大量CRUD页面结构相似,每个页面独立完成导致的后续高维护成本和扩展困难等问题,我们从手写每个页面代码 转变为 使用DSL配置描述页面结构。

在开发过程中,通过领域模型model和衍生出的各类project配置,结合统一的schema配置描述页面结构,动态生成页面。从而达到将重复代码沉淀下来,让我们有更多的精力去完成定制化需求。

六、开发过程中遇到的😅事情

vue动态组件 + ref 收集方式

  • 修改前的写法

        <component v-for='item in List"  :ref="handleSearchComList"/>  
    
        const searchComList = ref([]);
        const handleSearchComList = (el) => {
          searchComList.value.push(el);
        }
    
  • 错误的修改

        <component v-for="item in list"  :ref="searchComList"/>  
        const searchComList = ref([]);
    

    因为我定义的是 searchComList = ref([]),vue会认为这是一个ref对象,然后尝试searchComList.value = el;这样会导致 过程中每次ref都会覆盖,不会push,所以在我打印的时候看到的是空的

  • 正确的修改

        <component v-for="item in list"  ref="searchComList"/>  
        const searchComList = ref([]);
    
  • 情况比对

    写法 是否可用 原因
    :ref="schemaComList" 不可用 :ref="XXX"动态ref,是js表达式绑定,vue会理解为 XXX.value = el,会被覆盖,在最后组件更新或卸载的时候,打印出来的结果就是空数组或者null
    :ref="handleSearchComList" + handleSearchComList() 可用 定义的handleSearchComList是函数,vue会执行这段函数,会在函数中进行push
    ref="searchComList" + v-for 可用 ref="XXX" 静态ref,XXX只是一个字符串key,vue会在挂载后自动做 XXX.value = 组件实例;如果是v-for,vue会自动收集 XXX.value = [实例1,实例2...]

告别表单“黄油色”:如何优雅地重置 Chrome 自动填充样式

作者 火车叼位
2026年3月12日 18:13

在前端开发与 UI 还原的过程中,我们经常会遇到一个破坏设计美感的“顽疾”:当用户使用 Chrome 或其他 Webkit 内核浏览器自动填充账号密码时,输入框会被强制渲染上一层淡蓝色或淡黄色的背景。

对于追求完美视觉体验的项目来说,这种突兀的颜色往往会破坏整体的 UI 色调。今天我们就来聊聊,为什么会出现这个问题,以及如何优雅地解决它。

🔍 为什么常规的 background-color 无法覆盖?

当浏览器触发自动填充时,会给 <input> 元素自动加上一个 :-webkit-autofill 伪类。在这个伪类的用户代理样式表(User Agent Stylesheet)中,浏览器使用了 !important 来强制设置背景色和字体颜色。

正因为它的优先级极高,我们在 CSS 中常规编写的 background-colorcolor 属性都会失效。

🛠️ 解决方案大盘点

要对付这个高优先级的默认样式,我们需要采取一些“曲线救国”的 CSS 技巧。以下是目前业内最常用的三种解决方案:

# 方案一:内阴影遮挡法(最通用,适用于纯色背景)

这是目前最主流且兼容性最好的做法。既然背景色改不掉,我们就用一堵“厚厚的墙”把它挡住。利用极其宽大的内部阴影(inset),我们可以完美遮挡住浏览器默认的背景色。

/* 针对 Webkit 浏览器的自动填充样式重置 */
input:-webkit-autofill,
input:-webkit-autofill:hover, 
input:-webkit-autofill:focus, 
input:-webkit-autofill:active {
    /* 1. 使用足够大的内阴影来覆盖背景色,把 #ffffff 替换成你输入框原本的背景色 */
    -webkit-box-shadow: 0 0 0px 1000px #ffffff inset !important;
    
    /* 2. 由于常规的 color 属性也会失效,需要用这个属性修改文字颜色 */
    -webkit-text-fill-color: #333333 !important; 
    
    /* 3. 保留光标的正常颜色 */
    caret-color: #333333;
}

# 方案二:过渡延迟法(适用于透明背景输入框)

如果你设计的输入框是没有背景色的(即透明背景),那么方案一的纯色阴影就不适用了。这时,我们可以利用 CSS 的 transition 属性,给背景色的变化加上一个极长的延迟时间。

这样一来,即使浏览器想变色,也要等上好几个小时,在视觉上就等同于保持了透明。

input:-webkit-autofill,
input:-webkit-autofill:hover, 
input:-webkit-autofill:focus, 
input:-webkit-autofill:active {
    /* 将背景色的过渡时间设置为一个极大的值(如 5000秒),让变色无限延后 */
    transition: background-color 5000s ease-in-out 0s;
    
    /* 依然可以自定义文字颜色 */
    -webkit-text-fill-color: #333333 !important;
}

# 方案三:直接禁用自动填充(视业务场景而定)

如果你开发的系统对安全性要求极高,或者明确不需要浏览器记住密码(例如银行系统的动态验证码输入框),可以直接在 HTML 层面关闭它。

<input type="text" name="username" autocomplete="off">

<input type="password" name="password" autocomplete="new-password">

注意: 禁用自动填充会牺牲一部分用户便利性,在常规的登录注册页面中不建议滥用。通常推荐使用前两种 CSS 方案来兼顾用户体验与 UI 美观。

💡 总结

处理 :-webkit-autofill 是前端页面切图时的经典问题。遇到纯色背景,首选 box-shadow 内阴影覆盖;遇到透明背景,使用 transition 延迟生效;遇到特殊安全场景,再考虑 autocomplete="off"。掌握这三招,足以应对绝大多数表单样式重置的需求。

Volta启动项目自动切换Node版本

作者 _DoubleL
2026年3月12日 17:44

1. 为什么需要 Volta 自动切换 Node 版本

在前端开发中,不同项目往往依赖不同 Node.js 版本

  • 老项目可能只兼容 Node 14/16
  • 新项目需要 Node 18+ 甚至更高
  • 用 nvm 手动切换麻烦、容易忘、容易报错

如果只在电脑上安装 一个全局 Node 版本,就很容易出现以下问题:

  • ❌ 切换项目时需要 手动切换 Node 版本
  • ❌ 忘记切换导致 项目启动失败
  • ❌ 团队成员 Node 版本不一致,出现 环境问题
  • ❌ CI / 本地环境 构建结果不一致

2. 使用流程

  1. 在项目的package.json 里面添加如下配置
  "volta": {
    "node": "16.16.0" // 项目的node 版本号
  }
  1. 安装Volta
curl https://get.volta.sh | bash
  1. 修改配置文件,以 .zshrc 为例
open ~/.zshrc
export NVM_DIR="$HOME/.nvm"

[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm

[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion


# ---- Volta (必须在nvm后面) ----

export VOLTA_HOME="$HOME/.volta"

export PATH="$VOLTA_HOME/bin:$PATH"

  1. 刷新zshrc
source ~/.zshrc
  1. 切换到对应项目目录,输入 node -v 查看是否已经自动切换过来,如果已经切换过来,则可以直接启动项目

image.png

watch Cheatsheet

Basic Syntax

Core watch command forms.

Command Description
watch command Run a command repeatedly every 2 seconds
watch -n 5 command Refresh every 5 seconds
watch -n 1 date Update output every second
watch --help Show available options

Common Monitoring Tasks

Use watch to keep an eye on changing system state.

Command Description
watch free -h Monitor memory usage
watch df -h Monitor disk space
watch uptime Check load averages and uptime
`watch “ps -ef grep nginx”`
watch "ss -tulpn" Monitor listening sockets

Timing and Refresh Control

Control how often watch reruns the command.

Command Description
watch -n 0.5 command Refresh every 0.5 seconds
watch -n 10 command Refresh every 10 seconds
watch -t command Hide the header line
watch -p command Try to run at precise intervals
watch -x command arg1 arg2 Run the command directly without sh -c

Highlighting Changes

Make changing output easier to spot.

Command Description
watch -d command Highlight differences between updates
watch -d free -h Highlight memory changes
watch -d "ip -brief address" Highlight interface state or address changes
watch -d -n 1 "cat /proc/loadavg" Highlight load-average changes

Commands with Pipes and Quotes

Wrap pipelines and shell syntax in quotes unless you use -x.

Command Description
`watch “ps -ef grep apache”`
`watch “ls -lh /var/log tail”`
watch "grep -c error /var/log/syslog" Count matching lines repeatedly
watch -x ls -lh /var/log Run ls directly without shell parsing
`watch “find /tmp -maxdepth 1 -type f wc -l”`

Exit Conditions and Beeps

Stop or alert when output changes.

Command Description
watch -g command Exit when command output changes
watch -b command Beep if the command exits with non-zero status
watch -b -n 5 "systemctl is-active nginx" Alert if the service is no longer active
watch -g "cat /tmp/status.txt" Exit when the file content changes

Troubleshooting

Quick fixes for common watch issues.

Issue Check
Pipes or redirects do not work Quote the whole command or use sh -c
The screen flickers too much Increase the interval or hide the header with -t
Output changes are hard to spot Add -d to highlight differences
The command exits immediately Check the command syntax outside watch first
You need shell expansion and variables Use quoted shell commands instead of -x

Related Guides

Use these guides for broader monitoring and process tasks.

Guide Description
Linux Watch Command Full guide to watch options and examples
ps Command in Linux Inspect running processes
free Command in Linux Check memory usage
ss Command in Linux Inspect sockets and ports
top Command in Linux Monitor processes interactively

Flutter文本框添加图片表情(粗制滥造版)

作者 逍遥咸鱼
2026年3月12日 17:34

效果图:

无标题.png

使用 Unicode 的私有区做表情的占位符

继承重写 TextEditingController,在 buildTextSpan 内显示时调整为表情图片。

表情键值对:

final Map<int, String> emojiMap = {
    0xE001: 'assets/images/1.png',
    0xE002: 'assets/images/2.png',
    0xE003: 'assets/images/3.png',
  };

遍历字符串把占位符显示为表情的方法:

  List<InlineSpan> _parseText(String text, TextStyle? style) {
    final spans = <InlineSpan>[];
    final buffer = StringBuffer();
    for (final codePoint in text.runes) {
      // 如果是表情
      if (codePoint >= 0xE000 && codePoint <= 0xF8FF) {
        // 将 buffer 内的字符加入 spans 并清空 buffer。
        if (buffer.isNotEmpty) {
          spans.add(TextSpan(text: buffer.toString(), style: style));
          buffer.clear();
        }
        // 处理表情
        final asset = emojiMap[codePoint];
        if (asset != null) {
          spans.add(
            WidgetSpan(
              child: Image.asset(
                asset,
                width: style?.fontSize,
                height: style?.fontSize,
              ),
            ),
          );
        } else {
          buffer.writeCharCode(codePoint);
        }
      } else {
        buffer.writeCharCode(codePoint);
      }
    }
    if (buffer.isNotEmpty) {
      spans.add(TextSpan(text: buffer.toString(), style: style));
    }
    return spans;
  }

 

buildTextSpan:

  @override
  TextSpan buildTextSpan({
    required BuildContext context,
    TextStyle? style,
    required bool withComposing,
  }) {
    assert(
      !value.composing.isValid || !withComposing || value.isComposingRangeValid,
    );
    final bool composingRegionOutOfRange =
        !value.isComposingRangeValid || !withComposing;

    if (composingRegionOutOfRange) {
      // 修改为使用 _parseText()
      // 原本的:return TextSpan(style: style, text: text);
      return TextSpan(style: style, children: _parseText(text, style));
    }

    final TextStyle composingStyle =
        style?.merge(const TextStyle(decoration: TextDecoration.underline)) ??
        const TextStyle(decoration: TextDecoration.underline);

    // 这里也是修改为使用 _parseText()
    return TextSpan(
      style: style,
      children: <TextSpan>[
        TextSpan(
          children: _parseText(value.composing.textBefore(value.text), style),
        ),
        TextSpan(
          style: composingStyle,
          children: _parseText(value.composing.textInside(value.text), style),
        ),
        TextSpan(
          children: _parseText(value.composing.textAfter(value.text), style),
        ),
      ],
    );
  }

添加表情:

  void addEmoji(int codePoint) {
    // 获取当前选区,如果无效(如未聚焦),则默认光标在文本末尾
    final TextSelection currentSelection = selection.isValid
        ? selection
        : TextSelection.collapsed(offset: text.length);

    // 根据选区情况构造新文本和光标位置
    final String newText;
    final int newCursorOffset;

    if (currentSelection.isCollapsed) {
      // 折叠光标:直接插入
      final int pos = currentSelection.baseOffset;
      newText =
          text.substring(0, pos) +
          String.fromCharCode(codePoint) +
          text.substring(pos);
      newCursorOffset = pos + 1;
    } else {
      // 有选中文本:先删除选中内容,再在开始位置插入
      final int start = currentSelection.start;
      final int end = currentSelection.end;
      newText =
          text.substring(0, start) +
          String.fromCharCode(codePoint) +
          text.substring(end);
      newCursorOffset = start + 1;
    }

    // 更新控制器值,并设置光标折叠在新字符之后
    value = value.copyWith(
      text: newText,
      selection: TextSelection.collapsed(offset: newCursorOffset),
      // 清除组合范围,因为插入操作会中断输入法组合
      composing: TextRange.empty,
    );
  }

资深前端都在用的 9 个调试偏方

作者 冴羽
2026年3月12日 17:31

资深前端都在用的 9 个调试偏方

1. 技巧 1:计算属性名

不要这样写 ❌ :

const user = { name: "Alice", age: 30 };
const product = { id: 123, price: 49.99 };

console.log("user", user);
console.log("product", product);

现在这样写 ✅ :

console.log({ user, product });

使用 ES6 简写对象语法会将你的变量包装在一个对象中,这样你可以在控制台中立即看到变量名和它的值。当你有 20 个日志时,不用再猜测哪个日志对应哪个变量。

console.log 输出示例

2. 技巧 2:console.table()

当你处理对象数组时,console.log 几乎毫无用处。

试试这个 ✅:

const users = [
  { name: "Alice", age: 30, role: "Admin" },
  { name: "Bob", age: 25, role: "User" },
  { name: "Charlie", age: 35, role: "Moderator" },
];

console.table(users);

这会在浏览器控制台中渲染一个漂亮的、可排序的表格。

你可以点击列标题进行排序,它比嵌套对象更易读。

console.table 输出示例

3. 技巧 3:console.trace()

当你发现一个函数被多处调用,却不知道具体执行路径时:

function processPayment(amount) {
  function innerFn() {
    console.trace("Payment processing started");
  }

  innerFn();
}

processPayment(20);

console.trace() 会打印完整的调用堆栈,向你展示代码到达该点的确切路径。

当调试一个可能从 5 个不同地方调用的函数时,这很有用。

console.trace 输出示例

4. 技巧 4:条件断点 console.assert()

不要这样写 ❌ :

if (user.age < 18) {
  console.log("Underage user detected!");
}

现在这样写 ✅ :

console.assert(user.age >= 18, "Underage user detected!", user);

只有当断言失败(条件为 false)时,它才会记录日志。

代码更简洁,控制台噪音更少,而且它会自动包含实际数据。

console.assert 输出示例

5. 技巧 5:性能监控器 console.time()

想知道操作花了多少时间,这样写:

console.time("API Call");

fetch("https://api.example.com/data")
  .then((response) => response.json())
  .then((data) => {
    console.timeEnd("API Call");
    return data;
  });

console.timeEnd("API Call");

这能告诉你 console.time()console.timeEnd() 之间经过了多少毫秒。我经常用它来比较不同的实现或寻找性能瓶颈。

输出:

API Call: 342.87ms

6. 技巧 6:样式化日志

让你重要的日志无法被忽视:

console.log("%c CRITICAL ERROR", "color: red; font-size: 20px; font-weight: bold; background: yellow; padding: 10px;");

你可以使用 %c 为控制台日志添加 CSS 样式。这非常适合:

  • 需要立即关注的错误状态

  • 开发中的成功消息

  • 分隔复杂的调试输出

样式化控制台输出示例

7. 技巧 7:分组整理 console.group()

调试信息太多太乱?你可以将它们分组:

console.group("User Authentication");
console.log("Checking credentials...");
console.log("Token:", token);
console.log("Validating...");
console.groupEnd();

console.group("API Response");
console.log("Status:", response.status);
console.log("Data:", response.data);
console.groupEnd();

这会在控制台中创建可折叠的分组,让你在大量调试输出中导航变得更加容易。

如果希望分组默认收起,可以使用 console.groupCollapsed()

console.group 输出示例

8. 技巧 8:对象深度探索 console.dir()

对于 DOM 元素或具有特殊属性的对象:

const element = document.querySelector("#myButton");

console.log(element); // 显示 HTML 结构
console.dir(element); // 显示对象的属性和方法

console.dir() 显示对象属性的交互式列表,

当你想要检查方法和属性而不是 HTML 结构时,这特别适用于 DOM 元素。

console.dir 输出示例

9. 技巧 9:日志级别

别再所有事情都用 console.log()

JavaScript 给你不同的日志级别是有原因的:

console.log("Regular information"); // 普通信息
console.info("ℹ️ User logged in"); // 信息提示
console.warn("⚠️ API rate limit at 80%"); // 警告
console.error("❌ Payment failed"); // 错误
console.debug(" Variable state:", x); // 调试信息

现代浏览器的 DevTools 允许你按日志级别过滤。

在生产环境调试时,你可以隐藏所有的 console.logconsole.debug 语句,只查看警告和错误。

这样能让关键问题在大量的调试输出中不会被忽略。

最后

正确的调试技巧可以为你节省数小时的试错时间。

掌握这些工具,你将减少添加日志的时间,增加实际修复 bug 的时间。

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

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

flex: 1 vs flex: auto 最通俗的解释

作者 我叫蒙奇
2026年3月12日 17:19

flex: 1 vs flex: auto 最通俗的解释

一句话核心区别

  • flex: 1无视内容宽度,强制等分
  • flex: auto尊重内容宽度,按需分配

1. 用实际例子理解

基础代码

<div style="display: flex; width: 600px;">
  <div class="item1"></div>
  <div class="item2">这是一个很长的内容</div>
  <div class="item3">中等</div>
</div>

情况1:flex: 1

.item1, .item2, .item3 {
  flex: 1;
}

结果:三个项目严格等分,各占200px

[    短    ][这是一个很长的内容][   中等   ]
   200px        200px          200px
  • 不管内容多长多短,强制三等分
  • 长内容可能会被截断或换行

情况2:flex: auto

.item1, .item2, .item3 {
  flex: auto;
}

结果:根据内容长度分配空间

[  短  ][这是一个很长的内容][ 中等 ]
  ~150px        ~300px      ~150px
  • 内容长的项目占据更多空间
  • 内容短的项目占据较少空间

2. 本质区别是 flex-basis

属性 flex: 1 flex: auto
flex-grow 1 1
flex-shrink 1 1
flex-basis 0% auto

关键就在 flex-basis

  • flex-basis: 0%:以0为基准,忽略内容宽度
  • flex-basis: auto:以内容宽度为基准

3. 对比表格

特性 flex: 1 flex: auto
基准大小 0(忽略内容) 内容本身大小
分配逻辑 剩余空间按比例分 总空间按比例分
内容影响 不影响宽度 影响初始宽度
典型效果 严格等分 内容多的占的多
适用场景 导航栏、等分卡片 自适应列表、评论区

4. 再看几个对比案例

案例1:空内容 vs 有内容

<div style="display: flex; width: 400px;">
  <div style="flex: 1">A</div>        <!-- 约133px -->
  <div style="flex: 1">BBBBBBBB</div> <!-- 约133px -->
  <div style="flex: 1">C</div>        <!-- 约133px -->
</div>

<div style="display: flex; width: 400px;">
  <div style="flex: auto">A</div>        <!-- 约50px -->
  <div style="flex: auto">BBBBBBBB</div> <!-- 约300px -->
  <div style="flex: auto">C</div>        <!-- 约50px -->
</div>

案例2:固定宽度内容

<div style="display: flex; width: 500px;">
  <!-- 图片固定宽度100px -->
  <div style="flex: 1"><img src="a.jpg" width="100"></div>
  <div style="flex: 1">文字内容</div>
  <div style="flex: 1">文字内容</div>
</div>
<!-- 三个项目仍然等分,图片被压缩 -->

<div style="display: flex; width: 500px;">
  <div style="flex: auto"><img src="a.jpg" width="100"></div>
  <div style="flex: auto">文字内容</div>
  <div style="flex: auto">文字内容</div>
</div>
<!-- 图片项目保留100px,其他项目分剩余空间 -->

5. 实际应用场景

什么时候用 flex: 1

/* 导航菜单 - 希望严格等分 */
.nav-menu {
  display: flex;
}
.nav-item {
  flex: 1;  /* 不管文字多长,按钮一样宽 */
  text-align: center;
}

什么时候用 flex: auto

/* 评论区 - 希望内容多的占更多空间 */
.comment-list {
  display: flex;
}
.comment {
  flex: auto;  /* 长的评论占据更宽区域 */
  margin: 0 10px;
}

6. 计算方式的差异

flex: 1 的计算

总宽度 = 600px
flex-basis = 0
剩余空间 = 600 - 0 - 0 - 0 = 600px

项目1 = 0 + 600 × (1/3) = 200px
项目2 = 0 + 600 × (1/3) = 200px
项目3 = 0 + 600 × (1/3) = 200px

flex: auto 的计算

总宽度 = 600px
内容宽度 = 50px + 200px + 50px = 300px
剩余空间 = 600 - 300 = 300px

项目1 = 50 + 300 × (1/3) = 150px
项目2 = 200 + 300 × (1/3) = 300px
项目3 = 50 + 300 × (1/3) = 150px

7. 调试技巧

在浏览器开发者工具中查看:

  1. 选中flex容器
  2. 查看每个项目的实际宽度
  3. 观察内容长度对宽度的影响
/* 添加边框方便观察 */
.item {
  border: 1px solid red;
  overflow: auto;  /* 防止内容溢出影响观察 */
}

8. 记忆口诀

  • flex: 1 = 平均主义:不管能力(内容)大小,大家都一样
  • flex: auto = 按劳分配:能力(内容)大的占的多

总结

场景 flex: 1 flex: auto
导航菜单
等分卡片
评论区
标签列表
不希望内容影响宽度
希望内容决定初始宽度

最简记忆

  • 想要严格等分flex: 1
  • 想要内容自适应flex: auto

xx.d.ts 文件有什么用,为什么不引入都能生效?

作者 兆子龙
2026年3月12日 17:13

一、从一个现象说起

你有没有遇到过这种情况:

// 没有任何 import
const app = express();  // ✅ 类型正常
const router = Router();  // ✅ 类型正常

// 但是这些类型是哪来的?

打开 node_modules/@types/express/index.d.ts,发现:

declare function express(): Express.Application;
declare namespace Express {
  interface Application {}
}

疑问: 1. 为什么不需要 import 就能用? 2. declare 关键字是什么意思? 3. .d.ts 文件是如何工作的?

今天就来彻底搞懂这些问题。

二、.d.ts 文件是什么

2.1 定义

.d.ts 文件是 TypeScript 的类型声明文件(Type Declaration File),用于描述 JavaScript 代码的类型信息。

作用: - 为 JavaScript 库提供类型定义 - 让 TypeScript 理解 JavaScript 代码 - 提供代码提示和类型检查

2.2 为什么需要 .d.ts?

JavaScript 本身没有类型信息:

// math.js
export function add(a, b) {
  return a + b;
}

TypeScript 不知道 add 的参数和返回值类型:

import { add } from './math';
add(1, 2);  // ❌ TypeScript 不知道类型

解决方案:创建 .d.ts 文件

// math.d.ts
export function add(a: number, b: number): number;

现在 TypeScript 就知道类型了:

import { add } from './math';
add(1, 2);  // ✅ 类型正确
add('1', '2');  // ❌ 类型错误

三、declare 关键字

3.1 declare 的作用

declare 告诉 TypeScript:「这个东西在运行时存在,但我只是声明它的类型,不提供实现」。

// 声明全局变量
declare const API_URL: string;

// 声明全局函数
declare function fetchData(url: string): Promise<any>;

// 声明全局类
declare class User {
  name: string;
  age: number;
}

使用时不需要 import

// 直接使用,TypeScript 知道类型
console.log(API_URL);  // ✅
fetchData('/api/users');  // ✅
const user = new User();  // ✅

3.2 declare 的场景

场景 1:全局变量

// global.d.ts
declare const __DEV__: boolean;
declare const process: {
  env: {
    NODE_ENV: string;
  };
};

// 使用
if (__DEV__) {
  console.log('Development mode');
}

场景 2:第三方库

// jquery.d.ts
declare const $: {
  (selector: string): any;
  ajax(options: any): any;
};

// 使用
$('#app').hide();
$.ajax({ url: '/api' });

场景 3:模块扩展

// express.d.ts
declare namespace Express {
  interface Request {
    user?: User;
  }
}

// 使用
app.get('/', (req, res) => {
  console.log(req.user);  // ✅ TypeScript 知道 user 属性
});

四、为什么不需要 import?

4.1 全局声明 vs 模块声明

.d.ts 文件有两种模式

模式 1:全局声明(没有 import/export)

// global.d.ts
declare const API_URL: string;
declare function fetchData(url: string): Promise<any>;

这些声明是全局的,不需要 import 就能用。

模式 2:模块声明(有 import/export)

// types.d.ts
export interface User {
  name: string;
  age: number;
}

export function getUser(id: string): Promise<User>;

这些声明需要 import 才能用:

import { User, getUser } from './types';

4.2 TypeScript 如何找到 .d.ts 文件?

TypeScript 会自动查找 .d.ts 文件:

查找顺序

1. 项目根目录*.d.ts 2. src 目录src/**/*.d.ts 3. node_modules/@typesnode_modules/@types/*/index.d.ts 4. tsconfig.json 的 types:指定的类型包

// tsconfig.json
{
  "compilerOptions": {
    "types": ["node", "jest", "express"]
  }
}

4.3 自动包含的 .d.ts 文件

TypeScript 会自动包含:

project/
├── src/
│   ├── index.ts
│   └── types.d.ts        # ✅ 自动包含
├── global.d.ts           # ✅ 自动包含
└── node_modules/
    └── @types/
        ├── node/         # ✅ 自动包含
        └── express/      # ✅ 自动包含

不需要手动 import,TypeScript 会自动加载。

五、实战案例

5.1 为第三方库添加类型

假设你用了一个没有类型定义的库:

// awesome-lib.js
export function doSomething(value) {
  return value * 2;
}

创建类型声明:

// awesome-lib.d.ts
declare module 'awesome-lib' {
  export function doSomething(value: number): number;
}

现在可以安全使用:

import { doSomething } from 'awesome-lib';
doSomething(5);  // ✅ 类型正确
doSomething('5');  // ❌ 类型错误

5.2 扩展全局对象

// global.d.ts
declare global {
  interface Window {
    myApp: {
      version: string;
      init(): void;
    };
  }
}

export {};  // 让文件成为模块

使用:

window.myApp.version;  // ✅
window.myApp.init();   // ✅

5.3 环境变量类型

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: 'development' | 'production' | 'test';
    API_URL: string;
    API_KEY: string;
  }
}

使用:

const env = process.env.NODE_ENV;  // ✅ 类型是 'development' | 'production' | 'test'
const url = process.env.API_URL;   // ✅ 类型是 string

5.4 React 组件 Props

// components.d.ts
declare namespace JSX {
  interface IntrinsicElements {
    'my-component': {
      value: string;
      onChange: (value: string) => void;
    };
  }
}

使用:

<my-component value="hello" onChange={(v) => console.log(v)} />

六、常见问题

6.1 .d.ts 文件不生效?

原因 1:文件不在 TypeScript 的查找路径

// tsconfig.json
{
  "include": ["src/**/*"],  // 只包含 src 目录
  "exclude": ["node_modules"]
}

解决:把 .d.ts 文件放在 src 目录下,或修改 include

原因 2:文件有 import/export,变成了模块

// global.d.ts
import { Something } from 'somewhere';  // ❌ 变成模块了

declare const API_URL: string;  // 不再是全局声明

解决:使用 declare global

import { Something } from 'somewhere';

declare global {
  const API_URL: string;
}

6.2 如何调试类型问题?

// 查看类型
type Test = typeof API_URL;  // 鼠标悬停查看

// 强制类型检查
const _check: string = API_URL;  // 如果类型不对会报错

6.3 .d.ts 和 .ts 的区别?

特性 .ts .d.ts
包含实现
包含类型
编译成 .js
自动全局 ✅(无 import/export 时)

七、最佳实践

7.1 组织 .d.ts 文件

project/
├── src/
│   ├── types/
│   │   ├── global.d.ts      # 全局类型
│   │   ├── modules.d.ts     # 模块扩展
│   │   └── env.d.ts         # 环境变量
│   └── index.ts
└── tsconfig.json

7.2 命名规范

// ✅ 好的命名
global.d.ts
env.d.ts
express.d.ts

// ❌ 不好的命名
types.d.ts  // 太泛化
index.d.ts  // 不清楚内容

7.3 注释文档

/**
 * 全局 API 配置
 * @example
 * ```ts
 * console.log(API_URL);  // 'https://api.example.com'
 * ```
 */
declare const API_URL: string;

八、总结

.d.ts 文件的核心概念

1. 类型声明文件:只有类型,没有实现 2. declare 关键字:声明类型,不提供实现 3. 全局 vs 模块:无 import/export 是全局,有则是模块 4. 自动加载:TypeScript 自动查找并加载

为什么不需要 import?

- 全局声明的 .d.ts 文件会被 TypeScript 自动加载 - TypeScript 会扫描项目和 node_modules/@types - 全局声明对整个项目可见

使用场景

- 为 JavaScript 库添加类型 - 声明全局变量和函数 - 扩展第三方库的类型 - 定义环境变量类型

最佳实践

- 全局类型放在 global.d.ts - 模块类型使用 export - 添加注释文档 - 合理组织文件结构

如果这篇文章对你有帮助,欢迎点赞收藏。

万字解析 OpenClaw 源码架构:从入门到精通

作者 兆子龙
2026年3月12日 17:10

一、OpenClaw 项目概览

OpenClaw 是一个现代化的 Web 应用框架,专注于提供高性能、可扩展的全栈解决方案。

核心特点: - 全栈 TypeScript,类型安全 - Monorepo 架构,模块化设计 - 插件化系统,易于扩展 - 高性能运行时 - 完善的开发工具链

二、项目结构深度解析

2.1 Monorepo 架构

openclaw/
├── packages/              # 核心包
│   ├── core/             # 框架核心
│   ├── cli/              # 命令行工具
│   ├── server/           # 服务端
│   ├── client/           # 客户端
│   ├── router/           # 路由系统
│   ├── state/            # 状态管理
│   └── utils/            # 工具库
├── examples/             # 示例项目
├── docs/                 # 文档
└── scripts/              # 构建脚本

为什么选择 Monorepo?

1. 代码共享:包之间直接引用,无需发布 2. 统一版本:依赖版本一致 3. 原子提交:跨包修改一次提交 4. 统一工具链:共享配置

工具选择: - pnpm:快速、节省空间 - Turborepo:增量构建

// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

2.2 核心包架构

@openclaw/core

packages/core/
├── src/
│   ├── app/              # 应用实例
│   ├── middleware/       # 中间件
│   ├── plugin/           # 插件系统
│   └── lifecycle/        # 生命周期
└── types/                # 类型定义

三、核心模块实现

3.1 Application 类

export class Application {
  private plugins: Plugin[] = [];
  private middleware: Middleware[] = [];

  use(plugin: Plugin): this {
    this.plugins.push(plugin);
    plugin.install(this);
    return this;
  }

  middleware(fn: MiddlewareFunction): this {
    this.middleware.push(fn);
    return this;
  }

  async start(): Promise<void> {
    await this.runLifecycle('beforeStart');
    const composed = compose(this.middleware);
    await this.server.listen(this.options.port);
    await this.runLifecycle('afterStart');
  }
}

3.2 中间件系统(洋葱模型)

export function compose(middleware: Middleware[]): ComposedMiddleware {
  return function (context: Context, next?: Next) {
    let index = -1;

    function dispatch(i: number): Promise<void> {
      if (i <= index) {
        throw new Error('next() called multiple times');
      }
      index = i;
      const fn = middleware[i];
      if (!fn) return Promise.resolve();
      
      return Promise.resolve(fn(context, () => dispatch(i + 1)));
    }

    return dispatch(0);
  };
}

使用示例

app.middleware(async (ctx, next) => {
  console.log('Before');
  await next();
  console.log('After');
});

3.3 插件系统

export interface Plugin {
  name: string;
  install(app: Application): void;
  beforeStart?(context: Context): Promise<void>;
  afterStart?(context: Context): Promise<void>;
}

// 数据库插件示例
export const DatabasePlugin: Plugin = {
  name: 'database',
  
  install(app) {
    app.context.db = createDatabase();
  },
  
  async beforeStart(ctx) {
    await ctx.db.connect();
  }
};

四、路由系统设计

4.1 路由匹配

export class Router {
  private routes: Route[] = [];

  get(path: string, handler: RouteHandler): this {
    return this.register('GET', path, handler);
  }

  private register(method: string, path: string, handler: RouteHandler) {
    this.routes.push({
      method,
      path,
      handler,
      regex: pathToRegex(path)
    });
    return this;
  }

  match(method: string, path: string): RouteMatch | null {
    for (const route of this.routes) {
      if (route.method !== method) continue;
      const match = path.match(route.regex);
      if (match) return { route, params: extractParams(match) };
    }
    return null;
  }
}

路径转正则

function pathToRegex(path: string): RegExp {
  // /users/:id -> /users/([^/]+)
  const pattern = path
    .replace(/\//g, '\\/')
    .replace(/:(\w+)/g, '([^/]+)');
  return new RegExp(`^${pattern}$`);
}

4.2 嵌套路由

const apiRouter = new Router();
apiRouter.get('/users', getUsersHandler);

const app = new Application();
app.middleware(
  new Router().use('/api', apiRouter).middleware()
);
// 结果:GET /api/users

五、状态管理

5.1 Store 实现

export class Store<T> {
  private state: T;
  private listeners: Set<Listener<T>> = new Set();

  getState(): T {
    return this.state;
  }

  setState(updater: Updater<T>): void {
    const prevState = this.state;
    const nextState = typeof updater === 'function'
      ? updater(prevState)
      : updater;

    if (prevState === nextState) return;

    this.state = nextState;
    this.listeners.forEach(listener => {
      listener(nextState, prevState);
    });
  }

  subscribe(listener: Listener<T>): Unsubscribe {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
}

5.2 选择器优化

export function createSelector<T, R>(
  selector: (state: T) => R
): Selector<T, R> {
  let lastState: T;
  let lastResult: R;

  return (state: T): R => {
    if (state === lastState) return lastResult;
    const result = selector(state);
    lastState = state;
    lastResult = result;
    return result;
  };
}

六、构建系统

6.1 Turborepo 配置

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "cache": true
    },
    "test": {
      "dependsOn": ["build"],
      "cache": true
    }
  }
}

增量构建: - 只构建变化的包 - 缓存构建结果 - 并行执行任务

6.2 TypeScript 配置

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "composite": true,
    "declaration": true
  },
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/router" }
  ]
}

Project References: - 加快编译速度 - 增量编译 - 更好的类型检查

七、设计模式应用

7.1 工厂模式

export function createApplication(options: ApplicationOptions): Application {
  const app = new Application(options);
  
  // 注册默认插件
  app.use(LoggerPlugin);
  app.use(ErrorHandlerPlugin);
  
  return app;
}

7.2 观察者模式

// Store 的订阅机制
store.subscribe((state) => {
  console.log('State changed:', state);
});

7.3 责任链模式

// 中间件的洋葱模型
app.middleware(middleware1);
app.middleware(middleware2);
app.middleware(middleware3);

7.4 策略模式

// 路由匹配策略
interface MatchStrategy {
  match(path: string): boolean;
}

class ExactMatch implements MatchStrategy {
  match(path: string): boolean {
    return path === this.pattern;
  }
}

class RegexMatch implements MatchStrategy {
  match(path: string): boolean {
    return this.regex.test(path);
  }
}

八、性能优化

8.1 缓存策略

class CacheMiddleware {
  private cache = new Map<string, any>();

  middleware(): Middleware {
    return async (ctx, next) => {
      const key = ctx.request.url;
      
      if (this.cache.has(key)) {
        ctx.json(this.cache.get(key));
        return;
      }

      await next();

      if (ctx.response.status === 200) {
        this.cache.set(key, ctx.response.body);
      }
    };
  }
}

8.2 懒加载

// 路由懒加载
router.get('/admin', async (ctx) => {
  const { AdminController } = await import('./controllers/admin');
  return new AdminController().handle(ctx);
});

8.3 连接池

class DatabasePool {
  private pool: Connection[] = [];
  private maxSize = 10;

  async getConnection(): Promise<Connection> {
    if (this.pool.length > 0) {
      return this.pool.pop()!;
    }
    return await this.createConnection();
  }

  release(conn: Connection): void {
    if (this.pool.length < this.maxSize) {
      this.pool.push(conn);
    } else {
      conn.close();
    }
  }
}

九、测试策略

9.1 单元测试

describe('Router', () => {
  it('should match route', () => {
    const router = new Router();
    router.get('/users/:id', handler);

    const match = router.match('GET', '/users/123');
    expect(match).toBeDefined();
    expect(match.params.id).toBe('123');
  });
});

9.2 集成测试

describe('Application', () => {
  it('should handle request', async () => {
    const app = createApplication();
    app.middleware(async (ctx) => {
      ctx.json({ message: 'Hello' });
    });

    const response = await request(app)
      .get('/')
      .expect(200);

    expect(response.body.message).toBe('Hello');
  });
});

十、最佳实践

10.1 错误处理

app.middleware(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.response.status = err.status || 500;
    ctx.json({
      error: err.message,
      stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
    });
  }
});

10.2 日志记录

app.middleware(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.request.method} ${ctx.request.url} - ${ms}ms`);
});

10.3 安全防护

// CORS
app.middleware(async (ctx, next) => {
  ctx.response.setHeader('Access-Control-Allow-Origin', '*');
  await next();
});

// Rate Limiting
const limiter = new RateLimiter({ max: 100, window: 60000 });
app.middleware(limiter.middleware());

十一、总结

OpenClaw 的架构设计体现了现代 Web 框架的最佳实践:

核心特点: - Monorepo 架构,模块化设计 - 插件化系统,易于扩展 - 中间件洋葱模型,灵活组合 - TypeScript 类型安全 - 高性能运行时

设计模式: - 工厂模式:创建应用实例 - 观察者模式:状态订阅 - 责任链模式:中间件系统 - 策略模式:路由匹配

性能优化: - 缓存策略 - 懒加载 - 连接池 - 增量构建

通过深入理解 OpenClaw 的源码架构,你可以学到: - 如何设计一个可扩展的框架 - 如何实现高性能的运行时 - 如何组织大型项目的代码结构 - 如何应用设计模式解决实际问题

如果这篇文章对你有帮助,欢迎点赞收藏。

深入理解Vue中的插槽:概念、原理与应用

作者 左夕
2026年3月12日 16:58

在Vue.js的开发中,我们经常会遇到这样一个场景:需要创建一个可复用的组件,但组件的某些部分需要根据具体使用场景展示不同的内容。这时候,插槽(Slot)就成为了我们最得力的工具。插槽是Vue实现内容分发的一种机制,它允许我们在调用组件时向组件内部传递自定义内容,从而让组件变得更加灵活和可复用。

什么是插槽

想象一下,我们生活中常见的卡片组件。一张卡片通常有固定的结构——边框、背景色、圆角,但卡片内部的内容却千变万化,可能是文字、图片,也可能是按钮或者更复杂的组合。

如果我们要用Vue实现这样的卡片组件,传统的props传递方式会显得力不从心。props适合传递数据,但不适合传递复杂的HTML结构。让我们来看一个对比:

// 使用props传递HTML内容(不推荐)
<card content="<h2>标题</h2><p>内容</p>" />

// 使用插槽传递内容(推荐)
<card>
  <h2>标题</h2>
  <p>内容</p>
</card>

插槽本质上是一个占位符,它在子组件中预留了一个位置,当父组件使用这个子组件时,可以向这个位置填充自定义的模板内容。这种设计模式被称为"内容分发",它遵循了开放封闭原则——组件对扩展开放,对修改封闭。

插槽的核心作用

1. 实现内容自定义

通过插槽,我们可以创建出具有固定框架但内部内容可变的组件,大大提升组件的复用性。一个写好插槽的卡片组件,可以在项目中的任何地方使用,而每次使用时都可以填充完全不同的内容。

2. 促进职责分离

父组件负责业务逻辑和内容组织,子组件负责结构和样式表现,两者通过插槽进行优雅的协作。这种分离让代码更容易理解和维护。

3. 提供布局灵活性

特别是具名插槽的出现,让组件可以定义多个内容区域,使用者可以精确控制内容填充的位置,实现复杂的布局需求。

4. 实现反向数据流

作用域插槽更进一步,允许子组件向父组件传递数据,让父组件可以根据子组件的数据来渲染内容,实现了双向的交互。

插槽的三种类型及使用方式

1. 默认插槽

默认插槽是最基本的形式,当组件中只使用一个<slot>标签时,所有传递给组件的内容都会显示在这个位置。

我们先创建一个基础的卡片组件:

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      卡片标题
    </div>
    <div class="card-body">
      <!-- 插槽占位符,父组件传递的内容将显示在这里 -->
      <slot></slot>
    </div>
    <div class="card-footer">
      底部信息
    </div>
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  margin: 10px 0;
}
.card-header {
  font-weight: bold;
  border-bottom: 1px solid #ddd;
  padding-bottom: 5px;
}
.card-body {
  padding: 10px 0;
}
.card-footer {
  border-top: 1px solid #ddd;
  padding-top: 5px;
  color: #666;
}
</style>

在父组件中使用这个卡片:

<!-- Parent.vue -->
<template>
  <div>
    <card>
      <!-- 这里的内容会填充到子组件的slot位置 -->
      <p>这是卡片的主体内容</p>
      <button>点击查看详情</button>
    </card>
    
    <card>
      <ul>
        <li>列表项1</li>
        <li>列表项2</li>
        <li>列表项3</li>
      </ul>
    </card>
  </div>
</template>

<script>
import Card from './Card.vue'

export default {
  components: {
    Card
  }
}
</script>

如果希望插槽有默认内容,可以在<slot>标签内设置:

<template>
  <div class="card">
    <slot>
      <!-- 这是默认内容,当父组件没有传递内容时显示 -->
      <p>暂无内容,请稍后查看</p>
    </slot>
  </div>
</template>

2. 具名插槽

当一个组件需要多个内容区域时,就需要使用具名插槽。通过name属性可以区分不同的插槽。

创建一个带有多个区域的布局组件:

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header class="header">
      <!-- 头部插槽 -->
      <slot name="header"></slot>
    </header>
    
    <main class="main">
      <!-- 默认插槽,不设置name的插槽默认name为"default" -->
      <slot></slot>
    </main>
    
    <aside class="sidebar">
      <!-- 侧边栏插槽 -->
      <slot name="sidebar"></slot>
    </aside>
    
    <footer class="footer">
      <!-- 底部插槽,带默认内容 -->
      <slot name="footer">
        <p>版权所有 © 2024</p>
      </slot>
    </footer>
  </div>
</template>

<style scoped>
.layout {
  display: grid;
  grid-template-areas: 
    "header header"
    "sidebar main"
    "footer footer";
  grid-template-columns: 200px 1fr;
  gap: 20px;
}
.header { grid-area: header; }
.main { grid-area: main; }
.sidebar { grid-area: sidebar; }
.footer { grid-area: footer; }
</style>

使用具名插槽:

<!-- App.vue -->
<template>
  <layout>
    <!-- v-slot指令指定内容放入哪个插槽,可以简写为# -->
    <template v-slot:header>
      <h1>网站标题</h1>
      <nav>
        <a href="#">首页</a>
        <a href="#">关于</a>
        <a href="#">联系</a>
      </nav>
    </template>

    <!-- 默认插槽的内容 -->
    <article>
      <h2>文章标题</h2>
      <p>这是文章的主要内容...</p>
    </article>

    <!-- 使用简写形式 -->
    <template #sidebar>
      <ul>
        <li>分类1</li>
        <li>分类2</li>
        <li>分类3</li>
      </ul>
    </template>

    <!-- 覆盖默认的底部内容 -->
    <template #footer>
      <p>自定义底部信息 | 备案号XXX</p>
    </template>
  </layout>
</template>

3. 作用域插槽

作用域插槽允许子组件将数据传递给父组件的插槽内容。这在需要根据子组件内部状态来定制渲染内容时特别有用。

创建一个待办事项列表组件:

<!-- TodoList.vue -->
<template>
  <div class="todo-list">
    <h3>待办事项列表</h3>
    <ul>
      <li v-for="item in items" :key="item.id" class="todo-item">
        <!-- 
          通过v-bind将item数据绑定到插槽上
          这样父组件就可以访问到item对象
        -->
        <slot :todo="item" :index="index">
          <!-- 默认的渲染方式 -->
          <span>{{ item.text }}</span>
          <span :class="{ completed: item.done }">
            {{ item.done ? '已完成' : '进行中' }}
          </span>
        </slot>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true
    }
  }
}
</script>

在父组件中使用作用域插槽自定义渲染:

<!-- Parent.vue -->
<template>
  <div>
    <h2>默认渲染方式</h2>
    <todo-list :items="todos" />

    <h2>自定义渲染方式</h2>
    <todo-list :items="todos">
      <!-- 使用v-slot接收子组件传递的数据,可以解构 -->
      <template v-slot:default="{ todo, index }">
        <div class="custom-todo">
          <input type="checkbox" v-model="todo.done">
          <span :style="{ textDecoration: todo.done ? 'line-through' : 'none' }">
            {{ index + 1 }}. {{ todo.text }}
          </span>
          <button @click="deleteTodo(todo.id)">删除</button>
        </div>
      </template>
    </todo-list>
  </div>
</template>

<script>
import TodoList from './TodoList.vue'

export default {
  components: {
    TodoList
  },
  data() {
    return {
      todos: [
        { id: 1, text: '学习Vue插槽', done: false },
        { id: 2, text: '写博客文章', done: true },
        { id: 3, text: '复习JavaScript', done: false }
      ]
    }
  },
  methods: {
    deleteTodo(id) {
      this.todos = this.todos.filter(todo => todo.id !== id)
    }
  }
}
</script>

作用域插槽的另一种常见用法是用于列表组件的列自定义。以表格组件为例:

<!-- DataTable.vue -->
<template>
  <table>
    <thead>
      <tr>
        <th v-for="column in columns" :key="column.key">
          {{ column.title }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, rowIndex) in data" :key="rowIndex">
        <td v-for="column in columns" :key="column.key">
          <!-- 如果该列定义了自定义渲染插槽,则使用插槽 -->
          <template v-if="column.slotName">
            <slot :name="column.slotName" :row="row" :column="column">
              {{ row[column.key] }}
            </slot>
          </template>
          <template v-else>
            {{ row[column.key] }}
          </template>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script>
export default {
  props: {
    columns: Array,
    data: Array
  }
}
</script>

使用表格组件:

<template>
  <data-table :columns="columns" :data="users">
    <!-- 自定义状态列的渲染 -->
    <template #status="{ row }">
      <span :class="['status-badge', row.status]">
        {{ row.status === 'active' ? '启用' : '禁用' }}
      </span>
    </template>
    
    <!-- 自定义操作列的渲染 -->
    <template #actions="{ row }">
      <button @click="editUser(row)">编辑</button>
      <button @click="deleteUser(row)">删除</button>
    </template>
  </data-table>
</template>

插槽的工作原理

理解插槽的工作原理,需要从Vue的编译和渲染过程说起。当Vue编译模板时,它会构建一个虚拟DOM树。在这个过程中,遇到组件标签时,Vue会将该组件实例化,同时处理组件标签内的子节点。

对于普通的HTML元素,子节点会直接作为父节点的children。但对于组件,情况有所不同。组件标签内的内容会被编译为插槽的内容,而组件模板中的<slot>标签则会被编译为插槽的出口。

在渲染阶段,Vue会创建一个渲染函数,这个函数会返回虚拟DOM。当渲染函数执行时,它会解析组件模板中的<slot>标签,并将其替换为父组件传递进来的对应内容。如果父组件没有传递内容,则会渲染插槽中定义的后备内容。

对于作用域插槽,Vue会建立一个从子组件到父组件的数据通道。子组件在渲染插槽时,会将绑定的数据作为参数传递给插槽函数,父组件的插槽内容就可以访问到这些数据。

实际应用场景

场景一:弹窗组件

<!-- Modal.vue -->
<template>
  <div v-if="visible" class="modal-overlay">
    <div class="modal-container">
      <div class="modal-header">
        <slot name="header">
          <h3>{{ title }}</h3>
        </slot>
        <button @click="$emit('close')">×</button>
      </div>
      <div class="modal-body">
        <slot></slot>
      </div>
      <div class="modal-footer">
        <slot name="footer">
          <button @click="$emit('close')">关闭</button>
          <button class="primary" @click="$emit('confirm')">确认</button>
        </slot>
      </div>
    </div>
  </div>
</template>

场景二:列表项的多种展示模式

<template>
  <div>
    <!-- 卡片模式 -->
    <item-list :items="products" mode="card">
      <template #item="{ item }">
        <div class="product-card">
          <img :src="item.image" :alt="item.name">
          <h4>{{ item.name }}</h4>
          <p>¥{{ item.price }}</p>
          <button @click="addToCart(item)">加入购物车</button>
        </div>
      </template>
    </item-list>
    
    <!-- 列表模式 -->
    <item-list :items="products" mode="list">
      <template #item="{ item }">
        <div class="product-row">
          <span>{{ item.name }}</span>
          <span>¥{{ item.price }}</span>
          <input type="number" v-model.number="item.quantity">
        </div>
      </template>
    </item-list>
  </div>
</template>

使用技巧与注意事项

1. 合理设置后备内容

<slot name="loading">
  <div class="loading-spinner">加载中...</div>
</slot>

2. 解构作用域插槽的props

<template #item="{ id, name, price, index }">
  <div>{{ index }}. {{ name }} - {{ price }}</div>
</template>

3. 动态插槽名

<template #[dynamicSlotName]>
  动态插槽内容
</template>

4. 多个插槽的复用

如果多个插槽需要相同的内容,考虑提取为组件:

<template>
  <complex-component>
    <template #header>
      <common-content />
    </template>
    <template #sidebar>
      <common-content />
    </template>
  </complex-component>
</template>

5. 注意事项

  • 插槽内容的作用域:插槽内容无法访问子组件的数据,除非使用作用域插槽
  • 具名插槽的简写:v-slot:header 可以简写为 #header
  • 默认插槽的显式使用:当同时使用默认插槽和具名插槽时,建议显式包裹默认插槽

结语

插槽是Vue组件化设计中不可或缺的一部分,它体现了Vue灵活、渐进的设计理念。通过合理使用插槽,我们可以构建出既强大又灵活的组件库,提高开发效率和代码质量。

从简单的默认插槽,到处理复杂布局的具名插槽,再到实现数据反向流动的作用域插槽,每一种插槽类型都有其特定的应用场景。深入理解这些概念,能让我们在组件设计时做出更合理的决策,编写出更优雅的Vue应用。

在实际项目中,插槽的使用往往能反映出开发者对组件化思想的理解深度。掌握好这个工具,相信你的Vue开发之路会更加顺畅。

JeecgBoot低代码平台 Qiankun 微前端集成指南:主应用配置全流程

2026年3月12日 16:39

JeecgBoot AI专题研究 | JeecgBoot低代码微前端架构落地实践


微前端解决了什么问题?

当 JeecgBoot低代码项目发展到一定规模,单体前端的弊端开始显现:模块耦合严重、构建时间激增、团队协作困难。微前端架构允许将不同业务模块拆分为独立的子应用,各自开发、独立部署,通过 Qiankun 框架在运行时动态组合。

本文聚焦于如何将 JeecgBoot-Vue3 配置为 Qiankun 微前端的主应用(基座),接管路由分发和子应用生命周期管理。

第一步:安装 Qiankun 依赖

在 JeecgBoot低代码主应用项目中安装 Qiankun:

pnpm add qiankun

第二步:配置子应用注册信息

JeecgBoot 已经预置了 Qiankun 集成的代码框架,只需要取消注释并配置三个核心文件:

src/qiankun/apps.ts — 子应用注册表

在这个文件中定义每个微应用的元数据:

const apps = [
  {
    name: 'qiankun-app',          // 子应用唯一标识
    entry: '//localhost:8001',     // 子应用入口地址
    container: '#subapp-viewport', // 挂载容器
    activeRule: '/qiankun-app',    // 路由激活规则
  },
];

src/qiankun/state.ts — 全局状态管理

建立主应用与子应用之间的数据共享通道,用于传递用户信息、权限数据、主题配置等全局状态。

src/qiankun/index.ts — 注册与启动

注册所有子应用并配置生命周期钩子(加载前、挂载后、卸载后等),这是微前端运行时的核心调度逻辑。

第三步:配置环境变量

不同环境下子应用的入口地址不同,需要在环境变量文件中分别配置。

开发环境 .env.development

VITE_APP_SUB_qiankun-app = '//localhost:8001/qiankun-app'

生产环境 .env.production

VITE_APP_SUB_qiankun-app = '[生产域名]/qiankun-app'

同时,在环境变量中开启 Qiankun 全局开关:

VITE_GLOB_APP_OPEN_QIANKUN=true

第四步:子应用侧的适配要求

子应用要接入 JeecgBoot低代码主应用,需要满足以下条件:

1. 配置运行时公共路径

创建 public-path.js 文件,确保子应用在 Qiankun 沙箱环境下能正确加载静态资源:

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

2. 导出生命周期函数

子应用必须导出以下四个生命周期函数供主应用调用:

  • bootstrap() — 初始化,仅在首次加载时调用
  • mount() — 挂载,每次进入子应用时调用
  • unmount() — 卸载,每次离开子应用时调用
  • update() — 更新,主应用传递数据变更时调用

3. 构建输出配置

子应用的 vue.config.js 需要配置 UMD 格式输出,并开启 CORS 跨域头:

module.exports = {
  output: {
    library: 'qiankun-app',
    libraryTarget: 'umd',
  },
  devServer: {
    headers: { 'Access-Control-Allow-Origin': '*' },
  },
};

实践中的注意事项

  • 样式隔离:Qiankun 默认的沙箱机制可以隔离 JS 全局变量,但 CSS 隔离需要额外配置 strictStyleIsolationexperimentalStyleIsolation
  • 路由冲突:主应用和子应用的路由前缀不能重叠,建议每个子应用使用独立的路由命名空间
  • 通信机制:简单场景使用 Qiankun 内置的 initGlobalState,复杂场景可以引入 EventBus 或状态管理库

总结

JeecgBoot低代码平台对 Qiankun 微前端的集成已经做了大量预置工作,开发者只需取消注释、配置环境变量即可快速启用。微前端架构特别适合多团队协作的大型项目,能够在保持整体统一性的同时给予各业务团队充分的技术自主权。


本文为 JeecgBoot AI 专题研究系列文章。

虚拟 DOM、Diff 算法与 Fiber

作者 lcy453
2026年3月12日 16:30

一、虚拟 DOM 是什么?

一句话:用 JS 对象来描述真实 DOM 的结构,先在内存里算好差异,再最小化更新真实 DOM。

真实 DOM vs 虚拟 DOM

// 真实 DOM(浏览器里的)
<div class="box">
  <h1>标题</h1>
  <p>内容</p>
</div>

// 虚拟 DOM(JS 对象)
{
  type: 'div',
  props: {
    className: 'box',
    children: [
      { type: 'h1', props: { children: '标题' } },
      { type: 'p', props: { children: '内容' } }
    ]
  }
}

为什么要用虚拟 DOM?

操作真实 DOM 很(涉及浏览器重排重绘),而操作 JS 对象很

数据变化
    ↓
生成新的虚拟 DOM 树
    ↓
新旧虚拟 DOM 对比(Diff)
    ↓
找出最小差异
    ↓
只更新变化的真实 DOM(Patch)
方式 做法 性能
直接操作 DOM 数据一变就全量更新 DOM
虚拟 DOM 先算差异,只更新变化的部分 快(大多数场景)

注意:虚拟 DOM 不是"比直接操作 DOM 快",而是在大量更新时,通过批量 + 最小化更新来优化性能。极简场景下,直接操作 DOM 反而更快。

二、Diff 算法

React 用 Diff 算法对比新旧虚拟 DOM 树,找出需要更新的部分。

三个策略(把 O(n³) 降到 O(n))

策略 说明
同层比较 只比较同一层级的节点,不跨层级比较
类型判断 节点类型不同 → 直接销毁旧树,创建新树
Key 标识 同类型的列表元素用 key 来标识,精准匹配

策略一:同层比较

旧树:         新树:
  A              A
 / \            / \
B   C          B   D    ← 只比较同层:发现 C→D,替换
|               |
D               E

React 只会比较 A-A、B-B、C-D... 不会跨层去比较。如果把节点从一棵子树移到另一棵,React 会销毁+重建,而不是移动。

策略二:类型判断

// 旧          新
<div>         <span>
  <Counter/>    <Counter/>
</div>        </span>

// div → span:类型不同 → 整个销毁旧树(包括 Counter),重建新树
// Counter 的 state 会丢失!

策略三:Key 的作用(列表 Diff)

// 没有 key:插入一项,React 不知道哪个是新的,可能全部更新
// 旧:[A, B, C]
// 新:[A, X, B, C]
// React:A不变,B→X(错),C→B(错),新增C(错)—— 大量无效更新

// 有 key:React 能精准识别
// 旧:[A:1, B:2, C:3]
// 新:[A:1, X:4, B:2, C:3]
// React:A不变,新增X,B不变,C不变 —— 只做一次插入 ✅

Key 的最佳实践

// ❌ 用 index 做 key(增删排序时出问题)
list.map((item, i) => <li key={i}>{item.name}</li>)

// ❌ 用随机数做 key(每次渲染都变,等于没加)
list.map(item => <li key={Math.random()}>{item.name}</li>)

// ✅ 用唯一且稳定的 id
list.map(item => <li key={item.id}>{item.name}</li>)

三、Fiber 架构

旧架构的问题(React 15)

React 15 使用递归遍历虚拟 DOM 树(Stack Reconciler):

开始 Diff → 递归遍历整棵树 → 全部算完 → 更新 DOM
            ↑ 这个过程不能中断!

问题:如果组件树很大,递归遍历耗时超过 16ms(一帧),浏览器来不及渲染 → 页面卡顿

Fiber 是什么?(React 16+)

一句话:把大任务拆成小任务,每个小任务做完看看有没有更重要的事(比如用户输入),有就先去做,没有就继续。

旧(Stack):一口气干完  ████████████████████████ 卡了!
新(Fiber):分段干      ██ 空 ██ 空 ██ 空 ████    不卡!
                         ↑  ↑  ↑ 检查有没有更高优先级的任务

Fiber 的核心思想

概念 说明
可中断 渲染过程可以暂停,让出主线程给浏览器
可恢复 暂停后可以从断点继续,不用从头开始
优先级调度 高优先级(用户输入)优先处理,低优先级(数据请求后的渲染)延后
增量渲染 一帧只做一部分工作,分多帧完成

Fiber 节点结构

每个组件/元素对应一个 Fiber 节点,通过链表关联:

     App (Fiber)
      ↓ child
    Header (Fiber) → sibling → Main (Fiber) → sibling → Footer (Fiber)
      ↓ child                    ↓ child
    Logo (Fiber)              Content (Fiber)
      ↑ returnreturn
    Header                     Main
指针 指向
child 第一个子节点
sibling 下一个兄弟节点
return 父节点

遍历顺序:深度优先 — child → sibling → return。因为是链表,可以随时暂停,记住当前位置,之后继续。

Fiber 的两个阶段

阶段 名称 特点
Render 阶段 协调(Reconciliation) 计算差异,可中断,不操作 DOM
Commit 阶段 提交 把差异应用到真实 DOM,不可中断,同步执行
Render 阶段(可中断)          Commit 阶段(同步)
━━━━━━━━━━━━━━━━━━━         ━━━━━━━━━━━━━━━━━
遍历 Fiber 树                 更新真实 DOM
对比新旧,标记变化             执行生命周期/useEffect
可以暂停、恢复                 一口气做完,不暂停

四、双缓冲机制(Double Buffering)

React 同时维护两棵 Fiber 树:

作用
current 树 当前屏幕上显示的 UI
workInProgress 树 内存中正在构建的新树
current 树(屏幕上)       workInProgress 树(内存中)
      App                        App'
     / \                        / \
  Header Main               Header Main'
                                    |
                                Content'(有更新)

构建完成后 → workInProgress 变成新的 current(指针切换,瞬间完成)

好处:构建过程中用户看到的始终是完整的旧 UI,不会出现"半成品"。跟显卡双缓冲一个道理。

五、优先级模型(Lanes)

React 18 用 Lane 模型 给任务分优先级,高优先级可以打断低优先级:

优先级 场景 例子
同步(最高) 用户直接交互 打字、点击
连续输入 持续交互 拖拽、滚动
普通 数据更新 请求回来后 setState
过渡 不紧急的更新 useTransition 包裹的更新
空闲(最低) 可延后 offscreen 预渲染

核心思想:用户能感知的操作(输入、点击)必须立即响应,数据渲染可以稍等。

六、高频面试题

Q1:虚拟 DOM 一定比真实 DOM 快吗?

不一定。虚拟 DOM 有创建 JS 对象 + Diff 对比的开销。在以下场景,直接操作 DOM 可能更快:

  • 极简单的 UI(一两个元素)
  • 已知确切的 DOM 操作(不需要 Diff)

虚拟 DOM 的优势在于:在复杂应用中,自动帮你找到最小更新范围,开发者不用手动管理 DOM 更新。

Q2:key 为什么不能用 index?

当列表会增删或排序时,index 会变化,React 的 Diff 会把元素搞混:

旧:[A:0, B:1, C:2]   删除A后
新:[B:0, C:1]         key=0A 和 key=0B 对比 → React 认为 A 变成了 B → 错误复用

用唯一 id 做 key 就不会有这个问题。

Q3:Fiber 和之前的区别?

对比 Stack Reconciler (React 15) Fiber Reconciler (React 16+)
数据结构 递归调用栈 Fiber 链表
是否可中断 不可中断 可中断可恢复
调度 同步,一次性完成 按优先级分时间片
大组件树 可能卡顿 不卡顿

Q4:React 的渲染流程?

setState / props 变化
    ↓
触发调度(Scheduler)→ 按优先级安排任务
    ↓
Render 阶段 → 遍历 Fiber 树,Diff 对比,标记需要更新的节点
    ↓(可中断)
Commit 阶段 → 把标记的更新同步应用到真实 DOM
    ↓
浏览器绘制

Q5:什么是双缓冲?为什么需要?

React 在内存中构建 workInProgress 树,完成后一次性替换 current 树(切换指针)。好处是用户始终看到完整 UI,不会看到渲染到一半的中间状态。

Q6:React 怎么决定哪个更新先执行?

通过 Lane 模型。每个更新会被分配一个 Lane(优先级),Scheduler 按优先级调度。用户输入是最高优先级,useTransition 包裹的更新是低优先级,可以被高优先级打断。

JeecgBoot低代码平台从 WPS 切换到 OnlyOffice 的开发配置指南

2026年3月12日 16:29

JeecgBoot AI专题研究 | JeecgBoot低代码在线文档编辑器切换与配置实践


切换背景

JeecgBoot低代码平台同时支持 WPS 和 OnlyOffice 两种在线文档编辑方案。在实际项目中,你可能因为授权成本、部署方式或功能需求等原因,需要从 WPS 切换到 OnlyOffice。

整个切换过程只需要修改前端环境变量和后端配置文件,无需改动任何业务代码。

前端配置修改

切换操作集中在环境变量文件中(.env.development.env.production),涉及两项关键配置:

1. 修改文档编辑器版本标识

VITE_GLOB_ONLINE_DOCUMENT_VERSION=onlyoffice

将值从 wps 改为 onlyoffice,JeecgBoot低代码前端会自动加载对应的编辑器组件和交互逻辑。

2. 更新代理与域名地址

VITE_PROXY=[["/api","http://192.168.1.100:8080/jeecg-boot"]]
VITE_GLOB_DOMAIN_URL=http://192.168.1.100:8080/jeecg-boot

重要提示:这里不能使用 localhost127.0.0.1。OnlyOffice 运行在 Docker 容器中,回调请求发起方是容器内部的服务,localhost 指向的是容器自身而非你的开发机。必须使用开发机的实际 IP 地址。

后端配置修改

在 JeecgBoot低代码后端的 YAML 配置文件中,需要填写 OnlyOffice 服务的访问地址:

onlyoffice:
  doc-service-url: http://192.168.1.100:9000

确保该地址指向已经部署好的 OnlyOffice DocumentServer 实例。如果尚未部署,请先参考 Docker 安装指南完成 OnlyOffice 的部署。

配置核对清单

完成切换后,对照以下清单确认配置正确:

配置项 检查要点
VITE_GLOB_ONLINE_DOCUMENT_VERSION 值为 onlyoffice
VITE_PROXY IP 地址为实际地址,非 localhost
VITE_GLOB_DOMAIN_URL 同上
后端 OnlyOffice 地址 指向正确的 DocumentServer
OnlyOffice 容器 已开启私有 IP 访问权限

常见问题

编辑器加载失败:检查 OnlyOffice 服务是否正常运行,浏览器访问 http://IP:9000 确认。

文档保存失败:大概率是 IP 地址配置问题,确保前后端和 OnlyOffice 三者之间能互相通过 IP 访问。

跨域报错:检查 JeecgBoot低代码前端的代理配置是否正确,以及后端的 CORS 设置。


总结

JeecgBoot低代码平台的文档编辑器切换设计得非常优雅——通过一个环境变量即可完成前端的编辑器切换,配合后端地址配置,整个过程对业务代码零侵入。唯一需要注意的是 IP 地址的配置,务必避免使用 localhost。


本文为 JeecgBoot AI 专题研究系列文章。

❌
❌