阅读视图

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

LeetCode 热题100 --- 双指针专区

283. 移动零 - 力扣(LeetCode)

题目分析:

题目要求将数组 nums 中所有 0 移动至数组末尾,同时保持其他非零元素的相对顺序不变,并且要求在原数组上进行操作。

核心要求:

  • 0 要移动至数组末尾
  • 非零元素相对位置不变
  • 在原数组上进行操作

解法一(暴力使用数组方法)

遍历数组将其中所有为 0 的数直接使用splice删除并且记录 0 的个数,最后通过push填入“移动”的 0

var moveZeroes = function(nums) {
    let n = 0;
    for(let i = 0; i < nums.length;){
        if(nums[i] == 0){
            n++;
            nums.splice(i, 1);
        } else i++;
    }
    for(let j = 0; j < n; j++){
        nums.push(0);
    }
    return nums;
};

⏱️ 时间复杂度分析

  • splice(i, 1) 的代价
    在数组中间删除一个元素,需要将 i 后面的所有元素向前移动一位
    --> 时间复杂度为 O(k) ,其中 k 是从 i 到末尾的元素个数。

  • 最坏的情况:假设数组中有 z 个 0,且它们分布在前面:

    • 第 1 个 0 被删时,移动 n-1 个元素
    • 第 2 个 0 被删时,移动 n-2 个元素
    • ...
    • 最坏情况总移动次数 ≈ (n-1) + (n-2) + ... + (n-z) ≈ O(n²)

    (例如输入 [0,0,0,...,0,1]

  • push(0)的代价: 是 O(z),可忽略。

最坏时间复杂度:O(n²)

注:

JavaScript 数组在底层通常是连续内存,而splice(i, 1) 会触发 大量元素的内存拷贝

⚠️ 尽量避免在循环中使用 splice 删除元素,尤其是在处理大数组时,性能会急剧下降

解法二(双指针 + 交换)

题目的问题本质上是将所有非零的元素按照原先的相对顺序排在数组的最前方,那么不妨通过一个指针安排非零元素的位置,而另一个指针来查找所有非零的元素并且通过交换来将其放置在正确的位置。

var moveZeroes = function(nums) {
    let j = 0;
    for(let i = 0; i < nums.length; i++) {
        if (nums[i] != 0) {
            let temp = nums[j];
            nums[j] = nums[i];
            nums[i] = temp;
            // [灵茶山艾府] 灵神做法:
            // [nums[j],nums[i]] = [nums[i],nums[j]]; (更简洁、更现代)
            j++;
        }
    }
};

⏱️ 时间复杂度分析

  • 单次遍历
    使用 for 循环从 i = 0i = nums.length - 1 遍历数组一次 → O(n)
  • 交换操作
    每次遇到非零元素时,执行一次交换(通过临时变量或解构赋值),该操作为 O(1)
    由于 i 单调递增、j 不回退,每个元素最多被访问和交换一次 → 总交换开销为 O(n)
  • 总时间复杂度:O(n) + O(n) = O(n)

11. 盛最多水的容器 - 力扣(LeetCode)

题目分析:

题目要求从给定的整数数组 height 中选择两条垂线,使得它们与 x 轴共同构成的容器可以容纳最多的水,并返回该最大水量。

核心要求:

  • 容器不能倾斜
  • 较短的一条决定容器的高度
  • 容器的宽度为索引之差
  • 目标是使容器的面积(水量)最大化

本题本质上是在所有可能的垂线对 (i, j)(其中 i < j)中,寻找使面积

area=(ji)×min(height[i],height[j])\text{area} = (j - i) \times \min(\text{height}[i], \text{height}[j])

最大的组合。

解法(双指针)

题目本意就是要找到两个尽可能远且尽可能高的数据,那么不妨先把两个指针放置最远,再通过逐一减小距离来找到尽可能高的数据来平衡距离的缩减

优化: 由于距离在缩减,所以如果数据还减小的话,那么总量一定减小,所以只能牺牲更小的一边向中心查找是否能找到更高的数据。

var maxArea = function(height) {
    let left = 0, right = height.length - 1;
    // 总面积
    let m = 0;
    
    while(left < right) {
        // 计算当前面积并比较大小
        area = (right - left) * Math.min(height[left], height[right])
        m = Math.max(area, m);
        // 判断哪方更小,哪方移动
        if (height[left] < height[right]) left++;
        else right--;
    }
    return m;
}

15. 三数之和 - 力扣(LeetCode)

题目分析:

题目要求从给定的整数数组 nums 中找出所有下标不重复的三元组,使得三个数的和为 0,并且每组不同。

核心要求:

  • 三元组中的三个数之和必须等于 0
  • 结果中不能包含重复的三元组
  • 三元组内的元素可以按任意顺序排列
  • 每个三元组中的元素必须来自数组中不同的位置(但值可以相同)

本题本质上是在数组中寻找所有满足
nums[i]+nums[j]+nums[k]=0\text{nums}[i] + \text{nums}[j] + \text{nums}[k] = 0 \quad
的三元组,并确保结果集合中无重复。


解法(排序 + 双指针)

暴力枚举需要三重循环,这样时间复杂度将会飙升到 O(n³),效率极其低下,所以我们采用排序 + 固定一个数 + 双指针的优化策略。

关键思想:

  1. 先对数组排序,后续可以通过大小关系移动指针。当到达分界点 0时,后续所有值无论如何增加总值都不会为零(优化点);

  2. 固定第一个数 nums[i] ,将其转化为“在剩余数组中找两数之和为 -nums[i]”的问题;

  3. 使用双指针 left = i+1right = n-1 向中间收缩,根据当前和与目标值的大小关系移动指针;

  4. 跳过重复值

    • nums[i] == nums[i-1],跳过重复;
    • 找到一组解后,继续跳过 leftright 的重复值,防止重复答案。
var threeSum = function(nums) {
    // 先排序:升序排列,使用JS内置的 sort 方法
    // sort() 根据返回值决定 a 和 b 谁排在前面
    // a - b <= 0 ==> 顺序为 a, b
    // a - b >  0 ==> 顺序为 b, a
    nums.sort((a, b) => a - b);
    const res = [];

    // 固定第一个数 nums[i]
    for (let i = 0; i < nums.length - 2; i++) {
        // 最小值已大于 0,后续不可能有解
        if (nums[i] > 0) break;

        // 跳过重复的起始值,避免重复三元组
        if (i > 0 && nums[i] === nums[i - 1]) continue;
        // 双指针
        let left = i + 1;
        let right = nums.length - 1;

        while (left < right) {
            const sum = nums[i] + nums[left] + nums[right];
            if (sum === 0) {
                res.push([nums[i], nums[left], nums[right]]);
                
                // 移动双指针,继续查找
                left++;
                right--;
                // 跳过重复组
                while (left < right && nums[left] === nums[left - 1]) left++;
                while (left < right && nums[right] === nums[right + 1]) right--;
            } else if (sum < 0) {
                // 和太小,需增大 → 左指针右移
                left++;
            } else {
                // 和太大,需减小 → 右指针左移
                right--;
            }
        }
    }
    return res;
};

⏱️ 时间复杂度分析

  • 排序操作
    调用 nums.sort((a, b) => a - b) 对数组进行升序排序,JavaScript 引擎通常采用高效排序算法,时间复杂度 --> O(n log n)
  • 外层循环
    for 循环遍历 i0nums.length - 3,时间复杂度 --> O(n)
  • 内层双指针扫描
    对于每个固定的 ileftright 从两端向中间移动,每个元素在该轮中最多被访问一次,因此每轮内层循环的时间复杂度 --> O(n)
    由于外层循环执行 O(n) 次,本应当为 O(n²),但是并非每轮都走完整个数组,所以最坏情况下 --> O(n²)
  • 总时间复杂度:排序 O(n log n) + 双指针主逻辑 O(n²) = O(n²)

42. 接雨水 - 力扣(LeetCode)

题目分析:

题目要求计算在给定的非负整数数组 height 所表示的柱状图中,能接住多少单位的雨水。每个元素 height[i] 表示位置 i 处柱子的高度,宽度均为 1。

核心要求:

  • 雨水只能积在“凹陷”区域,即两侧有更高柱子的位置;
  • 每个位置能接的雨水量 = min(左侧最高柱, 右侧最高柱) - 当前高度(若结果为正,否则为 0);
  • 不能倾斜容器,雨水垂直下落并被两侧柱子围住
  • 目标是返回整个结构能储存的总雨水量

本题本质上是:对每个位置 i,快速确定其左侧最大高度右侧最大高度,从而计算该位置的积水。


解法(双指针 + 动态边界)

暴力解法需对每个位置遍历左右求最大值,时间复杂度 O(n²)。即使使用额外数组预处理左右最大值可优化至 O(n),但是空间占用还是过大。

所以解法采用双指针从两端向中间收缩,再利用贪心思想实现 O(1) 空间、O(n) 时间 的最优解。

关键思想:

  1. 维护两个变量 lmaxrmax,分别表示当前 left 指针左侧(含自身)的最大高度,以及 right 指针右侧(含自身)的最大高度;

  2. 比较 lmaxrmax

    • lmax < rmax,说明 left 位置的积水由左侧最大值决定(因为右侧存在更高的边界),此时可安全计算 left 处的积水;
    • 否则,right 位置的积水由右侧最大值决定,计算 right 处的积水;
  3. 每次只移动较矮一侧的指针,确保另一侧始终存在足够高的“挡板”,从而保证当前积水计算的正确性。

  4. 并且两个指针相遇的位置一定为最高的柱子,这个柱子是无法装水的。

var trap = function(height) {
    // 创建双指针 
    let left = 0;
    let right = height.length - 1;

    let m = 0;              // 总雨水量
    let lmax = 0, rmax = 0; // 当前左右侧最大高度

    while (left < right) {
        // 更新左右侧最大高度
        lmax = Math.max(lmax, height[left]);
        rmax = Math.max(rmax, height[right]);

        if (lmax < rmax) {
            // 左侧最大值更小 → left 位置的积水由 lmax 决定
            m += lmax - height[left];
            left++;
        } else {
            // 右侧最大值更小或相等 → right 位置的积水由 rmax 决定
            m += rmax - height[right];
            right--;
        }
    }
    return m;
};

⏱️ 时间复杂度分析

  • 双指针遍历
    leftright 从两端向中间移动,每个元素最多被访问一次,循环执行 O(n) 次。
  • 每轮操作
    包括 Math.max、比较、加法和指针移动,均为 O(1) 常数时间操作。
  • 总时间复杂度O(n)
  • 空间复杂度:仅使用常数个变量(left, right, m, lmax, rmax)→ O(1)

每日一题-会议室 III🔴

给你一个整数 n ,共有编号从 0n - 1n 个会议室。

给你一个二维整数数组 meetings ,其中 meetings[i] = [starti, endi] 表示一场会议将会在 半闭 时间区间 [starti, endi) 举办。所有 starti 的值 互不相同

会议将会按以下方式分配给会议室:

  1. 每场会议都会在未占用且编号 最小 的会议室举办。
  2. 如果没有可用的会议室,会议将会延期,直到存在空闲的会议室。延期会议的持续时间和原会议持续时间 相同
  3. 当会议室处于未占用状态时,将会优先提供给原 开始 时间更早的会议。

返回举办最多次会议的房间 编号 。如果存在多个房间满足此条件,则返回编号 最小 的房间。

半闭区间 [a, b)ab 之间的区间,包括 a 不包括 b

 

示例 1:

输入:n = 2, meetings = [[0,10],[1,5],[2,7],[3,4]]
输出:0
解释:
- 在时间 0 ,两个会议室都未占用,第一场会议在会议室 0 举办。
- 在时间 1 ,只有会议室 1 未占用,第二场会议在会议室 1 举办。
- 在时间 2 ,两个会议室都被占用,第三场会议延期举办。
- 在时间 3 ,两个会议室都被占用,第四场会议延期举办。
- 在时间 5 ,会议室 1 的会议结束。第三场会议在会议室 1 举办,时间周期为 [5,10) 。
- 在时间 10 ,两个会议室的会议都结束。第四场会议在会议室 0 举办,时间周期为 [10,11) 。
会议室 0 和会议室 1 都举办了 2 场会议,所以返回 0 。 

示例 2:

输入:n = 3, meetings = [[1,20],[2,10],[3,5],[4,9],[6,8]]
输出:1
解释:
- 在时间 1 ,所有三个会议室都未占用,第一场会议在会议室 0 举办。
- 在时间 2 ,会议室 1 和 2 未占用,第二场会议在会议室 1 举办。
- 在时间 3 ,只有会议室 2 未占用,第三场会议在会议室 2 举办。
- 在时间 4 ,所有三个会议室都被占用,第四场会议延期举办。 
- 在时间 5 ,会议室 2 的会议结束。第四场会议在会议室 2 举办,时间周期为 [5,10) 。
- 在时间 6 ,所有三个会议室都被占用,第五场会议延期举办。 
- 在时间 10 ,会议室 1 和 2 的会议结束。第五场会议在会议室 1 举办,时间周期为 [10,12) 。 
会议室 1 和会议室 2 都举办了 2 场会议,所以返回 1 。 

 

提示:

  • 1 <= n <= 100
  • 1 <= meetings.length <= 105
  • meetings[i].length == 2
  • 0 <= starti < endi <= 5 * 105
  • starti 的所有值 互不相同

n<=100,代码可以写得简单(比赛时想复杂了)

由于 $n\le 100$,可以直接按照题目模拟:

  1. 按开始时间排序,依次安排 meetings

  2. 维护每个会议室的 最早可用时间 $t$。每次安排会议$[start, end)$时,将那些 $t$ 早于 $start$ 的会议室的 $t$ 设为 $start$。这样处理后只需从中选择 $t$ 最早的会议室即可(如果有相等的选下标最小的)。

  3. 同时维护 $cnt$ 数组,遍历完成后按要求返回答案即可

###python

class Solution:
    def mostBooked(self, n: int, meetings: List[List[int]]) -> int:
        cnt, t = [0] * n, [0] * n
        
        for [s, e] in sorted(meetings):
            t = list(map(lambda x : max(x, s), t))
            choice = t.index(min(t))
            t[choice], cnt[choice] = t[choice] + e - s, cnt[choice] + 1
            
        return cnt.index(max(cnt))

双堆模拟(Python/Java/C++/Go)

本题 视频讲解 已出炉,欢迎素质三连,在评论区分享你对这场周赛的看法~


用两个小顶堆模拟:

  • $\textit{idle}$ 维护在 $\textit{start}_i$ 时刻空闲的会议室的编号;
  • $\textit{using}$ 维护在 $\textit{start}_i$ 时刻使用中的会议室的结束时间和编号。

这两类会议室是互补关系,伴随着会议的开始和结束,会议室在这两类中来回倒。

对 $\textit{meetings}$ 按照开始时间排序,然后遍历 $\textit{meetings}$,按照题目要求模拟即可,具体模拟方式见代码。

复杂度分析

  • 时间复杂度:$O(n+m(\log n + \log m))$,其中 $m$ 为 $\textit{meetings}$ 的长度。注意每个会议至多入堆出堆各一次。
  • 空间复杂度:$O(n)$。

相似题目

class Solution:
    def mostBooked(self, n: int, meetings: List[List[int]]) -> int:
        cnt = [0] * n
        idle, using = list(range(n)), []
        meetings.sort(key=lambda m: m[0])
        for st, end in meetings:
            while using and using[0][0] <= st:
                heappush(idle, heappop(using)[1])  # 维护在 st 时刻空闲的会议室
            if len(idle) == 0:
                e, i = heappop(using)  # 没有可用的会议室,那么弹出一个最早结束的会议室(若有多个同时结束的,会弹出下标最小的)
                end += e - st  # 更新当前会议的结束时间
            else:
                i = heappop(idle)
            cnt[i] += 1
            heappush(using, (end, i))  # 使用一个会议室
        ans = 0
        for i, c in enumerate(cnt):
            if c > cnt[ans]:
                ans = i
        return ans
class Solution {
    public int mostBooked(int n, int[][] meetings) {
        var cnt = new int[n];
        var idle = new PriorityQueue<Integer>();
        for (var i = 0; i < n; ++i) idle.offer(i);
        var using = new PriorityQueue<Pair<Long, Integer>>((a, b) -> !Objects.equals(a.getKey(), b.getKey()) ? Long.compare(a.getKey(), b.getKey()) : Integer.compare(a.getValue(), b.getValue()));
        Arrays.sort(meetings, (a, b) -> Integer.compare(a[0], b[0]));
        for (var m : meetings) {
            long st = m[0], end = m[1];
            while (!using.isEmpty() && using.peek().getKey() <= st) {
                idle.offer(using.poll().getValue()); // 维护在 st 时刻空闲的会议室
            }
            int id;
            if (idle.isEmpty()) {
                var p = using.poll(); // 没有可用的会议室,那么弹出一个最早结束的会议室(若有多个同时结束的,会弹出下标最小的)
                end += p.getKey() - st; // 更新当前会议的结束时间
                id = p.getValue();
            } else id = idle.poll();
            ++cnt[id];
            using.offer(new Pair<>(end, id)); // 使用一个会议室
        }
        var ans = 0;
        for (var i = 0; i < n; ++i) if (cnt[i] > cnt[ans]) ans = i;
        return ans;
    }
}
class Solution {
public:
    int mostBooked(int n, vector<vector<int>> &meetings) {
        int cnt[n]; memset(cnt, 0, sizeof(cnt));
        priority_queue<int, vector<int>, greater<>> idle;
        for (int i = 0; i < n; ++i) idle.push(i);
        priority_queue<pair<long, int>, vector<pair<long, int>>, greater<>> using_;
        sort(meetings.begin(), meetings.end(), [](auto &a, auto &b) { return a[0] < b[0]; });
        for (auto &m : meetings) {
            long st = m[0], end = m[1], id;
            while (!using_.empty() && using_.top().first <= st) {
                idle.push(using_.top().second); // 维护在 st 时刻空闲的会议室
                using_.pop();
            }
            if (idle.empty()) {
                auto[e, i] = using_.top(); // 没有可用的会议室,那么弹出一个最早结束的会议室(若有多个同时结束的,会弹出下标最小的)
                using_.pop();
                end += e - st; // 更新当前会议的结束时间
                id = i;
            } else {
                id = idle.top();
                idle.pop();
            }
            ++cnt[id];
            using_.emplace(end, id); // 使用一个会议室
        }
        int ans = 0;
        for (int i = 0; i < n; ++i) if (cnt[i] > cnt[ans]) ans = i;
        return ans;
    }
};
func mostBooked(n int, meetings [][]int) (ans int) {
cnt := make([]int, n)
idle := hp{make([]int, n)}
for i := 0; i < n; i++ {
idle.IntSlice[i] = i
}
using := hp2{}
sort.Slice(meetings, func(i, j int) bool { return meetings[i][0] < meetings[j][0] })
for _, m := range meetings {
st, end := m[0], m[1]
for len(using) > 0 && using[0].end <= st {
heap.Push(&idle, heap.Pop(&using).(pair).i) // 维护在 st 时刻空闲的会议室
}
var i int
if idle.Len() == 0 {
p := heap.Pop(&using).(pair) // 没有可用的会议室,那么弹出一个最早结束的会议室(若有多个同时结束的,会弹出下标最小的)
end += p.end - st // 更新当前会议的结束时间
i = p.i
} else {
i = heap.Pop(&idle).(int)
}
cnt[i]++
heap.Push(&using, pair{end, i}) // 使用一个会议室
}
for i, c := range cnt {
if c > cnt[ans] {
ans = i
}
}
return
}

type hp struct{ sort.IntSlice }
func (h *hp) Push(v interface{}) { h.IntSlice = append(h.IntSlice, v.(int)) }
func (h *hp) Pop() interface{}   { a := h.IntSlice; v := a[len(a)-1]; h.IntSlice = a[:len(a)-1]; return v }

type pair struct{ end, i int }
type hp2 []pair
func (h hp2) Len() int            { return len(h) }
func (h hp2) Less(i, j int) bool  { a, b := h[i], h[j]; return a.end < b.end || a.end == b.end && a.i < b.i }
func (h hp2) Swap(i, j int)       { h[i], h[j] = h[j], h[i] }
func (h *hp2) Push(v interface{}) { *h = append(*h, v.(pair)) }
func (h *hp2) Pop() interface{}   { a := *h; v := a[len(a)-1]; *h = a[:len(a)-1]; return v }

模拟

解法:模拟

因为“当会议室处于未占用状态时,将会优先提供给原开始时间更早的会议”,因此有重要性质:会议开始的相对顺序不会改变。我们只需要按顺序模拟每个会议分配给哪个会议室即可。

复杂度 $\mathcal{O}(m\log m + nm)$,其中 $m$ 是会议的数量。具体实现见参考代码,注意会议的结束时间可能超出 int 的范围。

参考代码(c++)

###c++

class Solution {
public:
    int mostBooked(int n, vector<vector<int>>& meetings) {
        int m = meetings.size();
        // 将会议按开始时间排序
        sort(meetings.begin(), meetings.end());
        // 每个会议室被分配了多少会议
        vector<int> cnt(n);
        // 每个会议室的最早可用时间
        vector<long long> tim(n);
        // 按顺序处理会议
        for (auto &meet : meetings) {
            // best 表示当前不可用,但可用时间最早的会议室编号
            int best = 0;
            for (int i = 0; i < n; i++) {
                if (tim[i] <= meet[0]) {
                    // 当前会议室可用,直接分配
                    cnt[i]++;
                    tim[i] = meet[1];
                    goto OK;
                } else if (tim[i] < tim[best]) best = i; // 当前会议室不可用,更新 best
            }
            // 当前没有会议室可用,等待会议室 best 可用再分配
            cnt[best]++;
            tim[best] += meet[1] - meet[0];
            OK: continue;
        }
        // 统计答案
        int ans = 0;
        for (int i = 0; i < n; i++) if (cnt[i] > cnt[ans]) ans = i;
        return ans;
    }
};

也可以使用线段树将复杂度降至 $\mathcal{O}(m\log m + m\log n)$。

参考代码(c++)

###c++

class Solution {
    vector<long long> mino;

    // 修改 pos 位置的值为 val
    void modify(int id, int l, int r, int pos, long long val) {
        if (l == r) mino[id] = val;
        else {
            int nxt = id << 1, mid = (l + r) >> 1;
            if (pos <= mid) modify(nxt, l, mid, pos, val);
            else modify(nxt | 1, mid + 1, r, pos, val);
            mino[id] = min(mino[nxt], mino[nxt | 1]);
        }
    }

    // 求编号最小的,且值小等于 val 的位置
    int query1(int id, int l, int r, int val) {
        if (l == r) return l;
        else {
            int nxt = id << 1, mid = (l + r) >> 1;
            if (mino[id] > val) return -1;
            else if (mino[nxt] <= val) return query1(nxt, l, mid, val);
            else return query1(nxt | 1, mid + 1, r, val);
        }
    }

    // 求值最小的位置在哪里
    int query2(int id, int l, int r) {
        if (l == r) return l;
        else {
            int nxt = id << 1, mid = (l + r) >> 1;
            return mino[nxt] <= mino[nxt | 1] ? query2(nxt, l, mid) : query2(nxt | 1, mid + 1, r);
        }
    }

public:
    int mostBooked(int n, vector<vector<int>>& meetings) {
        mino.resize(n * 4 + 10);
        int m = meetings.size();
        sort(meetings.begin(), meetings.end());

        vector<int> cnt(n);
        for (auto &meet : meetings) {
            // 是否直接有会议室可用
            int x = query1(1, 0, n - 1, meet[0]);
            if (x >= 0) cnt[x]++, modify(1, 0, n - 1, x, meet[1]); // 直接有会议室可用,更新该会议室的可用时间
            else {
                // 没有会议室直接可用,找接下来最早可用的会议室
                x = query2(1, 0, n - 1);
                // 更新该会议室的可用时间
                cnt[x]++;
                modify(1, 0, n - 1, x, mino[1] + meet[1] - meet[0]);
            }
        }

        // 统计答案
        int ans = 0;
        for (int i = 0; i < n; i++) if (cnt[i] > cnt[ans]) ans = i;
        return ans;
    }
};

别再让 AI 直接写页面了:一种更稳的中后台开发方式

本文讨论的不是 Demo 级别的 AI 编码体验,而是面向真实团队、长期维护的中后台工程实践。

AI 能写代码,但不意味着它适合直接“产出页面”。

最近一年,大模型在前端领域的讨论几乎都围绕一个问题:

“能不能让 AI 直接把页面写出来?”

在真实的中后台项目中,我的答案是:
不但不稳,而且很危险。

这篇文章想分享一种我在真实项目中实践过、可长期使用、可规模化的方式:
不是让 AI 写页面,而是把 AI 纳入中后台前端的工程体系中。 把 AI 的不确定性关进了笼子里,用工程流程保证可控性。

模板固化规范,Spec 描述变化,大模型生成 Spec,脚本生成代码,lint/test 做兜底。 它解决了 AI 上工程最致命的四件事:

  1. 可审计:变化在 spec,生成结果可 diff
  2. 可重复:同一个 spec 反复生成结果一致
  3. 可兜底:lint/test 是硬门槛
  4. 可规模化:从 prompt 工艺变成流程

在中后台场景,尤其是 CRUD 占比高 的项目里,这几乎就是“性价比最优解”。

一、中后台页面开发的真实困境

如果你做过中后台前端,一定对下面这些场景不陌生:

  • 页面 80% 是 CRUD
  • 列表页结构高度一致
  • 表单字段不断变化
  • 大量复制粘贴
  • 页面逻辑“看起来差不多,但永远不完全一样”

最终结果往往是:

  • 代码冗余
  • 风格不统一
  • 新人上手慢
  • 改一个字段要改好几个地方

这些问题不是某个框架的问题,而是中后台开发的结构性问题。

二、为什么“让 AI 直接写页面”在真实项目里行不通?

很多人第一反应是:

“既然页面这么重复,为什么不直接让 AI 写 Vue / React 页面?”

在真实项目中,这种方式往往会遇到几个致命问题。

1️⃣ 不稳定

  • 同样的 prompt,每次生成结果不同
  • 组件结构、命名风格不断漂移
  • 难以保证团队统一规范

2️⃣ 难以 review

  • AI 一次生成几百行代码
  • reviewer 很难判断“这是不是对的”
  • 出问题时难以定位责任

3️⃣ 无法规模化

  • prompt 是隐性的
  • 经验无法沉淀
  • 每个页面都是“重新生成一次”

4️⃣ 工程体系无法兜底

  • lint / test 很难提前发现语义问题
  • 一旦出错,往往是运行期问题

结论很明确:
AI 直接写页面,更像是 demo,而不是工程方案。

三、一个更稳的思路:把“变化”和“稳定”拆开

在真实项目中,我最终选择了一种更偏工程化的做法:

Template + Spec + Generator + AI

核心思想只有一句话:

模板负责稳定性,Spec 负责变化,AI 只参与变化。

这个流程长什么样?

需求描述
   ↓
页面规格(Spec)
   ↓
模板(Template)
   ↓
生成脚本(Generator)
   ↓
页面代码
   ↓
lint / test 校验

这不是为了“多一层抽象”,而是为了把 AI 的不确定性限制在可控范围内

四、什么是 Spec?

**Spec(Specification)**可以理解为:

页面的“规格说明书”

它描述的是:

  • 页面标题
  • 接口地址
  • 表格字段
  • 查询条件
  • 表单字段与校验规则

而不是:

  • 生命周期怎么写
  • API 怎么调用
  • UI 组件怎么拼

这些内容,非常适合用一份结构化数据来表达。

一个简化的 Spec 示例

{
  "title": "供应商管理",
  "api": {
    "list": "/api/supplier/list",
    "create": "/api/supplier/create",
    "update": "/api/supplier/update",
    "delete": "/api/supplier/delete"
  },
  "columns": [
    { "prop": "name", "label": "供应商名称" },
    { "prop": "contact", "label": "联系人" },
    { "prop": "status", "label": "状态" }
  ],
  "formSchema": [
    { "prop": "name", "label": "供应商名称", "required": true },
    { "prop": "contact", "label": "联系人" },
    {
      "prop": "status",
      "label": "状态",
      "type": "select",
      "options": ["启用", "停用"]
    }
  ]
}

这份 JSON 不依赖任何前端框架,但已经完整描述了一个中后台页面的“变化点”。

五、Template:把重复劳动固化成资产

Template 是固定不变的部分,例如:

  • 页面整体结构
  • 表格 / 表单 / 弹窗骨架
  • API 调用方式
  • 分页逻辑
  • 错误处理方式

它的特点是:

  • 人工维护
  • 版本化
  • 可 review
  • 很少变动

你可以用 Vue、React、Svelte,模板思想本身与框架无关

六、Generator:让生成变成确定性行为

Generator 的职责非常单一:

把 Spec 填进 Template,生成代码文件

这一点非常重要:

  • Generator 是脚本
  • 输出是确定的
  • 不涉及 AI 决策

换句话说:

Generator 不是“智能的”,但它是可靠的。

七、AI 在这里扮演什么角色?

在这套体系中,AI 的职责被严格限制在两点:

✅ 1. 从自然语言生成 Spec

AI 非常擅长:

  • 理解业务描述
  • 生成结构化 JSON
  • 补全字段信息

✅ 2. 按 lint / 报错做最小修复

  • 只修具体文件
  • 只做最小 diff
  • 不重写整体结构

❌ AI 不该做的事

  • 直接写页面代码
  • 修改模板
  • 改动基础设施
  • 引入新依赖

这样做的结果是:
AI 的能力被“工程流程”约束,而不是反过来。

✅ 正确姿势

让 Codex 做两件事:

1️⃣ 根据自然语言 生成 Spec JSON
2️⃣ 根据 lint / 报错 做最小 patch 修复

示例指令(在 Codex CLI / IDE 中):

specs/ 下生成 supplier.json,字段为供应商名称、联系人、电话、状态(启用/停用),接口路径为 /api/supplier/*,输出必须是严格 JSON。

然后:

yarn gen:page specs/supplier.json
yarn lint

如果 lint 报错,再让 Codex 修:

根据 lint 报错,只修改 src/views/supplier/List.vue,用最小改动修到通过。

八、为什么这种方式更适合真实团队?

从工程角度看,这种方式有几个明显优势:

  • 可控:模板稳定,变化集中在 Spec
  • 可 review:Spec 是结构化数据
  • 可回滚:git diff 非常清晰
  • 可规模化:不是 prompt 驱动,而是流程驱动
  • 可迁移:换框架只需换模板

这也是为什么它比“直接让 AI 写页面”更稳。

九、这套思路不只适用于中后台

这种模式可以自然扩展到:

  • 表单页 / 详情页
  • 权限路由生成
  • 页面迁移(如 Vue2 → Vue3)
  • 低代码 / 页面工厂
  • 前端工程自动化

核心不在工具,而在拆分变化与稳定的边界

十、模板化不是终点:一条更现实的“最佳进化路线”

需要说明的是,Template + Spec + Generator 并不是终极方案,而是一个非常合适的工程起点。并不是所有团队都需要走到配置驱动或 AST 修改阶段,对很多团队来说,Template + Spec 本身已经是最优解。

在真实项目中,我更推荐把它看作一条“可进化的路线”,而不是一锤定音的设计。

第一步:Template + Spec(现在的方案)

适用场景:

  • CRUD 页面占比高
  • 新页面数量多
  • 团队希望尽快统一规范

价值:

  • 快速落地
  • 风险可控
  • 非常适合引入 AI 的第一步

第二步:抽象稳定能力,弱化模板复杂度

当发现模板里开始出现大量条件分支时,一个更稳的做法是:

把模板中的稳定逻辑抽成基础组件(如 BaseCrudPage)

此时:

  • 模板变薄
  • spec 只描述“页面配置”
  • 页面本身不再频繁生成新文件

这一步,已经非常接近配置驱动页面渲染

第三步:从“生成代码”走向“配置即页面”

在 CRUD 占比极高的系统中,最终形态往往是:

页面 = 配置(Spec) + 渲染器

此时:

  • 新页面不再生成 .vue 文件
  • 只新增 spec + 路由配置
  • AI 直接生成 spec,收益最大

这本质上就是低代码/页面工厂的雏形

第四步:存量项目引入结构化修改(AST / Patch)

对于已有大量页面的系统,更稳妥的方式是:

  • 用 spec 描述“变更意图”
  • 用工具对代码做结构化修改(如只改 columns、formSchema)
  • AI 只产出 patch,而不是重写页面

这一步非常适合:

  • 老项目
  • 安全要求高的团队
  • 渐进式演进

一句话总结这条路线

模板化是把 AI 引入工程体系的第一步,
配置驱动和结构化修改,才是中后台工程的长期形态。

十一、总结

大模型的价值,不在于“替代工程师写页面”,
而在于:

把重复劳动结构化,并嵌入到工程体系中。

在中后台前端开发中,
最稳的方式永远不是“让 AI 自由发挥”,
而是让它在清晰的边界内工作。

如果只能记住一句话: 不要让 AI 直接写页面,让它写“变化”,其余交给工程。


如果你觉得这篇文章对你有启发,欢迎点赞或收藏 👍

Flutter组件封装:视频播放组件全局封装

一、需求来源

最近要开发一个在线视频播放的功能,每次实时获取播放,有些卡顿和初始化慢的问题,就随手优化一下。

二、使用示例

1、数据源

class VideoDetailsProvider extends ChangeNotifier {

    /// 当前播放中的
    final _videoModelController = StreamController<VideoDetailModel?>.broadcast();

    /// 当前播放中 Stream<VideoDetailModel>
    Stream<VideoDetailModel?> get videoModelStream => _videoModelController.stream;


    /// 点击(model不为空时播放,为空时关闭)
    sinkModelForVideoPlayer({required VideoDetailModel? model}) {
      _videoModelController.add(model);
    }

}

2、显示播放器组件

StreamBuilder<VideoDetailModel?>(
  stream: provider.videoModelStream,
  builder: (context, snapshot) {
    if (snapshot.data == null) {
      return const SizedBox();
    }
    final model = snapshot.data;
    if (model?.isVideo != true) {
      return const SizedBox();
    }

    return SafeArea(
      top: true,
      bottom: false,
      left: false,
      right: false,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.black,
        ),
        child: AppVideoPlayer(
          key: Key(model?.url ?? ""),
          url: model?.url ?? "",
          onFullScreen: (value) async {
            if (!value) {
              await Future.delayed(const Duration(milliseconds: 300));//等待旋转完成
              SystemChromeExt.changeDeviceOrientation(isPortrait: true);
            }
          },
          onClose: () {
            DLog.d("close");
            provider.sinkModelForVideoPlayer(model: null);
          },
        ),
      ),
    );
  },
)

三、源码

1、AppVideoPlayer.dart

//
//  AppVideoPlayer.dart
//  flutter_templet_project
//
//  Created by shang on 2025/12/12 18:11.
//  Copyright © 2025/12/12 shang. All rights reserved.
//

import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/basicWidget/AppVideoPlayer/AppVideoPlayerService.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';
import 'package:video_player/video_player.dart';

/// 播放器
class AppVideoPlayer extends StatefulWidget {
  const AppVideoPlayer({
    super.key,
    this.controller,
    required this.url,
    this.autoPlay = true,
    this.looping = false,
    this.aspectRatio = 16 / 9,
    this.isPortrait = true,
    this.fullScreenVN,
    this.onFullScreen,
    this.onClose,
  });

  final AppVideoPlayerController? controller;

  final String url;
  final bool autoPlay;
  final bool looping;
  final double aspectRatio;

  /// 设备方向
  final bool isPortrait;

  final ValueNotifier<bool>? fullScreenVN;

  final void Function(bool isFullScreen)? onFullScreen;

  final VoidCallback? onClose;

  @override
  State<AppVideoPlayer> createState() => _AppVideoPlayerState();
}

class _AppVideoPlayerState extends State<AppVideoPlayer> with WidgetsBindingObserver, AutomaticKeepAliveClientMixin {
  VideoPlayerController? _videoController;
  ChewieController? _chewieController;

  Duration position = Duration.zero;

  @override
  void dispose() {
    widget.controller?._detach(this);
    WidgetsBinding.instance.removeObserver(this);
    _onClose();
    // 不销毁 VideoPlayerController,让全局复用
    // _chewieController?.dispose();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    widget.controller?._attach(this);
    WidgetsBinding.instance.addObserver(this);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      initPlayer();
    });
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.inactive:
      case AppLifecycleState.hidden:
      case AppLifecycleState.paused:
        {
          _chewieController?.pause();
        }
        break;
      case AppLifecycleState.detached:
        break;
      case AppLifecycleState.resumed:
        {
          _chewieController?.play();
        }
        break;
    }
  }

  Future<void> initPlayer() async {
    // DLog.d(widget.url.split("/").last);
    assert(widget.url.startsWith("http"), "url 错误");

    _videoController = await AppVideoPlayerService.instance.getController(widget.url);

    _chewieController?.dispose();
    _chewieController = ChewieController(
      videoPlayerController: _videoController!,
      autoPlay: widget.autoPlay,
      looping: widget.looping,
      aspectRatio: widget.aspectRatio,
      autoInitialize: true,
      allowFullScreen: true,
      allowMuting: false,
      showControlsOnInitialize: false,
      // customControls: const AppVideoControls(),
    );

    _chewieController!.addListener(() {
      final isFullScreen = _chewieController!.isFullScreen;
      widget.fullScreenVN?.value = isFullScreen;
      widget.onFullScreen?.call(isFullScreen);
      if (isFullScreen) {
        DLog.d("进入全屏");
      } else {
        DLog.d("退出全屏");
      }
    });
    if (mounted) {
      setState(() {});
    }
  }

  Future<void> _onClose() async {
    if (_videoController?.value.isPlaying == true) {
      _videoController?.pause();
    }
  }

  @override
  void didUpdateWidget(covariant AppVideoPlayer oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.url != widget.url) {
      initPlayer();
    }
  }

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

    /// 🔥屏幕横竖切换时 rebuild Chewie,但不 rebuild video
    DLog.d([MediaQuery.of(context).orientation]);
    // setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    if (widget.url.startsWith("http") != true) {
      return const SizedBox();
    }

    if (_chewieController == null) {
      return const Center(child: CircularProgressIndicator());
    }
    // return Chewie(controller: _chewieController!);
    return Stack(
      children: [
        Positioned.fill(
          child: Chewie(
            controller: _chewieController!,
          ),
        ),
        Positioned(
          right: 20,
          top: 10,
          child: Container(
            // decoration: BoxDecoration(
            //   color: Colors.red,
            //   border: Border.all(color: Colors.blue),
            // ),
            child: buildCloseBtn(onTap: widget.onClose),
          ),
        ),
      ],
    );
  }

  Widget buildCloseBtn({VoidCallback? onTap}) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: () {
        DLog.d("buildCloseBtn");
        _onClose();
        if (onTap != null) {
          onTap();
          return;
        }
        Navigator.pop(context);
      },
      child: Container(
        // padding: EdgeInsets.all(6),
        // decoration: BoxDecoration(
        //   color: Colors.black54,
        //   shape: BoxShape.circle,
        //   border: Border.all(color: Colors.blue),
        // ),
        child: const Icon(Icons.close, color: Colors.white, size: 24),
      ),
    );
  }

  @override
  bool get wantKeepAlive => true;
}

class AppVideoPlayerController {
  _AppVideoPlayerState? _anchor;

  void _attach(_AppVideoPlayerState anchor) {
    _anchor = anchor;
  }

  void _detach(_AppVideoPlayerState anchor) {
    if (_anchor == anchor) {
      _anchor = null;
    }
  }

  VideoPlayerController? get videoController {
    assert(_anchor != null);
    return _anchor!._videoController;
  }

  ChewieController? get chewieController {
    assert(_anchor != null);
    return _anchor!._chewieController;
  }
}

2、AppVideoPlayerService.dart

//
//  AppVideoPlayerService.dart
//  flutter_templet_project
//
//  Created by shang on 2025/12/12 18:10.
//  Copyright © 2025/12/12 shang. All rights reserved.
//

import 'package:flutter_templet_project/extension/extension_local.dart';
import 'package:quiver/collection.dart';
import 'package:video_player/video_player.dart';

/// 视频播放控制器全局管理
class AppVideoPlayerService {
  AppVideoPlayerService._();
  static final AppVideoPlayerService _instance = AppVideoPlayerService._();
  factory AppVideoPlayerService() => _instance;
  static AppVideoPlayerService get instance => _instance;

  /// 播放器字典
  LruMap<String, VideoPlayerController> get controllerMap => _controllerMap;
  // final _controllerMap = <String, VideoPlayerController>{};
  final _controllerMap = LruMap<String, VideoPlayerController>(maximumSize: 10);

  /// 最新使用播放器
  VideoPlayerController? current;

  /// 有缓存控制器
  bool hasCtrl({required String url}) => _controllerMap[url] != null;

  /// 是网络视频
  static bool isVideo(String? url) {
    if (url?.isNotEmpty != true) {
      return false;
    }

    final videoUri = Uri.tryParse(url!);
    if (videoUri == null) {
      return false;
    }

    final videoExt = ['.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm'];
    final ext = url.toLowerCase();
    final result = videoExt.any((e) => ext.endsWith(e));
    return result;
  }

  /// 获取 VideoPlayerController
  Future<VideoPlayerController?> getController(String url, {bool isLog = false}) async {
    assert(url.startsWith("http"), "必须是视频链接,请检查链接是否合法");
    final vc = _controllerMap[url];
    if (vc != null) {
      current = vc;
      if (isLog) {
        DLog.d(["缓存: ${vc.hashCode}"]);
      }
      return vc;
    }

    final videoUri = Uri.tryParse(url);
    if (videoUri == null) {
      return null;
    }

    final ctrl = VideoPlayerController.networkUrl(videoUri);
    await ctrl.initialize();
    _controllerMap[url] = ctrl;
    current = ctrl;
    if (isLog) {
      DLog.d(["新建: ${_controllerMap[url].hashCode}"]);
    }
    return ctrl;
  }

  /// 播放
  Future<void> play({required String url, bool onlyOne = true}) async {
    await getController(url);
    for (final e in _controllerMap.entries) {
      if (e.key == url) {
        if (!e.value.value.isPlaying) {
          e.value.play();
        } else {
          e.value.pause();
        }
      } else {
        if (onlyOne) {
          e.value.pause();
        }
      }
    }
  }

  /// 暂停所有视频
  void pauseAll() {
    for (final e in _controllerMap.entries) {
      e.value.pause();
    }
  }

  void dispose(String url) {
    _controllerMap[url]?.dispose();
    _controllerMap.remove(url);
  }

  void disposeAll() {
    for (final c in _controllerMap.values) {
      c.dispose();
    }
    _controllerMap.clear();
  }
}

最后、总结

1、播放视频组件和视频列表是分开的,通过监听 VideoPlayerController 保持状态(播放,暂停)一致性,类似网络视频小窗口播放。

2、通过 AppVideoPlayerService 实现全局 VideoPlayerController 缓存,提高初始化加载速度。

3、通过 quiver 的 LruMap 实现最大缓存数控制,防止内存爆炸。

github

PAG在得物社区S级活动的落地

一、背景

近期,得物社区活动「用篮球认识我」推出 “用户上传图片生成专属球星卡” 核心玩法。

初期规划由服务端基于 PAG 技术合成,为了让用户可以更自由的定制专属球星卡,经多端评估后确定:由 H5 端承接 “图片交互调整 - 球星卡生成” 核心链路,支持用户单指拖拽、双指缩放 / 旋转人像,待调整至理想位置后触发合成。而 PAG 作为腾讯自研开源的动效工作流解决方案,凭借跨平台渲染一致性、图层实时编辑、轻量化文件性能,能精准匹配需求,成为本次核心技术选型。

鉴于 H5 端需落地该核心链路,且流程涉及 PAG 技术应用,首先需对 PAG 技术进行深入了解,为后续开发与适配奠定基础。

二、PAG是什么?

这里简单介绍一下,PAG 是腾讯自研并开源的动效工作流解决方案,核心是实现 Adobe After Effects(AE)动效的一键导出与跨平台应用,包含渲染 SDK、AE 导出插件(PAGExporter)、桌面预览工具(PAGViewer)三部分。

它导出的二进制 PAG 文件压缩率高、解码快,能集成多类资源;支持 Android、iOS、Web 等全平台,且各端渲染一致、开启 GPU 加速;既兼容大部分 AE 动效特性,也允许运行时编辑 —— 比如替换文本 / 图片、调整图层与时间轴,目前已广泛用于各类产品的动效场景。

已知业界中图片基础编辑(如裁剪、调色)、贴纸叠加、滤镜渲染等高频功能,在客户端发布器场景下已广泛采用 PAG技术实现,这一应用趋势在我司及竞品的产品中均有体现,成为支撑这类视觉交互功能的主流技术选择。

正是基于PAG 的跨平台渲染、图层实时编辑特性,其能精准承接 H5 端‘图片交互调整 + 球星卡合成’的核心链路,解决服务端固定合成的痛点,因此成为本次需求的核心技术选型。

为了让大家更直观地感受「用篮球认识我」活动中 “用户上传图片生成专属球星卡” 玩法,我们准备了活动实际效果录屏。通过录屏,你可以清晰看到用户如何通过单指拖拽、双指缩放 / 旋转人像,完成构图调整后生成球星卡的全过程。

截屏2025-12-26 下午2.53.53.png

截屏2025-12-26 下午2.54.01.png

接下来,我们将围绕业务目标,详细拆解实现该链路的具体任务优先级与核心模块。

三、如何实现核心交互链路?

结合「用篮球认识我」球星卡生成的核心业务目标,按‘基础功能→交互体验→拓展能力→稳定性’优先级,将需求拆解为以下 6 项任务:

  1. PAG 播放器基础功能搭建:实现播放 / 暂停、图层替换、文本修改、合成图导出,为后续交互打基础;
  2. 图片交互变换功能开发:支持单指拖拽、双指缩放 / 旋转,满足人像构图调整需求;
  3. 交互与预览实时同步:将图片调整状态实时同步至 PAG 图层,实现 “操作即预览”;
  4. 批量合成能力拓展:基于单张合成逻辑,支持一次性生成多张球星卡(依赖任务 1-3);
  5. 全链路性能优化:优化 PAG 实例释放、图层渲染效率,保障 H5 流畅度(贯穿全流程);
  6. 异常场景降级兼容:针对 SDK 不支持场景,设计静态图层、服务端合成等兜底方案(同步推进)。

在明确核心任务拆解后,首要环节是搭建 PAG 播放器基础能力 —— 这是后续图层替换、文本修改、球星卡合成的前提,需从 SDK 加载、播放器初始化、核心功能封装逐步落地。

四、基础PAG播放器实现

加载PAG SDK

因为是首次接触PAG ,所以在首次加载 SDK 环节便遇到了需要注意的细节:

libpag 的 SDK 加载包含两部分核心文件:

  • 主体 libpag.min.js
  • 配套的 libpag.wasm

需特别注意:默认情况下,wasm文件需与 libpag.min.js 置于同一目录,若需自定义路径,也可手动指定其位置。(加载SDK参考文档:pag.io/docs/use-we…

在本项目中,我们将两个文件一同上传至 OSS的同一路径下:

h5static.xx/10122053/li… h5static.xx/10122053/li…

通过 CDN 方式完成加载,确保资源路径匹配。

SDK加载核心代码:

const loadLibPag = useCallback(async () => {
  // 若已加载,直接返回
  if (window.libpag) {
    return window.libpag
  }
  
  try {
    // 动态创建script标签加载SDK
    const script = document.createElement('script')
    script.src = 'https://h5static.XX/10122053/libpag.min.js'
    document.head.appendChild(script)
    
    return new Promise((resolve, reject) => {
      script.onload = async () => {
        // 等待500ms确保库完全初始化
        await new Promise(resolve => setTimeout(resolve, 500))
        console.log('LibPag script loaded, checking window.libpag:'window.libpag)
        
        if (window.libpag) {
          resolve(window.libpag)
        } else {
          reject(new Error('window.libpag is not available'))
        }
      }
      // 加载失败处理
      script.onerror = () => reject(new Error('Failed to load libPag script'))
    })
  } catch (error) {
    throw new Error(`Failed to load libPag: ${error}`)
  }
}, [])

初始化播放器

加载完 SDK 后,window 对象会生成 libpag 对象,以此为基础可完成播放器初始化,步骤如下:

  • 准备 canvas 容器作为渲染载体;
  • 加载 PAG 核心库并初始化 PAG 环境;
  • 加载目标.pag 文件(动效模板);
  • 创建 PAGView 实例关联 canvas 与动效文件;
  • 封装播放器控制接口(播放 / 暂停 / 销毁等),并处理资源释放与重复初始化问题。

需说明的是,本需求核心诉求是 “合成球星卡图片”,不涉及PAG的视频相关能力,因此暂不扩展视频功能,在播放器初始化后完成立即暂停,后续仅围绕 “图层替换(如用户人像)”“文本替换(如球星名称)” 等核心需求展开。

核心代码如下:

const { width, height } = props


// Canvas渲染容器
const canvasRef = useRef<HTMLCanvasElement>(null)
// PAG动效模板地址(球星卡模板)
const src = 'https://h5static.XX/10122053/G-lv1.pag'


// 初始化播放器函数
const initPlayer = useCallback(async () => {
  
  try {
    setIsLoading(true)
    const canvas = canvasRef.current
    // 设置Canvas尺寸与球星卡匹配
    canvas.width = width
    canvas.height = height
    
    // 1. 加载PAG核心库并初始化环境
    const libpag = await loadLibPag()
    const PAG = await libpag.PAGInit({ useScalefalse })
    
    // 2. 加载PAG动效模板
    const response = await fetch(src)
    const buffer = await response.arrayBuffer()
    const pagFile = await PAG.PAGFile.load(buffer)
    
    // 3. 创建PAGView,关联Canvas与动效模板
    const pagView = await PAG.PAGView.init(pagFile, canvas)
    
    // 4. 封装播放器控制接口
    const player = {
      _pagView: pagView,
      _pagFile: pagFile,
      _PAGPAG,
      _isPlayingfalse,
      
      // 播放
      async play() {
        await this._pagView.play()
        this._isPlaying = true
      },
      // 暂停(初始化后默认暂停)
      pause() {
        this._pagView.pause()
        this._isPlaying = false
      },
      // 销毁实例,释放资源
      destroy() {
        this._pagView.destroy()
      },
    }
  } catch (error) {
    console.error('PAG Player initialization failed:', error)
  } 
}, [src, width, height])

实现效果

播放器初始化完成后,可在Canvas中正常展示球星卡动效模板(初始化后默认暂停):

接下来我们来实现替换图层及文本功能。

替换图层及文本

替换 “用户上传人像”(图层)与 “球星名称”(文本)是核心需求,需通过 PAGFile 的原生接口实现,并扩展播放器实例的操作方法:

  • 图片图层替换:调用pagFile.replaceImage(index, image) 接口,将指定索引的图层替换为用户上传图片(支持 CDN 地址、Canvas 元素、Image 元素作为图片源);
  • 文本内容替换:调用pagFile.setTextData(index, textData) 接口,修改指定文本图层的内容与字体;
  • 效果生效:每次替换后需调用 pagView.flush() 强制刷新渲染,确保修改实时生效。

实现方案

  • 替换图片图层:通过pagFile.replaceImage(index, image)接口,将指定索引的图层替换为用户上传图片;
  • 替换文本内容:通过pagFile.setTextData(index, textData)接口,修改指定文本图层的内容;
  • 扩展播放器接口后,需调用flush()强制刷新渲染,确保替换效果生效。

初期问题:文本字体未生效

替换文本后发现设定字体未应用。排查后确认:自定义字体包未在 PAG 环境中注册,导致 PAG 无法识别字体。

需在加载 PAG 模板前,优先完成字体注册,确保 PAG 能正常调用目标字体,具体实现步骤如下。

PAG提供PAGFont.registerFont()接口用于注册自定义字体,需传入 “字体名称” 与 “字体文件资源”(如.ttf/.otf 格式文件),流程为:

  • 加载字体文件(从 CDN/OSS 获取字体包);
  • 调用 PAG 接口完成注册;
  • 注册成功后,再加载.pag文件,确保后续文本替换时字体已生效。
// 需注册的字体列表(字体名称+CDN地址)
const fonts = [
  {
    family'POIZONSans',
    url'https://h5static.XX/10122053/20250827-febf35c67d9232d4.ttf',
  },
  {
    family'FZLanTingHeiS-DB-GB',
    url'https://h5static.XX/10122053/20250821-1e3a4fccff659d1c.ttf',
  },
]


// 在“加载PAG核心库”后、“加载PAG模板”前,新增字体注册逻辑
const initPlayer = useCallback(async () => {
  // ... 原有代码(Canvas准备、加载libpag)
  const libpag = await loadLibPag()
  const PAG = await libpag.PAGInit({ useScalefalse })
  
  // 新增:注册自定义字体
  if (fonts && fonts.length > 0 && PAG?.PAGFont?.registerFont) {
    try {
      for (const { family, url } of fonts) {
        if (!family || !url) continue
        // 加载字体文件(CORS跨域配置+强制缓存)
        const resp = await fetch(url, { mode'cors'cache'force-cache' })
        const blob = await resp.blob()
        // 转换为File类型(PAG注册需File格式)
        const filename = url.split('/').pop() || 'font.ttf'
        const fontFile = new File([blob], filename)
        // 注册字体
        await PAG.PAGFont.registerFont(family, fontFile)
        console.log('Registered font for PAG:', family)
      }
    } catch (e) {
      console.warn('Register fonts for PAG failed:', e)
    }
  }
  
  // 继续加载PAG模板(原有代码)
  const response = await fetch(src)
  const buffer = await response.arrayBuffer()
  const pagFile = await PAG.PAGFile.load(buffer)
  // ... 后续创建PAGView、封装播放器接口
}, [src, width, height])

最终效果

字体注册后,文本替换的字体正常生效,人像与文本均显示正确:

数字字体已应用成功

可以看到,替换文本的字体已正确应用。接下来我们来实现最后一步,将更新图层及文本后的内容导出为CDN图片。

PagPlayer截帧(导出PagPlayer当前展示内容)

截帧是将 “调整后的人像 + 替换后的文本 + 动效模板” 固化为最终图片的关键步骤。开发初期曾直接调用pagView.makeSnapshot()遭遇导出空帧,后通过updateSize()+flush()解决同步问题;此外,还有一种更直接的方案 ——直接导出PAG渲染对应的Canvas内容,同样能实现需求,且流程更简洁。

初期问题:直接调用接口导致空帧

开发初期,尝试直接使用PAGView提供的makeSnapshot()接口截帧,但遇到了返回空帧(全透明图片)情况经过反复调试和查阅文档,发现核心原因是PAG 渲染状态与调用时机不同步:

  • 尺寸不同步:PAGView 内部渲染尺寸与 Canvas 实际尺寸不匹配,导致内容未落在可视区域;
  • 渲染延迟:图层替换、文本修改后,GPU 渲染是异步的,此时截帧只能捕获到未更新的空白或旧帧。

解决方案

针对空帧问题,结合 PAG 在 H5 端 “基于 Canvas 渲染” 的特性,梳理出两种可行方案,核心都是 “先确保渲染同步,再获取画面”:

最终落地流程

  • 调用 pagView.updateSize() 与 pagView.flush() 确保渲染同步;
  • 通过canvas.toDataURL('image/jpeg', 0.9) 生成 Base64 格式图片(JPG 格式,清晰度 0.9,平衡质量与体积);
  • 将 Base64 图片上传至 CDN,获取可访问的球星卡链接。

点击截帧按钮后,即可生成对应的截图。

完成 PAG 播放器的基础功能(图层替换、文本修改、截帧导出)后,我们来聚焦用户核心交互需求 —— 人像的拖拽、缩放与旋转,通过封装 Canvas 手势组件,实现精准的人像构图调整能力。

五、图片变换功能开发:实现人像拖拽、缩放与旋转

在球星卡合成流程中,用户需自主调整上传人像的位置、尺寸与角度以优化构图。我们可以基于 Canvas 封装完整的手势交互能力组件,支持单指拖拽、双指缩放 / 旋转,同时兼顾高清渲染与跨设备兼容性。

功能目标

针对 “用户人像调整” 场景,组件需实现以下核心能力:

  • 基础交互:支持单指拖拽移动人像、双指缩放尺寸、双指旋转角度;
  • 约束控制:限制缩放范围(如最小 0.1 倍、最大 5 倍),可选关闭旋转功能;
  • 高清渲染:适配设备像素比(DPR),避免图片拉伸模糊;
  • 状态同步:实时反馈当前变换参数(偏移量、缩放比、旋转角),支持重置与结果导出。

效果展示

组件设计理念

在组件设计之初,我们来使用分层理念,将图片编辑操作分解为三个独立层次:

交互感知层

交互感知层 - 捕获用户手势并转换为标准化的变换意图

  • 手势语义化:将原始的鼠标/触摸事件转换为语义化的操作意图
  • 单指移动 = 平移意图
  • 双指距离变化 = 缩放意图
  • 双指角度变化 = 旋转意图
  • 双击 = 重置意图

变换计算层

变换计算层 - 处理几何变换逻辑和约束规则

  • 多点触控的几何计算:双指操作时,系统会实时计算两个触点形成的几何关系(距离、角度、中心点),然后将这些几何变化映射为图片的变换参数。
  • 交互连续性:每次手势开始时记录初始状态,移动过程中所有计算都基于这个初始状态进行增量计算,确保变换的连续性和平滑性。

渲染执行层

渲染执行层 - 将变换结果绘制到Canvas上

  • 高清适配:Canvas的物理分辨率和显示尺寸分离管理,物理分辨率适配设备像素比保证清晰度,显示尺寸控制界面布局。
  • 变换应用:绘制时按照特定顺序应用变换 - 先移动到画布中心建立坐标系,再应用用户的平移、旋转、缩放操作,最后以图片中心为原点绘制。这个顺序确保了变换的直观性。
  • 渲染控制:区分实时交互和静态显示两种场景,实时交互时使用requestAnimationFrame保证流畅性,静态更新时使用防抖减少不必要的重绘。

数据流设计

  • 单向数据流:用户操作 → 手势解析 → 变换计算 → 约束应用 → 状态更新 → 重新渲染 → 回调通知。这种单向流动保证了数据的可追踪性。
  • 状态同步机制:内部状态变化时,通过回调机制同步给外部组件,支持实时同步和延迟同步两种模式,适应不同的性能需求。

实现独立的人像交互调整功能后,关键是打通 “用户操作” 与 “PAG 预览” 的实时同步链路 —— 确保用户每一次调整都能即时反馈在球星卡模板中,这需要设计分层同步架构与高效调度策略。

六、交互与预览实时同步

在球星卡生成流程中,“用户调整人像” 与 “PAG 预览更新” 的实时同步是核心体验指标 —— 用户每一次拖拽、缩放或旋转操作,都需要即时反馈在球星卡模板中,才能让用户精准判断构图效果。我们先来看一下实现效果:

接下来,我们从逻辑架构、关键技术方案、边界场景处理三方面,拆解 “用户交互调整” 与 “PAG 预览同步” 链路的实现思路。

逻辑架构:三层协同同步模型

组件将 “交互 - 同步 - 渲染” 拆分为三个独立但协同的层级,各层职责单一且通过明确接口通信,避免耦合导致的同步延迟或状态混乱。

核心流转链路:用户操作 → CanvasImageEditor 生成实时 Canvas → 同步层直接复用 Canvas 更新 PAG 图层 → 调度层批量触发 flush → PagPlayer 渲染最新画面。

关键方案:低损耗 + 高实时性的平衡

为同时兼顾 “高频交互导致 GPU 性能瓶颈” 与 “实时预览需即时反馈” ,组件通过三大核心技术方案实现平衡。

复用 Canvas 元素

跳过格式转换环节,减少性能消耗,直接复用 Canvas 元素作为 PAG 图片源。

核心代码逻辑:

通过 canvasEditorRef.current.getCanvas() 获取交互层的 Canvas 实例,直接传入PAG 的 replaceImageFast 接口(快速替换,不触发即时刷新),避免数据冗余处理。

// 直接使用 Canvas 元素更新 PAG,无格式转换
const canvas = canvasEditorRef.current.getCanvas();
pagPlayerRef.current.replaceImageFast(editImageIndex, canvas); // 快速替换,不flush

智能批量调度:

分级处理更新,兼顾流畅与效率

针对用户连续操作(如快速拖拽)产生的高频更新,组件设计 “分级调度策略”,避免每一次操作都触发 PAG 的 flush(GPU 密集型操作):

调度逻辑

实时操作合并:通过 requestAnimationFrame 捕获连续操作,将 16ms 内的多次替换指令合并为一次;

智能 flush 决策

若距离上次 flush 超过 100ms(用户操作暂停),立即触发 flushPagView(),确保预览不延迟;

若操作仍在持续,延迟 Math.max(16, updateThrottle/2) 毫秒再 flush,合并多次更新。

防抖降级

当 updateThrottle > 16ms(低实时性需求场景),自动降级为防抖策略,避免过度调度。

核心代码片段

// 智能 flush 策略:短间隔合并,长间隔立即刷新
const timeSinceLastFlush = Date.now() - batchUpdate.lastFlushTime;
if (timeSinceLastFlush > 100) {
  await flushPagView(); // 间隔久,立即刷新
} else {
  // 延迟刷新,合并后续操作
  setTimeout(async () => {
    if (batchUpdate.pendingUpdates > 0) {
      await flushPagView();
    }
  }, Math.max(16, updateThrottle/2));
}

双向状态校验:

解决首帧 / 切换场景的同步空白

针对 “PAG 加载完成但 Canvas 未就绪”“Canvas 就绪但 PAG 未初始化” 等首帧同步问题,组件设计双向重试校验机制:

  • PAG 加载后校验:handlePagLoad 中启动 60 帧(约 1s)重试,检测 Canvas 与 PAG 均就绪后,触发初始同步;
  • Canvas 加载后校验:handleCanvasImageLoad 同理,若 PAG 未就绪,重试至两者状态匹配;
  • 编辑模式切换校验:进入 startEdit 时,通过像素检测(getImageData)判断 Canvas 是否有内容,有则立即同步,避免空白预览。

边界场景处理:保障同步稳定性

编辑模式切换的状态衔接

  • 进入编辑:暂停 PAG 播放,显示透明的 Canvas 交互层(opacity: 0,仅保留交互能力),触发初始同步;
  • 退出编辑:清理批量调度定时器,强制 flush 确保最终状态生效,按需恢复 PAG 自动播放。

文本替换与图片同步的协同

当外部传入 textReplacements(如球星名称修改)时,通过独立的 applyToPagText 接口更新文本图层,并与图片同步共享 flush 调度,避免重复刷新:

// 文本替换后触发统一 flush
useEffect(() => {
  if (textReplacements?.length) {
    applyToPagText();
    flushPagView();
  }
}, [textReplacements]);

组件卸载的资源清理

卸载时清除批量调度的定时器(clearTimeout),避免内存泄漏;同时 PAG 内部会自动销毁实例,释放 GPU 资源。

PAG人像居中无遮挡

假设给定任意一张图片,我们将其绘制到Canvas中时,图片由于尺寸原因可能会展示不完整,如下图:

那么,如何保证任意尺寸图片在固定尺寸Canvas中初始化默认居中无遮挡呢?

我们采用以下方案:

等比缩放算法(Contain模式)

// 计算适配缩放比例,确保图片完整显示
const fitScale = Math.min(
  editCanvasWidth / image.width,   // 宽度适配比例
  availableHeight / image.height   // 高度适配比例(考虑留白)
)

核心原理:

  • 选择较小的缩放比例,确保图片在两个方向上都不会超出边界;
  • 这就是CSS的object-fit: contain效果,保证图片完整可见。

顶部留白预留

实际的PAG模板中,顶部会有一部分遮挡,因此需要对整个画布Canvas顶部留白。

如下图所示:

  • 为人像的头部区域预留空间
  • 避免重要的面部特征被PAG模板的装饰元素遮挡

核心代码

// 顶部留白比例
const TOP_BLANK_RATIO = 0.2


const handleCanvasImageLoad = useCallback(
  async (image: HTMLImageElement) => {
    console.log('Canvas图片加载完成:', image.width, 'x', image.height)
    setIsImageReady(true)


    // 初始等比缩放以完整可见(contain)
    if (canvasEditorRef.current) {
      // 顶部留白比例
      const TOP_BLANK_RATIO = spaceTopRatio ?? 0
      const availableHeight = editCanvasHeight * (1 - TOP_BLANK_RATIO)


      // 以可用高度进行等比缩放(同时考虑宽度)
      const fitScale = Math.min(
        editCanvasWidth / image.width, 
        availableHeight / image.height
      )


      // 计算使图片顶部恰好留白 TOP_BLANK_RATIO 的位移
      const topMargin = editCanvasHeight * TOP_BLANK_RATIO
      const imageScaledHeight = image.height * fitScale
      const targetCenterY = topMargin + imageScaledHeight / 2
      const yOffset = targetCenterY - editCanvasHeight / 2
      
      canvasEditorRef.current.setTransform({ 
        x: 0, 
        y: yOffset, 
        scale: fitScale, 
        rotation: 0 
      })
    }
    // ...
  },
  [applyToPag, flushPagView, isEditMode, editCanvasWidth, editCanvasHeight]
)

在单张球星卡的交互、预览与合成链路跑通后,需进一步拓展批量合成能力,以满足多等级球星卡一次性生成的业务需求,核心在于解决批量场景下的渲染效率、资源管理与并发控制问题。

七、批量生成

在以上章节,我们实现了单个卡片的交互及合成,但实际的需求中还有批量生成的需求,用来合成不同等级的球星卡,因此接下来我们需要处理批量生成相关的逻辑(碍于篇幅原因,这里我们就不展示代码了,主要以流程图形式来呈现。

经统计,经过各种手段优化后本活动中批量合成8张图最快仅需3s,最慢10s,批量合成过程用户基本是感知不到。

关键技术方案

  • 离线渲染隐藏容器:避免布局干扰
  • 资源缓存与预加载:提升合成效率
  • 并发工作协程池:平衡性能与稳定性
  • 多层重试容错:提升合成成功率
  • 图片处理与尺寸适配:保障合成质量
  • 结合业务场景实现批量合成中断下次访问页面后台继续生成的逻辑:保障合成功能稳定性。

核心架构

  • 资源管理层:负责PAG库加载、buffer缓存、预加载调度
  • 任务处理层:单个模板的渲染流水线,包含重试机制
  • 并发控制层:工作协程池管理,任务队列调度

整体批量合成流程

节拍拉取:按照固定时间间隔依次拉取资源,而非一次性并发获取所有资源

单个模板处理流程

并发工作协程模式

共享游标:多个工作协程共同使用的任务队列指针,用于协调任务分配。

原子获取任务:确保在并发环境下,每个任务只被一个协程获取,避免重复处理。

资源管理与缓存策略

批量合成与单卡交互的功能落地后,需针对开发过程中出现的卡顿、空帧、加载慢等问题进行针对性优化,同时构建兼容性检测与降级方案,保障不同环境下功能的稳定可用。

八、性能优化与降级兼容

性能优化

上述功能开发和实现并非一蹴而就,过程中遇到很多问题,诸如:

  • 图片拖动卡顿
  • Canvas导出空图、导出图片模糊
  • 批量合成时间较久
  • PAG初始加载慢
  • 导出图片时间久

等等问题,因此,我们在开发过程中就对各功能组件进行性能优化,大体如下:

PagPlayer(PAG播放器)

资源管理优化

// src变化时主动销毁旧实例,释放WebGL/PAG资源
if (srcChanged) {
  if (pagPlayer) {
    try {
      pagPlayer.destroy()
    } catch (e) {
      console.warn('Destroy previous player failed:', e)
    }
  }
}

WebGL检查与降级

  • 检查WebGL支持,不可用时降级为2D警告
  • 验证Canvas状态和尺寸
  • PAGView创建带重试机制

字体预注册

  • 必须在加载PAG文件之前注册字体
  • 使用File类型进行字体注册

CanvasImageEditor(Canvas图片编辑器)

高DPI优化:

  • 自动检测设备像素比,适配高分辨率设备
  • 分离物理像素和CSS像素,确保清晰度

内存管理

  • 组件卸载时自动清理Canvas资源
  • 启用高质量图像平滑,避免出现边缘锯齿
  • 使用CSS touch-action控制触摸行为

EditablePagPlayer(可编辑PAG播放器)

智能批量更新系统:

// 高性能实时更新 - 使用RAF + 批量flush
const smartApplyToPag = useMemo(() => {
  return () => {
    rafId = requestAnimationFrame(async () => {
      await applyToPag() // 快速图片替换(无flush)
      smartFlush(batchUpdateRef.current) // 管理批量flush
    })
  }
}, [])

批量flush策略:

  • 距离上次flush超过100ms立即flush
  • 否则延迟16ms~updateThrottle/2合并多次更新
  • 减少PAG刷新次数,提升性能

内存优化

  • 自动管理Canvas和PAG资源生命周期
  • 智能预热:检测Canvas内容避免不必要初始化
  • 资源复用:复用Canvas元素

PAGBatchComposer(批量PAG合成器)

高并发处理:

// 工作协程:按队列取任务直至耗尽或取消
const runWorker = async () => {
  while (!this.cancelled) {
    const idx = cursor++
    if (idx >= total) break
    // 处理单个模板...
  }
}

智能重试机制

  • 外层重试:最多3次整体重试,递增延迟
  • 内层重试:PAG操作级别重试2次
  • 首次延迟:第一个PAG处理增加500ms延迟

内存管理

  • 每个模板处理完成后立即清理Canvas和PAG对象
  • 集成Canvas计数器监控内存使用
  • 支持强制清理超时实例

性能监控debugUtils

  • 提供详细的性能监控和调试日志
  • 支持批量统计分析(吞吐量、平均时间等)

降级兼容

由于核心业务依赖 PAG 技术栈,而 PAG 运行需 WebGL 和 WebAssembly 的基础API支持,因此必须在应用初始化阶段对这些基础 API 进行兼容性检测,并针对不支持的环境执行降级策略,以保障核心功能可用性。

核心API检测代码如下:

export function isWebGLAvailable(): boolean {
  if (typeof window === 'undefined'return false
  try {
    const canvas = document.createElement('canvas')
    const gl =
      canvas.getContext('webgl') ||
      (canvas.getContext('experimental-webgl'as WebGLRenderingContext | null)
    return !!gl
  } catch (e) {
    return false
  }
}


export function isWasmAvailable(): boolean {
  try {
    const hasBasic =
      typeof (globalThis as any).WebAssembly === 'object' &&
      typeof (WebAssembly as any).instantiate === 'function'
    if (!hasBasic) return false
    // 最小模块校验,规避“存在但不可用”的情况
    const bytes = new Uint8Array([0x000x610x730x6d0x010x000x000x00])
    const mod = new WebAssembly.Module(bytes)
    const inst = new WebAssembly.Instance(mod)
    return inst instanceof WebAssembly.Instance
  } catch (e) {
    return false
  }
}


export function isPagRuntimeAvailable(): boolean {
  return isWebGLAvailable() && isWasmAvailable()
}

环境适配策略

  • 兼容环境(检测通过):直接执行 H5 端 PAG 初始化流程,启用完整的前端交互编辑能力。
  • 不兼容环境(检测失败):自动切换至服务端合成链路,通过预生成静态卡片保障核心功能可用,确保用户仍能完成球星卡生成的基础流程。

九、小结

本次「用篮球认识我」球星卡生成功能开发,围绕 “用户自主调整 + 跨端一致渲染” 核心目标,通过 PAG 技术与 Canvas 交互的深度结合,构建了从单卡编辑到批量合成的完整技术链路,可从问题解决、技术沉淀、业务价值三方面总结核心成果:

问题解决:解决业务痛点,优化用户体验

针对初期 “服务端固定合成导致构图偏差” 的核心痛点,通过 H5 端承接关键链路,保障活动玩法完整性:

  • 交互自主性:基于 Canvas 封装的CanvasImageEditor组件,支持单指拖拽、双指缩放 / 旋转,让用户可精准调整人像构图,解决 “固定合成无法适配个性化需求” 问题;
  • 预览实时性:设计 “交互感知 - 同步调度 - 渲染执行” 三层模型,通过复用 Canvas 元素、智能批量调度等方案,实现操作与 PAG 预览的即时同步,避免 “调整后延迟反馈” 的割裂感;
  • 场景兼容性:针对 PAG 加载失败、WebGL 不支持等边界场景,设计静态图层兜底、服务端合成降级、截帧前渲染同步等方案,保障功能高可用性。

技术沉淀

本次开发过程中,围绕 PAG 技术在 H5 端的应用,沉淀出一套标准化的技术方案与组件体系,可复用于后续图片编辑、动效合成类需求:

  • 组件化封装:拆分出PagPlayer(基础播放与图层替换)、CanvasImageEditor(手势交互)、EditablePagPlayer(交互与预览同步)、PAGBatchComposer(批量合成)四大核心组件,各组件职责单一、接口清晰,支持灵活组合;
  • 性能优化:通过 “高清适配(DPR 处理)、资源复用(Canvas 直接传递)、调度优化(RAF 合并更新)、内存管理(实例及时销毁)” 等优化方向,为后续复杂功能的性能调优提供参考范例;
  • 问题解决案例:记录 PAG 字体注册失效、截帧空帧、批量合成卡顿等典型问题的排查思路与解决方案,形成技术文档,降低后续团队使用 PAG 的门槛。

业务价值:支撑活动爆发,拓展技术边界

从业务落地效果来看,本次技术方案不仅满足了「用篮球认识我」活动的核心需求,更为社区侧后续视觉化功能提供了技术支撑:

  • 活动保障:球星卡生成功能上线后,未出现因技术问题导致的功能不可用。
  • 技术能力拓展:首次在社区 H5 端落地 PAG 动效合成与手势交互结合的方案,填补了 “前端 PAG 应用” 的技术空白,为后续一些复杂交互奠定基础。

后续优化方向

尽管当前方案已满足业务需求,但仍有可进一步优化的空间:

  • 性能再提升:批量合成场景下,可探索 Web Worker 分担 PAG 解析压力,减少主线程阻塞。
  • 功能扩展:在CanvasImageEditor中增加图片裁剪、滤镜叠加等功能,拓展组件的适用场景。

往期回顾

  1. Ant Design 6.0 尝鲜:上手现代化组件开发|得物技术

  2. Java 设计模式:原理、框架应用与实战全解析|得物技术

  3. Go语言在高并发高可用系统中的实践与解决方案|得物技术

  4. 从0到1搭建一个智能分析OBS埋点数据的AI Agent|得物技术

  5. 数据库AI方向探索-MCP原理解析&DB方向实战|得物技术

文 /无限

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

我接入了微信小说小程序官方阅读器

概述

微信官方为小说类小程序提供了专用的阅读器插件,所有小说类目的小程序都必须使用该组件。

快速开始

1. 添加插件配置

在小程序的 app.json 文件中添加插件配置:

{
  "plugins": {
    "novel-plugin": {
      "version": "latest",
      "provider": "wx293c4b6097a8a4d0"
    }
  }
}

2. 初始化插件

app.js 中初始化插件并监听页面加载事件:

// app.js
const novelPlugin = requirePlugin('novel-plugin')

App({
  onLaunch() {
    // 监听阅读器页面加载事件
    novelPlugin.onPageLoad(onNovelPluginLoad)
  },
})

function onNovelPluginLoad(data) {
  // data.id - 阅读器实例ID
  const novelManager = novelPlugin.getNovelManager(data.id)
  
  // 设置目录状态
  novelManager.setContents({
    contents: [
      { index: 0, status: 0 }, // 第一章:免费
      { index: 1, status: 2 }, // 第二章:未解锁
      { index: 2, status: 1 }, // 第三章:已解锁
    ]
  })

  // 监听用户行为
  novelManager.onUserTriggerEvent(res => {
    console.log('用户操作:', res.event_id, res)
  })
}

3. 跳转到阅读页面

// 跳转到阅读器页面
wx.navigateTo({
  url: 'plugin-private://wx293c4b6097a8a4d0/pages/novel/index?bookId=书籍ID'
})

核心功能详解

页面跳转参数

跳转到阅读页面时可以传入以下参数:

参数 必填 说明
bookId 书籍ID
chapterIndex 跳转章节下标(从0开始)
fontSize 字体大小(0-9)
turnPageWay 翻页方式:SWIPE/MOVE/SCROLL
backgroundConfigIndex 背景色序号(1-5)
isNightMode 是否夜间模式
showListenButton 是否显示听书按钮

章节解锁功能

1. 创建解锁组件

首先创建一个自定义组件 charge-dialog

<!-- charge-dialog.wxml -->
<view class="charge-dialog">
  <text>解锁第 {{ chapterIndex + 1 }} 章</text>
  <button bindtap="unlock">立即解锁</button>
</view>
// charge-dialog.js
const novelPlugin = requirePlugin('novel-plugin')

Component({
  properties: {
    novelManagerId: Number,
    bookId: String,
    chapterIndex: Number,
    chapterId: String
  },

  methods: {
    unlock() {
      const novelManager = novelPlugin.getNovelManager(this.properties.novelManagerId)
      
      // 执行解锁逻辑...
      
      // 解锁完成后通知阅读器
      novelManager.paymentCompleted()
    }
  }
})

2. 注册组件

app.json 中注册组件:

{
  "plugins": {
    "novel-plugin": {
      "version": "latest",
      "provider": "wx293c4b6097a8a4d0",
      "genericsImplementation": {
        "novel": {
          "charge-dialog": "components/charge-dialog/charge-dialog"
        }
      }
    }
  }
}

消息推送配置

阅读器通过消息推送验证章节解锁状态,需要在服务器端配置消息接收:

// 服务器接收消息示例
{
  "ToUserName": "小程序原始ID",
  "FromUserName": "用户openid",
  "CreateTime": 时间戳,
  "MsgType": "event",
  "Event": "wxa_novel_chapter_permission",
  "BookId": "书籍ID",
  "ChapterIndex": 章节下标,
  "ChapterId": "章节ID",
  "Source": 1 // 1表示用户实际阅读
}

// 响应格式
{
  "ErrCode": 0,
  "ErrMsg": "",
  "ChapterPerms": [{
    "StartChapterIndex": 0,
    "EndChapterIndex": 2,
    "Perm": 1 // 0-免费 1-已解锁 2-未解锁
  }]
}

高级功能

1. 自定义解锁方式

// 设置不同的解锁方式
novelManager.setChargeWay({
  globalConfig: {
    mode: 2, // 1:默认 2:广告解锁 3:自定义解锁
    buttonText: "解锁"
  },
  chapterConfigs: [
    {
      chapterIndex: 10,
      mode: 3,
      buttonText: "VIP解锁"
    }
  ]
})

// 监听自定义解锁事件
novelManager.onUserClickCustomUnlock(res => {
  console.log('自定义解锁章节:', res.chapterIndex)
})

2. 插入自定义段落

// 在正文中插入自定义内容
novelManager.setParagraphBlock({
  chapterConfigs: [{
    chapterIndex: 0,
    blocks: [{
      height: 100,    // 高度
      position: 1,    // 位置(0:标题前,1:第一段前)
      ext: "自定义数据",
      key: "unique-id"
    }]
  }]
})

3. 广告插入

// 插入广告
novelManager.setAdBlock({
  chapterConfigs: [{
    chapterIndex: 0,
    blocks: [{
      type: 1,        // 1:强制观看 2:banner广告 3:书签广告
      position: 10,   // 广告位置
      duration: 6,    // 强制观看时长(秒)
      unitId: "广告单元ID"
    }]
  }]
})

实用API参考

获取阅读器信息

// 获取当前阅读器实例
const novelManager = novelPlugin.getCurrentNovelManager()

// 获取书籍ID
const bookId = novelManager.getBookId()

// 获取插件信息
const pluginInfo = novelManager.getPluginInfo()

导航控制

// 设置关闭行为
novelManager.setClosePluginInfo({
  url: '/pages/index/index',
  mode: 'redirectTo'
})

// 设置返回行为
novelManager.setLeaveReaderInfo({
  url: '/pages/bookshelf/index',
  mode: 'switchTab'
})

分享配置

novelManager.setShareParams({
  title: '书籍标题',
  imageUrl: '封面图片',
  args: {
    from: 'share',
    time: '2024'
  }
})

书架功能

// 设置书架状态
novelManager.setBookshelfStatus({
  bookshelfStatus: 1 // 0:未添加 1:已添加
})

// 监听书架点击
novelManager.onClickBookshelf(res => {
  // 处理书架逻辑
  novelManager.setBookshelfStatus({
    bookshelfStatus: res.bookshelfStatus ? 1 : 0
  })
})

事件监听

阅读器提供丰富的事件监听:

novelManager.onUserTriggerEvent(res => {
  switch(res.event_id) {
    case 'start_read':          // 开始阅读
    case 'change_chapter':      // 切换章节
    case 'change_fontsize':     // 调整字号
    case 'click_listen':        // 点击听书
    case 'audio_start':         // 音频开始
    // ... 更多事件
  }
})

让大语言模型拥有“记忆”:多轮对话与 LangChain 实践指南

让大语言模型拥有“记忆”:多轮对话与 LangChain 实践指南

在当前人工智能应用开发中,大语言模型(LLM)因其强大的自然语言理解与生成能力,被广泛应用于聊天机器人、智能客服、个人助理等场景。然而,一个常见的问题是:LLM 本身是无状态的——每一次 API 调用都独立于前一次,就像每次 HTTP 请求一样,模型无法“记住”你之前说过什么。

那么,如何让 LLM 拥有“记忆”,实现真正的多轮对话体验?本文将从原理出发,结合 LangChain 框架的实践代码,深入浅出地讲解这一关键技术。


一、为什么 LLM 默认没有记忆?

大语言模型(如 DeepSeek、GPT、Claude 等)在设计上遵循“输入-输出”模式。当你调用其 API 时,仅传递当前的问题或指令,模型不会自动保留之前的交互内容。例如:

ts
编辑
const res1 = await model.invoke('我叫王源,喜欢喝白兰地');
const res2 = await model.invoke('我叫什么名字?');

第二次调用时,模型完全不知道“王源”是谁,因此很可能回答:“我不知道你的名字。”
这是因为两次调用之间没有任何上下文传递。


二、实现“记忆”的核心思路:维护对话历史

要让 LLM 表现出“有记忆”的行为,最直接的方法是:在每次请求中显式传入完整的对话历史。通常以 messages 数组的形式组织:

json
编辑
[  {"role": "user", "content": "我叫王源,喜欢喝白兰地"},  {"role": "assistant", "content": "很高兴认识你,王源!白兰地是很优雅的选择。"},  {"role": "user", "content": "你知道我是谁吗?"}]

模型通过分析整个对话上下文,就能准确回答:“你是王源,喜欢喝白兰地。”

但这种方法存在明显问题:随着对话轮次增加,输入 token 数量不断膨胀,不仅增加计算成本,还可能超出模型的最大上下文长度限制(如 32768 tokens)。这就像滚雪球,越滚越大。


三、LangChain 提供的解决方案:模块化记忆管理

为了解决上述问题,LangChain 等 AI 应用框架提供了专门的 Memory 模块,帮助开发者高效管理对话历史,并支持多种存储策略(内存、数据库、向量化摘要等)。

以下是一个使用 @langchain/deepseekRunnableWithMessageHistory 的完整示例:

1. 初始化模型与提示模板

ts
编辑
import { ChatDeepSeek } from '@langchain/deepseek';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from '@langchain/core/runnables';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';

const model = new ChatDeepSeek({
  model: 'deepseek-chat',
  temperature: 0,
});

// 定义包含历史记录占位符的提示模板
const prompt = ChatPromptTemplate.fromMessages([
  ['system', '你是一个有记忆的助手'],
  ['placeholder', '{history}'], // 历史消息将插入此处
  ['human', '{input}']
]);

2. 构建带记忆的可运行链

ts
编辑
const runnable = prompt.pipe(model);

// 创建内存中的对话历史存储
const messageHistory = new InMemoryChatMessageHistory();

// 封装成支持会话记忆的链
const chain = new RunnableWithMessageHistory({
  runnable,
  getMessageHistory: async () => messageHistory,
  inputMessagesKey: 'input',
  historyMessagesKey: 'history',
});

3. 执行多轮对话

ts
编辑
// 第一轮:用户自我介绍
const res1 = await chain.invoke(
  { input: '我叫王源,喜欢喝白兰地' },
  { configurable: { sessionId: 'makefriend' } }
);
console.log(res1.content); // “你好,王源!白兰地确实很经典。”

// 第二轮:提问名字
const res2 = await chain.invoke(
  { input: '我叫什么名字?' },
  { configurable: { sessionId: 'makefriend' } }
);
console.log(res2.content); // “你叫王源。”

✅ 关键点:sessionId 用于区分不同用户的会话。同一个 sessionId 下的所有交互共享同一段历史。


四、进阶思考:如何优化长对话的记忆效率?

虽然内存存储简单易用,但在生产环境中,面对海量用户和长期对话,我们需要更高效的策略:

  1. 滑动窗口记忆:只保留最近 N 轮对话。
  2. 摘要压缩:定期将历史对话总结成一段摘要,替代原始记录。
  3. 向量数据库 + 语义检索:将关键信息存入向量库,按需检索相关上下文(适用于知识密集型对话)。
  4. 混合记忆:结合短期(最近几轮)+ 长期(摘要/数据库)记忆。

LangChain 已支持多种 Memory 类型,如 BufferWindowMemorySummaryMemoryVectorStoreRetrieverMemory 等,可根据场景灵活选择。


五、结语

让 LLM 拥有“记忆”,本质上是将无状态的模型调用转化为有状态的会话系统。通过维护对话历史并合理控制上下文长度,我们可以在成本与体验之间取得平衡。

LangChain 等框架极大简化了这一过程,使开发者能专注于业务逻辑,而非底层状态管理。未来,随着上下文窗口的扩大和记忆机制的智能化(如自动遗忘、重点记忆),AI 助手将越来越像一个真正“记得你”的朋友。

正如我们在代码中看到的那样——当模型说出“你叫王源”时,那一刻,它仿佛真的记住了你。

鸿蒙分布式KVStore冲突解决机制:原理、实现与工程化实践

鸿蒙分布式KVStore冲突解决机制:原理、实现与工程化实践

一、核心背景与问题定义

分布式KVStore是鸿蒙(HarmonyOS/OpenHarmony)实现跨设备数据协同的核心组件,适用于应用配置、用户状态等轻量级数据的跨设备同步场景。其基于最终一致性模型设计,在多设备并发写入同一Key时,必然产生数据冲突。本文聚焦 SingleKVStore(单版本模式) 的冲突解决机制,明确核心问题边界:当组网内多个设备对同一Key执行写入操作时,如何保证数据最终一致性,同时避免业务关键数据丢失。

二、冲突产生的底层逻辑

1. 冲突触发条件

  • 数据维度:同一SingleKVStore实例中,不同设备对同一Key执行写入(覆盖/更新)操作;

  • 网络维度:设备间网络中断后恢复连接,或多设备同时在线时并发写入;

  • 系统维度:同步过程中数据传输延迟、设备时钟偏差(影响时间戳判断)。

2. 默认冲突解决策略:LWW(Last Write Wins)

鸿蒙默认采用“最后写入者获胜”策略,核心判定依据为数据的写入时间戳(系统时间)或版本号:

  • 判定逻辑:同步时对比同一Key的时间戳,保留时间戳更新的写入数据;

  • 适用场景:配置类数据(如主题设置、字体大小),这类数据对“最新状态”的需求优先于“全量合并”;

  • 局限性:无法处理需要结构化合并的场景(如待办清单数组、多字段对象),且设备时钟偏差可能导致错误的优先级判定。

三、自定义冲突解决:实战实现(Stage模型+ArkTS)

1. 前置准备:权限与依赖

需在module.json5中声明分布式数据操作权限,确保跨设备数据同步能力正常启用:


{

  "module": {

    "requestPermissions": [

      {

        "name": "ohos.permission.DISTRIBUTED_DATASYNC",

        "reason": "跨设备数据同步需要",

        "usedScene": { "ability": ["com.demo.kvstore.DemoAbility"], "when": "always" }

      },

      {

        "name": "ohos.permission.GET_DISTRIBUTED_DEVICE_INFO",

        "reason": "获取组网设备信息需要",

        "usedScene": { "ability": ["com.demo.kvstore.DemoAbility"], "when": "always" }

      }

    ]

  }

}

依赖导入(API 10+,需同步升级SDK至对应版本):


import distributedData from '@ohos.data.distributedData';

import { BusinessError } from '@ohos.base';

import deviceManager from '@ohos.distributedHardware.deviceManager';

2. 核心实现步骤

步骤1:初始化分布式KVStore(指定单版本模式)

class DistributedKVManager {

  private kvStore: distributedData.SingleKVStore | null = null;

  private readonly STORE_NAME = 'demo_business_store'; // 跨设备统一存储名称

  private readonly SECURITY_LEVEL = distributedData.SecurityLevel.S1; // 基础安全等级(非加密)

  


  // 初始化KVStore,确保跨设备共享同一存储实例

  async init(): Promise<boolean> {

    try {

      const options: distributedData.Options = {

        createIfMissing: true,

        encrypt: false,

        securityLevel: this.SECURITY_LEVEL

      };

      // 获取SingleKVStore实例(单版本模式,支持跨设备同步)

      this.kvStore = await distributedData.getSingleKVStore(this.STORE_NAME, options);

      console.info('DistributedKVStore初始化成功');

      this.registerConflictListener(); // 注册冲突监听

      return true;

    } catch (error) {

      const err = error as BusinessError;

      console.error(`KVStore初始化失败:code=${err.code}, message=${err.message}`);

      return false;

    }

  }

}

步骤2:自定义冲突解决逻辑(以待办清单合并为例)

针对结构化数据(如待办清单数组),实现“数组去重合并”的自定义策略,替代默认LWW策略:


private async registerConflictListener() {

  if (!this.kvStore) return;

  


  // 监听所有数据变更(本地+远端),通过业务逻辑识别冲突

  this.kvStore.on('dataChange', distributedData.SubscribeType.SUBSCRIBE_TYPE_ALL, async (data) => {

    for (const entry of data.updateEntries) {

      const key = entry.key;

      const remoteValue = entry.value; // 远端同步过来的新数据

      const localValue = await this.kvStore!.get(key); // 本地当前数据

  


      // 1. 数据格式校验(约定value为{ ver: number, payload: any }结构)

      if (!this.validateDataFormat(localValue) || !this.validateDataFormat(remoteValue)) {

        console.warn(`数据格式非法,采用LWW策略:key=${key}`);

        return;

      }

  


      // 2. 判定冲突:本地与远端版本号不同时视为冲突

      if (localValue.ver !== remoteValue.ver) {

        console.info(`检测到冲突:key=${key},本地版本=${localValue.ver},远端版本=${remoteValue.ver}`);

        const mergedValue = this.mergeTodoList(localValue.payload, remoteValue.payload);

        const newVer = Math.max(localValue.ver, remoteValue.ver) + 1; // 生成新版本号

  


        // 3. 写入合并后的数据(避免循环同步,需原子操作)

        await this.kvStore!.put(key, { ver: newVer, payload: mergedValue });

        await this.kvStore!.flush(); // 强制刷盘,确保同步可靠性

      }

    }

  });

}

  


// 校验数据格式(业务自定义)

private validateDataFormat(data: any): boolean {

  return typeof data === 'object' && data !== null && 'ver' in data && 'payload' in data;

}

  


// 待办清单合并逻辑:去重并保留所有有效条目

private mergeTodoList(localTodo: Array<{ id: string; content: string; completed: boolean }>, 

                      remoteTodo: Array<{ id: string; content: string; completed: boolean }>): Array<any> {

  const todoMap = new Map<string, any>();

  // 先加入本地条目

  localTodo.forEach(todo => todoMap.set(todo.id, todo));

  // 加入远端条目(远端已完成状态优先,避免本地未同步的完成状态丢失)

  remoteTodo.forEach(todo => {

    const existTodo = todoMap.get(todo.id);

    if (existTodo) {

      todoMap.set(todo.id, { ...existTodo, completed: todo.completed });

    } else {

      todoMap.set(todo.id, todo);

    }

  });

  return Array.from(todoMap.values());

}

步骤3:主动同步触发(控制同步时机)

通过sync方法主动触发跨设备数据同步,支持指定同步模式和目标设备:


// 触发同步:向组网内所有设备推送本地数据并拉取远端数据

async syncData(): Promise<boolean> {

  if (!this.kvStore) return false;

  try {

    const syncOptions: distributedData.SyncOptions = {

      syncMode: distributedData.SyncMode.PUSH_PULL, // 推拉模式(双向同步)

      deviceIds: [], // 空数组表示同步至所有组网设备

      delayMs: 100 // 延迟100ms同步,避免频繁写入导致的抖动

    };

    await this.kvStore.sync(syncOptions);

    console.info('数据同步触发成功');

    return true;

  } catch (error) {

    const err = error as BusinessError;

    console.error(`同步失败:code=${err.code}, message=${err.message}`);

    return false;

  }

}

四、工程化最佳实践

1. 冲突规避:存储结构设计原则

  • 拆分Key粒度:将复杂对象拆分为多个独立Key(如todo_list_202506todo_config),减少同一Key的并发写入;

  • 设备维度隔离:非共享数据使用DeviceKVStore(按设备分片存储),天然避免跨设备冲突;

  • 版本号强制递增:约定所有写入操作必须生成新的版本号(如基于时间戳+设备ID),确保冲突判定准确性。

2. 性能优化:减少无效同步

  • 批量同步聚合同步请求:短时间内多次写入后,延迟100-300ms触发同步(通过delayMs配置),减少同步次数;

  • 过滤无效变更:在dataChange监听中,对比数据内容是否真的变化,避免因版本号误判导致的重复合并;

  • 控制数据规模:SingleKVStore建议单Key数据不超过10KB,总条目数不超过1000条,超出场景切换至分布式数据库。

3. 可靠性保障:异常处理与校验

  • 幂等性设计:合并逻辑确保多次执行结果一致,避免同步重试导致的数据重复;

  • 数据备份:关键业务数据定期备份至本地文件,避免KVStore同步异常导致的数据丢失;

  • 冲突日志:记录冲突发生时间、Key、本地/远端数据内容,便于问题排查。

五、常见问题与解决方案

1. 冲突监听不触发

  • 排查方向1:权限未授予(需动态申请DISTRIBUTED_DATASYNC权限,尤其是API 11+版本);

  • 排查方向2:订阅类型错误(需使用SUBSCRIBE_TYPE_ALL监听本地和远端变更);

  • 排查方向3:KVStore实例未正确初始化(确保getSingleKVStore调用成功后再注册监听)。

2. 合并后数据再次冲突

  • 解决方案:写入合并数据时生成全局唯一版本号(如Date.now() + 设备ID后缀),避免不同设备生成相同版本号;

  • 补充措施:同步后触发flush强制刷盘,确保数据持久化后再参与下一轮同步。

3. 设备时钟偏差导致LWW策略失效

  • 解决方案:替换时间戳为“版本号+设备优先级”的判定逻辑(如手机优先级高于手表,相同版本号时保留手机数据);

  • 实现方式:在数据结构中增加devicePriority字段,冲突时优先保留优先级高的设备数据。

六、核心总结

分布式KVStore的冲突解决核心在于“先规避、后解决”:通过合理的Key粒度设计和存储模式选择,减少冲突发生概率;针对无法规避的冲突,基于业务场景实现自定义合并逻辑(如数组合并、字段优先级合并),替代默认LWW策略。开发过程中需重点关注版本号管理、同步时机控制和异常日志记录,确保跨设备数据一致性的同时,保障业务数据可靠性。

Intersection Observer 的实战方案

Intersection Observer 的实战方案

  • 优化目标:长列表首屏渲染性能优化

1- 用户场景

用户行为路径:
1. 打开 AI 工具箱页面
2. 浏览热门工具(首屏)
3. 滚动或点击分类导航查看特定分类工具
4. 点击工具卡片查看详情

关键问题:用户首次进入页面时,大部分工具并不在视口内,但初版实现会一次性渲染所有工具。


初版实现与性能瓶颈

2.1 初版代码结构

// AIToolboxContent.tsx - 初版实现
export default function AIToolboxContent() {
    const [allToolsCategories, setAllToolsCategories] = useState<CategoryWithTools[]>([]);
    
    // 获取所有工具数据
    useEffect(() => {
        const fetchAllTools = async () => {
            const response = await toolApi.getAllTools();
            if (response.code === 200 && response.data) {
                setAllToolsCategories(response.data);
            }
        };
        fetchAllTools();
    }, []);

    return (
        <div className={styles.content}>
            {/* 侧边栏 */}
            <ToggleButton {...props} />
            
            {/* 主内容区 */}
            <div ref={rightContainerRef} className={styles.right}>
                {/* 热门工具 */}
                <ToolList
                    title="热门工具"
                    icon={getCategoryIcon('hot')}
                    tools={featuredTools}
                />

                {/* ! 一次性渲染所有分类 */}
                {allToolsCategories.map((category) => {
                    const categoryTools = category.tools.map((tool) => ({
                        id: tool.id,
                        title: tool.toolName,
                        description: tool.toolDesc,
                        iconUrl: tool.iconUrl,
                    }));

                    return (
                        <ToolList
                            key={category.categoryId}
                            title={category.categoryName}
                            icon={getCategoryIcon(category.categorySlug)}
                            tools={categoryTools}
                        />
                    );
                })}
            </div>
        </div>
    );
}

2.2 组件层级结构

AIToolboxContent (父组件)
├── ToggleButton (侧边栏)
└── RightContainer (主内容区)
    ├── ToolList (热门工具)
    │   └── ToolCard × 20
    ├── ToolList (AI 写作)
    │   └── ToolCard × 35
    ├── ToolList (图像处理)
    │   └── ToolCard × 42
    ├── ToolList (视频生成)
    │   └── ToolCard × 28
    └── ... (共 15+ 个 ToolList)
        └── ToolCard × N 

2.3 性能瓶颈分析

问题 1:首屏渲染时间长
初版性能指标(1000+ 工具):
- 首次渲染时间:2.5s - 3.5s
- DOM 节点数:3000+
- 内存占用:~80MB
- FCP (First Contentful Paint):1.8s
- TTI (Time to Interactive):3.2s
问题 2:无效渲染

问题描述

  • 用户首屏只能看到 "热门工具" 和前 1-2 个分类(约 50 个工具)
  • 但浏览器需要渲染全部 1000+ 个工具卡片
  • 剩余 950+ 个不在视口内的组件完全是无效渲染
问题 3:内存浪费
// 每个 ToolCard 组件的内存占用估算
单个 ToolCard 组件:
- React Fiber 节点:~1KB
- DOM 节点:~2KB
- 图片资源:~50KB (懒加载前)
- 事件监听:~0.5KB

1000ToolCard 总计:
- React 内存:~1MB
- DOM 内存:~2MB
- 图片内存:~50MB (未优化)
- 总计:~53MB (仅工具卡片部分)
问题 4:滚动性能差
  • 初次渲染后,滚动时会出现轻微卡顿
  • 长列表导致浏览器重排(reflow)计算复杂

3.1 方案设计

核心思路
1. 初始状态:只渲染首屏内容(热门工具 + 占位符)
2. 监听滚动:使用 Intersection Observer 监听每个分类容器
3. 触发加载:当分类容器即将进入视口时(提前 200px)
4. 渲染内容:替换占位符为实际的 ToolList 组件
5. 保持状态:已加载的分类保持渲染状态
数据流设计
// 状态设计
interface State {
  // 所有工具数据(一次性获取)
  allToolsCategories: CategoryWithTools[];
  
  // 记录哪些分类已经可见
  visibleCategories: Set<string>; // ['hot', 'writing', 'image']
}

// 渲染逻辑
function render() {
  allToolsCategories.map(category => {
    if (visibleCategories.has(category.slug)) {
      // 已可见 => 渲染完整 ToolList
      return <ToolList {...category} />;
    } else {
      // 未可见 => 渲染占位符
      return <Placeholder />;
    }
  });
}
架构设计
┌─────────────────────────────────────────┐
│        AIToolboxContent (Container)      │
│  ┌─────────────────────────────────┐   │
│  │  Intersection Observer Setup     │   │
│  │  - rootMargin: 200px            │   │
│  │  - threshold: 0.01               │   │
│  └─────────────────────────────────┘   │
│                                          │
│  ┌─────────────────────────────────┐   │
│  │  State Management               │   │
│  │  - visibleCategories: Set       │   │
│  │  - toolListRefs: Map            │   │
│  └─────────────────────────────────┘   │
│                                          │
│  ┌─────────────────────────────────┐   │
│  │  Render Logic                   │   │
│  │  - Conditional Rendering         │   │
│  │  - Placeholder / ToolList        │   │
│  └─────────────────────────────────┘   │
└─────────────────────────────────────────┘

        ↓ Scroll Event ↓

┌─────────────────────────────────────────┐
│      IntersectionObserver Callback       │
│  - Detect Intersection                   │
│  - Update visibleCategories             │
│  - Trigger Re-render                    │
└─────────────────────────────────────────┘

核心实现

4.1 状态管理

export default function AIToolboxContent() {
    // 所有工具数据(按分类组织)
    const [allToolsCategories, setAllToolsCategories] = useState<CategoryWithTools[]>([]);
    
    // 关键状态:记录哪些分类已经可见(进入过视口)
    // 初始值包含 'hot',确保热门工具立即渲染
    const [visibleCategories, setVisibleCategories] = useState<Set<string>>(
        new Set(['hot'])
    );

    // 右侧滚动容器的引用(作为 Intersection Observer 的 root)
    const rightContainerRef = useRef<HTMLDivElement>(null);
    
    // 存储每个 ToolList 的 DOM 引用(用于滚动和观察)
    const toolListRefs = useRef<Map<string, HTMLDivElement>>(new Map());
    
    // Intersection Observer 实例
    const observerRef = useRef<IntersectionObserver | null>(null);
}

5.2 Ref 管理系统

// 设置 ToolList ref 的辅助函数
// 使用 useCallback 避免每次渲染都创建新函数
const setToolListRef = useCallback((slug: string) => (element: HTMLDivElement | null) => {
    if (element) {
        // 元素挂载:保存引用并开始观察
        toolListRefs.current.set(slug, element);
        
        //  关键:立即将元素添加到 Observer 监听列表
        if (observerRef.current) {
            observerRef.current.observe(element);
        }
    } else {
        // 元素卸载:清理引用和监听
        const oldElement = toolListRefs.current.get(slug);
        if (oldElement && observerRef.current) {
            observerRef.current.unobserve(oldElement);
        }
        toolListRefs.current.delete(slug);
    }
}, []);

设计要点

  1. 闭包捕获:使用柯里化(Currying)传递 slug 参数
  2. 自动观察:元素挂载时自动添加到 Observer
  3. 清理机制:元素卸载时自动移

事件委托(Event Delegation)的原理

优化前的问题

原来的代码是这样的:

{allCategories.map((category) => (
  <div
    key={category.id}
    className={styles.categoryItem}
    onClick={() => onCategoryChange(category.slug)}  // ❌ 每个 item 都创建新函数
  >
    {/* ... */}
  </div>
))}

问题分析:

  1. 每次渲染都会为每个分类项创建一个新的箭头函数:() => onCategoryChange(category.slug)
  1. 假设有 20 个分类,就会创建 20 个函数,这些函数会形成闭包,占用内存
  1. 每次组件重新渲染时,React 会认为 onClick 是一个新的函数引用,可能导致不必要的子组件重新渲染
  1. 虽然通常不是性能瓶颈,但在分类数量很多时,这会造成不必要的开销

二、事件委托(Event Delegation)的原理

事件委托将事件处理函数绑定到父容器上,而不是每个子元素。利用事件冒泡,点击子元素时,事件会冒泡到父容器,父容器的事件处理函数接收事件,并根据目标元素执行相应逻辑。事件冒泡示意图:

点击子元素 → 事件冒泡 → 父容器捕获事件 → 根据 target 判断处理逻辑

三、优化后的实现

1. 在父容器上绑定统一的事件处理函数

 // 处理分类列表点击 - 使用事件委托
  const handleCategoryListClick = (e: React.MouseEvent<HTMLDivElement>) => {
    const target = e.target as HTMLElement;
    // 向上查找最近的 categoryItem 元素
    const categoryItem = target.closest(`.${styles.categoryItem}`);
    if (categoryItem) {
      const categorySlug = categoryItem.getAttribute('data-category-slug');
      if (categorySlug) {
        onCategoryChange(categorySlug);
      }
    }
  };

函数执行流程:

  1. e.target 获取被点击的具体元素(可能是图标、文字等)
  1. closest() 向上查找最近的 .categoryItem 元素
  1. 从该元素读取 data-category-slug 属性
  1. 调用 onCategoryChange(categorySlug)

2. 在父容器上绑定事件

      <div className={styles.categoryList} onClick={handleCategoryListClick}>

3. 为每个 item 添加 data 属性

  data-category-slug={category.slug}

使用 data-* 属性存储分类标识,符合 HTML5 规范。

四、代码执行流程示例

假设用户点击了"热门工具"分类项:

1. 用户点击 "热门工具" 文字
   ↓
2. 事件冒泡到 <div className={styles.categoryList}>
   ↓
3. handleCategoryListClick 函数被触发
   ↓
4. e.target = <span className={styles.categoryName}>热门工具</span>
   ↓
5. target.closest('.categoryItem') 向上查找
   → 找到 <div className={styles.categoryItem} data-category-slug="hot">
   ↓
6. categoryItem.getAttribute('data-category-slug') 
   → 返回 "hot"
   ↓
7. 调用 onCategoryChange("hot")
   ↓
8. 完成分类切换

五、为什么使用 closest()?

因为点击可能发生在 item 内部的任意元素上(图标、文字等)。closest() 可以向上查找最近的匹配元素:

<div className={styles.categoryItem} data-category-slug="hot">
  <div className={styles.categoryContent}>
    <div className={styles.categoryIcon}>ICON</div>  // ← 可能点击这里
    <span>热门工具</span>                          // ← 也可能点击这里
  </div>
</div>

无论点击图标还是文字,closest() 都能找到外层的 categoryItem。

### 六、其他优化方案的对比

  1. 创建单独的子组件 + useCallback
  • 优点:封装性好
  • 缺点:增加了组件层级和复杂度

JS复杂去重一定要先排序吗?深度解析与性能对比

引言

在日常开发中,数组去重是JavaScript中常见的操作。对于简单数据类型,我们通常会毫不犹豫地使用Set。但当面对复杂对象数组时,很多开发者会产生疑问:复杂去重一定要先排序吗?

这个问题背后其实隐藏着几个更深层次的考量:

  • 排序是否会影响原始数据顺序?
  • 排序的性能开销是否值得?
  • 是否有更优雅的解决方案?

1. 常见的排序去重方案

1.1 传统的排序去重思路
// 先排序后去重的经典写法
function sortThenUnique(arr, key) {
  return arr
    .slice()
    .sort((a, b) => {
      // 避免修改原始数组
      const valueA = key ? a[key] : a;
      const valueB = key ? b[key] : b;
      if (valueA < valueB) return -1;
      if (valueA > valueB) return 1;
      return 0;
    })
    .filter((item, index, array) => {
      if (index === 0) return true; // 保留第一个元素
      const value = key ? item[key] : item;
      const prevValue = key ? array[index - 1][key] : array[index - 1];
      return value !== prevValue; // 仅保留与前一个元素不同的元素
    });
}
1.2 排序去重的优缺点

优点:

  • 代码逻辑相对直观
  • 对于已排序或需要排序的数据,可以一步完成
  • 在某些算法题中可能是必要步骤

缺点:

  • 时间复杂度至少为 O(n log n)
  • 改变了原始数据的顺序
  • 对于不需要排序的场景是额外开销

2. 不排序的去重方案

2.1 基于Map的保持顺序方案
function uniqueByKey(arr, key) {
  const seen = new Map();
  const result = [];

  for (const item of arr) {
    const keyValue = item[key];
    if (!seen.has(keyValue)) {
      seen.set(keyValue, true);
      result.push(item);
    }
  }
  return result;
}

// 支持多个字段的复合键
function uniqueByMultipleKeys(arr, keys) {
  const seen = new Set();
  return arr.filter((item) => {
    const compositeKey = keys.map((key) => item[key]).join("|");
    if (seen.has(compositeKey)) {
      return false;
    }
    seen.add(compositeKey);
    return true;
  });
}
2.2 基于对象的缓存方案
function uniqueByKeyWithObject(arr, key) {
  const cache = {};
  return arr.filter((item) => {
    const keyValue = item[key];
    if (cache[keyValue]) {
      return false;
    }
    cache[keyValue] = true;
    return true;
  });
}
2.3 基于自定义比较函数的方案
function uniqueWithCustomComparator(arr, comparator) {
  return arr.filter((current, index, self) => {
    // 查找第一个相同元素的位置
    return self.findIndex((item) => comparator(item, current)) === index;
  });
}

// 使用示例
const users = [
  { id: 1, name: "Alice", age: 25 },
  { id: 2, name: "Bob", age: 30 },
  { id: 1, name: "Alice", age: 25 }, // 重复
  { id: 1, name: "Alice", age: 26 }, // ID相同但年龄不同
];

const uniqueUsers = uniqueWithCustomComparator(
  users,
  (a, b) => a.id === b.id && a.name === b.name
);

console.log(uniqueUsers);
// [ { id: 1, name: 'Alice', age: 25 }, { id: 2, name: 'Bob', age: 30 } ]

3. 性能对比分析

3.1 时间复杂度对比
方法 时间复杂度 空间复杂度 是否保持顺序
排序后去重 O(n log n) O(1) 或 O(n)
Map去重 O(n) O(n)
对象缓存去重 O(n) O(n)
filter + findIndex O(n²) O(1)
3.2 实际性能测试
// 性能测试代码示例
function generateTestData(count) {
  return Array.from({length: count}, (_, i) => ({
    id: Math.floor(Math.random() * count / 10), // 产生大量重复
    value: `item-${i}`,
    data: Math.random()
  }));
}

function runPerformanceTest() {
  const data = generateTestData(10000);
  
  console.time('Map去重');
  uniqueByKey(data, 'id');
  console.timeEnd('Map去重');
  
  console.time('排序去重');
  sortThenUnique(data, 'id');
  console.timeEnd('排序去重');
  
  console.time('filter+findIndex');
  uniqueWithCustomComparator(data, (a, b) => a.id === b.id);
  console.timeEnd('filter+findIndex');
}

测试结果趋势:

  • 数据量<1000:各种方法差异不大
  • 数据量1000-10000:Map方案明显占优
  • 数据量>10000:排序方案开始显现劣势

4. 应用场景与选择建议

4.1 什么时候应该考虑排序?
1.需要有序输出时
// 既要去重又要按特定字段排序
const getSortedUniqueUsers = (users) => {
  const uniqueUsers = uniqueByKey(users, 'id');
  return uniqueUsers.sort((a, b) => a.name.localeCompare(b.name));
};
2. 数据本身就需要排序时
// 如果业务本来就需要排序,可以合并操作
const processData = (data) => {
  // 先排序便于后续处理
  data.sort((a, b) => a.timestamp - b.timestamp);
  // 去重
  return uniqueByKey(data, 'id');
};
3.处理流式数据时
// 实时数据流,需要维持有序状态
class SortedUniqueCollection {
  constructor(key) {
    this.key = key;
    this.data = [];
    this.seen = new Set();
  }
  
  add(item) {
    const keyValue = item[this.key];
    if (!this.seen.has(keyValue)) {
      this.seen.add(keyValue);
      // 插入到正确位置维持有序
      let index = 0;
      while (index < this.data.length && 
             this.data[index][this.key] < keyValue) {
        index++;
      }
      this.data.splice(index, 0, item);
    }
  }
}
4.2 什么时候应该避免排序?
1.需要保持原始顺序时
// 日志记录、时间线数据等
const logEntries = [
  {id: 3, time: '10:00', message: '启动'},
  {id: 1, time: '10:01', message: '初始化'},
  {id: 3, time: '10:02', message: '启动'}, // 重复
  {id: 2, time: '10:03', message: '运行'}
];

// 保持时间顺序很重要!
const uniqueLogs = uniqueByKey(logEntries, 'id');
2.性能敏感的应用
// 实时渲染大量数据
function renderItems(items) {
  // 使用Map去重避免不必要的排序开销
  const uniqueItems = uniqueByKey(items, 'id');
  // 快速渲染
  return uniqueItems.map(renderItem);
}
3. 数据不可变要求
// React/Vue等框架中,避免改变原数组
const DeduplicatedList = ({ items }) => {
  // 不改变原始数据
  const uniqueItems = useMemo(
    () => uniqueByKey(items, 'id'),
    [items]
  );
  return <List items={uniqueItems} />;
};

5. 高级技巧和优化

5.1 惰性去重迭代器
function* uniqueIterator(arr, getKey) {
  const seen = new Set();
  for (const item of arr) {
    const key = getKey(item);
    if (!seen.has(key)) {
      seen.add(key);
      yield item;
    }
  }
}

// 使用示例
const data = [...]; // 大数据集
for (const item of uniqueIterator(data, x => x.id)) {
  // 逐个处理,节省内存
  processItem(item);
}
5.2 增量去重
class IncrementalDeduplicator {
  constructor(key) {
    this.key = key;
    this.seen = new Map();
    this.count = 0;
  }
  
  add(items) {
    return items.filter(item => {
      const keyValue = item[this.key];
      if (this.seen.has(keyValue)) {
        return false;
      }
      this.seen.set(keyValue, ++this.count); // 记录添加顺序
      return true;
    });
  }
  
  getAddedOrder(keyValue) {
    return this.seen.get(keyValue);
  }
}
5.3 内存优化版本
function memoryEfficientUnique(arr, key) {
  const seen = new Map();
  const result = [];
  
  // 使用WeakMap处理对象键
  const weakMap = new WeakMap();
  
  for (let i = 0; i < arr.length; i++) {
    const item = arr[i];
    const keyValue = item[key];
    
    // 对于对象类型的键值,使用WeakMap
    if (typeof keyValue === 'object' && keyValue !== null) {
      if (!weakMap.has(keyValue)) {
        weakMap.set(keyValue, true);
        result.push(item);
      }
    } else {
      if (!seen.has(keyValue)) {
        seen.set(keyValue, true);
        result.push(item);
      }
    }
  }
  
  return result;
}

6. 实战案例分析

6.1 电商商品去重
// 场景:合并多个来源的商品数据
const productsFromAPI = [...];
const productsFromCache = [...];
const userUploadedProducts = [...];

// 需求:按商品SKU去重,保持最新数据
function mergeProducts(productLists) {
  const merged = [];
  const skuMap = new Map();
  
  // 按优先级处理(后处理的优先级高)
  productLists.forEach(list => {
    list.forEach(product => {
      const existing = skuMap.get(product.sku);
      if (!existing || product.updatedAt > existing.updatedAt) {
        if (existing) {
          // 移除旧的
          const index = merged.findIndex(p => p.sku === product.sku);
          merged.splice(index, 1);
        }
        merged.push(product);
        skuMap.set(product.sku, product);
      }
    });
  });
  
  return merged;
}
6.2 实时消息去重
// 场景:聊天应用消息去重
class MessageDeduplicator {
  constructor(timeWindow = 5000) {
    this.timeWindow = timeWindow;
    this.messageIds = new Set();
    this.timestamps = new Map();
  }
  
  addMessage(message) {
    const now = Date.now();
    const { id } = message;
    
    // 清理过期记录
    this.cleanup(now);
    
    // 检查是否重复
    if (this.messageIds.has(id)) {
      return false;
    }
    
    // 添加新记录
    this.messageIds.add(id);
    this.timestamps.set(id, now);
    return true;
  }
  
  cleanup(now) {
    for (const [id, timestamp] of this.timestamps) {
      if (now - timestamp > this.timeWindow) {
        this.messageIds.delete(id);
        this.timestamps.delete(id);
      }
    }
  }
}

结论

回到最初的问题:JS复杂去重一定要先排序吗?

答案是否定的。 排序只是众多去重策略中的一种,而非必需步骤。

我的建议:

  1. 默认使用Map方案: 对于大多数场景,基于Map或Set的去重方法在性能和功能上都是最佳选择。
  2. 根据需求选择:
  • 需要保持顺序 → 使用Map
  • 需要排序结果 → 先排序或后排序
  • 数据量很大 → 考虑迭代器或流式处理
  • 内存敏感 → 使用WeakMap或定期清理
  1. 考虑可读性和维护性: 有时清晰的代码比微小的性能优化更重要。
  2. 进行实际测试: 在性能关键路径上,用真实数据测试不同方案。

实践总结:

// 通用推荐方案
function deduplicate(arr, identifier = v => v) {
  const seen = new Set();
  return arr.filter(item => {
    const key = typeof identifier === 'function' 
      ? identifier(item)
      : item[identifier];
    
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

// 需要排序时的方案
function deduplicateAndSort(arr, key, sortBy) {
  const unique = deduplicate(arr, key);
  return unique.sort((a, b) => {
    const aVal = a[sortBy];
    const bVal = b[sortBy];
    return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
  });
}

记住,没有银弹。最合适的去重方案取决于你的具体需求:数据规模、顺序要求、性能需求和代码上下文。希望这篇文章能帮助你在面对复杂去重问题时做出明智的选择!

别再让大模型“胡说八道”了!LangChain 的 JsonOutputParser 教你驯服 AI 输出

从“前端模块化”到“AI输出格式”,我们都在和混乱做斗争**


🧠 引子:当 AI 开始“自由发挥”

你有没有遇到过这样的场景?

你辛辛苦苦写了一堆提示词(prompt),满怀期待地调用大模型,结果它回你一段:

“好的!关于 Promise,这是一个 JavaScript 中用于处理异步操作的对象。它的核心思想是……(省略 300 字小作文)”

而你真正想要的,只是一个干净、结构化的 JSON!

{
  "name": "Promise",
  "core": "用于处理异步操作的代理对象",
  "useCase": ["网络请求", "定时任务", "并发控制"],
  "difficulty": "中等"
}

是不是想砸键盘?别急——LangChain 的 JsonOutputParser 就是来拯救你的!


📦 背景小剧场:从前端模块化说起

在讲 LangChain 之前,咱们先穿越回“远古时代”的前端开发。

🕰️ 没有模块化的年代

<script src="a.js"></script>
<script src="b.js"></script>
<script>
  const p = new Person('张三', 18);
  p.sayName(); // 希望 a.js 里定义了 Person...
</script>

那时候,JS 文件之间靠“默契”共享变量,一不小心就全局污染、命名冲突、依赖顺序错乱……简直是“混沌宇宙”。

后来,Node.js 带来了 CommonJS,ES6 推出了 import/export,前端终于有了清晰的模块边界。

模块化 = 约定 + 结构 + 可预测性

而今天,我们在调用大模型时,也面临同样的问题:输出太“自由”,缺乏结构
于是,LangChain 给我们带来了“AI 世界的模块化工具”——OutputParser


🔧 LangChain 的救星:JsonOutputParser

JsonOutputParser 是 LangChain 提供的一个输出解析器,专门用来把 LLM 返回的“散文”变成结构化数据(比如 JSON)。配合 Zod(一个超好用的 TypeScript 校验库),还能自动校验字段类型、枚举值、数组结构等。

✨ 它能做什么?

  • 强制模型只输出 JSON(通过提示词约束)
  • 自动解析字符串为 JS 对象
  • 用 Zod Schema 验证数据合法性
  • 报错时告诉你哪里不符合预期(而不是默默返回 undefined)

🛠️ 实战:用 LangChain 解析“前端概念”

假设我们要让 AI 解释一个前端概念(比如 Promise),并返回标准化 JSON。

第一步:定义 Schema(用 Zod)

import { z } from 'zod';

const FrontendConceptSchema = z.object({
  name: z.string().describe("概念名称"),
  core: z.string().describe("核心要点"),
  useCase: z.array(z.string()).describe("常见使用场景"),
  difficulty: z.enum(['简单', '中等', '复杂']).describe("学习难度")
});

这就像给 AI 发了一份“填空试卷”,还规定了每道题只能填什么类型!

第二步:创建 Parser 和 Prompt

import { JsonOutputParser } from '@langchain/core/output_parsers';
import { PromptTemplate } from '@langchain/core/prompts';

const jsonParser = new JsonOutputParser(FrontendConceptSchema);

const prompt = PromptTemplate.fromTemplate(`
你是一个只会输出 JSON 的 API,不允许输出任何解释性文字。

⚠️ 你必须【只返回】符合以下 Schema 的 JSON:
- 不允许增加或减少字段
- 字段名必须完全一致
- 返回结果必须能被 JSON.parse 成功解析

{format_instructions}

前端概念:{topic}
`);

注意 {format_instructions} ——这是 JsonOutputParser 自动生成的格式说明,会告诉模型具体该怎么写 JSON!

比如它可能生成:

The output should be a markdown code snippet formatted in the following schema:

{
  "name": "string",
  "core": "string",
  "useCase": ["string", ...],
  "difficulty": "简单" | "中等" | "复杂"
}

第三步:组装 Chain 并调用

const chain = prompt.pipe(model).pipe(jsonParser);

const response = await chain.invoke({
  topic: 'Promise',
  format_instructions: jsonParser.getFormatInstructions(),
});

console.log(response);
// ✅ 得到干净的 JS 对象!

😂 为什么这很重要?——因为 AI 太“人性化”了!

大模型天生喜欢“聊天”,它总想多说几句:“亲,你还想知道 async/await 吗?”
但我们的程序需要的是确定性,不是“贴心客服”。

AI 的自由 = 程序员的噩梦
结构化的输出 = 自动化的基石

JsonOutputParser,相当于给 AI 戴上“嘴套”,只让它吐 JSON,不许废话!


🚀 进阶思考:不只是 JSON,更是契约

其实,JsonOutputParser 背后的思想,和前端模块化、API 设计、TypeScript 类型系统一脉相承:

明确输入输出,才能构建可靠系统。

当你用 Zod 定义 Schema 时,你不仅是在约束 AI,更是在建立人与 AI 之间的契约。这个契约让后续的数据处理、UI 渲染、数据库存储变得安全可靠。


✅ 总结:三句话记住 JsonOutputParser

  1. AI 天生爱啰嗦,JsonOutputParser 让它闭嘴只吐 JSON。
  2. Zod Schema 是“法律”,parser 是“警察”,确保输出合法合规。
  3. 结构化输出 = 自动化流水线的第一块砖。

VueCropper加载OBS图片跨域问题

问题场景

在集成 VueCropper 图片裁剪组件时,遇到一个典型的跨域问题:加载存储在 OBS 上的图片时,浏览器会对同一张图片发起两次请求,最终第二次请求触发跨域错误。以下是问题的完整排查过程与现象梳理:

  1. 请求行为异常:同一张 OBS 图片被浏览器发起两次请求,第一次请求正常响应,第二次请求直接抛出跨域相关错误(如 CORS policy: No 'Access-Control-Allow-Origin' header);
  2. 初步排查方向:初期优先怀疑 OBS 服务器跨域配置缺失,反馈运维核查后,确认 OBS 已正确配置跨域允许规则(含允许当前前端域名、支持 GET 方法等);
  3. 关键测试突破:通过浏览器开发者工具测试发现,开启「停用缓存」功能后,跨域错误消失。据此锁定问题根源与浏览器缓存机制相关;
  4. 核心差异定位:对比两次请求的详细信息,发现跨域标识配置不一致——第一次加载图片未设置 crossOrigin 标识,第二次加载时则添加了该标识;
  5. 问题逻辑闭环:第一次请求因无 crossOrigin 标识,OBS 服务器识别为非跨域请求,未返回跨域响应头;第二次请求虽开启 crossOrigin,但因图片 URL 未变,浏览器直接复用缓存的无跨域响应头资源,导致跨域校验失败。

问题根源

为验证上述排查结论,通过原生 JavaScript 代码复现了问题场景,最终确认问题根源为 跨域策略与浏览器缓存冲突

window.addEventListener('DOMContentLoaded', () => {
    const image = new Image();
    // 第一次加载:未设置 crossOrigin 标识
    image.src = 'https://xxx.png'; 
    image.addEventListener('load', () => {
        console.log('图片加载完成');
        const image2 = new Image();
        // 第二次加载:设置跨域标识(与第一次不一致)
        image2.crossOrigin = "anonymous";
        // 加载同一张图片,触发跨域错误
        image2.src = image.src; 
        image2.addEventListener('load', () => {
            console.log('图片2加载完成');
        });
    });
});

该问题的核心矛盾是「同一张图片的两次请求采用不一致的跨域策略」,结合浏览器缓存机制与 OBS 服务器的跨域响应规则,具体错误逻辑可拆解为两步:

  1. 非跨域模式缓存:第一次加载未设置 crossOrigin,浏览器以「非跨域模式」发起请求(请求头 Sec-Fetch-Mode = no-cors);OBS 服务器识别该模式后,不返回 Access-Control-Allow-Origin 等跨域响应头,浏览器则将这份「无跨域响应头的图片资源」缓存至本地;
  2. 跨域模式缓存冲突:第二次加载设置 crossOrigin: "anonymous",浏览器需以「跨域模式」请求;但因图片 URL 未变,浏览器直接复用本地缓存的无跨域响应头资源,跨域校验无法通过,最终触发错误。

关键结论:同一张图片的所有请求,跨域标识必须完全统一(要么所有请求都设 crossOrigin,要么都不设),否则会因缓存机制导致跨域策略冲突。

解决办法

1. 核心通用方案:统一跨域标识(推荐首选)

思路:统一所有加载该图片的 Image 实例的跨域配置(均设置或均不设置 crossOrigin),确保两次请求的跨域策略一致,从根源避免缓存与跨域规则的冲突。

核心注意点:crossOrigin 必须在 src 赋值 之前 设置。若先赋值 src,浏览器会提前以默认模式发起请求,仍会触发跨域冲突。

正确代码示例:

window.addEventListener('DOMContentLoaded', () => {
  // 第一次加载:提前设置 crossOrigin,统一跨域策略
  const image = new Image();
  image.crossOrigin = "anonymous"; 
  image.src = 'https://xxx.png';

  image.addEventListener('load', () => {
      console.log('图片加载完成');
      // 第二次加载:保持 crossOrigin 配置一致
      const image2 = new Image();
      image2.crossOrigin = "anonymous"; 
      image2.src = image.src;

      image2.addEventListener('load', () => {
          console.log('图片2加载完成');
      });
  });

  // 添加错误监听,便于问题排查
  image.addEventListener('error', (e) => console.error('图片1加载失败:', e));
  image2.addEventListener('error', (e) => console.error('图片2加载失败:', e));
});

适用场景:绝大多数前端场景(包括 VueCropper 等依赖 canvas 的裁剪组件场景),且图片存储端(OBS/CDN)已配置跨域允许规则。

2. 绕过缓存方案:让第二次请求视为「新资源」

思路:若历史代码无法批量修改 crossOrigin 配置,可通过让第二次请求「避开缓存」,迫使浏览器重新向 OBS 服务器发起请求(而非复用旧缓存),从而避免跨域策略冲突。

推荐方案:给图片 URL 添加随机参数(如时间戳),使浏览器识别为「新资源」,触发全新请求:

image.addEventListener('load', () => {
  const image2 = new Image();
  image2.crossOrigin = "anonymous";
  // 追加时间戳参数,避开浏览器缓存
  image2.src = `${image.src}&t=${Date.now()}`; 
  image2.addEventListener('load', () => console.log('图片2加载完成'));
});

适用场景:历史代码架构复杂,无法批量统一 crossOrigin 配置,需快速临时修复跨域问题。

缺点:完全失去缓存优势,每次请求均需重新下载图片,会增加带宽消耗并延长页面加载时间,仅建议临时使用。

3. 后端/存储端方案:从根源消除跨域

思路:通过后端代理或域名绑定,将「跨域图片请求」转为「同源请求」,从根源上消除跨域问题,无需前端额外配置 crossOrigin

具体做法:

  1. 同源代理(推荐):前端请求自身后端的代理接口,由后端代为拉取 OBS 图片并返回给前端。此时前端接收的图片为同源资源,无需任何跨域配置。

示例(Node.js/Express 代理):

// 后端代理代码(Node.js/Express)
const express = require('express');
const axios = require('axios');
const app = express();

// 图片代理接口:接收前端传递的 OBS 图片 URL
app.get('/proxy-image', async (req, res) => {
  const imgUrl = req.query.url;
  try {
      // 后端请求 OBS 图片,以流形式返回给前端
      const response = await axios.get(imgUrl, { responseType: 'stream' });
      response.data.pipe(res);
  } catch (err) {
      res.status(404).send('图片加载失败');
  }
});

app.listen(3000, () => console.log('代理服务启动在 3000 端口'));

前端代码:

// 前端代码:请求后端代理接口,无跨域问题
const image = new Image();
image.src = `http://localhost:3000/proxy-image?url=${encodeURIComponent('https://xxx.png')}`;
image.addEventListener('load', () => console.log('图片加载完成'));

适用场景:前端需大量处理跨域图片(如批量裁剪、像素操作);追求稳定可靠的跨域解决方案,不希望依赖前端代码配置。

优点:彻底解决跨域问题,前端无需任何跨域相关配置;缓存机制可正常生效,不影响加载性能;安全性更高(避免设置 * 允许所有域名跨域)。

在 React 里优雅地 “隐藏 iframe 滚动条”

前端有一个经典问题:

你在宿主页面怎么写 CSS,都管不到 iframe 内部的滚动条。

所以正确的前端方案不是 “给外层容器加 overflow”,而是:尽量在 iframe 自己层面兜底 + 同源时向 iframe 内注入 CSS

本文只聚焦前端实现,不展开前后端传参链路。


1. 为什么你明明设置了,滚动条还是在?

因为滚动条来自 iframe 内部 document

  • 外层 divoverflow-hidden 只能裁剪 “iframe 这个盒子” 是否溢出
  • iframe 里面的页面是否出现滚动条,取决于 iframe 内部的 html/body 或某个容器的 overflow
  • 宿主页面的 CSS 不会跨 document 生效(iframe 天生隔离)

2. 我们最终用了 “两层方案”,解决现实世界的不确定性

实现集中在 src/components/Search/ViewExtensionIframe.tsx:1-88

2.1 第一层:scrolling="no" 作为低成本兜底

<iframe scrolling={hideScrollbar ? "no" : "auto"} />

它不是现代标准,但在某些 WebView/嵌入环境里仍能减少滚动条出现的概率。

它的价值在于:不依赖同源,哪怕你进不去 iframe,也能 “碰一碰运气”。

2.2 第二层(主力):同源时注入一段隐藏滚动条的 CSS

核心是这段逻辑(同文件 applyHideScrollbarToIframe):

  • src/components/Search/ViewExtensionIframe.tsx:5-39

做法很直接:

  1. 拿到 iframe.contentDocument
  2. 往里面塞一个带固定 id<style>
  3. 开关关闭时把这个 <style> 删掉

注入的 CSS 同时覆盖主流引擎:

* {
  scrollbar-width: none;      /* Firefox */
  -ms-overflow-style: none;   /* 老 IE/Edge 风格 */
}
*::-webkit-scrollbar {        /* Chrome/Safari */
  width: 0px;
  height: 0px;
}

为什么用 *

  • 扩展页面的滚动容器不一定是 body,可能是任意 div overflow-auto
  • * 能最大概率“全场隐藏”,更通用

为什么要固定 id

  • 防止重复注入(多次 onLoad / 状态变化)
  • 关闭时能精确移除,保证可逆

3. 为什么要 “onLoad + useEffect” 双触发?

这是最容易漏、也最影响体验的一点。

  • useEffect:响应 hideScrollbar 变化(开关切换时立刻生效)
  • onLoad:保证“首次加载完成后一定注入成功”

原因:iframe 的加载时序不可控。你在 React 渲染完时,iframe 可能还没 ready,contentDocument 为空;等到 onLoad 才能 100% 确认 document 存在。

对应代码:

  • src/components/Search/ViewExtensionIframe.tsx:52-55
  • src/components/Search/ViewExtensionIframe.tsx:78-84

4. 这个方案的边界与坑(提前写清楚,后面少掉头发)

4.1 “隐藏滚动条” 不等于 “不能滚”

我们只隐藏 scrollbar 的视觉表现,滚动行为仍存在(滚轮/触摸板/键盘都能滚)。
这在沉浸式页面很舒服,但在长页面里也可能让用户不知道 “还能滚”。

4.2 跨域 iframe:注入会失败,但不会炸

如果 iframe 加载跨域页面,访问 contentDocument 会触发同源限制。当前实现用 try/catch 静默吞掉异常,结果是:

  • CSS 注入失败
  • 只剩 scrolling="no" 兜底,效果不保证

这不是 bug,是浏览器安全模型决定的。

4.3 全局 * 的副作用

它会把 iframe 内所有滚动条都干掉,包括某些组件内部滚动区域、代码块滚动等。
目前我们选择“通用性优先”,但如果未来某些扩展需要保留局部滚动条,就要改为更精确的选择器策略。


5. 一句话总结

在前端想稳定控制 iframe 滚动条,最靠谱的思路是:

  • 先用 iframe 自身属性做兜底
  • 再在同源条件下对 iframe 内部 document 注入 CSS
  • 用固定 style id 保证幂等与可逆
  • 用 onLoad + effect 解决时序问题

这就是 hideScrollbar 在前端真正解决的问题:不是写没写 CSS,而是有没有把 CSS 写到“对的世界里”。

LangChain Memory 实战指南:让大模型记住你每一句话,轻松打造“有记忆”的AI助手


引言

你有没有遇到过这样的尴尬?
向 AI 提问:“我叫张三”,下一秒再问“我叫什么名字?”——它居然说:“抱歉,我不记得了。” 😅

别怪模型笨,这是所有 LLM 的“先天缺陷”:无状态调用

但今天,我们用 LangChain Memory 给它装上“大脑”,实现真正意义上的多轮对话记忆!

本文将带你从零构建一个会“记事”的智能助手,并深入剖析底层原理、性能瓶颈与生产级优化方案。


🧠 一、为什么你的 AI 总是“金鱼脑”?

1.1 大模型的“失忆症”真相

我们知道,大语言模型(LLM)本质上是一个“黑箱函数”:

response = model(prompt)

每次调用都是独立的 HTTP 请求,没有任何上下文保留机制 —— 就像每次见面都重新认识一遍。

举个例子:

// 第一次对话
await model.invoke("我叫陈昊,喜欢喝白兰地");
// 输出:很高兴认识你,陈昊!看来你喜欢喝白兰地呢~

console.log('------ 分割线 ------');

// 第二次对话
await model.invoke("我叫什么名字?");
// 输出:呃……不太清楚,能告诉我吗?

👉 结果令人崩溃:前脚刚自我介绍,后脚就忘了!

这在实际项目中是不可接受的。无论是客服机器人、教育助手还是个性化推荐系统,都需要记住用户的历史行为和偏好


1.2 解决思路:把“记忆”塞进 Prompt

既然模型不会自己记,那就我们来帮它记

核心思想非常简单:

✅ 每次请求前,把之前的对话历史拼接到当前 prompt 中
✅ 让模型“看到”完整的聊天记录,从而做出连贯回应

这就像是给模型戴上了一副“记忆眼镜”。

而 LangChain 的 Memory 模块,正是对这一过程的高度封装与自动化。


⚙️ 二、LangChain Memory 核心原理拆解

LangChain 提供了一套优雅的 API,让我们可以用几行代码实现“有记忆”的对话系统。

先看最终效果:

User: 我叫陈昊,喜欢喝白兰地
AI: 你好,陈昊!白兰地可是很有品味的选择哦~

User: 我喜欢喝什么酒?
AI: 你说你喜欢喝白兰地呀~是不是准备开一瓶庆祝一下?😉

✅ 成功记住用户名字和饮酒偏好!

下面我们就一步步实现这个功能。


2.1 核心组件一览

组件 作用
ChatMessageHistory 存储对话历史(内存/文件/数据库)
ChatPromptTemplate 定义提示词模板,预留 {history} 占位符
RunnableWithMessageHistory 自动注入历史 + 管理会话生命周期
sessionId 区分不同用户的会话,避免串台

2.2 完整代码实战

// 文件名:memory-demo.js
import { ChatDeepSeek } from "@langchain/deepseek";
import 'dotenv/config';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';

// 1. 初始化模型
const model = new ChatDeepSeek({
    model: 'deepseek-reasoner',
    temperature: 0.3,
});

// 2. 构建带历史的 Prompt 模板
const prompt = ChatPromptTemplate.fromMessages([
    ['system', '你是一个温暖且有记忆的助手,请根据对话历史回答问题'],
    ['placeholder', '{history}'],   // ← 历史消息自动插入这里
    ['human', '{input}']            // ← 当前输入
]);

// 3. 创建可运行链(含调试输出)
const runnable = prompt
    .pipe(input => {
        console.log('\n🔍 最终发送给模型的完整上下文:');
        console.log(JSON.stringify(input, null, 2));
        return input;
    })
    .pipe(model);

// 4. 初始化内存历史存储
const messageHistory = new InMemoryChatMessageHistory();

// 5. 创建带记忆的链
const chain = new RunnableWithMessageHistory({
    runnable,
    getMessageHistory: () => messageHistory,
    inputMessagesKey: 'input',
    historyMessagesKey: 'history'
});

// 6. 开始对话测试
async function testConversation() {
    // 第一轮:告知信息
    const res1 = await chain.invoke(
        { input: "我叫陈昊,喜欢喝白兰地" },
        { configurable: { sessionId: "user_001" } }
    );
    console.log("🤖 回应1:", res1.content);

    // 第二轮:提问历史
    const res2 = await chain.invoke(
        { input: "我叫什么名字?我喜欢喝什么酒?" },
        { configurable: { sessionId: "user_001" } }
    );
    console.log("🤖 回应2:", res2.content);
}

testConversation();

📌 运行结果示例

🤖 回应1: 你好,陈昊!听说你喜欢喝白兰地,真是个有品位的人呢~

🤖 回应2: 你叫陈昊,而且你说你喜欢喝白兰地哦~要不要来点搭配小吃?

🎉 成功!模型不仅记住了名字,还能综合多个信息进行推理回答!


2.3 关键机制图解

              +---------------------+
              |   用户新输入         |
              +----------+----------+
                         |
       +-----------------v------------------+
       |   ChatPromptTemplate              |
       |                                     |
       |   System: 你是有记忆的助手         |
       |   History: [之前的所有对话] ←───────+←─ 从 messageHistory 读取
       |   Human: 我叫什么名字?             |
       +-----------------+------------------+
                         |
                调用模型 → LLM
                         |
           返回响应 ←────+
                         |
       +-----------------v------------------+
       |  自动保存本次交互                  |
       |  user: 我叫什么名字?               |
       |  assistant: 你叫陈昊...            |
       |  写入 InMemoryChatMessageHistory   |
       +------------------------------------+

整个流程全自动闭环,开发者只需关注业务逻辑。


⚠️ 三、真实场景下的三大挑战与破解之道

虽然上面的例子很美好,但在生产环境中你会立刻面临三个“灵魂拷问”:


❌ 挑战1:Token “滚雪球”爆炸增长!

随着对话轮数增加,历史消息越积越多,导致:

  • 单次请求 Token 数飙升
  • 成本翻倍 💸
  • 响应变慢 ⏳
  • 可能超出模型最大长度限制(如 8192)
✅ 解法:选择合适的 Memory 类型
Memory 类型 特点 适用场景
BufferWindowMemory 只保留最近 N 轮 通用对话、短程记忆
ConversationSummaryMemory 自动生成一句话总结 长周期对话、节省 Token
EntityMemory 提取关键实体(人名/偏好) 推荐系统、CRM 助手
示例:使用窗口记忆(保留最近3轮)
import { BufferWindowMemory } from "langchain/memory";

const memory = new BufferWindowMemory({
    k: 3,
    memoryKey: "history"
});

📌 推荐组合:短期细节靠窗口 + 长期特征靠总结


❌ 挑战2:重启服务后历史全丢?!

InMemoryChatMessageHistory 是临时存储,服务一重启,记忆清零。

✅ 解法:持久化到数据库或文件
方案一:本地文件存储(轻量级)
import { FileChatMessageHistory } from "@langchain/core/chat_history";

const getMessageHistory = async (sessionId) => {
    return new FileChatMessageHistory({
        filePath: `./history/${sessionId}.json`
    });
};
方案二:Redis / MongoDB(高并发推荐)
npm install @langchain/redis
import { RedisChatMessageHistory } from "@langchain/redis";

const getMessageHistory = async (sessionId) => {
    return new RedisChatMessageHistory({
        sessionId,
        client: redisClient // 已连接的 Redis 客户端
    });
};

💡 生产环境强烈建议使用 Redis:高性能、支持过期策略、分布式部署无忧。


❌ 挑战3:多人同时聊天会不会串消息?

当然会!如果所有用户共用同一个 messageHistory,就会出现 A 用户看到 B 用户的对话。

✅ 解法:用 sessionId 隔离会话
await chain.invoke(
    { input: "我饿了" },
    { configurable: { sessionId: "user_123" } }  // 每个用户唯一 ID
)

await chain.invoke(
    { input: "我饿了" },
    { configurable: { sessionId: "user_456" } }  // 不同用户,不同历史
)

✅ 安全隔离,互不干扰!


🛠️ 四、Memory 的典型应用场景(附案例灵感)

场景 如何使用 Memory
客服机器人 记住订单号、投诉进度、用户情绪变化
教育辅导 追踪学习章节、错题记录、掌握程度
电商导购 记住预算、品牌偏好、尺码需求
编程助手 保持代码上下文、函数定义、项目结构
心理咨询 理解用户情绪演变、关键事件回顾

💡 创新玩法:结合 SummaryMemory + VectorDB,实现“长期人格记忆”——让 AI 记住你是内向还是外向、喜欢幽默还是严谨。


📘 五、高频面试题(LangChain 方向)

  1. LLM 为什么需要 Memory?它的本质是什么?
  2. Buffer vs Summary Memory 的区别?什么时候用哪种?
  3. 如何设计一个支持百万用户在线的记忆系统架构?
  4. 如何防止敏感信息被 Memory 记录?(安全考量)

📝 结语:让 AI 真正“懂你”,从一次对话记忆开始

“智能不是回答问题的能力,而是理解上下文的艺术。”

LangChain Memory 虽然只是整个 LLM 应用中的一个小模块,但它却是通往拟人化交互的关键一步。

从“无状态”到“有记忆”,我们不只是在写代码,更是在塑造一种新的沟通方式。

未来属于那些能让 AI 记住你、理解你、陪伴你的产品。

而现在,你已经有了打造它的钥匙。


从 iframe 到 Shadow DOM:一次关于「隔离」的前端边界思考

一、问题背景:我到底想解决什么?

在复杂前端系统中,我们经常会遇到这样的需求:

  • 页面需要嵌入第三方内容 / 子系统

  • 希望 样式、脚本互不影响

  • 同时又要保证:

    • 正常渲染
    • 合理交互
    • 可控的安全边界

常见方案是 iframe,但一旦深入使用,就会立刻遇到一系列问题:

  • Cookie 是否会被注入?
  • JS 能否互相访问?
  • 样式是否会污染?
  • sandbox 一加,页面怎么直接不显示了?
  • 不加 allow-same-origin 又为什么什么都“坏了”?

这篇文章,就是围绕这些真实问题展开。


二、iframe 的本质:浏览器级别的“硬隔离”

1️⃣ iframe 是什么?

从浏览器角度看,iframe 并不是一个普通 DOM,而是:

一个完整的、独立的浏览上下文(Browsing Context)

它拥有自己的:

  • DOM 树
  • JS 执行环境
  • CSS 作用域
  • Cookie / Storage(是否共享取决于策略)

这也是它“隔离性强”的根本原因。


2️⃣ iframe 天生适合做什么?

  • 微前端子应用
  • 第三方内容嵌入
  • 不可信页面展示
  • 强安全边界场景

它解决的是“不信任”的问题,而不是“优雅”的问题。


三、sandbox:安全从这里开始,也从这里失控

1️⃣ sandbox 到底干了什么?

当你给 iframe 加上:

<iframe sandbox></iframe>

你相当于对它说:

“你什么都不能干。”

包括但不限于:

  • ❌ JS 不执行
  • ❌ 表单提交被禁
  • ❌ 同源身份被剥夺
  • ❌ Cookie / localStorage 全部隔离

2️⃣ 为什么加了 sandbox,页面直接空白?

因为很多页面:

  • 依赖 JS 渲染
  • 依赖同源读取资源
  • 依赖 Cookie 维持状态

一旦 sandbox 默认限制生效,页面逻辑上还能加载,但功能全废


3️⃣ allow-scripts + allow-same-origin 为什么危险?

sandbox="allow-scripts allow-same-origin"

这是一个经典陷阱组合

原因是:

  • allow-scripts:允许 JS 执行
  • allow-same-origin:恢复同源身份

⚠️ 一旦两者同时存在:

iframe 内的 JS 可以认为自己是“正常页面”

从规范角度,它已经具备了逃逸 sandbox 的能力

这也是 MDN 明确标注的安全风险。


四、那我只是不想 Cookie 被注入,怎么办?

❌ 错误直觉

“去掉 allow-same-origin 就好了”

结果是:

  • JS 取不到任何资源
  • 页面渲染失败
  • iframe 内容直接消失

✅ 正确理解

Cookie 注入的本质是:

  • 同源 + 凭证传递

控制 Cookie 的正确方式是:

  • SameSite
  • HttpOnly
  • Secure
  • 服务端鉴权策略

而不是指望 iframe sandbox 去“顺便解决”。


五、Shadow DOM:另一种完全不同的“隔离”

1️⃣ Shadow DOM 隔离的是什么?

Shadow DOM 隔离的是:

  • 样式作用域
  • DOM 结构可见性

但它:

  • ❌ 不隔离 JS
  • ❌ 不隔离 Cookie
  • ❌ 不隔离安全上下文

它解决的是:

组件级的可维护性问题

而不是安全问题。


2️⃣ iframe vs Shadow DOM 对比

维度 iframe Shadow DOM
样式隔离 ✅ 强 ✅ 中
JS 隔离 ✅ 强
安全边界
通信成本
性能 较重
使用复杂度

一句话总结:

iframe 是安全隔离工具
Shadow DOM 是工程隔离工具

❌