普通视图

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

每日一题-分割数组中数字的数位🟢

2026年5月11日 00:00

给你一个正整数数组 nums ,请你返回一个数组 answer ,你需要将 nums 中每个整数进行数位分割后,按照 nums 中出现的 相同顺序 放入答案数组中。

对一个整数进行数位分割,指的是将整数各个数位按原本出现的顺序排列成数组。

  • 比方说,整数 10921 ,分割它的各个数位得到 [1,0,9,2,1] 。

 

示例 1:

输入:nums = [13,25,83,77]
输出:[1,3,2,5,8,3,7,7]
解释:
- 分割 13 得到 [1,3] 。
- 分割 25 得到 [2,5] 。
- 分割 83 得到 [8,3] 。
- 分割 77 得到 [7,7] 。
answer = [1,3,2,5,8,3,7,7] 。answer 中的数字分割结果按照原数字在数组中的相同顺序排列。

示例 2:

输入:nums = [7,1,3,9]
输出:[7,1,3,9]
解释:nums 中每个整数的分割是它自己。
answer = [7,1,3,9] 。

 

提示:

  • 1 <= nums.length <= 1000
  • 1 <= nums[i] <= 105

2553. 分割数组中数字的数位

作者 stormsunshine
2024年1月28日 13:37

解法

思路和算法

这道题要求将给定的正整数数组 $\textit{nums}$ 中的所有正整数按数位分割,保持数位顺序存入答案数组。需要首先遍历数组 $\textit{nums}$ 得到数位总数,然后将每个正整数按数位分割并存入答案数组。

首先遍历数组 $\textit{nums}$ 得到所有正整数的数位总数 $\textit{totalLength}$,然后创建长度为 $\textit{totalLength}$ 的答案数组 $\textit{answer}$,再次遍历数组 $\textit{nums}$,遍历过程中维护答案数组的当前下标 $\textit{index}$,对于每个正整数,执行如下操作。

  1. 用 $\textit{start}$ 表示当前正整数的数位填入答案数组的起始下标,$\textit{start} = \textit{index}$。

  2. 每次将 $\textit{num}$ 的最低位填入 $\textit{answer}[\textit{index}]$,然后将 $\textit{index}$ 的值增加 $1$,重复该操作直到 $\textit{num}$ 的所有位都填入答案数组。

  3. 当前正整数 $\textit{num}$ 填入答案数组的下标范围是 $[\textit{start}, \textit{index} - 1]$,为按照数位从低到高的顺序填入。为了和数组 $\textit{nums}$ 中的数位顺序保持一致,需要将答案数组的下标范围 $[\textit{start}, \textit{index} - 1]$ 的子数组翻转。

遍历结束之后,即可得到答案数组。

代码

###Java

class Solution {
    public int[] separateDigits(int[] nums) {
        int totalLength = 0;
        for (int num : nums) {
            totalLength += getLength(num);
        }
        int[] answer = new int[totalLength];
        int index = 0;
        for (int num : nums) {
            int start = index;
            int temp = num;
            while (temp != 0) {
                answer[index] = temp % 10;
                index++;
                temp /= 10;
            }
            reverse(answer, start, index - 1);
        }
        return answer;
    }

    public int getLength(int num) {
        int length = 0;
        while (num != 0) {
            length++;
            num /= 10;
        }
        return length;
    }

    public void reverse(int[] answer, int start, int end) {
        for (int i = start, j = end; i < j; i++, j--) {
            int temp = answer[i];
            answer[i] = answer[j];
            answer[j] = temp;
        }
    }
}

###C#

public class Solution {
    public int[] SeparateDigits(int[] nums) {
        int totalLength = 0;
        foreach (int num in nums) {
            totalLength += GetLength(num);
        }
        int[] answer = new int[totalLength];
        int index = 0;
        foreach (int num in nums) {
            int start = index;
            int temp = num;
            while (temp != 0) {
                answer[index] = temp % 10;
                index++;
                temp /= 10;
            }
            Reverse(answer, start, index - 1);
        }
        return answer;
    }

    public int GetLength(int num) {
        int length = 0;
        while (num != 0) {
            length++;
            num /= 10;
        }
        return length;
    }

    public void Reverse(int[] answer, int start, int end) {
        for (int i = start, j = end; i < j; i++, j--) {
            int temp = answer[i];
            answer[i] = answer[j];
            answer[j] = temp;
        }
    }
}

###C++

class Solution {
public:
    vector<int> separateDigits(vector<int>& nums) {
        int totalLength = 0;
        for (int num : nums) {
            totalLength += getLength(num);
        }
        vector<int> answer(totalLength);
        int index = 0;
        for (int num : nums) {
            int start = index;
            int temp = num;
            while (temp != 0) {
                answer[index] = temp % 10;
                index++;
                temp /= 10;
            }
            reverse(answer, start, index - 1);
        }
        return answer;
    }

    int getLength(int num) {
        int length = 0;
        while (num != 0) {
            length++;
            num /= 10;
        }
        return length;
    }

    void reverse(vector<int>& answer, int start, int end) {
        for (int i = start, j = end; i < j; i++, j--) {
            swap(answer[i], answer[j]);
        }
    }
};

###Python

class Solution:
    def separateDigits(self, nums: List[int]) -> List[int]:
        answer = []
        index = 0
        for num in nums:
            start = index
            temp = num
            while temp != 0:
                answer.append(temp % 10)
                index += 1
                temp //= 10
            self.reverse(answer, start, index - 1)
        return answer

    def getLength(self, num: int) -> int:
        length = 0
        while num != 0:
            length += 1
            num //= 10
        return length

    def reverse(self, answer: List[int], start: int, end: int) -> None:
        i, j = start, end
        while i < j:
            answer[i], answer[j] = answer[j], answer[i]
            i += 1
            j -= 1

###C

int getLength(int num) {
    int length = 0;
    while (num != 0) {
        length++;
        num /= 10;
    }
    return length;
}

void reverse(int* answer, int start, int end) {
    for (int i = start, j = end; i < j; i++, j--) {
        int temp = answer[i];
        answer[i] = answer[j];
        answer[j] = temp;
    }
}

int* separateDigits(int* nums, int numsSize, int* returnSize) {
    int totalLength = 0;
    for (int i = 0; i < numsSize; i++) {
        totalLength += getLength(nums[i]);
    }
    int* answer = (int*) malloc(sizeof(int) * totalLength);
    *returnSize = totalLength;
    int index = 0;
    for (int i = 0; i < numsSize; i++) {
        int start = index;
        int temp = nums[i];
        while (temp != 0) {
            answer[index] = temp % 10;
            index++;
            temp /= 10;
        }
        reverse(answer, start, index - 1);
    }
    return answer;
}

###Go

func separateDigits(nums []int) []int {
    totalLength := 0
    for _, num := range nums {
        totalLength += getLength(num)
    }
    answer := make([]int, totalLength)
    index := 0
    for _, num := range nums {
        start := index
        temp := num
        for temp != 0 {
            answer[index] = temp % 10
            index++
            temp /= 10
        }
        reverse(answer, start, index - 1)
    }
    return answer
}

func getLength(num int) int {
    length := 0
    for num != 0 {
        length++
        num /= 10
    }
    return length
}

func reverse(answer []int, start int, end int) {
    for i, j := start, end; i < j; i, j = i + 1, j - 1 {
        answer[i], answer[j] = answer[j], answer[i]
    }
}

###JavaScript

var separateDigits = function(nums) {
    let totalLength = 0;
    for (let num of nums) {
        totalLength += getLength(num);
    }
    let answer = new Array(totalLength);
    let index = 0;
    for (let num of nums) {
        let start = index;
        let temp = num;
        while (temp !== 0) {
            answer[index] = temp % 10;
            index++;
            temp = Math.floor(temp / 10);
        }
        reverse(answer, start, index - 1);
    }
    return answer;
};

var getLength = function(num) {
    let length = 0;
    while (num !== 0) {
        length++;
        num = Math.floor(num / 10);
    }
    return length;
};

var reverse = function(answer, start, end) {
    for (let i = start, j = end; i < j; i++, j--) {
        let temp = answer[i];
        answer[i] = answer[j];
        answer[j] = temp;
    }
};

###TypeScript

function separateDigits(nums: number[]): number[] {
    let totalLength = 0;
    for (let num of nums) {
        totalLength += getLength(num);
    }
    let answer = new Array(totalLength);
    let index = 0;
    for (let num of nums) {
        let start = index;
        let temp = num;
        while (temp !== 0) {
            answer[index] = temp % 10;
            index++;
            temp = Math.floor(temp / 10);
        }
        reverse(answer, start, index - 1);
    }
    return answer;
};

function getLength(num: number): number {
    let length = 0;
    while (num !== 0) {
        length++;
        num = Math.floor(num / 10);
    }
    return length;
};

function reverse(answer: number[], start: number, end: number): void {
    for (let i = start, j = end; i < j; i++, j--) {
        let temp = answer[i];
        answer[i] = answer[j];
        answer[j] = temp;
    }
};

复杂度分析

  • 时间复杂度:$O(n \log_{10} m)$,其中 $n$ 是数组 $\textit{nums}$ 的长度,$m$ 是数组 $\textit{nums}$ 的最大元素。计算答案数组的长度与将数位填入答案数组的时间是 $O(n \log_{10} m)$。

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

倒序插入

2023年3月9日 13:24

这个刚开始写的时候担心数组的长度和时间复杂度的原因,担心通过不了,结果没想过通过了。
我是用一个result数组来装将返回的分割数位值,考虑他们的长度,每一个元素的大小在10的五次方以内,分割之后100000共有六位,也就是最大是6,加上数组的长度最大是1000,于是我就把数组的大小取为6*10000=60000.
虽然我也知道浪费空间,但是我目前没有更好的方法,希望有大佬能够指点。

确定了返回数组了,然后就是然后把原始的整数拆分插入到结果数组中,后面我想到只要逐个不断求余就可以得到每一位数,后面我就在第一层遍历中添加一个循环得到整数的各个数位,但是提交的结果不符合要求,题目要求将数位按原本出现的顺序排列成数组,于是我就用一个数组作为辅助空间,将它反向添加,于是就得到了和题目意思相同的结果,辅助空间的大小就是一个整数拥有的各个位数的数量,由前面可以知道最大是6。

之后返回即可。

int* separateDigits(int* nums, int numsSize, int* returnSize){
    int *result=(int*)malloc(sizeof(int)*60000);
    int count=0;
    //遍历整个数组
    for(int i=0;i<numsSize;i++){
        int tmp=nums[i];
        int help[6];
        int tmpCount=0;
        //得到整数的各个数位,将他们储存在一个辅助数组中
        while(tmp){
            help[tmpCount++]=tmp%10;
            tmp=tmp/10;
        }
        //把数组中的元素倒序添加到结果数组中
        for(int j=tmpCount-1;j>=0;j--){
            result[count++]=help[j];
        }
    }
    *returnSize=count;
    return result;
}

两种方法:用字符串 / 不用字符串(Python/Java/C++/Go)

作者 endlesscheng
2023年2月5日 00:16

写法一:用字符串

把 $\textit{nums}[i]$ 转成字符串,即可从高到低遍历数位。

class Solution:
    def separateDigits(self, nums: List[int]) -> List[int]:
        return [int(ch) for x in nums for ch in str(x)]
class Solution {
    public int[] separateDigits(int[] nums) {
        List<Integer> digits = new ArrayList<>();
        for (int x : nums) {
            for (char ch : String.valueOf(x).toCharArray()) {
                digits.add(ch - '0');
            }
        }

        int m = digits.size();
        int[] ans = new int[m];
        for (int i = 0; i < m; i++) {
            ans[i] = digits.get(i);
        }
        return ans;
    }
}
class Solution {
public:
    vector<int> separateDigits(vector<int>& nums) {
        vector<int> ans;
        for (int x : nums) {
            for (char ch : to_string(x)) {
                ans.push_back(ch - '0');
            }
        }
        return ans;
    }
};
func separateDigits(nums []int) (ans []int) {
for _, x := range nums {
for _, ch := range strconv.Itoa(x) {
ans = append(ans, int(ch-'0'))
}
}
return
}

复杂度分析

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

方法二:不用字符串

不断地把 $n$ 除以 $10$(下取整)直到 $0$,例如 $123\to 12\to 1\to 0$。在这个过程中的 $n\bmod 10$,即为每个数位。

这样做是从低到高遍历数位,和题目要求的顺序相反。

我们可以从右到左遍历 $\textit{nums}$,从低到高遍历 $\textit{nums}[i]$ 的数位。最后把遍历过的数位反转,即为答案。

class Solution:
    def separateDigits(self, nums: list[int]) -> list[int]:
        ans = []
        for x in reversed(nums):
            while x > 0:
                ans.append(x % 10)
                x //= 10
        ans.reverse()
        return ans
class Solution {
    public int[] separateDigits(int[] nums) {
        List<Integer> digits = new ArrayList<>();
        for (int i = nums.length - 1; i >= 0; i--) {
            for (int x = nums[i]; x > 0; x /= 10) {
                digits.add(x % 10);
            }
        }

        int m = digits.size();
        int[] ans = new int[m];
        for (int i = 0; i < m; i++) {
            ans[i] = digits.get(m - 1 - i);
        }
        return ans;
    }
}
class Solution {
public:
    vector<int> separateDigits(vector<int>& nums) {
        vector<int> ans;
        for (int x : nums | views::reverse) {
            for (; x > 0; x /= 10) {
                ans.push_back(x % 10);
            }
        }
        ranges::reverse(ans);
        return ans;
    }
};
func separateDigits(nums []int) (ans []int) {
for _, x := range slices.Backward(nums) {
for ; x > 0; x /= 10 {
ans = append(ans, x%10)
}
}
slices.Reverse(ans)
return
}

复杂度分析

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

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

昨天 — 2026年5月10日技术

每日一题-达到末尾下标所需的最大跳跃次数🟡

2026年5月10日 00:00

给你一个下标从 0 开始、由 n 个整数组成的数组 nums 和一个整数 target

你的初始位置在下标 0 。在一步操作中,你可以从下标 i 跳跃到任意满足下述条件的下标 j

  • 0 <= i < j < n
  • -target <= nums[j] - nums[i] <= target

返回到达下标 n - 1 处所需的 最大跳跃次数

如果无法到达下标 n - 1 ,返回 -1

 

示例 1:

输入:nums = [1,3,6,4,1,2], target = 2
输出:3
解释:要想以最大跳跃次数从下标 0 到下标 n - 1 ,可以按下述跳跃序列执行操作:
- 从下标 0 跳跃到下标 1 。 
- 从下标 1 跳跃到下标 3 。 
- 从下标 3 跳跃到下标 5 。 
可以证明,从 0 到 n - 1 的所有方案中,不存在比 3 步更长的跳跃序列。因此,答案是 3 。 

示例 2:

输入:nums = [1,3,6,4,1,2], target = 3
输出:5
解释:要想以最大跳跃次数从下标 0 到下标 n - 1 ,可以按下述跳跃序列执行操作:
- 从下标 0 跳跃到下标 1 。 
- 从下标 1 跳跃到下标 2 。 
- 从下标 2 跳跃到下标 3 。 
- 从下标 3 跳跃到下标 4 。 
- 从下标 4 跳跃到下标 5 。 
可以证明,从 0 到 n - 1 的所有方案中,不存在比 5 步更长的跳跃序列。因此,答案是 5 。 

示例 3:

输入:nums = [1,3,6,4,1,2], target = 0
输出:-1
解释:可以证明不存在从 0 到 n - 1 的跳跃序列。因此,答案是 -1 。 

 

提示:

  • 2 <= nums.length == n <= 1000
  • -109 <= nums[i] <= 109
  • 0 <= target <= 2 * 109

C++, 动态规划

作者 liu-xiang-3
2023年7月12日 12:57

思路

  1. f[i]表示从0到下标i的最大跳跃次数;
  2. f[0]=0, f[i]=f[j]+1, 可以从j跳过来;
class Solution {
public:
    int maximumJumps(vector<int>& nums, int target) {
        int n = nums.size();
        vector<int> f(n, -1);
        f[0] = 0;
        /* 开始递推 */
        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (f[j] != -1 && abs(nums[i] - nums[j]) <= target) {
                    f[i] = max(f[i], f[j] + 1);
                }
            }
        }
        return f[n - 1];
    }
};
class Solution:
    def maximumJumps(self, nums: List[int], target: int) -> int:
        n = len(nums)
        f = [-1] * n
        f[0] = 0
        for j in range(1, n):
            for i in range(j):
                if f[i] != -1 and abs(nums[i] - nums[j]) <= target:
                    f[j] = max(f[j], f[i] + 1)
        return f[-1]

【什码情况】Java记忆化搜索

作者 smqk
2023年7月9日 15:37

image.png{:width=400}

解题思路

此处撰写解题思路

代码

###java

class Solution {
    // jump[i] 表示从下标 0 开始跳到 nums[i] 所需的最大跳跃次数
    int[] jump;

    public int maximumJumps(int[] nums, int target) {
        this.jump = new int[nums.length];
        Arrays.fill(jump, Integer.MIN_VALUE);
        jump[0] = 0;
        int maximumJumps = dfs(nums, nums.length - 1, target);
        return maximumJumps >= 0 ? maximumJumps : -1;
    }

    // 返回从下标 i 跳到下标 0 所需最大的跳跃次数
    private int dfs(int[] nums, int i, int target) {
        if (jump[i] != Integer.MIN_VALUE) {
            return jump[i];
        }

        int max = Integer.MIN_VALUE;
        for (int j = i - 1; j >= 0; j--) {
            int val = nums[j] - nums[i];
            if (-target <= val && val <= target) {
                int jump = dfs(nums, j, target) + 1;
                max = Math.max(max, jump);
            }
        }

        return jump[i] = max;
    }

}

最后

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

两种方法:普通 DP / 值域线段树优化 DP(Python/Java/C++/Go)

作者 endlesscheng
2023年7月9日 12:09

一、寻找子问题

想一想,最后一步发生了什么?

最后一步,我们从某个满足条件的下标 $i$ 跳到了下标 $n-1$。

枚举满足条件的 $i$,问题变成:

  • 从下标 $0$ 到达下标 $i$ 所需的最大跳跃次数。

这是和原问题相似的、规模更小的子问题,可以用递归解决。

注:从右往左思考,主要是方便把递归翻译成从左往右的递推。从左往右思考也是可以的。

二、状态定义与状态转移方程

根据上面的讨论,定义 $\textit{dfs}(j)$ 表示从下标 $0$ 到达下标 $j$ 所需的最大跳跃次数。

枚举满足 $0\le i<j$ 且 $|\textit{nums}[i]-\textit{nums}[j]|\le \textit{target}$ 的下标 $i$,问题变成从下标 $0$ 到达下标 $i$ 所需的最大跳跃次数,再加上从 $i$ 跳到 $j$ 的一次。

取最大值,得

$$
\textit{dfs}(j) = \max_{i} \textit{dfs}(i) + 1
$$

其中 $0\le i<j$ 且 $|\textit{nums}[i]-\textit{nums}[j]|\le \textit{target}$。

递归边界

  • $\textit{dfs}(0)=0$。从 $0$ 到 $0$ 不用跳。
  • 如果没有满足条件的 $i$,那么 $\textit{dfs}(j) = -\infty$。

递归入口:$\textit{dfs}(n-1)$,这是原问题,也是答案。

三、递归搜索 + 保存递归返回值 = 记忆化搜索

考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

  • 如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 $\textit{memo}$ 数组中。
  • 如果一个状态不是第一次遇到($\textit{memo}$ 中保存的结果不等于 $\textit{memo}$ 的初始值),那么可以直接返回 $\textit{memo}$ 中保存的结果。

Python 用户可以无视上面这段,直接用 @cache 装饰器。

关于记忆化搜索的原理,请看视频讲解 动态规划入门:从记忆化搜索到递推【基础算法精讲 17】,其中包含把记忆化搜索 1:1 翻译成递推的技巧。

class Solution:
    def maximumJumps(self, nums: List[int], target: int) -> int:
        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(j: int) -> int:
            if j == 0:  # 起点
                return 0
            res = -inf
            for i in range(j):
                if abs(nums[i] - nums[j]) <= target:  # 可以从 i 跳到 j
                    res = max(res, dfs(i) + 1)
            return res

        ans = dfs(len(nums) - 1)  # 终点
        return -1 if ans < 0 else ans
class Solution {
    public int maximumJumps(int[] nums, int target) {
        int n = nums.length;
        int[] memo = new int[n];
        int ans = dfs(n - 1, nums, target, memo);
        return ans < 0 ? -1 : ans;
    }

    private int dfs(int j, int[] nums, int target, int[] memo) {
        if (j == 0) { // 起点
            return 0;
        }

        if (memo[j] != 0) { // 之前计算过
            return memo[j];
        }

        int res = Integer.MIN_VALUE;
        for (int i = 0; i < j; i++) {
            if (Math.abs(nums[i] - nums[j]) <= target) { // 可以从 i 跳到 j
                res = Math.max(res, dfs(i, nums, target, memo) + 1);
            }
        }
        memo[j] = res; // 记忆化
        return res;
    }
}
class Solution {
public:
    int maximumJumps(vector<int>& nums, int target) {
        int n = nums.size();
        vector<int> memo(n);

        auto dfs = [&](this auto&& dfs, int j) -> int {
            if (j == 0) { // 起点
                return 0;
            }

            int& res = memo[j]; // 注意这里是引用
            if (res) { // 之前计算过
                return res;
            }

            res = INT_MIN;
            for (int i = 0; i < j; i++) {
                if (abs(nums[i] - nums[j]) <= target) { // 可以从 i 跳到 j
                    res = max(res, dfs(i) + 1);
                }
            }
            return res;
        };

        int ans = dfs(n - 1); // 终点
        return ans < 0 ? -1 : ans;
    }
};
func maximumJumps(nums []int, target int) int {
n := len(nums)
memo := make([]int, n)

var dfs func(int) int
dfs = func(j int) int {
if j == 0 { // 起点
return 0
}

p := &memo[j]
if *p != 0 { // 之前计算过
return *p
}

res := math.MinInt
for i, x := range nums[:j] {
if abs(x-nums[j]) <= target { // 可以从 i 跳到 j
res = max(res, dfs(i)+1)
}
}
*p = res // 记忆化
return res
}

ans := dfs(n - 1) // 终点
if ans < 0 {
return -1
}
return ans
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$,其中 $n$ 是 $\textit{nums}$ 的长度。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(n)$,单个状态的计算时间为 $\mathcal{O}(n)$,所以总的时间复杂度为 $\mathcal{O}(n^2)$。
  • 空间复杂度:$\mathcal{O}(n)$。保存多少状态,就需要多少空间。

四、1:1 翻译成递推

我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。

具体来说,$f[j]$ 的定义和 $\textit{dfs}(j)$ 的定义是完全一样的,都表示从下标 $0$ 到达下标 $j$ 所需的最大跳跃次数。

相应的递推式(状态转移方程)也和 $\textit{dfs}$ 一样:

$$
f[j] = \max_{i} f[i] + 1
$$

其中 $0\le i<j$ 且 $|\textit{nums}[i]-\textit{nums}[j]|\le \textit{target}$。

如果没有满足条件的 $i$,那么 $f[j] = -\infty$。

初始值 $f[0]=0$,翻译自递归边界 $\textit{dfs}(0)=0$。

答案为 $f[n-1]$,翻译自递归入口 $\textit{dfs}(n-1)$。

class Solution:
    def maximumJumps(self, nums: List[int], target: int) -> int:
        n = len(nums)
        f = [-inf] * n
        f[0] = 0
        for j in range(1, n):
            for i in range(j):
                if abs(nums[i] - nums[j]) <= target:  # 可以从 i 跳到 j
                    f[j] = max(f[j], f[i] + 1)
        return -1 if f[-1] < 0 else f[-1]
class Solution {
    public int maximumJumps(int[] nums, int target) {
        int n = nums.length;
        int[] f = new int[n];
        for (int j = 1; j < n; j++) {
            f[j] = Integer.MIN_VALUE;
            for (int i = 0; i < j; i++) {
                if (Math.abs(nums[i] - nums[j]) <= target) { // 可以从 i 跳到 j
                    f[j] = Math.max(f[j], f[i] + 1);
                }
            }
        }
        return f[n - 1] < 0 ? -1 : f[n - 1];
    }
}
class Solution {
public:
    int maximumJumps(vector<int>& nums, int target) {
        int n = nums.size();
        vector<int> f(n, INT_MIN);
        f[0] = 0;
        for (int j = 1; j < n; j++) {
            for (int i = 0; i < j; i++) {
                if (abs(nums[i] - nums[j]) <= target) { // 可以从 i 跳到 j
                    f[j] = max(f[j], f[i] + 1);
                }
            }
        }
        return f[n - 1] < 0 ? -1 : f[n - 1];
    }
};
func maximumJumps(nums []int, target int) int {
n := len(nums)
f := make([]int, n)

for j := 1; j < n; j++ {
f[j] = math.MinInt
for i, x := range nums[:j] {
if abs(x-nums[j]) <= target { // 可以从 i 跳到 j
f[j] = max(f[j], f[i]+1)
}
}
}

if f[n-1] < 0 {
return -1
}
return f[n-1]
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

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

五、值域线段树优化 DP

如果 $n=10^5$,上面的做法就超时了。

遍历到 $\textit{nums}[j]$ 时,我们需要知道满足 $\textit{nums}[j]-\textit{target} \le \textit{nums}[i] \le \textit{nums}[j]+\textit{target}$ 的最大的 $f[i]$。

这可以用一棵值域线段树维护。线段树的区间是值域区间,例如区间 $[20,23]$ 指的是 $\textit{nums}$ 中的元素 $20,21,22,23$。线段树的每个节点保存的是值域区间对应的最大的 $f[i]$。例如 $\textit{nums}[4]=20$ 且 $f[4] = 3$,那么线段树维护的位置 $20$ 更新为 $3$。

如此一来,满足 $\textit{nums}[j]-\textit{target} \le \textit{nums}[i] \le \textit{nums}[j]+\textit{target}$ 的最大的 $f[i]$,可以通过线段树的区间最值查询得到。

# 完整的线段树模板见 https://leetcode.cn/circle/discuss/mOr1u6/
class SegmentTree:
    def __init__(self, n: int) -> None:
        self.t = [-inf] * (2 << (n - 1).bit_length())

    def update(self, node: int, l: int, r: int, i: int, val: int) -> None:
        if l == r:  # 叶子
            self.t[node] = val
            return
        m = (l + r) // 2
        if i <= m:  # i 在左子树
            self.update(node * 2, l, m, i, val)
        else:  # i 在右子树
            self.update(node * 2 + 1, m + 1, r, i, val)
        self.t[node] = max(self.t[node * 2], self.t[node * 2 + 1])

    def query(self, node: int, l: int, r: int, ql: int, qr: int) -> int:
        if ql <= l and r <= qr:  # 当前子树完全在 [ql, qr] 内
            return self.t[node]
        m = (l + r) // 2
        if qr <= m:  # [ql, qr] 在左子树
            return self.query(node * 2, l, m, ql, qr)
        if ql > m:  # [ql, qr] 在右子树
            return self.query(node * 2 + 1, m + 1, r, ql, qr)
        return max(self.query(node * 2, l, m, ql, qr), self.query(node * 2 + 1, m + 1, r, ql, qr))


class Solution:
    def maximumJumps(self, nums: List[int], target: int) -> int:
        # 去重排序,便于离散化
        sorted_nums = sorted(set(nums))

        n = len(nums)
        m = len(sorted_nums)
        t = SegmentTree(m)  # 值域线段树

        # nums[0] 对应的 f[0] = 0
        t.update(1, 0, m - 1, bisect_left(sorted_nums, nums[0]), 0)

        for j in range(1, n):
            l = bisect_left(sorted_nums, nums[j] - target)       # >= nums[j]-target 的第一个数
            r = bisect_right(sorted_nums, nums[j] + target) - 1  # <= nums[j]+target 的最后一个数
            # t.query 返回满足 nums[j]-target <= nums[i] <= nums[j]+target 的最大的 f[i]
            fj = t.query(1, 0, m - 1, l, r) + 1
            t.update(1, 0, m - 1, bisect_left(sorted_nums, nums[j]), fj)

        return -1 if fj < 0 else fj
class Solution {
    // 完整的线段树模板见 https://leetcode.cn/circle/discuss/mOr1u6/
    private int[] tree;

    private void update(int node, int l, int r, int i, int val) {
        if (l == r) { // 叶子
            tree[node] = val;
            return;
        }
        int m = (l + r) / 2;
        if (i <= m) { // i 在左子树
            update(node * 2, l, m, i, val);
        } else { // i 在右子树
            update(node * 2 + 1, m + 1, r, i, val);
        }
        tree[node] = Math.max(tree[node * 2], tree[node * 2 + 1]);
    }

    private int query(int node, int l, int r, int ql, int qr) {
        if (ql <= l && r <= qr) { // 当前子树完全在 [ql, qr] 内
            return tree[node];
        }
        int m = (l + r) / 2;
        if (qr <= m) { // [ql, qr] 在左子树
            return query(node * 2, l, m, ql, qr);
        }
        if (ql > m) { // [ql, qr] 在右子树
            return query(node * 2 + 1, m + 1, r, ql, qr);
        }
        return Math.max(query(node * 2, l, m, ql, qr), query(node * 2 + 1, m + 1, r, ql, qr));
    }

    public int maximumJumps(int[] nums, int target) {
        int n = nums.length;
        int[] sorted = nums.clone(); // 用于离散化
        Arrays.sort(sorted);

        tree = new int[2 << (32 - Integer.numberOfLeadingZeros(n - 1))];
        Arrays.fill(tree, Integer.MIN_VALUE);

        // nums[0] 对应的 f[0] = 0
        update(1, 0, n - 1, lowerBound(sorted, nums[0]), 0);

        for (int j = 1; ; j++) {
            int l = lowerBound(sorted, (long) nums[j] - target);         // >= nums[j]-target 的第一个数
            int r = lowerBound(sorted, (long) nums[j] + target + 1) - 1; // <= nums[j]+target 的最后一个数
            // query 返回满足 nums[j]-target <= nums[i] <= nums[j]+target 的最大的 f[i]
            int fj = query(1, 0, n - 1, l, r) + 1;
            if (j == n - 1) {
                return fj < 0 ? -1 : fj;
            }
            update(1, 0, n - 1, lowerBound(sorted, nums[j]), fj);
        }
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, long target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}
// 完整的线段树模板见 https://leetcode.cn/circle/discuss/mOr1u6/
class SegmentTree {
    vector<int> tree;

public:
    SegmentTree(int n) : tree(2 << bit_width(n - 1u), INT_MIN) {}

    void update(int node, int l, int r, int i, int val) {
        if (l == r) { // 叶子
            tree[node] = val;
            return;
        }
        int m = (l + r) / 2;
        if (i <= m) { // i 在左子树
            update(node * 2, l, m, i, val);
        } else { // i 在右子树
            update(node * 2 + 1, m + 1, r, i, val);
        }
        tree[node] = max(tree[node * 2], tree[node * 2 + 1]);
    }

    int query(int node, int l, int r, int ql, int qr) const {
        if (ql <= l && r <= qr) { // 当前子树完全在 [ql, qr] 内
            return tree[node];
        }
        int m = (l + r) / 2;
        if (qr <= m) { // [ql, qr] 在左子树
            return query(node * 2, l, m, ql, qr);
        }
        if (ql > m) { // [ql, qr] 在右子树
            return query(node * 2 + 1, m + 1, r, ql, qr);
        }
        return max(query(node * 2, l, m, ql, qr), query(node * 2 + 1, m + 1, r, ql, qr));
    }
};

class Solution {
public:
    int maximumJumps(vector<int>& nums, int target) {
        // 排序去重,便于离散化
        auto sorted = nums;
        ranges::sort(sorted);
        sorted.erase(ranges::unique(sorted).begin(), sorted.end());

        int n = nums.size();
        int m = sorted.size();

        SegmentTree t(m); // 值域线段树

        // nums[0] 对应的 f[0] = 0
        int pos = ranges::lower_bound(sorted, nums[0]) - sorted.begin();
        t.update(1, 0, m - 1, pos, 0);

        long long tar = target;
        for (int j = 1; ; j++) {
            int l = ranges::lower_bound(sorted, nums[j] - tar) - sorted.begin();     // >= nums[j]-target 的第一个数
            int r = ranges::upper_bound(sorted, nums[j] + tar) - sorted.begin() - 1; // <= nums[j]+target 的最后一个数
            // t.query 返回满足 nums[j]-target <= nums[i] <= nums[j]+target 的最大的 f[i]
            int fj = t.query(1, 0, m - 1, l, r) + 1;
            if (j == n - 1) {
                return fj < 0 ? -1 : fj;
            }
            pos = ranges::lower_bound(sorted, nums[j]) - sorted.begin();
            t.update(1, 0, m - 1, pos, fj);
        }
    }
};
// 完整的线段树模板见 https://leetcode.cn/circle/discuss/mOr1u6/
type seg []int

func (t seg) update(node, l, r, i, val int) {
if l == r { // 叶子
t[node] = val
return
}
m := (l + r) / 2
if i <= m { // i 在左子树
t.update(node*2, l, m, i, val)
} else { // i 在右子树
t.update(node*2+1, m+1, r, i, val)
}
t[node] = max(t[node*2], t[node*2+1])
}

func (t seg) query(node, l, r, ql, qr int) int {
if ql <= l && r <= qr { // 当前子树完全在 [ql, qr] 内
return t[node]
}
m := (l + r) / 2
if qr <= m { // [ql, qr] 在左子树
return t.query(node*2, l, m, ql, qr)
}
if ql > m { // [ql, qr] 在右子树
return t.query(node*2+1, m+1, r, ql, qr)
}
return max(t.query(node*2, l, m, ql, qr), t.query(node*2+1, m+1, r, ql, qr))
}

func maximumJumps(nums []int, target int) int {
// 排序去重,便于离散化
sorted := slices.Clone(nums)
slices.Sort(sorted)
sorted = slices.Compact(sorted)

n := len(nums)
m := len(sorted)

t := make(seg, 2<<bits.Len(uint(m-1))) // 值域线段树
for i := range t {
t[i] = math.MinInt
}

// nums[0] 对应的 f[0] = 0
t.update(1, 0, m-1, sort.SearchInts(sorted, nums[0]), 0)

for j := 1; ; j++ {
l := sort.SearchInts(sorted, nums[j]-target)       // >= nums[j]-target 的第一个数
r := sort.SearchInts(sorted, nums[j]+target+1) - 1 // <= nums[j]+target 的最后一个数
// t.query 返回满足 nums[j]-target <= nums[i] <= nums[j]+target 的最大的 f[i]
fj := t.query(1, 0, m-1, l, r) + 1
if j == n-1 {
if fj < 0 {
return -1
}
return fj
}
t.update(1, 0, m-1, sort.SearchInts(sorted, nums[j]), fj)
}
}

复杂度分析

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

相似题目

2407. 最长递增子序列 II

更多相似题目,见下面动态规划题单的「§11.4 树状数组/线段树优化 DP」和「专题:跳跃游戏」。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

昨天以前技术

把一份前端 checklist 变成 AI 的 Skill:让 CR 不再靠记忆

作者 jump_jump
2026年5月9日 23:17

引子:一个吃灰三年的项目被重新盘活

写这篇博客的由头有点特别。

我有一个叫 front-end-checklist 的老项目(网页:wsafight.github.io/front-end-c…),2023 年初在公司做 Code Review 的时候顺手整理出来的。那会儿评审新同学的代码,总是在重复同样的话:"这里没做 XSS 转义"、"useEffect 里有竞态"、"label 没和 input 关联"。后来干脆把这些反复出现的问题写成清单,用 Jekyll 挂在 GitHub Pages 上。

然后它就一直在那儿积灰。2024 只有一次提交,2025 只有一次,到了 2026 年 5 月也还没动过。

2026-05-08 晚上,我本来只想顺手改一下清单里过时的条目,结果一头扎进 Claude Code,两小时做了 22 次提交,把这个静态页面彻底改造了一遍。

真正让我想写这篇文章的,不是"AI 让我写代码变快"。快是肯定快,但这次改造最后落到了一件我之前没想过的事情上——一份静态文档,换了个交付形式,才真正开始被用起来。

清单本身:三年沉淀下来的 160 条检查项

先交代清楚这个清单是什么。

它不是 ESLint 能查的那种风格规则,而是 Code Review 时需要结合业务和上下文判断的问题。目前一共 25 个分组、160 条检查项:

命名规范      数据与类型    函数设计      状态管理      控制流
异步处理      数据请求      UI 与渲染     样式与响应式  路由与权限
性能          安全与健壮性  表单与交互    错误处理      测试
无障碍访问    用户体验      代码质量      工程化        国际化
日志与监控    依赖管理      浏览器兼容    文档与协作    PR 自检

随便挑几条看看:

  • 区分"缺失"、"为空"、"为 0"、"为空数组"、"为空字符串"的业务含义
  • 处理并发请求的竞态问题,避免旧响应覆盖新状态
  • 同一表单/输入控件不要在受控与非受控之间切换
  • useEffect / watch 的依赖项必须完整,避免闭包捕获过期值
  • 定时器和事件监听记得清除,否则可能引发内存泄漏
  • 表单错误应定位到具体字段,而不是只给出笼统提示

这些不是靠格式化、类型推导或者简单 AST 规则就能稳定抓出来的问题。它们往往来自真实线上事故、返工、误解和交接成本。以前这些经验只能靠人去记,记不住就会在别的项目里重新踩一次。

痛点:清单挂在网上,但不会进入工作流

清单做完这三年,我一直有个遗憾:没人真的会去翻

我自己都不翻。做一个 PR 的时候,我知道清单里有条"处理并发请求的竞态",但我会不会每次都老老实实打开页面对一遍?不会。同事更不会。新人入职时我会把链接丢给他们,他们收藏,然后就再也不打开了。

这是很多静态文档共同的问题:信息在那里,但你必须主动去触达它。而在 CR 场景里,主动触达的前提是你已经意识到自己可能漏了什么。可真正漏掉的时候,人往往意识不到。

我试过一些办法:

  • 写成 Markdown 放 README:没人会在写代码时切到 README 里逐条对照。
  • 做成可搜索的网页:搜索的前提是你已经知道关键词,可 XSS、竞态、状态错位这类问题,经常就是因为你没意识到该搜什么。
  • 加上勾选和进度追踪:这次改造时我反而把它们删了,因为它把"查阅文档"变成了"填表",使用意愿更低。

根本问题不是文档形式不够花哨,而是查阅式文档很难主动进入人的工作流。

转折:把清单变成 AI 的上下文

Claude Code 的 Skill 机制改变了这件事。

简单说,Skill 是一段给 AI 读的说明书,加上它执行任务时需要参考的资料。当你在对话里说到特定触发词时,AI 会加载这个 Skill,并按说明书走流程。

我的 frontend-checklist Skill 里写的是这样的逻辑:

  1. 只在用户明确请求时触发:比如 /frontend-checklist、"按前端清单 review"、"按 checklist 检查这段代码"。用户没提,它不会自作主张去扫代码。
  2. 确认检查范围:如果用户指定了文件,就只看指定文件;如果说 "review PR",就用 git diff 定位变更;如果都没有,就先确认范围,不盲目扫整个仓库。
  3. 按语言选择清单:中文提问读中文版清单,英文提问读英文版清单。
  4. 只报命中的问题:通过和不适用的条目一律不输出。
  5. 按严重度排序:安全、数据丢失、竞态、内存泄漏这类硬问题优先,命名风格靠后。
  6. 每个问题都标出文件路径和行号:让人能直接跳到具体位置。

关键的翻转在这里:

以前是"你去翻清单"。现在是"清单来找你"。

写 PR 的时候,你不需要记得清单里每一条是什么,直接在 IDE 里说一句"按前端清单 review 我这个 PR",AI 会把 160 条和当前 diff 放在一起看,只告诉你命中的那些,并给出文件路径和行号,必要时附 1-3 行示例代码。心智负担从"记住 160 条"降到"知道有这么一个入口"。

一个真实跑出来的 review

空讲没意思,看一段 showcase 里的实际输出。

下面这段代码是我写的一个有意设计的"坏例子"(showcase/cases/01-xss/bad.tsx),一个评论列表组件:

export function CommentList(props: any) {
  const [list, setList] = useState([] as any);
  const [keyword, setKeyword] = useState('');

  useEffect(() => {
    fetch('/api/comments?topic=' + props.topic)
      .then((r) => r.json())
      .then((d) => {
        setList(d.data);
      });
  }, [props.topic]);

  const highlight = (text, kw) => {
    if (!kw) return text;
    return text.replace(new RegExp(kw, 'g'), '<mark>' + kw + '</mark>');
  };

  return (
    <div>
      <input type="text" onChange={onSearch} />
      <div id="tip" dangerouslySetInnerHTML={{ __html: props.tip }} />
      {list.map((c: Comment, i: number) => (
        <div key={i} className="comment">
          <img src={c.avatar} />
          <a href={'javascript:void(0)'} onClick={() => eval(c.author.onClick)}>
            {c.author.name}
          </a>
          <div dangerouslySetInnerHTML={{ __html: highlight(c.body, keyword) }} />
        </div>
      ))}
    </div>
  );
}

乍看能跑,TypeScript 不一定报错,ESLint 也不一定能拦住关键问题。但 Skill 跑完吐出来 11 条命中,挑几条看(行号对应 showcase/cases/01-xss/bad.tsx 原文件,不是上面代码块里的相对行号):

安全与健壮性(最严重的一批)

  • line 35dangerouslySetInnerHTML={{ __html: props.tip }} 直接吃外部 tip,存在 XSS 风险。建议默认文本渲染,真要富文本时先用 DOMPurify 消毒。
  • line 44highlight 用字符串拼接 HTML 再 dangerouslySetInnerHTMLbodykeyword 都没转义。建议改成 String.split 分段渲染,把命中段包进 <mark>,不要注入 HTML。
  • line 39eval(c.author.onClick) 把接口返回的字符串当代码执行,等于把任意脚本执行权交给接口数据。建议彻底删除,交互改成前端静态映射。

数据请求

  • line 14-20props.topic 变化时发起新请求但没取消旧请求,旧响应可能覆盖新数据。建议用 AbortControllerignore 标志在 cleanup 里关掉。

UI 与渲染

  • line 37key={i} 用数组下标,列表增删重排时可能导致状态错位。建议用稳定的业务 id,比如 c.id

无障碍

  • line 34, 38:搜索 input 没有 aria-label 或关联 label<img> 没有 alt

泛泛地让 AI 做 review,它很容易给出"结构清晰、建议补充错误处理"这类通用意见。扔给 ESLint,也大概率只会在 any、未定义变量、hook 依赖这类规则上发声。Skill 的价值在于把评审标准显式化:AI 不是凭感觉聊几句,而是按一把真实的尺子在量代码。

我还拿其他 showcase 跑了一遍:用户资料页命中 12 条(竞态、未清理副作用、无空值守卫),注册表单命中 17 条(a11y、字段级错误、密码明文 input、防重复提交)。这些都是一眼看过去"差不多能用",但上线后很容易变成坑的代码。

怎么用

Skill 装起来一条命令的事:

curl -fsSL https://github.com/wsafight/front-end-checklist/releases/latest/download/install.sh \
  | sh -s -- claude

装完在对话里说 /frontend-checklist 或"按前端清单 review 我这个 PR",就会触发它。也支持 Kiro / Cursor / Codex,把命令结尾换一下就行。

清单是中英双语的,AI 会根据你提问的语言自动选对应版本。


两小时改造一个老项目听起来像标题党,但实际发生的事情比这更有意思:一份躺了三年没人翻的清单,换了个交付形式,突然就活过来了。内容还是那 160 条,变的只是"它怎么到达读者"。

如果你手里也有这种"明明有价值但没人用"的老文档,值得花个晚上,把它接进 AI 看看。

— 2026-05-09 夜

你还在手动敲命令部署?GitHub Actions 让你 push 即上线,摸鱼时间翻倍

作者 kyriewen
2026年5月9日 23:08

你改完代码,打开终端,输入 npm run build,然后 FTP 上传,或者登录服务器 git pull。这一套操作每天重复 N 次,不累吗?今天我们来把“部署”这件事自动化——用 GitHub Actions,只要你 git push,代码自动测试、自动打包、自动发到服务器。以后你只管写代码,上线交给机器人。

前言

我见过太多团队还停留在“手工部署”时代:上线先发个群消息“我要部署了,大家别动”,然后手动打包、上传、解压、重启。万一忘了执行某个步骤,线上就挂了。

GitHub Actions 就是你的免费 DevOps 机器人。它能监听 GitHub 上的事件(push、pull request、issue),然后执行你写好的自动化脚本。我们只需要写一个 YAML 文件,放在 .github/workflows 目录下,剩下的全部自动。

今天我们就来写一个完整的工作流:当推送到 main 分支时,自动运行测试、构建、并部署到服务器(或 Vercel / 阿里云 OSS)。全程保姆级,复制粘贴就能用。

一、准备工作:你需要什么?

  • GitHub 仓库(私有或公开都可以)。
  • 一台服务器(或云存储,如阿里云 OSS、Vercel)。
  • 如果部署到自己的服务器,需要服务器的 SSH 密钥(免密登录)。

如果你没有服务器,可以用 Vercel(个人项目免费,连 GitHub 自动部署也是免费的,甚至不需要写 Actions——但为了教学,我们还是会演示自定义部署到服务器的流程)。

二、基础工作流:跑测试 + 打包

在项目根目录创建 .github/workflows/deploy.yml

name: CI/CD Pipeline

on:
  push:
    branches: [ main ]   # 当推送到 main 分支时触发
  pull_request:
    branches: [ main ]   # PR 时也跑测试,但不部署

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: 检出代码
        uses: actions/checkout@v4

      - name: 安装 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: 安装依赖
        run: npm ci

      - name: 运行测试
        run: npm test

  build:
    runs-on: ubuntu-latest
    needs: test   # 测试通过后才构建
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm ci
      - run: npm run build
      - name: 上传构建产物(给后续部署用)
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

提交这个文件后,每次 git push main,GitHub 就会自动跑测试和构建。你可以在仓库的 Actions 标签页看到实时日志。

三、部署到自己的服务器(通过 SSH)

deploy.yml 中增加一个 job,依赖 build,然后通过 SSH 把构建产物上传到服务器。

首先在 GitHub 仓库 Settings → Secrets and variables → Actions 中添加几个密钥:

  • SERVER_HOST:你的服务器 IP
  • SERVER_USERNAME:登录用户名(如 root、ubuntu)
  • SSH_PRIVATE_KEY:服务器的私钥内容(复制 ~/.ssh/id_rsa 整个内容)

然后在 deploy.yml 中添加:

  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'push'   # 只有 push 时部署,PR 不部署
    steps:
      - name: 下载构建产物
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: 通过 SSH 部署
        uses: easingthemes/ssh-deploy@main
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          ARGS: "-rlgoDzvc -i --delete"
          SOURCE: "dist/"
          REMOTE_HOST: ${{ secrets.SERVER_HOST }}
          REMOTE_USER: ${{ secrets.SERVER_USERNAME }}
          TARGET: "/var/www/myapp/"   # 服务器上的目标目录

这样,每次 git push main,代码会自动出现在 /var/www/myapp 中。如果服务器上跑着 Nginx,刷新页面就是新版。

如果想要重启 PM2 进程,可以在部署步骤后加一个 exec 命令:

      - name: 重启 PM2 服务(如果后端是 Node)
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            pm2 restart myapp

四、部署到 Vercel(更简单)

如果你的项目是前端静态站点,Vercel 本身就是和 GitHub 集成的。但你也可以手动写 Actions 来调用 Vercel CLI。不过更推荐直接在 Vercel 网站导入 GitHub 仓库,它会自动监听 main 分支并部署,连 YAML 都不用写。

如果你坚持要用 Actions 调用 Vercel:

      - name: 部署到 Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID}}
          vercel-project-id: ${{ secrets.PROJECT_ID}}
          vercel-args: '--prod'

五、部署到阿里云 OSS(静态网站托管)

阿里云 OSS 支持静态网站。我们可以用 aliyun-cli 同步文件:

      - name: 安装阿里云 CLI
        run: npm install -g @alicloud/oss

      - name: 同步到 OSS
        run: |
          oss cp dist/ oss://my-bucket/ -r --force --access-key-id ${{ secrets.OSS_KEY_ID }} --access-key-secret ${{ secrets.OSS_KEY_SECRET }} --endpoint oss-cn-hangzhou.aliyuncs.com

六、进阶:分环境部署(dev/staging/prod)

你可以通过分支名来区分环境:

  • main 分支 → 生产环境
  • develop 分支 → 测试环境

on.push.branches 里可以写多个分支,然后在 job 里根据 github.ref_name 做判断,选择不同的服务器目录或环境变量。

七、常见坑点

  • 密钥泄露:永远不要把 SSH 私钥、密码明文写在代码里,要用 GitHub Secrets。
  • 构建产物太大upload-artifact 可能较慢,对于几 MB 的项目还好,几百 MB 建议直接推送到服务器或云存储。
  • 权限问题:确保服务器上目标目录有写入权限。
  • 缓存依赖:可以加 actions/cache 来缓存 node_modules,每次 build 快很多。

八、总结:让机器人替你干活

  • 写好 .github/workflows/deploy.yml,push 即触发。
  • 用 Secrets 存放敏感信息。
  • 可以串联测试、构建、部署,还能加个钉钉/飞书通知。

从此你只需要 git add . && git commit -m "fix: xxx" && git push,然后去倒杯水。回来群里就会收到:“生产环境部署成功,版本 v1.2.3”。不用记命令、不用等上传、不怕忘步骤。这才是现代前端该有的体验。

如果你觉得今天的“自动化”够解放双手,点个赞让更多人看到。评论区聊聊:你经历过哪些手工部署的惨案?

Pinia 状态管理

作者 Csvn
2026年5月9日 21:38

引言

在 Vue 3 项目中,状态管理是不可或缺的一部分。随着 Vuex 逐渐被 Pinia 取代,Pinia 凭借其更简洁的 API、更好的 TypeScript 支持和更轻量的体积,成为了 Vue 生态中的首选状态管理方案。本文将深入讲解 Pinia 的核心概念和实战技巧。

什么是 Pinia?

Pinia 是 Vue 官方推荐的状态管理库,可以看作是 Vuex 的继任者。它具有以下特点:

  • 轻量级:仅 1KB 大小(压缩后)
  • TypeScript 友好:完整的类型推断
  • 简洁 API:基于 Composition API 的设计
  • 模块化:天然支持代码分割
  • DevTools 支持:集成 Vue DevTools

核心概念

Store

Store 是 Pinia 的核心,用于存储和管理应用状态。每个 Store 都是独立的,可以单独使用。

State

State 是响应式的数据源,类似于 Vue 组件中的 data

Getters

Getters 用于计算派生状态,类似于 Vue 的 computed

Actions

Actions 用于处理业务逻辑,可以包含异步操作,类似于 Vue 组件中的 methods

快速上手

安装

npm install pinia

创建 Store

使用 defineStore 创建 Store:

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // State
  state: () => ({
    count: 0,
    name: 'Pinia'
  }),
  
  // Getters
  getters: {
    doubleCount: (state) => state.count * 2,
    fullName: (state) => `Hello, ${state.name}!`
  },
  
  // Actions
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    setCount(value) {
      this.count = value
    },
    async fetchCount() {
      const response = await fetch('/api/count')
      const data = await response.json()
      this.count = data.count
    }
  }
})

在组件中使用

<template>
  <div>
    <h1>{{ counterStore.fullName }}</h1>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double: {{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">+</button>
    <button @click="counterStore.decrement">-</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

高级用法

Store 持久化

使用 pinia-plugin-persistedstate 实现状态持久化:

npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

app.use(pinia)
// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: null
  }),
  actions: {
    login(token, userInfo) {
      this.token = token
      this.userInfo = userInfo
    }
  },
  persist: true // 自动持久化到 localStorage
})

多个 Store 协作

// stores/cart.js
import { defineStore } from 'pinia'
import { useProductStore } from './product'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),
  actions: {
    addToProduct(productId, quantity) {
      const productStore = useProductStore()
      const product = productStore.getProductById(productId)
      
      const existingItem = this.items.find(item => item.id === productId)
      if (existingItem) {
        existingItem.quantity += quantity
      } else {
        this.items.push({
          id: productId,
          name: product.name,
          price: product.price,
          quantity
        })
      }
    },
    
    get totalPrice() {
      return this.items.reduce((sum, item) => {
        return sum + item.price * item.quantity
      }, 0)
    }
  }
})

组合 Store

// stores/composed.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useCartStore } from './cart'

export const useCheckoutStore = defineStore('checkout', () => {
  const userStore = useUserStore()
  const cartStore = useCartStore()
  
  const canCheckout = computed(() => {
    return userStore.isLoggedIn && cartStore.items.length > 0
  })
  
  const checkout = async () => {
    if (!canCheckout.value) {
      throw new Error('Cannot checkout')
    }
    
    // 处理结账逻辑
    const result = await fetch('/api/checkout', {
      method: 'POST',
      body: JSON.stringify({
        userId: userStore.userInfo.id,
        items: cartStore.items
      })
    })
    
    return result.json()
  }
  
  return { canCheckout, checkout }
})

最佳实践

1. 合理的 Store 划分

按业务模块划分 Store,而不是按功能类型:

stores/
├── user.js      # 用户相关
├── product.js   # 商品相关
├── cart.js      # 购物车
└── order.js     # 订单

2. 使用 TypeScript

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', {
  state: (): { user: User | null } => ({
    user: null
  }),
  actions: {
    setUser(user: User) {
      this.user = user
    }
  }
})

3. 避免直接修改 State

始终通过 Actions 修改状态:

// ❌ 不推荐
store.count = 100

// ✅ 推荐
store.setCount(100)

4. 使用 Getters 进行派生计算

// ✅ 推荐
getters: {
  activeUsers: (state) => state.users.filter(user => user.isActive)
}

// ❌ 不推荐
computed: {
  activeUsers() {
    return this.users.filter(user => user.isActive)
  }
}

总结

Pinia 以其简洁的 API、优秀的 TypeScript 支持和轻量的体积,成为了 Vue 3 项目状态管理的首选。通过合理的 Store 划分、类型安全和最佳实践,可以构建出可维护、可扩展的状态管理方案。

关键要点:

  • 使用 defineStore 创建 Store
  • State、Getters、Actions 三大核心
  • 支持持久化和多 Store 协作
  • 推荐按业务模块划分 Store
  • 充分利用 TypeScript 类型推断

Pinia 让 Vue 应用的状态管理变得更加简单和优雅!

Claude Code + Amazon Bedrock 使用指南

作者 一棵白菜
2026年5月9日 20:34

Claude Code + Amazon Bedrock 使用指南

一、Claude Code 是什么?

Claude Code 是 Anthropic 官方推出的 AI 编程 CLI 工具,可以:

  • 直接在终端中与 AI 对话,完成代码编写、调试、重构
  • 自动读取项目代码上下文,理解整个代码库
  • 执行 shell 命令、编辑文件、运行测试
  • 支持 VS Code / Cursor 等 IDE 集成

通过 Amazon Bedrock 接入,我们无需使用个人 Anthropic API Key,统一走公司 AWS 账号,费用由公司统一结算。

二、前置条件

在开始之前,请确认以下环境已准备就绪:

条件 要求 检查方式
Node.js >= 18.0 node --version
AWS CLI v2 aws --version
AWS 凭证 已配置 aws sts get-caller-identity
操作系统 macOS / Linux / Windows (WSL) -

2.1 安装 Node.js

如果尚未安装 Node.js,推荐使用 nvm

# macOS / Linux
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.zshrc  # 或 source ~/.bashrc
nvm install 22
nvm use 22

2.2 安装并配置 AWS CLI

# macOS
brew install awscli

# 验证安装
aws --version

2.3 配置 AWS 凭证

请联系 DevOps 团队获取你的 AWS Access Key,然后执行:

aws configure

按提示输入:

AWS Access Key ID: <你的 Access Key ID>
AWS Secret Access Key: <你的 Secret Access Key>
Default region name: us-east-1
Default output format: json

验证凭证是否生效:

aws sts get-caller-identity

如果返回了你的 Account ID 和 ARN,说明配置成功。

三、安装 Claude Code

# 全局安装
npm install -g @anthropic-ai/claude-code

# 验证安装
claude --version

如遇权限问题(macOS),可尝试:

sudo npm install -g @anthropic-ai/claude-code --unsafe-perm

四、配置 Bedrock 连接

4.1 创建配置文件

编辑(或创建)Claude Code 的配置文件 ~/.claude/settings.json

{
  "env": {
    "CLAUDE_CODE_USE_BEDROCK": "1",
    "ANTHROPIC_MODEL": "us.anthropic.claude-sonnet-4-6-v1",
    "AWS_REGION": "us-east-1",
    "AWS_PROFILE": "default",
    "DISABLE_AUTOUPDATE": "1"
  },
  "permissions": {
    "allow": [],
    "deny": []
  }
}

配置文件路径:

操作系统 路径
macOS / Linux ~/.claude/settings.json
Windows %USERPROFILE%.claude\settings.json

注意:如果文件已存在其他配置,请将 env 字段合并进去,不要覆盖已有内容。

4.2 跳过登录引导流程

Claude Code 首次启动会进入 Anthropic 的登录引导(Onboarding)流程。使用 Bedrock 时需要跳过此步骤。

创建(或编辑)文件 ~/.claude.json(注意:是用户主目录下的 .claude.json,不是 .claude/ 目录里的):

{
  "hasCompletedOnboarding": true
}

说明:设置 hasCompletedOnboardingtrue 后,Claude Code 启动时会跳过默认的 Anthropic OAuth 登录流程,直接使用 settings.json 中配置的 Bedrock 连接。

4.3 验证配置

修改完成后重新打开终端,然后执行:

claude --version

如果没有弹出登录提示,说明配置成功。

五、可用模型

公司 Bedrock 账号已开通以下 Claude 模型:

模型 Bedrock Model ID 适用场景 相对成本
Claude Sonnet 4.6 us.anthropic.claude-sonnet-4-6-v1 日常编码、代码审查、调试(推荐默认)
Claude Haiku 4.5 us.anthropic.claude-haiku-4-5-v1 快速问答、简单任务、节省成本 ★(最低)
Claude Opus 4.6 us.anthropic.claude-opus-4-6-v1 复杂架构设计、深度推理 ★★★(最高)

切换模型

# 方式一:启动时指定
claude --model us.anthropic.claude-opus-4-6-v1

# 方式二:对话中切换
# 输入 /model 命令选择模型

成本提醒:Opus 模型费用约为 Sonnet 的 5 倍,请根据任务复杂度合理选择。日常开发建议使用 Sonnet,简单查询使用 Haiku。

六、快速上手

6.1 启动 Claude Code

# 在项目根目录启动
cd your-project
claude

首次启动会显示欢迎信息,确认模型连接成功。

6.2 基本用法

# 让 AI 解释代码
> 解释一下 src/main/java/com/example/UserService.java 的主要逻辑

# 让 AI 写代码
> 帮我写一个用户注册的 REST API,使用 Spring Boot

# 让 AI 修 Bug
> 这个 NullPointerException 是什么原因?帮我修复

# 让 AI 重构
> 把这个方法拆分成更小的函数,遵循单一职责原则

# 运行命令
> 运行项目的单元测试并分析失败原因

6.3 IDE 集成

Claude Code 同时支持在 IDE 中使用:

VS Code:

  1. 安装扩展:搜索 "Claude Code" 并安装
  2. 打开命令面板(Cmd+Shift+P),输入 "Claude"
  3. 快捷键 Cmd+Esc 打开 Claude 面板

JetBrains(IntelliJ IDEA 等):

  1. Settings -> Plugins -> 搜索 "Claude Code" 并安装
  2. 重启 IDE
  3. 右侧工具栏会出现 Claude 图标

IDE 集成会自动继承终端中配置的环境变量(Bedrock 配置),无需额外设置。

七、常用技巧

7.1 让 AI 理解你的项目

在项目根目录创建 CLAUDE.md 文件,写入项目背景信息:

# 项目说明

- 这是一个 Spring Boot 微服务项目
- 使用 MyBatis 作为 ORM
- 数据库:MySQL 8.0
- 构建工具:Maven
- Java 版本:17

## 代码规范

- 遵循阿里巴巴 Java 开发手册
- Controller 层不写业务逻辑
- Service 层通过接口定义

Claude Code 每次启动时会自动读取该文件,确保 AI 理解项目上下文。

7.2 实用快捷键

快捷键 功能
Esc(连按两次) 退出 Claude Code
Cmd+C 中断当前 AI 响应
/help 查看所有可用命令
/clear 清空对话历史
/compact 压缩上下文(对话太长时使用)
Tab 自动补全文件路径

7.3 权限模式

Claude Code 在执行文件操作和 shell 命令时会请你确认:

  • 输入 y:允许本次操作
  • 输入 n:拒绝本次操作
  • 输入 !:本次会话中始终允许该类型操作

八、常见问题

Q1:报错 "Could not connect to Bedrock"

排查步骤:

# 1. 检查 AWS 凭证是否有效
aws sts get-caller-identity

# 2. 检查区域配置
echo $AWS_REGION

# 3. 检查 Bedrock 访问权限
aws bedrock list-foundation-models --region us-east-1 --query "modelSummaries[?contains(modelId, 'claude')]"

如果第 3 步报权限不足,请联系 DevOps 团队申请 Bedrock 模型访问权限。

Q2:报错 "Model not found" 或 "Access denied"

可能原因:

  • Bedrock 未开通对应模型 -> 联系 DevOps 开通
  • Region 不匹配 -> 确认 AWS_REGION 设置为 us-east-1
  • AWS 凭证过期 -> 重新执行 aws sso login

Q3:响应速度很慢

  • 切换到 Haiku 模型(更快、更便宜)
  • 检查网络连接,确保能访问 AWS
  • 使用 /compact 压缩过长的对话上下文

Q4:JSON 配置不生效

  • 确保 JSON 格式合法(可以用 jsonlint.com 校验)
  • 修改配置后必须重新打开终端
  • 检查配置文件路径是否正确

Q5:macOS 安装权限错误

# 方案一:使用 sudo
sudo npm install -g @anthropic-ai/claude-code --unsafe-perm

# 方案二:修改 npm 全局目录(推荐)
mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
source ~/.zshrc
npm install -g @anthropic-ai/claude-code

九、安全须知

  1. 不要在 Claude Code 对话中粘贴密码、Token 等敏感信息,AI 会将其作为上下文处理
  2. 不要让 AI 直接操作生产环境,所有生产操作请走正规发布流程
  3. 代码审查仍然必要,AI 生成的代码需要经过 Code Review 后才能合并
  4. 注意成本控制,避免无意义的长对话消耗 Token

十、获取帮助

场景 联系方式
AWS 凭证 / 权限问题 DevOps 团队

前端周刊:axios 疑遭朝鲜黑客“钓鱼“;CSS 新函数上线;npm 上线深色主题;Oxlint 兼容表;ESLint 支持 Temporal......

作者 Web情报局
2026年5月9日 20:16

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周 Web 开发圈的主要情报包括:

  • 🐞 Axios 主席疑遭朝鲜黑客“钓鱼“,自爆了社会工程的诈骗过程
  • 🌗 npm 可信发布支持 CircleCI,npm 官网新增深色主题
  • ✅ ESLint 10.2 支持 JS 最新的 Temporal API
  • ✅ Axios 1.15 支持 Deno / Bun,源码重构了 url.parse()

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 每周热搜

🔗 Axios 主席疑遭朝鲜黑客“钓鱼“

Axios 是 GitHub 第一请求库,周下载量过亿。

不幸的是,愚人节前夕,Axios 突然发布了 2 个中毒版本,它们只坚挺了 3 小时就被封杀了,但至少波及几十万用户,这是近一年内最大规模的 npm 供应链攻击。

随后 Axios 团队主席爆料了攻击事件的完整事后分析,首先是它遭到了社会工程“钓鱼“,通过伪造的在线会议安装了有毒软件,导致 npm 账户被盗用。

axios-post.png

Axios 源码本身没有 bug,黑客只添加了一个幽灵依赖 plain-crypto-js,其中包含了一个 postinstall 脚本,随后使用传统 npm token 发布中毒版本。

用户使用 npm install axios 之后,postinstall 脚本会自动执行,请求其他恶意软件,盗用系统资料。

post-install.png

之后,这个脚本会自尽,删除 postinstall 脚本,替换为正常 package.json,用户对这种“完美犯罪“浑然不知。

谷歌和微软深入调查了本次赛博攻击,部分证据表明攻击来自朝鲜的黑客组织,但可能很难像川普打伊朗那样直接证明。

korea-npm.png

总之,供应链攻击是一种系统性原罪。我们应该遵循 npm 发包的最佳实践,防止黑客轻易绕过了现代化的可信发布流程。

🛜 官方情报

🔗 Node bug 悬赏项目破产

2016 年,Node 加盟了 HackerOne 的 IBB(互联网 bug 悬赏)项目,通过众筹为 fix bug 的志愿者提供奖金。

Node 团队会继续接收 bug,但由于资金链中断,该悬赏项目现已暂停。

node-bug.png

特别鸣谢一直以来为 Node 安全贡献的开发者和赞助商!Node 是 Web 开发的重要基建,如果你愿意提供赞助,请随时联系 OpenJS 基金会。

🔗 npm 可信发布支持 CircleCI

GitHub 官宣,npm 可信发布支持 CircleCI 作为 OIDC(OpenID 连接)供应商。

circle-ci.png

CircleCI 现在和 GitHub Actions 与 GitLab CI / CD 一样,维护者能从部署流程鉴权发包,无需长期 token。

此外,npm 官网上线深色主题了。

🔗 Oxlint / Oxfmt 兼容表

Oxc 官网新增了 Oxlint / Oxfmt 兼容表,可以直观地查看它们支持哪些 JS 框架和文件类型,从夯到拉分为四大梯队:

  1. 完整支持,比如 Oxlint + Oxfmt 完整支持 React 的代码质检和格式化
  2. 部分支持,比如 Oxlint 暂不支持 Vue 模板的代码质检
  3. 不支持,比如 Svelte 没有提供 Prettier 插件,Oxfmt 也不支持
  4. 越界功能,比如 Oxlint 不支持 CSS 代码质检

oxc.png

🚦 版本更新

🔗 ESLint 10.2

ESLint 是 GitHub 第一 JS Linter(代码质检工具),最近更新了 10.2 次版本。

首先,ESLint 新增了 meta.languages 属性,作者可以指定规则适用的语言,比如 JS 专属规则或 Markdown 专属规则等。

eslint.png

此外,ESLint 还支持 JS 最新的 Temporal 全局变量,no-undef 规则能识别 Temporal 而不会报警,no-obj-calls 规则会在直接调用 Temporal 时报警。

temporal.png

🔗 Axios 1.15

Axios 发布了 1.15 次版本,现在能支持 Bun / Deno。

Axios 还修复了代理处理和头部注入等安全漏洞,CI(持续集成)采用 OIDC 来守卫 npm 发布。

源码使用了原生 URL API,url.parse() 重构为 new URL()

url.png

💡 前端信息差

🔗 CSS 新函数 contrast-color()

最近 npm 上线了深色主题,但还有一些无障碍 bug,比如切换深色主题后文字对比度不够,看不清楚。

npm-a11y.png

Google 专家 Una 之前就提出了一个新的 CSS 函数 contrast-color(),它可以接受任意颜色,然后根据输入的颜色去计算对比度,最终返回 blackwhite

contrast.png

contrast-color() 可以自动生成对比度更好的文字,完美解决 npm 的无障碍文字 bug。

CSS contrast-color() 函数目前已经达到 Baseline Newly Available(全新可用基线),所有新版的主流浏览器都支持这个功能喔。

baseline.png

🛠️ 工具推荐

为了对抗供应链攻击,本期我们主要分享一些 npm 防御式指南

🔗 npm 可信发布

首先是 npm 官方文档出品的 trusted publishing(可信发布),它可以让 npm 和 GitHub CI(持续集成)完美搭配。

npm-trust.png

🔗 npmx 网站

再来是比 npm 更现代化的网站 npmx,npmx 会显示盾牌来说明这个 axios 版本是通过可信发布持续部署的。

你会发现 axios 的某些版本没有盾牌,说明 axios 没有严格使用可信发布,黑客就会从这里寻找机会。

npmx-oidc.png

npmx 不仅会警告你这个版本可信度降低,还会爆料模块的性能问题或安全漏洞,我知道你很急,但是你先别升级。

npmx-axios.png

🔗 pnpm 供应链攻击

还有就是比 npm 更现代化的包管理器 pnpm,pnpm v10 的官网提供了缓解供应链攻击的完整指南。

pnpm-trust.png

这些配置在最新版的 pnpm 11 中会默认启用,所以提前了解也方便你之后升级到 pnpm 11。

🔗 npm 安全最佳实践

最后,Node 安全专家 Liran 也在 GitHub 上分享了一份《npm 安全最佳实践》,建议收藏。

npm-best.png

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

一个简单的套壳方案,就能让你的 Agent 少做重复初始化

作者 imoo
2026年5月9日 18:58

前言

随着 harness 的完善,Agent 的启动过程正在从“输入任务后直接执行”,逐渐变成“先完成一组初始化动作,再进入任务”。

常见初始化内容包括:

  • 个人记忆、协作偏好、输出风格
  • 项目背景、目录结构、技术栈、历史约定
  • 团队规范、工作流、提交和评审规则
  • 工具说明、MCP 配置、常用命令
  • 特定任务类型依赖的 skills / agents

这些内容大多不是一次性信息。它们变化不频繁,却会在每个新会话里反复出现。

问题也就集中在这里:

  • 重复:相同初始化内容在多个会话中反复执行。
  • 耗时:每次启动都要等待加载和确认。
  • 不稳定:步骤越多,越容易漏读、乱序或加载不完整。

当 Agent 使用越来越依赖记忆、项目上下文和工具体系后,初始化本身就变成了一个需要工程化处理的问题。

解决思路:复用稳定初始化

一次 Agent 任务可以拆成两部分:

  • 稳定初始化:记忆、规则、项目背景、工具、skills。
  • 具体任务:本次真正要做的分析、开发、写作或排查。

基座工程关注的是第一部分。

如果某些内容满足下面三个条件,就不应该每次都重新初始化:

  • 相对稳定
  • 高频使用
  • 启动时必需

更合理的方式是:先让 Agent 完成一次稳定初始化,把这个状态保存成基座;之后每个新任务都从这个基座派生。

可以把它理解成下面这条链路:

记忆 / 规则 / 项目上下文 / skills
        │
        ▼
   生成基座会话
        │
        ▼
  每次新对话,都 fork 基座会话
        │
        ▼
    执行具体任务

需要注意:基座复用优化的是初始化过程,不是让上下文内容本身消失。初始化内容仍然会成为会话状态的一部分。

实现方式:给 Codex 包一层启动器

要把这个思路落地,关键不是直接改 Codex 本身,而是在 Codex CLI 外面包一层启动器。

原因很简单:如果用户每次都直接执行 codex,启动前后没有地方插入自己的逻辑。我们无法在启动前判断基座是否过期,也无法自动决定应该生成基座、复用基座,还是从某个指定基座 fork。

所以需要一个自己的入口,负责在调用 Codex 之前先做一层调度。

在我的本地实现里,这个入口叫 cx。它不是新的 Agent,也不是 Codex 的替代品,只是 Codex CLI 外面的一层启动器:

用户输入 cx
   │
   ▼
cx 启动器:检查基座 registry、必要时刷新、解析别名
   │
   ▼
基于基座会话,派生新的工作会话,以免污染基座
   │
   ▼
进入真正的 Codex 任务会话

使用案例

下面举个具体的个人使用的例子。我的 harness 工程首轮对话会加载许多记忆、技能等,每次 codex 耗时约 30s,使用该方案就可以跳过这部分,从而直接聚焦于具体问题。

默认情况下,直接执行 cx,会从 memory 记忆基座派生新会话:

如果记忆相关的文件更新的情况下,则会通过文件哈希校验自动更新基座:

除此之外,我还添加了一套基座的管理命令,主要用于管理各个开发项目的上下文基座。

cx -add <session-id> <别名>     # 把某个已有 Codex 会话登记为基座
cx -list                       # 查看当前登记的基座
cx -load <别名|session-id>      # 从指定基座 fork 新会话
cx -remove <别名>               # 删除手动登记的基座

默认基座:memory

当前实现里,memory 是默认基座。用户不指定 -load 时,都会走它。

memory 基座是托管基座:

  • 没有本机状态时,首次执行 cx 会自动生成。
  • 关键初始化资产变化时,会自动刷新。
  • 它不能通过 -remove memory 删除,避免默认启动链路被误删。

机器 B 第一次使用时不需要从机器 A 复制 state。只要 repo 里的基座定义还在,执行 cx 时就会在机器 B 上重新生成自己的 memory 基座。

手动基座:通过别名管理

除了默认 memory,还可以把某次已有 Codex 对话登记成一个手动基座。

这类基座适合临时复用某个已经加载过上下文的会话,比如某个项目、某类排查现场或某次专项分析。

不过它和 memory 不一样:手动基座记录的是本机 Codex session id,本质上是本机状态,换机器后不能自动恢复。

所以可以把基座分成两类:

类型 生成方式 是否可跨机器自动恢复 适合场景
memory 默认基座 根据 repo 里的记忆和规则自动生成 可以重新生成 长期稳定初始化
手动基座 cx -add <session-id> <别名> 不可以,需要重新登记 临时项目现场、专项上下文

资产指纹:判断 memory 是否过期

对于默认 memory 基座,启动器会跟踪一组关键初始化资产。

const KEY_FILES = [
  "src/brain/claude.md",
  "src/brain/knowledge/claude.md",
  "src/config/skills/claude.md",
  "src/config/agents/claude.md",
  "src/brain/knowledge/reinforced-rules.md",
  "src/brain/knowledge/workflow-superpowers.md",
];

这些文件决定了 Agent 启动后要加载哪些记忆、遵守哪些规则、知道哪些 skills / agents。

启动器会对它们计算 hash,并合成一个整体指纹:

function computeFingerprint(root) {
  const lines = KEY_FILES.map(file => {
    const content = fs.readFileSync(path.join(root, file));
    return sha256(content) + "  " + file;
  });

  return sha256(lines.join("\n"));
}

指纹没变,说明 memory 基座仍然可用。

指纹变了,说明记忆、规则或 skill 索引发生变化,需要重新生成 memory 基座。

哪些内容适合被基座化

适合进入基座的内容,通常满足三个条件:

  • 稳定:变化频率低。
  • 高频:多个任务都会用到。
  • 必要:缺失后会明显影响执行质量。

常见基座类型:

类型 适合放入的内容
个人基座 长期记忆、协作偏好、输出风格、常用工作习惯
项目基座 项目背景、目录结构、技术栈、常用命令、开发约定

不适合进入基座的内容也要明确:

  • 单次任务里的临时信息
  • 尚未确认的推测
  • 变化频繁的业务细节
  • 只对当前会话有价值的过程记录

简单判断标准是:如果一段内容下次大概率还会用到,并且启动时就需要知道,它就有机会被基座化。

给 AI Agent 装上"长期记忆":Karpathy 的 LLM Wiki 思想,我做成了工具

2026年5月9日 18:30

你的 AI 每次对话都在重新推导知识。而一个由 Agent 自己维护、会复利增长的 Wiki,让它越用越聪明。

这篇文章不是教你怎么敲 CLI 命令。memex 的入口在 agent 对话里——你只需要说 /memex:capture/memex:ingest/memex:query,Agent 自己知道怎么做。


一、Karpathy 在 2026 年 4 月提出了一个思想

HE9kEdZaMAADLIU.jpg

Andrej Karpathy 是 OpenAI 创始团队成员、前 Tesla AI 总监。2026 年 4 月 4 日,他在 GitHub Gist 上发布了一篇 LLM Wiki Pattern,系统阐述了一个思想:

为什么人类用 Wiki 积累知识,而 AI 每次对话都在从零推导?

他的主张很直接:给 LLM 一个结构化 Markdown Wiki,让它自己维护。人类只负责往 raw/ 里扔源材料,LLM 负责把知识编译进 wiki/——更新概念页、建立交叉引用、标注矛盾、写综合页。每轮对话不是"检索",是"阅读一本已经写好的书"。

他打了个比方,传得很广:

"Obsidian is the IDE, the LLM is the programmer, the wiki is the codebase."

翻译过来就是:"Obsidian 是 IDE,LLM 是程序员,Wiki 是代码库。"

什么意思?你写代码时——IDE 是你的界面,程序员是写代码的人,代码库是持续构建的产物。类比到这里——Obsidian(或任意 Markdown 浏览器)只是你看知识的界面,LLM 才是真正写知识的人,Wiki 就是 LLM 持续构建和维护的知识产物。你不写 Wiki,你看 Wiki;LLM 不读 Wiki,LLM 写 Wiki。

Karpathy 的核心洞见其实用一句话就能说清——他把知识库当代码仓库管理:

软件工程 知识库工程
src/ raw/(原始资料,不可变)
build/ wiki/(编译产物,LLM 自动生成)
编译器 LLM(把 raw 编译成结构化 wiki)
IDE Obsidian / 任意 Markdown 浏览器
Lint / CI 健康检查(断链、矛盾、过期页)
增量编译 每次只 ingest 新增的 raw,不改旧文件

我是开发出身,第一眼看到这张表就懂了。这不就是 CI/CD 的知识库版本吗?

软件工程 → 知识库工程 映射

而 Karpathy 用了一个词来概括这一切——编译(Compile)。把原始资料编译成结构化知识。raw 是源码,wiki 是编译产物。你不会把 .class.java 混在一起,笔记也一样。

核心区别在于:RAG 每次重推,Wiki 持续复利。

这句话拆开看——

RAG LLM Wiki
知识形态 文档切片,无关联 结构化页面,交叉引用
更新方式 重新索引 Agent 直接编辑 Markdown
查询 向量相似度拼凑 读已组织好的页面
累积性 没有复利 每次 ingest 在旧知识上修改、关联
所有权 在厂商的向量库里 在本地 Git 仓库里

Karpathy 给的是思想。我把它做成了工程:memex


二、memex 怎么用?在 agent 对话里说话就行

最重要的概念先摆出来——

你不是在终端敲 memex distillmemex ingest。你是在 agent 对话框里说 /memex:capture/memex:ingest/memex:query。CLI 只在 Agent 脚下跑,你感觉不到它。

memex 提供了 6 个 slash command,覆盖完整的知识生命周期:

Slash Command 你做什么 Agent 做什么
/memex:capture 给 Agent 一个 URL、一段文字、一个文件 Agent 保存到 raw/,记录出处,不变形
/memex:ingest "把这些新东西消化进知识库" Agent 读 raw 源材料,更新 concept/entity/source 页面,写交叉引用,更新 index
/memex:query "关于 X,我们知道哪些?" Agent 搜 wiki,综合答案,带引用
/memex:distill "这次对话有不少好结论,存下来" Agent 把会话要点蒸馏成结构化 raw 笔记
/memex:lint "检查一下知识库健不健康" Agent 跑机械检查 + 语义扫描,报问题,修问题
/memex:status "看看知识库现在什么状态" Agent 报告页面数、最近变化、待处理项

你不需要记住命令参数。你只需要用自然语言告诉 Agent 你想干什么,Agent 自己调对应的 slash command。

别上来就搞 RAG

一提"AI + 笔记",很多人的第一反应是搭 RAG:选 Embedding 模型、搭向量数据库、调切片策略。整套架构搞了一个月,笔记库里还是只有 20 篇文章。

Karpathy 的思路反过来:先跑通流程,再优化基础设施。 知识库规模不大的时候(几百篇文章以内),维护几个索引文件就够了。LLM 先读 index.md 定位,再直接阅读相关内容。简单、可靠、零额外成本。等你的笔记真的过了一万条,搜东西开始找不到、找不全了,再考虑 RAG 不迟。

这道理写代码的人都懂,但轮到自己搭知识库的时候就忘了。

每次问答也能存回知识库

还有一个 Karpathy 特别强调的设计:好的问答结果应该存回 wiki,而不是消失在聊天记录里。 你问了一个复杂问题,Agent 查 wiki、综合答案、带引用——这个答案本身就是一份有价值的知识产物。把它存成新页面。下次类似问题,Agent 直接读已有的分析,不用重新推导。

你每跟 AI 聊一次,知识库就增加一层。这就是复利。

知识编译管线:capture → ingest → query → lint


三、五个场景:memex 到底能带来什么价值

下面这五个场景,是我自己用了三个月的真实感受。

场景 1:长期研究 —— 让知识库自己长起来

痛点:你在研究"Agent Memory vs RAG"这个话题,今天看一篇论文,明天读一个开源项目,后天和 AI 讨论两个小时。三周后你想写篇总结文章——发现所有讨论散落在十几个聊天窗口里,找不到线索。

怎么做

你:/memex:capture https://arxiv.org/abs/xxxx --scene research
你:读到新的论文或讨论出新想法时,继续 capture 进去
你:积累几份材料后——
你:/memex:ingest 把这些新研究材料消化进 wiki
你:/memex:query "agent memory 和 RAG 的设计取舍,我们目前知道哪些?"

你始终在 agent 对话里。Agent 负责:

  • 把每篇论文、每次讨论存成 raw/research/ 下的源文件
  • ingest 时把新知识合并进 concepts/agent-memory.md、更新对比页 summaries/agent-memory-vs-rag.md
  • query 时综合 wiki 里的所有内容,带引用回答

价值:三周后,你拥有的不是十几个聊天窗口,而是一个结构化的知识地图——概念定义、方案对比、源材料索引、开放问题清单。写文章时,直接 /memex:query "agent memory 技术路线对比"

场景1:长期研究 — 知识库随时间生长

场景 2:长期项目 —— 让项目记忆可继承

痛点:你的项目已经迭代了三个月。今天用 Claude Code,明天用 Codex,后天用 Cursor。每个新 Agent 都要重新理解架构、踩过的坑、命名的原因、测试的边界。

怎么做

你:帮我连接这个项目到 memex 知识库
Agent:安装项目级别的 context 文件,记录相关的 scene

你:读当前代码和文档,然后起草这个项目的 architecturecommand-designknown-pitfalls 页面
Agent:读源码,写带有文件路径引用的 code-reading 笔记到 raw/

你:/memex:ingest 把这次 code-reading 结论写进项目 wiki
Agent:更新架构决策页、命令设计页、已知坑页、测试契约页

每次新会话开始:

你:/memex:query "继续 ai-memex-cli 网站和文档工作"
Agent:从 wiki 拉出最近的 handoff 笔记、未完成的任务、需要遵守的测试契约
你:从上次中断的地方继续

价值:项目知识不再是散落在聊天里的只言片语。新 Agent 开局就能回答"为什么这么设计"、"哪些地方容易踩坑"、"上次改到哪了"。代码仓库本身就是 source of truth,wiki 存的是 Agent 从代码、文档、issue、反馈中提炼出来的可继承理解

场景2:长期项目 — 三个 Agent 共用一个 wiki

场景 3:跨会话继承 —— 多次会话之间携带上下文

痛点:今天 Claude Code 做了一半,明天 Codex 继续,后天出差回来用 Cursor 检查。每个新会话都是一个黑洞——上下文全丢。

怎么做

你:/memex:distill 这次 Codex 会话,写清楚做到了哪、下一步做什么、有没有阻塞
Agent:找到当前 agent 的会话数据,蒸馏成 raw/sessions/ 下的结构化笔记

你:/memex:ingest 把这次 handoff 合并进项目记忆
Agent:更新项目 wiki 中的进度页和 log.md

——第二天,换了一个 agent——

你:/memex:query "上次中断的工作,下一步是什么"
Agent:从 wiki 里拉出 handoff 笔记和未完成项

跨 Agent 完全无感——Claude Code 写的,Codex 能读;Codex 补充的,Cursor 继续改。它们不共享一个聊天窗口,它们共享 raw/wiki/index.mdlog.md

价值:连续性不再绑定任何一个厂商。你可以换 Agent、换模型、等一周再回来,任务状态还在同一个 wiki 里等你。

场景3:跨会话继承 — 有无 memex 的对比

场景 4:对话沉淀 —— 把聊天里的好结论留下

痛点:一场深入对话里,你们讨论了产品定位、架构边界、某个 bug 根因、三个被否决的方案。聊完很爽,一周后只记得大概——细节全丢了。

怎么做

你:/memex:distill 这次对话,我们聊清楚了产品定位和几个关键的取舍
Agent:把对话蒸馏成 source 页,保留上下文、决策、未解问题

你:/memex:ingest 只要这次确定的稳定结论,合并到已有的 positioning 页面里
Agent:读蒸馏产物,提取可复用的结论,增量更新已有页面,不复制已有内容

什么样的结论值得沉淀?

  • 产品定位:怎么描述产品、避免用什么说法
  • 架构边界:为什么 CLI 不做语义层、为什么 raw 不可变
  • Bug 根因:排查路径、实际原因、回归测试要点
  • 被否决的方案:为什么没选、当时的前提是什么

价值:聊天不再是消耗品。重要推理先变成可追溯的 source,再变成结构化的 wiki 知识。下次 query 时,能同时看到结论和它为什么成立。如果前提变了,wiki 也能记录"老判断基于什么、新判断基于什么"。

场景4:对话沉淀 — 从聊天到 wiki 的蒸馏流

场景 5:结构化维护 —— 让 Agent 持续维护知识,而不是只回答一次

痛点:大部分人用 AI 的模式是"问一次答一次"。知识在回答完后原地消失。没人去更新、去合并重复页、去修断链、去标记过期内容。

怎么做

你:/memex:status
Agent:报告 vault 整体健康状况——页面数、最近更新的 source、哪些页面过时了、哪些维护任务待处理

你:/memex:lint 检查断链、孤儿页、过期页、缺失的 frontmatter
Agent:跑机械 lint(路径、链接、frontmatter 正确性)+ 语义扫描(矛盾、重复、过时论断)
你:机械问题直接修,语义问题先给我看方案
你:把 Karpathy 的 LLM Wiki gist 加入知识库
Agent:capture 源文件 → 创建 concept 页 → 更新相关页面交叉引用 → 写 log
你:告诉我改了什么,还有什么需要 review

价值:Wiki 不是一堆文件的堆积。它是一个被持续维护的结构化系统。每次 Agent 用它,也能同时改善它。重复页被合并或标注、孤儿页被找到、断链被修复、index.md 是真正的导航入口而非文件列表。

场景5:结构化维护 — lint 健康检查的四个维度


四、Agent 和 CLI 的分工边界

这里有一个设计决策需要讲清楚——CLI 永远只做机械正确性的事,不做语义判断。

谁负责 做什么
Agent Claude Code / Codex / Cursor 判断哪些页面要更新、哪些概念要链接、哪些矛盾要保留、哪些总结要重写
Slash Command /memex:capture 等 6 个 把用户的自然语言意图翻译成底层 CLI 调用
CLI memex 命令行工具 文件读写、frontmatter 校验、链接检查、关键词搜索、会话解析——纯机械,不调 LLM API

这意味着:

  • 你的知识不绑定任何厂商——Agent 可以换,wiki 不变
  • 你的知识是 Git 化的 Markdown——可以 diff、可以 blame、可以回退
  • CLI 永远不帮你做语义决策——"这两个页面是不是该合并"这种问题,Agent 自己判断但会问你

memex 三层架构:raw(不可变)→ wiki(编译)→ 输出

CLI 的补充能力

上面 6 个 slash command 覆盖日常 90% 的交互。CLI 底层还提供几个高阶能力,但不建议作为日常入口:

CLI 命令 用途 说明
memex watch 自愈守护进程 监听 raw/ 变化,自动触发 ingest → lint 循环。适合长期跑
memex inject 上下文注入 会话开始前,按任务描述从 wiki 拉最相关页面注入当前上下文
memex install-hooks 安装 Agent hooks 把 SessionStart / SessionEnd hook 写入 Agent 配置,自动 distill 和 inject
memex search 命令行搜索 全文搜索 wiki,适合脚本化场景

但这些不是入口。日常入口是 agent 对话框,是说 /memex:query 而不是敲 memex search


五、两周跑通最小闭环

如果你想试,不需要什么额外工具。装好 memex,在你的 Agent 里说话就行。

第一周:搭 raw → wiki 的最小循环。 装好 memex,运行 memex onboard。然后开始往知识库喂东西——看到好文章、好推文、好想法,直接对 Agent 说 /memex:capture。攒够 5 到 10 条后,说 /memex:ingest 把这些新素材消化进知识库。Agent 会生成摘要、提取概念、更新索引。

第二周:让问答开始积累,跑第一次健康检查。 每次对知识库做复杂提问,结果让 Agent 存回 wiki。然后说 /memex:lint 给知识库做一次全面体检。Agent 会扫出断链、矛盾、过期页、孤儿页——先让它修机械问题,语义问题你看一下再决定。

两周之后你有一个能持续运转的小系统。规模不重要,流程跑通了就行。后面就是往 raw/ 里不断喂素材,让 Agent 持续编译。


六、知识库的"GitHub 时刻"

回到 Karpathy。他那篇 Gist 的最后一句话是:

这套东西目前仍然像一堆 hacky scripts,但有空间做成 incredible new product。

我想到 2006 年前的版本控制。那时候也是 svn、cvs、git 命令行,只有程序员在用。然后有人把它做成了 GitHub,整个协作方式都变了。

个人知识库可能正在类似的节点。今天它是 Obsidian + LLM + 手搓脚本的组合,看起来还很粗糙。但底层范式已经有了:把知识当代码管理。 有输入,有编译,有产物,有测试。

如果你是程序员,好消息是你不需要学任何新东西。代码仓库怎么管,知识库就怎么管。你积累了这么多年的工程直觉,终于可以用在自己的笔记上了。

Karpathy 原文里还有一段话:

人类放弃 Wiki 是因为维护负担的增长速度永远超过它带来的价值。你得亲手写每个页面、手动保持一致性、记住所有交叉引用。

但 LLM 不会无聊。它可以一次触碰 10-15 个页面,把新知识合并进去,更新索引,同时保持系统自洽。

人的工作:策展、取舍、提问、思考。LLM 的工作:剩下的全部。

memex 做的,就是把这句话变成可以跑的东西。

别让你的笔记腐烂。让它们被编译。


快速开始:

npm install -g ai-memex-cli
memex onboard

然后在你的 Claude Code / Codex / Cursor 里说第一句话:

你:/memex:capture https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f --scene research
你:/memex:ingest Karpathy 的 LLM Wiki 思想,作为 research 场景的第一份材料
你:/memex:query "Karpathy 的 LLM Wiki 核心思想是什么?"

给 AI 一份会生长的记忆。


项目地址: github.com/zelixag/ai-…

理念来源: Karpathy's LLM Wiki Pattern

大模型和function calling分别是如何工作的

作者 review44543
2026年5月9日 18:02

以一个表处理为例:

github地址:github.com/YueJingGe/p…

用大模型处理表格

描述:有一个本周饮品销量小表,希望大模型输出按饮品汇总销量的列表,以及销量最高的饮品是什么

代码示例+运行结果

image.png

核心思想

当你把表格 用 df.to_string() 转成文本,然后放进 prompt 里时,大模型(如通义千问、GPT-4)会做这几步:

  • 1、阅读理解:识别出这是一个三列表格,理解列名含义(“饮品”“销量_杯”“门店”)。
  • 2、信息提取:逐行读取数据,记住每行对应关系。
  • 3、简单计算:将“美式”的两行销量 30 和 12 相加,得到 42;将“拿铁”的 18 单独作为总和;将“橙汁”的 12 作为总和。
  • 4、格式组织:按要求的格式输出“美式:42,拿铁:18,橙汁:12”。   这些能力完全来自大模型自身的训练——它在海量数据中学到了如何理解文本表格、如何进行基本的聚合计算(求和、计数等)、如何比较大小。你不需要额外实现任何逻辑,只要把表格文本喂给模型,它就能尝试回答。  

技术架构图

image.png

局限性

虽然模型能处理简单的表格问答,但有以下问题:

  • 1、计算错误:当表格行数多、数字复杂(如带小数、需加权平均),或者需要多步运算(先分组再排序再筛选),模型容易算错或遗漏。
  • 2、上下文长度:表格太大(比如1000行),放进 prompt 可能超过模型的 token 限制,或导致注意力分散、遗漏关键信息。
  • 3、不确定性:同一个表格问同一个问题,模型可能给出略有差异的答案(因为生成有随机性)。
  • 4、无法执行复杂逻辑:比如“找出销量最高的门店,再列出该门店销量前两名的饮品”,这类需要多步条件筛选和排序的任务,模型往往做不好。  

Function Calling + 大模型处理表

首先,Function calling(也称工具调用)不是让模型直接分析表格,而是让模型决定去调用一个外部工具(比如写好的 Python 函数) 来精确处理表格,再把工具返回的结果整理成自然语言回答。

描述:有一个本周饮品销量小表,希望大模型输出按饮品汇总销量的列表,以及销量最高的饮品是什么,以及哪个门店销量最高

典型流程:

用户问:“按饮品汇总销量。”
模型看到可用的函数列表,比如 compute_groupby_sum(column, group_by)。
模型不自己计算,而是输出一个函数调用指令:compute_groupby_sum(column="销量_杯", group_by="饮品")。
你的程序拦截这个指令,让 Python 后端实际执行 df.groupby("饮品")["销量_杯"].sum(),拿到精确结果 {"美式":42, "拿铁":18, "橙汁":12}。
你的程序把这个结果送回模型,模型再组织成一句人话:“美式共卖出42杯,拿铁18杯,橙汁12杯。”

代码示例

import os
import json
import pandas as pd
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(
    api_key=os.getenv('DASHSCOPE_API_KEY'),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# ---------- 定义真实的数据查询函数(后端执行) ----------
# 使用 pandas 创建数据表
df = pd.DataFrame({
    "饮品": ["美式", "拿铁", "橙汁", "美式"],
    "销量_杯": [30, 18, 12, 12],
    "门店": ["A店", "A店", "B店", "B店"],
})

def get_sales_summary(by: str = "饮品"):
    """
    获取销量汇总数据。
    by: 分组依据,目前支持 "饮品" 或 "门店"
    """
    if by == "饮品":
        grouped = df.groupby("饮品")["销量_杯"].sum().reset_index()
        grouped = grouped.sort_values("销量_杯", ascending=False)
        # 转为字典列表,方便模型阅读
        result = grouped.to_dict(orient="records")
    elif by == "门店":
        grouped = df.groupby("门店")["销量_杯"].sum().reset_index()
        grouped = grouped.sort_values("销量_杯", ascending=False)
        result = grouped.to_dict(orient="records")
    else:
        result = {"error": f"不支持的聚合方式: {by}"}
    return json.dumps(result, ensure_ascii=False)

def get_top_sales(by: str = "饮品"):
    """获取销量冠军"""
    if by == "饮品":
        grouped = df.groupby("饮品")["销量_杯"].sum()
        top_item = grouped.idxmax()
        top_value = int(grouped.max())
        return json.dumps({"冠军": top_item, "销量": top_value}, ensure_ascii=False)
    elif by == "门店":
        grouped = df.groupby("门店")["销量_杯"].sum()
        top_item = grouped.idxmax()
        top_value = int(grouped.max())
        return json.dumps({"冠军": top_item, "销量": top_value}, ensure_ascii=False)
    else:
        return json.dumps({"error": f"不支持的冠军查询: {by}"})

# ---------- 定义工具描述(给大模型看的说明书) ----------
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_sales_summary",
            "description": "按饮品或门店获取销量汇总,返回排序后的列表",
            "parameters": {
                "type": "object",
                "properties": {
                    "by": {
                        "type": "string",
                        "enum": ["饮品", "门店"],
                        "description": "分组维度,例如'饮品'或'门店'"
                    }
                },
                "required": ["by"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_top_sales",
            "description": "获取销量冠军(销量最高的饮品或门店)",
            "parameters": {
                "type": "object",
                "properties": {
                    "by": {
                        "type": "string",
                        "enum": ["饮品", "门店"],
                        "description": "冠军类型,例如'饮品'或'门店'"
                    }
                },
                "required": ["by"]
            }
        }
    }
]

# ---------- 主对话循环 ----------
def ask_with_function_calling(user_question):
    messages = [{"role": "user", "content": user_question}]
    
    # 第一次调用:让模型判断是否需要调用函数
    first_response = client.chat.completions.create(
        model="qwen-plus",  # 或 qwen-turbo
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )
    response_message = first_response.choices[0].message
    messages.append(response_message)
    
    # 模型要求调用函数吗?
    if response_message.tool_calls:
        for tool_call in response_message.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)
            print(f"🔧 模型调用: {func_name}, 参数: {func_args}")
            
            # 执行真实的 Python 函数
            if func_name == "get_sales_summary":
                result = get_sales_summary(**func_args)
            elif func_name == "get_top_sales":
                result = get_top_sales(**func_args)
            else:
                result = json.dumps({"error": "未知函数"})
            
            # 将工具结果加入对话
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })
        
        # 第二次调用:模型根据工具结果生成最终回答
        second_response = client.chat.completions.create(
            model="qwen-plus",
            messages=messages,
        )
        final_answer = second_response.choices[0].message.content
        return final_answer
    else:
        # 没有工具调用,直接返回模型回答
        return response_message.content

# ---------- 运行示例 ----------
if __name__ == "__main__":
    # 问题1:按饮品汇总销量
    q1 = "按饮品汇总销量,并告诉我销量最高的饮品是什么"
    print(f"🙋 用户: {q1}")
    ans1 = ask_with_function_calling(q1)
    print(f"🤖 AI: {ans1}\n")
    
    # 问题2:销量冠军门店
    q2 = "哪个门店的销量最高?"
    print(f"🙋 用户: {q2}")
    ans2 = ask_with_function_calling(q2)
    print(f"🤖 AI: {ans2}\n")

运行结果:

image.png

核心思想

核心思想就是:

1、你给模型预定义一组函数,告诉模型这些函数能做什么。

2、用户问自然语言问题,比如“按饮品汇总销量,然后自然地说出来”。

3、大模型自己决定该不该调用函数、调用哪个函数、传什么参数。

4、模型返回的不是最终答案,而是一个函数调用指令(例如 get_sales_summary(by='drink'))。

5、你的代码收到这个指令后,实际去执行 pandas 计算(或查数据库、调API等),然后把精确的计算结果再发回给模型。

6、模型根据结果生成最终的自然语言回答。

整个过程中,模型在主动决策“我需要调用工具来帮忙”,而不仅仅是接收现成数据。

技术架构图

image.png

总结

不用 Function Calling 的时候主要是依赖大模型本身的语言理解和推理能力,适合你完全清楚要算什么的场景,代码简单高效。

用 Function Calling是作为一种“增强手段”来提升准确性和能力上限,适合搭建对话式数据分析助手,用户可以随意提问,模型自动选择合适的函数去执行。

深入学习

Function Calling 官方资料:

阿里云百炼平台:搜索“阿里云百炼 Function Call”,参考官方文档。

社区博客:搜索“超实用!用 FunctionCall 实现快递 AI 助手”,了解用它构建真实 AI 助手的详细步骤。

CSDN 博客:搜索“通义千问的 Function Call”,更直观地理解整个实现流程。

用ethers.js连接MetaMask实现Web3钱包登录:从踩坑到稳定运行的完整记录

作者 竹林818
2026年5月9日 18:01

前言:一个看似简单的需求,让我折腾了两天

事情是这样的:上个月我在做一个DeFi看板项目,核心功能是让用户连接MetaMask钱包后,能看到自己的资产和交易记录。老板说"钱包登录很简单,就是调个接口嘛",我当时也觉得——不就是调个window.ethereum吗?结果真正动手时,踩坑踩到怀疑人生。

从连接失败、签名验证错误,到用户拒绝连接后状态混乱,再到不同链之间切换导致数据错乱……每一个问题都让我抓狂。但最终,我总结出了一套稳定可用的方案。这篇文章就是把我这两天的踩坑经历完整记录下来,希望对你有帮助。


背景:一个DeFi看板项目的前端需求

当时我正在开发一个叫"DeFiDash"的项目,本质上就是一个聚合看板,用户连接钱包后能查看自己在多个链上的资产、持仓和交易记录。前端用的是React + TypeScript,后端是Node.js。

核心需求其实就三个:

  1. 用户点击"连接钱包"按钮,弹出MetaMask授权窗口
  2. 用户签名一条消息,后端验证签名后返回JWT token
  3. 页面根据用户地址展示对应的链上数据

看起来很简单对吧?但真正实现时,我遇到了第一个大坑——连接过程的状态管理。


问题分析:为什么"一行代码"搞不定?

我最初的思路是直接写一个connectWallet函数:

// 第一版代码,天真到不行
async function connectWallet() {
  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
  setAddress(accounts[0]);
}

然后我发现了三个问题:

问题1:用户拒绝连接时,代码会直接崩溃。 如果用户点击了MetaMask弹窗的"取消"按钮,request会抛出一个错误,但我的代码没有捕获它,导致页面白屏。

问题2:连接后没有验证链ID。 用户可能连接的是以太坊主网,也可能连接的是Goerli测试网,但我根本没有检查。后来用户反馈说"连接后看不到资产",排查了半天才发现是链ID不匹配。

问题3:页面刷新后连接状态丢失。 用户连接成功后,刷新页面就需要重新连接。这体验太差了,而且每次刷新都弹MetaMask窗口,用户会疯的。

这三个问题让我意识到,钱包登录远不止"调一个接口"那么简单。我需要一个完整的连接流程,包括状态管理、错误处理、链ID验证和持久化。


核心实现:一步步搭建稳定的钱包登录

第一步:初始化Provider和检测MetaMask

在React中,我习惯把所有Web3相关的逻辑封装在一个自定义Hook里。首先,我需要一个provider——这是与区块链交互的底层对象。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

export function useWallet() {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [address, setAddress] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [error, setError] = useState<string>('');

  // 初始化:检测MetaMask是否安装
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setError('请安装MetaMask浏览器插件');
      return;
    }
    // 注意:这里不要自动请求连接,只在用户点击按钮时才触发
    const ethersProvider = new BrowserProvider(window.ethereum);
    setProvider(ethersProvider);
  }, []);
}

这里有个坑: 不要一加载页面就调用eth_requestAccounts。有些用户不想连接钱包,只是浏览页面,自动弹窗会吓到他们。正确的做法是只检测MetaMask是否存在,连接操作交给用户点击按钮触发。

第二步:实现连接逻辑,处理所有异常

连接钱包的核心是调用eth_requestAccounts,但必须做好错误处理。我当时用了一个笨办法——直接在catch里打印错误信息,后来发现不同错误类型需要不同处理方式。

// hooks/useWallet.ts(续)
const connect = useCallback(async () => {
  if (!provider) {
    setError('Provider未初始化');
    return;
  }

  try {
    // 关键:先请求账户,再获取签名者
    const accounts = await provider.send('eth_requestAccounts', []);
    if (accounts.length === 0) {
      throw new Error('没有获取到账户');
    }

    const userAddress = accounts[0];
    const userSigner = await provider.getSigner();
    const network = await provider.getNetwork();
    const userChainId = Number(network.chainId);

    // 验证链ID是否在支持的范围内
    const SUPPORTED_CHAIN_IDS = [1, 5, 137]; // 以太坊主网、Goerli、Polygon
    if (!SUPPORTED_CHAIN_IDS.includes(userChainId)) {
      // 这里可以提示用户切换网络,但先保存状态
      console.warn(`当前链ID ${userChainId} 不在支持列表中`);
    }

    setAddress(userAddress);
    setSigner(userSigner);
    setChainId(userChainId);
    setError('');

    // 持久化:把地址存到localStorage,下次刷新时自动恢复
    localStorage.setItem('walletAddress', userAddress);
    localStorage.setItem('walletChainId', userChainId.toString());

  } catch (err: any) {
    // 处理不同类型的错误
    if (err.code === 4001) {
      // 用户拒绝了连接请求
      setError('用户拒绝了连接请求');
    } else if (err.code === -32002) {
      // MetaMask正在处理另一个请求
      setError('请先处理MetaMask中的其他请求');
    } else {
      setError(err.message || '连接钱包失败');
    }
  }
}, [provider]);

注意这个细节: 错误码4001是用户拒绝,-32002是重复请求。这两个错误码我查了MetaMask文档才搞清楚,之前一直用err.message判断,结果发现不同版本的MetaMask返回的消息格式不一样。

第三步:消息签名与后端验证

登录不只是连接钱包,还需要让后端验证用户身份。最常用的方式是"消息签名"——前端让用户签名一条包含随机数(nonce)的消息,后端用公钥验证签名。

// hooks/useWallet.ts(续)
const signMessage = useCallback(async (message: string): Promise<string> => {
  if (!signer) {
    throw new Error('请先连接钱包');
  }

  try {
    // 注意:message应该包含一个nonce,防止重放攻击
    const signature = await signer.signMessage(message);
    return signature;
  } catch (err: any) {
    if (err.code === 4001) {
      throw new Error('用户取消了签名');
    }
    throw new Error('签名失败: ' + err.message);
  }
}, [signer]);

// 实际登录流程
const login = useCallback(async () => {
  if (!address) {
    setError('请先连接钱包');
    return;
  }

  try {
    // 1. 从后端获取nonce
    const nonceResponse = await fetch('/api/auth/nonce?address=' + address);
    const { nonce } = await nonceResponse.json();

    // 2. 让用户签名nonce
    const message = `欢迎登录DeFiDash,本次登录的随机码为:${nonce}`;
    const signature = await signMessage(message);

    // 3. 发送地址和签名到后端验证
    const loginResponse = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address, signature, message }),
    });

    const { token } = await loginResponse.json();
    // 存储token,后续API请求带上
    localStorage.setItem('authToken', token);
    setError('');

  } catch (err: any) {
    setError(err.message || '登录失败');
  }
}, [address, signMessage]);

这里有个坑: 签名消息的格式很重要。有些项目直接签address,这太不安全了,因为任何网站都可以伪造。一定要包含nonce,并且最好加上一些上下文信息(比如"欢迎登录XXX"),让用户在MetaMask里能看到清晰的内容。

第四步:页面刷新后自动恢复连接状态

用户连接成功后刷新页面,如果直接显示"未连接",体验很差。我通过localStorage保存地址,在页面加载时尝试恢复。

// hooks/useWallet.ts(续)
// 页面加载时恢复连接
useEffect(() => {
  const savedAddress = localStorage.getItem('walletAddress');
  const savedChainId = localStorage.getItem('walletChainId');

  if (savedAddress && provider) {
    // 恢复时只设置地址,不主动请求连接
    setAddress(savedAddress);
    setChainId(Number(savedChainId));
    // 注意:这里不设置signer,因为signer需要用户授权
    // 实际使用时,如果用户需要签名,再调用connect获取signer
  }
}, [provider]);

// 监听账户变化和链变化
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    if (accounts.length === 0) {
      // 用户断开了连接
      disconnect();
    } else {
      setAddress(accounts[0]);
      localStorage.setItem('walletAddress', accounts[0]);
    }
  };

  const handleChainChanged = (chainIdHex: string) => {
    const newChainId = parseInt(chainIdHex, 16);
    setChainId(newChainId);
    localStorage.setItem('walletChainId', newChainId.toString());
    // 链变化后,signer需要重新获取
    if (provider) {
      provider.getSigner().then(setSigner);
    }
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  return () => {
    window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum.removeListener('chainChanged', handleChainChanged);
  };
}, [provider]);

const disconnect = useCallback(() => {
  setAddress('');
  setSigner(null);
  setChainId(0);
  localStorage.removeItem('walletAddress');
  localStorage.removeItem('walletChainId');
  localStorage.removeItem('authToken');
}, []);

注意这个细节: chainChanged事件返回的是十六进制字符串(比如"0x5"),需要转成十进制。我当时直接用了parseInt,但忘记加基数参数,导致"0x5"被解析成0。排查了半天才发现。


完整代码:可直接运行的React Hook

我把上面所有代码整合成一个完整的useWallet Hook,你可以直接复制到项目中使用。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

interface WalletState {
  provider: BrowserProvider | null;
  signer: JsonRpcSigner | null;
  address: string;
  chainId: number;
  error: string;
  isConnecting: boolean;
}

export function useWallet() {
  const [state, setState] = useState<WalletState>({
    provider: null,
    signer: null,
    address: '',
    chainId: 0,
    error: '',
    isConnecting: false,
  });

  // 初始化:检测MetaMask
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setState(prev => ({ ...prev, error: '请安装MetaMask插件' }));
      return;
    }
    const ethersProvider = new BrowserProvider(window.ethereum);
    setState(prev => ({ ...prev, provider: ethersProvider }));
  }, []);

  // 恢复上次连接
  useEffect(() => {
    const savedAddress = localStorage.getItem('walletAddress');
    if (savedAddress && state.provider) {
      setState(prev => ({ ...prev, address: savedAddress }));
    }
  }, [state.provider]);

  // 连接钱包
  const connect = useCallback(async () => {
    if (!state.provider) {
      setState(prev => ({ ...prev, error: 'Provider未初始化' }));
      return;
    }

    setState(prev => ({ ...prev, isConnecting: true, error: '' }));

    try {
      const accounts = await state.provider.send('eth_requestAccounts', []);
      if (accounts.length === 0) {
        throw new Error('没有获取到账户');
      }

      const userAddress = accounts[0];
      const userSigner = await state.provider.getSigner();
      const network = await state.provider.getNetwork();
      const userChainId = Number(network.chainId);

      localStorage.setItem('walletAddress', userAddress);
      localStorage.setItem('walletChainId', userChainId.toString());

      setState(prev => ({
        ...prev,
        address: userAddress,
        signer: userSigner,
        chainId: userChainId,
        isConnecting: false,
      }));
    } catch (err: any) {
      let errorMsg = '连接钱包失败';
      if (err.code === 4001) errorMsg = '用户拒绝了连接请求';
      else if (err.code === -32002) errorMsg = '请先处理MetaMask中的其他请求';
      else if (err.message) errorMsg = err.message;

      setState(prev => ({ ...prev, error: errorMsg, isConnecting: false }));
    }
  }, [state.provider]);

  // 断开连接
  const disconnect = useCallback(() => {
    localStorage.removeItem('walletAddress');
    localStorage.removeItem('walletChainId');
    localStorage.removeItem('authToken');
    setState(prev => ({
      ...prev,
      address: '',
      signer: null,
      chainId: 0,
      error: '',
    }));
  }, []);

  // 监听事件
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnect();
      } else {
        setState(prev => ({ ...prev, address: accounts[0] }));
        localStorage.setItem('walletAddress', accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      const newChainId = parseInt(chainIdHex, 16);
      setState(prev => ({ ...prev, chainId: newChainId }));
      localStorage.setItem('walletChainId', newChainId.toString());
      // 重新获取signer
      if (state.provider) {
        state.provider.getSigner().then(signer => {
          setState(prev => ({ ...prev, signer }));
        });
      }
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    };
  }, [state.provider, disconnect]);

  // 签名消息
  const signMessage = useCallback(async (message: string): Promise<string> => {
    if (!state.signer) {
      throw new Error('请先连接钱包');
    }
    try {
      return await state.signer.signMessage(message);
    } catch (err: any) {
      if (err.code === 4001) throw new Error('用户取消了签名');
      throw new Error('签名失败: ' + err.message);
    }
  }, [state.signer]);

  return {
    ...state,
    connect,
    disconnect,
    signMessage,
  };
}

使用示例:

// App.tsx
import { useWallet } from './hooks/useWallet';

function App() {
  const { address, chainId, error, isConnecting, connect, disconnect, signMessage } = useWallet();

  const handleLogin = async () => {
    try {
      // 假设后端返回nonce
      const signature = await signMessage('登录nonce: 123456');
      // 发送signature到后端验证
      console.log('签名结果:', signature);
    } catch (err: any) {
      console.error(err.message);
    }
  };

  return (
    <div>
      {address ? (
        <div>
          <p>已连接: {address.slice(0, 6)}...{address.slice(-4)}</p>
          <p>链ID: {chainId}</p>
          <button onClick={handleLogin}>签名登录</button>
          <button onClick={disconnect}>断开连接</button>
        </div>
      ) : (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录:我实际遇到的4个问题

1. ethers.getDefaultProvider() 在浏览器端报错

  • 报错信息:Error: network error: The method eth_getBlockByNumber does not exist/is not available
  • 原因:getDefaultProvider() 会尝试连接以太坊节点,但在浏览器端,应该使用MetaMask注入的provider。
  • 解决:统一使用 new BrowserProvider(window.ethereum)

2. 用户拒绝连接后,再次点击连接按钮无反应

  • 现象:用户第一次点击"连接钱包"时取消了MetaMask弹窗,再次点击按钮,弹窗不出现了。
  • 原因:MetaMask检测到已有挂起的请求,返回错误码-32002
  • 解决:在catch中处理这个错误,提示用户"请先处理MetaMask中的其他请求",并建议用户刷新页面。

3. 签名时MetaMask弹窗不显示消息内容

  • 现象:调用signer.signMessage()时,MetaMask弹窗只显示"签名请求",没有显示具体的消息内容。
  • 原因:消息是纯字符串,没有格式化为可读的EIP-712类型数据。
  • 解决:对于简单登录,可以使用personal_sign方法,或者确保消息中包含可读的上下文(如"登录XXX")。

4. 链切换后,signer对象失效

  • 现象:用户在MetaMask中切换了网络,但调用signer.getAddress()时返回了旧地址。
  • 原因:signer是在连接时创建的,链变化后需要重新获取signer。
  • 解决:在chainChanged事件监听中,重新调用provider.getSigner()

小结

连接MetaMask实现钱包登录,核心就三点:正确处理用户授权、严格管理状态变化、做好异常处理。不要被"一行代码"的错觉迷惑,真正的坑都在细节里——比如错误码、链ID格式、事件监听的生命周期。

如果你继续深入,可以研究一下:

  • 使用wagmiRainbowKit等库简化钱包连接
  • 实现多链支持,让用户在不同链之间切换
  • 集成EIP-712类型数据签名,提升用户体验

希望这篇文章能帮你少踩一些坑。如果你在实现过程中遇到了其他问题,欢迎在评论区交流。

如何用 Recast 实现静态配置文件源码级读写

作者 heyCHEEMS
2026年5月9日 17:29

当你使用 Node.js 修改代码时,用正则直接操作字符串极其危险且难以维护。通过 AST 就可以像操作 JSON 一样精确地增删改查代码。

  • Babel (@babel/parser):负责执行将代码拆成 Token、将 Token 组装成树,把字符串拆解成一个个节点,如变量名、数值、函数。但是在修改并回写代码时,会丢失原有的缩进、空格和注释,导致代码格式全乱。
  • Recast:在解析时会记录每个节点的原始位置和格式,在回写阶段会进行自动对比,只更新你修改过的部分。

Recast 基础用法

  1. recast.parse:把代码字符串解析成一颗 AST 树。
  2. recast.visit:在树上找节点,比如找到名为 config 的变量。
  3. recast.types.builders (简称 b ) :假如你想把数字 1 改成 2,你需要用 builder 造出一个“数字 2”的节点来替换。

使用 recast.visit 时,可以从 path.node 拿到需要的数据。

path.node 常用属性

type:节点的类型(如 Identifier, Literal),这是判断“它是什么”的第一步。

loc:包含 startend 的行号列号,Recast 靠它实现精准的局部替换。

comments:存放该节点的注释信息,可以通过 b.commentLine 往里推入新注释。

变量声明 (VariableDeclarator)

id:左手边的变量名节点,通常 node.id.name 就能拿到 "port"

init:右手边的初始值节点,它是你要读取或替换的核心。

对象属性 (ObjectProperty)

key:键名,注意:如果是 { 'a-b': 1 },key 是 StringLiteral;如果是 { a: 1 },key 是 Identifier

value:键值,可以是任何表达式(数字、函数、另一个对象)。

computed:布尔值,如果为 true,说明是 { [prop]: 1 } 这种计算属性。

成员表达式 (MemberExpression)

object:点号左边的部分,如 process.env.PORT 中的 process.env

property:点号右边的部分,如 PORT

函数调用 (CallExpression)

arguments:一个数组,存放所有传入的参数节点,修改它就能增删函数参数。

字面量 (Literal 系列)

value:存放在 JS 里的实际值(如数字 8080,字符串 "localhost")。

raw:原始文本,比如源码写的是 0x10value16,而 raw 就是 "0x10"

1. 定义解析规则

const recast = require('recast');
const parser = require('@babel/parser');
const b = recast.types.builders; // 用来创建新的代码节点

const options = {
  // 解析器配置
  parser: {
    parse: source => parser.parse(source, {
      sourceType: 'module', // es模块化
      plugins: ['typescript'] // 开启 TS 插件
    })
  }
};

2. 从源码提取数据

比如把 const port = 8080 变成 JS 对象 { port: 8080 }

function getConfig(code) {
  const ast = recast.parse(code, options); // 先解析为 AST
  const result = {};

  recast.visit(ast, {
    // 遍历所有的变量定义
    visitVariableDeclarator(path) {
      const node = path.node;
      // node.id.name 是变量名,node.init.value 是变量的值
      result[node.id.name] = node.init.value; 
      return false; // 找到后停止向下搜寻
    }
  });
  return result;
}

3. 回写源码内容

假设把源码里的 port 改为 9090,并加上注释。

function updateConfig(oldCode, newValues) {
  const ast = recast.parse(oldCode, options); // 先解析为 AST

  recast.visit(ast, {
    visitVariableDeclarator(path) {
      const varName = path.node.id.name;
      if (newValues[varName]) {
        // 用 builder 创建一个新的 number 节点
        const newValueNode = b.numericLiteral(newValues[varName]);
        
        // 替换旧的初始值
        path.get('init').replace(newValueNode);

        // 添加一行注释
        path.parentPath.node.comments = [b.commentLine(' 自动生成的配置')];
      }
      return false;
    }
  });

  // 输出转换结果
  return recast.print(ast, { quote: 'single' }).code;
}

常用的遍历节点类型:

visitVariableDeclarator:匹配变量定义,如 const a = 1 中的 a = 1 部分。

visitObjectProperty:匹配对象属性,用于读写 { key: value } 中的键值对。

visitArrayExpression:匹配数组配置,常用于增删 [item1, item2] 中的元素。

visitImportDeclaration:匹配导入语句,用于分析或修改 import 的路径与成员。

visitExportNamedDeclaration:匹配导出语句,用于处理 export const config = {}

visitCallExpression:匹配函数调用,用于修改 init({ port: 80 }) 等执行语句的参数。

visitAssignmentExpression:匹配赋值操作,如修改 module.exports = {} 或变量重赋值。

visitMemberExpression:匹配成员访问,用于处理 process.env.NODE_ENV 这种点语法。

visitIdentifier:匹配所有标识符,即代码中出现的变量名、函数名或属性名。

visitStringLiteral / NumericLiteral:匹配字符串或数字字面量,用于直接改写基础值。

visitExpressionStatement:匹配独立的表达式语句,常用于在文件顶层插入新代码行。

visitIfStatement:匹配条件判断,用于自动化修改 if (isDev) 等逻辑分支。

visitArrowFunctionExpression:匹配箭头函数,用于重构或分析回调函数内容。

visitClassDeclaration:匹配类定义,用于提取类名、继承关系或修改装饰器。


4. 引用变量的提取与回写

处理节点时,引用变量是一个比较麻烦的地方。比如代码中不仅仅只是简单的 port: 8080,而是 port: DEFAULT_PORTpath: process.env.URL。直接读取 node.init.value 会得到 undefined,因为这些值不是字面量(Literal) ,而是标识符(Identifier)或成员表达式(MemberExpression) 。

提取逻辑示例

遇到如 IdentifierMemberExpression 等非字面量节点时,递归提取其完整路径,并用特殊标记(如 __isRef: true)包装,防止丢失引用关系。

// 递归提取引用路径
_getMemberPath(node) {
    if (node.type === 'Identifier') return node.name;
    if (node.type === 'MemberExpression') {
        // 递归向上拼接
        return `${this._getMemberPath(node.object)}.${node.property.name}`;
    }
    return '';
}

// 如果是引用则返回包装对象
if (node.type === 'Identifier' || node.type === 'MemberExpression') {
    return { __isRef: true, __refName: this._getMemberPath(node) };
}

回写逻辑示例

回写时识别标记,通过 split('.') 将路径切开,利用 reduce 配合 b.memberExpression 进行还原。

// 将字符串还原为 AST 
if (val && val.__isRef) {
    const parts = val.__refName.split('.');
    return parts.reduce((sum, cur) => {
        if (!sum) return b.identifier(cur); // 第一个基础标识符
        return b.memberExpression(sum, b.identifier(cur)); // 向下递归拼接
    }, null);
}

Recast 是如何进行自动对比的?

在执行 recast.parse 时,Recast 会为 AST 的每个节点打上一个隐藏的标签,记录该节点在原始字符串中的起始位置 loc.start 和结束位置 loc.end ,以及它周围的所有空格、换行、分号。

当你使用 replace 修改了某个节点或者它的属性时,Recast 会将该节点标记为脏节点 。

在执行 recast.print 时,Recast 的渲染器会遍历整棵树:

  • 如果是干净节点,则直接从原始字符串中,根据记录的 loc 坐标切出那一段文本。
  • 如果是脏节点,则递归调用生成器,根据 Babel 规则重新生成这一小段代码,并尝试参考父节点的缩进风格进行对齐。

❌
❌