阅读视图

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

利用方向向量简化代码,O(1) 空间优化(Python/Java/C++/Go)

本题是模拟题,由于每一圈互相独立,可以分别处理。

对于每一圈:

  1. 从左上角开始,顺时针遍历这一圈,把遍历到的元素记录到一个数组 $a$ 中。
  2. 把 $a$ 循环左移 $k\bmod N$ 次,其中 $N$ 是数组 $a$ 的长度。这是因为,循环左移 $N$ 次等同于没有左移,循环左移 $N+1$ 次等同于循环左移 $1$ 次,依此类推,循环左移 $k$ 次等同于循环左移 $k\bmod N$ 次。
  3. 把 $a$ 中元素按照步骤 1 中的顺序,重新填回 $\textit{grid}$。

对于步骤 1,可以仿照 54. 螺旋矩阵方法二,使用一个方向向量数组分别表示顺时针的右、下、左、上。对于最外圈,分别移动 $n-1,m-1,n-1,m-1$ 次;对于次外圈,分别移动 $n-3,m-3,n-3,m-3$ 次;依此类推。

DIRS = (0, 1), (1, 0), (0, -1), (-1, 0)  # 右下左上

class Solution:
    def rotateGrid(self, grid: List[List[int]], k: int) -> List[List[int]]:
        m0, n0 = len(grid), len(grid[0])

        # 从外到内枚举圈
        for i in range(min(m0, n0) // 2):
            m, n = m0 - i * 2, n0 - i * 2  # 这一圈的行数和列数
            x, y = i, i  # 这一圈的左上角
            a = []
            for dx, dy in DIRS:
                for _ in range(n - 1):
                    a.append(grid[x][y])
                    x += dx
                    y += dy
                m, n = n, m  # 见 54. 螺旋矩阵 我的题解

            shift = k % len(a)
            a = a[shift:] + a[:shift]

            # 注意此时 (x, y) 又回到了左上角
            j = 0
            for dx, dy in DIRS:
                for _ in range(n - 1):
                    grid[x][y] = a[j]
                    j += 1
                    x += dx
                    y += dy
                m, n = n, m

        return grid
class Solution {
    private static final int[][] DIRS = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 右下左上

    public int[][] rotateGrid(int[][] grid, int k) {
        int m0 = grid.length, n0 = grid[0].length;
        List<Integer> a = new ArrayList<>((m0 + n0 - 2) * 2); // 预分配空间

        // 从外到内枚举圈
        for (int i = 0; i < Math.min(m0, n0) / 2; i++) {
            int m = m0 - i * 2, n = n0 - i * 2; // 这一圈的行数和列数
            int x = i, y = i; // 这一圈的左上角
            a.clear();
            for (int[] dir : DIRS) {
                for (int t = 0; t < n - 1; t++) {
                    a.add(grid[x][y]);
                    x += dir[0];
                    y += dir[1];
                }
                int tmp = m; // 见 54. 螺旋矩阵 我的题解
                m = n;
                n = tmp; 
            }

            int shift = k % a.size();
            Collections.rotate(a, -shift);

            // 注意此时 (x, y) 又回到了左上角
            int j = 0;
            for (int[] dir : DIRS) {
                for (int t = 0; t < n - 1; t++) {
                    grid[x][y] = a.get(j++);
                    x += dir[0];
                    y += dir[1];
                }
                int temp = m;
                m = n;
                n = temp;
            }
        }

        return grid;
    }
}
class Solution {
    static constexpr int DIRS[4][2] = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; // 右下左上

public:
    vector<vector<int>> rotateGrid(vector<vector<int>>& grid, int k) {
        int m0 = grid.size(), n0 = grid[0].size();
        vector<int> a;
        a.reserve((m0 + n0 - 2) * 2); // 预分配空间

        // 从外到内枚举圈
        for (int i = 0; i < min(m0, n0) / 2; i++) {
            int m = m0 - i * 2, n = n0 - i * 2; // 这一圈的行数和列数
            int x = i, y = i; // 这一圈的左上角
            a.resize(0);
            for (auto& [dx, dy] : DIRS) {
                for (int t = 0; t < n - 1; t++) {
                    a.push_back(grid[x][y]);
                    x += dx;
                    y += dy;
                }
                swap(m, n); // 见 54. 螺旋矩阵 我的题解
            }

            int shift = k % a.size();
            ranges::rotate(a, a.begin() + shift);

            // 注意此时 (x, y) 又回到了左上角
            int j = 0;
            for (auto& [dx, dy] : DIRS) {
                for (int t = 0; t < n - 1; t++) {
                    grid[x][y] = a[j++];
                    x += dx;
                    y += dy;
                }
                swap(m, n);
            }
        }

        return grid;
    }
};
var dirs = [4][2]int{{0, 1}, {1, 0}, {0, -1}, {-1, 0}} // 右下左上

func rotateGrid(grid [][]int, k int) [][]int {
m0, n0 := len(grid), len(grid[0])
a := make([]int, 0, (m0+n0-2)*2) // 预分配空间

// 从外到内枚举圈
for i := range min(m0, n0) / 2 {
m, n := m0-i*2, n0-i*2 // 这一圈的行数和列数
x, y := i, i // 这一圈的左上角
a = a[:0]
for _, dir := range dirs {
for range n - 1 {
a = append(a, grid[x][y])
x += dir[0]
y += dir[1]
}
m, n = n, m // 见 54. 螺旋矩阵 我的题解
}

shift := k % len(a)
a = append(a[shift:], a[:shift]...)

// 注意此时 (x, y) 又回到了左上角
j := 0
for _, dir := range dirs {
for range n - 1 {
grid[x][y] = a[j]
j++
x += dir[0]
y += dir[1]
}
m, n = n, m
}
}

return grid
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn)$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(m+n)$。

空间优化

利用 189. 轮转数组 的技巧,可以做到 $\mathcal{O}(1)$ 空间。详见 我的题解

class Solution:
    def rotateGrid(self, grid: List[List[int]], k: int) -> List[List[int]]:
        m0, n0 = len(grid), len(grid[0])

        # 从外到内枚举圈
        for i in range(min(m0, n0) // 2):
            m, n = m0 - i * 2 - 1, n0 - i * 2 - 1  # 注意这里减一了

            # 返回这一圈顺时针下标 p 对应 grid 的位置 (x, y)
            def index(p: int) -> Tuple[int, int]:
                # 左上角在 (i, i)
                if p < n:
                    return i, i + p
                if p < n + m:
                    return i + p - n, i + n
                if p < n * 2 + m:
                    return i + m, i - p + n * 2 + m
                return i - p + (n + m) * 2, i

            def reverse(l: int, r: int) -> None:
                while l < r:
                    x1, y1 = index(l)
                    x2, y2 = index(r)
                    grid[x1][y1], grid[x2][y2] = grid[x2][y2], grid[x1][y1]
                    l += 1
                    r -= 1

            # 189. 轮转数组(改成向左轮转)
            size = (m + n) * 2
            shift = k % size
            reverse(0, shift - 1)
            reverse(shift, size - 1)
            reverse(0, size - 1)

        return grid
class Solution {
    public int[][] rotateGrid(int[][] grid, int k) {
        int m0 = grid.length, n0 = grid[0].length;

        // 从外到内枚举圈
        for (int i = 0; i < Math.min(m0, n0) / 2; i++) {
            int m = m0 - i * 2 - 1, n = n0 - i * 2 - 1; // 注意这里减一了

            // 189. 轮转数组(改成向左轮转)
            int size = (m + n) * 2;
            int shift = k % size;
            reverse(grid, i, m, n, 0, shift - 1);
            reverse(grid, i, m, n, shift, size - 1);
            reverse(grid, i, m, n, 0, size - 1);
        }

        return grid;
    }

    private void reverse(int[][] grid, int i, int m, int n, int l, int r) {
        while (l < r) {
            int[] p1 = index(i, m, n, l);
            int[] p2 = index(i, m, n, r);
            int x1 = p1[0], y1 = p1[1];
            int x2 = p2[0], y2 = p2[1];

            int tmp = grid[x1][y1];
            grid[x1][y1] = grid[x2][y2];
            grid[x2][y2] = tmp;

            l++;
            r--;
        }
    }

    private int[] index(int i, int m, int n, int p) {
        // 左上角在 (i, i)
        if (p < n) {
            return new int[]{i, i + p};
        }
        if (p < n + m) {
            return new int[]{i + p - n, i + n};
        }
        if (p < n * 2 + m) {
            return new int[]{i + m, i - p + n * 2 + m};
        }
        return new int[]{i - p + (n + m) * 2, i};
    }
}
class Solution {
public:
    vector<vector<int>> rotateGrid(vector<vector<int>>& grid, int k) {
        int m0 = grid.size(), n0 = grid[0].size();

        // 从外到内枚举圈
        for (int i = 0; i < min(m0, n0) / 2; i++) {
            int m = m0 - i * 2 - 1, n = n0 - i * 2 - 1; // 注意这里减一了

            // 返回这一圈顺时针下标 p 对应 grid 的位置 (x, y)
            auto index = [&](int p) -> pair<int, int> {
                // 左上角在 (i, i)
                if (p < n) {
                    return {i, i + p};
                }
                if (p < n + m) {
                    return {i + p - n, i + n};
                }
                if (p < n * 2 + m) {
                    return {i + m, i - p + n * 2 + m};
                }
                return {i - p + (n + m) * 2, i};
            };

            auto reverse = [&](int l, int r) {
                while (l < r) {
                    auto [x1, y1] = index(l);
                    auto [x2, y2] = index(r);
                    swap(grid[x1][y1], grid[x2][y2]);
                    l++;
                    r--;
                }
            };

            // 189. 轮转数组(改成向左轮转)
            int size = (m + n) * 2;
            int shift = k % size;
            reverse(0, shift - 1);
            reverse(shift, size - 1);
            reverse(0, size - 1);
        }

        return grid;
    }
};
func rotateGrid(grid [][]int, k int) [][]int {
m0, n0 := len(grid), len(grid[0])

// 从外到内枚举圈
for i := range min(m0, n0) / 2 {
m, n := m0-i*2-1, n0-i*2-1 // 注意这里减一了

// 返回这一圈顺时针下标 p 对应 grid 的位置 (x, y)
index := func(p int) (x, y int) {
// 左上角在 (i, i)
if p < n {
return i, i + p
}
if p < n+m {
return i + p - n, i + n
}
if p < n*2+m {
return i + m, i - p + n*2 + m
}
return i - p + (n+m)*2, i
}

reverse := func(l, r int) {
for l < r {
x1, y1 := index(l)
x2, y2 := index(r)
grid[x1][y1], grid[x2][y2] = grid[x2][y2], grid[x1][y1]
l++
r--
}
}

// 189. 轮转数组(改成向左轮转)
size := (m + n) * 2
shift := k % size
reverse(0, shift-1)
reverse(shift, size-1)
reverse(0, size-1)
}

return grid
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn)$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

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

给你一个大小为 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

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

解题思路

题目的条件很友好地指明 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
}

循环轮转矩阵

方法一:枚举每一层

思路与算法

对于一个 $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)

解题思路

我们首先把每一层的元素按顺时针取出来,放到数组 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]);
    }
}

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

你维护着五六个项目,每个都单独开一个 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 组件缓存的实现原理

面试手写 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 是什么 & 为什么选它

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

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 种实现方式,学会用不同思路解决问题

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 是什么?

前言

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

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

面试官:?

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数学公式

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

事情是这样的

前两天接到一个需求:数据库里存了一堆 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 做了一个前额叶训练的微信小程序

体验方式在最后~

最近花了两周半的时间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应该有的了解

在项目中添加 .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中的惰性函数基本介绍

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 实战指南

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实战-会议室冲突检测的红绿重构循环

本文通过一个真实的"三个会议交叉重叠"场景,完整展示测试驱动开发(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 做出高级视觉特效

之前做的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 依赖

等了大半年,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 数据库实战

从今天开始, 你是架构师,学会关键词就行,代码让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 工作原理

  • • 实现注册登录功能


思考题:

如果要给文章表添加一个外键关联用户(作者),表结构应该怎么设计?

欢迎在评论区分享你的设计。


如果觉得有帮助,欢迎点赞、在看、转发。

❌