简单直接的方法
1.将int数组转换成一个字符串
2.定义一个新数组长度是字符串的长度
3.用chartAt方法取出每一个下标下的char转为数字塞进新数组里
![]()
1.将int数组转换成一个字符串
2.定义一个新数组长度是字符串的长度
3.用chartAt方法取出每一个下标下的char转为数字塞进新数组里
![]()
给你一个正整数数组 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 <= 10001 <= nums[i] <= 105这道题要求将给定的正整数数组 $\textit{nums}$ 中的所有正整数按数位分割,保持数位顺序存入答案数组。需要首先遍历数组 $\textit{nums}$ 得到数位总数,然后将每个正整数按数位分割并存入答案数组。
首先遍历数组 $\textit{nums}$ 得到所有正整数的数位总数 $\textit{totalLength}$,然后创建长度为 $\textit{totalLength}$ 的答案数组 $\textit{answer}$,再次遍历数组 $\textit{nums}$,遍历过程中维护答案数组的当前下标 $\textit{index}$,对于每个正整数,执行如下操作。
用 $\textit{start}$ 表示当前正整数的数位填入答案数组的起始下标,$\textit{start} = \textit{index}$。
每次将 $\textit{num}$ 的最低位填入 $\textit{answer}[\textit{index}]$,然后将 $\textit{index}$ 的值增加 $1$,重复该操作直到 $\textit{num}$ 的所有位都填入答案数组。
当前正整数 $\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)$。注意返回值不计入空间复杂度。
这个刚开始写的时候担心数组的长度和时间复杂度的原因,担心通过不了,结果没想过通过了。
我是用一个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;
}
把 $\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
}
不断地把 $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
}
欢迎关注 B站@灵茶山艾府
给你一个下标从 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] <= 1090 <= target <= 2 * 109思路
f[i]表示从0到下标i的最大跳跃次数;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]
{: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;
}
}
如果有帮助到你,请给题解点个赞 和 收藏,让更多的人看到 ~ ("▔□▔)/
想一想,最后一步发生了什么?
最后一步,我们从某个满足条件的下标 $i$ 跳到了下标 $n-1$。
枚举满足条件的 $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}(n-1)$,这是原问题,也是答案。
考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:
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
}
我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。
具体来说,$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
}
如果 $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)
}
}
更多相似题目,见下面动态规划题单的「§11.4 树状数组/线段树优化 DP」和「专题:跳跃游戏」。
欢迎关注 B站@灵茶山艾府
写这篇博客的由头有点特别。
我有一个叫 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 让我写代码变快"。快是肯定快,但这次改造最后落到了一件我之前没想过的事情上——一份静态文档,换了个交付形式,才真正开始被用起来。
先交代清楚这个清单是什么。
它不是 ESLint 能查的那种风格规则,而是 Code Review 时需要结合业务和上下文判断的问题。目前一共 25 个分组、160 条检查项:
命名规范 数据与类型 函数设计 状态管理 控制流
异步处理 数据请求 UI 与渲染 样式与响应式 路由与权限
性能 安全与健壮性 表单与交互 错误处理 测试
无障碍访问 用户体验 代码质量 工程化 国际化
日志与监控 依赖管理 浏览器兼容 文档与协作 PR 自检
随便挑几条看看:
- 区分"缺失"、"为空"、"为 0"、"为空数组"、"为空字符串"的业务含义
- 处理并发请求的竞态问题,避免旧响应覆盖新状态
- 同一表单/输入控件不要在受控与非受控之间切换
useEffect/watch的依赖项必须完整,避免闭包捕获过期值- 定时器和事件监听记得清除,否则可能引发内存泄漏
- 表单错误应定位到具体字段,而不是只给出笼统提示
这些不是靠格式化、类型推导或者简单 AST 规则就能稳定抓出来的问题。它们往往来自真实线上事故、返工、误解和交接成本。以前这些经验只能靠人去记,记不住就会在别的项目里重新踩一次。
清单做完这三年,我一直有个遗憾:没人真的会去翻。
我自己都不翻。做一个 PR 的时候,我知道清单里有条"处理并发请求的竞态",但我会不会每次都老老实实打开页面对一遍?不会。同事更不会。新人入职时我会把链接丢给他们,他们收藏,然后就再也不打开了。
这是很多静态文档共同的问题:信息在那里,但你必须主动去触达它。而在 CR 场景里,主动触达的前提是你已经意识到自己可能漏了什么。可真正漏掉的时候,人往往意识不到。
我试过一些办法:
根本问题不是文档形式不够花哨,而是查阅式文档很难主动进入人的工作流。
Claude Code 的 Skill 机制改变了这件事。
简单说,Skill 是一段给 AI 读的说明书,加上它执行任务时需要参考的资料。当你在对话里说到特定触发词时,AI 会加载这个 Skill,并按说明书走流程。
我的 frontend-checklist Skill 里写的是这样的逻辑:
/frontend-checklist、"按前端清单 review"、"按 checklist 检查这段代码"。用户没提,它不会自作主张去扫代码。git diff 定位变更;如果都没有,就先确认范围,不盲目扫整个仓库。关键的翻转在这里:
以前是"你去翻清单"。现在是"清单来找你"。
写 PR 的时候,你不需要记得清单里每一条是什么,直接在 IDE 里说一句"按前端清单 review 我这个 PR",AI 会把 160 条和当前 diff 放在一起看,只告诉你命中的那些,并给出文件路径和行号,必要时附 1-3 行示例代码。心智负担从"记住 160 条"降到"知道有这么一个入口"。
空讲没意思,看一段 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 35:dangerouslySetInnerHTML={{ __html: props.tip }} 直接吃外部 tip,存在 XSS 风险。建议默认文本渲染,真要富文本时先用 DOMPurify 消毒。line 44:highlight 用字符串拼接 HTML 再 dangerouslySetInnerHTML,body 和 keyword 都没转义。建议改成 String.split 分段渲染,把命中段包进 <mark>,不要注入 HTML。line 39:eval(c.author.onClick) 把接口返回的字符串当代码执行,等于把任意脚本执行权交给接口数据。建议彻底删除,交互改成前端静态映射。数据请求
line 14-20:props.topic 变化时发起新请求但没取消旧请求,旧响应可能覆盖新数据。建议用 AbortController 或 ignore 标志在 cleanup 里关掉。UI 与渲染
line 37:key={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 夜
你改完代码,打开终端,输入
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)。全程保姆级,复制粘贴就能用。
如果你没有服务器,可以用 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 标签页看到实时日志。
在 deploy.yml 中增加一个 job,依赖 build,然后通过 SSH 把构建产物上传到服务器。
首先在 GitHub 仓库 Settings → Secrets and variables → Actions 中添加几个密钥:
SERVER_HOST:你的服务器 IPSERVER_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 本身就是和 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 支持静态网站。我们可以用 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
你可以通过分支名来区分环境:
main 分支 → 生产环境develop 分支 → 测试环境在 on.push.branches 里可以写多个分支,然后在 job 里根据 github.ref_name 做判断,选择不同的服务器目录或环境变量。
upload-artifact 可能较慢,对于几 MB 的项目还好,几百 MB 建议直接推送到服务器或云存储。actions/cache 来缓存 node_modules,每次 build 快很多。.github/workflows/deploy.yml,push 即触发。从此你只需要 git add . && git commit -m "fix: xxx" && git push,然后去倒杯水。回来群里就会收到:“生产环境部署成功,版本 v1.2.3”。不用记命令、不用等上传、不怕忘步骤。这才是现代前端该有的体验。
如果你觉得今天的“自动化”够解放双手,点个赞让更多人看到。评论区聊聊:你经历过哪些手工部署的惨案?
在 Vue 3 项目中,状态管理是不可或缺的一部分。随着 Vuex 逐渐被 Pinia 取代,Pinia 凭借其更简洁的 API、更好的 TypeScript 支持和更轻量的体积,成为了 Vue 生态中的首选状态管理方案。本文将深入讲解 Pinia 的核心概念和实战技巧。
Pinia 是 Vue 官方推荐的状态管理库,可以看作是 Vuex 的继任者。它具有以下特点:
Store 是 Pinia 的核心,用于存储和管理应用状态。每个 Store 都是独立的,可以单独使用。
State 是响应式的数据源,类似于 Vue 组件中的 data。
Getters 用于计算派生状态,类似于 Vue 的 computed。
Actions 用于处理业务逻辑,可以包含异步操作,类似于 Vue 组件中的 methods。
npm install pinia
使用 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>
使用 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
})
// 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)
}
}
})
// 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 }
})
按业务模块划分 Store,而不是按功能类型:
stores/
├── user.js # 用户相关
├── product.js # 商品相关
├── cart.js # 购物车
└── order.js # 订单
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
}
}
})
始终通过 Actions 修改状态:
// ❌ 不推荐
store.count = 100
// ✅ 推荐
store.setCount(100)
// ✅ 推荐
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 创建 StorePinia 让 Vue 应用的状态管理变得更加简单和优雅!
Claude Code 是 Anthropic 官方推出的 AI 编程 CLI 工具,可以:
通过 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) | - |
如果尚未安装 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
# macOS
brew install awscli
# 验证安装
aws --version
请联系 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,说明配置成功。
# 全局安装
npm install -g @anthropic-ai/claude-code
# 验证安装
claude --version
如遇权限问题(macOS),可尝试:
sudo npm install -g @anthropic-ai/claude-code --unsafe-perm
编辑(或创建)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字段合并进去,不要覆盖已有内容。
Claude Code 首次启动会进入 Anthropic 的登录引导(Onboarding)流程。使用 Bedrock 时需要跳过此步骤。
创建(或编辑)文件 ~/.claude.json(注意:是用户主目录下的 .claude.json,不是 .claude/ 目录里的):
{
"hasCompletedOnboarding": true
}
说明:设置 hasCompletedOnboarding 为 true 后,Claude Code 启动时会跳过默认的 Anthropic OAuth 登录流程,直接使用 settings.json 中配置的 Bedrock 连接。
修改完成后重新打开终端,然后执行:
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。
# 在项目根目录启动
cd your-project
claude
首次启动会显示欢迎信息,确认模型连接成功。
# 让 AI 解释代码
> 解释一下 src/main/java/com/example/UserService.java 的主要逻辑
# 让 AI 写代码
> 帮我写一个用户注册的 REST API,使用 Spring Boot
# 让 AI 修 Bug
> 这个 NullPointerException 是什么原因?帮我修复
# 让 AI 重构
> 把这个方法拆分成更小的函数,遵循单一职责原则
# 运行命令
> 运行项目的单元测试并分析失败原因
Claude Code 同时支持在 IDE 中使用:
VS Code:
Cmd+Shift+P),输入 "Claude"Cmd+Esc 打开 Claude 面板JetBrains(IntelliJ IDEA 等):
Settings -> Plugins -> 搜索 "Claude Code" 并安装IDE 集成会自动继承终端中配置的环境变量(Bedrock 配置),无需额外设置。
在项目根目录创建 CLAUDE.md 文件,写入项目背景信息:
# 项目说明
- 这是一个 Spring Boot 微服务项目
- 使用 MyBatis 作为 ORM
- 数据库:MySQL 8.0
- 构建工具:Maven
- Java 版本:17
## 代码规范
- 遵循阿里巴巴 Java 开发手册
- Controller 层不写业务逻辑
- Service 层通过接口定义
Claude Code 每次启动时会自动读取该文件,确保 AI 理解项目上下文。
| 快捷键 | 功能 |
|---|---|
Esc(连按两次) |
退出 Claude Code |
Cmd+C |
中断当前 AI 响应 |
/help |
查看所有可用命令 |
/clear |
清空对话历史 |
/compact |
压缩上下文(对话太长时使用) |
Tab |
自动补全文件路径 |
Claude Code 在执行文件操作和 shell 命令时会请你确认:
y:允许本次操作n:拒绝本次操作!:本次会话中始终允许该类型操作排查步骤:
# 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 模型访问权限。
可能原因:
AWS_REGION 设置为 us-east-1
aws sso login
/compact 压缩过长的对话上下文# 方案一:使用 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
| 场景 | 联系方式 |
|---|---|
| AWS 凭证 / 权限问题 | DevOps 团队 |
打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。
欢迎阅读《Web 周刊》,上周 Web 开发圈的主要情报包括:
Temporal APIurl.parse()
PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局。
Axios 是 GitHub 第一请求库,周下载量过亿。
不幸的是,愚人节前夕,Axios 突然发布了 2 个中毒版本,它们只坚挺了 3 小时就被封杀了,但至少波及几十万用户,这是近一年内最大规模的 npm 供应链攻击。
随后 Axios 团队主席爆料了攻击事件的完整事后分析,首先是它遭到了社会工程“钓鱼“,通过伪造的在线会议安装了有毒软件,导致 npm 账户被盗用。
![]()
Axios 源码本身没有 bug,黑客只添加了一个幽灵依赖 plain-crypto-js,其中包含了一个 postinstall 脚本,随后使用传统 npm token 发布中毒版本。
用户使用 npm install axios 之后,postinstall 脚本会自动执行,请求其他恶意软件,盗用系统资料。
![]()
之后,这个脚本会自尽,删除 postinstall 脚本,替换为正常 package.json,用户对这种“完美犯罪“浑然不知。
谷歌和微软深入调查了本次赛博攻击,部分证据表明攻击来自朝鲜的黑客组织,但可能很难像川普打伊朗那样直接证明。
![]()
总之,供应链攻击是一种系统性原罪。我们应该遵循 npm 发包的最佳实践,防止黑客轻易绕过了现代化的可信发布流程。
2016 年,Node 加盟了 HackerOne 的 IBB(互联网 bug 悬赏)项目,通过众筹为 fix bug 的志愿者提供奖金。
Node 团队会继续接收 bug,但由于资金链中断,该悬赏项目现已暂停。
![]()
特别鸣谢一直以来为 Node 安全贡献的开发者和赞助商!Node 是 Web 开发的重要基建,如果你愿意提供赞助,请随时联系 OpenJS 基金会。
GitHub 官宣,npm 可信发布支持 CircleCI 作为 OIDC(OpenID 连接)供应商。
![]()
CircleCI 现在和 GitHub Actions 与 GitLab CI / CD 一样,维护者能从部署流程鉴权发包,无需长期 token。
此外,npm 官网上线深色主题了。
Oxc 官网新增了 Oxlint / Oxfmt 兼容表,可以直观地查看它们支持哪些 JS 框架和文件类型,从夯到拉分为四大梯队:
![]()
ESLint 是 GitHub 第一 JS Linter(代码质检工具),最近更新了 10.2 次版本。
首先,ESLint 新增了 meta.languages 属性,作者可以指定规则适用的语言,比如 JS 专属规则或 Markdown 专属规则等。
![]()
此外,ESLint 还支持 JS 最新的 Temporal 全局变量,no-undef 规则能识别 Temporal 而不会报警,no-obj-calls 规则会在直接调用 Temporal 时报警。
![]()
Axios 发布了 1.15 次版本,现在能支持 Bun / Deno。
Axios 还修复了代理处理和头部注入等安全漏洞,CI(持续集成)采用 OIDC 来守卫 npm 发布。
源码使用了原生 URL API,url.parse() 重构为 new URL()。
![]()
最近 npm 上线了深色主题,但还有一些无障碍 bug,比如切换深色主题后文字对比度不够,看不清楚。
![]()
Google 专家 Una 之前就提出了一个新的 CSS 函数 contrast-color(),它可以接受任意颜色,然后根据输入的颜色去计算对比度,最终返回 black 或 white。
![]()
contrast-color() 可以自动生成对比度更好的文字,完美解决 npm 的无障碍文字 bug。
CSS contrast-color() 函数目前已经达到 Baseline Newly Available(全新可用基线),所有新版的主流浏览器都支持这个功能喔。
![]()
为了对抗供应链攻击,本期我们主要分享一些 npm 防御式指南。
首先是 npm 官方文档出品的 trusted publishing(可信发布),它可以让 npm 和 GitHub CI(持续集成)完美搭配。
![]()
再来是比 npm 更现代化的网站 npmx,npmx 会显示盾牌来说明这个 axios 版本是通过可信发布持续部署的。
你会发现 axios 的某些版本没有盾牌,说明 axios 没有严格使用可信发布,黑客就会从这里寻找机会。
![]()
npmx 不仅会警告你这个版本可信度降低,还会爆料模块的性能问题或安全漏洞,我知道你很急,但是你先别升级。
![]()
还有就是比 npm 更现代化的包管理器 pnpm,pnpm v10 的官网提供了缓解供应链攻击的完整指南。
![]()
这些配置在最新版的 pnpm 11 中会默认启用,所以提前了解也方便你之后升级到 pnpm 11。
最后,Node 安全专家 Liran 也在 GitHub 上分享了一份《npm 安全最佳实践》,建议收藏。
![]()
以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。
👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。
😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~
![]()
随着 harness 的完善,Agent 的启动过程正在从“输入任务后直接执行”,逐渐变成“先完成一组初始化动作,再进入任务”。
常见初始化内容包括:
这些内容大多不是一次性信息。它们变化不频繁,却会在每个新会话里反复出现。
问题也就集中在这里:
当 Agent 使用越来越依赖记忆、项目上下文和工具体系后,初始化本身就变成了一个需要工程化处理的问题。
一次 Agent 任务可以拆成两部分:
基座工程关注的是第一部分。
如果某些内容满足下面三个条件,就不应该每次都重新初始化:
更合理的方式是:先让 Agent 完成一次稳定初始化,把这个状态保存成基座;之后每个新任务都从这个基座派生。
可以把它理解成下面这条链路:
记忆 / 规则 / 项目上下文 / skills
│
▼
生成基座会话
│
▼
每次新对话,都 fork 基座会话
│
▼
执行具体任务
需要注意:基座复用优化的是初始化过程,不是让上下文内容本身消失。初始化内容仍然会成为会话状态的一部分。
要把这个思路落地,关键不是直接改 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 是默认基座。用户不指定 -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 基座,启动器会跟踪一组关键初始化资产。
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 自己维护、会复利增长的 Wiki,让它越用越聪明。
这篇文章不是教你怎么敲 CLI 命令。memex 的入口在 agent 对话里——你只需要说
/memex:capture、/memex:ingest、/memex:query,Agent 自己知道怎么做。
![]()
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 distill、memex 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。
一提"AI + 笔记",很多人的第一反应是搭 RAG:选 Embedding 模型、搭向量数据库、调切片策略。整套架构搞了一个月,笔记库里还是只有 20 篇文章。
Karpathy 的思路反过来:先跑通流程,再优化基础设施。 知识库规模不大的时候(几百篇文章以内),维护几个索引文件就够了。LLM 先读 index.md 定位,再直接阅读相关内容。简单、可靠、零额外成本。等你的笔记真的过了一万条,搜东西开始找不到、找不全了,再考虑 RAG 不迟。
这道理写代码的人都懂,但轮到自己搭知识库的时候就忘了。
还有一个 Karpathy 特别强调的设计:好的问答结果应该存回 wiki,而不是消失在聊天记录里。 你问了一个复杂问题,Agent 查 wiki、综合答案、带引用——这个答案本身就是一份有价值的知识产物。把它存成新页面。下次类似问题,Agent 直接读已有的分析,不用重新推导。
你每跟 AI 聊一次,知识库就增加一层。这就是复利。
![]()
下面这五个场景,是我自己用了三个月的真实感受。
痛点:你在研究"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/ 下的源文件concepts/agent-memory.md、更新对比页 summaries/agent-memory-vs-rag.md
价值:三周后,你拥有的不是十几个聊天窗口,而是一个结构化的知识地图——概念定义、方案对比、源材料索引、开放问题清单。写文章时,直接 /memex:query "agent memory 技术路线对比"。
![]()
痛点:你的项目已经迭代了三个月。今天用 Claude Code,明天用 Codex,后天用 Cursor。每个新 Agent 都要重新理解架构、踩过的坑、命名的原因、测试的边界。
怎么做:
你:帮我连接这个项目到 memex 知识库
Agent:安装项目级别的 context 文件,记录相关的 scene
你:读当前代码和文档,然后起草这个项目的 architecture、command-design、known-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、反馈中提炼出来的可继承理解。
![]()
痛点:今天 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.md、log.md。
价值:连续性不再绑定任何一个厂商。你可以换 Agent、换模型、等一周再回来,任务状态还在同一个 wiki 里等你。
![]()
痛点:一场深入对话里,你们讨论了产品定位、架构边界、某个 bug 根因、三个被否决的方案。聊完很爽,一周后只记得大概——细节全丢了。
怎么做:
你:/memex:distill 这次对话,我们聊清楚了产品定位和几个关键的取舍
Agent:把对话蒸馏成 source 页,保留上下文、决策、未解问题
你:/memex:ingest 只要这次确定的稳定结论,合并到已有的 positioning 页面里
Agent:读蒸馏产物,提取可复用的结论,增量更新已有页面,不复制已有内容
什么样的结论值得沉淀?
价值:聊天不再是消耗品。重要推理先变成可追溯的 source,再变成结构化的 wiki 知识。下次 query 时,能同时看到结论和它为什么成立。如果前提变了,wiki 也能记录"老判断基于什么、新判断基于什么"。
![]()
痛点:大部分人用 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 是真正的导航入口而非文件列表。
![]()
这里有一个设计决策需要讲清楚——CLI 永远只做机械正确性的事,不做语义判断。
| 层 | 谁负责 | 做什么 |
|---|---|---|
| Agent | Claude Code / Codex / Cursor | 判断哪些页面要更新、哪些概念要链接、哪些矛盾要保留、哪些总结要重写 |
| Slash Command |
/memex:capture 等 6 个 |
把用户的自然语言意图翻译成底层 CLI 调用 |
| CLI |
memex 命令行工具 |
文件读写、frontmatter 校验、链接检查、关键词搜索、会话解析——纯机械,不调 LLM API |
这意味着:
![]()
上面 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 持续编译。
回到 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-…
以一个表处理为例:
github地址:github.com/YueJingGe/p…
描述:有一个本周饮品销量小表,希望大模型输出按饮品汇总销量的列表,以及销量最高的饮品是什么
![]()
当你把表格 用 df.to_string() 转成文本,然后放进 prompt 里时,大模型(如通义千问、GPT-4)会做这几步:
![]()
虽然模型能处理简单的表格问答,但有以下问题:
首先,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")
运行结果:
![]()
核心思想就是:
1、你给模型预定义一组函数,告诉模型这些函数能做什么。
2、用户问自然语言问题,比如“按饮品汇总销量,然后自然地说出来”。
3、大模型自己决定该不该调用函数、调用哪个函数、传什么参数。
4、模型返回的不是最终答案,而是一个函数调用指令(例如 get_sales_summary(by='drink'))。
5、你的代码收到这个指令后,实际去执行 pandas 计算(或查数据库、调API等),然后把精确的计算结果再发回给模型。
6、模型根据结果生成最终的自然语言回答。
整个过程中,模型在主动决策“我需要调用工具来帮忙”,而不仅仅是接收现成数据。
![]()
不用 Function Calling 的时候主要是依赖大模型本身的语言理解和推理能力,适合你完全清楚要算什么的场景,代码简单高效。
用 Function Calling是作为一种“增强手段”来提升准确性和能力上限,适合搭建对话式数据分析助手,用户可以随意提问,模型自动选择合适的函数去执行。
Function Calling 官方资料:
阿里云百炼平台:搜索“阿里云百炼 Function Call”,参考官方文档。
社区博客:搜索“超实用!用 FunctionCall 实现快递 AI 助手”,了解用它构建真实 AI 助手的详细步骤。
CSDN 博客:搜索“通义千问的 Function Call”,更直观地理解整个实现流程。
事情是这样的:上个月我在做一个DeFi看板项目,核心功能是让用户连接MetaMask钱包后,能看到自己的资产和交易记录。老板说"钱包登录很简单,就是调个接口嘛",我当时也觉得——不就是调个window.ethereum吗?结果真正动手时,踩坑踩到怀疑人生。
从连接失败、签名验证错误,到用户拒绝连接后状态混乱,再到不同链之间切换导致数据错乱……每一个问题都让我抓狂。但最终,我总结出了一套稳定可用的方案。这篇文章就是把我这两天的踩坑经历完整记录下来,希望对你有帮助。
当时我正在开发一个叫"DeFiDash"的项目,本质上就是一个聚合看板,用户连接钱包后能查看自己在多个链上的资产、持仓和交易记录。前端用的是React + TypeScript,后端是Node.js。
核心需求其实就三个:
看起来很简单对吧?但真正实现时,我遇到了第一个大坑——连接过程的状态管理。
我最初的思路是直接写一个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验证和持久化。
在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。排查了半天才发现。
我把上面所有代码整合成一个完整的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>
);
}
1. ethers.getDefaultProvider() 在浏览器端报错
Error: network error: The method eth_getBlockByNumber does not exist/is not available
getDefaultProvider() 会尝试连接以太坊节点,但在浏览器端,应该使用MetaMask注入的provider。new BrowserProvider(window.ethereum)。2. 用户拒绝连接后,再次点击连接按钮无反应
-32002。3. 签名时MetaMask弹窗不显示消息内容
signer.signMessage()时,MetaMask弹窗只显示"签名请求",没有显示具体的消息内容。personal_sign方法,或者确保消息中包含可读的上下文(如"登录XXX")。4. 链切换后,signer对象失效
signer.getAddress()时返回了旧地址。chainChanged事件监听中,重新调用provider.getSigner()。连接MetaMask实现钱包登录,核心就三点:正确处理用户授权、严格管理状态变化、做好异常处理。不要被"一行代码"的错觉迷惑,真正的坑都在细节里——比如错误码、链ID格式、事件监听的生命周期。
如果你继续深入,可以研究一下:
wagmi或RainbowKit等库简化钱包连接希望这篇文章能帮你少踩一些坑。如果你在实现过程中遇到了其他问题,欢迎在评论区交流。
当你使用 Node.js 修改代码时,用正则直接操作字符串极其危险且难以维护。通过 AST 就可以像操作 JSON 一样精确地增删改查代码。
recast.parse:把代码字符串解析成一颗 AST 树。recast.visit:在树上找节点,比如找到名为 config 的变量。recast.types.builders (简称 b ) :假如你想把数字 1 改成 2,你需要用 builder 造出一个“数字 2”的节点来替换。使用 recast.visit 时,可以从 path.node 拿到需要的数据。
type:节点的类型(如 Identifier, Literal),这是判断“它是什么”的第一步。
loc:包含 start 和 end 的行号列号,Recast 靠它实现精准的局部替换。
comments:存放该节点的注释信息,可以通过 b.commentLine 往里推入新注释。
id:左手边的变量名节点,通常 node.id.name 就能拿到 "port"。
init:右手边的初始值节点,它是你要读取或替换的核心。
key:键名,注意:如果是 { 'a-b': 1 },key 是 StringLiteral;如果是 { a: 1 },key 是 Identifier。
value:键值,可以是任何表达式(数字、函数、另一个对象)。
computed:布尔值,如果为 true,说明是 { [prop]: 1 } 这种计算属性。
object:点号左边的部分,如 process.env.PORT 中的 process.env。
property:点号右边的部分,如 PORT。
arguments:一个数组,存放所有传入的参数节点,修改它就能增删函数参数。
value:存放在 JS 里的实际值(如数字 8080,字符串 "localhost")。
raw:原始文本,比如源码写的是 0x10,value 是 16,而 raw 就是 "0x10"。
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 插件
})
}
};
比如把 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;
}
假设把源码里的 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:匹配类定义,用于提取类名、继承关系或修改装饰器。
处理节点时,引用变量是一个比较麻烦的地方。比如代码中不仅仅只是简单的 port: 8080,而是 port: DEFAULT_PORT 或 path: process.env.URL。直接读取 node.init.value 会得到 undefined,因为这些值不是字面量(Literal) ,而是标识符(Identifier)或成员表达式(MemberExpression) 。
遇到如 Identifier 或 MemberExpression 等非字面量节点时,递归提取其完整路径,并用特殊标记(如 __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.parse 时,Recast 会为 AST 的每个节点打上一个隐藏的标签,记录该节点在原始字符串中的起始位置 loc.start 和结束位置 loc.end ,以及它周围的所有空格、换行、分号。
当你使用 replace 修改了某个节点或者它的属性时,Recast 会将该节点标记为脏节点 。
在执行 recast.print 时,Recast 的渲染器会遍历整棵树:
loc 坐标切出那一段文本。