普通视图

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

每日一题-循环轮转矩阵🟡

2026年5月9日 00:00

给你一个大小为 m x n 的整数矩阵 grid ,其中 mn 都是 偶数 ;另给你一个整数 k

矩阵由若干层组成,如下图所示,每种颜色代表一层:

矩阵的循环轮转是通过分别循环轮转矩阵中的每一层完成的。在对某一层进行一次循环旋转操作时,层中的每一个元素将会取代其 逆时针 方向的相邻元素。轮转示例如下:

返回执行 k 次循环轮转操作后的矩阵。

 

示例 1:

输入:grid = [[40,10],[30,20]], k = 1
输出:[[10,20],[40,30]]
解释:上图展示了矩阵在执行循环轮转操作时每一步的状态。

示例 2:

输入:grid = [[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]], k = 2
输出:[[3,4,8,12],[2,11,10,16],[1,7,6,15],[5,9,13,14]]
解释:上图展示了矩阵在执行循环轮转操作时每一步的状态。

 

提示:

  • m == grid.length
  • n == grid[i].length
  • 2 <= m, n <= 50
  • mn 都是 偶数
  • 1 <= grid[i][j] <=5000
  • 1 <= k <= 109

数学题目,重在公式推导和下标边界处理

作者 lachimere
2021年6月27日 14:43

解题思路

题目的条件很友好地指明 mn 均为偶数,那么我们易得:在一个 $m \times n$ 的矩阵中,总共有 $\min(m, n) / 2$ 圈。我们可设最外层为第 $0$ 圈,那么我们易得该圈的元素个数 sz 为 $2(m+n)-4$,根据数学归纳法,我们可知第 $i$ 圈的元素个数 sz 为 $2(m+n-4i) - 4$。因此我们对第 $i$ 圈进行旋转 $k$ 次时,实际上相当于我们对该圈旋转了 k % sz 次。

现在我们需要推出第 $i$ 圈元素的坐标,同理,我们可从第 $0$ 圈获得启发。易知第 $0$ 圈的元素坐标从左上角顺时针依次为 $(0, 0), (0,1), \cdots, (0, n-1), (1, n-1), \cdots, (m-1, n-1), \cdots, (m-1, 1), (m-1, 0), \cdots, (1, 0)$。我们可推出第 $i$ 圈的元素坐标从左上角顺时针依次为 $(i, i), \cdots, (i, n-i-1), \cdots, (m-i-1, n-i-1), \cdots, (m-i-1, i), \cdots, (i+1, i)$。

对于第 $i$ 圈上的点坐标 $(x, y)$,我们可分为四个边:

  • $x = i,\ y < n-i-1$,与其右侧交换
  • $x < m-i-1,\ y = n-i-1$,与其下侧交换
  • $x = m-i-1,\ y > i$,与其左侧交换
  • $x > i,\ y = i$,与其上侧交换

每次旋转我们需要完成 sz - 1次交换。

代码

###C++

class Solution {
private:
    int m, n, layer;
    
    void rotateLayer(vector<vector<int>>& grid, int i, int k) {
        int sz = 2 * (m + n - 4*i) - 4;
        k %= sz;
        
        while (k--) {
            int x = i, y = i;
            for (int j = 0; j < sz-1; ++j) {
                if (x == i && y < n-i-1) {
                    swap(grid[x][y], grid[x][y+1]);
                    ++y;
                } else if (x < m-i-1 && y == n-i-1) {
                    swap(grid[x][y], grid[x+1][y]);
                    ++x;
                } else if (x == m-i-1 && y > i) {
                    swap(grid[x][y], grid[x][y-1]);
                    --y;
                } else if (x > i && y == i) {
                    swap(grid[x][y], grid[x-1][y]);
                    --x;
                }
            }
        }
    }
    
public:
    vector<vector<int>> rotateGrid(vector<vector<int>>& grid, int k) {
        m = grid.size(), n = grid[0].size();
        layer = min(m, n) / 2;
        
        for (int i = 0; i < layer; ++i) {
            rotateLayer(grid, i, k);
        }
        
        return grid;
    }
};

###Golang

func rotateGrid(grid [][]int, k int) [][]int {
    m, n := len(grid), len(grid[0])
    layer := m / 2
    if n < m {
        layer = n / 2
    }
    
    rotateLayer := func(i int) {
        sz := 2 * (m + n - 4*i) - 4
        kk := k % sz
        
        for kk > 0 {
            x, y := i, i
            for j := 0; j < sz-1; j++ {
                if x == i && y < n-i-1 {
                    grid[x][y], grid[x][y+1] = grid[x][y+1], grid[x][y]
                    y++
                } else if x < m-i-1 && y == n-i-1 {
                    grid[x][y], grid[x+1][y] = grid[x+1][y], grid[x][y]
                    x++
                } else if x == m-i-1 && y > i {
                    grid[x][y], grid[x][y-1] = grid[x][y-1], grid[x][y]
                    y--
                } else if x > i && y == i {
                    grid[x][y], grid[x-1][y] = grid[x-1][y], grid[x][y]
                    x--
                }
            }
            kk--
        }
    }
    
    for i := 0; i < layer; i++ {
        rotateLayer(i)
    }
    
    return grid
}

循环轮转矩阵

2021年6月27日 13:24

方法一:枚举每一层

思路与算法

对于一个 $m \times n$ 的矩阵 $\textit{grid}$,它的层数为 $\min(m / 2, n / 2)$。我们可以从外向内枚举矩阵的每一层模拟循环轮转操作。

为了方便模拟,我们从左上角起按照逆时针方向遍历每一层的元素。在本文中,我们将遍历过程分为四个部分,每个部分按顺序遍历每条边除了最后一个元素以外的所有元素。

我们将这些元素的行坐标、列坐标与数值保存在对应的数组 $r, c, \textit{val}$ 中,并计算元素总数,即数组的长度 $\textit{total}$。此时,如果对该层元素进行 $\textit{total}$ 次循环轮转操作,那么该层元素不会改变。因此,实际的循环轮转操作数量即为 $\textit{kk} = k % \textit{total}$。

那么,这一层中遍历到的第 $i$ 个位置在轮转操作后存放的值对应 $\textit{val}$ 数组中下标为 $(i - \textit{kk} + \textit{total}) % \textit{total}$ 的值。此处在取模时加上 $\textit{total}$ 是为了避免出现负数。

我们遍历行列坐标数组,并在 $\textit{grid}$ 中更新每个坐标对应的轮转操作后的取值。当枚举并更新完所有层后,$\textit{grid}$ 即为轮转操作后的矩阵。

代码

###C++

class Solution {
public:
    vector<vector<int>> rotateGrid(vector<vector<int>>& grid, int k) {
        int m = grid.size();
        int n = grid[0].size();
        int nlayer = min(m / 2, n / 2);   // 层数
        // 从左上角起逆时针枚举每一层
        for (int layer = 0; layer < nlayer; ++layer){
            vector<int> r, c, val;   // 每个元素的行下标,列下标与数值
            for (int i = layer; i < m - layer - 1; ++i){   // 左
                r.push_back(i);
                c.push_back(layer);
                val.push_back(grid[i][layer]);
            }
            for (int j = layer; j < n - layer - 1; ++j){   // 下
                r.push_back(m - layer - 1);
                c.push_back(j);
                val.push_back(grid[m-layer-1][j]);
            }
            for (int i = m - layer - 1; i > layer; --i){   // 右
                r.push_back(i);
                c.push_back(n - layer - 1);
                val.push_back(grid[i][n-layer-1]);
            }
            for (int j = n - layer - 1; j > layer; --j){   // 上
                r.push_back(layer);
                c.push_back(j);
                val.push_back(grid[layer][j]);
            }
            int total = val.size();   // 每一层的元素总数
            int kk = k % total;   // 等效轮转次数
            // 找到每个下标对应的轮转后的取值
            for (int i = 0; i < total; ++i){
                int idx = (i + total - kk) % total;   // 轮转后取值对应的下标
                grid[r[i]][c[i]] = val[idx];
            }
        }
        return grid;
    }
};

###Python

class Solution:
    def rotateGrid(self, grid: List[List[int]], k: int) -> List[List[int]]:
        m, n = len(grid), len(grid[0])
        nlayer = min(m // 2, n // 2)   # 层数
        # 从左上角起逆时针枚举每一层
        for layer in range(nlayer):
            r = []   # 每个元素的行下标
            c = []   # 每个元素的列下标
            val = []   # 每个元素的数值
            for i in range(layer, m - layer - 1):   # 左 
                r.append(i)
                c.append(layer)
                val.append(grid[i][layer])
            for j in range(layer, n - layer - 1):   # 下
                r.append(m - layer - 1)
                c.append(j)
                val.append(grid[m-layer-1][j])
            for i in range(m - layer - 1, layer, -1):   # 右
                r.append(i)
                c.append(n - layer - 1)
                val.append(grid[i][n-layer-1])
            for j in range(n - layer - 1, layer, -1):   # 上
                r.append(layer)
                c.append(j)
                val.append(grid[layer][j])
            total = len(val)   # 每一层的元素总数
            kk = k % total   # 等效轮转次数
            # 找到每个下标对应的轮转后的取值
            for i in range(total):
                idx = (i + total - kk) % total   # 轮转后取值对应的下标
                grid[r[i]][c[i]] = val[idx]
        return grid

###Java

class Solution {
    public int[][] rotateGrid(int[][] grid, int k) {
        int m = grid.length;
        int n = grid[0].length;
        int nlayer = Math.min(m / 2, n / 2);   // 层数
        // 从左上角起逆时针枚举每一层
        for (int layer = 0; layer < nlayer; ++layer){
            List<Integer> r = new ArrayList<>();
            List<Integer> c = new ArrayList<>();
            List<Integer> val = new ArrayList<>();   // 每个元素的行下标,列下标与数值
            for (int i = layer; i < m - layer - 1; ++i){   // 左
                r.add(i);
                c.add(layer);
                val.add(grid[i][layer]);
            }
            for (int j = layer; j < n - layer - 1; ++j){   // 下
                r.add(m - layer - 1);
                c.add(j);
                val.add(grid[m - layer - 1][j]);
            }
            for (int i = m - layer - 1; i > layer; --i){   // 右
                r.add(i);
                c.add(n - layer - 1);
                val.add(grid[i][n - layer - 1]);
            }
            for (int j = n - layer - 1; j > layer; --j){   // 上
                r.add(layer);
                c.add(j);
                val.add(grid[layer][j]);
            }
            int total = val.size();   // 每一层的元素总数
            int kk = k % total;   // 等效轮转次数
            // 找到每个下标对应的轮转后的取值
            for (int i = 0; i < total; ++i){
                int idx = (i + total - kk) % total;   // 轮转后取值对应的下标
                grid[r.get(i)][c.get(i)] = val.get(idx);
            }
        }
        return grid;
    }
}

###C#

public class Solution {
    public int[][] RotateGrid(int[][] grid, int k) {
        int m = grid.Length;
        int n = grid[0].Length;
        int nlayer = Math.Min(m / 2, n / 2);   // 层数
        // 从左上角起逆时针枚举每一层
        for (int layer = 0; layer < nlayer; ++layer){
            List<int> r = new List<int>();
            List<int> c = new List<int>();
            List<int> val = new List<int>();   // 每个元素的行下标,列下标与数值
            for (int i = layer; i < m - layer - 1; ++i){   // 左
                r.Add(i);
                c.Add(layer);
                val.Add(grid[i][layer]);
            }
            for (int j = layer; j < n - layer - 1; ++j){   // 下
                r.Add(m - layer - 1);
                c.Add(j);
                val.Add(grid[m - layer - 1][j]);
            }
            for (int i = m - layer - 1; i > layer; --i){   // 右
                r.Add(i);
                c.Add(n - layer - 1);
                val.Add(grid[i][n - layer - 1]);
            }
            for (int j = n - layer - 1; j > layer; --j){   // 上
                r.Add(layer);
                c.Add(j);
                val.Add(grid[layer][j]);
            }
            int total = val.Count;   // 每一层的元素总数
            int kk = k % total;   // 等效轮转次数
            // 找到每个下标对应的轮转后的取值
            for (int i = 0; i < total; ++i){
                int idx = (i + total - kk) % total;   // 轮转后取值对应的下标
                grid[r[i]][c[i]] = val[idx];
            }
        }
        return grid;
    }
}

###Go

func rotateGrid(grid [][]int, k int) [][]int {
    m := len(grid)
    n := len(grid[0])
    nlayer := min(m / 2, n / 2)   // 层数
    // 从左上角起逆时针枚举每一层
    for layer := 0; layer < nlayer; layer++ {
        r := make([]int, 0)
        c := make([]int, 0)
        val := make([]int, 0)   // 每个元素的行下标,列下标与数值
        for i := layer; i < m - layer - 1; i++ {   // 左
            r = append(r, i)
            c = append(c, layer)
            val = append(val, grid[i][layer])
        }
        for j := layer; j < n - layer - 1; j++ {   // 下
            r = append(r, m - layer - 1)
            c = append(c, j)
            val = append(val, grid[m-layer - 1][j])
        }
        for i := m - layer - 1; i > layer; i-- {   // 右
            r = append(r, i)
            c = append(c, n - layer - 1)
            val = append(val, grid[i][n - layer - 1])
        }
        for j := n - layer - 1; j > layer; j-- {   // 上
            r = append(r, layer)
            c = append(c, j)
            val = append(val, grid[layer][j])
        }
        total := len(val)   // 每一层的元素总数
        kk := k % total   // 等效轮转次数
        // 找到每个下标对应的轮转后的取值
        for i := 0; i < total; i++ {
            idx := (i + total - kk) % total   // 轮转后取值对应的下标
            grid[r[i]][c[i]] = val[idx]
        }
    }
    return grid
}

###C

int** rotateGrid(int** grid, int gridSize, int* gridColSize, int k, int* returnSize, int** returnColumnSizes) {
    int m = gridSize;
    int n = gridColSize[0];
    *returnSize = m;
    *returnColumnSizes = (int*)malloc(m * sizeof(int));
    for (int i = 0; i < m; i++) {
        (*returnColumnSizes)[i] = n;
    }
    
    int nlayer = fmin(m / 2, n / 2);   // 层数
    // 从左上角起逆时针枚举每一层
    for (int layer = 0; layer < nlayer; ++layer) {
        int maxSize = 2 * (m + n - 4 * layer - 2);
        int* r = (int*)malloc(maxSize * sizeof(int));
        int* c = (int*)malloc(maxSize * sizeof(int));
        int* val = (int*)malloc(maxSize * sizeof(int));   // 每个元素的行下标,列下标与数值
        int idx = 0;
        
        for (int i = layer; i < m - layer - 1; ++i) {   // 左
            r[idx] = i;
            c[idx] = layer;
            val[idx] = grid[i][layer];
            idx++;
        }
        for (int j = layer; j < n - layer - 1; ++j) {   // 下
            r[idx] = m - layer - 1;
            c[idx] = j;
            val[idx] = grid[m - layer - 1][j];
            idx++;
        }
        for (int i = m - layer - 1; i > layer; --i) {   // 右
            r[idx] = i;
            c[idx] = n - layer - 1;
            val[idx] = grid[i][n - layer - 1];
            idx++;
        }
        for (int j = n - layer - 1; j > layer; --j) {   // 上
            r[idx] = layer;
            c[idx] = j;
            val[idx] = grid[layer][j];
            idx++;
        }
        
        int total = idx;   // 每一层的元素总数
        int kk = k % total;   // 等效轮转次数
        // 找到每个下标对应的轮转后的取值
        for (int i = 0; i < total; ++i) {
            int pos = (i + total - kk) % total;   // 轮转后取值对应的下标
            grid[r[i]][c[i]] = val[pos];
        }
        
        free(r);
        free(c);
        free(val);
    }
    return grid;
}

###JavaScript

var rotateGrid = function(grid, k) {
    const m = grid.length;
    const n = grid[0].length;
    const nlayer = Math.min(Math.floor(m / 2), Math.floor(n / 2));   // 层数
    // 从左上角起逆时针枚举每一层
    for (let layer = 0; layer < nlayer; ++layer) {
        const r = [];
        const c = [];
        const val = [];   // 每个元素的行下标,列下标与数值
        for (let i = layer; i < m - layer - 1; ++i) {   // 左
            r.push(i);
            c.push(layer);
            val.push(grid[i][layer]);
        }
        for (let j = layer; j < n - layer - 1; ++j) {   // 下
            r.push(m - layer - 1);
            c.push(j);
            val.push(grid[m - layer - 1][j]);
        }
        for (let i = m - layer - 1; i > layer; --i) {   // 右
            r.push(i);
            c.push(n - layer - 1);
            val.push(grid[i][n - layer - 1]);
        }
        for (let j = n - layer - 1; j > layer; --j) {   // 上
            r.push(layer);
            c.push(j);
            val.push(grid[layer][j]);
        }
        const total = val.length;   // 每一层的元素总数
        const kk = k % total;   // 等效轮转次数
        // 找到每个下标对应的轮转后的取值
        for (let i = 0; i < total; ++i) {
            const idx = (i + total - kk) % total;   // 轮转后取值对应的下标
            grid[r[i]][c[i]] = val[idx];
        }
    }
    return grid;
};

###TypeScript

function rotateGrid(grid: number[][], k: number): number[][] {
    const m: number = grid.length;
    const n: number = grid[0].length;
    const nlayer: number = Math.min(Math.floor(m / 2), Math.floor(n / 2));   // 层数
    // 从左上角起逆时针枚举每一层
    for (let layer = 0; layer < nlayer; ++layer) {
        const r: number[] = [];
        const c: number[] = [];
        const val: number[] = [];   // 每个元素的行下标,列下标与数值
        for (let i = layer; i < m - layer - 1; ++i) {   // 左
            r.push(i);
            c.push(layer);
            val.push(grid[i][layer]);
        }
        for (let j = layer; j < n - layer - 1; ++j) {   // 下
            r.push(m - layer - 1);
            c.push(j);
            val.push(grid[m - layer - 1][j]);
        }
        for (let i = m - layer - 1; i > layer; --i) {   // 右
            r.push(i);
            c.push(n - layer - 1);
            val.push(grid[i][n - layer - 1]);
        }
        for (let j = n - layer - 1; j > layer; --j) {   // 上
            r.push(layer);
            c.push(j);
            val.push(grid[layer][j]);
        }
        const total: number = val.length;   // 每一层的元素总数
        const kk: number = k % total;   // 等效轮转次数
        // 找到每个下标对应的轮转后的取值
        for (let i = 0; i < total; ++i) {
            const idx: number = (i + total - kk) % total;   // 轮转后取值对应的下标
            grid[r[i]][c[i]] = val[idx];
        }
    }
    return grid;
}

###Rust

impl Solution {
    pub fn rotate_grid(mut grid: Vec<Vec<i32>>, k: i32) -> Vec<Vec<i32>> {
        let m = grid.len();
        let n = grid[0].len();
        let nlayer = (m / 2).min(n / 2);   // 层数
        let k = k as usize;
        // 从左上角起逆时针枚举每一层
        for layer in 0..nlayer {
            let mut r = Vec::new();
            let mut c = Vec::new();
            let mut val = Vec::new();   // 每个元素的行下标,列下标与数值
            for i in layer..m - layer - 1 {   // 左
                r.push(i);
                c.push(layer);
                val.push(grid[i][layer]);
            }
            for j in layer..n - layer - 1 {   // 下
                r.push(m - layer - 1);
                c.push(j);
                val.push(grid[m - layer - 1][j]);
            }
            for i in (layer + 1..=m - layer - 1).rev() {   // 右
                r.push(i);
                c.push(n - layer - 1);
                val.push(grid[i][n - layer - 1]);
            }
            for j in (layer + 1..=n - layer - 1).rev() {   // 上
                r.push(layer);
                c.push(j);
                val.push(grid[layer][j]);
            }
            let total = val.len();   // 每一层的元素总数
            let kk = k % total;   // 等效轮转次数
            // 找到每个下标对应的轮转后的取值
            for i in 0..total {
                let idx = (i + total - kk) % total;   // 轮转后取值对应的下标
                grid[r[i]][c[i]] = val[idx];
            }
        }
        grid
    }
}

复杂度分析

  • 时间复杂度:$O(mn)$,其中 $m$ 和 $n$ 分别为 $\textit{grid}$ 的行数和列数。即为遍历 $\textit{grid}$ 并进行旋转的时间复杂度。

  • 空间复杂度:$O(m + n)$,即为存储每一层行列与数值的辅助数组大小。事实上,我们可以利用原地旋转将空间复杂度优化至 $O(1)$,但这样写出的代码并不直观,因此本题解中不给出空间复杂度最优的写法。

Java 思路巨简单,分组旋转就好,逐行注释(6ms,39.3MB)

作者 hxz1998
2021年6月27日 12:09

解题思路

我们首先把每一层的元素按顺时针取出来,放到数组 data 中。
然后对 data 旋转 k % (data.length) 次,这里使用队列来简化。
然后再放回去就好了。
具体见程序吧。

代码

###java

class Solution {
    public int[][] rotateGrid(int[][] grid, int k) {
        // 矩阵尺寸
        int m = grid.length, n = grid[0].length;
        // 计算一共有多少层
        int layerNumber = Math.min(m, n) / 2;
        // 逐层处理
        for (int i = 0; i < layerNumber; ++i) {
            // 计算出来当前层需要多大的数组来存放, 计算方法是当前层最大尺寸 - 内部下一层尺寸.
            int[] data = new int[(m - 2 * i) * (n - 2 * i) - (m - (i + 1) * 2) * (n - (i + 1) * 2)];
            int idx = 0;
            // 从左往右
            for (int offset = i; offset < n - i - 1; ++offset)
                data[idx++] = grid[i][offset];
            // 从上往下
            for (int offset = i; offset < m - i - 1; ++offset)
                data[idx++] = grid[offset][n - i - 1];
            // 从右往左
            for (int offset = n - i - 1; offset > i; --offset)
                data[idx++] = grid[m - i - 1][offset];
            // 从下往上
            for (int offset = m - i - 1; offset > i; --offset)
                data[idx++] = grid[offset][i];
            // 把旋转完的放回去
            Integer[] ret = rotateVector(data, k);
            idx = 0;
            // 从左往右
            for (int offset = i; offset < n - i - 1; ++offset)
                grid[i][offset] = ret[idx++];
            // 从上往下
            for (int offset = i; offset < m - i - 1; ++offset)
                grid[offset][n - i - 1] = ret[idx++];
            // 从右往左
            for (int offset = n - i - 1; offset > i; --offset)
                grid[m - i - 1][offset] = ret[idx++];
            // 从下往上
            for (int offset = m - i - 1; offset > i; --offset)
                grid[offset][i] = ret[idx++];
        }
        return grid;
    }

    private Integer[] rotateVector(int[] vector, int offset) {
        // 取余, 否则会有无用功, 超时
        offset = offset % vector.length;
        Deque<Integer> deque = new ArrayDeque<>();
        for (int item : vector) deque.add(item);
        // 旋转操作
        while (offset-- > 0) {
            deque.addLast(deque.pollFirst());
        }
        return deque.toArray(new Integer[0]);
    }
}
昨天 — 2026年5月8日技术

你的代码仓库变成“毛线团”了?Monorepo 用 Turborepo 拆成“乐高积木”

作者 kyriewen
2026年5月8日 23:12

你维护着五六个项目,每个都单独开一个 Git 仓库。改一个公共组件,要挨个进每个项目,复制粘贴,提交,发布。一上午就没了。今天我们来学 Monorepo——用 Turborepo 把多个项目放进同一个仓库,共享代码、统一构建、一键发布。让你的“多仓库噩梦”变成“搭积木游戏”。

前言

Polyrepo(多仓库)刚开始很爽:每个项目独立,互不干扰。但公共代码一多,就成了复制粘贴地狱。你修了一个 bug,五个项目都要同步,漏一个线上就崩。

Monorepo(单仓库)不是把代码随便堆在一起,而是用工具(Turborepo、Nx、Lerna)把多个项目“有序地”放在同一个 Git 仓库里,让它们能共享依赖、共享配置、共享构建缓存。今天我们用 Turborepo(Vercel 出品,Next.js 同款团队)搭一个 Monorepo,里面有 React 应用、Node API、一个共享的 UI 组件库。全程实战,告别“复制粘贴工程师”。

一、Monorepo 解决了什么?

  • 代码共享:公共组件放在 packages/shared,所有应用直接 import
  • 统一依赖:根目录一个 package.json,用 pnpmyarn workspaces 管理依赖,避免重复安装。
  • 原子提交:一次 commit 修改多个项目,版本同步。
  • 任务缓存:Turborepo 会记住每个任务的输入输出,第二次构建直接取缓存,秒完成。

二、准备工作:安装 pnpm 和 Turborepo

我们选择 pnpm 作为包管理器(比 npm/yarn 快,节省磁盘空间)。如果你没装 pnpm:

npm install -g pnpm

创建项目目录:

mkdir my-monorepo
cd my-monorepo
pnpm init

三、配置 pnpm workspace

在根目录创建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"

这样 apps/ 下的每个子目录是一个应用(比如 React 前端、Node 后端),packages/ 下的子目录是共享包(比如 UI 组件库、工具函数)。

四、安装 Turborepo

pnpm add -g turbo
# 或者在项目中安装
pnpm add -D turbo

创建 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "test": {}
  }
}

pipeline 定义了任务依赖关系。^build 表示执行某个包的 build 之前,先构建它的依赖包。

五、创建共享组件库

mkdir -p packages/ui
cd packages/ui
pnpm init

packages/ui/package.json 中,给包起个名字(重要):

{
  "name": "@myrepo/ui",
  "version": "0.0.1",
  "main": "./src/index.tsx",
  "types": "./src/index.tsx",
  "scripts": {
    "build": "tsc"
  }
}

安装 React 和 TypeScript 依赖(在根目录执行):

pnpm add -D react react-dom typescript @types/react -w

-w 表示安装在根 workspace。

写一个简单的 Button 组件:packages/ui/src/Button.tsx

import React from 'react';

export const Button: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return <button style={{ padding: '8px 16px', background: 'blue', color: 'white' }}>{children}</button>;
};

packages/ui/src/index.tsx

export { Button } from './Button';

配置 TypeScript:packages/ui/tsconfig.json

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "ESNext",
    "target": "ES2020",
    "declaration": true,
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}

六、创建 React 应用

我们用 Vite 创建一个 React 应用放在 apps/web

cd apps
pnpm create vite web --template react-ts
cd web

修改 apps/web/package.json,添加对共享包的依赖:

"dependencies": {
  "@myrepo/ui": "workspace:*",
  ...
}

workspace:* 表示使用当前 workspace 中的对应包。

apps/web/src/App.tsx 中引入共享按钮:

import { Button } from '@myrepo/ui';

function App() {
  return (
    <div>
      <h1>Monorepo Demo</h1>
      <Button>来自共享组件库的按钮</Button>
    </div>
  );
}
export default App;

现在在根目录运行 pnpm install,它会自动链接本地包。

七、配置 Turborepo 任务

修改根 turbo.json,让 build 任务在 React 应用里产生输出:

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

然后在根 package.json 添加脚本:

"scripts": {
  "dev": "turbo dev",
  "build": "turbo build",
  "lint": "turbo lint"
}

运行 pnpm dev,Turborepo 会同时启动两个应用的开发服务器(如果你还有 Node 后端的话)。第一次启动正常速度,第二次因为缓存,秒开。

八、共享配置与依赖提升

想在根目录统一管理 TypeScript、ESLint、Prettier 配置?在根目录创建 tsconfig.base.json,然后每个子项目的 tsconfig.json 继承它:

// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  }
}

ESLint 同理,根目录装 eslint,每个子项目通过根配置运行。

九、生产构建与部署

运行 pnpm build,Turborepo 会按照依赖顺序构建:先构建 @myrepo/ui,再构建 apps/web。并且第二次构建时会复用缓存,毫秒级完成。

构建产物可以分别部署:apps/web/dist 部署到 Vercel/Netlify,Node 应用部署到服务器。因为它们在一个仓库里,但部署是独立的。

十、总结:Monorepo 不是银弹,但能救你于复制粘贴

  • 适合场景:多个项目共享代码、团队规模中等、希望统一 CI/CD。
  • 不适合:项目之间几乎没有依赖、团队权限隔离要求极高(可加 CODEOWNERS 缓解)。
  • 工具选择:Turborepo 速度快、配置简单;Nx 功能更强(但复杂);Lerna 已过时(现在用 Nx 或 Turborepo)。

下次你又在不同项目间同步代码时,想一想:能不能把它们放进同一个 Monorepo,用 Turborepo 一键构建?省下的时间,正好可以摸会儿鱼。

面试手写 KeepAlive:React 组件缓存的实现原理

作者 Lee川
2026年5月8日 20:16

面试手写 KeepAlive:React 组件缓存的实现原理

面试官:"用过 Vue 的 <keep-alive> 吗?如果让你在 React 中手写一个,你会怎么实现?"

这看似是一道框架 API 题,实际上考察的是你对 React 组件渲染机制DOM 复用策略 的理解深度。本文将带你从零手写一个 KeepAlive 组件,把每一步的设计决策讲透彻。


先搞懂本质:KeepAlive 解决什么问题?

看一个具体场景。我们的 App 有两个 Tab:

// App.jsx
const App = () => {
    const [activeTab, setActiveTab] = useState('A')

    return (
        <div>
            <button onClick={() => setActiveTab('A')}>显示A组件</button>
            <button onClick={() => setActiveTab('B')}>显示B组件</button>

            <KeepAlive activeId={activeTab}>
                {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
            </KeepAlive>
        </div>
    )
}

Counter 组件内部有一个 count 状态:

const Counter = ({ name }) => {
    const [count, setCount] = useState(0)
    // 挂载/卸载的生命周期日志
    useEffect(() => {
        console.log('挂载', name)
        return () => console.log('卸载', name)
    }, [])

    return (
        <div>
            <h3>{name}视图</h3>
            <p>当前计数:{count}</p>
            <button onClick={() => setCount(count + 1)}>加1</button>
        </div>
    )
}

没有 KeepAlive 时,用户在 A 组件把 count 点到 5,切换到 B,再切回 A:

切换 BA 组件卸载(state 销毁,count 归零,DOM 移除)
切回 AA 组件重新挂载(count 重新从 0 开始,useEffect 再次执行)

用户体验:辛辛苦苦点的数全白费了。


核心思路:把 JSX 元素存进一个对象里

React 的组件渲染本质上就是把 JSX 转成 Virtual DOM,再映射到真实 DOM。那如果我们不销毁这个 JSX 对应的 DOM,而是把它"藏起来"呢?

关键认知:JSX 本质上就是一个 JavaScript 对象引用。 只要引用不被 GC 回收,React 内部维护的 Fiber 节点和对应的真实 DOM 就不会被销毁。

设计数据结构:

// cache 对象的结构
{
    'A': <Counter name="A" />,     // JSX 对象引用
    'B': <OtherCounter name="B" />,
}
  • key:用 activeId 作为缓存键,唯一标识每个需要缓存的视图
  • value:存储该视图对应的 JSX 元素(注意:是首次渲染时的那个 JSX 对象,不是每次都创建新的)

一步步写出来

第一版:能跑就行的朴素实现

import { useState, useEffect } from 'react'

const KeepAlive = ({ activeId, children }) => {
    const [cache, setCache] = useState({})

    useEffect(() => {
        if (!cache[activeId]) {
            setCache(prev => ({
                ...prev,
                [activeId]: children
            }))
        }
    }, [activeId, children, cache])

    return (
        <>
            {Object.entries(cache).map(([id, component]) => (
                <div
                    key={id}
                    style={{ display: id === activeId ? 'block' : 'none' }}
                >
                    {component}
                </div>
            ))
            }
        </>
    )
}

export default KeepAlive

逐行解析

1. 缓存状态:const [cache, setCache] = useState({})

用一个对象存储所有被缓存过的视图。为什么用 useState 而不是 useRef?因为我们需要在状态更新时触发重新渲染——新的 children 被存入缓存后,必须让 React 重新执行 render 才能把新 DOM 渲染出来。

2. 缓存时机:if (!cache[activeId])
useEffect(() => {
    if (!cache[activeId]) {
        setCache(prev => ({
            ...prev,
            [activeId]: children
        }))
    }
}, [activeId, children, cache])

这是整个组件的灵魂。判断逻辑是:

场景 cache[activeId] 是否存在 行为
首次切换到某个 Tab 不存在 保存 children 到缓存
再次切换回已缓存的 Tab 已存在 什么都不做,复用旧缓存

注意:这里保存的是第一次渲染时的 children 引用。一旦保存,后续即使 children 变化(其他 Tab 的 JSX),已缓存的引用不会被覆盖。这就是状态得以保留的根源——React 始终渲染的是最初那个 Fiber 节点。

3. 显示策略:display: block / none
{Object.entries(cache).map(([id, component]) => (
    <div key={id} style={{ display: id === activeId ? 'block' : 'none' }}>
        {component}
    </div>
))}

所有被缓存过的组件全部渲染在 DOM 树中,但只把当前激活的那个设为可见:

  • 激活的 Tab:display: block(正常显示)
  • 隐藏的 Tab:display: none(DOM 存在但不可见)

这是整个方案最巧妙的地方:React 看到 {component} 引用没变,不会重新执行函数组件,不会触发 Hooks 重新计算,不会触发 useEffect。Fiber 节点一直挂在树上,状态完好无损。

当你从 B 切回 A 时,控制台不会打印"挂载 A",因为 A 组件的 Fiber 从未被卸载过。这就是 KeepAlive 的本质——DOM 存在但不显示,而非销毁后重建。


运行效果:对比控制台日志

// 初始加载
挂载 A              ← useEffect 触发

// 切换到 B
挂载 BB 首次进入缓存,执行挂载
// 注意:没有 "卸载 A"!

// 切回 A
// 没有 "挂载 A"!    ← A 从未卸载,缓存命中

// 再次切到 B
// 没有 "挂载 B"!    ← B 也从未卸载

A 组件切走时,控制台没有打印"卸载 A",因为 display: none 只是隐藏,React 的 cleanup 函数不会执行。切回来时也没有"挂载 A",计数仍然保持离开时的数字。


面试进阶:面试官可能会追问什么

Q1:为什么用 children 而不是让 KeepAlive 自己去渲染?

// ❌ 不好的设计:KeepAlive 内部 import 组件
<KeepAlive activeId={activeTab} components={{ A: Counter, B: OtherCounter }} />

// ✅ 好的设计:通过 children 让父组件控制渲染
<KeepAlive activeId={activeTab}>
    {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>

原因children 模式遵循 React 的组合优于继承原则。父组件完全控制子组件的 props、条件渲染逻辑,KeepAlive 只负责缓存,职责单一。

Q2:所有缓存组件都在 DOM 中,性能会不会有问题?

会有。每个隐藏的组件虽然不可见,但它的 DOM 节点和 Fiber 节点全部真实存在于内存中。如果你的 Tab 内容包含 1000 个列表项,那缓存 10 个 Tab 就是 10000 个 DOM 节点——对内存和首屏渲染性能都是负担。

生产级方案(如 react-activation)会做更精细的优化:通过 React Portal 把隐藏组件的 DOM 移到一个独立的、脱离文档流的容器中挂起。

Q3:useEffect 的依赖数组里有 cache,会不会导致无限循环?

cache[activeId] 不存在时才调用 setCache,更新后的 cacheactiveId 已存在,下次 useEffect 执行时 if (!cache[activeId])false,不会再调用 setCache。所以不会无限循环。

但这里有一个可优化的点:依赖 cache 对象意味着每次缓存更新后 useEffect 都会对整个 cache 重新求值。更好的写法是用函数式 setState + 单独的 useEffect 监听:

useEffect(() => {
    setCache(prev => {
        if (prev[activeId]) return prev  // 已缓存,不更新
        return { ...prev, [activeId]: children }
    })
}, [activeId, children])

这样去掉了对 cache 的依赖,效果一样但更简洁。

Q4:display: none 和条件渲染有什么区别?

display: none 条件渲染 {visible && <Comp />}
DOM 存在 ✅ 存在 ❌ 移除
state 保留 ✅ 保留 ❌ 销毁
useEffect cleanup ❌ 不触发 ✅ 触发
组件函数是否重新执行 ❌ 不执行 ✅ 重新执行

条件渲染的本质是移除 DOM → 销毁 Fiber → 清除 state → 执行 cleanup。display: none 的本质是 DOM 还在 → Fiber 还在 → state 还在 → cleanup 不执行。前者是"删了重建",后者是"藏起来再拿出来"。


从面试代码到生产级方案

这个 25 行的实现抓住了 KeepAlive 的核心思想,但它缺少几个关键能力:

缺失能力 生产级方案(react-activation)
滚动位置恢复 内置 saveScrollPosition 属性
缓存淘汰策略 支持 LRU,限制最大缓存数量
多实例管理 AliveScope 全局缓存池统一调度
生命周期钩子 useActivate / useUnactivate 替代 useEffect
SSR 兼容 提供 SSRKeepAlive 降级方案
动画过渡 切换时可配合 CSS Transition

但面试官要看的不是你会不会用库——而是你是否理解状态保留的本质是保留 JSX 引用,保留引用的本质是不让 Fiber 卸载,不让 Fiber 卸载的本质是 DOM 不离树。


总结

手写 KeepAlive 是一个优质的面试题,它串起了 React 的多个核心概念:

JSX 对象引用 → useState 缓存 → display:none 保活
         ↘        Fiber 持久化       ↙
              状态与 DOM 永不销毁

记住这一条线,你就能在任何面试中把 KeepAlive 的原理讲得明明白白。

一句话版本:KeepAlive = useState 存 JSX 引用 + display: none 隐藏非激活 DOM,让 React 的 Fiber 节点不被卸载,从而保住所有组件内部状态。

TEngine 入门系列(一):TEngine 是什么 & 为什么选它

作者 烛阴
2026年5月8日 19:43

一、做游戏为什么需要框架

1.1 没有框架的开发是什么样的

想象你在盖一栋房子。如果没有图纸、没有脚手架,你能盖吗?能。但你会遇到这些问题:

  • 墙歪了才发现地基没打好
  • 水管和电线混在一起
  • 改个窗户位置,整面墙得拆了重来

游戏开发不用框架,几乎一模一样:

开发阶段 没有框架的典型问题
初期 感觉很快,随便写写就能跑
中期 脚本之间互相引用,牵一发而动全身
后期 加新功能要改 10 个文件,改完原来的功能又坏了
上线后 想热更一个 Bug,发现根本没法热更
多人协作 每个人写法不一样,合并代码就是灾难

1.2 框架解决什么问题

一个好的游戏框架,本质上是帮你制定了一套规则和工具

  • 资源管理:资源怎么加载、怎么卸载、怎么打包——有标准流程
  • UI 管理:界面怎么打开、怎么关闭、怎么分层——有统一入口
  • 事件系统:模块之间怎么通信——不需要互相引用
  • 流程控制:游戏启动、登录、主界面、战斗——有清晰的状态切换
  • 热更新:代码和资源都能在不重新发版的情况下更新

一句话总结:框架让你把精力花在游戏玩法上,而不是重复造轮子。


二、TEngine 是什么

2.1 一句话定位

TEngine 是一个基于 Unity 的开箱即用游戏开发框架,集成了资源管理、UI 系统、事件系统、网络模块、热更新等完整的游戏开发基础设施。

它的目标是:让独立开发者和小团队不需要从零搭建底层架构,直接开始写游戏逻辑。

2.2 核心特性

  • 开箱即用:导入就能跑,不需要复杂配置
  • 模块化设计:每个功能是独立模块,用什么导什么
  • YooAsset 资源管理:业界成熟的资源打包和加载方案
  • HybridCLR 热更新:支持代码和资源双热更
  • 完整的 UI 框架:分层管理、生命周期、堆栈式导航
  • 事件驱动:模块间松耦合通信
  • 持续维护:GitHub 活跃更新,社区支持

2.3 TEngine 的模块全景

┌─────────────────────────────────────────────────────┐
│                    TEngine 框架                      │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│ 资源管理  │ UI 框架   │ 事件系统  │ 流程控制  │ 网络模块 │
│(YooAsset)│(UIModule)│(EventMgr)│(Procedure│(Network)│
│          │          │          │ + FSM)   │         │
├──────────┼──────────┼──────────┼──────────┼─────────┤
│ 对象池   │ 音频管理  │ 计时器   │ 配置表   │ 调试器   │
│(ObjPool) │(AudioMgr)│(TimerMgr)│(DataTable│(Debugger│
│          │          │          │)         │)        │
├──────────┴──────────┴──────────┴──────────┴─────────┤
│              热更新(HybridCLR + YooAsset)            │
└─────────────────────────────────────────────────────┘

每个模块一句话说明:

模块 做什么
资源管理 加载/卸载/打包游戏资源
UI 框架 管理所有界面的打开、关闭、层级
事件系统 模块间发消息,不需要互相认识
流程控制 管理游戏整体阶段切换
网络模块 与服务器通信
对象池 复用频繁创建/销毁的物体
音频管理 播放背景音乐、音效、语音
计时器 延时执行、倒计时、循环计时
配置表 从 Excel 读取游戏数值
调试器 运行时查看日志、性能、内存
热更新 不重新发版就能更新游戏内容

三、为什么选 TEngine

3.1 选几个主流框架对比

对比项 TEngine GameFramework QFramework 不用框架(裸写)
上手难度 中等 较高 最低(初期)
开箱即用 否(需大量配置) 部分
资源管理 YooAsset(成熟) 自带(较老) 需自己接 Resources.Load
热更新 HybridCLR + YooAsset 需自己接 需自己接 不支持
UI 框架 完整(分层+堆栈) 完整但复杂 简洁 自己写
文档质量 中文文档 + 示例 中文文档丰富 中文教程多
适合项目规模 中小型商业项目 中大型项目 小型/原型 极小型 Demo
学习曲线 前期稍陡,后期省力 前期很陡 平缓 前期平缓,后期灾难
社区活跃度 活跃 活跃 活跃 -
  • 除这些之外还有很多有名的框架,比如,ET,MyFramework等等,感兴趣可以自己查看

3.2 什么情况选 TEngine

TEngine 最适合以下场景:

  • 独立开发者或 2~5 人小团队:不想花几周搭底层架构
  • 需要热更新的手游项目:TEngine 原生集成 HybridCLR + YooAsset
  • 有一定 Unity 基础,想进阶到工程化开发:TEngine 的模块设计是很好的学习样本
  • 希望用中文文档和社区获得支持:国内开发者维护,交流无障碍

3.3 TEngine vs 裸写:一个真实场景对比

假设你要实现一个常见功能:玩家完成关卡后,弹出结算面板,显示得分和奖励。

裸写方式:

// GameManager.cs 里
public class GameManager : MonoBehaviour
{
    public GameObject resultPanel; // 在 Inspector 里拖引用
    public Text scoreText;
    public Text rewardText;

    public void OnLevelComplete(int score, int reward)
    {
        resultPanel.SetActive(true);
        scoreText.text = "得分: " + score;
        rewardText.text = "奖励: " + reward;
        // 问题:如果 resultPanel 被销毁了呢?
        // 问题:如果要加动画呢?
        // 问题:如果有多个面板要管理呢?
        // 问题:如果其他脚本也要知道关卡完成了呢?
    }
}

TEngine 方式:

// 1. 定义事件
public static class GameEvents
{
    public static readonly int LevelComplete = "LevelComplete".GetHashCode();
}

// 2. 关卡逻辑完成时,广播事件(不需要知道谁在听)
GameEvent.Send(GameEvents.LevelComplete, new LevelResult { Score = 100, Reward = 50 });

// 3. 结算面板自己监听事件(不需要知道谁发的)
public class UIResultPanel : UIWindow
{
    protected override void OnCreate()
    {
        GameEvent.AddEventListener<LevelResult>(GameEvents.LevelComplete, OnLevelComplete);
    }

    private void OnLevelComplete(LevelResult result)
    {
        // UI 框架自动管理面板的打开/关闭/层级/动画
        FindChildComponent<Text>("ScoreText").text = $"得分: {result.Score}";
        FindChildComponent<Text>("RewardText").text = $"奖励: {result.Reward}";
    }

    protected override void OnDestroy()
    {
        GameEvent.RemoveEventListener<LevelResult>(GameEvents.LevelComplete, OnLevelComplete);
    }
}

关键区别:

  • 关卡逻辑和 UI 面板完全解耦——改一个不影响另一个
  • UI 的生命周期由框架管理——不会出现空引用
  • 想加新面板监听同一个事件?加就行,不用改关卡逻辑的一行代码

TypeScript 数组去重的 20 种实现方式,学会用不同思路解决问题

作者 刀法如飞
2026年5月8日 19:16

TypeScript 数组去重的 20 种实现方式,用不同思路解决问题

数组去重是最常见的编程算法,非常简单,但也可以有很多的实现方案。TypeScript 在 JavaScript 的基础上加了静态类型,让通用工具函数可以用泛型写一次、对所有可比较类型可用。本文整理 TS 数组去重的 20 种写法,按 5 个策略分类。AI时代,可以不手写代码了,但需要知道代码背后的原理,这样才能更好地指导AI编程。

为什么性能差异这么大?

最简单的写法,新建一个数组,把不在结果里的添加进去。

function unique<T>(arr: T[]): T[] {
  const result: T[] = []
  for (const item of arr) {
    // includes 是 O(n) 线性扫描,整体则是 O(n²)
    if (!result.includes(item)) {
      result.push(item)
    }
  }
  return result
}

本文源码:github.com/microwind/a…*

问题在于每次 includes 都要全量扫一遍 result,复杂度是 O(n²)。

优化思路:换一种判重方式

  • Set / Map O(1) 查询:new Set(arr)
  • 排序 O(n log n):相同元素相邻后扫一遍
  • filter + 闭包:在函数式管道里携带"已见"状态
  • JSON 序列化:处理对象、嵌套数组等不可哈希元素
  • 递归:换种表达方式,本质仍是上面的思路

TS 相比 JS 的优势

  • 泛型 <T>:写一次、对所有类型类型安全可用
  • 类型约束T extends string | number 限定基本类型,避免对象误用 Object 字面量
  • 编译期校验:传入错误类型立即报错,不会在运行时才崩

推荐方案

需求 代码 性能 保序
一行最简 [...new Set<T>(arr)] O(n)
函数式 + Set arr.filter(x => !seen.has(x) && seen.add(x)) O(n)
按字段去重 [...new Map(arr.map(x => [x.id, x])).values()] O(n)
对象数组 JSON.stringify 作为 Set 的键 O(n×m)

第1类:基础循环(方法1-6)

策略原理:不用任何内置数组方法,纯靠下标、嵌套循环、indexOf 这种"原始"手段完成去重。每一步判重都是 O(n),整体 O(n²)。

适用场景:教学、面试手撕。生产代码不建议使用。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[取下一个元素]
    B --> C{遍历结果数组<br/>是否已存在?}
    C -->|否| D[push 追加]
    C -->|是| E[跳过]
    D --> F([继续])
    E --> F
    F --> B

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#3A86FF,color:#fff,stroke:#2b63c4,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class B,D,E step
    class C check
// 方法1:双循环索引比较——i 与左侧每个 j 比对
static unique1<T>(arr: T[]): T[] {
  const result: T[] = []
  for (let i = 0, l = arr.length; i < l; i++) {
    for (let j = 0; j <= i; j++) {
      if (arr[i] === arr[j]) {
        // i === j 表示前面没有相同值,是首次出现
        if (i === j) result.push(arr[i])
        break
      }
    }
  }
  return result
}

// 方法2:新建数组 + includes 检查
static unique2<T>(arr: T[]): T[] {
  const result: T[] = []
  for (const item of arr) {
    // includes 是 O(n) 线性扫描,每个元素都要扫描一次,整体是 O(n²)
    if (!result.includes(item)) {
      result.push(item)
    }
  }
  return result
}

// 方法3:从后往前原地 splice
static unique3<T>(arr: T[]): T[] {
  let l = arr.length
  while (l-- > 0) {
    // 从后往前遍历,避免删除后索引变化导致跳过元素
    // 每个元素都要扫描一次,整体是 O(n²)
    for (let i = 0; i < l; i++) {
      if (arr[l] === arr[i]) {
        arr.splice(l, 1)
        break
      }
    }
  }
  return arr
}

// 方法4:从前往后原地 splice(删后面相同项)
static unique4<T>(arr: T[]): T[] {
  let l = arr.length
  for (let i = 0; i < l; i++) {
    // 从前往后遍历,每个元素都要扫描一次,整体是 O(n²)
    for (let j = i + 1; j < l; j++) {
      if (arr[i] === arr[j]) {
        arr.splice(j, 1)
        j--; l--
      }
    }
  }
  return arr
}

// 方法5:forEach + indexOf
// indexOf 返回首次出现下标,等于当前下标即首次
static unique5<T>(arr: T[]): T[] {
  const result: T[] = []
  // forEach 是 O(n) 线性扫描,每个元素都要扫描一次
  arr.forEach((item, i) => {
    if (arr.indexOf(item) === i) result.push(item)
  })
  return result
}

// 方法6:双重 while 倒序 splice
static unique6<T>(arr: T[]): T[] {
  let l = arr.length
  while (l-- > 0) {
    let i = l
    // 从后往前遍历,每个元素都要扫描一次,整体是 O(n²)
    while (i-- > 0) {
      if (arr[l] === arr[i]) {
        arr.splice(l, 1)
        break
      }
    }
  }
  return arr
}

所有泛型方法的 T 不需要额外约束——=== 比较对所有 TS 类型都有效(虽然引用类型只比指针)。


第2类:内置数组方法(方法7-11)

策略原理:JavaScript 数组自带 filterreduceforEach 等高阶方法,可以把"判重 + 收集"写成函数式风格。注意 indexOf / includes 仍是 O(n),需要用 Set<T> 闭包才能压到 O(n)。

适用场景:现代 TS 工程的常态写法。可读性高,链式组合方便。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[filter / reduce 管道]
    B --> C{选择策略}
    C -->|indexOf 判重| D["O(n²)"  但简洁]
    C -->|Set 闭包判重| E["O(n)" 推荐使用]
    C -->|对象键| F["O(n)" 但有类型陷阱]
    D --> G([新数组])
    E --> G
    F --> G

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#0F3460,color:#fff,stroke:#0a2647,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,G start
    class B,D,E,F step
    class C check
// 方法7:filter + indexOf 一行经典
// indexOf 返回首次出现下标,等于当前下标即首次出现,用 filter 过滤出首次出现的元素
static unique7<T>(arr: T[]): T[] {
  return arr.filter((item, i) => arr.indexOf(item) === i)
}

// 方法8:filter + Set 闭包——推荐写法
// Set.add 返回 Set 自身(truthy),结合短路 && 实现首次见到才返回 true
static unique8<T>(arr: T[]): T[] {
  const seen = new Set<T>()
  return arr.filter(item => !seen.has(item) && !!seen.add(item))
}

// 方法9:reduce 累加(用数组)
// 函数式风格,但 includes 仍是 O(n²)
// 注意 reduce 的泛型参数 T[],初始值为 [] as T[]
static unique9<T>(arr: T[]): T[] {
  // 用 reduce 累加数组,每次判断是否已存在,不存在则添加
  return arr.reduce<T[]>((acc, item) => {
    if (!acc.includes(item)) acc.push(item)
    return acc
  }, [])
}

// 方法10:reduce + Set 闭包——O(n) 函数式
static unique10<T>(arr: T[]): T[] {
  const seen = new Set<T>()
  // 用 reduce 累加数组,每次判断是否已存在,不存在则添加
  return arr.reduce<T[]>((acc, item) => {
    if (!seen.has(item)) {
      seen.add(item)
      acc.push(item)
    }
    return acc
  }, [])
}

// 方法11:Object + typeof 键
// 用 typeof + value 拼成字符串作为对象键,避免 1 与 '1' 冲突
// 类型约束 T extends string | number | boolean 限制为基本类型
static unique11<T extends string | number | boolean>(arr: T[]): T[] {
  const obj: Record<string, true> = {}
  // 用 filter 过滤出首次出现的元素,用 typeof + value 拼成字符串作为对象键
  return arr.filter(item => {
    const key = typeof item + String(item)
    return Object.prototype.hasOwnProperty.call(obj, key)
      ? false
      : (obj[key] = true)
  })
}

TS 加分项T extends string | number | boolean 限定调用方只能传基本类型数组,对象数组在编译期就会报错——避免运行时陷阱。


第3类:集合容器(方法12-14)

策略原理:ES6 引入的 SetMap 用 SameValueZero 算法判等,键唯一且 O(1),是 JS/TS 里最自然的去重工具。Object 字面量虽然也能当哈希用,但有"键自动字符串化""数字键被引擎重排"等坑。

适用场景:日常项目首选 Set;需要保留 value 选 Map;只在小数据或特殊兼容场景才用 Object

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B{选择容器}
    B -->|Set 'T'| C[键唯一<br/>保持插入顺序]
    B -->|Map 'K, V'| D[键唯一<br/>值可携带类型]
    B -->|Object 'Record'| E[键自动字符串化<br/>有重排陷阱]
    C --> F([转回数组])
    D --> F
    E --> F

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#8338EC,color:#fff,stroke:#5e27a8,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class C,D,E step
    class B check
// 方法12:new Set 转数组——一行经典
// Set<T> 用 SameValueZero 比较,NaN 也能正确去重
static unique12<T>(arr: T[]): T[] {
  return [...new Set(arr)]
}

// 方法13:Map<T, T> + keys
// 适合"按键去重,值携带其他信息"的场景
static unique13<T>(arr: T[]): T[] {
  const map = new Map<T, T>()
  // 用 Map<T, T> + keys 转数组,保持插入顺序
  arr.forEach(item => map.set(item, item))
  return [...map.keys()]
}

// 方法14:Object 字面量哈希——T extends string | number 防误用
// 注意:1 与 '1' 会被合并;数字键会被引擎按升序重排
static unique14<T extends string | number>(arr: T[]): T[] {
  const obj = {} as Record<string, T>
  // 用 Object 字面量哈希,键自动字符串化,数字键会被引擎按升序重排
  for (const item of arr) obj[String(item)] = item
  return Object.values(obj)
}

TS 类型提醒Map<K, V> 的两个泛型参数让你显式声明键值类型,比 JS 的 new Map() 更安全。如果按业务字段去重,可以写 new Map<number, User>() 表明键是 id(number),值是 User。


第4类:排序后去重(方法15-17)

策略原理:先 sort 让相同元素相邻,再扫一遍删除相邻相同项。复杂度由排序决定,O(n log n)。优点是不需要额外的哈希结构,"相邻判等"是最便宜的判重方式;缺点是会破坏原顺序。

适用场景:输出本就需要排序、不在意原顺序。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([原数组]) --> B[sort<br/>相同元素相邻]
    B --> C{相邻是否相同?}
    C -->|是| D[splice/skip]
    C -->|否| E[保留]
    D --> F([结果])
    E --> F

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#FF6B6B,color:#fff,stroke:#cc4444,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,F start
    class B,D,E step
    class C check
// 方法15:sort + splice 升序去重(仅 number[])
// JS sort 不传比较函数会按字符串排序,必须传 (a, b) => a - b
static unique15(arr: number[]): number[] {
  arr.sort((a, b) => a - b)
  let l = arr.length
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  while (l-- > 1) {
    if (arr[l] === arr[l - 1]) arr.splice(l, 1)
  }
  return arr
}

// 方法16:sort + filter 相邻判重
static unique16(arr: number[]): number[] {
  arr.sort((a, b) => a - b)
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  return arr.filter((item, i) => i === 0 || item !== arr[i - 1])
}

// 方法17:经典双指针(LeetCode 26)
// 排序后原地双指针,O(1) 额外空间
static unique17(arr: number[]): number[] {
  if (arr.length === 0) return arr
  arr.sort((a, b) => a - b)
  let slow = 0
  // 先排序,从后往前遍历,相邻元素相同则删除当前元素
  for (let fast = 1; fast < arr.length; fast++) {
    if (arr[fast] !== arr[slow]) {
      arr[++slow] = arr[fast]
    }
  }
  return arr.slice(0, slow + 1)
}

泛化排序的难点:要让排序方法也支持任意 T,得让调用方传 compareFn: (a: T, b: T) => number——参考 Array.prototype.sort 的设计。这里为简明起见限定为 number[]


第5类:递归与特殊(方法18-20)

策略原理:递归用自调用替代循环,是函数式思维的体现,主要用于教学。JSON.stringify 把对象映射为字符串,是处理"不可哈希元素"(对象数组、嵌套数组)的常见招数。

适用场景:递归——教学;JSON——对象数组按整体结构去重。

%%{init: {'flowchart': {'nodeSpacing': 30, 'rankSpacing': 25, 'padding': 8}}}%%
graph LR
    A([数组 length=n]) --> B{length <= 1?}
    B -->|是| C([返回])
    B -->|否| D[检查末尾元素<br/>是否在前面出现]
    D --> E{重复?}
    E -->|是| F[丢弃末尾]
    E -->|否| G[保留末尾]
    F --> H[递归 length-1]
    G --> H
    H --> A

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a,stroke-width:2px
    classDef step  fill:#118AB2,color:#fff,stroke:#0b5f7a,stroke-width:2px
    classDef check fill:#FFB703,color:#000,stroke:#cc8c00,stroke-width:2px
    class A,C start
    class D,F,G,H step
    class B,E check
// 方法18:递归原地删除
static unique18<T>(arr: T[], length: number): T[] {
  if (length <= 1) return arr
  const last = length - 1
  // 从后往前遍历,检查末尾元素是否在前面出现
  for (let i = last - 1; i >= 0; i--) {
    if (arr[last] === arr[i]) {
      arr.splice(last, 1)
      break
    }
  }
  return UniqueArray.unique18(arr, length - 1)
}

// 方法19:递归拼接返回(不修改原数组)
static unique19<T>(arr: T[], length: number): T[] {
  if (length <= 1) return arr.slice(0, length)
  const last = length - 1
  const lastItem = arr[last]
  let isRepeat = false
  // 从后往前遍历,检查末尾元素是否在前面出现
  for (let i = last - 1; i >= 0; i--) {
    if (lastItem === arr[i]) {
      isRepeat = true
      break
    }
  }
  const head = UniqueArray.unique19(arr, length - 1)
  return isRepeat ? head : head.concat(lastItem)
}

// 方法20:JSON 字符串判重——处理对象数组
// 把对象序列化成字符串作为 Set 的键,能去重 {id:1} 这类结构
static unique20<T>(arr: T[]): T[] {
  const seen = new Set<string>()
  const result: T[] = []
  // 遍历数组,把每个对象序列化成字符串作为 Set 的键
  for (const item of arr) {
    const key = JSON.stringify(item)
    if (!seen.has(key)) {
      seen.add(key)
      result.push(item)
    }
  }
  return result
}

// 用法示例:
// UniqueArray.unique20<{id: number}>([{id: 1}, {id: 2}, {id: 1}])
// => [{id: 1}, {id: 2}]

JSON 的两个限制:① 字段顺序不同的对象会被认为不同({a:1,b:2}{b:2,a:1});② undefined、函数、循环引用会丢失或抛错。


选择指南

%%{init: {'flowchart': {'nodeSpacing': 25, 'rankSpacing': 15, 'padding': 5}}}%%
graph TD
    Start(["数组去重"]) --> Need{"是否需要保序?"}

    Need -->|不需要| Fast["看数据特征"]
    Need -->|需要| Ordered["保留原顺序"]

    Fast --> Q1{"数据形态"}
    Q1 -->|顺便要排序| Sort["sort + Set"]
    Q1 -->|纯基本类型| Set1["[...new Set(arr)]"]

    Ordered --> Q2{"侧重点"}
    Q2 -->|代码简洁| Set2["[...new Set(arr)]<br/>一行解决"]
    Q2 -->|函数式 O(n)| FilterSet["filter + Set 闭包"]
    Q2 -->|按字段去重| MapByKey["Map + keyFn"]
    Q2 -->|对象数组| JSON["JSON.stringify + Set"]

    classDef start fill:#2E8B57,color:#fff,stroke:#1e5c3a
    classDef decision fill:#FE8B57,color:#fff,stroke:#141b2d
    classDef fast fill:#3A86FF,color:#fff,stroke:#2b63c4
    classDef ordered fill:#8338EC,color:#fff,stroke:#5e27a8
    classDef method fill:#0f3460,color:#fff,stroke:#0a2647

    class Start start
    class Need,Q1,Q2 decision
    class Fast fast
    class Ordered ordered
    class Sort,Set1,Set2,FilterSet,MapByKey,JSON method
类别 时间复杂度 是否保序 主要场景
基础循环 O(n²) 教学、面试手撕
内置数组方法 O(n) ~ O(n²) 函数式风格
集合容器 O(n) 看具体类 日常项目首选
排序后去重 O(n log n) 顺便要排序
递归 / JSON 视实现 看实现 教学 / 对象数组

实际项目里怎么选

绝大多数情况一行就够:

// 保序、O(n)、写法最短,工程首选
const result = [...new Set<T>(arr)]

// 或函数式风格,O(n)
const seen = new Set<T>()
const result = arr.filter(x => !seen.has(x) && !!seen.add(x))

按业务字段去重(最常用):

interface User {
  id: number
  name: string
}

// 按 id 去重
const result = [...new Map(users.map(u => [u.id, u])).values()]

对象数组按整体结构去重:

const seen = new Set<string>()
const result = arr.filter(x => {
  const key = JSON.stringify(x)
  return !seen.has(key) && !!seen.add(key)
})

需要排序:

const result = [...new Set(arr)].sort((a, b) => a - b)

带业务逻辑的去重

实际工作里经常遇到这样的情况:遇到重复时不能简单丢弃,要按某个规则做处理。比如:

  • id 去重,但要保留分数最高的那条记录
  • 去重的同时累加重复次数
  • 数值在某个区间内才参与去重

这类需求 Set 直接搞不定,需要把"判重"和"处理"两步拆开来写。TS 里通常用泛型 Map<K, V> + 合并函数:

/**
 * 带业务规则的去重。
 *
 * @param data 原数据
 * @param keyFn 从元素提取去重键
 * @param onDup 遇到重复时如何合并 (旧值, 新值) -> 新代表值
 */
function uniqueBy<T, K>(
  data: T[],
  keyFn: (item: T) => K,
  onDup?: (oldVal: T, newVal: T) => T,
): T[] {
  // Map 保证遍历顺序与首次出现顺序一致
  const chosen = new Map<K, T>()
  for (const item of data) {
    const key = keyFn(item)
    if (!chosen.has(key)) {
      chosen.set(key, item)
    } else if (onDup) {
      chosen.set(key, onDup(chosen.get(key)!, item))
    }
  }
  return [...chosen.values()]
}

例 1:按 id 去重,保留分数最高的:

interface Student {
  id: number
  name: string
  score: number
}

const students: Student[] = [
  { id: 1, name: '张三', score: 90 },
  { id: 1, name: '张三', score: 95 },   // 同 id,分数更高
  { id: 2, name: '李四', score: 85 },
]

const result = uniqueBy(
  students,
  s => s.id,
  (oldS, newS) => newS.score > oldS.score ? newS : oldS,
)
// [{id:1, score:95, ...}, {id:2, score:85, ...}]

例 2:去重同时统计频次:

const counts = new Map<string, number>()
for (const item of data) {
  counts.set(item, (counts.get(item) ?? 0) + 1)
}
// counts.keys() 是保序的去重结果

例 3:区间过滤——只对 [0, 100] 区间内的值去重,区间外原样保留:

const seen = new Set<number>()
const result: number[] = []
for (const x of data) {
  if (x >= 0 && x <= 100) {
    if (seen.has(x)) continue
    seen.add(x)
  }
  result.push(x)
}

这三个例子是同一种思路:把判重与业务规则分开。判重用 Set/Map 保证 O(n),规则部分留给回调或显式分支处理。


对象数组去重的几种 TS 写法

TS 比 JS 的优势在于——可以为每种去重写法显式标注键类型,编译器会帮你检查:

写法 1:按字段去重(最常见)

interface User {
  id: number
  name: string
}

// new Map<id类型, 值类型>
const result: User[] = [
  ...new Map<number, User>(users.map(u => [u.id, u])).values()
]

写法 2:按多字段组合

const result: User[] = [
  ...new Map<string, User>(
    users.map(u => [`${u.id}|${u.name}`, u])
  ).values()
]

写法 3:按整体结构(用 JSON)

const seen = new Set<string>()
const result = arr.filter(x => {
  const key = JSON.stringify(x)
  return !seen.has(key) && !!seen.add(key)
})

写法 4:写一个通用的 uniqueBy(推荐)

function uniqueBy<T, K>(arr: T[], keyFn: (item: T) => K): T[] {
  const seen = new Set<K>()
  return arr.filter(item => {
    const key = keyFn(item)
    return !seen.has(key) && !!seen.add(key)
  })
}

// 使用
const unique = uniqueBy(users, u => u.id)

TS 类型小贴士

Set 的泛型参数:永远显式标注 Set<T>,避免推断成 Set<unknown>

const seen = new Set<number>()      // ✓ 类型明确
const seen = new Set()              // ✗ Set<unknown>

reduce 的初始值:泛型推断有时会推成原数组类型,需要显式标注:

// ✗ 类型错误:推断 acc 为 number 而非 number[]
arr.reduce((acc, x) => [...acc, x], [])

// ✓ 用 reduce<T[]> 或 [] as T[]
arr.reduce<number[]>((acc, x) => [...acc, x], [])

Set.add 返回类型Set<T>.add 返回 Set<T>(truthy),用 && 短路时需要 !! 转布尔:

// JS 里直接 && 即可,TS 严格模式下需 !!
return !seen.has(item) && !!seen.add(item)

总结

工程应用选择:

  • 默认用 [...new Set<T>(arr)]:保序、一行、O(n)、类型安全
  • 函数式用 arr.filter(x => !seen.has(x) && !!seen.add(x))
  • 按字段去重用通用泛型 uniqueBy<T, K>(arr, keyFn)
  • 对象整体去重用 JSON.stringify 作为键
  • 顺便排序用 [...new Set(arr)].sort((a, b) => a - b)
  • 业务规则干预用 Map<K, V> + 合并函数

核心思路:

  1. 同一个问题可以从多个角度切入
  2. 选对数据结构往往比写更聪明的代码更重要
  3. O(n²) 与 O(n) 在数据变大时是几百倍的实际差距
  4. 不要过度优化——能用 new Set 就别绕弯
  5. TS 的泛型让通用工具函数写一次、对所有类型可用,比 JS 更值得封装

更多算法

不同语言算法实现:github.com/microwind/a…

AI编程知识库:microwind.github.io

面试官:说一下你现在使用的 AI IDE,什么,JoyCode 是什么?

作者 海石
2026年5月8日 19:06

前言

面试官:同学你好,先自我介绍一下吧

我:好嘞!我叫海石,是一位能工智人🤖

面试官:?

ME1778235521506.png

我:哦不是,是一位在AI Coding上有一定经验的研发...😁

面试官:既然你都这么说了,那你日常在用什么 AI IDE ?

我:JoyCode!

面试官:嗯嗯,Claude Co我也用,确实好……等等,JoyCode?这是什么?

我:点击此处,快速访问官网呢亲

咳咳,节目效果到此为止

我想不少掘友,听到 JoyCode 的第一反应都是"啥?没听过 。"

⬇️从谷歌搜索指数中也可见一斑⬇️

2026-02-28-17-10KPYi8S105LHDUeeV.png

作为一个已经把 JoyCode 当成"靠谱队友"用了大半年的京东 JDer,我觉得是时候认真 聊一聊:JoyCode 到底是什么?它好用吗"?

image.png

(个人的分享往往是有限的,一些最佳实践随着技术的迭代,模型的升级,往往也会过时,因此本文只是一次抛砖引玉,欢迎掘友们交流)


「观前叠甲」

1、有些案例现在看确实陈旧,比如rules+mcp,但其实是当初25年mcp比较热门时,skill的概念还没推出时,社区里也比较推崇的方式,大家不妨以一种回顾一路走来的AI Coding发展历史的心态阅读

2、从Vibe Coding 到 Context、SDD、到Agentic Coding(Multi-Agent)、再到Harness Engineering,AI时代给人一种“只要我学得晚,我就可以什么都不用学”的感受😂

3、笔者个人目前最常用的(门槛也最低)方式还是SDD 或者 有时候就是安装一些best practice的skills,开个plan模式,基本上就足够日常开发需求了

至于参考OpenAI的实践,用Harness Engineering的思路对当前工程仓库进行改造,我们可以下回一起聊一聊,不仅仅只是停留于概念的解释,而是真的结合业务需求进行实战

看看这样做了,编码质量到底能提高多少


一、JoyCode 到底是什么?

借用官网的一句话:JoyCode,专为应对企业级复杂任务而设计的智能编码工具

适合在以下场景使用:

  • 企业复杂任务场景:助力对业务需求精准理解,代码仓库的深度解析
  • 需要开箱即用的全流程智能开发体验:JoyCode 提供完整的 AI 辅助开发体验
  • 寻求 AI 辅助编程提升效率的开发者:利用 AI 能力加速开发过程
  • 需要智能代码补全和实时编程建议的场景:提高编码效率和质量

以具体业务需求开发为例,聊聊我的"编程队友"JoyCode 是怎么为我提效的


二、并行任务

先来还原一个我上周二下午的真实状态:

我裂开了

1️⃣ xx系统的页面要补监控配置——监控不能等;

2️⃣ 测试同事在群里 @ 我:"那 份埋点数据呢?"——人不能等;

3️⃣ 产品又甩来一个新需求:"这个加一下,今天能上吗 ?"——需求也不能等。

image.png

放在以前,这种时候我只有两个选择:

  • A. 加班
  • B. 报风险,然后被产品蛐蛐

但现在,我可以把新需求的代码实现交给 JoyCode,我自己专心搞前两件 。

这就是我想说的第一个核心用法:异步协作

很多人对 AI 写代码的印象是:"写得快,但写得野。"

变量随便起、组件库瞎引、规范全靠猜——交付出来的代码我还得花一个小时给它"擦屁股", 那不如自己写。

那有没有办法让 AI 既写得快、又写得"懂规矩"?

有,Rules + MCP

Step 1:给 JoyCode 配 Rules,把它从"专门"变成"专业"

JoyCode 支持类似 Cursor 的 .mdc 规则文件,配置入口在这里 👇

image.png

Rule 创建

不知道写什么 Rules?给大家推荐一个 star 接近 3k 的开源项目:

awesome-cursor-rules-mdc

👉 awesome-cursor-rules-mdc 👈

各种语言、各种框架的 Rules 应有尽有,基本是开箱即用。

创建好的 Rule

创建好的 Rule 会落到这个目录里

Rules 目录

Step2:基于业务实践沉淀Rule

通用的 Rules 解决不了"业务私有"的问题。

我自己负责的项目用的是京东自研的组件库,其中 jd-icon 在 Vue2 兼容写法 和 Vue3 <script setup> 写法下,用法完全不同——这种"内部知识"AI 是不可能自己悟出来的。

于是我手搓了一份 dong-design-icon.mdc

把"踩过的坑"沉淀成 Rule,这一步看着繁琐,但ROI很高——AI 再也不会写出 <jd-icon icon="plus" /> 这种"四不像"了

Step 3:MCP 一开, 直通"京东内网生态"

京东内部各中台沉淀了很多mcp工具提供给上下游

这提高了我们用大模型进行编码的质量,减少了返工

三、新版本体验

(截止本文发布,版本已经更新至2.6.x)

JoyCode 从 v0.5.0 一路更新到 v2.3.6,最新的 v3.0.0 Preview 也已经在路上。它的更新主题是「提效、智能、便捷

版本一览

「便捷」:满分 10 分,我打 8.4分,因为确实有1.6

✅ 拖拽文件、一键添加上下文

拖拽文件

✅ 一键选中代码加上下文 + Cmd + L 快捷键

快捷键

✅ Auto 模式默认选中

Auto 模式

减法美学,给减负点赞。

✅ AI 提交行数披露

提交行数

从 v2.3.4 升到 v2.3.6,统计准确多了,看着有成就感。😋

✅ Repo Wiki:基于当前工程仓库生成WIKI,类似Zread 和 DeepWiki


四、技术氛围

公司内部的技术氛围还是不错的,不过这个还是“因组而异”

经常有这样一句话流传:同一个公司组和组之间的区别,可能比公司与公司之间的区别还要大

我们组每周会组织AI相关的分享,包括但不限于AI提效范式的探讨、CopilotKit、AGUI协议、A2UI的实践、AI Coding的干货技巧等等

个人觉得组里对新技术和AI前沿还是很看重


五、结语

AI时代大家不可避免的会感到焦虑

前端已死都不知道听了多少回了

之前逛论坛和社区也会经常看到这样一张图:

守旧派如何如何

维新派如何如何

以及觉得AI已经可以替代程序员的人又如何如何

我的想法不多,如图所示,与君共勉

结束

一次搞懂:在Vue里用Showdown渲染Markdown+KaTeX数学公式

2026年5月8日 18:11

一个前端小白的踩坑日记,帮你避开那些“根号变触手”的诡异场面

事情是这样的

前两天接到一个需求:数据库里存了一堆 Markdown 格式的文本,里面还夹杂着各种数学公式,比如 $E=mc^2$ 这种,更狠的还有 $$\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}$$。要在 Vue 页面里把它们渲染得漂漂亮亮的,公式要有专业排版,不能用图片糊弄。

我心想:Markdown 渲染嘛,网上插件一抓一大把,分分钟搞定。结果……一抓就是一把 bug,尤其是那些数学公式,跟成精了似的,根号能变出分身来。这一折腾就是两天,最后总算整明白了。今天就把这个过程揉碎了写给你,让你少走点弯路。

市面上的 Markdown 解析器很多,什么 marked、markdown-it、showdown……我最后选了 showdown,原因就两条:

  • :老牌库,这么多年了,坑基本都被填平了。
  • 插件友好showdown-katex 插件刚好能满足我们 LaTeX 公式的需求,而且配置非常灵活。

装起来也就两行命令的事儿:

npm install showdown
npm install showdown-katex

先上能跑的代码,免得你们着急。这是一个简单的 Vue 组件,用来展示带公式的 Markdown 内容。

<template>
  <div class="msg" v-html="transformMsg(msgInfo)"></div>
</template>

<script>
import showdown from "showdown";
import showdownKatex from "showdown-katex";

export default {
  data() {
    return {
      msgInfo: ""  // 从后端拿到的 Markdown 字符串
    }
  },
  methods: {
    transformMsg(msgInfo = "") {
      // 第一步:预处理脏数据(后面解释为什么)
      msgInfo = msgInfo.replaceAll("<br  />", "\n");

      // 第二步:创建 showdown 转换器,配置好插件
      let converter = new showdown.Converter({
        tables: true,           // 支持表格
        strikethrough: true,    // 支持 ~~删除线~~
        underline: true,        // 防止下划线被误解析为斜体
        extensions: [
          showdownKatex({
            throwOnError: false,   // 公式写错了也别抛异常,页面别崩
            displayMode: false,    // 默认行内模式(可以改成 true 让 $$ 变块级)
            delimiters: [
              { left: "$$", right: "$$", display: false },
              { left: "$", right: "$", display: false },
              { left: "~", right: "~", display: false, asciimath: true },
            ],
          }),
        ],
      });

      // 第三步:转成 HTML 并返回
      return converter.makeHtml(msgInfo);
    }
  }
}
</script>

看着挺简单对吧?我当时也是这么想的,直到我遇到了那个“根号怪”。

奇奇怪怪问题一:根号怎么变分身了?

第一个让我破防的 bug:只要公式里有根号(比如 $\sqrt{2}$),页面上就会出现两个根号!第一个是正常的,第二个像个伸着长脖子的怪物,根号线无限拉长,里面的公式还重复显示。那场面,简直像公式在分裂繁殖。

根号分身示意图(自行脑补)转存失败,建议直接上传图片文件

经过一番搜索(其实就是去 GitHub 翻 issue),才知道这是 KaTeX 渲染机制导致的。KaTeX 为了兼顾屏幕阅读器,会同时生成两个 DOM 结构:一个用于正常显示(.katex),另一个做辅助(.katex-html)。在某些复杂公式(尤其是根号)下,两个结构都会在页面上渲染出来,就造成了重影。

解决方法?简单粗暴,CSS 一刀切:

.katex-html {
  display: none;
}

放心,这不会影响正常公式的显示,屏幕阅读器也能从 .katex 里读取内容。加完这句,根号立马老实了,世界清净。

奇奇怪怪问题二:数据库里的 <br /> 不听话

我们的数据是老系统倒过来的,里面换行用的是 <br />(中间俩空格)。结果 showdown 转换后,这些 <br /> 原样输出到了 HTML 里,根本没变成换行。页面上一段话全挤在一起,像没分段的作文。

解决办法是在转换前做替换,把 <br /> 变成 Markdown 认的换行符 \n

msgInfo = msgInfo.replaceAll("<br  />", "\n");

如果你数据库里还有 <br><br/> 等各种变体,可以写个正则一把抓:

msgInfo = msgInfo.replace(/<br\s*\/?>/gi, "\n");

奇奇怪怪问题三:下划线被当成斜体,公式乱套

物理老师最爱写 F_gravity,但 Markdown 里下划线默认表示斜体。于是 F_gravity 就变成了 F<em>gravity</em>,显示出来“F gravity”斜了,完全不是那个意思。

showdown 提供了一个 underline 选项,设为 true 后,__双下划线__ 会变成下划线,但单下划线 _ 还是斜体。这不够啊,我们想要的是:在普通文字里保留 Markdown 语法,但公式里的下划线别捣乱

其实 showdown-katex 插件在解析时会先把公式内容“保护”起来,只要你的公式被 $...$$$...$$ 包住,里面的下划线就不会被 Markdown 引擎解析。所以关键是:所有公式都必须写定界符

如果历史数据里确实有裸下划线没加 $,那只能写个脚本批量包一下,或者在转换前用正则把 \b_\w+_\b 之类的模式手动包成公式。这活儿有点糙,但能救急。

奇奇怪怪问题四:行内公式和块级公式不分家

看上面代码的 delimiters 配置,$$$display 都是 false,这意味着无论你写 $E=mc^2$ 还是 $$E=mc^2$$,都会被当成行内公式,不换行,不居中。这显然不符合常识。

正确的配置应该是:$ 行内,$$ 块级。改一下:

delimiters: [
  { left: "$$", right: "$$", display: true },   // 块级公式,独占一行
  { left: "$", right: "$", display: false },    // 行内公式,夹在文字中间
  { left: "~", right: "~", display: false, asciimath: true }
]

当然,如果你希望 $$ 也当行内用,那就保持原样。但根据 LaTeX 习惯,还是建议区分开来。

如果直接在模板里写 v-html="transformMsg(msgInfo)",每次组件重新渲染(比如窗口滚动、数据变化)都会重新解析 Markdown 和公式。公式一多,页面就卡得像幻灯片。

改成计算属性(computed)缓存一下结果:

computed: {
  renderedHtml() {
    return this.transformMsg(this.msgInfo);
  }
}

模板里用 v-html="renderedHtml",只有当 msgInfo 真正变化时才会重新转换。

KaTeX 自带样式,但默认块级公式的上下边距可能跟你的页面不搭。可以自己在全局 CSS 里调一下:

.katex-display {
  margin: 1.5em 0;
  overflow-x: auto;  /* 防止超宽公式溢出 */
}

如果行内公式显得太小,可以来个:

.katex {
  font-size: 1.05em;
}

throwOnError: false 是个好习惯。公式写错了,页面不会白屏,只会显示一段红色的错误提示。用户至少能看到“这里有个公式没渲染好”,而不是整个页面崩掉。

我把上面的点整合成一个 Vue 单文件组件,你直接拷到项目里就能用:

<template>
  <div class="markdown-math" v-html="renderedHtml"></div>
</template>

<script>
import showdown from "showdown";
import showdownKatex from "showdown-katex";

export default {
  name: "MarkdownMath",
  props: {
    content: {
      type: String,
      default: ""
    },
    // 可选:自定义定界符
    delimiters: {
      type: Array,
      default: null
    }
  },
  computed: {
    renderedHtml() {
      return this.transform(this.content);
    }
  },
  methods: {
    transform(raw) {
      if (!raw) return "";
      // 预处理奇怪的换行
      let processed = raw.replace(/<br\s*\/?>/gi, "\n");

      const customDelimiters = this.delimiters || [
        { left: "$$", right: "$$", display: true },
        { left: "$", right: "$", display: false },
        { left: "\\[", right: "\\]", display: true },
        { left: "\\(", right: "\\)", display: false }
      ];

      const converter = new showdown.Converter({
        tables: true,
        strikethrough: true,
        underline: false,   // 关掉下划线解析,避免干扰公式
        simpleLineBreaks: true,
        extensions: [
          showdownKatex({
            throwOnError: false,
            delimiters: customDelimiters
          })
        ]
      });
      return converter.makeHtml(processed);
    }
  }
};
</script>

<style scoped>
/* 组件内部样式 */
.markdown-math {
  line-height: 1.6;
  word-wrap: break-word;
}
</style>

<style>
/* 全局样式,解决根号分身 */
.katex-html {
  display: none;
}
.katex-display {
  margin: 1em 0;
  overflow-x: auto;
  overflow-y: hidden;
}
</style>

我用两周半 Vibe Coding 做了一个前额叶训练的微信小程序

2026年5月8日 17:46

体验方式在最后~

最近花了两周半的时间vibe coding了一款微信小程序,这也是属于自己的第一款产品,叫「前额叶专注训练」,定位是前额叶训练小游戏。简单说,就是把舒尔特方格、数字记忆、N-Back、Stroop、go/no-go、河内塔、24 点这类认知训练任务,包装成一个轻娱乐产品:用户每天玩几局,训练自己的专注能力和记忆力。

这个项目从需求讨论、技术方案、功能实现,都是我跟 AI 一轮一轮聊出来的,自己没有写过一行代码,整个过程大概两周半,从五月初开始规划,到五月前开发完成并发布上线,备案的过程也是开始开发的时候就完成了。写这篇文章是想分享下vibe coding的一些感受。

image.png

第一,让AI写代码之前,一定要把需求聊清楚

从一开始的windsurf、cursor、trae到claude code和codex,我都使用过,在这里不讨论谁好用谁不好用,不管使用哪个代码编辑器,不管写多大的功能还少就是一个小的迭代,我的习惯都是先跟AI把需求聊明白,我习惯在每次对话后加上一句:先产出实现方案,等我同意再开始写代码。这样做的好处就是不用每次AI生成的代码不满意再回退,经常用AI编程的应该都深有感受,AI生成的代码一次次的回退是很招人烦的。所以,千万不要一上来就跟 AI 说“帮我写一个xxx小程序”,“帮我实现一个xxx功能”。这样做运气好的话能得到理想的效果,但是不保证每次运气都那么好。

我一开始也只是一个很模糊的想法:想做一个训练前额叶能力的小程序,而且我只知道一个舒尔特方格(我承认这个是从别人那得到的灵感),后来跟 AI 聊了之后,发现有好多可以实现的小游戏都可以用来训练专注力和注意力,这就是和AI先聊的好处。这个过程中,AI 的作用不是替我拍板,而是不断帮我把想法摊开。比如我说想做“脑力训练”,它会继续追问或展开成工作记忆、持续注意、抑制控制、认知灵活性、计划决策这些方向。然后我们再反过来判断,这些方向能不能变成普通用户愿意玩的小游戏。

确定了大概要做的内容,之后就是产出产品文档,有了产品文档之后,再让AI帮忙产出技术文档,有了这两份文档,基本是就知道该怎么实现了。另外还有一点,一定要分阶段实现,每一阶段开发完成后一定要验证,有bug就让AI改,避免最后整体验证bug数量过多的问题。

所以 vibe coding 第一步不是写代码,而是把需求聊到足够具体。你越能说清楚边界,AI 写出来的东西越接近你想要的效果。反过来,如果自己都没想清楚,AI 只会很努力地把混乱放大。

先实现一个能跑通的MVP版本

需求聊完之后,我没有一开始就让它帮我做完整的项目。而是先是做一个能跑通的版本,这个版本一定能跑通你的核心流程,比如我的小程序的核心流程就是用户能打开一款游戏去玩,所以我的第一版的功能就是:用户能打开小程序,能登录,能看到第一款游戏,能开始一局游戏即可。

这个链路跑通以后,说明第一版本的代码没问题,此时再往上叠加其他功能才最合适。否则你可能做了很多页面,但核心闭环其实是断的。

这样后面加游戏就和第一个游戏的开发一样,变成了一个固定流程:

  1. 写游戏组件;
  2. 写游戏定义;
  3. 注册到游戏列表;
  4. 在云函数里补评分规则。

所以项目后来能比较快地扩到 12 款游戏,不是因为每款游戏都随便生成一下就完了,而是因为前面先把模式定住了。AI 很适合在这种稳定模式下继续扩展。如果每个页面都从零开始聊,速度反而会越来越慢。

这也是我对 MVP 的理解:不是做一个很丑的半成品,而是先把最小闭环做扎实。它可以功能少,但关键链路一定要能跑。

即使产品失败了,没人用也不要气馁

忘了之前在哪看的,说独立开发人员在开发一款应用前都会有一种错觉,觉得自己的应用一定会火,一定有人用,我就是这样。所以在上线后天天盯着数据,发现用的人少心态都不好了,但其实成功毕竟是少数的,尤其是现在有了AI的加持,上线一款应用的成本这么低,注定会有许多人的产品一定没人用。但是并不是说没人用就一点收获都没有,以前总是想着自己作出一款属于自己的产品,这不就有了,现在试错成本这么低,多做几款又何妨。所以一定要放平心态。

最后的感受

两周半做出一个备案上线的小程序,在没有AI之前是不可能完成的事,但有了AI之后就将这种不可能变成了可能。AI 最大的价值,是让我一直保持“下一步能做什么”的状态。它能把模糊想法变成初稿,把初稿变成代码,把报错变成修改建议,把新功能拆成文件和步骤。

所以,不必焦虑AI把我们替代了这类问题,而是要积极的拥抱AI,用AI将自己脑子中的想法落地,这才是正解。

打个广告,欢迎体验,有问题欢迎私聊~

打开微信搜一搜:前额叶专注训练 image.png 也欢迎扫码体验

image.png

初学者对与.gitignore应该有的了解

作者 Rkgua
2026年5月8日 17:12

在项目中添加 .gitignore 文件,核心作用就是为版本控制系统建立一道“过滤网”,主动告诉 Git 哪些文件不需要被纳入管理。

基本用法讲解

.gitignore 的语法其实非常直观,核心就是通过一些特殊符号来告诉 Git 哪些文件需要“屏蔽”。针对你提到的 */ 以及如何忽略文件夹,接下来较为实用的语法规则和具体示例:

忽略文件夹与特定文件

  • 忽略文件夹:在文件夹名字后面加上斜杠 /
    • 例如:logs/ 会忽略项目中所有名为 logs 的文件夹及其内部的所有文件。
  • 忽略特定文件:直接写文件名。
    • 例如:.env 会忽略所有层级下名为 .env 的文件。

通配符 *** 的妙用

  • 单星号 *:匹配任意多个字符(但不跨目录)。
    • 例如:*.log 会忽略所有以 .log 结尾的文件(如 debug.logerror.log)。
    • 例如:temp* 会忽略所有以 temp 开头的文件或文件夹。
  • **双星号 ****:匹配任意多级目录(跨目录递归匹配)。
    • 例如:**/build/ 会忽略项目里任何层级下名为 build 的文件夹(无论是根目录的 /build 还是深层的 /src/components/build)。

斜杠 / 的路径限定作用

  • 开头的 /:表示只匹配项目根目录下的文件或文件夹。
    • 例如:/dist/ 只会忽略根目录下的 dist 文件夹,但不会忽略 src/dist/
    • 例如:/config.json 只会忽略根目录下的 config.json 文件。
  • 结尾的 /:明确表示这是一个文件夹,而不是同名文件。
    • 例如:docs/ 会忽略名为 docs 的文件夹,但不会忽略名为 docs 的纯文本文件。

取反符号 !(在原来基础上的例外规则)

  • 感叹号 !:表示“不要忽略”,即把前面规则忽略掉的文件重新捞回来。
    • 例如:
      *.log       # 忽略所有 .log 文件
      !important.log  # 但是!important.log 这个文件要保留,交给 Git 管理
      

综合举例

# 1. 忽略操作系统自动生成的系统文件
.DS_Store
Thumbs.db

# 2. 忽略所有的依赖包文件夹(前端 node_modules,Python venv)
node_modules/
venv/

# 3. 忽略编译打包后的产物文件夹(dist 或 build)
dist/
build/

# 4. 忽略所有日志文件,但保留根目录下的 error.log 用于排查线上问题
*.log
!/error.log

# 5. 忽略所有 .env 开头的环境配置文件(保护密钥)
.env
.env.*

# 6. 忽略所有 IDE 的配置文件
.vscode/
.idea/

添加它的具体用途和好处体现在以下 4 个方面:

1. 保持仓库整洁,聚焦核心代码

在实际开发中,项目里总会产生大量不需要版本控制的“噪音文件”。比如:

  • 编译缓存与临时文件:如 Python 的 __pycache__/*.pyc,或者编辑器的临时备份文件(.swp)。
  • IDE 个人配置:如 .vscode/.idea/ 等文件夹,这些通常是个人的开发环境偏好,不需要强加给团队成员。 通过 .gitignore 过滤掉这些文件,执行 git status 时界面会非常干净,让你能一眼看到真正需要提交的核心代码变更。

2. 保护敏感信息,防止意外泄露

这是 .gitignore 极其重要的安全作用。项目中经常包含一些绝对不能公开的私密文件,例如:

  • 环境变量与密钥:如 .env 文件,里面通常存放着数据库密码、API 密钥等。
  • 本地私有配置:如 config.local.json 等。 如果不添加忽略规则,一旦手滑执行 git add . 并推送到 GitHub 等公开仓库,这些敏感信息就会彻底泄露,带来极大的安全隐患。

3. 减小仓库体积,提升协作效率

很多依赖包或构建产物的体积非常庞大。比如前端项目中的 node_modules/ 文件夹,或者 AI 项目中的模型权重文件(*.pt, *.bin)。

  • 如果不忽略它们,仓库体积会急剧膨胀。
  • 团队成员在拉取代码(git clonegit pull)时,需要下载这些动辄几百 MB 甚至几个 GB 的无用文件,导致速度极慢。
  • 此外,庞大的文件还会严重拖慢 CI/CD(自动化构建与部署)的流程。

4. 避免环境差异引发的冲突

不同开发者的电脑系统(Windows、Mac、Linux)或 IDE 版本不同,自动生成的系统文件或配置文件往往也不一样。比如 Mac 系统会自动生成 .DS_Store,Windows 会生成 Thumbs.db。如果不忽略这些文件,团队成员之间会频繁产生毫无意义的文件冲突,增加合并代码的麻烦。


还有一个非常重要的实践建议:

.gitignore 只对未跟踪(Untracked)的文件生效。因此,在项目创建初期就第一时间添加并配置好 .gitignore 是最佳实践

如果等项目做了一半,不小心把 node_modules.env 提交进了仓库,后续再想通过 .gitignore 去忽略它们就会非常麻烦(必须使用 git rm --cached 手动清理历史记录)。提前配置好,能从源头上规避掉这些棘手的麻烦,所以配置项的详细用法和最佳实践,请参考官方文档:git-scm.com/docs/gitign…

JS中的惰性函数基本介绍

作者 Rkgua
2026年5月8日 17:09

JS中的惰性函数(Lazy Function)其实是一种非常巧妙的性能优化设计模式,也就是利用js的动态重写函数的思想,用第一次调用时微乎其微的代价,换取了后续所有调用的极致性能,它的核心思想非常直白:让函数在第一次执行时“记住”环境特性或计算结果,并在后续调用中直接走捷径,不再做无意义的重复判断或计算。

你可以把它想象成下班回家认路:第一天到一个新小区,你需要停下来辨认方向、确认门牌号(这是第一次执行的“脏活累活”);但如果你住了十年,每天回家还在每个路口重新认路就太荒谬了。惰性函数就是让你“第一次辛苦认路,以后闭着眼走直线”。

核心原理:函数自我重写

在 JavaScript 中,函数是一等公民,这意味着函数可以在运行时被重新赋值和修改。惰性函数正是利用了这一特性,在函数内部将自己重写为一个优化后的“精简版”函数。

在 JS 中,实现惰性函数主要有以下两种经典写法:

1. 函数内直接重写(延迟执行版)

这种写法在第一次调用时才进行环境检测和函数重写。

function createXHR() {
  // 第一次进入这里,做繁重的环境检测
  if (window.XMLHttpRequest) {
    // 如果是标准浏览器,将 createXHR 重写为直接返回新对象的函数
    createXHR = function () {
      return new XMLHttpRequest();
    };
  } else {
    // 如果是古董 IE,重写为兼容版本
    createXHR = function () {
      return new ActiveXObject("Microsoft.XMLHTTP");
    };
  }
  // 重写完后,手动调用一次新函数,保证第一次调用也能拿到正确的结果
  return createXHR();
}

运行流程: 第一次调用 createXHR() 时,它进入原函数体做判断,把全局的 createXHR 指针指向新函数,然后返回结果。以后再调用 createXHR(),它就已经是那个精简版的新函数了,直接返回结果,完全跳过了 if/else 判断。

2. 闭包 + 立即执行(立即执行版)

这种写法在代码加载阶段(函数被赋值时)就立刻执行判断,并返回最终的函数形态。

const addEvent = (function () {
  // 这个外层自执行函数只跑一遍
  if (document.addEventListener) {
    // 标准浏览器,直接返回优化后的函数
    return function (el, type, handler) {
      el.addEventListener(type, handler, false);
    };
  } else if (document.attachEvent) {
    // 古董 IE,返回兼容版函数
    return function (el, type, handler) {
      el.attachEvent("on" + type, handler);
    };
  }
})();

运行流程: addEvent 在定义时,外层函数就立即执行并根据环境返回了最终形态。后续无论调用多少次 addEvent,它都是最终那个没有判断逻辑的函数。

经典应用场景

  1. 浏览器兼容性检测:比如检测 addEventListenerlocalStorageIntersectionObserver 等 API 是否存在。因为浏览器的运行环境在页面打开那一刻就已经确定了,不会中途改变,完全没必要每次调用都去扫描一遍。
  2. 单次初始化逻辑:比如生成一次性的复杂正则表达式、拉取远程配置后把 init 函数替换成空函数(防止重复初始化)。
  3. 复杂计算或资源加载缓存:对于一些第一次计算非常耗时的操作,可以在第一次算出结果后缓存起来,后续直接返回缓存值。

概念辨析:惰性函数 vs 惰性求值

在学习过程中,你可能会遇到另一个相似的概念叫**“惰性求值(Lazy Evaluation)”**。这两者虽然都带“惰性”,但解决的问题完全不同:

  • 惰性函数(Lazy Function):侧重于一次判断,终身受益。通过重写函数来消除重复的条件判断,提升高频调用时的执行效率。
  • 惰性求值(Lazy Evaluation):侧重于按需计算,节省资源。通常利用 ES6 的生成器来实现,推迟昂贵计算或处理无限数据流,只有当真正需要某个值时才去计算它(例如处理超大数组的过滤和映射)。

掌握惰性函数,能让你在处理跨浏览器兼容或高频工具函数时,写出性能更极致、逻辑更优雅的代码。

Riverpod 实战指南

2026年5月8日 16:38

Riverpod 实战指南

本文不堆概念,用 9 个递进的完整场景 把 Riverpod 讲透。
每个场景给出 完整可运行代码 + 运行效果 + 踩坑点
建议边读边敲,不要只看。

基于 flutter_riverpod: ^2.6.x(Riverpod 2),文末附 Riverpod 3 迁移要点。


准备工作

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  riverpod_annotation: ^2.6.1
  http: ^1.2.0
  freezed_annotation: ^2.4.1

dev_dependencies:
  riverpod_generator: ^2.6.2
  build_runner: ^2.4.0
  freezed: ^2.5.2
  json_serializable: ^6.7.1
  flutter_test:
    sdk: flutter
# 生成代码(一直跑着)
dart run build_runner watch -d

入口固定写法(后面每个场景都假设已有这段):

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Demo',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
      home: const HomePage(), // 替换成各场景页面
    );
  }
}

场景 1:主题切换 —— 最小的「跨组件共享状态」

需求:页面顶部一个开关切换深色/浅色模式,底部文字实时响应。

完整代码

// theme_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

// StateProvider 适合"一个布尔值、一个枚举"级别的极简状态
final isDarkModeProvider = StateProvider<bool>((ref) => false);
// theme_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'theme_provider.dart';

class ThemePage extends ConsumerWidget {
  const ThemePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ① watch:build 里订阅,值变 → 自动重建
    // 🔄 刷新范围:isDark 变化 → 整个 ThemePage.build() 重跑
    //    → Scaffold、Switch、Text 全部重建(因为 watch 写在最顶层)
    final isDark = ref.watch(isDarkModeProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('场景1:主题切换')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Switch(
              value: isDark,
              onChanged: (v) {
                // ② read:事件回调里只"读一次"并修改,不建立订阅
                ref.read(isDarkModeProvider.notifier).state = v;
              },
            ),
            const SizedBox(height: 20),
            Text(
              isDark ? '当前是深色模式 🌙' : '当前是浅色模式 ☀️',
              style: const TextStyle(fontSize: 24),
            ),
          ],
        ),
      ),
    );
  }
}

学到什么

要点 说明
ref.watch 写在 build 里,状态变了界面自动刷新
ref.read 写在 onChanged / onPressed 里,只读一次去改值
StateProvider 一个值、没有业务方法时够用

反面教材

// ❌ 在 build 里用 read → 界面永远不会因为 isDarkMode 变化而刷新
final isDark = ref.read(isDarkModeProvider);

场景 2:待办清单 —— 有业务方法的状态用 Notifier

需求:添加待办、标记完成、删除。比一个布尔值复杂,需要方法。

完整代码

// todo_model.dart
class Todo {
  final String id;
  final String title;
  final bool completed;

  Todo({required this.id, required this.title, this.completed = false});

  Todo copyWith({String? title, bool? completed}) {
    return Todo(
      id: id,
      title: title ?? this.title,
      completed: completed ?? this.completed,
    );
  }
}
// todo_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_model.dart';

class TodoListNotifier extends Notifier<List<Todo>> {
  @override
  List<Todo> build() => []; // 初始状态:空列表

  void add(String title) {
    // 不可变更新:创建新 list
    state = [
      ...state,
      Todo(id: DateTime.now().millisecondsSinceEpoch.toString(), title: title),
    ];
  }

  void toggle(String id) {
    state = [
      for (final todo in state)
        if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo,
    ];
  }

  void remove(String id) {
    state = state.where((t) => t.id != id).toList();
  }
}

final todoListProvider =
    NotifierProvider<TodoListNotifier, List<Todo>>(TodoListNotifier.new);

// 派生 Provider:已完成数量(只读、自动跟随 todoList 变化)
final completedCountProvider = Provider<int>((ref) {
  final todos = ref.watch(todoListProvider);
  return todos.where((t) => t.completed).length;
});
// todo_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_notifier.dart';

class TodoPage extends ConsumerWidget {
  const TodoPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:todoListProvider 或 completedCountProvider 任一变化
    //    → 整个 TodoPage.build() 重跑
    //    → AppBar(计数文字) + ListView(所有 ListTile) 全部重建
    //    → FloatingActionButton 也重建(但 const Icon 会被 Flutter 复用)
    final todos = ref.watch(todoListProvider);
    final doneCount = ref.watch(completedCountProvider);

    return Scaffold(
      appBar: AppBar(title: Text('待办 (已完成 $doneCount/${todos.length})')),
      body: ListView.builder(
        itemCount: todos.length,
        itemBuilder: (_, i) {
          final todo = todos[i];
          return ListTile(
            leading: Checkbox(
              value: todo.completed,
              onChanged: (_) =>
                  ref.read(todoListProvider.notifier).toggle(todo.id),
            ),
            title: Text(
              todo.title,
              style: TextStyle(
                decoration:
                    todo.completed ? TextDecoration.lineThrough : null,
              ),
            ),
            trailing: IconButton(
              icon: const Icon(Icons.delete),
              onPressed: () =>
                  ref.read(todoListProvider.notifier).remove(todo.id),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddDialog(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController();
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('添加待办'),
        content: TextField(controller: controller, autofocus: true),
        actions: [
          TextButton(
            onPressed: () {
              if (controller.text.isNotEmpty) {
                ref.read(todoListProvider.notifier).add(controller.text);
              }
              Navigator.pop(context);
            },
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }
}

学到什么

要点 说明
Notifier 状态有"动作"(add/toggle/remove)时用它,比 StateProvider 清晰
state = ... 必须整体赋新值(不可变更新),Riverpod 才能检测到变化
派生 Provider completedCountProvider 自动跟随 todoListProvider,不需要手动同步
.notifier 在回调里 ref.read(xxx.notifier).method() 调用业务方法

踩坑点

// ❌ 直接 mutate list → Riverpod 检测不到变化,界面不刷新
void add(String title) {
  state.add(Todo(...)); // 错!引用没变
}

// ✅ 赋新 list
void add(String title) {
  state = [...state, Todo(...)];
}

场景 3:网络请求 —— FutureProvider + Loading/Error/Data 三态

需求:从网络拉取用户列表,显示加载中 → 数据 → 出错三种状态。

完整代码

// user_model.dart
class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as int,
        name: json['name'] as String,
        email: json['email'] as String,
      );
}
// user_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'user_model.dart';

class UserRepository {
  Future<List<User>> fetchUsers() async {
    final response = await http.get(
      Uri.parse('https://jsonplaceholder.typicode.com/users'),
    );
    if (response.statusCode != 200) {
      throw Exception('请求失败: ${response.statusCode}');
    }
    final list = jsonDecode(response.body) as List;
    return list.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();
  }
}
// user_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_repository.dart';
import 'user_model.dart';

// 依赖注入:Repository 本身也是 Provider,方便测试时替换
final userRepositoryProvider = Provider<UserRepository>((ref) {
  return UserRepository();
});

// FutureProvider:声明"这个数据怎么来",框架管 loading/error/data
final userListProvider = FutureProvider<List<User>>((ref) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.fetchUsers();
});
// user_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_providers.dart';

class UserListPage extends ConsumerWidget {
  const UserListPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:userListProvider 状态切换(loading → data → error)
    //    → 整个 UserListPage.build() 重跑
    //    → body 在 CircularProgressIndicator / ListView / 错误提示 之间切换
    //    → AppBar 不受数据影响(标题是 const),但仍在 build 树内会被重建
    final usersAsync = ref.watch(userListProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('场景3:网络请求'),
        actions: [
          // 下拉刷新:invalidate 让 Provider 重新执行
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => ref.invalidate(userListProvider),
          ),
        ],
      ),
      // .when 一次性处理三种状态,编译器保证你不遗漏
      body: usersAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('出错了: $err'),
              const SizedBox(height: 8),
              ElevatedButton(
                onPressed: () => ref.invalidate(userListProvider),
                child: const Text('重试'),
              ),
            ],
          ),
        ),
        data: (users) => ListView.builder(
          itemCount: users.length,
          itemBuilder: (_, i) => ListTile(
            leading: CircleAvatar(child: Text('${users[i].id}')),
            title: Text(users[i].name),
            subtitle: Text(users[i].email),
          ),
        ),
      ),
    );
  }
}

学到什么

要点 说明
FutureProvider 声明"数据怎么来",自动管理 loading/error/data 生命周期
AsyncValue.when 三态分支,编译器强制你每个都处理,不会漏
ref.invalidate 让缓存失效,Provider 重新执行(用于刷新/重试)
Repository 注入 userRepositoryProvider 让测试时可以 override 成假实现

踩坑点

// ❌ 只判断 data,忘了 loading 和 error → 白屏
body: Text(usersAsync.value?.first.name ?? ''),

// ✅ 用 .when 或 .maybeWhen 显式处理每种状态

场景 4:详情页 —— family 按参数缓存 + autoDispose 自动释放

需求:列表点击进入用户详情页,每个用户 ID 对应独立缓存;离开页面自动释放。

完整代码

// user_detail_provider.dart
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'user_model.dart';

// .family:按 userId 参数化,每个 id 一份独立缓存
// .autoDispose:没人看这个详情页时,自动释放缓存
final userDetailProvider =
    FutureProvider.autoDispose.family<User, int>((ref, userId) async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/users/$userId'),
  );
  if (response.statusCode != 200) throw Exception('请求失败');
  return User.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
});
// user_detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_detail_provider.dart';

class UserDetailPage extends ConsumerWidget {
  final int userId;
  const UserDetailPage({super.key, required this.userId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:该 userId 对应的数据状态变化(loading → data)
    //    → 整个 UserDetailPage.build() 重跑
    //    → body 从 CircularProgressIndicator 切换到用户详情
    //    → 其他 userId 的 Provider 变化不影响这个页面
    final detailAsync = ref.watch(userDetailProvider(userId));

    return Scaffold(
      appBar: AppBar(title: Text('用户 #$userId')),
      body: detailAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('$e')),
        data: (user) => Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(user.name, style: const TextStyle(fontSize: 28)),
              const SizedBox(height: 8),
              Text(user.email, style: const TextStyle(fontSize: 18)),
              const SizedBox(height: 24),
              const Text(
                '💡 返回列表后,这个 Provider 会自动释放\n'
                '   再次进入会重新请求',
              ),
            ],
          ),
        ),
      ),
    );
  }
}

从场景 3 的列表页跳转:

// 在 user_list_page.dart 的 ListTile 加 onTap
onTap: () => Navigator.push(
  context,
  MaterialPageRoute(
    builder: (_) => UserDetailPage(userId: users[i].id),
  ),
),

学到什么

要点 说明
.family 一个 Provider 定义 → 按参数生成 N 个独立实例
.autoDispose 页面 pop 后无人 watch → 自动释放,不留内存
组合链 FutureProvider.autoDispose.family —— 这三个能力可以自由组合

踩坑点

// ❌ family 参数用了复杂对象,但没实现 == 和 hashCode
//    → 每次 build 都认为是"新参数",无限重建
final p = FutureProvider.family<Data, MyFilter>((ref, filter) { ... });

// ✅ family 参数优先用基本类型(int, String, enum)
//    复杂参数务必正确实现 == / hashCode(推荐用 freezed)

场景 5:登录认证 —— AsyncNotifier + listen 做副作用 + 依赖链

需求:登录表单 → 提交 → 加载中 → 成功后自动跳转首页 / 失败显示错误。
这是把前面学到的东西串起来的「综合场景」。

完整代码

// auth_state.dart
class AuthState {
  final String? token;
  final String? username;

  const AuthState({this.token, this.username});

  bool get isLoggedIn => token != null;
}
// auth_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_state.dart';

class AuthNotifier extends AsyncNotifier<AuthState> {
  @override
  Future<AuthState> build() async {
    // 初始状态:未登录(实际项目这里可以读本地 token)
    return const AuthState();
  }

  Future<void> login(String username, String password) async {
    // 先切到 loading 状态
    state = const AsyncLoading();

    // AsyncValue.guard 自动把异常转为 AsyncError
    state = await AsyncValue.guard(() async {
      // 模拟网络请求
      await Future.delayed(const Duration(seconds: 2));

      if (password != '123456') {
        throw Exception('密码错误');
      }

      return AuthState(token: 'fake_token_abc', username: username);
    });
  }

  void logout() {
    state = const AsyncData(AuthState());
  }
}

final authProvider =
    AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);

// 派生:当前是否已登录(其他地方只关心这个布尔值)
final isLoggedInProvider = Provider<bool>((ref) {
  return ref.watch(authProvider).valueOrNull?.isLoggedIn ?? false;
});
// login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_notifier.dart';

class LoginPage extends ConsumerStatefulWidget {
  const LoginPage({super.key});
  @override
  ConsumerState<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends ConsumerState<LoginPage> {
  final _userCtrl = TextEditingController(text: 'admin');
  final _passCtrl = TextEditingController();

  @override
  void dispose() {
    _userCtrl.dispose();
    _passCtrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // 🔄 刷新范围:authProvider 变化(未登录 → loading → 已登录/错误)
    //    → 整个 _LoginPageState.build() 重跑
    //    → ElevatedButton:loading 时变 disabled + 显示转圈
    //    → TextField 不受影响(由 TextEditingController 自己管理内容)
    final authState = ref.watch(authProvider);

    // ③ listen:监听状态变化做副作用(跳转)
    // ⚠️ listen 不触发 build 重建!它只在值变化时执行回调
    //    跳转和 SnackBar 是"副作用",不是 UI 重建
    ref.listen(authProvider, (prev, next) {
      // 登录成功 → 跳转
      if (next.valueOrNull?.isLoggedIn == true) {
        Navigator.pushReplacement(
          context,
          MaterialPageRoute(builder: (_) => const HomePage()),
        );
      }
      // 登录失败 → 弹错误提示
      if (next.hasError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('${next.error}')),
        );
      }
    });

    final isLoading = authState is AsyncLoading;

    return Scaffold(
      appBar: AppBar(title: const Text('场景5:登录')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _userCtrl,
              decoration: const InputDecoration(labelText: '用户名'),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _passCtrl,
              obscureText: true,
              decoration: const InputDecoration(
                labelText: '密码',
                hintText: '输入 123456 登录成功',
              ),
            ),
            const SizedBox(height: 24),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: isLoading
                    ? null
                    : () => ref.read(authProvider.notifier).login(
                          _userCtrl.text,
                          _passCtrl.text,
                        ),
                child: isLoading
                    ? const SizedBox(
                        height: 20,
                        width: 20,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Text('登录'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class HomePage extends ConsumerWidget {
  const HomePage({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:authProvider 变化 → 整个 HomePage.build() 重跑
    //    → AppBar title 更新用户名
    //    → body 是 const,Flutter 会复用,但仍在 build 产出树内
    final auth = ref.watch(authProvider).valueOrNull;
    return Scaffold(
      appBar: AppBar(
        title: Text('欢迎, ${auth?.username ?? ""}'),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: () {
              ref.read(authProvider.notifier).logout();
              Navigator.pushReplacement(
                context,
                MaterialPageRoute(builder: (_) => const LoginPage()),
              );
            },
          ),
        ],
      ),
      body: const Center(child: Text('登录成功!', style: TextStyle(fontSize: 24))),
    );
  }
}

学到什么

要点 说明
AsyncNotifier 有异步方法(login)的状态用它,自带 loading/error/data 生命周期
AsyncValue.guard 一行代码把 try/catch 变成 AsyncData 或 AsyncError
ref.listen 副作用(跳转、SnackBar)放这里,不是放在 build 返回的 Widget 树里
派生 Provider isLoggedInProvider 只暴露布尔值,其他页面不需要知道 token 细节
ConsumerStatefulWidget 需要 TextEditingController 等有生命周期的东西时用它

watch / read / listen 完整对照

┌──────────────────────────────────────────────────────────────┐
│                      build() 方法体                          │
│                                                              │
│   ref.watch(authProvider)  ← 订阅,值变了 build 重跑         │
│   ref.listen(authProvider, callback)  ← 订阅,值变了跑回调   │
│                                                              │
├──────────────────────────────────────────────────────────────┤
│                   onPressed / 事件回调                        │
│                                                              │
│   ref.read(authProvider.notifier).login(...)  ← 读一次,调方法│
│                                                              │
└──────────────────────────────────────────────────────────────┘

场景 6:搜索 + 防抖 —— 多 Provider 协作的真实模式

需求:搜索框输入关键词 → 防抖 500ms → 发请求 → 显示结果。
展示多个 Provider 组合成链的典型做法。

完整代码

// search_providers.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;

// ① 搜索关键词(UI 写入,下游 watch)
final searchQueryProvider = StateProvider<String>((ref) => '');

// ② 防抖后的关键词
final debouncedQueryProvider = FutureProvider.autoDispose<String>((ref) async {
  final query = ref.watch(searchQueryProvider);

  // 关键:等 500ms;如果 500ms 内 searchQueryProvider 又变了,
  // 这个 Provider 会被 dispose 重建 → 旧的 Future 被丢弃 → 天然防抖
  await Future.delayed(const Duration(milliseconds: 500));

  // 如果这里 Provider 已被 dispose(用户又输入了),不会继续往下
  return query;
});

// ③ 搜索结果(依赖防抖后的关键词)
final searchResultsProvider =
    FutureProvider.autoDispose<List<String>>((ref) async {
  // watch 防抖后的值;它是 AsyncValue,用 .value 取实际值
  final query = await ref.watch(debouncedQueryProvider.future);

  if (query.isEmpty) return [];

  // 用 JSONPlaceholder 模拟搜索
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/users?username=$query'),
  );
  final list = jsonDecode(response.body) as List;
  return list.map((e) => '${e["name"]} (${e["email"]})').toList();
});
// search_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'search_providers.dart';

class SearchPage extends ConsumerWidget {
  const SearchPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:searchResultsProvider 变化(防抖结束 → 请求中 → 拿到结果)
    //    → 整个 SearchPage.build() 重跑
    //    → Expanded 区域在 loading 转圈 / 结果列表 / "无结果" 之间切换
    //    ⚠️ TextField 不受影响:它通过 ref.read 写入 searchQueryProvider,
    //       自己不 watch 任何 Provider,所以搜索结果变化不会导致输入框重建或丢失焦点
    final resultsAsync = ref.watch(searchResultsProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('场景6:搜索防抖')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              decoration: const InputDecoration(
                hintText: '试试输入 Bret 或 Delphine',
                prefixIcon: Icon(Icons.search),
              ),
              onChanged: (value) {
                // 写入搜索关键词 → 触发整条依赖链
                ref.read(searchQueryProvider.notifier).state = value;
              },
            ),
          ),
          Expanded(
            child: resultsAsync.when(
              loading: () => const Center(child: CircularProgressIndicator()),
              error: (e, _) => Center(child: Text('搜索出错: $e')),
              data: (results) => results.isEmpty
                  ? const Center(child: Text('无结果'))
                  : ListView.builder(
                      itemCount: results.length,
                      itemBuilder: (_, i) => ListTile(
                        leading: const Icon(Icons.person),
                        title: Text(results[i]),
                      ),
                    ),
            ),
          ),
        ],
      ),
    );
  }
}

依赖链图

用户输入
   ↓
searchQueryProvider (StateProvider<String>)
   ↓  watch
debouncedQueryProvider (FutureProvider, 500ms 延迟)
   ↓  watch
searchResultsProvider (FutureProvider, 发请求)
   ↓  watch
SearchPage UI (显示结果)

学到什么

要点 说明
Provider 链 复杂逻辑拆成多个 Provider,每个只做一件事
autoDispose 实现防抖 上游变化 → 旧 Provider dispose → 新 Provider 重新等 500ms
数据流可追溯 出 bug 时沿着链条一个一个检查,而不是在一坨代码里找

场景 7:实时数据 —— StreamProvider 监听持续变化

需求:页面实时接收服务器推送事件(类似 WebSocket / Firebase / SSE),展示连接状态 + 累积的消息历史。离开页面后自动断开连接。

这是 FutureProvider(一次性拉取)覆盖不了的场景——数据是连续推过来的

完整代码

// live_event.dart
class LiveEvent {
  final int id;
  final String content;
  final DateTime time;

  LiveEvent({required this.id, required this.content, required this.time});
}
// live_event_provider.dart
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'live_event.dart';

// 模拟服务器推送(实际项目替换为 WebSocket / Firebase / SSE 连接)
Stream<LiveEvent> _connectToServer() async* {
  const contents = [
    '新订单 #1024',
    '用户A发来消息',
    '库存预警:商品B不足10件',
    '支付成功:¥299.00',
    '系统维护提醒',
  ];
  for (var i = 0;; i++) {
    await Future.delayed(const Duration(seconds: 2));
    yield LiveEvent(
      id: i,
      content: contents[i % contents.length],
      time: DateTime.now(),
    );
  }
}

// StreamProvider:声明"数据流怎么来",框架自动管理订阅和取消
// autoDispose:离开页面 → 无人 watch → 自动取消 stream 订阅
final liveEventProvider = StreamProvider.autoDispose<LiveEvent>((ref) {
  ref.onDispose(() {
    // 实际项目:关闭 WebSocket 连接、释放资源
    print('实时连接已关闭');
  });
  return _connectToServer();
});
// live_event_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'live_event_provider.dart';
import 'live_event.dart';

class LiveEventPage extends ConsumerStatefulWidget {
  const LiveEventPage({super.key});
  @override
  ConsumerState<LiveEventPage> createState() => _LiveEventPageState();
}

class _LiveEventPageState extends ConsumerState<LiveEventPage> {
  // StreamProvider 只持有"最新一条",历史记录用局部 state 累积
  final List<LiveEvent> _history = [];

  @override
  Widget build(BuildContext context) {
    // 🔄 刷新范围:每次 stream 推送新事件 → 整个 _LiveEventPageState.build() 重跑
    //    → 顶部 Container(连接状态颜色+文字)更新
    //    → AppBar title(计数)更新
    //    → ListView(历史记录)更新
    final latestAsync = ref.watch(liveEventProvider);

    // listen:每来一条新事件,追加到历史列表
    // ⚠️ listen 本身不触发 build;但它内部的 setState(() => _history.insert(...))
    //    会触发 StatefulWidget 自己的重建。两次重建(watch + setState)
    //    在同一帧内被 Flutter 合并,实际只执行一次 build
    ref.listen(liveEventProvider, (prev, next) {
      final event = next.valueOrNull;
      if (event != null && !_history.any((e) => e.id == event.id)) {
        setState(() => _history.insert(0, event));
      }
    });

    return Scaffold(
      appBar: AppBar(title: Text('实时事件(${_history.length} 条)')),
      body: Column(
        children: [
          // 顶部:连接状态指示
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(16),
            color: latestAsync.when(
              loading: () => Colors.orange.shade100,
              error: (_, __) => Colors.red.shade100,
              data: (_) => Colors.green.shade100,
            ),
            child: latestAsync.when(
              loading: () => const Row(
                children: [
                  SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                  SizedBox(width: 8),
                  Text('正在连接服务器...'),
                ],
              ),
              error: (e, _) => Text('连接断开: $e'),
              data: (event) => Text(
                '最新: ${event.content}',
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
            ),
          ),
          // 下方:历史记录
          Expanded(
            child: _history.isEmpty
                ? const Center(child: Text('等待事件推送...'))
                : ListView.builder(
                    itemCount: _history.length,
                    itemBuilder: (_, i) {
                      final e = _history[i];
                      final t = e.time;
                      return ListTile(
                        leading: CircleAvatar(child: Text('${e.id}')),
                        title: Text(e.content),
                        subtitle: Text(
                          '${t.hour.toString().padLeft(2, '0')}:'
                          '${t.minute.toString().padLeft(2, '0')}:'
                          '${t.second.toString().padLeft(2, '0')}',
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

FutureProvider vs StreamProvider 对比

FutureProvider(场景 3)          StreamProvider(场景 7)
┌─────────────────────┐          ┌─────────────────────┐
│  请求 ──→ 等待 ──→ 结果 │          │  订阅 ──→ 事件1      │
│       (一次性)      │          │         ──→ 事件2      │
│                     │          │         ──→ 事件3      │
│                     │          │         ──→ ...持续    │
└─────────────────────┘          └─────────────────────┘
  用于:GET 接口、配置加载           用于:WebSocket、Firebase、
        一次拉取的数据                    实时推送、传感器数据

学到什么

要点 说明
StreamProvider 声明"流怎么来",框架自动订阅/取消,暴露 AsyncValue
ref.onDispose Provider 销毁时的清理回调,用于关闭连接、释放资源
autoDispose + Stream 离开页面 → 无人 watch → Provider 销毁 → Stream 取消 → 不泄漏
watch + listen 配合 watch 驱动 UI 刷新,listen 处理"累积历史"这种副作用

踩坑点

// ❌ 忘了 autoDispose → 离开页面后 stream 还在跑,浪费资源
final provider = StreamProvider<Event>((ref) => myStream);

// ✅ 加 autoDispose,离开页面自动取消
final provider = StreamProvider.autoDispose<Event>((ref) => myStream);
// ❌ 想在 StreamProvider 里累积历史 → 做不到,它只持有最新值
final historyProvider = StreamProvider<List<Event>>((ref) => ...);
// 每次 stream 发一个 event,你拿到的是单个 event 不是列表

// ✅ StreamProvider 拿最新值 + 另一个 Notifier/StatefulWidget 累积
//    就像上面例子中 ref.listen + setState 的做法

场景 8(番外):测试 —— Riverpod 的「高级价值」

前面 7 个场景都能用别的方案做,但测试体验是 Riverpod 真正拉开差距的地方。

// 纯逻辑测试,不需要 Flutter、不需要 Widget、不需要模拟器
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_notifier.dart';

void main() {
  test('添加待办', () {
    // 创建一个独立容器,和 app 完全隔离
    final container = ProviderContainer();
    addTearDown(container.dispose);

    expect(container.read(todoListProvider), isEmpty);

    container.read(todoListProvider.notifier).add('买牛奶');
    expect(container.read(todoListProvider), hasLength(1));
    expect(container.read(todoListProvider).first.title, '买牛奶');
  });

  test('完成数量自动更新', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    container.read(todoListProvider.notifier).add('任务A');
    container.read(todoListProvider.notifier).add('任务B');
    expect(container.read(completedCountProvider), 0);

    final id = container.read(todoListProvider).first.id;
    container.read(todoListProvider.notifier).toggle(id);
    expect(container.read(completedCountProvider), 1);
  });

  test('替换 Repository 做假数据测试', () async {
    final container = ProviderContainer(
      overrides: [
        // 把真 Repository 换成假的,不发网络请求
        userRepositoryProvider.overrideWithValue(FakeUserRepository()),
      ],
    );
    addTearDown(container.dispose);

    final users = await container.read(userListProvider.future);
    expect(users.first.name, 'Fake User');
  });
}

// 假实现
class FakeUserRepository implements UserRepository {
  @override
  Future<List<User>> fetchUsers() async {
    return [User(id: 1, name: 'Fake User', email: 'fake@test.com')];
  }
}

核心优势ProviderContainer 在纯 Dart 环境下工作,不需要 pumpWidget,测试跑得快;overrides 让你能替换任何一层依赖,不需要全局 mock 框架。


场景 9:模块化开发 —— 主项目与子插件之间的状态同步

需求:团队按业务拆包,主项目(app)和多个子插件(package)独立开发。
子插件需要读主项目的状态(比如登录用户信息),主项目也要响应子插件的状态变化(比如购物车数量)。
关键约束:子插件不能 import 主项目代码,否则就不叫"独立"了。

目录结构

my_flutter_app/               ← 主项目
├── lib/
│   ├── main.dart
│   ├── auth/
│   │   └── auth_providers.dart      ← 主项目持有登录状态
│   └── app_providers.dart           ← 组装所有模块的 overrides
│
├── packages/
│   ├── core_shared/           ← 共享层:只放接口和数据模型,不放实现
│   │   └── lib/
│   │       ├── models/
│   │       │   └── user_info.dart
│   │       └── providers/
│   │           ├── shared_auth_provider.dart    ← 抽象 Provider(占位)
│   │           └── shared_cart_provider.dart    ← 抽象 Provider(占位)
│   │
│   ├── feature_profile/       ← 子插件A:个人中心
│   │   └── lib/
│   │       └── profile_page.dart    ← watch 共享层的 Provider
│   │
│   └── feature_cart/          ← 子插件B:购物车
│       └── lib/
│           ├── cart_notifier.dart    ← 购物车业务逻辑
│           └── cart_page.dart

核心思路:三层

┌──────────────────────────────────────────────────────┐
│  主项目 (app)                                         │
│  · 持有真实实现(auth_providers 等)                    │
│  · 在 ProviderScope(overrides: [...]) 里把真实实现      │
│    注入到共享层的"占位 Provider"                        │
├──────────────────────────────────────────────────────┤
│  共享层 (core_shared package)                         │
│  · 只定义接口 + 数据模型 + "占位 Provider"              │
│  · 不依赖任何具体实现                                  │
├──────────────────────────────────────────────────────┤
│  子插件 (feature_xxx packages)                        │
│  · 只 import core_shared                              │
│  · watch/read 共享层的 Provider                        │
│  · 完全不知道主项目的存在                               │
└──────────────────────────────────────────────────────┘

第一步:共享层 —— 定义接口和占位 Provider

// packages/core_shared/lib/models/user_info.dart

class UserInfo {
  final String uid;
  final String name;
  final String avatar;

  const UserInfo({
    required this.uid,
    required this.name,
    required this.avatar,
  });

  static const empty = UserInfo(uid: '', name: '', avatar: '');

  bool get isLoggedIn => uid.isNotEmpty;
}
// packages/core_shared/lib/providers/shared_auth_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/user_info.dart';

/// 占位 Provider:运行时必须被主项目 override,否则直接报错。
/// 子插件只 watch 这个,不需要知道登录逻辑的实现细节。
final sharedUserProvider = Provider<UserInfo>((ref) {
  throw UnimplementedError(
    'sharedUserProvider 必须在主项目的 ProviderScope 中 override!'
    '请检查 main.dart 的 ProviderScope(overrides: [...])',
  );
});
// packages/core_shared/lib/providers/shared_cart_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';

/// 购物车条目数(子插件写入,主项目可以 watch)
/// 默认实现返回 0;子插件会 override 注入真实 Notifier
final sharedCartCountProvider = Provider<int>((ref) => 0);

第二步:子插件 A(个人中心) —— 只 import 共享层

# packages/feature_profile/pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  core_shared:
    path: ../core_shared     # 只依赖共享层
  # ↑ 注意:不依赖主项目,不依赖 feature_cart
// packages/feature_profile/lib/profile_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/providers/shared_cart_provider.dart';

class ProfilePage extends ConsumerWidget {
  const ProfilePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:sharedUserProvider 或 sharedCartCountProvider 任一变化
    //    → 整个 ProfilePage.build() 重跑
    //    → CircleAvatar、用户名 Text、购物车数 Text 全部更新
    //    ⚠️ CartPage 和 MainShell 不受 ProfilePage 重建影响(各自独立 watch)
    final user = ref.watch(sharedUserProvider);
    final cartCount = ref.watch(sharedCartCountProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('个人中心(子插件A)')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CircleAvatar(
              radius: 40,
              child: Text(user.name.isNotEmpty ? user.name[0] : '?',
                  style: const TextStyle(fontSize: 32)),
            ),
            const SizedBox(height: 16),
            Text('用户名:${user.name}', style: const TextStyle(fontSize: 20)),
            Text('UID:${user.uid}'),
            const Divider(height: 32),
            Text('购物车商品数:$cartCount',
                style: const TextStyle(fontSize: 18)),
            const SizedBox(height: 12),
            const Text(
              '👆 这个数字来自 feature_cart 子插件,\n'
              '   但 profile 完全不知道 cart 的存在,\n'
              '   两个插件通过共享层的 Provider 间接通信。',
              style: TextStyle(color: Colors.grey),
            ),
          ],
        ),
      ),
    );
  }
}

第三步:子插件 B(购物车) —— 暴露 Notifier 供主项目注入

# packages/feature_cart/pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  core_shared:
    path: ../core_shared     # 只依赖共享层
// packages/feature_cart/lib/cart_notifier.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/models/user_info.dart';

class CartItem {
  final String id;
  final String name;
  final int quantity;

  const CartItem({required this.id, required this.name, this.quantity = 1});

  CartItem copyWith({int? quantity}) =>
      CartItem(id: id, name: name, quantity: quantity ?? this.quantity);
}

class CartNotifier extends Notifier<List<CartItem>> {
  @override
  List<CartItem> build() {
    // 购物车依赖当前用户 → watch 共享层的 user
    // 用户切换时,购物车自动清空重建
    final user = ref.watch(sharedUserProvider);
    if (!user.isLoggedIn) return [];

    // 实际项目这里可以从本地缓存加载该用户的购物车
    return [];
  }

  void addItem(String name) {
    state = [...state, CartItem(id: '${state.length + 1}', name: name)];
  }

  void removeItem(String id) {
    state = state.where((item) => item.id != id).toList();
  }

  void clear() {
    state = [];
  }
}

// 子插件内部的完整 Provider(主项目会用它来 override 共享层的计数)
final cartProvider =
    NotifierProvider<CartNotifier, List<CartItem>>(CartNotifier.new);
// packages/feature_cart/lib/cart_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'cart_notifier.dart';

class CartPage extends ConsumerWidget {
  const CartPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:cartProvider 变化(添加/删除/清空商品)
    //    → 整个 CartPage.build() 重跑
    //    → AppBar(件数) + ListView(商品列表) 全部更新
    //    ⚠️ 同时 MainShell 的 cartCount 也会变 → MainShell 也重建(独立的 watch 链路)
    final items = ref.watch(cartProvider);

    return Scaffold(
      appBar: AppBar(
        title: Text('购物车(${items.length} 件)'),
        actions: [
          if (items.isNotEmpty)
            IconButton(
              icon: const Icon(Icons.delete_sweep),
              onPressed: () => ref.read(cartProvider.notifier).clear(),
            ),
        ],
      ),
      body: items.isEmpty
          ? const Center(child: Text('购物车是空的'))
          : ListView.builder(
              itemCount: items.length,
              itemBuilder: (_, i) => ListTile(
                title: Text(items[i].name),
                trailing: IconButton(
                  icon: const Icon(Icons.remove_circle_outline),
                  onPressed: () =>
                      ref.read(cartProvider.notifier).removeItem(items[i].id),
                ),
              ),
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(cartProvider.notifier).addItem('商品 ${items.length + 1}'),
        child: const Icon(Icons.add_shopping_cart),
      ),
    );
  }
}

第四步:主项目 —— 用 overrides 把一切串起来

// lib/auth/auth_providers.dart  (主项目内部的真实登录实现)

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/models/user_info.dart';

class AuthNotifier extends Notifier<UserInfo> {
  @override
  UserInfo build() => UserInfo.empty;

  void login() {
    state = const UserInfo(
      uid: 'u_10086',
      name: '张三',
      avatar: 'https://example.com/avatar.png',
    );
  }

  void logout() {
    state = UserInfo.empty;
  }
}

final authProvider =
    NotifierProvider<AuthNotifier, UserInfo>(AuthNotifier.new);
// lib/main.dart  (核心:ProviderScope overrides 把所有模块连起来)

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 共享层
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/providers/shared_cart_provider.dart';

// 主项目
import 'auth/auth_providers.dart';

// 子插件
import 'package:feature_profile/profile_page.dart';
import 'package:feature_cart/cart_notifier.dart';
import 'package:feature_cart/cart_page.dart';

void main() {
  runApp(
    ProviderScope(
      overrides: [
        // ★ 关键:把共享层的"占位 Provider"指向真实实现

        // 1. 共享用户信息 ← 主项目的 authProvider
        sharedUserProvider.overrideWith((ref) {
          return ref.watch(authProvider);  // auth 变 → 共享 user 变 → 子插件自动刷新
        }),

        // 2. 共享购物车数量 ← 子插件 cart 的 cartProvider
        sharedCartCountProvider.overrideWith((ref) {
          return ref.watch(cartProvider).length;  // cart 变 → 数量变 → profile 自动刷新
        }),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '模块化 Riverpod Demo',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
      home: const MainShell(),
    );
  }
}

class MainShell extends ConsumerWidget {
  const MainShell({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:authProvider / cartProvider / _tabIndexProvider 任一变化
    //    → 整个 MainShell.build() 重跑
    //    → AppBar(用户名/登录按钮) + BottomNavigationBar(购物车 Badge + 选中态) 更新
    //    ⚠️ body 是 const _TabBody(),它是独立的 ConsumerWidget
    //       MainShell 重建时 Flutter 发现 _TabBody 是同一个 const 实例,跳过重建
    //       _TabBody 只在自己 watch 的 _tabIndexProvider 变化时才重建
    final user = ref.watch(authProvider);
    final cartCount = ref.watch(cartProvider).length;

    return Scaffold(
      appBar: AppBar(
        title: Text(user.isLoggedIn ? '你好, ${user.name}' : '未登录'),
        actions: [
          if (user.isLoggedIn)
            IconButton(
              icon: const Icon(Icons.logout),
              onPressed: () => ref.read(authProvider.notifier).logout(),
            )
          else
            IconButton(
              icon: const Icon(Icons.login),
              onPressed: () => ref.read(authProvider.notifier).login(),
            ),
        ],
      ),
      body: const _TabBody(),
      bottomNavigationBar: BottomNavigationBar(
        items: [
          const BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
          BottomNavigationBarItem(
            icon: Badge(
              label: Text('$cartCount'),
              isLabelVisible: cartCount > 0,
              child: const Icon(Icons.shopping_cart),
            ),
            label: '购物车',
          ),
        ],
        onTap: (i) => ref.read(_tabIndexProvider.notifier).state = i,
        currentIndex: ref.watch(_tabIndexProvider),
      ),
    );
  }
}

final _tabIndexProvider = StateProvider<int>((ref) => 0);

class _TabBody extends ConsumerWidget {
  const _TabBody();
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:_tabIndexProvider 变化(切换 tab)
    //    → _TabBody.build() 重跑 → 切换显示 ProfilePage 或 CartPage
    //    ⚠️ authProvider / cartProvider 变化不影响 _TabBody(它不 watch 它们)
    //       但 ProfilePage / CartPage 各自有自己的 watch,会独立刷新
    return switch (ref.watch(_tabIndexProvider)) {
      0 => const ProfilePage(),  // 子插件 A
      1 => const CartPage(),     // 子插件 B
      _ => const SizedBox(),
    };
  }
}

数据流全景图

┌─ 主项目 ──────────────────────────────────────────────────────────────┐
│                                                                       │
│  authProvider (AuthNotifier)                                          │
│       │                                                               │
│       │  override                                                     │
│       ▼                                                               │
│  ┌─ 共享层 ──────────────────────────────────────────────────────┐    │
│  │  sharedUserProvider ◄──── watch ──── CartNotifier.build()     │    │
│  │                                      (用户切换→购物车清空)     │    │
│  │  sharedCartCountProvider ◄── override ── cartProvider.length  │    │
│  └───────────────────────────────────────────────────────────────┘    │
│       │                    │                                          │
│       │ watch              │ watch                                    │
│       ▼                    ▼                                          │
│  ProfilePage          CartPage                                        │
│  (子插件A)            (子插件B)                                       │
│  显示用户名+购物车数   添加/删除商品                                   │
└───────────────────────────────────────────────────────────────────────┘

学到什么

要点 说明
共享层只放接口 core_shared 只有数据模型 + 占位 Provider,不含任何业务逻辑实现
占位 Provider 抛异常 忘了 override 会立即报错,不会悄悄返回错误数据
overrideWith 建立桥梁 主项目在 ProviderScope 把真实实现注入占位 Provider
子插件互不 import profile 不 import cart,cart 不 import profile,但通过共享层间接通信
依赖方向清晰 子插件 → 共享层 ← 主项目;箭头永远指向共享层,不会交叉
用户切换自动连锁 auth 变 → sharedUser 变 → CartNotifier.build 重跑 → 购物车清空 → 数量归零 → profile 页刷新

踩坑点

// ❌ 子插件直接 import 主项目代码 → 循环依赖,无法独立编译
import 'package:my_app/auth/auth_providers.dart'; // 千万别这样

// ✅ 子插件只 import core_shared
import 'package:core_shared/providers/shared_auth_provider.dart';
// ❌ 忘了在 ProviderScope 写 override → 运行时 UnimplementedError 崩溃
ProviderScope(
  overrides: [], // 漏了!
  child: MyApp(),
)

// ✅ 占位 Provider 的报错信息会明确告诉你漏了什么
// ❌ override 里用 read 代替 watch → 后续变化不同步
sharedUserProvider.overrideWith((ref) {
  return ref.read(authProvider); // 只读一次,用户登出后 profile 不会更新!
}),

// ✅ override 里用 watch → 源头变化自动传播到所有下游
sharedUserProvider.overrideWith((ref) {
  return ref.watch(authProvider); // auth 变 → sharedUser 变 → 全链路刷新
}),

Riverpod 3 迁移速查(影响线上行为的 5 条)

如果你的项目要升级到 Riverpod 3.0,以下是会改变运行时行为的变更:

变更 影响 应对
自动重试 失败的 Provider 默认自动重试 非幂等操作需要显式 retry: (...) => null
不可见时暂停 页面不可见时 watch 暂停 需要后台持续监听的场景用 TickerMode(enabled: true) 包裹
统一用 == 过滤 相等的新值不再触发更新 Stream 场景需注意;可 override updateShouldNotify
异常包装 catch 拿到的是 ProviderException 需要 e.exception is XxxError 二次拆包
Legacy import StateProvider 等移到 legacy.dart 新代码用 Notifier,旧代码改 import 路径

总结:一张表选 Provider 类型

你的需求 用什么 场景参照
一个开关/枚举 StateProvider 场景 1
有方法的同步状态 Notifier + NotifierProvider 场景 2
异步只读数据 FutureProvider 场景 3
按参数缓存 .family 场景 4
页面级临时状态 .autoDispose 场景 4
有方法的异步状态 AsyncNotifier 场景 5
多步数据管道 多个 Provider 链式 watch 场景 6
只读派生计算 Provider watch 上游 场景 2 (completedCount)
实时数据流 StreamProvider.autoDispose + ref.onDispose 场景 7
主项目与子插件状态同步 共享层占位 Provider + overrideWith 场景 9

附录:9 个场景的刷新范围总览

一句话原则

ref.watch 写在哪个 Widget 的 build 里,那个 Widget 就是刷新边界。
Provider 变化只会重建 watch 了它的 ConsumerWidget / ConsumerStatefulWidget,不会波及其他。

全景对照表

场景1  isDarkModeProvider 变化
       └─🔄 ThemePage.build()          ← 整个页面重建(Switch + Text)

场景2  todoListProvider 变化
       └─🔄 TodoPage.build()           ← AppBar(计数) + ListView(所有 ListTile) 重建
          └─ completedCountProvider     ← 派生 Provider 跟着自动重算

场景3  userListProvider 状态切换 (loading ↔ data ↔ error)
       └─🔄 UserListPage.build()       ← body 区域三态切换

场景4  userDetailProvider(userId) 状态切换
       └─🔄 UserDetailPage.build()     ← 仅该 userId 的详情页重建
          ⚠️ 其他 userId 的页面不受影响  ← family 的隔离作用

场景5  authProvider 变化 (未登录 → loading → 已登录)
       ├─🔄 _LoginPageState.build()    ← ElevatedButton 切换 loading/可点击
       │  └─ ref.listen → 回调         ← ⚠️ 不触发 build,只执行跳转/SnackBar
       └─🔄 HomePage.build()           ← AppBar 用户名更新

场景6  searchResultsProvider 变化 (防抖 → 请求 → 结果)
       └─🔄 SearchPage.build()         ← Expanded 区域切换
          ⚠️ TextField 不受影响         ← 它通过 ref.read 写入,不 watch

场景7  liveEventProvider (stream 每 2s 推送)
       └─🔄 _LiveEventPageState.build()← Container(状态) + ListView(历史) 更新
          └─ ref.listen + setState      ← 与 watch 合并为同一帧,不双重渲染

场景9  authProvider 变化 (登录/登出)
       ├─🔄 MainShell.build()          ← AppBar(用户名) + BottomNav 更新
       │  └─ const _TabBody()          ← ⚠️ 不被 MainShell 重建波及(const 复用)
       ├─ override 链: auth → sharedUser → CartNotifier.build()
       │  └─🔄 CartPage.build()        ← 购物车清空,列表重建
       │  └─ override 链: cart.length → sharedCartCount
       │     └─🔄 ProfilePage.build()  ← 购物车数字归零
       └─🔄 _TabBody.build()           ← 仅在 _tabIndexProvider 变化时重建

       cartProvider 变化 (添加商品)
       ├─🔄 CartPage.build()           ← 列表更新
       ├─🔄 MainShell.build()          ← Badge 数字更新
       └─🔄 ProfilePage.build()        ← 购物车数字更新
          ⚠️ _TabBody 不受影响          ← 它不 watch cartProvider

如何缩小刷新范围(进阶技巧)

上面每个场景中,ref.watch 都写在 Widget 的 build 最顶层,所以整个 build 树都会重建。
实际项目中可以用以下手段 缩小刷新范围

先理解一个前提build() 重跑 ≠ 整个页面像素级重绘。Flutter 渲染分三层:
Widget 树(build 产出)→ Element 树(框架做新旧 diff)→ RenderObject 树(真正布局和绘制像素)。
const Widget 在 diff 时直接跳过;只有真正内容变了的 RenderObject 才会重新 layout/paint。
所以 build 本身是轻量的,真正昂贵的是 layout 和 paint
下面的技巧目标是:连 build 的调用范围也收窄到最小


技巧 1:Consumer 局部包裹 —— 最常用,改造成本最低

场景:一个商品详情页,只有底部的「购物车数量」需要实时更新,其他部分(图片、标题、描述)都是静态的。

// product_detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 假设已有
final cartProvider = StateProvider<int>((ref) => 0);

// ⚠️ 注意:外层是普通 StatelessWidget,不是 ConsumerWidget
//    → 它自己永远不会因为 Provider 变化而重建
class ProductDetailPage extends StatelessWidget {
  const ProductDetailPage({super.key});

  @override
  Widget build(BuildContext context) {
    print('ProductDetailPage.build()'); // 只在首次进入时打印一次

    return Scaffold(
      appBar: AppBar(title: const Text('AirPods Pro')),
      body: Column(
        children: [
          // ────── 这些全是静态内容,永远不重建 ──────
          const SizedBox(
            height: 200,
            child: Placeholder(), // 模拟商品图片
          ),
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text(
              'Apple AirPods Pro 第二代',
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
          ),
          const Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text('主动降噪 · 自适应通透模式 · 个性化空间音频'),
          ),
          const Divider(height: 32),

          // ────── 只有这一小块会因为 cartProvider 变化而重建 ──────
          Consumer(
            builder: (context, ref, child) {
              print('  Consumer.build()'); // 每次 cart 变化都打印
              // 🔄 刷新范围:仅此 Consumer 内部的 Row
              final count = ref.watch(cartProvider);
              return Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text('购物车: $count 件',
                        style: const TextStyle(fontSize: 18)),
                    FilledButton.icon(
                      onPressed: () =>
                          ref.read(cartProvider.notifier).state++,
                      icon: const Icon(Icons.add_shopping_cart),
                      label: const Text('加入购物车'),
                    ),
                  ],
                ),
              );
            },
          ),

          // ────── 这下面也是静态内容,永远不重建 ──────
          const SizedBox(height: 20),
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text('商品详情介绍...(很长的文字)'),
          ),
        ],
      ),
    );
  }
}

刷新范围图

ProductDetailPage (StatelessWidget)          ← 永远不重建
├── AppBar                                   ← 永远不重建
├── 商品图片 (const)                          ← 永远不重建
├── 商品标题 (const)                          ← 永远不重建
├── 商品描述 (const)                          ← 永远不重建
├── Consumer                                 ← 🔄 仅此块重建
│   └── Row: "购物车: N 件" + 按钮            ← 🔄 count 变了才更新
├── 商品详情 (const)                          ← 永远不重建

学到什么

  • 外层用 StatelessWidget 而不是 ConsumerWidget → 整个页面骨架永远不参与刷新
  • Consumer 是一个普通 Widget,可以塞在树的任意位置,不需要重构整个页面
  • 点击「加入购物车」→ cartProvider 变化 → 只有 Consumer 内部的 Row 重建,图片/标题/描述纹丝不动

技巧 2:select 精确订阅某个字段 —— 大对象只关心一部分时

场景:用户资料有很多字段(头像、昵称、签名、积分、等级...),但某个 Widget 只显示昵称。

// user_profile_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

class UserProfile {
  final String name;
  final String avatar;
  final String bio;
  final int points;
  final int level;

  const UserProfile({
    required this.name,
    required this.avatar,
    required this.bio,
    required this.points,
    required this.level,
  });
}

class UserProfileNotifier extends Notifier<UserProfile> {
  @override
  UserProfile build() => const UserProfile(
        name: '张三',
        avatar: 'https://example.com/avatar.png',
        bio: 'Flutter 开发者',
        points: 1000,
        level: 5,
      );

  void addPoints(int p) {
    state = UserProfile(
      name: state.name,
      avatar: state.avatar,
      bio: state.bio,
      points: state.points + p,
      level: state.level,
    );
  }

  void updateName(String name) {
    state = UserProfile(
      name: name,
      avatar: state.avatar,
      bio: state.bio,
      points: state.points,
      level: state.level,
    );
  }
}

final userProfileProvider =
    NotifierProvider<UserProfileNotifier, UserProfile>(UserProfileNotifier.new);
// select_demo_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_profile_provider.dart';

class SelectDemoPage extends ConsumerWidget {
  const SelectDemoPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ❌ 不用 select:user 的任何字段变化(积分、等级...)都会让这里重建
    // final user = ref.watch(userProfileProvider);

    // ✅ 用 select:只订阅 name 字段
    //    积分变了?不重建。等级变了?不重建。只有 name 变了才重建
    // 🔄 刷新范围:仅当 name 字段的 == 比较结果变化时,才重建 SelectDemoPage
    final name = ref.watch(
      userProfileProvider.select((profile) => profile.name),
    );

    print('SelectDemoPage.build() - name=$name');

    return Scaffold(
      appBar: AppBar(title: Text('你好, $name')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text('当前用户: $name', style: const TextStyle(fontSize: 24)),
            const SizedBox(height: 24),
            // 点这个按钮 → 积分变了 → 但 name 没变 → SelectDemoPage 不重建
            ElevatedButton(
              onPressed: () =>
                  ref.read(userProfileProvider.notifier).addPoints(100),
              child: const Text('加 100 积分(不触发本页重建)'),
            ),
            const SizedBox(height: 12),
            // 点这个按钮 → name 变了 → SelectDemoPage 重建
            ElevatedButton(
              onPressed: () =>
                  ref.read(userProfileProvider.notifier).updateName('李四'),
              child: const Text('改名为李四(触发本页重建)'),
            ),
            const SizedBox(height: 32),
            // 用另一个 Consumer + select 单独显示积分
            Consumer(
              builder: (context, ref, _) {
                // 🔄 刷新范围:仅当 points 变化时,这个 Consumer 重建
                final points = ref.watch(
                  userProfileProvider.select((p) => p.points),
                );
                print('  Points Consumer.build() - points=$points');
                return Text('积分: $points', style: const TextStyle(fontSize: 20));
              },
            ),
          ],
        ),
      ),
    );
  }
}

刷新范围图

用户点击「加 100 积分」→ userProfileProvider 变化(points 字段改了)

SelectDemoPage (select: name)                ← ⚠️ 不重建!name 没变
├── AppBar(title: '你好, 张三')               ← ⚠️ 不重建
├── Text('当前用户: 张三')                     ← ⚠️ 不重建
├── ElevatedButton('加积分')                  ← ⚠️ 不重建
├── ElevatedButton('改名')                    ← ⚠️ 不重建
└── Consumer (select: points)                ← 🔄 重建!points 变了
    └── Text('积分: 1100')                    ← 🔄 更新数字

用户点击「改名为李四」→ userProfileProvider 变化(name 字段改了)

SelectDemoPage (select: name)                ← 🔄 重建!name 变了
├── AppBar(title: '你好, 李四')               ← 🔄 更新
├── Text('当前用户: 李四')                     ← 🔄 更新
└── Consumer (select: points)                ← ⚠️ 不重建!points 没变

学到什么

  • select== 对比投影结果,相等就跳过 → 精确到字段级别的刷新控制
  • 同一个 Provider 可以在不同位置用不同 select,各自只关心自己要的字段
  • 特别适合大 Model 对象(User、Order、Settings...),避免无关字段变化导致的连锁重建

技巧 3:拆分成多个小 ConsumerWidget —— 中大型页面的标准做法

场景:把场景 2 的待办清单拆分,让「AppBar 计数」和「列表内容」各自独立刷新。

// todo_page_optimized.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_notifier.dart';

// ⚠️ 主页面是普通 StatelessWidget,不参与任何 Provider 刷新
class TodoPageOptimized extends StatelessWidget {
  const TodoPageOptimized({super.key});

  @override
  Widget build(BuildContext context) {
    print('TodoPageOptimized.build()'); // 只在首次时打印一次
    return Scaffold(
      appBar: AppBar(
        title: const _TodoAppBarTitle(), // 独立 ConsumerWidget
      ),
      body: const _TodoListBody(),       // 独立 ConsumerWidget
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddDialog(BuildContext context) {
    final controller = TextEditingController();
    showDialog(
      context: context,
      builder: (_) => Consumer(
        builder: (context, ref, _) => AlertDialog(
          title: const Text('添加待办'),
          content: TextField(controller: controller, autofocus: true),
          actions: [
            TextButton(
              onPressed: () {
                if (controller.text.isNotEmpty) {
                  ref.read(todoListProvider.notifier).add(controller.text);
                }
                Navigator.pop(context);
              },
              child: const Text('确定'),
            ),
          ],
        ),
      ),
    );
  }
}

// ────── 拆出来的小组件 1:AppBar 标题 ──────
class _TodoAppBarTitle extends ConsumerWidget {
  const _TodoAppBarTitle();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('  _TodoAppBarTitle.build()');
    // 🔄 刷新范围:仅此 Widget
    //    用 select 只订阅 length 和完成数,列表内容变化但数量不变时不重建
    final total = ref.watch(todoListProvider.select((list) => list.length));
    final done = ref.watch(completedCountProvider);
    return Text('待办 (已完成 $done/$total)');
  }
}

// ────── 拆出来的小组件 2:列表 ──────
class _TodoListBody extends ConsumerWidget {
  const _TodoListBody();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('  _TodoListBody.build()');
    // 🔄 刷新范围:仅此 Widget
    //    列表数据变化时只重建列表,不影响 AppBar
    final todos = ref.watch(todoListProvider);
    return ListView.builder(
      itemCount: todos.length,
      itemBuilder: (_, i) {
        final todo = todos[i];
        return ListTile(
          leading: Checkbox(
            value: todo.completed,
            onChanged: (_) =>
                ref.read(todoListProvider.notifier).toggle(todo.id),
          ),
          title: Text(
            todo.title,
            style: TextStyle(
              decoration: todo.completed ? TextDecoration.lineThrough : null,
            ),
          ),
          trailing: IconButton(
            icon: const Icon(Icons.delete),
            onPressed: () =>
                ref.read(todoListProvider.notifier).remove(todo.id),
          ),
        );
      },
    );
  }
}

刷新范围对比(优化前 vs 优化后)

优化前(场景 2 原始写法):
  todoListProvider 变化
  └─🔄 TodoPage.build()               ← 整页重建(AppBar + ListView + FAB 全跑一遍)

优化后(拆分写法):
  勾选一个待办(toggle)→ todoListProvider 变化
  ├─ TodoPageOptimized.build()         ← ⚠️ 不重建(StatelessWidget,没 watch)
  ├─🔄 _TodoAppBarTitle.build()        ← 🔄 重建(completedCount 变了)
  ├─🔄 _TodoListBody.build()           ← 🔄 重建(列表内容变了)
  └─ FloatingActionButton              ← ⚠️ 不重建(const Icon)

  添加一个待办 → todoListProvider 变化,但没有新完成的
  ├─ TodoPageOptimized.build()         ← ⚠️ 不重建
  ├─🔄 _TodoAppBarTitle.build()        ← 🔄 重建(total 从 23)
  ├─🔄 _TodoListBody.build()           ← 🔄 重建(列表多了一条)
  └─ FloatingActionButton              ← ⚠️ 不重建

学到什么

  • 把一个大 ConsumerWidget 拆成 外壳 StatelessWidget + 多个小 ConsumerWidget
  • 每个小组件只 watch 自己关心的数据 → 不相关的变化不会波及
  • 配合 select 还能进一步收窄(比如 _TodoAppBarTitle 只 select length)
  • 这是中大型项目的标准做法,不是过度优化

三种技巧选择指南
情况 推荐技巧 改造成本
页面大部分是静态的,只有一小块需要动态数据 Consumer 局部包裹 最低,加 3 行代码
一个大对象(User/Order),但只用其中 1-2 个字段 select 精确订阅 低,改一行 watch
页面复杂、多个区域依赖不同 Provider 拆分小 ConsumerWidget 中等,需要提取组件
以上组合 Consumer + select + 拆 Widget 混用 视情况而定

总结:场景 1-9 的示例为了教学清晰,用整个 ConsumerWidget 做页面。
生产项目中应该按上面的技巧 把刷新范围收窄到真正需要更新的部分
build() 重跑不等于像素重绘(Flutter 框架会 diff),但收窄 build 范围仍然是好习惯——
减少不必要的 Widget 实例创建、diff 对比和 GC 压力,尤其在列表页和复杂表单页效果明显。

TDD实战-会议室冲突检测的红绿重构循环

作者 花满溪
2026年5月7日 17:49

本文通过一个真实的"三个会议交叉重叠"场景,完整展示测试驱动开发(TDD)的"红-绿-重构"循环。你将从零开始,亲眼见证测试如何驱动代码设计、暴露边缘情况、最终产出健壮的实现。

第一步:需求分析

业务场景

会议室预约系统允许用户将多个会议分配到同一间会议室。当会议时间发生重叠时,系统需要自动检测冲突并标红。本次要实现的策略是冲突均标红:只要两个会议的时间有重叠,两者都标记为冲突。

聚焦一个场景:三个会议交叉重叠

假设有三个会议,时间如下:

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,但 Meeting 1 和 Meeting 3 不重叠——它们首尾相接(09:00 = 09:00)。

用时间轴线表示如下:

Meeting 1  ████████░░░░░░░░░░░░
Meeting 2  ░░░░████████░░░░░░░░
Meeting 3  ░░░░░░░░░███████████
           08:00    09:00    10:00

测试用例

对应该场景,我们列出了 7 个测试用例,覆盖三种操作类型:

操作类型一:添加会议

用例名称 操作 预期结果
1A 2A 3A => 1x 2x 3x 三个会议依次分配到 A 全部标红

操作类型二:移出会议

用例名称 操作 预期结果
-> 1 null => 2x 3x 移出 1 2、3 冲突,1 正常
-> 2 null => 移出 2 全部正常
-> 3 null => 1x 2x 移出 3 1、2 冲突,3 正常

操作类型三:移动到其他会议室

用例名称 操作 预期结果
-> 1 B => 2x 3x 1 移到 B 2、3 冲突,1 正常
-> 2 B => 2 移到 B 全部正常
-> 3 B => 1x 2x 3 移到 B 1、2 冲突,3 正常

关键洞察: "移出 2"这个用例——当"桥梁"会议被移除后,1 和 3 不重叠,冲突全部消失。这是整个场景中最关键的设计洞察。


第二步:搭建测试环境

首先安装 Vitest:

pnpm add -D vitest

package.json 中添加测试脚本:

{
  "scripts": {
    "test": "vitest",
  }
}

第三步:红——编写第一个失败的测试

TDD 的第一步是:先写一个会失败的测试,明确描述你期望的最小行为。

我们在 test/three-cross.spec.js 中开始:

import { describe, it, expect } from 'vitest'
import { handleRoomChange } from '../index'

describe('三个会议交叉重叠 - 冲突均标红', () => {

    it('无会议时,添加第一个会议应该无冲突', () => {
        const meeting1 = {
            id: 1, name: 'Meeting 1',
            start: '2021-01-01 08:00:00', end: '2021-01-01 09:00:00',
            date: '2021-01-01', isConflict: false, roomId: null
        }

        meeting1.roomId = 'A'
        handleRoomChange(meeting1)

        expect(meeting1.isConflict).toBe(false)
    })
})

运行测试:

pnpm test

输出:

 FAIL  test/three-cross.spec.js
  TypeError: handleRoomChange is not a function

测试失败,符合预期——因为 handleRoomChange 还不存在。这正是"红"阶段:我们需要创建这个模块。


第四步:绿——编写最小化代码通过测试

创建一个最简的方法,让它刚好通过当前测试:

// index.js
  handleRoomChange(meeting: Meeting): void {
    meeting.isConflict = false
  }

这个实现很简单——它无条件地把 isConflict 设为 false最小化代码意味着用最少的工作让当前测试变绿

运行测试:

✓ 三个会议交叉重叠 - 冲突均标红
  ✓ 无会议时,添加第一个会议应该无冲突

绿了。


第五步:红——添加第二个测试,暴露新行为

现在写第二个测试——添加两个时间重叠的会议,两者都应被标记为冲突:

// 在同一个 describe 块中添加
it('两个重叠的会议应都被标记为冲突', () => {
    const meeting1 = {
        id: 1, name: 'Meeting 1',
        start: '2021-01-01 08:00:00', end: '2021-01-01 09:00:00',
        date: '2021-01-01', isConflict: false, roomId: null
    }
    const meeting2 = {
        id: 2, name: 'Meeting 2',
        start: '2021-01-01 08:30:00', end: '2021-01-01 09:30:00',
        date: '2021-01-01', isConflict: false, roomId: null
    }

    meeting1.roomId = 'A'
    handleRoomChange(meeting1)
    meeting2.roomId = 'A'
    handleRoomChange(meeting2)

    expect(meeting1.isConflict).toBe(true)
    expect(meeting2.isConflict).toBe(true)
})

运行:

 FAIL  test/three-cross.spec.ts
  ✓ 无会议时,添加第一个会议应该无冲突
  ✗ 两个重叠的会议应都被标记为冲突
    AssertionError: expected false to be true

红。 现在的代码把所有会议都设为 false,显然不对。


第六步:绿——让第二个测试通过

现在需要真正实现冲突检测逻辑了。但我们仍然只做刚好够用的代码:

import dayjs from 'dayjs'

let meetingsMap = {}

function hasConflict(m1, m2) {
    return dayjs(m1.end).isAfter(dayjs(m2.start))
        && dayjs(m2.end).isAfter(dayjs(m1.start))
}
export function handleRoomChange(meeting) {
    const { date, roomId } = meeting;
    if (!meetingsMap[date]) meetingsMap[date] = {}
    if (!meetingsMap[date][roomId]) meetingsMap[date][roomId] = []
    const roomMeetings = meetingsMap[date][roomId]
    roomMeetings.push(meeting)

    // 暴力检测所有会议两两之间的冲突
    for (let i = 0; i < roomMeetings.length; i++) {
        for (let j = i + 1; j < roomMeetings.length; j++) {
            if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                roomMeetings[i].isConflict = true
                roomMeetings[j].isConflict = true
            }
        }
    }
}

关键设计决策分析:

  1. 为什么用 meetingsMap 因为需要按日期和会议室维度持久化会议列表,才能做两两比较。
  2. 为什么用 dayjs 时间比较需要精确到秒,用字符串比较不可靠,dayjs 是项目已有的日期库。
  3. 冲突条件m1 未结束 && m2 已开始——经典的区间重叠判断。
  4. 嵌套循环:最简单直观的两两比较方式,O(n²) 复杂度,但对于一个会议室通常只有几个会议的场景完全够用。

运行测试:

✓ 无会议时,添加第一个会议应该无冲突
✓ 两个重叠的会议应都被标记为冲突

两个测试都通过了。


第七步:红——用核心场景驱动设计

现在到了我们真正关心的场景:三个会议交叉重叠。先标记所有会议:

it('1 A 2 A 3 A => 1 x 2 x 3 x', () => {
    const meetings = [
        { id: 1, name: 'Meeting 1', start: '2021-01-01 08:00', end: '2021-01-01 09:00',
          date: '2021-01-01', isConflict: false, roomId: null },
        { id: 2, name: 'Meeting 2', start: '2021-01-01 08:30', end: '2021-01-01 09:30',
          date: '2021-01-01', isConflict: false, roomId: null },
        { id: 3, name: 'Meeting 3', start: '2021-01-01 09:00', end: '2021-01-01 10:00',
          date: '2021-01-01', isConflict: false, roomId: null },
    ]

    meetings.forEach(m => { m.roomId = 'A'; 
    handleRoomChange(m)
    })

    expect(meetings[0].isConflict).toBe(true)  // 1-2 重叠
    expect(meetings[1].isConflict).toBe(true)  // 2-3 重叠
    expect(meetings[2].isConflict).toBe(true)  // 2-3 重叠
})

运行测试,通过了!因为嵌套循环已经处理了所有两两关系。

现在加上关键用例——移出会议

it('1 A 2 A 3 A -> 2 null =>', () => {
        const meetings = [
            {
                id: 1, name: 'Meeting 1', start: '2021-01-01 08:00', end: '2021-01-01 09:00',
                date: '2021-01-01', isConflict: false, roomId: null
            },
            {
                id: 2, name: 'Meeting 2', start: '2021-01-01 08:30', end: '2021-01-01 09:30',
                date: '2021-01-01', isConflict: false, roomId: null
            },
            {
                id: 3, name: 'Meeting 3', start: '2021-01-01 09:00', end: '2021-01-01 10:00',
                date: '2021-01-01', isConflict: false, roomId: null
            },
        ]

        meetings.forEach(m => { m.roomId = 'A'; handleRoomChange(m) })

        // 移出 Meeting 2
        meetings[1].prevRoomId = meetings[1].roomId
        meetings[1].roomId = null
        handleRoomChange(meetings[1])

        expect(meetings[0].isConflict).toBe(false)  // 1 和 3 不重叠
        expect(meetings[1].isConflict).toBe(false)  // 已移出
        expect(meetings[2].isConflict).toBe(false)  // 3 和 1 不重叠
    })

运行:

 FAIL  ✗ 1 A 2 A 3 A -> 2 null =>
  AssertionError: expected true to be false

红! 问题在于:当我们移出 Meeting 2 时,之前的 handleRoomChange 只会往列表里加会议,从没考虑过移除。Meeting 1 的 isConflict 仍然停留在 true,没有被重新计算。

现在,移出需求驱动我们设计一个新的能力。


第八步:绿——实现移除和重算

我们需要区分"添加"和"移除"两种情况。设计 handleRoomChange 的分支逻辑:

export function clearMap() {
    meetingsMap = {}
}

export function handleRoomChange(meeting) {
    const { prevRoomId, roomId, date } = meeting

    // 先从旧会议室移除
    if (prevRoomId) {
        if (!meetingsMap[date]) meetingsMap[date] = {}
        if (!meetingsMap[date][prevRoomId]) meetingsMap[date][prevRoomId] = []
        const roomMeetings = meetingsMap[date][prevRoomId]
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index !== -1) roomMeetings.splice(index, 1)

        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    }

    // 再添加到新会议室
    if (roomId) {
        if (!meetingsMap[date]) meetingsMap[date] = {}
        if (!meetingsMap[date][roomId]) meetingsMap[date][roomId] = []
        const roomMeetings = meetingsMap[date][roomId]
        roomMeetings.push(meeting)

        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    } else {
        meeting.isConflict = false
    }
}

这个步骤:

  • 每个测试用例都需要将 meetingsMap 重置。
  • 每次添加或移除会议后,都重新计算整个会议室的状态。虽然看起来做了重复计算,但胜在简单且正确——会议室里的会议数量极少,性能不是问题,正确性才是。

运行测试:

✓ 两个重叠的会议应都被标记为冲突
✓ 1 A 2 A 3 A => 1 x 2 x 3 x
✓ 1 A 2 A 3 A -> 2 null =>

所有测试通过。


第九步:红——继续添加剩余场景

测完核心逻辑,快速补全剩余用例:

it('1 A 2 A 3 A -> 1 null => 2 x 3 x', () => {
    // 移出 1 → 2 和 3 仍然冲突
    ...
    expect(meetings[0].isConflict).toBe(false)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(true)
})

it('1 A 2 A 3 A -> 3 null => 1 x 2 x', () => {
    // 移出 3 → 1 和 2 仍然冲突
    ...
    expect(meetings[0].isConflict).toBe(true)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(false)
})

it('1 A 2 A 3 A -> 1 B => 2 x 3 x', () => {
    // 1 移到 B → A 会议室只剩 2 和 3,它们冲突
    // 1 在 B 会议室只有自己,无冲突
    ...
    expect(meetings[0].isConflict).toBe(false)
    expect(meetings[1].isConflict).toBe(true)
    expect(meetings[2].isConflict).toBe(true)
})

由于 handleRoomChange 已经同时处理了移除和添加,这些用例都直接通过:

1 A 2 A 3 A -> 1 null => 2 x 3 x
✓ 1 A 2 A 3 A -> 2 null =>
✓ 1 A 2 A 3 A -> 3 null => 1 x 2 x
✓ 1 A 2 A 3 A -> 1 B => 2 x 3 x
✓ 1 A 2 A 3 A -> 2 B =>
✓ 1 A 2 A 3 A -> 3 B => 1 x 2 x

7 个测试,全部通过。


第十步:重构——在安全网下优化代码

测试全部通过后,我们站在一个安全的位置审视代码。

问题 1:面向过程的代码重构

import dayjs from 'dayjs'

export default class AllConflictManager {
    meetingsMap = {}

    clear() {
        this.meetingsMap = {}
    }

    handleRoomChange(meeting) {
        const { prevRoomId, roomId } = meeting
        if (prevRoomId) {
            this.removeMeetingFromRoom(meeting, prevRoomId)
        }
        if (roomId) {
            this.addMeetingToRoom(meeting, roomId)
        } else {
            meeting.isConflict = false
        }
    }

    addMeetingToRoom(meeting, roomId) {
        const { date } = meeting
        const roomMeetings = this.getRoomMeetings(date, roomId)
        roomMeetings.push(meeting)
        const conflictIds = this.findAllConflicts(roomMeetings)
        this.updateConflictStatus(roomMeetings, conflictIds)
    }

    getRoomMeetings(date, roomId) {
        if (!this.meetingsMap[date]) this.meetingsMap[date] = {}
        if (!this.meetingsMap[date][roomId]) this.meetingsMap[date][roomId] = []
        return this.meetingsMap[date][roomId]
    }

    removeMeetingFromRoom(meeting, roomId) {
        const { date } = meeting
        const roomMeetings = this.getRoomMeetings(date, roomId)
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index !== -1) roomMeetings.splice(index, 1)
        const conflictIds = this.findAllConflicts(roomMeetings)
        this.updateConflictStatus(roomMeetings, conflictIds)
    }

    findAllConflicts(roomMeetings) {
        const conflictIds = new Set()
        for (let i = 0; i < roomMeetings.length; i++) {
            for (let j = i + 1; j < roomMeetings.length; j++) {
                if (this.hasConflict(roomMeetings[i], roomMeetings[j])) {
                    conflictIds.add(roomMeetings[i].id)
                    conflictIds.add(roomMeetings[j].id)
                }
            }
        }
        return conflictIds
    }

    updateConflictStatus(roomMeetings, conflictIds) {
        roomMeetings.forEach(m => {
            m.isConflict = conflictIds.has(m.id)
        })
    }

    hasConflict(m1, m2) {
        return dayjs(m1.end).isAfter(dayjs(m2.start)) && dayjs(m2.end).isAfter(dayjs(m1.start))
    }
}

问题 2:测试中重复的测试数据

每个测试用例都重复定义了三个 meeting 对象。提取到公共数据:

// data/three-cross.json
[
    { "id": 1, "name": "Meeting 1", "start": "2021-01-01 08:00", "end": "2021-01-01 09:00",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null },
    { "id": 2, "name": "Meeting 2", "start": "2021-01-01 08:30", "end": "2021-01-01 09:30",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null },
    { "id": 3, "name": "Meeting 3", "start": "2021-01-01 09:00", "end": "2021-01-01 10:00",
      "date": "2021-01-01", "isConflict": false, "roomId": null, "prevRoomId": null }
]

问题 3:Manager 实例创建和重置重复

提取到 test-utils.js

import AllConflictManager from '../index'

const manager = new AllConflictManager()

export function resetMeetings(meetings) {
    meetings.forEach(m => {
        m.isConflict = false
        m.prevRoomId = null
        m.roomId = null
    })
    manager.clear()
}

export function assignToRoom(meeting, roomId) {
    meeting.roomId = roomId
    manager.handleRoomChange(meeting)
}

export function removeMeetingFromRoom(meeting) {
    meeting.prevRoomId = meeting.roomId
    meeting.roomId = null
    manager.handleRoomChange(meeting)
}

export function moveMeeting(meeting, newRoomId) {
    meeting.prevRoomId = meeting.roomId
    meeting.roomId = newRoomId
    manager.handleRoomChange(meeting)
}

问题 4:测试代码精简

重构后的测试文件干净很多:

import { describe, it, expect, beforeEach } from 'vitest'
import { resetMeetings, assignToRoom, removeMeetingFromRoom, moveMeeting } from './test-utils'
import meetings from '../data/three-cross.json';

describe('三个会议交叉重叠 - 冲突均标红', () => {
    beforeEach(() => {
        resetMeetings(meetings)
        assignToRoom(meetings[0], 'A')
        assignToRoom(meetings[1], 'A')
        assignToRoom(meetings[2], 'A')
    })

    it('1 A 2 A 3 A => 1 x 2 x 3 x', () => {
        expect(meetings[0].isConflict).toBe(true)
        expect(meetings[1].isConflict).toBe(true)
        expect(meetings[2].isConflict).toBe(true)
    })
    // ...其余用例
})

重构后运行测试:

 Test Files  1 passed (1)
      Tests  7 passed (7)

全部通过。 我们可以在安全网的保护下自信地说:重构没有破坏任何功能。


最终代码全景

源码骨架

AllConflictManager
├── meetingsMap            ← 按 date → roomId 的二维存储
├── clear()                ← 重置状态
├── handleRoomChange()     ← 入口:先移除旧房间,再添加新房间
├── addMeetingToRoom()     ← 添加 + 重算冲突
├── removeMeetingFromRoom()← 移除 + 重算冲突
├── findAllConflicts()     ← O(n²) 两两比较
├── updateConflictStatus() ← 批量更新标记
└── hasConflict()          ← 区间重叠判断

测试覆盖

7 个用例覆盖 3 类操作:
  ┌─ 添加会议(1 个)
  ├─ 移出会议(3 个)
  └─ 移动会议(3 个)

回顾:TDD 如何驱动了设计

让我们回顾整个过程中,测试是如何驱动设计决策的:

测试驱动了接口设计

第一个测试只验证"添加无冲突会议",导致最初实现只是一个空壳方法。当第二个测试需要"检测重叠"时,才被迫引入 meetingsMap 存储和 hasConflict 方法。

测试驱动了移除逻辑

移出会议的测试迫使我们设计 handleRoomChange 的分支结构(先移除旧房间,再添加新房间)。如果只是按直觉写"添加"逻辑,永远不会考虑到移除后的状态重算。有了移除逻辑后,发现测试用例仍无法通过,meetingsMap 存储需重置。

测试驱动了冲突重算策略

当移出 Meeting 2 后,Meeting 1 的 isConflict 仍然为 true——这个失败迫使我们意识到:每次状态变更后都需要完整重算,而不是增量更新recalculateConflicts 方法从这里诞生。

测试驱动了数据抽取

测试代码中的重复数据——7 个用例写了 21 个 meeting 对象——驱动我们将测试数据提到 JSON 文件。这既是重构,也是一种设计信号:测试数据应该与测试逻辑分离。


总结:TDD 的节奏感

回顾完整的红-绿-重构循环:

RED (写测试)      →  1. 定义行为期望
                     2. 确认当前代码做不到
                     ↓
GREEN (写代码)    →  3. 用最直接的方式让测试通过
                     4. 确信结果正确
                     ↓
REFACTOR (优化)   →  5. 在测试保护下改进代码质量
                     6. 测试依然通过
                     ↓
(回到第 1 步,覆盖下一个场景)

这个循环的核心价值在于节奏感:每一步都有明确的目标,每一步的结果都可以立即验证。没有模糊区间,没有"感觉应该没问题"——只有绿色的通过和红色的失败。

当你习惯了这个节奏后,你会发现编码的过程不再是"写完再看",而是一个持续获得正向反馈的过程。每个测试从红变绿的那一刻,都在告诉你:你又推进了一步。

你不需要一次设计出完美的架构。你只需要写一个会失败的测试,然后让它通过。然后重复。最终,好的设计会自己浮现出来。

测试用例

两个重叠的会议

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 10:00
时间关系:Meeting 1 和 Meeting 2 互相重叠
用例名称 操作步骤 预期结果
1 A 2 A => 2 x Meeting 1 分配到 A,Meeting 2 分配到 A Meeting 1 正常,Meeting 2 冲突
1 A 2 A -> 1 null => 上述基础上,Meeting 1 移出 两个会议都正常
1 A 2 A -> 1 B => 上述基础上,Meeting 1 移到 B 两个会议都正常
1 A 2 A -> 2 null => 上述基础上,Meeting 2 移出 两个会议都正常
1 A 2 A -> 2 B => 上述基础上,Meeting 2 移到 B 两个会议都正常

三个全重叠的会议

会议 开始时间 结束时间
Meeting 1 08:00 09:30
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00
时间关系:三个会议两两之间都互相重叠
用例名称 操作步骤 预期结果
1 A 2 A 3 A => 1 x 2 x 3 x 初始状态 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 x Meeting 1 移出 Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => 1x 3 x Meeting 2 移出 Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 x 3 x Meeting 1 移到 B Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => 1 x 3 x Meeting 2 移到 B Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常

三个会议 - 交叉重叠

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,Meeting 1 与 Meeting 3 不重叠(首尾相接)

用例名称 操作步骤 预期结果
1 A 2 A 3 A => 1 x 2 x 3 x 初始状态 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 x Meeting 1 移出 Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => Meeting 2 移出 三个会议都正常
1 A 2 A 3 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 x 3 x Meeting 1 移到 B Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => Meeting 2 移到 B 三个会议都正常
1 A 2 A 3 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 1 冲突,Meeting 2 冲突,Meeting 3 正常

四个会议 - 链式重叠

会议 开始时间 结束时间
Meeting 1 08:00 09:00
Meeting 2 08:30 09:30
Meeting 3 09:00 10:00
Meeting 4 09:30 10:30

时间关系:链式重叠,1-2 重叠,2-3 重叠,3-4 重叠,1-3、1-4、2-4 不重叠

用例名称 操作步骤 预期结果
1 A 2 A 3 A 4 A => 1 x 2 x 3 x 4 x 初始状态 Meeting 1、 2、3、4 冲突
1 A 2 A 3 A 4 A -> 1 null => 2 x 3 x 4 x Meeting 1 移出 Meeting 1 正常,Meeting 2、3 冲突,Meeting 4 正常
1 A 2 A 3 A 4 A -> 2 null => 3 x 4 x Meeting 2 移出 Meeting 1、2 正常,Meeting 3、4冲突
1 A 2 A 3 A 4 A -> 3 null => 1 x 2 x Meeting 3 移出 Meeting 3、4 正常,Meeting 1、2 冲突
1 A 2 A 3 A 4 A -> 4 null => 1 x 2 x 3 x Meeting 4 移出 Meeting 4 正常,Meeting 1、2、3 冲突
1 A 2 A 3 A 4 A -> 1 B => 2 x 3 x 4 x Meeting 1 移到 B Meeting 1 正常,Meeting 2、3、4 冲突
1 A 2 A 3 A 4 A -> 2 B => 3 x 4 x Meeting 2 移到 B Meeting 1、2 正常,Meeting 3、4 冲突
1 A 2 A 3 A 4 A -> 3 B => 1 x 2 x Meeting 3 移到 B Meeting 3、4 正常,Meeting 1、2 冲突
1 A 2 A 3 A 4 A -> 4 B => 1 x 2 x 3 x Meeting 4 移到 B Meeting4 正常,Meeting 1、2、3 冲突

耗时 2 小时!我复刻了全网超火的通透 3D 水晶球动效,Vue3+Three.js 做出高级视觉特效

作者 李剑一
2026年5月7日 16:31

之前做的3D文本效果# 别再写死静态文字了!Vue3+Three.js 实现超酷3D流光旋转文字科技感拉满!【附完整源码】有兄弟表示效果太单一,其实那个效果能做的非常炫酷。

核心就是材质上要把握好,重点其实不在技术,而在调色

简单搞一个3D的水晶球弹跳效果,具备一定的物理效果。

QQ20260507-162509.gif

需求实现

球体效果相对比较简单,直接使用SphereGeometry创建即可。

主要是重力弹跳的物理效果实现起来比较麻烦,落地回弹的效果通过控制球体高度的正负实现。

另外就是阴影部分,阴影跟随球体的高度进行变化。

最后就是旋转视角和窗口自适应,还是用OrbitControlswindow.resize实现。

物体实现代码

灯光部分拆分成三个部分:环境光、平行光、点光源。

function addLights() {
    // 环境光:照亮整个场景,无阴影
    const ambient = new THREE.AmbientLight(0xffffff, 0.4)
    scene.add(ambient)

    // 平行光:太阳光效果,能投射阴影
    const dirLight = new THREE.DirectionalLight(0xffffff, 1.3)
    dirLight.position.set(4, 6, 3)    // 光源位置
    dirLight.castShadow = true        // 开启投影
    dirLight.shadow.mapSize.set(2048, 2048) // 阴影清晰度
    dirLight.shadow.radius = 10       // 阴影柔和度
    scene.add(dirLight)

    // 蓝色氛围点光源
    const blueLight = new THREE.PointLight(0x639bff, 1.6, 12)
    blueLight.position.set(-5, 3, 4)
    scene.add(blueLight)

    // 紫色氛围点光源
    const purpleLight = new THREE.PointLight(0xc563ff, 1.4, 12)
    purpleLight.position.set(5, -1, 4)
    scene.add(purpleLight)
}

但是只有光,没有地面无法正常显示物体在光下的投影,所以需要增加地面。

function createGround() {
    const geo = new THREE.PlaneGeometry(25, 25) // 地面大小
    const mat = new THREE.MeshStandardMaterial({
        color: 0x1e2238,    // 地面颜色
        roughness: 0.75,    // 粗糙度
        metalness: 0.05    // 金属感
    })
    ground = new THREE.Mesh(geo, mat)
    ground.rotation.x = -Math.PI / 2  // 平放地面
    ground.position.y = groundHeight  // 地面位置
    ground.receiveShadow = true       // 允许接收阴影
    scene.add(ground)
}

除此之外就是创建水晶球了,这部分直接使用SphereGeometry创建球体。

然后用MeshPhysicalMaterial增加相应的材质即可。

function createCrystalBall() {
    // 球体几何体(半径,横向分段,纵向分段)
    const geo = new THREE.SphereGeometry(1, 128, 128)

    // 水晶/玻璃材质(超通透质感)
    const mat = new THREE.MeshPhysicalMaterial({
        color: 0xffffff,        // 基础颜色
        transparent: true,      // 开启透明
        transmission: 0.95,     // 透光率(1=完全透明)
        opacity: 1,            // 不透明度
        roughness: 0,          // 表面光滑度(0=镜面)
        metalness: 0,          // 金属感
        ior: 1.58,             // 折射率(水晶标准值)
        thickness: 1.8,        // 物体厚度
        envMapIntensity: 2.2,  // 环境光反射强度
        clearcoat: 1,          // 表面清漆(更亮)
        clearcoatRoughness: 0  // 清漆光滑度
    })

    ball = new THREE.Mesh(geo, mat)
    ball.castShadow = true    // 允许投射阴影
    ball.receiveShadow = true // 允许接收阴影
    ball.position.y = 3       // 初始高度(从空中落下)
    scene.add(ball)
}

物理弹跳效果

核心代码是通过控制小球的高度变化,实现弹跳效果。

通过控制其下落的速度不断增加,实现重力加速效果。

落地碰撞主要是通过检测小球高度和地面高度,当相等的时候,就认为已经落地了。

unction updateBallBounce() {
    // 1. 重力效果:速度不断增加
    velocity += gravity

    // 2. 根据速度更新小球 Y 轴位置
    ball.position.y += velocity

    // 3. 落地碰撞检测 + 回弹
    // 小球最低位置 = 地面高度 + 小球半径(防止陷进地里)
    const minHeight = groundHeight + ballRadius
    if (ball.position.y <= minHeight) {
        ball.position.y = minHeight // 重置位置
        velocity = -velocity * bounce // 反向速度 × 弹性(回弹)
    }

    // 4. 小球自动旋转
    ball.rotation.y += 0.005

    // 5. 阴影同步效果:
    // 跳得越高 → 越小越淡
    // 离地面越近 → 越大越浓
    const height = ball.position.y - minHeight
    const shadowScale = THREE.MathUtils.clamp(1.2 - height * 0.15, 0.5, 1.2)
    ball.scale.set(shadowScale, shadowScale, shadowScale)
}

总结

其实场景的搭建非常简单,鼠标操作也是用的封装好的东西。

重点是在物理弹跳效果上,通过控制速度,间接控制小球的高度。

以及小球高度与地面高度的对比实现落地反弹效果。

还有就是模拟真实世界的重力衰减效果,随着不断地反弹落地,最终小球高度完全等于地面高度。

pnpm 11.0 正式登场:安装起飞、安全拉满、彻底告别 npm 依赖

作者 前端Hardy
2026年5月7日 13:33

等了大半年,pnpm 11.0 终于正式发布了!

这一版根本不是小修小补,而是从底层到体验全重构

抛弃旧 JSON 索引、换上 SQLite、安装速度再上台阶;

默认加强供应链安全,新发包强制等 1 天再允许安装;

自带原生 publish,不再调用 npm CLI;

甚至连全局安装都彻底隔离,互不干扰……

再加上官方透露:Rust 重写的 Pacquet 已经重启开发。 很明显:pnpm 在下一盘大棋。

今天这篇,用最实在、最不绕弯的话,告诉你 pnpm 11 到底强在哪、升级要注意什么,看完你就知道该不该冲。

一、最狠升级:Store v11 + SQLite,安装真的快疯了

以前 pnpm 快,是因为硬链接、软链接玩得溜。 这次 v11 直接把存储引擎换了

  • 抛弃以前每个包一个 JSON 的索引
  • 换成单一 SQLite 数据库:index.db
  • 包信息、摘要、哈希直接存在库里
  • 减少大量系统调用、文件读取、重复解析

结果就是: 冷安装更快、热安装更快、monorepo 更快。 尤其是大项目、多包仓库,差距肉眼可见。

而且 95% 的包能跨 Node 版本、跨架构复用, 升级 Node 不用重新下一遍依赖。

二、安全默认更强:新包必须等 1 天才能装

pnpm 11 直接把供应链安全做成默认

  • minimumReleaseAge: 1440(默认 1 天)
  • blockExoticSubdeps: true(拦截怪异依赖)

意思很简单: 刚发布的包,必须等 24 小时才能被你安装。

防止 you-know-what 式投毒、抢发恶意包。 不想等也能关,但大多数公司和团队都会直接开着。

三、终于摆脱 npm:publish/login 全原生实现

以前 pnpm publish 本质是调用 npm CLI。 现在 全部重写原生实现,不依赖 npm 了:

  • publish
  • login / logout
  • view / deprecate / unpublish
  • dist-tag / version

包括 OTP 验证、二维码登录、交互提示全都自带。

环境变量也从 NPM_CONFIG_OTP 换成 PNPM_CONFIG_OTP, 更干净、更稳定、更少玄学报错。

四、全局安装彻底隔离:再也不会互相炸环境

这是老 pnpm 用户怨念最深的点: 全局包会互相冲突、版本打架、bin 错乱。

pnpm 11 直接解决:

  • 每个 pnpm add -g 都是独立目录
  • 自带独立 package.json、node_modules、lockfile
  • 互不干扰,不 hoist,不共享依赖
  • 全局 bin 统一放到 PNPM_HOME/bin

以后全局装什么都不怕炸环境了。

五、超实用新功能:pack-app / sbom / ci / clean

pnpm pack-app

把 Node 项目直接打成单可执行文件(SEA), 不用配置、不用插件,一行打包分发。

pnpm sbom

一键生成软件物料清单:CycloneDX / SPDX, 公司合规、安全扫描直接满足。

pnpm ci + pnpm clean

  • pnpm clean:一键删所有项目 node_modules
  • pnpm ci:clean + frozen-lockfile 一体

CI 脚本直接少写三行。

pnpm peers check

peerDependencies 问题一键检查, 不用再盯着红色警告瞎猜。

六、必须注意:破坏性变化(升级前看这一段就够)

  1. 要求 Node.js ≥22 不支持 18/19/20/21,CI 要先升 Node。

  2. 配置大改

    • .npmrc 只留认证和 registry
    • 其他配置搬到 pnpm-workspace.yaml 或全局 config.yaml
    • 环境变量从 npm_config_*pnpm_config_*
  3. 构建配置统一 旧的 onlyBuiltDependencies / neverBuiltDependencies 全部删掉 换成 allowBuilds

    allowBuilds:
      electron: true
      core-js: false
    
  4. audit 从 CVE 换成 GHSA ignoreCvesignoreGhsas

  5. 升级完必须执行

    pnpm setup
    

七、重磅消息:Rust 重写的 Pacquet 重启了

文章最后官方放了个大料: 基于 Rust 重构的 Pacquet 项目重新启动开发。

意味着不远的将来: pnpm 将拥有 Rust 级别的速度、内存占用、启动性能。 现在的 pnpm 11 只是前奏。

总结一句实在话

pnpm 11.0 不是“新版本”, 是下一代包管理器的正式定型

  • 更快(SQLite)
  • 更安全(默认供应链防护)
  • 更干净(原生 publish、纯 ESM)
  • 更稳(全局隔离、配置规范化)
  • 未来更强(Rust 重写归来)

老用户建议直接升; 还在用 npm / yarn 的,真的可以试试了。

快速升级

npm install -g pnpm@latest

升级完跑一遍:

pnpm setup

官方地址:pnpm.io/zh/blog/rel…

你项目现在用的是 pnpm 几?最期待 SQLite 加速还是全局隔离?评论区说一句


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

成为AI全栈 - 第4课:Drizzle ORM SQLite Elysia 数据库实战

作者 铁皮饭盒
2026年5月8日 16:41

从今天开始, 你是架构师,学会关键词就行,代码让AI实现😁


数据库就像持久化的 Excel,ORM 让你用代码操作它


今天你会学到这些关键词

| 关键词 | 一句话解释 | | :-- | :-- | | Drizzle ORM | TypeScript 友好的 ORM,用代码操作数据库 | | SQLite | 轻量级文件数据库,无需安装 | | Elysia | 高性能 Web 框架,链式 API 设计 | | 参数化查询 | 防止 SQL 注入的安全查询方式 |

一句话总结:用 Drizzle ORM + 参数化查询操作 SQLite,让 Elysia 服务拥有真正的数据持久化能力。


上节课回顾

上节课我们用 Elysia 实现了用户管理 API:

GET /users      → 查询所有用户
GET /users/:id  → 查询单个用户
POST /users     → 创建用户
PUT /users/:id  → 更新用户
DELETE /users/:id → 删除用户

但数据存在内存里:

const users = new Map();

问题:

  • • 服务重启,数据丢失

  • • 无法多服务共享数据

  • • 不能复杂查询

解决方案:使用数据库。


数据库是什么?

一句话:持久化存储数据的地方。

内存存储(Map/数组)    数据库(SQLite/MySQL)
─────────────────────────────────────────────
• 服务重启数据丢失      • 数据持久保存
• 存在内存里            • 存在硬盘上
• 简单快速              • 功能强大,支持复杂查询

类比:

  • • 内存存储 = 草稿纸,写完就扔

  • • 数据库 = 笔记本,永久保存


为什么选择 SQLite 学习数据库?

SQLite 的适用场景:

| 场景 | 是否适合 SQLite | | :-- | :-- | | 学习数据库基础 | ✅ 完美 | | 开发测试环境 | ✅ 快速搭建 | | 小型应用(用户 < 10万) | ✅ 推荐 | | 独立桌面/移动应用 | ✅ 推荐 | | 高并发写入(> 1000 QPS) | ❌ 不推荐 | | 多进程同时写入 | ❌ 不推荐 | | 需要网络访问 | ❌ 不推荐 |

为什么用 SQLite 学习 SQL?

传统数据库(MySQL/PostgreSQL)    SQLite
─────────────────────────────────────────────
• 需要安装数据库服务            • 零配置,零安装
• 需要启动数据库服务            • 直接打开文件就能用
• 配置复杂(用户、权限、端口)    • 就是一个 .db 文件
• 占用系统资源多                • 占用资源极少
• 命令行工具需要单独学习         • 直接用 AI 生成 SQL

SQLite 学习路线:

第4课(SQLite)→ 理解数据库和 ORM 基础
     ↓
第8课(PostgreSQL)→ Docker 部署生产级数据库
     ↓
第15课(MySQL)→ Java Spring Boot 生产环境

一句话:先用 SQLite 快速入门,等项目大了再换 PostgreSQL。


SQL 是什么?

SQL 是操作数据库的语言。

-- 查询所有用户
SELECT * FROM users;

-- 创建新用户
INSERT INTO users (name, email) VALUES ('张三''zs@example.com');

-- 更新用户
UPDATE users SET name = '李四' WHERE id = 1;

-- 删除用户
DELETE FROM users WHERE id = 1;

但我们不需要手写 SQL。


ORM 是什么?

ORM = 用代码操作数据库,不用写 SQL。

// 不用写 SQL,用代码操作
await db.insert(users).values({ name: "张三", email: "zs@example.com" });

// 自动转成:INSERT INTO users (name, email) VALUES (?, ?)

好处:

  • • ✅ 不用记 SQL 语法

  • • ✅ 类型安全,IDE 有提示

  • • ✅ 代码更易维护


Drizzle ORM 简介

Drizzle 是一个轻量级、类型安全的 ORM,完美支持 Bun.js 和 Node.js。

安装:

bun add drizzle-orm
bun add -d drizzle-kit

# Node.js 用户
npm install drizzle-orm
npm install -D drizzle-kit

用 AI 生成完整代码

复制这段提示词:

用 Bun.js + Drizzle ORM + SQLite 创建用户管理系统

要求:
1. 数据库配置
   - 使用 bun:sqlite(Bun 用户)或 better-sqlite3(Node.js 用户)
   - 数据库文件 app.db

2. 用户表结构:
   - id: 整数,自增主键
   - name: 文本,必填
   - email: 文本,必填,唯一
   - createdAt: 时间戳,默认当前时间

3. 实现 RESTful API:
   - GET /users - 查询所有用户
   - GET /users/:id - 查询单个用户
   - POST /users - 创建用户
   - PUT /users/:id - 更新用户
   - DELETE /users/:id - 删除用户

4. 安全要求:
   - 所有 SQL 使用 :name 占位符
   - 禁止字符串拼接 SQL

5. 统一响应格式:
   { success, data, message }

6. 使用 Elysia 框架

请生成完整的项目代码,包含:
- schema.ts - 表定义
- db.ts - 数据库连接
- index.ts - API 路由

AI 生成的代码结构

1. schema.ts - 定义表结构

import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  idinteger("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date())
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

对应 SQL:

CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  created_at INTEGER NOT NULL
);

2. db.ts - 数据库连接

💡 Bun 用户:使用 bun:sqlite💡 Node.js 用户:使用 better-sqlite3

// Bun 用户
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";

const sqlite = new Database("app.db");
export const db = drizzle(sqlite, { schema });

// 自动创建表
sqlite.run(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);
// Node.js 用户
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite";
import * as schema from "./schema";

const sqlite = new Database("app.db");
export const db = drizzle(sqlite, { schema });

// 自动创建表
sqlite.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);

3. index.ts - API 实现

import { eq } from "drizzle-orm";
import { Elysia } from "elysia";
import { db } from "./db";
import { users } from "./schema";

const app = new Elysia()
  .onRequest(({ request }) => {
    console.log(`${request.method} ${new URL(request.url).pathname}`);
  })

  // 查询所有用户
  .get("/users", async () => {
    const allUsers = await db.select().from(users).all();
    return {
      success: true,
      data: allUsers,
      message: "查询成功"
    };
  })

  // 查询单个用户
  .get("/users/:id", async ({ params: { id }, set }) => {
    const user = await db.select().from(users).where(eq(users.id, Number(id))).get();

    if (!user) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: user,
      message: "查询成功"
    };
  })

  // 创建用户
  .post("/users", async ({ body, set }) => {
    const { name, email } = body as { name: string; email: string };

    if (!name || !email) {
      set.status = 400;
      return { success: false, data: null, message: "name 和 email 不能为空" };
    }

    try {
      const result = await db.insert(users).values({
        name,
        email
      }).returning();

      set.status = 201;
      return {
        success: true,
        data: result[0],
        message: "创建成功"
      };
    } catch (error) {
      set.status = 400;
      return { success: false, data: null, message: "邮箱已存在" };
    }
  })

  // 更新用户
  .put("/users/:id", async ({ params: { id }, body, set }) => {
    const { name, email } = body as { name?: string; email?: string };

    const result = await db.update(users)
      .set({ name, email })
      .where(eq(users.id, Number(id)))
      .returning();

    if (result.length === 0) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: result[0],
      message: "更新成功"
    };
  })

  // 删除用户
  .delete("/users/:id", async ({ params: { id }, set }) => {
    const result = await db.delete(users).where(eq(users.id, Number(id))).returning();

    if (result.length === 0) {
      set.status = 404;
      return { success: false, data: null, message: "用户不存在" };
    }

    return {
      success: true,
      data: null,
      message: "删除成功"
    };
  })

  .listen(3000);

console.log(`Server running at http://localhost:${app.server?.port}`);

💡 Node.js 用户注意:需要使用 Node 适配器

import { Elysia } from "elysia";
import { node } from "@elysiajs/node";

// ... 路由代码 ...

.listen(3000, node);  // ← 加上 node 适配器

运行测试

安装依赖:

bun install
# Node.js 用户
npm install

启动服务:

bun run index.ts
# Node.js 用户
npm run dev

测试接口:

# 创建用户
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "张三", "email": "zs@example.com"}'

# 查询所有用户
curl http://localhost:3000/users

# 服务重启后,数据还在!

核心概念对照

| 概念 | Drizzle 代码 | 对应 SQL | | :-- | :-- | :-- | | 查询所有 | db.select().from(users) | SELECT * FROM users | | 条件查询 | .where(eq(users.id, id)) | WHERE id = ? | | 插入 | db.insert(users).values({...}) | INSERT INTO users ... | | 更新 | db.update(users).set({...}) | UPDATE users SET ... | | 删除 | db.delete(users) | DELETE FROM users |


安全提示:参数化查询

❌ 错误写法(SQL 注入风险):

const sql = `SELECT * FROM users WHERE email = '${email}'`;
// 如果 email = "' OR '1'='1"
// 变成:SELECT * FROM users WHERE email = '' OR '1'='1'
// 结果:返回所有用户!

✅ 正确写法(Drizzle 自动参数化):

await db.select().from(users).where(eq(users.email, email));
// 自动使用占位符,安全!

核心收获

今天学习了:

✅ 数据库 = 持久化存储✅ ORM = 用代码操作数据库✅ Drizzle = Bun.js/Node.js 的 ORM 选择✅ 参数化查询 = 防止 SQL 注入


下节课预告

第5课:登录功能怎么实现?一文搞懂认证

我们将:

  • • 理解认证的概念

  • • 学习 JWT 工作原理

  • • 实现注册登录功能


思考题:

如果要给文章表添加一个外键关联用户(作者),表结构应该怎么设计?

欢迎在评论区分享你的设计。


如果觉得有帮助,欢迎点赞、在看、转发。

告别手动切换 Node 版本:从 nvm 迁移到 Volta

作者 MPGWJPMTJT
2026年5月8日 16:00

从 nvm 迁移到 Volta:让 Node 版本跟着项目自动切换

做前端开发时,多个项目使用不同 Node.js 版本几乎是常态。

比如有的老项目还停留在 Node 14.x,有的项目依赖 Node 16.x,新项目又可能要求 Node 22+。如果每次进入项目都要手动切换版本,不仅麻烦,还很容易忘。

我之前一直使用的是 nvm,它能解决多版本安装的问题,但版本切换依赖手动执行命令。一旦进入项目后忘记切换 Node 版本,轻则安装依赖报错,重则出现一些很难定位的构建问题。

所以这次我决定把本地 Node 版本管理从 nvm 迁移到 Volta

为什么选择 Volta

Volta 也是一个 Node.js 版本管理工具,但它和 nvm 的使用体验不太一样。

它最大的特点是:可以把项目需要的 Node 版本写进 package.json,之后只要进入项目目录,Volta 就会自动使用项目指定的版本。

也就是说,迁移完成后,基本不需要再记住“这个项目应该用哪个 Node 版本”,工具会替你处理。

迁移前准备

在卸载 nvm 之前,建议先查看当前已经安装过哪些 Node 版本:

nvm list

把仍然需要使用的版本记录下来,比如:

14.21.3
16.20.2
22.22.2

这些版本后面可以通过 Volta 重新安装。

另外,卸载前最好关闭所有正在运行的 Node 相关进程,避免因为进程占用导致卸载不完整。

卸载 nvm

先关闭 nvm 对 Node 的管理:

nvm off

然后在 Windows 中卸载 NVM for Windows

Windows 设置 -> 应用 -> 已安装的应用 -> NVM for Windows -> 卸载

卸载完成后,可以重新打开一个 PowerShell,确认 nvm 已经不可用。

nvm version

如果命令不存在,说明 nvm 已经卸载完成。

安装 Volta

在 PowerShell 中执行:

winget install Volta.Volta

安装完成后,重新打开终端,让环境变量生效。

可以通过下面的命令确认 Volta 是否安装成功:

volta --version

设置默认 Node 版本

先安装一个较新的 Node.js 版本作为全局默认版本。

例如我这里使用的是:

volta install node@22.22.2

安装完成后,可以检查当前默认版本:

node -v

后续如果想修改默认 Node 版本,也继续使用 volta install

volta install node@新版本

需要注意的是,这里的默认版本主要影响没有单独配置 Node 版本的目录。

为项目指定 Node 版本

进入某个项目目录,然后使用 volta pin 指定该项目需要的 Node 版本。

例如某个老项目需要 Node 14:

cd D:\project\old-app
volta pin node@14.21.3

执行完成后,项目的 package.json 中会自动新增一个 volta 字段:

{
  "volta": {
    "node": "14.21.3"
  }
}

之后只要在这个项目目录中执行:

node -v

Volta 就会自动使用项目指定的 Node 版本。

这也是我从 nvm 迁移到 Volta 的主要原因:版本跟项目绑定,而不是靠人脑记忆。

查看已安装版本

可以使用下面的命令查看 Volta 当前管理的工具和版本:

volta list all

如果某个项目 pin 了一个本地还没有安装过的 Node 版本,Volta 会在需要时自动处理对应版本。

全局包怎么安装

迁移到 Volta 后,全局包不建议再使用:

npm install -g 包名

更推荐使用:

volta install 包名

例如:

volta install pnpm
volta install yarn

这样安装的全局工具会由 Volta 管理,行为更稳定,也不会被当前项目的 Node 版本影响。

如果想安装指定版本:

volta install 包名@版本

例如:

volta install pnpm@9.15.0

项目内的包管理器版本

项目内安装依赖时,仍然使用项目自己的包管理工具,比如:

npm install
yarn install
pnpm install

如果某个项目对包管理器版本也有要求,可以同样通过 volta pin 固定。

例如固定 Yarn 版本:

volta pin yarn@1.22.22

执行后,package.json 中的 volta 字段会变成类似这样:

{
  "volta": {
    "node": "14.21.3",
    "yarn": "1.22.22"
  }
}

这样团队成员拉取代码后,也能使用一致的 Node 和包管理器版本。

迁移后的体验

迁移完成后,我最明显的感受是:不用再频繁思考 Node 版本了。

以前进入项目后,第一反应是:

nvm use 14.21.3

现在只需要进入项目目录,Volta 会自动根据 package.json 中的配置切换版本。

对同时维护多个新老项目的人来说,这个体验提升还是很明显的。

总结

这次从 nvm 迁移到 Volta,核心流程其实很简单:

  1. 记录原来 nvm 中需要保留的 Node 版本
  2. 卸载 NVM for Windows
  3. 安装 Volta
  4. 使用 volta install 设置默认 Node 版本
  5. 在项目中使用 volta pin 固定 Node 版本
  6. 使用 volta install 管理全局工具

如果你也经常在多个前端项目之间切换,尤其是项目 Node 版本差异比较大,Volta 会比手动切换版本省心很多。

它不只是“安装多个 Node 版本”,更重要的是把版本管理这件事从个人习惯变成项目配置。

❌
❌