普通视图

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

每日一题-分割正方形 I🟡

2026年1月13日 00:00

给你一个二维整数数组 squares ,其中 squares[i] = [xi, yi, li] 表示一个与 x 轴平行的正方形的左下角坐标和正方形的边长。

找到一个最小的 y 坐标,它对应一条水平线,该线需要满足它以上正方形的总面积 等于 该线以下正方形的总面积。

答案如果与实际答案的误差在 10-5 以内,将视为正确答案。

注意:正方形 可能会 重叠。重叠区域应该被 多次计数 

 

示例 1:

输入: squares = [[0,0,1],[2,2,1]]

输出: 1.00000

解释:

任何在 y = 1y = 2 之间的水平线都会有 1 平方单位的面积在其上方,1 平方单位的面积在其下方。最小的 y 坐标是 1。

示例 2:

输入: squares = [[0,0,2],[1,1,1]]

输出: 1.16667

解释:

面积如下:

  • 线下的面积:7/6 * 2 (红色) + 1/6 (蓝色) = 15/6 = 2.5
  • 线上的面积:5/6 * 2 (红色) + 5/6 (蓝色) = 15/6 = 2.5

由于线以上和线以下的面积相等,输出为 7/6 = 1.16667

 

提示:

  • 1 <= squares.length <= 5 * 104
  • squares[i] = [xi, yi, li]
  • squares[i].length == 3
  • 0 <= xi, yi <= 109
  • 1 <= li <= 109
  • 所有正方形的总面积不超过 1012

二分

作者 tsreaper
2025年2月16日 14:03

解法:二分

二分水平线的坐标,找到最小的坐标,使得水平线上面的面积和小等于水平线下面的面积和即可。

因为坐标最大值是 $2 \times 10^9$,而精度要求是 $10^{-5}$,我们的二分次数应该提供至少 $10^{-14}$ 的精度。直接二分 $100$ 次,$2^{-100}$ 远小于 $10^{-14}$。

复杂度 $\mathcal{O}(tn)$,其中 $t = 100$ 是二分次数。

参考代码(c++)

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        int mx = 0;
        for (auto &sq : squares) mx = max(mx, sq[1] + sq[2]);

        auto check = [&](double lim) {
            // a:水平线下面的面积和
            // b:水平线上面的面积和
            long double a = 0, b = 0;
            for (auto &sq : squares) {
                // 正方形完全位于水平线下面
                if (sq[1] + sq[2] <= lim) a += 1LL * sq[2] * sq[2];
                // 正方形完全位于水平线上面
                else if (sq[1] >= lim) b += 1LL * sq[2] * sq[2];
                // 正方形被水平线分成两半
                else {
                    double t = (lim - sq[1]) * sq[2];
                    a += t;
                    b += 1LL * sq[2] * sq[2] - t;
                }
            }
            return a >= b;
        };

        // 二分 100 次
        double head = 0, tail = mx;
        for (int i = 0; i < 100; i++) {
            double mid = (head + tail) / 2;
            if (check(mid)) tail = mid;
            else head = mid;
        }
        return (head + tail) / 2;
    }
};

三种方法:浮点二分 / 整数二分 / 差分(Python/Java/C++/Go)

作者 endlesscheng
2025年2月16日 08:43

方法一:浮点二分

所有正方形的面积之和为

$$
\textit{totalArea} = \sum_{i=0}^{n-1} l_i^2
$$

设在水平线 $Y=y$ 下方的面积之和为 $\textit{area}_y$,那么水平线上方的面积之和为 $\textit{totalArea}-\textit{area}_y$。

题目要求

$$
\textit{area}_y = \textit{totalArea}-\textit{area}_y
$$

$$
\textit{area}_y\cdot 2 = \textit{totalArea}
$$

我们可以二分最小的 $y$,满足

$$
\textit{area}_y\cdot 2 \ge \textit{totalArea}
$$

$\textit{area}_y$ 怎么算?

枚举正方形 $(x_i,y_i,l_i)$,如果水平线在正方形底边上面,即 $y_i < y$,那么这个正方形在水平线下方的面积为

$$
l_i\cdot\min(y-y_i, l_i)
$$

否则在水平线下方的面积为 $0$。总的来说就是

$$
l_i\cdot\min(\max(y-y_i,0), l_i)
$$

在水平线下方的总面积为

$$
\textit{area}y = \sum{i=0}^{n-1} l_i\cdot\min(\max(y-y_i,0), l_i)
$$

细节

二分的左边界为 $0$,右边界为 $\max(y_i+l_i)$。这里无需讨论开闭区间,因为我们算的是小数。

循环条件怎么写?

推荐的写法是固定一个循环次数,因为浮点数有舍入误差,可能算出的 $\textit{mid}$ 和 $\textit{left}$ 相等,此时 $\textit{left}=\textit{mid}$ 不会更新 $\textit{left}$,导致死循环。

:本题由于值域小,也可以在 $\textit{left}$ 和 $\textit{right}$ 相距小于 $10^{-5}$ 时结束循环。但这种做法无法用于值域较大的场景,所以不推荐。

循环多少次?

设初始二分区间长度为 $L$,每二分一次,二分区间长度减半。要至少减半到 $10^{-5}$ 才能满足题目的误差要求。设循环次数为 $k$,我们有

$$
\dfrac{L}{2^k} \le 10^{-5}
$$

解得

$$
k\ge \log_2 (L\cdot 10^5)
$$

在本题的数据范围下,可以取 $k=48$(或者 $47$,取决于代码实现方式)。

本题视频讲解,欢迎点赞关注~

###py

class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        M = 100_000
        total_area = sum(l * l for _, _, l in squares)

        def check(y: float) -> bool:
            area = 0
            for _, yi, l in squares:
                if yi < y:
                    area += l * min(y - yi, l)
            return area >= total_area / 2

        left = 0
        right = max_y = max(y + l for _, y, l in squares)
        for _ in range((max_y * M).bit_length()):
            mid = (left + right) / 2
            if check(mid):
                right = mid
            else:
                left = mid
        return (left + right) / 2  # 区间中点误差小

###java

class Solution {
    public double separateSquares(int[][] squares) {
        long totArea = 0;
        int maxY = 0;
        for (int[] sq : squares) {
            int l = sq[2];
            totArea += (long) l * l;
            maxY = Math.max(maxY, sq[1] + l);
        }

        double left = 0;
        double right = maxY;
        for (int i = 0; i < 47; i++) {
            double mid = (left + right) / 2;
            if (check(squares, mid, totArea)) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return (left + right) / 2; // 区间中点误差小
    }

    private boolean check(int[][] squares, double y, long totArea) {
        double area = 0;
        for (int[] sq : squares) {
            double yi = sq[1];
            if (yi < y) {
                double l = sq[2];
                area += l * Math.min(y - yi, l);
            }
        }
        return area >= totArea / 2.0;
    }
}

###cpp

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        long long tot_area = 0;
        int max_y = 0;
        for (auto& sq : squares) {
            int l = sq[2];
            tot_area += 1LL * l * l;
            max_y = max(max_y, sq[1] + l);
        }

        auto check = [&](double y) -> bool {
            double area = 0;
            for (auto& sq : squares) {
                double yi = sq[1];
                if (yi < y) {
                    double l = sq[2];
                    area += l * min(y - yi, l);
                }
            }
            return area >= tot_area / 2.0;
        };

        double left = 0, right = max_y;
        for (int i = 0; i < 47; i++) {
            double mid = (left + right) / 2;
            (check(mid) ? right : left) = mid;
        }
        return (left + right) / 2; // 区间中点误差小
    }
};

###go

func separateSquares(squares [][]int) float64 {
totArea := 0
maxY := 0
for _, sq := range squares {
l := sq[2]
totArea += l * l
maxY = max(maxY, sq[1]+l)
}

check := func(y float64) bool {
area := 0.
for _, sq := range squares {
yi := float64(sq[1])
if yi < y {
l := float64(sq[2])
area += l * min(y-yi, l)
}
}
return area >= float64(totArea)/2
}

left, right := 0., float64(maxY)
for range bits.Len(uint(maxY * 1e5)) {
mid := (left + right) / 2
if check(mid) {
right = mid
} else {
left = mid
}
}
return (left + right) / 2 // 区间中点误差小
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log (MU))$,其中 $n$ 是 $\textit{squares}$ 的长度,$M=10^5$,$U=\max(y_i+l_i)$。
  • 空间复杂度:$\mathcal{O}(1)$。

方法二:整数二分(写法一)

1)

方法一的 $y$ 是个小数。

记 $M = 10^5$,改为二分整数 $y\cdot M$,最后把二分结果再除以 $M$,即为答案。

在使用整数计算的前提下,这可以保证返回结果与正确答案的绝对误差严格小于 $1/M=10^{-5}$。

2)

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

  • 开区间左端点初始值:$0$。无法满足要求。
  • 开区间右端点初始值:$\max(y_i+l_i) \cdot M$。一定满足要求。

3)

能否全程使用整数计算?(只在返回的时候计算浮点数)

设当前二分的整数值为 $\textit{multiY}$,那么水平线下方的面积为

$$
\begin{aligned}
& l_i\cdot\min\left(\max\left(\dfrac{\textit{multiY}}{M}-y_i,0\right), l_i\right) \
={} & \dfrac{l_i\cdot\min(\max(\textit{multiY}-y_i\cdot M,0), l_i \cdot M)}{M} \
\end{aligned}
$$

所以有

$$
\textit{area}y = \dfrac{1}{M} \sum{i=0}^{n-1} l_i\cdot\min(\max(\textit{multiY}-y_i\cdot M,0),l_i \cdot M)
$$

判定条件

$$
\textit{area}_y\cdot 2 \ge \textit{totalArea}
$$

可以改为

$$
2 \sum_{i=0}^{n-1} l_i\cdot\min(\max(\textit{multiY}-y_i\cdot M,0), l_i\cdot M)\ge \textit{totalArea}\cdot M
$$

这样就可以全程使用整数计算了,只在最终返回时用到了浮点数。

###py

class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        M = 100_000
        total_area = sum(l * l for _, _, l in squares)

        def check(multi_y: int) -> bool:
            area = 0
            for _, y, l in squares:
                if y * M < multi_y:
                    area += l * min(multi_y - y * M, l * M)
            return area * 2 >= total_area * M

        max_y = max(y + l for _, y, l in squares)
        return bisect_left(range(max_y * M), True, key=check) / M

###java

class Solution {
    private static final int M = 100_000;

    public double separateSquares(int[][] squares) {
        long totArea = 0;
        int maxY = 0;
        for (int[] sq : squares) {
            int l = sq[2];
            totArea += (long) l * l;
            maxY = Math.max(maxY, sq[1] + l);
        }

        long left = 0;
        long right = (long) maxY * M;
        while (left + 1 < right) {
            long mid = (left + right) >>> 1;
            if (check(squares, mid, totArea)) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return (double) right / M;
    }

    private boolean check(int[][] squares, long multiY, double totArea) {
        long area = 0;
        for (int[] sq : squares) {
            long y = sq[1];
            if (y * M < multiY) {
                long l = sq[2];
                area += l * Math.min(multiY - y * M, l * M);
            }
        }
        return area * 2 >= totArea * M;
    }
}

###cpp

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        long long tot_area = 0;
        int max_y = 0;
        for (auto& sq : squares) {
            int l = sq[2];
            tot_area += 1LL * l * l;
            max_y = max(max_y, sq[1] + l);
        }

        const int M = 100'000;
        auto check = [&](long long multi_y) -> bool {
            long long area = 0;
            for (auto& sq : squares) {
                long long y = sq[1];
                if (y * M < multi_y) {
                    long long l = sq[2];
                    area += l * min(multi_y - y * M, l * M);
                }
            }
            return area * 2 >= tot_area * M;
        };

        long long left = 0, right = 1LL * max_y * M;
        while (left + 1 < right) {
            long long mid = left + (right - left) / 2;
            (check(mid) ? right : left) = mid;
        }
        return 1.0 * right / M;
    }
};

###go

func separateSquares(squares [][]int) float64 {
totArea := 0
maxY := 0
for _, sq := range squares {
l := sq[2]
totArea += l * l
maxY = max(maxY, sq[1]+l)
}

const m = 100_000
multiY := sort.Search(maxY*m, func(multiY int) bool {
area := 0
for _, sq := range squares {
y, l := sq[1], sq[2]
if y*m < multiY {
area += l * min(multiY-y*m, l*m)
}
}
return area*2 >= totArea*m
})
return float64(multiY) / m
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log (MU))$,其中 $n$ 是 $\textit{squares}$ 的长度,$M=10^5$,$U=\max(y_i+l_i)$。
  • 空间复杂度:$\mathcal{O}(1)$。

方法二:整数二分(写法二)

改成在 $0$ 到 $\max(y_i+l_i)$ 中二分最小的整数 $y$,满足

$$
\textit{area}_y\cdot 2 \ge \textit{totalArea}
$$

那么答案就在整数 $y-1$ 到整数 $y$ 之间。

由于输入都是整数,所以从 $y-1$ 到 $y$,在水平线下方的面积和是线性增加的,我们可以直接把答案解出来。

由于从 $y-1$ 到 $y$,矩形的底边长之和不变,所以用矩形面积的增量,除以矩形的高 $y-(y-1)=1$,就是矩形的底边长之和

$$
\textit{sumL} = \textit{area}y - \textit{area}{y-1}
$$

设答案为 $y'$,那么

$$
\textit{area}_{y'} = \textit{area}_y - (y-y')\cdot \textit{sumL}
$$

题目要求

$$
\textit{area}_{y'} \cdot 2 = \textit{totalArea}
$$

解得

$$
y' = y - \dfrac{\textit{area}_y - \textit{totalArea}/2}{\textit{sumL}} = y - \dfrac{\textit{area}_y\cdot 2 - \textit{totalArea}}{\textit{sumL}\cdot 2}
$$

###py

class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        def calc_area(y: int) -> int:
            area = 0
            for _, yi, l in squares:
                if yi < y:
                    area += l * min(y - yi, l)
            return area

        tot_area = sum(l * l for _, _, l in squares)
        max_y = max(y + l for _, y, l in squares)
        y = bisect_left(range(max_y), tot_area, key=lambda y: calc_area(y) * 2)

        area_y = calc_area(y)
        sum_l = area_y - calc_area(y - 1)
        return y - (area_y * 2 - tot_area) / (sum_l * 2)  # 这样写误差更小

###java

class Solution {
    public double separateSquares(int[][] squares) {
        long totArea = 0;
        int maxY = 0;
        for (int[] sq : squares) {
            int l = sq[2];
            totArea += (long) l * l;
            maxY = Math.max(maxY, sq[1] + l);
        }

        int left = 0;
        int right = maxY;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (calcArea(squares, mid) * 2 >= totArea) {
                right = mid;
            } else {
                left = mid;
            }
        }
        int y = right;

        long areaY = calcArea(squares, y);
        long sumL = areaY - calcArea(squares, y - 1);
        return y - (areaY * 2 - totArea) / (sumL * 2.0); // 这样写误差更小
    }

    private long calcArea(int[][] squares, int y) {
        long area = 0;
        for (int[] sq : squares) {
            int yi = sq[1];
            if (yi < y) {
                int l = sq[2];
                area += (long) l * Math.min(y - yi, l);
            }
        }
        return area;
    }
}

###cpp

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        long long tot_area = 0;
        int max_y = 0;
        for (auto& sq : squares) {
            int l = sq[2];
            tot_area += 1LL * l * l;
            max_y = max(max_y, sq[1] + l);
        }

        auto calc_area = [&](int y) {
            long long area = 0;
            for (auto& sq : squares) {
                int yi = sq[1];
                if (yi < y) {
                    int l = sq[2];
                    area += 1LL * l * min(y - yi, l);
                }
            }
            return area;
        };

        int left = 0, right = max_y;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            (calc_area(mid) * 2 >= tot_area ? right : left) = mid;
        }
        int y = right;

        long long area_y = calc_area(y);
        long long sum_l = area_y - calc_area(y - 1);
        return y - (area_y * 2 - tot_area) / (sum_l * 2.0); // 这样写误差更小
    }
};

###go

func separateSquares(squares [][]int) float64 {
totArea := 0
maxY := 0
for _, sq := range squares {
l := sq[2]
totArea += l * l
maxY = max(maxY, sq[1]+l)
}

calcArea := func(y int) (area int) {
for _, sq := range squares {
yi := sq[1]
if yi < y {
l := sq[2]
area += l * min(y-yi, l)
}
}
return
}
y := sort.Search(maxY, func(y int) bool { return calcArea(y)*2 >= totArea })

areaY := calcArea(y)
sumL := areaY - calcArea(y-1)
return float64(y) - float64(areaY*2-totArea)/float64(sumL*2) // 这样写误差更小
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log U)$,其中 $n$ 是 $\textit{squares}$ 的长度,$U=\max(y_i+l_i)$。
  • 空间复杂度:$\mathcal{O}(1)$。

方法三:差分+扫描线

lc3453.png

想象有一根水平扫描线在从下往上扫描,对于示例 2,这根扫描线依次扫过 $y=0,1,2$:

  • 从 $y=0$ 到 $y=1$,面积的增加量可以视作一个底边长为 $2$,高为 $1$ 的矩形的面积,即 $2\cdot 1 = 2$。
  • 从 $y=1$ 到 $y=2$,面积的增加量可以视作一个底边长为 $2+1=3$(重叠的要累加),高为 $1$ 的矩形的面积,即 $3\cdot 1 = 3$。

扫描的过程中,维护面积之和 $\textit{area}$,底边长之和 $\textit{sumL}$。

设当前 $y$ 与下一个 $y'$ 之差为 $y'-y$,则新增面积为

$$
\textit{sumL}\cdot (y'-y)
$$

如果发现

$$
\textit{area} \cdot 2 \ge \textit{totalArea}
$$

那么可以直接算出答案,计算公式和上面「方法二:整数二分(写法二)」是一样的。

$\textit{sumL}$ 可以用差分维护。原理讲解,推荐和【图解】从一维差分到二维差分 一起看。

###py

class Solution:
    def separateSquares(self, squares: List[List[int]]) -> float:
        tot_area = 0
        diff = defaultdict(int)
        for _, y, l in squares:
            tot_area += l * l
            diff[y] += l
            diff[y + l] -= l

        area = sum_l = 0
        for y, y2 in pairwise(sorted(diff)):
            sum_l += diff[y]  # 矩形底边长度之和
            area += sum_l * (y2 - y)  # 底边长 * 高 = 新增面积
            if area * 2 >= tot_area:
                return y2 - (area * 2 - tot_area) / (sum_l * 2)

###java

class Solution {
    public double separateSquares(int[][] squares) {
        long totArea = 0;
        TreeMap<Integer, Long> diff = new TreeMap<>();
        for (int[] sq : squares) {
            int y = sq[1];
            long l = sq[2];
            totArea += l * l;
            diff.merge(y, l, Long::sum);
            diff.merge(y + (int) l, -l, Long::sum);
        }

        long area = 0;
        long sumL = 0;
        int preY = 0; // 不好计算下一个 y,改成维护上一个 y
        for (var e : diff.entrySet()) {
            int y = e.getKey();
            area += sumL * (y - preY); // 底边长 * 高 = 新增面积
            if (area * 2 >= totArea) {
                return y - (area * 2 - totArea) / (sumL * 2.0);
            }
            preY = y;
            sumL += e.getValue(); // 矩形底边长度之和
        }
        return -1;
    }
}

###cpp

class Solution {
public:
    double separateSquares(vector<vector<int>>& squares) {
        long long tot_area = 0;
        map<int, long long> diff;
        for (auto& sq : squares) {
            int y = sq[1], l = sq[2];
            tot_area += 1LL * l * l;
            diff[y] += l;
            diff[y + l] -= l;
        }

        long long area = 0, sum_l = 0;
        for (auto it = diff.begin();;) {
            auto [y, sl] = *it;
            int y2 = (++it)->first;
            sum_l += sl; // 矩形底边长度之和
            area += sum_l * (y2 - y); // 底边长 * 高 = 新增面积
            if (area * 2 >= tot_area) {
                return y2 - (area * 2 - tot_area) / (sum_l * 2.0);
            }
        }
    }
};

###go

func separateSquares(squares [][]int) float64 {
totArea := 0
diff := map[int]int{}
for _, sq := range squares {
y, l := sq[1], sq[2]
totArea += l * l
diff[y] += l
diff[y+l] -= l
}

ys := slices.Sorted(maps.Keys(diff))
area, sumL := 0, 0
for i := 0; ; i++ {
sumL += diff[ys[i]] // 矩形底边长度之和
area += sumL * (ys[i+1] - ys[i]) // 底边长 * 高 = 新增面积
if area*2 >= totArea {
return float64(ys[i+1]) - float64(area*2-totArea)/float64(sumL*2)
}
}
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{squares}$ 的长度。瓶颈在排序/维护有序集合上。
  • 空间复杂度:$\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自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

从零构建 Vue 弹窗组件

作者 yyt_
2026年1月13日 00:05

整体学习路线:简易弹窗 → 完善基础功能 → 组件内部状态管理 → 父→子传值 → 子→父传值 → 跨组件传值(最终目标)


步骤 1:搭建最基础的弹窗(静态结构,无交互)

目标:实现一个固定显示在页面中的弹窗,包含标题、内容、关闭按钮,掌握 Vue 组件的基本结构。

组件文件:BasicPopup.vue

<template>
  <!-- 弹窗外层容器(遮罩层) -->
  <div class="popup-mask">
    <!-- 弹窗主体 -->
    <div class="popup-content">
      <h3>简易弹窗</h3>
      <p>这是最基础的弹窗内容</p>
      <button>关闭</button>
    </div>
  </div>
</template>

<script setup>
// 现阶段无逻辑,仅搭建结构
</script>

<style scoped>
/* 遮罩层:占满整个屏幕,半透明背景 */
.popup-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

/* 弹窗主体:白色背景,固定宽高,圆角 */
.popup-content {
  width: 400px;
  padding: 20px;
  background-color: #fff;
  border-radius: 8px;
  text-align: center;
}

/* 按钮样式 */
button {
  margin-top: 20px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

使用组件(App.vue

<template>
  <h1>弹窗学习演示</h1>
  <BasicPopup />
</template>

<script setup>
import BasicPopup from './components/BasicPopup.vue';
</script>

步骤 2:添加基础交互(控制弹窗显示/隐藏)

目标:通过「响应式状态」控制弹窗的显示与隐藏,给关闭按钮添加点击事件,掌握 ref 和事件绑定。

改造 BasicPopup.vue(新增响应式状态和点击事件)

<template>
  <!-- 用 v-if 控制弹窗是否显示 -->
  <div class="popup-mask" v-if="isShow">
    <div class="popup-content">
      <h3>简易弹窗</h3>
      <p>这是最基础的弹窗内容</p>
      <!-- 绑定关闭按钮点击事件 -->
      <button @click="closePopup">关闭</button>
    </div>
  </div>
</template>

<script setup>
// 1. 导入 ref 用于创建响应式状态
import { ref } from 'vue';

// 2. 定义响应式变量,控制弹窗显示/隐藏
const isShow = ref(true); // 初始值为 true,默认显示弹窗

// 3. 定义关闭弹窗的方法
const closePopup = () => {
  isShow.value = false; // 响应式变量修改需要通过 .value
};
</script>

<style scoped>
/* 样式同步骤 1,不变 */
.popup-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.popup-content {
  width: 400px;
  padding: 20px;
  background-color: #fff;
  border-radius: 8px;
  text-align: center;
}

button {
  margin-top: 20px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

补充:在 App.vue 添加「打开弹窗」按钮

我们知道,Vue 遵循「单向数据流」和「组件封装隔离」,子组件内部的方法 / 私有状态默认是对外隐藏的,外部父组件无法直接访问。

ref 就是打破这种 “隔离” 的合法方式(非侵入式),让父组件能够:

  1. 调用子组件通过 defineExpose 暴露的方法(如 openPopup 方法,用于打开弹窗)。
  2. 访问子组件通过 defineExpose 暴露的响应式状态(如弹窗内部的 isShow 状态)。
  3. 实现「父组件主动控制子组件」的交互场景(如主动打开 / 关闭弹窗、主动刷新子组件数据)。
<template>
  <h1>弹窗学习演示</h1>
  <!-- 新增打开弹窗按钮 -->
  <button @click="handleOpenPopup">打开弹窗</button>
  <BasicPopup ref="popupRef" />
</template>

<script setup>
import { ref } from 'vue';
import BasicPopup from './components/BasicPopup.vue';

// 获取弹窗组件实例
const popupRef = ref(null);

// 打开弹窗的方法(调用子组件的方法,后续步骤会完善)
const handleOpenPopup = () => {
  if (popupRef.value) {
    popupRef.value.openPopup();
  }
};
</script>

<style>
button {
  margin: 10px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

BasicPopup.vue 补充「打开弹窗」方法(暴露给父组件)

<template>
  <div class="popup-mask" v-if="isShow">
    <div class="popup-content">
      <h3>简易弹窗</h3>
      <p>这是最基础的弹窗内容</p>
      <button @click="closePopup">关闭</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const isShow = ref(false); // 初始值改为 false,默认隐藏

const closePopup = () => {
  isShow.value = false;
};

// 新增:打开弹窗的方法
const openPopup = () => {
  isShow.value = true;
};

// 暴露组件方法,让父组件可以调用(关键)
defineExpose({
  openPopup
});
</script>

<style scoped>
/* 样式同前 */
</style>

核心知识点

  1. ref 创建响应式状态,修改时需要 .value
  2. @click 事件绑定,触发自定义方法
  3. defineExpose 暴露组件内部方法/状态,供父组件调用
  4. v-if 控制元素的渲染与销毁(实现弹窗显示/隐藏)

步骤 3:父组件 → 子组件传值(Props 传递)

目标:父组件向弹窗组件传递「弹窗标题」和「弹窗内容」,掌握 defineProps 的使用,实现弹窗内容的动态化。

改造 BasicPopup.vue(接收父组件传递的参数)

<template>
  <div class="popup-mask" v-if="isShow">
    <div class="popup-content">
      <!-- 渲染父组件传递的标题 -->
      <h3>{{ popupTitle }}</h3>
      <!-- 渲染父组件传递的内容 -->
      <p>{{ popupContent }}</p>
      <button @click="closePopup">关闭</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';

// 1. 定义 Props,接收父组件传递的值(指定类型和默认值)
const props = defineProps({
  // 弹窗标题
  popupTitle: {
    type: String,
    default: '默认弹窗标题' // 默认值,防止父组件未传递
  },
  // 弹窗内容
  popupContent: {
    type: String,
    default: '默认弹窗内容'
  }
});

const isShow = ref(false);

const closePopup = () => {
  isShow.value = false;
};

const openPopup = () => {
  isShow.value = true;
};

defineExpose({
  openPopup
});
</script>

<style scoped>
/* 样式同前 */
</style>

改造 App.vue(向子组件传递 Props 数据)

<template>
  <h1>弹窗学习演示</h1>
  <button @click="openPopup">打开弹窗</button>
  <!-- 向子组件传递 props 数据(静态传递 + 动态传递均可) -->
  <BasicPopup
    ref="popupRef"
    popupTitle="父组件传递的标题"
    :popupContent="dynamicContent"
  />
</template>

<script setup>
import { ref } from 'vue';
import BasicPopup from './components/BasicPopup.vue';

const popupRef = ref(null);
// 动态定义弹窗内容(也可以是静态字符串)
const dynamicContent = ref('这是父组件通过 Props 传递的动态弹窗内容~');

const openPopup = () => {
  if (popupRef.value) {
    popupRef.value.openPopup();
  }
};
</script>

<style>
button {
  margin: 10px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

核心知识点

  1. defineProps 定义组件接收的参数,支持类型校验和默认值
  2. Props 传递规则:父组件通过「属性绑定」向子组件传值,子组件只读(不能修改 Props,遵循单向数据流)
  3. 静态传值(直接写字符串)直接把等号后面的内容作为纯字符串传递给子组件的 Props,Vue 不会对其做任何解析、计算,原样传递。动态传值(:xxx="变量") Vue 会先解析求值,再把结果传递给子组件的 Props。

步骤 4:子组件 → 父组件传值(Emits 事件派发)

目标:弹窗关闭时,向父组件传递「弹窗关闭的状态」和「自定义数据」,掌握 defineEmits 的使用,实现子向父的通信。

改造 BasicPopup.vue(派发事件给父组件)

<template>
  <div class="popup-mask" v-if="isShow">
    <div class="popup-content">
      <h3>{{ popupTitle }}</h3>
      <p>{{ popupContent }}</p>
      <!-- 关闭按钮点击时,触发事件派发 -->
      <button @click="handleClose">关闭</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';

// 1. 定义 Props
const props = defineProps({
  popupTitle: {
    type: String,
    default: '默认弹窗标题'
  },
  popupContent: {
    type: String,
    default: '默认弹窗内容'
  }
});

// 2. 定义 Emits,声明要派发的事件(支持数组/对象格式,对象格式可校验)
const emit = defineEmits([
  'popup-close', // 弹窗关闭事件
  'send-data'    // 向父组件传递数据的事件
]);

const isShow = ref(false);

// 3. 改造关闭方法,派发事件给父组件
const handleClose = () => {
  isShow.value = false;
  
  // 派发「popup-close」事件,可携带参数(可选)
  emit('popup-close', {
    closeTime: new Date().toLocaleString(),
    message: '弹窗已正常关闭'
  });
  
  // 派发「send-data」事件,传递自定义数据
  emit('send-data', '这是子组件向父组件传递的额外数据');
};

const openPopup = () => {
  isShow.value = true;
};

defineExpose({
  openPopup
});
</script>

<style scoped>
/* 样式同前 */
</style>

改造 App.vue(监听子组件派发的事件)

<template>
  <h1>弹窗学习演示</h1>
  <button @click="openPopup">打开弹窗</button>
  <!-- 监听子组件派发的事件,绑定处理方法 -->
  <BasicPopup
    ref="popupRef"
    popupTitle="父组件传递的标题"
    :popupContent="dynamicContent"
    @popup-close="handlePopupClose"
    @send-data="handleReceiveData"
  />
</template>

<script setup>
import { ref } from 'vue';
import BasicPopup from './components/BasicPopup.vue';

const popupRef = ref(null);
const dynamicContent = ref('这是父组件通过 Props 传递的动态弹窗内容~');

const openPopup = () => {
  if (popupRef.value) {
    popupRef.value.openPopup();
  }
};

// 1. 处理子组件派发的「popup-close」事件
const handlePopupClose = (closeInfo) => {
  console.log('接收弹窗关闭信息:', closeInfo);
  alert(`弹窗已关闭,关闭时间:${closeInfo.closeTime}`);
};

// 2. 处理子组件派发的「send-data」事件
const handleReceiveData = (data) => {
  console.log('接收子组件传递的数据:', data);
  alert(`收到子组件数据:${data}`);
};
</script>

<style>
button {
  margin: 10px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

核心知识点

  1. defineEmits 声明组件要派发的事件,遵循「事件名小写+短横线分隔」规范
  2. emit 方法用于派发事件,第一个参数是事件名,后续参数是要传递的数据
  3. 父组件通过 @事件名 监听子组件事件,处理方法的参数就是子组件传递的数据
  4. 单向数据流补充:子组件不能直接修改 Props,如需修改,可通过「子组件派发事件 → 父组件修改数据 → Props 重新传递」实现

步骤 5:跨组件传值(非父子组件,使用 Provide / Inject)

目标:实现「非父子组件」(如:Grandpa.vueParent.vuePopup.vue,爷爷组件向弹窗组件传值)的通信,掌握 provide / inject 的使用,这是 Vue 中跨组件传值的核心方案之一。

步骤 5.1:创建层级组件结构

├── App.vue(入口)
├── components/
│   ├── Grandpa.vue(爷爷组件,提供数据)
│   ├── Parent.vue(父组件,中间层级,无数据处理)
│   └── Popup.vue(弹窗组件,注入并使用数据)

步骤 5.2:爷爷组件 Grandpa.vue(提供数据,provide

<template>
  <div class="grandpa">
    <h2>爷爷组件</h2>
    <button @click="updateGlobalData">修改跨组件传递的数据</button>
    <!-- 引入父组件(中间层级) -->
    <Parent />
  </div>
</template>

<script setup>
import { ref, provide } from 'vue';
import Parent from './Parent.vue';

// 1. 定义要跨组件传递的响应式数据
const globalPopupConfig = ref({
  author: 'yyt',
  version: '1.0.0',
  theme: 'light',
  maxWidth: '500px'
});

// 2. 提供(provide)数据,供后代组件注入使用
// 第一个参数:注入标识(字符串/Symbol),第二个参数:要传递的数据
provide('popupGlobalConfig', globalPopupConfig);

// 3. 修改响应式数据(后代组件会同步更新)
const updateGlobalData = () => {
  globalPopupConfig.value = {
    ...globalPopupConfig.value,
    version: '2.0.0',
    theme: 'dark',
    updateTime: new Date().toLocaleString()
  };
};
</script>

<style scoped>
.grandpa {
  padding: 20px;
  border: 2px solid #666;
  border-radius: 8px;
  margin: 10px;
}

button {
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

步骤 5.3:父组件 Parent.vue(中间层级,仅做组件嵌套)

<template>
  <div class="parent">
    <h3>父组件(中间层级)</h3>
    <button @click="openPopup">打开跨组件传值的弹窗</button>
    <!-- 引入弹窗组件 -->
    <Popup ref="popupRef" />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import Popup from './Popup.vue';

const popupRef = ref(null);

// 打开弹窗
const openPopup = () => {
  if (popupRef.value) {
    popupRef.value.openPopup();
  }
};
</script>

<style scoped>
.parent {
  padding: 20px;
  border: 2px solid #999;
  border-radius: 8px;
  margin: 10px;
  margin-top: 20px;
}

button {
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

步骤 5.4:弹窗组件 Popup.vue(注入数据,inject

<template>
  <div class="popup-mask" v-if="isShow" :style="{ backgroundColor: themeBg }">
    <div class="popup-content" :style="{ maxWidth: globalConfig.maxWidth, background: globalConfig.theme === 'dark' ? '#333' : '#fff', color: globalConfig.theme === 'dark' ? '#fff' : '#333' }">
      <h3>{{ popupTitle }}</h3>
      <p>{{ popupContent }}</p>
      <!-- 渲染跨组件传递的数据 -->
      <div class="global-info" style="margin: 15px 0; padding: 10px; border: 1px solid #eee; border-radius: 4px;">
        <p>跨组件传递的配置:</p>
        <p>作者:{{ globalConfig.author }}</p>
        <p>版本:{{ globalConfig.version }}</p>
        <p>主题:{{ globalConfig.theme }}</p>
        <p v-if="globalConfig.updateTime">更新时间:{{ globalConfig.updateTime }}</p>
      </div>
      <button @click="handleClose">关闭</button>
    </div>
  </div>
</template>

<script setup>
import { ref, inject, computed } from 'vue';

// 1. 定义 Props
const props = defineProps({
  popupTitle: {
    type: String,
    default: '默认弹窗标题'
  },
  popupContent: {
    type: String,
    default: '默认弹窗内容'
  }
});

// 2. 注入(inject)爷爷组件提供的数据
// 第一个参数:注入标识(与 provide 一致),第二个参数:默认值(可选)
const globalConfig = inject('popupGlobalConfig', ref({ author: '默认作者', version: '0.0.1' }));

// 3. 基于注入的数据创建计算属性(可选,优化使用体验)
const themeBg = computed(() => {
  return globalConfig.value.theme === 'dark' ? 'rgba(0, 0, 0, 0.8)' : 'rgba(0, 0, 0, 0.5)';
});

// 4. 定义 Emits
const emit = defineEmits(['popup-close']);

const isShow = ref(false);

// 5. 关闭方法
const handleClose = () => {
  isShow.value = false;
  emit('popup-close', { message: '弹窗已关闭' });
};

// 6. 打开弹窗方法
const openPopup = () => {
  isShow.value = true;
};

// 7. 暴露方法
defineExpose({
  openPopup
});
</script>

<style scoped>
.popup-mask {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  align-items: center;
}

.popup-content {
  padding: 20px;
  border-radius: 8px;
  text-align: center;
}

button {
  margin-top: 20px;
  padding: 8px 16px;
  background-color: #1890ff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

步骤 5.5:入口 App.vue(引入爷爷组件)

<template>
  <h1>跨组件传值演示(Provide / Inject)</h1>
  <Grandpa />
</template>

<script setup>
import Grandpa from './components/Grandpa.vue';
</script>

核心知识点

  1. provide / inject 用于跨层级组件通信(无论层级多深),爷爷组件提供数据,后代组件注入使用
  2. 传递响应式数据:provide 时传递 ref/reactive 包装的数据,后代组件可感知数据变化(同步更新)
  3. 注入标识:建议使用 Symbol 避免命名冲突(生产环境推荐),本步骤为简化使用字符串标识
  4. 注入默认值:inject 第二个参数为默认值,防止祖先组件未提供数据时出现报错
  5. 与 Props/Emits 的区别:Props 适用于父子组件,provide/inject 适用于跨层级组件
昨天 — 2026年1月12日技术

👋 手搓 gzip 实现的文件分块压缩上传

作者 源心锁
2026年1月12日 23:13

👋 手搓 GZIP 实现的文件分块压缩上传

1 前言

已经半年多的时间没有闲下来写文章了。一方面是重新迷上了玩游戏,另一方面是 AI 时代的到来,让我对普通技术类文章的阅读频率减少了很多,相应的,自己动笔的动力也减缓了不少。

但经过这段时间的摸索,有一点是可以确定的:具有一定技术深度、带有强烈个人风格或独特创意的文章,在 AI 时代仍具有不可替代的价值。

所以,本篇来了。

在上一篇文章中,我们实现了在浏览器中记录结构化日志,现在,我们需要将这部分日志上传到云端,方便工程师调试。

我们面临的首要问题就是,文件太大了,必须分片上传。

我们将从零构建一套大文件上传系统。和普通的大文件上传系统(如阿里 OSS、七牛云常见的方案)相似,我们具备分片上传、断点续传的基础能力。但不同的是,我们为此引入了两个高阶特性:

  1. AWS S3 预签名直传(Presigned URL) :降低服务端带宽压力。
  2. 独立分片 Gzip 压缩:在客户端对分片进行独立压缩,但最终在服务端合并成一个合法的 Gzip 文件。

阅读本篇,你将收获:

  • Gzip (RFC 1952) 与 Deflate (RFC 1951) 协议的底层实现原理。
  • 基于 AWS S3 实现大文件分片直传的完整架构。
  • 一个生产级前端上传 SDK 的设计思路。

2 基础方案设计

在正式开始设计之前,我们需要先了解以下知识:AWS 提供服务端的大文件上传或下载能力,但不直接提供直传场景(presign url)的大文件分片上传能力。

基于 AWS 实现的常规流程的大文件上传 flow 为:

  • 后端先启用 CreateMultipartUpload,得到 uploadId,返回前端

    • 在启用时,需遵循以下规则:

      • ✅ 分段上传的最大文件大小为 5TB
      • ⚠️ 最大分段数为 10000
      • ⚠️ 分段大小单次限制为 5MB-5GB,最后一段无限制
    • 需提前定义 x-amz-acl

    • 需提前定义使用的校验和算法 x-amz-checksum-algorithm

    • 需提前定义校验和类型 x-amz-checksum-type

  • 在上传时,可以通过 presign url 上传

    • 每一段都必须在 header 中包含 uploadId
    • 每一段都建议计算校验和,并携带到 header 中(声明时如定义了 **x-amz-checksum-algorithm 则必传)**
    • 每一段上传时,都必须携带分段的序号 partNumber
    • 上传后,返回每一段的 ETag 和 PartNumber,如果使用了校验和算法,则也返回;该返回数据需要记录下来
  • 上传完成后,调用 CompleteMultipartUpload

    • 必须包含参数 part,使用类似于:
    • ⚠️ 除了最后一段外,单次最小 5MB,否则 complete 阶段会报错

好在这并不意味着我们要在「直传」和「分片上传」中间二选一。

来看到我们的架构图,我们在 BFF 总共只需要三个接口,分别负责「创建上传任务」「获取分片上传 URL」「完成分片上传」的任务,而实际上传时,调用预授权的 AWS URL。

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-09-05-092658.png

更细节的部分,可以参考这份时序图。

Mermaid Chart - Create complex, visual diagrams with text. A smarter way of creating diagrams.-2025-09-05-092540.png

2.1 关键接口

📤 创建上传任务

  • 接口地址POST /createSliceUpload

  • 功能

    • 检查文件是否已存在
    • 检查是否存在未完成的上传任务
    • 创建新的分片上传任务
  • 返回示例

    • ✅ 文件已存在:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "url": "https://..."
      }
      
    • 🔄 任务进行中:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "uploadId": "abc123",
        "uploadedParts": [1, 2, 3]
      }
      
    • 🆕 新建任务:

      {
        "id": "xxx",
        "fileName": "example.txt",
        "uploadId": "abc123",
        "uploadedParts": []
      }
      

🔗 获取分片上传 URL

  • 接口地址POST /getSlicePresignedUrl

  • 功能:获取指定分片的预签名上传 URL

  • 请求参数

    {
      "id": "xxx",
      "fileName": "example.txt",
      "partNumber": 1,
      "uploadId": "abc123"
    }
    
  • 返回示例

    {
      "uploadUrl": "https://..."
    }
    

/getSlicePresignedUrl 接口中,我们通过 AWS SDK 可以预签一个直传 URL

import { UploadPartCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

  const uploadUrl = await getSignedUrl(
    s3client,
    new UploadPartCommand({
      Bucket: AWS_BUCKET,
      Key: fileKey,
      PartNumber: partNumber,
      UploadId: uploadId,
    }),
    { expiresIn: 3600 },
  );

✅ 完成分片上传

  • 接口地址POST /completeSliceUpload

  • 功能:合并所有已上传的分片

  • 请求参数

    {
      "id": "xxx",
      "fileName": "example.txt",
      "uploadId": "abc123",
      "parts": [
        { "ETag": "etag1", "PartNumber": 1 },
        { "ETag": "etag2", "PartNumber": 2 }
      ]
    }
    
  • 返回示例

    {
      "id": "xxx",
      "location": "https://..."
    }
    

2.2 前端设计

为了方便使用,我们尝试构建一套方便使用的 SDK,设计的 Options 如下

interface UploadSliceOptions {
  fileName: string;
  id: string;
  getContent: (
    uploadSlice: (params: { content: ArrayBufferLike; partNumber: number; isLast?: boolean }) => Promise<void>,
  ) => Promise<void>;
  acl?: 'public-read' | 'authenticated-read';
  contentType?: string;
  contentEncoding?: 'gzip';
}

这些参数的设计意图是:

  • fileName: 分片最终合并时呈现的名字

  • id :同名文件可能实际并不同,可以使用 hash 值来区分

  • 核心上传逻辑的抽象(getContent 函数):

    • 职责:负责异步地生成或获取每一个文件分片(比如从本地文件中读取一块数据)

    • 不直接接收文件内容,而是接收一个回调函数 uploadSlice 作为参数。

      • uploadSlice 的职责是:负责异步地将这一个分片的数据(content)和它的序号(partNumber)发送到服务器。
  • 可选的文件属性(HTTP 头部相关):

  • contentType?: string: 可选。指定文件的 MIME 类型(例如 'image/jpeg''application/pdf')。这在云存储中很重要,它会影响文件被访问时的 Content-Type 响应头。

  • contentEncoding?: 'gzip': 可选。指明文件内容是否(或如何)被压缩的。在这里,它明确只支持 'gzip',意味着如果提供了这个选项,上传的内容会被进行独立分片压缩

2.2.1 核心功能实现

📤 单个分片上传

uploadSlice 函数实现逻辑如下:

  1. 通过 FileClient 获取预签名 URL
  2. 使用 fetch API 将分片内容上传到该 URL
  3. 获取 ETag,并返回上传结果
export const uploadSlice = async ({ id, fileName, partNumber, content, uploadId }: UploadSliceParams) => {
  const { uploadUrl: presignedUrl } = await FileClient.getSlicePresignedUrl({
    id,
    fileName,
    partNumber,
    uploadId,
  });

  const uploadRes = await fetch(presignedUrl, {
    method: 'PUT',
    body: content,
  });
  const etag = uploadRes.headers.get('etag');
  if (!etag) throw new Error('Upload failed');
  return {
    ETag: etag,
    PartNumber: partNumber,
  };
};

🔁 分片上传流程控制

uploadSliceFile 实现完整上传逻辑:

  1. 创建上传任务,获取 uploadId
  2. 若返回完整 URL(如小文件无需分片),则直接返回
  3. 调用 getContent 回调,获取各分片内容并上传
  4. 对失败的分片进行重试
  5. 所有分片上传完成后,调用接口合并分片
  const uploadTask = await FileClient.createSliceUpload({
    fileName,
    id,
    acl,
    contentEncoding,
    contentType,
  });
  
  if (uploadTask.url) {
    return uploadTask.url; // 代表这个 id 的文件实际上已经上传过了
  }
  
  const { uploadedParts = [] } = uploadTask;
  const uploadId = uploadTask.uploadId as string;

  const parts: { PartNumber: number; ETag: string }[] = [...(uploadedParts as { PartNumber: number; ETag: string }[])];
  
  await getContent(async ({content,isLast})=>{
     ...
     const part = await uploadSlice({
         content: new Blob([content]),
         partNumber: currentPartNumber,
         uploadId,
         id,
         fileName,
     });
     parts.push(part);
  })
  
  
  return FileClient.completeSliceUpload(...)

❗ 错误处理与重试机制

  • 最大重试次数:MAX_RETRY_TIMES = 3
  • 重试延迟时间:RETRY_DELAY = 1000ms
  • 若分片上传失败,则按策略重试
  • 合并上传前需校验所有分片是否上传成功

🔄 分片去重处理

合并前对已上传分片进行去重:

  1. 按分片序号排序
  2. 使用 Set 记录已处理的分片编号
  3. 构建唯一的分片列表

2.2.2 使用示例

虽然咋一看有些奇怪,但这种方式对于流式上传支持度更好,且在普通场景也同样适用。如下边这份代码是普通文件的上传 demo

// 示例:上传一个大文件
const fileId = 'unique-file-id';
const fileName = 'large-file.mp4';
const file = /* 获取文件对象 */;
const chunkSize = 5 * 1024 * 1024; // 每片5MB
const chunks = Math.ceil(file.size / chunkSize);

const fileUrl = await uploadSliceFile({
  fileName,
  id: fileId,
  getContent: async (uploadSlice) => {
    for (let i = 0; i < chunks; i++) {
      const start = i * chunkSize;
      const end = Math.min(file.size, start + chunkSize);
      const chunk = file.slice(start, end);

      await uploadSlice({
        content: chunk,
        partNumber: i + 1, // 分片编号从1开始
      });
    }
  },
});

console.log('文件上传成功,访问地址:', fileUrl);

3 进阶:分块 GZIP 压缩

我们的日志,其以字符串的形式保存,上传时,最终也是要上传成一份文本型文件。

所以,我们可以考虑在上传前进行压缩,以进一步减少上传时的体积——这个过程中,我们可以考虑使用 gzip、brotli、zstd 等算法。

从兼容性考虑 💭,现在 Web 浏览器支持率最高的算法是 gzip 和 brotli 算法。但 brotli 的原理决定了我们可能很难完整发挥出 brotli 算法的效果。

原因有几个。

第一个致命的原因是,brotli(RFC 7932) 是一种 raw stream 格式,它的数据流由一个或多个“元块”(Meta-Block) 组成。流中的最后一个元块会包含一个特殊的 ISLAST 标志位,它相当于一个「文件结束符」

当我们单独压缩每一个分散的文本片段时:

  • 文本A -> 片段A.br (最后一个元块包含 ISLAST=true)
  • 文本B -> 片段B.br (最后一个元块包含 ISLAST=true)
  • 文本C -> 片段C.br (最后一个元块包含 ISLAST=true)
  • ...

当我们把它们合并在一起时(例如通过 cat A.br B.br C.br > final.br),我们得到的文件结构是: [A的数据... ISLAST=true] [B的数据... ISLAST=true] [C的数据... ISLAST=true]

当一个标准的 Brotli 解码器(比如浏览器)读取这个 final.br 文件时:

  1. 解码器开始读取 [A的数据...]
  2. 解码器读取到 A 的最后一个元块,看到了 ISLAST=true 标志。
  3. 解码器立即停止解码,因为它认为流已经结束了。
  4. [B的数据...][C的数据...] 会被完全忽略,当成文件末尾的“垃圾数据”。

最终结果 我们只能成功解压出 文本A,所有后续的文本内容都会丢失。

——即便我们手动将 IS_LAST 修改正确,但「独立压缩」会导致另一个严重问题——压缩率的极大损失。

因为 br 的压缩过程中,需要先建立一个滑动窗口字典。而如果我们对每一个分片都进行压缩,br 实际上需要为每一个分片建立一个字典。

这意味着这个过程中,最核心的字典不断被重置,br 压缩器丢失了用于判断内部重复的关键工具, 进而会导致压缩率极大的下降。

而对于 gzip 来讲,虽然 gzip body 采用的 deflate 算法同样需要字段,但其窗口大小只有 32KB(br 则是 4-16MB),而我们单个分片单最小大小即是 %MB,所以对于 gzip 来说,分成 5MB 再压缩还是 500MB 直接压缩区别并不大。

所以,我们选择 gzip 来做分块压缩。

Gzip 协议是一种文件格式,它充当一个“容器”。这个容器包裹了使用 DEFLATE (RFC 1951) 算法压缩的数据块,并为其添加了元信息和校验和,以确保文件的完整性和可识别性。

一个 gzip 文件由三个核心部分组成:

  1. Header (头部) :识别文件并提供元信息。
  2. Body (主体) :包含 DEFLATE 压缩的数据流。
  3. Footer (尾部) :提供数据完整性校验。

这意味着,我们进行分块压缩时,可以通过手动创建 header + body + footer 的方式进行分块压缩。

3.1 HEADER & FOOTER

头部至少有 10 个字节。

偏移量 (字节) 长度 (字节) 字段名 固定值 / 描述
0 1 ID1 0x1f (或 31)。这是识别 gzip 文件的“魔术数字”第一部分。
1 1 ID2 0x8b (或 139)。“魔术数字”第二部分。
2 1 CM 0x08 (或 8)。表示压缩方法 (Compression Method) 为 DEFLATE
3 1 FLG 标志位 (Flags)。这是一个极其重要的字节,它的每一位都代表一个布尔值,用于控制是否存在“可选头部”。
4 4 MTIME 文件的最后修改时间 (Modification Time),以 4 字节的 Unix 时间戳格式存储。
8 1 XFL 额外标志 (Extra Flags)。通常用于指示 DEFLATE 压缩器使用的压缩级别(例如 0x02 = 最高压缩率,0x04 = 最快压缩率)。
9 1 OS 操作系统 (Operating System)。0x03 = Unix, 0x00 = Windows/FAT, 0xFF = 未知。

其中的核心部分是 FLG,即标志位。这是头部第 4 个字节 (偏移量 3),我们需要按位 (bit) 来解析它:

Bit (位) 掩码 (Hex) 字段名 描述
0 (最低位) 0x01 FTEXT 如果置 1,表示文件可能是 ASCII 文本文件(这只是一个提示)。
1 0x02 FHCRC 如果置 1,表示头部包含一个 2 字节的头部校验和 (CRC-16)
2 0x04 FEXTRA 如果置 1,表示头部包含一个扩展字段 (extra field)
3 0x08 FNAME 如果置 1,表示头部包含原始文件名
4 0x10 FCOMMENT 如果置 1,表示头部包含注释
5 0x20 RESERVED 保留位,必须为 0。
6 0x40 RESERVED 保留位,必须为 0。
7 0x80 RESERVED 保留位,必须为 0。

然后,根据 FLG 标志位的设置,紧跟在 10 字节固定头部后面的,可能会按顺序出现以下字段:

  • FEXTRA (如果 FLG & 0x04 为真):

    • XLEN (2 字节): 扩展字段的总长度 N。
    • EXTRA (N 字节): N 字节的扩展数据。
  • FNAME (如果 FLG & 0x08 为真):

    • 原始文件名,以 NULL ( 0x00 ) 字节结尾的 C 风格字符串。
  • FCOMMENT (如果 FLG & 0x10 为真):

    • 注释,以 NULL ( 0x00 ) 字节结尾的 C 风格字符串。
  • FHCRC (如果 FLG & 0x02 为真):

    • 一个 2 字节的 CRC-16 校验和,用于校验整个头部(包括所有可选部分)的完整性。

我们的话,我们需要写入 filename,所以转换成代码,就是如下的实现:

/**
 * 生成标准 GZIP Header(10 字节)
 * 符合 RFC 1952 规范。
 * 可用于拼接 deflate raw 数据生成完整 .gz 文件。
 */
/**
 * 生成包含文件名的标准 GZIP Header
 * @param {string} filename - 要嵌入头部的原始文件名
 */
export function createGzipHeader(filename: string): Uint8Array {
  // 1. 创建基础的10字节头部,并将Flags位设置为8 (FNAME)
  const header = new Uint8Array([
    0x1f,
    0x8b, // ID1 + ID2: magic number
    0x08, // Compression method: deflate (8)
    0x08, // Flags: 设置FNAME位 (bit 3)
    0x00,
    0x00,
    0x00,
    0x00, // MTIME: 0
    0x00, // Extra flags: 0
    0x03, // OS: 3 (Unix)
  ]);

  // 动态设置 MTIME
  const mtime = Math.floor(Date.now() / 1000);
  header[4] = mtime & 0xff;
  header[5] = (mtime >> 8) & 0xff;
  header[6] = (mtime >> 16) & 0xff;
  header[7] = (mtime >> 24) & 0xff;

  // 2. 将文件名字符串编码为字节
  const encoder = new TextEncoder(); // 默认使用 UTF-8
  const filenameBytes = encoder.encode(filename);

  // 3. 拼接最终的头部
  // 最终头部 = 10字节基础头 + 文件名字节 + 1字节的null结束符
  const finalHeader = new Uint8Array(10 + filenameBytes.length + 1);

  finalHeader.set(header, 0);
  finalHeader.set(filenameBytes, 10);
  // 最后一个字节默认为0,作为null结束符

  return finalHeader;
}

footer 则相对简单一些,尾部是固定 8 字节的块,由 CRC32 和 ISIZE 组成:

偏移量 长度 (字节) 字段名 描述
0 4 CRC-32 原始未压缩数据的 CRC-32 校验和。
4 4 ISIZE 原始未压缩数据的大小 (字节数)。由于它只有 4 字节,gzip 文件无法正确表示大于 4GB 的文件(解压后的大小)。

这两个值是 gzip 压缩过程中需要从整个文件角度计算的信息,由于两者均可以增量计算,问题不大。(crc32 本身计算量不大,推荐直接使用 sheetjs 库就行)

这样的话,我们就得到了这样的代码:

export function createGzipFooter(crc32: number, size: number): Uint8Array {
  const footer = new Uint8Array(8);
  const view = new DataView(footer.buffer);
  view.setUint32(0, crc32, true);
  view.setUint32(4, size % 0x100000000, true);
  return footer;
}

3.2 BODY

对我们来说,中间的 raw 流是最麻烦的。

gzip body 中的 DEFLATE 流 (RFC 1951) 并不是一个单一的、连续的东西,它本身就有一套非常重要的“特殊规则”。

DEFLATE 流的真正结构是由一个或多个数据“块” (Block) 拼接而成的。

gzip压缩器在工作时,会根据数据的情况,智能地将原始数据分割成不同类型的“块”来处理。它可能会先用一种块,然后再换另一种,以达到最佳的压缩效果。

DEFLATE 流中的每一个“块”,都必须以一个 3-bit (比特) 的头部开始。这个 3-bit 的头部定义了这个块的所有规则。

这 3 个 bit (比特) 分为两部分:

  1. BFINAL (1-bit): “最后一块”标记

    • 1: 这是整个 DEFLATE 流的最后一个块。解压器在处理完这个块后,就应该停止,并去寻找 gzip 的 Footer (CRC-32 和 ISIZE)。
    • 0: 后面还有更多的块,请继续。
  2. BTYPE (2-bits): “块类型”

    • 这 2 个 bit 决定了紧随其后的整个块的数据要如何被解析。

BTYPE 字段有三种可能的值,每一种都代表一套完全不同的压缩规则:

****规则 1:BTYPE = 00 (无压缩块) 压缩器在分析数据时,如果发现数据是完全随机的(比如已经压缩过的图片、或加密数据),它会发现压缩后的体积反而变大了。

  • 此时,它会切换到 00 模式,意思是:“我放弃压缩,直接原文存储。”

  • 结构:

    1. (BFINAL, 00) 这 3-bit 头部。
    2. 跳到下一个字节边界 (Byte-alignment)。
    3. LEN (2 字节): 声明这个块里有多少字节的未压缩数据(长度 N)。
    4. NLEN (2 字节): LEN 的“反码”(NOT LEN),用于校验 LEN 是否正确。
    5. N 字节的原始数据(原文照搬)。

规则 2:BTYPE = 01 (静态霍夫曼压缩)

  • 这是“标准”规则。 压缩器使用一套固定的、在 RFC-1951 规范中预先定义好的霍夫曼树(Huffman Tree)来进行压缩。

  • 这套“静态树”是基于对大量英语文本统计分析后得出的最佳通用编码表(例如,'e'、'a'、' ' 的编码非常短)。

  • 优点: 压缩器不需要在数据流中包含霍夫曼树本身,解压器直接使用它内置的这套标准树即可。这节省了头部空间。

  • 缺点: 如果你的数据不是英语文本(比如是中文或代码),这套树的效率可能不高。

  • 结构:

    1. (BFINAL, 01) 这 3-bit 头部。
    2. 紧接着就是使用“静态树”编码的 LZ77 + 霍夫曼编码 的数据流。
    3. 数据流以一个特殊的“块结束”(End-of-Block, EOB) 符号(静态树中的 256 号符号)结尾。

规则 3:BTYPE = 10 (动态霍夫曼压缩)

  • 这是“定制”规则,也是压缩率最高的规则。

  • 压缩器会先分析这个块的数据,统计出所有字符的准确频率,然后为这个块“量身定做”一套最优的霍夫曼树。

  • 优点: 压缩率最高,因为它完美贴合了当前数据块的特征(比如在压缩 JS 时,{ } ( ) . 的编码会变得极短)。

  • 缺点: 压缩器必须把这套“定制树”本身也压缩后,放到这个块的开头,以便解压器知道该如何解码。这会占用一些头部空间。

  • 结构:

    1. (BFINAL, 10) 这 3-bit 头部。
    2. 一个“定制霍夫曼树”的描述信息(这部分本身也是被压缩的)。
    3. 紧接着是使用这套“定制树”编码的 LZ77 + 霍夫曼编码 的数据流。
    4. 数据流以一个特殊的“块结束”(End-of-Block, EOB) 符号(定制树中的 256 号符号)结尾。

——不过,于我们而言,我们先通过静态霍夫曼压缩即可。

这个过程中,我们需要借助三方库,目前浏览器虽然支持 CompressionStream API,但并不支持我们进行精确流控制。

import pako from 'pako';

export async function compressBufferRaw(buf: ArrayBufferLike, isLast?: boolean): Promise<ArrayBufferLike> {
  const originalData = new Uint8Array(buf);

  const deflater = new pako.Deflate({ raw: true });
  deflater.push(originalData, isLast ? pako.constants.Z_FINISH : pako.constants.Z_SYNC_FLUSH);
  if (!isLast) {
    deflater.onEnd(pako.constants.Z_OK);
  }
  const compressedData = deflater.result;
  return compressedData.buffer;
}

我们用一个示例来表示一个完整 gzip 文件的话,方便理解。假设我们压缩一个叫 test.txt 的文件,它的 Gzip 文件 test.txt.gz 在十六进制编辑器中可能如下所示:

Offset  Data
------  -------------------------------------------------------------
0000    1F 8B         (ID1, ID2: Gzip 魔术数字)
0002    08            (CM: DEFLATE)
0003    08            (FLG: 0x08 = FNAME 标志位置 1)
0004    XX XX XX XX   (MTIME: 4 字节时间戳)
0008    04            (XFL: 最快压缩)
0009    03            (OS: Unix)

(可选头部开始)
000A    74 65 73 74   (t e s t)
000E    2E 74 78 74   (. t x t)
0012    00            (FNAME: NULL 终结符)

(Body 开始)
0013    ED C0 ...     (DEFLATE 压缩流开始...)
...
...     ...           (...此块数据流的末尾包含一个 EOB 符号...)
                      (... DEFLATE 压缩流结束)

(Footer 开始)
XXXX    YY YY YY YY   (CRC-32: 原始 test.txt 文件的校验和)
XXXX+4  ZZ ZZ ZZ ZZ   (ISIZE: 原始 test.txt 文件的大小)

至此,我们完成了一套社区前列的分片上传方案。S3 将所有上传的部分按序合并后,在S3上形成的文件结构是:[Gzip Header][Deflate_Chunk_1][Deflate_Chunk_2]...[Deflate_Last_Chunk][Gzip Footer] 这个拼接起来的文件是一个完全合法、可流式解压的 .gz 文件。

4 性能 & 对比

为了验证该方案(Smart S3 Gzip)的实际效果,我们构建了一个基准测试环境,将本文方案与「普通直传」及「传统前端压缩上传」进行全方位对比。

4.1 测试环境

  • 测试文件:1GB Nginx Access Log (纯文本)
  • 网络环境:模拟家用宽带上行 50Mbps (约 6.25MB/s)
  • 测试设备:MacBook Pro (M1 Pro), 32GB RAM
  • 浏览器:Chrome 143

4.2 核心指标对比

核心指标 方案 A:普通直传 方案 B:前端整体压缩 方案 C:本文方案 (分片 Gzip 流)
上传总耗时 ~165 秒 ~45 秒 (但等待压缩很久) ~38 秒 (边压边传)
首字节发送时间 0 秒 (立即开始) 30 秒+ (需等待压缩完成) 0.5 秒 (首个分片压缩完即发)
峰值内存占用(计算值) 50MB (流式) 2GB+ (需读入全量文件) 100MB (仅缓存并发分片)
网络流量消耗 1GB ~120MB ~121MB (略多出的 Header 开销可忽略)
客户端 CPU 负载 极低 (<5%) 单核 100% (持续一段时间,可能 OOM) 多核均衡 (并发压缩,利用率高)

4.3 深度解析

🚀 1. 速度提升的秘密:流水线效应

在方案 B(整体压缩)中,用户必须等待整个 1GB 文件在本地压缩完成,才能开始上传第 1 个字节。这是一种「串行阻断」模型。 而本文方案 C 采用了「流水线(Pipeline)」模型:压缩第 N 个分片的同时,正在上传第 N-1 个分片。 对于高压缩率的文本文件(通常压缩比 5:1 到 10:1),网络传输往往比本地 CPU 压缩要慢。这意味着 CPU 的压缩几乎是“免费”的,因为它掩盖在了网络传输的时间里。

💰 2. 成本分析:不仅是快,还省钱

AWS S3 的计费主要包含存储费和流量费。

  • 存储成本:1GB 的日志存入 S3,如果未压缩,每月存储费是压缩后的 5-10 倍。虽然 S3 本身很便宜,但对于 PB 级日志归档,这笔费用惊人。
  • 传输加速成本:如果使用了 S3 Transfer Acceleration,费用是按流量计算的。压缩后上传意味着流量费用直接打一折。

🛡️ 3. 内存安全性

方案 B 是前端的大忌。试图将 1GB 文件读入 ArrayBuffer 进行整体 gzip 压缩,极其容易导致浏览器 Tab 崩溃(OOM)。本文方案将内存控制在 分片大小 * 并发数 (例如 5MB * 5 = 25MB) 的安全范围内,即使上传 100GB 文件也不会爆内存。

4.4 适用场景与局限性

✅ 强烈推荐场景:

  • 日志归档 / 数据备份:CSV, JSON, SQL Dump, Log 文件。压缩率极高,收益巨大。
  • 弱网环境:上传带宽受限时,压缩能显著减少等待时间。

❌ 不推荐场景:

  • 已经压缩的文件:MP4, JPG, ZIP, PNG。再次 Gzip 几乎无压缩效果,反而浪费 CPU。
  • 超低端设备:如果用户的设备是性能极差的老旧手机,CPU 压缩速度可能低于网络上传速度,反而成为瓶颈。建议在 SDK 增加 navigator.hardwareConcurrency 检测,自动降级。

5 结语

通过深入理解 HTTP、AWS S3 协议以及 Gzip 的二进制结构,我们打破了“压缩”与“分片”不可兼得的魔咒。这套系统目前已在我们内部的日志回放平台稳定运行,有效减少文件上传时长。

有时候,技术的突破口往往就藏在那些看似枯燥的 RFC 文档里。希望这篇“硬核”的实战总结,能给你带来一些启发。

丧心病狂!在浏览器全天候记录用户行为排障

作者 源心锁
2026年1月12日 22:40

1 前言

QA:“bug, 你把这个 bug 处理一下。”

我:“这个 bug 复现不了,你先复现一下。”

QA:“我也复现不了。”

(PS: 面面相觑脸 x 2)

众所周知,每个公司每个项目都可能存在偶现的缺陷,毋庸置疑,这为问题的定位和修复带来了严重的阻碍。

要解决这个问题,社区方案中常常依赖 datadog、sentry 等问题记录工具,但这些工具存在采样率限制或依赖错误做信息收集,很难做到 100% 的日志记录。

emoji_002.png

偶然间,我看到了 pagespy,它符合需求,但又不完全符合,好在调研下来,我们只要魔改一番,保留其基础的日志能力,修改其存储方式,就能得到一个能做全天候日志采集的工具。

那么,目标明确:

  • 实现全时段用户行为录制与回放
  • 最小化对用户体验的影响
  • 确保数据安全与隐私保护
  • 与现有系统(如 intercom )无缝集成

2 SDK 设计

目前 pagespy 设计目标和我们预期并不一致,并不能开箱即用。pagespy 的方案不满足我们需求的点在于:

  1. 没有持久化能力,内存存储,单次录制不对数据做导出则数据清空。
  2. pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

经过对 pagespy 的源码解析以及文档阅读,整理出来其中分支的 OSpy(离线版 pagespy 的数据走向如下):

image.png

我们可以通过 inject 的形式,把这两个能力代理到我们的逻辑中。

image.png

样式上,则通过插入一段 style 强制将 dom 样式隐藏。

  document.head.insertAdjacentHTML(
    'beforeend',
    `<style>
    #o-spy {
      display: none;
    }
    </style>`,
  );

至此,我们已经基本脱离了 pagespy 的数据 in & out 逻辑,所有数据都由我们来处理,包括数据存储也需要我们重新设计。

2.1 日志存储方案

✅ 确定日志存储方案。需要注意避免大量日志将用户的电脑卡死。

✅ pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

✅ pagespy 为了防止爆内存引入了时间上限等因素,会时不时清除数据(rrweb 存在非常重要的首屏帧,缺少该帧后续都无法渲染成功),这会导致以单个浏览器标签作为切片的设计逻辑被迫中断,会对我们的逻辑带来负面影响。

为了实现全时段存储的目标,经评估除了 indexDB 之外没有其他很好的存储方案可以满足我们的大容量需求。在此,决定引入 dexie 进行数据库管理。

import type { EntityTable } from 'dexie';
import Dexie from 'dexie';

const DB_NAME = 'SpyDataHarborDB';

export class DataHarborClient {
  db: DBType;
  constructor() {
    this.db = new Dexie(DB_NAME) as DBType;
    this.db.version(1).stores({
      logs: '++id,[tabId+timestamp],tabId, timestamp',
      metas: '++id,tabId,startTime,endTime',
    });
  }
}

export const { db } = new DataHarborClient();

我们将日志以浏览器标签页为维度进行拆分,引入了 tabId 的概念。并设计了两个表,一个用于存储日志,一个用于存在 tab 的基本信息。

type DBType = Dexie & {
  logs: EntityTable<{
    id?: number;
    tabId: string;
    timestamp: number;
    data: string;
  }>;
  metas: EntityTable<{
    id?: number;
    tabId: string;
    size: number;
    startTime: number;
    endTime: number;
  }>;
};

这意味着,从 pagespy 得到的数据只需要直接入库,我们在每次入库后做一次日志清理,即可实现一个基本的存储系统。

  async addLog(data: CacheMessageItem) {
    const now = new Date();

    const dataStr = JSON.stringify(data);

    await db.logs.add({
      tabId: this.tabId,
      timestamp: now.getTime(),
      data: dataStr,
    });

    await db.transaction('rw', ['metas'], async (tx) => {
      const meta = await tx.metas.get({
        tabId: this.tabId,
      });
      if (meta) {
        meta.size += dataStr.length;
        meta.endTime = now.getTime();
        await db.metas.put(meta);
        return meta;
      } else {
        await db.metas.add({
          tabId: this.tabId,
          size: dataStr.length,
          startTime: now.getTime(),
          endTime: now.getTime(),
        });
      }
    });
  }

在我们完成日志入库之后,额外需要考虑的是持续直接入库的性能损耗。 经测试,通过 worker 进行操作与直接在主线程进行操作,对主线程的耗时影响对比表格如下(基于 performance.now()):

操作方式 峰值 最低值 中位数 平均值
worker + insert 5.3 ms 0ms 0.1ms 0.31ms
直接 insert 149.5 ms 0.4ms 3.6ms 55.29ms

所以最终决策将数据库操作转移到 worker 中实现——但这又反应了一点问题,目前 pagespy 的入库数据是序列化后的字符串,并不能很好地享受主线程和 worker 线程之间通过 transfer 传输的性能优势。

2.2 安全和合规问题

目前可知,我们的方案先天就存在较严重的合规问题 🙋,这体现在:

  1. pagespy 会保存一些隐秘的 storage、cookie 数据到 indexedDB 中,有一定安全风险。
  2. pagespy 基于 rrweb ⏺️ 录制页面,用户在电脑上的行为和信息可能被记录。(如 PII 数据)

第一个问题,我们可以考虑直接基于 Pagespy 来记录,其实际上提供了 API 允许我们自行决定要抛弃哪些信息。

使用时,类似于:

    network: (data) => {
      if (['fetch', 'xhr'].includes(data.requestType)) {
        data.responseHeader?.forEach((item) => {
          if (item[0] === 'set-cookie') {
            item[1] = obfuscate(item[1]);
          }
        });
        return true;
      }
      return true;
    },

image.png 第二个问题,我们应考虑基于 rrweb 的默认隐私策略来做处理,rrweb 在 sentry、posthog 中都有使用,都是基于默认屏蔽规则来允许,所以我们使用默认屏蔽规则,其他库的隐私合规也相当于一起做了。

所以,我们需遵循以下规则(rrweb 默认屏蔽规则)修改 Web 端,而不是 SDK:

  • 具有该类名的元素.rr-block不会被记录。它将被替换为具有相同尺寸的占位符。
  • 具有该类名的元素.rr-ignore将不会记录其输入事件。
  • 具有类名的元素.rr-mask及其子元素的所有文本都将被屏蔽。和 block 的区别是,只会屏蔽文本,不会直接替换 dom 结构(也就是背景颜色之类的会保留)
  • input[type="password"]将被默认屏蔽。

根据元素是否包含“用户输入能力”,分为 3 种处理方式:

  • 1️⃣ 包含输入能力(如 input, textarea,canvas 可编辑区域)

    • 目的:既屏蔽用户的输入行为,也屏蔽输入内容
    • 处理方式:添加 rr-ignorerr-block 两个类
    • 效果:

image.png

  • 2️⃣ 不包含输入能力(如纯展示类的文本)

    • 目的:保留结构,隐藏文本内容,避免泄露隐私
    • 处理方式:添加 rr-mask 类,将文本进行混淆显示
    • 效果:

image.png

  • 3️⃣ 图片、只读 canvas 包含隐私信息(如签名)

    • 目的:隐藏内容
    • 处理方式:添加 rr-block

2.3 日志获取和处理

在上述流程中,我们设计了基于浏览器标签页的存储系统,但由于 rrweb 和 ospy 的设计,我们仍有两个问题待解决:

  1. ospy 中的 meta 帧只在 download 时获取,并需要是 logs 的最后一帧。
  2. rrweb 存在特殊限制,即必须存在首 2 帧,否则提取出来的日志无法显示页面。

这两个问题我们需要特殊处理,针对 meta 帧的情况,首先要知道,meta 帧包含了客户端信息等数据:

image.png

image.png

这部分信息虽然相比之下不是那么重要,但在特定场景中非常有用,nice to have。在此前提下,由于 ospy 未提供对外函数,我们需要自行添加该帧。目前,meta 帧会在 spy 初始化时自动插入,然后在读取时排序到尾部。

// 这个其实是 spy 的源码
export const minifyData = (d: any) => {
  return strFromU8(zlibSync(strToU8(JSON.stringify(d)), { level: 9 }), true);
};

export const getMetaLog = () => {
  return minifyData({
    ua: navigator.userAgent,
    title: document.title,
    url: window.location.href,
    startTime: 0,
    endTime: 0,
    remark: '',
  });
};

第二个问题相比之下更加致命,但解决起来又异常简单。rrweb 的机制决定了我们在导出的时候必定要查询出第一二帧,我们在获取日志时需要特殊处理:

  1. 获取用户指定日期范围内的日志的 tabId。
  2. 基于 tabId 筛查出所有日志,筛查出 < endTime 的所有日志。
async getTabLogs({ tabId, end }: { tabId: string; end: number }) {
    // 日志获取逻辑
}

(如你所见,获取日志阶段 start 直接 gank 没了)

此外,由于持续存储特性,读取日志时会面临数据量过大的问题。例如,8 分钟连续操作导出的日志约 17MB,一小时约 120MB。按照平均每小时录制数据量估算,静态浏览约 2 - 5MB,普通交互约 50MB,高频交互约 100MB。以单个用户每日使用 8 小时计算,平均用户约 400MB / 天,重度用户约 800MB / 天。基于 14 天保留策略,单用户最大存储空间约为 12GB。

这意味着如果用户选择的时间范围较大,传统读取流程可能读取 10GB+ 日志到内存,这显然会导致浏览器内存溢出。

为避免读取大量日志导致浏览器内存溢出,我们采用分片式读取。核心思想是将指定 tab 的日志数据按需 “分片提取”,通过回调逐步传输给调用方,确保高效、稳定地处理大体积日志的读取与传输:

  1. 读取元信息 (meta):

    • 通过 tabIddb.metas 获取对应日志的元信息(如日志总大小)。
  2. 判断是否需要分片:

    • 如果日志总大小小于阈值 MIN_SLICE_CHUNK_SIZE一次性读取所有日志,拼接成完整 JSON,再调用 callback 发送。
  3. 大文件分片处理逻辑:

    • 根据日志总大小计算合适的 chunkSize,从而决定分片数量 chunkCount
    • 每次读取一部分日志数据(受限于计算出的 limit),拼接为 JSON 片段,通过 callback 逐步传出。
    • 每片都使用 Comlink.transfer() 进行内存零拷贝传输,提高性能。
  4. 合并与补充 meta 信息:

    • 如果日志数据中有 meta 类型数据(携带一些压缩信息),在最后一片中进行处理与拼接,保持语义完整。
  5. 进度追踪与标记:

    • 每一片传输都附带 progresspartNumber,便于前端追踪处理进度。
  async getTabLogs(
    {
      tabId,
      end,
    }: {
      tabId: string;
      end: number;
    },
    callback: (log: { content: Uint8Array; progress: number; partNumber: number }) => void | Promise<void>,
  ) {
  
    ...

    const totalSize = meta.size + BUFFER_SIZE;
    // 根据 totalSize、MAX_SLICE_CHUNK、MIN_SLICE_CHUNK_SIZE 计算出最佳分片大小
    const chunkSize = Math.max(Math.min(totalSize / MAX_SLICE_CHUNK, MIN_SLICE_CHUNK_SIZE), MIN_SLICE_CHUNK_SIZE);

    const chunkCount = Math.ceil(totalSize / chunkSize);

    let offset = 0;
    const count = await db.logs
      .where('tabId')
      .equals(tabId)
      .and((log) => log.timestamp <= end)
      .count();

    const limit = Math.max(1, Math.ceil(count / chunkCount / 3));

    let metaData: string | null = null;

    let startTime = 0;
    let endTime = 0;

    let preLogStr = '';
    let progressContentSize = 0;
    let partNumber = 1;
    while (offset <= count) {
      try {
        const logs = await db.logs
          .where('tabId')
          .equals(tabId)
          .and((log) => log.timestamp <= end)
          .offset(offset)
          .limit(limit)
          .toArray();

        let baseStr = preLogStr;
        if (offset > 0) {
          baseStr += ',';
        } else if (offset === 0) {
          baseStr += '[';
        }

        endTime = logs?.[logs.length - 1]?.timestamp ?? endTime;
        if (offset === 0) {
          startTime = logs?.[0].timestamp ?? 0;
        }

        offset += logs.length;

        const logData = logs.map((log) => log.data).filter((log) => log !== '"PERIOD_DIVIDE_IDENTIFIER"');
        ...

        const logsStr = logData.join(',');
        baseStr += logsStr;

        if (offset === count) {
          if (!metaData) {
            await callback({
              content: transfer(baseStr + ']'),
              progress: 1,
              partNumber,
            });
          } else {
            const metaJson = JSON.parse(metaData);
            const parseMetaData = parseMinifiedData(metaJson.data);
            const metaMinifyData = minifyData({
              ...parseMetaData,
              startTime,
              endTime,
            });
            const metaStr = JSON.stringify({
              type: 'meta',
              timestamp: endTime,
              data: metaMinifyData,
            });
            await callback({
              content: transfer(baseStr + ',' + metaStr + ']'),
              progress: 1,
              partNumber,
            });
          }
          break;
        }

        progressContentSize += baseStr.length;
        const progress = Math.min(0.99, progressContentSize / totalSize);

        // 如果 size < minSize,那么就继续获取
        if (baseStr.length < MIN_SLICE_CHUNK_SIZE) {
          preLogStr = baseStr;
          continue;
        }

        preLogStr = '';
        await callback({
          content: transfer(baseStr),
          progress,
          partNumber,
        });
        partNumber++;
      } catch (error) {
        console.log(error);
        break;
      }
    }
  }

3 工作流设计

3.1 👼 基础工作流

我们公司采用 intercom 和外部客户沟通,用户可以在网页右下角的 intercom iframe 中和客服沟通。

image.png

所以,如果有办法将整个日志流程合并到目前的 intercom 流程中,不仅贴合目前的业务情况,而且不改变用户习惯。

通过调研,可以确定以下方案:

  1. CS 侧配置默认时间范围,需要 POST /configure-card 进行表单填写,填写后表单会在下一步被携带到 payload 中。
  2. CS 侧在发送时,会 POST /initialize接口(由自有后端提供),接口需返回 canvas json 数据。如:
{
  canvas: {
    content: {
      components: [
        {
          type: "text",
          text: "*Log Submission*",
          style: "header",
        },
        {
          type: "button",
          label: "Select logs",
          style: "primary",
          id: "submit_button",
          action: {
            type: "sheet",
            url: "xxxxxx",
          },
        },
      ],
    },
  },
}
  1. 发送后,用户点击 sheet 按钮可以跳转到前端,但需注意,该请求为 POST 请求。
  2. 用户填写完表单,提交时可以直接请求后端接口,也可以由 intercom 服务端向后端发起 POST 请求。
  3. 如期望在提交后修改消息状态,则必须在上一步执行【由 intercom 服务端向后端发起 POST 请求】(推荐,最完整的 flow),此时后端需返回 canvas json,后端同步触发逻辑,添加 note 到 intercom 页面,方便 CS 创建 jira 单时携带复现链接

Editor _ Mermaid Chart-2025-05-09-062511.png

3.2 ⚠️ 增强工作流

在我们上述 flow 中,需要获取用户授权,由用户操作触发下载和上传日志的过程,但实际上有比较刑的方案。

具体 flow 如图:

image.png

该方案的整体优势是:

  1. 无需 CS 介入,无需修改 CS 流程。
  2. 用户对日志上传感知力度小

换句话说,隐私合规风险较大。

4 工作流技术要点

4.1 😈 iframe 实现

Iframe 指的是 【日志上传 iframe】,对应这一步骤:

image.png

由于 intercom 将基于 POST 请求去调用服务希望得到 html 的限制,这里存在两个问题:

  1. Intercom 使用 POST 请求,则我们的服务需要支持 POST 请求返回 html,目前是不支持的,所以需要解决方案。
  2. 由于我们的 iframe 网页要读取日志,那么 iframe 地址必须和 Web 端同源,但生产的 API 地址和 Web 端不同源。

基本方向上,我们可以通过反向代理的方式实现:

image.png

iframe 的同源限制比预想的还要麻烦一些,由于 intercom 的接入方式是 iframe 嵌套,类似于:A(<https://samesite.com/>)->B(<https://xxxx.com/>)->A(<https://samesite.com>)

这个过程会导致两个跨域限制:

  1. Cookie 的跨域限制,具体表现为用于登录态的 Cookie 由于未显式设置 Samesite: None ,无法被携带进内层网页,进而丢失登录态。
  2. indexedDB 的跨域限制,由于中间多了一层外域,浏览器限制了最里边的网页读取 indexedDB,具体表现为读取到的数据为🈳。

Cookie 的跨域限制通过显式设置 Samesite 可以解决,但进一步地,为了确保安全性,我们需要给网页其他路径添加X-Frame-Options SAMEORIGIN; 防止外域嵌套我们的其他网页。

后者卡了一阵子,最后的解决思路是通过 postMessage 通信的方式变相读取——反正能读取到就行。

  window.top.postMessage(
    {
      type: 'uploadLogs',
      id: topUUID,
      params: {
        start,
        end,
      },
    },
    '*',
  );

(有趣的是,排查过程中发现了 chrome devtools 的缺陷,devtools 里的 document 都指不到最外层,但是实际上 window.top 和 window.parent.parent.parent 都是最外层,具体不细说了)

4.2 🥹 日志安全与上传

日志的格式是 JSON 格式,将其拖拽到 ospy 中即可复原用户浏览器操作记录,一旦泄漏会有极高的安全风险。在此,提出加密方案用于解决该问题。

思路其实很简单:在文件上传前对文件内容进行 AES 加密,对 AES 密钥做 RSA 非对称加密,通过公钥加密,然后将加密后的密钥附加到文件尾。

image.png

其实还可以进一步,我们在写入日志的时候就加密,但这样读取的时候压力会比较大,因为日志是一段一段的,或许我们还需要定制分隔符。

5 总结

好,那么理所当然的,我们应该不会遇到其他卡点卡,方案落地应该是没问题了。但——

Leader: “有个问题,我们没有分片上传”

我: ”Woc? 又要自己写?”

欲知后事如何,且听下回分解。

GDAL 实现投影转换

作者 GIS之路
2026年1月12日 22:40

^ 关注我,带你一起学GIS ^

前言

在GIS开发中,对于数据源首先要确定的第一件事就是坐标系统,这就涉及到坐标转换处理问题。而经常遇到的便是Shapefile数据的投影转换,如何高效、准确的将源数据坐标系转换到目标坐标系是我们需要研究解决的问题。

在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDAL 实现投影转换

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 数据准备

如下是本文选取的全国省级行政区Shp数据(数据坐标系为4326):

部分景点数据:

3. 投影形式

参考网站:https://epsg.io/4490

坐标定义信息具有多种格式,可以选择符合通用标准的OGC格式,下面以EPSG编码4490,即CGCS2000坐标系为例进行展示。

  • OGC WKT
GEOGCS["China Geodetic Coordinate System 2000",    DATUM["China_2000",        SPHEROID["CGCS2000",6378137,298.257222101,            AUTHORITY["EPSG","1024"]],
        AUTHORITY["EPSG","1043"]],
    PRIMEM["Greenwich",0,        AUTHORITY["EPSG","8901"]],
    UNIT["degree",0.0174532925199433,        AUTHORITY["EPSG","9122"]],
    AUTHORITY["EPSG","4490"]]
  • ESRI WKT
GEOGCS["GCS_China_Geodetic_Coordinate_System_2000",    DATUM["D_China_2000",        SPHEROID["CGCS2000",6378137.0,298.257222101]],
    PRIMEM["Greenwich",0.0],
    UNIT["Degree",0.0174532925199433]]
  • PROJ.4
+proj=longlat +ellps=GRS80 +no_defs +type=crs
  • Proj4js
proj4.defs("EPSG:4490","+proj=longlat +ellps=GRS80 +no_defs +type=crs");
  • GeoServer
4490=GEOGCS["China Geodetic Coordinate System 2000",DATUM["China_2000",SPHEROID["CGCS2000",6378137,298.257222101,AUTHORITY["EPSG","1024"]],AUTHORITY["EPSG","1043"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4490"]]

4. 获取图层坐标系统

使用GetSpatialRef方法可以获取图层坐标参考,若图层缺少投影信息,则返回None

# 获取图层及坐标系统
sourceLayer = shpDs.GetLayer(0)
geomType = sourceLayer.GetGeomType()
sourceSrs = sourceLayer.GetSpatialRef()

也可以使用Geometry对象方法GetSpatialReference获取。

# 获取坐标系统
geom = feature.GetGeometryRef()
sourceSrs = geom.GetSpatialReference()

5. 图层投影转换

5.1. 导入依赖

Shp作为一种矢量数据格式,可以使用矢量库OGR进行处理,用于打开数据源和获取图层。还需要引入osr模块用于坐标定义以及os模块用于判断文件数据路径。

from osgeo import ogr,osr
import os

5.2. 获取数据

定义一个方法LayerProject用于图层投影转换,该方法接收两个参数,一个源数据文件路径sourcePath,一个投影转换文件数据路径projectPath

"""
说明:GDAL 投影转换
参数:
    -sourcePath:源文件Shp数据路径
    -projectPath:投影转换图层数据路径
"""
def LayerProject(sourcePath,projectPath):

按照老规矩添加数据驱动,使用checkFilePath方法检查文件路径是否存在,使用checkDriver判断数据驱动是否正常,之后获取图层及坐标系统。

# 检查文件是否存在
checkFilePath(sourcePath)
checkFilePath(projectPath)

# 获取数据驱动
shpDriver = ogr.GetDriverByName("ESRI Shapefile")

# 检查数据驱动是否正常
checkDriver(shpDriver)

# 获取数据源
shpDs = shpDriver.Open(sourcePath)

# 获取源图层及坐标信息
sourceLayer = shpDs.GetLayer(0)
geomType = sourceLayer.GetGeomType()
sourceSrs = sourceLayer.GetSpatialRef()

# 获取源数据结构
featureDefn = sourceLayer.GetLayerDefn()
fieldCount = featureDefn.GetFieldCount()

文件和数据驱动检查方法定义如下。

"""
说明:检查文件路径是否正常
参数:
    -filePath:文件数据路径
"""
def checkFilePath(filePath):
    if os.path.exists(filePath):
        print(f"{filePath} 文件数据路径存在")
    else:
        print(f"{filePath} 文件数据路径不存在,请检查!")

"""
说明:检查数据驱动是否正常
"""
def checkDriver(driver):
    if driver is None:
        print("数据驱动不可用")
        return False

5.3. 创建投影

使用osr.SpatialReference()创建空间参考,然后将投影信息导入到坐标系统。可以使用如下多种方法:

- ImportFromEPSG(SpatialReference self, int arg)
- ImportFromESRI(SpatialReference self, char ** ppszInput)
- ImportFromProj4(SpatialReference self, char * ppszInput)
- ImportFromUSGS(SpatialReference self, long proj_code, long zone=0, double [15] argin=0, long datum_code=0)
- ImportFromWkt(SpatialReference self, char ** ppszInput)

其中个人觉得最简单方便的还是ImportFromEPSG方法。

5.4. 创建投影图层

使用CreateDataSource方法创建投影数据源和图层,并根据源数据复制要素。创建图层方法CreateLayer第二个参数用于指定数据坐标系,坐标系统可以使用ImportFromEPSG方法定义,只需传入一个EPSG编码。

"""
投影转换操作
"""
# 创建投影数据源
prjDs = shpDriver.CreateDataSource(projectPath)

# 投影坐标对象
srs = osr.SpatialReference()
srs.ImportFromEPSG(4522)

# 创建投影图层
prjLayer = prjDs.CreateLayer("prj_layer",srs,geomType)

# 添加属性结构
for i in range(fieldCount):
    fieldDefn = featureDefn.GetFieldDefn(i)
    prjLayer.CreateField(fieldDefn)

# 写入要素
for feature in sourceLayer:    
    prjLayer.CreateFeature(feature)    

5.5. 导出投影

可以使用以下方法导出投影信息。

- ExportToPrettyWkt(SpatialReference self, int simplify=0)
- ExportToProj4(SpatialReference self)
- ExportToUSGS(SpatialReference self)
- ExportToWkt(SpatialReference self, char ** options=None)
- ExportToXML(SpatialReference self, char const * dialect="")

5.6. 几何投影

使用CoordinateTransformation方法定义转换信息,第一个参数为源数据坐标系,第二个参数为目标投影坐标系,然后调用Geometry对象方法Transform进行坐标转换。

# 源数据坐标参考
sourceSrs = osr.SpatialReference()
sourceSrs.ImportFromEPSG(4326)
print("源数据坐标系名称:",sourceSrs.GetName())

# 添加几何对象
geom = feature.GetGeometryRef()
print("之前的坐标:",geom.GetX(),geom.GetY())

# 几何投影
targetSrs = osr.SpatialReference()
targetSrs.ImportFromEPSG(4522)
print("目标数据坐标系名称:",targetSrs.GetName())

# 坐标转换
coordsTransform = osr.CoordinateTransformation(sourceSrs,targetSrs)
geom.Transform(coordsTransform)

print("之后的坐标:",geom.GetX(),geom.GetY())

坐标系输出信息显示如下。

5.7. 创建投影文件

对于缺少.prj文件的数据可以使用空间参考方法MorphToESRI()修改坐标信息,使用ExportToWkt()方法导出坐标参考后将其写入投影文件中。

创建一个方法CreatePrjFile用于创建投影文件。

"""
说明:创建.prj文件
参数:
    -shpPath: Shp文件路径
"""
def CreatePrjFile(shpPath):
    prjSrs = osr.SpatialReference()
    prjSrs.ImportFromEPSG(4522)

    prjSrs.MorphToESRI()

    fileName = os.path.splitext(shpPath)[0]
    prjFile = fileName + ".prj"

    with open(prjFile,"w") as f:
        f.write(prjSrs.ExportToWkt())
        print(f"成功创建投影文件: {prjFile}")

6. 注意事项

windows开发环境中同时安装GDALPostGIS,其中投影库PROJ的环境变量指向PostGIS的安装路径,在运行GDAL程序时,涉及到要素、几何与投影操作时会导致异常。具体意思为GDAL不支持PostGIS插件中的投影库版本,需要更换投影库或者升级版本。

RuntimeError: PROJ: proj_identify: D:Program FilesPostgreSQL13sharecontribpostgis-3.5projproj.db contains DATABASE.LAYOUT.VERSION.MINOR = 2 whereas a number >= 5 is expected. It comes from another PROJ installation.

解决办法为修改PROJ的环境变量到GDAL支持的版本或者在GDAL程序开头添加以下代码:

os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Libsite-packages\osgeo\data\proj'

图片效果


OpenLayers示例数据下载,请在公众号后台回复:ol数据

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现矢量合并

GDAL 图层合并操作

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

日本欲打造“本土版”星链系统

GDAL 实现矢量裁剪

GDAL 实现空间分析

GDAL 空间关系解析

GDAL 实现数据空间查询

GDAL 实现数据属性查询

吉林一号国内首张高分辨率彩色夜光卫星影像发布

GDAL 实现创建几何对象

GDAL 数据类型大全

从“无”到“有”:手动实现一个 3D 渲染循环全过程

作者 烛阴
2026年1月12日 22:34

一、 Three.js 的基本构成

  1. Scene :场景。
  2. Camera :摄像机。
  3. Renderer :渲染器,。

二、具体实现

1. 初始化场景 (The Scene)

场景是一切物体的容器。

const scene = new THREE.Scene();
// 💡 INTP 视角:把它想象成一个坐标系原点为 (0,0,0) 的无限空腔。

2. 配置相机 (The Camera)

最常用的是 透视相机(PerspectiveCamera) ,它模拟了人眼的“近大远小”效果。

const camera = new THREE.PerspectiveCamera(
    75, // 视角 (Field of View)
    window.innerWidth / window.innerHeight, // 宽高比
    0.1, // 近剪裁面
    1000 // 远剪裁面
);
camera.position.z = 5; // 将相机后退 5 个单位,否则会在物体中心

3. 加入材质 (The Mesh)

一个物体由两部分组成:

  • 几何体(Geometry)
  • 材质(Material)
const geometry = new THREE.BoxGeometry(1, 1, 1); // 形状:立方体
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); // 材质:基础绿色
const cube = new THREE.Mesh(geometry, material); // 组合成网格
scene.add(cube); // 必须添加到场景中

4. 渲染与循环 (The Render Loop)

这是最关键的一步。我们需要每一秒刷新 60 次屏幕,才能看到动画。

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

function animate() {
    requestAnimationFrame(animate); // 核心:请求下一帧
    
    // 让物体动起来,增加一点生命力
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    renderer.render(scene, camera); // 真正绘制的一行
}
animate();

📂 核心代码与完整示例:   my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

JavaScript 中的 sort 排序问题

2026年1月12日 22:12

在 JavaScript 中,以下两种写法是等价的:

写法一:

let fruits = ["banana", "apple", "cherry", "Apple"]  
fruits.sort()  
console.log(fruits) // ["Apple", "apple", "banana", "cherry"]  

写法二:

let fruits = ["banana", "apple", "cherry", "Apple"]
fruits.sort((a, b) => {
  return a > b ? 1 : -1
})
console.log(fruits)

sort 排序基本原理

因为 sort 函数默认是字符的 ASCII 码升序排列的。

比如:

'A'.charCode() // 65
'a'.charCode() // 97
'b'.charCode() // 98

因此如果是10和2排序的话,其实是'10'和'2'排序,'1'.charCode() 为 49,'2'.charCode() 为 50,导致出现 2 比 10 大,出现在 10 后面。

比如下面的代码:

let nums = [3, 10, 2]
nums.sort()
console.log('nums') // [10, 2, 3]

基础

那么问题来了,如果我想实现以下数组按照 appName 字典顺序降序排列怎么办?

let apps = [
  ['chrome', { cpu: 30, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['firefox', { cpu: 80, memory: 90 }],
  ['safari', { cpu: 10, memory: 50 }],
]

注:chrome、edge 这些是 appName

欢迎在评论区解答。

进阶

再扩展一下,给定一个数组 sortRules,这个数组只能取 cpu 和 memory 两个值,可能是 0、1、2 个。

比如 sortRules 可能是:[]['cpu']['memory', 'cpu']['cpu', 'memory'] 等。

请实现先按照给定的 sortRules 的值依次升序排序,再按照 appName 降序排序。

比如 sortRules 是 ['cpu'],则排序结果是:

let apps = [
  ['safari', { cpu: 10, memory: 50 }],
  ['chrome', { cpu: 30, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['firefox', { cpu: 80, memory: 90 }],
]

比如 sortRules 是 ['cpu', 'memory'],则排序结果是:

let apps = [
  ['safari', { cpu: 10, memory: 50 }],
  ['edge', { cpu: 30, memory: 20 }],
  ['chrome', { cpu: 30, memory: 50 }],
  ['firefox', { cpu: 80, memory: 90 }],
]

欢迎在评论区回复~

Service Worker 缓存请求:前端性能优化的进阶利器

作者 eason_fan
2026年1月12日 21:51

Service Worker 缓存请求:前端性能优化的进阶利器

在前端性能优化的赛道上,缓存始终是绕不开的核心话题。从基础的 HTTP 缓存到 localStorage 本地存储,每一种缓存方案都在特定场景下发挥着价值。而今天要重点聊的Service Worker 缓存,则是突破传统缓存局限、实现进阶性能优化的关键手段——它不仅能加速资源加载,更能解锁离线访问等高级能力,让前端应用的体验实现质的飞跃。

一、为什么需要 Service Worker 缓存?传统缓存的痛点

在聊 Service Worker 之前,我们先回顾下前端最常用的 HTTP 原生缓存(强缓存 + 协商缓存)。它的优势很明显:无需前端开发成本,由浏览器自动遵循 HTTP 响应头(Cache-Control、Expires、Etag 等)执行,能有效减少静态资源的重复请求。但在实际开发中,它的局限性也愈发突出:

  • 控制权缺失:缓存规则完全由后端通过响应头控制,前端无法主动决定缓存哪些资源、何时更新或删除缓存;
  • 缓存范围受限:默认不缓存 POST 请求、带鉴权头(如 Authorization)的请求、跨域请求,而这些恰恰是接口请求的常见场景;
  • 策略单一:只有「强缓存 → 协商缓存」这一种固定逻辑,无法适配复杂的业务场景(如“先显示缓存再后台更新”);
  • 无离线能力:一旦断网,未被缓存的资源(尤其是接口数据)会直接加载失败,导致页面白屏或功能失效;
  • 缓存稳定性差:缓存存储在浏览器的内存/磁盘中,可能被浏览器在内存不足时自动清理,开发者无法干预。

而 Service Worker 缓存的出现,正是为了解决这些痛点——它让前端开发者完全掌控网络请求的缓存逻辑,实现更灵活、更强大的性能优化方案。

二、Service Worker 缓存核心认知:它是什么?怎么工作?

在深入缓存实现前,我们先理清 Service Worker 的核心特性,这是理解其缓存能力的基础:

1. 什么是 Service Worker?

Service Worker(简称 SW)是浏览器在后台独立运行的「无界面 JS 线程」,独立于当前页面,具备以下关键特性:

  • 基于 HTTPS 环境(本地开发 localhost 例外),保障安全性;
  • 能拦截当前域名下的所有网络请求(fetch/ajax、静态资源、接口等);
  • 拥有专属的持久化缓存仓库 Cache Storage,不受页面生命周期影响;
  • 页面关闭后仍可运行,支持离线推送、后台同步等高级能力。

2. Service Worker 缓存的核心工作流

SW 缓存的本质是「拦截请求 + 自定义处理」,核心流程如下:

  1. 页面加载时,注册并激活 Service Worker;
  2. 当页面发起网络请求时,请求被 SW 拦截;
  3. 开发者通过代码定义缓存策略(如“先查缓存再走网络”“先走网络再补缓存”等);
  4. SW 执行策略:从 Cache Storage 读取缓存,或发起真实网络请求;
  5. 将结果(缓存数据/网络数据)返回给页面,并根据策略更新缓存。

整个过程完全由前端代码控制,这也是 SW 缓存相较于 HTTP 缓存的核心优势。

三、Service Worker 缓存的核心价值:性能优化的关键场景

SW 缓存的价值不仅是“加速加载”,更在于解决传统缓存无法覆盖的优化场景,具体可分为以下 4 类:

1. 突破缓存限制:缓存传统方案搞不定的请求

这是 SW 缓存最直观的优势。对于 HTTP 缓存默认不支持的请求类型,SW 都能轻松搞定:

  • POST 接口缓存:HTTP 缓存默认不缓存 POST 请求(认为其是“数据提交”操作),但 SW 可拦截 POST 请求,将「请求体 + 响应数据」一起存入 Cache Storage
  • 带鉴权的请求缓存:含 Authorization、Token 等请求头的接口,HTTP 缓存会直接跳过,SW 可正常缓存;
  • 跨域请求缓存:HTTP 缓存对跨域资源的缓存支持有限,SW 可通过 CORS 正常拦截并缓存跨域接口/资源;
  • 动态参数请求缓存:如 /api/list?_t=1699999999 这类带随机参数的请求,HTTP 缓存会认为是不同请求而重复加载,SW 可自定义规则忽略无效参数,合并缓存。

2. 灵活缓存策略:适配不同业务场景的性能优化

HTTP 缓存只有“强缓存 → 协商缓存”一种固定逻辑,而 SW 支持多种经典缓存策略,可根据资源类型精准适配:

策略 1:Cache First(缓存优先)—— 适用于不常更新的静态资源

核心逻辑:优先从缓存读取资源,无缓存时才走网络,拿到网络数据后更新缓存。 适用场景:字体文件、图标库、第三方 SDK、不常更新的图片等。 优势:加载速度最快,减少网络请求次数。

// 缓存优先策略示例
self.addEventListener('fetch', (event) => {
  // 对静态资源应用缓存优先
  if (event.request.url.match(/.(png|jpg|font|js)$/)) {
    event.respondWith(
      caches.match(event.request)
        .then(cacheRes => {
          // 有缓存直接返回,无缓存则发起网络请求
          return cacheRes || fetch(event.request).then(networkRes => {
            // 更新缓存
            caches.open('static-cache-v1').then(cache => {
              cache.put(event.request, networkRes.clone());
            });
            return networkRes;
          });
        })
    );
  }
});
策略 2:Network First(网络优先)—— 适用于动态接口数据

核心逻辑:优先发起网络请求,拿到最新数据后更新缓存;若网络失败(断网/超时),则返回缓存数据兜底。 适用场景:列表接口、详情接口等需要实时更新的数据。 优势:保证数据新鲜度,同时实现断网降级,避免页面白屏。

策略 3:Stale-While-Revalidate(缓存兜底 + 后台更新)—— 性能与新鲜度兼顾

这是前端性能优化的「黄金策略」,核心逻辑: 1. 页面请求时,立即返回缓存数据(用户无感知等待); 2. 同时在后台发起网络请求,获取最新数据; 3. 用最新数据更新缓存,供下次请求使用。 适用场景:首页核心数据、个人中心信息等对加载速度和新鲜度都有要求的场景。 优势:完美平衡“加载速度”和“数据时效性”,用户体验拉满。

// 缓存兜底更新策略示例
self.addEventListener('fetch', (event) => {
  // 对核心接口应用 stale-while-revalidate
  if (event.request.url.includes('/api/core/')) {
    event.respondWith(
      caches.match(event.request).then(cacheRes => {
        // 并行发起网络请求
        const networkPromise = fetch(event.request).then(networkRes => {
          // 更新缓存
          caches.open('api-cache-v1').then(cache => {
            cache.put(event.request, networkRes.clone());
          });
          return networkRes;
        });
        // 有缓存先返回缓存,无缓存则等网络请求
        return cacheRes || networkPromise;
      })
    );
  }
});
策略 4:Cache Only(仅缓存)—— 适用于离线资源

核心逻辑:只从缓存读取资源,不发起任何网络请求。 适用场景:离线页面的静态资源(如离线提示图、离线文案)。 优势:确保断网时页面仍能正常展示基础内容。

3. 离线可用:从“加速”到“可用”的体验升级

这是 SW 缓存最具标志性的能力。HTTP 缓存只能“加速加载”,而 SW 缓存能让应用在断网时依然可用:

  • 缓存首页骨架屏、核心样式、基础 JS,断网时用户打开页面仍能看到完整的基础结构;
  • 缓存历史接口数据,断网时用户可查看之前加载过的列表、详情等内容;
  • 配合 Workbox 等工具,可快速实现 PWA(渐进式 Web 应用)的离线访问能力。

4. 精细化缓存管理:避免缓存污染与冗余

HTTP 缓存的最大痛点之一是“无法手动管理”,而 SW 可通过 Cache API 实现对缓存的完全掌控:

  • 缓存版本控制:给缓存命名时添加版本号(如 static-cache-v1),页面迭代时,通过代码删除旧版本缓存,避免缓存污染;
  • 精准清理缓存:可根据资源路径、请求类型手动删除指定缓存(如删除某个过期的接口缓存);
  • 缓存容量控制:定期清理长期未使用的缓存,避免占用过多浏览器空间。
// 清理旧版本缓存示例
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          // 删除非当前版本的缓存
          if (cacheName !== 'static-cache-v1' && cacheName !== 'api-cache-v1') {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

四、最佳实践:Service Worker 缓存与 HTTP 缓存的协同优化

很多开发者会误以为“用了 SW 就不用 HTTP 缓存了”,但实际上,二者是「分层缓存」的关系,最佳实践是「HTTP 缓存兜底 + SW 缓存增强」,原因如下:

SW 是在 HTTP 缓存之后拦截请求的:如果一个请求命中了 HTTP 强缓存,请求根本不会走到 SW,直接从浏览器内存/磁盘返回,效率最高;只有当 HTTP 缓存失效(强缓存过期、协商缓存命中 304)时,请求才会被 SW 拦截,再执行 SW 的缓存策略。

具体协同方案

  1. HTTP 缓存负责静态资源兜底

    1. 不常更新的资源(字体、第三方库):设置 Cache-Control: max-age=31536000(永久强缓存);
    2. 常更新的资源(业务 JS/CSS):设置 Cache-Control: max-age=0, must-revalidate(协商缓存),配合 Etag/Last-Modified 验证资源是否更新。
  2. SW 缓存负责进阶优化

    1. 缓存 HTTP 缓存搞不定的请求(POST 接口、带鉴权接口等);
    2. 对核心静态资源(如首页 JS/CSS)叠加 SW 缓存,实现“双重保险”;
    3. 用 Stale-While-Revalidate 策略优化核心接口,兼顾速度与新鲜度;
    4. 缓存离线所需的基础资源,实现离线访问。

五、工具推荐:降低 Service Worker 开发成本

手动编写 Service Worker 代码需要处理注册、激活、缓存策略、版本管理等诸多细节,推荐使用成熟工具简化开发:

  • Workbox:Google 官方推出的 SW 开发工具库,内置了多种缓存策略(如 CacheFirstNetworkFirst),支持自动缓存打包后的静态资源,还能处理缓存更新、过期清理等问题,开箱即用;
  • Create React App/Vite:主流构建工具内置了 SW 支持,可通过简单配置启用(如 CRA 的 serviceWorker: true),自动生成基础的 SW 缓存逻辑;
  • Lighthouse:Google 性能检测工具,可检测 SW 的配置是否合理、离线能力是否达标,提供优化建议。

六、注意事项与避坑指南

  1. HTTPS 环境要求:除 localhost 外,SW 仅在 HTTPS 环境下生效(保障请求拦截的安全性),生产环境需部署 HTTPS;
  2. 缓存更新问题:SW 激活后会持续运行,若修改了 SW 代码,需通过“版本号更新”触发重新注册(如修改缓存名称的版本号);
  3. 避免过度缓存:不要缓存所有请求(如登录接口、实时支付接口),需根据业务场景精准筛选缓存范围;
  4. 兼容性处理:部分老旧浏览器(如 IE 全系列)不支持 Service Worker,需做降级处理(检测 SW 支持性,不支持则走传统缓存);
  5. 调试技巧:在 Chrome 开发者工具的「Application → Service Workers」面板,可查看 SW 状态、手动触发更新、清除缓存,方便调试。

七、总结

Service Worker 缓存并非对传统缓存的替代,而是前端性能优化的「进阶补充」。它的核心价值在于「前端完全掌控缓存逻辑」,既能突破 HTTP 缓存的局限,缓存传统方案搞不定的请求,又能通过灵活的策略适配不同业务场景,甚至实现离线访问能力。

在实际开发中,只要合理搭配「HTTP 缓存兜底 + SW 缓存增强」的分层方案,就能在保证性能的同时,最大化提升用户体验。对于追求极致性能的前端应用(如移动端 H5、PWA、电商首页),Service Worker 缓存绝对是值得投入的优化手段。

最后,附上一句实践心得:缓存的本质是「用空间换时间」,而 Service Worker 让我们能更聪明地“换”——精准缓存需要的资源,灵活控制缓存生命周期,让每一份缓存都能发挥最大的价值。

深入理解Vue数据流:单向与双向的哲学博弈

作者 北辰alk
2026年1月12日 21:16

前言:数据流为何如此重要?

在Vue的世界里,数据流就像城市的交通系统——合理的流向设计能让应用运行如行云流水,而混乱的数据流向则可能导致"交通拥堵"甚至"系统崩溃"。今天,我们就来深入探讨Vue中两种核心数据流模式:单向数据流双向数据流的博弈与融合。

一、数据流的本质:理解两种模式

1.1 什么是数据流?

在Vue中,数据流指的是数据在应用各层级组件间的传递方向和方式。想象一下水流,有的河流只能单向流淌(单向数据流),而有的则像潮汐可以来回流动(双向数据流)。

graph TB
    A[数据流概念] --> B[单向数据流]
    A --> C[双向数据流]
    
    B --> D[数据源 -> 视图]
    D --> E[Props向下传递]
    E --> F[事件向上通知]
    
    C --> G[数据源 <-> 视图]
    G --> H[自动双向同步]
    H --> I[简化表单处理]
    
    subgraph J [核心区别]
        B
        C
    end

1.2 单向数据流:Vue的默认哲学

Vue默认采用单向数据流作为其核心设计理念。这意味着数据只能从一个方向传递:从父组件流向子组件。

// ParentComponent.vue
<template>
  <div>
    <!-- 单向数据流:父传子 -->
    <ChildComponent :message="parentMessage" @update="handleUpdate" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      parentMessage: 'Hello from Parent'
    }
  },
  methods: {
    handleUpdate(newMessage) {
      // 子组件通过事件通知父组件更新
      this.parentMessage = newMessage
    }
  }
}
</script>

// ChildComponent.vue
<template>
  <div>
    <p>接收到的消息: {{ message }}</p>
    <button @click="updateMessage">更新消息</button>
  </div>
</template>

<script>
export default {
  props: {
    message: String  // 只读属性,不能直接修改
  },
  methods: {
    updateMessage() {
      // 错误做法:直接修改prop ❌
      // this.message = 'New Message'
      
      // 正确做法:通过事件通知父组件 ✅
      this.$emit('update', 'New Message from Child')
    }
  }
}
</script>

1.3 双向数据流:Vue的特殊礼物

虽然Vue默认是单向数据流,但它提供了v-model指令来实现特定场景下的双向数据绑定。

// 双向绑定示例
<template>
  <div>
    <!-- 语法糖:v-model = :value + @input -->
    <CustomInput v-model="userInput" />
    
    <!-- 等价于 -->
    <CustomInput 
      :value="userInput" 
      @input="userInput = $event" 
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      userInput: ''
    }
  }
}
</script>

二、单向数据流:为什么它是默认选择?

2.1 单向数据流的优势

flowchart TD
    A[单向数据流优势] --> B[数据流向可预测]
    A --> C[调试追踪简单]
    A --> D[组件独立性高]
    A --> E[状态管理清晰]
    
    B --> F[更容易理解应用状态]
    C --> G[通过事件追溯数据变更]
    D --> H[组件可复用性强]
    E --> I[单一数据源原则]
    
    F --> J[降低维护成本]
    G --> J
    H --> J
    I --> J

2.2 实际项目中的单向数据流应用

// 大型项目中的单向数据流架构示例
// store.js - Vuex状态管理(单向数据流典范)
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    user: null,
    products: []
  },
  mutations: {
    // 唯一修改state的方式(单向)
    SET_USER(state, user) {
      state.user = user
    },
    ADD_PRODUCT(state, product) {
      state.products.push(product)
    }
  },
  actions: {
    // 异步操作,提交mutation
    async login({ commit }, credentials) {
      const user = await api.login(credentials)
      commit('SET_USER', user)  // 单向数据流:action -> mutation -> state
    }
  },
  getters: {
    // 计算属性,只读
    isAuthenticated: state => !!state.user
  }
})

// UserProfile.vue - 使用单向数据流
<template>
  <div>
    <!-- 单向数据流:store -> 组件 -->
    <h2>{{ userName }}</h2>
    <UserForm @submit="updateUser" />
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    // 单向:从store读取数据
    ...mapState({
      userName: state => state.user?.name
    })
  },
  methods: {
    // 单向:通过action修改数据
    ...mapActions(['updateUserInfo']),
    
    async updateUser(userData) {
      // 事件驱动:表单提交触发action
      await this.updateUserInfo(userData)
      // 数据流:组件 -> action -> mutation -> state -> 组件
    }
  }
}
</script>

2.3 单向数据流的最佳实践

// 1. 严格的Prop验证
export default {
  props: {
    // 类型检查
    title: {
      type: String,
      required: true,
      validator: value => value.length > 0
    },
    // 默认值
    count: {
      type: Number,
      default: 0
    },
    // 复杂对象
    config: {
      type: Object,
      default: () => ({})  // 工厂函数避免引用共享
    }
  }
}

// 2. 自定义事件规范
export default {
  methods: {
    handleInput(value) {
      // 事件名使用kebab-case
      this.$emit('user-input', value)
      
      // 提供详细的事件对象
      this.$emit('input-change', {
        value,
        timestamp: Date.now(),
        component: this.$options.name
      })
    }
  }
}

// 3. 使用.sync修饰符(Vue 2.x)
// 父组件
<template>
  <ChildComponent :title.sync="pageTitle" />
</template>

// 子组件
export default {
  props: ['title'],
  methods: {
    updateTitle() {
      // 自动更新父组件数据
      this.$emit('update:title', 'New Title')
    }
  }
}

三、双向数据流:v-model的魔法

3.1 v-model的工作原理

// v-model的内部实现原理
<template>
  <div>
    <!-- v-model的本质 -->
    <input 
      :value="message" 
      @input="message = $event.target.value"
    />
    
    <!-- 自定义组件的v-model -->
    <CustomInput v-model="message" />
    
    <!-- Vue 2.x:等价于 -->
    <CustomInput 
      :value="message" 
      @input="message = $event" 
    />
    
    <!-- Vue 3.x:等价于 -->
    <CustomInput 
      :modelValue="message" 
      @update:modelValue="message = $event" 
    />
  </div>
</template>

3.2 实现自定义组件的v-model

// CustomInput.vue - Vue 2.x实现
<template>
  <div class="custom-input">
    <input 
      :value="value" 
      @input="$emit('input', $event.target.value)"
      @blur="$emit('blur')"
    />
    <span v-if="error" class="error">{{ error }}</span>
  </div>
</template>

<script>
export default {
  // 接收value,触发input事件
  props: ['value', 'error'],
  model: {
    prop: 'value',
    event: 'input'
  }
}
</script>

// CustomInput.vue - Vue 3.x实现
<template>
  <div class="custom-input">
    <input 
      :value="modelValue" 
      @input="$emit('update:modelValue', $event.target.value)"
    />
  </div>
</template>

<script>
export default {
  // Vue 3默认使用modelValue和update:modelValue
  props: ['modelValue'],
  emits: ['update:modelValue']
}
</script>

3.3 多v-model绑定(Vue 3特性)

// ParentComponent.vue
<template>
  <UserForm
    v-model:name="user.name"
    v-model:email="user.email"
    v-model:age="user.age"
  />
</template>

<script>
export default {
  data() {
    return {
      user: {
        name: '',
        email: '',
        age: 18
      }
    }
  }
}
</script>

// UserForm.vue
<template>
  <form>
    <input :value="name" @input="$emit('update:name', $event.target.value)">
    <input :value="email" @input="$emit('update:email', $event.target.value)">
    <input 
      type="number" 
      :value="age" 
      @input="$emit('update:age', parseInt($event.target.value))"
    >
  </form>
</template>

<script>
export default {
  props: ['name', 'email', 'age'],
  emits: ['update:name', 'update:email', 'update:age']
}
</script>

四、两种数据流的对比与选择

4.1 详细对比表

特性 单向数据流 双向数据流
数据流向 单向:父 → 子 双向:父 ↔ 子
修改方式 Props只读,事件通知 自动同步修改
代码量 较多(需要显式事件) 较少(v-model简化)
可预测性 高,易于追踪 较低,隐式更新
调试难度 容易,通过事件追溯 较难,更新可能隐式发生
适用场景 大多数组件通信 表单输入组件
性能影响 最小,精确控制更新 可能更多重新渲染
测试难度 容易,输入输出明确 需要模拟双向绑定

4.2 何时使用哪种模式?

flowchart TD
    A[选择数据流模式] --> B{组件类型}
    
    B --> C[展示型组件]
    B --> D[表单型组件]
    B --> E[复杂业务组件]
    
    C --> F[使用单向数据流]
    D --> G[使用双向数据流]
    E --> H[混合使用]
    
    F --> I[Props + Events<br>保证数据纯净性]
    G --> J[v-model<br>简化表单处理]
    H --> K[单向为主<br>双向为辅]
    
    I --> L[示例<br>ProductList, UserCard]
    J --> M[示例<br>CustomInput, DatePicker]
    K --> N[示例<br>复杂表单, 编辑器组件]

4.3 混合使用实践

// 混合使用示例:智能表单组件
<template>
  <div class="smart-form">
    <!-- 单向数据流:显示验证状态 -->
    <ValidationStatus :errors="errors" />
    
    <!-- 双向数据流:表单输入 -->
    <SmartInput 
      v-model="formData.username"
      :rules="usernameRules"
      @validate="updateValidation"
    />
    
    <!-- 单向数据流:提交控制 -->
    <SubmitButton 
      :disabled="!isValid" 
      @submit="handleSubmit"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      formData: {
        username: '',
        email: ''
      },
      errors: {},
      isValid: false
    }
  },
  methods: {
    updateValidation(field, isValid) {
      // 单向:更新验证状态
      if (isValid) {
        delete this.errors[field]
      } else {
        this.errors[field] = `${field}验证失败`
      }
      this.isValid = Object.keys(this.errors).length === 0
    },
    
    handleSubmit() {
      // 单向:提交数据
      this.$emit('form-submit', {
        data: this.formData,
        isValid: this.isValid
      })
    }
  }
}
</script>

五、Vue 3中的新变化

5.1 Composition API与数据流

// 使用Composition API处理数据流
<script setup>
// Vue 3的<script setup>语法
import { ref, computed, defineProps, defineEmits } from 'vue'

// 定义props(单向数据流入口)
const props = defineProps({
  initialValue: {
    type: String,
    default: ''
  }
})

// 定义emits(单向数据流出口)
const emit = defineEmits(['update:value', 'change'])

// 响应式数据
const internalValue = ref(props.initialValue)

// 计算属性(单向数据流处理)
const formattedValue = computed(() => {
  return internalValue.value.toUpperCase()
})

// 双向绑定处理
function handleInput(event) {
  internalValue.value = event.target.value
  // 单向:通知父组件
  emit('update:value', internalValue.value)
  emit('change', {
    value: internalValue.value,
    formatted: formattedValue.value
  })
}
</script>

<template>
  <div>
    <input 
      :value="internalValue" 
      @input="handleInput"
    />
    <p>格式化值: {{ formattedValue }}</p>
  </div>
</template>

5.2 Teleport和状态提升

// 使用Teleport和状态提升管理数据流
<template>
  <!-- 状态提升到最外层 -->
  <div>
    <!-- 模态框内容传送到body,但数据流仍可控 -->
    <teleport to="body">
      <Modal 
        :is-open="modalOpen"
        :content="modalContent"
        @close="modalOpen = false"
      />
    </teleport>
    
    <button @click="openModal('user')">打开用户模态框</button>
    <button @click="openModal('settings')">打开设置模态框</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 状态提升:在共同祖先中管理状态
const modalOpen = ref(false)
const modalContent = ref('')

function openModal(type) {
  // 单向数据流:通过方法更新状态
  modalContent.value = type === 'user' ? '用户信息' : '设置选项'
  modalOpen.value = true
}
</script>

六、最佳实践与常见陷阱

6.1 必须避免的陷阱

// 陷阱1:直接修改Prop(反模式)
export default {
  props: ['list'],
  methods: {
    removeItem(index) {
      // ❌ 错误:直接修改prop
      this.list.splice(index, 1)
      
      // ✅ 正确:通过事件通知父组件
      this.$emit('remove-item', index)
    }
  }
}

// 陷阱2:过度使用双向绑定
export default {
  data() {
    return {
      // ❌ 错误:所有数据都用v-model
      // user: {},
      // products: [],
      // settings: {}
      
      // ✅ 正确:区分状态类型
      user: {},           // 适合v-model
      products: [],       // 适合单向数据流
      settings: {         // 混合使用
        theme: 'dark',    // 适合v-model
        permissions: []   // 适合单向数据流
      }
    }
  }
}

// 陷阱3:忽略数据流的可追溯性
export default {
  methods: {
    // ❌ 错误:隐式更新,难以追踪
    updateData() {
      this.$parent.$data.someValue = 'new'
    },
    
    // ✅ 正确:显式事件,易于调试
    updateData() {
      this.$emit('data-updated', {
        value: 'new',
        source: 'ChildComponent',
        timestamp: Date.now()
      })
    }
  }
}

6.2 性能优化建议

// 1. 合理使用v-once(单向数据流优化)
<template>
  <div>
    <!-- 静态内容使用v-once -->
    <h1 v-once>{{ appTitle }}</h1>
    
    <!-- 动态内容不使用v-once -->
    <p>{{ dynamicContent }}</p>
  </div>
</template>

// 2. 避免不必要的响应式(双向数据流优化)
export default {
  data() {
    return {
      // 不需要响应式的数据
      constants: Object.freeze({
        PI: 3.14159,
        MAX_ITEMS: 100
      }),
      
      // 大数组考虑使用Object.freeze
      largeList: Object.freeze([
        // ...大量数据
      ])
    }
  }
}

// 3. 使用computed缓存(单向数据流优化)
export default {
  props: ['items', 'filter'],
  computed: {
    // 缓存过滤结果,避免重复计算
    filteredItems() {
      return this.items.filter(item => 
        item.name.includes(this.filter)
      )
    },
    
    // 计算属性依赖变化时才重新计算
    itemCount() {
      return this.filteredItems.length
    }
  }
}

6.3 测试策略

// 单向数据流组件测试
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'

describe('UserCard - 单向数据流', () => {
  it('应该正确接收props', () => {
    const wrapper = mount(UserCard, {
      propsData: {
        user: { name: '张三', age: 30 }
      }
    })
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('30')
  })
  
  it('应该正确触发事件', async () => {
    const wrapper = mount(UserCard)
    
    await wrapper.find('button').trigger('click')
    
    // 验证是否正确触发事件
    expect(wrapper.emitted()['user-click']).toBeTruthy()
    expect(wrapper.emitted()['user-click'][0]).toEqual(['clicked'])
  })
})

// 双向数据流组件测试
import CustomInput from './CustomInput.vue'

describe('CustomInput - 双向数据流', () => {
  it('v-model应该正常工作', async () => {
    const wrapper = mount(CustomInput, {
      propsData: {
        value: 'initial'
      }
    })
    
    // 模拟输入
    const input = wrapper.find('input')
    await input.setValue('new value')
    
    // 验证是否触发input事件
    expect(wrapper.emitted().input).toBeTruthy()
    expect(wrapper.emitted().input[0]).toEqual(['new value'])
  })
  
  it('应该响应外部value变化', async () => {
    const wrapper = mount(CustomInput, {
      propsData: { value: 'old' }
    })
    
    // 更新prop
    await wrapper.setProps({ value: 'new' })
    
    // 验证输入框值已更新
    expect(wrapper.find('input').element.value).toBe('new')
  })
})

七、实战案例:构建一个任务管理应用

// 完整示例:Todo应用的数据流设计
// App.vue - 根组件
<template>
  <div id="app">
    <!-- 单向:传递过滤条件 -->
    <TodoFilter 
      :filter="currentFilter"
      @filter-change="updateFilter"
    />
    
    <!-- 双向:添加新任务 -->
    <TodoInput v-model="newTodo" @add="addTodo" />
    
    <!-- 单向:任务列表 -->
    <TodoList 
      :todos="filteredTodos"
      @toggle="toggleTodo"
      @delete="deleteTodo"
    />
    
    <!-- 单向:统计数据 -->
    <TodoStats :stats="todoStats" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      todos: [],
      newTodo: '',
      currentFilter: 'all'
    }
  },
  computed: {
    // 单向数据流:计算过滤后的任务
    filteredTodos() {
      switch(this.currentFilter) {
        case 'active':
          return this.todos.filter(todo => !todo.completed)
        case 'completed':
          return this.todos.filter(todo => todo.completed)
        default:
          return this.todos
      }
    },
    
    // 单向数据流:计算统计信息
    todoStats() {
      const total = this.todos.length
      const completed = this.todos.filter(t => t.completed).length
      const active = total - completed
      
      return { total, completed, active }
    }
  },
  methods: {
    // 单向:添加任务
    addTodo() {
      if (this.newTodo.trim()) {
        this.todos.push({
          id: Date.now(),
          text: this.newTodo.trim(),
          completed: false,
          createdAt: new Date()
        })
        this.newTodo = ''
      }
    },
    
    // 单向:切换任务状态
    toggleTodo(id) {
      const todo = this.todos.find(t => t.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    
    // 单向:删除任务
    deleteTodo(id) {
      this.todos = this.todos.filter(t => t.id !== id)
    },
    
    // 单向:更新过滤条件
    updateFilter(filter) {
      this.currentFilter = filter
    }
  }
}
</script>

// TodoInput.vue - 双向数据流组件
<template>
  <div class="todo-input">
    <input 
      v-model="localValue"
      @keyup.enter="handleAdd"
      placeholder="添加新任务..."
    />
    <button @click="handleAdd">添加</button>
  </div>
</template>

<script>
export default {
  props: {
    value: String
  },
  data() {
    return {
      localValue: this.value
    }
  },
  watch: {
    value(newVal) {
      // 单向:响应外部value变化
      this.localValue = newVal
    }
  },
  methods: {
    handleAdd() {
      // 双向:更新v-model绑定的值
      this.$emit('input', '')
      // 单向:触发添加事件
      this.$emit('add')
    }
  }
}
</script>

// TodoList.vue - 单向数据流组件
<template>
  <ul class="todo-list">
    <TodoItem 
      v-for="todo in todos"
      :key="todo.id"
      :todo="todo"
      @toggle="$emit('toggle', todo.id)"
      @delete="$emit('delete', todo.id)"
    />
  </ul>
</template>

<script>
export default {
  props: {
    todos: Array  // 只读,不能修改
  },
  components: {
    TodoItem
  }
}
</script>

八、总结与展望

8.1 核心要点回顾

  1. 单向数据流是Vue的默认设计,它通过props向下传递,事件向上通知,保证了数据流的可预测性和可维护性。

  2. 双向数据流通过v-model实现,主要适用于表单场景,它本质上是:value + @input的语法糖。

  3. 选择合适的数据流模式

    • 大多数情况:使用单向数据流
    • 表单输入:使用双向数据流(v-model)
    • 复杂场景:混合使用,但以单向为主
  4. Vue 3的增强

    • 多v-model支持
    • Composition API提供更灵活的数据流管理
    • 更好的TypeScript支持

8.2 未来发展趋势

随着Vue生态的发展,数据流管理也在不断进化:

  1. Pinia的兴起:作为新一代状态管理库,Pinia提供了更简洁的API和更好的TypeScript支持。

  2. Composition API的普及:使得逻辑复用和数据流管理更加灵活。

  3. 响应式系统优化:Vue 3的响应式系统性能更好,为复杂数据流提供了更好的基础。

8.3 最后的建议

记住一个简单的原则:当你不确定该用哪种数据流时,选择单向数据流。它可能代码量稍多,但带来的可维护性和可调试性是值得的。

双向数据流就像是甜点——适量使用能提升体验,但过度依赖可能导致"代码肥胖症"。而单向数据流则是主食,构成了健康应用的基础。

❌
❌