阅读视图

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

每日一题-设置交集大小至少为2🔴

给你一个二维整数数组 intervals ,其中 intervals[i] = [starti, endi] 表示从 startiendi 的所有整数,包括 startiendi

包含集合 是一个名为 nums 的数组,并满足 intervals 中的每个区间都 至少两个 整数在 nums 中。

  • 例如,如果 intervals = [[1,3], [3,7], [8,9]] ,那么 [1,2,4,7,8,9][2,3,4,8,9] 都符合 包含集合 的定义。

返回包含集合可能的最小大小。

 

示例 1:

输入:intervals = [[1,3],[3,7],[8,9]]
输出:5
解释:nums = [2, 3, 4, 8, 9].
可以证明不存在元素数量为 4 的包含集合。

示例 2:

输入:intervals = [[1,3],[1,4],[2,5],[3,5]]
输出:3
解释:nums = [2, 3, 4].
可以证明不存在元素数量为 2 的包含集合。 

示例 3:

输入:intervals = [[1,2],[2,3],[2,4],[4,5]]
输出:5
解释:nums = [1, 2, 3, 4, 5].
可以证明不存在元素数量为 4 的包含集合。 

 

提示:

  • 1 <= intervals.length <= 3000
  • intervals[i].length == 2
  • 0 <= starti < endi <= 108

【宫水三叶の相信科学系列】贪心运用题

贪心

不要被样例数据误导了,题目要我们求最小点集的数量,并不规定点集 S 是连续段。

为了方便,我们令 intervalsins

当只有一个线段时,我们可以在线段内取任意两点作为 S 成员,而当只有两个线段时,我们可以两个线段重合情况进行决策:

  1. 当两个线段完全不重合时,为满足题意,我们需要从两个线段中各取两个点,此时这四个点都可以任意取;
  2. 当两个线段仅有一个点重合,为满足 S 最小化的题意,我们可以先取重合点,然后再两个线段中各取一个;
  3. 当两个线段有两个及以上的点重合,此时在重合点中任选两个即可。

不难发现,当出现重合的所有情况中,必然可以归纳某个线段的边缘点上。即不存在两个线段存在重合点,仅发生在两线段的中间部分:

image.png

因此我们可以从边缘点决策进行入手。

具体的,我们可以按照「右端点从小到大,左端点从大到小」的双关键字排序,然后从前往后处理每个区间,处理过程中不断往 S 中添加元素,由于我们已对所有区间排序且从前往后处理,因此我们往 S 中增加元素的过程中必然是单调递增,同时在对新的后续区间考虑是否需要往 S 中添加元素来满足题意时,也是与 S 中的最大/次大值(点集中的边缘元素)做比较,因此我们可以使用两个变量 ab 分别代指当前集合 S 中的次大值和最大值(ab 初始化为足够小的值 $-1$),而无需将整个 S 存下来。

不失一般性的考虑,当我们处理到 $ins[i]$ 时,该如何决策:

  1. 若 $ins[i][0] > b$(当前区间的左端点大于当前点集 S 的最大值),说明 $ins[i]$ 完全不被 S 所覆盖,为满足题意,我们要在 $ins[i]$ 中选两个点,此时直观思路是选择 $ins[i]$ 最靠右的两个点(即 $ins[i][1] - 1$ 和 $ins[i][1]$);
  2. 若 $ins[i][0] > a$(即当前区间与点集 S 存在一个重合点 b,由于次大值 a 和 最大值 b 不总是相差 $1$,我们不能写成 $ins[i][0] == b$),此时为了满足 $ins[i]$ 至少被 $2$ 个点覆盖,我们需要在 $ins[i]$ 中额外选择一个点,此时直观思路是选择 $ins[i]$ 最靠右的点(即$ins[i][1]$);
  3. 其余情况,说明当前区间 $ins[i]$ 与点集 S 至少存在两个点 ab,此时无须额外引入其余点来覆盖 $ins[i]$。

上述情况是对「右端点从小到大」的必要性说明,而「左端点从大到小」目的是为了方便我们处理边界情况而引入的:若在右端点相同的情况下,如果「左端点从小到大」处理的话,会有重复的边缘点被加入 S

有同学对重复边缘点加入 S 不理解,假设 S 当前的次大值和最大值分别为 jk(其中 $k - j > 1$),如果后面有两个区间分别为 $[k - 1, d]$ 和 $[k + 1, d]$ 时,就会出现问题(其中 d 为满足条件的任意右端点)
更具体的:假设当前次大值和最大值分别为 $2$ 和 $4$,后续两个区间分别为 $[3, 8]$ 和 $[5, 8]$,你会发现先处理 $[3, 8]$ 的话,数值 $8$ 会被重复添加

上述决策存在直观判断,需要证明不存在比该做法取得的点集 S 更小的合法解

若存在更小的合法集合方案 A(最优解),根据我们最前面对两个线段的重合分析知道,由于存在任意选点均能满足覆盖要求的情况,因此最优解 A 的具体方案可能并不唯一。

因此首先我们先在不影响 A 的集合大小的前提下,对具体方案 A 中的非关键点(即那些被选择,但既不是某个具体区间的边缘点,也不是边缘点的相邻点)进行调整(修改为区间边缘点或边缘点的相邻点)

这样我们能够得到一个唯一的最优解具体方案,该方案既能取到最小点集大小,同时与贪心解 S 的选点有较大重合度。

此时如果贪心解并不是最优解的话,意味着贪心解中存在某些不必要的点(可去掉,同时不会影响覆盖要求)。

然后我们在回顾下,我们什么情况下会往 S 中进行加点,根据上述「不失一般性」的分析:

  1. 当 $ins[i][0] > b$ 时,我们会往 S 中添加两个点,若这个不必要的点是在这个分支中被添加的话,意味着当前 $ins[i]$ 可以不在此时被覆盖,而在后续其他区间 $ins[j]$ 被覆盖时被同步覆盖(其中 $j > i$),此时必然对应了我们重合分析中的后两种情况,可以将原本在 $ins[j]$ 中被选择的点,调整为 $ins[i]$ 的两个边缘点,结果不会变差(覆盖情况不变,点数不会变多):

image.png

即此时原本在最优解 A 中不存在,在贪心解 S 中存在的「不必要点」会变成「必要点」。

  1. 当 $ins[i] > a$ 时,我们会往 S 中添加一个点,若这个不必要的点是在这个分支被添加的话,分析方式同理,且情况 $1$ 不会发生,如果 $ins[i]$ 和 $ins[j]$ 只有一个重合点的话,起始 $ins[i][1]$ 不会是不必要点:

image.png

综上,我们可以经过两步的“调整”,将贪心解变为最优解:第一步调整是在最优解的任意具体方案 A 中发生,通过将所有非边缘点调整为边缘点,来得到一个唯一的最优解具体方案;然后通过反证法证明,贪心解 S 中并不存在所谓的可去掉的「不必要点」,从而证明「贪心解大小必然不会大于最优解的大小」,即 $S > A$ 不成立,$S \leq A$ 恒成立,再结合 A 是最优解的前提($A \leq S$),可得 $S = A$。

代码:

###Java

class Solution {
    public int intersectionSizeTwo(int[][] ins) {
        Arrays.sort(ins, (a, b)->{
            return a[1] != b[1] ? a[1] - b[1] : b[0] - a[0];
        });
        int a = -1, b = -1, ans = 0;
        for (int[] i : ins) {
            if (i[0] > b) {
                a = i[1] - 1; b = i[1];
                ans += 2;
            } else if (i[0] > a) {
                a = b; b = i[1];
                ans++;
            }
        }
        return ans;
    }
}

###TypeScript

function intersectionSizeTwo(ins: number[][]): number {
    ins = ins.sort((a, b)=> {
        return a[1] != b[1] ? a[1] - b[1] : b[0] - a[0]
    });
    let a = -1, b = -1, ans = 0
    for (const i of ins) {
        if (i[0] > b) {
            a = i[1] - 1; b = i[1]
            ans += 2
        } else if (i[0] > a) {
            a = b; b = i[1]
            ans++
        }
    }
    return ans
};
  • 时间复杂度:$O(n\log{n})$
  • 空间复杂度:$O(\log{n})$

最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

设置交集大小至少为2【贪心】

方法一:贪心

我们对intervals进行排序,intervals[0]升序,inervals[1]降序,然后从后向前进行遍历。
为什么要一个升序一个降序呢?
假设我们有一个intervals = [[2,3],[3,4],[5,10],[5,8]] (已排好序), 只要我们满足了和[5,8]的交集大于等于2,则对于[5,10](左区间相同,右区间降序,保证在左区间相同的情况下让区间范围最小的在最右边)这个区间来说,必定是满足交集大于等于2的,因为小区间满足,大区间必然满足,反过来不一定,在左区间相同的情况下,我们取最小区间的两个元素就可以满足所有左区间相同的区间。因此我们贪心的取interval[n-1][0]interval[n-1][0] + 1做为开始的两个集合元素,设初始两个元素为curnext,则cur = intervals[n - 1][0],next = intervals[n - 1][0] + 1
然后开始分类讨论上一个区间[xi,yi]的情况,根据排序有xi <= cur

  • yi >= next ,则是一个大区间,一定满足交集为2的情况
  • yi < cur,那一定没有交集,我们还是贪心的取cur = xi,next = xi + 1
  • cur <= yi < next,有一个交集,我们贪心的取next = cur,cur = xi
    保证每次都是取左边界或者左边界和左边界+1
    image.png
    最后返回答案即可
    代码如下
class Solution {
    public int intersectionSizeTwo(int[][] intervals) {
        Arrays.sort(intervals, (o1, o2) -> o1[0] == o2[0] ? o2[1] - o1[1] : o1[0] - o2[0]);
        int n = intervals.length;
        //初始的两个元素
        int cur = intervals[n - 1][0];
        int next = intervals[n - 1][0] + 1;
        int ans = 2;
        //从后向前遍历
        for (int i = n - 2; i >= 0; i--) {
            //开始分类讨论
            if (intervals[i][1] >= next) {
                continue;
            } else if (intervals[i][1] < cur) {
                cur = intervals[i][0];
                next = intervals[i][0] + 1;
                ans = ans + 2;
            } else {
                next = cur;
                cur = intervals[i][0];
                ans++;
            }
        }
        return ans;
    }
}
class Solution:
    def intersectionSizeTwo(self, intervals: List[List[int]]) -> int:
        if not intervals:
            return 0
        intervals.sort(key = lambda x : (x[0], -x[1]))
        ans = 2
        cur, next = intervals[-1][0], intervals[-1][0]+1
        for xi, yi in reversed(intervals[:-1]):
            if yi >= next:
                continue
            elif yi < cur:
                cur = xi
                next = xi + 1
                ans += 2
            elif cur <= yi < next:
                next = cur
                cur = xi
                ans += 1
        return ans
class Solution {
public:
    int intersectionSizeTwo(vector<vector<int>>& intervals) {
        sort(intervals.begin(), intervals.end(), [](vector<int>& a, vector<int>& b){
            if(a[0] == b[0]){
                return a[1] > b[1];
            }
            return a[0] < b[0];
        });  // 左边界升序,若相同,右边界降序
        int res = 2, ls = intervals.back()[0], rs = ls + 1;
        for(int i = intervals.size() - 2; i >= 0; i--){
            if(intervals[i][1] >= rs){  // 有两个及以上的交点
                continue;
            }else if(intervals[i][1] < ls){  // 没有交点
                ls = intervals[i][0];
                rs = intervals[i][0] + 1;
                res += 2;
            }else{  // 一个交点
                rs = ls;
                ls = intervals[i][0];
                res++;
            }
        }
        return res;
    }
};

感谢@fergus-peng小伙伴的py代码
感谢@handsomechar小伙伴的c++代码
image.png
给大家写了一个打印结果的代码,方便大家理解
代码如下

    public int intersectionSizeTwo(int[][] intervals) {
        Arrays.sort(intervals, (o1, o2) -> o1[0] == o2[0] ? o2[1] - o1[1] : o1[0] - o2[0]);
        System.out.println("排序后intervals:" + Arrays.deepToString(intervals));
        int n = intervals.length;
        int cur = intervals[n - 1][0];
        int next = intervals[n - 1][0] + 1;
        int ans = 2;
        List<Integer> list = new ArrayList<>();
        list.add(cur);
        list.add(next);
        for (int i = n - 2; i >= 0; i--) {
            System.out.println("待比较区间:" + Arrays.toString(intervals[i]) + "\t当前集合S:" + list);
            if (intervals[i][1] >= next) {
                continue;
            } else if (intervals[i][1] < cur) {
                cur = intervals[i][0];
                next = intervals[i][0] + 1;
                ans = ans + 2;
                list.add(0, next);
                list.add(0, cur);
            } else {
                next = cur;
                cur = intervals[i][0];
                ans++;
                list.add(0, cur);
            }
        }
        return ans;
    }

示例:

int[][] intervals = {{2, 10}, {3, 7}, {3, 15}, {4, 11}, {6, 12}, {6, 16}, {7, 8}, {7, 11}, {7, 15}, {11, 12}};

//结果
排序后intervals:[[2, 10], [3, 15], [3, 7], [4, 11], [6, 16], [6, 12], [7, 15], [7, 11], [7, 8], [11, 12]]
待比较区间:[7, 8]当前集合S:[11, 12]
待比较区间:[7, 11]当前集合S:[7, 8, 11, 12]
待比较区间:[7, 15]当前集合S:[7, 8, 11, 12]
待比较区间:[6, 12]当前集合S:[7, 8, 11, 12]
待比较区间:[6, 16]当前集合S:[7, 8, 11, 12]
待比较区间:[4, 11]当前集合S:[7, 8, 11, 12]
待比较区间:[3, 7]当前集合S:[7, 8, 11, 12]
待比较区间:[3, 15]当前集合S:[3, 7, 8, 11, 12]
待比较区间:[2, 10]当前集合S:[3, 7, 8, 11, 12]

写题解不易,如果对您有帮助,记得关注 + 点赞 + 收藏呦!!!
每天都会更新每日一题题解,大家加油!!

设置交集大小至少为2

方法一:贪心

思路与算法

首先我们从稍简化的情况开始分析:如果题目条件为设置交集大小为 $1$,为了更好的分析我们将 $\textit{intervals}$ 按照 $[s,e]$ 序对进行升序排序,其中 $\textit{intervals}$ 为题目给出的区间集合,$s,e$ 为区间的左右边界。设排序后的 $\textit{intervals} = {[s_1,e_1,],\dots,[s_n,e_n]}$,其中 $n$ 为区间集合的大小,并满足 $\forall i,j \in [1,n],i < j$ 有 $s_i \leq s_j$ 成立。然后对于最后一个区间 $[s_n,e_n]$ 来说一定是要提供一个最后交集集合中的元素,那么我们思考我们选择该区间中哪个元素是最优的——最优的元素应该尽可能的把之前的区间尽可能的覆盖。那么我们选择该区间的开头元素 $s_n$ 一定是最优的,因为对于前面的某一个区间 $[s_i,s_j]$:

  • 如果 $s_j < s_n$:那么无论选择最后一个区间中的哪个数字都不会在区间 $[s_i,s_j]$ 中。
  • 否则 $s_j \geq s_n$:因为 $s_n \geq s_i$ 所以此时选择 $s_n$ 一定会在区间 $[s_i,s_j]$ 中。

即对于最后一个区间 $[s_n,e_n]$ 来说选择区间的开头元素 $s_n$ 一定是最优的。那么贪心的思路就出来了:排序后从后往前进行遍历,每次选取与当前交集集合相交为空的区间的最左边的元素即可,然后往前判断前面是否有区间能因此产生交集,产生交集的直接跳过即可。此时算法的时间复杂度为:$O(n \log n)$ 主要为排序的时间复杂度。对于这种情况具体也可以见本站题目 452. 用最少数量的箭引爆气球

那么我们用同样的思路来扩展到需要交集为 $m~,~m > 1$ 的情况:此时同样首先对 $\textit{intervals}$ 按照 $[s,e]$ 序对进行升序排序,然后我们需要额外记录每一个区间与最后交集集合中相交的元素(只记录到 $m$ 个为止)。同样我们从最后一个区间往前进行处理,如果该区间与交集集合相交元素个数小于 $m$ 个时,我们从该区间左边界开始往后添加不在交集集合中的元素,并往前进行更新需要更新的区间,重复该过程直至该区间与交集元素集合有 $m$ 个元素相交。到此就可以解决问题了,不过我们也可以来修改排序的规则——我们将区间 $[s,e]$ 序对按照 $s$ 升序,当 $s$ 相同时按照 $e$ 降序来进行排序,这样可以实现在处理与交集集合相交元素个数小于 $m$ 个的区间 $[s_i,e_i]$ 时,保证不足的元素都是在区间的开始部分,即我们可以直接从区间开始部分进行往交集集合中添加元素。

而对于本题来说,我们只需要取 $m = 2$ 的情况即可。

代码

###Python

class Solution:
    def intersectionSizeTwo(self, intervals: List[List[int]]) -> int:
        intervals.sort(key=lambda x: (x[0], -x[1]))
        ans, n, m = 0, len(intervals), 2
        vals = [[] for _ in range(n)]
        for i in range(n - 1, -1, -1):
            j = intervals[i][0]
            for k in range(len(vals[i]), m):
                ans += 1
                for p in range(i - 1, -1, -1):
                    if intervals[p][1] < j:
                        break
                    vals[p].append(j)
                j += 1
        return ans

###C++

class Solution {
public:
    void help(vector<vector<int>>& intervals, vector<vector<int>>& temp, int pos, int num) {
        for (int i = pos; i >= 0; i--) {
            if (intervals[i][1] < num) {
                break;
            }
            temp[i].push_back(num);
        }
    }

    int intersectionSizeTwo(vector<vector<int>>& intervals) {
        int n = intervals.size();
        int res = 0;
        int m = 2;
        sort(intervals.begin(), intervals.end(), [&](vector<int>& a, vector<int>& b) {
            if (a[0] == b[0]) {
                return a[1] > b[1];
            }
            return a[0] < b[0];
        });
        vector<vector<int>> temp(n);
        for (int i = n - 1; i >= 0; i--) {
            for (int j = intervals[i][0], k = temp[i].size(); k < m; j++, k++) {
                res++;
                help(intervals, temp, i - 1, j);
            }
        }
        return res;
    }
};

###Java

class Solution {
    public int intersectionSizeTwo(int[][] intervals) {
        int n = intervals.length;
        int res = 0;
        int m = 2;
        Arrays.sort(intervals, (a, b) -> {
            if (a[0] == b[0]) {
                return b[1] - a[1];
            }
            return a[0] - b[0];
        });
        List<Integer>[] temp = new List[n];
        for (int i = 0; i < n; i++) {
            temp[i] = new ArrayList<Integer>();
        }
        for (int i = n - 1; i >= 0; i--) {
            for (int j = intervals[i][0], k = temp[i].size(); k < m; j++, k++) {
                res++;
                help(intervals, temp, i - 1, j);
            }
        }
        return res;
    }

    public void help(int[][] intervals, List<Integer>[] temp, int pos, int num) {
        for (int i = pos; i >= 0; i--) {
            if (intervals[i][1] < num) {
                break;
            }
            temp[i].add(num);
        }
    }
}

###C#

public class Solution {
    public int IntersectionSizeTwo(int[][] intervals) {
        int n = intervals.Length;
        int res = 0;
        int m = 2;
        Array.Sort(intervals, (a, b) => {
            if (a[0] == b[0]) {
                return b[1] - a[1];
            }
            return a[0] - b[0];
        });
        IList<int>[] temp = new IList<int>[n];
        for (int i = 0; i < n; i++) {
            temp[i] = new List<int>();
        }
        for (int i = n - 1; i >= 0; i--) {
            for (int j = intervals[i][0], k = temp[i].Count; k < m; j++, k++) {
                res++;
                Help(intervals, temp, i - 1, j);
            }
        }
        return res;
    }

    public void Help(int[][] intervals, IList<int>[] temp, int pos, int num) {
        for (int i = pos; i >= 0; i--) {
            if (intervals[i][1] < num) {
                break;
            }
            temp[i].Add(num);
        }
    }
}

###C

static void help(int** intervals, int** temp, int *colSize, int pos, int num) {
    for (int i = pos; i >= 0; i --) {
        if (intervals[i][1] < num) {
            break;
        }
        temp[i][colSize[i]++] = num;
    }
}

static inline int cmp(const void* pa, const void* pb) {
    if ((*(int **)pa)[0] == (*(int **)pb)[0]) {
        return (*(int **)pb)[1] - (*(int **)pa)[1];
    }
    return (*(int **)pa)[0] - (*(int **)pb)[0];
}

int intersectionSizeTwo(int** intervals, int intervalsSize, int* intervalsColSize){
    int res = 0;
    int m = 2;
    qsort(intervals, intervalsSize, sizeof(int *), cmp);
    int **temp = (int **)malloc(sizeof(int *) * intervalsSize);
    for (int i = 0; i < intervalsSize; i++) {
        temp[i] = (int *)malloc(sizeof(int) * 2);
    }
    int *colSize = (int *)malloc(sizeof(int) * intervalsSize);
    memset(colSize, 0, sizeof(int) * intervalsSize);
    for (int i = intervalsSize - 1; i >= 0; i --) {
        for (int j = intervals[i][0], k = colSize[i]; k < m; j++, k++) {
            res++;
            help(intervals, temp, colSize, i - 1, j);
        }
    }
    for (int i = 0; i < intervalsSize; i++) {
        free(temp[i]);
    }
    free(colSize);
    return res;
}

###go

func intersectionSizeTwo(intervals [][]int) (ans int) {
    sort.Slice(intervals, func(i, j int) bool {
        a, b := intervals[i], intervals[j]
        return a[0] < b[0] || a[0] == b[0] && a[1] > b[1]
    })
    n, m := len(intervals), 2
    vals := make([][]int, n)
    for i := n - 1; i >= 0; i-- {
        for j, k := intervals[i][0], len(vals[i]); k < m; k++ {
            ans++
            for p := i - 1; p >= 0 && intervals[p][1] >= j; p-- {
                vals[p] = append(vals[p], j)
            }
            j++
        }
    }
    return
}

###JavaScript

var intersectionSizeTwo = function(intervals) {
    const n = intervals.length;
    let res = 0;
    let m = 2;
    intervals.sort((a, b) => {
        if (a[0] === b[0]) {
            return b[1] - a[1];
        }
        return a[0] - b[0];
    });
    const temp = new Array(n).fill(0);
    for (let i = 0; i < n; i++) {
        temp[i] = [];
    }

    const help = (intervals, temp, pos, num) => {
        for (let i = pos; i >= 0; i--) {
            if (intervals[i][1] < num) {
                break;
            }
            temp[i].push(num);
        }
    }

    for (let i = n - 1; i >= 0; i--) {
        for (let j = intervals[i][0], k = temp[i].length; k < m; j++, k++) {
            res++;
            help(intervals, temp, i - 1, j);
        }
    }
    return res;
};

复杂度分析

  • 时间复杂度:$O(n \log n + nm)$,其中 $n$ 为给定区间集合 $\textit{intervals}$ 的大小,$m$ 为设置交集大小,本题为 $2$。

  • 空间复杂度:$O(nm)$,其中 $n$ 为给定区间集合 $\textit{intervals}$ 的大小,$m$ 为设置交集的大小,本题为 $2$。主要开销为存储每一个区间与交集集合的相交的元素的开销。

TypeScript 简史:它是怎么拯救我的烂代码的

看烂代码的场景

接手老旧 JavaScript 项目的时候,盯着屏幕上的一行代码发呆,这种绝望你一定体会过:

JavaScript

function process(data) {
    return data.value + 10; // 此时 data 是 undefined,程序崩了
}

看着这个 data,我满脑子都是问号:

  • 它是对象还是数字?
  • 到底是谁传进来的?
  • 我要是改了它,会不会导致隔壁模块的页面挂掉?
  • 为什么明明是字符串 '10',结果拼成了 '1010'

这时候我就在想,要是代码能自己告诉我“我是谁,我从哪里来,我要去哪里”,该多好。

这就是 TypeScript 诞生的意义。它不是微软为了炫技造的轮子,而是为了解决 JavaScript 在大规模应用中“失控”的必然选择。


为什么我们需要 TypeScript?

JavaScript 的“娘胎顽疾”

JavaScript 诞生的时候,Brendan Eich 只用了 10 天。这事儿说穿了挺传奇,但也留下了隐患。

当时的场景很简单:验证一下表单,改改页面背景色。所以它的设计哲学是 “怎么方便怎么来”

JavaScript

var data = "hello";
data = 123;           // 随便改类型,没事
data.abcd = "what?";  // 随便加属性,也不报错

这种“自由”在写几十行代码时是天堂,但在写几十万行代码时就是地狱。

事情是怎么失控的?

随着 AjaxNode.js 的出现,前端不再是画页面的,而是写应用程序的。

想想也是,当代码量从 500 行变成 50,000 行,团队从 1 个人变成 20 个人:

  • 你写的 getUser(id),同事调用时传了个对象。
  • 后端 API 偷偷改了一个字段名,前端只有等到用户点击报错了才知道。
  • 重构?别逗了,改一行代码,心里都要祈祷半天。

问题的根源在于 JavaScript 是动态弱类型。它在运行前完全不知道自己错了,非要等到撞了南墙(报错)才回头。


它是怎么解决问题的?

核心思路:给 JS 穿上铠甲

TypeScript 的原理说穿了挺简单:它就是给 JavaScript 加上了类型约束,但在运行前又把这些约束脱得干干净净。

看个流程图就明白了:

代码段

graph LR
    A[TypeScript源码] -->|编译/检查| B(类型检查器)
    B -- 报错 --> C[修复代码]
    B -- 通过 --> D[抹除类型信息]
    D --> E[纯净的JavaScript]
    E --> F[浏览器/Node运行]

巧妙的“超集”策略

微软的大神 Anders Hejlsberg(这也是 C# 之父)非常聪明。他知道如果搞一门新语言,开发者肯定不买账(看看 Google 的 Dart 就知道了)。

所以他搞了个 “超集(Superset)” 策略:

  1. 向后兼容:任何合法的 JavaScript 代码,直接粘贴进 TypeScript 文件,它都能跑。你不需要重写代码。
  2. 类型擦除:TS 编译后就是普通的 JS。浏览器根本不知道 TS 的存在,不用装任何插件。

TypeScript

// TypeScript 写法
function add(a: number, b: number): number {
    return a + b;
}

// 编译出来的 JavaScript(类型全没了)
function add(a, b) {
    return a + b;
}

这招“瞒天过海”非常高明,既让你爽了(有类型检查),又让浏览器爽了(只认 JS)。


为什么 TS 能赢?(深度分析)

在 TS 出来之前,Google 的 Closure 和 Dart,甚至 Facebook 的 Flow 都尝试过解决这个问题。为什么最后是 TypeScript 统一了江湖?

对比分析

我们来看看这张技术演进图:

代码段

timeline
    title 前端类型探索之路
    2009 : Closure Compiler : 注释太繁琐
    2009 : CoffeeScript : 语法糖,无类型
    2011 : Dart : 甚至想换掉JS虚拟机
    2012 : TypeScript 诞生 : 拥抱JS,做超集
    2014 : Angular 宣布采用 TS
    2016 : VS Code 崛起
    2023 : 统治地位确立

TypeScript 胜出的关键点

  1. 工具链的降维打击

    这点必须得吹一下 VS Code。VS Code 是用 TS 写的,它对 TS 的支持简直是原生级的。

    • 智能补全:你打个点 .,属性全出来了,不用去翻文档。
    • 重构神器:按 F2 重命名一个变量,整个项目几百个文件里的引用全改好了。
  2. 渐进式的温柔

    CoffeeScript 和 Dart 要求你“学会新语法,忘掉旧习惯”。

    TypeScript 说:“没事,你先用 any 凑合着,等有空了再补类型。”这种低门槛让很多老项目敢于尝试迁移。

  3. 生态圈的马太效应

    现在你装个第三方库,如果没有自带 TypeScript 类型定义(d.ts),大家都会觉得这个库“不正规”。Angular、Vue3、React 全部深度拥抱 TS。

潜在的坑

当然,TS 也不是银弹,这里要注意几个软肋:

  • AnyScript 现象:很多新手遇到类型报错就写 any,结果写成了“带编译过程的 JavaScript”,完全失去了类型的意义。
  • 体操级类型:有时候为了描述一个复杂的动态结构,类型定义写得比业务逻辑还长,人称“类型体操”。
  • 编译时间:项目大了以后,tsc 跑一遍确实挺慢的(虽然现在有了 SWC/Esbuild 等加速方案)。

写在最后

TypeScript 的成功告诉我们要顺势而为。它没有试图颠覆 JavaScript,而是承认了 JS 的混乱,然后提供了一套工具来管理这种混乱。

下次当你接手一个全是 any 的 TS 项目时,你会知道:

  • 这哥们儿可能是在迁移初期。
  • 或者他只是单纯的懒。
  • 最重要的:至少你还能重构,因为编译器会教你做人。

如果你的团队还在用纯 JS 裸奔,赶紧试试 TS 吧。哪怕只是为了那个“点一下能出属性提示”的爽快感,也值了。


相关文档

只有前端 Leader 才会告诉你:那些年踩过的模块加载失败的坑(二)

最近项目准备要发布了,项目有比较多的一些代码提交和修复,发现在生产环境中,偶尔会遇到下面的错误:

TypeError: Failed to fetch dynamically imported module:
https://***-dev.***.internal.***.tech/assets/index-DUebgR3_.js

Failed to load module script: Expected a JavaScript-or-Wasm module script 
but the server responded with a MIME type of "text/html".

去做了一些调研,整理了这篇文章

🔍 问题原因分析

1. 根本原因

Nginx 配置问题:当浏览器请求不存在的静态资源文件时,nginx 返回了 index.html 而不是 404 错误。

原始的nginx配置:

location / {
    try_files $uri $uri/ /index.html;
}

这个配置作用是让前端路由(比如 /about/user/profile 这类路径)在刷新或直接访问时不会返回 404,导致所有找不到的文件(包括 /assets/ 下的 JS 文件)都返回 index.html(对应MIME type: text/html),但浏览器期望的是 JavaScript 文件。

2. 触发场景

我们的触发场景主要是场景A,其他两种也是可能会触发的场景

场景 A:版本更新后的缓存问题

1. 用户访问网站,浏览器缓存了 index.html(引用 index-ABC123.js)
2. 服务器部署新版本,生成新的哈希文件 index-DEF456.js
3. 用户刷新页面,浏览器使用缓存的 HTML,尝试加载已删除的 index-ABC123.js
4. 文件不存在,nginx 返回 index.html
5. 浏览器把 HTML 当作 JS 解析,抛出 TypeError

场景 B:部署不完整

1. CI/CD 部署过程中,HTML 文件已更新
2. 某些 chunk 文件因网络问题未完全上传
3. 用户访问时,HTML 引用了不存在的 chunk 文件
4. 触发模块加载错误

场景 C:CDN/浏览器缓存不一致

1. CDN 缓存了新版本的 HTML
2. 某些静态资源仍指向旧版本或缓存未更新
3. 导致文件引用不匹配

3. 错误链条

graph TD
    A[浏览器请求 /assets/index-DUebgR3_.js] --> B{文件是否存在?}
    B -->|否| C[nginx 执行 try_files]
    C --> D[返回 /index.html]
    D --> E[MIME type: text/html]
    E --> F[浏览器期望: application/javascript]
    F --> G[TypeError: MIME type 不匹配]

✅ 解决方案

方案 1:修复 Nginx 配置(核心)

修改内容

server {
    listen       80;
    server_name  _;

    root   /usr/share/nginx/html;
    index  index.html;

    # 静态资源:找不到返回 404,不返回 index.html
    location /assets/ {
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA 路由:只对非静态资源路径生效
    location / {
        try_files $uri $uri/ /index.html;
        # 禁用 index.html 缓存,确保用户总是获取最新版本
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # Gzip 压缩
    gzip on;
    gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
    gzip_min_length 1024;
}

改进点

  • /assets/ 路径返回真实的 404,避免误返回 HTML
  • ✅ 静态资源设置长期缓存(1年),提升性能
  • index.html 禁用缓存,防止引用过期的静态资源
  • ✅ 区分 SPA 路由和静态资源路由

📘 SPA 加载流程详解

单页应用的完整加载过程

很多人误以为 SPA 只是"返回 index.html 就完事了",实际上 index.html 只是入口,JS 文件才是应用的核心

🔄 完整加载流程(6个步骤)
用户访问: https://yourapp.com/users/123
    ↓
① 服务器返回 index.html(HTML 入口文件)
    ↓
② 浏览器解析 HTML,发现 <script src="/assets/index-DUebgR3_.js">
    ↓
③ 浏览器自动发起第二个请求: GET /assets/index-DUebgR3_.js
    ↓                           ⚠️ 我们的错误发生在这一步,由于项目更新,打包部署了新的版本,文件的hash值也发生了变化,但是本地之前启动项目的缓存请求的还是旧文件,期望拿到js文件,但是由于服务端找不到,返回了html,就出现了开头的错误
④ 服务器返回 JS 文件(包含 React 应用代码)
    ↓
⑤ 浏览器执行 JS:React 启动,读取 URL (/users/123)
    ↓
⑥ React Router 匹配路由,渲染 <UserProfile id="123" /> 组件
📄 index.html 和 JS 文件的关系

index.html 的实际内容(简化版):

<!DOCTYPE html>
<html>
<head>
    <title>Innies</title>
    <link rel="stylesheet" href="/assets/style-ABC123.css">
</head>
<body>
    <div id="root"></div>  <!-- ⚠️ 注意:这里是空的! -->

    <!-- ⚠️ 关键:这行代码触发浏览器请求 JS 文件 -->
    <script type="module" src="/assets/index-DUebgR3_.js"></script>
</body>
</html>

JS 文件包含真正的应用代码

// /assets/index-DUebgR3_.js 的内容
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { BrowserRouter } from 'react-router-dom';

// 这里才开始渲染页面内容
ReactDOM.render(
  <BrowserRouter>
    <App />  {/* 包含所有路由、组件、业务逻辑 */}
  </BrowserRouter>,
  document.getElementById('root')  // 找到 HTML 里的 <div id="root">,开始渲染
);

关键理解

  • index.html 只是一个空壳(只有一个空的 <div id="root">
  • 所有页面内容、路由、组件都在 JS 文件里
  • 没有 JS 文件,页面就是空白,什么都显示不出来
⚠️ 错误发生的位置

本文档解决的错误发生在第③步

正常流程

③ 浏览器请求: GET /assets/index-DUebgR3_.js
    ↓
✅ 服务器返回: JavaScript 文件(Content-Type: application/javascript)
    ↓
✅ 浏览器执行 JS,React 启动
    ↓
✅ 页面渲染成功

错误流程(修复前)

③ 浏览器请求: GET /assets/index-DUebgR3_.js
    ↓
❌ 服务器找不到文件(可能是旧版本文件已被删除)
    ↓
❌ Nginx 配置错误:try_files $uri $uri/ /index.html
    ↓
❌ 返回: index.html 内容(Content-Type: text/html)
    ↓
❌ 浏览器期望 JavaScript,但收到 HTML
    ↓
❌ TypeError: Expected JavaScript but got MIME type "text/html"
    ↓
❌ React 无法启动,页面白屏或崩溃
📊 请求和响应对比
步骤 正常情况 错误情况(修复前)
浏览器请求 GET /assets/index-DUebgR3_.js GET /assets/index-DUebgR3_.js
文件状态 ✅ 文件存在 ❌ 文件不存在(旧版本)
服务器返回 JavaScript 代码 ❌ index.html 内容
Content-Type application/javascript text/html
浏览器行为 ✅ 执行 JS,渲染页面 ❌ MIME type 不匹配报错
用户体验 ✅ 页面正常显示 ❌ 白屏或错误提示
💡 为什么 JS 文件找不到会导致整个应用崩溃?

因为 JS 文件包含:

  • ✅ React 核心代码
  • ✅ 所有组件定义
  • ✅ 路由配置
  • ✅ 状态管理
  • ✅ 业务逻辑

没有这个 JS 文件,index.html 只是一个空壳,无法渲染任何内容。

这就像:

  • index.html = 汽车的外壳
  • index-DUebgR3_.js = 发动机
  • 没有发动机,汽车就无法启动

🎯 为什么要区分 SPA 路由和静态资源路由?

问题背景: 单页应用(SPA)和传统的静态资源服务有本质区别,需要不同的处理策略。

📘 SPA 路由工作原理

单页应用(SPA)的核心机制

  1. 服务器层面:所有 URL 路径都返回同一个 index.html

    /users/123    → 服务器返回 index.html
    /dashboard    → 服务器返回 index.html
    /settings     → 服务器返回 index.html
    
  2. 浏览器层面:前端路由(如 React Router)解析 URL 并渲染对应组件

    // index.html 加载后,前端路由接管 URL 解析
    /users/123React Router 匹配 → <UserProfile id="123" />
    /dashboard    → React Router 匹配 → <Dashboard />
    /settings     → React Router 匹配 → <Settings />
    

为什么 /users/123 需要返回 index.html

典型场景:

  • 用户直接在浏览器输入 https://yourapp.com/users/123
  • 或在 /users/123 页面刷新浏览器
  • 服务器收到 HTTP 请求:GET /users/123

如果不返回 index.html 会发生什么?

❌ 服务器在文件系统中找不到 /users/123 文件
❌ 返回 404 错误
❌ 用户看到错误页面,应用无法加载

返回 index.html 后的完整流程

1. 服务器返回 index.html(包含 React 应用的启动代码)
2. 浏览器加载并执行 index.html 中的 JavaScript
3. React 应用启动
4. React Router 读取当前 URL: /users/123
5. 匹配路由规则,渲染 <UserProfile id="123" /> 组件
6. 用户看到正确的页面 ✅
📊 路由类型对比
类型 路径示例 期望行为 原因
SPA 路由 /users/123
/settings
/dashboard
返回 index.html 这些是前端路由,由 React Router 处理,服务器没有对应文件
静态资源 /assets/index-DUebgR3_.js
/assets/style.css
/favicon.ico
返回文件或 404 这些是真实的物理文件,不存在就应该报错

修复前的问题

location / {
    try_files $uri $uri/ /index.html;  # ❌ 所有路径都用这个规则
}

这会导致:

  • /users/123 → 返回 index.html ✓(正确)
  • /assets/missing.js → 返回 index.html ✗(错误!应该返回 404)

修复后的方案

# 规则 1:静态资源 - 严格匹配
location /assets/ {
    try_files $uri =404;  # 找不到就返回 404,绝不返回 HTML
}

# 规则 2:SPA 路由 - 兜底方案
location / {
    try_files $uri $uri/ /index.html;  # 找不到才返回 HTML
}

工作原理

请求: /users/123
  ↓ 不匹配 /assets/
  ↓ 进入 location /
  ↓ $uri 不存在 → 返回 index.html ✅
  
请求: /assets/index-ABC.js (存在)
  ↓ 匹配 /assets/
  ↓ $uri 存在 → 返回文件 ✅
  
请求: /assets/index-OLD.js (不存在)
  ↓ 匹配 /assets/
  ↓ $uri 不存在 → 返回 404 ✅(不是 HTML!)

核心收益

  1. 类型安全:浏览器期望 JS 文件,就不会收到 HTML 文件
  2. 快速失败:资源缺失立即返回 404,触发前端错误处理(重试/提示)
  3. 正确缓存:静态资源和 HTML 可以设置不同的缓存策略
  4. 问题可见:404 错误可以被监控系统捕获,便于及时发现部署问题

⚠️ 重要说明:404 不是终极解决方案

返回 404 的作用

❌ 修复前:返回 HTML → MIME type 错误 → 用户看到技术错误 → 无法恢复
✅ 修复后:返回 404 → 触发 error 事件 → 前端捕获错误 → 自动重试/提示用户

404 只是让问题"正确地暴露",真正的解决方案是 方案 3 的前端错误处理

  • 🔄 自动重试加载(处理临时网络问题)
  • 🔄 自动刷新页面(清除过期缓存)
  • 💬 友好的用户提示(引导用户清除缓存)

完整的解决链条

文件不存在 
  ↓
nginx 返回 404(不是 HTML)
  ↓
浏览器触发 error 事件
  ↓
前端错误处理器捕获
  ↓
自动重试(2次)或提示用户清除缓存
  ↓
问题解决 ✅

三个方案的角色

方案 角色 作用
方案 1 (Nginx) 🚦 正确的错误信号 让错误以正确的方式暴露(404 而非 HTML)
方案 2 (Vite) 🛡️ 减少问题发生 优化构建,减少 chunk 文件数量和依赖复杂度
方案 3 (前端) 🔧 自动修复 捕获错误并自动恢复,用户无感知或有友好提示

根本性的预防措施(见后文"预防措施"章节):

  • ✅ 禁用 index.html 缓存
  • ✅ 原子性部署(避免文件不完整)
  • ✅ 保留旧版本静态资源(避免缓存引用失效)

方案 2:优化 Vite 构建配置

修改内容

export default defineConfig(({ mode }) => {
  return {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            // 拆分大型依赖,减少单个文件失败的影响
            'react-vendor': ['react', 'react-dom', 'react-router-dom'],
            'ui-vendor': ['@zhiman/design', 'framer-motion', 'lucide-react'],
          },
        },
      },
      assetsDir: 'assets',
      chunkSizeWarningLimit: 1000,
    },
    // ... 其他配置
  };
});

改进点

  • ✅ 合理拆分 chunk,避免单个巨大文件
  • ✅ 减少动态导入失败的影响范围
  • ✅ 提升首屏加载速度

方案 3:添加前端错误处理

新增文件src/utils/moduleLoadErrorHandler.ts

核心功能

export function setupModuleLoadErrorHandler(): void {
  // 监听全局错误
  window.addEventListener('error', (event) => {
    // 检测模块加载错误
    if (isModuleLoadError(event.message)) {
      // 自动重试(最多 2 次)
      if (reloadCount < MAX_RELOAD_ATTEMPTS) {
        sessionStorage.setItem(RELOAD_KEY, String(reloadCount + 1));
        setTimeout(() => window.location.reload(), 500);
      } else {
        // 显示友好的错误提示
        showErrorUI();
      }
    }
  });
}

特性

  • ✅ 自动检测模块加载失败
  • ✅ 智能重试机制(最多 2 次)
  • ✅ 友好的用户提示界面
  • ✅ 一键清除缓存并重新加载
  • ✅ 防止无限重载循环

使用方式

// src/main.tsx
import { setupModuleLoadErrorHandler } from './utils/moduleLoadErrorHandler';

setupModuleLoadErrorHandler();

📊 效果对比

修复前

用户体验:❌ 白屏 / 加载失败
错误信息:❌ 技术性错误提示
恢复方式:❌ 用户需要手动清除缓存
影响范围:❌ 版本更新时频繁出现

修复后

用户体验:✅ 自动重试 / 友好提示
错误信息:✅ 用户友好的提示界面
恢复方式:✅ 自动重试 + 一键清除缓存
影响范围:✅ 大幅减少错误发生率
  • 💡 总结与建议

    问题本质

    说白了就是:旧文件找不到了,Nginx 配置有问题,返回了错的东西。

    用户浏览器缓存的旧 HTML 里写着"去加载 index-ABC123.js",但服务器上这个文件早删了。正常情况应该返回 404,但 Nginx 配置写错了,返回了一个 HTML 页面。浏览器期望拿到 JS 文件,结果拿到 HTML,直接懵了,抛错。

    解决思路很简单

    核心就一句话:让 Nginx 该返回 404 就返回 404,别瞎返回 HTML。然后前端监听到 404 错误,自动刷新页面就行了。

    三个方案其实就是围绕这个思路:

    1. 改 Nginx:找不到文件就老老实实返回 404,别整那些花里胡哨的
    2. 前端兜底:监听加载失败,自动重试或者刷新页面,用户基本无感
    3. 优化打包:把文件拆小点,减少出问题的概率

    为什么这么简单的问题会困扰这么久?

    因为大多数人(包括我们一开始)都把 Nginx 配置写成了:

    nginx

    location / {
        try_files $uri $uri/ /index.html;  # 啥都找不到就返回 HTML
    }
    

    这个配置的本意是支持 SPA 前端路由,但副作用是连静态资源文件找不到也返回 HTML,这就埋了个大坑。

    三个方案的优先级

    如果时间紧迫,只能先做一个,建议顺序是:

    1. 先改 Nginx(5分钟搞定,治本)
    2. 再加前端兜底(半小时搞定,救急)
    3. 最后优化构建(锦上添花,可选)

    一些踩坑经验

    关于 Nginx 配置:

    • 静态资源目录(/assets/)要单独配置,找不到就返回 404
    • 测试方法:直接浏览器访问一个不存在的 /assets/xxx.js,看返回的是不是 404
    • 别偷懒,该分开配置就分开配置

    关于缓存策略:

    • index.html 千万别缓存,或者设置较短时间的缓存,
    • 这是问题的根源
    • 静态资源随便缓存,文件名带 hash,不会冲突
    • CDN 的缓存规则要和 Nginx 保持一致

前端跨标签页通信方案(下)

前情

平时开发很少有接触到有什么需求需要实现跨标签页通信,但最近因为一些变故,不得不重新开始找工作了,其中就有面试官问到一道题,跨标签页怎么实现数据通信,我当时只答出二种,面试完后特意重新查资料,因此有些文章

SharedWorker

共享工作线程可以在多个标签页之间共享数据和逻辑,通过postMessage通信

关键代码如下:

标签页1

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SharedWorker0</title>
</head>
<body>
  <h1>SharedWorker0</h1>
  <button id="communication">SharedWorker0.html 发送消息</button>
  <script>
    // 主线程
    const worker = new SharedWorker('sw.js');

    // 发送消息
    document.getElementById('communication').addEventListener('click', () => {
      worker.port.postMessage('Hello from Tab:SharedWorker0.html');
    });
  </script>
</body>
</html>

标签页2

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SharedWorker1</title>
</head>
<body>
  <h1>SharedWorker1</h1>
  <script>
    // 主线程
    const worker = new SharedWorker('sw.js');

    // 接收消息
    worker.port.onmessage = (e) => {
      console.log('Received:SharedWorker1.html', e.data);
    };
  </script>
</body>
</html>

sw.js关键代码:

const connections = [];

self.onconnect = (e) => {
  const port = e.ports[0];
  connections.push(port);
  
  port.onmessage = (e) => {
    // 广播给所有连接的页面
    connections.forEach(p => p.postMessage(e.data));
  };
};

动图演示:

20250923_203404.gif

提醒:

  • 同源标签才有效
  • 不同页面创建 SharedWorker 时,若指定的脚本路径不同(即使内容相同),会创建不同的 worker 实例
  • 页面与 SharedWorker 之间通过 MessagePort 通信,需通过 port.postMessage() 发送消息,通过 port.onmessage 接收消息
  • SharedWorker 无法访问 DOM、window 对象或页面的全局变量,仅能使用 JavaScript 核心 API 和部分 Web API(如 fetchWebSocket
  • 兼容性一般,安卓webview全系不兼容

Service Worker

专门用于同源标签页通信的 API,创建一个频道后,所有加入该频道的页面都能收到消息

关键代码如下:

标签页1

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ServiceWorker0</title>
</head>
<body>
  <h1>ServiceWorker0</h1>
  <button id="sendBtn">发送消息</button>
  <script>
    // 注册ServiceWorker
    let swReg;
    navigator.serviceWorker.register('ServiceWorker.js')
      .then(reg => {
        swReg = reg;
        console.log('SW注册成功');
      });
    
    // 发送消息
    document.getElementById('sendBtn').addEventListener('click', () => {
      if (swReg && swReg.active) {
        swReg.active.postMessage('来自页面0的消息');
      }
    });
  </script>
</body>
</html>

标签页2

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ServiceWorker1</title>
</head>
<body>
  <h1>ServiceWorker1</h1>
  <script>
    // 注册ServiceWorker
    navigator.serviceWorker.register('ServiceWorker.js')
      .then(() => console.log('SW注册成功'));
    
    // 接收消息
    navigator.serviceWorker.addEventListener('message', (e) => {
      console.log('---- Received:ServiceWorker1.html ----:',  e.data);
    });
  </script>
</body>
</html>

ServiceWorker.js关键代码

// 快速激活
self.addEventListener('install', e => e.waitUntil(self.skipWaiting()));
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));

// 消息转发
self.addEventListener('message', e => {
  self.clients.matchAll().then(clients => {
    clients.forEach(client => {
      if (client.id !== e.source.id) {
        client.postMessage(e.data);
      }
    });
  });
});

演示动图如下:

20250923_212126.gif

提醒:

  • Service Worker 要求页面必须在 HTTPS 环境 下运行(localhost 除外,方便本地开发),这是出于安全考虑,防止中间人攻击篡改 Service Worker 脚本
  • Service Worker 有严格的生命周期(安装、激活、空闲、销毁),一旦注册成功会长期运行在后台,更新 Service Worker 需满足两个条件:
  1. 脚本 URL 不变但内容有差异
  2. 需在 install 事件中调用 self.skipWaiting(),并在 activate 事件中调用 self.clients.claim() 让新 Worker 立即生效
  • Service Worker 的作用域由注册路径决定,默认只能控制其所在路径及子路径下的页面,例如:/sw.js 可控制全站,/js/sw.js 默认只能控制 /js/ 路径下的页面,可通过 scope 参数指定作用域,但不能超出注册文件所在路径的范围
  • 可在浏览器开发者工具的 Application > Service Workers 面板进行调试, • 查看当前运行的 Service Worker 状态 • 强制更新、停止或注销 Worker • 模拟离线环境
  • 主流浏览器都支持,使用的时候可以通过Is service worker ready?,测试兼容性

window.open + window.opener

如果标签页是通过window.open打开的,可以直接通过opener属性通信 父窗口,打开子窗口的页面关键代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>parent</title>
</head>
<body>
  <h1>window.open parent</h1>
  <button id="openBtn">打开子窗口</button>
  <button id="sendBtn">发送消息</button>
  <div id="messageDisplay"></div>
  <script>
    let childWindow = null;
    let messageHandler = null;
    
    // 打开子窗口
    document.getElementById('openBtn').addEventListener('click', () => {
      // 如果已有窗口,先关闭
      if (childWindow && !childWindow.closed) {
        childWindow.close();
      }
      childWindow = window.open('./children.html', 'childWindow');
    });

    // 发送消息
    document.getElementById('sendBtn').addEventListener('click', () => {
      if (childWindow && !childWindow.closed) {
      // window.location.origin限制接收域名
        childWindow.postMessage('Hello child', window.location.origin);
      } else {
        alert('请先打开子窗口');
      }
    });
    
    // 接收子窗口的消息
    messageHandler = (e) => {
      if (e.origin === window.location.origin && e.source !== window) {
        document.getElementById('messageDisplay').textContent = '收到消息: ' + e.data;
        console.log('父页面收到消息:', e.data);
      }
    };
    
    window.addEventListener('message', messageHandler);
  </script>
</body>
</html>

通过window.open打开的子页面关键代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>children</title>
</head>
<body>
  <h1>子窗口</h1>
  <button id="replyBtn">回复父窗口</button>
  <div id="messageDisplay"></div>
  
  <script>
    let messageHandler = null;
    
    // 只在页面加载完成后设置消息监听
    window.onload = function() {
      // 接收父页面消息
      messageHandler = (e) => {
        if (e.origin === window.location.origin && e.source !== window) {
          console.log('子页面收到消息:', e.data);
          
          // 显示收到的消息
          document.getElementById('messageDisplay').textContent = '收到消息: ' + e.data;
          
          window.opener.postMessage('子窗口已收到消息', e.origin);
        }
      };
      
      window.addEventListener('message', messageHandler);
    };
    
    // 手动回复按钮
    document.getElementById('replyBtn').addEventListener('click', () => {
      if (window.opener) {
        window.opener.postMessage('来自子窗口的回复', window.location.origin);
      }
    });
  </script>
</body>
</html>

提醒:

  • 允许跨域通信,但必须由开发者显式指定信任的源,避免恶意网站滥用
  • 在事件监听的时候记得判断e.source,避免自己发送的事件自己接收了
  • 若子窗口被关闭,父窗口中对它的引用(如 childWindow)会变成无效对象,调用其方法会报错
  • window.open使用会有一些限制,最好是在事件中使用,有的浏览器还会有权限提示,需要用户同意才行,若 window.open 被浏览器拦截(非用户主动触发),会返回 null,导致后续通信失败

总结

面试官有提到Service Worker也可以,我面试完后的查询资料尝试了这些方法,都挺顺利的,就是Service Worker折腾了一会才跑通,使用起来相比前面的一些方式,它稍微复杂一些,我觉得用于消息通信只是它的冰山一角,它有一个主要功能就是用来解决一些耗性能的计算密集任务

个人技术有限,如果你有更好的跨标签页通信方式,期待你的分享,你工作中有遇到这种跨标签页通信的需求么,如果有你用的是哪一种了,期待你的留言

解决 Monorepo 项目中 node-sass 安装失败的 Python 版本兼容性问题

解决 Monorepo 项目中 node-sass 安装失败的 Python 版本兼容性问题

问题背景

在最近的一个 Monorepo 项目(具体是 a-mono 中的 table-list 子项目)中,我遇到了一个令人头疼的依赖安装问题。项目在使用 eden-mono 工具安装依赖时卡在 node-sass 的构建阶段,导致整个开发流程受阻。

项目环境

  • 项目类型:Monorepo(使用 eden-mono 管理)
  • 构建工具:eden-mono
  • 依赖管理:pnpm

错误现象

在项目根目录执行 eden-mono install --filter=table-list 时,安装过程在 node-sass@6.0.1 的 postinstall 脚本处失败,报错信息如下:

ValueError: invalid mode: 'rU' while trying to load binding.gyp
gyp ERR! configure error
gyp ERR! stack Error: `gyp` failed with exit code: 1

问题分析

根本原因

经过深入分析,发现问题出在 Python 版本兼容性 上:

  1. node-gyp 版本过旧:项目使用的是 node-gyp@7.1.2,这个版本发布于 2020 年
  2. Python 版本过高:系统安装了 Python 3.11.13,而旧的 node-gyp 不支持
  3. 语法不兼容'rU' 模式是 Python 2 的语法,在 Python 3 中已被移除

技术细节

node-gyp 在构建过程中会调用 Python 脚本来生成构建配置,其中使用了 open(build_file_path, "rU") 这样的语法。Python 3.11 不再支持 'rU' 模式,导致构建失败。

尝试过的解决方案

方案一:升级 node-gyp

npm install -g node-gyp@latest

结果:失败,因为 monorepo 项目中的 node-sass 版本锁定了 node-gyp 版本,升级全局包无法影响项目内部依赖

方案二:配置 Python 路径

npm config set python /path/to/python3.10

结果:失败,npm 配置语法在较新版本中发生了变化,而且 monorepo 项目的依赖管理更加复杂

方案三:降级 Node.js 版本

nvm use 16.20.2

结果:部分成功,但仍然遇到 Python 兼容性问题,因为 node-gyp 仍然调用不兼容的 Python 版本

方案四:替换 node-sass 为 sass

# 在 monorepo 中尝试替换
npm uninstall node-sass
npm install sass

结果:理论上可行,但 monorepo 项目中存在复杂的间接依赖关系,无法完全替换 node-sass

最终解决方案:暴力卸载重装

在尝试了所有常规方法后,我决定采用最粗暴但有效的方法:完全卸载所有 Python 版本,只保留通过 pyenv 管理的 Python 3.10.19

执行步骤

  1. 查看当前 Python 版本
pyenv versions
which -a python3 python3.10 python3.11
  1. 卸载 Python 3.11
pyenv uninstall 3.11.9
brew uninstall python@3.11
  1. 卸载通过 Homebrew 安装的 Python 3.10
brew uninstall python@3.10
  1. 清理 monorepo 缓存和依赖
cd /Users/bytedance/Documents/work-space/ttam_core_mono/packages/campaign-list
rm -rf node_modules
rm -rf .pnpm-store
pnpm store prune
  1. 重新安装依赖(使用 eden-mono)
nvm exec 16.20.2 eden-mono install --filter=campaign-list

结果

成功! 安装过程顺利完成,node-sass 构建成功,monorepo 项目可以正常运行。

经验教训

版本兼容性的重要性

这次问题深刻说明了开发环境中版本兼容性的重要性:

  1. Node.js 版本:不同版本的 Node.js 对依赖包的支持程度不同
  2. Python 版本:构建工具的 Python 支持存在版本限制
  3. 依赖版本:间接依赖可能导致意想不到的兼容性问题

最佳实践建议

  1. 使用版本管理工具

    • Node.js:使用 nvm 管理版本,确保 .nvmrc 文件存在
    • Python:使用 pyenv 管理版本,确保 .python-version 文件存在
  2. 锁定环境版本

    • 在 monorepo 根目录和子项目目录都放置版本锁定文件
    • 明确指定项目所需的运行时版本
    • 定期检查和更新这些版本锁定文件
  3. 定期更新依赖

    • 避免使用过时的依赖包(如 node-sass)
    • 及时升级到官方推荐的替代方案(如 sass)
    • 在 monorepo 中使用批量更新工具
  4. 环境隔离

    • 为不同项目使用不同的虚拟环境
    • 避免全局安装可能产生冲突的工具
    • 考虑使用容器化技术隔离复杂的构建环境

总结

虽然通过暴力卸载重装解决了这个 monorepo 项目的问题,但这并不是最理想的方式。在理想情况下,我们应该:

  1. 提前规划好 monorepo 项目的运行时环境,考虑各子项目的兼容性
  2. 使用容器化技术(如 Docker)来标准化复杂的 monorepo 环境
  3. 建立完善的 CI/CD 流程来检测环境兼容性问题
  4. 为 monorepo 项目建立专门的构建环境管理策略

这次经历让我更加重视开发环境的标准化管理,特别是对于复杂的 monorepo 项目。希望这篇文章能帮助遇到类似问题的开发者少走弯路,特别是在处理 monorepo 项目中的环境兼容性问题时。


参考链接

Next.js第八章(路由处理程序)

路由处理程序(Route Handlers)

路由处理程序,可以让我们在Next.js中编写API接口,并且支持与客户端组件的交互,真正做到了什么叫前后端分离人不分离

文件结构

定义前端路由页面我们使用的page.tsx文件,而定义API接口我们使用的route.ts文件,并且他两都不受文件夹的限制,可以放在任何地方,只需要文件的名称以route.ts结尾即可。

注意:page.tsx文件和route.ts文件不能放在同一个文件夹下,否则会报错,因为Next.js就搞不清到底用哪一个了,所以我们最好把前后端代码分开。

为此我们可以定义一个api文件夹,然后在这个文件夹下创建一对应的模块例如user login register等。

目录结构如下

app/
├── api
│   ├── user
│   │   └── route.ts
│   ├── login
│   │   └── route.ts
│   └── register
│       └── route.ts

定义请求

Next.js是遵循RESTful API的规范,所以我们可以使用HTTP方法来定义请求。

export async function GET(request) {}
 
export async function HEAD(request) {}
 
export async function POST(request) {}
 
export async function PUT(request) {}
 
export async function DELETE(request) {}
 
export async function PATCH(request) {}
 
//如果没有定义OPTIONS方法,则Next.js会自动实现OPTIONS方法
export async function OPTIONS(request) {}

注意: 我们在定义这些请求方法的时候不能修改方法名称而且必须是大写,否则无效。

工具准备: 打开vsCode / Cursor 找到插件市场搜索REST Client,安装完成后,我们可以使用REST Client来测试API接口。

image.png

定义GET请求

src/app/api/user/route.ts

import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
    const query = request.nextUrl.searchParams; //接受url中的参数
    console.log(query.get('id'));
    return NextResponse.json({ message: 'Get request successful' }); //返回json数据
}

REST client测试:

在src目录新建test.http文件,编写测试请求

src/test.http

GET http://localhost:3000/api/user?id=123 HTTP/1.1

image.png

定义Post请求

src/app/api/user/route.ts

import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest){
    //const body = await request.formData(); //接受formData数据
    //const body = await request.text(); //接受text数据
    //const body = await request.arrayBuffer(); //接受arrayBuffer数据
    //const body = await request.blob(); //接受blob数据
    const body = await request.json(); //接受json数据
    console.log(body); //打印请求体中的数据
    return NextResponse.json({ message: 'Post request successful', body },{status: 201});
     //返回json数据
}

REST client测试:

src/test.http

POST http://localhost:3000/api/user HTTP/1.1
Content-Type: application/json

{
    "name": "张三",
    "age": 18
}

image.png

动态参数

我们可以在路由中使用方括号[]来定义动态参数,例如/api/user/[id],其中[id]就是动态参数,这个参数可以在请求中传递,这个跟前端路由的动态路由类似。

src/app/api/user/[id]/route.ts

接受动态参参数,需要在第二个参数解构{ params },需注意这个参数是异步的,所以需要使用await来等待参数解析完成。

import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest, 
{ params }: { params: Promise<{ id: string }> }) {
    const { id } = await params;
    console.log(id);
    return NextResponse.json({ message: `Hello, ${id}!` });
}

REST client测试:

src/test.http

GET http://localhost:3000/api/user/886 HTTP/1.1

image.png

cookie

Next.js也内置了cookie的操作可以方便让我们读写,接下来我们用一个登录的例子来演示如何使用cookie。

安装手动挡组件库shadcn/ui官网地址

npx shadcn@latest init 

为什么使用这个组件库?因为这个组件库是把组件放入你项目的目录下面,这样做的好处是可以让你随时修改组件库样式,并且还能通过AI分析修改组件库

安装button,input组件

npx shadcn@latest add button
npx shadcn@latest add input

新建login接口 src/app/api/login/route.ts

import { cookies } from "next/headers"; //引入cookies
import { NextRequest, NextResponse } from "next/server"; //引入NextRequest, NextResponse
//模拟登录成功后设置cookie
export async function POST(request: NextRequest) {
    const body = await request.json();
    if(body.username === 'admin' && body.password === '123456'){
        const cookieStore = await cookies(); //获取cookie
        cookieStore.set('token', '123456',{
            httpOnly: true, //只允许在服务器端访问
            maxAge: 60 * 60 * 24 * 30, //30天
        });
        return NextResponse.json({ code: 1 }, { status: 200 });
    }else{
        return NextResponse.json({ code: 0 }, { status: 401 });
    }
}
//检查登录状态
export async function GET(request: NextRequest) {
    const cookieStore = await cookies();
    const token = cookieStore.get('token');
    if(token && token.value === '123456'){
        return NextResponse.json({ code:1 }, { status: 200 });
    }else{
        return NextResponse.json({ code:0 }, { status: 401 });
    }
}

src/app/page.tsx

'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useRouter } from 'next/navigation';

export default  function HomePage() {
    const router = useRouter();
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const handleLogin = () => {
        fetch('/api/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ username, password }),
        }).then(res => {
            return res.json();
        }).then(data => {
            if(data.code === 1){
                router.push('/home');
            }
        });
    }
    return (
        <div className='mt-10 flex flex-col items-center justify-center gap-4'>
            <Input value={username} onChange={(e) => setUsername(e.target.value)} className='w-[250px]' placeholder="请输入用户名" />
            <Input value={password} onChange={(e) => setPassword(e.target.value)} className='w-[250px]' placeholder="请输入密码" />
            <Button onClick={handleLogin}>登录</Button>
        </div>
    )
}

src/app/home/page.tsx

'use client';
import { useEffect } from 'react';
import { redirect } from 'next/navigation';
const checkLogin = async () => {
    const res = await fetch('/api/login');
    const data = await res.json();
    if (data.code === 1) {
        return true;
    } else {
        redirect('/');
    }
}
export default function HomePage() {
    useEffect(() => {
        checkLogin()    
    }, []);
    return <div>你已经登录进入home页面</div>;
}

123.gif

Cloudflare 崩溃梗图

1. 新闻

昨天,Cloudflare 崩了。

随后,OpenAI、X、Spotify、AWS、Shopify 等大型网站也崩了。

据说全球 20% 的网站都受到波及,不知道你是否也被影响了?

2. 事故原因

整个事故持续了 5 个小时,根据 Cloudflare 的报告,最初公司怀疑是遭到了超大规模 DDoS 攻击,不过很快就发现了核心问题。

事故的根本原因是因为 Cloudflare 内部的一套用于识别和阻断恶意机器人流量的自动生成配置文件。

该配置文件在例行升级后规模意外变大,远超系统预期,撑爆了路由网络流量的软件限制,继而导致大量流量被标记为爬虫而被 Ban。

CEO 发布了道歉声明:

不过这也不是第一次发生这种大规模事故了。

一个月前,亚马逊 AWS 刚出现持续故障,超过一千个网站和在线应用数小时瘫痪。

今年 7 月,美国网络安全服务提供商 CrowdStrike 的一次软件升级错误则造成全球范围蓝屏事故,机场停航、银行受阻、医院手术延期,影响持续多日。

3. 梗图

每次这种大事故都会有不少梗图出现,这次也不少。

3.1. 第一天上班

苦了这位缩写为 SB 的老哥 😂

3.2. 真正的底座

原本你以为的 Cloudflare:

经过这次事故,实际的 Cloudflare:

3.3. 死循环

3.4. 按秒赔偿

3.5. 影响到我了

3.6. 影响惨了

3.7. 这是发动战争了?

3.8. 加速失败

3.9. mc 亦有记载

基本数据类型Symbol的基本应用场景

Symbol 作为 ES6 新增的基本数据类型,核心特性是唯一性不可枚举性,在实际项目中主要用于解决命名冲突、保护对象私有属性等场景。以下是具体的应用举例及代码实现:

一、作为对象的唯一属性名,避免属性冲突

当多人协作开发或引入第三方库时,普通字符串属性名容易被覆盖,Symbol 可确保属性唯一。

示例:组件库的私有属性

// 定义唯一的 Symbol 属性
const internalState = Symbol('internalState');

class Button {
  constructor() {
    // 用 Symbol 作为私有属性名,外部无法直接访问
    this[internalState] = {
      clicked: false,
      disabled: false
    };
  }

  click() {
    if (!this[internalState].disabled) {
      this[internalState].clicked = true;
      console.log('按钮被点击');
    }
  }

  disable() {
    this[internalState].disabled = true;
  }
}

const btn = new Button();
btn.click(); // 正常执行

// 外部无法通过常规方式访问或修改 internalState
console.log(btn.internalState); // undefined
console.log(btn[Symbol('internalState')]); // undefined(Symbol 是唯一的)

二、定义常量,避免魔术字符串

魔术字符串(直接写在代码中的字符串)易出错且难维护,用 Symbol 定义唯一常量更可靠。

示例:状态管理中的事件类型

// event-types.js
export const EVENT_TYPES = {
  LOGIN: Symbol('login'),
  LOGOUT: Symbol('logout'),
  UPDATE_USER: Symbol('updateUser')
};

// 使用常量
function handleEvent(eventType) {
  switch (eventType) {
    case EVENT_TYPES.LOGIN:
      console.log('用户登录');
      break;
    case EVENT_TYPES.LOGOUT:
      console.log('用户登出');
      break;
    default:
      console.log('未知事件');
  }
}

handleEvent(EVENT_TYPES.LOGIN); // 输出“用户登录”

三、实现对象的 “私有属性”

虽然 JavaScript 没有真正的私有属性,但 Symbol 属性默认不可被 for...inObject.keys() 枚举,可模拟私有属性。

示例:类的私有方法 / 属性

const privateMethod = Symbol('privateMethod');

class User {
  constructor(name) {
    this.name = name; // 公共属性
    this[Symbol('id')] = Math.random().toString(36).slice(2); // 私有属性
  }

  [privateMethod]() {
    // 私有方法,外部无法调用
    return `用户ID:${this[Symbol('id')]}`;
  }

  getInfo() {
    // 公共方法间接调用私有方法
    return `${this.name} - ${this[privateMethod]()}`;
  }
}

const user = new User('Alice');
console.log(user.getInfo()); // 正常输出

// 无法枚举 Symbol 属性
console.log(Object.keys(user)); // ['name']
for (const key in user) {
  console.log(key); // 仅输出 'name'
}

四、自定义迭代器(Iterator)

Symbol.iterator 是内置 Symbol,用于定义对象的迭代器,让对象可被 for...of 遍历。

示例:自定义可迭代对象

const iterableObj = {
  data: ['a', 'b', 'c'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.data.length) {
          return { value: this.data[index++], done: false };
        } else {
          return { done: true };
        }
      }
    };
  }
};

// 可通过 for...of 遍历
for (const item of iterableObj) {
  console.log(item); // 输出 a、b、c
}

五、Vue 中的应用:自定义组件的 v-model 修饰符

在 Vue 3 中,可通过 Symbol 定义自定义的 v-model 修饰符,避免与内置修饰符冲突。

示例:Vue 组件的自定义修饰符

// 定义唯一的修饰符 Symbol
const trimSymbol = Symbol('trim');

// 组件内
export default {
  props: {
    modelValue: String,
    modelModifiers: {
      default: () => ({})
    }
  },
  emits: ['update:modelValue'],
  methods: {
    handleInput(e) {
      let value = e.target.value;
      // 判断是否使用自定义修饰符
      if (this.modelModifiers[trimSymbol]) {
        value = value.trim();
      }
      this.$emit('update:modelValue', value);
    }
  }
};

总结

Symbol 在项目中的核心应用场景包括:

  1. 避免属性名冲突(多人协作 / 第三方库集成);
  2. 模拟私有属性 / 方法(不可枚举特性);
  3. 定义唯一常量(替代魔术字符串);
  4. 扩展内置对象行为(如自定义迭代器)。

【AI省流快讯】Cloudflare 炸了 / Gemini 3 来了 / Antigravity 独家实测 (附:无法登录解法)

1. Cloudflare 挂了

🤡 昨晚陆续刷到 "CF挂了" 的消息,没太在意,直到无法打开" 盗版漫画" 站点,我才意识到问题的严重性:

🤣 原因众说纷纭,刷到这哥们的 "梗图",差点把我笑岔气:

😃 还有人猜测可能是 Google 发布的 "哈基米 3" (Gemini) 发起的攻击:

时间线

  • 19:30】用户开始报告网站无法访问,出现10xx、52x、50x系列错误;Cloudflare Dashboard无法访问;部分Cloudflare域名解析中断。
  • 19:48】Cloudflare正式确认服务异常,启动紧急调查。
  • 20:03】持续调查中,未发现明显进展。
  • 20:13】部分服务开始恢复,但错误率仍高于正常水平。
  • 20:21】监测到部分服务恢复迹象;多次反复出现故障与恢复的波动;20:23、20:55等时间点再次中断。
  • 21:04】技术团队紧急关闭伦敦节点的WARP服务接入以控制影响范围。
  • 21:09】官方确认定位到根本原因,开始实施修复方案。
  • 21:13】Cloudflare Access与WARP服务全面恢复,错误率回落至日常水平。
  • 22:12】X应用恢复。
  • 22:22】Cloudflare状态页更新:"我们正在继续努力修复此问题"。
  • 22:34】状态页再次更新:"我们已经部署了一项变更,已恢复仪表板服务。我们仍在努力解决对整体应用服务的影响"。
  • 22:42】全局恢复完成,Cloudflare宣布事件解决,后续监控与处理继续进行中。

Cloudflare 发言人 Jackie Dutton在官方声明中表示,故障源于一个 用于管理威胁流量的自动生成配置文件。该配置文件原本用于防护潜在安全威胁,但由于文件规模异常庞大,导致多项内部系统在处理流量时发生故障:

截止目前,Cloudflare 全球网络服务已全面恢复,受影响的X、ChatGPT、Facebook 等主流平台均已恢复正常使用。😀 在网上看到大佬的原因分析,也贴下:

😆 难兄难弟啊,前阵子 亚马逊AWS 的大规模宕机 (10.20,美东区域数据库权限和DNS管理系统配置故障),故障持续约15小时,直接造成全球互联网大面积混乱。

2. Gemini 3 来了

😄 千呼万唤的 "哈基米 3" (Gemini) 终于来了,不过竟然没搞个发布会,只是在 官方博客 发下文章:

《A new era of intelligence with Gemini 3》

先简要回顾了一下 Gemini 系列的发展历程:

  • Gemini 1:着重在 "原生多模态" (文本+图像) 和 "长上下文窗口"。
  • Gemini 2:开始推动 "智能代理式 (agentic) " 与 "推理与思考" 能力。

Gemini 3 在上述基础上进一步提升,方称其为迄今 "最智能、最安全" 的模型:

  • 推理能力 & 多模态理解:在各种 AI 基准测试 (benchmarks) 上表现优异:LMArena (1501 Elo)、GPQA Diamond (91.9%)、MMMU-Pro (81%)、Video-MMMU (87.6%)、SimpleQA Verified (72.1%)。模型能更好理解背景、意图,给予更有深度、少空话的回答
  • Gemini 3 Deep Think:"深思" 增强模式,可进行更深的链式推理、更强代码执行与工具调用,提升复杂问题的求解能力,Humanity's Last Exam (41.0%)、GPQA Diamond (93.8%)、ARC-AGI-2 (带代码执行,45.1%)。该模式将在数周内向 Google AI Ultra 订阅用户开放。

三大应用场景

学习

  • 模型支持文本、图像、视频、音频、代码等多模态输入,100w token 的上下文窗口。
  • 如:可将手写不同语言的食谱翻译并制作家庭食谱;分析视频运动比赛 (如Picklebal) 帮助你提高训练。
  • 可在 Google 搜索中的 "AI Mode" 借助 Gemini 3 提供生成式 UI、互动工具、仿真体验。

构建

  • 强 "零样本生成" 能力:不用给示例、不用教,只说想法,直接生成你想要的东西。能处理复杂 提示/指令 (提示/指令),生成更丰富、互动性更强的 Web UI。基准测试:WebDev Arena (比谁能更好地完成Web开发任务,1487 Elo,战绩亮眼)、Terminal-Bench 2.0 (54.2%,命令行处理真实开发任务的能力)、SWE-bench Verified (软件工程能力-修bug、补功能,76.2%-非常高,大部分模型在30%-40%)。
  • 可在 Google 的 AI Studio、Vertex AI、Gemini CLI、以及新出的 AI IDE-Google Antigravity 中使用。第三方平台也支持,如:Cursor、GitHub、JetBrains、Manus、Replit 等。

计划

  • 在长期多步骤任务中表现提升,如:Vending-Bench 2 中可 "模拟一年" 运营决策。
  • 新增 Gemini Agent 工具,能够代表用户自动完成多步骤复杂任务,如管理邮箱、自动化工作流程和旅行计划,且仍受用户控制。已向 Google AI Ultra 用户开放早期体验。

😄 打开 ai.studio 直接就能看到最新的模型了:


谷歌官方 在演示中展示了三个 Vibe Coding 例子:

  • AI 课程平台登录页:通过简单 Prompt ("新布鲁特主义风格,创意有趣设计,平滑滚动动画,谷歌色彩,深浅主题"),直接生成了一个完整的、具有动画效果和深浅主题切换的登陆页。
  • SaaS 数据看板:用户上传 CSV 数据文件和参考设计截图,自动生成了一个具有图表、筛选器、深色主题的专业数据仪表盘。
  • 互动游戏:通过复杂 Prompt (涉及React、Three.js、3D 效果等技术细节),生成一个完全可玩的3D游戏。

😄 国内 自媒体 基本都是在吹它的 "前端能力" (看效果图确实挺6的):

  • 能生成精确的 SVG 矢量图:包括复杂的动画 SVG (如:旋转风扇动画),而非简单栅格图。
  • 3D 和动画:支持生成 Three.js 3D 模型、WebGL 着色器、CSS 动画等高级视觉效果。
  • 完成应用框架:能理解复杂的技术栈要求 (React、Three.js Fiber、TypeScript 等),生成模块化、结构清晰的代码。
  • 注解修改:用户可以在生成的界面上用 "标注" 的方式指出要修改的地方 (画圈、画箭头、添加文字),Gemini 3 会理解这些视觉标注并精确修改代码。这得益于它 多模态理解能力的显著提升 (对屏幕截图的理解准确率达到 72.7%,达到现有水平的两倍)。
  • 去 "AI味":排版、色彩搭配、组件结构看起来是 "精心设计" 的,而非生硬地套模版。

🤔 目前杰哥还没 深度体验 这个新模型,不好评价,只实测下这个新出的 AI IDE —— Antigravity 吧~

3. Google Antigravity (反重力)

3.1. 下载安装

下载地址:

下载 Google Antigravity

下载完双击安装:

接着是不断按 Next 的 傻瓜式安装 (是否从VS Code 或 Cursor 导入设置):

选主题:

选使用 Agent 的方式 & 编辑器配置 (默认就好):

😐 最后,大部分人会卡在登录这里,杰哥也是折腾了一早上,买了两个号才登上的。

3.2. 登录问题的解决

3.2.1. 代理问题

如图,先检查 TUN (全局/系统代理) 模式有没有开:

接着看 代理 的 "地区",香港是不行滴,群里有人说新加坡/日本可以,杰哥用的 美国,其次是 代理 的 "质量"。

3.2.2. 账号问题

代理没问题了,基本是能自动打开浏览器,跳转到授权页,然后授权成功的:

接着返回 Antigravity 可能会出现这两种情况:

这极大概率就是 "Google账号" 的问题,先访问下述网址,查看:账号当前的国家或地区版本

《Google 服务条款》

比如我的号:

🤷‍♀️ 香港肯定是不行的,可以访问下述地址申请修改 (一年只能改一次 ❗️)

《账号关联地区更改请求》

具体操作:

😐 改完,如果还不行的话,那应该是 "账号本身有问题" 或者触发了 Google 莫名其妙的拦截规则。我一开始在海鲜市场买了一个 "美区" 的老号,一直卡 Setting Up 那里转。后面又收了个 "日区" 的号,秒进 ❗️❗️❗️

😄 还有个群友提供了一个野路子:

登Google play,在美区买本0刀的免费电子书,就成美区了。

💡 反正进不去,就是 "代理" 和 "账号" 的问题!我现在的组合是:日区号 + 美国代理。都没问题,会进入这个是否允许采集信息的页面,取消勾选,然后 Next

接着就能来到 IDE 的主页面了:

3.3. 初体验

🤣 熟悉的 VS Code 套壳界面,还是很有亲切感的,右侧有常规的 AI Chat

除了选模型外,还支持选模式:

Ctrl + E 可以打开类似于 Cursor Agents 模式的 "Agent Manager":

上面我写了一个,让 Antigravity 基于 Claudeflare 故障信息生成一个用于发布到 自媒体平台 的长图的 简单的Prompt,发送后可以看到 Agent 开始干活:

涉及到命令执行,让你 Accept

觉得烦可以点下右侧切成 Turbo 模式:

活干完,要预览,跳转 Chrome,提示安装一个 浏览器插件

以便 Agent 能直接操作浏览器 (如获取页面节点、自动化、截图等)。最后看下生成效果:

🤔 同样的 Prompt,分别看下 Claude 4.5GPT 5 的生成效果:

🤣 哈哈,你更 Pick (喜欢) 哪个模型生成的页面呢?

3.4. 限额

群里有小伙伴没蹬几次就出现了这个:

看了下官网:

《Google Antigravity Plans》

💡 额度 由Google动态决定 (基于系统容量、防止滥用),每五小时刷新一次,额度与任务复杂度相关。🐶 官方表示:只有 极少数高强度用户 会撞到每5小时上限。

😄 所以这个额度是 "不透明" 的,L站 有人说不一定得等五个小时,等了十几分钟又可以用了~

【开源】耗时数月、我开发了一款功能全面【30W行代码】的AI图床

AI编程发展迅猛,现在如果你是一个全栈开发,借助AI编辑器,比如Trae你可以开发很多你以往无法实现的功能,并且效率会大大提示,TareCoding能力已经非常智强了,借助他,我完成了这个30W行代码的开源项目,现在,想把这个项目推荐给你。

当前文章主要是介绍我们开发出的这个项目,在后续,将会新开一个文章专门介绍AI Coding的各种技巧,如何使用他,才能让他在大型项目中依然如虎添翼。

如果你想快速了解此项目、你可以访问PixelPunk官方文档

如果你想快速体验此项目、你可以访问V1版本,前往体验功能。

如果你想完整体验前后台全面的功能、你可以访问测试环境。【root 123456】

如果您认可我的项目,愿意为我献上一个宝贵的Star,你可以前往PixelPunk项目。

image.png

开发这样一个项目在现在这个时间来看似乎没什么必要,实际上开发这样一个项目的原因其实就是在年初的时候失业了一段时间,闲在家里无聊,于是想着做一个自己的开源项目,最开始其实做的是另一个类型的项目,开发了一段时间感觉有些无聊就转战做了现在的这个项目PixelPunk图床, 其实并不只是想要做一个图床,只是当前来说的一期功能比较贴合图床,后期的跌代准备支持更多格式包括视频文档等等,并且由于AI多模态模型的能力发展迅速,后期结合AI可以实现更多可玩性比较高的内容。

市面上已经有了非常多的开源图床了,开发这样一个项目要追求一些差异点才会有价值,本质上来说这类服务其实核心就是个存图片而已,其他功能并不是很重要,但是作为个人使用用户来说,除了存图也愿意去尝试一些便捷的功能,于是思考到这个点之后,我开始了这个项目的开发,项目的命名[PixelPunk] 是我让AI起的,因为要做就要做一个不一样的有特点,我要做一个ui上就不一样的图床出来,于是有了此命名,中文翻译过来是像素朋克,由于像素风格感觉ui不是很适合工具类网站使用,于是我选择了赛博朋克风格,围绕这个ui来开发了此项目。

项目概览

首先呢项目从开发就一个全栈项目,前后端放一起了,采用的技术栈是 go+vue, 前端会将打包的文件放入到go中一起打包为二进制安装包,这样部署起来将非常简单。 项目分为了用户端和管理端,并且同属于一个项目,不分离开发,为了符合我们定制化的ui,所有组件都是自定义的组件,项目使用了70+自定义组件。 项目接入了多模态AI大模型,在以前我们的图床要实现各种功能需要对接各种各样的平台,现在有了AI,我们只需要一个模型就能完成非常多的功能,比如【语义化描述图片】,【**图片OCR识别】,【**图片自动分类】,【**图片自动打标】,【**图标自动审核NSFW、违规图、敏感图、血腥图等等】,【图片颜色提取】等等功能都只需要配置一个AI模型即可,对于成本而言,比之前的三方API可能更加便宜

关于部署

作为一个开源项目,我想的是需要使用者使用起来足够简单,部署起来也足够快,本身来讲,我们项目需要用来这些内容,mysql|Sqlite + Redis|系统内存 + qdrant向量数据库, 这三件套,为了安装简单,我将向量数据库直接集成到安装包,用户可以无需关系任何内容,我们可以做到0配置启动项目,不需要你做任何配置即可超快部署项目。

我们提供了两种部署方式,一种是安装包部署,你可以下载对应平台的安装包**.zip安装包**,下载到你的服务器,解压之后里面有一个install.sh,直接sh ./install.sh即可安装,当然手动部署 可能还需要两个步骤,你可以使用我们的脚本直接进行部署,也可以看看我们的部署文档,PixelPunk部署文档

安装包一键部署 curl -fsSL https://download.pixelpunk.cc/shell/install.sh | bash

docker-compose一键部署脚本 curl -fsSL https://download.pixelpunk.cc/shell/docker-install.sh | bash

我们的部署非常简单,你只需要执行完脚本即可直接启动项目,我们的docker-compose部署方式已经配置了所有数据库缓存等信息,启动项目进入 http://ip:9520 会自动跳转到安装页面,添加管理员账号密码即可完成安装, 如果使用安装包模式呢,那么就支持你可以自定义选择数据库,可以填写自己的mysql,也可以使用系统内置的Sqlite,可以选择自己的向量数据库和缓存,也可以使用系统内置的,自由选择,总之,一键脚本预估20S就可以帮你安装并且启动项目,无需你的任何配置,希望会自己内置生成一些必要信息,比如jwt,系统安装后你可以进入后台管理进行修改。

image.png

项目部分功能

我们的项目功能可以说已经非常全面了,并且还在持续迭代,目前代码总行数已经达到了30W行,很多功能需要你自己体验,我们覆盖了主流图床的全部功能,并且还在进一步持续加入更多有趣的元素,我们可以列举一些功能做简要说明。

10+精美主题

作为个人使用的工具,我一直在持续优化UI和交互,始终认为,UI还是很重要的一个步骤,目前的UI还不够精美,也是后续会持续调整的一个点,目前提供了10多套主题,并且您可以自定义主题,我在项目中放置了主题开发文档,你可以根据模板文件去替换一套变量即可完成一套主题的开发。

image.png

image.png

多语言双风格

我们目前内置了三种语言中英日,并且为了迎合我们PixelPunk风格的特色,我们新增了一种风格选项,你可以选择赛博朋克风格的文案,让系统的所有内容提示充满赛博味道~

image.png

image.png

多布局系统

我们网站为了更好的工作体验,提供了两种的布局方式,传统布局,工作布局,既可以使用传统的轻量化的布局让人轻松,也可以使用工作台布局让工作更高效。并且您还可以在后台限制这些功能,使用固定布局而不开放这些功能,在后台管理部分都可以实现。

image.png

image.png

多种登录方式

我们内置默认使用传统邮箱的登录方式,并且支持关闭注册,邮箱配置也是后台配置即可,同时系统对接了GithubGoogleLinuxDo三种接入成本非常低的快捷登录方式,并且可能由于你是国内服务器,无法直接访问这些服务,所以系统贴心的准备了(代理服务)配置,让你即使是国内服务器也依然可以顺利完成这些快捷登录。

image.png

image.png

强大的特色文件上传

文件上传是一个基础的功能,所有图床都支持,我们当然也支持,我们在此功能上进行了耐心的打磨,支持很多特色功能,

  • 支持后台动态配置允许格式,动态配置上传图片限制尺寸
  • 支持大文件分片上传断点续传等功能。
  • 支持秒传,后台动态开启是否启用
  • 支持游客模式,限制游客上传数量,并限制上传资源有效时间
  • 支持自定义资源保存时间,后台可配置用户允许选择有效期,精确到分钟
  • 支持重复图检测,重复图自动秒传,不占用空间
  • 支持水印,并且特色化水印,开发了一个水印专用面板用于你配置特色化水印,支持文字、图片水印。
  • 支持中断上传,取消上传
  • 支持上传实时进度提示
  • 支持自动优化图片自定义上传文件夹
  • 支持文件夹上传,拖拽上传,项目内全局上传(任意页面都支持上传)
  • 支持原图复制,MD格式复制,HTML格式复制,缩略图格式复制,全部图片链接批量复制
  • 支持公开、私密、受保护,三种权限设置,公开图片可以在任何地方显示并且授权给管理员可以用于推荐,并且在作者首页可以展示对外,私密则仅自己可见,系统其他人不可见,受保护权限图片只能在系统观看打开,无法产生链接,除自己登录系统外,其他人无法观看。
  • 支持持久化,你可以选择图片并且上传中,跳转到任何页面而不会中断你的上传,你可以在上传过程中干任何事情
  • 支持特色悬浮球,当你不在上传页面的其他页面,如果有上传内容,会有一个特色上传球实时告知您上传的进度,并且你可以随意拖动控制悬浮球的位置。

image.png

image.png

image.png

超过上传渠道支持

作为一个图床的基本素养,都会支持对接三方,目前我们已经支持了10+的三方云储存渠道,并且添加时候可以测试渠道保障你的配置,首先我们支持服务器自身存储,这也是系统默认渠道,你可以在后台渠道配置更多,比如阿里云COS,腾讯云OSS,七牛云,雨云,又拍云,S3,Cloudfare R2,WebDav,AZure,Sftp,Ftp,等等渠道,S3协议本身就可以支持非常多的基于S3协议的渠道了,并且,如果你想要更多渠道,可以去往我们官网网站提起诉求,我们可以很快支持新的渠道。

image.png

image.png

特色AI功能

AI也是我们图床的一大特色,我们利用AI做了这些事情

  • 自动分类
  • 自动打标
  • 语义化图片,提取信息,提取色调
  • 实现ai自然语言搜索
  • 实现相似图搜索
  • 实现NSFW违规图审核

而这些,仅仅只需要配置一个openai的gpt4-mini即可完成,后续会支持更多渠道(目前仅支持配置OPENAI格式的渠道),目前测试感觉gpt-4.1-mini足以胜任工作,并且价格低廉,性价比很高。

  • 违规图片检测 可以自定义等级 宽松或严格

image.png

  • 自然语言搜索图片

image.png

  • 相似图搜索

image.png

  • 图片ai描述

image.png

  • 自动分类打标签

image.png

文件夹功能

文件分类是一个和合理的诉求,所以我们可以自定义创建文件夹,同样可以控制不同的权限,并且文件夹可以无限嵌套 你可以自定义多层级的文件夹 合理管理你的文件,并且我们支持文件夹移动图片移动批量操作右键菜单拖拽排序等等特色功能,你可以灵活的管理你的文件。

image.png

  • 右键菜单

image.png

分享系统

我们拥有大量素材,或者一些收藏资源需要分享给好友,我们可以任意创建分享,可以分享自己的文件夹,图片,也可以组合分享,也可以分享用户公开的推荐图,选择任意探索广场的图进行分享,并且可以统计访问次数,限制访问次数,达到访问次数关闭分享,密码分享,限制时间有效期分享,邮箱通知等等功能。

您可以观看我们的演示分享内容

  • 创建分享

image.png

image.png

防盗链管理

图床安全始终是一个问题,经常会遇到被盗刷的风险,由于流量费用贵,鄙人有幸被刷破产过一次(TMD),我们加入了防盗链配置,可以配置白黑名单域名|IP,可以配置网站refer等内容,并且对于违规的用户,我们可以配置让其302到他地址,可以自定义返回固定图,也可以直接拒绝

当我们对接三方渠道的时候,正常情况我们会拿到远程url地址,我们依然可以在后台配置渠道将其隐藏远程url地址,如果配置,那么域名请求将会从我们服务器代理获取,可以隐藏掉三方的地址,或者配置私有存储桶通过秘钥去动态获取图片,防止你的图片被盗刷大量流量

image.png

开放API

图床的基本功能之一,我们可以生成秘钥在三方进行上传文件,不同的是,我们系统支持了设置秘钥时可以限制其使用的空间限制上传次数,可以指定上传的文件夹,指定文件格式等等,并位置提供了完整的上传文档,支持单文件上传,多文件上传

image.png

随机图片

经常会有人需要一个随机图片的API,但是受限于使用别人的不够稳定,也不够灵活,于是我们开放了一个随机API功能,你可以动态的配置,选择其绑定你需要随机的图片,比如指定随机任意文件夹,指定返回方式302重定向,或直接返回图片,你可以点击pixelpunk随机图片API演示 ,每次刷新你可以获取新的图片。

image.png

空间带宽控制

我们允许为用户配置限制的使用空间和流量,后台动态灵活配置这些内容,保证多用户使用的时候限制用户使用量。

更多功能

image.png

image.png

image.png

image.png

image.png

总之我们的功能远不止如此,我们还有很多有意思的功能,一些更多的细节需要你去探索,比如,公告系统、消息通知、活动日志、限制登录、IP登录记录,超全的管理系统、埋点统计、访客系统等等模块,这是我个人第一个花费较多时间开发的一套系统,目前对比市面上所有的开源图床,自我认为是一款相对功能最全面的图床,耗费了我大量时间。

如果佬友花费时间看到了这里,那么希望能收获你的一个宝贵的Star,后续的功能我依然会持续跌代,如果你有任何需求,可以私信我,如果合理,我可以无偿免费优先加入到后续跌代中去。

待更新预期功能

  • 后端多语言适配
  • UI 美化
  • Desktop 端开发
  • 更多格式支持 (视频|文档)
  • 交互体验优化
  • 更多渠道支持
  • 更多AI接入
  • 图片处理工具箱

Node+Express+MySQL 后端生产环境部署,实现注册功能(三)

一、部署前准备

  • 本地环境:MacOS(开发端)
  • 服务器环境:阿里云 Ubuntu 22.04 轻量应用服务器
  • 技术栈:Node.js + Express + MySQL
  • 核心目标:将本地开发完成的 Express 后端项目部署到阿里云,实现公网访问接口

二、服务器环境配置(最终生效配置)

1. 登录服务器

通过 Mac 终端 SSH 连接服务器:

ssh root@你的服务器公网IP # 例如:ssh root@47.101.129.155

输入服务器登录密码即可进入。

2. 安装核心依赖软件
# 更新系统包
sudo apt update && sudo apt upgrade -y

# 安装Node.js(v16+)
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt install -y nodejs

# 安装MySQL服务器
sudo apt install -y mysql-server

# 安装PM2(Node服务进程管理)
sudo npm install pm2 -g

# 安装Nginx(反向代理)
sudo apt install -y nginx

创建本地数据库

  1. 确保数据库已创建:用 MySQL Bench 连接本地 MySQL(root/admin123/3306),创建数据库 mydb(字符集 utf8mb4,排序规则 utf8mb4_unicode_ci)。

三、创建用户表(存储注册信息)

在 MySQL Bench 中,对 mydb 数据库执行以下 SQL,创建 users 表(用于存储注册用户):

USE mydb; -- 切换到 mydb 数据库

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY, -- 自增主键
  email VARCHAR(100) NOT NULL UNIQUE, -- 邮箱(唯一,避免重复注册)
  password VARCHAR(255) NOT NULL, -- 加密后的密码(bcrypt 加密后长度固定60)
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 注册时间
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 更新时间
);

四 新建项目

创建文件夹node-api-test

  1. 安装依赖:需要 mysql2(数据库连接)、bcrypt(密码加密,不能明文存储)、express-validator(参数校验),dotenv 判断环境变量

npm init -y

npm install mysql2 bcrypt express-validator deotnev

2. 多环境配置文件(区分测试 / 正式)

在项目根目录创建 多个 .env 文件,分别对应不同环境:

project/
├── .env.development  # 开发环境(本地调试)
├── .env.production   # 生产环境(正式服务器)
├── .env.test         # 测试环境(可选,测试服务器)
└── .gitignore        # 忽略 .env* 文件,避免提交到 Git

文件内容示例

NODE_ENV=development  # 标识环境
DB_HOST=localhost     # 本地数据库地址
DB_USER=root          # 本地数据库账号
DB_PASSWORD=admin123  # 本地数据库密码
DB_NAME=mydb          # 本地数据库名
API_PORT=3000         # 开发环境端口

.env.production(生产环境):

NODE_ENV=production
DB_HOST=10.0.0.1      # 服务器数据库地址(内网 IP)
DB_USER=prod_user     # 服务器数据库账号(非 root,更安全)
DB_PASSWORD=Prod@123  # 服务器数据库密码(复杂密码)
DB_NAME=mydb_prod     # 生产环境数据库名(可与开发环境不同)
API_PORT=3000         # 生产环境端口
3. 在代码中加载对应环境的配置

db/mysql.js,根据 NODE_ENV 自动加载对应的 .env 文件:

// db/mysql.js(ESM 版)
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';

// 解决 ESM 中 __dirname 问题
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 1. 确定当前环境(默认 development)
const env = process.env.NODE_ENV || 'development';

// 2. 加载对应环境的 .env 文件(如 .env.development 或 .env.production)
const envPath = path.resolve(__dirname, `../.env.${env}`);
dotenv.config({ path: envPath });  // 加载指定路径的 .env 文件

// 3. 从 process.env 中读取配置(环境变量全部是字符串类型)
const dbConfig = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  port: Number(process.env.DB_PORT) || 3306,  // 转换为数字
  connectionLimit: 10,
};

// 创建连接池
const pool = mysql.createPool(dbConfig);

// 测试连接时打印当前环境
export async function testDbConnection() {
  try {
    await pool.getConnection();
    console.log(`✅ 数据库连接成功(环境:${env},数据库:${dbConfig.database}`);
  } catch (err) {
    console.error(`❌ 数据库连接失败(环境:${env}):`, err.message);
    throw err;
  }
}

export { pool };

app.js如下:


import express from 'express'
import bodyParser from 'body-parser'
import userRouter from './routes/user.js'
import { testDbConnection } from './db/mysql.js'
import HttpError from './utils/HttpError.js' // 导入自定义错误类

const app = express()
// 从环境变量读取端口(对应.env中的API_PORT)
const port = process.env.API_PORT || 3000

// 解析JSON请求(必须,否则无法获取req.body)
app.use(bodyParser.json())

// 挂载用户模块路由
app.use('/api/user', userRouter)

// 全局错误处理中间件(必须放在所有路由和中间件之后)
app.use((err, req, res, next) => {
  // 1. 处理自定义HttpError
  if (err instanceof HttpError) {
    return res.status(err.statusCode).json({
      status: err.statusCode, // 业务错误状态码(如400)
      message: err.message, // 错误提示信息
      errors: err.errors, // 详细错误列表(如参数校验错误)
    })
  }

  // 2. 处理系统错误(如数据库连接失败、代码bug等)
  console.error('系统错误堆栈:', err.stack) // 打印堆栈,方便后端调试
  res.status(500).json({
    status: 500,
    message:
      process.env.NODE_ENV === 'production'
        ? '服务器内部错误,请稍后重试' // 生产环境隐藏具体错误
        : `系统错误:${err.message}`, // 开发环境显示具体错误(便于调试)
    errors: [],
  })
})

// 启动服务(端口来自环境变量)
app.listen(port, () => {
  console.log(
    `服务启动成功(环境:${process.env.NODE_ENV}):http://localhost:${port}`
  )
  testDbConnection() // 启动时验证数据库连接
})

注册接口编写

在根目录下新建routes/user.js

import express from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcrypt'
import { pool } from '../db/mysql.js'
import HttpError from '../utils/HttpError.js'

const router = express.Router()

// 注册接口:POST /api/user/register
router.post(
  '/register',
  // 参数校验(字段名与前端传入、数据库字段一致)
  [
    body('email').isEmail().withMessage('邮箱格式错误'), // 对应数据库email字段
    body('password').isLength({ min: 6 }).withMessage('密码至少6位'), // 对应password字段
    body('nickname')
      .optional()
      .isLength({ max: 50 })
      .withMessage('昵称最多50字'), // 对应nickname字段
  ],
  async (req, res, next) => {
    try {
      // 校验参数
      const errors = validationResult(req)
      if (!errors.isEmpty()) {
        throw new HttpError(400, '参数校验失败', errors.array())
      }

      // 解构前端传入的参数(字段名与数据库字段一致)
      const { email, password, nickname } = req.body

      // 1. 检查邮箱是否已注册(SQL中使用email字段,与数据库一致)
      const [existingUsers] = await pool.query(
        'SELECT id FROM users WHERE email = ?', // WHERE条件用email字段
        [email]
      )
      if (existingUsers.length > 0) {
        throw new HttpError(400, '该邮箱已被注册')
      }

      // 2. 密码加密
      //   const hashedPassword = await bcrypt.hash(password, 10)

      // 3. 插入数据库(字段名与数据库表完全一致)
      const [result] = await pool.query(
        'INSERT INTO users (email, password, nickname) VALUES (?, ?, ?)', // 字段顺序:email, password, nickname
        [email, password, nickname || null] // 对应字段的值
      )

      // 4. 返回结果(包含数据库自动生成的id和字段)
      res.status(200).json({
        code: 200,
        message: '注册成功',
        data: {
          userId: result.insertId, // 数据库自增id
          email: email, // 与数据库email字段一致
          nickname: nickname || null, // 与数据库nickname字段一致
          createdAt: new Date().toISOString(),
        },
      })
    } catch (err) {
      next(err)
    }
  }
)

// 获取所有用户
router.get('/allUsers', async (req, res, next) => {
  try {
    const [users] = await pool.query('SELECT * FROM users')
    res.status(200).json({
      code: 200,
      message: '获取成功',
      data: users,
    })
  } catch (err) {
    next(err)
  }
})

export default router

本地postman测试

image.png

数据库查看这条数据

image.png

下一篇学习如何把代码发布到服务器,通过域名来访问接口,实现注册,顺便把前端页面也发布上去

【Amis源码阅读】低代码如何实现交互?(上)

基于 6.13.0 版本

前期回顾

  1. 【Amis源码阅读】组件注册方法远比预想的多!
  2. 【Amis源码阅读】如何将json配置渲染成页面?

前言

  • 组件渲染搞定了,那组件如何进行交互呢?amis提出了事件动作的概念,在监听到事件后通过动作做出反应
    • 事件:
      • 渲染器事件:组件内部执行的事件,会暴露给外部监听。比如初始化、点击、值变化等事件
      • 广播事件:全局事件,其他组件可在自身监听相关广播事件
    • 动作:监听到事件时,希望执行的逻辑。比如打开弹窗、toast提示、刷新接口等
  • 本篇先聊事件的工作逻辑,从常用的渲染器事件入手(渲染器等同组件)

渲染器事件

onEvent事件监听

  • amis支持onEvent的形式监听组件事件的触发时机,比如组件被点击时触发一个toast。那写入onEvent中的动作是何时何地被执行的呢?
{
  "onEvent": {
    "click": {
      "actions": [ 
        {
          "actionType": "toast",
          "args": {
            "msgType": "success",
            "msg": "点击成功"
          }
        }
      ]
    }
  }
}

组件中的事件触发

  • 以常见的Page组件中init(初始化)事件为例,它实际就是在类组件的componentDidMount生命周期(挂载阶段)中触发了一次,dispatchEvent就是事件的入口了
// packages/amis/src/renderers/Page.tsx

export default class Page extends React.Component<PageProps> {
...

async componentDidMount() {
    const {
      initApi,
      initFetch,
      initFetchOn,
      store,
      messages,
      data,
      dispatchEvent,
      env
    } = this.props;

    this.mounted = true;

    const rendererEvent = await dispatchEvent('init', data, this);
...
  }
}
  • 再以Tpl组件中的click(点击)、mouseenter(鼠标移入)、mouseleave(鼠标移出)事件为例,可以直观的看出他们就是在组件绑定的onClickonMouseEnteronMouseLeave事件中执行了一遍dispatchEvent
  • 此时可以推测出,onEvent应该是在dispatchEvent中被执行了
// packages/amis/src/renderers/Tpl.tsx

export interface TplSchema extends BaseSchema {
...

@autobind
  handleClick(e: React.MouseEvent<HTMLDivElement>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }
  
  @autobind
  handleMouseEnter(e: React.MouseEvent<any>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }

  @autobind
  handleMouseLeave(e: React.MouseEvent<any>) {
    const {dispatchEvent, data} = this.props;
    dispatchEvent(e, data);
  }
  
  render() {
  return (
      <Component
      ...
        onClick={this.handleClick}
        onMouseEnter={this.handleMouseEnter}
        onMouseLeave={this.handleMouseLeave}
        {...testIdBuilder?.getChild('tpl')?.getTestId()}
      >
        ...
      </Component>
    );
  }
}

工作流

触发事件(dispatchEvent)

  • broadcast参数可忽略,没用(factory.tsx中的类型定义也说明了这点)
  • rendererEventListeners是个全局变量(事件队列),所有的事件监听器都存储在这
  • dispatchEvent流程
    • bindEvent绑定事件,返回unbindEvent销毁函数
    • createRendererEvent创建事件对象
    • 事件队列里的事件按权重排序确定执行优先级
    • 遍历事件队列,若有事件有debounce属性,就设置防抖,同时设置executing为真;若没有就直接执行事件(runAction
    • 遍历事件队列的时候会通过checkExecuted函数计数,当遍历完毕后(也就意味着事件都执行完毕了,会等待防抖的事件执行完),执行unbindEvent销毁事件
// packages/amis-core/src/utils/renderer-event.ts

let rendererEventListeners: RendererEventListener[] = [];
...

// 触发事件
export async function dispatchEvent(
  e: string | React.MouseEvent<any>,
  renderer: React.Component<RendererProps>,
  scoped: IScopedContext,
  data: any,
  broadcast?: RendererEvent<any>
): Promise<RendererEvent<any> | void> {
  let unbindEvent: ((eventName?: string) => void) | null | undefined = null;
  const eventName = typeof e === 'string' ? e : e.type;

  const from = renderer?.props.id || renderer?.props.name || '';
...

  broadcast && renderer.props.onBroadcast?.(e as string, broadcast, data);

  if (!broadcast) {
    const eventConfig = renderer?.props?.onEvent?.[eventName];

    if (!eventConfig) {
      // 没命中也没关系
      return Promise.resolve();
    }

    unbindEvent = bindEvent(renderer);
  }
  // 没有可处理的监听
  if (!rendererEventListeners.length) {
    return Promise.resolve();
  }
  // 如果是广播动作,就直接复用
  const rendererEvent =
    broadcast ||
    createRendererEvent(eventName, {
      env: renderer?.props?.env,
      nativeEvent: e,
      data,
      scoped
    });

  // 过滤&排序
  const listeners = rendererEventListeners
    .filter(
      (item: RendererEventListener) =>
        item.type === eventName &&
        (broadcast
          ? true
          : item.renderer === renderer &&
            item.actions === renderer.props?.onEvent?.[eventName].actions)
    )
    .sort(
      (prev: RendererEventListener, next: RendererEventListener) =>
        next.weight - prev.weight
    );
  let executedCount = 0;
  const checkExecuted = () => {
    executedCount++;
    if (executedCount === listeners.length) {
      unbindEvent?.(eventName);
    }
  };
  for (let listener of listeners) {
    const {
      wait = 100,
      trailing = true,
      leading = false,
      maxWait = 10000
    } = listener?.debounce || {};
    if (listener?.debounce) {
      const debounced = debounce(
        async () => {
          await runActions(listener.actions, listener.renderer, rendererEvent);
          checkExecuted();
        },
        wait,
        {
          trailing,
          leading,
          maxWait
        }
      );
      rendererEventListeners.forEach(item => {
        // 找到事件队列中正在执行的事件加上标识,下次待执行队列就会把这个事件过滤掉
        if (
          item.renderer === listener.renderer &&
          listener.type === item.type
        ) {
          item.executing = true;
          item.debounceInstance = debounced;
        }
      });
      debounced();
    } else {
      await runActions(listener.actions, listener.renderer, rendererEvent);
      checkExecuted();
    }

    if (listener?.track) {
      const {id: trackId, name: trackName} = listener.track;
      renderer?.props?.env?.tracker({
        eventType: listener.type,
        eventData: {
          trackId,
          trackName
        }
      });
    }

    // 停止后续监听器执行
    if (rendererEvent.stoped) {
      break;
    }
  }
  return Promise.resolve(rendererEvent);
}

绑定事件(bindEvent)

  • 所谓绑定事件就是把事件推入事件队列
  • 首先会遍历onEvent中的内容
  • 然后处理防抖场景:如果存在相同的事件且在防抖时间内(executing为真),则取消旧事件防抖并移除旧事件,把新事件加入事件队列。比如说,事件队列中存有用户触发了3次的事件a(假设都在防抖时间内),则前2次事件在bindEvent阶段会被删除,只保留第3次事件
  • 如果不存在上述情况,直接加入事件队列
  • 最终都是返回解绑事件的函数(从事件队列中移除)
// packages/amis-core/src/utils/renderer-event.ts

// 绑定事件
export const bindEvent = (renderer: any) => {
  if (!renderer) {
    return undefined;
  }
  const listeners: EventListeners = renderer.props.$schema.onEvent;
  if (listeners) {
    // 暂存
    for (let key of Object.keys(listeners)) {
      const listener = rendererEventListeners.find(
        (item: RendererEventListener) =>
          item.renderer === renderer &&
          item.type === key &&
          item.actions === listeners[key].actions
      );
    // 存在相同的事件且在防抖时间内
      if (listener?.executing) {
        listener?.debounceInstance?.cancel?.();
        rendererEventListeners = rendererEventListeners.filter(
          (item: RendererEventListener) =>
            !(
              item.renderer === listener.renderer && item.type === listener.type
            )
        );
        listener.actions.length &&
          rendererEventListeners.push({
            renderer,
            type: key,
            debounce: listener.debounce || null,
            track: listeners[key].track || null,
            weight: listener.weight || 0,
            actions: listener.actions
          });
      }
      if (!listener && listeners[key].actions?.length) {
        rendererEventListeners.push({
          renderer,
          type: key,
          debounce: listeners[key].debounce || null,
          track: listeners[key].track || null,
          weight: listeners[key].weight || 0,
          actions: listeners[key].actions
        });
      }
    }
    return (eventName?: string) => {
      // eventName用来避免过滤广播事件
      rendererEventListeners = rendererEventListeners.filter(
        (item: RendererEventListener) =>
          // 如果 eventName 为 undefined,表示全部解绑,否则解绑指定事件
          eventName === undefined
            ? item.renderer !== renderer
            : item.renderer !== renderer || item.type !== eventName
      );
    };
  }

  return undefined;
};

执行动作(runActions)

  • 这里只是一个执行动作的前置处理
  • 遍历动作,通过getActionByType查找动作实例,若没有则判断是否是组件专有动作(组件都有可调用),若再没有则判断是否是打开页面相关的动作,若还是没有则直接调用组件自定义的动作
  • 实际的动作执行还是在runAction中,等下一篇再完整的分析动作相关流程
// packages/amis-core/src/actions/Action.ts

export const runActions = async (
  actions: ListenerAction | ListenerAction[],
  renderer: ListenerContext,
  event: any
) => {
  if (!Array.isArray(actions)) {
    actions = [actions];
  }

  for (const actionConfig of actions) {
    let actionInstrance = getActionByType(actionConfig.actionType);

    // 如果存在指定组件ID,说明是组件专有动作
    if (
      !actionInstrance &&
      (actionConfig.componentId || actionConfig.componentName)
    ) {
      actionInstrance = [
        'static',
        'nonstatic',
        'show',
        'visibility',
        'hidden',
        'enabled',
        'disabled',
        'usability'
      ].includes(actionConfig.actionType)
        ? getActionByType('status')
        : getActionByType('component');
    } else if (['url', 'link', 'jump'].includes(actionConfig.actionType)) {
      // 打开页面动作
      actionInstrance = getActionByType('openlink');
    }

    // 找不到就通过组件专有动作完成
    if (!actionInstrance) {
      actionInstrance = getActionByType('component');
    }

    try {
      // 这些节点的子节点运行逻辑由节点内部实现
      await runAction(actionInstrance, actionConfig, renderer, event);
    } catch (e) {
      ...
    }

    if (event.stoped) {
      break;
    }
  }
};

设计特性

全局事件管理

  • 通过rendererEventListeners队列统一管理,和react的事件委托有点类似
  • 支持跨组件通信
  • 支持全局权重排序、防抖

延迟绑定,执行完销毁

  • 事件都是触发后,在bindEvent中绑定的(加入事件队列),减少内存占用
  • 然后执行完毕会立即销毁,避免内存泄漏

广播事件

  • 独立于渲染器事件的全局事件,基于BroadcastChannel类实现

工作流

  • 由于是全局事件,肯定得优先绑定了

绑定事件入口

  • 组件渲染(渲染流程可参考之前的组件渲染篇)时绑定
  • 组件生成时都会传入childRef,直接在组件的ref上通过bindGlobalEvent绑定了广播事件
// packages/amis-core/src/SchemaRenderer.tsx

import {
  bindEvent,
  bindGlobalEventForRenderer as bindGlobalEvent
} from './utils/renderer-event';

export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
...
@autobind
  childRef(ref: any) {
    ...
    while (ref?.getWrappedInstance?.()) {
      ref = ref.getWrappedInstance();
    }

    ...

    if (ref) {
      // 这里无法区分监听的是不是广播,所以又bind一下,主要是为了绑广播
      this.unbindEvent?.();
      this.unbindGlobalEvent?.();

      this.unbindEvent = bindEvent(ref);
      this.unbindGlobalEvent = bindGlobalEvent(ref);
    }
    ...
  }
  
  render(): JSX.Element | null {
...

    let component = supportRef ? (
      <Component {...props} ref={this.childRef} storeRef={this.storeRef} />
    ) : (
      <Component
        {...props}
        forwardedRef={this.childRef}
        storeRef={this.storeRef}
      />
    );

    ...

    return this.props.env.enableAMISDebug ? (
      <DebugWrapper renderer={renderer}>{component}</DebugWrapper>
    ) : (
      component
    );
  }
}

绑定事件(bindGlobalEventForRenderer)

  • 这里并未区分广播事件
  • 遍历事件,创建BroadcastChannel对象,推入bcs广播事件队列
  • 挂载onmessage消息监听,接收到广播时通过runActions触发动作
  • 最终返回一个注销广播实例的函数
  • 小疑问:这里直接把renderer.props.$schema.onEvent中所有的动作都绑定了广播事件,虽然统一管理了广播事件的绑定,但是绑定了很多多余的动作,这里实际可以判断actionTypebroadcast才绑定?
// packages/amis-core/src/utils/renderer-event.ts

export const bindGlobalEventForRenderer = (renderer: any) => {
  ...
  const listeners: EventListeners = renderer.props.$schema.onEvent;
  let bcs: Array<{
    renderer: any;
    bc: BroadcastChannel;
  }> = [];
  if (listeners) {
    for (let key of Object.keys(listeners)) {
      const listener = listeners[key];
      ...
      const bc = new BroadcastChannel(key);
      bcs.push({
        renderer: renderer,
        bc
      });
      bc.onmessage = e => {
        const { eventName, data } = e.data;
        const rendererEvent = createRendererEvent(eventName, {
          env: renderer?.props?.env,
          nativeEvent: eventName,
          scoped: renderer?.context,
          data
        });
        // 过滤掉当前的广播事件,避免循环广播
        const actions = listener.actions.filter(
          a => !(a.actionType === 'broadcast' && a.eventName === eventName)
        );

        runActions(actions, renderer, rendererEvent);
      };
    }
    return () => {
      bcs
        .filter(item => item.renderer === renderer)
        .forEach(item => item.bc.close());
    };
  }
  return void 0;
};

触发事件(dispatchGlobalEventForRenderer)

  • 广播动作packages/amis-core/src/actions/BroadcastAction.ts中调用了dispatchGlobalEventForRenderer
  • 代码较短,内部直接调用dispatchGlobalEvent方法,然后创建BroadcastChannel实例发送消息,然后关闭,齐活!
  • 接收消息的地方就是上文bindGlobalEventForRenderer中挂载了onmessage事件的地方,不赘述
// packages/amis-core/src/utils/renderer-event.ts

export async function dispatchGlobalEventForRenderer(
  eventName: string,
  renderer: React.Component<RendererProps>,
  scoped: IScopedContext,
  data: any,
  broadcast: RendererEvent<any>
) {
  ...
  dispatchGlobalEvent(eventName, data);
}

export async function dispatchGlobalEvent(eventName: string, data: any) {
  ...

  const bc = new BroadcastChannel(eventName);
  bc.postMessage({
    eventName,
    data
  });
  bc.close();
}

解绑事件

  • 广播事件是长期绑定的,只有在组件卸载时才解绑
// packages/amis-core/src/SchemaRenderer.tsx

import {
  bindEvent,
  bindGlobalEventForRenderer as bindGlobalEvent
} from './utils/renderer-event';

export class SchemaRenderer extends React.Component<SchemaRendererProps, any> {
...
componentWillUnmount() {
    this.unbindEvent?.();
    this.unbindGlobalEvent?.();
  }

}

总结

  • amis的事件管理还是挺值得学习的
  • 渲染器事件就是在组件的执行过程中开了个口子,支持插入想执行的逻辑
  • 广播事件就是依赖BroadcastChannel的原生功能
  • 下篇再写动作,脑子不够用了

JavaScript-小游戏-2048

需求

开局生成随机靠边方块 点击方向键移动方块 相邻方块且数值相同发生合并 方块占满界面触发游戏结束提示

游戏界面

标签结构HTML

area区域和death区域分别表示游戏区域和死亡提示区域

 <!-- 页面 -->
  <div class="area"></div>
  <!-- 死亡提示区域 -->
  <div class="death"></div>

层叠样式 css

设置游戏区域为flex布局 主轴默认是水平 flex-wrap: wrap;设置主轴自动换行 注意这里游戏区域的大小是200px*200px的 后面可以算出每个方块的大小

 .area {
      display: flex;
      flex-wrap: wrap;
      width: 200px;
      height: 200px;
      font-size: 30px;
    }

获取元素 js

获取两个游戏区域和游戏结束提示区域的div

   const area = document.querySelector('.area')
    const death = document.querySelector('.death')

数据分析

二维数组arr[y][x]第一个索引表示y坐标 第二个索引表示x坐标 数组的值表示方块的数值(2,4,8...)没有值就表示这个坐标位置没有方块 这里还要初始化一个rArr数组是用来将数组旋转结果存入新数组防止重复处理的

后面的移动和合并都是需要旋转数组进行操作

let arr = [] //方块坐标和数值
    let rArr = [] //旋转数组
    //初始化
    for (let i = 0; i < 5; i++) {
      arr[i] = []
      rArr[i] = []
    }

功能实现

渲染方法

  • 像素点的思路渲染游戏区域为5*5个方块 所以算出每个方块 的大小为40px*40px 渲染
  • 如果有方块 就渲染为粉色 方块的数值${arr[y][x]}也渲染在div
  • 其余部分就是画布
  • block字符串累加完毕之后渲染到页面上
 //渲染页面
    function render() {
      block = ''
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            block += `<div class="square"style="width: 40px;height: 40px;text-align: center; line-height: 40px; background-color: pink;">${arr[y][x]}</div>`
          }
          else {
            block += ' <div class="block" style="width: 40px;height: 40px;background-color: antiquewhite;"></div>'
          }
        }
      }
      area.innerHTML = block
    }
    render()

游戏区域页面如下 游戏区域

开局随机生成两个方块

这里的方块坐标不能重复

  • 声明两个数字类型的全局变量 用来表示两个随机生成的坐标
  • 这里的randomStart函数用来随机生成不重复的两个坐标 ac变量表示随机生成[0,4]之间的整数坐标 如果生成的随机整数坐标a c重复 就重新调用randomStart函数生成
  • 这里运用了三元运算符 来根据 Math.random() 随机生成[0,1)范围内的两个小数的大小 决定这两个方块的坐标位置 这里坐标位置可以重复
  • 第一个方块Math.random()> 0.5 执行前面的表达式arr[0][a] = 2 方块随机在上面的边 否则执行后面的表达式 arr[a][0] = 2方块随机在左边的边 第二个方块同理

三元运算符

条件?表达式A:表达式B 条件为真执行前面的表达式A 条件为假执行后面的表达式B三元运算符例常见用法是处理null值 这里表示 如果传入的参数是假的话name的值为 'stranger' 如果是真的话就是参数name属性的值

 //三元运算符
    //以箭头函数形式 定义greeting函数 这里传入的参数是对象
    const greeting = (person) => {
      const name = person ? person.name : 'stranger'
      return `hello ${name}`
    }
    console.log(greeting({ age: 18, name: 'a' })); //hello a

    //这里三元运算符是真 
    // 但是字符串本身没有name属性
    console.log(greeting('b')); //undefined

    //传入的都是字面量 是最简单的表达式 可以通过代码直接看出值
    //这些是假值 
    //数字字面量
    console.log(greeting(0)); //hello stranger
    //null字面量
    console.log(greeting(null)); //hello stranger
    //字符串字面量
    console.log(greeting('')); //hello stranger

最终开局随机生成两个方块功能代码如下

//随机生成两个不相同随机坐标
    let a = 0, c = 0
    function randomStart() {
      //生成的随机数不能重复 连续两次的话
      a = Math.floor(Math.random() * 5)  //0-5
      c = Math.floor(Math.random() * 5)
      if (c === a) {
        randomStart()
      }
    }
    //开局生成两个不重复的方块
    randomStart()
    Math.random() > 0.5 ? arr[0][a] = 2 : arr[a][0] = 2
    Math.random() > 0.5 ? arr[0][c] = 2 : arr[c][0] = 2

效果如下 随机

点击方向键移动方块

分析点击方向键之后方块变化

这里有上下左右四个方向 每个方向都要写移动逻辑的话代码冗余了 所以可以根据要旋转的方向不同对数组进行旋转处理 然后再左移 这时候再进行左移这个方向的合并判断 最后把数组旋转回去 移动处理完毕之后还需要生成随机新方块

这里的设计不太符合单一职责(一个函数 模块只负责一件事)事件监听承受了太多功能 目前还不知道怎么优化比较好

根据分析将功能拆分

  • 除了向左移动的情况 其他方向都是 点击键盘方向键之后先更改type的值再根据type调用rotate()函数旋转数组 move()往左移动 add()判断合并最后再rotate()旋转回去

这里的rotate()要传入具体的type参数 因为在上下移动过程中出现了顺时针和逆时针的旋转 这里将数组旋转回去的时候要传入的type就不同了

  • 向左移动的情况很简单 只需要直接move()移动再执行合并 移动完之后
  • 生成新随机方块 random()
  • 此时arr已经处理完毕 rander()渲染页面
  • 判断是否死亡 deathJudge() 键盘按键事件如下
let type = ''//移动方向
    //每次移动完生成一个边缘新方块
    document.addEventListener('keydown', function (e) {
      if (e.key === 'ArrowUp') {
        type = 'up'
        rotate('up')//旋转
        move()//移动
        add()
        rotate('down')//旋转回去
      }

      //这里处理错了down
      else if (e.key === 'ArrowDown') {
        type = 'down'
        rotate('down')//旋转
        move()//移动
        add()
        rotate('up')//旋转回去
      }
      else if (e.key === 'ArrowLeft') {
        type = 'left'
        move()
        add()
      }
      else if (e.key === 'ArrowRight') {
        type = 'right'
        rotate('right')//旋转
        move()//移动
        add()
        //因为这里是翻转 所以和之前一样处理翻转回去就行
        rotate('right')
      }
      //移动处理完毕生成新随机方块
      random()
      render()
      deathJudge()
    })

旋转处理

  • 如果有值就根据type不同旋转 right 这里相当于翻转 所以旋转回去只需要再翻转一次rotate('right')就行rightdown这里相当于顺时针旋转90度 所以旋转回去需要逆时针旋转90度也就是rotate(up)down

up这里相当于逆时针旋转90度 所以旋转回去需要顺时针旋转90度也就是rotate('down')up

  • 旋转完毕后 删除已经处理完旋转的元素 便于后面将旋转之后的数组给arr

这里删除处理也可以用delete 不会破坏索引!delete

  • 然后遍历rArr把数值给arr 再把rArr相应的元素删除方便下次rotate()旋转

放进新数组是为了防止旋转后的元素被重复旋转遍历 最终代码如下

  //翻转数组 变成左移
    function rotate(type) {
      console.log('rotate', type);
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            //翻转
            if (type === 'right') {
              //这里旋转结果必须放进新数组防止重复处理
              rArr[y][4 - x] = arr[y][x]
            }
            //顺时针90
            else if (type === 'down') {
              rArr[x][4 - y] = arr[y][x]
            }
            //逆时针90
            else if (type === 'up') {
              rArr[4 - x][y] = arr[y][x]
            }
            //删除原位置元素
            // arr[y][x] = 0
            delete arr[y][x]
          }
        }
      }
      //把旋转之后的数组给arr 方便转回去
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          if (rArr[j][i]) {
            arr[j][i] = rArr[j][i]
            delete rArr[j][i]
          }
        }
      }
      // console.log('rotate旋转处理之后的arr', arr);
    }

移动处理

旋转完毕后 数组只需要左移动就可以

  • 首先遍历arr数组 如果这行出现空位 !arr[y][x]true 表示 arr[y][x]为false也就是为空和0的时候 执行后面的语句
  • 遍历这一行空位后面的部分 这里循环的起始点是i = x 如果后面有值就给前面空位 这里记得删掉后面的值 然后直接break跳出空位后元素遍历循环继续对这一行进行空位搜索

补充说明breakcontinue的区别 break只跳出一层循环 这里相当于 x>=1的情况下都不执行内层循环了 breakbreakcontinue跳过当前 然后进行下一个迭代 这里相当于只有在x===1的情况下才不执行内层循环 其他情况是正常执行的 continuecontinue

    // 移动逻辑
    //所有数组都旋转处理成左移判断
    function move() {
      console.log('move');
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (!arr[y][x]) {
            //后面如果后面有值就给前面空位
            for (let i = x; i < 5; i++) {
              //只执行一次 找出最靠近空位的值
              if (arr[y][i]) {
                arr[y][x] = arr[y][i]
                arr[y][i] = 0
                //这里用哪个符号比较好
                break //跳出for i 循环么
              }
            }
          }
        }
      }
    }

点击方向键移动方块效果如下 移动

合并判断

这里合并判断也会根据方向不同 判断的方向不同 所以这里放在数组旋转回去之前 所以无论方向如何都是对左移进行合并判断

  • 合并这里是向左合并的 判断的是arr[j][i] 和右边相邻位置 arr[j][i + 1]的元素数值 这里的判断范围缩小i < 4 如果相等就左边方块数值累加然后右边方块数值清空

注意 这里只完成了数值的合并 合并完之后还要向左移动 如图例合并之后移动

  • 全部合并完之后要都向左移动move()
 //合并
    function add() {
      console.log('合并add', type, arr);
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 4; i++) {
          //相邻检测
          if (arr[j][i]) {
            if (arr[j][i] === arr[j][i + 1]) {
              console.log('找到左移方块', j, i);
              arr[j][i] += arr[j][i]
              arr[j][i + 1] = 0
            }
          }
        }
      }
      //全部合并完之后再向左移动
      move()
    }

合并效果如下 合并

移动完毕生成新随机方块

这些方块是从没有方块的坐标中随机生成的

  • scope数组表示没有方块的坐标 第一个索引表示方块序号可以用来随机选取方块 第二个索引表示方块的x或者y值 scope
  • 循环遍历arr把没有值的坐标放进scope数组
  • index表示随机的scope索引 范围为[0,scope.length-1]

Math.floor Math.random 表示从[a,b]随机整数 Math.floor(Math.random() * (b - a + 1)) + a; Math.random()随机生成[0,1)之间的随机小数 Math.random() * (b - a + 1) 生成[0,b-a+1) Math.floor 对小数向下取整得到[0,b-a]之间的整数 最后再加上a 范围偏移成[a,b]

  • 这里randomY表示随机到的y坐标 也就是scope[index][0]index个方块的第0个坐标
  • 最后随机生成的方块数值可能为2和4 继续用Math.random()随机生成的小数和0.5的大小比较决定随机生成方块的数值
//移动之后再随机生成一个方块 数值可能是4或者2
    function random() {
      let scope = []
      //因为可能后面没有值 长度实际上小于10
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          //方块不重复
          //把没有值的坐标放进数组 然后再随机索引进行选择
          if (!arr[j][i]) {
            scope.push([j, i])
          }
        }
      }
      //随机索引
      let index = Math.floor(Math.random() * scope.length)
      let randomY = scope[index][0]
      let randomX = scope[index][1]

      //随机数值
      Math.random() > 0.5 ? arr[randomY][randomX] = 2 : arr[randomY][randomX] = 4
    }

移动完毕生成新随机方块效果如下 新方块

死亡功能

死亡判断

开局只生成两个方块不需要判断 之后每次移动完毕都要判断一次

  • death表示是否出现死亡 Boolean类型初始值是true
  • 遍历arr 如果位置上元素没有值 就还没有死亡 death = false 这个是要找的非常态 出现这种情况就知道最终结果了 所以初始值是常态true 元素上有值还要继续往下找

这里不能用!arr[j].every((value) => value > 1) 因为every对稀疏数组的空槽是不执行的 在random()randomStart()给随机坐标赋值的时候造成了arr[j]是稀疏数组 会导致判断失误 补充说明 稀疏数组创建方式稀疏数组

  • 最终的death值就能表示死亡情况
  • 如果death === true就执行死亡效果
   //死亡判断
    function deathJudge() {
      let death = true //表示是否进入死亡
      for (let y = 0; y < 5; y++) {
        //如果这一行有空位 就跳出循环
        //这里不是表示每个都大于
        //不能用every因为稀疏数组不执行
        for (let x = 0; x < 5; x++) {
          //有一个位置上的元素没有值 就不是死亡
          if (!arr[y][x]) {
            death = false
          }
        }
      }
      console.log(death, '死亡判断');
      if (death === true) {
        deachCss()  //死亡效果
      }
    }

死亡效果

要找出方块数值最大值打印在游戏结束区域

  • maxArr数组用来放每行的最大值
  • maxX表示每行的最大值 找出来之后放进maxArr数组
  • arr[y]采用线性遍历 像直线一样逐个排查 这里默认每行的第一个元素为最大值 然后遍历后面的元素 如果比maxX大 就更新 把值给maxX

后面还会更新数组最大值的一些比较方法

  • 把每行的最大值pushmaxArr数组
  • 然后再对maxArr进行线性遍历 从而找到整个arr数组的最大值
  • 最后把最大数值渲染到页面
 function deachCss() {
      //找到数组中的最大数值
      let maxArr = []
      let maxX = 0
      for (let y = 0; y < 5; y++) {
        maxX = arr[y][0]
        for (let x = 1; x < 5; x++) {
          if (arr[y][x] > maxX) {
            maxX = arr[y][x]
          }
        }
        maxArr.push(maxX) //每行的最大
      }
      console.log(maxArr);
      //再从每行的最大里面找
      let max = maxArr[0]
      for (let a = 1; a < maxArr.length; a++) {
        if (max < maxArr[a]) {
          max = maxArr[a]
        }
      }
      //死亡效果
      death.innerText = `游戏结束 最大方块为${max}`
    }

死亡效果如下 死亡效果

最终代码

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>2048</title>
</head>

<body>
  <!-- 页面 -->
  <div class="area"></div>
  <!-- 死亡提示区域 -->
  <div class="death"></div>
  <style>
    .area {
      display: flex;
      flex-wrap: wrap;
      width: 200px;
      height: 200px;
      font-size: 25px;
    }
  </style>
  <script>
    const area = document.querySelector('.area')
    const death = document.querySelector('.death')
    let arr = [] //方块坐标和数值
    let rArr = [] //旋转数组
    //初始化
    for (let i = 0; i < 5; i++) {
      arr[i] = []
      rArr[i] = []
    }
    deathJudge() //开局只随机生成两个方块不需要判断死亡

    //随机生成两个不相同随机坐标
    let a = 0, c = 0
    function randomStart() {
      //生成的随机数不能重复 连续两次的话
      a = Math.floor(Math.random() * 5)  //0-5
      c = Math.floor(Math.random() * 5)
      if (c === a) {
        randomStart()
      }
    }
    //开局生成两个不重复的方块
    randomStart()
    Math.random() > 0.5 ? arr[0][a] = 2 : arr[a][0] = 2
    Math.random() > 0.5 ? arr[0][c] = 2 : arr[c][0] = 2

    //移动之后再随机生成一个方块 数值可能是4或者2
    function random() {
      let scope = []
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          //方块不重复
          //把没有值的坐标放进数组 然后再随机索引进行选择
          if (!arr[j][i]) {
            scope.push([j, i])
          }
        }
      }
      //随机索引
      let index = Math.floor(Math.random() * scope.length)
      let randomY = scope[index][0]
      let randomX = scope[index][1]

      //随机数值
      Math.random() > 0.5 ? arr[randomY][randomX] = 2 : arr[randomY][randomX] = 4
    }


    let type = ''//移动方向
    //每次移动完生成一个边缘新方块

    document.addEventListener('keydown', function (e) {
      if (e.key === 'ArrowUp') {
        type = 'up'
        rotate('up')//旋转
        move()//移动
        add()
        rotate('down')//旋转回去

      }

      //这里处理错了down
      else if (e.key === 'ArrowDown') {
        type = 'down'
        rotate('down')//旋转
        move()//移动
        add()
        rotate('up')//旋转回去

      }
      else if (e.key === 'ArrowLeft') {
        type = 'left'
        move()
        add()
      }
      else if (e.key === 'ArrowRight') {
        type = 'right'
        rotate('right')//旋转
        move()//移动
        add()
        //因为这里是翻转 所以和之前一样处理翻转回去就行
        rotate('right')
      }
      //移动处理完毕生成新随机方块
      random()
      render()
      deathJudge()
    })

    //翻转数组 变成左移
    function rotate(type) {
      console.log('rotate', type);
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            //翻转
            if (type === 'right') {
              //这里旋转结果必须放进新数组防止重复处理
              rArr[y][4 - x] = arr[y][x]
            }
            //顺时针90
            else if (type === 'down') {
              rArr[x][4 - y] = arr[y][x]
            }
            //逆时针90
            else if (type === 'up') {
              rArr[4 - x][y] = arr[y][x]
            }
            //删除原位置元素
            // arr[y][x] = 0
            delete arr[y][x]
          }
        }
      }
      //把旋转之后的数组给arr 方便转回去
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          if (rArr[j][i]) {
            arr[j][i] = rArr[j][i]
            delete rArr[j][i]
          }
        }
      }
      // console.log('rotate旋转处理之后的arr', arr);
    }


    // 移动逻辑
    //所有数组都旋转处理成左移判断
    function move() {
      console.log('move');
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (!arr[y][x]) {
            //后面如果后面有值就给前面空位
            for (let i = x; i < 5; i++) {
              //只执行一次 找出最靠近空位的值
              if (arr[y][i]) {
                arr[y][x] = arr[y][i]
                arr[y][i] = 0
                //这里用哪个符号比较好
                break //跳出for i 循环么
              }
            }
          }
        }
      }
    }

    //合并
    function add() {
      console.log('合并add', type, arr);
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 4; i++) {
          //相邻检测
          if (arr[j][i]) {
            if (arr[j][i] === arr[j][i + 1]) {
              console.log('找到左移方块', j, i);
              arr[j][i] += arr[j][i]
              arr[j][i + 1] = 0
            }
          }
        }
      }
      //全部合并完之后再向左移动
      move()
    }

    //死亡判断
    function deathJudge() {
      let death = true //表示是否进入死亡
      for (let y = 0; y < 5; y++) {
        //如果这一行有空位 就跳出循环
        //这里不是表示每个都大于
        //不能用every因为稀疏数组不执行
        for (let x = 0; x < 5; x++) {
          //有一个位置上的元素没有值 就不是死亡
          if (!arr[y][x]) {
            death = false
          }
        }
      }
      console.log(death, '死亡判断');
      if (death === true) {
        deachCss()  //死亡效果
      }
    }


    function deachCss() {
      //找到数组中的最大数值
      let maxArr = []
      let maxX = 0
      for (let y = 0; y < 5; y++) {
        maxX = arr[y][0]
        for (let x = 1; x < 5; x++) {
          if (arr[y][x] > maxX) {
            maxX = arr[y][x]
          }
        }
        maxArr.push(maxX) //每行的最大
      }
      console.log(maxArr);
      //再从每行的最大里面找
      let max = maxArr[0]
      for (let a = 1; a < maxArr.length; a++) {
        if (max < maxArr[a]) {
          max = maxArr[a]
        }
      }
      //死亡效果
      death.innerText = `游戏结束 最大方块为${max}`
    }


    //渲染页面
    function render() {
      block = ''
      for (let y = 0; y < 5; y++) {
        for (let x = 0; x < 5; x++) {
          if (arr[y][x]) {
            block += `<div class="square"style="width: 40px;height: 40px;text-align: center; line-height: 40px; background-color: pink;">${arr[y][x]}</div>`
          }
          else {
            block += ' <div class="block" style="width: 40px;height: 40px;background-color: antiquewhite;"></div>'

          }
        }
      }
      area.innerHTML = block
    }
    render()


  </script>
</body>

</html>

JavaScript-实现函数方法-改变this指向call apply bind

this

  • 函数执行时决定的不是定义时决定的
  • this只和函数的调用有关*
  • obj.fn() fnthis就是obj arr[0] () this就是arr

重定义

call

  • 会直接执行函数
  • 函数的参数逐个传进去 call
实现
  • 首先先判断thisArg参数 如果为空就默认window 这里运用了||运算符 有一个t结果就为t 所以短路效果为前面的表达式结果为t 后面的表达式就不执行

补充说明&&运算符 前面一个f结果就为f 所以短路效果是前面为f后面不执行

  • symbol(基本数据类型)有唯一性 可以保证属性与原对象不冲突 let key = Symbol('temp')创建一个symbol类型的变量key其中temp是这个变量的描述符
  • 利用对象的方法中的this指向对象 :通过把这个函数给thisArg对象的方法再通过这个对象调用就改变了函数this的指向 其中利用数组的展开运算符把value(剩余参数数组)挨个传进这个方法完成了mycall改变this指向和直接执行函数的功能
    //实现call
    Function.prototype.myCall = function (thisArg, ...value) {
      //如果参数为空 就默认是window
      //利用||特性
      thisArg = thisArg || window

      let key = Symbol('temp')
      thisArg[key] = this
      //利用...数组的展开运算符把value的所有元素 逐个全部传入
      thisArg[key](...value)
      delete thisArg[key]

    }
  • 测试用例
//测试用例
    //this指向对象
    function fnCall(a, b) {
      console.log(this, a, b);
    }
    fnCall.myCall(obj, 1, 2)
    //this指向函数
    function fn1() {
      console.log('这是测试函数');
    }
    fnCall.myCall(fn1, 1, 2)

apply

  • 也会直接执行函数
  • 参数以对象(一般是数组的形式传进去) apply
实现

mycall的基本思想是一样的区别如下

  • 因为参数是数组形式 所以myApply中的value参数不需要用剩余参数数组...value直接获取传入的数组value即可 然后还是使用...展开运算符把参数数组展开传入
 //实现apply
     Function.prototype.myApply = function (thisArg, value) {
      thisArg = thisArg || window
      let key = Symbol('temp')
      thisArg[key] = this
      //利用...数组的展开运算符把value的所有元素 逐个全部传入
      thisArg[key](...value)
      delete thisArg[key]
    }
    fnCall.myApply(obj, [1, 2])
  • 测试用例同mycall

bind

  • 参数逐个传入
  • 返回新数组 调用之后才执行(参数自动传进去 this改变) bind
实现

不会立即执行函数 而是返回新的函数

  • 因为返回的新函数中的this就不是调用函数了 所以要把this放进fn函数里
  • 返回一个新函数 先给thisArg对象添加fn方法(就是要改变this的函数) 然后把原函数的返回结果也就是对象方法的结果原函数的返回结果放进result作为新函数的返回结果 删除属性避免对原thisArg造成干扰 最后把result变量return出去

举例子(没有this干扰)理解result变量的必要性 没有result 这里把内部函数的返回值return出去 就能实现调用outside函数返回一个inside函数并且把他的她的返回值也复制了有result最终代码

 //实现bind
    //创建新函数 this绑定到指定对象上
    Function.prototype.myBind = function (thisArg, ...value) {
      //不会立即执行函数 而是返回新的函数
      thisArg = thisArg || window
      let fn = this //把调用的函数存起来
      return function () {
        let key = Symbol('temp') //每次调用都创建新的临时键
        thisArg[key] = fn
        const result = thisArg[key](...value)
        delete thisArg.fn
        return result  //返回原函数的执行结果
      }
    }
  • 测试用例
//生成新的函数
    function bindFn(a, b) {
      console.log('myBind结果', this, a, b);
    }
    let Person = {
      name: 'a'
    }
    const newBindfn = bindFn.myBind(Person, 2, 1)
//传入thisArg为空的情况
    const newBindfn1 = bindFn.myBind('', 2, 3)  //this指向window
//新生成的函数返回值
    function fn() {
      return '函数返回结果'
    }
    console.log(fn.myBind(Person)());//函数返回结果

第4章:布局类组件 —— 4.8 LayoutBuilder、AfterLayout

4.8 LayoutBuilder、AfterLayout

📚 章节概览

本章节是第4章的最后一节,将学习如何在布局过程中动态构建UI,以及如何获取组件的实际尺寸和位置:

  • LayoutBuilder - 布局过程中获取约束信息
  • BoxConstraints - 约束信息详解
  • 响应式布局 - 根据约束动态构建
  • AfterLayout - 布局完成后获取尺寸
  • RenderAfterLayout - 自定义RenderObject
  • localToGlobal - 坐标转换
  • Build和Layout - 交错执行机制

🎯 核心知识点

LayoutBuilder vs AfterLayout

特性 LayoutBuilder AfterLayout
执行时机 布局阶段(Layout) 布局完成后(Post-Layout)
获取信息 约束信息(BoxConstraints) 实际尺寸和位置
主要用途 响应式布局 尺寸获取
性能 较好 稍差(额外回调)

1️⃣ LayoutBuilder(布局构建器)

1.1 什么是LayoutBuilder

LayoutBuilder 可以在布局过程中拿到父组件传递的约束信息(BoxConstraints),然后根据约束信息动态地构建不同的布局。

1.2 构造函数

LayoutBuilder({
  Key? key,
  required Widget Function(BuildContext, BoxConstraints) builder,
})

1.3 基础用法

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // 打印约束信息(调试用)
    print('LayoutBuilder约束: $constraints');
    print('  maxWidth: ${constraints.maxWidth}');
    print('  maxHeight: ${constraints.maxHeight}');
    
    // constraints包含父组件传递的约束信息
    if (constraints.maxWidth > 600) {
      return DesktopLayout();
    } else {
      return MobileLayout();
    }
  },
)

控制台输出示例:

LayoutBuilder约束: BoxConstraints(0.0<=w<=392.7, 0.0<=h<=Infinity)
  maxWidth: 392.7272644042969
  maxHeight: Infinity

1.4 BoxConstraints(约束信息)

class BoxConstraints {
  final double minWidth;   // 最小宽度
  final double maxWidth;   // 最大宽度
  final double minHeight;  // 最小高度
  final double maxHeight;  // 最大高度
  
  bool get isTight;        // 是否为固定约束
  bool get isNormalized;   // 是否标准化
  // ... 更多方法
}

常用属性:

  • minWidth / maxWidth:宽度范围
  • minHeight / maxHeight:高度范围
  • isTight:是否固定尺寸(min == max)
  • biggest:最大可用尺寸
  • smallest:最小可用尺寸

2️⃣ 响应式布局实战

2.1 响应式Column

根据可用宽度动态切换单列/双列布局:

class ResponsiveColumn extends StatelessWidget {
  const ResponsiveColumn({super.key, required this.children});

  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        if (constraints.maxWidth < 200) {
          // 最大宽度小于200,显示单列
          return Column(
            children: children,
            mainAxisSize: MainAxisSize.min,
          );
        } else {
          // 大于200,显示双列
          var widgets = <Widget>[];
          for (var i = 0; i < children.length; i += 2) {
            if (i + 1 < children.length) {
              widgets.add(Row(
                children: [children[i], children[i + 1]],
                mainAxisSize: MainAxisSize.min,
              ));
            } else {
              widgets.add(children[i]);
            }
          }
          return Column(
            children: widgets,
            mainAxisSize: MainAxisSize.min,
          );
        }
      },
    );
  }
}

使用示例:

ResponsiveColumn(
  children: [
    Text('Item 1'),
    Text('Item 2'),
    Text('Item 3'),
    Text('Item 4'),
  ],
)

2.2 响应式断点

常见的响应式断点:

enum DeviceType { mobile, tablet, desktop }

DeviceType getDeviceType(double width) {
  if (width < 600) {
    return DeviceType.mobile;    // 手机
  } else if (width < 1200) {
    return DeviceType.tablet;    // 平板
  } else {
    return DeviceType.desktop;   // 桌面
  }
}

// 使用
LayoutBuilder(
  builder: (context, constraints) {
    final deviceType = getDeviceType(constraints.maxWidth);
    
    switch (deviceType) {
      case DeviceType.mobile:
        return MobileLayout();
      case DeviceType.tablet:
        return TabletLayout();
      case DeviceType.desktop:
        return DesktopLayout();
    }
  },
)

2.3 自适应网格

根据宽度自动调整列数:

LayoutBuilder(
  builder: (context, constraints) {
    // 计算列数
    final cardWidth = 120.0;
    final spacing = 8.0;
    final columns = (constraints.maxWidth / (cardWidth + spacing))
        .floor()
        .clamp(1, 6);  // 最少1列,最多6列
    
    return GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: columns,
        crossAxisSpacing: spacing,
        mainAxisSpacing: spacing,
      ),
      itemBuilder: (context, index) => Card(...),
    );
  },
)

3️⃣ AfterLayout(布局后回调)

3.1 什么是AfterLayout

AfterLayout 是一个自定义组件,用于在布局完成后获取组件的实际尺寸和位置信息。

3.2 实现原理

通过自定义 RenderObject,在 performLayout 方法中添加回调:

class AfterLayout extends SingleChildRenderObjectWidget {
  const AfterLayout({
    super.key,
    required this.callback,
    super.child,
  });

  final ValueChanged<RenderAfterLayout> callback;

  @override
  RenderAfterLayout createRenderObject(BuildContext context) {
    return RenderAfterLayout(callback: callback);
  }

  @override
  void updateRenderObject(
    BuildContext context,
    RenderAfterLayout renderObject,
  ) {
    renderObject.callback = callback;
  }
}

class RenderAfterLayout extends RenderProxyBox {
  RenderAfterLayout({required this.callback});

  ValueChanged<RenderAfterLayout> callback;

  @override
  void performLayout() {
    super.performLayout();
    // 布局完成后触发回调
    WidgetsBinding.instance.addPostFrameCallback((_) {
      callback(this);
    });
  }

  /// 获取组件在屏幕中的偏移坐标
  Offset get offset => localToGlobal(Offset.zero);
}

3.3 基础用法

AfterLayout(
  callback: (RenderAfterLayout ral) {
    print('AfterLayout回调:');
    print('  尺寸: ${ral.size}');        // Size(105.0, 17.0)
    print('  位置: ${ral.offset}');      // Offset(42.5, 290.0)
  },
  child: Text('flutter@wendux'),
)

控制台输出:

AfterLayout回调:
  尺寸: Size(105.0, 17.0)
  位置: Offset(42.5, 290.0)

3.4 获取相对坐标

使用 localToGlobal 方法获取相对于某个父组件的坐标:

Builder(builder: (context) {
  return Container(
    color: Colors.grey.shade200,
    width: 100,
    height: 100,
    child: AfterLayout(
      callback: (RenderAfterLayout ral) {
        // 获取相对于Container的坐标
        Offset offset = ral.localToGlobal(
          Offset.zero,
          ancestor: context.findRenderObject(),
        );
        print('占用空间范围: ${offset & ral.size}');
      },
      child: Text('A'),
    ),
  );
})

4️⃣ RenderAfterLayout详解

4.1 继承关系

RenderObject
    ↓
RenderBox
    ↓
RenderProxyBox
    ↓
RenderAfterLayout

4.2 主要方法

方法/属性 说明 返回值
size 组件尺寸 Size
offset 屏幕坐标 Offset
localToGlobal(Offset) 转换为全局坐标 Offset
localToGlobal(..., ancestor) 转换为相对坐标 Offset
paintBounds 绘制边界 Rect

4.3 坐标转换

// 转换为屏幕坐标
Offset screenOffset = ral.localToGlobal(Offset.zero);

// 转换为相对于ancestor的坐标
Offset relativeOffset = ral.localToGlobal(
  Offset.zero,
  ancestor: ancestorRenderObject,
);

// 计算占用空间
Rect bounds = offset & size;  // Rect.fromLTWH(x, y, width, height)

5️⃣ Build和Layout的交错执行

5.1 执行流程

graph TB
    A[开始Build] --> B[遇到LayoutBuilder]
    B --> C[进入Layout阶段]
    C --> D[执行LayoutBuilder.builder]
    D --> E[返回新Widget]
    E --> F[继续Build新Widget]
    F --> G[完成]
    
    style A fill:#e1f5ff
    style C fill:#ffe1e1
    style F fill:#e1f5ff

关键点:

  • Build 和 Layout 不是严格按顺序执行的
  • LayoutBuilder 的 builder 在 Layout 阶段执行
  • builder 中可以返回新 Widget,触发新的 Build

5.2 执行顺序示例

print('1. 开始Build');

LayoutBuilder(
  builder: (context, constraints) {
    print('3. 执行LayoutBuilder.builder(Layout阶段)');
    return Column(
      children: [
        Text('Hello'),  // 4. 触发新的Build
      ],
    );
  },
)

print('2. LayoutBuilder创建完成');

输出顺序:

1. 开始Build
2. LayoutBuilder创建完成
3. 执行LayoutBuilder.builder(Layout阶段)
4. Build Text Widget

🤔 常见问题(FAQ)

Q1: LayoutBuilder和MediaQuery的区别?

A:

特性 LayoutBuilder MediaQuery
获取信息 父组件约束 屏幕尺寸
作用范围 当前组件 全局
响应变化 父约束变化 屏幕尺寸变化
使用场景 组件级响应式 全局响应式
// LayoutBuilder - 父组件约束
LayoutBuilder(
  builder: (context, constraints) {
    // constraints来自父组件
    return Text('宽度: ${constraints.maxWidth}');
  },
)

// MediaQuery - 屏幕尺寸
final screenWidth = MediaQuery.of(context).size.width;

Q2: 如何在StatefulWidget中使用AfterLayout?

A: 使用 addPostFrameCallback 避免在 build 中调用 setState

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  Size _size = Size.zero;

  @override
  Widget build(BuildContext context) {
    return AfterLayout(
      callback: (RenderAfterLayout ral) {
        // ✅ 正确:使用 addPostFrameCallback
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (mounted) {
            setState(() {
              _size = ral.size;
            });
          }
        });
      },
      child: Text('Hello'),
    );
  }
}

Q3: LayoutBuilder的builder何时执行?

A: 在以下情况会执行:

  1. 首次布局:组件首次被添加到树中
  2. 约束变化:父组件传递的约束发生变化
  3. 重新布局:调用 markNeedsLayout()
LayoutBuilder(
  builder: (context, constraints) {
    print('Builder执行,约束: $constraints');
    return Container();
  },
)

Q4: 如何优化LayoutBuilder性能?

A:

  1. 避免过度嵌套
  2. 缓存计算结果
  3. 使用const构造函数
LayoutBuilder(
  builder: (context, constraints) {
    // ❌ 每次都创建新Widget
    return Column(
      children: [
        Text('Item 1'),
        Text('Item 2'),
      ],
    );
    
    // ✅ 使用const
    return const Column(
      children: [
        Text('Item 1'),
        Text('Item 2'),
      ],
    );
  },
)

Q5: AfterLayout会影响性能吗?

A: 会有轻微影响,因为:

  1. 额外的回调开销
  2. 可能触发额外的 setState
  3. 每次布局都会执行回调

优化建议:

  • 只在必要时使用
  • 避免在回调中进行重量级操作
  • 使用防抖/节流

🎯 跟着做练习

练习1:实现一个响应式导航栏

目标: 宽度>600显示完整标签,否则显示图标

步骤:

  1. 使用 LayoutBuilder
  2. 判断 constraints.maxWidth
  3. 返回不同的UI
💡 查看答案
class ResponsiveNavigationBar extends StatelessWidget {
  const ResponsiveNavigationBar({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final showLabels = constraints.maxWidth > 600;
        
        return Container(
          height: 60,
          color: Colors.blue,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildNavItem(
                icon: Icons.home,
                label: '首页',
                showLabel: showLabels,
              ),
              _buildNavItem(
                icon: Icons.search,
                label: '搜索',
                showLabel: showLabels,
              ),
              _buildNavItem(
                icon: Icons.person,
                label: '我的',
                showLabel: showLabels,
              ),
            ],
          ),
        );
      },
    );
  }

  Widget _buildNavItem({
    required IconData icon,
    required String label,
    required bool showLabel,
  }) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: Colors.white),
        if (showLabel) ...[
          const SizedBox(height: 4),
          Text(
            label,
            style: const TextStyle(color: Colors.white, fontSize: 12),
          ),
        ],
      ],
    );
  }
}

练习2:实现文本溢出检测

目标: 检测Text是否溢出,显示"展开"按钮

步骤:

  1. 使用 AfterLayout 获取Text尺寸
  2. 计算是否溢出
  3. 显示/隐藏展开按钮
💡 查看答案
class ExpandableText extends StatefulWidget {
  const ExpandableText({super.key, required this.text});

  final String text;

  @override
  State<ExpandableText> createState() => _ExpandableTextState();
}

class _ExpandableTextState extends State<ExpandableText> {
  bool _expanded = false;
  bool _isOverflow = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        AfterLayout(
          callback: (RenderAfterLayout ral) {
            // 检查是否溢出
            final textPainter = TextPainter(
              text: TextSpan(text: widget.text),
              maxLines: _expanded ? null : 3,
              textDirection: TextDirection.ltr,
            )..layout(maxWidth: ral.size.width);

            WidgetsBinding.instance.addPostFrameCallback((_) {
              if (mounted) {
                setState(() {
                  _isOverflow = textPainter.didExceedMaxLines;
                });
              }
            });
          },
          child: Text(
            widget.text,
            maxLines: _expanded ? null : 3,
            overflow: TextOverflow.ellipsis,
          ),
        ),
        if (_isOverflow)
          TextButton(
            onPressed: () {
              setState(() {
                _expanded = !_expanded;
              });
            },
            child: Text(_expanded ? '收起' : '展开'),
          ),
      ],
    );
  }
}

📋 小结

核心概念

组件 用途 执行时机
LayoutBuilder 获取约束,响应式布局 Layout阶段
AfterLayout 获取尺寸和位置 Layout完成后
BoxConstraints 约束信息 Layout阶段传递

LayoutBuilder使用场景

场景 示例
响应式布局 根据宽度显示不同UI
自适应网格 动态调整列数
断点设计 手机/平板/桌面切换
动态组件 根据空间大小选择组件

AfterLayout使用场景

场景 示例
尺寸获取 获取组件实际大小
位置计算 计算组件坐标
溢出检测 判断Text是否溢出
动画准备 获取起始位置

记忆技巧

  1. LayoutBuilder:Layout阶段构建UI
  2. AfterLayout:Layout之后获取信息
  3. Build和Layout:可以交错执行
  4. BoxConstraints:约束向传递
  5. RenderObject:渲染树的节点

🔗 相关资源


JavaScript-小游戏-单词消消乐

需求

生成六个按钮 按钮上的内容随机生成 点到匹配的按钮 那两个按钮就隐藏 (之后会做从单词库随机选取单词的进阶版消消乐)

游戏界面

标签结构(html)

创建一个div类名为game 里面嵌套了六个按钮

 <div class="game">
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
  </div>

层叠样式(css)

  • 先用通配符选择器 * 匹配页面中所有的元素清除默认边距和边框
  • 设置button的宽高 写border-radius设计成圆角的按钮 背景色粉色 字体上网找了一个萌一点的字体 字的颜色为白色
 /* 清除默认样式 */
    * {
      padding: 0;
      margin: 0;
      border: 0;
    }

    button {
      width: 100px;
      height: 100px;
      border-radius: 15%;
      background-color: pink;
      font-size: 15px;
      color: white;
      font-family: Verdana, sans-serif;
    }

获取元素

界面结构写好之后获取div区域 获取按钮用的是querySelectorAll将页面中所有的按钮都获取 这里获得的是NodeList 类数组

document.querySelectorAll('div')得到的数组是div元素

 const btns = document.querySelectorAll('div')
    console.log(btns);  //NodeList [div.game]

获取元素代码如下

//获取
    let game = document.querySelector('.game')
    let buttons = document.querySelectorAll('button')

游戏界面如下 在这里插入图片描述

数据分析

判断数组

用两个数组a和b分别存放英文和中文 确保这两个数组对应的单词和翻译的索引是一样的 定义的

这两个数组可以简化代码 在判断消除的时候不需要把每个可能都罗列上去 而且可以直接拼接数组 为后面打乱顺序使用

每次刷新按钮上都会打乱顺序生成这些英文和中文 所以另一个数组arr由英文数组a和中文数组b拼接而成

  //用来对应判断的数组
    let a = ['understand', 'peace', 'forget']
    let b = ['理解', '和平', '忘记']
    //用来打乱的数组
    let arr = a.concat(b)

功能实现

按钮内容随机

随机打乱数组

采用洗牌法 从后往前遍历 当前元素和自己以及前面的元素随机交换 循环的示意图如下 在这里插入图片描述

循环过程中 交换范围[0-i]是闭区间的原因
  • 如果是[0-i) 不包含自身来交换 就会打乱平衡在这里插入图片描述

  • [0-i]是闭区间保证了元素在每个位置的概率均等 而且每个元素在某个位置的概率也均等![[1958b7eac80038003f846ec6bc8514e2.png]]

交换

传统的元素交换方式就是创建一个temp变量 交换本质上不会改变值 只会改变空间的指向 值可以被多个空间指向 ![[Pasted image 20251021105630.png]]

以下是数组元素交换的代码举例

 arr = [0, 1]
    //元素交换
    let t = arr[0]
    arr[0] = arr[1]
    arr[1] = t
    console.log(arr, t);  //[1, 0] 0
随机打乱数组最终代码如下

这里采用的是传统的交换方式

  //从后往前遍历 拿到的元素和前面的随机交换
    for (let i = arr.length - 1; i > 0; i--) {
      let r = Math.floor(Math.random() * i)
      //元素交换
      let t = arr[i]
      arr[i] = arr[r]
      arr[r] = t
    }

渲染到按钮上

设置buttons数组元素的innerText属性为随机打乱后的数组 渲染到按钮上

 //随机交换之后渲染到页面
    for (let i = 0; i < arr.length; i++) {
      buttons[i].innerText = arr[i]
    }

最终效果如下 点击刷新按钮之后随机打乱的数组会渲染到按钮上 完成了按钮随机功能 ![[刷新.gif]]

消除功能

数据分析

一开始存在了两个数组里 后面发现存在对象里更合适

对象是一种无序的数据集合 有属性和方法 这里采用的是属性来存储 属性是以键值对(Key-Value)的形式存在的每个属性由一个键(key)和一个与之关联的值(value)组成。 数组是特殊的对象 属性名是索引是有序的所以是有序的数据集合

按钮内容存为对象属性的key用于判断中英文是否匹配,对应的e.target作为value用于匹配成功之后隐藏按钮 都存在对象里 当判断完毕之后清空对象也更方便

点击事件

btns是NodeList类数组 可以用forEach方法遍历数组然后添加点击事件

    //map给按钮添加点击事件
    btns.forEach((value) => {
      value.addEventListener('click', () => {
        console.log('click');
      })
    }
    )

以下是遍历数组然后添加点击事件效果图示 在这里插入图片描述

类数组中没有map方法 如果要使用map方法 需要先 Array.from把btns转换为数组

 //map给按钮添加点击事件
    Array.from(btns).map((value, index) => {
      value.addEventListener('click', () => {
        console.log('click');
      })
    }
    )
  • 最终利用for循环把所有的按钮都添加点击事件
  • 对象的e.target.innerText属性对应值为e.target 属性名用于判断 属性值用于判断成功之后隐藏对应的按钮
  • 如果judge方法判断匹配 返回true 就把相应的e.target的可见值改为hidden
  • 然后清空对象 便于下次判断
let obj = {}  //把内容和按钮存在对象里
    for (let i = 0; i < buttons.length; i++) {
      buttons[i].addEventListener('click', (e) => {
        obj[e.target.innerText] = e.target //重复点击也不会有重复的元素
        //点击两个不同的元素才判断
        if (judge()) {
          Object.values(obj)[0].style.visibility = 'hidden'
          Object.values(obj)[1].style.visibility = 'hidden'
          obj = {}
        }
      })
    }

判断方法

  • 遍历a英文数组或者b中文数组 (这里遍历其中任意一个数组就可以 因为这两个数组索引是相对应的 长度也相等) 不需要每种可能都罗列上去
  • 调用Object.keys(obj)获取这个对象中所有的属性名 返回一个数组
  • 如果这个数组中有对应的英文且也有对应的中文 就返回true

这里判断条件改为arr.some((value) => value === a[i] && value===b[i])是不行因为这里的value表示的是当前的元素这个表达式的返回值固定为false当前的元素不可能又等于a[i]又等于b[i] 所以只能分别判断 都为真就返回真

 //判断方法
    function judge() {
      for (let i = 0; i < a.length; i++) {
        if (Object.keys(obj).some((values) => values === a[i])
          && Object.keys(obj).some((values) => values === b[i])) {
          return true
        }
      }
    }

消除功能最终效果如下

![[最终 1.gif]]

最终代码


<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>单词消消乐</title>
</head>

<body>
  <div class="game">
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
    <button></button>
  </div>
  <style>
    /* 清除默认样式 */
    * {
      padding: 0;
      margin: 0;
      border: 0;
    }

    button {
      width: 100px;
      height: 100px;
      border-radius: 15%;
      background-color: pink;
      font-size: 15px;
      color: white;
      font-family: Verdana, sans-serif;
    }
  </style>

  <script>
    //获取
    let game = document.querySelector('.game')
    let buttons = document.querySelectorAll('button')
    const btns = document.querySelectorAll('div')
    console.log(btns);  //NodeList [div.game]

    //用来对应判断的数组
    let a = ['understand', 'peace', 'forget']
    let b = ['理解', '和平', '忘记']
    //用来打乱的数组
    let arr = a.concat(b)


    //1每次打开页面 按钮位置随机
    //从后往前遍历 拿到的元素和前面的随机交换
    for (let i = arr.length - 1; i > 0; i--) {
      let r = Math.floor(Math.random() * i)
      //元素交换
      let t = arr[i]
      arr[i] = arr[r]
      arr[r] = t
    }

    //随机交换之后渲染到页面
    for (let i = 0; i < arr.length; i++) {
      buttons[i].innerText = arr[i]
    }


    //2 点击对应的两个按钮 就消除
    let obj = {}  //把内容和按钮存在对象里
    for (let i = 0; i < buttons.length; i++) {
      buttons[i].addEventListener('click', (e) => {
        obj[e.target.innerText] = e.target //重复点击也不会有重复的元素
        //点击两个不同的元素才判断
        if (judge()) {
          Object.values(obj)[0].style.visibility = 'hidden'
          Object.values(obj)[1].style.visibility = 'hidden'
          obj = {}
        }
      })
    }

    //判断方法
    function judge() {
      for (let i = 0; i < a.length; i++) {
        if (Object.keys(obj).some((values) => values === a[i])
          && Object.keys(obj).some((values) => values === b[i])) {
          return true
        }
      }
    }

  </script>

</body>

</html>
❌