普通视图

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

每日一题-最大化城市的最小电量🔴

2025年11月7日 00:00

给你一个下标从 0 开始长度为 n 的整数数组 stations ,其中 stations[i] 表示第 i 座城市的供电站数目。

每个供电站可以在一定 范围 内给所有城市提供电力。换句话说,如果给定的范围是 r ,在城市 i 处的供电站可以给所有满足 |i - j| <= r 且 0 <= i, j <= n - 1 的城市 j 供电。

  • |x| 表示 x 的 绝对值 。比方说,|7 - 5| = 2 ,|3 - 10| = 7 。

一座城市的 电量 是所有能给它供电的供电站数目。

政府批准了可以额外建造 k 座供电站,你需要决定这些供电站分别应该建在哪里,这些供电站与已经存在的供电站有相同的供电范围。

给你两个整数 r 和 k ,如果以最优策略建造额外的发电站,返回所有城市中,最小电量的最大值是多少。

k 座供电站可以建在多个城市。

 

示例 1:

输入:stations = [1,2,4,5,0], r = 1, k = 2
输出:5
解释:
最优方案之一是把 2 座供电站都建在城市 1 。
每座城市的供电站数目分别为 [1,4,4,5,0] 。
- 城市 0 的供电站数目为 1 + 4 = 5 。
- 城市 1 的供电站数目为 1 + 4 + 4 = 9 。
- 城市 2 的供电站数目为 4 + 4 + 5 = 13 。
- 城市 3 的供电站数目为 5 + 4 = 9 。
- 城市 4 的供电站数目为 5 + 0 = 5 。
供电站数目最少是 5 。
无法得到更优解,所以我们返回 5 。

示例 2:

输入:stations = [4,4,4,4], r = 0, k = 3
输出:4
解释:
无论如何安排,总有一座城市的供电站数目是 4 ,所以最优解是 4 。

 

提示:

  • n == stations.length
  • 1 <= n <= 105
  • 0 <= stations[i] <= 105
  • 0 <= r <= n - 1
  • 0 <= k <= 109

2528. 二分查找+差分数组

作者 minimote
2023年1月8日 00:48

2528. 最大化城市的最小供电站数目

[TOC]

思路

  由于我们要求的是某个值的最大值,所以我们可以采用 <二分查找> 的方式来“猜”结果。

  对于每次猜测,创建函数 $f(c)$ ,返回该数是否合法。

  我们可以采用<差分数组>的形式记录差值,差分数组相关内容参考本文补充部分。从左到右遍历,设本次猜的数为 $c$ ,遇到位置为 $i$ 处的电站小于 $c$ ,则在 $\min(i + r, n - 1)$ 处<临时>添加电站,也就是位于 $[i, \min(i + 2 * r, n - 1)]$ 的城市都增加了临时电站的覆盖。若在本次猜测中,剩余可加的电站不足,则返回 $False$,若最后剩余电站数大于等于0,则返回 $True$。

代码

class Solution:
    def maxPower(self, stations: List[int], r: int, k: int) -> int:
        # 差分数组
        diff = defaultdict(int)
        n = len(stations)
        for i, val in enumerate(stations):
            left = max(0, i - r)
            right = min(n, i + r + 1)
            diff[left] += val
            diff[right] -= val

        # 判断猜的数字是否合法
        def f(c):
            # 临时电站的差分数组
            td = defaultdict(int)
            # 本次猜测剩余可加电站数
            tk = k
            # 当前位置覆盖的电站数
            cnt = 0
            for i in range(n):
                # i 处的电站数
                cnt += diff[i] + td[i]
                if cnt < c:
                    # 将该位置电站数补充到 c
                    tk -= c - cnt
                    if tk < 0:
                        # 剩余可加电站不足
                        return False
                    # 补充的电站放在 min(n - 1, i * 2 * r) 处
                    td[min(n, i + 2 * r + 1)] -= c - cnt
                    # 更新该位置的电站数
                    cnt = c
            return True

        #预估上下界
        a, b = min(stations), sum(stations) + k
        # 维护左边界合法
        while a + 1 < b:
            c = a + (b - a) // 2
            if f(c):
                a = c
            else:
                b = c - 1
        if f(b):
            return b
        return a

复杂度分析

  • 时间复杂度:$O(n \log D)$。每次猜测的时间复杂度为 $O(n)$,需要进行$D = sum(stations) + k - min(stations)$次猜测,总复杂度为 $O(n \log D)$.
  • 空间复杂度:$O(n)$.

补充:差分数组

  给定边界 $S \geq 0$,数组 $arr$,其中 $arr[i] = [a_{i}, b_{i}],\ (0 \leq a_{i} \leq b_{i} \leq S)$,表示在 $[a_{i}, b_{i}]$ 内每个整数位置都放一个小球,返回每个坐标下的小球数量列表。

  若采用暴力方法,时间复杂度为 $O(nS)\ (n = len(arr))$。

def function(arr: list[list[int]], S: int) -> list[int]:
    ans = [0] * (S + 1)
    for a, b in arr:
        for i in range(a, b + 1):
            ans[i] += 1
    return ans

  实际上,我们只要记录每个坐标和前一个坐标的差值,即可在 $O(S)$ 的时间内计算出每个位置最后的小球数。

  创建数组 $diff$,其中 $diff[i]$ 表示 $i$ 位置和 $i - 1$位置的差值,则 $ans[i] = ans[i - 1] + diff[i]$,其中$ans[0] = 0 + diff[0]$.

  那么如何建立差分数组 $diff$ 呢?对于 $arr[i] = [a_{i}, b_{i}]$,表面含义是在 $[a_{i}, b_{i}]$ 内每个整数位置都放一个小球,我们可以从另一个角度理解:

  • 位置 $a_{i}$ 相对于位置 $a_{i} - 1$来说,多增加了一个球,所以差值 $diff[a_{i}] += 1$
  • 位置 $b_{i} + 1$ 相对于位置 $b_{i}$来说,少增加了一个球,所以 $diff[b_{i} + 1] -= 1$
  • 对于 $[a_{i} + 1, b_{i}]$ 内的位置来说,和前一个位置一样,都增加了一个小球,所以差值没变,不需要修改差分数组 $diff$

  调整差分数组的时间复杂度为$O(n)\ (n = len(arr))$,根据差分数组求每个位置小球数的时间复杂度为 $O(S)$,则总时间复杂度为 $O(n) + O(S) = O(\min(n, S))$.

def function(arr: list[list[int]], S: int) -> list[int]:
    # 建立差分数组
    diff = defaultdict(int)
    # 调整差分数组
    for a, b in arr:
        diff[a] += 1
        diff[b] -= 1
    # 根据差分数组求各个位置的小球数
    ans = [0] * (S + 1)
    ans[i] = diff[0]
    for i in range(1, S + 1):
        ans[i] = ans[i - 1] + diff[i]
    return ans

END

  码字不易,点个赞再走呗!

二分答案+前缀和+差分数组+贪心(Python/Java/C++/Go)

作者 endlesscheng
2023年1月8日 00:28

转化

如果存在一种方案,可以让所有城市的电量都 $\ge \textit{low}$,那么也可以 $\ge \textit{low}-1$ 或者更小的值(要求更宽松)。

如果不存在让所有城市的电量都 $\ge \textit{low}$ 的方案,那么也不存在 $\ge \textit{low}+1$ 或者更大的方案(要求更苛刻)。

据此,可以二分猜答案。关于二分算法的原理,请看 二分查找 红蓝染色法【基础算法精讲 04】

现在问题转化成一个判定性问题:

  • 给定 $\textit{low}$,是否存在建造 $k$ 座额外电站的方案,使得所有城市的电量都 $\ge \textit{low}$?

如果存在,说明答案 $\ge \textit{low}$,否则答案 $<\textit{low}$。

思路

由于已经建造的电站是可以发电的,我们需要在二分之前,用 $\textit{stations}$ 计算每个城市的初始电量 $\textit{power}$。这可以用前缀和或者滑动窗口做,具体后面解释。

然后从左到右遍历 $\textit{power}$,挨个处理。如果 $\textit{power}[i] < \textit{low}$,就需要建造电站,额外提供 $\textit{low} - \textit{power}[i]$ 的电力。

在哪建造电站最好呢?

由于我们是从左到右遍历的,在 $i$ 左侧的城市已经处理好了,所以建造的电站越靠右越好,尽可能多地覆盖没遍历到的城市。具体地,$i$ 应当恰好在电站供电范围的边缘上,也就是把电站建在 $i+r$ 的位置,使得电站覆盖范围为 $[i,i+2r]$。

我们要建 $m = \textit{low} - \textit{power}[i]$ 个供电站,也就是把下标在 $[i,i+2r]$ 中的城市的电量都增加 $m$。

这里有一个「区间增加 $m$」的需求,用差分数组实现,原理见 差分数组原理讲解

我们要一边做差分更新,一边计算差分数组的前缀和,以得到当前城市的实际电量。

遍历的同时,累计额外建造的电站数量,如果超过 $k$,不满足要求,可以提前跳出循环。

细节

下面代码采用开区间二分,这仅仅是二分的一种写法,使用闭区间或者半闭半开区间都是可以的,喜欢哪种写法就用哪种。

  • 开区间左端点初始值:$\min(\textit{power}) + \left\lfloor\dfrac{k}{n}\right\rfloor$。把 $k$ 均摊,即使 $r=0$,每个城市都能至少分到 $\left\lfloor\dfrac{k}{n}\right\rfloor$ 的额外电量,所以 $\textit{low} = \min(\textit{power}) + \left\lfloor\dfrac{k}{n}\right\rfloor$ 时一定满足要求。
  • 开区间右端点初始值:$\min(\textit{power}) + k + 1$。即使把所有额外电站都建给电量最小的城市,也无法满足要求。

对于开区间写法,简单来说 check(mid) == true 时更新的是谁,最后就返回谁。相比其他二分写法,开区间写法不需要思考加一减一等细节,更简单。推荐使用开区间写二分。

写法一:前缀和

能覆盖城市 $i$ 的电站下标范围是 $[i-r,i+r]$。注意下标不能越界,所以实际范围是 $[\max(i-r,0),\min(i+r,n-1)]$。

我们需要计算 $\textit{stations}$ 的这个范围(子数组)的和。

计算 $\textit{stations}$ 的 前缀和 数组后,可以 $\mathcal{O}(1)$ 计算 $\textit{stations}$ 的任意子数组的和。

class Solution:
    def maxPower(self, stations: List[int], r: int, k: int) -> int:
        n = len(stations)
        # 前缀和
        s = list(accumulate(stations, initial=0))
        # 初始电量
        power = [s[min(i + r + 1, n)] - s[max(i - r, 0)] for i in range(n)]

        def check(low: int) -> bool:
            diff = [0] * n  # 差分数组
            sum_d = built = 0
            for i, p in enumerate(power):
                sum_d += diff[i]  # 累加差分值
                m = low - (p + sum_d)
                if m <= 0:
                    continue
                # 需要在 i+r 额外建造 m 个供电站
                built += m
                if built > k:  # 不满足要求
                    return False
                # 把区间 [i, i+2r] 加一
                sum_d += m  # 由于 diff[i] 后面不会再访问,我们直接加到 sum_d 中
                if (right := i + r * 2 + 1) < n:
                    diff[right] -= m
            return True

        # 开区间二分
        mn = min(power)
        left, right = mn + k // n, mn + k + 1
        while left + 1 < right:
            mid = (left + right) // 2
            if check(mid):
                left = mid
            else:
                right = mid
        return left
class Solution:
    def maxPower(self, stations: List[int], r: int, k: int) -> int:
        n = len(stations)
        # 前缀和
        s = list(accumulate(stations, initial=0))
        # 初始电量
        power = [s[min(i + r + 1, n)] - s[max(i - r, 0)] for i in range(n)]

        def check(low: int) -> bool:
            low += 1  # 二分最小的不满足要求的 low(符合库函数),这样最终返回的就是最大的满足要求的 low
            diff = [0] * n  # 差分数组
            sum_d = built = 0
            for i, p in enumerate(power):
                sum_d += diff[i]  # 累加差分值
                m = low - (p + sum_d)
                if m <= 0:
                    continue
                # 需要在 i+r 额外建造 m 个供电站
                built += m
                if built > k:  # 不满足要求
                    return True
                # 把区间 [i, i+2r] 加一
                sum_d += m  # 由于 diff[i] 后面不会再访问,我们直接加到 sum_d 中
                if (right := i + r * 2 + 1) < n:
                    diff[right] -= m
            return False

        mn = min(power)
        left, right = mn + k // n, mn + k
        return bisect_left(range(right), True, lo=left, key=check)
class Solution {
    public long maxPower(int[] stations, int r, int k) {
        int n = stations.length;
        // 前缀和
        long[] sum = new long[n + 1];
        for (int i = 0; i < n; i++) {
            sum[i + 1] = sum[i] + stations[i];
        }

        // 初始电量
        long[] power = new long[n];
        long mn = Long.MAX_VALUE;
        for (int i = 0; i < n; i++) {
            power[i] = sum[Math.min(i + r + 1, n)] - sum[Math.max(i - r, 0)];
            mn = Math.min(mn, power[i]);
        }

        // 开区间二分
        long left = mn + k / n;
        long right = mn + k + 1;
        while (left + 1 < right) {
            long mid = left + (right - left) / 2;
            if (check(mid, power, r, k)) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }

    private boolean check(long low, long[] power, int r, int k) {
        int n = power.length;
        long[] diff = new long[n + 1];
        long sumD = 0;
        long built = 0;
        for (int i = 0; i < n; i++) {
            sumD += diff[i]; // 累加差分值
            long m = low - (power[i] + sumD);
            if (m <= 0) {
                continue;
            }
            // 需要在 i+r 额外建造 m 个供电站
            built += m;
            if (built > k) { // 不满足要求
                return false;
            }
            // 把区间 [i, i+2r] 加一
            sumD += m; // 由于 diff[i] 后面不会再访问,我们直接加到 sumD 中
            diff[Math.min(i + r * 2 + 1, n)] -= m;
        }
        return true;
    }
}
class Solution {
public:
    long long maxPower(vector<int>& stations, int r, int k) {
        int n = stations.size();
        // 前缀和
        vector<long long> sum(n + 1);
        for (int i = 0; i < n; i++) {
            sum[i + 1] = sum[i] + stations[i];
        }

        // 初始电量
        vector<long long> power(n);
        long long mn = LLONG_MAX;
        for (int i = 0; i < n; i++) {
            power[i] = sum[min(i + r + 1, n)] - sum[max(i - r, 0)];
            mn = min(mn, power[i]);
        }

        auto check = [&](long long low) -> bool {
            vector<long long> diff(n + 1);
            long long sum_d = 0, built = 0;
            for (int i = 0; i < n; i++) {
                sum_d += diff[i]; // 累加差分值
                long long m = low - (power[i] + sum_d);
                if (m <= 0) {
                    continue;
                }
                // 需要在 i+r 额外建造 m 个供电站
                built += m;
                if (built > k) { // 不满足要求
                    return false;
                }
                // 把区间 [i, i+2r] 加一
                sum_d += m; // 由于 diff[i] 后面不会再访问,我们直接加到 sum_d 中
                diff[min(i + r * 2 + 1, n)] -= m;
            }
            return true;
        };

        // 开区间二分
        long long left = mn + k / n, right = mn + k + 1;
        while (left + 1 < right) {
            long long mid = left + (right - left) / 2;
            (check(mid) ? left : right) = mid;
        }
        return left;
    }
};
func maxPower(stations []int, r int, k int) int64 {
n := len(stations)
// 前缀和
sum := make([]int, n+1)
for i, x := range stations {
sum[i+1] = sum[i] + x
}

// 初始电量
power := make([]int, n)
mn := math.MaxInt
for i := range power {
power[i] = sum[min(i+r+1, n)] - sum[max(i-r, 0)]
mn = min(mn, power[i])
}

// 二分答案
left := mn + k/n
right := mn + k
ans := left + sort.Search(right-left, func(low int) bool {
// 这里 +1 是为了二分最小的不满足要求的 low(符合库函数),这样最终返回的就是最大的满足要求的 low
low += left + 1
diff := make([]int, n+1) // 差分数组
sumD, built := 0, 0
for i, p := range power {
sumD += diff[i] // 累加差分值
m := low - (p + sumD)
if m <= 0 {
continue
}
// 需要在 i+r 额外建造 m 个供电站
built += m
if built > k { // 不满足要求
return true
}
// 把区间 [i, i+2r] 加一
sumD += m // 由于 diff[i] 后面不会再访问,我们直接加到 sumD 中
diff[min(i+r*2+1, n)] -= m
}
return false
})
return int64(ans)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log k)$,其中 $n$ 是 $\textit{stations}$ 的长度。二分 $\mathcal{O}(\log k)$ 次,每次 $\mathcal{O}(n)$。
  • 空间复杂度:$\mathcal{O}(n)$。

写法二:滑动窗口

滑动窗口 计算 $\textit{power}$。

class Solution:
    def maxPower(self, stations: List[int], r: int, k: int) -> int:
        n = len(stations)
        # 滑动窗口
        s = sum(stations[:r])  # 先计算 [0, r-1] 的发电量,为第一个窗口做准备
        power = [0] * n
        for i in range(n):
            # 右边进
            if (right := i + r) < n:
                s += stations[right]
            # 左边出
            if (left := i - r - 1) >= 0:
                s -= stations[left]
            power[i] = s

        def check(low: int) -> bool:
            diff = [0] * n  # 差分数组
            sum_d = built = 0
            for i, p in enumerate(power):
                sum_d += diff[i]  # 累加差分值
                m = low - (p + sum_d)
                if m <= 0:
                    continue
                # 需要在 i+r 额外建造 m 个供电站
                built += m
                if built > k:  # 不满足要求
                    return False
                # 把区间 [i, i+2r] 加一
                sum_d += m  # 由于 diff[i] 后面不会再访问,我们直接加到 sum_d 中
                if (right := i + r * 2 + 1) < n:
                    diff[right] -= m
            return True

        # 开区间二分
        mn = min(power)
        left, right = mn + k // n, mn + k + 1
        while left + 1 < right:
            mid = (left + right) // 2
            if check(mid):
                left = mid
            else:
                right = mid
        return left
class Solution:
    def maxPower(self, stations: List[int], r: int, k: int) -> int:
        n = len(stations)
        # 滑动窗口
        s = sum(stations[:r])  # 先计算 [0, r-1] 的发电量,为第一个窗口做准备
        power = [0] * n
        for i in range(n):
            # 右边进
            if (right := i + r) < n:
                s += stations[right]
            # 左边出
            if (left := i - r - 1) >= 0:
                s -= stations[left]
            power[i] = s

        def check(low: int) -> bool:
            low += 1  # 二分最小的不满足要求的 low(符合库函数),这样最终返回的就是最大的满足要求的 low
            diff = [0] * n  # 差分数组
            sum_d = built = 0
            for i, p in enumerate(power):
                sum_d += diff[i]  # 累加差分值
                m = low - (p + sum_d)
                if m <= 0:
                    continue
                # 需要在 i+r 额外建造 m 个供电站
                built += m
                if built > k:  # 不满足要求
                    return True
                # 把区间 [i, i+2r] 加一
                sum_d += m  # 由于 diff[i] 后面不会再访问,我们直接加到 sum_d 中
                if (right := i + r * 2 + 1) < n:
                    diff[right] -= m
            return False

        mn = min(power)
        left, right = mn + k // n, mn + k
        return bisect_left(range(right), True, lo=left, key=check)
class Solution {
    public long maxPower(int[] stations, int r, int k) {
        int n = stations.length;
        // 滑动窗口
        // 先计算 [0, r-1] 的发电量,为第一个窗口做准备
        long sum = 0;
        for (int i = 0; i < r; i++) {
            sum += stations[i];
        }
        long[] power = new long[n];
        long mn = Long.MAX_VALUE;
        for (int i = 0; i < n; i++) {
            // 右边进
            if (i + r < n) {
                sum += stations[i + r];
            }
            // 左边出
            if (i - r - 1 >= 0) {
                sum -= stations[i - r - 1];
            }
            power[i] = sum;
            mn = Math.min(mn, sum);
        }

        // 开区间二分
        long left = mn + k / n;
        long right = mn + k + 1;
        while (left + 1 < right) {
            long mid = left + (right - left) / 2;
            if (check(mid, power, r, k)) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }

    private boolean check(long low, long[] power, int r, int k) {
        int n = power.length;
        long[] diff = new long[n + 1];
        long sumD = 0;
        long built = 0;
        for (int i = 0; i < n; i++) {
            sumD += diff[i]; // 累加差分值
            long m = low - (power[i] + sumD);
            if (m <= 0) {
                continue;
            }
            // 需要在 i+r 额外建造 m 个供电站
            built += m;
            if (built > k) { // 不满足要求
                return false;
            }
            // 把区间 [i, i+2r] 加一
            sumD += m; // 由于 diff[i] 后面不会再访问,我们直接加到 sumD 中
            diff[Math.min(i + r * 2 + 1, n)] -= m;
        }
        return true;
    }
}
class Solution {
public:
    long long maxPower(vector<int>& stations, int r, int k) {
        int n = stations.size();
        // 滑动窗口
        // 先计算 [0, r-1] 的发电量,为第一个窗口做准备
        long long sum = reduce(stations.begin(), stations.begin() + r, 0LL);
        vector<long long> power(n);
        long long mn = LLONG_MAX;
        for (int i = 0; i < n; i++) {
            // 右边进
            if (i + r < n) {
                sum += stations[i + r];
            }
            // 左边出
            if (i - r - 1 >= 0) {
                sum -= stations[i - r - 1];
            }
            power[i] = sum;
            mn = min(mn, sum);
        }

        auto check = [&](long long low) -> bool {
            vector<long long> diff(n + 1);
            long long sum_d = 0, built = 0;
            for (int i = 0; i < n; i++) {
                sum_d += diff[i]; // 累加差分值
                long long m = low - (power[i] + sum_d);
                if (m <= 0) {
                    continue;
                }
                // 需要在 i+r 额外建造 m 个供电站
                built += m;
                if (built > k) { // 不满足要求
                    return false;
                }
                // 把区间 [i, i+2r] 加一
                sum_d += m; // 由于 diff[i] 后面不会再访问,我们直接加到 sum_d 中
                diff[min(i + r * 2 + 1, n)] -= m;
            }
            return true;
        };

        // 开区间二分
        long long left = mn + k / n, right = mn + k + 1;
        while (left + 1 < right) {
            long long mid = left + (right - left) / 2;
            (check(mid) ? left : right) = mid;
        }
        return left;
    }
};
func maxPower(stations []int, r int, k int) int64 {
n := len(stations)
// 滑动窗口
// 先计算 [0, r-1] 的发电量,为第一个窗口做准备
sum := 0
for _, x := range stations[:r] {
sum += x
}
power := make([]int, n)
mn := math.MaxInt
for i := range power {
// 右边进
if i+r < n {
sum += stations[i+r]
}
// 左边出
if i-r-1 >= 0 {
sum -= stations[i-r-1]
}
power[i] = sum
mn = min(mn, sum)
}

// 二分答案
left := mn + k/n
right := mn + k
ans := left + sort.Search(right-left, func(low int) bool {
// 这里 +1 是为了二分最小的不满足要求的 low(符合库函数),这样最终返回的就是最大的满足要求的 low
low += left + 1
diff := make([]int, n+1) // 差分数组
sumD, built := 0, 0
for i, p := range power {
sumD += diff[i] // 累加差分值
m := low - (p + sumD)
if m <= 0 {
continue
}
// 需要在 i+r 额外建造 m 个供电站
built += m
if built > k { // 不满足要求
return true
}
// 把区间 [i, i+2r] 加一
sumD += m // 由于 diff[i] 后面不会再访问,我们直接加到 sumD 中
diff[min(i+r*2+1, n)] -= m
}
return false
})
return int64(ans)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log k)$,其中 $n$ 是 $\textit{stations}$ 的长度。二分 $\mathcal{O}(\log k)$ 次,每次 $\mathcal{O}(n)$。
  • 空间复杂度:$\mathcal{O}(n)$。

分类题单

如何科学刷题?

  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站@灵茶山艾府

二分 & 贪心

作者 tsreaper
2023年1月8日 00:10

解法:二分 & 贪心

求最小值最大,容易想到二分答案。接下来思考如何确定二分值 $V$ 是否有效。

我们可以用一个滑动窗口从左到右计算每个城市的电量。若当前城市电量不足,我们需要新建供电站补上。由于左边的城市的电量都大于等于 $V$,因此新的供电站应该尽量建在右边。显然这些供电站应该建在滑动窗口内部的最右侧。

二分答案后模拟以上过程即可。复杂度 $\mathcal{O}(n\log (nA + k))$,其中 $A$ 是 stations[i] 的最大值。

参考代码(c++)

###c++

class Solution {
public:
    long long maxPower(vector<int>& stations, int R, int K) {
        int n = stations.size();

        auto check = [&](long long LIM) {
            vector<long long> vec;
            for (int x : stations) vec.push_back(x);

            // 初始化滑动窗口的和
            long long sm = 0;
            for (int i = 0; i <= R && i < n; i++) sm += vec[i];
            
            // 表示还有几个供电站可以新建
            long long rem = K;
            // 从左到右计算每个城市的电量,同时维护滑动窗口 [l, r]
            for (int i = 0, l = 0, r = R; ; i++) {
                if (sm < LIM) {
                    // 当前城市电量不足
                    long long delta = LIM - sm;
                    // 新供电站不够,返回 false
                    if (delta > rem) return false;
                    // 新供电站足够,建在滑动窗口最右边
                    rem -= delta;
                    vec[r] += delta;
                    sm += delta;
                }
                if (i == n - 1) break;

                // 滑动窗口向前移动一个城市
                if (i >= R) sm -= vec[l], l++;
                if (r != n - 1) sm += vec[r + 1], r++;
            }
            return true;
        };

        // 二分答案
        long long head = 0, tail = 2e10;
        while (head < tail) {
            long long mid = (head + tail + 1) >> 1;
            if (check(mid)) head = mid;
            else tail = mid - 1;
        }
        return head;
    }
};

为什么 React 中的 key 不能用索引?

2025年11月7日 00:28

大家好,我是 前端架构师 - 大卫

关注微信公众号 @程序员大卫,回复 [资料] 即可领取前端精品资料包。

前言

在 React 中,React 自身无法自动生成合适的 key。 只有你(开发者)最清楚你的数据结构,也只有你知道:在两次渲染之间,哪些元素是“相同”的(哪怕内容有变),哪些元素又是“全新的”。

通常情况下,key 应该来自数据本身,例如数据库中的唯一 ID、对象的唯一标识符等,而不应该使用数组的索引或随机数。

示例

来看一个简单的例子:

当我们删除 b 这一项时,预期渲染结果应该是:

a
c

但实际结果却成了:

a
b

问题出在哪?看一下代码:

import { useState } from "react";
import "./App.css";

function App() {
  const [list, setList] = useState(["a", "b", "c"]);

  const handleDelete = (index) => {
    list.splice(index, 1);
    setList([...list]);
  };

  return (
    <div className="App">
      {list.map((item, index) => (
        <div key={index}>
          <input defaultValue={item} />
          <button onClick={() => handleDelete(index)}>Delete</button>
        </div>
      ))}
    </div>
  );
}

export default App;

问题的本质:索引不是稳定的 key

当你使用数组索引作为 key 时,元素位置一旦变化,React 会错误地认为组件“没变”,于是继续复用旧的组件实例。

这导致本该删除或更新的节点没有被正确识别和替换,进而引发各种渲染问题(例如输入框错位、删除异常等)。

如果你使用随机 key 会怎样?

如果你改成这样:

<div key={Math.random()}>

React 会认为:“每次渲染,这些节点全都是全新的。” 于是它会:

  • 销毁所有旧组件;
  • 重新创建新的组件;
  • 丢失所有组件内部状态(比如输入框的光标、临时值等)。

这样做虽然能“避免”索引问题,但也彻底失去了 React 高效更新的核心优势。 每次渲染都像“重建整棵树”一样,没有任何性能可言。

总结

在 React 中:

  • key 应该是数据层面的唯一标识(如 ID、UUID、唯一名称);
  • 不要使用数组索引或随机数作为 key;
  • key 的作用是帮助 React 找到前后渲染中相同的元素,从而实现高效更新。

记住一句话:

React 不在乎你渲染了什么,而在乎的是:哪些东西变了,哪些没变。

「Ant Design 组件库探索」五:Tabs组件

作者 李仲轩
2025年11月6日 22:32

闲言少叙,这次是tabs组件的探索,开始吧!

组件概述

Ant Design 的 Tabs 组件是一个功能强大的标签页组件,支持多种样式、动画效果和交互模式。它基于底层的 rc-tabs 库构建,提供了丰富的定制化选项和优雅的用户体验。

核心设计理念

1. 分层架构设计

Tabs 组件采用了清晰的分层架构:

  • 基础层 (rc-tabs): 提供核心的标签页功能
  • 业务层 (Ant Design Tabs): 添加 Ant Design 特有的样式和功能
  • 样式层 (CSS-in-JS): 使用 @ant-design/cssinjs 实现动态样式

2. 类型系统设计

组件定义了完善的 TypeScript 类型系统:

export type TabsType = 'line' | 'card' | 'editable-card';
export type TabsPosition = 'top' | 'right' | 'bottom' | 'left';

这种设计确保了类型安全和良好的开发体验。

核心实现解析

1. 组件结构

Tabs 组件的主要文件结构:

  • index.tsx - 主组件实现
  • TabPane.ts - 标签页面板组件(已标记为废弃)
  • hooks/ - 自定义 React Hooks
  • style/ - 样式相关文件
  • demo/ - 示例代码

2. 核心 Hook 机制

useAnimateConfig Hook

这个 Hook 负责处理动画配置的逻辑:

export default function useAnimateConfig(
  prefixCls: string,
  animated: TabsProps['animated'] = {
    inkBar: true,
    tabPane: false,
  },
): AnimatedConfig {
  // 处理不同的动画配置模式
  if (animated === false) {
    mergedAnimated = { inkBar: false, tabPane: false };
  } else if (animated === true) {
    mergedAnimated = { inkBar: true, tabPane: true };
  } else {
    mergedAnimated = { inkBar: true, ...animated };
  }
  
  // 配置标签页切换动画
  if (mergedAnimated.tabPane) {
    mergedAnimated.tabPaneMotion = {
      ...motion,
      motionName: getTransitionName(prefixCls, 'switch'),
    };
  }
  
  return mergedAnimated;
}

3. 样式系统设计

Ant Design Tabs 的样式系统是其设计的亮点之一:

设计令牌系统

组件定义了丰富的设计令牌:

export interface ComponentToken {
  zIndexPopup: number;
  cardBg: string;
  cardHeight: number;
  cardHeightSM: number;
  cardHeightLG: number;
  // ... 更多令牌
}

样式生成函数

样式系统采用函数式生成的方式:

const genCardStyle: GenerateStyle<TabsToken> = (token: TabsToken): CSSObject => {
  return {
    [`${componentCls}-card`]: {
      [`${componentCls}-tab`]: {
        margin: 0,
        padding: tabsCardPadding,
        background: cardBg,
        border: `${unit(token.lineWidth)} ${token.lineType} ${colorBorderSecondary}`,
        transition: `all ${token.motionDurationSlow} ${token.motionEaseInOut}`,
      },
      // ... 更多样式规则
    }
  };
};

4. 响应式设计

组件支持多种尺寸和响应式布局:

const genSizeStyle: GenerateStyle<TabsToken> = (token: TabsToken): CSSObject => {
  return {
    [componentCls]: {
      '&-small': {
        [`${componentCls}-tab`]: {
          padding: horizontalItemPaddingSM,
          fontSize: token.titleFontSizeSM,
        },
      },
      '&-large': {
        [`${componentCls}-tab`]: {
          padding: horizontalItemPaddingLG,
          fontSize: token.titleFontSizeLG,
        },
      },
    }
  };
};

功能特性详解

1. 多种标签页类型

  • Line: 线性标签页,默认样式
  • Card: 卡片式标签页
  • Editable-card: 可编辑的卡片标签页

2. 丰富的定位选项

支持四个方向的标签页布局:

export type TabsPosition = 'top' | 'right' | 'bottom' | 'left';

3. 动画系统

组件内置了平滑的动画效果:

  • 指示条动画: 标签切换时的滑动效果
  • 内容切换动画: 标签页内容的淡入淡出效果
  • 溢出处理: 自动处理标签过多时的滚动和下拉菜单

4. 无障碍访问支持

组件内置了完整的无障碍访问支持:

  • 键盘导航支持
  • 屏幕阅读器兼容
  • 焦点管理

实现技巧和最佳实践

1. 组件组合模式

Tabs 组件采用了组合模式的设计:

type CompoundedComponent = typeof InternalTabs & { TabPane: typeof TabPane };
const Tabs = InternalTabs as CompoundedComponent;
Tabs.TabPane = TabPane;

这种设计允许用户通过 Tabs.TabPane 的方式使用子组件。

2. 配置合并策略

组件实现了智能的配置合并策略:

const mergedAnimated = useAnimateConfig(prefixCls, animated);
const mergedItems = useLegacyItems(items, children);
const mergedIndicator = {
  align: indicator?.align ?? tabs?.indicator?.align,
  size: indicator?.size ?? indicatorSize ?? tabs?.indicator?.size,
};

3. 向后兼容性处理

组件提供了完善的向后兼容性支持:

if (process.env.NODE_ENV !== 'production') {
  const warning = devUseWarning('Tabs');
  warning.deprecated(!('destroyInactiveTabPane' in props), 'destroyInactiveTabPane', 'destroyOnHidden');
}

使用示例

基本用法

import { Tabs } from 'antd';

const App = () => (
  <Tabs
    defaultActiveKey="1"
    items={[
      { key: '1', label: 'Tab 1', children: 'Content 1' },
      { key: '2', label: 'Tab 2', children: 'Content 2' },
    ]}
  />
);

高级用法

<Tabs
  type="editable-card"
  onEdit={(key, action) => {
    if (action === 'add') {
      // 添加新标签页
    } else {
      // 删除标签页
    }
  }}
  items={[...]}
/>

总结

Ant Design 的 Tabs 组件是一个设计精良、功能丰富的组件,其核心特点包括:

  1. 模块化设计: 清晰的架构分层和职责分离
  2. 类型安全: 完善的 TypeScript 类型定义
  3. 样式系统: 基于设计令牌的动态样式生成
  4. 动画效果: 平滑的过渡动画和交互反馈
  5. 无障碍访问: 完整的键盘导航和屏幕阅读器支持
  6. 向后兼容: 良好的版本迁移和兼容性处理

OK,我是李仲轩,下一篇再见吧!👋

🧩 深入浅出讲解:analyzeScriptBindings —— Vue 如何分析 <script> 里的变量绑定

作者 excel
2025年11月6日 20:01

一、这段代码是干什么的?

Vue 组件有两种写法:

类型 示例 特点
普通 <script> export default { data(){...}, props:{...} } 传统写法
<script setup> 顶层直接写 const count = ref(0) Vue3 新写法

而 Vue 编译器在处理 .vue 文件时,需要知道:

每个变量来自哪里?它是 props 吗?data 吗?methods 吗?

这段源码的作用就是:
👉 当我们用“普通写法”时,分析出每个变量的“来源类型”。


二、运行结果长什么样?

假设我们有个组件:

export default {
  props: ['title'],
  data() {
    return { count: 0 }
  },
  methods: {
    inc() { this.count++ }
  }
}

经过这段分析函数后,会得到这样的结果对象:

{
  title: 'props',
  count: 'data',
  inc: 'options',
  __isScriptSetup: false
}

这就告诉 Vue:

  • title 来自 props;
  • count 来自 data;
  • inc 是 methods;
  • 不是 <script setup>

三、从头到尾一步步看源码逻辑

Step 1️⃣:找到 export default { ... }

export function analyzeScriptBindings(ast: Statement[]): BindingMetadata {
  for (const node of ast) {
    if (
      node.type === 'ExportDefaultDeclaration' &&
      node.declaration.type === 'ObjectExpression'
    ) {
      return analyzeBindingsFromOptions(node.declaration)
    }
  }
  return {}
}

🧠 意思:

  • AST 是整段脚本的语法树。

  • 遍历每个语句,找到:

    export default { ... }
    
  • 然后调用 analyzeBindingsFromOptions() 来分析里面的对象内容。


Step 2️⃣:创建结果对象并标记类型

const bindings: BindingMetadata = {}
Object.defineProperty(bindings, '__isScriptSetup', {
  enumerable: false,
  value: false,
})

📘 这一步干嘛?

  • 初始化一个结果对象;
  • 加一个隐藏属性 __isScriptSetup=false,告诉系统“这是普通 script”。

Step 3️⃣:逐个分析对象里的属性

例如:

export default {
  props: ['foo'],
  data() { return { msg: 'hi' } },
  methods: { sayHi() {} }
}

程序就会循环每个属性(props、data、methods...),判断它是哪种类型。


Step 4️⃣:不同类型的属性,分别分析

(1) props 分析

if (property.key.name === 'props') {
  for (const key of getObjectOrArrayExpressionKeys(property.value)) {
    bindings[key] = BindingTypes.PROPS
  }
}

🧠 支持两种写法:

  • props: ['foo', 'bar']
  • props: { foo: String }

结果:

{ foo: 'props', bar: 'props' }

(2) inject 分析

else if (property.key.name === 'inject') {
  for (const key of getObjectOrArrayExpressionKeys(property.value)) {
    bindings[key] = BindingTypes.OPTIONS
  }
}

对应:

inject: ['token']

👉 结果 { token: 'options' }


(3) methods / computed 分析

else if (
  property.value.type === 'ObjectExpression' &&
  (property.key.name === 'computed' || property.key.name === 'methods')
)

📘 当 methods: { sayHi(){} }computed: { total(){} } 时,
把每个函数名记录下来:

{ sayHi: 'options', total: 'options' }

(4) data / setup 分析

else if (
  property.type === 'ObjectMethod' &&
  (property.key.name === 'setup' || property.key.name === 'data')
)

这时要进入函数体里查找 return 的内容:

data() {
  return { count: 0 }
}
setup() {
  return { foo: ref(0) }
}

📘 分析结果:

  • data 返回的变量 → BindingTypes.DATA
  • setup 返回的变量 → BindingTypes.SETUP_MAYBE_REF

四、辅助函数们(简化理解)

1️⃣ 获取对象的键名

function getObjectExpressionKeys(node) {
  // 从 { foo: 1, bar: 2 } 中提取出 ['foo', 'bar']
}

2️⃣ 获取数组的键名

function getArrayExpressionKeys(node) {
  // 从 ['foo', 'bar'] 中提取出 ['foo', 'bar']
}

3️⃣ 自动判断是对象还是数组

export function getObjectOrArrayExpressionKeys(value) {
  // 根据类型选择上面的函数
}

五、整体运行逻辑图

AST语法树
   ↓
找到 export default {}
   ↓
进入 analyzeBindingsFromOptions()
   ↓
循环每个属性:
   - props → PROPS
   - inject → OPTIONS
   - methods/computed → OPTIONS
   - data → DATA
   - setup → SETUP_MAYBE_REF
   ↓
返回 BindingMetadata

六、为什么这么做?

因为 Vue 在模板编译时,需要知道哪些名字是:

  • 响应式变量(data、setup)
  • 只读输入(props)
  • 普通函数(methods)

这样模板里写的:

<p>{{ count }}</p>

才能被编译成正确的访问代码:

_ctx.count

或者:

_props.title

七、你可以怎么用它?

如果你想做:

  • 自定义 Vue 编译工具;
  • 分析 .vue 文件中定义的变量;
  • 或者写一个 ESLint 规则来检测组件结构;

就可以直接复用这段逻辑,让它帮你快速“读懂” Vue 组件结构。


八、潜在问题

问题 说明
不支持动态 key [foo]: value 这种会被忽略
不识别 TS 类型 如果写 props: { foo: String as PropType<number> } 不会处理
无法分析复杂 setup 返回逻辑 例如条件 return 不被识别

✅ 总结一句话

这段代码的作用就是让编译器“看懂”一个普通 Vue 组件里的变量来源,区分哪些是 props、data、methods、setup 返回的。


本文部分内容借助 AI 辅助生成,并由作者整理审核。

昨天 — 2025年11月6日技术

[Python3/Java/C++] 一题一解:并查集+有序集合(清晰题解)

作者 lcbin
2025年11月6日 07:43

方法一:并查集 + 有序集合

我们可以使用并查集(Union-Find)来维护电站之间的连接关系,从而确定每个电站所属的电网。对于每个电网,我们使用有序集合(如 Python 中的 SortedList、Java 中的 TreeSet 或 C++ 中的 std::set)来存储该电网中所有在线的电站编号,以便能够高效地查询和删除电站。

具体步骤如下:

  1. 初始化并查集,处理所有连接关系,将连接的电站合并到同一个集合中。
  2. 为每个电网创建一个有序集合,初始时将所有电站编号加入对应电网的集合中。
  3. 遍历查询列表:
    • 对于查询 $[1, x]$,首先找到电站 $x$ 所属的电网根节点,然后检查该电网的有序集合:
      • 如果电站 $x$ 在线(存在于集合中),则返回 $x$。
      • 否则,返回集合中的最小编号电站(如果集合非空),否则返回 -1。
    • 对于查询 $[2, x]$,找到电站 $x$ 所属的电网根节点,并将电站 $x$ 从该电网的有序集合中删除,表示该电站离线。
  4. 最后,返回所有类型为 $[1, x]$ 的查询结果。

###python

class UnionFind:
    def __init__(self, n):
        self.p = list(range(n))
        self.size = [1] * n

    def find(self, x):
        if self.p[x] != x:
            self.p[x] = self.find(self.p[x])
        return self.p[x]

    def union(self, a, b):
        pa, pb = self.find(a), self.find(b)
        if pa == pb:
            return False
        if self.size[pa] > self.size[pb]:
            self.p[pb] = pa
            self.size[pa] += self.size[pb]
        else:
            self.p[pa] = pb
            self.size[pb] += self.size[pa]
        return True


class Solution:
    def processQueries(
        self, c: int, connections: List[List[int]], queries: List[List[int]]
    ) -> List[int]:
        uf = UnionFind(c + 1)
        for u, v in connections:
            uf.union(u, v)
        st = [SortedList() for _ in range(c + 1)]
        for i in range(1, c + 1):
            st[uf.find(i)].add(i)
        ans = []
        for a, x in queries:
            root = uf.find(x)
            if a == 1:
                if x in st[root]:
                    ans.append(x)
                elif len(st[root]):
                    ans.append(st[root][0])
                else:
                    ans.append(-1)
            else:
                st[root].discard(x)
        return ans

###java

class UnionFind {
    private final int[] p;
    private final int[] size;

    public UnionFind(int n) {
        p = new int[n];
        size = new int[n];
        for (int i = 0; i < n; ++i) {
            p[i] = i;
            size[i] = 1;
        }
    }

    public int find(int x) {
        if (p[x] != x) {
            p[x] = find(p[x]);
        }
        return p[x];
    }

    public boolean union(int a, int b) {
        int pa = find(a), pb = find(b);
        if (pa == pb) {
            return false;
        }
        if (size[pa] > size[pb]) {
            p[pb] = pa;
            size[pa] += size[pb];
        } else {
            p[pa] = pb;
            size[pb] += size[pa];
        }
        return true;
    }
}

class Solution {
    public int[] processQueries(int c, int[][] connections, int[][] queries) {
        UnionFind uf = new UnionFind(c + 1);
        for (int[] e : connections) {
            uf.union(e[0], e[1]);
        }

        TreeSet<Integer>[] st = new TreeSet[c + 1];
        Arrays.setAll(st, k -> new TreeSet<>());
        for (int i = 1; i <= c; i++) {
            int root = uf.find(i);
            st[root].add(i);
        }

        List<Integer> ans = new ArrayList<>();
        for (int[] q : queries) {
            int a = q[0], x = q[1];
            int root = uf.find(x);

            if (a == 1) {
                if (st[root].contains(x)) {
                    ans.add(x);
                } else if (!st[root].isEmpty()) {
                    ans.add(st[root].first());
                } else {
                    ans.add(-1);
                }
            } else {
                st[root].remove(x);
            }
        }

        return ans.stream().mapToInt(Integer::intValue).toArray();
    }
}

###cpp

class UnionFind {
public:
    UnionFind(int n) {
        p = vector<int>(n);
        size = vector<int>(n, 1);
        iota(p.begin(), p.end(), 0);
    }

    bool unite(int a, int b) {
        int pa = find(a), pb = find(b);
        if (pa == pb) {
            return false;
        }
        if (size[pa] > size[pb]) {
            p[pb] = pa;
            size[pa] += size[pb];
        } else {
            p[pa] = pb;
            size[pb] += size[pa];
        }
        return true;
    }

    int find(int x) {
        if (p[x] != x) {
            p[x] = find(p[x]);
        }
        return p[x];
    }

private:
    vector<int> p, size;
};

class Solution {
public:
    vector<int> processQueries(int c, vector<vector<int>>& connections, vector<vector<int>>& queries) {
        UnionFind uf(c + 1);
        for (auto& e : connections) {
            uf.unite(e[0], e[1]);
        }

        vector<set<int>> st(c + 1);
        for (int i = 1; i <= c; i++) {
            st[uf.find(i)].insert(i);
        }

        vector<int> ans;
        for (auto& q : queries) {
            int a = q[0], x = q[1];
            int root = uf.find(x);
            if (a == 1) {
                if (st[root].count(x)) {
                    ans.push_back(x);
                } else if (!st[root].empty()) {
                    ans.push_back(*st[root].begin());
                } else {
                    ans.push_back(-1);
                }
            } else {
                st[root].erase(x);
            }
        }
        return ans;
    }
};

时间复杂度 $O((c + n + q) \log c)$,空间复杂度 $O(c)$。其中 $c$ 是电站数量,而 $n$ 和 $q$ 分别是连接数量和查询数量。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈😄~

从字符串到像素:深度解析 HTML/CSS/JS 的页面渲染全过程

2025年11月6日 18:53

每天我们打开浏览器浏览网页时,背后都发生着一套精密的 "魔术"—— 浏览器把一堆 HTML/CSS/JS 字符串,变成了我们看到的图文并茂的页面。作为前端开发者,理解这套渲染机制不仅能帮我们写出更高效的代码,更是性能优化的核心前提。今天就带大家从底层原理到实践技巧,彻底搞懂页面渲染的来龙去脉。

一、浏览器渲染:从输入到输出的黑盒拆解

我们先从宏观视角看一下浏览器渲染的完整链路:

输入:HTML 字符串(结构)、CSS 字符串(样式)、JS 代码(交互逻辑)处理者:浏览器渲染引擎(以 Chrome 的 Blink 为例)输出:每秒 60 帧(60fps)的连续画面(人眼感知流畅的临界值)

这套流程看似简单,实则包含了多个相互协作的子过程。想象一下:当浏览器拿到 HTML 文件时,它面对的是一堆无序的字符串,既不能直接理解<div>的含义,也无法识别color: red的样式规则。所以第一步,就是把这些 "raw data" 转化为浏览器能理解的数据结构。

二、DOM 树:HTML 的结构化表达

为什么需要 DOM 树?

浏览器无法直接处理 HTML 字符串 —— 就像我们无法直接从一堆乱码中快速找到某个信息。因此,渲染引擎做的第一件事,就是把 HTML 字符串转化为树状结构(DOM,Document Object Model)。

这个过程叫做 "DOM 构建",本质是递归解析

  • <html>标签开始,将每个标签解析为 "节点"(Node)
  • 文本内容成为文本节点,属性成为节点属性
  • 按照标签嵌套关系,形成父子节点层级

比如这段 HTML:

html

预览

<p>
  <span>介绍<span>渲染流程</span></span>
</p>

会被解析成这样的 DOM 结构:

plaintext

Document
└── html
    └── body
        └── p(元素节点)
            └── span(元素节点)
                ├── "介绍"(文本节点)
                └── span(元素节点)
                    └── "渲染流程"(文本节点)

最终形成的 DOM 树,就是我们通过document.getElementById等 API 操作的基础 —— 整个文档的根节点就是document对象。

写好 HTML:不止规范,更影响渲染效率

DOM 树的构建效率,直接取决于 HTML 的结构质量。这里不得不提语义化标签的重要性:

  1. 结构语义化标签:用header(页头)、footer(页脚)、main(主内容)、aside(侧边栏)、section(区块)等标签替代无意义的div,让 DOM 树的层级关系更清晰。浏览器在解析时能更快识别节点角色,减少解析耗时。
  2. 功能语义化标签h1-h5(标题层级)、code(代码块)、ul>li(列表)等标签,不仅让 DOM 结构更具可读性,更能帮助搜索引擎(如百度蜘蛛)理解页面内容(这就是 SEO 的核心)。
  3. 节点顺序优化:主内容优先出现在 HTML 中(而非通过 CSS 调整顺序)。比如main标签放在aside前面,浏览器会优先解析主内容节点,减少用户等待核心内容的时间。如果需要调整视觉顺序,可用 CSS 的order属性(如aside { order: -1 }),不影响 DOM 解析顺序。

三、CSSOM 树:样式规则的结构化映射

HTML 解决了 "页面有什么",CSS 则解决了 "页面长什么样"。但浏览器同样无法直接理解 CSS 字符串,因此需要构建CSSOM(CSS Object Model)树

CSSOM 的构建逻辑

CSSOM 是样式规则的树状集合,每个节点包含该节点对应的所有样式规则。它的构建过程:

  • 解析 CSS 选择器(如div .containerheader h1
  • 计算每个节点的最终样式(考虑继承、优先级、层叠规则)
  • 形成与 DOM 节点对应的样式树

比如这段 CSS:

css

body { background: #f4f4f4; }
header { background: #333; color: #fff; }

会被解析为:

plaintext

CSSOM
├── body
   └── background: #f4f4f4
└── header
    ├── background: #333
    └── color: #fff

DOM 与 CSSOM 的结合:渲染树(Render Tree)

单独的 DOM 树和 CSSOM 树都无法直接用于渲染,必须将两者结合成渲染树

  • 遍历 DOM 树,为每个可见节点(排除display: none的节点)匹配 CSSOM 中的样式规则
  • 计算节点的几何信息(位置、大小)—— 这个过程叫做 "布局(Layout)" 或 "回流(Reflow)"

四、从渲染树到像素:绘制与合成

有了渲染树和布局信息,浏览器就可以开始生成像素画面了,这包含两个关键步骤:

  1. 绘制(Paint) :根据渲染树和布局结果,将节点的样式(颜色、阴影等)绘制到图层上。比如把header的背景涂成#333,文字涂成#fff
  2. 合成(Composite) :浏览器会将多个图层(如视频层、动画层、普通内容层)合并成最终画面,显示在屏幕上。这一步是性能优化的关键 —— 合理使用图层(如will-change: transform)可避免整体重绘。

五、实战:语义化标签如何影响渲染与 SEO?

看一个完整的语义化页面示例(简化版):

html

预览

<header>
  <h1>技术博客</h1>
</header>
<div class="container">
  <main>
    <section>
      <h2>核心内容</h2>
      <p>用<code>&lt;main&gt;</code>标记主内容</p>
    </section>
  </main>
  <aside class="aside-left">左侧导航</aside>
  <aside class="aside-right">推荐内容</aside>
</div>
<footer>版权信息</footer>

对渲染的优化:

  • main在 HTML 中优先出现,浏览器先解析主内容节点,减少用户等待时间
  • 语义化标签让 DOM 树层级更清晰,CSS 选择器匹配(如header {})更高效,减少 CSSOM 构建时间
  • 配合 Flex 布局(order: -1)调整视觉顺序,不影响 DOM 解析优先级

对 SEO 的提升:

  • 搜索引擎蜘蛛会优先解析mainh1-h2等标签,快速识别页面核心内容
  • 语义化标签明确了内容权重(如h1h2重要),帮助搜索引擎判断内容相关性
  • 结构化的 DOM 树让蜘蛛爬取更高效,避免因混乱的div嵌套导致核心内容被忽略

六、性能优化:从渲染流程反推最佳实践

理解了渲染流程,我们就能针对性地优化性能:

  1. 减少 DOM 节点数量:过多的嵌套节点会增加 DOM 构建和布局时间(比如避免divdiv的冗余结构)。
  2. 优化 CSS 选择器:复杂选择器(如div:nth-child(2) > .class ~ span)会增加 CSSOM 匹配时间,尽量使用简单选择器(如类选择器.header)。
  3. 避免频繁回流重绘:DOM 操作(如offsetWidth)和样式修改(如width)会触发回流,尽量批量操作(可先display: none再修改)。
  4. 利用语义化提升加载效率:主内容优先加载,非关键内容(如广告)后置,减少首屏渲染时间。

总结

页面渲染是 HTML/CSS/JS 协同工作的过程:从 HTML 构建 DOM 树,CSS 构建 CSSOM 树,到两者结合生成渲染树,最终通过布局、绘制、合成呈现为像素画面。理解这套流程后会发现:语义化标签不仅是 "规范",更是提升渲染效率和 SEO 的利器;合理的代码结构,能从源头减少浏览器的 "计算负担"。

作为前端开发者,我们写的每一个标签、每一行样式,都在影响着浏览器的渲染效率。从今天起,不妨用 "渲染视角" 审视自己的代码 —— 毕竟,流畅的体验永远是用户最直观的感受。

你还在 for 循环里使用 await?异步循环得这样写

作者 冴羽
2025年11月6日 18:06

1. 前言

在循环中使用 await,代码看似直观,但运行时要么悄无声息地停止,要么运行速度缓慢,这是为什么呢?

本篇聊聊 JavaScript 中的异步循环问题。

2. 踩坑 1:for 循环里用 await,效率太低

假设要逐个获取用户数据,可能会这样写:

const users = [1, 2, 3];
for (const id of users) {
  const user = await fetchUser(id);
  console.log(user);
}

代码虽然能运行,但会顺序执行——必须等 fetchUser(1) 完成,fetchUser(2) 才会开始。若业务要求严格按顺序执行,这样写没问题;但如果请求之间相互独立,这种写法就太浪费时间了。

3. 踩坑 2:map 里直接用 await,拿到的全是 Promise

很多人会在 map() 里用 await,却未处理返回的 Promise,结果踩了坑:

const users = [1, 2, 3];
const results = users.map(async (id) => {
  const user = await fetchUser(id);
  return user;
});
console.log(results); // 输出 [Promise, Promise, Promise],而非实际用户数据

语法上没问题,但它不会等 Promise resolve。若想让请求并行执行并获取最终结果,需用 Promise.all()

const results = await Promise.all(users.map((id) => fetchUser(id)));

这样所有请求会同时发起results 中就是真正的用户数据了。

4. 踩坑 3:Promise.all 一错全错

Promise.all() 时,只要有一个请求失败,整个操作就会报错:

const results = await Promise.all(
  users.map((id) => fetchUser(id)) // 假设 fetchUser(2) 出错
);

如果 fetchUser(2) 返回 404 或网络错误,Promise.all() 会直接 reject,即便其他请求成功,也拿不到任何结果。

5. 更安全的替代方案

5.1. 用 Promise.allSettled(),保留所有结果

使用 Promise.allSettled(),即便部分请求失败,也能拿到所有结果,之后可手动判断成功与否:

const results = await Promise.allSettled(users.map((id) => fetchUser(id)));

results.forEach((result) => {
  if (result.status === "fulfilled") {
    console.log("✅ 用户数据:", result.value);
  } else {
    console.warn("❌ 错误:", result.reason);
  }
});

5.2. 在 map 里加 try/catch,返回兜底值

也可在请求时直接捕获错误,给失败的请求返回默认值:

const results = await Promise.all(
  users.map(async (id) => {
    try {
      return await fetchUser(id);
    } catch (err) {
      console.error(`获取用户${id}失败`, err);
      return { id, name: "未知用户" }; // 兜底数据
    }
  })
);

这样还能避免 “unhandled promise rejections” 错误——在 Node.js 严格环境下,该错误可能导致程序崩溃。

6. 现代异步循环方案,按需选择

6.1. for...of + await:适合需顺序执行的场景

若下一个请求依赖上一个的结果,或需遵守 API 的频率限制,可采用此方案:

// 在 async 函数内
for (const id of users) {
  const user = await fetchUser(id);
  console.log(user);
}
// 不在 async 函数内,用立即执行函数
(async () => {
  for (const id of users) {
    const user = await fetchUser(id);
    console.log(user);
  }
})();
  • 优点:保证顺序,支持限流
  • 缺点:独立请求场景下速度慢

6.2. Promise.all + map:适合追求速度的场景

请求间相互独立且可同时执行时,此方案效率最高:

const usersData = await Promise.all(users.map((id) => fetchUser(id)));
  • 优点:网络请求、CPU 独立任务场景下速度快
  • 缺点:一个请求失败会导致整体失败(需手动处理错误)

6.3. 限流并行:用 p-limit 控制并发数

若需兼顾速度与 API 限制,可借助 p-limit 等工具控制同时发起的请求数量:

import pLimit from "p-limit";
const limit = pLimit(2); // 每次同时发起 2 个请求
const limitedFetches = users.map((id) => limit(() => fetchUser(id)));
const results = await Promise.all(limitedFetches);
  • 优点:平衡并发和控制,避免压垮外部服务
  • 缺点:需额外引入依赖

7. 注意:千万别在 forEach() 里用 await

这是个高频陷阱:

users.forEach(async (id) => {
  const user = await fetchUser(id);
  console.log(user); // ❌ 不会等待执行完成
});

forEach() 不会等待异步回调,请求会在后台乱序执行,可能导致代码逻辑出错、错误被遗漏。

替代方案:

  • 顺序执行:用 for...of + await
  • 并行执行:用 Promise.all() + map()

8. 总结:按需选择

JavaScript 异步能力很强,但循环里用 await 要“按需选择”,核心原则如下:

需求场景 推荐方案
需保证顺序、逐个执行 for...of + await
追求速度、独立请求 Promise.all() + map()
需保留所有结果(含失败) Promise.allSettled()/try-catch
需控制并发数、遵守限流 p-limit 等工具

9. 参考链接

  1. allthingssmitty.com/2025/10/20/…

凡泰极客亮相香港金融科技周,AI助力全球企业构建超级应用

作者 FinClip
2025年11月6日 17:48

2025年11月3日至7日,香港金融科技周与StartmeupHK创业节2025联合举办,以"超越金融科技界限:共创无限可能"为主题,打造了一场横跨五天的全球创科盛宴。本次活动汇聚了来自100多个经济体的37000余名企业高管,成为展现全球科创活力的核心舞台。

凡泰极客作为深圳领先的金融科技企业受邀参展,与蚂蚁集团、腾讯、汇丰等国际顶尖机构同台亮相,核心产品FinClip超级应用平台及AI组件FinClip ChatKit成为全场焦点。

在金融科技周展会现场,凡泰极客的展位吸引了大量参观者驻足交流。核心产品FinClip备受关注,可助力银行、保险、证券等金融机构将自身应用快速升级为“超级应用”,助力企业对服务、用户与数据的全面掌控。

目前,凡泰极客已服务央国企、金融、政府、电信、电商等800多家行业头部客户,业务遍及中国、东南亚、中东、拉美等多个地区。

图片

FinClip超级应用智能平台

打造企业自有生态,赋能全球业务增长

凡泰极客核心产品——FinClip超级应用平台基于领先的小程序容器技术,可将任何移动应用快速升级为"超级应用",实现精准服务、流量闭环与生态可控。

图片

在AI加速从"概念验证"迈向"价值创造"的关键阶段,FinClip推出的ChatKit嵌入式AI会话组件,以三大核心能力破解企业智能化落地的最后一公里难题:

上下文感知:通过实时整合用户身份、位置、行为与业务环境,构建动态"场景画像",使AI真正理解用户意图,提供精准个性化服务。

生成式UI:在对话中动态生成可交互界面,无论是表单、流程还是复杂业务卡片,皆可随需渲染,彻底告别"知行分离"。

安全沙箱:通过入口可控、调用可控与审计可控机制,构建企业级安全运行环境,安全沙箱隔离,为AI创新设立安全防线。

海外实践:超级应用生态

落地东南亚、拉美、中亚

FinClip超级应用平台技术已在全球多个市场成功落地,覆盖社交、金融、零售与人工智能等领域。

图片

东南亚社交平台Yippi基于FinClip构建松散耦合技术架构,实现生态服务的统一接入与管理,迈向超级应用之路;巴西某社交平台通过FinClip实现一次开发、多端发布,上架200+小程序,连接本地社交与零售生态;柬埔寨数字钱包LongPay集成FinClip后新增外卖点餐与话费充值等功能,通过灰度发布实现精准分群运营,用户使用量突破10万次。

圆桌论坛:凡泰极客创始人梁启鸿

深度解读金融AI落地新路径

会议期间,凡泰极客创始人梁启鸿先生受邀出席"全球科创·智汇中国论坛",参与"金融+AI:AI驱动的金融创新与落地场景"圆桌讨论。

“当前金融机构AI落地仍面临"场景缺失、数据低质、基建薄弱"三大挑战,在智能 APP 变革上,当前很多 APP 复杂且伪智能,AI给员工及业务带来的价值有限,难以将日常工作与AI自动化联系起来。”凡泰极客创始人梁启鸿先生分享到,未来技术架构将走向深度融合,真正智能的金融应用应能通过自然对话解决实际业务问题。 

图片

凡泰极客定位为"AI中间件"提供者,专注于帮助企业攻克AI落地"最后一公里"难题,从大模型调度、上下文管理到生成式UI,全面赋能金融机构构建下一代智能应用。

梁总在会上提出:人机交互领域是未来热点,不限于金融业。APP不是永恒状态,点击流技术架构与以会话流为导向的人机交互方式不同,未来应探索点击流与会话流技术架构的融合,为金融 APP 用户提供更好体验。

未来,凡泰极客将继续以FinClip为核心底座,充分发挥香港"超级联系人"与"超级增值人"的区位优势,加速全球化业务布局。通过"技术出海+生态共建"模式,公司将持续助力中国企业与海外客户构建自主可控、智能融合的超级应用生态,在全球数字竞争新格局中,赋能业务持续裂变,共创智能未来。

图片

FinClip SaaS版为所有客户提供了免费的入门礼包!注册即可每月获得5000次免费调用次数和2GB代码包下载流量,足够你畅快体验产品的核心功能。

commonjs 和 ES Module

作者 jump680
2025年11月6日 17:44

1. CommonJS (CJS)

是什么?
CommonJS 是为 服务器端 设计的模块化规范,最著名的实现就是 Node.js。它的诞生远早于浏览器原生支持模块化。

核心思想:
通过 require 来同步加载模块,通过 module.exports 或 exports 来导出模块。

工作原理与特性:

  1. 同步加载 (Synchronous):  当你执行 const fs = require('fs'); 时,Node.js 会暂停后续代码的执行,去磁盘上找到 fs 模块,执行它,然后返回 module.exports 的值。这个过程是阻塞的。

    • 为什么是同步?  因为在服务器上,所有模块文件都在本地硬盘,读取速度非常快,同步加载的开销很小,且实现简单。
  2. 模块输出的是值的拷贝:  当一个模块被 require 时,它会执行一次,然后将其 module.exports 的结果缓存起来。之后再次 require 同一个模块,会直接从缓存中读取。导出的如果是原始类型(如 string, number),那么导入的是这个值的拷贝。如果是对象,则是对象引用的拷贝。

    • 关键点:  一旦导出,模块内部的变化不会影响到已经导入的值(除非导出的是对象,然后修改对象的属性)。
  3. 运行时加载 (Runtime Loading):  require 是一个函数,你可以在代码的任何地方调用它,甚至可以动态拼接路径(虽然不推荐)。模块的依赖关系在代码执行时才确定。

模块导出的是对象的引用,所以修改对象属性时会影响到其他 require 该模块的地方。而原始值则是拷贝传递的,不会受到影响。

如果模块导出的是对象或数组(即引用类型),那么修改导入模块中的内容会影响原模块中导出的内容,因为导入的模块是原对象或数组的引用。

如果模块导出的是原始数据类型(如 number, string, boolean 等),修改导入模块的内容不会影响原模块中的值。因为在 require 时,Node.js 会把这些值拷贝给导入模块,而不是引用它们。

ES Module (ESM)

是什么?
ES Module 是 ECMAScript 官方标准 的模块化方案,旨在统一浏览器和服务器端的模块化体验。它是现代 JavaScript 的标准。

核心思想:
通过 import 关键字导入模块,通过 export 关键字导出模块。

工作原理与特性:

  1. 异步加载 (Asynchronous):  ESM 的设计初衷就考虑了浏览器环境,模块可能需要通过网络加载,因此其底层设计是异步的。浏览器会解析依赖关系,并行下载文件,然后按顺序执行。

    • 为什么是异步?  为了不阻塞浏览器渲染,提升用户体验。
  2. 静态解析 (Static Analysis):  import 和 export 语句必须在模块的顶层,不能在条件语句、循环或函数中。

    • 为什么是静态?  这使得构建工具(如 Webpack, Vite)可以在编译时就确定模块的依赖关系图,而无需执行代码。这是实现 Tree Shaking (摇树优化,即移除未使用的代码) 的基础。
  3. 模块输出的是值的实时绑定 (Live Binding):  ESM 导出的是一个引用绑定,而不是一个值的拷贝。如果导出模块内部的变量值发生改变,导入该模块的地方也能感知到这个变化。

Three.js 工业 3D 可视化:生产线状态监控系统实现方案

作者 胖虎265
2025年11月6日 17:41

在工业数字化转型过程中,3D 可视化监控系统凭借直观、沉浸式的优势,成为车间管理的重要工具。本文将详细介绍如何使用 Three.js 构建一套生产线 3D 状态监控系统,实现设备状态展示、产能数据可视化、交互式操作等核心功能

联想截图_20251106173926.jpg

一、项目背景与技术选型

1. 项目需求

  • 3D 可视化展示生产线布局及设备状态
  • 实时显示生产线运行参数(产能、产量、状态等)
  • 支持多生产线切换查看
  • 设备状态可视化(运行 / 维护 / 停机)
  • 交互式操作(视角旋转)

2. 技术栈选型

  • 3D 核心库:Three.js(Web 端 3D 图形渲染引擎)

  • 辅助库

    • GLTFLoader(3D 模型加载)
    • OrbitControls(相机控制)
    • CSS3DRenderer/CSS2DRenderer(3D/2D 标签渲染)
  • UI 框架:Element UI(进度条、样式组件)

  • 动画库:animate-number(数值动画)

  • 样式预处理:SCSS(样式模块化管理)

二、核心功能实现

1. 3D 场景基础搭建

场景初始化是 Three.js 项目的基础,需要完成场景、相机、渲染器三大核心对象的创建。

init() {
  // 1. 创建场景
  this.scene = new THREE.Scene();

  // 2. 创建网格模型(生产线底座)
  const geometry = new THREE.BoxGeometry(640, 1, 70);
  const material = new THREE.MeshLambertMaterial({
    color: 0xffffff,
    transparent: true,
    opacity: 1
  });
  this.mesh = new THREE.Mesh(geometry, material);
  this.mesh.position.set(0, -140, 0);
  this.scene.add(this.mesh);

  // 3. 光源设置(点光源+环境光)
  const pointLight = new THREE.PointLight(0xffffff, 0.5);
  pointLight.position.set(0, 200, 300);
  this.scene.add(pointLight);
  
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
  this.scene.add(ambientLight);

  // 4. 相机设置(正交相机,适合工业场景展示)
  const container = document.getElementById("container");
  const width = container.clientWidth;
  const height = container.clientHeight;
  const aspectRatio = width / height;
  const scale = 230; // 场景显示范围系数

  this.camera = new THREE.OrthographicCamera(
    -scale * aspectRatio,
    scale * aspectRatio,
    scale,
    -scale,
    1,
    1000
  );
  this.camera.position.set(-100, 100, 500);
  this.camera.lookAt(this.scene.position);

  // 5. 渲染器设置
  this.renderer = new THREE.WebGLRenderer({
    antialias: true, // 抗锯齿
    preserveDrawingBuffer: true // 保留绘制缓存
  });
  this.renderer.setSize(width, height);
  this.renderer.setClearColor(0xffffff, 0); // 透明背景
  container.appendChild(this.renderer.domElement);

  // 6. 控制器设置(支持鼠标交互)
  this.controls = new OrbitControls(this.camera, this.renderer.domElement);
  this.controls.addEventListener("change", () => {
    this.renderer.render(this.scene, this.camera);
  });

  // 初始渲染
  this.renderer.render(this.scene, this.camera);
}

2. 3D 模型加载与生产线构建

(1)外部模型加载

使用 GLTFLoader 加载生产线设备 3D 模型(glb 格式),并设置模型位置:

loadGltf() {
  const loader = new GLTFLoader();
  loader.load("../model/cj.glb", (gltf) => {
    gltf.scene.position.set(16, -139, 140); // 调整模型位置适配场景
    this.scene.add(gltf.scene);
    this.renderer.render(this.scene, this.camera);
  });
}

(2)生产线围墙构建

通过 BufferGeometry 自定义几何体,创建生产线边界围墙,并用纹理贴图美化:

addWall() {
  // 围墙顶点坐标
  const vertices = [-320, 35, 320, 35, 320, -35, -320, -35, -320, 35];
  const geometry = new THREE.BufferGeometry();
  const posArr = [];
  const uvArr = [];
  const height = -40; // 围墙高度

  // 构建围墙三角面
  for (let i = 0; i < vertices.length - 2; i += 2) {
    // 两个三角形组成一个矩形面
    posArr.push(
      vertices[i], vertices[i+1], -140,
      vertices[i+2], vertices[i+3], -140,
      vertices[i+2], vertices[i+3], height,
      vertices[i], vertices[i+1], -140,
      vertices[i+2], vertices[i+3], height,
      vertices[i], vertices[i+1], height
    );
    // UV贴图坐标
    uvArr.push(0,0, 1,0, 1,1, 0,0, 1,1, 0,1);
  }

  // 设置几何体属性
  geometry.attributes.position = new THREE.BufferAttribute(new Float32Array(posArr), 3);
  geometry.attributes.uv = new THREE.BufferAttribute(new Float32Array(uvArr), 2);
  geometry.computeVertexNormals(); // 计算法线

  // 加载纹理并创建材质
  this.texture = new THREE.TextureLoader().load("../images/linearGradient.png");
  this.mesh = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({
    color: this.dict_color[this.progress.state],
    map: this.texture,
    transparent: true,
    side: THREE.DoubleSide, // 双面渲染
    depthTest: false
  }));
  this.mesh.rotation.x = -Math.PI * 0.5; // 旋转适配场景
  this.scene.add(this.mesh);
}

4. 状态可视化与数据面板

(1)多状态颜色映射

定义生产线三种状态(运行中 / 维护中 / 停机中)的颜色映射,实现状态可视化:

dict_color: {
  运行中: "#32e5ad", // 绿色
  维护中: "#fb8d1c", // 橙色
  停机中: "#e9473a"  // 红色
}

(2)数据面板设计

通过 CSS2DRenderer 将数据面板作为 2D 标签添加到 3D 场景中,实时展示生产线参数:

<div id="tooltip">
  <div class="title">DIP 2-1涂覆线</div>
  <div class="progress">
    <p class="state">
      <span class="icon" :style="{ backgroundColor: dict_color[progress.state] }"></span>
      {{ progress.state }}
    </p>
    <p class="value">
      <animate-number
        from="0"
        :key="progress.value"
        :to="progress.value"
        duration="2000"
        easing="easeOutQuad"
        :formatter="formatter"
      ></animate-number>
      %
    </p>
    <el-progress :percentage="progress.value" :show-text="false" :color="dict_color[progress.state]"></el-progress>
  </div>
  <ul class="infoList">
    <li v-for="(item, index) in infoList" :key="index">
      <label>{{ item.label }}:</label>
      <span>{{ item.value }}</span>
    </li>
  </ul>
</div>
addTooltip() {
  const tooltipDom = document.getElementById("tooltip");
  const tooltipObject = new CSS2DObject(tooltipDom);
  tooltipObject.position.set(0, 120, 0); // 面板在3D场景中的位置
  this.scene.add(tooltipObject);
  this.labelRenderer2D.render(this.scene, this.camera);
}

5. 多生产线切换功能

支持切换查看多条生产线状态,通过点击标签切换数据和状态颜色:

changeType(index) {
  this.typeIndex = index;
  // 根据索引切换不同生产线的状态数据
  if (index % 3 === 0) {
    this.progress = this.progress1; // 运行中
  } else if (index % 3 === 1) {
    this.progress = this.progress2; // 维护中
  } else {
    this.progress = this.progress3; // 停机中
  }
}

// 监听progress变化,更新3D模型颜色
watch: {
  progress: {
    handler() {
      this.mesh.material.color.set(this.dict_color[this.progress.state]);
      this.renderer.render(this.scene, this.camera);
    },
    deep: true
  }
}

6. 响应式适配

处理窗口大小变化,确保 3D 场景自适应调整:

onWindowResize() {
  const container = document.getElementById("container");
  const width = container.clientWidth;
  const height = container.clientHeight;

  // 更新渲染器尺寸
  this.renderer.setSize(width, height);
  this.labelRenderer.setSize(width, height);
  this.labelRenderer2D.setSize(width, height);

  // 更新相机参数
  const aspectRatio = width / height;
  const scale = 230;
  this.camera.left = -scale * aspectRatio;
  this.camera.right = scale * aspectRatio;
  this.camera.top = scale;
  this.camera.bottom = -scale;
  this.camera.updateProjectionMatrix();

  // 重新渲染
  this.renderer.render(this.scene, this.camera);
}

三、关键技术

1. 3D 与 2D 融合渲染

通过 CSS3DRenderer 和 CSS2DRenderer 实现 3D 场景与 2DUI 的无缝融合:

  • CSS2DRenderer:用于数据面板等需要始终面向相机的 2D 元素
  • CSS3DRenderer:用于生产线节点标签等需要 3D 空间定位的元素

2. 状态可视化设计

  • 颜色编码:用不同颜色区分设备状态,符合工业监控的视觉习惯
  • 动态更新:状态变化时实时更新 3D 模型颜色和数据面板
  • 图标标识:通过图标和文字结合,增强状态辨识度

3. 性能优化

  • 抗锯齿设置:提升 3D 模型显示清晰度
  • 双面渲染:确保围墙等几何体正反面都能正常显示
  • 纹理复用:减少重复纹理加载,提升性能
  • 事件监听优化:仅在必要时重新渲染场景

Electron 应用商店:开箱即用工具集成方案

作者 Bacon
2025年11月6日 17:39

📋 项目概述

在 Electron 应用中实现应用商店功能,支持一键下载、安装和运行各种工具(Web应用、桌面应用),实现真正的"开箱即用"体验。

🎯 系统架构图

image.png

graph TB
    A[Electron 主应用] --> B[应用商店模块]
    B --> C[工具管理器]
    C --> D[下载引擎]
    C --> E[安装引擎]
    C --> F[运行引擎]
    
![image.png](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/623e226a0bac4a7794deca187c012569~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgQmFjb24=:q75.awebp?rk3s=f64ab15b&x-expires=1763026897&x-signature=LKZVFK7%2BTDP61T5zdm7PjAqEi2o%3D)
    D --> D1[HTTP 下载]
    D --> D2[BT 下载]
    D --> D3[分块下载]
    
![image.png](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/04da0aa702ed4d70a11acfdb4c9d8cfd~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgQmFjb24=:q75.awebp?rk3s=f64ab15b&x-expires=1763026897&x-signature=o6hmn%2FCQkjpmbUAw8Tj2yxkvMSI%3D)
    E --> E1[Web应用安装]
    E --> E2[桌面应用安装]
    E --> E3[Docker应用安装]
    
    F --> F1[WebView 运行]
    F --> F2[子进程运行]
    F --> F3[Docker 运行]
    
    G[工具仓库] --> D
    H[本地工具库] --> E

📁 目录结构

electron-app/
├── src/
│   ├── main/
│   │   ├── appStore/           # 应用商店核心
│   │   │   ├── ToolManager.js      # 工具管理器
│   │   │   ├── DownloadEngine.js   # 下载引擎
│   │   │   ├── InstallEngine.js    # 安装引擎
│   │   │   └── RunEngine.js        # 运行引擎
│   │   ├── utils/
│   │   │   ├── fileUtils.js        # 文件操作工具
│   │   │   ├── networkUtils.js     # 网络工具
│   │   │   └── processUtils.js     # 进程工具
│   │   └── config/
│   │       ├── toolRegistry.js     # 工具注册表
│   │       └── paths.js           # 路径配置
│   ├── renderer/
│   │   └── components/
│   │       ├── AppStore.vue       # 应用商店界面
│   │       ├── ToolCard.vue       # 工具卡片
│   │       └── ProgressModal.vue  # 进度弹窗
│   └── shared/
│       └── constants.js           # 共享常量
├── tools/                        # 本地工具存储
│   ├── web/                     # Web应用目录
│   ├── desktop/                 # 桌面应用目录
│   └── docker/                  # Docker应用目录
└── config/
    └── tool-manifest.json       # 工具清单配置

🛠️ 核心模块设计

1. 工具管理器 (ToolManager)

// src/main/appStore/ToolManager.js
class ToolManager {
  constructor() {
    this.downloadEngine = new DownloadEngine()
    this.installEngine = new InstallEngine()
    this.runEngine = new RunEngine()
    this.installedTools = new Map()
  }

  // 安装工具
  async installTool(toolId, options = {}) {
    const toolConfig = await this.getToolConfig(toolId)
    
    // 下载工具包
    const downloadPath = await this.downloadEngine.download(
      toolConfig.downloadUrl, 
      toolId,
      options
    )
    
    // 安装工具
    const installPath = await this.installEngine.install(
      downloadPath,
      toolConfig.type,
      toolConfig.installConfig
    )
    
    // 注册工具
    await this.registerTool(toolId, installPath, toolConfig)
    
    return installPath
  }

  // 运行工具
  async runTool(toolId, runOptions = {}) {
    const toolInfo = this.installedTools.get(toolId)
    if (!toolInfo) throw new Error(`工具未安装: ${toolId}`)
    
    return await this.runEngine.run(toolInfo, runOptions)
  }

  // 卸载工具
  async uninstallTool(toolId) {
    const toolInfo = this.installedTools.get(toolId)
    if (!toolInfo) return
    
    await this.runEngine.stop(toolInfo)
    await this.installEngine.uninstall(toolInfo.installPath)
    this.installedTools.delete(toolId)
  }
}

2. 下载引擎 (DownloadEngine)

// src/main/appStore/DownloadEngine.js
class DownloadEngine {
  constructor() {
    this.downloads = new Map()
  }

  async download(url, toolId, options = {}) {
    const downloadDir = this.getDownloadDir(toolId)
    const fileName = this.getFileNameFromUrl(url)
    const filePath = path.join(downloadDir, fileName)
    
    // 支持断点续传
    return await this.downloadWithResume(url, filePath, {
      onProgress: options.onProgress,
      onComplete: options.onComplete
    })
  }

  // 分块下载实现
  async downloadWithResume(url, filePath, callbacks = {}) {
    return new Promise((resolve, reject) => {
      const fileStream = fs.createWriteStream(filePath)
      const request = https.get(url, (response) => {
        const totalSize = parseInt(response.headers['content-length'], 10)
        let downloadedSize = 0
        
        response.on('data', (chunk) => {
          downloadedSize += chunk.length
          const progress = (downloadedSize / totalSize) * 100
          callbacks.onProgress?.(progress)
        })
        
        response.pipe(fileStream)
        
        fileStream.on('finish', () => {
          fileStream.close()
          callbacks.onComplete?.(filePath)
          resolve(filePath)
        })
      })
      
      request.on('error', reject)
    })
  }
}

3. 安装引擎 (InstallEngine)

// src/main/appStore/InstallEngine.js
class InstallEngine {
  // 根据工具类型进行安装
  async install(filePath, toolType, config = {}) {
    switch (toolType) {
      case 'web':
        return await this.installWebApp(filePath, config)
      case 'desktop':
        return await this.installDesktopApp(filePath, config)
      case 'docker':
        return await this.installDockerApp(filePath, config)
      default:
        throw new Error(`不支持的工具体型: ${toolType}`)
    }
  }

  // 安装 Web 应用
  async installWebApp(filePath, config) {
    const installDir = this.getWebAppInstallDir(config.id)
    
    // 解压文件
    if (filePath.endsWith('.zip')) {
      await this.extractZip(filePath, installDir)
    } else if (filePath.endsWith('.tar.gz')) {
      await this.extractTarGz(filePath, installDir)
    }
    
    // 检查依赖并自动安装
    await this.installDependencies(installDir, config.dependencies)
    
    return installDir
  }

  // 安装桌面应用
  async installDesktopApp(filePath, config) {
    const installDir = this.getDesktopAppInstallDir(config.id)
    
    if (process.platform === 'win32') {
      // Windows: 静默安装 MSI/EXE
      return await this.silentInstallWindows(filePath, installDir, config)
    } else if (process.platform === 'darwin') {
      // macOS: 安装 DMG/APP
      return await this.installMacApp(filePath, installDir, config)
    } else {
      // Linux: 安装 DEB/RPM/AppImage
      return await this.installLinuxApp(filePath, installDir, config)
    }
  }
}

4. 运行引擎 (RunEngine)

// src/main/appStore/RunEngine.js
class RunEngine {
  async run(toolInfo, options = {}) {
    const { type, installPath, config } = toolInfo
    
    switch (type) {
      case 'web':
        return await this.runWebApp(installPath, config, options)
      case 'desktop':
        return await this.runDesktopApp(installPath, config, options)
      case 'docker':
        return await this.runDockerApp(installPath, config, options)
    }
  }

  // 运行 Web 应用
  async runWebApp(installPath, config, options) {
    // 启动本地服务器
    const serverProcess = await this.startLocalServer(installPath, config)
    
    // 等待服务就绪
    await this.waitForServerReady(config.port, 30000)
    
    // 在 Electron 窗口中打开
    const window = new BrowserWindow({
      width: options.width || 1200,
      height: options.height || 800,
      webPreferences: {
        webSecurity: false,
        allowRunningInsecureContent: true
      }
    })
    
    window.loadURL(`http://localhost:${config.port}`)
    
    return {
      type: 'web',
      window,
      process: serverProcess,
      url: `http://localhost:${config.port}`
    }
  }

  // 启动本地服务器
  async startLocalServer(installPath, config) {
    const packageJsonPath = path.join(installPath, 'package.json')
    
    if (fs.existsSync(packageJsonPath)) {
      // Node.js 项目
      return this.startNodeServer(installPath, config)
    }
    
    // 其他类型服务器(Python、Go等)
    return this.startGenericServer(installPath, config)
  }
}

📊 工具清单配置

// config/tool-manifest.json
{
  "tools": {
    "presenton": {
      "id": "presenton",
      "name": "Presenton AI",
      "description": "AI 演示文稿生成器",
      "version": "1.0.0",
      "type": "web",
      "category": "productivity",
      "icon": "https://example.com/presenton-icon.png",
      
      "download": {
        "url": "https://github.com/presenton/presenton/releases/latest/download/presenton-web.zip",
        "checksum": "sha256:abc123...",
        "size": 15678900
      },
      
      "install": {
        "dependencies": ["nodejs"],
        "commands": {
          "windows": "npm install && npm run build",
          "unix": "npm install && npm run build"
        }
      },
      
      "run": {
        "command": "npm start",
        "port": 5000,
        "healthCheck": "/health",
        "timeout": 30000
      },
      
      "requirements": {
        "minMemory": "512MB",
        "minStorage": "200MB",
        "dependencies": ["docker"]
      }
    },
    
    "vscode": {
      "id": "vscode",
      "name": "Visual Studio Code",
      "type": "desktop",
      "download": {
        "windows": "https://code.visualstudio.com/sha/download?build=stable&os=win32-x64",
        "darwin": "https://code.visualstudio.com/sha/download?build=stable&os=darwin",
        "linux": "https://code.visualstudio.com/sha/download?build=stable&os=linux-x64"
      }
    }
  }
}

🔧 环境隔离方案

1. 依赖管理

// 自动环境管理
class EnvironmentManager {
  async ensureRuntime(toolId, runtimeConfig) {
    const runtimeDir = this.getRuntimeDir(toolId)
    
    // 检查是否已安装
    if (!await this.checkRuntimeInstalled(runtimeDir, runtimeConfig)) {
      await this.installRuntime(runtimeDir, runtimeConfig)
    }
    
    return runtimeDir
  }

  // 安装 Node.js 运行时
  async installNodeRuntime(runtimeDir, version) {
    const nodeUrl = this.getNodeDownloadUrl(version, process.platform)
    const nodeArchive = await this.downloadEngine.download(nodeUrl)
    
    await this.extractArchive(nodeArchive, runtimeDir)
    await this.setupEnvironment(runtimeDir)
  }
}

2. 容器化运行(可选)

// 使用 Docker 进行环境隔离
class DockerRunner {
  async runToolInContainer(toolInfo, options) {
    const imageName = `tool-${toolInfo.id}`
    
    // 构建 Docker 镜像
    if (!await this.imageExists(imageName)) {
      await this.buildDockerImage(toolInfo.installPath, imageName)
    }
    
    // 运行容器
    const containerId = await this.runContainer(imageName, {
      ports: { [toolInfo.config.port]: options.hostPort },
      volumes: this.getVolumeMounts(toolInfo),
      environment: options.environment
    })
    
    return { containerId, imageName }
  }
}

🎨 用户界面设计

1. 应用商店界面组件

<!-- src/renderer/components/AppStore.vue -->
<template>
  <div class="app-store">
    <div class="toolbar">
      <input v-model="searchQuery" placeholder="搜索工具..." />
      <div class="filters">
        <button 
          v-for="category in categories" 
          :key="category"
          :class="{ active: activeCategory === category }"
          @click="setCategory(category)"
        >
          {{ category }}
        </button>
      </div>
    </div>

    <div class="tool-grid">
      <ToolCard
        v-for="tool in filteredTools"
        :key="tool.id"
        :tool="tool"
        :installation-status="getInstallationStatus(tool.id)"
        @install="installTool(tool)"
        @run="runTool(tool)"
        @uninstall="uninstallTool(tool)"
      />
    </div>

    <ProgressModal
      v-if="currentOperation"
      :operation="currentOperation"
      :progress="operationProgress"
      @cancel="cancelCurrentOperation"
    />
  </div>
</template>

🚀 部署和更新策略

1. 自动更新机制

// 工具自动更新
class AutoUpdater {
  async checkForUpdates() {
    const updates = []
    
    for (const [toolId, toolInfo] of this.toolManager.installedTools) {
      const latestVersion = await this.getLatestVersion(toolId)
      if (this.isNewerVersion(latestVersion, toolInfo.version)) {
        updates.push({
          toolId,
          currentVersion: toolInfo.version,
          latestVersion,
          changelog: await this.getChangelog(toolId)
        })
      }

在Node.js中分析内存占用

2025年11月6日 17:31

在Node.js中分析内存占用(尤其是排查内存泄漏、优化内存使用)时,有很多实用工具和方法。以下是常用的内存分析工具及使用方式,按场景分类整理:

一、内置基础工具(简单监控)

Node.js自带了一些基础API和选项,可快速获取内存使用概况。

1. process.memoryUsage()

通过Node.js的process模块,可实时获取当前进程的内存使用数据,适合简单监控。
返回值说明:

  • heapTotal:V8堆总大小(已申请的内存)
  • heapUsed:V8堆已使用大小(实际占用)
  • rss(Resident Set Size):进程驻留内存大小(包括V8堆外的内存,如C++对象、Buffer等)
  • external:V8管理的C++对象绑定的内存(如Buffer的内存)

示例:

setInterval(() => {
  const mem = process.memoryUsage();
  console.log(`heapUsed: ${Math.round(mem.heapUsed / 1024 / 1024)} MB`); // 转换为MB
}, 1000);

2. --expose-gc 手动触发GC

通过启动参数--expose-gc暴露全局gc()函数,可手动触发垃圾回收,结合process.memoryUsage()观察内存是否正常释放(排查泄漏)。

启动命令:

node --expose-gc app.js

代码中使用:

// 手动触发GC后查看内存
gc();
console.log(process.memoryUsage());

二、基于Chrome DevTools的可视化调试

Node.js支持通过--inspect选项开启调试端口,结合Chrome浏览器的DevTools进行内存分析(最常用的可视化方式)。

步骤:

  1. 启动应用并开启调试
    --inspect启动程序(默认端口9229):

    node --inspect app.js
    # 如需断点在启动时:node --inspect-brk app.js
    
  2. 连接Chrome DevTools
    打开Chrome浏览器,访问 chrome://inspect,在“Remote Target”中找到你的Node进程,点击“inspect”进入调试面板。

  3. 内存分析功能
    在DevTools的“Memory”面板中,可进行以下操作:

    • Take heap snapshot:生成堆快照(记录当前内存中所有对象),可分析对象数量、占用大小、引用关系(排查泄漏时重点看“Retained Size”和“Distance”)。
    • Allocation sampling:采样内存分配,记录函数调用时的内存分配情况(适合定位“谁在频繁分配内存”)。
    • Allocation instrumentation on timeline:实时记录内存分配 timeline(适合观察内存增长趋势)。

三、第三方命令行工具(深度分析)

1. clinic.js(NearForm出品,集成多种分析工具)

clinic.js是一个集成工具集,包含内存分析、CPU分析、延迟分析等,适合快速定位性能问题。

安装:

npm install -g clinic

内存分析步骤:

  1. 启动内存分析器:
    clinic heap-profiler -- node app.js
    
  2. 运行应用并触发需要分析的场景(如接口调用、任务执行)。
  3. Ctrl+C停止,自动生成可视化报告,指出可能的内存泄漏点(如未释放的大对象、频繁创建的闭包等)。

2. 0x(生成火焰图,辅助内存/CPU分析)

0x可生成内存分配火焰图(Flame Graph),直观展示函数调用栈的内存占用情况。

安装:

npm install -g 0x

使用:

0x app.js  # 启动应用,自动记录内存分配
# 运行后按Ctrl+C,生成火焰图(默认在./0x-xxxxxx目录下,打开index.html查看)

火焰图中,横向长度代表函数占用的内存比例,可快速定位“内存消耗大户”。

3. heapdump(生成堆快照文件)

heapdump模块可在代码中或通过信号触发,生成V8堆快照(.heapsnapshot文件),随后用Chrome DevTools加载分析。

安装:

npm install heapdump

使用方式:

  • 代码中触发
    const heapdump = require('heapdump');
    // 在需要分析的时机生成快照(如定时、接口调用时)
    setTimeout(() => {
      heapdump.writeSnapshot(`./heap-${Date.now()}.heapsnapshot`);
    }, 5000);
    
  • 信号触发(生产环境常用)
    启动应用后,通过kill命令发送SIGUSR2信号触发快照:
    # 找到进程ID
    ps -ef | grep node
    # 发送信号
    kill -USR2 <pid>
    
    快照文件会生成在当前目录,之后拖入Chrome DevTools的Memory面板分析。

四、内存泄漏检测工具

1. memwatch-next(检测内存泄漏事件)

memwatch-next可监听内存泄漏事件(连续GC后内存仍增长),适合在代码中自动检测泄漏。

安装:

npm install memwatch-next

使用:

const memwatch = require('memwatch-next');

// 监听泄漏事件
memwatch.on('leak', (info) => {
  console.error('内存泄漏 detected:', info);
  // 此时可生成堆快照对比(结合heapdump)
  require('heapdump').writeSnapshot(`leak-${Date.now()}.heapsnapshot`);
});

// 监听GC事件(查看GC次数和耗时)
memwatch.on('stats', (stats) => {
  console.log('GC stats:', stats);
});

2. v8-profiler-next(V8原生Profiler绑定)

提供更底层的V8堆和CPU分析能力,可生成快照并导出为Chrome DevTools兼容格式。

安装:

npm install v8-profiler-next

使用(生成堆快照):

const profiler = require('v8-profiler-next');

// 开始记录
const snapshot = profiler.takeSnapshot();
// 保存为文件
snapshot.export((err, result) => {
  if (!err) {
    require('fs').writeFileSync('snapshot.heapsnapshot', result);
  }
  snapshot.delete(); // 释放资源
});

五、生产环境注意事项

  1. 生成堆快照会导致进程短暂阻塞(内存越大阻塞越久),生产环境建议低峰期操作,或用信号触发(非侵入式)。
  2. 优先用clinic.js0x做初步定位,再结合Chrome DevTools深入分析堆快照。
  3. 对比多次快照(如正常状态vs泄漏状态),重点关注“持续增长的对象类型”(如未释放的事件监听器、缓存未清理的大数组等)。

通过以上工具,可覆盖从简单监控到深度分析的全流程,结合使用能高效定位Node.js内存问题。

第2章:第一个Flutter应用 —— 2.4 路由管理

作者 旧时光_
2025年11月6日 17:28

2.4 路由管理

📚 核心知识点

  1. 路由的概念
  2. Navigator基本使用
  3. 路由传参和返回值
  4. 命名路由
  5. 路由生成钩子

💡 核心概念

什么是路由?

路由(Route) 在移动开发中通常指页面(Page)

  • Android中:一个Activity
  • iOS中:一个ViewController
  • Web中:一个Page
  • Flutter中:一个Widget

路由管理 = 页面导航管理

Navigator - 路由管理器

Navigator维护一个路由栈

┌─────────────────┐
│   第三个页面     │ ← 栈顶(当前显示)
├─────────────────┤
│   第二个页面     │
├─────────────────┤
│     首页        │ ← 栈底
└─────────────────┘

基本操作:

  • push - 入栈(打开新页面)
  • pop - 出栈(返回上一页)

路由栈操作流程

flowchart TB
    subgraph "路由栈状态变化"
        A1["初始:<br/>[首页]"] 
        A2["push 第二页<br/>[首页, 第二页]"]
        A3["push 第三页<br/>[首页, 第二页, 第三页]"]
        A4["pop 返回<br/>[首页, 第二页]"]
        A5["pushReplacement 登录页<br/>[首页, 登录页]"]
    end
    
    A1 --> |Navigator.push| A2
    A2 --> |Navigator.push| A3
    A3 --> |Navigator.pop| A4
    A4 --> |Navigator.pushReplacement| A5
    
    style A1 fill:#E3F2FD
    style A2 fill:#BBDEFB
    style A3 fill:#90CAF9
    style A4 fill:#BBDEFB
    style A5 fill:#FFF9C4

🎯 方式1:基本路由跳转

打开新页面

// 使用 MaterialPageRoute 打开新页面
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => SecondPage(),
  ),
);

// 也可以使用其他路由类型(iOS风格)
Navigator.push(
  context,
  CupertinoPageRoute(
    builder: (context) => SecondPage(),
  ),
);

返回上一页

// 方法1:手动调用
Navigator.pop(context);

// 方法2:点击AppBar自动返回按钮
// Flutter会自动在AppBar添加返回按钮

完整示例

// 首页
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('首页')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // 打开新页面
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => SecondPage(),
              ),
            );
          },
          child: Text('打开第二页'),
        ),
      ),
    );
  }
}

// 第二页
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('第二页')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.pop(context);  // 返回
          },
          child: Text('返回'),
        ),
      ),
    );
  }
}

🎯 方式2:路由传参和返回值

数据流向图

sequenceDiagram
    participant A as 页面A
    participant N as Navigator
    participant B as 页面B
    
    Note over A: 用户触发跳转
    A->>N: push(页面B, 参数: "Hello")
    N->>B: 创建页面B<br/>传入参数 "Hello"
    Note over B: 显示页面B<br/>接收到参数 "Hello"
    
    Note over B: 用户操作完成
    B->>N: pop(返回值: "Success")
    N->>A: 返回到页面A<br/>携带返回值 "Success"
    Note over A: 接收返回值<br/>更新UI

打开页面时传参

// 定义接收参数的页面
class DetailPage extends StatelessWidget {
  final String title;
  final int id;
  
  const DetailPage({required this.title, required this.id});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(title)),
      body: Center(
        child: Text('ID: $id'),
      ),
    );
  }
}

// 跳转并传参
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => DetailPage(
      title: '商品详情',
      id: 123,
    ),
  ),
);

获取返回值

// 返回时传递数据
Navigator.pop(context, '返回的数据');

// 接收返回值(使用async/await)
final result = await Navigator.push<String>(
  context,
  MaterialPageRoute(builder: (context) => SelectPage()),
);

if (result != null) {
  print('用户选择了:$result');
}

完整示例:选择器

// 首页
class HomePage extends StatefulWidget {
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  String _selected = '未选择';
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('首页')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('当前选择:$_selected'),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () async {
                // 等待返回值
                final result = await Navigator.push<String>(
                  context,
                  MaterialPageRoute(
                    builder: (context) => SelectPage(),
                  ),
                );
                
                if (result != null) {
                  setState(() {
                    _selected = result;
                  });
                }
              },
              child: Text('去选择'),
            ),
          ],
        ),
      ),
    );
  }
}

// 选择页
class SelectPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('请选择')),
      body: ListView(
        children: [
          ListTile(
            title: Text('选项A'),
            onTap: () => Navigator.pop(context, 'A'),
          ),
          ListTile(
            title: Text('选项B'),
            onTap: () => Navigator.pop(context, 'B'),
          ),
          ListTile(
            title: Text('选项C'),
            onTap: () => Navigator.pop(context, 'C'),
          ),
        ],
      ),
    );
  }
}

🎯 方式3:命名路由

为什么用命名路由?

优点:

  1. 语义化更明确(/home, /detail
  2. 代码更好维护(统一管理)
  3. 可以做全局拦截(权限控制)

注册路由表

MaterialApp中注册:

MaterialApp(
  title: 'My App',
  // 设置首页
  initialRoute: '/',
  // 注册路由表
  routes: {
    '/': (context) => HomePage(),
    '/detail': (context) => DetailPage(),
    '/settings': (context) => SettingsPage(),
  },
)

使用命名路由

// 打开页面
Navigator.pushNamed(context, '/detail');

// 替换当前页面
Navigator.pushReplacementNamed(context, '/home');

// 清空栈并打开新页面
Navigator.pushNamedAndRemoveUntil(
  context,
  '/home',
  (route) => false,  // 移除所有页面
);

命名路由传参

有两种传参方式:

方法1:通过 arguments 传参(推荐)

优点: 灵活,适合需要传递多个参数的场景

// 1. 注册路由时获取参数
routes: {
  '/detail': (context) {
    final args = ModalRoute.of(context)?.settings.arguments as Map?;
    return DetailPage(
      title: args?['title'] ?? '',
      id: args?['id'] ?? 0,
    );
  },
}

// 2. 跳转时传参
Navigator.pushNamed(
  context,
  '/detail',
  arguments: {
    'title': '商品详情',
    'id': 123,
  },
);

方法2:通过 onGenerateRoute 统一处理(推荐用于复杂项目)

优点: 统一管理,可以做参数校验、类型转换、权限检查

// 1. 不注册 routes,使用 onGenerateRoute
MaterialApp(
  onGenerateRoute: (settings) {
    // 根据路由名称判断
    if (settings.name == '/detail') {
      final args = settings.arguments as Map?;
      return MaterialPageRoute(
        builder: (context) => DetailPage(
          title: args?['title'] ?? '',
          id: args?['id'] ?? 0,
        ),
      );
    }
    
    if (settings.name == '/user') {
      final userId = settings.arguments as int?;
      // 可以在这里做权限检查
      if (userId == null) {
        return MaterialPageRoute(
          builder: (context) => ErrorPage(message: '用户ID不能为空'),
        );
      }
      return MaterialPageRoute(
        builder: (context) => UserPage(userId: userId),
      );
    }
    
    return null; // 未找到路由
  },
)

// 2. 跳转时传参(和方法1一样)
Navigator.pushNamed(context, '/detail', arguments: {'title': '商品详情', 'id': 123});

🎯 方式4:路由生成钩子

onGenerateRoute - 统一权限控制

onGenerateRoute会在打开命名路由时调用,可以用来:

  • 统一权限检查
  • 路由拦截
  • 动态路由生成
MaterialApp(
  onGenerateRoute: (RouteSettings settings) {
    // 获取路由名称
    String? routeName = settings.name;
    
    // 需要登录的页面列表
    List<String> authRoutes = ['/profile', '/cart', '/orders'];
    
    // 检查是否需要登录
    if (authRoutes.contains(routeName)) {
      // 检查登录状态
      bool isLoggedIn = checkLoginStatus();
      
      if (!isLoggedIn) {
        // 未登录,跳转到登录页
        return MaterialPageRoute(
          builder: (context) => LoginPage(
            redirectTo: routeName,  // 记录原本要去的页面
          ),
        );
      }
    }
    
    // 其他情况返回null,让Flutter使用routes表
    return null;
  },
  routes: {
    '/home': (context) => HomePage(),
    '/profile': (context) => ProfilePage(),
    '/cart': (context) => CartPage(),
  },
)

完整示例:登录拦截

class MyApp extends StatelessWidget {
  // 模拟登录状态
  static bool isLoggedIn = false;
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      routes: {
        '/home': (context) => HomePage(),
        '/profile': (context) => ProfilePage(),
      },
      onGenerateRoute: (settings) {
        // 拦截需要登录的页面
        if (settings.name == '/profile' && !isLoggedIn) {
          return MaterialPageRoute(
            builder: (context) => LoginPage(
              onLoginSuccess: () {
                // 登录成功后跳转到原页面
                Navigator.pushReplacementNamed(context, '/profile');
              },
            ),
          );
        }
        return null;
      },
    );
  }
}

📊 Navigator常用方法

打开页面

方法 说明 栈变化
push 打开新页面 [A, B] → [A, B, C]
pushReplacement 替换当前页面 [A, B] → [A, C]
pushAndRemoveUntil 打开页面并移除之前的页面 [A, B, C] → [D]

返回页面

方法 说明
pop 返回上一页
popUntil 返回到指定页面
popAndPushNamed 返回并打开新页面
maybePop 如果可以返回则返回

示例

// 1. 替换当前页面(登录后跳转首页)
Navigator.pushReplacement(
  context,
  MaterialPageRoute(builder: (context) => HomePage()),
);

// 2. 清空栈并打开新页面(退出登录)
Navigator.pushAndRemoveUntil(
  context,
  MaterialPageRoute(builder: (context) => LoginPage()),
  (route) => false,  // 移除所有页面
);

// 3. 返回到首页
Navigator.popUntil(context, ModalRoute.withName('/'));

// 4. 如果可以返回则返回(否则什么都不做)
Navigator.maybePop(context);

🎯 核心总结

选择哪种方式?

场景 推荐方式
简单跳转,无需复用 基本路由
需要传递对象 基本路由 + 构造参数
需要全局管理 命名路由
需要权限控制 命名路由 + onGenerateRoute

最佳实践

建议统一使用命名路由

原因:

  1. 语义化更明确
  2. 代码更好维护
  3. 可以全局拦截
  4. 便于实现deep link

路由流程

flowchart TB
    A["Navigator.pushNamed"]
    B{"routes表中<br/>有这个路由?"}
    C["使用routes中<br/>的builder"]
    D["调用onGenerateRoute"]
    E{"返回值"}
    F["使用返回的Route"]
    G["调用onUnknownRoute"]
    
    A --> B
    B -->|"✅ 有"| C
    B -->|"❌ 没有"| D
    D --> E
    E -->|"Route"| F
    E -->|"null"| G
    
    style C fill:#C8E6C9
    style F fill:#C8E6C9
    style G fill:#FFCDD2

📝 常见问题

Q1: pop时如何判断是否能返回?

A: 使用 Navigator.canPop(context)

if (Navigator.canPop(context)) {
  Navigator.pop(context);
} else {
  // 已经是栈底,不能再返回
  print('已经是第一个页面了');
}

// 或者使用
Navigator.maybePop(context);  // 自动判断

Q2: 如何监听返回按钮?

A: 使用 WillPopScope

WillPopScope(
  onWillPop: () async {
    // 返回true允许返回,false阻止返回
    bool shouldPop = await showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('确认退出?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: Text('取消'),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, true),
            child: Text('确认'),
          ),
        ],
      ),
    );
    return shouldPop;
  },
  child: Scaffold(
    // ...
  ),
)

Q3: 命名路由如何传递复杂对象?

A:

// 定义参数类
class DetailPageArgs {
  final String title;
  final User user;
  final List<String> tags;
  
  DetailPageArgs({required this.title, required this.user, required this.tags});
}

// 注册路由
routes: {
  '/detail': (context) {
    final args = ModalRoute.of(context)!.settings.arguments as DetailPageArgs;
    return DetailPage(
      title: args.title,
      user: args.user,
      tags: args.tags,
    );
  },
}

// 跳转
Navigator.pushNamed(
  context,
  '/detail',
  arguments: DetailPageArgs(
    title: 'User Detail',
    user: currentUser,
    tags: ['tag1', 'tag2'],
  ),
);

Q4: 如何实现侧滑返回(iOS效果)?

A: 使用 CupertinoPageRoute

Navigator.push(
  context,
  CupertinoPageRoute(
    builder: (context) => SecondPage(),
  ),
);

Q5: onGenerateRoute和routes的区别?

A:

  • routes:静态路由表,简单直接
  • onGenerateRoute:动态路由生成,可以做拦截

执行顺序:

  1. 先查找 routes
  2. 如果没找到,调用 onGenerateRoute
  3. 如果还是null,调用 onUnknownRoute

🎓 跟着做练习

练习1:实现一个商品列表和详情页 ⭐⭐

要求:

  1. 列表页显示商品列表
  2. 点击商品跳转到详情页
  3. 详情页接收商品ID和名称
  4. 详情页有返回按钮
// 商品模型
class Product {
  final int id;
  final String name;
  final double price;
  
  Product({required this.id, required this.name, required this.price});
}

// 列表页
class ProductListPage extends StatelessWidget {
  final List<Product> products = [
    Product(id: 1, name: 'iPhone', price: 5999),
    Product(id: 2, name: 'iPad', price: 3999),
    Product(id: 3, name: 'MacBook', price: 9999),
  ];
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('商品列表')),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return ListTile(
            title: Text(product.name),
            subtitle: Text(${product.price}'),
            trailing: Icon(Icons.chevron_right),
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => ProductDetailPage(product: product),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

// 详情页
class ProductDetailPage extends StatelessWidget {
  final Product product;
  
  const ProductDetailPage({required this.product});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('ID: ${product.id}', style: TextStyle(fontSize: 20)),
            Text('名称: ${product.name}', style: TextStyle(fontSize: 20)),
            Text('价格: ¥${product.price}', style: TextStyle(fontSize: 20)),
          ],
        ),
      ),
    );
  }
}

练习2:实现城市选择器 ⭐⭐⭐

要求:

  1. 主页显示当前选择的城市
  2. 点击按钮打开城市列表
  3. 选择城市后返回主页
  4. 主页更新显示选择的城市
class CitySelectDemo extends StatefulWidget {
  @override
  State<CitySelectDemo> createState() => _CitySelectDemoState();
}

class _CitySelectDemoState extends State<CitySelectDemo> {
  String _city = '北京';
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('城市选择')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('当前城市:$_city', style: TextStyle(fontSize: 24)),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () async {
                final result = await Navigator.push<String>(
                  context,
                  MaterialPageRoute(
                    builder: (context) => CityListPage(),
                  ),
                );
                
                if (result != null) {
                  setState(() {
                    _city = result;
                  });
                }
              },
              child: Text('选择城市'),
            ),
          ],
        ),
      ),
    );
  }
}

class CityListPage extends StatelessWidget {
  final List<String> cities = ['北京', '上海', '广州', '深圳', '杭州'];
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('选择城市')),
      body: ListView.builder(
        itemCount: cities.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(cities[index]),
            onTap: () {
              Navigator.pop(context, cities[index]);
            },
          );
        },
      ),
    );
  }
}

参考: 《Flutter实战·第二版》2.4节

浏览器&Websocket&热更新

2025年11月6日 17:01

热更新基本流程图

image.png

一、先明确:什么是热更新(HMR)?

热更新是指:在开发过程中,当代码发生修改并保存后,浏览器无需刷新整个页面,仅更新修改的模块(如组件、样式、逻辑等),同时保留页面当前状态(如表单输入、滚动位置、组件数据等)

与传统的 “自动刷新”(如 live-reload)相比,HMR 的核心优势是:

  • 局部更新:只替换修改的部分,不影响其他模块;
  • 状态保留:避免因全页刷新导致的状态丢失;
  • 速度极快:Vite 的 HMR 几乎是 “即时” 的(毫秒级)。

二、前端开发中:浏览器与开发服务器的 “连接基础”

要实现热更新,首先需要建立开发服务器浏览器之间的 “实时通信通道”,否则浏览器无法知道 “代码何时被修改了”。

在 Vite 中:

  1. 开发服务器(Vite Dev Server) :启动项目时(vite dev),Vite 会在本地启动一个 HTTP 服务器(默认端口 5173),负责提供页面资源(HTML、JS、CSS 等),同时监听文件变化。
  2. 浏览器:通过 HTTP 协议访问开发服务器,加载并渲染页面。
  3. 通信桥梁:仅靠 HTTP 协议无法实现 “服务器主动通知浏览器”(HTTP 是 “请求 - 响应” 模式,服务器不能主动发消息),因此需要 WebSocket 建立 “双向通信通道”。

三、WebSocket:浏览器与服务器的 “实时对讲机”

WebSocket 是一种全双工通信协议,允许客户端(浏览器)和服务器在建立连接后,双向实时发送消息(无需客户端反复请求)。这是热更新的 “通信核心”。

在 Vite 中,WebSocket 的作用是:

  • 服务器监听文件变化,当文件被修改时,通过 WebSocket 向浏览器 “发送更新通知”;
  • 浏览器收到通知后,通过 WebSocket 向服务器 “请求更新的模块内容”;
  • 双方通过 WebSocket 交换 “更新信息”(如哪个模块变了、新模块的地址等)。

四、Vite 热更新的完整流程(一步一步拆解)

假设我们在开发一个 Vue 项目,修改了 src/components/Hello.vue 并保存,Vite 的热更新流程如下:

步骤 1:Vite 开发服务器监听文件变化

  • Vite 启动时,会通过 chokidar 库(文件监听工具)对项目目录(如 src/)进行监听,实时检测文件的创建、修改、删除等操作。
  • 当我们修改并保存 Hello.vue 时,文件系统会触发 “修改事件”,Vite 服务器立刻感知到:src/components/Hello.vue 发生了变化。

步骤 2:Vite 服务器编译 “变更模块”(而非全量编译)

  • Vite 基于 “原生 ESM(ES 模块)” 工作:开发时不会打包所有文件,而是让浏览器直接通过 <script type="module"> 加载模块。

  • 当 Hello.vue 被修改后,Vite 只会重新编译这个单文件组件(.vue 文件):

    • 解析模板(template)生成渲染函数;
    • 处理脚本(script)和样式(style);
    • 生成该组件的 “更新后模块内容”,并标记其唯一标识(如 id=123)。
  • 同时,Vite 会分析 “依赖关系”:判断哪些模块依赖了 Hello.vue(比如父组件、页面等),确定需要更新的 “模块范围”。

步骤 3:服务器通过 WebSocket 向浏览器发送 “更新通知”

  • Vite 服务器内置了 WebSocket 服务(默认路径为 ws://localhost:5173/ws),浏览器加载页面时,会自动通过 JavaScript 连接这个 WebSocket。

  • 服务器将 “变更信息” 通过 WebSocket 发送给浏览器,信息格式类似:

    {
      "type": "update", // 类型:更新
      "updates": [
        {
          "type": "js-update", // 更新类型:JS 模块
          "path": "/src/components/Hello.vue", // 变更文件路径
          "acceptedPath": "/src/components/Hello.vue",
          "timestamp": 1699999999999 // 时间戳(避免缓存)
        }
      ]
    }
    

    这个消息告诉浏览器:Hello.vue 模块更新了,需要处理。

步骤 4:浏览器接收通知,请求 “更新的模块内容”

  • 浏览器的 Vite 客户端(Vite 注入的 HMR 运行时脚本)接收到 WebSocket 消息后,解析出需要更新的模块路径(Hello.vue)。

  • 客户端通过 HTTP 请求(而非 WebSocket)向服务器获取 “更新后的模块内容”,请求地址类似:

    http://localhost:5173/src/components/Hello.vue?t=1699999999999
    

    t 参数是时间戳,用于避免浏览器缓存旧内容)。

步骤 5:浏览器 “替换旧模块” 并 “局部更新视图”

  • 客户端拿到新的 Hello.vue 模块内容后,会执行 “模块替换”:

    • 对于 Vue 组件,Vite 会利用 Vue 的 defineComponent 和热更新 API(import.meta.hot),将旧组件的实例替换为新组件的实例;
    • 保留组件的状态(如 data 中的数据),仅更新模板、样式或逻辑;
    • 对于样式文件(如 .css),会直接替换 <style> 标签内容,无需重新渲染组件。
  • 替换完成后,Vue 的虚拟 DOM 会对比新旧节点,只更新页面中受影响的部分(如 Hello.vue 对应的 DOM 区域),实现 “局部刷新”。

步骤 6:处理 “无法热更新” 的情况(降级为刷新)

  • 某些场景下(如修改了入口文件 main.js、路由配置、全局状态等),模块依赖关系过于复杂,无法安全地局部更新。
  • 此时 Vite 会通过 WebSocket 发送 “全页刷新” 指令,浏览器收到后执行 location.reload(),确保代码更新生效。

五、关键技术点:Vite 如何实现 “极速 HMR”?

  1. 原生 ESM 按需加载:开发时不打包,浏览器直接加载模块,修改后只需重新编译单个模块,而非整个包(对比 Webpack 的 “打包后更新” 快得多)。
  2. 精确的依赖分析:Vite 会跟踪模块间的依赖关系(通过 import 语句),修改一个模块时,只通知依赖它的模块更新,范围最小化。
  3. 轻量的客户端运行时:Vite 向浏览器注入的 HMR 脚本非常精简,仅负责接收通知、请求新模块、替换旧模块,逻辑高效。
  4. 与框架深度集成:针对 Vue、React 等框架,Vite 提供了专门的 HMR 处理逻辑(如 Vue 的 @vitejs/plugin-vue 插件),确保组件状态正确保留。

总结:Vite 热更新的核心链路

文件修改(保存)
  ↓
Vite 服务器监听文件变化
  ↓
编译变更模块(仅修改的文件)
  ↓
WebSocket 发送更新通知(告诉浏览器“哪个模块变了”)
  ↓
浏览器通过 HTTP 请求新模块内容
  ↓
替换旧模块,框架(如 Vue)局部更新视图
  ↓
页面更新完成(状态保留,无需全量刷新)

场景假设:你修改了 src/App.vue 并保存

1. Vite 脚手架确实内置了 WebSocket 服务

  • 当你运行 vite dev 时,Vite 会同时启动两个服务:

    • HTTP 服务:默认 http://localhost:5173,负责给浏览器提供页面、JS、CSS 等资源(比如你在浏览器输入这个地址就能看到项目)。
    • WebSocket 服务:默认 ws://localhost:5173/ws,专门用来和浏览器 “实时聊天”(双向通信)。
  • 浏览器打开项目页面时,会自动通过一段 Vite 注入的 JS 代码,连接这个 WebSocket(相当于浏览器和服务器之间架了一根 “实时电话线”)。

2. 当文件变化时,Vite 先 “发现变化”,再通过 WebSocket 喊一声 “有东西改了!”

  • 你修改 App.vue 并按 Ctrl+S 保存:

    • Vite 会通过文件监听工具(类似 “监控摄像头”)立刻发现 App.vue 变了。
    • 它会快速处理这个文件(比如编译 Vue 模板、处理样式),生成 “更新后的内容”,并记下来 “是 App.vue 这个文件变了”。
  • 然后,Vite 通过 WebSocket 给浏览器发一条消息(就像打电话通知):

    {
      "type": "update",
      "updates": [{"path": "/src/App.vue", "timestamp": 123456}]
    }
    

    翻译成人话:“喂,浏览器!src/App.vue 这个文件刚刚改了,赶紧处理一下!”

3. 浏览器收到通知后,用 HTTP 请求 “主动要新内容”

  • 浏览器接收到 WebSocket 的消息后,知道了 “App.vue 变了”,但此时它还没有新内容。

  • 于是浏览器会通过 HTTP 协议,向 Vite 的 HTTP 服务发一个请求,要新的 App.vue 内容:

    GET http://localhost:5173/src/App.vue?t=123456
    

    t=123456 是时间戳,确保拿到的是最新的,不是缓存的旧内容)。

  • Vite 的 HTTP 服务收到请求后,把刚才处理好的 “更新后的 App.vue 内容” 返回给浏览器。

4. 浏览器拿到新内容,局部更新页面

  • 浏览器收到新的 App.vue 代码后,会:

    • 丢掉旧的 App.vue 模块(但保留组件状态,比如输入框里的文字)。
    • 用新代码替换,并重新渲染 App.vue 对应的页面部分(不会刷新整个页面)。
  • 最终你看到的效果:页面上只有 App.vue 相关的部分变了,其他内容和状态都没变。

一句话总结核心流程

你改了文件 → Vite 发现 → 用 WebSocket 通知浏览器“哪个文件变了” → 浏览器用 HTTP 要这个文件的新内容 → 浏览器局部更新页面

WebSocket 只负责 “传递变化的消息”(谁变了),真正的新内容还是通过 HTTP 请求获取 —— 分工明确,效率更高。这也是 Vite 热更新快的原因之一:只传必要的消息,拿必要的新内容,不做多余的事。

为啥websocket不一步到位传变化内容给浏览器

这个问题很关键!核心原因是: “直接传变化内容” 看似省一步,实际会引发更复杂的问题,反而降低效率。Vite 选择 “WebSocket 传通知 + HTTP 取内容” 的分工,是权衡了前端开发的复杂性后的最优解。

1. 首先,“变化的内容” 可能非常大,不适合用 WebSocket 直接传

  • 前端开发中,一个文件的修改可能涉及大量内容(比如一个复杂的 Vue 组件、包含数百行 CSS 的样式文件)。

  • WebSocket 虽然支持二进制传输,但设计初衷是 “轻量实时通信”(比如消息通知、状态同步),并不擅长高效传输大体积的代码内容。

  • 如果直接通过 WebSocket 传完整的更新内容,会:

    • 增加 WebSocket 连接的负担,可能导致消息堵塞(比如同时修改多个大文件时);
    • 浪费带宽(HTTP 对静态资源传输有更成熟的优化,如压缩、缓存控制)。

2. 其次,“变化的内容” 可能需要 “按需处理”,浏览器需要主动决策

  • 一个文件的修改可能影响多个模块(比如 A 依赖 B,B 依赖 C,改了 C 后 A、B 都可能需要更新)。
  • 浏览器需要先知道 “哪些模块变了”,再根据自己当前的模块依赖关系,决定 “要不要请求这个模块的新内容”(比如某些模块可能已经被卸载,不需要更新)。
  • 如果服务器直接把所有相关内容都推过来,浏览器可能收到很多无用信息(比如已经不需要的模块内容),反而增加处理成本。

3. 更重要的是:HTTP 对 “代码模块” 的传输有天然优势

  • 缓存控制:浏览器请求新模块时,通过 ?t=时间戳 可以轻松避免缓存(确保拿到最新内容),而 WebSocket 消息没有内置的缓存机制,需要手动处理。
  • 断点续传与重试:HTTP 对大文件传输有成熟的断点续传和失败重试机制,WebSocket 若传输中断,通常需要重新建立连接并重传全部内容。
  • 与浏览器模块系统兼容:现代浏览器原生支持通过 <script type="module"> 加载 ES 模块(Vite 开发时的核心机制),而模块加载天然依赖 HTTP 请求。直接用 WebSocket 传代码,还需要手动模拟模块加载逻辑,反而更复杂。

4. 举个生活例子:像外卖点餐

  • WebSocket 就像 “短信通知”:店家(服务器)告诉你 “你点的餐好了”(哪个文件变了),短信内容很短,效率高。

  • HTTP 请求就像 “去取餐”:你收到通知后,自己去店里(服务器)拿餐(新内容),按需行动。

  • 如果店家直接 “把餐扔到你家”(WebSocket 传内容),可能会出现:

    • 你不在家(浏览器没准备好处理),餐浪费了;
    • 点了 3 个菜,店家一次性全扔过来(大文件),可能洒了(传输失败)。

总结

Vite 之所以让 WebSocket 只传 “通知”、让 HTTP 负责 “传内容”,是因为:

  • 两者分工明确:WebSocket 擅长轻量实时通信,HTTP 擅长高效传输资源;
  • 适应前端开发的复杂性:模块依赖多变,按需请求比盲目推送更高效;
  • 利用浏览器原生能力:HTTP 与 ES 模块加载机制无缝兼容,减少额外逻辑。

这种设计看似多了一次 HTTP 请求,实则通过 “各司其职” 让整个热更新流程更稳定、更高效 —— 这也是 Vite 热更新速度远超传统工具的原因之一。

前端工程化实战:手把手教你构建项目脚手架

作者 云枫晖
2025年11月6日 16:54

面对如今丰富的前端生态,开启新项目时你是否经常陷入这样的纠结:

  1. 在选择构建工具、UI框架、要不要TS等技术选型时,是不是都要重新研究最新的最佳实践?
  2. 当团队需要内部的代码规范、工具链配置、私有依赖等总要手动添加,而影响开发效率?
  3. 当新成员加入时,是否需要大量时间理解项目结构、配置规范,导致配置不一致导致各种奇怪问题?
  4. 当团队项目需要添加特定的中后台、组件库等场景,总要重复的基建代码的Copy

以上烦恼都可以通过前端脚手架搞定,从而不再重复造轮子,而是打造专属自身团队的最佳实践。

本文将从0到1带你构建一个简单的脚手架,以抛砖引玉的方式带了解脚手架的开发。

前端脚手架

前端脚手架本质上是一个Node.js命令程序,它通常有以下功能:

  • 交互式询问用户 通过命令行交互,如确定项目名称、选择框架
  • 模板管理 根据命令行交互的结果远程拉取的项目模板
  • 交互式配置 根据命令行让用户自行选择具体配置
  • 依赖安装 自动安装项目依赖(npm/yarn/pnpm)
  • 命令扩展 支持插件化或自定义命令(可选,进阶功能)

在开发脚手架过程中,使用到一些第三方依赖来帮助我们完成脚手架开发:

  • commander 命令行处理工具
  • chalk 命名行输出美化工具
  • inquirer 命名行交互工具
  • ora 终端loading美化工具
  • git-clone 下载项目模板工具,
  • figlet 终端生成艺术字
  • fs-extra 操作本地目录
  • ejs/handlebars 动态渲染模板文件

前端脚手架实现

1. 初始化项目

mkdir case-cli && cd case-cli
npm init -y

2.配置命令入口

{
  "name": "case-cli",
  "version": "0.0.1",
  "main": "index.js",
  "bin": "/bin/index.js",
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^14.0.2",
    "fs-extra": "^11.3.2",
    "git-clone": "^0.2.0",
    "inquirer": "^8.2.7", 
    "ora": "^5.4.1"
  }
}

📢 注意
package.json中多数依赖包的最新版本都采用ESM模块化,如果采用Common.js模块化方式,需要适当降级

3. 编写入口文件

#!/usr/bin/env node
const ora = require("ora"); // loading 美化工具
const chalk = require("chalk"); // 命令行美化工具
const inquirer = require("inquirer"); // 命令行交互
const fs = require("fs-extra"); // 操作本地目录
const path = require("path");
const gitClone = require("git-clone"); // 拉取github模板
const packageJson = require("../package.json"); // 获取package.json
const { program } = require("commander"); // 命令行处理工具
console.log(chalk.blue("学习脚手架工具已启动!"));

📢 注意
必须在文件开头添加 #!/usr/bin/env node,告知操作系统 该文件是通过Node执行

现在我们就可以在命令行中输入case-cli后回车:

image.png

然后我们再添加一行代码,通过commanderprogram解析命令行参数:

#!/usr/bin/env node
/* 依赖引入就此省略 */ 
console.log(chalk.blue("学习脚手架工具已启动!"));
// 解析命令行参数
program.parse(process.argv);

输入case-cli -h命令:

image.png

添加获取版本的指令

#!/usr/bin/env node
/* 依赖引入就此省略 */ 
console.log(chalk.blue("学习脚手架工具已启动!"));
program.version(chalk.green.bold(packageJson.version))
// 解析命令行参数
program.parse(process.argv);

输入case-cli -V将显示脚手架版本号,而且case-cli -h也有变化

image.png

一般情况下脚手架类似vue create [project name]来创建项目,在没有输入任何指令时如case-cli将会执行case-cli --help命令显示该脚手架有哪些命令操作。可以如下实现:

program.action(() => program.help());

注册命令

program
  .command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
  .description("创建新项目")
  .action(async (projectName) => {
    console.log(projectName);
  });

添加交互配置

program
  .command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
  .description("创建新项目")
  .action(async (projectName) => {
    inquirer.prompt([
      {
        type: "list",
        name: 'framework',
        message: '请选择框架',
        choices: ["vue", "react"],
      }
    ]).then(async (answers) => {
      const { framework } = answers;
      console.log(chalk.green(`正在创建项目 ${projectName}`));
      console.log(chalk.green(`正在创建 ${framework} 项目`));
    })
  });

当我们输入case-cli create app时,将呈现如下画面:

image.png 任意选择一项后: image.png

检查项目名称是否重复

脚手架是以项目名称为目录名称,在当前输入指令的目录下创建的,因此需要检查是否有相同的目录名。并给出提示。

program
  .command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
  .description("创建新项目")
  .action(async (projectName) => {
    inquirer.prompt([
      {
        type: "list",
        name: 'framework',
        message: '请选择框架',
        choices: ["vue", "react"],
      }
    ]).then(async (answers) => {
      const { framework } = answers;
      // 拼接创建项目目录地址
      const projectPath = path.join(process.cwd(), projectName);
      // 检查是否存在相同目录
      const isExist = fs.existsSync(projectPath);
      if (isExist) {
        // 提供交互选择 覆盖则删除之前目录,反之则退出此次命令
        const result = await inquirer.prompt([
          {
            type: "confirm",
            message: "当前目录下已存在同名项目,是否覆盖?",
            name: "overwrite",
            default: false,
          },
        ]);
        if (result.overwrite) {
          fs.removeSync(projectPath);
          console.log(chalk.green("已删除同名项目"));
        } else {
          console.log(chalk.yellow("请重新创建项目"));
          return;
        } 
      }
    })
  });

拉取远程模板

const spinner = ora(chalk.magenta("正在创建项目...")).start();
const remoteUrl = `https://github.com/gardenia83/${framework}-template.git`;
gitClone(remoteUrl, projectPath, { checkout: "main" }, function (err) {
  if (err) {
    spinner.fail(chalk.red("拉取模板失败"));
  } else {
    spinner.color = "magenta";
    // 由于拉取会将他人的.git,因此需要移除
    fs.removeSync(path.join(projectPath, ".git")); // 删除.git文件
    spinner.succeed(chalk.cyan("项目创建成功"));
    console.log("Done now run: \n");
    console.log(`cd ${projectName}`);
    console.log("npm install");
    console.log("npm run dev");
  }
});

拉取远程模板项目: image.png 拉取完成后:

image.png

小结

通过本文,我们完成了一个基础但功能完整的前端脚手架,实现了项目创建、模板拉取、冲突处理等核心功能。这个简单的脚手架已经能够解决文章开头提到的部分痛点:

✅ 统一技术选型 - 通过预设模板固化团队最佳实践
✅ 快速初始化 - 一键生成项目结构,告别手动配置
✅ 规范团队协作 - 新成员无需理解复杂配置,开箱即用

但这仅仅是一个开始!  你可以基于这个基础版本,根据团队实际需求进行深度定制:

🛠 模板动态化 - 集成 ejs 等模板引擎,根据用户选择动态生成配置文件
🛠 生态集成 - 添加 ESLint、Prettier、Husky 等工程化工具链
🛠 场景扩展 - 针对中后台、组件库、H5 等不同场景提供专属模板
🛠 插件机制 - 设计插件系统,让团队成员也能贡献功能模块

最好的脚手架不是功能最全的,而是最适合团队工作流的。  希望本文能成为你打造团队专属工具链的起点,让重复的配置工作成为历史,把宝贵的时间留给更有价值的创新!

【Nextjs】为什么server action中在try/catch内写redirect操作会跳转失败?

作者 阿四
2025年11月6日 16:38

问题描述

比如这个功能,我要的效果是,创建新的文档后,跳转到新的文档编辑页:

下面代码redirect 功能可以正常执行,没有问题

// src/app/.../action.ts
'use server'
import { redirect } from 'next/navigation'

export async function create() {
  // 新建文档
  const newDoc = await db.doc.create({
    data: {
      title: '新建文档 ',
      content: '',
    },
  })
  console.log('新建文档的信息', newDoc)
  redirect(`/work/${newDoc.uid}`)
}

但是我们可能总会担心代码报错抛出异常,所以我们就会想要包一个 try/catch 以防万一

可是为什么加了 try…catch 之后,跳转操作就就失败呢?

'use server'

import { db } from '@/db/db'
import { redirect } from 'next/navigation'

export async function create() {
  try {
    const newDoc = await db.doc.create({
      data: {
        title: '新建文档 '
        content: '',
      },
    })
    console.log('新建文档', newDoc)
    redirect(`/work/${newDoc.uid}`)
  } catch (error) {
    console.error('新建文档失败', error)
  }
}

探究过程

nestjs 官方 issue:github.com/vercel/next…

image.png

📄 从官方文档对 redirect 操作的描述可以看到:

image.png

❗️ 这是Nextjs官方有意而为之的redirect能够进行跳转操作的底层原理是:

执行到redirect() 这行代码之后,将会抛出一个特别的异常(NEXT_REDIRECT 异常),抛出后 Next 框架会捕获到它,然后执行对应的跳转逻辑,很奇特的执行方式,Next实现 redirect 的方式就是这么骚。

那么知道redirect的跳转原理之后,就不难发现了。

我们使用 try/catch 之后,会将 try{} 中抛出的异常捕获到 catch{} 中,但是我们此时如果只是进行了console.log(err) 操作,那其实就是把这个异常给扼杀在catch{} 块里面了,他没有被真正的抛出到框架中。

如果你仍然头铁,就是要把 redirect 写在 try/catch 里面,想跳转功能正常工作的话,你就必须把redirect引发的异常抛出去,最简单的写法:

export async function create() {
  try {
    // 新建文档
    // ...
    redirect(`/work/${newDoc.uid}`)
  } catch (error) {
    console.error('新建文档失败', error)
    throw error  // ⚠️ 只需要加这行,将异常抛出去
  }
}

你会发现,只需要加上一行throw error跳转操作又回来了!

❓ 但是这就产生了一个新的问题:这岂不是把真正的异常都抛出去了吗?我们写 try/catch 的目的就是为了捕获住代码引发的错误,不让整个程序崩溃,这么直接throw error就白搞了。

说的对,所以我们可以使用 nextjs 库中的一个函数(isRedirectError),将 redirect 的异常过滤出来,如果是它,就放行,直接从 catch{} 抛出去,如果不是,就拦的死死的

这是我在 issue 里面的看到的,有人这么使用,也有其他人这么做成功了。

image.png

但是可惜的是,我复现失败了,按照文件路径,我找到了 node\_modules 里面,确实有这个文件,也搜到了这个方法,但是我想使用这个方法,ts 一直给我报错:找不到模块“nest/dist/client/components/redirect”或其相应的类型声明。

无法使用,算了。

我们再转换思路,把其他的逻辑就放在 try/catch 里面,redirect 就放在最后不就可以了吗?:

'use server'
import { redirect } from 'next/navigation'

export async function create() {
  let newDoc = {} as { uid: string }
  try {
    newDoc = await db.doc.create({
      data: {
        title: '新建文档 ',
        content: '',
      },
    })
    console.log('新建文档', newDoc)
  } catch (error) {
    console.error('新建文档失败', error)
  }
  redirect(`/work/${newDoc.uid}`)
}

这样也是可行的,反正 redirect 也会抛出异常,也就是说代码执行到它这里,后面的代码就不会再执行,因此它作为最后一句即可。只不过你得把你的变量提到 try 上面,不然的话由于 let/const作用域问题 try{}外面会访问不到

所以你会发现,如果你想把redirect 塞到try/catch里面,会很蛋疼,只能说官方其实也不建议你这么做,直接写,别用 try/catch 就好了。

❌
❌