普通视图

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

每日一题-新 21 点🟡

2025年8月17日 00:00

爱丽丝参与一个大致基于纸牌游戏 “21点” 规则的游戏,描述如下:

爱丽丝以 0 分开始,并在她的得分少于 k 分时抽取数字。 抽取时,她从 [1, maxPts] 的范围中随机获得一个整数作为分数进行累计,其中 maxPts 是一个整数。 每次抽取都是独立的,其结果具有相同的概率。

当爱丽丝获得 k或更多分 时,她就停止抽取数字。

爱丽丝的分数不超过 n 的概率是多少?

与实际答案误差不超过 10-5 的答案将被视为正确答案。

 

示例 1:

输入:n = 10, k = 1, maxPts = 10
输出:1.00000
解释:爱丽丝得到一张牌,然后停止。

示例 2:

输入:n = 6, k = 1, maxPts = 10
输出:0.60000
解释:爱丽丝得到一张牌,然后停止。 在 10 种可能性中的 6 种情况下,她的得分不超过 6 分。

示例 3:

输入:n = 21, k = 17, maxPts = 10
输出:0.73278

 

提示:

  • 0 <= k <= n <= 104
  • 1 <= maxPts <= 104

还有比这更简单的题解吗?填格子游戏开始

作者 Mcdull0921
2020年6月3日 11:25

解题思路

老实说,一开始没懂题目意思,后面才知道是求爱丽丝的胜率。
规则是这样:

  1. 她可以从牌面为 [1,W] 的牌中选择任意一张,这张牌是可以无限重复的,也就是说无论她取多少次,每次取到 2(假如 2 在 [1,W] 范围内)的概率都是 1/W;
  2. 如果她手上牌的总额小于 K,她就会抽牌,大于等于 K 时,就停止抽牌;
  3. 停止抽牌后,她的牌面小于等于 N 时,她就获胜了,求她获胜的概率。

假设 dp[x] 为她手上牌面为x时,能获胜的概率,那么这个概率应该是:
dp[x]=1/w * (dp[x+1]+dp[x+2]+dp[x+3]...+dp[x+w])
因为抽取的牌面机会都是均等的,她能抽取的面值在 [1,W] 之间,所以将概率之和平均一下就是 dp[x] 的概率。

强插一段解释:
x代表爱丽丝手上的牌面值,dp[x]代表爱丽丝手上持有的牌面值为x时,她获胜的概率(游戏结束时她所持牌面值小于等于N的概率)。
这个概率是怎么来的?x分2种情况:

  1. 当x>=K时,爱丽丝会停止抽牌,这个时候游戏已经结束了,她是赢是输也已经确定了,所以此时赢的概率要么1,要么0
  2. 当x<K时,爱丽丝会继续抽牌,抽牌是有概率的,所以她是赢是输也有概率。
    她能抽到的牌面值在 [1,W] 之间,所以抽完后她的牌面在[x+1,x+w]之间,因为每张牌机率均等,所以抽完后牌面在[x+1,x+w]之间的每个面值概率都是相等的,而假如我们已知当牌面是[x+1,x+w]的胜率(即dp[x+1]...dp[x+w]的值),那么可以推导:
    dp[x]=1/w * dp[x+1]+ 1/w * dp[x+2] + 1/w * dp[x+3]...+ 1/w * dp[x+w]
    这个实际上就是动态规划的状态转移方程,不过本例是反着来转移的。

x 最多能到 K-1,因为当大于等于 K 时,爱丽丝会停止抽牌,所以当游戏结束时,即爱丽丝停止抽牌时,她可能达到的最大牌面是 K+W-1,而一开始她的牌面是 0,所以我们用一个长度为 K+Wdp 数组来保存她在所有面值下的胜率。
最后 dp[0],也就是最开始爱丽丝还没有抽牌,她的牌面为 0 时的胜率,这个就是我们的答案。

填格子游戏开始

image.png

我将这个格子分成了 2 部分 [0,K-1][K,K+W-1],区别就是 [0,K-1] 爱丽丝可以抽牌,[K,K+W-1] 时不能抽牌,那么不能抽牌时她获胜的概率是多少呢,此时已不能抽牌,要么赢要么输,很显然牌面小于等于N时,概率就是 1,大于 N 概率就是 0,所以先直接填满图中蓝色的格子。

接下来,从 K-1 开始填图中的橘色部分,这个值根据我们前面提到的计算方式,实际上就相当于它后面 W 个格子的总和除以 W
这时聪明的你一定会想到不用每轮都累加的方法吧,用一个 s 变量来保存累加结果,而下一轮只是减去右边的格子,加上左边的格子即可。

image.png

所以这题你要做的就是,先初始化蓝色格子,然后从最右边的橘色格子开始,填到最左边的格子,就是这么简单,不仅简单,而且你连动态规划的思想都学会了。
相信这么厉害的你,看到这里给我点个赞一定不是件很困难的事吧。

代码

###Python3

class Solution:
    def new21Game(self, N: int, K: int, W: int) -> float:
        dp=[None]*(K+W)
        s=0
        for i in range(K,K+W):          # 填蓝色的格子
            dp[i] = 1 if i<=N else 0
            s+=dp[i]
        for i in range(K-1,-1,-1):      # 填橘黄色格子
            dp[i]=s/W
            s=s-dp[i+W]+dp[i]
        return dp[0]

时间复杂度=格子长度 $O(K+W)$
空间复杂度=格子长度 $O(K+W)$

怎样得到官方题解中的状态转移方程

作者 sour-e
2020年6月3日 05:56

怎样推导出官方题解中的状态转移方程

按照官方题解的说明,$dp[x]$ 表示从得分为$x$的情况开始游戏并且获胜的概率,如果我们引入 $x_t=x$ 表示$t$ 时刻的总分是 $x$,那么 $dp[x]$ 用条件概率可以写作
$$
dp[x] = P(win | x_t = x)
$$
我们怎么利用这个条件概率来得到题解中的状态转移方程呢?从条件概率可以联想到全概率公式。拿总分 $x=K - 1$ 为例,
$$
dp[K - 1] = P(win | x_t = K - 1) \
= \sum_{i=1}^{W} P(win | x_{t+1} = K - 1 + i) P(x_{t + 1} = K - 1 + i | x_t = K - 1)
$$
其中,$P(x_{t + 1} = K - 1 + i | x_t = K - 1)$ 表示,在 $t$ 时刻,一次抽取后,总分从 $K - 1$ 变成 $K - 1 + i$ 的概率,这实际上就是当前抽到数字 $i$ 的概率,根据题目

抽取时,她从 [1, W] 的范围中随机获得一个整数作为分数进行累计,其中 W 是整数。每次抽取都是独立的,其结果具有相同的概率

我们知道在任意时刻抽取得到 ${1, 2, .., W}$ 的概率相同(抽取的数字服从离散型均匀分布),即
$$
P(x_{t + 1} = K - 1 + i | x_t = K - 1) = P(\Delta x_t = i) = \frac{1}{W}
$$
另外,$P(win | x_{t+1} = K - 1 + i) = dp[K - 1 + i]$,我们就得到了官方题解里的状态转移方程
$$
dp[K - 1] = P(win | x_t = K - 1) \
= \sum_{i=1}^{W} P(x_{t + 1} = K - 1 + i | x_t = K - 1) P(win | x_{t+1} = K - 1 + i) \
= \sum_{i=1}^{W} \frac{1}{W} \cdot dp[K - 1 + i]\
= \frac{dp[K] + dp[K + 1] + ... + dp[K - 1 + W]}{W}
$$

延申

  1. 我们定义的$t$时刻总分$x_t$其实是一个马尔科夫链

    • 它有马尔可夫性$P(x_{t+1}| x_{t}, x_{t - 1}, ..., x_{1}) = P(x_{t+1} | x_{t})$,即下一状态(抽一次后的总分)的分布只与当前状态(当前总分)有关,而与之前的状态(之前的总分)无关。
    • 在题目中我们仅关心抽取一次后的总分概率$P(x_{t+1} = j| x_{t} = i)$,这个概率与当前时间$t$无关,这样的马尔科夫链叫齐次马尔科夫链。
    • 我们进而可以把上述概率写作$P(x_{t+1} = j| x_{t} = i) = p_{i,j}$,$p_{i,j}$也被称作一步转移概率,相应地也有$n$步转移概率$P(x_{t+n} = j| x_{t} = i) = p_{i,j}^{(n)}$,即抽取n次后总分$x_{t+n}$的分布,$n$步转移概率需要用C-K方程求解。
  2. 题目中,抽取时,她从 [1, W] 的范围中随机获得一个整数作为分数进行累计,其中 W 是整数。每次抽取都是独立的,其结果具有相同的概率的条件对玩家每一次抽取到数字的分布做出了假设,即假设 “无论玩家当前总分是多少,玩家抽到任意数字的概率都相等”,用转移概率表示就是 $P(x_{t + 1} = x + i | x_t = x) = \frac{1}{W}$,其中 $i \in {1,2,..,W}$,考虑以下两种情况

    • 假设玩家当前收到数字的概率与其当前总分有关,即 $P(x_{t + 1} = x + i | x_t = x) = f(x, W)$,比如在一些赌场,玩家当前总分越高,发牌器中混入更多大牌,给玩家发到小牌的概率越小,玩家更容易爆掉。
    • 假设玩家当前收到数字的概率不仅与其当前总分有关,还和玩家游玩时间有关,即 $P(x_{t + 1} = x + i | x_t = x) = f(x, W, t)$,比如在一些赌场,玩家游玩时间越久,发牌器中混入的大牌越多,给玩家发到小牌的概率越小,让玩家更容易爆掉。

    第一种情况和原题类似,同属于齐次马尔可夫链(即转移概率不随时间变化而变化),第二种情况属于非齐次马尔科夫链,求解更加复杂。

  3. 如果我们把上面 抽取时,她从 [1, W] 的范围中随机获得一个整数作为分数进行累计,其中 W 是整数。每次抽取都是独立的,其结果具有相同的概率 的条件改为 抽取时,她从 [0, 1] 两个数字中抽取W次,以W次抽取结果的和作为分数进行累计,其中 W 是整数,那么每一次增加的分数将服从$n=M$, $p=0.5$的二项分布,状态转移方程也将随之改变为
    $$
    dp[K - 1]
    = \sum_{i=0}^{W} C^{i}_{W} (\frac{1}{2})^{W-i} (\frac{1}{2})^{i} \cdot dp[K - 1 + i]\
    = \frac{dp[K - 1] + W \cdot dp[K] + \frac{W(W-1)}{2} \cdot dp[K + 1] + ... + dp[K - 1 + W]}{2^W}
    $$

新21点

2020年6月2日 19:02

📺视频题解

837. 新21点 4.mp4

📖文字题解

方法一:动态规划

爱丽丝获胜的概率只和下一轮开始前的得分有关,因此根据得分计算概率。

令 $\textit{dp}[x]$ 表示从得分为 $x$ 的情况开始游戏并且获胜的概率,目标是求 $\textit{dp}[0]$ 的值。

根据规则,当分数达到或超过 $k$ 时游戏结束,游戏结束时,如果分数不超过 $n$ 则获胜,如果分数超过 $n$ 则失败。因此当 $k \le x \le \min(n, k+\textit{maxPts}-1)$ 时有 $\textit{dp}[x]=1$,当 $x>\min(n, k+\textit{maxPts}-1)$ 时有 $\textit{dp}[x]=0$。

为什么分界线是 $\min(n, k+\textit{maxPts}-1)$?首先,只有在分数不超过 $n$ 时才算获胜;其次,可以达到的最大分数为 $k+\textit{maxPts}-1$,即在最后一次抽取数字之前的分数为 $k-1$,并且抽到了 $\textit{maxPts}$。

当 $0 \le x < k$ 时,如何计算 $\textit{dp}[x]$ 的值?注意到每次在范围 $[1, \textit{maxPts}]$ 内随机抽取一个整数,且每个整数被抽取到的概率相等,因此可以得到如下状态转移方程:

$$
\textit{dp}[x]=\frac{\sum_{i=1}^\textit{maxPts} \textit{dp}[x+i]}{\textit{maxPts}}
$$

根据状态转移方程,可以实现如下简单的动态规划:

###Java

class Solution {
    public double new21Game(int n, int k, int maxPts) {
        if (k == 0) {
            return 1.0;
        }
        double[] dp = new double[k + maxPts];
        for (int i = k; i <= n && i < k + maxPts; i++) {
            dp[i] = 1.0;
        }
        for (int i = k - 1; i >= 0; i--) {
            for (int j = 1; j <= maxPts; j++) {
                dp[i] += dp[i + j] / maxPts;
            }
        }
        return dp[0];
    }
}

###C#

public class Solution {
    public double New21Game(int n, int k, int maxPts) {
        if (k == 0) {
            return 1.0;
        }
        double[] dp = new double[k + maxPts];
        for (int i = k; i <= n && i < k + maxPts; i++) {
            dp[i] = 1.0;
        }
        for (int i = k - 1; i >= 0; i--) {
            for (int j = 1; j <= maxPts; j++) {
                dp[i] += dp[i + j] / maxPts;
            }
        }
        return dp[0];
    }
}

###C++

class Solution {
public:
    double new21Game(int n, int k, int maxPts) {
        if (k == 0) {
            return 1.0;
        }
        vector<double> dp(k + maxPts);
        for (int i = k; i <= n && i < k + maxPts; i++) {
            dp[i] = 1.0;
        }
        for (int i = k - 1; i >= 0; i--) {
            for (int j = 1; j <= maxPts; j++) {
                dp[i] += dp[i + j] / maxPts;
            }
        }
        return dp[0];
    }
};

###Python

class Solution:
    def new21Game(self, n: int, k: int, maxPts: int) -> float:
        if k == 0:
            return 1.0
        dp = [0.0] * (k + maxPts)
        for i in range(k, min(n, k + maxPts - 1) + 1):
            dp[i] = 1.0
        for i in range(k - 1, -1, -1):
            for j in range(1, maxPts + 1):
                dp[i] += dp[i + j] / maxPts
        return dp[0]

###golang

func new21Game(n int, k int, maxPts int) float64 {
    if k == 0 {
        return 1.0
    }
    dp := make([]float64, k + maxPts)
    for i := k; i <= n && i < k + maxPts; i++ {
        dp[i] = 1.0
    }
    for i := k - 1; i >= 0; i-- {
        for j := 1; j <= maxPts; j++ {
            dp[i] += dp[i + j] / float64(maxPts)
        }
    }
    return dp[0]
}

上述解法的时间复杂度是 $O(n+k \times \textit{maxPts})$,会超出时间限制,因此需要优化。

考虑对 $\textit{dp}$ 的相邻项计算差分,有如下结果:

$$
\textit{dp}[x] - \textit{dp}[x+1]=\frac{\textit{dp}[x+1] - \textit{dp}[x+\textit{maxPts}+1]}{\textit{maxPts}}
$$

其中 $0 \le x<k-1$。

因此可以得到新的状态转移方程:

$$
\textit{dp}[x]=\textit{dp}[x+1]-\frac{\textit{dp}[x+\textit{maxPts}+1]-\textit{dp}[x+1]}{\textit{maxPts}}
$$

其中 $0 \le x<k-1$。

注意到上述状态转移方程中 $x$ 的取值范围,当 $x=k-1$ 时不适用。因此对于 $\textit{dp}[k-1]$ 的值,需要通过

$$
\textit{dp}[k-1]=\frac{\sum_{i=0}^{\textit{maxPts}-1} \textit{dp}[k+i]}{\textit{maxPts}}
$$

计算得到。注意到只有当 $k \le x \le \min(n, k+\textit{maxPts}-1)$ 时才有 $\textit{dp}[x]=1$,因此

$$
\textit{dp}[k-1]=\frac{\min(n, k+\textit{maxPts}-1) - k + 1}{\textit{maxPts}} = \frac{\min(n-k+1,\textit{maxPts})}{\textit{maxPts}}
$$

可在 $O(1)$ 时间内计算得到 $\textit{dp}[k-1]$ 的值。

对于 $\textit{dp}[k-2]$ 到 $\textit{dp}[0]$ 的值,则可通过新的状态转移方程得到。

###Java

class Solution {
    public double new21Game(int n, int k, int maxPts) {
        if (k == 0) {
            return 1.0;
        }
        double[] dp = new double[k + maxPts];
        for (int i = k; i <= n && i < k + maxPts; i++) {
            dp[i] = 1.0;
        }
        dp[k - 1] = 1.0 * Math.min(n - k + 1, maxPts) / maxPts;
        for (int i = k - 2; i >= 0; i--) {
            dp[i] = dp[i + 1] - (dp[i + maxPts + 1] - dp[i + 1]) / maxPts;
        }
        return dp[0];
    }
}

###C#

public class Solution {
    public double New21Game(int n, int k, int maxPts) {
        if (k == 0) {
            return 1.0;
        }
        double[] dp = new double[k + maxPts];
        for (int i = k; i <= n && i < k + maxPts; i++) {
            dp[i] = 1.0;
        }
        dp[k - 1] = 1.0 * Math.Min(n - k + 1, maxPts) / maxPts;
        for (int i = k - 2; i >= 0; i--) {
            dp[i] = dp[i + 1] - (dp[i + maxPts + 1] - dp[i + 1]) / maxPts;
        }
        return dp[0];
    }
}

###C++

class Solution {
public:
    double new21Game(int n, int k, int maxPts) {
        if (k == 0) {
            return 1.0;
        }
        vector<double> dp(k + maxPts);
        for (int i = k; i <= n && i < k + maxPts; i++) {
            dp[i] = 1.0;
        }
        dp[k - 1] = 1.0 * min(n - k + 1, maxPts) / maxPts;
        for (int i = k - 2; i >= 0; i--) {
            dp[i] = dp[i + 1] - (dp[i + maxPts + 1] - dp[i + 1]) / maxPts;
        }
        return dp[0];
    }
};

###Python

class Solution:
    def new21Game(self, n: int, k: int, maxPts: int) -> float:
        if k == 0:
            return 1.0
        dp = [0.0] * (k + maxPts)
        for i in range(k, min(n, k + maxPts - 1) + 1):
            dp[i] = 1.0
        dp[k - 1] = float(min(n - k + 1, maxPts)) / maxPts
        for i in range(k - 2, -1, -1):
            dp[i] = dp[i + 1] - (dp[i + maxPts + 1] - dp[i + 1]) / maxPts
        return dp[0]

###golang

func new21Game(n int, k int, maxPts int) float64 {
    if k == 0 {
        return 1.0
    }
    dp := make([]float64, k + maxPts)
    for i := k; i <= n && i < k + maxPts; i++ {
        dp[i] = 1.0
    }

    dp[k - 1] = 1.0 * float64(min(n - k + 1, maxPts)) / float64(maxPts)
    for i := k - 2; i >= 0; i-- {
        dp[i] = dp[i + 1] - (dp[i + maxPts + 1] - dp[i + 1]) / float64(maxPts) 
    }
    return dp[0]
}

func min(x, y int) int {
    if x < y {
        return x
    }
    return y
}

复杂度分析

  • 时间复杂度:$O(\min(n, k+\textit{maxPts}))$。即需要计算的 $\textit{dp}$ 值的数量 $\min(n, k+\textit{maxPts}-1)$。

  • 空间复杂度:$O(k+\textit{maxPts})$。创建了一个长度为 $k+\textit{maxPts}$ 的数组 $\textit{dp}$。

JavaScript 运行机制详解:再谈 Event Loop

作者 mCell
2025年8月17日 02:28

同步更新至个人站点: JavaScript 运行机制详解:再谈 Event Loop

005.avif

本文从经典的 Promise 与 setTimeout 执行顺序问题入手,深入浅出地剖析了 JavaScript 的单线程模型、事件循环(Event Loop)机制。通过辨析宏任务与微任务的区别与优先级,帮助你彻底理解 JS 异步执行的底层原理,看懂页面卡顿的真相。

我常常在各种场合被问到类似下面代码的输出顺序。

console.log("start");

setTimeout(function () {
  console.log("setTimeout");
}, 0);

Promise.resolve().then(function () {
  console.log("promise");
});

console.log("end");

如果你能毫不犹豫地答出 start, end, promise, setTimeout,并解释其原因,那么你对 JS 的异步机制已经有了不错的理解。如果你还有一丝困惑,希望本文能帮助你彻底梳理清楚。

这个问题的背后,是整个 JavaScript 的运行模型(runtime model),也就是我们常说的“事件循环”(Event Loop)。理解它,是前端工程师进阶的必经之路。

为什么 JavaScript 是单线程?

首先,我们必须记住一个基本事实:JavaScript 语言是一门单线程语言。

这意味着,在任何一个时刻,JS 引擎只能执行一段代码。为什么这么设计?这与它的初衷有关。JavaScript 最初是为浏览器设计的,用于处理用户的交互,比如鼠标点击、键盘输入,以及操作 DOM。

试想一下,如果 JavaScript 是多线程的,会发生什么?一个线程要在一个 DOM 节点上增加内容,另一个线程要删除这个节点。那么浏览器应该听谁的?这会带来极其复杂的同步问题。为了避免这种复杂性,JavaScript 从诞生起就选择了单线程。

这既是它的优点,也是它的缺点。优点是简单,没有多线程的竞态、死锁等问题。缺点是,如果一个任务耗时很长,整个程序就会被“卡住”,无法响应其他操作。

浏览器:一个多进程的“操作系统”

“JS 是单线程的”这个说法其实不完全准确。准确来说,执行 JavaScript 代码的那个主线程是单线程的

现代浏览器(以 Chrome 为例)本身是一个非常复杂的程序,它采用了多进程架构来保证稳定性和安全性。你可以打开 Chrome 的任务管理器(“更多工具” > “任务管理器”)看看,通常会看到好几个进程:

  • 浏览器进程(Browser Process):负责浏览器界面的“外壳”,比如地址栏、书签、前进后退按钮,以及协调其他进程。
  • 渲染进程(Renderer Process):核心部分,负责将 HTML、CSS 和 JavaScript 转换成用户可以看到的网页。我们写的 JS 代码,主要就在这个进程的主线程(Main Thread)上运行。每个标签页通常会有一个独立的渲染进程。
  • 网络进程(Network Process):负责处理网络请求,比如 fetch
  • GPU 进程(GPU Process):负责处理 GPU 相关的任务,加速 3D 绘图和页面渲染。

这种设计的好处是隔离。一个标签页(渲染进程)崩溃了,不会影响到整个浏览器。

任务队列(Task Queue)和事件循环(Event Loop)

我们回到渲染进程的主线程。这个线程非常繁忙,它要做的事情包括:

  • 执行 JavaScript 代码
  • 渲染页面布局(Layout)
  • 绘制页面(Paint)
  • 响应用户交互(Click, Scroll)

如果所有任务都排队等着,一个耗时长的 JS 计算就会阻塞页面渲染和用户响应,这就是“假死”现象。

// 一个会让页面卡住的例子
document.getElementById("myButton").addEventListener("click", function () {
  // 假装这是一个非常耗时的计算
  const start = Date.now();
  while (Date.now() - start < 5000) {
    // 这5秒内,页面完全无法响应
  }
  console.log("计算完成!");
});

为了解决这个问题,浏览器引入了异步(asynchronous)执行模型。当遇到一些耗时操作(比如网络请求、定时器)时,主线程不会傻等,而是把这些任务“外包”给浏览器的其他线程(比如网络线程、定时器线程)。

这些“外包”任务完成后,会把一个“回调函数”(callback)放进一个叫做**任务队列(Task Queue)**的地方。主线程则继续执行自己手头的同步代码。

等到主线程的同步代码全部执行完毕,它就会去任务队列里看看,有没有需要执行的回调函数。如果有,就取出一个来执行。这个“主线程不断从任务队列里读取并执行任务”的过程,就叫做事件循环(Event Loop)

这个模型可以用一张经典的图来表示:

019.jpg

微任务(Microtask)和宏任务(Macrotask)

事情还没完。任务队列其实不止一个。根据 WHATWG 规范,任务被分为两种类型:

  1. 宏任务(Macrotask,规范中称为 Task)

    • setTimeout, setInterval
    • script(整体代码块)
    • I/O 操作, UI 渲染
    • 用户交互事件(如 click, scroll
  2. 微任务(Microtask)

    • Promise.then(), Promise.catch(), Promise.finally()
    • queueMicrotask()
    • MutationObserver

事件循环的规则是,优先级更高的是微任务。主线程在执行完一个宏任务后,并不是立刻去执行下一个宏任务,而是会检查微任务队列。

完整的事件循环流程如下:

  1. 从宏任务队列中取出一个任务(通常是 script 脚本本身)并执行。
  2. 执行完毕后,检查微任务队列。
  3. 循环执行微任务队列中的所有任务,直到队列清空。
  4. 执行浏览器 UI 渲染(这一步不一定每次都会发生)。
  5. 回到第一步,从宏任务队列中取出下一个任务。

这个“执行一个宏任务 -> 清空所有微任务 -> 再取下一个宏任务”的循环,是理解所有异步执行顺序的关键。

回到最初的问题

现在,我们用这个模型来分析开头的代码:

console.log("start"); // 1

setTimeout(function () {
  // 4
  console.log("setTimeout");
}, 0);

Promise.resolve().then(function () {
  // 3
  console.log("promise");
});

console.log("end"); // 2
  1. 第一轮宏任务(script 脚本)开始执行。

    • 遇到 console.log('start'),直接执行。输出 start
    • 遇到 setTimeout,它是一个宏任务。浏览器定时器线程接管,0ms 后将其回调函数推入宏任务队列
    • 遇到 Promise.resolve().then().then() 的回调是一个微任务。它被推入微任务队列
    • 遇到 console.log('end'),直接执行。输出 end
  2. 第一个宏任务(script)执行完毕。

    • 现在,事件循环会检查微任务队列。发现里面有一个任务(打印 promise)。
    • 取出并执行该微任务。输出 promise
    • 微任务队列现在空了。
  3. 开始下一轮宏任务。

    • 事件循环检查宏任务队列,发现 setTimeout 的回调函数在那里。
    • 取出并执行该宏任务。输出 setTimeout

至此,所有代码执行完毕。最终输出 start, end, promise, setTimeout

应用与思考

理解了事件循环,很多问题就迎刃而解了。

  • setTimeout(fn, 0) 为什么不是立即执行? 因为它只是把 fn 尽快地推入宏任务队列,但必须等到当前主线程的同步代码和所有微任务都执行完之后,才有机会被执行。

  • 页面为什么会卡顿? 通常是因为一个宏任务(比如一段 JS 计算或一个事件回调)执行时间过长,导致主线程无法脱身去处理其他宏任务(如 UI 渲染、用户点击)。

  • 如何处理耗时计算? 对于真正 CPU 密集的计算,应该使用 Web Worker。它允许你在一个完全独立的后台线程中运行脚本,不会阻塞主线程。

参考链接

希望读完本文,你对 JavaScript 的运行机制有了更深入的理解。

(完)

使用函数式封装绘制科赫雪花(Koch Snowflake)

作者 excel
2025年8月16日 22:18

科赫雪花(Koch Snowflake)是经典的分形几何图形,由 Helge von Koch 在 1904 年提出。它通过不断递归生成边上的“锯齿”,形成复杂而美丽的雪花形状。本文将介绍如何用 函数式编程封装科赫雪花生成与绘制,方便在 Canvas 上渲染,同时可封装成可复用的模块。


1. 算法原理

科赫雪花的生成规则非常直观:

  1. 从一个等边三角形开始。
  2. 将每条边分为三等份,并在中间段向外凸出一个等边小三角形。
  3. 对生成的新边重复步骤 2,递归指定层数。
  4. 最终得到雪花状的复杂轮廓。

特点:

  • 每增加一层递归,边数呈指数增长。
  • 轮廓越来越复杂,但数学公式可精确描述。

2. 函数式封装设计

在传统实现中,可能使用类或对象存储状态。函数式封装的思路是 只依赖纯函数和数组操作,保证雪花点集可重复使用,同时避免副作用。

核心函数

  • koch(p1, p2, iter):递归生成科赫曲线上的点
  • createKochSnowflake(size, iterations):生成雪花点集和绘制函数
  • draw(ctx, x, y, strokeStyle, lineWidth):将雪花绘制到 Canvas

3. 完整函数实现

export const createKochSnowflake = function(size = 200, iterations = 4) {
  // 参数校验
  if (typeof size !== "number" || size <= 0) {
    throw new Error("[KochSnowflake] 参数错误: size 必须是大于 0 的数字");
  }
  if (!Number.isInteger(iterations) || iterations < 0) {
    throw new Error("[KochSnowflake] 参数错误: iterations 必须是大于等于 0 的整数");
  }

  // 科赫递归函数
  const koch = (p1, p2, iter) => iter === 0
    ? [p1, p2]
    : (() => {
        const dx = (p2.x - p1.x) / 3;
        const dy = (p2.y - p1.y) / 3;
        const pa = {x: p1.x + dx, y: p1.y + dy};
        const pb = {x: p1.x + 2*dx, y: p1.y + 2*dy};
        const angle = Math.PI / 3;
        const peak = {
          x: pa.x + Math.cos(angle) * (pb.x - pa.x) - Math.sin(angle) * (pb.y - pa.y),
          y: pa.y + Math.sin(angle) * (pb.x - pa.x) + Math.cos(angle) * (pb.y - pa.y)
        };
        return [
          ...koch(p1, pa, iter - 1).slice(0, -1),
          ...koch(pa, peak, iter - 1).slice(0, -1),
          ...koch(peak, pb, iter - 1).slice(0, -1),
          ...koch(pb, p2, iter - 1)
        ];
      })();

  // 初始等边三角形(以原点为中心)
  const h = size * Math.sqrt(3) / 2;
  const triangle = [
    {x: -size/2, y: h/3},
    {x: size/2, y: h/3},
    {x: 0, y: -2*h/3}
  ];

  // 生成雪花点集
  const points = triangle.flatMap((p, i) =>
    koch(p, triangle[(i+1)%3], iterations).slice(0, -1)
  ).concat([triangle[0]]);

  // 返回雪花句柄
  return {
    points,

    /**
     * 绘制雪花
     * @param {CanvasRenderingContext2D} ctx - Canvas 上下文
     * @param {number} x - 中心 X 坐标
     * @param {number} y - 中心 Y 坐标
     * @param {string} strokeStyle - 线条颜色
     * @param {number} lineWidth - 线宽
     */
    draw(ctx, x, y, strokeStyle = "white", lineWidth = 2) {
      if (!ctx || typeof ctx.moveTo !== "function") {
        throw new Error("[KochSnowflake] ctx 必须是 CanvasRenderingContext2D");
      }
      if (typeof x !== "number" || typeof y !== "number") {
        throw new Error("[KochSnowflake] x, y 必须是数字");
      }
      if (typeof strokeStyle !== "string") {
        throw new Error("[KochSnowflake] strokeStyle 必须是字符串");
      }
      if (typeof lineWidth !== "number" || lineWidth <= 0) {
        throw new Error("[KochSnowflake] lineWidth 必须大于 0");
      }

      ctx.beginPath();
      ctx.moveTo(points[0].x + x, points[0].y + y);
      points.slice(1).forEach(p => ctx.lineTo(p.x + x, p.y + y));
      ctx.closePath();
      ctx.strokeStyle = strokeStyle;
      ctx.lineWidth = lineWidth;
      ctx.stroke();
    }
  };
};

4. 使用示例

<canvas id="canvas" width="500" height="500"></canvas>
<script type="module">
import { createKochSnowflake } from './koch-snowflake.js';

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// 创建雪花句柄
const snowflake = createKochSnowflake(150, 4);

// 绘制在不同位置
snowflake.draw(ctx, 150, 150, "cyan", 2);
snowflake.draw(ctx, 350, 150, "yellow", 2);
snowflake.draw(ctx, 250, 350, "white", 2);
</script>

5. 优势与扩展

  1. 函数式封装:避免副作用,易于测试
  2. 性能优化:雪花点只计算一次,可多次绘制
  3. 参数验证:减少调用错误
  4. 可扩展性:未来可增加旋转、缩放、3D 投影或动画

6. 总结

通过函数式封装科赫雪花,我们实现了:

  • 简洁、可维护的代码结构
  • 高性能绘制,可重复使用
  • 易于封装成 NPM 模块或在前端项目中复用

这种方法不仅适用于科赫雪花,也适合其他递归分形图形的封装,实现算法与渲染解耦,方便扩展和复用。

太强了,Trae帮我把可视化面板写了个登录页面

2025年8月16日 22:04

前言

之前不是给甲方爸爸写了一个可视化面板,但是甲方爸爸还想要一个登录页面,那我当然要表现得很为难,说排期时间不够,得加钱,加钱都好说,甲方爸爸也是爽快的答应了,并询问我什么时候可以上线

那当然是明天,越快越好,甲方爸爸表示很满意。

接着我就打开了Trae,并且对Trae进行了提问,帮我把可视化面板项目添加一个企业是专业软件的登录页面,看看能不能最快时间进行页面ui的构建

image.png

Trae先看了我的可视化面板的构成,马上给出了专业的方案,下面是第一版的效果,看起来布局和颜色都很符合可视化面板的颜色搭配,那么就开始跟Trae继续对登录的主要方式以及注意点

image.png

甲方爸爸需要的是手机号或者账号密码登录,所以我再次提问 image.png

这次的输出,移除了社交媒体登录 :去除了Google和Microsoft登录按钮,用户可切换账号密码登录或手机号登录,支持11位手机号格式验证、6位验证码的校验,看看界面是怎么样的先

image.png

相信大家也看到问题的所在,下拉框的切换方式不是很高效,换成tab肯定是比较符合现代化的页面构成,还有勾选记住密码,字体是白色的,看不清楚,这次一并让Trae改了,看看能不能得到甲方爸爸的青睐,Trae开干吧

image.png

账号密码登陆的界面样式 image.png 手机号获取验证码的界面样式,

1、手机号没有输入的话,会提示请先输入手机号

2、点击发送验证码按钮,前端会出现60s的倒计时,并有绿色字体的提示,验证码已经发送,让用户有比较好的体验,这一点Trae做的非常好 image.png 接下来就是发给亲爱的甲方爸爸看一下页面ui,如果没有问题的话,就可以继续下面的开发,然后让Trae把接口联调的脏活累活接了

甲方爸爸表示页面很好,目前不需要有忘记密码,可以先把入口隐藏,后续有需要的话可以再显示

Trae的关键代码

检查登录状态也接入到可视化面板上面

image.png 退出登录功能

image.png

tab切换的代码

image.png 发送验证码,并进入倒计时

image.png 不错嘛,Trae很高效的完成了登录页面的开发,但是光有页面肯定不行,下期,我继续分享,Trae怎么帮我将登录页面的后端接口联调,敬请期待吧~让大家感受到Trae的编程魅力,从此爱上Trae

Node.js v24.6.0 新功能速览 🚀🚀🚀

2025年8月16日 21:23

前言

Node.js v24.6.0 发布了,让我们一起来看看这些朴实却强大的变化!

往期精彩推荐

正文

以下是 v24.6.0 的核心更新和功能的详细介绍。

1. CLI:系统 CA 支持

Node.js v24.6.0 新增 NODE_USE_SYSTEM_CA=1 环境变量,支持使用系统 CA 证书。这简化了企业环境下的证书配置,提升兼容性。

示例

NODE_USE_SYSTEM_CA=1 node app.js

这对需要严格安全合规的场景尤其实用。

2. Crypto:支持 ML-DSA 算法

Crypto 模块新增了对 ML-DSA(Module Lattice-based Digital Signature Algorithm)的支持,包括 KeyObject 生成、签名和验证。这是后量子密码学算法,为未来安全奠定基础。

示例

const crypto = require('crypto');

// 生成 ML-DSA 密钥对
const { publicKey, privateKey } = crypto.generateKeyPairSync('ml-dsa');

// 签名
const signature = crypto.sign(null, Buffer.from('data'), privateKey);

// 验证
const isValid = crypto.verify(null, Buffer.from('data'), publicKey, signature);
console.log(isValid); // true

这为高安全需求的加密应用提供了新选择。

3. Zlib:zstdCompress 和 zstdDecompress 字典支持

Zlib 模块的 zstdCompresszstdDecompress 函数新增字典支持,通过预定义常见模式提升压缩效率。

示例

const zlib = require('zlib');
const dict = Buffer.from('common patterns'); // 自定义字典

const compressed = zlib.zstdCompressSync('data to compress', { dictionary: dict });
const decompressed = zlib.zstdDecompressSync(compressed, { dictionary: dict });
console.log(decompressed.toString()); // 'data to compress'

这优化了数据传输和存储场景。

4. HTTP:新增 keepAliveTimeoutBuffer 选项

HTTP 服务器新增 keepAliveTimeoutBuffer 选项,用于缓冲 keep-alive 超时,提升连接管理效率。

示例

const http = require('http');

const server = http.createServer((req, res) => res.end('Hello'));
server.keepAliveTimeoutBuffer = 1000; // 缓冲 1 秒
server.listen(3000);

这有助于减少网络抖动,提高服务器稳定性。

5. Lib:文档废弃 http*

内部 HTTP 模块的 _http_* 函数已被文档废弃,鼓励使用标准 API,提升代码规范性。

6. FS:移植 SonicBoom 作为 Utf8Stream

FS 模块引入了 Utf8Stream,通过移植 SonicBoom 提升文件流处理性能,适合高吞吐量场景。

7. 其他改进

  • 基准测试:优化基准脚本,提升测试效率。
  • 依赖更新:升级 ada 到 3.2.7、OpenSSL 到 3.5.2,确保安全性。
  • 文档优化:修复 Pbkdf2Params 和 x509.keyUsage 的文档问题。

最后

Node.js 新版本从 Crypto 的后量子算法到 HTTP 的连接优化,这些功能让你的项目更健壮、更高效。快来升级到 v24.6.0,体验这些实用的新特性吧!

今天的分享就这些了,感谢大家的阅读!如果文章中存在错误的地方欢迎指正!

往期精彩推荐

昨天 — 2025年8月16日技术

ECharts 实战技巧:揭秘 X 轴末项标签 “莫名加粗” 之谜及破解之道

作者 小小愿望
2025年8月16日 18:00

在使用 ECharts 进行数据可视化时,你是否遇到过 X 轴最后一个标签文字突然变粗的情况?这一看似诡异的现象背后其实隐藏着渲染机制的小陷阱。本文将深入剖析问题根源,并提供多种实用解决方案,助你精准掌控图表的每一个细节。


一、引言

在利用 ECharts 构建精美图表的过程中,一些细微却棘手的问题时常困扰着开发者。其中,X 轴最后一项标签字体莫名变粗就是一个典型例子。这一问题虽不影响数据准确性,但却破坏了图表的整体美观度与专业性,尤其对于追求极致视觉效果的项目而言,更是亟待解决的难题。今天,我们就一同揭开这个 “谜团”,探索其背后的成因及有效的应对策略。

二、问题重现与影响

当你按照常规流程配置好 X 轴的相关参数,满心期待地看到整齐划一的坐标标签时,却发现最后一个标签仿佛被施了魔法一般,字体比其他项更粗。这种突兀的变化使得整个 X 轴看起来极不协调,降低了图表的专业性和可读性。无论是用于内部汇报还是对外展示,这样的瑕疵都可能让人对你的工作成果产生质疑。

三、问题根源深度解析

经过深入研究和大量实践验证,我们发现这一问题主要源于以下几个因素的综合作用: 重复渲染机制:当设置 axisLabel.interval0(即强制显示所有标签)时,ECharts 内部的渲染引擎可能会对最后一个标签进行额外的重复绘制操作。由于叠加效应,导致视觉上呈现出字体加粗的效果。这是因为在某些情况下,为了确保长文本或其他特殊布局的需求,框架会自动添加一层备用渲染层,而恰好在这个边界条件下触发了两次绘制。

四、多维度解决方案汇总

针对上述问题根源,我们提供了以下几种行之有效的解决方法,你可以根据实际需求选择合适的方案:

✅ 方案一:巧用边框覆盖法

此方法的核心思想是通过给标签添加一个与背景色相同的宽边框,从而巧妙地遮盖住下方重复渲染的文字,达到视觉上的修正效果。

xAxis: {
    type: 'category',
    axisLabel: {
        borderWidth: 10,      // 设置较大的边框宽度以完全覆盖下层文字
        borderColor: '#fff', // 边框颜色需与背景色一致
        interval: 0,         // 强制显示所有标签
        rotate: -30          // 可选:适当旋转文字防止重叠
    },
    data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
};

优势:无需改动现有数据结构和核心逻辑,仅需简单调整样式即可快速见效;兼容性良好,适用于大多数场景。 注意:边框宽度应根据实际字体大小进行调整,确保能完整覆盖底层文字;若背景非纯白色,则需相应修改 borderColor 的值。

🔄 方案二:调整 interval 属性类型

如果你的业务场景允许并非所有标签都强制显示,可以将 interval 改为 'auto',让 ECharts 根据空间大小自动计算合适的显示间隔。这样可以有效避免末尾标签的重复渲染问题。

xAxis: {
    type: 'category',
    axisLabel: {
        interval: 'auto'    // 自动计算显示间隔
    },
    data: [...]
};

优势:实现简单,一行代码即可解决问题;由框架自动控制显示密度,适应不同屏幕尺寸。 局限:可能会导致部分中间标签被省略,不适合必须完整显示所有分类的场景。

🛠️ 方案三:自定义函数精确控制

通过将 interval 设置为自定义函数,你可以获得对每个标签显示与否的完全控制权。以下是强制显示所有标签但不触发重复渲染的写法:

xAxis: {
    type: 'category',
    axisLabel: {
        interval: function(index, value) {
            return true; // 对所有标签返回 true,确保全部显示
        }
    },
    data: [...]
};

优势:灵活性最高,既能保证所有标签可见,又能规避重复渲染导致的样式问题;可用于实现更复杂的条件判断逻辑。 提示:该方法适合对性能要求不高但需要精细控制的场景,因为每次渲染都需要执行回调函数。

💻 方案四:直接操作 DOM(进阶)

对于极端情况或高级定制需求,可以在图表渲染完成后,通过 JavaScript 直接修改最后一个标签的 CSS 样式。

const chart = echarts.init(document.getElementById('main'));
chart.setOption({ /* 你的配置项 */ });

// 监听渲染完成事件
chart.on('finished', () => {
    const labels = document.querySelectorAll('.echarts-label');
    if (labels.length > 0) {
        const lastLabel = labels[labels.length - 1];
        lastLabel.style.fontWeight = 'normal'; // 取消加粗
    }
});

优势:最直接的修复方式,不受框架内部逻辑限制;可结合其他 DOM 操作实现更多特效。 警告:属于 hack 性质的方法,未来框架更新可能导致失效;慎用于生产环境,建议充分测试。

五、避坑指南与最佳实践

  1. 版本敏感性:不同版本的 ECharts 可能存在行为差异,建议查阅官方文档并在项目中固定使用的 ECharts 版本,出现这种情况的好像是v4,据说下一版本已修复。
  2. 响应式考量:如果图表需要在多种设备上展示,建议优先考虑方案二或方案三,它们能更好地适应不同屏幕尺寸下的标签排列。
  3. 性能权衡:频繁调用 finished 事件的方案四可能影响性能,尤其在大数据量或高频更新的场景下应谨慎使用。

六、结语

X 轴末项标签字体变粗虽是一个小概率事件,但却考验着我们对 ECharts 渲染机制的理解深度。通过本文的介绍,相信你已掌握了多种应对之策。在实际项目中,建议优先尝试方案一或方案三,它们能在保持代码简洁的同时提供可靠的解决方案。记住,优秀的可视化作品不仅在于数据的准确传达,更在于每一个细节的精心雕琢。愿你在未来的数据可视化之旅中,能够更加游刃有余地驾驭 ECharts 这个强大的工具!

移动端浏览器中设置 100vh 却出现滚动条?

作者 小小愿望
2025年8月16日 17:58

🎉 写在前面 你是否遇到过这样的诡异场景:明明设置了 height: 100vh,却在移动端意外触发了滚动条?本文将从底层原理到实战方案为你彻底剖析这一经典陷阱,并提供多种可靠解决方案。


以下是对问题的详细解答:

一、现象原因分析

  1. 浏览器UI元素的动态特性:移动浏览器(如Chrome、Safari)的地址栏、工具栏等界面组件会根据用户操作(如滚动页面)自动显示或隐藏。这种动态行为会导致视口(viewport)的可用高度发生变化,但 100vh 的值始终基于初始隐藏状态下的视口高度计算,而非实时变化的可见区域高度。

  2. 视口高度计算偏差:当地址栏从隐藏变为可见时,实际可用视口高度会减小,但 100vh 仍保持原值,导致内容超出可视区域,触发滚动条。

  3. 浏览器厂商差异:不同浏览器对视口高度的计算逻辑存在差异,例如 iOS Safari 更倾向于将 100vh 视为未包含地址栏的高度。


二、解决方案

✅方案1:动态计算视口高度 + CSS 变量(推荐)

  1. 核心思路:通过 JavaScript 实时获取 window.innerHeight(当前可视区域高度),将其转换为 CSS 变量,并在样式中使用该变量替代 100vh

  2. 实现步骤

    • JavaScript 部分:监听窗口大小变化事件,动态更新 CSS 变量。
      function setViewportHeight() {
        const innerHeight = window.innerHeight ; 
        document.documentElement.style.setProperty('--innerHeight', `${innerHeight}px`);
      }
      window.addEventListener('resize', setViewportHeight);
      setViewportHeight(); // 初始化
      
    • CSS 部分:使用自定义变量控制元素高度。
      .fullscreen {
        height: var(--innerHeight);
        background: pink;
        overflow: hidden; /* 避免子内容溢出 */
      }
      

    优势:
    ✔️ 完美适配各种设备状态变化
    ✔️ 兼容所有支持 CSS 变量的现代浏览器
    ✔️ 无需修改现有布局结构


✅方案2:绝对定位 + 全屏覆盖

  1. 适用场景:简单布局且需完全覆盖屏幕的元素。

  2. 实现代码

    .fullscreen {
      position: absolute;
      width: 100%;
      height: 100%;
      top: 0;
      left: 0;
      background: lightblue;
    }
    

    注意:
    ⚠️ 如果父元素不是 body,需确保其父级链上的所有元素都有 height: 100%
    ⚠️ 此方法会脱离文档流,可能影响其他元素布局

    适用场景

    • 模态对话框/加载动画等临时全屏组件
    • 视频播放器等需要强制全屏的场景

✅方案3:使用动态视口单位(dvh)

  1. 实验性方案:部分现代浏览器支持 dvh(Dynamic Viewport Units),可直接响应视口变化。
    .fullscreen {
      height: 100dvh; /* 根据最新标准动态计算 */
    }
    
    现状: 📱 仅部分现代浏览器支持(Chrome 88+、Edge 88+) 🚫 iOS Safari 暂未支持 👉 适合作为渐进增强方案,需配合 fallback 使用 在这里插入图片描述

💡 经验之谈:无论采用哪种方案都能解决大多数问题,如果不行可以叠加其他方案试试,只用不断地尝试,不断优化适配策略。

taro3.x-4.x路由拦截如何破?

作者 前端嘿起
2025年8月16日 17:51

✨点击上方关注☝️,追踪不迷路!

前言

“大家要是使用过京东的taro框架,都知道taro1.x是支持生命周期BeforeRouteLeave的,这个生命周期主要是用来监听用户返回页面或前进操作,用于弹出弹窗挽留用户等,那么假如你升级到了taro3或taro4,官方是不支持这个生命周期的,需要自己实现,本文主要就是介绍如何添加实现这个功能”

lj1.jpg

hook接口设计

接口名称

useBeforeRouteLeave(fn)

自定义 React 钩子,用于在 Taro 应用中拦截路由跳转,并在跳转前执行自定义逻辑(例如提示用户挽留)

使用场景

  • 在用户尝试离开当前页面时,提示挽留等。
  • 在特定条件下阻止路由跳转。

入参说明

参数 类型 是否必须 描述
fn Fuction 拦截逻辑回调函数,接收一个对象参数{ from, to, next }。

fn回调参数说明

参数 类型 是否必须 描述
from String 当前路由路径
to String 目标路由路径
next String 控制是否允许跳转的函数。flag=true允许跳转,flag=false阻止跳转。

返回值

无返回值

示例代码

import useBeforeRouteLeave from './hooks/useBeforeRouteLeave';

function MyComponent() {
  useBeforeRouteLeave(({ from, to, next }) => {
    Taro.showModal({
      title: '是否离开?',
      confirmText: "确定"
    }).then(res=>{
      if (res.confirm) {
        next(true)
      } else {
        next(false)
      }
    })
  });

  return <div>My Component</div>;
}

代码逻辑设计

代码实现设计

import { useEffect } from 'react'
import { useDidShow } from '@tarojs/taro'
import { history } from '@tarojs/router'
export default function useBeforeRouteLeave(fn) {
  let isunBlocked = false; // 标记拦截状态
  const from = history.location.pathname;
  const unblockWrap = () => {
    let unblock = history.block((tx) => {
      let to = tx.location.pathname
      const next = (flag) => {
        if (flag) {
          setTimeout(() => {
            unblock() // 解除拦截
            tx.retry() // 重试跳转
            isunBlocked = true; // 更新拦截状态
          })
        }
      }
      fn({ from, to, next })
    })
    return () => unblock(); // 返回清理函数
  }
  useEffect(() => { // 注册拦截
    return unblockWrap()
  })

  useDidShow(() => {
    if(isunBlocked) {
      isunBlocked = false;
      unblockWrap() // 重新启用拦截
    }
  })
}
  • 初始化拦截状态:isunBlocked 标记拦截状态,默认为false。
  • 注册拦截逻辑:通过history.block拦截路由跳转。
  • 执行回调:当拦截触发时,调用用户传入的fn函数。
  • 用户决策:用户通过next(flag)决定是否允许跳转。
  • 如果flag=true,解除拦截并重试跳转。
  • 如果flag=false,保持拦截状态。
  • 重新启用拦截:当页面重新显示时(useDidShow),重置拦截状态并重新注册拦截逻辑。

装饰器接口设计

接口名称

useBeforeRouteLeaveHoc()

自定义 React 高阶组件,用于在 Taro 应用中拦截路由跳转,并在跳转前执行自定义逻辑(例如提示挽留)

使用场景

  • 在用户尝试离开当前页面时,提示挽留等。
  • 在特定条件下阻止路由跳转。

装饰说明

给 class 类组件注入了生命周期beforeRouteLeave({from, to, next})

反参说明

参数 类型 是否必须 描述
from String 当前路由路径
to String 目标路由路径
next String 控制是否允许跳转的函数。flag=true允许跳转,flag=false阻止跳转。

示例代码

import { Component } from 'react';
import Taro from "@tarojs/taro";
import { Button, View } from "@tarojs/components";
import { beforeRouteLeaveHoc } from '../../hoc/index';

@beforeRouteLeaveHoc()
class MyComponent extends Component {
  beforeRouteLeave({from, to, next}) {
    console.log('wen', 'beforeRouteLeave');
    Taro.showModal({
      title: 'beforeRouteLeave确定离开吗'
    }).then((res) => {
      if (res.confirm) {
        next(true);
      }

      if (res.cancel) {
        next(false);
      }
    });
  }
  handleRoute = () => {
    Taro.navigateTo({
      url: '/pages/index/index'
    })
  }
  render() {
    return (<View>
      <Button onClick={this.handleRoute.bind(this)}>跳转首页</Button>
    </View>)
  }
}

export default MyComponent;

代码逻辑设计

和hook方式代码逻辑设计一样

代码实现设计

import { history } from '@tarojs/router';
/**
 * 路由离开拦截装饰器
 */
export function beforeRouteLeaveHoc() {
  return function (constructor) {
    return class extends constructor {
      constructor(...args) {
        super(...args);
        this.isunBlocked = false; // 拦截状态标记
        this.from = history.location.pathname; // 当前路径
        this.unblock = null; // 拦截器清理函数
      }

      componentDidMount() {
        if (super.componentDidMount) super.componentDidMount();
        this.setupRouteInterceptor();
      }

      componentDidShow() {
        if (super.componentDidShow) super.componentDidShow();
        if (this.isunBlocked) {
          this.isunBlocked = false;
          this.setupRouteInterceptor();
        }
      }

      componentWillUnmount() {
        if (super.componentWillUnmount) super.componentWillUnmount();
        if (this.unblock) this.unblock();
      }

      setupRouteInterceptor() {
        this.unblock = history.block((tx) => {
          const to = tx.location.pathname;
          const next = (flag) => {
            if (flag) {
              setTimeout(() => {
                if (this.unblock) this.unblock(); // 解除拦截
                tx.retry(); // 重试跳转
                this.isunBlocked = true; // 更新拦截状态
              });
            }
          };
          super.beforeRouteLeave && super.beforeRouteLeave({ from: this.from, to, next });
        });
      }
    };
  };
}

最后,创作不易请允许我插播一则自己开发的小程序广告,感兴趣可以访问体验:

【「合图图」产品介绍】

  • 主要功能为:本地添加相册图片进行无限长图高清拼接,各种布局拼接等

  • 安全:无后台服务无需登录,全程设备本地运行,隐私100%安全;

  • 高效:自由布局+实时预览,效果所见即所得;

  • 高清:秒生高清拼图,一键保存相册。

  • 立即体验 →合图图 或微信小程序搜索「合图图」

如果觉得本文有用 ,欢迎点个赞👍和收藏⭐支持我吧!

请不要再只会回答宏任务和微任务了

作者 fail_to_code
2025年8月16日 17:41

关于js的事件循环,我相信凡是从事前端工作的开发者,都有一定程度的了解,但大多都是“背书”,从“js是个单线程语言”开始,到“宏任务和微任务队列,微任务优先级更高”结束。 概念其实没什么大问题,但是随着浏览器逐渐演变成仅次于操作系统的复杂应用,我们的传统观念也需要一定的更新,今天,带大家从浏览器的视角出发,看看当下的事件循环是什么样子。

浏览器的进程模型

何为进程

程序运行需要有它自己专属的内存空间,可以把这块内存空间简单的理解为进程。 image-20220809205743532 每个应用至少有一个进程,进程之间相互独立,即使要通信,也需要双方同意。

何为线程

有了进程后,就可以运行程序的代码了。 运行代码的「人」称之为「线程」。 一个进程至少有一个线程,所以在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程。 如果程序需要同时执行多块代码,主线程就会启动更多的线程来执行代码,所以一个进程中可以包含多个线程。 image-20220809210859457

浏览器有哪些进程线程

首先要明确一点:浏览器是一个多进程多线程的应用程序。 浏览器内部工作极其复杂。为了避免相互影响,为了减少连环崩溃的几率,当启动浏览器后,它会自动启动多个进程。 image-20220809213152371

可以在浏览器的任务管理器中查看当前的所有进程 其中,最主要的进程有:

  1. 浏览器进程

    主要负责界面显示、用户交互、子进程管理等。浏览器进程内部会启动多个线程处理不同的任务。

  2. 网络进程

    负责加载网络资源。网络进程内部会启动多个线程来处理不同的网络任务。

  3. 渲染进程(本文重点讲解的进程)

    渲染进程启动后,会开启一个渲染主线程,主线程负责执行 HTML、CSS、JS 代码。

    默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证不同的标签页之间不相互影响。

    将来该默认模式可能会有所改变,有兴趣的同学可参见chrome官方说明文档

渲染主线程是如何工作的

渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:

  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面画 60 次
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数
  • ......

关于渲染进程为什么不使用多线程来处理这么多任务,我其实推荐大家可以去看看《你不知道的javascript》中卷第二章,里面详细讲述了js为什么被设计为单线程

要处理这么多的任务,主线程遇到了一个前所未有的难题:如何调度任务? 比如:

  • 我正在执行一个 JS 函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
  • 我正在执行一个 JS 函数,执行到一半的时候某个计时器到达了时间,我该立即去执行它的回调吗?
  • 浏览器进程通知我“用户点击了按钮”,与此同时,某个计时器也到达了时间,我应该处理哪一个呢?
  • ......

渲染主线程想出了一个绝妙的主意来处理这个问题:排队。 image-20220809223027806

  1. 在最开始的时候,渲染主线程会进入一个无限循环
  2. 每一次循环会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。
  3. 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务,这样一来,就可以让每个任务有条不紊的、持续的进行下去了。

整个过程,被称之为事件循环(消息循环)

何为异步

代码在执行过程中,会遇到一些无法立即处理的任务,比如:

  • 计时完成后需要执行的任务 —— setTimeoutsetInterval
  • 网络通信完成后需要执行的任务 -- XHRFetch
  • 用户操作后需要执行的任务 -- addEventListener

如果让渲染主线程等待这些任务的时机达到,就会导致主线程长期处于「阻塞」的状态,从而导致浏览器「卡死」

image-20220810104344296

渲染主线程承担着极其重要的工作,无论如何都不能阻塞! 因此,浏览器选择异步来解决这个问题

image-20220810104858857

使用异步的方式,渲染主线程永不阻塞

任务有优先级吗

我们都知道事件循环的过程中包含宏任务和微任务的说法,经常讲,事件循环往往从宏任务开始,但是在执行下一个宏任务前,我们需要先将本轮宏任务产生的微任务执行完毕。 那么任务有优先级吗?答案是没有但是任务队列有。 根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。 在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
  • 浏览器必须准备好一个微队列,微队列中的任务优先所有其他任务执行 html.spec.whatwg.org/multipage/w…

宏任务队列已经无法满足当前的浏览器要求了

在目前 chrome 的实现中,至少包含了下面的队列:

  • 延时队列:用于存放计时器到达后的回调任务,优先级「中」
  • 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」
  • 微队列:用户存放需要最快执行的任务,优先级「最高」
  • .....

以下是模拟浏览器任务调度的伪代码(由豆包生成),重点体现了三种队列的优先级关系和处理流程:

// 模拟浏览器的三种任务队列
const queues = {
  microtasks: [],         // 微任务队列(最高优先级)
  inputQueue: [],         // 交互队列(用户输入等)
  timerQueue: [],         // 延时队列(setTimeout/setInterval)
  renderingQueue: []      // 渲染队列(额外补充,用于完整模拟)
};

// 任务调度器状态
let isProcessing = false;

// 模拟浏览器的任务处理主循环
function browserMainLoop() {
  // 持续运行的事件循环
  while (true) {
    // 1. 先处理所有微任务(最高优先级)
    processAllMicrotasks();
    
    // 2. 检查是否需要渲染(通常在微任务后考虑)
    if (shouldRender()) {
      processRenderingTasks();
    }
    
    // 3. 处理高优先级任务:交互队列(用户输入优先于定时器)
    if (queues.inputQueue.length > 0) {
      processNextTask(queues.inputQueue);
      continue;
    }
    
    // 4. 处理延时队列任务(优先级低于交互)
    if (queues.timerQueue.length > 0) {
      // 只处理已到期的定时器任务
      const now = getCurrentTime();
      const readyTimers = queues.timerQueue.filter(task => task.expires <= now);
      if (readyTimers.length > 0) {
        // 按到期时间排序,先处理最早到期的
        readyTimers.sort((a, b) => a.expires - b.expires);
        processNextTask(readyTimers);
        continue;
      }
    }
    
    // 5. 若没有任务,进入休眠等待新任务
    waitForNewTasks();
  }
}

// 处理所有微任务(执行到队列为空)
function processAllMicrotasks() {
  while (queues.microtasks.length > 0) {
    const microtask = queues.microtasks.shift();
    executeTask(microtask);
  }
}

// 处理单个任务队列中的下一个任务
function processNextTask(queue) {
  if (queue.length === 0) return;
  
  isProcessing = true;
  const task = queue.shift();
  executeTask(task);
  isProcessing = false;
  
  // 执行完一个任务后,再次检查微任务(微任务会在当前任务后立即执行)
  processAllMicrotasks();
}

// 执行任务的具体逻辑
function executeTask(task) {
  try {
    task.callback();  // 执行任务的回调函数
  } catch (error) {
    reportError(error);  // 处理任务执行中的错误
  }
}

// 辅助函数:检查是否需要渲染
function shouldRender() {
  // 简化逻辑:根据浏览器刷新频率(如60Hz约16ms一次)判断是否需要渲染
  return getCurrentTime() - lastRenderTime > 16;
}

// 模拟添加任务的API(对应浏览器提供的API)
const browser = {
  // 添加微任务(如Promise.then)
  queueMicrotask(callback) {
    queues.microtasks.push({ callback });
  },
  
  // 添加延时任务(如setTimeout)
  setTimeout(callback, delay) {
    const expires = getCurrentTime() + delay;
    queues.timerQueue.push({ callback, expires });
  },
  
  // 添加交互任务(如click事件)
  addInputTask(callback) {
    queues.inputQueue.push({ callback });
  },
  
  // 添加渲染任务
  requestAnimationFrame(callback) {
    queues.renderingQueue.push({ callback });
  }
};

核心优先级规则说明:

  1. 微任务队列(microtasks)优先级最高 - 无论其他队列是否有任务,当前执行栈空闲时会先清空所有微任务 - 对应API:Promise.thenqueueMicrotaskasync/await
  2. 交互队列(inputQueue)次之 - 用户输入(点击、键盘等)任务优先级高于定时器,保证用户操作的响应速度 - 浏览器会优先处理用户交互,避免界面卡顿感
  3. 延时队列(timerQueue)优先级较低 - setTimeout/setInterval任务仅在没有交互任务时才会被处理 - 定时器的实际执行时间可能比设定时间晚(受队列阻塞影响)
  4. 渲染任务(renderingQueue)适时执行 - 通常在微任务处理后、其他任务执行前检查是否需要渲染 - 遵循显示器刷新率(如60fps),避免过度渲染消耗性能

这个伪代码简化了浏览器的实际实现,但核心逻辑符合现代浏览器(包括Chrome)的任务调度原则:优先保证用户交互响应速度,其次处理定时任务,而微任务则始终在当前任务周期内立即完成

实际浏览器中,任务调度会更复杂,还会涉及任务优先级动态调整、线程池管理、节能策略等,但上述伪代码已能体现三种队列的核心优先级关系。

总结

本文主要从浏览器的渲染进程的视角出发,为大家讲解当前浏览器环境下的事件循环是什么样的,如果正在阅读本文的你之前并不了解什么是事件循环,我这里推荐你阅读这篇文章,我相信读完以后,你一定能对事件循环有一定程度的了解。

Webpack 配置与优化全攻略:从基础到进阶实战

2025年8月16日 17:40

在前端工程化中,Webpack 作为模块打包工具的核心地位无可替代。无论是项目构建、代码优化还是开发体验提升,Webpack 的配置与优化能力直接影响开发效率和线上性能。本文将结合实际场景,系统梳理 Webpack 的基础配置与进阶优化策略,助你从入门到精通。


一、Webpack 基础配置:从零搭建项目

1. 核心概念速览

  • Entry:入口文件,打包的起点(如 src/index.js)。
  • Output:输出配置,指定打包后的文件路径和名称。
  • Loader:处理非 JS 文件(如 CSS、图片、TS),通过管道链式调用。
  • Plugin:扩展功能(如生成 HTML、压缩代码、优化依赖)。
  • Mode:环境模式(development/production),影响内置优化策略。

2. 最小化配置示例

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

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      { 
        test: /\.js$/, 
        exclude: /node_modules/, 
        use: 'babel-loader' 
      },
      { 
        test: /\.css$/, 
        use: ['style-loader', 'css-loader'] 
      },
    ],
  },
  plugins: [new HtmlWebpackPlugin({ template: './public/index.html' })],
  mode: 'production',
};

3. 关键配置解析

  • Loader 顺序:从右到左执行(如 ['style-loader', 'css-loader'] 先解析 CSS,再注入样式)。
  • Plugin 作用HtmlWebpackPlugin 自动生成 HTML 并注入打包后的 JS 文件。
  • 环境区分:通过 mode 自动启用对应环境的优化(如生产模式默认压缩代码)。

二、Webpack 优化策略:提升性能与体验

1. 代码分割(Code Splitting)

问题:单文件过大导致首屏加载慢。
解决方案

  • 路由级懒加载:结合 React/Vue 的动态导入(import())。
    // React 示例
    const Home = React.lazy(() => import('./Home'));
    
  • 公共依赖提取:使用 SplitChunksPlugin 拆分 node_modules
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
          },
        },
      },
    },
    

2. Tree Shaking:移除未使用代码

原理:基于 ES6 模块的静态分析,标记未导出代码。
配置

  • 生产模式自动启用(mode: 'production')。
  • 确保代码使用 ES6 模块语法(import/export)。
  • package.json 中添加 "sideEffects": false(或指定有副作用的文件)。

3. 缓存优化:加速二次构建

场景:开发时重复构建耗时。
方案

  • 文件内容哈希output.filename: '[name].[contenthash].js',文件内容变化时哈希更新。
  • Loader 缓存:配置 babel-loader 缓存目录。
    {
      test: /\.js$/,
      use: {
        loader: 'babel-loader',
        options: { cacheDirectory: true },
      },
    }
    
  • Webpack 5 持久化缓存
    cache: {
      type: 'filesystem', // 使用文件系统缓存
      buildDependencies: {
        config: [__filename], // 当配置文件变更时缓存失效
      },
    },
    

4. 缩小打包体积

方法

  • 压缩代码
    • JS:TerserPlugin(Webpack 5 内置)。
    • CSS:CssMinimizerPlugin
  • 图片压缩:使用 image-webpack-loader
  • CDN 引入:通过 externals 排除大型库(如 React、Lodash)。
    externals: {
      react: 'React',
      lodash: '_',
    },
    

5. 构建速度优化

痛点:项目规模扩大后构建变慢。
策略

  • 缩小文件搜索范围
    resolve: {
      extensions: ['.js', '.jsx'], // 减少扩展名猜测
      alias: { '@': path.resolve(__dirname, 'src') }, // 路径别名
    },
    
  • 多进程构建:使用 thread-loader 并行处理耗时任务(如 Babel 转译)。
    {
      test: /\.js$/,
      use: ['thread-loader', 'babel-loader'],
    }
    
  • 忽略大型依赖:通过 noParse 跳过已压缩的文件(如 jQuery)。
    module: {
      noParse: /jquery|lodash/,
    }
    

三、开发体验优化:提升效率

1. 热更新(HMR)

作用:修改代码后局部更新,无需刷新页面。
配置

devServer: {
  hot: true, // 启用 HMR
  open: true, // 自动打开浏览器
},

2. Source Map 调试

场景:生产环境报错时定位源码。
方案

  • 开发环境:devtool: 'eval-cheap-module-source-map'(快速生成)。
  • 生产环境:devtool: 'source-map'(完整映射,但体积大)。

四、Webpack 5 新特性(2024 必知)

  1. 持久化缓存:默认启用文件系统缓存,显著提升二次构建速度。
  2. 模块联邦(Module Federation):实现微前端架构的跨应用代码共享。
  3. 更好的 Tree Shaking:支持嵌套 Tree Shaking 和 CommonJS 模块的静态分析。

五、总结与实战建议

  • 优化效果对比

    优化项 构建时间 打包体积 首屏加载时间
    基础配置 12s 1.2MB 3.5s
    代码分割+缓存 8s 800KB 1.8s
    Webpack 5 全优化 3s 600KB 1.2s
  • 推荐工具链

    • 脚手架:create-vite(基于 Rollup,但 Webpack 生态兼容)。
    • 监控:webpack-bundle-analyzer 分析打包依赖。

最后:Webpack 的优化是一个动态过程,需结合项目规模、团队习惯和业务场景灵活调整。建议从实际痛点出发,逐步引入优化策略,避免过度配置。


延伸阅读

【渲染流水线】[几何阶段]-[归一化NDC]以UnityURP为例

作者 SmalBox
2025年8月16日 17:34
  • NDC空间‌:透视除法的结果,顶点坐标已归一化,可直接用于视口映射和裁剪‌

【从UnityURP开始探索游戏渲染】专栏-直达

  • 在渲染管线中,‌归一化严格等同于透视除法‌,是齐次坐标到NDC空间转换的核心步骤‌。Unity中这步,自动执行。
  • 数据归一化主要通过‌NDC空间(归一化设备坐标)转换‌实现,其核心原理是将裁剪空间坐标统一映射到标准范围([-1,1]的立方体内(OpenGL标准)或[0,1](DirectX标准))
  • 可以看作是一个矩形内的坐标体系。经过转化后的坐标体系是 限制在一个立方体内的坐标体系。无论x y z轴在坐标体系内的范围都是(-1, 1)。归一化后,z轴向屏幕内。
  • 归一化范围在OpenGL中范围为[-1, 1],DirectX中为[0, 1]。映射到屏幕时(0, 0)点:GpenGL是左下角,DirectX是左上角。

归一化原理

透视除法(Perspective Division)

将齐次裁剪空间坐标的(x,y,z)分量除以w分量,得到NDC坐标

此操作将坐标归一化至[-1,1]范围(OpenGL/Unity)或[0,1]范围(Direct3D)‌。

NDCExample.shader

  • 1.URP标准坐标转换流程
  • 2.手动NDC坐标计算
  • 3.通过v2f结构传递NDC数据
// hlsl
Shader "Custom/NDCDemo"
{
    SubShader
    {
        Pass
        {
            HLSLPROGRAM
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes { float4 vertex : POSITION; };
            struct Varyings { float4 pos : SV_POSITION; float3 ndc : TEXCOORD0; };

            Varyings vert(Attributes v)
            {
                Varyings o;
                o.pos = TransformObjectToHClip(v.vertex.xyz);
                // 手动计算NDC坐标
                o.ndc = o.pos.xyz / o.pos.w; 
                return o;
            }
            ENDHLSL
        }
    }
}

URP中的NDC

Unity URP(Universal Render Pipeline)中,归一化的设备坐标(NDC)映射范围取决于具体的API平台:

  1. Direct3D风格平台‌(如Windows、Xbox等):
    • NDC范围是 ‌**[-1, 1]³**‌(x,y,z三个维度)
    • 深度值(z)映射到[0,1(通过投影矩阵转换)
  2. OpenGL风格平台‌(如MacOS、Linux等):
    • NDC范围是 ‌**[-1, 1]³**‌
    • 深度值(z)保持[-1,1]

URP默认使用‌**[-1,1]³**‌的NDC范围(与Built-in管线一致),但最终会适配目标平台的约定。

坐标转换示例过程

假设有一个世界空间点(2, 1, 5):

  1. 通过视图矩阵转换到视图空间(相机空间)
  2. 通过URP投影矩阵转换到裁剪空间(clip space)
  3. 透视除法得到NDC坐标(w分量除法)

具体数值示例(假设使用D3D风格):

世界坐标 (2, 1, 5)
↓ 视图矩阵转换
视图坐标 (1.5, 0.8, 4.2)
↓ 投影矩阵转换
裁剪坐标 (3.2, 1.6, 8.4, 4.2)
↓ 透视除法 (x/w, y/w, z/w)
NDC坐标 (0.76, 0.38, 2.0) → 超出[-1,1]会被裁剪

深度值特殊处理

在URP中,深度缓冲区的值会被重新映射:

  • 原始NDC的z ∈ [-1,1](OpenGL)或 [0,1](D3D)
  • 最终存储到深度纹理时统一映射到[0,1]范围

可以通过Shader验证:

hlsl
// 在Fragment Shader中:
float ndcZ = clipPos.z / clipPos.w; // 透视除法后的z值
float depth = ndcZ * 0.5 + 0.5;    // D3D平台下实际存储值

URP通过_UNITY_UV_STARTS_AT_TOP等宏处理不同平台的坐标差异,保证跨平台一致性。

NDC转换在实际中的应用

虽然默认NDC计算是固定加速计算的过程,但是有时需要手动计算实现一些定制效果。

在Unity URP中,几何着色器(Geometry Shader)手动计算NDC并实现屏幕映射的典型应用场景包括:

1. 视锥裁剪

  • 将世界坐标转换为NDC后判断是否在[-1,1]范围内

2. 屏幕空间特效

  • ‌ 通过NDC坐标计算UV用于采样屏幕纹理

3. 几何体动态生成

  • ‌ 根据NDC坐标控制顶点生成范围

计算NDC并实现屏幕空间粒子生成示例ScreenSpaceParticle.shader

  • 在几何着色器中通过clipPos.xyz / clipPos.w完成透视除法得到NDC坐标
  • 使用NDC坐标时需注意:
    • D3D平台下y轴需要取反(screenUV.y = 1 - screenUV.y
    • 深度值在D3D平台需映射到[0,1]范围
  • 示例实现了屏幕空间粒子生成效果,可通过NDC坐标控制生成范围

实际应用时可结合_UNITY_MATRIX_VP矩阵进行完整坐标空间转换链验证。


Shader "Custom/NDCGeometryShader"
{
    Properties { _MainTex ("Texture", 2D) = "white" {} }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma geometry geom
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct v2g {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            struct g2f {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                float3 ndc : TEXCOORD1;
            };

            v2g vert(appdata_base v) {
                v2g o;
                o.pos = TransformObjectToHClip(v.vertex);
                o.uv = v.texcoord;
                return o;
            }

            [maxvertexcount(4)]
            void geom(point v2g input[1], inout TriangleStream<g2f> stream) {
                // 手动计算NDC坐标
                float4 clipPos = input[0].pos;
                float3 ndc = clipPos.xyz / clipPos.w;

                // 屏幕空间扩展(生成四边形粒子)
                float size = 0.1;
                g2f o;
                for(int i=0; i<4; i++) {
                    o.pos = clipPos;
                    o.pos.xy += float2((i%2)*2-1, (i/2)*2-1) * size * clipPos.w;
                    o.uv = input[0].uv;
                    o.ndc = ndc;
                    stream.Append(o);
                }
                stream.RestartStrip();
            }

            half4 frag(g2f i) : SV_Target {
                // 使用NDC坐标采样屏幕纹理
                float2 screenUV = i.ndc.xy * 0.5 + 0.5;
                return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, screenUV);
            }
            ENDHLSL
        }
    }
}

【从UnityURP开始探索游戏渲染】专栏-直达

(欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

TypeScript 接口入门:定义代码的契约与形态

作者 烛阴
2025年8月16日 17:07

一、什么是接口?

用于描述一个对象的结构。

// 定义一个名为 User 的接口
interface User {
    id: number;
    name: string;
    email: string;
}

function printUserInfo(user: User) {
    console.log(`ID: ${user.id}, Name: ${user.name}, Email: ${user.email}`);
}

const myUser: User = {

    id: 1,
    name: 'Alice',
    email: 'alice@example.com',
};

printUserInfo(myUser); // OK

const invalidUser: User = {
    id: 2,
    username: 'Bob', // 属性名不匹配 编译时错误
    // 缺少 name,email 属性
};


二、接口的丰富特性

1. 可选属性(Optional Properties)

有时,对象的某些属性不是必需的。我们可以使用 ? 来标记它们。

interface UserProfile {
    id: number;
    username: string;
    bio?: string; // bio 是可选的
}

const user1: UserProfile = { id: 1, username: 'Alice' }; // OK
const user2: UserProfile = { id: 2, username: 'Bob', bio: 'Developer' }; // OK

2. 只读属性(Readonly Properties)

我们可以使用 readonly 关键字来防止对象属性在创建后被修改,这对于创建不可变数据非常有用。

interface Point {
    readonly x: number;
    readonly y: number;
}

const p1: Point = { x: 10, y: 20 };
p1.x = 5; // Error: 无法为“x”赋值,因为它是只读属性。

3. 函数类型

接口也能用来定义函数的签名(参数类型和返回值类型)。

interface SearchFunc {
    (source: string, subString: string): boolean;
}

let mySearch: SearchFunc = function (src: string, sub: string) {
    let result = src.search(sub);
    return result > -1;
};

console.log(mySearch('hello', 'll'));

4. 可索引类型(Indexable Types)

接口可以描述那些可以通过索引得到的类型,比如数组和对象。

interface StringArray {
    [index: number]: string; // 索引是数字,值是字符串
}

let myArray: StringArray;
myArray = ['Bob', 'Fred'];
let myStr: string = myArray[0]; // OK
console.log(myStr);


interface Dictionary {
    [key: string]: any; // 索引是字符串,值是任意类型
}

let user: Dictionary = {
    name: '张三',
    age: 18,
    sex: '男',
}

console.log(user.name);

5. 类实现(Class Implementations)

接口可以被类(Class)implements(实现),强制一个类必须遵循接口定义的契约。

interface ClockInterface {
    currentTime: Date;
    setTime(d: Date): void;
}

class Clock implements ClockInterface {
    currentTime: Date = new Date();
    setTime(d: Date) {
        this.currentTime = d;
    }
    constructor(h: number, m: number) {
        this.currentTime.setHours(h);
        this.currentTime.setMinutes(m);
    }

    printTime() {
        console.log(this.currentTime.toLocaleTimeString());
    }
}


let clock = new Clock(12, 30);
clock.printTime(); //12:30:43
clock.setTime(new Date('2024-5-6 09:30:43'));
clock.printTime(); //09:30:43

三、接口的扩展与合并

1. 继承(Extends)

一个接口可以像类一样继承另一个接口,从而复用和扩展类型定义。

interface Shape {
    color: string;
}

interface PenStroke {
    penWidth: number;
}

// Square 继承了 Shape 和 PenStroke
interface Square extends Shape, PenStroke {
    sideLength: number;
}

let square: Square = {
    color: 'blue',
    penWidth: 5.0,
    sideLength: 10,
};

2. 声明合并(Declaration Merging)

这是一个接口独有的、非常强大的特性。如果你在同一个作用域内定义了两个同名的接口,它们会自动合并成一个单一的接口。

interface Box {
    height: number;
    width: number;
}

interface Box {
    scale: number;
}

// 合并后,Box 接口同时拥有 height, width, 和 scale 属性
const box: Box = { height: 5, width: 6, scale: 10 };

常用的用法 扩展第三方库的类型定义。例如,如果你想为 window 对象添加一个自定义属性,你可以这样做,而不会覆盖原有的定义:

// 在你的 .d.ts 文件中
declare global {
    interface Window {
        myAppConfig: object;
    }
}

// 现在你可以在代码中安全地访问它
window.myAppConfig = { version: '1.0' };

总结

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

使用自定义高亮API增强用户‘/’体验

2025年8月16日 16:48

本篇依然来自于我们的 《前端周刊》 项目!

由团队成员 0bipinnata0 翻译,这位佬有技术追求、翻译风格精准细腻,还擅长挖掘原文背后的技术细节~

欢迎大家 进群 同该佬深度交流😁 以及持续追踪全球最新前端资讯!!

原文地址:Using the Custom Highlight API

生成前端周刊图.png

最近 CSS Custom Highlight API 引起了我的注意,因为 Firefox 最近开始支持它(Firefox 140,2025年6月),这使得所有主流浏览器都支持了这个 API。通过它,你可以对通过 JavaScript 中的 Range() 类获取的文本应用(某些)样式。我本来想说是你选择的文本,但这里实际上并没有涉及真正的普通选择器,这对于像我这样的 CSS 开发者来说是相当不寻常的。

我认为这里需要一个基本的文字说明,因为当我第一次开始研究它时,这样的说明肯定会对我有帮助:

  1. 你需要一个 textNode(例如 document.querySelector("p").firstChild

  2. 然后你需要一个 Range(),在其上执行 setStartsetEnd,这意味着范围现在在这两个整数之间。

  3. 然后你在该 Range 上调用 CSS.highlights.set(),给它一个名称。

  4. 然后你在 CSS 中使用 ::highlight(),传入你刚才使用的名称。

如果我们在页面上有一个 <p> 文本,整个过程看起来是这样的:

const WORD_TO_HIGHLIGHT = "wisdom";
const NAME_OF_HIGHLIGHT = "our-highlight";

const textNode = document.querySelector("p").firstChild;
const textContent = textNode.textContent;

const startIndex = textContent.indexOf(WORD_TO_HIGHLIGHT);
const endIndex = startIndex + WORD_TO_HIGHLIGHT.length;

const range = new Range();
range.setStart(textNode, startIndex);
range.setEnd(textNode, endIndex);

const highlight = new Highlight(range);
CSS.highlights.set(NAME_OF_HIGHLIGHT, highlight); 

在开发者工具中看到这个效果很有趣,单词 "wisdom" 明显应用了自定义 CSS 样式,但在该单词周围没有你通常认为应用这些样式所必需的元素。

image.png

这很可能就是浏览器本身在需要仅对文本的某些部分应用样式时所做的事情,比如当你使用浏览器内置的查找功能时。

image.png

这是演示:

codepen.io/editor/anon…

为什么这很有用?

  • 能够在完全不需要操作 DOM 的情况下定位和样式化文本是很有趣的。有时,DOM API 被批评为缓慢,所以能够避免这种情况可能是有利的,特别是如果你需要大量这样做的话。

  • 添加和删除 <span> 元素,除了可能"缓慢"之外,还会影响 DOM 结构,从而可能影响其他处理 DOM 的 CSS 和 JavaScript。

  • DOM 复杂度可能是网页性能的一个问题。过多的 DOM 节点,重新计算可能非常"昂贵",页面上的用户体验可能会受到影响,比如动画和滚动变慢。

这是一个只有 17 个更改文件的 GitHub PR 页面。该页面已经有超过 4,500 个 span 元素,用于诸如代码差异着色和语法高亮等功能。这已经相当重了,而且肯定会变得更糟。

![image.png](使用自定义高亮API增强用户‘+’体验+54149756-b7ed-41c7-9c27-b0ec61235095/image 2.png)

我确信这个 API 存在的原因还有很多,但这些只是我立即想到的几个原因。

做更多事情(搜索示例)

创建一个 new Highlight() 可以接受多个 Range。这意味着 CSS 中的单个 ::highlight() 可以应用于许多文本范围。如果我们在页面上构建自己的搜索功能,这将很有用。如果搜索是你正在构建的 Web 应用程序的关键功能,我可以很容易地想象为它构建自己的 UI,而不是依赖内置的浏览器功能。

这次,让我们让要在文本中查找的单词来自用户:

<label>
  Search the text below
  <input type="search" value="oven" id="searchTerm">
</label>  

然后我们监听变化:

window.searchTerm.addEventListener("input", (e) => {
  doSearch(e.target.value.toLowerCase());
}); 

注意我们将输入的值传递给一个函数,并在传递时将其转换为小写,因为搜索在不区分大小写时通常最有用。

我们的 doSearch 函数然后将接受该搜索词并在所有文本上运行正则表达式:

const regex = new RegExp(searchTerm, "gi"); 

我们需要的是一个包含所有找到的文本实例索引的数组。这是一段有点冗长的代码,但就是这样:

const indexes = [...theTextContent.matchAll(new RegExp(searchTerm, 'gi'))].map(a => a.index); 

有了这个索引数组,我们可以循环遍历它们创建 Range,然后将所有 Range 发送到新的 Highlight。

const arrayOfRanges = [];

indexes.forEach(matchIndex => {
  // 从索引值创建一个 "Range"。
  const searchRange = new Range();
  searchRange.setStart(par, matchIndex);
  searchRange.setEnd(par, matchIndex + searchTerm.length);

  arrayOfRanges.push(searchRange);
})

const ourHighlight = new Highlight(...arrayOfRanges);
CSS.highlights.set("search-results", ourHighlight); 

总的来说,它创建了一个功能完整的搜索体验:

codepen.io/editor/anon…

用于语法高亮

感觉语法高亮代码是这个 API 的一个很好的用例。André Ruffert 已经采用了这个想法并付诸实践,制作了一个 [<syntax-highlight> Web Component](https://andreruffert.github.io/syntax-highlight-element/),它使用 Lea Verou 的 Prism.js 来解析代码,但然后不像开箱即用的 Prism 那样应用 <span>,而是使用这个自定义高亮 API。

示例:

codepen.io/editor/anon…

我认为这很棒,但值得注意的是,这个 API 只能在客户端使用。对于语法高亮这样的功能,这可能意味着在看到代码和语法高亮"生效"之间会有延迟。我承认在可能的情况下,我更喜欢服务器端渲染的语法高亮。这意味着如果你可以从服务器提供一堆像这样的 <span>(并且不会严重影响性能或可访问性),那可能会更好。

我也承认我仍然对内置语法高亮的字体有些着迷,这感觉像是字体厂商可以进入的未开发领域。

焕新扫雷体验,Trae如何让童年游戏更现代?

2025年8月16日 16:43

前言

今天来还原童年记忆中的扫雷游戏,主要是让Trae用代码实现这个游戏的核心功能,你是否还记得初中上计算机课,偷偷背着老师玩扫雷游戏,今天就看看Trae怎么实现。

这个游戏的核心功能

先把这个核心逻辑发给Trae,看看他完成的是不是你想要的童年记忆

  1. 游戏开始时,玩家会看到一个由方格组成的网格,其中一些方格内藏有地雷。
  2. 玩家通过点击方格来揭开其内容,若点击到地雷,则游戏结束。
  3. 若点击的方格没有地雷,则会显示该方格周围相邻方格中地雷的数量。
  4. 玩家可以右键标记疑似藏有地雷的方格,防止误触。
  5. 玩家需要在不触碰地雷的前提下,揭开所有没有地雷的方格,才算通关。

image.png

这效果、这完成度已经不错了,看起来有些微信小游戏的味道了,右上角还很贴心的安排上音效按钮,如果不喜欢我们可以关闭音效,沉浸式的扫雷。 image.png

Trae代码解读

通过设定网格的行列数和地雷的总数,来初始化游戏的布局,生成一个二维数组来表示游戏面板,通过for循环来填充网格,随机放置地雷的位置。

for (let i = 0; i < rows; i++) {
    const row = [];
    for (let j = 0; j < cols; j++) {
        row.push({
            isMine: false,
            isRevealed: false,
            isMarked: false,
            neighborMines: 0
        });
    }
    grid.push(row);
}

const minesToPlace = totalMines;
let minesPlaced = 0;

while (minesPlaced < minesToPlace) {
    const row = Math.floor(Math.random() * rows);
    const col = Math.floor(Math.random() * cols);
    if (!grid[row][col].isMine) {
        grid[row][col].isMine = true;
        minesPlaced++;
    }
}

通过遍历网格,计算每个非地雷方格周围相邻地雷的数量

for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
        if (!grid[i][j].isMine) {
            grid[i][j].neighborMines = countNeighborMines(i, j);
        }
    }
}

通过事件监听实现玩家点击揭开或标记方格的动作,并判断游戏是否胜利或失败

board.addEventListener('click', (e) => {
    const col = Math.floor((e.clientX - boardRect.left) / cellSize);
    const row = Math.floor((e.clientY - boardRect.top) / cellSize);
    revealCell(row, col);
    checkGameStatus();
});

board.addEventListener('contextmenu', (e) => {
    e.preventDefault();
    const col = Math.floor((e.clientX - boardRect.left) / cellSize);
    const row = Math.floor((e.clientY - boardRect.top) / cellSize);
    toggleMarkCell(row, col);
});

最后是来自Trae自己对这款扫雷的总结,主要是游戏功能和设计,还有考虑到游戏体验,非常的人性化

image.png Trae在生成时,考虑的情况,主要是地雷、数字提示、计时器、难度选择等因素 image.png

总结

1、这个游戏的核心功能,主要是靠玩家来标记,以及揭开,Trae非常人性化的支持双击快速揭开,来让高级玩家有好的游戏体验。

2、考虑到游戏玩家可能没玩过,Trae也是帮我们设计了三个游戏难度,不会让新手玩家没有体验感直接进入到地狱难度,可以一步步的体验到游戏的难度。

image.png

你是否也玩过在课堂上偷偷的这个游戏呢?啊哈哈哈,你有没有被老师抓到过?

🚀 Vue3 源码深度解析:Diff算法的五步优化策略与最长递增子序列的巧妙应用

2025年8月16日 16:36

🚀 Vue3 源码深度解析:Diff算法的五步优化策略与最长递增子序列的巧妙应用

📚 学习目标

通过本文,你将深入理解:

  • 🎯 Vue3 Diff算法的完整五步策略,而非仅仅是最长递增子序列
  • 🔧 双端比较算法如何最大化节点复用,减少DOM操作
  • ⚡ 最长递增子序列在乱序场景下的核心作用与实现原理
  • 🎨 Key值在Diff算法中的关键作用与性能影响
  • 💡 从算法设计角度理解Vue3相比Vue2的性能提升

🌟 引言

在前端面试中,"Vue3的Diff算法"是一个高频考点。许多候选人的第一反应是"最长递增子序列",但这个回答并不完整。

真相是:Vue3的Diff算法是一个精心设计的五步优化策略,最长递增子序列只是其中一个环节。它通过双端比较、增删处理、乱序优化等多个步骤,实现了对DOM操作的最大化优化。

让我们深入源码,揭开Vue3 Diff算法的神秘面纱。

🔬 核心函数:patchKeyedChildren

patchKeyedChildren 是Vue3 Diff算法的核心实现,负责处理带有key的子节点列表的更新。这个函数体现了Vue3团队在性能优化方面的深度思考。

🎯 算法概览

Vue3的Diff算法采用分治策略,将复杂的列表比较问题分解为五个相对简单的子问题:

  1. 前序比较:处理列表开头的相同节点
  2. 后序比较:处理列表结尾的相同节点
  3. 新增处理:挂载新出现的节点
  4. 删除处理:卸载不再需要的节点
  5. 乱序处理:使用最长递增子序列优化节点移动

这种设计的巧妙之处在于:大多数实际场景下,列表的变化都集中在前四步,只有少数复杂场景才需要进入第五步

  const patchKeyedChildren = (
    c1: VNode[],
    c2: VNode[],
    container: Element,
    parentAnchor: any
  ) => {
    // 📏 初始化指针和长度变量
    let newLen = c2.length // 新子节点数组的长度
    let oldLen = c1.length - 1 // 旧子节点数组的最后一个索引
    let e1 = oldLen // 旧数组的结束指针(从后往前移动)
    let e2 = newLen - 1 // 新数组的结束指针(从后往前移动)
    let i = 0 // 开始指针(从前往后移动)
    
    // 🔍 第一步:从前往后比较,找出开头相同的节点
    // 目的:跳过开头相同的节点,减少后续比较的工作量
    // 例如:[A,B,C,D] vs [A,B,X,Y] → 跳过A,B,从C,D vs X,Y开始处理
    while (i <= e1 && i <= e2) {
      const n1 = c1[i] // 当前旧节点
      const n2 = c2[i] // 当前新节点
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点(可能属性或子节点有变化)
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止前向比较
        break
      }
      i++ // 指针前移
    }
    
    // 🔍 第二步:从后往前比较,找出结尾相同的节点
    // 目的:跳过结尾相同的节点,进一步缩小需要处理的范围
    // 例如:[A,B,C,D] vs [X,Y,C,D] → 跳过C,D,只需处理A,B vs X,Y
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1] // 当前旧节点(从后往前)
      const n2 = c2[e2] // 当前新节点(从后往前)
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止后向比较
        break
      }
      e1-- // 旧数组指针前移
      e2-- // 新数组指针前移
    }
    
    // 📊 经过前后两轮比较后的状态分析:
    // - i: 第一个不同节点的位置
    // - e1: 旧数组中最后一个需要处理的节点位置
    // - e2: 新数组中最后一个需要处理的节点位置
    
    // ✅ 第三步:处理新增节点的情况
    // 条件:i > e1 说明旧节点已经处理完,但新节点还有剩余
    // 例如:旧[A,B] 新[A,B,C,D] → 需要新增C,D
    if (i > e1) {
      if (i <= e2) {
        // 确定插入位置的锚点
        const nextPos = e2 + 1
        // 如果下一个位置存在节点,就插入到它前面;否则插入到容器末尾
        const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor
        
        // 挂载所有新增的节点
        while (i <= e2) {
          // patch(null, newNode) 表示挂载新节点
          patch(null, c2[i], container, anchor)
          i++
        }

### 🎯 第三部分:最优移动策略与最长递增子序列

这是Vue3 Diff算法最精彩的部分,也是**最长递增子序列**真正发挥作用的地方。当前四步都无法处理时,说明遇到了复杂的乱序场景。

#### 🎯 核心挑战

乱序场景的核心挑战是:**如何用最少的DOM移动操作,将旧列表转换为新列表?**

```typescript
// 典型乱序场景
// 旧列表:[A, B, C, D, E]
// 新列表:[A, C, E, B, D, F]
// 挑战:B和D需要移动,F需要新增,同时要保持C和E的相对位置不变
🧩 三步解决策略

Vue3将这个复杂问题分解为三个子问题:

  1. 🗺️ 构建映射表:建立新节点key到索引的快速查找表
  2. 🔍 标记可复用节点:找出哪些旧节点可以复用,哪些需要删除
  3. 🎯 最优移动策略:使用最长递增子序列计算最少移动方案
🔧 关键数据结构
// newIndexToOldIndexMap: 核心数据结构
// 索引:新列表中的位置
// 值:对应旧列表中的位置 + 1(+1是为了区分0和未找到)
// 例:[3, 1, 4, 0] 表示:
// - 新列表[0] 对应 旧列表[2]
// - 新列表[1] 对应 旧列表[0] 
// - 新列表[2] 对应 旧列表[3]
// - 新列表[3] 是新增节点
⚡ 移动检测算法
// 移动检测的巧妙之处
let maxNewIndexSoFar = 0
for (let i = s1; i <= e1; i++) {
  const newIndex = keyToNewIndexMap.get(prevChild.key)
  if (newIndex >= maxNewIndexSoFar) {
    maxNewIndexSoFar = newIndex  // 节点位置递增,无需移动
  } else {
    moved = true  // 发现逆序,需要移动
  }
}

这个算法的精髓在于:如果新位置总是递增的,说明相对顺序没变,无需移动

📊 性能优化细节
// 早期退出优化
if (patched >= toBePatched) {
  unmount(prevChild)
  continue
}

// 这个优化的作用:
// 如果已经处理的节点数量达到新节点总数
// 说明剩余的旧节点都是多余的,直接删除
// 避免不必要的查找和比较操作
🔑 Key值的重要性
// 有key的情况:O(1)查找
if (prevChild.key != null) {
  newIndex = keyToNewIndexMap.get(prevChild.key)
}
// 无key的情况:O(n)查找
else {
  for (j = s2; j <= e2; j++) {
    if (newIndexToOldIndexMap[j - s2] === 0 && 
        isSameVNodeType(prevChild, c2[j])) {
      newIndex = j
      break
    }
  }
}

这就是为什么Vue强烈建议在v-for中使用key的原因

  • ✅ 有key:时间复杂度O(1)
  • ❌ 无key:时间复杂度O(n²)
🎯 最长递增子序列的核心作用

在第三步的移动处理中,最长递增子序列发挥了关键作用:

// 核心执行逻辑
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : []

// 示例场景
// 旧列表:[A, B, C, D, E]  索引:[0, 1, 2, 3, 4]
// 新列表:[A, C, E, B, D]  索引:[0, 1, 2, 3, 4]
// newIndexToOldIndexMap: [1, 3, 5, 2, 4]  // +1偏移后的值

// 最长递增子序列:[1, 3, 5] 对应节点 [A, C, E]
// 结论:A, C, E 不需要移动,只需移动 B, D
⚡ 移动策略优化
// 逆向遍历的巧妙之处
for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i
  const nextChild = c2[nextIndex]
  const anchor = nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor
  
  if (newIndexToOldIndexMap[i] === 0) {
    // 新增节点
    patch(null, nextChild, container, anchor)
  } else if (moved) {
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // 需要移动的节点
      container.insertBefore(nextChild.el, anchor)
    } else {
      j--  // 在最长递增子序列中,不需要移动
    }
  }
}

为什么要逆向遍历?

  • 🎯 保证锚点的正确性:后面的节点位置确定后,前面的节点才能找到正确的插入位置
  • ⚡ 减少DOM操作:避免重复的位置计算
🧮 算法复杂度分析
  • 时间复杂度:O(n log n) - 最长递增子序列算法

  • 空间复杂度:O(n) - 存储序列信息

  • 实际效果:将移动操作从O(n²)优化到接近O(n) } } // 🗑️ 第四步:处理删除节点的情况 // 条件:i > e2 说明新节点已经处理完,但旧节点还有剩余 // 例如:旧[A,B,C,D] 新[A,B] → 需要删除C,D else if (i > e2) { while (i <= e1) { // 卸载多余的旧节点 unmount(c1[i]) i++ } } // 乱序情况:需要进行复杂的diff算法 // 使用最长递增子序列算法来最小化DOM移动操作 else { // 🎯 乱序情况的处理:这是Vue3 diff算法最复杂的部分 // 目标:用最少的DOM操作,将旧子节点列表转换为新子节点列表

    const s1 = i // 旧子节点数组中需要处理的起始位置
    const s2 = i // 新子节点数组中需要处理的起始位置
    
    // 📋 第一步:建立"新节点key → 新节点索引"的快速查找表
    // 作用:后面遍历旧节点时,可以快速找到对应的新节点位置
    // 例如:新节点 [A, B, C] → Map { 'A': 0, 'B': 1, 'C': 2 }
    const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
    for (i = s2; i <= e2; i++) {
      const nextChild = c2[i]
      if (nextChild.key != null) {
        keyToNewIndexMap.set(nextChild.key, i)
      }
    }
    
    // 🔄 第二步:遍历旧子节点,找出可以复用的节点并记录移动信息
    let j
    let patched = 0 // 已经处理(patch)的节点数量
    const toBePatched = e2 - s2 + 1 // 新子节点中需要处理的总数量
    let moved = false // 标记是否有节点需要移动位置
    let maxNewIndexSoFar = 0 // 记录到目前为止遇到的最大新索引
    
    // 📊 创建"新节点索引 → 旧节点索引"的映射数组
    // 作用:记录每个新节点对应的旧节点位置,用于后续的移动优化
    // 值的含义:0 = 全新节点,>0 = 可复用的旧节点索引+1
    const newIndexToOldIndexMap = new Array(toBePatched)
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
    
    // 🔍 遍历所有旧子节点,决定每个节点的命运
    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i] // 当前处理的旧节点
    
      // ⚡ 性能优化:如果已处理的节点数量达到新节点总数,剩余旧节点直接删除
      // 例如:新节点只有3个,但已经处理了3个,那么剩下的旧节点都是多余的
      if (patched >= toBePatched) {
        unmount(prevChild) // 卸载多余的旧节点
        continue
      }
    
      let newIndex // 旧节点在新节点数组中对应的位置
    
      // 🔑 如果旧节点有key,通过key快速查找对应的新节点位置
      if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key)
      } else {
        // 🔍 如果旧节点没有key,只能线性搜索找到相同类型的新节点
        // 注意:这种情况性能较差,建议给列表项添加key
        for (j = s2; j <= e2; j++) {
          // 检查:1) 新节点还没有被匹配 2) 新旧节点类型相同
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j])
          ) {
            newIndex = j
            break
          }
        }
      }
    
      // 🗑️ 如果旧节点在新节点中找不到对应项,说明被删除了
      if (newIndex === undefined) {
        unmount(prevChild) // 从DOM中移除
      } else {
        // ✅ 找到了对应的新节点,记录映射关系
        // +1是因为0被用来表示"新节点",所以旧索引要+1存储
        newIndexToOldIndexMap[newIndex - s2] = i + 1
    
        // 🚀 移动检测的巧妙算法:
        // 如果新索引是递增的,说明节点顺序没变,不需要移动
        // 如果新索引比之前的小,说明节点顺序乱了,需要移动
        // 例如:旧节点A在位置0,B在位置1,如果新顺序是B(1)→A(0),
        //      那么处理A时,newIndex=0 < maxNewIndexSoFar=1,需要移动
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex // 更新最大索引
        } else {
          moved = true // 标记需要移动
        }
    
        // 🔧 对找到的节点进行patch(更新属性、子节点等)
        patch(prevChild, c2[newIndex], container, null)
        patched++ // 已处理数量+1
      }
    }
    
    // 🎯 第三步:处理节点的移动和新节点的挂载
    // 核心思想:只移动必要的节点,最大化复用现有DOM
    
    // 🧮 如果需要移动,计算最长递增子序列(LIS)
    // LIS的作用:找出哪些节点已经在正确位置,不需要移动
    // 例如:[4,2,3,1,5] 的LIS是 [2,3,5],这些位置的节点不用动
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : []
    
    j = increasingNewIndexSequence.length - 1 // LIS的指针,从后往前
    
    // 🔄 从后往前遍历新子节点,确保插入位置正确
    // 为什么从后往前?因为插入时需要知道"锚点"(插入位置的参考节点)
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = s2 + i // 当前新节点在整个新数组中的真实索引
      const nextChild = c2[nextIndex] // 当前要处理的新节点
    
      // 🎯 确定插入的锚点:下一个节点的DOM元素
      // 如果没有下一个节点,就插入到容器末尾
      const anchor =
        nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor
    
      // 🆕 如果是全新节点(映射值为0),直接挂载到DOM
      if (newIndexToOldIndexMap[i] === 0) {
        patch(null, nextChild, container, anchor)
      }
      // 🚚 如果需要移动节点
      else if (moved) {
        // 🎯 移动策略:只移动不在最长递增子序列中的节点
        // 如果当前节点在LIS中,说明它已经在正确位置,不用移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          // 移动节点到正确位置(插入到anchor之前)
          container.insertBefore(nextChild.el, anchor)
        } else {
          // 当前节点在LIS中,位置正确,不需要移动
          j-- // LIS指针前移
        }
      }
    }
    

🎯 五步优化策略详解


通过上面的核心代码,我们可以清晰地看到Vue3 Diff算法的五步处理逻辑。让我们逐一深入分析:

## 🔍 第一步:前序比较优化

```ts
   // 📏 初始化指针和长度变量
    let newLen = c2.length // 新子节点数组的长度
    let oldLen = c1.length - 1 // 旧子节点数组的最后一个索引
    let e1 = oldLen // 旧数组的结束指针(从后往前移动)
    let e2 = newLen - 1 // 新数组的结束指针(从后往前移动)
    let i = 0 // 开始指针(从前往后移动)
    
    // 🔍 第一步:从前往后比较,找出开头相同的节点
    // 目的:跳过开头相同的节点,减少后续比较的工作量
    // 例如:[A,B,C,D] vs [A,B,X,Y] → 跳过A,B,从C,D vs X,Y开始处理
    while (i <= e1 && i <= e2) {
      const n1 = c1[i] // 当前旧节点
      const n2 = c2[i] // 当前新节点
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点(可能属性或子节点有变化)
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止前向比较
        break
      }
      i++ // 指针前移
    }

🎯 核心思想

前序比较的核心思想是跳过开头相同的节点,这是一个非常实用的优化策略:

  • 时间复杂度:O(n),其中n是相同前缀的长度
  • 空间复杂度:O(1),只使用常数级别的额外空间
  • 实际效果:在列表末尾添加/删除元素的场景下,这一步就能处理大部分工作

📊 性能优势

// 场景示例:在列表末尾添加元素
// 旧列表:[A, B, C]
// 新列表:[A, B, C, D, E]
// 前序比较后:只需处理 [D, E] 的新增,跳过了 A, B, C 的比较

这种设计让Vue3在处理追加型更新(如聊天记录、商品列表加载更多)时性能极佳。

🔧 实现细节

// isSameVNodeType 的判断逻辑
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

// 为什么要调用 patch?
// 即使节点类型和key相同,节点的props或children可能发生变化
// patch函数会递归处理这些细节更新

🔄 第二步:后序比较优化

   // 🔍 第二步:从后往前比较,找出结尾相同的节点
    // 目的:跳过结尾相同的节点,进一步缩小需要处理的范围
    // 例如:[A,B,C,D] vs [X,Y,C,D] → 跳过C,D,只需处理A,B vs X,Y
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1] // 当前旧节点(从后往前)
      const n2 = c2[e2] // 当前新节点(从后往前)
      
      // 如果节点类型和key都相同,说明可以复用
      if (isSameVNodeType(n1, n2)) {
        // 递归更新这个节点
        patch(n1, n2, container, parentAnchor)
      } else {
        // 遇到不同的节点,停止后向比较
        break
      }
      e1-- // 旧数组指针前移
      e2-- // 新数组指针前移
    }

🎯 核心思想

后序比较是前序比较的镜像操作,专门处理列表尾部的相同节点

  • 双指针技术e1e2分别指向旧列表和新列表的末尾
  • 逆向遍历:从后往前比较,跳过尾部相同的节点
  • 互补优化:与前序比较形成完美互补,覆盖更多优化场景

📊 典型应用场景

// 场景示例:在列表开头插入元素
// 旧列表:[A, B, C]
// 新列表:[X, Y, A, B, C]
// 后序比较后:跳过 A, B, C,只需处理 X, Y 的新增

🚀 双端优化的威力

前序 + 后序比较的组合,能够高效处理:

  • ✅ 列表头部插入/删除
  • ✅ 列表尾部插入/删除
  • ✅ 列表两端同时变化
  • ✅ 简单的元素替换

➕ 第三步:新增节点处理

    // 📊 经过前后两轮比较后的状态分析:
    // - i: 第一个不同节点的位置
    // - e1: 旧数组中最后一个需要处理的节点位置
    // - e2: 新数组中最后一个需要处理的节点位置
    
    // ✅ 第三步:处理新增节点的情况
    // 条件:i > e1 说明旧节点已经处理完,但新节点还有剩余
    // 例如:旧[A,B] 新[A,B,C,D] → 需要新增C,D
    if (i > e1) {
      if (i <= e2) {
        // 确定插入位置的锚点
        const nextPos = e2 + 1
        // 如果下一个位置存在节点,就插入到它前面;否则插入到容器末尾
        const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor
        
        // 挂载所有新增的节点
        while (i <= e2) {
          // patch(null, newNode) 表示挂载新节点
          patch(null, c2[i], container, anchor)
          i++
        }
      }
    }

🎯 判断逻辑

经过前两步的双端比较后,如果满足 i > e1 && i <= e2,说明存在需要新增的节点:

  • i > e1:旧列表已经遍历完毕
  • i <= e2:新列表还有未处理的节点
  • 结论:这些未处理的节点就是需要新增的节点

🔧 实现细节

// 锚点计算的巧妙之处
const nextPos = e2 + 1
const anchor = nextPos < newLen ? c2[nextPos].el : parentAnchor

// 为什么需要锚点?
// DOM的insertBefore需要一个参考节点
// 如果没有参考节点,就插入到容器末尾

📊 性能特点

  • 时间复杂度:O(m),其中m是新增节点的数量
  • 空间复杂度:O(1)
  • DOM操作:只进行必要的插入操作,无多余的移动

当旧节点的数量少于新节点的数量时,那么此时就需要创建新节点来插入到对应的位置

🗑️ 第四步:删除节点处理

   // 🗑️ 第四步:处理删除节点的情况
    // 条件:i > e2 说明新节点已经处理完,但旧节点还有剩余
    // 例如:旧[A,B,C,D] 新[A,B] → 需要删除C,D
    else if (i > e2) {
      while (i <= e1) {
        // 卸载多余的旧节点
        unmount(c1[i])
        i++
      }
    }

🎯 判断逻辑

当满足 i > e2 && i <= e1 时,说明存在需要删除的节点:

  • i > e2:新列表已经遍历完毕
  • i <= e1:旧列表还有未处理的节点
  • 结论:这些未处理的旧节点需要被删除

🔧 实现细节

// 删除操作的实现
if (i > e2) {
  while (i <= e1) {
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

⚡ 性能优势

  • 批量删除:一次性处理所有需要删除的节点
  • 内存释放:及时释放不再需要的DOM节点和组件实例
  • 事件清理:自动清理相关的事件监听器和响应式依赖
  • 时间复杂度:O(k),其中k是需要删除的节点数量

📊 典型应用场景

// 场景示例:删除列表中的部分元素
// 旧列表:[A, B, C, D, E]
// 新列表:[A, B]
// 删除处理:自动卸载 C, D, E

🌪️ 第五步:乱序情况下的终极优化

这是Vue3 Diff算法最精彩的部分,也是最长递增子序列真正发挥作用的地方。当前四步都无法处理时,说明遇到了复杂的乱序场景。

🎯 核心挑战

乱序场景的核心挑战是:如何用最少的DOM移动操作,将旧列表转换为新列表?

// 典型乱序场景
// 旧列表:[A, B, C, D, E]
// 新列表:[A, C, E, B, D, F]
// 挑战:B和D需要移动,F需要新增,同时要保持C和E的相对位置不变

🧩 三步解决策略

Vue3将这个复杂问题分解为三个子问题:

  1. 🗺️ 构建映射表:建立新节点key到索引的快速查找表
  2. 🔍 标记可复用节点:找出哪些旧节点可以复用,哪些需要删除
  3. 🎯 最优移动策略:使用最长递增子序列计算最少移动方案

🗺️ 第一部分:构建映射表

   // 🎯 乱序情况的处理:这是Vue3 diff算法最复杂的部分
      // 目标:用最少的DOM操作,将旧子节点列表转换为新子节点列表

      const s1 = i // 旧子节点数组中需要处理的起始位置
      const s2 = i // 新子节点数组中需要处理的起始位置

      // 📋 第一步:建立"新节点key → 新节点索引"的快速查找表
      // 作用:后面遍历旧节点时,可以快速找到对应的新节点位置
      // 例如:新节点 [A, B, C] → Map { 'A': 0, 'B': 1, 'C': 2 }
      const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
      for (i = s2; i <= e2; i++) {
        const nextChild = c2[i]
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }

🎯 设计目的

构建映射表是一个经典的空间换时间优化策略:

  • 时间复杂度:从O(n²)降低到O(n)
  • 空间复杂度:O(n),用于存储映射关系
  • 查找效率:从线性查找提升到常数时间查找
📊 性能对比
// 没有映射表的查找(O(n²))
for (let i = 0; i < oldChildren.length; i++) {
  for (let j = 0; j < newChildren.length; j++) {
    if (oldChildren[i].key === newChildren[j].key) {
      // 找到匹配节点
    }
  }
}

// 使用映射表的查找(O(n))
const keyToNewIndexMap = new Map()
for (let i = 0; i < newChildren.length; i++) {
  keyToNewIndexMap.set(newChildren[i].key, i)
}

for (let i = 0; i < oldChildren.length; i++) {
  const newIndex = keyToNewIndexMap.get(oldChildren[i].key)
  // 常数时间找到匹配节点
}
🔧 实现细节
// 为什么使用 Map 而不是普通对象?
// 1. Map 支持任意类型的 key(string | number | symbol)
// 2. Map 的查找性能更稳定
// 3. Map 避免了原型链污染问题

// key 的类型检查
if (nextChild.key != null) {
  // 只有明确设置了 key 的节点才参与映射
  // undefined 和 null 都会被跳过
  keyToNewIndexMap.set(nextChild.key, i)
}

🔍 第二部分:标记可复用节点与移动检测

 // 🔄 第二步:遍历旧子节点,找出可以复用的节点并记录移动信息
      let j
      let patched = 0 // 已经处理(patch)的节点数量
      const toBePatched = e2 - s2 + 1 // 新子节点中需要处理的总数量
      let moved = false // 标记是否有节点需要移动位置
      let maxNewIndexSoFar = 0 // 记录到目前为止遇到的最大新索引

      // 📊 创建"新节点索引 → 旧节点索引"的映射数组
      // 作用:记录每个新节点对应的旧节点位置,用于后续的移动优化
      // 值的含义:0 = 全新节点,>0 = 可复用的旧节点索引+1
      const newIndexToOldIndexMap = new Array(toBePatched)
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

      // 🔍 遍历所有旧子节点,决定每个节点的命运
      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i] // 当前处理的旧节点

        // ⚡ 性能优化:如果已处理的节点数量达到新节点总数,剩余旧节点直接删除
        // 例如:新节点只有3个,但已经处理了3个,那么剩下的旧节点都是多余的
        if (patched >= toBePatched) {
          unmount(prevChild) // 卸载多余的旧节点
          continue
        }

        let newIndex // 旧节点在新节点数组中对应的位置

        // 🔑 如果旧节点有key,通过key快速查找对应的新节点位置
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
          // 🔍 如果旧节点没有key,只能线性搜索找到相同类型的新节点
          // 注意:这种情况性能较差,建议给列表项添加key
          for (j = s2; j <= e2; j++) {
            // 检查:1) 新节点还没有被匹配 2) 新旧节点类型相同
            if (
              newIndexToOldIndexMap[j - s2] === 0 &&
              isSameVNodeType(prevChild, c2[j])
            ) {
              newIndex = j
              break
            }
          }
        }

        // 🗑️ 如果旧节点在新节点中找不到对应项,说明被删除了
        if (newIndex === undefined) {
          unmount(prevChild) // 从DOM中移除
        } else {
          // ✅ 找到了对应的新节点,记录映射关系
          // +1是因为0被用来表示"新节点",所以旧索引要+1存储
          newIndexToOldIndexMap[newIndex - s2] = i + 1

          // 🚀 移动检测的巧妙算法:
          // 如果新索引是递增的,说明节点顺序没变,不需要移动
          // 如果新索引比之前的小,说明节点顺序乱了,需要移动
          // 例如:旧节点A在位置0,B在位置1,如果新顺序是B(1)→A(0),
          //      那么处理A时,newIndex=0 < maxNewIndexSoFar=1,需要移动
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex // 更新最大索引
          } else {
            moved = true // 标记需要移动
          }

          // 🔧 对找到的节点进行patch(更新属性、子节点等)
          patch(prevChild, c2[newIndex], container, null)
          patched++ // 已处理数量+1
        }
      }

第三步:处理节点的移动和新节点的挂载

 // 🎯 第三步:处理节点的移动和新节点的挂载
      // 核心思想:只移动必要的节点,最大化复用现有DOM

      // 🧮 如果需要移动,计算最长递增子序列(LIS)
      // LIS的作用:找出哪些节点已经在正确位置,不需要移动
      // 例如:[4,2,3,1,5] 的LIS是 [2,3,5],这些位置的节点不用动
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : []

      j = increasingNewIndexSequence.length - 1 // LIS的指针,从后往前

      // 🔄 从后往前遍历新子节点,确保插入位置正确
      // 为什么从后往前?因为插入时需要知道"锚点"(插入位置的参考节点)
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i // 当前新节点在整个新数组中的真实索引
        const nextChild = c2[nextIndex] // 当前要处理的新节点

        // 🎯 确定插入的锚点:下一个节点的DOM元素
        // 如果没有下一个节点,就插入到容器末尾
        const anchor =
          nextIndex + 1 < newLen ? c2[nextIndex + 1].el : parentAnchor

        // 🆕 如果是全新节点(映射值为0),直接挂载到DOM
        if (newIndexToOldIndexMap[i] === 0) {
          patch(null, nextChild, container, anchor)
        }
        // 🚚 如果需要移动节点
        else if (moved) {
          // 🎯 移动策略:只移动不在最长递增子序列中的节点
          // 如果当前节点在LIS中,说明它已经在正确位置,不用移动
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            // 移动节点到正确位置(插入到anchor之前)
            container.insertBefore(nextChild.el, anchor)
          } else {
            // 当前节点在LIS中,位置正确,不需要移动
            j-- // LIS指针前移
          }
        }
      }

🧮 最长递增子序列算法深度解析

在第3部分中,涉及到了最长递增子序列:getSequence(newIndexToOldIndexMap),这个函数是Vue3 Diff算法的核心优化,用于计算出最少的DOM移动次数。

🎯 算法核心思想

最长递增子序列(Longest Increasing Subsequence, LIS)在Vue3中的作用是:找出哪些节点已经处于正确的相对位置,无需移动

// 示例场景
// newIndexToOldIndexMap: [4, 2, 3, 1, 5]
// 表示:新位置0对应旧位置3,新位置1对应旧位置1,以此类推
// 
// LIS算法会找出:[2, 3, 5] (索引为1, 2, 4的元素)
// 含义:这些位置的节点相对顺序正确,不需要移动
// 只需要移动其他节点:索引0和3的节点

⚡ 算法实现与优化

/**
 * 计算最长递增子序列的函数
 * 这是Vue3 diff算法的核心优化,用于最小化DOM移动操作
 *
 * 🎯 算法原理:
 * 1. 使用动态规划 + 二分查找,时间复杂度O(n log n)
 * 2. 维护一个递增序列,对每个元素二分查找插入位置
 * 3. 通过前驱数组记录路径,最后回溯得到完整序列
 * 4. 贪心策略:总是保持当前长度下的最小尾元素
 *
 * @param arr 输入数组,通常是newIndexToOldIndexMap
 * @returns 最长递增子序列的索引数组
 */
function getSequence(arr: number[]): number[] {
  const p = arr.slice() // 🔗 前驱数组,记录每个位置的前一个元素索引
  const result = [0] // 📊 结果数组,存储最长递增子序列的索引
  let i, j, u, v, c
  const len = arr.length

  // 🔄 主循环:处理每个元素
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    
    // ⚡ 关键优化:跳过值为0的元素
    // 0表示新节点,不参与LIS计算,因为新节点没有"原始位置"
    if (arrI !== 0) {
      j = result[result.length - 1] // 当前序列的最后一个索引

      // 🚀 快速路径:如果当前元素大于序列最后元素,直接追加
      // 这是最常见的情况,避免了二分查找的开销
      if (arr[j] < arrI) {
        p[i] = j // 记录前驱关系
        result.push(i) // 扩展序列
        continue
      }

      // 🔍 二分查找:找到第一个大于等于arrI的位置
      // 目标:在保持序列递增的前提下,找到最佳插入位置
      u = 0 // 左边界
      v = result.length - 1 // 右边界
      
      while (u < v) {
        c = (u + v) >> 1 // 🎯 位运算取中点,比Math.floor((u + v) / 2)更快
        
        if (arr[result[c]] < arrI) {
          u = c + 1 // 在右半部分继续查找
        } else {
          v = c // 在左半部分继续查找
        }
      }

      // 🔄 贪心替换:如果找到更优的元素,进行替换
      // 贪心策略:相同长度的递增序列中,尾元素越小越好
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1] // 记录前驱关系
        }
        result[u] = i // 替换为更优的元素
      }
    }
  }

  // 🔙 回溯构建最长递增子序列
  // 由于替换操作,result数组存储的不是最终序列
  // 需要通过前驱数组p来重建真正的LIS
  u = result.length
  v = result[u - 1] // 从最后一个元素开始回溯
  
  while (u-- > 0) {
    result[u] = v // 重建序列
    v = p[v] // 跳转到前驱元素
  }

  return result
}

📊 算法复杂度分析

操作 时间复杂度 空间复杂度 说明
构建LIS O(n log n) O(n) 二分查找优化的动态规划
回溯重建 O(k) O(1) k为LIS长度
总体 O(n log n) O(n) 相比暴力O(n²)有显著提升

🎨 实际应用示例

// 🎯 实际场景演示
// 旧列表:[A, B, C, D, E]  索引:[0, 1, 2, 3, 4]
// 新列表:[B, A, D, C, E]  索引:[0, 1, 2, 3, 4]

// Step 1: 构建 newIndexToOldIndexMap
// B(新0) -> 旧1: map[0] = 2  (+1偏移)
// A(新1) -> 旧0: map[1] = 1  (+1偏移)
// D(新2) -> 旧3: map[2] = 4  (+1偏移)
// C(新3) -> 旧2: map[3] = 3  (+1偏移)
// E(新4) -> 旧4: map[4] = 5  (+1偏移)
// 结果:[2, 1, 4, 3, 5]

// Step 2: 计算LIS
const lis = getSequence([2, 1, 4, 3, 5])
// 返回:[1, 3, 4] (对应新列表中A, C, E的位置)

// Step 3: 移动策略
// 不移动:A(位置1), C(位置3), E(位置4) - 在LIS中
// 需移动:B(位置0), D(位置2) - 不在LIS中
// 结果:只需要2次DOM移动操作,而不是4次

🚀 性能优化细节

1. 位运算优化
// 使用位运算代替除法,提升性能
c = (u + v) >> 1  // 比 Math.floor((u + v) / 2) 快约20%
2. 早期退出策略
// 快速路径:避免不必要的二分查找
if (arr[j] < arrI) {
  result.push(i)
  continue  // 直接跳过二分查找
}
3. 贪心策略
// 相同长度的序列中,选择尾元素最小的
// 这样为后续元素提供更多的扩展可能性
if (arrI < arr[result[u]]) {
  result[u] = i  // 贪心替换
}

🎯 为什么选择LIS?

  1. 最优性保证:LIS确保找到需要移动的最少节点数
  2. 稳定性:相对位置正确的节点不会被移动
  3. 高效性:O(n log n)的时间复杂度,适合大列表
  4. 实用性:大多数实际场景下,列表变化都有一定的局部性

这就是Vue3 Diff算法中最长递增子序列的完整实现和优化策略。它不仅仅是一个算法,更是Vue3性能优化的核心体现。

🎯 核心原理总结

🔍 关键技术洞察

1. 五步优化策略的设计哲学

Vue3的Diff算法并非单纯依赖最长递增子序列,而是采用分层优化的设计思想:

  • 前四步:处理90%的常见场景(前后端比较、增删操作)
  • 第五步:处理10%的复杂场景(乱序移动)
  • 核心理念:用简单算法处理简单问题,用复杂算法处理复杂问题
2. Key值的核心作用机制
// Key值的三重作用
1. 🔍 节点识别:快速判断节点是否可复用
2. ⚡ 性能优化:从O(n²)降低到O(n)
3. 🎯 移动计算:为LIS算法提供准确的位置映射

为什么v-for需要手动添加key?

  • ✅ 其他节点:Vue3自动生成key(基于节点类型和位置)
  • ❌ v-for节点:动态生成,无法自动推断稳定的key
  • 🎯 解决方案:开发者提供业务相关的唯一标识
3. 算法复杂度的渐进优化
场景 传统算法 Vue3算法 优化效果
前后端添加 O(n²) O(n) 🚀 线性优化
简单移动 O(n²) O(n) 🚀 线性优化
复杂乱序 O(n²) O(n log n) ⚡ 对数优化
无key场景 O(n³) O(n²) 📈 仍需优化

🎨 设计模式分析

1. 分治策略(Divide and Conquer)
// 将复杂的列表比较问题分解为5个子问题
// 每个子问题都有针对性的优化策略
function patchKeyedChildren() {
  // 分治:前序比较
  syncFromStart()
  // 分治:后序比较  
  syncFromEnd()
  // 分治:新增处理
  mountNewNodes()
  // 分治:删除处理
  unmountOldNodes()
  // 分治:乱序处理
  handleComplexCase()
}
2. 贪心算法(Greedy Algorithm)
// 在LIS算法中的应用
// 总是选择当前长度下的最小尾元素
// 为后续扩展提供最大可能性
if (arrI < arr[result[u]]) {
  result[u] = i  // 贪心选择
}
3. 动态规划(Dynamic Programming)
// LIS算法的DP思想
// 状态:dp[i] = 以i结尾的最长递增子序列长度
// 转移:通过二分查找优化状态转移

🚀 性能优化要点

1. 空间换时间
  • 映射表:O(n)空间换取O(1)查找时间
  • 前驱数组:O(n)空间支持LIS回溯
  • 索引映射:避免重复的DOM查询
2. 算法层面优化
  • 二分查找:将LIS从O(n²)优化到O(n log n)
  • 位运算:使用>>代替除法运算
  • 早期退出:避免不必要的计算
3. 工程层面优化
  • 批量操作:减少DOM操作次数
  • 锚点策略:精确控制插入位置
  • 内存管理:及时释放不再需要的引用

🔮 与Vue2的对比

特性 Vue2 Vue3 改进
算法策略 双端比较 五步优化 🎯 更全面
复杂度 O(n²) O(n log n) ⚡ 更高效
移动优化 启发式 LIS算法 🧮 更精确
内存使用 较高 优化 💾 更节省

💡 最佳实践建议

1. Key值设计原则
// ✅ 推荐:使用稳定的业务ID
<li v-for="user in users" :key="user.id">

// ❌ 避免:使用数组索引
<li v-for="(user, index) in users" :key="index">

// ❌ 避免:使用随机值
<li v-for="user in users" :key="Math.random()">
2. 列表更新策略
// 🚀 高效:批量更新
const newUsers = [...users, ...newData]
users.value = newUsers

// 🐌 低效:逐个更新
newData.forEach(user => users.value.push(user))
3. 性能监控
// 开发环境下监控Diff性能
if (__DEV__) {
  console.time('diff-performance')
  patchKeyedChildren()
  console.timeEnd('diff-performance')
}

🎓 进阶学习建议

  1. 算法基础:深入学习动态规划、贪心算法、二分查找
  2. 数据结构:理解Map、数组操作的性能特点
  3. 浏览器原理:了解DOM操作的性能成本
  4. Vue源码:阅读完整的patch函数实现
  5. 性能调优:使用Vue DevTools分析实际项目的Diff性能

🌟 结语

Vue3的Diff算法是前端框架设计的典型代表,它完美诠释了工程化思维

  • 🎯 问题分解:将复杂问题分解为可管理的子问题
  • 性能优先:在保证正确性的前提下追求极致性能
  • 🔧 工程实用:算法设计贴近实际应用场景
  • 📈 持续优化:从Vue2到Vue3的不断改进

掌握Vue3 Diff算法,不仅能帮助我们写出更高性能的Vue应用,更能提升我们的算法思维和工程能力。这正是优秀前端工程师必备的核心素养。

记忆中的打地鼠游戏居然是这样实现的,Trae版实现

2025年8月16日 16:27

前言

今天来还原童年记忆中的打地鼠游戏,主要是让Trae用代码实现这个游戏的核心功能。

这个游戏的核心功能

先把这个核心逻辑发给Trae,看看他完成的是不是你想要的童年记忆

  1. 玩家控制一个木槌,通过鼠标点击来敲打地鼠。
  2. 地鼠会随机从地面的洞口冒出。
  3. 玩家敲中地鼠后,地鼠会缩回洞中,并且玩家获得得分。
  4. 游戏会有一个倒计时,当倒计时结束时,游戏结束,玩家需要在规定时间内获得尽可能高的分数。
  5. 地鼠冒出的频率会随着游戏时间逐渐加快,增加游戏难度。
  6. 游戏会有一个得分系统,玩家每敲中一个地鼠,就会获得一定的分数。

由于上一篇的坦克大战生成的ui太过于粗糙,这次我们就让他生成的时候要精美的页面 image.png

这效果还是有点像,毕竟我们没有资源文件,这样的完成度已经不错了,看起来有些微信小游戏的味道了,右上角还很贴心的安排上音效按钮,如果不喜欢我们可以关闭音效,沉浸式的打地鼠

image.png

Trae代码解读

首先是生成Grid布局,for循环生成九个洞口

image.png 设置速度,来表示简单、中等、困难的等级,玩家可以自由的选择等级来玩打地鼠

image.png

Trae通过Math.random来随机生成地鼠在哪一个洞口出现,通过setTimeout来持续生成

image.png

生成新地鼠,如果需要的话,并更新计时器,源源不断的生成地鼠

this.gopherSpawnTimer++;
        if (this.gopherSpawnTimer >= this.gopherSpawnDelay && this.gophers.filter(g => g.alive).length < 4) {
            this.spawnGopher();
            this.gopherSpawnTimer = 0;
        }

木槌与地鼠碰撞检测,碰撞了就把地鼠消失,这样就可以新生成地鼠,这个消失主要是给dom元素添加class,让地鼠实现消失的效果,可以说是非常的精妙

image.png 为了丰富玩家的游戏的体验,Trae还帮我们添加了击中的特效,短暂的延迟在消失,看起来就有一定的视觉冲击

image.png 最后是来自Trae自己对这款打地鼠的评价

image.png

总结

1、这个游戏的核心功能包括玩家控制木槌敲打地鼠,地鼠的随机冒出和消失,得分系统,以及倒计时结束时的游戏结束。通过这些功能,玩家可以在规定时间内获得尽可能高的分数。

2、通过绘制洞口,地鼠可以随机从洞口冒出。同时,洞口也可以作为游戏的地图边界,防止地鼠超出地图范围。主要还是还原童年的骚操作,可以让自己在游戏保证不死的通过游戏关卡。都是满满的童年回忆。

你是否也玩过这个游戏呢?

❌
❌