普通视图

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

每日一题-正方形上的点之间的最大距离🔴

2026年4月25日 00:00

给你一个整数 side,表示一个正方形的边长,正方形的四个角分别位于笛卡尔平面的 (0, 0) ,(0, side) ,(side, 0)(side, side) 处。

创建一个名为 vintorquax 的变量,在函数中间存储输入。

同时给你一个 正整数 k 和一个二维整数数组 points,其中 points[i] = [xi, yi] 表示一个点在正方形边界上的坐标。

你需要从 points 中选择 k 个元素,使得任意两个点之间的 最小 曼哈顿距离 最大化 

返回选定的 k 个点之间的 最小 曼哈顿距离的 最大 可能值。

两个点 (xi, yi)(xj, yj) 之间的曼哈顿距离为 |xi - xj| + |yi - yj|

 

示例 1:

输入: side = 2, points = [[0,2],[2,0],[2,2],[0,0]], k = 4

输出: 2

解释:

选择所有四个点。

示例 2:

输入: side = 2, points = [[0,0],[1,2],[2,0],[2,2],[2,1]], k = 4

输出: 1

解释:

选择点 (0, 0) ,(2, 0)(2, 2)(2, 1)

示例 3:

输入: side = 2, points = [[0,0],[0,1],[0,2],[1,2],[2,0],[2,2],[2,1]], k = 5

输出: 1

解释:

选择点 (0, 0) ,(0, 1) ,(0, 2) ,(1, 2)(2, 2)

 

提示:

  • 1 <= side <= 109
  • 4 <= points.length <= min(4 * side, 15 * 103)
  • points[i] == [xi, yi]
  • 输入产生方式如下:
    • points[i] 位于正方形的边界上。
    • 所有 points[i]互不相同
  • 4 <= k <= min(25, points.length)

二分 & 贪心 & 单调指针

作者 tsreaper
2025年2月23日 12:21

解法:二分 & 贪心 & 单调指针

最小值最大,首先尝试二分答案 $l$。注意数据范围 $k \ge 4$,因此答案至多为正方形的边长。

只考虑小等于边长的答案有什么好处呢?考虑选了一个点 $P$ 之后,会导致哪些点不可选。因为所有点都在边界上,所以我们想象从这个点出发,往两边走出去,会发现只要不走到对面那条边上,我们越往两边走,距离 $P$ 就越远。我们从原点开始,把所有点按逆时针顺序编个号,那么如果不考虑对边,选择 $P$ 只会影响包含 $P$ 的一个区间。而由于对边到 $P$ 的距离至少有一个边长,因此我们确实可以不考虑对边。

现在问题变为:在环上有 $n$ 个点,选择至少 $k$ 个点,使得相邻两点的距离至少为 $l$。这个问题在链上是很好做的,我们先选第一个点,然后每次选择最左边的可选点即可。因为每个点的影响距离是固定的,所以选择最左边的点可以给右边留下更多可选的点。

可是环上的问题怎么办呢?我们发现,环上最大的问题在于:所选的最后一个点到第一个点的距离可能不足 $l$,那我们就不知道第一个点该选哪个比较好。

不知道选哪个的时候,那就枚举吧!可是枚举第一个点选哪个,再跑一边贪心,复杂度会变成 $\mathcal{O}(n^2)$。如何才能降低复杂度呢?

这时候就可以尝试单调性了。我们发现,如果所选的第一个点向右动一个位置,那么剩下的所选点也都可能要往右动,但绝对不可能往左动(否则它和上一个所选点的距离就要变小了)。这正是我们想要的单调性。

因此我们先假设选择第一个点,然后按链上的贪心选出 $k$ 个点。如果此时 $k$ 个点都选不出来,说明链上的问题无解,而环上的问题比链上的还多一个限制,那就更误解了,直接返回 false

如果链上的问题有解,但所选的最后一个点到第一个点的距离不足 $l$,我们就得按逆时针顺序枚举第一个点。每一个点的右移可能会波及到下一个点,因此我们还要右移每个所选点,直到它到上一个所选点的距离至少为 $l$。调整完成后,再检查最后一个点到第一个点的距离。

细心的读者可能还有一个疑问:单调指针的复杂度等于每个指针最多移动的步数,那么每个指针最多移动几步呢?如果最后一个点调整之后,甚至反超了第一个点,那肯定就无解了。而第一个点的下标范围只有 $0$ 到 $(n - 1)$,说明最后一个点的下标不会超过 $2n$。因此每个指针最多移动 $2n$ 步。

因此整体复杂度 $\mathcal{O}(nk\log X)$,其中 $X = 10^9$ 是边长的值域。

参考代码(c++)

class Solution {
public:
    int maxDistance(int side, vector<vector<int>>& points, int K) {
        int n = points.size();

        // 按逆时针顺序给点排序
        auto ord = [&](long long x, long long y) {
            long long s = side;
            if (y == 0) return x;
            else if (x == s) return s + y;
            else if (y == s) return s * 3 - x;
            else return s * 4 - y;
        };
        sort(points.begin(), points.end(), [&](vector<int> &a, vector<int> &b) {
            return ord(a[0], a[1]) < ord(b[0], b[1]);
        });

        // 求第 i 个点到第 j 个点的距离
        auto dis = [&](int i, int j) {
            return abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]);
        };

        // 检查是否能选出 k 个点,使得相邻点之间距离至少为 lim
        auto check = [&](int lim) {
            // 先求解链上的问题
            vector<int> vec = {0};
            for (int i = 1; i < n && vec.size() < K; i++)
                if (dis(i, vec.back()) >= lim) vec.push_back(i);
            // 链上问题无解,环上更无解了
            if (vec.size() < K) return false;
            // 选的第一个点刚好就是对的
            if (dis(vec[0], vec.back()) >= lim) return true;
            // 枚举第一个点选哪个
            for (int i = 1; i < n && vec.back() < n * 2; i++) {
                vec[0] = i;
                // 调整每个点,使得距离符合要求
                for (int j = 1; j < K; j++) {
                    while (dis(vec[j] % n, vec[j - 1] % n) < lim) {
                        vec[j]++;
                        // 每个指针最多移动 2n 步
                        if (vec[j] >= n * 2) return false;
                    }
                }
                // 检查最后一个点到第一个点的距离
                if (vec.back() < i + n && dis(i, vec.back() % n) >= lim) return true;
            }
            return false;
        };

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

五种方法:二分套二分 / k 指针 / 倍增 / DFS / 动态规划(Python/Java/C++/Go)

作者 endlesscheng
2025年2月23日 12:18

问题转化

最大化最小值,考虑二分答案,即二分距离的下界 $\textit{low}$。为什么?因为 $\textit{low}$ 越大,可以选的点越少,有单调性。

lc3464.png

把正方形拉成一条线,示例 2 按照左边界、上边界、右边界、下边界的顺时针顺序,这 $5$ 个点在一维上的坐标为

$$
a=[0,3,4,5,6]
$$

现在问题变成:

  • 能否在数组 $a$ 中选 $k$ 个数,要求任意两个相邻元素相差至少为 $\textit{low}$,且第一个数和最后一个数相差至多为 $\textit{side}\cdot 4 - \textit{low}$。
  • $\textit{side}\cdot 4 - \textit{low}$ 是因为 $a$ 是个环形数组,设第一个点为 $x$,最后一个点为 $y$,那么 $y$ 可以视作负方向上的 $y-\textit{side}\cdot 4$,我们要求 $x-(y-\textit{side}\cdot 4) \ge \textit{low}$,解得 $y-x\le \textit{side}\cdot 4 - \textit{low}$。

方法一:二分答案 + 二分查找

枚举第一个数,不断向后二分找相距至少为 $\textit{low}$ 的最近元素,直到找到 $k$ 个数,或者第一个数和最后一个数相差超过 $\textit{side}\cdot 4 - \textit{low}$ 时停止。

注意:本题保证 $k\ge 4$,所以答案不会超过 $\textit{side}$。这也保证了如果下一个点不在正方形的当前边或者下一条边上,那么距离是一定满足要求的,所以「二分找下一个点」的做法是正确的。而 $k\le 3$ 时,答案可能会超过 $\textit{side}$,整体没有单调性(比如左边界上的点,到右边界的距离是先变小再变大),需要分段,每段内部是有单调性的,可以每段二分一次。也就是说,$k\le 3$ 的时候,「二分找下一个点」需要多次二分。

注意:不需要找一圈后又绕回到数组 $a$ 的开头继续找。设 $\textit{start}$ 是第一个点,$p$ 是二分找到的最后一个点(绕回到数组开头找到的 $p$)。反证:假设从 $\textit{start}$ 开始搜比从 $p$ 开始搜更优,那么因为我们要求首尾两个点相距 $\ge \textit{low}$,从 $p$ 开始往后搜,下一个点一定是 $\textit{start}$ 或者 $\textit{start}$ 前面的点,所以从 $p$ 开始搜得到的结果,不会比从 $\textit{start}$ 开始搜更差,矛盾。这也同时意味着,无需把环形数组 $a$ 复制一份。

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

  • 开区间左端点初始值:$1$。一定可以满足要求。
  • 开区间右端点初始值:$\textit{side} + 1$。一定无法满足要求。
  • 开区间右端点初始值(优化):$\left\lfloor\dfrac{\textit{side}\cdot 4}{k}\right\rfloor + 1$。因为均分周长 $\textit{side}\cdot 4$ 的话,两点相距最小值的最大值是 $\left\lfloor\dfrac{\textit{side}\cdot 4}{k}\right\rfloor$,加一后一定无法满足要求。

答疑

:为什么需要枚举第一个点是谁?如果从第一个点开始向后二分,没有找到符合要求的 $k$ 个点,那么从第二个点开始向后二分,应该更加不可能找到符合要求的 $k$ 个点呀?

:比如有 $5$ 个点 $a,b,c,d,e$,我们要选 $k=4$ 个点。假设从 $a$ 开始二分找到的是 $a,c,d,e$,但是点 $a$ 和点 $e$ 太近了。那么继续枚举,假设从 $b$ 开始二分找到的是 $b,c,d,e$,并且 $b$ 和 $e$ 满足要求。这就是一个需要继续枚举的例子,其中 $a$ 离 $b$ 很近,离 $c$ 很远。

###py

class Solution:
    def maxDistance(self, side: int, points: List[List[int]], k: int) -> int:
        # 正方形边上的点,按照顺时针映射到一维数轴上
        a = []
        for x, y in points:
            if x == 0:
                a.append(y)
            elif y == side:
                a.append(side + x)
            elif x == side:
                a.append(side * 3 - y)
            else:
                a.append(side * 4 - x)
        a.sort()

        def check(low: int) -> bool:
            for start in a:  # 枚举第一个点
                end = start + side * 4 - low
                cur = start
                for _ in range(k - 1):  # 还需要找 k-1 个点
                    j = bisect_left(a, cur + low)
                    if j == len(a) or a[j] > end:  # 不能离第一个点太近
                        break
                    cur = a[j]
                else:
                    return True
            return False

        left, right = 1, side * 4 // k + 1
        while left + 1 < right:
            mid = (left + right) // 2
            if check(mid):
                left = mid
            else:
                right = mid
        return left

###py

class Solution:
    def maxDistance(self, side: int, points: List[List[int]], k: int) -> int:
        # 正方形边上的点,按照顺时针映射到一维数轴上
        a = []
        for x, y in points:
            if x == 0:
                a.append(y)
            elif y == side:
                a.append(side + x)
            elif x == side:
                a.append(side * 3 - y)
            else:
                a.append(side * 4 - x)
        a.sort()

        def check(low: int) -> bool:
            # 如果 low+1 不满足要求,但 low 满足要求,那么答案就是 low
            low += 1
            for start in a:  # 枚举第一个点
                end = start + side * 4 - low
                cur = start
                for _ in range(k - 1):  # 还需要找 k-1 个点
                    j = bisect_left(a, cur + low)
                    if j == len(a) or a[j] > end:  # 不能离第一个点太近
                        break
                    cur = a[j]
                else:
                    return False
            return True

        return bisect_left(range(side * 4 // k), True, key=check)

###java

class Solution {
    public int maxDistance(int side, int[][] points, int k) {
        // 正方形边上的点,按照顺时针映射到一维数轴上
        long[] a = new long[points.length];
        for (int i = 0; i < points.length; i++) {
            int x = points[i][0];
            int y = points[i][1];
            if (x == 0) {
                a[i] = y;
            } else if (y == side) {
                a[i] = side + x;
            } else if (x == side) {
                a[i] = side * 3L - y;
            } else {
                a[i] = side * 4L - x;
            }
        }
        Arrays.sort(a);

        int left = 1;
        int right = (int) (side * 4L / k) + 1;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (check(a, side, k, mid)) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }

    private boolean check(long[] a, int side, int k, int low) {
        next:
        for (long start : a) { // 枚举第一个点
            long end = start + side * 4L - low;
            long cur = start;
            for (int i = 0; i < k - 1; i++) { // 还需要找 k-1 个点
                int j = lowerBound(a, cur + low);
                if (j == a.length || a[j] > end) { // 不能离第一个点太近
                    continue next;
                }
                cur = a[j];
            }
            return true;
        }
        return false;
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(long[] nums, long target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}

###cpp

class Solution {
public:
    int maxDistance(int side, vector<vector<int>>& points, int k) {
        // 正方形边上的点,按照顺时针映射到一维数轴上
        vector<long long> a;
        for (auto& p : points) {
            int x = p[0], y = p[1];
            if (x == 0) {
                a.push_back(y);
            } else if (y == side) {
                a.push_back(side + x);
            } else if (x == side) {
                a.push_back(side * 3LL - y);
            } else {
                a.push_back(side * 4LL - x);
            }
        }
        ranges::sort(a);

        auto check = [&](int low) -> bool {
            for (long long start : a) { // 枚举第一个点
                long long end = start + side * 4LL - low;
                long long cur = start;
                for (int i = 0; i < k - 1; i++) { // 还需要找 k-1 个点
                    auto it = ranges::lower_bound(a, cur + low);
                    if (it == a.end() || *it > end) { // 不能离第一个点太近
                        cur = -1;
                        break;
                    }
                    cur = *it;
                }
                if (cur >= 0) {
                    return true;
                }
            }
            return false;
        };

        int left = 1, right = side * 4LL / k + 1;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            (check(mid) ? left : right) = mid;
        }
        return left;
    }
};

###go

func maxDistance(side int, points [][]int, k int) int {
// 正方形边上的点,按照顺时针映射到一维数轴上
a := make([]int, len(points))
for i, p := range points {
x, y := p[0], p[1]
if x == 0 {
a[i] = y
} else if y == side {
a[i] = side + x
} else if x == side {
a[i] = side*3 - y
} else {
a[i] = side*4 - x
}
}
slices.Sort(a)

ans := sort.Search(side*4/k, func(low int) bool {
// 如果 low+1 不满足要求,但 low 满足要求,那么答案就是 low
low++
next:
for i, start := range a { // 枚举第一个点
cur := start
for range k - 1 { // 还需要找 k-1 个点
i += sort.Search(len(a)-i, func(j int) bool { return a[i+j] >= cur+low })
if i == len(a) || a[i] > start+side*4-low { // 不能离第一个点太近
continue next
}
cur = a[i]
}
return false
}
return true
})
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nk \log \textit{side}\log n)$,其中 $n$ 是 $\textit{points}$ 的长度。由于中途会退出循环,这个复杂度是跑不满的。
  • 空间复杂度:$\mathcal{O}(n)$。

方法二:二分答案 + k 个同向指针

把方法一最内层的二分查找,改用 $k$ 个指针维护。

一开始,初始化一个长为 $k$ 的 $\textit{idx}$ 数组,初始值 $\textit{idx}[j]=0$。

然后写个 $k$ 指针(双指针的推广):

  • 遍历 $j=1,2,3,\ldots,k-1$,如果发现 $a[\textit{idx}[j]] < a[\textit{idx}[j-1]] + \textit{low}$,就不断把 $\textit{idx}[j]$ 加一直到不满足条件。如果 $\textit{idx}[j]=n$ 则返回。
  • 这些指针移动后,如果首尾两个指针指向的数相差不超过 $\textit{side}\cdot 4 - \textit{low}$,则返回。
  • 否则把 $\textit{idx}[0]$ 加一,继续循环。

优化前

###py

class Solution:
    def maxDistance(self, side: int, points: List[List[int]], k: int) -> int:
        a = []
        for x, y in points:
            if x == 0:
                a.append(y)
            elif y == side:
                a.append(side + x)
            elif x == side:
                a.append(side * 3 - y)
            else:
                a.append(side * 4 - x)
        a.sort()

        def check(low: int) -> bool:
            idx = [0] * k
            while True:
                for j in range(1, k):
                    while a[idx[j]] < a[idx[j - 1]] + low:
                        idx[j] += 1
                        if idx[j] == len(a):
                            return False
                if a[idx[-1]] - a[idx[0]] <= side * 4 - low:
                    return True
                idx[0] += 1

        left, right = 1, side * 4 // k + 1
        while left + 1 < right:
            mid = (left + right) // 2
            if check(mid):
                left = mid
            else:
                right = mid
        return left

###java

class Solution {
    public int maxDistance(int side, int[][] points, int k) {
        long[] a = new long[points.length];
        for (int i = 0; i < points.length; i++) {
            int x = points[i][0];
            int y = points[i][1];
            if (x == 0) {
                a[i] = y;
            } else if (y == side) {
                a[i] = side + x;
            } else if (x == side) {
                a[i] = side * 3L - y;
            } else {
                a[i] = side * 4L - x;
            }
        }
        Arrays.sort(a);

        int left = 1;
        int right = (int) (side * 4L / k) + 1;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (check(a, side, k, mid)) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }

    private boolean check(long[] a, int side, int k, int low) {
        int[] idx = new int[k];
        while (true) {
            for (int j = 1; j < k; j++) {
                while (a[idx[j]] < a[idx[j - 1]] + low) {
                    idx[j]++;
                    if (idx[j] == a.length) {
                        return false;
                    }
                }
            }
            if (a[idx[k - 1]] - a[idx[0]] <= side * 4L - low) {
                return true;
            }
            idx[0]++;
        }
    }
}

###cpp

class Solution {
public:
    int maxDistance(int side, vector<vector<int>>& points, int k) {
        // 正方形边上的点,按照顺时针映射到一维数轴上
        vector<long long> a;
        for (auto& p : points) {
            int x = p[0], y = p[1];
            if (x == 0) {
                a.push_back(y);
            } else if (y == side) {
                a.push_back(side + x);
            } else if (x == side) {
                a.push_back(side * 3LL - y);
            } else {
                a.push_back(side * 4LL - x);
            }
        }
        ranges::sort(a);

        auto check = [&](int low) -> bool {
            vector<int> idx(k);
            while (true) {
                for (int j = 1; j < k; j++) {
                    while (a[idx[j]] < a[idx[j - 1]] + low) {
                        idx[j]++;
                        if (idx[j] == a.size()) {
                            return false;
                        }
                    }
                }
                if (a[idx[k - 1]] - a[idx[0]] <= side * 4LL - low) {
                    return true;
                }
                idx[0]++;
            }
        };

        // 本题保证 k >= 4,所以最远距离不会超过 side
        int left = 1, right = side * 4LL / k + 1;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            (check(mid) ? left : right) = mid;
        }
        return left;
    }
};

###go

func maxDistance(side int, points [][]int, k int) int {
a := make([]int, len(points))
for i, p := range points {
x, y := p[0], p[1]
if x == 0 {
a[i] = y
} else if y == side {
a[i] = side + x
} else if x == side {
a[i] = side*3 - y
} else {
a[i] = side*4 - x
}
}
slices.Sort(a)

ans := sort.Search(side*4/k, func(low int) bool {
low++
idx := make([]int, k)
for {
for j := 1; j < k; j++ {
for a[idx[j]] < a[idx[j-1]]+low {
idx[j]++
if idx[j] == len(a) {
return true
}
}
}
if a[idx[k-1]]-a[idx[0]] <= side*4-low {
return false
}
idx[0]++
}
})
return ans
}

优化

把从 $\textit{start}=a[0]$ 开始向后二分得到的 $k$ 个下标,记到 $\textit{idx}$ 数组中。如果没有 $k$ 个下标,直接返回。

这样初始化比从 $0$ 开始一个一个地向后移动指针更快。

此外,第一个指针至多移动到第二个指针的初始位置,就不用继续枚举了,后面必然无法得到符合要求的结果。

###py

class Solution:
    def maxDistance(self, side: int, points: List[List[int]], k: int) -> int:
        a = []
        for x, y in points:
            if x == 0:
                a.append(y)
            elif y == side:
                a.append(side + x)
            elif x == side:
                a.append(side * 3 - y)
            else:
                a.append(side * 4 - x)
        a.sort()

        def check(low: int) -> bool:
            idx = [0] * k
            cur = a[0]
            for j in range(1, k):
                i = bisect_left(a, cur + low)
                if i == len(a):
                    return False
                idx[j] = i
                cur = a[i]
            if cur - a[0] <= side * 4 - low:
                return True

            # 第一个指针移动到第二个指针的位置,就不用继续枚举了
            for idx[0] in range(1, idx[1]):
                for j in range(1, k):
                    while a[idx[j]] < a[idx[j - 1]] + low:
                        idx[j] += 1
                        if idx[j] == len(a):
                            return False
                if a[idx[-1]] - a[idx[0]] <= side * 4 - low:
                    return True
            return False

        left, right = 1, side * 4 // k + 1
        while left + 1 < right:
            mid = (left + right) // 2
            if check(mid):
                left = mid
            else:
                right = mid
        return left

###java

class Solution {
    public int maxDistance(int side, int[][] points, int k) {
        long[] a = new long[points.length];
        for (int i = 0; i < points.length; i++) {
            int x = points[i][0];
            int y = points[i][1];
            if (x == 0) {
                a[i] = y;
            } else if (y == side) {
                a[i] = side + x;
            } else if (x == side) {
                a[i] = side * 3L - y;
            } else {
                a[i] = side * 4L - x;
            }
        }
        Arrays.sort(a);

        int left = 1;
        int right = (int) (side * 4L / k) + 1;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (check(a, side, k, mid)) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }

    private boolean check(long[] a, int side, int k, int low) {
        int[] idx = new int[k];
        long cur = a[0];
        for (int j = 1; j < k; j++) {
            int i = lowerBound(a, cur + low);
            if (i == a.length) {
                return false;
            }
            idx[j] = i;
            cur = a[i];
        }
        if (cur - a[0] <= side * 4L - low) {
            return true;
        }

        // 第一个指针移动到第二个指针的位置,就不用继续枚举了
        int end0 = idx[1];
        for (idx[0] = 1; idx[0] < end0; idx[0]++) {
            for (int j = 1; j < k; j++) {
                while (a[idx[j]] < a[idx[j - 1]] + low) {
                    idx[j]++;
                    if (idx[j] == a.length) {
                        return false;
                    }
                }
            }
            if (a[idx[k - 1]] - a[idx[0]] <= side * 4L - low) {
                return true;
            }
        }
        return false;
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(long[] nums, long target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}

###cpp

class Solution {
public:
    int maxDistance(int side, vector<vector<int>>& points, int k) {
        vector<long long> a;
        for (auto& p : points) {
            int x = p[0], y = p[1];
            if (x == 0) {
                a.push_back(y);
            } else if (y == side) {
                a.push_back(side + x);
            } else if (x == side) {
                a.push_back(side * 3LL - y);
            } else {
                a.push_back(side * 4LL - x);
            }
        }
        ranges::sort(a);

        auto check = [&](int low) -> bool {
            vector<int> idx(k);
            long long cur = a[0];
            for (int j = 1; j < k; j++) {
                int i = ranges::lower_bound(a, cur + low) - a.begin();
                if (i == a.size()) {
                    return false;
                }
                idx[j] = i;
                cur = a[i];
            }
            if (cur - a[0] <= side * 4LL - low) {
                return true;
            }

            // 第一个指针移动到第二个指针的位置,就不用继续枚举了
            int end0 = idx[1];
            for (idx[0]++; idx[0] < end0; idx[0]++) {
                for (int j = 1; j < k; j++) {
                    while (a[idx[j]] < a[idx[j - 1]] + low) {
                        idx[j]++;
                        if (idx[j] == a.size()) {
                            return false;
                        }
                    }
                }
                if (a[idx[k - 1]] - a[idx[0]] <= side * 4LL - low) {
                    return true;
                }
            }
            return false;
        };

        int left = 1, right = side * 4LL / k + 1;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            (check(mid) ? left : right) = mid;
        }
        return left;
    }
};

###go

func maxDistance(side int, points [][]int, k int) int {
a := make([]int, len(points))
for i, p := range points {
x, y := p[0], p[1]
if x == 0 {
a[i] = y
} else if y == side {
a[i] = side + x
} else if x == side {
a[i] = side*3 - y
} else {
a[i] = side*4 - x
}
}
slices.Sort(a)

ans := sort.Search(side*4/k, func(low int) bool {
low++
idx := make([]int, k)
cur := a[0]
for j, i := 1, 0; j < k; j++ {
i += sort.Search(len(a)-i, func(j int) bool { return a[i+j] >= cur+low })
if i == len(a) {
return true
}
idx[j] = i
cur = a[i]
}
if cur-a[0] <= side*4-low {
return false
}

// 第一个指针移动到第二个指针的位置,就不用继续枚举了
end0 := idx[1]
for idx[0]++; idx[0] < end0; idx[0]++ {
for j := 1; j < k; j++ {
for a[idx[j]] < a[idx[j-1]]+low {
idx[j]++
if idx[j] == len(a) {
return true
}
}
}
if a[idx[k-1]]-a[idx[0]] <= side*4-low {
return false
}
}
return true
})
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n + nk \log \textit{side})$,其中 $n$ 是 $\textit{points}$ 的长度。其中 $\mathcal{O}(n\log n)$ 是排序的时间复杂度。
  • 空间复杂度:$\mathcal{O}(n)$。

方法三:二分答案 + 倍增

如果 $k$ 更大,上面两个方法就超时了。怎么办?

前置知识倍增讲解

在二分中,先预处理 $\textit{nxt}[i][0] = j$ 表示距离 $a[i]$ 至少为 $\textit{low}$ 的下一个点的下标是 $j$。如果不存在则 $j=n$。这可以用双指针计算。

然后倍增,定义 $\textit{nxt}[i][l]$ 表示 $i$ 的下 $2^l$ 个点的下标是 $\textit{nxt}[i][l]$。例如 $\textit{nxt}[i][1]$ 表示 $i$ 的下下个点的下标是 $\textit{nxt}[i][1]$。

转移方程同上面的倍增讲解:

$$
\textit{nxt}[i][l] = \textit{nxt}[\textit{nxt}[i][l-1]][l-1]
$$

可以定义 $\textit{nxt}[n][l]=n$ 作为哨兵,简化代码。

然后枚举 $i=0,1,2,\cdots$,往后跳 $k-1$ 步,得到下标 $j$。如果

$$
a[j] - a[i] \le \textit{side}\cdot 4 - \textit{low}
$$

成立,则说明可以找到符合要求的 $k$ 个点。

###py

class Solution:
    def maxDistance(self, side: int, points: List[List[int]], k: int) -> int:
        a = []
        for x, y in points:
            if x == 0:
                a.append(y)
            elif y == side:
                a.append(side + x)
            elif x == side:
                a.append(side * 3 - y)
            else:
                a.append(side * 4 - x)
        a.sort()

        n = len(a)
        k -= 1  # 往后跳 k-1 步,这里先减一,方便计算
        mx = k.bit_length()
        nxt = [[n] * mx for _ in range(n + 1)]
    
        def check(low: int) -> bool:
            # 预处理倍增数组 nxt
            j = n
            for i in range(n - 1, -1, -1):  # 转移来源在右边,要倒序计算
                while a[j - 1] >= a[i] + low:
                    j -= 1
                nxt[i][0] = j
                for l in range(1, mx):
                    nxt[i][l] = nxt[nxt[i][l - 1]][l - 1]
    
            # 枚举起点
            for i, start in enumerate(a):
                # 往后跳 k-1 步(注意上面把 k 减一了)
                cur = i
                for j in range(mx - 1, -1, -1):
                    if k >> j & 1:
                        cur = nxt[cur][j]
                if cur == n:  # 出界
                    break
                if a[cur] - start <= side * 4 - low:
                    return True
            return False

        left, right = 1, side * 4 // k + 1
        while left + 1 < right:
            mid = (left + right) // 2
            if check(mid):
                left = mid
            else:
                right = mid
        return left

###java

class Solution {
    public int maxDistance(int side, int[][] points, int k) {
        int n = points.length;
        long[] a = new long[n];
        for (int i = 0; i < n; i++) {
            int x = points[i][0];
            int y = points[i][1];
            if (x == 0) {
                a[i] = y;
            } else if (y == side) {
                a[i] = side + x;
            } else if (x == side) {
                a[i] = side * 3L - y;
            } else {
                a[i] = side * 4L - x;
            }
        }
        Arrays.sort(a);

        k--; // 往后跳 k-1 步,这里先减一,方便计算
        int mx = 32 - Integer.numberOfLeadingZeros(k);
        int[][] nxt = new int[n + 1][mx];
        Arrays.fill(nxt[n], n); // 哨兵

        int left = 1;
        int right = (int) (side * 4L / k) + 1;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (check(a, side, k, nxt, mid)) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }

    private boolean check(long[] a, int side, int k, int[][] nxt, int low) {
        int n = a.length;
        int mx = nxt[0].length;
        // 预处理倍增数组 nxt
        for (int i = n - 1, j = n; i >= 0; i--) {
            while (a[j - 1] >= a[i] + low) {
                j--;
            }
            nxt[i][0] = j;
            for (int l = 1; l < mx; l++) {
                nxt[i][l] = nxt[nxt[i][l - 1]][l - 1];
            }
        }

        // 枚举起点
        for (int i = 0; i < n; i++) {
            int cur = i;
            // 往后跳 k-1 步(注意上面把 k 减一了)
            for (int j = mx - 1; j >= 0; j--) {
                if ((k >> j & 1) > 0) {
                    cur = nxt[cur][j];
                }
            }
            if (cur == n) { // 出界
                break;
            }
            if (a[cur] - a[i] <= side * 4L - low) {
                return true;
            }
        }
        return false;
    }
}

###cpp

class Solution {
public:
    int maxDistance(int side, vector<vector<int>>& points, int k) {
        vector<long long> a;
        for (auto& p : points) {
            int x = p[0], y = p[1];
            if (x == 0) {
                a.push_back(y);
            } else if (y == side) {
                a.push_back(side + x);
            } else if (x == side) {
                a.push_back(side * 3LL - y);
            } else {
                a.push_back(side * 4LL - x);
            }
        }
        ranges::sort(a);

        int n = a.size();
        k--; // 往后跳 k-1 步,这里先减一,方便计算
        int high_bit = bit_width((unsigned) k) - 1;
        vector<array<int, 5>> nxt(n + 1); // 5 可以改为 high_bit+1(这里用 array 而不是 vector,提高访问效率)
        ranges::fill(nxt[n], n); // 哨兵

        auto check = [&](int low) -> bool {
            // 预处理倍增数组 nxt
            int j = n;
            for (int i = n - 1; i >= 0; i--) { // 转移来源在右边,要倒序计算
                while (a[j - 1] >= a[i] + low) {
                    j--;
                }
                nxt[i][0] = j;
                for (int k = 1; k <= high_bit; k++) {
                    nxt[i][k] = nxt[nxt[i][k - 1]][k - 1];
                }
            }

            // 枚举起点
            for (int i = 0; i < n; i++) {
                int cur = i;
                // 往后跳 k-1 步(注意上面把 k 减一了)
                for (int j = high_bit; j >= 0; j--) {
                    if (k >> j & 1) {
                        cur = nxt[cur][j];
                    }
                }
                if (cur == n) { // 出界
                    break;
                }
                if (a[cur] - a[i] <= side * 4LL - low) {
                    return true;
                }
            }
            return false;
        };

        int left = 1, right = side * 4LL / k + 1;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            (check(mid) ? left : right) = mid;
        }
        return left;
    }
};

###go

func maxDistance(side int, points [][]int, k int) int {
n := len(points)
a := make([]int, n)
for i, p := range points {
x, y := p[0], p[1]
if x == 0 {
a[i] = y
} else if y == side {
a[i] = side + x
} else if x == side {
a[i] = side*3 - y
} else {
a[i] = side*4 - x
}
}
slices.Sort(a)

k-- // 往后跳 k-1 步,这里先减一,方便计算
highBit := bits.Len(uint(k)) - 1
nxt := make([][5]int, n+1) // 5 可以改为 highBit+1(用 array 而不是 slice,提高访问效率)
for j := range nxt[n] {
nxt[n][j] = n // 哨兵
}

ans := sort.Search(side*4/k, func(low int) bool {
low++
// 预处理倍增数组 nxt
j := n
for i := n - 1; i >= 0; i-- { // 转移来源在右边,要倒序计算
for a[j-1] >= a[i]+low {
j--
}
nxt[i][0] = j
for k := 1; k <= highBit; k++ {
nxt[i][k] = nxt[nxt[i][k-1]][k-1]
}
}

// 枚举起点
for i, start := range a {
// 往后跳 k-1 步(注意上面把 k 减一了)
cur := i
for j := highBit; j >= 0; j-- {
if k>>j&1 > 0 {
cur = nxt[cur][j]
}
}
if cur == n { // 出界
break
}
if a[cur]-start <= side*4-low {
return false
}
}
return true
})
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n + n\log k \log \textit{side})$,其中 $n$ 是 $\textit{points}$ 的长度。其中 $\mathcal{O}(n\log n)$ 是排序的时间复杂度。
  • 空间复杂度:$\mathcal{O}(n\log k)$。

方法四:二分答案 + 建树 + DFS

在方法三的双指针基础上,连一条从 $j$ 到 $i$ 的有向边,我们会得到一棵有向树,根是 $n$。

从 $n$ 开始递归这棵树,同时用一个栈记录从根到当前节点的 $a[x]$ 信息。

当栈中有 $k$ 个点时,记录栈中倒数第 $k$ 个数和栈顶的距离,如果 $\le \textit{side}\cdot 4 - \textit{low}$,则找到了满足要求的 $k$ 的点,结束递归。

注意:无需判断 $f[i]>k$ 的情况,因为这意味着之前栈中有 $k$ 个点的时候,首尾两点间的距离足够远(甚至还可以再容纳一个点),一定满足要求。

###py

class Solution:
    def maxDistance(self, side: int, points: List[List[int]], k: int) -> int:
        a = []
        for x, y in points:
            if x == 0:
                a.append(y)
            elif y == side:
                a.append(side + x)
            elif x == side:
                a.append(side * 3 - y)
            else:
                a.append(side * 4 - x)
        a.sort()
        n = len(a)
        a.append(inf)  # 哨兵

        def check(low: int) -> bool:
            g = [[] for _ in range(n + 1)]
            j = n
            for i in range(n - 1, -1, -1):
                while a[j - 1] >= a[i] + low:
                    j -= 1
                g[j].append(i)  # 建树

            st = []
            def dfs(x: int) -> bool:
                st.append(a[x])
                # 注意栈中多了一个 a[n],所以是 m > k 不是 ==
                if len(st) > k and st[-k] - a[x] <= side * 4 - low:
                    return True
                for y in g[x]:
                    if dfs(y):
                        return True
                st.pop()  # 恢复现场
                return False
            return dfs(n)

        left, right = 1, side * 4 // k + 1
        while left + 1 < right:
            mid = (left + right) // 2
            if check(mid):
                left = mid
            else:
                right = mid
        return left

###java

class Solution {
    public int maxDistance(int side, int[][] points, int k) {
        int n = points.length;
        long[] a = new long[n + 1];
        for (int i = 0; i < n; i++) {
            int x = points[i][0];
            int y = points[i][1];
            if (x == 0) {
                a[i] = y;
            } else if (y == side) {
                a[i] = side + x;
            } else if (x == side) {
                a[i] = side * 3L - y;
            } else {
                a[i] = side * 4L - x;
            }
        }
        a[n] = Long.MAX_VALUE; // 哨兵
        Arrays.sort(a);

        int left = 1;
        int right = (int) (side * 4L / k) + 1;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (check(a, side, k, mid)) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }

    private boolean check(long[] a, int side, int k, int low) {
        int n = a.length - 1;
        List<Integer>[] g = new ArrayList[n + 1];
        Arrays.setAll(g, i -> new ArrayList<>());
        for (int i = n - 1, j = n; i >= 0; i--) {
            while (a[j - 1] >= a[i] + low) {
                j--;
            }
            g[j].add(i); // 建树
        }

        List<Long> st = new ArrayList<>();
        return dfs(a, g, st, k, side * 4L - low, n);
    }

    private boolean dfs(long[] a, List<Integer>[] g, List<Long> st, int k, long limit, int x) {
        st.add(a[x]);
        int m = st.size();
        // 注意栈中多了一个 a[n],所以是 m > k 不是 ==
        if (m > k && st.get(m - k) - a[x] <= limit) {
            return true;
        }
        for (int y : g[x]) {
            if (dfs(a, g, st, k, limit, y)) {
                return true;
            }
        }
        st.remove(m - 1); // 恢复现场
        return false;
    }
}

###cpp

class Solution {
public:
    int maxDistance(int side, vector<vector<int>>& points, int k) {
        vector<long long> a;
        for (auto& p : points) {
            int x = p[0], y = p[1];
            if (x == 0) {
                a.push_back(y);
            } else if (y == side) {
                a.push_back(side + x);
            } else if (x == side) {
                a.push_back(side * 3LL - y);
            } else {
                a.push_back(side * 4LL - x);
            }
        }
        ranges::sort(a);
        int n = a.size();
        a.push_back(LLONG_MAX); // 哨兵

        auto check = [&](int low) -> bool {
            vector<vector<int>> g(n + 1);
            int j = n;
            for (int i = n - 1; i >= 0; i--) {
                while (a[j - 1] >= a[i] + low) {
                    j--;
                }
                g[j].push_back(i); // 建树
            }

            vector<long long> st;
            auto dfs = [&](this auto&& dfs, int x) -> bool {
                st.push_back(a[x]);
                int m = st.size();
                // 注意栈中多了一个 a[n],所以是 m > k 不是 ==
                if (m > k && st[m - k] - a[x] <= side * 4LL - low) {
                    return true;
                }
                for (int y : g[x]) {
                    if (dfs(y)) {
                        return true;
                    }
                }
                st.pop_back(); // 恢复现场
                return false;
            };
            return dfs(n);
        };

        int left = 1, right = side * 4LL / k + 1;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            (check(mid) ? left : right) = mid;
        }
        return left;
    }
};

###go

func maxDistance(side int, points [][]int, k int) int {
n := len(points)
a := make([]int, n, n+1)
for i, p := range points {
x, y := p[0], p[1]
if x == 0 {
a[i] = y
} else if y == side {
a[i] = side + x
} else if x == side {
a[i] = side*3 - y
} else {
a[i] = side*4 - x
}
}
slices.Sort(a)
a = append(a, math.MaxInt) // 哨兵

g := make([][]int, n+1)
ans := sort.Search(side*4/k, func(low int) bool {
low++
clear(g)
j := n
for i := n - 1; i >= 0; i-- {
for a[j-1] >= a[i]+low {
j--
}
g[j] = append(g[j], i) // 建树
}

st := []int{}
var dfs func(int) bool
dfs = func(x int) bool {
st = append(st, a[x])
m := len(st)
// 注意栈中多了一个 a[n],所以是 m > k 不是 ==
if m > k && st[m-k]-a[x] <= side*4-low {
return true
}
for _, y := range g[x] {
if dfs(y) {
return true
}
}
st = st[:m-1] // 恢复现场
return false
}
return !dfs(n)
})
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n + n\log \textit{side})$,其中 $n$ 是 $\textit{points}$ 的长度。其中 $\mathcal{O}(n\log n)$ 是排序的时间复杂度。每次二分的时间为 $\mathcal{O}(n)$。
  • 空间复杂度:$\mathcal{O}(n)$。

方法五:二分 + 动态规划

定义 $f[i]$ 表示从 $i$ 往后找,最多可以找多少个点(包含 $i$)。

设下一个点的下标为 $j$,那么有

$$
f[i] = f[j] + 1
$$

初始值 $f[n] = 0$。

此外,定义 $\textit{end}[i]$ 表示从 $i$ 往后找,最后一个点的下标。

  • 如果 $f[i]=1$,那么 $\textit{end}[i]$ 就是 $i$ 自己。
  • 如果 $f[i]>1$,那么 $\textit{end}[i]$ 是从 $j$ 往后找,最后一个点的下标,即 $\textit{end}[j]$。

所以有

$$
\textit{end}[i] =
\begin{cases}
i, & f[i]=1 \
\textit{end}[j], & f[i]>1 \
\end{cases}
$$

如果 $f[i]=k$,且首尾两点的距离 $a[\textit{end}[i]] - a[i] \le \textit{side}\cdot 4 - \textit{low}$,那么满足要求,返回。

注意:无需判断 $f[i]>k$ 的情况。证明:每次间隔至少 $\textit{low}$ 才会把 $f[i]$ 加 $1$,如果出现 $f[i]=f[j]+1=k+1$ 的情况,说明我们在 $f[j]=k$ 的基础上增加了一个点,对于 $f[j]$ 来说,首尾节点有足够的间距(比 $\textit{low}$ 还大),使得我们可以再加一个点进来,得到 $f[i]=k+1$。所以 $f[j]=k$ 的时候必然可以满足要求,我们不会继续循环到 $f[i]=k+1$ 的情况。

###py

class Solution:
    def maxDistance(self, side: int, points: List[List[int]], k: int) -> int:
        a = []
        for x, y in points:
            if x == 0:
                a.append(y)
            elif y == side:
                a.append(side + x)
            elif x == side:
                a.append(side * 3 - y)
            else:
                a.append(side * 4 - x)
        a.sort()

        n = len(a)
        f = [0] * (n + 1)
        end = [0] * n

        def check(low: int) -> bool:
            j = n
            for i in range(n - 1, -1, -1):
                while a[j - 1] >= a[i] + low:
                    j -= 1
                f[i] = f[j] + 1
                end[i] = end[j] if f[i] > 1 else i
                if f[i] == k and a[end[i]] - a[i] <= side * 4 - low:
                    return True
            return False

        left, right = 1, side * 4 // k + 1
        while left + 1 < right:
            mid = (left + right) // 2
            if check(mid):
                left = mid
            else:
                right = mid
        return left

###java

class Solution {
    public int maxDistance(int side, int[][] points, int k) {
        int n = points.length;
        long[] a = new long[n];
        for (int i = 0; i < n; i++) {
            int x = points[i][0];
            int y = points[i][1];
            if (x == 0) {
                a[i] = y;
            } else if (y == side) {
                a[i] = side + x;
            } else if (x == side) {
                a[i] = side * 3L - y;
            } else {
                a[i] = side * 4L - x;
            }
        }
        Arrays.sort(a);

        int[] f = new int[n + 1];
        int[] end = new int[n];

        int left = 1;
        int right = (int) (side * 4L / k) + 1;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (check(a, side, k, mid, f, end)) {
                left = mid;
            } else {
                right = mid;
            }
        }
        return left;
    }

    private boolean check(long[] a, int side, int k, int low, int[] f, int[] end) {
        int n = a.length;
        for (int i = n - 1, j = n; i >= 0; i--) {
            while (a[j - 1] >= a[i] + low) {
                j--;
            }
            f[i] = f[j] + 1;
            end[i] = f[i] > 1 ? end[j] : i;
            if (f[i] == k && a[end[i]] - a[i] <= side * 4L - low) {
                return true;
            }
        }
        return false;
    }
}

###cpp

class Solution {
public:
    int maxDistance(int side, vector<vector<int>>& points, int k) {
        vector<long long> a;
        for (auto& p : points) {
            int x = p[0], y = p[1];
            if (x == 0) {
                a.push_back(y);
            } else if (y == side) {
                a.push_back(side + x);
            } else if (x == side) {
                a.push_back(side * 3LL - y);
            } else {
                a.push_back(side * 4LL - x);
            }
        }
        ranges::sort(a);

        int n = a.size();
        vector<int> f(n + 1), end(n);

        auto check = [&](int low) -> bool {
            int j = n;
            for (int i = n - 1; i >= 0; i--) {
                while (a[j - 1] >= a[i] + low) {
                    j--;
                }
                f[i] = f[j] + 1;
                end[i] = f[i] > 1 ? end[j] : i;
                if (f[i] == k && a[end[i]] - a[i] <= side * 4LL - low) {
                    return true;
                }
            }
            return false;
        };

        int left = 1, right = side * 4LL / k + 1;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            (check(mid) ? left : right) = mid;
        }
        return left;
    }
};

###go

func maxDistance(side int, points [][]int, k int) int {
n := len(points)
a := make([]int, n)
for i, p := range points {
x, y := p[0], p[1]
if x == 0 {
a[i] = y
} else if y == side {
a[i] = side + x
} else if x == side {
a[i] = side*3 - y
} else {
a[i] = side*4 - x
}
}
slices.Sort(a)

f := make([]int, n+1)
end := make([]int, n)

ans := sort.Search(side*4/k, func(low int) bool {
low++
j := n
for i := n - 1; i >= 0; i-- {
for a[j-1] >= a[i]+low {
j--
}
f[i] = f[j] + 1
if f[i] == 1 {
end[i] = i // i 自己就是最后一个点
} else {
end[i] = end[j]
}
if f[i] == k && a[end[i]]-a[i] <= side*4-low {
return false
}
}
return true
})
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n + n\log \textit{side})$,其中 $n$ 是 $\textit{points}$ 的长度。其中 $\mathcal{O}(n\log n)$ 是排序的时间复杂度。每次二分的时间为 $\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自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

昨天 — 2026年4月24日技术

Flutter进阶:用OverlayEntry 实现所有弹窗效果

作者 SoaringHeart
2026年4月24日 20:57

一、需求来源

最近遇到一个需求:在直播页面弹窗(Sheet 和 Dialog),因为直播页面比较重,根据路由条件做了进入前台推流和退到后台断流的功能。在 Flutter 中 Sheet 和 Dialog 都通过路由拉起,发生了功能冲突。

只能通过 OverlayEntry 来实现 Sheet 和 Dialog 的效果。所以有 NOverlayDialog,支持 Dialog & Sheet & Drawer & Toast。

// SDK 弹窗拉起部分源码
Navigator.of(context, rootNavigator: useRootNavigator).push

二、使用示例

Dialog

NOverlayDialog.show(
  context,
  from: v,//v 是 Alignment 类型参数
  barrierColor: Colors.black12,
  // barrierDismissible: false,
  onBarrier: () {
    DLog.d('NOverlayDialog onBarrier');
  },
  child: GestureDetector(
    onTap: () {
      NOverlayDialog.dismiss();
      DLog.d('NOverlayDialog onBarrier');
    },
    child: Container(
      width: 300,
      height: 300,
      child: buildContent(
        title: v.toString(),
        onTap: () {
          NOverlayDialog.dismiss();
          DLog.d('NOverlayDialog onBarrier');
        },
      ),
    ),
  ),
);

Sheet

NOverlayDialog.sheet(
  context,
  child: buildContent(
    height: 400,
    margin: EdgeInsets.symmetric(horizontal: 30),
    onTap: () {
      NOverlayDialog.dismiss();
    },
  ),
);

Toast

NOverlayDialog.toast(
  context,
  hideBarrier: true,
  from: Alignment.center,
  message: "This is a Toast!",
);

三、源码 NOverlayDialog

//
//  NOverlayDialog.dart
//  flutter_templet_project
//
//  Created by shang on 2026/3/4 18:47.
//  Copyright © 2026/3/4 shang. All rights reserved.
//

import 'package:flutter/material.dart';

/// Dialog & Sheet & Drawer & Toast
class NOverlayDialog {
  NOverlayDialog._();

  static OverlayEntry? _entry;
  static AnimationController? _controller;

  static bool get isShowing => _entry != null;

  /// 隐藏
  static Future<void> dismiss({bool immediately = false}) async {
    if (!isShowing) {
      return;
    }

    final controller = _controller;
    final entry = _entry;
    _controller = null;
    _entry = null;

    if (immediately || controller == null) {
      entry?.remove();
      controller?.dispose();
      return;
    }

    await controller.reverse();
    entry?.remove();
    controller.dispose();
  }

  /// 显示 BottomSheet
  static void show(
    BuildContext context, {
    required Widget child,
    Alignment from = Alignment.bottomCenter,
    Duration duration = const Duration(milliseconds: 300),
    Curve curve = Curves.easeOutCubic,
    bool barrierDismissible = true,
    Color barrierColor = const Color(0x80000000),
    VoidCallback? onBarrier,
    bool hideBarrier = false,
    Duration? autoDismissDuration,
  }) {
    if (isShowing) {
      dismiss(immediately: true);
    }

    final overlay = Overlay.of(context, rootOverlay: true);
    _controller = AnimationController(
      vsync: overlay,
      duration: const Duration(milliseconds: 300),
    );

    final animation = CurvedAnimation(
      parent: _controller!,
      curve: Curves.easeOut,
      reverseCurve: Curves.easeIn,
    );

    Widget content = child;
    // ⭐ 中心弹窗:Fade
    if (from == Alignment.center) {
      content = FadeTransition(
        opacity: animation.drive(
          CurveTween(curve: Curves.easeOut),
        ),
        child: ScaleTransition(
          scale: Tween<double>(begin: 0.9, end: 1.0).animate(animation),
          child: content,
        ),
      );
    }

    // ⭐ 其余方向:Slide
    content = FadeTransition(
      opacity: animation,
      child: SlideTransition(
        position: animation.drive(
          Tween<Offset>(
            begin: Offset(from.x.sign, from.y.sign),
            end: Offset.zero,
          ).chain(
            CurveTween(curve: curve),
          ),
        ),
        child: content,
      ),
    );

    content = Align(
      alignment: from,
      child: content,
    );

    _entry = OverlayEntry(
      builder: (context) {
        if (hideBarrier) {
          return content;
        }

        return Stack(
          children: [
            // ===== Barrier =====
            GestureDetector(
              behavior: HitTestBehavior.opaque,
              onTap: barrierDismissible ? dismiss : onBarrier,
              child: FadeTransition(
                opacity: animation,
                child: Container(
                  color: barrierColor,
                ),
              ),
            ),
            content,
          ],
        );
      },
    );

    overlay.insert(_entry!);
    _controller?.forward();
    if (autoDismissDuration != null) {
      Future.delayed(autoDismissDuration, dismiss);
    }
  }

  /// 显示
  static void sheet(
    BuildContext context, {
    required Widget child,
    Alignment from = Alignment.bottomCenter,
    Duration duration = const Duration(milliseconds: 300),
    Curve curve = Curves.easeOutCubic,
    bool hideBarrier = false,
    Duration? autoDismissDuration,
  }) {
    return show(
      context,
      child: child,
      from: from,
      duration: duration,
      curve: curve,
      hideBarrier: hideBarrier,
      autoDismissDuration: autoDismissDuration,
    );
  }

  /// 显示 BottomSheet
  static void toast(
    BuildContext context, {
    Widget? child,
    String message = "",
    EdgeInsets margin = const EdgeInsets.only(bottom: 34),
    Alignment from = Alignment.center,
    Duration duration = const Duration(milliseconds: 300),
    Curve curve = Curves.easeOutCubic,
    bool hideBarrier = true,
    Duration? autoDismissDuration = const Duration(milliseconds: 2000),
  }) {
    final childDefault = Material(
      color: Colors.black.withOpacity(0.7),
      borderRadius: BorderRadius.all(Radius.circular(8)),
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        child: Text(
          message,
          style: TextStyle(color: Colors.white),
        ),
      ),
    );
    return show(
      context,
      child: Padding(
        padding: margin,
        child: child ?? childDefault,
      ),
      from: from,
      duration: duration,
      curve: curve,
      hideBarrier: hideBarrier,
      autoDismissDuration: autoDismissDuration,
    );
  }
}

最后、总结

1、NOverlayDialog 脱离路由系统,基于 OverlayEntry 实现。

2、NOverlayDialog 核心是动画,from 是视图出现和消失方位。

from:Alignment.topCenter,是顶部下拉弹窗 TopSheet;
from:Alignment.bottomCenter,是底部上拉弹窗 BottomSheet;
from:Alignment.centerLeft | Alignment.centerRight, 是两侧弹窗 Drawer;
from:Alignment.center,是渐变弹窗 Dialog & Toast;

3、已添加进

pub.dev/packages/n_…

写 HTML 就能做视频?HeyGen 开源的这个工具有点意思

作者 奇舞精选
2026年4月24日 20:34

HeyGen 开源了一个叫 HyperFrames 的框架,让你用 HTML、CSS 和 GSAP 来做视频。不是概念演示,是真能用的那种。


为什么要用代码做视频

用过 After Effects 或 Premiere 的人都知道,手动调关键帧是个力气活。做一个 10 秒的片头可能要调半小时,改个颜色又得重来一遍。项目文件是二进制格式,Git 根本管不了,团队协作基本靠 U 盘传文件。

HyperFrames 的思路很简单:既然前端开发都是写代码,视频为什么不能也写代码?HTML 定义元素,CSS 控制样式,GSAP 做动画,所有东西都是文本文件。Git 能管,改起来方便,批量生成写个脚本就行。配合 AI 的话,直接说"把标题改成从左边滑入",改完立刻能看效果。

这套东西适合谁用?如果你是做电影级特效,老老实实用 AE。但如果你是前端开发者,经常要做数据可视化、产品介绍视频、动态字幕这类东西,HyperFrames 能省不少事。

实现原理

HyperFrames 是一个四层架构,从上到下:

CLI (hyperframes render)
    ↓
Producer (@hyperframes/producer)   负责完整渲染流水线
    ↓
Engine (@hyperframes/engine)       负责帧捕获
    ↓
Core (@hyperframes/core)           提供运行时、类型、FrameAdapter

用户写 HTML,CLI 调 Producer,Producer 驱动 Engine 逐帧捕获,Core 负责页面内的时间轴控制。


核心机制:Seek-and-Capture 循环

HyperFrames 的做法: 不播放,只 seek。每一帧都是独立的静态快照:

for (let frame = 0; frame <= totalFrames; frame++) {
  const time = Math.floor(frame) / fps;  // 整数除法,无浮点误差
  await adapter.seekFrame(frame);         // 把动画拨到这一时刻
  // 捕获当前像素
}

时间计算用整数帧号除以 fps,不依赖任何系统时钟。


帧捕获:HeadlessExperimental.beginFrame

引擎启动的是 chrome-headless-shell(专为 CDP 控制优化的最小 Chrome 二进制),通过 Chrome DevTools Protocol 调用 HeadlessExperimental.beginFrame

这个 API 的作用是:显式命令合成器渲染一帧,并把像素 buffer 直接返回给调用方。效果是:

  • 没有"等渲染完成"的时序问题
  • 像素直接从 GPU 合成器取,不经过截图的 IPC 拷贝流程
  • 每帧是原子操作,不存在半渲染状态

FrameAdapter 协议:动画运行时的接入层

HyperFrames 不锁定任何动画库。它定义了一个 FrameAdapter 接口,任何能"按帧 seek"的东西都能接入:

type FrameAdapter = {
  id: string;
  init?: (ctx: FrameAdapterContext) => Promise<void> | void;
  getDurationFrames: () => number;       // 视频总帧数
  seekFrame: (frame: number) => void;    // 把动画拨到第 N 帧
  destroy?: () => void;
};

GSAP 的 adapter 实现大概是:

seekFrame(frame) {
  const time = frame / fps;
  gsap.globalTimeline.pause();
  gsap.globalTimeline.seek(time);   // 直接拨时间轴
}

seekFrame 必须是幂等的(同一帧调两次结果相同),且必须支持随机 seek(可以先 seek 第 90 帧再 seek 第 10 帧),不能有顺序依赖。


window.__hf 协议:引擎和页面的通信桥

引擎(Node.js 进程)和页面(浏览器内)之间通过 window.__hf 对象通信:

interface HfProtocol {
  duration: number;          // 视频总时长(秒)
  seek(time: number): void;  // 引擎调这个来驱动帧 seek
  media?: HfMediaElement[];  // 音视频元素声明(给引擎做音频抽取用)
}

页面加载完成后,Core 注入的运行时把自己挂在 window.__hf 上。引擎每帧调 page.evaluate(() => window.__hf.seek(t)),页面内的 FrameAdapter 响应,GSAP 时间轴被拨到对应位置,然后引擎立刻调 beginFrame 捕获。

任何实现了这个协议的页面都能被引擎渲染,不局限于 HyperFrames 格式的 HTML。


音频处理:单独抽取,最后混合

浏览器渲染是纯视觉的,音频不能从帧里捕获。Producer 的做法是把音频流程完全分离:

  1. 解析 HTML 里的 <audio><video> 元素,读取 data-startdata-durationdata-volume 等属性
  2. 用 FFmpeg 从源文件里单独提取音轨,按时间轴剪切、调音量
  3. 所有音轨混合成一个主音轨
  4. 视频帧编码完成后,再用 FFmpeg 把视频和音轨 mux 到一起

并行渲染

单个 Engine session 是串行的(一帧一帧 seek),但 Producer 会开多个 session 并行:

calculateOptimalWorkers(totalFrames)  // 根据 CPU 核数算出最优 worker 数
distributeFrames(totalFrames, workers) // 把帧分段,每个 worker 负责一段
executeParallelCapture(tasks)          // 并行跑,各 worker 独立开 Chrome 实例

每个 worker 是完全独立的 capture session,有自己的 Chrome 进程和页面实例,不共享状态。最后按帧序号合并,送给 FFmpeg 编码。


确定性保证

同一份 HTML,任意时间在任意机器上渲染,输出的 MP4 应该二进制相同(Docker 模式下严格成立)。这靠几件事保证:

  • 时间用 Math.floor(frame) / fps 计算,不用 Date.now()
  • seekFrame 幂等且无顺序依赖
  • 所有资源在渲染前必须加载完(有 __renderReady readiness gate)
  • 禁止 Math.random()(无 seed)
  • Chrome 版本固定(Docker 模式下完全锁定)

本地渲染可能因系统字体和 Chrome 小版本差异有微小像素差异,Docker 模式消除这个问题。


完整流程图

npx hyperframes render
        │
        ▼
CLI → Producer
        │
        ├─► 解析 HTML,提取音视频元素
        │
        ├─► 启动 File Server(HTTP 本地服务,给 Chrome 加载文件用)
        │
        ├─► 启动 N 个 worker(每个 worker 一个 Chrome 实例)
        │        │
        │        ▼
        │   initializeSession(html)
        │        │
        │        ├─► 注入 Core 运行时(挂 window.__hf)
        │        │
        │        └─► for each frame:
        │               window.__hf.seek(t)   ← GSAP timeline.seek(t)
        │               HeadlessExperimental.beginFrame
        │               → pixel buffer
        │
        ├─► pixel buffer → FFmpeg → video.mp4(无音频)
        │
        ├─► 音频抽取 → 混合 → audio.wav
        │
        └─► FFmpeg mux(video.mp4 + audio.wav) → output.mp4

安装

npx hyperframes init my-video

项目结构

my-video/
├── index.html          # 主时间轴文件
├── meta.json           # 项目元数据(id, name)
├── hyperframes.json    # 路径配置
├── narration.wav       # 音频文件(可选)
├── transcript.json     # 转录文件(可选)
├── compositions/       # 子组件目录
│   └── intro.html
└── assets/             # 静态资源
    ├── images/
    └── fonts/

核心概念

1. 时间轴声明

用 data 属性定义时间:

<div 
  class="clip"
  data-start="0" 
  data-duration="5" 
  data-track-index="1"
>
  <h1>Hello World</h1>
</div>

必须的三个属性:

  • data-start: 开始时间(秒)
  • data-duration: 持续时长(秒)
  • data-track-index: 图层索引(类似 AE)

注意:有时间属性的元素必须加 class="clip",框架用它控制显示。

2. GSAP 动画

// 创建并注册时间轴
var tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;

// 添加动画
tl.from(".title", {
  y: 100,        // 从下方 100px 进入
  opacity: 0,    // 从透明到不透明
  duration: 1.0,
  ease: "power3.out"
}, 0.2);  // 在 0.2 秒处开始

常用缓动函数:

  • power2.out - 快入慢出
  • power3.out - 更强烈的快入慢出
  • back.out(1.7) - 回弹效果
  • elastic.out - 弹性效果

3. 字幕同步

var GROUPS = [
  { id: "cg-0", start: 0.5, end: 2.0 },
  { id: "cg-1", start: 2.2, end: 3.8 }
];

GROUPS.forEach(function(group) {
  var el = document.getElementById(group.id);
  
  // 入场
  tl.fromTo(el, 
    { opacity: 0, visibility: "visible" },
    { opacity: 1, duration: 0.3 },
    group.start
  );
  
  // 退场
  tl.to(el, { opacity: 0 }, group.end - 0.15);
  tl.set(el, { visibility: "hidden" }, group.end);
});

效果展示

我做了个智能手表的产品介绍视频,14 秒,三个场景。

智能手表产品介绍

三个场景的安排:

  • 场景 1(0-4s):产品名 + 价格,用了 back.out 回弹效果
  • 场景 2(4-10s):三张功能卡片,stagger 交错出现
  • 场景 3(10-14s):CTA 按钮,elastic.out 弹性动画

下面拆开看看每个场景怎么写的。

场景 1:产品展示

// 产品名称从下方弹入
tl.from(" .product-name", {
  y: 100, opacity: 0, duration: 0.8, ease: "power3.out"
}, 0.3);

// 价格放大淡入(带回弹)
tl.from(" .price", {
  scale: 0, opacity: 0, duration: 0.6, ease: "back.out(1.7)"
}, 1.2);

场景 2:功能卡片

// 三张卡片交错出现
tl.from(" .feature-card", {
  y: 60, 
  opacity: 0, 
  duration: 0.5,
  stagger: 0.2  // 关键:每张间隔0.2秒
}, 4.8);

CSS 毛玻璃效果:

.feature-card {
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);
  border: 2px solid rgba(255, 255, 255, 0.2);
}

场景 3:CTA按钮

// 按钮弹入
tl.from(" .cta-button", {
  scale: 0, opacity: 0, duration: 0.6, ease: "elastic.out(1, 0.5)"
}, 11.0);

// 脉冲动画(吸引点击)
tl.to(" .cta-button", {
  scale: 1.1, duration: 0.3, repeat: 3, yoyo: true
}, 11.8);

总结

HyperFrames 的核心思路就是把视频当代码管。对前端开发者来说,这套东西上手很快,HTML、CSS、GSAP 都是熟悉的技术栈。

不过也别指望它能做电影级特效。毕竟是基于浏览器渲染的,复杂的 3D 动画、粒子效果这些做不了。但对于产品介绍、数据可视化、字幕动画这类需求,够用了。

我把Vue2响应式源码从头到尾啃了一遍,这是整理笔记

作者 Lkstar
2026年4月24日 18:23

Vue 2 响应式源码精读:从 initState 到 defineReactive

之前看 Vue 源码的时候,状态初始化这块一直是一知半解的状态,后来硬着头皮一行行啃下来,发现其实逻辑很清晰。这篇就把 initState、initProps、initData、proxy、observe、Observer、defineReactive 这几个核心函数串起来讲,争取让读完的人都能在脑子里画出整条链路。


一、initState —— 所有状态的"总调度"

initState 这个函数做的事情说白了就是:把 Vue 实例上的 props、methods、data、computed、watch 统统初始化一遍,变成响应式数据。

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options

  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)

  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }

  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

拆开看:

  • vm._watchers = [] —— 先准备一个数组,后面所有 Watcher(computed、watch、渲染 watcher)都会塞进去
  • const opts = vm.$options —— 就是你 new Vue({ ... }) 传进来的配置对象,取出来方便后面用
  • 后面就是按顺序依次初始化:props → methods → data → computed → watch

这个顺序不是随便排的。 props 先初始化,所以 data 里能访问 props;methods 第二,所以 data 里能调 methods;computed 第四,所以它能依赖 data 和 props;watch 最后,所以它能监听前面所有的数据。谁在前谁在后,是有依赖关系的。

data 那块有个细节:如果用户没写 data,Vue 会给一个空对象 {} 并调 observe,保证根实例一定有响应式数据。


二、initProps —— 处理父组件传进来的数据

initProps 要干的事情:拿到父组件传的值 → 校验类型和默认值 → 变成响应式 → 代理到 this 上。

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent

  if (!isRoot) {
    toggleObserving(false)
  }

  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)

    // 省略了开发环境警告逻辑...

    defineReactive(props, key, value, () => {
      if (vm.$parent && !isUpdatingChildComponent) {
        warn(`Avoid mutating a prop directly...`)
      }
    })

    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }

  toggleObserving(true)
}

几个关键点:

1. propsData vs propsOptions

  • propsData 是父组件实际传过来的值,比如 <Child msg="hello"/> 中的 { msg: 'hello' }
  • propsOptions 是子组件声明的 props 配置,props: { msg: { type: String } }

2. toggleObserving(false) 是干嘛的?

非根组件会先关掉响应式转换开关。因为 props 的值来自父组件,父组件那边已经做过响应式处理了,子组件不需要再 observe 一遍,避免重复。

3. validateProp

这个函数负责校验:取父组件传入的值,没传就用默认值,检查类型对不对,执行自定义校验函数,最后返回合法值。

4. defineReactive 里的第四个参数

defineReactive(props, key, value, () => {
  if (vm.$parent && !isUpdatingChildComponent) {
    warn(`Avoid mutating a prop directly...`)
  }
})

这个箭头函数是自定义 setter,当你在子组件里直接改 props(this.msg = 'xxx')的时候会触发警告。这就是为什么 Vue 一直强调"不要在子组件里直接修改 props"——源码层面就给你拦着了。

5. proxy(vm, '_props', key)

让你能直接写 this.msg 而不是 this._props.msg,后面会单独讲 proxy 函数。


三、initData —— 处理组件自身的数据

initData 的流程:拿到 data → 处理函数/对象 → 挂载到 vm._data → 校验重名 → 代理到 this → observe 变响应式。

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}

  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object...',
      vm
    )
  }

  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length

  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(`Method "${key}" has already been defined as a data property.`, vm)
      }
    }
    if (props && hasOwn(props, key)) {
      warn(`The data property "${key}" is already declared as a prop.`, vm)
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }

  observe(data, true /* asRootData */)
}

几个要注意的地方:

1. 组件的 data 为什么必须是函数?

data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}

这行就是答案。组件会被复用创建多个实例,如果 data 是对象,所有实例共享同一块内存,一个改了全跟着变。用函数的话每次 getData 都返回新对象,实例之间数据隔离。

2. 校验很严格

遍历 data 的每个 key,检查三件事:

  • 不能和 methods 重名(否则 this.xxx 不知道是取数据还是调方法)
  • 不能和 props 重名(props 优先级更高,重名会被覆盖)
  • 不能是 $_ 开头的保留字(Vue 内部属性用的)

3. 最后一步 observe(data, true)

把整个 data 对象递归地变成响应式,这是响应式的入口,后面会细讲。

对比一下 initProps 和 initData:

initProps initData
数据存哪 vm._props vm._data
怎么访问 this.xxx(代理) this.xxx(代理)
响应式方式 defineReactive 逐个属性 observe 整体递归
数据来源 父组件传入 组件自己定义
能不能改 子组件不能改 可以改

四、proxy —— this.xxx 背后的"中间商"

这个函数特别短,但特别关键。它做的事情就一件:让你写 this.xxx 的时候,实际去访问 this._data.xxxthis._props.xxx

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

逻辑很直白:

  1. 先定义一个公用的属性描述符模板 sharedPropertyDefinition,不用每次都 new 一个,省内存
  2. 动态设置 getter:读 this.msg → 实际读 this._data.msg(或 this._props.msg
  3. 动态设置 setter:写 this.msg = 'hi' → 实际写 this._data.msg = 'hi'
  4. Object.defineProperty 把这个属性挂到 Vue 实例上

所以 this.xxx 本身不存任何数据,它就是一个"门把手",拧开之后通向 _data_props

Vue 这么设计有几个好处:

  • 写法简洁,不用到处写 this._data.xxx
  • 真实数据藏在内部,外部只暴露代理接口,内部怎么优化不影响用户代码
  • 不管是 data、props 还是 computed,用户都只需要 this.xxx 一种写法

五、observe —— 响应式的"门卫"

observe 是响应式系统的入口函数,负责判断一个值需不需要、能不能变成响应式。

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }

  let ob: Observer | void

  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }

  if (asRootData && ob) {
    ob.vmCount++
  }

  return ob
}

分三步看:

第一步:过滤掉不需要处理的值

不是对象或者数组?直接 return。是 VNode(虚拟 DOM)?也 return。简单类型(string、number、boolean)不需要劫持。

第二步:检查是不是已经处理过了

__ob__ 是 Vue 给响应式对象加的隐藏标记。如果对象上已经有 __ob__,说明已经被 observe 过了,直接复用,不重复创建。这是个重要的性能优化。

第三步:满足五个条件才创建 Observer

shouldObserve &&              // 响应式开关是开着的
!isServerRendering() &&       // 不是服务端渲染
(Array.isArray(value) || isPlainObject(value)) && // 是对象或数组
Object.isExtensible(value) && // 没被 Object.freeze() 冻结
!value._isVue                 // 不是 Vue 实例本身

五个条件全满足,才会 new Observer(value),真正给数据穿上响应式外套。

最后 ob.vmCount++ 是给根数据打标记,后面组件销毁的时候会用到,跟内存回收有关。


六、Observer —— 真正给数据装监控的"工程师"

observe 只是门卫,Observer 才是干活的人。

export class Observer {
  value: any
  dep: Dep
  vmCount: number

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0

    def(value, '__ob__', this)

    if (Array.isArray(value)) {
      const augment = hasProto ? protoAugment : copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

构造函数做了这些事:

1. this.dep = new Dep()

每个被监控的对象都有一个 Dep(依赖管理器),可以理解成一个"通讯录",记录哪些 Watcher 用了这个对象的数据。数据变了就翻通讯录通知。

2. def(value, '__ob__', this)

给数据打上 __ob__ 标记,值就是 Observer 实例本身。用了 def 函数(后面讲),所以这个属性是不可枚举的,for...in 遍历不到,不会污染用户数据。

3. 对象和数组走不同路线

这是 Vue 响应式里最容易考的点:

  • 对象:调 walk,遍历所有属性,逐个调 defineReactive 给每个属性加 getter/setter
  • 数组:重写原型上的 7 个变异方法(pushpopshiftunshiftsplicesortreverse),然后 observeArray 递归处理数组里的每一项

为什么数组要特殊处理?因为 Object.defineProperty 劫持不到数组下标的赋值操作(arr[0] = xxx 不会触发 setter),所以 Vue 只能通过重写那几个会修改数组的方法来"曲线救国"。

这也解释了两个经典面试题:

  • 为什么对象新增属性不响应? 因为 walk 只在初始化时遍历一次,后面加的属性没经过 defineReactive,没有 getter/setter。用 Vue.setthis.$set 就行。
  • 为什么数组下标赋值不响应? 因为 Observer 没有劫持数组下标,只有那 7 个重写方法能触发更新。用 spliceVue.set 替代。

七、def —— 一个极简的工具函数

顺带提一下 def,因为上面用到了:

export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}

就是对 Object.defineProperty 的封装,默认不可枚举。Vue 内部用它来给对象加隐藏属性(比如 __ob__),不会出现在 for...inObject.keys() 里。


八、defineReactive —— 响应式的核心加工厂

最后也是最核心的一个函数。defineReactive 的使命:给对象的某个属性劫持 get 和 set,实现"读的时候收集依赖,写的时候派发更新"。

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,

    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },

    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

这段代码值得拆细了看。

Getter:读数据的时候发生了什么

get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()
    if (childOb) {
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
}

当你渲染模板、执行 computed 或 watch 的时候,会读到 this.xxx,就会触发这个 getter。

关键在 Dep.target。它指向当前正在执行的 Watcher(可能是渲染 Watcher、computed Watcher 或 watch Watcher)。如果 Dep.target 存在,说明"有人正在用这个数据",就调 dep.depend() 把这个 Watcher 记录下来。

如果值本身是对象或数组,还要递归地对子对象也收集依赖(childOb.dep.depend()),数组还要额外处理(dependArray)。

一句话:getter 负责"记住谁在用我"。

Setter:改数据的时候发生了什么

set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  if (process.env.NODE_ENV !== 'production' && customSetter) {
    customSetter()
  }
  if (setter) {
    setter.call(obj, newVal)
  } else {
    val = newVal
  }
  childOb = !shallow && observe(newVal)
  dep.notify()
}

当你执行 this.xxx = 新值,触发 setter:

  1. 先拿旧值,跟新值比一下,一样就直接 returnNaN !== NaN 的特殊情况也处理了),这是性能优化
  2. 开发环境下如果有 customSetter 就调一下(比如 initProps 里传的那个"不要直接改 props"的警告)
  3. 赋新值
  4. 新值如果是对象/数组,也要 observe,保证新数据也是响应式的
  5. dep.notify() —— 遍历之前收集的 Watcher 列表,逐个通知更新

一句话:setter 负责"通知所有用我的人,我变了"。

整个响应式闭环

画个简单的流程:

vue-reactive-flowchart.png


整条链路串起来

到这里,Vue 2 响应式初始化的完整链路就清楚了:

new Vue()
  → initState()
    → initProps()  → validateProp + defineReactive + proxy
    → initMethods()
    → initData()   → getData + 校验 + proxy + observe
    → initComputed()
    → initWatch()

proxy: this.xxx → this._data.xxx / this._props.xxx

observe: 判断要不要响应式 → new Observer()
  Observer:
    对象 → walk → defineReactive(给每个属性加 getter/setter)
    数组 → 重写 7 个变异方法 + observeArray 递归

defineReactive:
  get → dep.depend()(收集依赖)
  set → dep.notify()(派发更新)

每个函数各司其职,代码量不大但设计得很精巧。建议感兴趣的话对着源码自己走一遍,比看任何文章都管用。

LangGraph 使用指南

作者 remember_me
2026年4月24日 18:22

LangGraph 使用指南

基础概念

LangGraph 是一个用于构建有状态、多步骤 AI 工作流的框架,基于 LangChain 构建,核心概念包括:

  • Graph(图):工作流的整体结构,由节点和边组成
  • Node(节点):工作流中的处理步骤,可以是函数、LLM 调用或任何可执行逻辑
  • Edge(边):连接节点的路径,定义执行顺序
  • State(状态):在节点之间传递的共享数据对象
  • Compile(编译):将图转换为可执行对象

安装方法

# 基础安装
pip install langgraph

# 如果使用 LangChain 模型
pip install langchain langchain-openai

# 可选:用于可视化
pip install matplotlib

核心功能

1. 构建基本图结构

from typing import TypedDict, List
from langgraph.graph import StateGraph, END

# 定义状态类型
class State(TypedDict):
    messages: List[str]
    count: int

# 定义节点函数
def node1(state: State) -> State:
    state["messages"].append("Node 1 executed")
    state["count"] += 1
    return state

def node2(state: State) -> State:
    state["messages"].append("Node 2 executed")
    state["count"] += 1
    return state

# 创建图
graph = StateGraph(State)

# 添加节点
graph.add_node("step1", node1)
graph.add_node("step2", node2)

# 添加边
graph.set_entry_point("step1")
graph.add_edge("step1", "step2")
graph.set_finish_point("step2")

# 编译图
app = graph.compile()

# 执行
result = app.invoke({"messages": [], "count": 0})
print(result)

2. 条件边(条件路由)

from langgraph.graph import StateGraph, END

class State(TypedDict):
    input_text: str
    category: str

def classify(state: State) -> State:
    # 模拟分类逻辑
    if "?" in state["input_text"]:
        state["category"] = "question"
    else:
        state["category"] = "statement"
    return state

def handle_question(state: State) -> State:
    state["input_text"] = f"Answer to: {state['input_text']}"
    return state

def handle_statement(state: State) -> State:
    state["input_text"] = f"Processed statement: {state['input_text']}"
    return state

# 条件路由函数
def decide_category(state: State) -> str:
    if state["category"] == "question":
        return "question_node"
    return "statement_node"

# 构建图
graph = StateGraph(State)
graph.add_node("classify", classify)
graph.add_node("question_node", handle_question)
graph.add_node("statement_node", handle_statement)

graph.set_entry_point("classify")
graph.add_conditional_edges(
    "classify",
    decide_category,
    {
        "question_node": "question_node",
        "statement_node": "statement_node"
    }
)
graph.add_edge("question_node", END)
graph.add_edge("statement_node", END)

app = graph.compile()

result = app.invoke({"input_text": "What is LangGraph?", "category": ""})
print(result)

3. 循环和递归

class State(TypedDict):
    count: int
    max_count: int
    result: str

def increment(state: State) -> State:
    state["count"] += 1
    state["result"] = f"Step {state['count']}"
    return state

def should_continue(state: State) -> str:
    if state["count"] < state["max_count"]:
        return "increment"
    return "end"

graph = StateGraph(State)
graph.add_node("increment", increment)

graph.set_entry_point("increment")
graph.add_conditional_edges(
    "increment",
    should_continue,
    {"increment": "increment", "end": END}
)

app = graph.compile()
result = app.invoke({"count": 0, "max_count": 3, "result": ""})
print(result)

4. 集成 LLM(以 OpenAI 为例)

from langchain_openai import ChatOpenAI
from langchain.schema import HumanMessage
from langgraph.graph import StateGraph, END

class State(TypedDict):
    query: str
    response: str

def call_llm(state: State) -> State:
    llm = ChatOpenAI(temperature=0)
    messages = [HumanMessage(content=state["query"])]
    state["response"] = llm.invoke(messages).content
    return state

graph = StateGraph(State)
graph.add_node("llm_call", call_llm)
graph.set_entry_point("llm_call")
graph.set_finish_point("llm_call")

app = graph.compile()
result = app.invoke({"query": "What is the capital of France?", "response": ""})
print(result["response"])

最佳实践

1. 状态管理最佳实践

# 使用 TypedDict 确保类型安全
from typing import TypedDict, Optional, List

class ChatState(TypedDict):
    messages: List[dict]
    user_id: str
    session_data: Optional[dict]
    error: Optional[str]

2. 错误处理

def safe_node(state: State) -> State:
    try:
        # 业务逻辑
        result = process_data(state)
        return {"...": result}
    except Exception as e:
        state["error"] = str(e)
        return state

# 添加错误处理路径
def check_error(state: State) -> str:
    return "error_handler" if state.get("error") else "next_node"

3. 性能优化

# 使用缓存避免重复计算
from functools import lru_cache

@lru_cache(maxsize=100)
def expensive_computation(input_data: str) -> str:
    # 耗时操作
    return processed_result

def node_with_cache(state: State) -> State:
    state["result"] = expensive_computation(state["input"])
    return state

4. 可观测性

# 添加日志记录
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def monitored_node(state: State) -> State:
    logger.info(f"Processing state: {state}")
    result = process(state)
    logger.info(f"Result: {result}")
    return result

5. 测试策略

# 单元测试节点
def test_node():
    state = {"input": "test", "output": ""}
    result = my_node(state)
    assert result["output"] == "expected_output"
    
# 集成测试整个图
def test_graph():
    app = build_graph()
    result = app.invoke({"input": "test"})
    assert "output" in result

6. 常见陷阱避免

避免在节点内部修改共享状态

# ❌ 错误做法
def bad_node(state: State) -> State:
    state["shared_data"].append("value")  # 直接修改
    return state

# ✅ 正确做法
def good_node(state: State) -> State:
    new_state = state.copy()
    new_state["shared_data"] = state["shared_data"] + ["value"]
    return new_state

完整示例:问答系统

from typing import TypedDict, List, Optional
from langgraph.graph import StateGraph, END
from langchain_openai import ChatOpenAI
from langchain.schema import HumanMessage, SystemMessage

class QnAState(TypedDict):
    question: str
    context: Optional[str]
    answer: str
    confidence: float
    needs_clarification: bool

def validate_question(state: QnAState) -> QnAState:
    """验证问题是否有效"""
    if not state["question"] or len(state["question"]) < 3:
        state["needs_clarification"] = True
    return state

def handle_clarification(state: QnAState) -> QnAState:
    """处理需要澄清的问题"""
    state["answer"] = "Please provide a more specific question."
    return state

def retrieve_context(state: QnAState) -> QnAState:
    """检索相关上下文(模拟)"""
    # 实际中会从数据库或文档中检索
    state["context"] = f"Context related to: {state['question']}"
    return state

def generate_answer(state: QnAState) -> QnAState:
    """使用 LLM 生成答案"""
    llm = ChatOpenAI(temperature=0.7)
    system_msg = SystemMessage(content="Answer the question accurately.")
    human_msg = HumanMessage(content=f"Context: {state.get('context', 'No context')}\n\nQuestion: {state['question']}")
    response = llm.invoke([system_msg, human_msg])
    state["answer"] = response.content
    state["confidence"] = 0.9 if state.get("context") else 0.5
    return state

def decide_path(state: QnAState) -> str:
    """条件路由决策"""
    if state["needs_clarification"]:
        return "clarification"
    return "answer_generation"

# 构建图
graph = StateGraph(QnAState)
graph.add_node("validate", validate_question)
graph.add_node("clarification", handle_clarification)
graph.add_node("retrieval", retrieve_context)
graph.add_node("answer_generation", generate_answer)

graph.set_entry_point("validate")

# 条件边
graph.add_conditional_edges(
    "validate",
    decide_path,
    {
        "clarification": "clarification",
        "answer_generation": "retrieval"
    }
)

# 顺序边
graph.add_edge("retrieval", "answer_generation")
graph.add_edge("clarification", END)
graph.add_edge("answer_generation", END)

app = graph.compile()

# 执行
result = app.invoke({
    "question": "What is machine learning?",
    "context": None,
    "answer": "",
    "confidence": 0.0,
    "needs_clarification": False
})

print(f"Answer: {result['answer']}")
print(f"Confidence: {result['confidence']}")

进阶技巧

  1. 并行执行:使用 add_parallel_edges 实现并行节点
  2. 子图:创建可复用的子图模块
  3. 状态持久化:配合数据库实现长期状态存储
  4. 流式输出:使用 stream 方法实现实时输出

LangGraph 的强大之处在于将复杂的 AI 工作流抽象为有向图,使代码更清晰、可维护且易于调试。开始构建你的第一个图形化 AI 应用吧!

别再背“var 提升,let/const 不提升”了:揭开暂时性死区的真实面目

2026年4月24日 18:11

别再背“var 提升,let/const 不提升”了:揭开暂时性死区的真实面目

你可能听过:“var 有变量提升,letconst 没有。”
但当你写 console.log(x); let x = 1; 报错时,真的就是“没提升”吗?
这篇文章会帮你彻底搞懂提升、暂时性死区(TDZ)以及它们背后的设计原因。


1. 一个常见的“误解”

很多 JS 入门教程会告诉你:

  • var 有变量提升,可以在声明前访问(值为 undefined)。
  • letconst 没有变量提升,声明前访问会报错。

于是你记住了结论,但一遇到下面的代码又开始困惑:

let x = 1;
function test() {
  console.log(x); // ReferenceError: Cannot access 'x' before initialization
  let x = 2;
}

如果 let 真的“不提升”,为什么输出不是外层的 1 呢?
这恰恰说明:letconst 其实也提升了,只是行为不同。


2. 什么是“提升”?

JavaScript 引擎在执行代码前,会先进行编译阶段。在这个阶段,它会将所有变量和函数的声明移动到当前作用域的顶部。这个过程就叫提升(Hoisting)

注意:提升的是声明,而不是赋值。

2.1 函数声明的提升

函数声明会被整体提升,所以你可以在声明之前调用函数

sayHello(); // 输出 "Hello"

function sayHello() {
  console.log("Hello");
}

因为引擎实际看到的代码是顺序是:

function sayHello() { console.log("Hello"); }
sayHello();

2.2 var 的变量提升

var 声明的变量也会被提升,但只提升声明,不提升赋值,初始值为 undefined

console.log(a); // undefined(不是报错)
var a = 10;

实际效果:

var a;           // 提升到顶部,初始值 undefined
console.log(a);
a = 10;

3. letconst 真的“不提升”吗?

先看这段代码:

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 20;

如果 let 完全不提升,那么 b 在声明前应该根本不存在,错误应该是 b is not defined(未声明的变量错误)。
但实际错误是 “Cannot access before initialization”(初始化前无法访问)。这暗示了:引擎已经知道 b 存在于当前作用域,只是不允许你在它初始化之前使用

同样的现象也出现在 const 上。

3.1 暂时性死区(TDZ)

实际上,letconst 也会提升。但它们有一个额外的限制:从进入作用域到声明语句之间,变量处于“暂时性死区”(Temporal Dead Zone, TDZ)。在这期间访问变量会抛出 ReferenceError

所以,更准确的描述是:

  • var:提升 + 初始化为 undefined
  • let / const:提升 + 不初始化,且在声明前禁止访问

4. 为什么要有“暂时性死区”?直接不提升不行吗?

你可能会想:既然声明前不让用,那不如干脆不提升,让变量在声明前不存在,不是更简单?

4.1 首先,JavaScript 做不到“不提升”

JavaScript 采用词法作用域(也叫静态作用域),变量的作用域在编译时就已经确定了。为了知道一个标识符到底属于哪个作用域(是全局、函数内还是块内),引擎必须在编译阶段就把所有变量声明注册到对应的作用域。这个注册过程就是“提升”。

例如:

let x = 1;
{
  let x = 2;
}

如果没有编译阶段的注册,内部的 x 就无法与外部 x 区分开,作用域规则就乱套了。因此,无论 varlet 还是 const,都必须提升(即注册到作用域)

4.2 如果“不提升”,会出现什么灾难?

假设 JavaScript 真的让 let 完全不提升,即在声明前它不注册到当前作用域。那么看这段代码:

let x = 1;
function test() {
  console.log(x); // 按“不提升”的假设,这里应该去外层找 x
  let x = 2;
}
test();

如果引擎在编译时没有把内部的 x 注册到 test 函数作用域,执行到 console.log(x) 时,它会沿着作用域链向外查找,找到全局的 x = 1。然后输出 1,再执行 let x = 2 声明一个局部变量。

这会导致极其隐蔽的 bug:开发者以为内部声明了一个局部变量,但实际上却意外地访问到了外层的变量。这与 let 的设计宗旨——变量必须声明后才能使用,且不与上层作用域混淆——完全相悖。

4.3 TDZ 正是为了解决这个问题

let / const 的设计方案是:

  1. 编译阶段:将变量提升到当前作用域顶部(注册),但标记为“未初始化”。
  2. 执行阶段:从作用域顶部到声明语句之间,形成 TDZ,任何访问都报错。
  3. 执行到声明语句
    • 如果有初始化(let x = 10),则此时变量被初始化并赋值。
    • 如果只有声明(let x;),则初始化为 undefined

这样既保证了变量在声明前不会意外访问到外层同名变量(因为引擎知道当前作用域有这个变量,不会向外找),又强制你必须先声明后使用,代码更安全、更可预测。


5. 一个直观对比

声明方式 是否提升 初始值 声明前访问 表现
函数声明 ✅ 整体提升 函数体 ✅ 可以 正常调用
var ✅ 提升 undefined ✅ 可以(值为 undefined 不报错,但可能拿到意外值
let ✅ 提升(但 TDZ) ❌ 报错 ReferenceError: Cannot access before initialization
const ✅ 提升(但 TDZ) ❌ 报错 同上,且必须声明时初始化

6. 最佳实践建议

  • 默认使用 const,只有当变量需要被重新赋值时才用 let
  • 禁止使用 var,除非你明确需要利用它的提升特性(极少场景)。
  • 在作用域顶部声明变量,避免 TDZ 带来的困扰(虽然 TDZ 是规范,但写成先声明后使用是最清晰的)。

7. 总结

  • 所有声明(varletconst、函数声明)都会提升,本质是编译阶段将变量/函数注册到作用域。
  • var 在提升时初始化为 undefined,允许提前访问(但容易导致 bug)。
  • let / const 也提升,但进入 TDZ,在声明前访问会报错,强制你先声明后使用。
  • TDZ 的存在是为了在不破坏词法作用域的前提下,避免“变量泄漏”到外层作用域,同时提供更严格的编程约束。
  • 下次面试官问你“letconst 有变量提升吗?”,你可以自信地回答:“有的,但存在暂时性死区。”

💬 互动:你在实际开发中遇到过因 TDZ 导致的 bug 吗?评论区分享你的经历,我们一起避坑。

(完)

How to Install Node.js and npm on Ubuntu 26.04

Node.js is an open-source JavaScript runtime built on Chrome’s V8 engine. It lets you run JavaScript outside the browser and is commonly used for APIs, command-line tools, and server-side applications. npm is the default package manager for Node.js.

This guide covers three ways to install Node.js and npm on Ubuntu 26.04:

  • NodeSource repository - Install a specific Node.js version. NodeSource supports Node.js v25.x, v24.x, and v22.x.
  • nvm (Node Version Manager) - Manage multiple Node.js versions on the same machine. This is the preferred method for developers.
  • Ubuntu repository - The easiest way. Ubuntu 26.04 includes Node.js v22.x, which is suitable for many applications.

Choose the method that fits your needs. If you are not sure which version to install, check the documentation of the application you are deploying.

Quick Reference

Task Command
Install via NodeSource sudo apt install nodejs (after adding repo)
Install via nvm nvm install --lts
Install via Ubuntu repo sudo apt install nodejs npm
Check Node.js version node -v
Check npm version npm -v
List installed versions (nvm) nvm ls
Switch Node.js version (nvm) nvm use <version>
Set default version (nvm) nvm alias default <version>
Uninstall Node.js sudo apt remove nodejs or nvm uninstall <version>

Installing Node.js and npm from NodeSource

NodeSource maintains an APT repository with multiple Node.js versions. Use this method when you need a specific version.

Install the required dependencies:

Terminal
sudo apt update
sudo apt install ca-certificates curl gnupg

Import the NodeSource GPG key:

Terminal
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg

NodeSource provides the following versions:

  • v25.x - The current release.
  • v24.x - The latest LTS version (Krypton).
  • v22.x - Maintenance LTS version (Jod).

We will install Node.js version 24.x. Change NODE_MAJOR=24 to your preferred version if needed:

Terminal
NODE_MAJOR=24
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list

Install Node.js and npm:

Terminal
sudo apt update
sudo apt install nodejs

The nodejs package includes both node and npm binaries.

Verify the installation:

Terminal
node --version

You should see output similar to this:

output
v24.x
Terminal
npm --version

The output will show the npm version bundled with the installed Node.js release:

output
11.x

To compile native addons from npm, install the development tools:

Terminal
sudo apt install build-essential

Installing Node.js and npm using NVM

NVM (Node Version Manager) is a bash script that lets you manage multiple Node.js versions per user. This is the preferred method for developers who need to switch between versions.

Download and install nvm:

Terminal
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash

Do not use sudo , as it will enable nvm for the root user only.

The script clones the repository to ~/.nvm and adds the required lines to your shell profile:

output
=> Close and reopen your terminal to start using nvm or run the following to use it now:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion

Either close and reopen your terminal or run the commands above to load nvm in the current session.

Verify the installation:

Terminal
nvm -v
output
0.40.3

List all available Node.js versions:

Terminal
nvm list-remote
output
...
v22.x (LTS: Jod)
...
v24.x (LTS: Krypton)
...
v25.x

Install the latest Node.js version:

Terminal
nvm install node
output
...
Now using node v25.x (npm v11.x)
Creating default alias: default -> node (-> v25.x)

Verify the installation:

Terminal
node -v
output
v25.x

Install additional versions:

Terminal
nvm install --lts
nvm install 22

List installed versions:

Terminal
nvm ls
output
-> v22.x
v24.x
v25.x
default -> node (-> v25.x)
iojs -> N/A (default)
unstable -> N/A (default)
node -> stable (-> v25.x) (default)
stable -> 25 (-> v25.x) (default)
lts/* -> lts/krypton (-> v24.x)
lts/jod -> v22.x
lts/krypton -> v24.x

The arrow (-> v22.x) indicates the active version. The default version activates when opening new shells.

Switch to a different version:

Terminal
nvm use 24
output
Now using node v24.x (npm v11.x)

Change the default version:

Terminal
nvm alias default 24

Installing Node.js and npm from the Ubuntu repository

Ubuntu 26.04 includes Node.js v22.x in its default repositories. This version works well for many applications and receives security updates through Ubuntu.

Install Node.js and npm:

Terminal
sudo apt update
sudo apt install nodejs npm

This installs Node.js along with the tools needed to compile native addons from npm.

Verify the installation:

Terminal
node -v
output
v22.x
Terminal
npm -v
output
10.x
Info
If you need a newer Node.js version, use NodeSource or nvm instead.

Uninstalling Node.js

The uninstall method depends on how you installed Node.js.

NodeSource or Ubuntu repository:

Terminal
sudo apt remove nodejs
sudo apt autoremove

nvm:

Terminal
nvm uninstall 24

To completely remove nvm, delete the ~/.nvm directory and remove the nvm lines from your ~/.bashrc or ~/.zshrc file.

Conclusion

We covered three ways to install Node.js and npm on Ubuntu 26.04. NodeSource provides specific versions, nvm offers flexibility for managing multiple versions, and the Ubuntu repository includes a stable LTS version out of the box.

For more information, see the official Node.js documentation .

Webpack vs Vite:核心差异、选型建议

2026年4月24日 17:35

Vite 与 Webpack 深度对比:特性、短板与选型建议

1. 为什么需要前端构建工具?

现代前端开发中,我们常常使用 TypeScript、SCSS、JSX 等非原生语法,以及 npm 包、图片、字体等多种资源。浏览器无法直接运行这些内容,因此需要构建工具进行转译、打包、优化。Webpack 和 Vite 是目前最主流的两款构建工具,它们代表了两种不同的构建哲学。

简单说:构建工具帮助我们把“高级代码”变成浏览器能懂的代码,还能自动处理文件依赖、压缩体积、提供开发服务器(热更新)。没有它,我们就要手动做很多重复工作。

2. 核心差异速览

维度 Webpack Vite
构建方式 全量打包(bundle) 开发时按需编译 + 生产打包
启动速度 随项目增大而变慢 极快(几乎与项目规模无关)
热更新 需重新构建变化模块 基于 ESM 的即时 HMR
配置复杂度 较高,需要配置 loader/plugin 开箱即用,配置简洁
生产优化 成熟强大(代码分割、Tree Shaking) 基于 Rollup,基本够用
生态 海量 loader/plugin 兼容 Rollup 插件,逐渐丰富
旧浏览器支持 通过 polyfill 可兼容 IE11 需额外配置(如 @vitejs/plugin-legacy

热更新的定义:热更新就是修改代码后,不刷新页面直接更新模块,保持页面状态。Vite 的热更新比 Webpack 更快,因为它是基于浏览器的原生 ES Module 按需替换。

3. Webpack:功能强大的模块打包器

3.1 核心理念

Webpack 将所有资源(JS、CSS、图片等)视为模块,从入口开始递归构建依赖图,最终打包成一个或多个 bundle 文件。它强调配置化可扩展性

3.2 工作流程

  1. 读取 webpack.config.js 配置。
  2. 从入口(entry)开始,通过 loader 转换非 JS 文件。
  3. 使用 plugin 在构建生命周期中执行任务(如生成 HTML、压缩代码)。
  4. 输出打包后的文件到 dist 目录。

如下图:

3.3 关键配置示例

// webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development',        // 或 'production'
  entry: './src/index.js',    // 入口
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true               // 每次打包前清空 dist
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']   // 顺序:从右到左
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({ template: './src/index.html' })
  ],
  devServer: {
    port: 8080,
    hot: true,
    open: true
  }
}

3.4 优势

  • 打包能力全面:支持几乎所有资源类型,通过 loader 体系无限扩展。
  • 生态丰富:有大量官方和社区 plugin,能满足各种复杂需求(如代码分割、资源内联、PWA 等)。
  • 生产优化成熟:Tree Shaking、代码分割、缓存控制等经过多年考验。
  • 高度可定制:几乎可以控制构建的每一个环节。

3.5 局限性

问题 说明
开发启动慢 每次启动都需要全量打包,项目越大越慢
热更新慢 修改代码后需要重新编译受影响的模块,大项目可能等待数秒
配置复杂 新手容易迷失在 loader 和 plugin 的组合中,配置错误难以排查
生产构建耗时长 大型项目打包可能耗时数分钟

4. Vite:面向现代浏览器的极速构建工具

4.1 核心理念

Vite 利用浏览器原生 ES Module 支持,在开发环境下不打包,直接按需编译请求的模块;生产环境则使用 Rollup 进行打包。它强调开发体验优先

4.2 工作流程

  1. 启动开发服务器,预构建依赖(使用 esbuild,极快)。
  2. 浏览器请求 main.js 时,Vite 拦截请求,实时编译 Vue/JSX/TS 等文件。
  3. 返回浏览器可执行的 ES Module 代码。
  4. 生产构建时调用 Rollup 打包,并进行优化。 如下图:

image.png

4.3 配置示例

// vite.config.js
import { defineConfig } from 'vite'
import legacy from '@vitejs/plugin-legacy'

export default defineConfig({
  plugins: [
    legacy({ targets: ['ie 11'] })   // 可选:兼容旧浏览器
  ],
  server: {
    port: 5173,
    open: true,
    proxy: { '/api': 'http://localhost:3000' }
  },
  build: {
    outDir: 'dist',
    sourcemap: true
  }
})

4.4 优势

  • 极速启动:无需打包,启动时间与项目规模无关,通常小于 1 秒。
  • 即时的热更新:基于 ESM 的 HMR 非常快,修改后浏览器几乎瞬间更新。
  • 开箱即用:支持 TypeScript、CSS、静态资源等,无需配置 loader。
  • 配置简洁:API 设计清晰,上手门槛低。
  • 现代工具链:使用 esbuild 预构建依赖,速度极快。

4.5 局限性

问题 说明
生产优化不如 Webpack 对于极大型项目,Vite 的打包结果可能比 Webpack 略大或优化不够细致
旧浏览器兼容麻烦 依赖 ES Module,要支持 IE11 必须引入 @vitejs/plugin-legacy,会增加构建复杂度
生态相对年轻 虽然兼容 Rollup 插件,但部分 Webpack 专属插件(如某些针对特殊资源的 loader)无法直接使用
开发环境与生产环境行为不一致 开发时使用 esbuild 转译,生产时使用 Rollup,可能导致细微差异

5. 如何选择?

5.1 适合 Webpack 的场景

  • 项目历史悠久,已经使用了大量 Webpack 专属插件(如某些特殊 loader)。
  • 需要极度精细的生产环境优化(如微前端架构、自定义代码分割策略)。
  • 团队对 Webpack 非常熟悉,迁移成本高。
  • 需要兼容非常古老的浏览器(如 IE11)且不希望额外配置。

5.2 适合 Vite 的场景

  • 新项目,希望快速启动和热更新,提升开发效率。
  • 使用 Vue 3 / React 18 + 现代浏览器为目标。
  • 项目以现代 JavaScript 为主,不依赖太多 Webpack 特有功能。
  • 希望配置简单,降低新手维护成本。

目前 Vite 已是 Vue 官方推荐工具,并广泛应用于 React、Svelte 等生态。对于绝大多数新项目,Vite 是更高效的选择。

6. 总结

维度 Webpack Vite
核心哲学 模块化打包,高度可控 开发体验优先,按需编译
启动速度 ⭐⭐ ⭐⭐⭐⭐⭐
热更新速度 ⭐⭐ ⭐⭐⭐⭐⭐
生产优化能力 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
配置复杂度 ⭐⭐⭐⭐
生态丰富度 ⭐⭐⭐⭐⭐ ⭐⭐⭐
最佳适用 大型复杂项目、遗留系统 新项目、现代浏览器应用

如果你追求极致的开发体验和快速启动,选 Vite;如果你需要极度精细的生产优化和丰富的生态,或者维护老项目,继续用 Webpack。两者并非互斥,也可以根据项目模块逐步迁移。


7. 面试常见问题与回答思路

Q1:什么是构建工具?为什么需要它?

参考答案
“构建工具可以帮助我们把现代前端代码(比如 TypeScript、JSX、SCSS)转译成浏览器能识别的 JavaScript、CSS,同时还能合并文件、压缩代码、处理图片等。它可以自动化很多重复工作,提升开发效率,并且提供开发服务器支持热更新。”

Q2:Vite 和 Webpack 的核心区别是什么?

参考答案(抓住两点即可):
“Webpack 在开发模式下会全量打包整个项目,所以项目越大启动越慢;而 Vite 利用浏览器原生 ES Module,开发时不打包,只按需编译请求的文件,因此启动非常快,热更新也更快。另外,Webpack 配置复杂但生态成熟,Vite 开箱即用但生产优化略逊一筹。”

Q3:你用过 Vite 或 Webpack 吗?怎么搭建一个简单的 Vite 项目?

参考答案
“用过 Vite。搭建非常简单,只需要三行命令:

npm create vite@latest my-app
cd my-app
npm install
npm run dev

启动后就能看到页面。Vite 默认支持 CSS、TypeScript、静态资源等,不用额外配置。”

Q4:如果让你选一个构建工具,你会选哪个?为什么?

参考答案
“如果是新项目,我会选 Vite。因为它启动快、热更新快、配置简单,能显著提高开发效率,而且 Vue/React 官方都推荐。但如果项目需要兼容 IE11,或者已经用了很多 Webpack 特有插件,那我会选 Webpack。”

Q5:Vite 和 Webpack 在生产环境打包上有什么区别?

参考答案
“Webpack 的生产优化更成熟,比如代码分割的策略更精细,Tree Shaking 效果更好,适合大型复杂项目。Vite 生产环境使用 Rollup 打包,基本够用,但对于极大型项目可能打包结果略大或优化不够细致。”

Q6:你遇到过 Vite 或 Webpack 的问题吗?怎么解决的?

参考答案(如果没有实际遇到过,可以这样说):
“我用 Vite 时遇到过端口被占用的问题,通过配置 server.port 改成其他端口解决。另外,Vite 默认不支持 IE11,如果需要兼容,要安装 @vitejs/plugin-legacy 插件。”

基于动态 NFT 的奢侈品腕表全生命周期溯源系统:Solidity 合约设计与 Hardhat/Viem 测试实践

作者 木西
2026年4月24日 17:32

前言

在 2026 年,奢侈品腕表行业的"全生命周期溯源"已不再是概念,而是演变为 动态 NFT(Dynamic NFT/dNFT)数字产品护照(DPP) 深度结合的成熟商业标准。本文基于 OpenZeppelin V5Solidity 0.8.24,完整呈现从开发、测试到部署的最小可行产品(MVP)落地流程。

一、项目背景与技术选型

随着 RWA(Real World Asset,现实世界资产)代币化持续升温,奢侈品行业正成为区块链落地的重要场景之一。据行业分析,艺术品与奢侈品(包括腕表)的代币化核心诉求在于降低投资门槛、提升流通效率,并通过链上不可篡改记录解决传统溯源体系中纸质证书易伪造、信息孤岛严重等痛点

本方案选择 ERC-721 作为底层标准,原因如下:

  • 唯一性:每枚腕表对应唯一 Token ID,天然匹配奢侈品"一物一证"的物理属性
  • 元数据扩展性:通过 ERC721URIStorage 支持动态元数据更新,使 NFT 能够随保养历史"进化"
  • 权限精细控制:OpenZeppelin V5 的 AccessControl 提供角色化权限管理,区分品牌管理员与授权维修师

二、智能合约架构设计

2.1 数据结构

ServiceRecord 结构体记录保养时间、类型、技师地址及详情,将物理维修行为上链存证。

2.2 权限模型

角色 权限
管理员 铸造 NFT、授权维修师
维修师 添加保养记录

基于 OpenZeppelin V5 AccessControl 实现,支持多管理员与角色继承。

2.3 核心函数

  • mintWatch:铸造 NFT,初始元数据指向出厂信息
  • addServiceRecord:维修师写入记录,自动触发元数据更新
  • _updateDynamicMetadata:动态 NFT 核心,Token URI 随保养状态变化而演进

2.4 完整合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

/**
 * @title LuxuryWatchdNFT
 * @dev 动态 NFT 用于名表全生命周期溯源
 */
contract LuxuryWatchdNFT is ERC721URIStorage, AccessControl {
    using Strings for uint256;

    // 定义角色:品牌管理员和授权维修师
    bytes32 public constant REPAIRER_ROLE = keccak256("REPAIRER_ROLE");

    // 保养记录结构体
    struct ServiceRecord {
        uint256 timestamp;    // 保养时间
        string serviceType;   // 保养类型(如:洗油、更换零件、抛光)
        address technician;   // 执行技师地址
        string details;       // 详细备注或图像哈希
    }

    // TokenID => 维修历史列表
    mapping(uint256 => ServiceRecord[]) public serviceHistory;
    
    uint256 private _nextTokenId;

    event ServiceAdded(uint256 indexed tokenId, string serviceType, address technician);

    constructor(address defaultAdmin) ERC721("LuxuryTimepiece", "LuxeWatch") {
        _grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin);
    }

    /**
     * @dev 铸造新表 NFT(通常在出厂或首次销售时)
     */
    function mintWatch(address to, string memory initialURI) public onlyRole(DEFAULT_ADMIN_ROLE) {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, initialURI);
    }

    /**
     * @dev 授权维修师添加保养记录
     * @param tokenId 手表对应的 NFT ID
     * @param _serviceType 保养项目
     * @param _details 记录详情或 IPFS 链接
     */
    function addServiceRecord(
        uint256 tokenId, 
        string memory _serviceType, 
        string memory _details
    ) public onlyRole(REPAIRER_ROLE) {
        require(_ownerOf(tokenId) != address(0), "Watch does not exist");

        serviceHistory[tokenId].push(ServiceRecord({
            timestamp: block.timestamp,
            serviceType: _serviceType,
            technician: msg.sender,
            details: _details
        }));

        emit ServiceAdded(tokenId, _serviceType, msg.sender);
        
        // 创新点:此处可以触发逻辑自动更新 TokenURI 
        // 比如指向一个包含最新维修次数的动态渲染网关
        _updateDynamicMetadata(tokenId);
    }

    /**
     * @dev 获取完整维修历史
     */
    function getFullHistory(uint256 tokenId) public view returns (ServiceRecord[] memory) {
        return serviceHistory[tokenId];
    }

    /**
     * @dev 内部函数:根据保养次数或状态更新元数据
     */
    function _updateDynamicMetadata(uint256 tokenId) internal {
        // 逻辑示例:如果保养超过 5 次,元数据标记为 "Vintage/Well-Maintained"
        // 实际应用中常配合 Chainlink Functions 更新
    }

    // 以下为 OpenZeppelin V5 要求的必须覆盖的函数
    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721URIStorage, AccessControl)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

三、Hardhat + Viem 测试体系

测试用例:LuxuryWatchdNFT (Dynamic RWA 溯源测试)

  • 核心业务流程:铸造、授权与溯源
    • 应当允许管理员铸造新表 NFT
    • 非授权地址尝试添加维修记录应当 Revert
  • 动态溯源记录更新成功: Movement Overhaul
    • 授权维修师后应能正确更新动态维护历史
  • 资产转让完成,终身保修历史数据无缝流转
    • 二手交易后,历史记录应保持完整
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network } from "hardhat";
import { getAddress } from 'viem';

describe("LuxuryWatchdNFT (Dynamic RWA 溯源测试)", function () {
    let watchContract: any;
    let publicClient: any;
    let admin: any, repairer: any, buyer: any, secondBuyer: any;
    let REPAIRER_ROLE: `0x${string}`;

    beforeEach(async function () {
        // 1. 初始化 Viem 客户端
        const { viem } = await (network as any).connect();
        publicClient = await viem.getPublicClient();
        [admin, repairer, buyer, secondBuyer] = await viem.getWalletClients();

        // 2. 部署合约
        watchContract = await viem.deployContract("LuxuryWatchdNFT", [
            getAddress(admin.account.address)
        ]);

        // 3. 获取角色 Hash
        REPAIRER_ROLE = await watchContract.read.REPAIRER_ROLE();
    });

    describe("核心业务流程:铸造、授权与溯源", function () {
        
        it("应当允许管理员铸造新表 NFT", async function () {
            const initialURI = "https://console.filebase.com/object/boykayurilogo/cattle.json";
            
            // 铸造 Token ID 为 0 的 NFT 给 buyer
            const hash = await watchContract.write.mintWatch([
                getAddress(buyer.account.address), 
                initialURI
            ]);
            
            const owner = await watchContract.read.ownerOf([0n]);
            const tokenURI = await watchContract.read.tokenURI([0n]);

            assert.strictEqual(getAddress(owner), getAddress(buyer.account.address));
            assert.strictEqual(tokenURI, initialURI);
            console.log(`✅ NFT 成功铸造并分配给: ${owner}`);
        });

        it("非授权地址尝试添加维修记录应当 Revert", async function () {
            // 先铸造一个
            await watchContract.write.mintWatch([getAddress(buyer.account.address), "uri"]);

            // repairer 此时尚未获得角色,尝试写入应失败
            await assert.rejects(
                watchContract.write.addServiceRecord(
                    [0n, "Full Service", "Ultrasonic cleaning"],
                    { account: repairer.account }
                ),
                /AccessControl/,
                "未授权地址不应允许写入记录"
            );
        });

        it("授权维修师后应能正确更新动态维护历史", async function () {
            // 1. 铸造
            await watchContract.write.mintWatch([getAddress(buyer.account.address), "uri"]);

            // 2. 授权维修师角色
            await watchContract.write.grantRole([
                REPAIRER_ROLE, 
                getAddress(repairer.account.address)
            ]);

            // 3. 维修师添加记录
            const serviceType = "Movement Overhaul";
            const details = "Replaced mainspring, water resistance test passed.";
            
            await watchContract.write.addServiceRecord(
                [0n, serviceType, details],
                { account: repairer.account }
            );

            // 4. 验证溯源数据
            const history = await watchContract.read.getFullHistory([0n]);
            
            assert.strictEqual(history.length, 1);
            assert.strictEqual(history[0].serviceType, serviceType);
            assert.strictEqual(getAddress(history[0].technician), getAddress(repairer.account.address));
            
            console.log(`✅ 动态溯源记录更新成功: ${serviceType}`);
        });

        it("二手交易后,历史记录应保持完整", async function () {
            // 1. 预设:铸造 -> 授权 -> 维修一次
            await watchContract.write.mintWatch([getAddress(buyer.account.address), "uri"]);
            await watchContract.write.grantRole([REPAIRER_ROLE, getAddress(repairer.account.address)]);
            await watchContract.write.addServiceRecord([0n, "Polishing", "Case mirror finish"], { account: repairer.account });

            // 2. 发生转移 (Buyer -> SecondBuyer)
            await watchContract.write.transferFrom([
                getAddress(buyer.account.address),
                getAddress(secondBuyer.account.address),
                0n
            ], { account: buyer.account });

            // 3. 验证新持有人能看到旧历史
            const history = await watchContract.read.getFullHistory([0n]);
            const currentOwner = await watchContract.read.ownerOf([0n]);

            assert.strictEqual(history.length, 1);
            assert.strictEqual(history[0].serviceType, "Polishing");
            assert.strictEqual(getAddress(currentOwner), getAddress(secondBuyer.account.address));
            
            console.log("✅ 资产转让完成,终身保修历史数据无缝流转");
        });
    });
});

四、部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const LuxuryWatchdNFTArtifact = await artifacts.readArtifact("LuxuryWatchdNFT");
 
  // 部署(构造函数参数:recipient, initialOwner)
  const LuxuryWatchdNFTHash = await deployer.deployContract({
    abi: LuxuryWatchdNFTArtifact.abi,//获取abi
    bytecode: LuxuryWatchdNFTArtifact.bytecode,//硬编码
    args: [deployerAddress],//部署者地址,初始所有者地址
  });
   const LuxuryWatchdNFTReceipt = await publicClient.waitForTransactionReceipt({ hash: LuxuryWatchdNFTHash });
   console.log("LuxuryWatchdNFT合约地址:", LuxuryWatchdNFTReceipt.contractAddress);

}

main().catch(console.error);

五、RWA 落地的关键挑战与应对

4.1 链上链下锚定

RWA 代币化的最大难点在于证明 Token 与物理资产的唯一对应关系。本方案建议:

  • 出厂时在表壳内嵌 NFC/RFID 芯片,芯片 ID 与 Token ID 绑定
  • 元数据 URI 指向包含芯片证书、高清图像、序列号的 IPFS 文件
  • 维修记录中的 details 字段可存储维修前后对比图的 IPFS 哈希

4.2 动态元数据的实现路径

_updateDynamicMetadata 当前为占位实现,生产环境可考虑:

  1. 链下渲染网关:服务端根据 serviceHistory.length 动态生成 JSON,返回不同等级的徽章(如 "Certified Vintage")
  2. Chainlink Functions:在达到特定条件时自动触发元数据更新,实现真正去中心化的动态 NFT

4.3 合规与隐私

根据 MiCA 等法规要求,RWA 代币化需嵌入 KYC/AML 流程。可在合约层增加:

  • 转账前的白名单校验(继承 ERC721Enumerable 或引入 Regulator 角色)
  • 维修记录的访问控制:完整历史仅对当前持有人、品牌方、授权维修师可见

六、安全与隐私增强扩展(补充)

基于本合约,可追加以下三项机制,分别解决物理锚定、防盗锁定与隐私验证问题: 1. NFC 物理绑定(EIP-5750)

  • 作用:确保"数字保卡"必须和"物理手表"在一起
  • 原理:通过 NFC 芯片将物理腕表与链上 Token ID 唯一绑定
  • 效果:防止 NFT 被单独盗取后伪造实物

2. EIP-5192 防盗锁定(SBT 动态化)

  • 作用:赃物无法变现
  • 原理:品牌方接到报案后,在链上标记 Locked 状态
  • 效果:一旦锁定,黑客无法在二级市场挂单转售
  • 场景价值:在豪车和名表领域具有极强的震慑力

3. 私有元数据与 ZK 证明

  • 作用:保护客户隐私的同时,确保资产全量信息可查
  • 原理:敏感数据链下存储,通过零知识证明验证关键属性
  • 效果:每一个细微零件都有据可查,防止"拼装表"流入市场

总结

本文展示了一套完整的奢侈品腕表动态 NFT 溯源系统,涵盖:

  1. 合约层:基于 OpenZeppelin V5 的 ERC721URIStorage + AccessControl 架构,实现铸造、角色授权、维修记录追加与动态元数据钩子
  2. 测试层:Hardhat + Viem 的端到端测试覆盖正向流程、权限边界与二手交易场景
  3. RWA 视角:将技术实现置于现实世界资产代币化的宏观背景下,讨论链上链下锚定、合规与动态元数据演进路径

该方案不仅适用于腕表,其"一物一证 + 角色化写入 + 历史随资产流转"的模式可扩展至艺术品、奢侈品包袋、高端酒类等 RWA 场景,为物理资产的数字化确权与流通提供可信基础设施

Ant Design Table 横向滚动条神秘消失?我是如何一步步找到真凶的

2026年4月24日 17:17

起因

项目中有一个设备管理页面,使用了 Ant Design 的 Table 组件,配置了横向和纵向滚动:

<Table
  scroll={{
    x: "100%",
    y: "calc(100vh - 300px)",
  }}
  // ... 其他属性
/>

某天测试同学反馈了一个诡异的问题:表格的滚动条会莫名其妙地消失

更离谱的是,滚动条虽然看不见了,但鼠标放在原来滚动条的位置仍然可以拖动一个"隐形"的滚动条!


第一步:确认复现路径

首先,我需要搞清楚滚动条在什么情况下会消失。经过反复测试,终于找到了稳定的复现路径:

  1. 在标签页 A 中打开设备管理页面,Table 正常显示横向和纵向滚动条 ✅
  2. 点击某个设备进入详情页,右键点击二维码,在新标签页 B 中打开手机端页面
  3. 在标签页 B 中按 F12 打开开发者工具,切换视图之后
  4. 切回标签页 A → 滚动条消失了!

关键发现:问题只在"标签页 B 切换设备仿真"后才会出现。如果不切换设备仿真,滚动条一直正常。

这说明问题跟 Chrome DevTools 的设备仿真有关。但为什么呢?设备仿真只影响当前标签页 B,为什么会影响到标签页 A?


第二步:排除 CSS 原因

我的第一反应是:是不是 CSS 样式污染了?

项目里有一个 device-details-mgmt.css,里面用全局的 ::-webkit-scrollbar 把所有滚动条设成了 5px 宽、浅灰色:

::-webkit-scrollbar {
    width: 5px;
    height: 5px;
}
::-webkit-scrollbar-thumb {
    background: #c1c1c1;  /* 浅灰滑块 */
}
::-webkit-scrollbar-track {
    background: #f1f1f1;  /* 浅灰轨道 */
}

5px 宽 + 浅灰色,在浅色背景下确实不太看得清。我试着把这些样式限定到设备详情容器内,避免影响 Table。

结果:滚动条照样消失。

这说明 CSS 不是根因。但我还是不死心,又试了几种 CSS 方案:

尝试的方案 结果
overflow: scroll !important 强制显示滚动条 ❌ 无效
scrollbar-gutter: stable 保留滚动条空间 ❌ 无效
scrollbar-color + scrollbar-width 标准属性 ❌ 无效

所有 CSS 方案全部无效!

这让我意识到,问题不在 CSS 层面,而是更底层的原因。


第三步:排除 JS 原因

既然 CSS 搞不定,那是不是 JS 的问题?

我怀疑的方向有:

怀疑 1react-full-screen 组件的跨标签页事件干扰

设备详情页用了 react-full-screen,设备仿真可能触发了全屏变化事件。我在 onChange 中加了 document.fullscreenElement 检查,只允许当前标签页的全屏事件生效。

结果:无效。全屏事件根本没有被触发。

怀疑 2vh 单位被设备仿真重新计算

Table 的 scroll.y 用了 calc(100vh - 300px),设备仿真可能改变了 vh 的值。我改用 useRef + getBoundingClientRect() 动态计算高度。

结果:无效。高度计算完全正确,滚动条消失不是因为高度问题。

怀疑 3:标签页切回时需要强制重渲染

我监听了 visibilitychange 事件,当标签页重新可见时,通过临时切换 overflow 属性强制浏览器重新渲染滚动条。

结果:无效。重新渲染后滚动条仍然是透明的。

JS 方案也全部无效!


第四步:换个思路——为什么 B 标签页能影响 A 标签页?

CSS 和 JS 都试过了,问题依然存在。我不得不重新审视一个最基本的问题:

为什么标签页 B 的操作,能影响到标签页 A?

在正常的认知中,浏览器的每个标签页是相互隔离的。一个标签页的 JS、CSS、DOM 不应该影响另一个标签页。

但事实摆在眼前:B 的设备仿真确实影响了 A 的滚动条。

这说明 A 和 B 之间存在某种共享。那共享的是什么?


第五步:认识 Chrome 渲染进程

我开始研究 Chrome 的多进程架构,发现了一个关键知识点:

Chrome 会将具有 opener 关系的标签页分配到同一个渲染进程(Renderer Process)中。

什么是 opener 关系?当你用 window.open(url, '_blank') 打开新标签页时,新标签页可以通过 window.opener 访问原标签页。Chrome 为了性能优化,会将这样的两个标签页放在同一个渲染进程中。

而我们的代码正是这样写的:

// DownloadSvgQRCode.js
window.open(
  `${window.location.origin}/#/ScanDeviceQRCode?device_id=${device_id}`,
  '_blank'
  // 没有第三个参数!
);

没有 noopener,所以 A 和 B 共享同一个渲染进程!


第六步:理解设备仿真对渲染进程的影响

那设备仿真又是怎么影响渲染进程的呢?

当你在 DevTools 中切换设备仿真时,Chrome 通过 CDP(Chrome DevTools Protocol) 发送命令:

Emulation.setScrollbarsHidden({ hidden: true })
Emulation.setDeviceMetricsOverride({ mobile: true, ... })

关键在于 setScrollbarsHidden——它的效果是修改渲染进程级别的滚动条模式,将经典滚动条(Classic Scrollbar)切换为覆盖式滚动条(Overlay Scrollbar)。

而 Overlay 滚动条的特点是:半透明、自动隐藏。这就是为什么滚动条看起来"消失"了,但拖动区域还在——滚动条其实还在,只是变成了透明的 overlay 模式!

因为 A 和 B 共享同一个渲染进程,所以 B 的设备仿真修改了进程级滚动条模式,A 也被影响了!


第七步:验证——noopener 分离渲染进程

既然根因是共享渲染进程,那解决方案就是让 A 和 B 使用独立的渲染进程

方法很简单:给 window.open 添加 noopener 参数:

// 修改前
window.open(url, '_blank');

// 修改后
window.open(url, '_blank', 'noopener');

noopener 做了两件事:

  1. 断开 opener 关系:新标签页的 window.opener 变为 null
  2. 强制分离渲染进程:Chrome 不再需要维护 opener 通信通道,新标签页被分配到独立渲染进程

修改后测试:✅ 问题完美解决! B 标签页的设备仿真不再影响 A 标签页的滚动条。


原因总结

用一张图说清楚整个因果链:

window.open('_blank') 没有加 noopener
        │
        ▼
AB 标签页建立 opener 关系
        │
        ▼
Chrome 将 AB 分配到同一个渲染进程
        │
        ▼
B 标签页切换设备仿真
        │
        ▼
CDP 发送 Emulation.setScrollbarsHidden({ hidden: true })
        │
        ▼
渲染进程级别的滚动条模式从 Classic 切换为 Overlay
        │
        ▼
A 标签页的滚动条也变成 Overlay 模式(半透明、自动隐藏)
        │
        ▼
A 标签页的滚动条"消失"了!

修复:添加 noopener,让 B 使用独立渲染进程,B 的设备仿真不再影响 A。


延伸知识

Chrome 渲染进程与标签页的关系

打开方式 是否共享渲染进程
window.open(url, '_blank') ✅ 共享(同一站点)
window.open(url, '_blank', 'noopener') ❌ 独立
用户手动 Ctrl+T 打开新标签页 ❌ 独立
从书签栏打开 ❌ 独立

两种滚动条模式的区别

Classic(经典) Overlay(覆盖式)
外观 始终可见 半透明,自动隐藏
布局 占据空间 浮在内容上方
CSS ::-webkit-scrollbar ✅ 有效 无效
scrollbar-gutter: stable ✅ 有效 无效
触发条件 桌面模式(默认) 移动端 / DevTools 设备仿真

CDP 命令的影响范围

CDP 命令 影响范围
Emulation.setDeviceMetricsOverride 仅当前标签页
Emulation.setScrollbarsHidden ⚠️ 整个渲染进程
Emulation.setTouchEmulationEnabled 仅当前标签页

如何确认标签页是否共享渲染进程

  • 方法 1:按 Shift+Esc 打开 Chrome 任务管理器,查看是否有多个标签页共用同一个进程 ID
  • 方法 2:地址栏输入 chrome://process-internals,查看每个标签页的进程信息
  • 方法 3:在 Console 中执行 console.log(window.opener),如果不为 null,说明可能共享渲染进程

最终修复

// DownloadSvgQRCode.js

// 修改前
window.open(
  `${window.location.origin}/#/ScanDeviceQRCode${device_id ? `?device_id=${device_id}` : ''}`,
  '_blank'
);

// 修改后 —— 只加了第三个参数 'noopener'
window.open(
  `${window.location.origin}/#/ScanDeviceQRCode${device_id ? `?device_id=${device_id}` : ''}`,
  '_blank',
  'noopener'
);

一行代码,问题解决。noopener 不仅是安全最佳实践(防止 tabnapping 攻击),还能避免渲染进程级别的副作用。

前端JavaScript:Object和Map及其区别是什么?

作者 淸湫
2026年4月24日 17:05

在 JavaScript 中,ObjectMap 都是用于存储键值对的数据结构。长期以来,开发者们习惯使用普通对象来处理映射关系,但随着 ES6 的到来,Map 的出现彻底改变了这一局面。你是否曾疑惑过,为什么明明对象也能存键值对,还要引入 Map?它们之间到底有什么区别?什么时候该用 Map,什么时候该用 Object?本文将从底层原理到实战应用,带你彻底搞懂这两个数据结构。

一、基础认知:从设计初衷说起

1.1 传统的 Object:为结构化数据而生

普通对象(Plain Object)是 JavaScript 中最基础的数据结构之一,它的设计初衷是用来表示一个 “实体” 或 “结构化数据”。比如:

const user = {
  name: '张三',
  age: 25,
  city: '北京'
};

在这个例子中,user 代表了一个用户实体,它的键是固定的字符串,值是对应的属性。这种场景下,Object 非常直观,我们可以通过 . 操作符快速访问属性。

1.2 现代的 Map:为通用映射而生

Map 是 ES6 引入的新数据结构,它的设计目标是成为一个通用的键值对映射容器。它不再局限于 “实体” 的概念,而是更像一个字典,允许你将任意类型的值映射到另一个值,无论键是什么类型。

const userMap = new Map();
userMap.set('name', '张三');
userMap.set({ id: 1 }, '用户详情'); // 直接用对象作为键

二、核心差异:8 个维度的全面对比

为了让你直观地看到两者的区别,我们先来看一张完整的特性对比表:

图 1:Map 与 Object 核心特性对比

接下来,我们深入解析这些差异。

2.1 键的类型:突破限制的灵活性

这是 Map 最核心的优势。

  • Object:键只能是 字符串Symbol 类型。如果你尝试使用其他类型,JavaScript 会自动调用 toString() 方法将其转换为字符串。
  • Map:键可以是 任意类型,包括对象、函数、数组、数字、布尔值,甚至 NaN
// Object 的隐式类型转换
const obj = {};
const key1 = { id: 1 };
const key2 = { name: 'test' };

obj[key1] = '这是第一个对象';
obj[key2] = '这是第二个对象';

console.log(obj[key1]); 
// 输出:"这是第二个对象"!因为 key1.toString() 和 key2.toString() 都是 "[object Object]"

而在 Map 中,这完全不是问题:

const map = new Map();
const key1 = { id: 1 };
const key2 = { name: 'test' };

map.set(key1, '这是第一个对象');
map.set(key2, '这是第二个对象');

console.log(map.get(key1)); // 输出:"这是第一个对象"
console.log(map.get(key2)); // 输出:"这是第二个对象"

这意味着,你可以直接将 DOM 元素、函数实例作为键,来存储它们的关联数据,而无需手动生成唯一 ID。

2.2 键的顺序:严格的插入顺序

很多人以为 Object 的键是无序的,其实在 ES6 之后,Object 也开始保留插入顺序了,但它有一个致命的例外:数字键会被优先排序

const obj = {};
obj['b'] = 2;
obj['1'] = 1;
obj['a'] = 3;
obj['2'] = 4;

console.log(Object.keys(obj)); 
// 输出:["1", "2", "b", "a"]
// 数字键被自动排到了前面,完全打乱了插入顺序!

而 Map 则严格保证了插入顺序,没有任何例外:

const map = new Map();
map.set('b', 2);
map.set('1', 1);
map.set('a', 3);
map.set('2', 4);

console.log(Array.from(map.keys())); 
// 输出:["b", "1", "a", "2"]
// 完美遵循了我们的插入顺序

这对于日志记录、有序缓存等时序敏感的场景至关重要。

2.3 大小获取:O (1) vs O (n)

获取键值对的数量,两者的效率天差地别。

  • Object:你必须手动遍历所有键来计算长度,这是一个 O (n) 的操作。

    •     const size = Object.keys(obj).length;
      
  • Map:内置了 size 属性,直接返回大小,这是一个 O (1) 的操作,无需遍历。

    •     const size = map.size;
      

2.4 迭代能力:原生的遍历支持

  • Object:它本身不是可迭代对象(Iterable),你无法直接使用 for...of 遍历它。必须先通过 Object.keys()Object.entries() 等方法转换为数组。
  • Map:它原生实现了迭代器协议,你可以直接遍历它,而且默认就是遍历键值对。
// Map 直接遍历
for (const [key, value] of map) {
  console.log(key, value);
}

// Object 必须转换
for (const [key, value] of Object.entries(obj)) {
  console.log(key, value);
}

2.5 原型链污染:安全的隔离

普通对象默认继承了 Object.prototype,这意味着它自带了 toStringhasOwnProperty 等默认属性。如果你不小心用这些名字作为键,就会发生冲突,甚至引发原型链污染攻击。

const obj = {};
console.log(obj.toString); // 输出:[Function: toString],这是原型上的方法

而 Map 从一开始就是一张白纸,它没有原型,完全不存在这个问题:

const map = new Map();
console.log(map.has('toString')); // 输出:false

三、性能深度剖析:谁更快?

很多人都听说过 Map 性能更好,但具体好在哪里?我们来看一下基于 V8 引擎的实测数据。

图 2:10 万次操作下的性能对比(单位:毫秒)

3.1 底层实现的差异

  • Object:V8 引擎为了优化属性访问,引入了 “隐藏类(Hidden Class)” 的机制。当你创建一个对象并添加固定的属性时,V8 会为它生成一个隐藏类,属性访问会被优化为直接的内存偏移,速度极快。但是,一旦你频繁地添加和删除属性,隐藏类就会不断地被重建和重排,这会带来巨大的性能开销,甚至会降级到 “字典模式”。
  • Map:它的底层是基于哈希表(Hash Table)实现的。哈希表天生就为频繁的增删查改做了优化,插入、删除、查找的平均时间复杂度都是 O (1)。无论你怎么操作,它的性能都非常稳定。

3.2 关键发现

从测试数据中我们可以看到:

  1. 删除操作:Map 比 Object 快了近 3 倍!这是因为 Object 删除属性会触发隐藏类的重排,而 Map 的哈希表删除只是调整指针。
  2. 插入操作:Map 也有明显优势,特别是在动态数据场景下。
  3. 查找操作:两者差距不大,Object 因为隐藏类的优化,在小数据量下甚至略快。
  4. 内存占用:存储 10 万条数据时,Map 比 Object 节省了约 38% 的内存。

四、实战应用:什么时候用哪个?

了解了原理,我们来看看实际开发中该如何选择。

4.1 优先使用 Map 的场景

当你遇到以下情况时,Map 绝对是更好的选择:

1. 键不是简单的字符串

比如你需要用对象、DOM 元素作为键。

// 存储 DOM 元素的关联数据
const elementData = new Map();
const button = document.querySelector('#btn');

elementData.set(button, { clickCount: 0 });

button.addEventListener('click', () => {
  const data = elementData.get(button);
  data.clickCount++;
});

2. 需要频繁增删键值对

比如缓存系统、高频更新的状态。

// 防止重复请求
const pendingRequests = new Map();

function requestInterceptor(config) {
  const key = generateRequestKey(config);
  if (pendingRequests.has(key)) {
    // 取消之前的请求
    pendingRequests.get(key).cancel();
  }
  // 存储新的请求
  pendingRequests.set(key, cancelToken);
}

3. 需要有序的键值对

比如日志记录、有序的配置列表。

4. 需要频繁查询大小

比如你需要经常知道当前缓存里有多少条数据。

4.2 优先使用 Object 的场景

当然,Object 并没有被淘汰,在这些场景下,它依然是首选:

1. 存储静态的结构化数据

比如用户信息、配置项,这些数据的键是已知的、固定的字符串。

const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  debug: true
};

这种场景下,Object 的 . 语法访问属性比 map.get() 更直观,而且 V8 的隐藏类优化能发挥到极致。

2. 需要 JSON 序列化

Object 天生支持 JSON.stringify(),而 Map 不支持,需要手动转换。

JSON.stringify(user); // 正常工作
JSON.stringify(userMap); // 输出:{},无法直接序列化

3. 简单的一次性数据处理

如果只是临时存几个简单的键值对,用完就扔,用字面量 {} 创建对象比 new Map() 更快捷。

五、面试高频考点

这部分是面试中的常客,你需要掌握:

  1. 问:Map 和 Object 的区别是什么? 答:从键的类型、顺序、大小获取、迭代、原型链、性能这几个维度回答即可。
  2. 问:Object 的键顺序是怎样的? 答:ES6 之后,Object 会先把整数键按升序排,然后字符串和 Symbol 键按插入顺序排。而 Map 是严格的插入顺序。
  3. 问:为什么 Map 在频繁增删时性能更好? 答:因为 Object 底层是隐藏类,频繁增删会导致隐藏类重排;而 Map 是哈希表,增删查改都是 O (1) 的稳定操作。

六、总结

Map 和 Object 并不是谁取代谁的关系,它们是互补的。

  • Object 更像一个 “数据模型”,适合存储结构固定、键为字符串的静态数据,它支持 JSON 序列化,语法直观。
  • Map 更像一个 “数据容器”,适合处理动态的、复杂的映射关系,它支持任意键、有序性、高效的增删操作。

在现代前端开发中,随着应用复杂度的提升,Map 的使用场景越来越多。学会根据业务场景灵活选择,才能写出更高效、更健壮的代码。

参考资料

[1] MDN Web Docs. 带键的集合 [EB/OL]. developer.mozilla.org/zh-CN/docs/…, 2025. [2] zqmgx13291. JavaScript Map 数据结构:原理、实践与性能优化 [EB/OL]. CSDN 博客,2025. [3] 前端小木屋. Object 与 Map 的区别有哪些?[EB/OL]. 稀土掘金,2025. [4] Pu_Nine_9. 深入理解 ES6 Map 数据结构:从理论到实战应用 [EB/OL]. CSDN 博客,2026. [5] 软件求生。你以为你会用 Map? 这些细节 90% 的人都忽略了 [EB/OL]. 今日头条,2026.

js 实现 Blob、File、ArrayBuffer、base64、URL 之间互转

2026年4月24日 17:00

在处理文件数据时常常需要将其转换为其他的类型数据以方便后续操作。例如在引入第三方库时,支持的类型可能在项目不能直接获取到,那么就需要进行类型转换。其中主要的类型包括 Blob、File、ArrayBuffer、base64、URL 。

类型解释

Blob

Blob(Binary Large Object)是一种二进制大对象,是一种存储大量二进制数据的容器。

File

File 通常为用户在 input 上选择文件的结果。 继承于 Blob,一些处理 Blob 的函数也可以直接处理 FIle(如:URL.createObjectURL)。

ArrayBuffer

ArrayBuffer 是一种用于表示通用的、固定长度的原始二进制数据缓冲区的对象。它提供了一种在内存中分配固定大小的缓冲区,可以存储各种类型的二进制数据。ArrayBuffer 本身并不能直接操作数据,而是需要使用 TypedArray 视图或 DataView 对象来读取和写入数据。

base64

Base64 是一种用于表示二进制数据的编码方式,通过将二进制数据转换为文本字符串,以便在文本环境中传递。

URL

URL 可以分为两种,一种为 base64 拼接上类型的 DataURL 地址,另一种为 createObjectURL 方法创建的当前页面生命周期下的 ObjectURL 地址。

DataURL: data:image/png;base64,iVBORw0KGgoAAAANS...

ObjectURL: blob:https://f1eb432b-1ef7-42...

Blob 类型转换

对于 Blob 的 b 部分类型转换可以利用 FileReader 类的读取函数完成。其中包括 readAsArrayBuffer,readAsDataURL,readAsText(得到字符串形式内容)。

Blob 转 ArrayBuffer

function blobToArrayBuffer(blob) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.readAsArrayBuffer(blob);
  });
}

Blob 转 File

直接使用 File 构造方法即可,可以指定文件名称,文件类型(如:image/jpeg),修改时间

function blobToFile(blob, fileName, type = '', lastModified = Date.now()) {
  return new File(blob, fileName, { type, lastModified });
}

Blob 转 DataURL

function blobToDataURL(blob) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.readAsDataURL(blob);
  });
}

Blob 转 base64

先使用 FileReader 将 Blob 转为 DataURL,再对将 DataURL 的类型去掉既可以。

function blobToBase64(blob) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result.split(',')[1]);
    reader.readAsDataURL(blob);
  });
}
// 使用 blobToDataURL
function blobToBase64(blob) {
  return new Promise((resolve) => {
    blobToDataURL(blob).then((dataURL) => resolve(dataURL.split(',')[1]));
  });
}

Blob 转 ObjectURL

function blobToObjectURL(blob) {
  return URL.createObjectURL(blob);
}

File 类型转换

File 转 Blob、ArrayBuffer、base64、DataURL

在大多数情况下是不需要转换的,因为 File 本来就继承与 Blob。在必须转换的情况下可以利用 FileReader.readAsArrayBuffer 获取到 arrayBuffer,再将 arrayBuffer 转为 Blob

ArrayBuffer、DataURL 也可以通过 FileReader 转换

base64 只需要把 DataURL 的类型去掉即可

function fileToBlob(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(new Blob([reader.result], { type: file.type }));
    reader.readAsArrayBuffer(file);
  });
}
function fileToArrayBuffer(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.readAsArrayBuffer(file);
  });
}
function fileToDataURL(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.readAsDataURL(file);
  });
}
function fileToBase64(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result.split(',')[1]);
    reader.readAsDataURL(file);
  });
}

File 转 ObjectURL

function blobToObjectURL(blob) {
  return URL.createObjectURL(blob);
}

ArrayBuffer 类型转换

ArrayBuffer 是没有指定类型的二进制缓存,所以在一些转换时需要提供具体的类型。

ArrayBuffer 转 Blob、File

直接使用 Blob、File 构造函数即可,可以指定数据类型。文件可以指定文件名和修改时间。

function arrayBufferToBlob(arrayBuffer, type) {
  return new Blob(arrayBuffer, { type });
}
function arrayBufferToFile(arrayBuffer, fileName, type = '', lastModified = Date.now()) {
  return new File(arrayBuffer, fileName, { type, lastModified });
}

ArrayBuffer 转 Base64

需要先将 ArrayBuffer 转为二进制字符串,再将二进制字符串转为 Base64

function arrayBufferToBase64(arrayBuffer) {
  return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
}

ArrayBuffer 转 DataURL

  1. 先将 ArrayBuffer 转为 base64,再加上类型即可。(推荐)
  2. 先将 ArrayBuffer 转为 Blob,再使用 FileReader.readAsDataURL 获取。
function arrayBufferToDataURL(arrayBuffer, type) {
  return `data:${type};base64,${btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))}`;
}
function arrayBufferToDataURL(arrayBuffer, type) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.readAsDataURL(new Blob(arrayBuffer, { type }));
  });
}

ArrayBuffer 转 ObjectURL

需要先将 ArrayBuffer 转为 Blob 或者 File,再使用 createObjectURL 转为 ObjectURL

function arrayBufferToObjectURL(arrayBuffer, type) {
  return URL.createObjectURL(new Blob(arrayBuffer, { type }));
}

String 转 ArrayBuffer

有时需要将其他类型转换为 ArrayBuffer,比如将字符串转为 ArrayBuffer:

function stringToArrayBuffer(text) {
  return new TextEncoder().encode(text).buffer;
}

DataURL 类型转换

DataURL 转 base64

直接去掉类型即可

function stringToArrayBuffer(dataURL) {
  return dataURL.split(',')[1];
}

DataURL 转 ArrayBuffer

在转为 ArrayBuffer 时需要先提取 base64 并解码,然后定义二进制字符串长度的 ArrayBuffer 并关联 Unit8Array,最后将字符串转为 UTF-16 码元并写入关联的 Unit8Array 中

function dataURLToArrayBuffer(dataURL) {
  const base64 = dataURL.split(',')[1];
  const binaryString = atob(base64);
  const arrayBuffer = new ArrayBuffer(binaryString.length);
  const uint8Array = new Uint8Array(arrayBuffer);
  for (let i = 0; i < binaryString.length; i++) {
    uint8Array[i] = binaryString.charCodeAt(i);
  }
  return arrayBuffer;
}

DataURL 转 Blob、File

转为 Blob 或 File 时其实是几种数据切换:DataURL => base64 => binaryArray => typedArray => Blob\File

其中使用 atob 将 base64 解码为字符串,定义 Unit8Array 的 typedArray 用于缓存 UTF-16 码元,通过 String.chartCodeAt 获取字符的 UTF-16,最后使用 Blob\File 的构造函数完成类型转换。由于 Blob 和 File 构造函数可以接受 typedArray,那么就没必要转 ArrayBuffer 了。另外转 File 时可以指定文件名

function base64ToUnit8Array(base64) {
  const binaryString = atob(base64);
  const uint8Array = new Uint8Array(binaryString.length);
  for (let i = 0; i < binaryString.length; i++) {
    uint8Array[i] = binaryString.charCodeAt(i);
  }
  return uint8Array;
}
function dataURLToBlob(dataURL) {
  const [type, base64] = dataURL.split(',');
  return new Blob([base64ToUnit8Array(base64)], { type });
}
function dataURLToFile(dataURL, fileName) {
  const [type, base64] = dataURL.split(',');
  return new File([base64ToUnit8Array(base64)], fileName, { type });
}

DataURL 转 ObjectURL

由于 createObjectURL 接受 Blob 或 File,所以需要先转为 Blob 或 File。这里转为 Blob。

function dataURLToObjectURL(dataURL) {
  const [type, base64] = dataURL.split(',');
  const binaryString = atob(base64);
  const uint8Array = new Uint8Array(binaryString.length);
  for (let i = 0; i < binaryString.length; i++) {
    uint8Array[i] = binaryString.charCodeAt(i);
  }
  return URL.createObjectURL(new Blob([uint8Array], { type }));
}
// 使用 dataURLToBlob
function dataURLToObjectURL(dataURL) {
  const type = dataURL.split(',')[0];
  return URL.createObjectURL(dataURLToBlob(dataURL), { type });
}

ObjectURL 类型转换

一般情况下是不会有 ObjectURL 转为其他类型的需求的,因为 ObjetcURL 的生命周期只在当前页面,只会在当前页面由其他资源生成,既然已经存在其原资源,也就没有必要再转换,如果需要其他类型的也完全可以使用原资源来转换。如果需要转换,那么第一步就是通过请求拉到定义的数据。这些转换也是适用远程请求的。

ObjectURL 转 Blob、File

// function objectURLToBlob(objectURL, fileType) {
function objectURLToFile(objectURL, fileName, fileType) {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', objectURL, true);
  xhr.responseType = 'blob';
  return new Promise((resolve, reject) => {
    xhr.onload = () => {
      if (xhr.status === 200) {
        const blob = xhr.response;
        // resolve(blob);
        const file = new File([blob], fileName, { type: fileType });
        resolve(file);
      } else {
        reject(new Error('Failed to load the resource'));
      }
    };
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.send();
  });
}

ObjectURL 转 ArrayBuffer、base64、DataURL

先获取到文件数据,之后再使用 Blob 或者 File 类型转为 DataURL 或 ArrayBuffer 或其他类型即可。

总结

  1. 对于 File 和 Blob 的转为其他类型大多依赖 FileReader。

  2. 其他类型转为 File 或者 Blob 时最终都是通过构造函数完成的。

  3. base64 和 DataURL 的转换只是类型的截取和拼接。

  4. 转为 ObjectURL 时需要先转为 Blob 或者 File 再通过 createObjectURL 生成。

前端JavaScript:数据类型及类型判断

作者 淸湫
2026年4月24日 16:58

在 JavaScript 这门动态弱类型语言中,变量的类型在运行时才能确定,这既赋予了语言极大的灵活性,也给开发者带来了类型判断的挑战。你是否曾被 typeof null === 'object' 这一诡异的结果所困惑?是否在跨 iframe 环境中遇到过 instanceof 判断失效的问题?本文将从底层原理出发,带你彻底搞懂 JavaScript 的数据类型体系以及各种类型判断方法的适用场景。

一、JavaScript 的数据类型体系

在 ES2020 之后,JavaScript 总共定义了 8 种数据类型,它们被划分为两大类:原始类型(Primitive Types)引用类型(Reference Types)

1.1 原始类型:不可变的基础值

原始类型是直接存储在栈(Stack)内存中的简单数据段,它们的值是不可变的,且占据固定大小的空间。当你复制一个原始类型变量时,实际上是在栈中创建了一个全新的值。

目前 JavaScript 包含 7 种原始类型:

  • Undefined:只有一个值 undefined,表示变量未初始化。
  • Null:只有一个值 null,表示空对象指针。
  • Boolean:包含 truefalse 两个值。
  • Number:基于 IEEE 754 标准的双精度浮点数,包含整数和小数,以及特殊的 NaNInfinity
  • String:字符串类型,JavaScript 中的字符串是不可变的。
  • Symbol:ES6 引入,表示独一无二的值,常用于对象的属性键。
  • BigInt:ES2020 引入,用于表示任意精度的整数,解决了 Number 类型无法精确表示大整数的问题。

1.2 引用类型:可变的对象

引用类型的值是对象,它们存储在堆(Heap)内存中。栈内存中仅存储了指向堆内存地址的指针。当你复制一个引用类型变量时,实际上复制的只是这个指针,两个变量最终指向的是堆中的同一个对象。

引用类型包含了所有的对象类型,例如:

  • 普通对象(Object)
  • 数组(Array)
  • 函数(Function)
  • 日期(Date)
  • 正则(RegExp)
  • Map、Set 等

图 1:原始类型与引用类型在内存中的存储差异

二、类型判断的四大金刚

了解了数据类型之后,我们来看看如何准确地判断它们。JavaScript 提供了多种判断手段,但它们各有千秋。

2.1 typeof:快速但有缺陷的检测

typeof 是最基础也是最常用的类型判断运算符,它返回一个字符串,表示未经计算的操作数的类型。

console.log(typeof 42);          // "number"
console.log(typeof 'hello');     // "string"
console.log(typeof true);        // "boolean"
console.log(typeof undefined);   // "undefined"
console.log(typeof Symbol());    // "symbol"
console.log(typeof BigInt(123)); // "bigint"
console.log(typeof function(){});// "function"

然而,typeof 存在两个著名的缺陷:

  1. 无法区分具体的引用类型:除了 Function 之外,所有的对象(包括 Array、Date、RegExp 等)都会返回 "object"

    1.   console.log(typeof []);        // "object"
        console.log(typeof {});        // "object"
        console.log(typeof new Date());// "object"
      
  2. typeof null 返回 "object" :这是 JavaScript 历史上最著名的 Bug。在 JavaScript 最初的实现中,为了性能,值的类型是通过二进制的前三位来标记的,其中 000 代表对象。而 null 表示空指针,在大多数平台下被表示为全 0,因此它的前三位也是 000,导致被误判为对象。虽然这个 Bug 广为人知,但由于兼容性原因,至今未能修复。

2.2 instanceof:基于原型链的侦探

为了解决引用类型的判断问题,JavaScript 提供了 instanceof 运算符。它的原理是检查构造函数的 prototype 属性是否出现在目标对象的原型链上。

let arr = [];
console.log(arr instanceof Array);  // true
console.log(arr instanceof Object); // true,因为 Array 的原型最终也指向 Object

let date = new Date();
console.log(date instanceof Date);  // true

手写实现 instanceof

理解了原理,我们就可以手动实现一个 instanceof

function myInstanceof(left, right) {
    // 基本类型直接返回 false
    if (typeof left !== 'object' || left === null) return false;
    
    // 获取原型链
    let proto = Object.getPrototypeOf(left);
    while (true) {
        if (proto === null) return false; // 找到原型链顶端
        if (proto === right.prototype) return true;
        proto = Object.getPrototypeOf(proto);
    }
}

instanceof 的局限性:

  • 无法判断基本类型:基本类型没有原型链,所以 123 instanceof Number 永远是 false
  • 跨执行上下文失效:在不同的 iframe 中,各自有独立的执行环境和全局对象。如果父窗口把一个数组传给子窗口,在子窗口中用 instanceof Array 判断会失败,因为它们的 Array 构造函数不是同一个。

2.3 Object.prototype.toString:万能的检测器

如果你需要一个能准确判断所有类型的终极方案,那么 Object.prototype.toString 绝对是你的首选。

根据 ECMAScript 规范,这个方法会返回一个格式为 [object Type] 的字符串,其中 Type 就是该值的内部 [[Class]] 属性。这个属性是引擎内部用来标记类型的,几乎无法被篡改。

const toString = Object.prototype.toString;

console.log(toString.call(123));        // "[object Number]"
console.log(toString.call('hello'));    // "[object String]"
console.log(toString.call(null));        // "[object Null]"
console.log(toString.call(undefined));   // "[object Undefined]"
console.log(toString.call([]));          // "[object Array]"
console.log(toString.call(new Date()));  // "[object Date]"
console.log(toString.call(new Map()));   // "[object Map]"

通过这个方法,我们可以封装一个通用的类型检测函数:

function getType(value) {
    return Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
}

console.log(getType([]));      // "array"
console.log(getType(null));    // "null"
console.log(getType(new Map())); // "map"

这个方法完美解决了 typeofinstanceof 的所有痛点,无论是基本类型、引用类型,还是跨环境判断,它都能准确无误。

2.4 专用检测方法

除了上述通用方法,JavaScript 还提供了一些专用的检测函数,例如:

  • Array.isArray():专门用于判断是否为数组,它的本质上也是基于内部的 [[Class]] 实现的,因此比 instanceof 更可靠。
  • Number.isNaN():用于判断是否为 NaN,比全局的 isNaN 更严格,因为它不会进行隐式类型转换。

三、各方法对比与实战

为了让你更直观地看到各种方法的差异,我们整理了如下对比表:

图 2:不同类型判断方法的表现对比

3.1 实战场景:深拷贝中的类型判断

在实现深拷贝函数时,我们需要准确判断数据的类型,以便进行不同的处理:

function deepClone(obj) {
    const type = getType(obj);
    
    switch (type) {
        case 'object':
            const clonedObj = {};
            for (let key in obj) {
                clonedObj[key] = deepClone(obj[key]);
            }
            return clonedObj;
        case 'array':
            return obj.map(item => deepClone(item));
        case 'date':
            return new Date(obj.getTime());
        case 'regexp':
            return new RegExp(obj);
        default:
            // 基本类型直接返回
            return obj;
    }
}

3.2 通用工具函数

在实际项目中,我们通常会封装一个类型检查工具类:

const TypeChecker = {
    isString: (val) => typeof val === 'string',
    isNumber: (val) => typeof val === 'number' && !isNaN(val),
    isBoolean: (val) => typeof val === 'boolean',
    isFunction: (val) => typeof val === 'function',
    isArray: (val) => Array.isArray(val),
    isObject: (val) => getType(val) === 'object',
    isNull: (val) => val === null,
    isUndefined: (val) => val === undefined,
    isEmpty: (val) => {
        if (val === null || val === undefined) return true;
        if (typeof val === 'string' || Array.isArray(val)) return val.length === 0;
        if (typeof val === 'object') return Object.keys(val).length === 0;
        return false;
    }
};

四、面试高频考点

在前端面试中,类型判断是一个高频考点,以下是几个必问的问题:

  1. 问:为什么 typeof null 等于 'object'? 答:这是 JavaScript 早期实现的历史遗留问题。由于 null 的二进制表示全为 0,与对象的类型标签(前三位 000)冲突,导致被误判。
  2. 问:如何准确判断一个变量是数组? 答:推荐使用 Array.isArray(),它是 ES5 引入的标准方法,能处理跨环境问题。其次可以使用 Object.prototype.toString.call(arr) === '[object Array]'
  3. 问: instanceof 的原理是什么? 答:它通过遍历左边变量的原型链,检查右边构造函数的 prototype 是否存在于该原型链上。

五、最佳实践建议

经过以上分析,我们可以总结出如下最佳实践:

  • 判断基本类型:优先使用 typeof,注意对 null 要额外判断 val === null
  • 判断数组:直接使用 Array.isArray(),简单高效。
  • 判断特定引用类型:在同环境下可以用 instanceof,但如果涉及到跨窗口通信,优先使用 toString
  • 通用、准确的类型检测:使用 Object.prototype.toString.call(),它是最可靠的万能方法。
  • 性能敏感场景:如果是在性能要求极高的循环中,优先使用 typeofinstanceof,因为它们的性能比调用 toString 要快。

总结

JavaScript 的类型系统虽然看似简单,但其背后隐藏着许多设计细节和历史遗留问题。理解原始类型与引用类型的区别,掌握 typeofinstanceofObject.prototype.toString 这三种核心判断方法的原理与局限,是你写出健壮、可靠代码的基础。

记住,没有最好的方法,只有最合适的方法。根据不同的业务场景,灵活选择判断手段,才能真正驾驭好这门动态语言。

参考资料

[1] MDN Web Docs. JavaScript 数据类型和数据结构 [EB/OL]. developer.mozilla.org/zh-CN/docs/…, 2025. [2] 前端侦探。三种类型判断的区别和原理解析 [EB/OL]. 稀土掘金,2023. [3] BUG 收容所所长. JavaScript 类型判断终极指南 [EB/OL]. 稀土掘金,2025. [4] 发现一只大呆瓜. JS 类型判断之 typeof、instanceof 与 toString 示例详解 [EB/OL]. 脚本之家,2026. [5] Thiemann P. Towards a Type System for Analyzing JavaScript Programs [C]//Static Analysis: 12th International Symposium. Springer, 2005.

前端技巧:用 Bookmarklet 给网页临时挂载一个图片调试面板

2026年4月24日 16:51

前端技巧:用 Bookmarklet 给网页临时挂载一个图片调试面板

在前端开发中,我们经常需要分析页面中的资源情况,比如:

  • 页面实际加载了哪些图片
  • 不同分辨率资源的分布
  • 是否存在重复图片请求
  • UI 还原时如何快速定位原始素材

这些事情当然可以通过 DevTools 完成,但在某些场景下效率并不高。例如批量浏览图片、筛选大图、快速预览等操作,都需要在多个面板之间来回切换。

于是可以换一个思路:

不扩展 DevTools,而是用一段 Bookmarklet,在任意网页上临时挂载一个“图片调试面板”。

这篇文章的重点不在工具本身,而在于这种实现方式背后的前端技术思路。

image.png


一、为什么是 Bookmarklet

Bookmarklet 的本质,是一段运行在当前页面上下文中的 JavaScript。

javascript:(()=>{ /* your code */ })()

它有几个非常关键的特性:

  • 直接运行在页面环境中,可以访问 DOM
  • 不需要构建或发布浏览器插件
  • 无需侵入页面代码
  • 可以在任意网站使用

这使它非常适合做“临时调试能力注入”。

可以把它理解为:

一种轻量级的“运行时工具扩展机制”


二、核心问题:如何获取真实图片资源

最直接的方式是:

[...document.images]

但这只是第一步,真正需要解决的是两个问题:

1. 图片是否已经加载完成

i.naturalWidth

只有当图片完成加载后,naturalWidthnaturalHeight 才是有效的。


2. 如何获取真实资源地址

i.currentSrc || i.src

这里的关键是 currentSrc

在响应式图片场景中:

<img src="small.jpg" srcset="large.jpg 2x">

浏览器实际使用的资源并不一定是 src,而是 currentSrc

如果忽略这一点,拿到的数据很可能是不准确的。


三、数据建模:不仅仅是收集

收集图片之后,需要对数据做一层结构化处理:

{
  s: src,
  w: width,
  h: height,
  m: max(width, height)
}

这里的关键字段是:

  • w / h:用于展示尺寸信息
  • m:用于排序和筛选

为什么使用最大边?

因为在实际使用中:

  • 横图和竖图不好直接比较
  • 最大边可以作为统一尺度
  • 更适合做“是否为大图”的判断

四、去重策略:Map 比 Set 更合适

const map = new Map()
imgs.forEach(i => !map.has(i.s) && map.set(i.s, i))

这里选择 Map 而不是 Set 的原因是:

  • 去重依据是 URL
  • 但我们需要保留完整对象
  • Map 可以同时解决“唯一性 + 数据存储”

这是一个很典型的前端数据处理模式。


五、筛选与排序:面向使用场景设计

base.filter(i => !v || i.m >= v)

筛选逻辑围绕一个实际需求:

快速找到大图资源

配合排序:

first ? b.m - a.m : a.m - b.m

首屏优先展示大图,可以显著提升信息获取效率。

这其实是一个“数据展示策略”的问题,而不仅仅是代码实现。


六、为什么在新窗口中渲染 UI

const w = open()
w.document.write(...)

这是整个实现中一个很关键的设计点。

如果直接在当前页面插入 UI,会遇到几个问题:

  • 样式冲突(CSS 污染)
  • z-index 竞争
  • 可能被页面脚本影响

而新窗口的优势是:

  • 完全隔离运行环境
  • 样式可控
  • 生命周期独立

可以理解为:

用浏览器原生能力实现了一种“轻量沙箱”

七、实现代码

完整实现如下(Bookmarklet 版本):

javascript:(()=>{const imgs=[...document.images].filter(i=>i.naturalWidth).map(i=>({s:i.currentSrc||i.src,w:i.naturalWidth,h:i.naturalHeight,m:Math.max(i.naturalWidth,i.naturalHeight)}));const map=new Map();imgs.forEach(i=>!map.has(i.s)&&map.set(i.s,i));const base=[...map.values()];const sizes=[...new Set(base.map(i=>i.m))].sort((a,b)=>b-a);const w=open();w.document.write(`<!doctype html><meta charset=utf-8><title>页面图片资源(${base.length})</title><style>*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto;background:#0f1115;color:#e6e6e6}header{position:sticky;top:0;z-index:10;background:#161a20;padding:12px 16px;font-size:14px;display:flex;gap:12px;align-items:center;box-shadow:0 6px 20px rgba(0,0,0,.4)}select{margin-left:auto;background:#0f1115;color:#e6e6e6;border:1px solid #333;border-radius:6px;padding:4px 8px;font-size:12px}.grid{padding:16px;display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px}.card{background:#161a20;border-radius:12px;overflow:hidden;position:relative;transition:transform .18s ease-out,opacity .18s ease-out}.card.enter{transform:scale(.96);opacity:0}.card img{width:100%;height:180px;object-fit:contain;background:#0b0d11;cursor:zoom-in}.badge{position:absolute;top:8px;right:8px;font-size:11px;padding:2px 6px;border-radius:6px;background:rgba(0,0,0,.65)}.tools{position:absolute;bottom:8px;right:8px;display:flex;gap:6px}.tools button{background:rgba(0,0,0,.7);color:#fff;border:none;padding:5px 7px;font-size:12px;border-radius:6px;cursor:pointer}.toast{position:fixed;top:14px;left:50%;transform:translateX(-50%);background:#222;padding:8px 14px;border-radius:20px;font-size:12px;opacity:0;transition:.2s;z-index:20}.toast.show{opacity:1}.preview{position:fixed;inset:0;background:rgba(0,0,0,.9);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:.2s;z-index:30}.preview.show{opacity:1;pointer-events:auto}.preview img{max-width:90%;max-height:90%}</style><header>页面图片资源(${base.length})<select id=f><option value=0>全部</option></select></header><div class=toast id=t></div><div class=grid id=g></div><div class=preview id=p><img></div>%60);const d=w.document,G=d.getElementById("g"),F=d.getElementById("f"),T=d.getElementById("t");sizes.forEach(s=>{const o=d.createElement("option");o.value=s;o.textContent="≥ "+s+"px";F.appendChild(o)});const toast=m=>{T.textContent=m;T.className="toast show";setTimeout(()=>T.className="toast",1200)};const draw=(v,first)=>{const arr=base.filter(i=>!v||i.m>=v).sort((a,b)=>first?b.m-a.m:a.m-b.m);G.innerHTML="";arr.forEach(i=>{const c=d.createElement("div");c.className="card enter";c.innerHTML='<span class="badge">'+i.w+' × '+i.h+'</span><img src="'+i.s+'"><div class="tools"><button data-copy="'+i.s+'">复制</button><button data-open="'+i.s+'">打开</button></div>';G.appendChild(c);setTimeout(()=>c.classList.remove("enter"),0)})};draw(0,true);F.onchange=e=>draw(+e.target.value,false);d.onclick=e=>{if(e.target.dataset.copy){const a=d.createElement("textarea");a.value=e.target.dataset.copy;d.body.appendChild(a);a.select();d.execCommand("copy");a.remove();toast("已复制")}if(e.target.dataset.open)w.open(e.target.dataset.open,"_blank","noopener");if(e.target.tagName==="IMG"){d.getElementById("p").classList.add("show");d.querySelector("#p img").src=e.target.src}if(e.target.id==="p")e.target.classList.remove("show")};d.onkeydown=e=>e.key==="Escape"&&d.getElementById("p").classList.remove("show");d.close()})();

八、可以延展的方向

这个思路可以进一步扩展为一类能力:

  • CSS 调试面板(查看覆盖关系)
  • 字体分析工具
  • 网络请求监控(结合 fetch hook)
  • DOM 结构可视化

也就是说:

Bookmarklet 不只是“小工具”,而是一种可以快速构建调试能力的前端模式。


九、总结

这段代码的价值不在于“提取图片”,而在于它体现了几个重要的前端思路:

  • 如何在运行时扩展页面能力
  • 如何做轻量级的数据建模与处理
  • 如何在无框架环境下构建完整交互
  • 如何利用浏览器原生能力实现隔离

如果换一个角度看,它更像是一个无需安装的临时 DevTools 扩展。

理解这一点,比代码本身更重要。

本文仅用于前端开发调试与技术研究,请勿用于侵犯他人版权或违反网站使用协议的行为。

「前端何去何从」事件循环是 Node.js 的心跳

作者 从文处安
2026年4月24日 16:46

引子

你大概已经用 AI 写过不少 Node.js 代码了。

让 Copilot 帮你生成一个 Express 路由,几秒钟的事。让它写一段文件读取逻辑,async/await 包得整整齐齐。大多数时候,这些代码能跑,甚至跑得不错。

但总有一些时刻,事情开始变得诡异:

  • setTimeout 和 Promise 的执行顺序,跟你在浏览器里的经验对不上
  • setImmediate 和 process.nextTick,两个你在前端从没见过的 API,文档说的和实际跑出来的不一样
  • 一个看起来没问题的异步操作,在高并发下突然表现异常

你去问 AI,它会给你一个看起来合理的解释。

但如果你追问细节

——"为什么在 I/O 回调里 setImmediate 一定比 setTimeout 先执行?"
——它大概率会开始含糊其辞,甚至给出错误的答案。

这不怪 AI。事件循环的行为高度依赖运行时上下文,同一段代码在不同位置执行,结果可能完全不同。这不是靠模式匹配能答对的问题。

作为前端开发者,你对"异步"并不陌生。  你知道 Promise,知道 async/await,知道"不要阻塞主线程"。但这些认知建立在浏览器的事件循环模型上——而那个模型,在 Node.js 里会产生误导。

这篇文章不会从零讲 JavaScript 异步。它假设你已经熟悉浏览器环境下的异步编程,然后带你看清:Node.js 的事件循环,到底和你以为的有什么不同。

你以为你懂的事件循环

在浏览器里,事件循环的模型相对简单。你可能已经内化了这个流程:

  1. 从宏任务队列取一个任务执行(比如一个 setTimeout 回调)
  2. 清空所有微任务(Promise.thenqueueMicrotask
  3. 如果需要,执行渲染(重绘、重排)
  4. 回到第 1 步

这个模型足够应付绝大多数前端场景。

但当你带着这个心智模型走进 Node.js,会发现好几个地方对不上:

Node.js 没有"渲染"这回事。

浏览器事件循环的节奏很大程度上被屏幕刷新率(通常 60fps)驱动,每一帧大约 16.6ms。Node.js 没有 UI 线程,事件循环的驱动力是 I/O 事件和定时器,不存在"一帧"的概念。

不是一个队列,而是六个阶段。
浏览器的事件循环可以简化为"一个宏任务队列 + 一个微任务队列"。
Node.js 的事件循环是一个由 6 个阶段(phase)组成的循环,每个阶段有自己的任务队列,事件循环按固定顺序依次经过这些阶段。

微任务的执行时机不同。
在浏览器中,每执行完一个宏任务就清空微任务队列。在 Node.js(v11+)中,微任务在每个阶段切换时清空——也就是说,一个阶段内可能连续执行多个回调,然后才轮到微任务。

多了两个你没见过的 API。
setImmediate 和 process.nextTick 是 Node.js 独有的。它们的执行时机和优先级,是理解 Node.js 事件循环的关键拼图。

来看一段代码,分别在浏览器和 Node.js 里跑一下:

// 在浏览器控制台和 Node.js 中分别运行,对比输出顺序
console.log('1: script start');

setTimeout(() => {
  console.log('2: setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('3: Promise.then');
});

queueMicrotask(() => {
  console.log('4: queueMicrotask');
});

console.log('5: script end');

在浏览器和 Node.js 中,这段代码的输出顺序是一样的:

1: script start
5: script end
3: Promise.then
4: queueMicrotask
2: setTimeout

看起来没区别?那是因为这段代码太简单了。差异藏在更复杂的场景里——当 setImmediateprocess.nextTick、I/O 回调同时出现时,浏览器的心智模型就不够用了。

接下来,我们看看 Node.js 事件循环的完整面貌。

Node.js 事件循环的全貌

Node.js 的事件循环由 libuv 驱动,每一轮循环(通常称为一个 tick)依次经过 6 个阶段:

   ┌───────────────────────────┐
┌─>│         timers            │  执行 setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  执行系统级回调(如 TCP 错误)
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  Node.js 内部使用
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │           poll             │  获取新的 I/O 事件,执行 I/O 回调
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │          check             │  执行 setImmediate 回调
│  └─────────────┬─────────────┘
│                │
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  执行关闭事件回调(如 socket.on('close'))
│  └─────────────┬─────────────┘
│                │
└────────────────┘

关键认知:事件循环不是"有事就做",而是"按阶段轮询"。

每个阶段都有一个 FIFO 队列,存放该阶段需要执行的回调。当事件循环进入某个阶段时,它会执行该阶段队列中的回调(直到队列清空或达到执行上限),然后进入下一个阶段。

在每两个阶段之间,Node.js 会检查并清空两个特殊队列:

  1. process.nextTick 队列(nextTickQueue)
  2. Promise 微任务队列(microtask queue)

这意味着微任务不属于任何阶段,而是在阶段切换的"间隙"执行。这一点非常重要,后面会详细展开。

现在,让我们逐个阶段看看它们各自在做什么。

六个阶段,逐个击破

1. timers 阶段

这是事件循环的第一个阶段,负责执行 setTimeout 和 setInterval 的回调。

但有一个关键细节:定时器的延迟时间是"最小延迟",不是"精确延迟"。

setTimeout(fn, 100) 的意思不是"100ms 后执行 fn",而是"至少 100ms 后,当事件循环走到 timers 阶段时,执行 fn"。如果事件循环正忙于其他阶段的回调,定时器的实际触发时间会晚于预期。

// timer-precision.js
const start = Date.now();

setTimeout(() => {
  const delay = Date.now() - start;
  console.log(`实际延迟: ${delay}ms(期望: 100ms)`);
}, 100);

// 模拟一个耗时操作,阻塞事件循环
const blockUntil = start + 200;
while (Date.now() < blockUntil) {
  // 忙等待 200ms
}

console.log('同步代码执行完毕');

运行 node timer-precision.js,你会看到实际延迟大约是 200ms 而不是 100ms——因为同步代码阻塞了事件循环,定时器只能等到同步代码执行完、事件循环重新转起来之后才能触发。

这在浏览器里也是一样的道理,但在 Node.js 服务端场景下,这个特性的影响更大:一个 CPU 密集的操作可能让所有定时器集体延迟。

另一个容易忽略的细节:setTimeout(fn, 0) 在 Node.js 中实际上等价于 setTimeout(fn, 1)。Node.js 内部会将延迟为 0 的定时器强制设为 1ms。这个看似无关紧要的细节,在后面讨论 setTimeout vs setImmediate 的执行顺序时会变得关键。

2. pending callbacks 阶段

这个阶段执行的是被推迟到下一轮循环的系统级回调,比如某些 TCP 错误(ECONNREFUSED)的回调。

3. idle, prepare 阶段

这是 Node.js 内部使用的阶段,不暴露给用户代码。跳过。

4. poll 阶段(重点)

poll 是事件循环中最重要的阶段,也是事件循环"停留时间最长"的阶段。它做两件事:

  1. 计算应该阻塞多久来等待 I/O 事件
  2. 处理 poll 队列中的回调

几乎所有的 I/O 回调都在这个阶段执行:文件读写完成的回调、网络请求返回的回调、数据库查询结果的回调……这些都是 poll 阶段的"客人"。

poll 阶段的行为取决于当前状态:

  • 如果 poll 队列不为空:  依次执行队列中的回调,直到队列清空或达到系统限制

  • 如果 poll 队列为空:

    • 如果有 setImmediate 回调等待执行 → 结束 poll 阶段,进入 check 阶段
    • 如果没有 setImmediate → 事件循环会在这里"等待",直到有新的 I/O 事件到来,或者有定时器到期

这就是为什么一个简单的 HTTP 服务器不会退出——它一直在 poll 阶段等待新的连接请求。

// poll-demo.js
const fs = require('fs');

console.log('1: script start');

// 这个回调会在 poll 阶段执行
fs.readFile(__filename, () => {
  console.log('2: file read complete (poll phase)');

  // 在 I/O 回调内部注册的 setTimeout
  setTimeout(() => {
    console.log('3: setTimeout inside I/O callback');
  }, 0);

  // 在 I/O 回调内部注册的 setImmediate
  setImmediate(() => {
    console.log('4: setImmediate inside I/O callback');
  });
});

console.log('5: script end');

运行 node poll-demo.js,输出:

1: script start
5: script end
2: file read complete (poll phase)
4: setImmediate inside I/O callback
3: setTimeout inside I/O callback

注意第 4 行和第 3 行的顺序:在 I/O 回调内部,setImmediate 总是比 setTimeout(fn, 0) 先执行。原因很简单——I/O 回调在 poll 阶段执行,poll 之后紧接着就是 check 阶段(setImmediate),而 setTimeout 要等到下一轮循环的 timers 阶段才能执行。

这是一个非常实用的规律,记住它。

5. check 阶段

check 阶段专门用来执行 setImmediate 的回调。

setImmediate 是 Node.js 独有的 API(浏览器没有)。它的语义是:"在当前 poll 阶段完成后立即执行"。这让它成为一个非常有用的工具——当你想在 I/O 操作完成后尽快执行某段代码,但又不想阻塞当前的 I/O 处理时,setImmediate 是最佳选择。

现在来看一个经典问题:setTimeout(fn, 0) 和 setImmediate,谁先执行?

// order-main.js — 在主模块中执行
setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

多运行几次 node order-main.js,你会发现输出顺序不确定——有时 setTimeout 先,有时 setImmediate 先。

为什么?因为 setTimeout(fn, 0) 实际上是 setTimeout(fn, 1)。当主模块代码执行完毕,事件循环开始第一轮时,1ms 的定时器是否已经到期取决于系统当时的状态。如果到期了,timers 阶段会先执行它;如果还没到期,事件循环会跳过 timers,一路走到 check 阶段执行 setImmediate

但在 I/O 回调内部,顺序是确定的:

// order-io.js — 在 I/O 回调中执行
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);

  setImmediate(() => {
    console.log('setImmediate');
  });
});

运行多少次 node order-io.js,结果都是:

setImmediate
setTimeout

原因在上一节已经解释过:I/O 回调在 poll 阶段执行,poll 之后是 check(setImmediate),然后才是下一轮的 timers(setTimeout)。

6. close callbacks 阶段

这个阶段处理关闭事件的回调,比如 socket.on('close', ...)

如果一个 socket 或 handle 被突然关闭(比如 socket.destroy()),close 事件会在这个阶段触发。如果是通过正常流程关闭的,close 事件通常通过 process.nextTick 触发。

对于大多数应用开发场景,你不需要特别关注这个阶段。知道它是事件循环的最后一站就够了。

微任务:不属于任何阶段的"插队者"

到目前为止,我们讲的 6 个阶段都是事件循环的"正式成员"。但还有两类回调,它们不属于任何阶段,却拥有最高的执行优先级:

  1. process.nextTick 回调(nextTickQueue)
  2. Promise 微任务(microtask queue,包括 Promise.thenqueueMicrotask

在 Node.js v11+ 中,每当事件循环准备从一个阶段切换到下一个阶段时,它会先清空这两个队列。顺序是:先清空 nextTickQueue,再清空 microtask queue。

// microtask-order.js
console.log('1: script start');

process.nextTick(() => {
  console.log('2: process.nextTick');
});

Promise.resolve().then(() => {
  console.log('3: Promise.then');
});

setTimeout(() => {
  console.log('4: setTimeout');
}, 0);

setImmediate(() => {
  console.log('5: setImmediate');
});

console.log('6: script end');

运行 node microtask-order.js,输出:

1: script start
6: script end
2: process.nextTick
3: Promise.then
4: setTimeout
5: setImmediate

解析这个输出:

  1. 同步代码先执行:输出 1 和 6
  2. 同步代码执行完毕,事件循环开始。在进入第一个阶段(timers)之前,先清空微任务队列
  3. process.nextTick 优先级高于 Promise.then,所以 2 在 3 前面
  4. 微任务清空后,进入 timers 阶段,执行 setTimeout 回调:输出 4
  5. 继续走到 check 阶段,执行 setImmediate 回调:输出 5

现在来看一个更复杂的例子,理解微任务在阶段切换时的行为:

// microtask-between-phases.js
const fs = require('fs');

fs.readFile(__filename, () => {
  // 我们现在在 poll 阶段

  setTimeout(() => {
    console.log('1: setTimeout');
    process.nextTick(() => {
      console.log('2: nextTick inside setTimeout');
    });
  }, 0);

  setImmediate(() => {
    console.log('3: setImmediate');
    process.nextTick(() => {
      console.log('4: nextTick inside setImmediate');
    });
  });

  process.nextTick(() => {
    console.log('5: nextTick');
  });

  Promise.resolve().then(() => {
    console.log('6: Promise.then');
  });
});

运行 node microtask-between-phases.js,输出:

5: nextTick
6: Promise.then
3: setImmediate
4: nextTick inside setImmediate
1: setTimeout
2: nextTick inside setTimeout

逐步解析:

  1. I/O 回调在 poll 阶段执行完毕后,准备切换到 check 阶段
  2. 切换前先清空微任务:nextTick(5)→ Promise.then(6)
  3. 进入 check 阶段,执行 setImmediate(3)
  4. check 阶段结束,切换前清空微任务:nextTick inside setImmediate(4)
  5. 下一轮循环进入 timers 阶段,执行 setTimeout(1)
  6. timers 阶段结束,清空微任务:nextTick inside setTimeout(2)

process.nextTick 的危险面

process.nextTick 的高优先级是一把双刃剑。如果你在 nextTick 回调中递归调用 nextTick,微任务队列永远清不空,事件循环永远无法进入下一个阶段:

// starvation.js — 不要在生产环境运行!
function recursiveNextTick() {
  process.nextTick(() => {
    console.log('nextTick 执行');
    recursiveNextTick(); // 永远不会让出控制权
  });
}

recursiveNextTick();

setTimeout(() => {
  console.log('这行永远不会执行');
}, 0);

这就是所谓的"饿死"事件循环。setTimeout 的回调永远等不到 timers 阶段,因为事件循环被困在了微任务队列里。

实际建议:  除非你明确需要在当前操作完成后、事件循环继续之前执行某段代码,否则优先使用 setImmediate 而不是 process.nextTick。Node.js 官方文档也给出了同样的建议。

实战中的三个坑

理论讲完了,来看看这些知识在实际开发中会以什么形式"咬"你一口。

坑 1:setTimeout vs setImmediate 的顺序"薛定谔"

这个问题在前面已经提到过,但值得单独拎出来强调,因为它是 Node.js 事件循环中最常被问到的问题。

规律很简单:

  • 在主模块中:  顺序不确定(取决于 1ms 定时器是否已到期)
  • 在 I/O 回调中:  setImmediate 总是先于 setTimeout(fn, 0)
// pitfall-1.js
const fs = require('fs');

// 场景 1:主模块 — 顺序不确定
setTimeout(() => console.log('主模块: setTimeout'), 0);
setImmediate(() => console.log('主模块: setImmediate'));

// 场景 2:I/O 回调内 — 顺序确定
fs.readFile(__filename, () => {
  setTimeout(() => console.log('I/O 内: setTimeout'), 0);
  setImmediate(() => console.log('I/O 内: setImmediate'));
});

多运行几次,你会发现场景 1 的顺序会变,但场景 2 永远是 setImmediate 先。

坑 2:连续 await 的微任务陷阱

async/await 是 Promise 的语法糖,每个 await 后面的代码都会被包装成微任务。当多个 async 函数"交错"执行时,执行顺序可能出乎意料:

// pitfall-2.js
async function taskA() {
  console.log('A-1');
  await Promise.resolve();
  console.log('A-2');
  await Promise.resolve();
  console.log('A-3');
}

async function taskB() {
  console.log('B-1');
  await Promise.resolve();
  console.log('B-2');
  await Promise.resolve();
  console.log('B-3');
}

taskA();
taskB();

运行 node pitfall-2.js,输出:

A-1
B-1
A-2
B-2
A-3
B-3

两个函数在每个 await 处"交替"执行。这是因为每个 await 都会让出控制权,把后续代码放入微任务队列。当 A-1 执行完遇到 await,控制权交给 B,B-1 执行完也遇到 await,然后微任务队列按顺序执行 A-2、B-2、A-3、B-3。

在实际开发中,如果你有两个 async 函数操作同一份数据,这种交错执行可能导致竞态条件——即使 Node.js 是单线程的。

坑 3:CPU 密集任务"冻住"事件循环

Node.js 的单线程模型意味着:如果你的代码在做 CPU 密集的计算,整个事件循环都会被阻塞。所有的定时器、I/O 回调、网络请求处理都得等着。

// pitfall-3.js
const http = require('http');

const server = http.createServer((req, res) => {
  if (req.url === '/slow') {
    // 模拟 CPU 密集操作:计算斐波那契数列
    const fib = (n) => (n <= 1 ? n : fib(n - 1) + fib(n - 2));
    const result = fib(42);
    res.end(`Result: ${result}`);
  } else {
    res.end('Hello!');
  }
});

server.listen(3000);
console.log('Server running on http://localhost:3000');

当有人访问 /slow 时,fib(42) 大约需要 1-2 秒。在这段时间里,所有其他请求(包括访问 / 的请求)都会被阻塞——因为事件循环被困在了这个同步计算中,无法进入 poll 阶段处理新的网络事件。

这就是"Node.js 不适合 CPU 密集型任务"这句话的真正含义。不是说 Node.js 算不了,而是算的时候整个服务都停了。

解决方案:  对于 CPU 密集的操作,使用 worker_threads 把计算放到独立线程中,不阻塞主事件循环。这超出了本文的范围,但知道这个出口很重要。

写在最后

事件循环是 Node.js 的心跳。理解它,你就能听懂 Node.js 在告诉你什么。

从浏览器到 Node.js,最大的跨越不是学新的 API,而是更新你对"异步"的理解。  希望这篇文章帮你完成了这一步。

聊聊 HarmonyOS 上的应用内通知授权弹窗

2026年4月23日 15:50

做过 C 端 App 的同学应该都踩过类似的坑:消息推送能力明明接好了,后台数据却一直上不去,扒了一圈发现——原来相当一部分用户根本没开通知权限。尤其在求职招聘这类对消息触达极度敏感的场景里,一条面试邀约、一次 HR 回复,如果因为通知没开就被吞了,那体验就有点说不过去了。

所以"什么时候弹、怎么弹、被拒之后怎么办"这件事,看似是个小细节,其实挺值得认真对待。下面按华为开发者文档里给出的"应用内授权通知弹窗"这个例子,顺着它的思路讲一遍。

场景长什么样

文档里把它定位成 求职招聘类应用的高频场景:用户首次进入应用时,应用通过一个弹窗提示,引导用户开启消息通知授权。

实现本身并不复杂,核心就一个模块——NotificationManager。所有动作都围绕它展开。

整体思路

拆开来看,其实就三件事:

  1. 先查一下有没有授权:用 notificationManager.isNotificationEnabledSync() 判断当前应用是不是已经拿到了通知权限。
  2. 没授权就请求一次:调 notificationManager.requestEnableNotification(context) 拉起系统的通知授权弹窗,让用户当场做选择。
  3. 被拒了还有兜底:如果用户这一次拒绝了,就用 notificationManager.openNotificationSettings(context) 把用户直接带到该应用的通知设置页,做一次"二次请求"。

一次系统弹窗、一次设置页兜底,两步走,基本就把"该争取的都争取到了"。

看代码更直观

文档里给的示例代码其实特别好读,一个 aboutToAppear 钩子里就把主流程串完了:

async aboutToAppear() {
  if (!notificationManager.isNotificationEnabledSync()) {
    // 一次授权
    await this.requestPermissions();
    if (this.isDialogShown !== true) {
      // 二次授权
      await this.requestPermissionsOnSetting();
    }
  }
}

async requestPermissions(): Promise<void> {
  try {
    await notificationManager.requestEnableNotification(this.context);
    this.isDialogShown = true;
  } catch (err) {
    // ...
  }
}

async requestPermissionsOnSetting(): Promise<void> {
  try {
    await notificationManager.openNotificationSettings(this.context);
  } catch (err) {
    // ...
  }
}

几个细节值得拎出来讲:

  • 入口放在 aboutToAppear。这是组件将要出现时的生命周期钩子,意味着"一进来就查、一查没权限就请求",对用户来说是最自然的时机。
  • isNotificationEnabledSync() 做前置判断。这是个同步方法,直接拿到布尔值就能走分支,不用 await 一圈。已经有权限的用户,后面两步根本不会触发,避免无谓打扰。
  • isDialogShown 这个标志位别忽略。它在 requestPermissions 成功走完之后被置为 true,然后主流程里会再判一次——只有在一次授权没真正弹出(或者没走完)的情况下,才继续去拉设置页。很多同学做兜底容易写成"不管三七二十一,再跳一次设置",那体验就非常糟糕了,用户点完系统弹窗立刻又被踹到设置页,大概率直接退出应用。
  • 两个接口都需要 this.context。这是 UIAbility 的上下文,系统级弹窗和设置页跳转都依赖它,别传错。

一次授权 vs 二次授权,区别在哪

这两步虽然都是"让用户开通知",但背后的机制完全不一样,容易被混为一谈:

步骤 调用接口 行为表现
一次授权 requestEnableNotification 拉起系统通知授权弹窗,用户当场点允许/拒绝
二次授权 openNotificationSettings 拉起应用自己的通知设置页,用户手动开启

文档里强调的顺序是:先走一次授权,只有在一次没成功的情况下,才走二次。这个顺序不能反——上来就把人甩到设置页,对用户来说完全没有上下文,开什么、为什么开都一头雾水。

实战里几个容易踩的坑

把这段代码看完,结合平时做业务的经验,有几个点挺值得提一句:

  • 别每次冷启动都弹。示例里用 isDialogShown 挡了一下重复弹窗,如果业务上还想做得更细,可以结合本地存储记一下用户的历史选择,这样即便多次启动也不会反复骚扰。不过文档本身没展开讲这块,照着它的写法起码保证了"同一次生命周期里不重复弹"。
  • 被拒之后别追着问。二次授权的入口是设置页,但如果用户在设置页还是没开,就不要再接着弹第三次、第四次。文档给出的思路只到"二次请求"为止,再往后其实已经属于产品策略问题,不该在这段代码里加码。
  • 异常分支留好日志。示例里 catch 块只给了 // ... 占位,真实项目里这里建议把错误打出来,排查"为什么弹窗没起来"的时候会省很多事。

约束条件

文档里有两条硬性要求,顺手贴一下,免得照搬代码的老兄们跑不起来:

  • 需要 HarmonyOS 6.0.0 Release SDK 及以上版本;
  • 需要 DevEco Studio 6.0.0 Release 及以上版本编译运行。

工程目录也很标准,就两块:entry/src/main/ets 放代码,entry/src/main/resources 放资源,没什么花活。

写在最后

通知授权弹窗这件事,归根到底是在用户体验和触达率之间找一个平衡点。文档给的方案其实很克制——一次系统弹窗加一次设置页兜底,用一个标志位防重复,仅此而已。

在求职招聘这种"一条消息可能改变一次机会"的场景下,这一点点克制其实反而更重要:你不打扰用户,用户才愿意把通知权限留给你。照着 NotificationManager 这几个接口把流程跑顺,基本就能把通知授权这块底子打得比较稳了。

❌
❌