阅读视图

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

每日一题-统计特殊三元组🟡

给你一个整数数组 nums

特殊三元组 定义为满足以下条件的下标三元组 (i, j, k)

  • 0 <= i < j < k < n,其中 n = nums.length
  • nums[i] == nums[j] * 2
  • nums[k] == nums[j] * 2

返回数组中 特殊三元组 的总数。

由于答案可能非常大,请返回结果对 109 + 7 取余数后的值。

 

示例 1:

输入: nums = [6,3,6]

输出: 1

解释:

唯一的特殊三元组是 (i, j, k) = (0, 1, 2),其中:

  • nums[0] = 6, nums[1] = 3, nums[2] = 6
  • nums[0] = nums[1] * 2 = 3 * 2 = 6
  • nums[2] = nums[1] * 2 = 3 * 2 = 6

示例 2:

输入: nums = [0,1,0,0]

输出: 1

解释:

唯一的特殊三元组是 (i, j, k) = (0, 2, 3),其中:

  • nums[0] = 0, nums[2] = 0, nums[3] = 0
  • nums[0] = nums[2] * 2 = 0 * 2 = 0
  • nums[3] = nums[2] * 2 = 0 * 2 = 0

示例 3:

输入: nums = [8,4,2,8,4]

输出: 2

解释:

共有两个特殊三元组:

  • (i, j, k) = (0, 1, 3)
    • nums[0] = 8, nums[1] = 4, nums[3] = 8
    • nums[0] = nums[1] * 2 = 4 * 2 = 8
    • nums[3] = nums[1] * 2 = 4 * 2 = 8
  • (i, j, k) = (1, 2, 4)
    • nums[1] = 4, nums[2] = 2, nums[4] = 4
    • nums[1] = nums[2] * 2 = 2 * 2 = 4
    • nums[4] = nums[2] * 2 = 2 * 2 = 4

 

提示:

  • 3 <= n == nums.length <= 105
  • 0 <= nums[i] <= 105

3583. 统计特殊三元组

解法

思路和算法

由于特殊三元组的定义为左边元素值与右边元素值都等于中间元素值的两倍,因此可以遍历中间元素下标并计算每个中间元素下标对应的特殊三元组的数目。

数组 $\textit{nums}$ 的长度是 $n$。对于 $1 \le i < n - 1$ 的每个下标 $i$,如果在下标 $i$ 的左边有 $x$ 个元素 $\textit{nums}[i] \times 2$ 且下标 $i$ 的右边有 $y$ 个元素 $\textit{nums}[i] \times 2$,则以下标 $i$ 作为中间下标的特殊三元组的数目是 $x \times y$。

使用哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 分别记录从左到右遍历数组 $\textit{nums}$ 和从右到左遍历数组 $\textit{nums}$ 的过程中的每个元素的出现次数,创建长度为 $n$ 的数组 $\textit{leftSpecial}$ 和 $\textit{rightSpecial}$,对于 $0 \le i < n$ 的每个下标 $i$,$\textit{leftSpecial}[i]$ 表示下标 $i$ 左边的元素值 $\textit{nums}[i] \times 2$ 的个数,$\textit{rightSpecial}[i]$ 表示下标 $i$ 右边的元素值 $\textit{nums}[i] \times 2$ 的个数。根据如下操作计算相应的值。

  1. 从左到右遍历数组 $\textit{nums}$。对于遍历到的每个下标 $i$,从哈希表 $\textit{leftCounts}$ 中得到元素 $\textit{nums}[i] \times 2$ 的出现次数填入 $\textit{leftSpecial}[i]$,然后在哈希表 $\textit{leftCounts}$ 中将元素 $\textit{nums}[i]$ 的出现次数增加 $1$。

  2. 从右到左遍历数组 $\textit{nums}$。对于遍历到的每个下标 $i$,从哈希表 $\textit{rightCounts}$ 中得到元素 $\textit{nums}[i] \times 2$ 的出现次数填入 $\textit{rightSpecial}[i]$,然后在哈希表 $\textit{rightCounts}$ 中将元素 $\textit{nums}[i]$ 的出现次数增加 $1$。

上述操作中,应首先计算 $\textit{leftSpecial}[i]$ 和 $\textit{rightSpecial}[i]$,然后在哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 中更新 $\textit{nums}[i]$ 的出现次数。理由如下:计算 $\textit{leftSpecial}[i]$ 和 $\textit{rightSpecial}[i]$ 时,下标 $i$ 作为特殊三元组的中间下标,因此哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 应分别只能包含下标范围 $[0, i - 1]$ 和 $[i + 1, n - 1]$ 中的所有元素的出现次数。如果计算 $\textit{leftSpecial}[i]$ 和 $\textit{rightSpecial}[i]$ 与更新哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 的顺序颠倒,则当 $\textit{nums}[i] = 0$ 时会导致错误地计算下标重合的三元组。

由于特殊三元组的中间下标不可能是 $0$ 或 $n - 1$,因此只需要考虑 $1 \le i < n - 1$ 的每个下标 $i$ 作为中间下标的特殊三元组的数目。计算数组 $\textit{leftSpecial}$ 和 $\textit{rightSpecial}$ 之后,从左到右遍历 $1 \le i < n - 1$ 的每个下标 $i$,以下标 $i$ 作为中间下标的特殊三元组的数目是 $\textit{leftSpecial}[i] \times \textit{rightSpecial}[i]$,将该数目加到答案。遍历结束之后,即可得到数组 $\textit{nums}$ 中特殊三元组的总数。

代码

###Java

class Solution {
    static final int MODULO = 1000000007;

    public int specialTriplets(int[] nums) {
        Map<Integer, Integer> leftCounts = new HashMap<Integer, Integer>();
        Map<Integer, Integer> rightCounts = new HashMap<Integer, Integer>();
        int n = nums.length;
        int[] leftSpecial = new int[n];
        int[] rightSpecial = new int[n];
        for (int i = 0; i < n; i++) {
            leftSpecial[i] = leftCounts.getOrDefault(nums[i] * 2, 0);
            leftCounts.put(nums[i], leftCounts.getOrDefault(nums[i], 0) + 1);
        }
        for (int i = n - 1; i >= 0; i--) {
            rightSpecial[i] = rightCounts.getOrDefault(nums[i] * 2, 0);
            rightCounts.put(nums[i], rightCounts.getOrDefault(nums[i], 0) + 1);
        }
        int specialCount = 0;
        for (int i = 1; i < n - 1; i++) {
            int currSpecialCount = (int) ((long) leftSpecial[i] * rightSpecial[i] % MODULO);
            specialCount = (specialCount + currSpecialCount) % MODULO;
        }
        return specialCount;
    }
}

###C#

public class Solution {
    const int MODULO = 1000000007;

    public int SpecialTriplets(int[] nums) {
        IDictionary<int, int> leftCounts = new Dictionary<int, int>();
        IDictionary<int, int> rightCounts = new Dictionary<int, int>();
        int n = nums.Length;
        int[] leftSpecial = new int[n];
        int[] rightSpecial = new int[n];
        for (int i = 0; i < n; i++) {
            leftSpecial[i] = leftCounts.ContainsKey(nums[i] * 2) ? leftCounts[nums[i] * 2] : 0;
            leftCounts.TryAdd(nums[i], 0);
            leftCounts[nums[i]]++;
        }
        for (int i = n - 1; i >= 0; i--) {
            rightSpecial[i] = rightCounts.ContainsKey(nums[i] * 2) ? rightCounts[nums[i] * 2] : 0;
            rightCounts.TryAdd(nums[i], 0);
            rightCounts[nums[i]]++;
        }
        int specialCount = 0;
        for (int i = 1; i < n - 1; i++) {
            int currSpecialCount = (int) ((long) leftSpecial[i] * rightSpecial[i] % MODULO);
            specialCount = (specialCount + currSpecialCount) % MODULO;
        }
        return specialCount;
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。需要遍历数组常数次,遍历过程中对于每个元素的操作时间都是 $O(1)$。

  • 空间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。哈希表 $\textit{leftCounts}$ 和 $\textit{rightCounts}$ 以及数组 $\textit{leftSpecial}$ 和 $\textit{rightSpecial}$ 的空间是 $O(n)$。

两种方法:枚举中间 / 一次遍历(Python/Java/C++/Go)

方法一:枚举中间

三变量问题,一般枚举中间的变量最简单。为什么?对比一下:

  • 枚举 $i$,后续计算中还需保证 $j < k$。
  • 枚举 $j$,那么 $i$ 和 $k$ 自动被 $j$ 隔开,互相独立,后续计算中无需关心 $i$ 和 $k$ 的位置关系。

枚举中间的 $j$,问题变成:

  • 在 $[0,j-1]$ 中,$\textit{nums}[j]\cdot 2$ 的出现次数。
  • 在 $[j+1,n-1]$ 中,$\textit{nums}[j]\cdot 2$ 的出现次数。
  • 在这些出现次数中,左右两边各选一个。根据乘法原理,把这两个出现次数相乘,加到答案中。

用哈希表(或者数组)统计 $j$ 左右每个数的出现次数。

  • 右边的元素出现次数,可以先统计整个数组,然后再次遍历数组,撤销 $[0,j]$ 中统计的元素出现次数,即为 $[j+1,n-1]$ 中的元素出现次数。
  • 左边的元素出现次数,可以一边遍历 $\textit{nums}$,一边统计。

由于答案不超过 $n\cdot 10^5\cdot 10^5 \le 10^{15}$,可以只在返回时取模。

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

###py

class Solution:
    def specialTriplets(self, nums: List[int]) -> int:
        MOD = 1_000_000_007
        suf = Counter(nums)

        ans = 0
        pre = defaultdict(int)  # 比 Counter 快
        for x in nums:  # x = nums[j]
            suf[x] -= 1  # 撤销
            # 现在 pre 中的是 [0,j-1],suf 中的是 [j+1,n-1]
            ans += pre[x * 2] * suf[x * 2]
            pre[x] += 1
        return ans % MOD

###java

// 更快的写法见【Java 数组】
class Solution {
    public int specialTriplets(int[] nums) {
        final int MOD = 1_000_000_007;
        Map<Integer, Integer> suf = new HashMap<>();
        for (int x : nums) {
            suf.merge(x, 1, Integer::sum); // suf[x]++
        }

        long ans = 0;
        Map<Integer, Integer> pre = new HashMap<>();
        for (int x : nums) { // x = nums[j]
            suf.merge(x, -1, Integer::sum); // suf[x]-- // 撤销
            // 现在 pre 中的是 [0,j-1],suf 中的是 [j+1,n-1]
            ans += (long) pre.getOrDefault(x * 2, 0) * suf.getOrDefault(x * 2, 0);
            pre.merge(x, 1, Integer::sum); // pre[x]++
        }
        return (int) (ans % MOD);
    }
}

###java

class Solution {
    public int specialTriplets(int[] nums) {
        final int MOD = 1_000_000_007;
        int mx = 0;
        for (int x : nums) {
            mx = Math.max(mx, x);
        }

        int[] suf = new int[mx + 1];
        for (int x : nums) {
            suf[x]++;
        }

        long ans = 0;
        int[] pre = new int[mx + 1];
        for (int x : nums) {
            suf[x]--;
            if (x * 2 <= mx) {
                ans += (long) pre[x * 2] * suf[x * 2];
            }
            pre[x]++;
        }
        return (int) (ans % MOD);
    }
}

###cpp

class Solution {
public:
    int specialTriplets(vector<int>& nums) {
        const int MOD = 1'000'000'007;
        unordered_map<int, int> suf;
        for (int x : nums) {
            suf[x]++;
        }

        long long ans = 0;
        unordered_map<int, int> pre;
        for (int x : nums) { // x = nums[j]
            suf[x]--; // 撤销
            // 现在 pre 中的是 [0,j-1],suf 中的是 [j+1,n-1]
            ans += 1LL * pre[x * 2] * suf[x * 2];
            pre[x]++;
        }
        return ans % MOD;
    }
};

###go

func specialTriplets(nums []int) (ans int) {
const mod = 1_000_000_007
suf := map[int]int{}
for _, x := range nums {
suf[x]++
}

pre := map[int]int{}
for _, x := range nums { // x = nums[j]
suf[x]-- // 撤销
// 现在 pre 中的是 [0,j-1],suf 中的是 [j+1,n-1]
ans += pre[x*2] * suf[x*2]
pre[x]++
}
return ans % mod
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

方法二:枚举右,维护左(一次遍历)

枚举 $k$,设 $x=\textit{nums}[k]$,问题变成:

  • 有多少个二元组 $(i,j)$,满足 $i<j<k$ 且 $\textit{nums}[i]=x$ 且 $\textit{nums}[j] = \dfrac{x}{2}$。用哈希表 $\textit{cnt}_{12}$ 记录这样的二元组个数。

这个问题也可以枚举右维护左,即枚举 $j$,问题变成:

  • 在 $j$ 左边有多少个数等于 $\textit{nums}[j]\cdot 2$?用哈希表 $\textit{cnt}_{1}$ 记录。

###py

class Solution:
    def specialTriplets(self, nums: List[int]) -> int:
        MOD = 1_000_000_007
        cnt1 = defaultdict(int)
        cnt12 = defaultdict(int)
        cnt123 = 0
        for x in nums:
            if x % 2 == 0:
                cnt123 += cnt12[x // 2]  # 把 x 当作 nums[k]
            cnt12[x] += cnt1[x * 2]  # 把 x 当作 nums[j]
            cnt1[x] += 1  # 把 x 当作 nums[i]
        return cnt123 % MOD

###java

class Solution {
    public int specialTriplets(int[] nums) {
        final int MOD = 1_000_000_007;
        Map<Integer, Integer> cnt1 = new HashMap<>();
        Map<Integer, Long> cnt12 = new HashMap<>();
        long cnt123 = 0;
        for (int x : nums) {
            if (x % 2 == 0) {
                cnt123 += cnt12.getOrDefault(x / 2, 0L); // 把 x 当作 nums[k]
            }
            cnt12.merge(x, (long) cnt1.getOrDefault(x * 2, 0), Long::sum); // 把 x 当作 nums[j]
            cnt1.merge(x, 1, Integer::sum); // 把 x 当作 nums[i]
        }
        return (int) (cnt123 % MOD);
    }
}

###cpp

class Solution {
public:
    int specialTriplets(vector<int>& nums) {
        const int MOD = 1'000'000'007;
        unordered_map<int, int> cnt1;
        unordered_map<int, long long> cnt12;
        long long cnt123 = 0;
        for (int x : nums) {
            if (x % 2 == 0) {
                cnt123 += cnt12[x / 2]; // 把 x 当作 nums[k]
            }
            cnt12[x] += cnt1[x * 2]; // 把 x 当作 nums[j]
            cnt1[x]++; // 把 x 当作 nums[i]
        }
        return cnt123 % MOD;
    }
};

###go

func specialTriplets(nums []int) (cnt123 int) {
const mod = 1_000_000_007
cnt1 := map[int]int{}
cnt12 := map[int]int{}
for _, x := range nums {
if x%2 == 0 {
cnt123 += cnt12[x/2] // 把 x 当作 nums[k]
}
cnt12[x] += cnt1[x*2] // 把 x 当作 nums[j]
cnt1[x]++ // 把 x 当作 nums[i]
}
return cnt123 % mod
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

相似题目

更多相似题目,见下面数据结构题单的「§0.2 枚举中间」和动态规划题单的「专题:前后缀分解」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

2025 年 TC39 都在忙什么?Import Bytes、Iterator Chunking 来了

TC39 2025:Import Bytes、Iterator Chunking 和那些即将落地的新特性

写跨平台的 JS 代码时,读个二进制文件都得写三套逻辑:

// 浏览器
const bytes = await fetch('./photo.png').then(r => r.arrayBuffer());

// Node.js
const bytes = require('fs').readFileSync('./photo.png');

// Deno
const bytes = await Deno.readFile('./photo.png');

同样的需求,三种写法。想写个同构的图片处理库?先把这三套 API 适配一遍再说。

好消息是,TC39 在 2025 年推进了好几个提案来解决这类问题。这篇文章聊聊其中最值得关注的几个:Import Bytes、Iterator Chunking,以及今年已经进入 Stage 4 的新特性。

Import Bytes:一行代码搞定二进制导入

现在是什么状态

Stage 2.7(截至 2025 年 9 月),离正式标准就差临门一脚了。提案负责人是 Steven Salat,Guy Bedford 是共同作者。

核心语法

import bytes from "./photo.png" with { type: "bytes" };
// bytes 是 Uint8Array,底层是不可变的 ArrayBuffer

动态导入也支持:

const bytes = await import("./photo.png", { with: { type: "bytes" } });

就这么简单。不管你在浏览器、Node.js 还是 Deno,同一行代码,同样的结果。

为什么返回 Uint8Array 而不是 ArrayBuffer

提案选择返回 Uint8Array 而不是裸的 ArrayBuffer,理由挺实在的:

  1. 少一步操作 - 拿到 ArrayBuffer 你还得自己创建 TypedView,Uint8Array 直接就能用
  2. 跟现有 API 保持一致 - Response.bytes()Blob.bytes() 都返回 Uint8Array
  3. Node.js Buffer 兼容 - Buffer 本身就是 Uint8Array 的子类

为什么底层是不可变的 ArrayBuffer

这个设计决定挺有意思的。底层 ArrayBuffer 被设计成不可变的,原因有三:

  1. 避免共享可变状态 - 多个模块导入同一个文件,拿到的是同一个对象。如果可变,一个模块改了数据,其他模块全受影响
  2. 嵌入式场景 - 不可变数据可以直接放 ROM 里
  3. 安全性考虑 - 防止模块间通过共享 buffer 建立隐蔽通信通道

实际能干什么

图片处理

import imageBytes from "./logo.png" with { type: "bytes" };
// 用 satori 之类的同构库处理
processImage(imageBytes);

加载字体

import fontBytes from "./custom.woff" with { type: "bytes" };
// Canvas 或 PDF 生成时用
registerFont(fontBytes);

机器学习模型

import modelBytes from "./model.bin" with { type: "bytes" };
loadModel(modelBytes);

工具链支持

好消息是,主流工具已经在跟进了。Deno 2.4、Bun 1.1.7 都有类似实现,Webpack、esbuild、Parcel 也支持类似的二进制导入机制。等提案正式落地,统一语法只是时间问题。

Iterator Chunking:迭代器分块终于有原生方案了

现在是什么状态

Stage 2.7(截至 2025 年 9 月),由 Michael Ficarra 主导。

两个核心方法

chunks(size) - 非重叠分块

const numbers = [1, 2, 3, 4, 5, 6, 7].values();
const chunked = numbers.chunks(3);

for (const chunk of chunked) {
  console.log(chunk);
}
// [1, 2, 3]
// [4, 5, 6]
// [7]

windows(size) - 滑动窗口

const numbers = [1, 2, 3, 4].values();
const windowed = numbers.windows(2);

for (const window of windowed) {
  console.log(window);
}
// [1, 2]
// [2, 3]
// [3, 4]

区别很直观:chunks 是切成一块一块互不重叠,windows 是滑动窗口每次移动一格。

解决什么问题

以前想做分块操作,要么自己写,要么引入 lodash:

// lodash 方案
import chunk from 'lodash/chunk';
const chunks = chunk([1, 2, 3, 4], 2);

// 原生方案
const chunks = [1, 2, 3, 4].values().chunks(2);

原生方案的优势:

  • 不用装依赖
  • 惰性求值,内存友好
  • 跟整个迭代器生态无缝衔接
  • 支持异步迭代器

实际场景

批量 API 请求

async function batchProcess(items) {
  const batches = items.values().chunks(50);

  for (const batch of batches) {
    await Promise.all(batch.map(item => api.process(item)));
    await sleep(1000); // 避免触发限流
  }
}

移动平均计算

function movingAverage(numbers, windowSize) {
  return numbers
    .values()
    .windows(windowSize)
    .map(w => w.reduce((a, b) => a + b) / windowSize)
    .toArray();
}

const prices = [100, 102, 98, 105, 103, 107];
const ma3 = movingAverage(prices, 3);
// 3日移动平均

N-gram 生成

function generateNGrams(text, n) {
  const words = text.split(' ');
  return words.values()
    .windows(n)
    .map(w => w.join(' '))
    .toArray();
}

const bigrams = generateNGrams("The quick brown fox", 2);
// ["The quick", "quick brown", "brown fox"]

边界情况的讨论

这个提案在推进过程中遇到了一个有意思的问题:如果迭代器元素少于窗口大小,windows() 应该返回什么?

const small = [1, 2].values();
const result = small.windows(3); // 只有2个元素,请求3个的窗口

// 选项1:不返回任何窗口
// 选项2:返回 [1, 2] 作为不完整窗口

委员会讨论后认为两种场景都有合理的使用需求,所以决定把 windows() 拆分成多个方法来分别处理这两种情况。这也是提案从 Stage 2 到 Stage 2.7 花了点时间的原因。

2025 年进入 Stage 4 的特性

除了上面两个还在推进的提案,2025 年还有好几个特性已经正式"毕业"了:

RegExp.escape(2 月)

安全转义正则表达式字符串,防止注入:

const userInput = "user@example.com (admin)";
const safePattern = RegExp.escape(userInput);
const regex = new RegExp(safePattern);
// 不用担心括号被解析成分组了

这个需求太常见了,以前都得自己写转义函数或者用第三方库。

Float16Array(2 月)

半精度浮点数的 TypedArray:

const f16Array = new Float16Array([1.5, 2.7, 3.1]);

主要面向机器学习和图形处理场景。模型权重经常用 fp16 存储,有了原生支持就不用自己做转换了。

Error.isError(5 月)

可靠地判断一个值是不是 Error:

if (Error.isError(value)) {
  console.log(value.message);
}

为什么不用 instanceof Error?因为跨 realm(比如 iframe 或 Node.js 的 vm 模块)的 Error 实例会被判成 false。这个方法解决了这个历史问题。

Math.sumPrecise(7 月)

高精度求和:

const sum = Math.sumPrecise([0.1, 0.2, 0.3]);
// 比普通累加更精确,减少浮点误差累积

做金融计算或科学计算的应该会喜欢这个。

Uint8Array Base64 编解码(7 月)

原生的 Base64 编解码:

const bytes = Uint8Array.fromBase64('SGVsbG8=');
const base64 = bytes.toBase64();
// 还有 fromHex() 和 toHex()

终于不用为了 Base64 转换去找第三方库了。

Explicit Resource Management(已 Stage 4)

using 关键字,自动资源清理:

using file = await openFile('data.txt');
// 离开作用域自动关闭,不用手动 finally

借鉴了 Python 的 with 和 C# 的 using,解决了 JS 里资源管理一直很混乱的问题。

还有几个值得关注的 Stage 2 提案

Seeded PRNG(5 月进入 Stage 2)

可种子化的随机数生成器:

const random = new Random(12345); // 种子
const value = random.next();
// 同样的种子,同样的序列

游戏开发、测试、仿真这些场景经常需要可重现的随机序列。

Error Stack Accessor(5 月进入 Stage 2)

标准化错误堆栈的访问方式。现在各个引擎的 error.stack 格式都不一样,这个提案要统一它。

提案流程简单回顾

TC39 的提案分 5 个阶段:

  • Stage 0:想法
  • Stage 1:正式提案,开始讨论
  • Stage 2:规范草案,API 基本稳定
  • Stage 2.7:规范文本接近完成,准备写测试
  • Stage 3:等待实现反馈
  • Stage 4:正式纳入标准

Import Bytes 和 Iterator Chunking 都到了 Stage 2.7,离 Stage 3 就差 test262 测试和浏览器实现承诺了。

总结

2025 年 TC39 的进展还是挺给力的:

  • Import Bytes 解决了跨平台二进制导入的老大难问题,同构库开发终于能省心了
  • Iterator Chunking 补上了迭代器工具链的空白,chunks 和 windows 覆盖了大部分分块场景
  • 一堆特性进入 Stage 4:RegExp.escape、Float16Array、Math.sumPrecise、Base64 编解码、资源管理...

这些特性有的已经可以通过 Babel 或 TypeScript 提前尝鲜了。如果你在用 Deno 或 Bun,Import Bytes 类似的功能现在就能用。


顺手安利几个我的开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

参考链接

"讲讲原型链" —— 面试官最爱问的 JavaScript 基础

JavaScript 原型与原型链:从困惑到完全理解

以前在看 JavaScript 代码的时候,经常会遇到一个问题:

const arr = [1, 2, 3];
arr.push(4);      // 4
arr.join(',');    // "1,2,3,4"
arr.toString();   // "1,2,3,4"

我明明只创建了一个数组,为什么它能调用 pushjointoString 这些方法?这些方法是从哪来的?

再看这段代码:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function() {
  console.log(`Hello, I'm ${this.name}`);
};

const person = new Person('张三');
person.sayHello(); // "Hello, I'm 张三"

person 对象本身没有 sayHello 方法,但却能调用它。这背后的机制就是原型链。


先搞清楚几个概念

在深入之前,先把几个容易混淆的概念理清楚:

[[Prototype]]__proto__prototype 的区别

概念 是什么 属于谁 作用
[[Prototype]] 内部属性 所有对象 指向对象的原型,隐藏属性
__proto__ 访问器属性 所有对象 暴露 [[Prototype]],非标准但广泛支持
prototype 普通属性 函数 存放给实例共享的属性和方法

简单说:

  • prototype函数才有的属性,用来存放共享方法
  • __proto__所有对象都有的属性,指向它的原型对象
  • [[Prototype]]__proto__ 的内部实现
function Foo() {}
const foo = new Foo();

// prototype 只有函数才有
console.log(Foo.prototype);      // {constructor: ƒ}
console.log(foo.prototype);      // undefined

// __proto__ 所有对象都有
console.log(foo.__proto__ === Foo.prototype);  // true

现代写法:Object.getPrototypeOf()

__proto__ 虽然好用,但它不是 ECMAScript 标准的一部分,只是各浏览器都实现了。推荐用标准方法:

// 获取原型
Object.getPrototypeOf(foo) === Foo.prototype  // true

// 设置原型
Object.setPrototypeOf(obj, prototype)

// 创建时指定原型
Object.create(prototype)

原型是什么

JavaScript 里每个函数都有一个 prototype 属性,指向一个对象。这个对象叫做原型对象,它的作用是让该函数创建的所有实例共享属性和方法。

function Car(brand) {
  this.brand = brand;
}

// 方法定义在原型上,所有实例共享
Car.prototype.start = function() {
  console.log(`${this.brand} 启动了`);
};

const car1 = new Car('丰田');
const car2 = new Car('本田');

car1.start(); // 丰田 启动了
car2.start(); // 本田 启动了

// 两个实例用的是同一个方法
console.log(car1.start === car2.start); // true

这就是原型的核心价值:方法只需要定义一次,所有实例都能用

如果把方法定义在构造函数里,每创建一个实例就会新建一个函数,浪费内存:

// 不推荐的写法
function BadCar(brand) {
  this.brand = brand;
  this.start = function() {  // 每个实例都有一份
    console.log(`${this.brand} 启动了`);
  };
}

const bad1 = new BadCar('丰田');
const bad2 = new BadCar('本田');
console.log(bad1.start === bad2.start); // false,两个不同的函数

new 关键字到底做了什么

理解原型链之前,得先搞清楚 new 的工作原理。当你写 new Foo() 时,JavaScript 引擎会执行以下四个步骤:

flowchart LR
    A['1. 创建空对象']:::step --> B['2. 设置原型链']:::step
    B --> C['3. 执行构造函数']:::step
    C --> D['4. 返回对象']:::success

    classDef step fill:#cce5ff,stroke:#0d6efd,color:#004085
    classDef success fill:#d4edda,stroke:#28a745,color:#155724

详细步骤

function Person(name) {
  this.name = name;
}
Person.prototype.greet = function() {
  console.log(`Hi, I'm ${this.name}`);
};

const john = new Person('John');

Step 1:创建一个空对象

// 内部创建:{}

Step 2:将空对象的 [[Prototype]] 指向构造函数的 prototype

// 内部操作:newObj.__proto__ = Person.prototype

Step 3:用这个空对象作为 this 执行构造函数

// 内部操作:Person.call(newObj, 'John')
// 执行后 newObj 变成 { name: 'John' }

Step 4:返回对象

  • 如果构造函数返回一个对象,就用那个对象
  • 否则返回 Step 1 创建的对象

手写一个 new

理解了原理,可以自己实现一个:

function myNew(Constructor, ...args) {
  // 1. 创建空对象,原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);

  // 2. 执行构造函数,this 绑定到新对象
  const result = Constructor.apply(obj, args);

  // 3. 如果构造函数返回对象,就用它;否则用新创建的对象
  return result instanceof Object ? result : obj;
}

// 测试
const p = myNew(Person, 'Alice');
p.greet(); // Hi, I'm Alice
console.log(p instanceof Person); // true

原型链的查找机制

当访问对象的属性或方法时,JavaScript 会按照这个顺序查找:

  1. 先在对象自身找
  2. 找不到,去对象的原型 (__proto__) 上找
  3. 还找不到,继续往上一级原型找
  4. 直到 Object.prototype,再往上就是 null

这条查找链路就是原型链

flowchart TB
    A["dog 实例<br/>{ name: 'Buddy' }"]:::instance -->|__proto__| B["Dog.prototype<br/>{ bark: ƒ }"]:::proto
    B -->|__proto__| C["Animal.prototype<br/>{ speak: ƒ }"]:::proto
    C -->|__proto__| D["Object.prototype<br/>{ toString: ƒ, ... }"]:::rootProto
    D -->|__proto__| E["null"]:::endNode

    classDef instance fill:#cce5ff,stroke:#0d6efd,color:#004085
    classDef proto fill:#d4edda,stroke:#28a745,color:#155724
    classDef rootProto fill:#fff3cd,stroke:#ffc107,color:#856404
    classDef endNode fill:#f8d7da,stroke:#dc3545,color:#721c24

代码示例

function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function() {
  console.log(`${this.name} makes a sound`);
};

function Dog(name) {
  Animal.call(this, name);
}

// 建立原型链:Dog.prototype -> Animal.prototype
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log('Woof!');
};

const dog = new Dog('Buddy');

// 查找过程:
dog.name;    // 在 dog 自身找到
dog.bark();  // 在 Dog.prototype 找到
dog.speak(); // 在 Animal.prototype 找到
dog.toString(); // 在 Object.prototype 找到

用代码验证这条链:

console.log(dog.__proto__ === Dog.prototype);                 // true
console.log(Dog.prototype.__proto__ === Animal.prototype);    // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null);             // true

这就解释了开头的问题。数组能调用 pushjoin,是因为这些方法定义在 Array.prototype 上。能调用 toString,是因为顺着原型链能找到 Object.prototype.toString(虽然 Array 重写了这个方法)。


完整的原型链图谱

JavaScript 的原型链比想象中更复杂,函数本身也是对象,也有自己的原型链:

flowchart TB
    subgraph IL[实例层]
        foo["foo 实例"]:::instance
    end

    subgraph PL[原型层]
        FooP["Foo.prototype"]:::proto
        ObjP["Object.prototype"]:::rootProto
    end

    subgraph FL[函数层]
        Foo["Foo 函数"]:::func
        Obj["Object 函数"]:::func
        Func["Function 函数"]:::func
    end

    subgraph FPL[函数原型层]
        FuncP["Function.prototype"]:::funcProto
    end

    foo -->|__proto__| FooP
    FooP -->|__proto__| ObjP
    ObjP -->|__proto__| NULL["null"]:::endNode

    Foo -->|prototype| FooP
    Foo -->|__proto__| FuncP

    Obj -->|prototype| ObjP
    Obj -->|__proto__| FuncP

    Func -->|prototype| FuncP
    Func -->|__proto__| FuncP

    FuncP -->|__proto__| ObjP

    classDef instance fill:#cce5ff,stroke:#0d6efd,color:#004085
    classDef proto fill:#d4edda,stroke:#28a745,color:#155724
    classDef rootProto fill:#fff3cd,stroke:#ffc107,color:#856404
    classDef func fill:#e2d9f3,stroke:#6f42c1,color:#432874
    classDef funcProto fill:#fce4ec,stroke:#e91e63,color:#880e4f
    classDef endNode fill:#f8d7da,stroke:#dc3545,color:#721c24

    style IL fill:#e8f4fc,stroke:#0d6efd
    style PL fill:#e8f5e9,stroke:#28a745
    style FL fill:#f3e5f5,stroke:#6f42c1
    style FPL fill:#fce4ec,stroke:#e91e63

几个关键点

1. 所有函数都是 Function 的实例

console.log(Foo.__proto__ === Function.prototype);    // true
console.log(Object.__proto__ === Function.prototype); // true
console.log(Function.__proto__ === Function.prototype); // true(自己创建自己)

2. Function.prototype 也是对象,它的原型是 Object.prototype

console.log(Function.prototype.__proto__ === Object.prototype); // true

3. Object.prototype 是原型链的终点

console.log(Object.prototype.__proto__ === null); // true

4. 一个有趣的循环

// Object 是函数,所以它的 __proto__ 是 Function.prototype
console.log(Object.__proto__ === Function.prototype); // true

// Function.prototype 是对象,所以它的 __proto__ 是 Object.prototype
console.log(Function.prototype.__proto__ === Object.prototype); // true

// 这形成了一个有趣的"鸡生蛋蛋生鸡"的关系

属性遮蔽(Property Shadowing)

如果对象自身和原型上有同名属性,会发生什么?

function Person(name) {
  this.name = name;
}

Person.prototype.name = 'Default';
Person.prototype.greet = function() {
  console.log(`Hello, ${this.name}`);
};

const john = new Person('John');

// 自身属性遮蔽原型属性
console.log(john.name); // 'John',不是 'Default'

// 删除自身属性后,原型属性就露出来了
delete john.name;
console.log(john.name); // 'Default'

这就是属性遮蔽:自身属性会"遮住"原型链上的同名属性。

检查属性来源

const john = new Person('John');

// hasOwnProperty 只检查自身属性
console.log(john.hasOwnProperty('name'));  // true
console.log(john.hasOwnProperty('greet')); // false

// in 操作符检查整个原型链
console.log('name' in john);  // true
console.log('greet' in john); // true

实现继承

理解了原型链,继承就好办了。核心就两步:

  1. 调用父构造函数,继承实例属性
  2. 设置原型链,继承原型方法
function Vehicle(type) {
  this.type = type;
  this.speed = 0;
}

Vehicle.prototype.accelerate = function(amount) {
  this.speed += amount;
  console.log(`${this.type} 加速到 ${this.speed} km/h`);
};

function Car(brand) {
  Vehicle.call(this, '汽车');  // 继承实例属性
  this.brand = brand;
}

Car.prototype = Object.create(Vehicle.prototype);  // 继承原型方法
Car.prototype.constructor = Car;

// 添加子类特有的方法
Car.prototype.honk = function() {
  console.log(`${this.brand} 鸣笛`);
};

// 重写父类方法
Car.prototype.accelerate = function(amount) {
  Vehicle.prototype.accelerate.call(this, amount);
  if (this.speed > 120) {
    console.log('超速警告');
  }
};

const myCar = new Car('丰田');
myCar.accelerate(50);   // 汽车 加速到 50 km/h
myCar.accelerate(80);   // 汽车 加速到 130 km/h
                        // 超速警告
myCar.honk();           // 丰田 鸣笛

为什么用 Object.create() 而不是直接赋值

// 错误写法
Car.prototype = Vehicle.prototype;
// 问题:修改 Car.prototype 会影响 Vehicle.prototype

// 错误写法
Car.prototype = new Vehicle();
// 问题:会执行 Vehicle 构造函数,可能有副作用

// 正确写法
Car.prototype = Object.create(Vehicle.prototype);
// 创建一个新对象,原型指向 Vehicle.prototype

ES6 的 class 语法

ES6 引入了 class 关键字,写起来更清爽:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a sound`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  bark() {
    console.log('Woof!');
  }
}

const dog = new Dog('Buddy', 'Labrador');
dog.speak(); // Buddy makes a sound
dog.bark();  // Woof!

但要清楚,class 只是语法糖,底层还是原型链那套:

console.log(typeof Dog); // "function"
console.log(dog.__proto__ === Dog.prototype); // true
console.log(Dog.prototype.__proto__ === Animal.prototype); // true

class 的一些特性

class Example {
  // 实例属性(ES2022+)
  instanceProp = 'instance';

  // 私有属性(ES2022+)
  #privateProp = 'private';

  // 静态属性
  static staticProp = 'static';

  // 静态方法
  static staticMethod() {
    return 'static method';
  }

  // getter/setter
  get value() {
    return this.#privateProp;
  }
}

几个容易踩的坑

1. 引用类型放原型上会共享

function Student(name) {
  this.name = name;
}

Student.prototype.hobbies = [];  // 所有实例共享这个数组

const s1 = new Student('张三');
const s2 = new Student('李四');

s1.hobbies.push('reading');
console.log(s2.hobbies); // ['reading']  // s2 也有了,出问题了

引用类型(数组、对象)应该放在构造函数里:

function Student(name) {
  this.name = name;
  this.hobbies = [];  // 每个实例独立
}

2. 别直接替换 prototype 对象

function Foo() {}

// 直接替换 prototype 会丢失 constructor
Foo.prototype = {
  method: function() {}
};

const foo = new Foo();
console.log(foo.constructor === Foo); // false,变成 Object 了

要么记得补上 constructor,要么用属性添加的方式:

// 方式一:补上 constructor
Foo.prototype = {
  constructor: Foo,
  method: function() {}
};

// 方式二:直接添加属性(推荐)
Foo.prototype.method = function() {};

3. 箭头函数不能用作构造函数

const Foo = () => {};
const foo = new Foo(); // TypeError: Foo is not a constructor

箭头函数没有 prototype 属性,也没有自己的 this,所以不能用 new

4. instanceof 的局限性

// instanceof 检查的是原型链
console.log([] instanceof Array);  // true
console.log([] instanceof Object); // true

// 跨 iframe/realm 时会失效
// iframe 里的 Array 和主页面的 Array 不是同一个

更可靠的类型检查:

Object.prototype.toString.call([]);  // "[object Array]"
Array.isArray([]);  // true

性能考虑

原型链查找有开销

属性查找会沿着原型链向上,链越长开销越大。虽然现代引擎有优化,但还是要注意:

// 如果频繁访问原型链上的属性,可以缓存
const method = obj.someMethod;
for (let i = 0; i < 1000000; i++) {
  method.call(obj);  // 比 obj.someMethod() 快
}

Object.create(null) 创建纯净对象

// 普通对象会继承 Object.prototype
const obj = {};
console.log(obj.toString); // ƒ toString() { [native code] }

// 纯净对象没有原型链
const pureObj = Object.create(null);
console.log(pureObj.toString); // undefined

// 适合用作字典/哈希表,不用担心键名冲突
const dict = Object.create(null);
dict['hasOwnProperty'] = 'safe';  // 不会覆盖原型方法

小结

原型链说穿了就是一条查找链:找属性时从对象自身开始,顺着 __proto__ 一路往上找,直到 null

几个要点:

  • prototype 是函数的属性,用于存放共享的方法
  • __proto__(或 [[Prototype]])是对象的属性,指向它的原型
  • 推荐用 Object.getPrototypeOf() 代替 __proto__
  • new 关键字做了四件事:创建对象、设置原型、执行构造函数、返回对象
  • 方法定义在原型上,省内存
  • class 是语法糖,底层还是原型链
  • Object.prototype 是原型链的终点,它的 __proto__null

理解了这个机制,再看 JavaScript 的面向对象就清晰多了。框架源码里大量使用原型链,比如 Vue 2 的响应式系统、各种插件的 mixin 实现,都是基于这套机制。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

参考资料

【Virtual World 04】我们的目标,无限宇宙!!

这是纯前端手搓虚拟世界第四篇。

前端佬们,前三篇的基础打好,基本创建一个单体的应用没啥问题了。但前端佬肯定发现一个很尴尬的问题!

93B1A00879A9B67271080936B8A2D89CE1D69417_size242_w423_h220.gif

那就是我们的 Canvas 只有 600x600。

如果你想直接搞一幅《清明上河图》,那画着画着,就会发现——没地儿了

立Flag

按照最简单的方法,把canvas加大,把屏幕加大,这样就不只600x600了。

But!!!!

装X装到头,抠抠搜搜加那点资源,还是不够用。既然是虚拟世界,我们的目前,就应该无限宇宙!

这篇,就是要把想法变成现实,一个通向无限世界的“窗口”,实现一个虚拟摄像机(Virtual Camera) 。你看到的只是摄像机拍到的画面,而世界本身,你的梦想有多大,那么就有多大。

timg (1).gif


战略思考

上了class的贼船,首先想法就是抽象一个类,来管理这个。Viewport(视口) ,专门处理这些事情。

它的核心功能只有两个:

  1. 坐标转换:搞清楚“鼠标点在屏幕上的 (100,100)”到底对应“虚拟世界里的哪一个坐标”。
  2. 平移交互(Pan) :按住 空格键 + 鼠标左键拖拽,移动摄像机,浏览世界的其他角落。

嗯,够逼格了!

timg (25).gif


基础原理

要个无限空间其实是一个视觉小把戏,首先我们得确认浏览器是不会允许你创建一个 width: 99999999px 的 Canvas 的,这样浏览器的内存会直接嗝屁。

然后我们移动鼠标位置是固定不变的,大致示意图如下:

image.png

虚拟层就是在数学层面上的模拟层,所有的移动,添加都是放到虚拟层上。

我们脑子里始终要记得两套坐标系:

  • 屏幕坐标系 (Screen Space)

    • 这是物理现实
    • 原点 (0,0) 永远在 Canvas 的左上角。
    • 范围有限,比如 600x600
    • 用途:接收鼠标事件 (evt.offsetX, evt.offsetY),最终渲染图像。
  • 世界坐标系 (World Space)

    • 这是虚拟数据
    • 原点 (0,0) 是世界的中心。
    • 范围无限,你的点可以是 (-5000, 99999)
    • 用途:存储 PointSegment 的真实位置。

视口变换的原理,并不是真的把 Canvas 的 DOM 元素拖走了,而是我们在绘制之前,对所有的坐标做了一次数学偏移。

想象一下你拿着一个相机(视口)在拍风景,如果你想看右边的树,你需要把相机向移,但在相机取景框里,那棵树看起来是向移了。

嗯,大概就是这样了。


上代码

听不懂??直接上代码!!!!

0.gif

视口控制器:Viewport

src 下新建文件夹 view,然后创建 viewport.js

这玩意的数学原理其实就是一个简单的减法:

世界坐标 = 屏幕坐标 - 视口中心偏移量

如果我把视口往右移 100px,那原本的世界原点 (0,0) 现在就在屏幕的 (100,0) 位置。

JavaScript

// src/view/viewport.js
import Point2D from "../primitives/point2D.js";

export default class Viewport {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");

    // 视口的初始缩放级别(为下一篇做铺垫)
    this.zoom = 1;
    // 视口的中心偏移量(相当于摄像机的位置)
    // 默认让 (0,0) 在画布中心,这样更符合数学直觉
    this.center = new Point2D(canvas.width / 2, canvas.height / 2);
    // 另一种更通用的做法是记录 offset,也就是 panning 的距离
    this.offset = new Point2D(0, 0);

    // 拖拽状态管理
    this.drag = {
      start: new Point2D(0, 0), // 鼠标按下的起始点
      end: new Point2D(0, 0),   // 鼠标当前的结束点
      offset: new Point2D(0, 0),// 这一瞬间拖了多远
      active: false             // 是否正在拖拽
    };

    this.#addEventListeners();
  }

  // 核心数学题:屏幕坐标 -> 世界坐标
  getMouse(evt) {
    // 现在的计算逻辑:(鼠标位置 - 中心点) * 缩放系数 - 偏移量
    // 暂时先不加缩放,只处理平移
    return new Point2D(
      (evt.offsetX - this.center.x) - this.offset.x,
      (evt.offsetY - this.center.y) - this.offset.y
    );
  }

  // 获取当前的偏移量,给 Canvas 用
  getOffset() {
     return new Point2D(
         this.center.x + this.offset.x, 
         this.center.y + this.offset.y
     );
  }

  #addEventListeners() {
    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有按住空格(Space)且点击左键(0)时,才触发平移
      // 这里的 evt.button == 0 是左键
      // 这里的 evt.ctrlKey / shiftKey 等也可以判断,但我们要判断空格键状态
      // 由于 mousedown 里拿不到键盘持续状态,我们需要一个外部变量或者在 window 上监听键盘
      // 简单的做法:检查 evt 里是否包含按键信息?不包含。
      // 所以我们通常单独存一个键盘状态,或者直接利用 "wheel" 做缩放,用 "middle" 做平移
      // 但既然需求是 "空格+左键",我们需要配合 keydown/keyup
    });
  }
}

这里歇一歇,理理代码思路。

9.gif

**“按住空格”**这个逻辑在 DOM 事件里稍微有点麻烦。因为 mousedown 事件对象里不直接告诉你“空格键是不是正被按着”。

我们需要给 Viewport 增加键盘监听。

修正后的完整 src/view/viewport.js

import Point2D from "../primitives/point2D.js";

export default class Viewport {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");

    this.zoom = 1;
    // 这里的 offset 就是我们一共平移了多少距离
    this.offset = new Point2D(0, 0); 
    
    // 拖拽计算用的临时变量
    this.drag = {
      start: new Point2D(0, 0),
      end: new Point2D(0, 0),
      offset: new Point2D(0, 0),
      active: false
    };

    this.#addEventListeners();
  }

  // 计算:鼠标在屏幕上的点,对应的真实世界坐标在哪里
  getMouse(evt, subtractDragOffset = false) {
    const p = new Point2D(
      (evt.offsetX - this.canvas.width / 2) * this.zoom - this.offset.x,
      (evt.offsetY - this.canvas.height / 2) * this.zoom - this.offset.y
    );
    return p;
  }
  
  // 给 Canvas 渲染用的,告诉它该平移多少
  getOffset() {
      return new Point2D(
          this.offset.x + this.canvas.width / 2,
          this.offset.y + this.canvas.height / 2
      );
  }

  #addEventListeners() {
    // 记录空格键状态
    this.isSpacePressed = false;

    window.addEventListener("keydown", (evt) => {
       if (evt.code === "Space") {
           this.isSpacePressed = true;
       }
    });

    window.addEventListener("keyup", (evt) => {
       if (evt.code === "Space") {
           this.isSpacePressed = false;
       }
    });

    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有按住空格 + 左键,才开始拖拽视口
      if (this.isSpacePressed && evt.button === 0) {
        this.drag.start = this.getMouse(evt);
        this.drag.active = true;
      }
    });

    this.canvas.addEventListener("mousemove", (evt) => {
      if (this.drag.active) {
        this.drag.end = this.getMouse(evt);
        // 计算这一瞬间移动了多少
        this.drag.offset = new Point2D(
            this.drag.end.x - this.drag.start.x,
            this.drag.end.y - this.drag.start.y
        );
        // 累加到总偏移量里
        this.offset.x += this.drag.offset.x;
        this.offset.y += this.drag.offset.y;
        
        // 重要:重置 start,因为我们已经处理了这段位移
        // 如果不重置,你会发现画面飞得越来越快
        this.drag.start = this.getMouse(evt); 
      }
    });

    this.canvas.addEventListener("mouseup", () => {
      if (this.drag.active) {
        this.drag.active = false;
      }
    });
  }
}

修改 GraphEditor

这就是上篇埋的坑。之前我们在 GraphEditor 里直接用了 evt.offsetX。现在不行了,必须通过 viewport.getMouse(evt) 来获取坐标。

修改 src/editors/graphEditor.js

  1. 构造函数接收 viewport
  2. 事件监听改用 viewport.getMouse(evt)
  3. 冲突解决:如果按住了空格(正在拖拽视图),就不要触发“画点”的逻辑。
// src/editors/graphEditor.js
import Point2D from "../primitives/point2D.js";
import Segment from "../primitives/segment.js";

export default class GraphEditor {
  constructor(canvas, graph, viewport) {
    this.canvas = canvas;
    this.graph = graph;
    this.viewport = viewport;

    this.ctx = canvas.getContext("2d");

    // 状态机
    this.selected = null; // 当前选中的点(用于连线起点)
    this.hovered = null; // 鼠标悬停的点
    this.dragging = false; // 预留给未来拖拽用
    this.mouse = null; // 当前鼠标位置

    // 启动监听
    this.#addEventListeners();
  }

  #addEventListeners() {
    // 1. 鼠标按下事件
    this.canvas.addEventListener("mousedown", (evt) => {
      // 只有左键(0)和右键(2)才处理
      if (evt.button == 2) {
        // 右键逻辑
        if (this.selected) {
          this.selected = null; // 取消当前选中,停止连线
        } else if (this.hovered) {
          this.#removePoint(this.hovered); // 删除点
        }
      }

      if (evt.button == 0) {
        // [新增] 如果视口正在被拖拽,或者是空格按下状态,不要画点
        if (this.viewport.drag.active || this.viewport.isSpacePressed) return;

        // 左键逻辑
        // 如果鼠标在某个点上,就选中它;如果不在,就新建一个点并选中它
        if (this.hovered) {
          this.#select(this.hovered);
          this.dragging = true;
          return;
        }
        this.graph.tryAddPoint(this.mouse);
        this.#select(this.mouse); // 自动选中新点,方便连续画线
        this.hovered = this.mouse;
        this.dragging = true;
      }
    });

    // 2. 鼠标移动事件
    this.canvas.addEventListener("mousemove", (evt) => {
      // 获取鼠标在 Canvas 里的坐标(即使 Canvas 缩放或偏移也能用)
      // 这里先简化处理,假设 Canvas 铺满或者无偏移
      // 实际上我们应该写个 getViewportPoint,但暂时先直接读取 offsetX/Y
      this.mouse = this.viewport.getMouse(evt);

      // 检查鼠标有没有悬停在某个点上
      this.hovered = this.#getNearestPoint(this.mouse);

      // 移动的时候不需要重绘吗?需要的,但我们会在 World 里统一驱动动画循环
    });

    // 3. 禁止右键菜单弹出
    this.canvas.addEventListener("contextmenu", (evt) => evt.preventDefault());

    // 4. 鼠标抬起(结束拖拽状态)
    this.canvas.addEventListener("mouseup", () => (this.dragging = false));
  }

  #select(point) {
    // 如果之前已经选中了一个点,现在又选了一个点,说明要连线
    if (this.selected) {
      // 尝试添加线段
      this.graph.tryAddSegment(new Segment(this.selected, point));
    }
    this.selected = point;
  }

  #removePoint(point) {
    this.graph.removePoint(point);
    this.hovered = null;
    if (this.selected == point) {
      this.selected = null;
    }
  }

  // 辅助函数:找离鼠标最近的点
  #getNearestPoint(point, minThreshold = 15) {
    let nearest = null;
    let minDist = Number.MAX_SAFE_INTEGER;

    for (const p of this.graph.points) {
      const dist = Math.hypot(p.x - point.x, p.y - point.y);
      if (dist < minThreshold && dist < minDist) {
        minDist = dist;
        nearest = p;
      }
    }
    return nearest;
  }

  // 专门负责画编辑器相关的 UI(比如高亮、虚线)
  display() {
    this.graph.draw(this.ctx);

    // 如果有悬停的点,画个特殊的样式
    if (this.hovered) {
      this.hovered.draw(this.ctx, { outline: true });
    }

    // 如果有选中的点,也高亮一下
    if (this.selected) {
      // 获取鼠标位置作为意图终点
      const intent = this.hovered ? this.hovered : this.mouse;
      // 画出“虚拟线条”:从选中点 -> 鼠标位置
      new Segment(this.selected, intent).draw(this.ctx, {
        color: "rgba(0,0,0,0.5)",
        width: 1,
        dash: [3, 3],
      });
      this.selected.draw(this.ctx, { outline: true, outlineColor: "blue" });
    }
  }
}

4.3. 重构 World:应用视口变换

最后,去 index.js 把这一切串起来。Canvas 的变换(Translate)需要包裹在 save()restore() 之间,否则你的 UI(如果有的话)也会跟着一起跑。

修改 src/index.js

import Point2D from "./primitives/point2D.js";
import Segment from "./primitives/segment.js";
import Graph from "./math/graph.js";
import GraphEditor from "./editors/graphEditor.js";
import Viewport from "./view/viewport.js"; // 引入新成员

export default class World {
  constructor(canvas, width = 600, height = 600) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    this.canvas.width = width;
    this.canvas.height = height;

    this.graph = new Graph();
    // 1. 先初始化视口
    this.viewport = new Viewport(this.canvas);
    // 2. 把视口传给编辑器
    this.editor = new GraphEditor(this.canvas, this.graph, this.viewport);

    this.animate();
  }

  animate() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // [核心步骤] 保存当前状态 -> 移动画布 -> 画画 -> 恢复状态
    this.ctx.save();
    
    // 获取视口当前的偏移,并应用平移变换
    // 注意:这里我们用 scale(1/zoom) 是为了配合鼠标计算,暂时还没做缩放,
    // 但我们可以把 translate 先写好
    const offset = this.viewport.getOffset();
    this.ctx.translate(offset.x, offset.y);
    
    // 所有的绘制现在都在"移动后"的坐标系里进行了
    this.editor.display();
    
    this.ctx.restore();

    requestAnimationFrame(() => this.animate());
  }
}

大概示意图

image.png

嗯....NICE!

今天就这样了。

完整代码戳这里:github.com/Float-none/…

npm几个实用命令

前言

最近接到公司安全团队的项目风险表格,列出了当前前端项目的一些安全风险,大概有190多项,第一个大坨是XSS的,第二大坨就是npm包需要升级的,看了下,需要升级的大概有55个包,最近在做这些包升级的时候发现了NPM以前很少用,但是确是很实用的几个命令,特记录分享

实用命令

npm install package@version

此命令相对其它要介绍的几个命令应该是使用率算高的,它的功能就是指定安装特定的版本 先来看一下package.json版本规则符号对比表:

符号 示例 允许的版本范围 核心场景
无符号 1.4.2 1.4.2 版本敏感,禁止任何更新
^(插入号) ^1.4.2 1.4.2 → 1.9.9(主版本不变) 日常依赖,自动更功能/修复
~(波浪号) ~1.4.2 1.4.2 → 1.4.9(仅修订号更新) 保守更新,仅接受 bug 修复
>/< >=1.4.2 <3.0.0 1.4.2 → 2.9.9 精确控制版本区间
*(星号) 1.* 1.0.0 → 1.999.999 无版本依赖(极少用)
!(感叹号) >=1.4.0 !1.4.2 1.4.0、1.4.1、1.4.3+(排除 1.4.2) 禁止有问题的版本

如果你使用普通的npm install package,那它在package.json里写入的版本规则是第二项,如项目有lock文件的时候问题不大,但是没有的时候,你执行npm i,它会在可允许的范围内主动更新版本,如果你使用npm install package@version,那写入package.json的版本规则会是第一种,此次安全团队给出了要更新到的指定版本,所以在此处我选择的通过npm install package@version来实现安装指定版本并锁死版本号 使用示例:

npm install sass@1.94.2

npm ls package [--depth=0]

此命令用于查看你当前项目安装的package版本是多少,它会列出安装的所有的版本,此处我们以vue包为例,执行如下命令

npm ls vue

控制台会看到如下输出结果:

image.png

从上图我们还看到部分包后还带有deduped的灰色字,这个的意思是“去重” 的意思,是 de-duplicated 的缩写,代表这个包是 “重复依赖被合并后,复用了已安装的版本”,核心是 npm 优化依赖树的一种机制,当项目中多个依赖同时需要同一个包(且版本兼容)时,npm 会自动把重复的包合并到依赖树的更上层(比如根目录的 node_modules),避免重复安装。此时在 npm ls 中,被合并的重复包就会标注 deduped,表示 “这个包没单独装,而是复用了其他地方已有的版本” 上面命令还有一个可选参数就是--depth,控制依赖树的显示层级,避免默认npm ls输出过于冗长,用的不多,看二个例子:

  • npm ls vue --depth=0 只显示项目直接依赖(不显示子依赖)
  • npm ls vue --depth=1 显示直接依赖 + 一级子依赖

npm explain package

精准解释某个包被安装的原因(谁依赖它、依赖版本范围、安装路径),解决「明明没手动安装,却出现在node_modules」的困惑,个人感觉这个命令和上面ls有一点相似,都可以查看当前包安装了什么版本,哪一个版本被安装是因为哪一个依赖,我们还是以vue包来例

npm explain vue

在命令行看到如下结果:

image1.png

npm view package [version[s]]

此命令就是查看package包线上可用的版本,分带versiont和不带version,version又有分带不带s,

  • 不带version,从npm官方仓库(或当前配置的镜像源)中查询vue包的公开信息
  • 带version,并且带s就是列出所有可用历史版本
  • 带version,不带s就是当前最新的稳定可用版本 此处我们还是以vue包为例:
npm view vue

控制台输出如下:

image2.png

此命令还可以指定版本号,使用方式是npm view vue@version,就是查看指定版本的公开信息,这个命令给我的感觉就是在命令行中打开vue包的npm简版详情页

npm view vue versions

控制台会看到如下输出结果:

image3.png

如果不带s,控制台输出如下:

image4.png

npm view package[@version] dependencies [--json]

此命令就是上面npm view的另一种用法,它可以查看当前包当指版本的生产依赖和开发依赖,为什么单提出来说了,因为这个是我这一次用的最多的命令,因为安全团队给的需要你做升级的包里,它并不是当前项目的直接依赖,很多都是二级或者三级依赖,如果你要升三级依赖,那你就得去查看祖先级包哪一个版本可以让你这个依赖升级,单提出来的另一个原因是它还有一个在线web工具 此处我们还是以vue包为例:

npm view vue dependencies --json

命令行输出如下:

image5.png

它有在线版本:npmgraph.js.org/ 使用体验如下:

image6.png

一开始我是使用上在命令行查看的,后面发现这个在线的工具后,就使用的都是这个在线的工具了,顺便说个题外话,如果同样的功能可以通过cli来用,也可以通过GUI来用,你通常会选择哪一个来用了?当然,我是后者,你了?

小结

对于做前端开发的我们基本每天都有在用NPM命令,但是还是有很多好用的功能是没有发现的,所以我一直都很敬畏技术,就像我写个人简历我觉得我自己掌握了的技术我敢用的二个字就是熟练,从来不敢用精通,因为精通一个东西真的不是那么容易的 个人的知识和能力是有限度,你在使用npm的时候有没有发现其它的一些好用的但是使用不太高频的功能,期待你的留言分享,一起学习一起进步

优雅的React表单状态管理

优雅的React表单状态管理

🌸 你是否还在为React表单中的重复状态管理代码感到困扰?今天我们就来学习如何用自定义Hook来优雅地解决这个问题~

传统表单管理的痛点

在React中,我们通常这样管理表单:

import React, { useState } from 'react';

function LoginForm() {
  // 每个字段都需要单独的state
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [email, setEmail] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    // 处理表单提交
    console.log({ username, password, email });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)} // 每个字段都需要单独的onChange
        placeholder="用户名"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="密码"
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="邮箱"
      />
      <button type="submit">登录</button>
    </form>
  );
}

当表单字段增多时,这种写法会变得非常繁琐:

  • 每个字段都需要单独的state和setter
  • 每个input都需要重复的onChange逻辑
  • 表单数据分散在多个state中,不方便统一处理

优雅方案:自定义useForm Hook

让我们创建一个通用的useForm Hook来解决这些问题:

import { useState } from 'react';

/**
 * 通用表单状态管理Hook
 * @param {Object} initialValues - 表单初始值
 * @returns {Object} 包含表单值、onChange处理函数和重置函数
 */
const useForm = (initialValues) => {
  // 用一个对象统一管理所有表单字段
  const [values, setValues] = useState(initialValues);

  // 通用的onChange处理函数
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    // 处理复选框等特殊类型
    setValues(prevValues => ({
      ...prevValues,
      [name]: type === 'checkbox' ? checked : value
    }));
  };

  // 重置表单到初始状态
  const resetForm = () => {
    setValues(initialValues);
  };

  return { values, handleChange, resetForm };
};

// 使用示例
function LoginForm() {
  // 一行代码初始化整个表单
  const { values, handleChange, resetForm } = useForm({
    username: '',
    password: '',
    email: ''
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('表单数据:', values);
    // 提交后重置表单
    resetForm();
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 所有input共用一个handleChange */}
      <input
        type="text"
        name="username" // 注意name属性要和初始值的key对应
        value={values.username}
        onChange={handleChange}
        placeholder="用户名"
      />
      <input
        type="password"
        name="password"
        value={values.password}
        onChange={handleChange}
        placeholder="密码"
      />
      <input
        type="email"
        name="email"
        value={values.email}
        onChange={handleChange}
        placeholder="邮箱"
      />
      <button type="submit">登录</button>
      <button type="button" onClick={resetForm}>重置</button>
    </form>
  );
}

进阶:添加表单验证

我们可以进一步增强这个Hook,添加表单验证功能:

import { useState, useEffect } from 'react';

/**
 * 带验证的表单状态管理Hook
 * @param {Object} initialValues - 表单初始值
 * @param {Function} validate - 验证函数,返回错误信息对象
 * @returns {Object} 表单控制对象
 */
const useFormWithValidation = (initialValues, validate) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // 当表单值变化时自动验证
  useEffect(() => {
    if (isSubmitting) {
      const validationErrors = validate(values);
      setErrors(validationErrors);
      setIsSubmitting(false);
    }
  }, [values, isSubmitting, validate]);

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setValues(prevValues => ({
      ...prevValues,
      [name]: type === 'checkbox' ? checked : value
    }));
    // 清除该字段的错误信息
    setErrors(prevErrors => ({
      ...prevErrors,
      [name]: ''
    }));
  };

  const handleSubmit = (callback) => (e) => {
    e.preventDefault();
    const validationErrors = validate(values);
    setErrors(validationErrors);
    setIsSubmitting(true);
    
    // 如果没有错误,执行提交回调
    if (Object.keys(validationErrors).length === 0) {
      callback(values);
    }
  };

  const resetForm = () => {
    setValues(initialValues);
    setErrors({});
  };

  return { values, errors, handleChange, handleSubmit, resetForm };
};

// 使用示例
function RegisterForm() {
  // 验证函数
  const validate = (values) => {
    const errors = {};
    
    if (!values.username.trim()) {
      errors.username = '用户名不能为空';
    } else if (values.username.length < 3) {
      errors.username = '用户名至少3个字符';
    }
    
    if (!values.email) {
      errors.email = '邮箱不能为空';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
      errors.email = '请输入有效的邮箱地址';
    }
    
    if (!values.password) {
      errors.password = '密码不能为空';
    } else if (values.password.length < 6) {
      errors.password = '密码至少6个字符';
    }
    
    return errors;
  };

  const { values, errors, handleChange, handleSubmit, resetForm } = useFormWithValidation(
    { username: '', email: '', password: '' },
    validate
  );

  const onSubmit = (formData) => {
    console.log('注册成功:', formData);
    resetForm();
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          type="text"
          name="username"
          value={values.username}
          onChange={handleChange}
          placeholder="用户名"
        />
        {errors.username && <p style={{ color: 'red' }}>{errors.username}</p>}
      </div>
      
      <div>
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          placeholder="邮箱"
        />
        {errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}
      </div>
      
      <div>
        <input
          type="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          placeholder="密码"
        />
        {errors.password && <p style={{ color: 'red' }}>{errors.password}</p>}
      </div>
      
      <button type="submit">注册</button>
    </form>
  );
}

核心优势

  1. 代码复用:一个Hook可以用于项目中的所有表单,减少重复代码
  2. 集中管理:所有表单数据集中在一个对象中,便于处理和提交
  3. 灵活扩展:可以根据需要添加验证、异步提交等功能
  4. 类型安全:结合TypeScript使用时,可以提供完整的类型提示

最佳实践

💡 使用时的小建议:

  1. 命名规范:确保input的name属性与初始值的key完全对应
  2. 复杂表单:对于非常复杂的表单,可以考虑使用成熟的表单库(如Formik、React Hook Form)
  3. 性能优化:对于包含大量字段的表单,可以考虑使用useReducer替代useState来优化性能
  4. 异步验证:如果需要异步验证(如检查用户名是否已存在),可以在自定义Hook中添加相应的处理逻辑

总结

通过自定义Hook,我们可以将表单状态管理的逻辑封装起来,使组件代码更加简洁优雅。这种方式不仅提高了代码的复用性和可维护性,还让我们能够更好地专注于业务逻辑的实现。

下次遇到表单时,不妨试试这个方法吧~相信你会爱上这种简洁的表单管理方式!😉


🫶 今天的小技巧就分享到这里啦!如果你有更好的表单管理方法,欢迎在评论区交流哦~

Vue Router 组件内路由钩子全解析

一、什么是组件内路由钩子?

在 Vue Router 中,组件内路由钩子(也称为导航守卫)是在路由变化时自动调用的特殊函数,它们允许我们在特定时机执行自定义逻辑,比如:

  • • 权限验证(是否登录)
  • • 数据预加载
  • • 页面离开确认
  • • 滚动行为控制
  • • 动画过渡处理
// 一个简单的示例
export default {
  name'UserProfile',
  beforeRouteEnter(to, from, next) {
    console.log('组件还未创建,但即将进入...')
    next()
  }
}

二、三大核心钩子函数详解

Vue Router 提供了三个主要的组件内路由钩子,它们组成了一个完整的导航生命周期:

1. beforeRouteEnter - 进入前的守卫

调用时机:在组件实例被创建之前调用,此时组件还未初始化。

特点

  • • 不能访问 this(因为组件实例还未创建)
  • • 可以通过回调函数访问组件实例
export default {
  beforeRouteEnter(to, from, next) {
    // ❌ 这里不能使用 this
    console.log('from'from.path// 可以访问来源路由
    
    // ✅ 通过 next 的回调访问组件实例
    next(vm => {
      console.log('组件实例:', vm)
      vm.loadData(to.params.id)
    })
  },
  
  methods: {
    loadData(id) {
      // 加载数据逻辑
    }
  }
}

适用场景

  • • 基于路由参数的权限验证
  • • 预加载必要数据
  • • 重定向到其他页面

2. beforeRouteUpdate - 路由更新守卫

调用时机:在当前路由改变,但组件被复用时调用。

常见情况

  • • 从 /user/1 导航到 /user/2
  • • 查询参数改变:/search?q=vue → /search?q=react
export default {
  data() {
    return {
      usernull
    }
  },
  
  beforeRouteUpdate(to, from, next) {
    // ✅ 可以访问 this
    console.log('路由参数变化:'from.params.id'→', to.params.id)
    
    // 重新加载数据
    this.fetchUserData(to.params.id)
    
    // 必须调用 next()
    next()
  },
  
  methods: {
    async fetchUserData(id) {
      const response = await fetch(`/api/users/${id}`)
      this.user = await response.json()
    }
  }
}

实用技巧:使用这个钩子可以避免重复渲染,提升性能。

3. beforeRouteLeave - 离开前的守卫

调用时机:在离开当前路由时调用。

重要特性

  • • 可以阻止导航
  • • 常用于保存草稿或确认离开
export default {
  data() {
    return {
      hasUnsavedChangesfalse,
      formData: {
        title'',
        content''
      }
    }
  },
  
  beforeRouteLeave(to, from, next) {
    if (this.hasUnsavedChanges) {
      const answer = window.confirm(
        '您有未保存的更改,确定要离开吗?'
      )
      
      if (answer) {
        next() // 允许离开
      } else {
        next(false// 取消导航
      }
    } else {
      next() // 直接离开
    }
  },
  
  methods: {
    onInput() {
      this.hasUnsavedChanges = true
    },
    
    save() {
      // 保存逻辑
      this.hasUnsavedChanges = false
    }
  }
}

三、完整导航流程图

让我们通过一个完整的流程图来理解这些钩子的执行顺序:

是

否

是

next

next false

beforeRouteEnter 特殊处理
无法访问 this通过 next 回调访问实例开始导航组件是否复用?调用 beforeRouteUpdate调用 beforeRouteEnter组件内部处理确认导航 next创建组件实例执行 beforeRouteEnter 的回调渲染组件用户停留页面用户触发新导航?调用 beforeRouteLeave允许离开?执行新导航停留在当前页面

四、实际项目中的应用案例

案例1:用户权限验证系统

// UserProfile.vue
export default {
  beforeRouteEnter(to, from, next) {
    // 检查用户是否登录
    const isAuthenticated = checkAuth()
    
    if (!isAuthenticated) {
      // 未登录,重定向到登录页
      next({
        path: '/login',
        query: { redirect: to.fullPath }
      })
    } else if (!hasPermission(to.params.id)) {
      // 没有权限,重定向到403页面
      next('/403')
    } else {
      // 允许访问
      next()
    }
  },
  
  beforeRouteLeave(to, from, next) {
    // 如果是管理员,记录操作日志
    if (this.user.role === 'admin') {
      logAdminAccess(from.fullPath, to.fullPath)
    }
    next()
  }
}

案例2:电商商品详情页优化

// ProductDetail.vue
export default {
  data() {
    return {
      productnull,
      relatedProducts: []
    }
  },
  
  beforeRouteEnter(to, from, next) {
    // 预加载商品基础信息
    preloadProduct(to.params.id)
      .then(product => {
        next(vm => {
          vm.product = product
          // 同时开始加载相关商品
          vm.loadRelatedProducts(product.category)
        })
      })
      .catch(() => {
        next('/404'// 商品不存在
      })
  },
  
  beforeRouteUpdate(to, from, next) {
    // 商品ID变化时,平滑过渡
    this.showLoading = true
    this.fetchProductData(to.params.id)
      .then(() => {
        this.showLoading = false
        next()
      })
      .catch(() => {
        next(false// 保持当前商品
      })
  },
  
  methods: {
    async fetchProductData(id) {
      const [product, related] = await Promise.all([
        api.getProduct(id),
        api.getRelatedProducts(id)
      ])
      this.product = product
      this.relatedProducts = related
    },
    
    loadRelatedProducts(category) {
      // 异步加载相关商品
    }
  }
}

五、高级技巧与最佳实践

1. 组合式API中的使用

import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

export default {
  setup() {
    const unsavedChanges = ref(false)
    
    // 使用组合式API守卫
    onBeforeRouteLeave((to, from) => {
      if (unsavedChanges.value) {
        return confirm('确定要离开吗?')
      }
    })
    
    onBeforeRouteUpdate(async (to, from) => {
      // 处理路由参数更新
      await loadData(to.params.id)
    })
    
    return { unsavedChanges }
  }
}

2. 异步操作的优雅处理

export default {
  beforeRouteEnter(tofromnext) {
    // 使用async/await
    const enterGuard = async () => {
      try {
        const isValid = await validateToken(to.query.token)
        if (isValid) {
          next()
        } else {
          next('/invalid-token')
        }
      } catch (error) {
        next('/error')
      }
    }
    
    enterGuard()
  }
}

3. 避免常见的坑

坑1:忘记调用 next()

// ❌ 错误示例 - 会导致导航挂起
beforeRouteEnter(to, from, next) {
  if (checkAuth()) {
    // 忘记调用 next()
  }
}

// ✅ 正确示例
beforeRouteEnter(to, from, next) {
  if (checkAuth()) {
    next()
  } else {
    next('/login')
  }
}

坑2:beforeRouteEnter 中直接修改数据

// ❌ 错误示例
beforeRouteEnter(to, from, next) {
  next(vm => {
    // 避免直接修改响应式数据
    vm.someData = 'value' // 可能导致响应式问题
  })
}

// ✅ 正确示例
beforeRouteEnter(to, from, next) {
  next(vm => {
    vm.$nextTick(() => {
      vm.someData = 'value' // 在下一个tick中修改
    })
  })
}

六、与其他导航守卫的配合

组件内守卫还可以与全局守卫、路由独享守卫配合使用:

// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log('全局守卫 → 组件守卫')
  next()
})

// 路由配置中的独享守卫
const routes = [
  {
    path'/user/:id',
    componentUserProfile,
    beforeEnter(to, from, next) => {
      console.log('路由独享守卫 → 组件守卫')
      next()
    }
  }
]

执行顺序

    1. 导航被触发
    1. 调用全局 beforeEach
    1. 调用路由配置中的 beforeEnter
    1. 调用组件内的 beforeRouteEnter
    1. 导航被确认
    1. 调用全局的 afterEach

七、性能优化建议

1. 懒加载守卫逻辑

export default {
  beforeRouteEnter(to, from, next) {
    // 按需加载验证模块
    import('@/utils/auth').then(module => {
      if (module.checkPermission(to.meta.requiredRole)) {
        next()
      } else {
        next('/forbidden')
      }
    })
  }
}

2. 缓存验证结果

let authCache = null

export default {
  beforeRouteEnter(to, from, next) {
    if (authCache === null) {
      // 首次验证
      checkAuth().then(result => {
        authCache = result
        handleNavigation(result, next)
      })
    } else {
      // 使用缓存结果
      handleNavigation(authCache, next)
    }
  }
}

总结

Vue Router 的组件内路由钩子为我们提供了强大的导航控制能力。通过合理使用这三个钩子函数,我们可以:

  1. 1. beforeRouteEnter:在组件创建前进行权限验证和数据预加载
  2. 2. beforeRouteUpdate:优化动态参数页面的用户体验
  3. 3. beforeRouteLeave:防止用户意外丢失未保存的数据

记住这些钩子的调用时机和限制,结合实际的业务需求,你就能构建出更加健壮、用户友好的单页应用。

零硬件交互:如何用纯前端把摄像头变成 4000 个粒子的魔法棒?

关键词:Vue 3 / Three.js / MediaPipe / AI / WebGL / 创意编程 / 前端工程化

引言:当哈利波特的魔法棒变成了一行 URL

想象这样一个场景:

你不需要购买昂贵的 Apple Vision Pro,也不需要戴上笨重的 VR 头盔,甚至不需要安装任何 App。 你只需要打开一个网页,允许摄像头权限,对着屏幕伸出手。

握拳,屏幕上 4000 个原本漂浮的粒子瞬间向中心坍缩,聚集成一个紧密的能量球; 张手,这些粒子仿佛受到冲击,瞬间向四周炸裂,如同烟花般绚烂; 挥动,粒子流随着你的指尖起舞,从地球变成爱心,从爱心变成土星。

这就是我最近做的一个开源小项目 —— Hand Controlled 3D Particles

在过去,这种级别的体感交互往往意味着:专业的深度摄像头(Kinect)、高性能的本地显卡、以及复杂的 C++/Unity 开发环境。 但现在,得益于 Web AI 和 WebGL 的进化,我们用纯前端技术就能复刻这种魔法。

一、效果:指尖上的粒子宇宙

在这个项目里,你的手就是控制一切的遥控器。

1. 实时手势识别

基于 Google MediaPipe Hands 模型,浏览器能以惊人的速度(60fps+)捕捉你手部的 21 个关键点。 这不是简单的"动量检测",而是真正的"骨骼识别":

  • 拇指与食指捏合/靠近 → 触发引力场
  • 手掌完全张开 → 触发斥力场
  • 左右手势切换 → 切换 3D 模型形态

2. 视觉形态演变

粒子不仅仅是散乱的点,它们按照数学公式排列成 5 种形态:

  • Earth(地球):经典的 Fibonacci 球面分布
  • Heart(爱心):浪漫的心形数学曲线
  • Saturn(土星):带光环的行星系统
  • Tree(圣诞树):圆锥螺旋分布
  • Fireworks(烟花):完全随机的爆炸效果

这一切,都运行在一个普通的 Chrome 浏览器里。

二、技术解构:三驾马车如何协同?

整个项目使用 Vue 3 + TypeScript 构建,核心逻辑其实非常简单,主要依赖三个技术的有机结合。

1. The Eye: MediaPipe Hands

这是 Google 开源的轻量级机器学习模型。它的特点是:快,极快。 它不需要将视频流上传到云端处理,而是直接在浏览器端(WASM)利用 GPU 加速推理。

核心逻辑只需要关注两个关键点:

  • Landmark 4 (拇指尖)
  • Landmark 8 (食指尖)
// 计算拇指和食指的欧几里得距离
const distance = Math.sqrt(
  (thumbTip.x - indexTip.x) ** 2 + 
  (thumbTip.y - indexTip.y) ** 2
)

// 设定阈值进行状态判断
if (distance < 0.08) {
  // 触发"握拳/收缩"状态
  emit('contract')
} else if (distance > 0.16) {
  // 触发"张开/爆炸"状态
  emit('explode')
}

简单的高中数学,就能把连续的模拟信号转化为离散的交互指令。

2. The Canvas: Three.js 粒子系统

4000 个粒子在 Three.js 中并不是 4000 个独立的 Mesh(那样浏览器会卡死),而是一个 THREE.Points 系统。 所有的粒子共享一个 Geometry,通过 BufferAttribute 来管理每个粒子的位置。

不同形态的切换,本质上是目标位置(Target Position)的计算。比如心形曲线:

// 心形参数方程
const x = 16 * Math.pow(Math.sin(t), 3)
const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)
const z = t * 2 // 增加一点厚度

3. The Motion: 简单的物理插值

为了让动画看起来自然,粒子不是"瞬移"到新位置的,而是每一帧都向目标位置"滑行"一点点。

// 每一帧的渲染循环中
for (let i = 0; i < particleCount; i++) {
  // 当前位置 += (目标位置 - 当前位置) * 阻尼系数
  positions[i] += (targetPositions[i] - positions[i]) * 0.08
}

这个简单的公式(Lerp,线性插值)赋予了粒子重量感和惯性。

三、Web AI 的启示:算力下沉与隐私红利

做完这个项目,我有几个关于 Web 技术演进的强烈感受。

1. 算力正在向端侧回流

在云计算主导的十年里,我们习惯了把一切丢给服务器。但 MediaPipe 这类 Web AI 技术的成熟,标志着端侧算力的觉醒。 现在的手机和笔记本显卡足够强大,可以在浏览器里跑百亿参数以下的小模型。 这意味着:零延迟的交互体验。你的手一动,粒子立马跟着动,不需要等待网络请求往返。

2. 隐私是最大的护城河

用户越来越介意"上传摄像头视频"。 纯前端实现的 AI 有一个天然优势:所有计算都在本地发生。 视频流只在内存里流转,从未离开过用户的设备。这种"隐私安全感"是云端 AI API 无法比拟的。

3. 创意的门槛在指数级降低

五年前,做这个效果需要懂 OpenCV、懂 C++、懂 Shader 编程。 现在,你只需要会写 JavaScript,调几个 npm 包。 技术在变得越来越平民化(Democratization),这让开发者能把精力从"怎么实现"转移到"做什么好玩的东西"上。

四、未来:还可以怎么玩?

这个 Demo 只是一个起点,它展示了 Web 交互的一种新范式——自然用户界面(NUI)

基于这个架子,我们完全可以扩展出更多玩法:

  • WebAR 营销:在电商页面,用手势"隔空"旋转商品模型;
  • 无接触展示:在博物馆或展厅的大屏上,观众挥手就能翻页、缩放展品;
  • 音乐可视化:让粒子不仅仅跟随手势,还随着背景音乐的频谱跳动;
  • 多人远程互动:结合 WebRTC,让异地的两个人通过手势共同控制同一个 3D 场景。

五、最后

项目已开源,欢迎 Fork 玩耍,或者直接点个 Star ⭐️。

GitHub: github.com/wangmiaozer…

Flutter PopScope:iOS左滑返回失效分析与方案探讨

本文发布于公众号:移动开发那些事:Flutter PopScope:iOS左滑返回失效分析与方案探讨

1 背景

Flutter 3.10版本中,WillPopScope被弃用并替换为更现代化、功能更强大的PopScope组件。PopScope主要用于拦截用户尝试退出当前路由的各种操作(如Android的返回键、AppBar的返回箭头、以及iOS的屏幕边缘左滑手势)。

PopScope的核心属性有两个:

  • canPop:一个布尔值,设置为 false 时,会阻止路由退出。
  • onPopInvokedWithResult:当尝试退出路由时触发的回调,并返回是否成功退出(即didPop)。

在开发过程中,笔者发现了一个显著的平台差异:

  • Android/其它平台: 当我设置 canPop = false 并尝试使用返回键或返回箭头时,onPopInvokedWithResult 被调用。
  • iOS: 当我设置 canPop = false 并尝试使用屏幕边缘左滑手势 (Edge Swipe Gesture) 时,onPopInvokedWithResult 不会被调用,并且左滑手势会被系统完全忽略,导致应用看起来“卡住”了,没有提供任何用户反馈。

这无疑是一个严重的问题,它导致我们在iOS上无法通过PopScope来捕获左滑手势并执行自定义逻辑(如弹出“是否保存”对话框)。

一个典型的使用示例为:

PopScope(
    canPop: false,
    onPopInvokedWithResult: (value, _) {
    // 在iOS平台这里永远不会被回调
        if (!value) {
          // do custom action here
          }
          return;
     },
     child: Container())

2 失效原因分析

2.1 PopScope失效原因

PopScope组件是通过 Platform Navigator 与原生平台的返回事件机制进行通信的, 主要负责监听和阻止 Navigator.pop() 调用或系统触发的 返回操作(Android 返回键,或者 iOS 的左滑返回)。

iOS 左滑有特殊性: iOS 的左滑手势(Interactive Pop Gesture)是独立于 Flutter Navigator 运行的原生手势。当这个手势被启动时,如果它发现 PopScope 设置了 canPop: false,它会简单地取消手势并停止,而不会向 Flutter 的 Navigator 发送一个明确的弹出(Pop)请求。

因此,onPopInvokedWithResult 回调自然不会被触发,因为它只在 Flutter 接收到 Navigator.pop() 调用或系统返回键事件时触发。

  • Android: Android的返回键事件(Back Button Event)是一个明确的、可拦截的系统事件,它会直接传递给Flutter引擎,引擎继而触发PopScope的回调。
  • iOS (左滑手势): iOS的左滑返回手势(Interactive Pop Gesture)是由UINavigationController管理的。

在默认情况下,当一个Flutter View(FlutterViewController)被推入到原生导航栈中时:

  • 如果设置了 canPop = false,Flutter引擎会通知原生层“当前路由不可退出”。
  • 在iOS上,这个通知只阻止系统尝试自动执行 Pop 操作,但没有将“用户进行了左滑手势”这一手势输入事件转化为一个通用的“Pop 尝试”事件并传给PopScope。相反,系统只是简单地禁用了这个手势的默认行为,并未向上层报告任何事件。

简而言之,Android传递的是一个“意图”(返回键被按下),而iOS传递的是一个“手势”,且当手势被禁用时,这个“意图”就从未产生。

2.2 源码分析

通过分析Flutter源码,我们发现问题的核心在于 ModalRoutepopGestureEnabled 属性判断逻辑。当 PopScope.canPop 设置为false 时,路由的popDisposition 会被设置为 RoutePopDisposition.doNotPop ,这会导致 popGestureEnabled 变为 false ,从而禁用iOS的侧滑手势识别。

具体来说,在 CupertinoBackGestureDetector 中,有一个 enabledCallback 回调用于控制手势检测器的启用状态。当 enabledCallback 返回 false 时,手势检测器不会记录触摸起点,后续的一系列手势监听方法都不会被触发。

3 如何解决?

要解决这个问题,我们不能等待Flutter引擎去捕获一个它根本没收到的事件。我们必须在原生iOS层工作,主动拦截这个左滑手势,并在拦截成功后,手动地将其作为一个事件发送给Flutter。

我们的目标是:当用户在屏幕边缘左滑时,即使canPopfalse,也要触发onPopInvokedWithResult。这里采用了在原生层面拦截左滑的手势,并传递给Flutter层的方案。

这里在原生的核心代码为:

  /// 如果需要,设置左滑返回手势拦截(在这个plugin初始化时调用)
  ///
  /// 该方法会检查 rootViewController 的类型:
  /// - 如果是 UINavigationController,直接使用
  /// - 如果是 FlutterViewController,会创建或使用现有的 NavigationController
  private func setupInteractivePopGestureIfNeeded() {
  guard let window = UIApplication.shared.windows.first,
         let rootViewController = window.rootViewController else {
                return
            }

        if let navController = rootViewController as? UINavigationController {
                // 直接使用现有的 NavigationController
                self.navigationController = navController
         } else if let flutterVC = rootViewController as? FlutterViewController {
                // 如果 FlutterViewController 已经有 navigationController,直接使用
                if let existingNavController = flutterVC.navigationController {
                    self.navigationController = existingNavController
                } else {
                    // 否则创建新的 NavigationController 并封装 FlutterViewController
                    // 先将 window.rootViewController 设置为 nil,避免视图层次冲突
                    window.rootViewController = nil
                    let newNavController = UINavigationController(rootViewController: flutterVC)
                    self.navigationController = newNavController
                    // 再设置新的 NavigationController 为 rootViewController
                    window.rootViewController = newNavController
                }
         }
    setupInteractivePopGesture()
  }


  /// 设置左滑返回手势的拦截
  ///
  /// 保存原始的手势识别器代理,然后将自己设置为新的代理,以便拦截手势事件
  private func setupInteractivePopGesture() {
    // 保存原始的代理
    self.originalDelegate = self.navigationController?.interactivePopGestureRecognizer?.delegate
    
    // 设置自己为代理
    self.navigationController?.interactivePopGestureRecognizer?.delegate = self
  }

    /// 控制手势识别器是否应该开始识别手势
  ///
  /// 当检测到左滑返回手势时,会通知 Flutter 层处理,并返回 false 阻止系统默认行为
  public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
    // 检测到系统左滑手势,发送事件给 Flutter  关键点:当发现是对应事件时,发送给Flutter层
    if gestureRecognizer == self.navigationController?.interactivePopGestureRecognizer {
      channel?.invokeMethod("onSystemBackGesture", arguments: nil)
      // 返回 false 阻止系统默认的返回行为,由 Flutter 层处理
      return false
    }
    
    // 其他手势交由原来的代理处理
    if let originalDelegate = self.originalDelegate {
      return originalDelegate.gestureRecognizerShouldBegin?(gestureRecognizer) ?? true
    }
    
    return true
  }

而在Flutter层,则监听对应的方法回调就可以:

  /// 处理来自原生端的方法调用
  Future<dynamic> _handleMethodCall(MethodCall call) async {
    switch (call.method) {
      case 'onSystemBackGesture':
        // 当接收到系统返回手势时
        debugPrint('PopscopeIos: onSystemBackGesture pop');
        // 1. 如果设置了自动处理导航,尝试调用 maybePop()
        if (_autoHandleNavigation) {
          final navigator = _navigatorKey?.currentState;
          if (navigator != null) {
          // 关键点2: 这里其实就是调用导航的api: Navigator.maybePop(context);
            await navigator.maybePop();
          } else {
            debugPrint('PopscopeIos: NavigatorState is null, cannot pop');
          }
        }
        
        // 2. 调用用户自定义回调(无论是否自动处理)
        _onSystemBackGesture?.call();
        break;
      default:
        throw MissingPluginException('未实现的方法: ${call.method}');
    }
  }

当调用了Navigator.maybePop(context)的方法后,不管你PopScope里的canPop的值设置为什么,对应的回调方法onPopInvokedWithResult都会被调用了;

这个库对应的代码放在github:github.com/WoodJim/pop…

4 总结

通过在原生iOS层面上介入,我们成功地将iOS的左滑手势事件手动转化为一个Flutter可识别的 Pop 事件,从而弥补了原生和框架之间的差异。

现在,无论是Android的返回键还是iOS的左滑手势,当PopScopecanPop设置为false时,onPopInvokedWithResult回调都能可靠地被触发,确保了跨平台一致的用户体验,让开发者能够真正无忧地处理路由拦截逻辑。

但这个方案有个致命的缺点:只能保证有回调,无法做到跟手的左滑效果,如果想做到很丝滑的跟手的处理,还是需要去研究CupertinoPageTransitionsBuilder 相关的代码来看如何实现,但当项目使用GetX来做路由时,好像不管怎样设置这个CupertinoPageTransitionsBuilder都无法生效。

如果大家有其他更好的方案的话,欢迎一起探讨。

CSS中常使用的函数

除了 clamp(),CSS 还有很多功能强大的函数。以下是分类介绍:

一、尺寸与计算相关函数

1. min()  - 取最小值

css

.element {
  width: min(50%, 500px); /* 取50%和500px中的较小值 */
  padding: min(2vw, 20px);
}

2. max()  - 取最大值

css

.element {
  width: max(300px, 50%); /* 至少300px */
  font-size: max(1rem, 12px);
}

3. calc()  - 数学计算

css

/* 基本计算 */
.element {
  width: calc(100% - 2rem);
  height: calc(50vh + 100px);
}

/* 复杂计算 */
.grid-item {
  width: calc((100% - 3 * 20px) / 4); /* 4列网格 */
}

/* 嵌套计算 */
.element {
  font-size: calc(var(--base-size) * 1.2);
}

4. fit-content()  - 内容适应

css

.container {
  width: fit-content(500px); /* 不超过500px的内容宽度 */
  margin: 0 auto;
}

/* 表格列自适应 */
td {
  width: fit-content;
}

二、clamp() 函数详解

语法

clamp(min, preferred, max)
  • min:最小值(下限)
  • preferred:首选值(通常使用相对单位)
  • max:最大值(上限)

工作原理

/* 实际值会在这个范围内:min ≤ preferred ≤ max */
width: clamp(200px, 50%, 800px);
/* 意思是:宽度最小200px,首选50%视口宽度,最大800px */

实际应用示例

1. 响应式字体大小

/* 字体在16px-24px之间,首选3vw(视口宽度的3%) */
.font-responsive {
  font-size: clamp(16px, 3vw, 24px);
}

/* 标题响应式 */
h1 {
  font-size: clamp(2rem, 5vw, 3.5rem);
}

2. 响应式容器宽度

.container {
  width: clamp(300px, 80%, 1200px);
  margin: 0 auto;
}

/* 图片自适应 */
img {
  width: clamp(150px, 50%, 600px);
  height: auto;
}

三、渐变与图像函数

1. linear-gradient()  - 线性渐变

css

/* 基本渐变 */
.gradient-1 {
  background: linear-gradient(45deg, red, blue);
}

/* 多色渐变 */
.gradient-2 {
  background: linear-gradient(
    to right,
    red 0%,
    orange 25%,
    yellow 50%,
    green 75%,
    blue 100%
  );
}

/* 透明渐变 */
.gradient-3 {
  background: linear-gradient(
    to bottom,
    transparent,
    rgba(0,0,0,0.5)
  );
}

2. radial-gradient()  - 径向渐变

css

/* 圆形渐变 */
.radial-1 {
  background: radial-gradient(circle, red, yellow, green);
}

/* 椭圆渐变 */
.radial-2 {
  background: radial-gradient(
    ellipse at center,
    red 0%,
    blue 100%
  );
}

/* 重复径向渐变 */
.radial-3 {
  background: repeating-radial-gradient(
    circle,
    red,
    red 10px,
    blue 10px,
    blue 20px
  );
}

3. conic-gradient()  - 锥形渐变

css

/* 色轮 */
.conic-1 {
  background: conic-gradient(
    red, yellow, lime, aqua, blue, magenta, red
  );
}

/* 饼图效果 */
.pie-chart {
  background: conic-gradient(
    red 0% 33%,
    yellow 33% 66%,
    blue 66% 100%
  );
}

4. image-set()  - 响应式图像

css

/* 根据分辨率加载不同图片 */
.responsive-bg {
  background-image: image-set(
    "image-low.jpg" 1x,
    "image-high.jpg" 2x,
    "image-ultra.jpg" 3x
  );
}

/* 格式支持 */
.optimized-bg {
  background-image: image-set(
    "image.avif" type("image/avif"),
    "image.webp" type("image/webp"),
    "image.jpg" type("image/jpeg")
  );
}

四、变换与动画函数

1. translate() / translateX() / translateY()

css

.element {
  transform: translate(50px, 100px);
  transform: translateX(100px);
  transform: translateY(50%);
}

/* 3D变换 */
.element-3d {
  transform: translate3d(100px, 50px, 20px);
}

2. scale() / scaleX() / scaleY()

css

/* 缩放 */
.element {
  transform: scale(1.5);          /* 整体放大1.5倍 */
  transform: scale(1.2, 0.8);     /* 宽放大,高缩小 */
  transform: scaleX(2);           /* 水平放大2倍 */
}

3. rotate()

css

/* 旋转 */
.element {
  transform: rotate(45deg);      /* 45度旋转 */
  transform: rotate(1turn);      /* 360度旋转 */
  transform: rotate3d(1, 1, 1, 45deg); /* 3D旋转 */
}

4. matrix() / matrix3d()

css

/* 矩阵变换(组合所有变换) */
.complex-transform {
  transform: matrix(1, 0, 0, 1, 100, 50);
  /* 等同于:translate(100px, 50px) */
}

5. cubic-bezier()  - 贝塞尔曲线

css

/* 自定义缓动函数 */
.animation {
  animation: move 2s cubic-bezier(0.68, -0.55, 0.27, 1.55);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

/* 预设曲线 */
.ease-in-out { transition-timing-function: ease-in-out; }
.custom-ease { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }

6. steps()  - 步进动画

css

/* 逐帧动画 */
.sprite-animation {
  animation: walk 1s steps(8) infinite;
}

/* 打字机效果 */
.typewriter {
  animation: typing 3s steps(40) forwards;
}

五、滤镜效果函数

1. blur()  - 模糊

css

.blur-effect {
  filter: blur(5px);
  backdrop-filter: blur(10px); /* 背景模糊 */
}

2. brightness()  - 亮度

css

.image {
  filter: brightness(150%); /* 变亮 */
  filter: brightness(50%);  /* 变暗 */
}

3. contrast()  - 对比度

css

.photo {
  filter: contrast(200%); /* 增加对比度 */
}

4. drop-shadow()  - 阴影

css

/* 比 box-shadow 更符合元素形状 */
.icon {
  filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.5));
}

/* 多重阴影 */
.text {
  filter: 
    drop-shadow(1px 1px 0 white)
    drop-shadow(-1px -1px 0 white);
}

5. 组合滤镜

css

.instagram-filter {
  filter: 
    brightness(1.2)
    contrast(1.1)
    saturate(1.3)
    sepia(0.1);
}

六、布局与网格函数

1. minmax()  - 网格尺寸范围

css

.grid-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}

/* 复杂的网格布局 */
.layout {
  grid-template-columns: 
    minmax(200px, 300px)
    minmax(auto, 1fr)
    minmax(150px, 200px);
}

2. repeat()  - 重复模式

css

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr); /* 重复3次 */
  grid-template-rows: repeat(auto-fill, minmax(100px, auto));
}

/* 命名网格线 */
.complex-grid {
  grid-template-columns: 
    [sidebar-start] 250px [sidebar-end content-start] 
    repeat(2, [col] 1fr)
    [content-end];
}

3. fit-content()  - 网格尺寸

css

.grid-item {
  grid-column: 1 / fit-content(500px);
}

七、其他实用函数

1. var()  - CSS 变量

css

:root {
  --primary-color: #3498db;
  --spacing: 1rem;
  --font-family: 'Roboto', sans-serif;
}

.element {
  color: var(--primary-color);
  padding: var(--spacing);
  font-family: var(--font-family);
  
  /* 默认值 */
  margin: var(--custom-margin, 10px);
}

2. attr()  - 属性值

css

/* 显示 data-* 属性值 */
.tooltip::after {
  content: attr(data-tooltip);
}

/* 配合计数器 */
.item::before {
  content: attr(data-index);
}

/* 动态样式 */
.progress {
  width: attr(data-progress percent);
}

3. counter() / counters()  - 计数器

css

/* 自动编号 */
ol {
  counter-reset: section;
}

li::before {
  counter-increment: section;
  content: counter(section) ". ";
}

/* 嵌套计数器 */
ol ol {
  counter-reset: subsection;
}

li li::before {
  counter-increment: subsection;
  content: counter(section) "." counter(subsection) " ";
}

4. url()  - 资源路径

css

/* 图片 */
.background {
  background-image: url("image.jpg");
  background-image: url("data:image/svg+xml,..."); /* 内联SVG */
}

/* 字体 */
@font-face {
  font-family: 'CustomFont';
  src: url('font.woff2') format('woff2');
}

/* 光标 */
.custom-cursor {
  cursor: url('cursor.png'), auto;
}

5. env()  - 环境变量

css

/* 安全区域(适配刘海屏) */
.safe-area {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

/* 视口单位 */
.full-height {
  height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
}

八、函数组合使用示例

复杂响应式设计

css

:root {
  --min-width: 320px;
  --max-width: 1200px;
  --fluid-scale: min(max(var(--min-width), 100vw), var(--max-width));
}

.container {
  /* 组合多个函数 */
  width: clamp(
    var(--min-width),
    calc(100vw - 2 * var(--spacing)),
    var(--max-width)
  );
  
  padding: var(--spacing);
  
  /* 响应式字体 */
  font-size: clamp(
    1rem,
    calc(0.5rem + 1vw),
    1.5rem
  );
  
  /* 响应式渐变背景 */
  background: linear-gradient(
    to bottom right,
    hsl(calc(220 + var(--hue-adjust)) 70% 50% / 0.9),
    hsl(calc(280 + var(--hue-adjust)) 60% 40% / 0.8)
  );
  
  /* 动态阴影 */
  box-shadow: 
    0 calc(2px + 0.1vw) calc(4px + 0.2vw) 
    color-mix(in srgb, currentColor 20%, transparent);
}

现代按钮组件

css

.button {
  /* 尺寸响应式 */
  padding: clamp(0.5rem, 2vw, 1rem) clamp(1rem, 4vw, 2rem);
  
  /* 颜色动态 */
  background: color-mix(
    in srgb, 
    var(--primary-color) 
    calc(var(--hover, 0) * 20%), 
    black
  );
  
  /* 交互效果 */
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  
  /* 悬停状态 */
  &:hover {
    --hover: 1;
    transform: translateY(calc(-1 * var(--hover, 0) * 2px));
    filter: brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2));
  }
}

十、浏览器支持与回退方案

渐进增强策略

css

/* 1. 基础样式 */
.element {
  width: 90%;
  max-width: 800px;
  min-width: 300px;
}

/* 2. 使用 @supports 检测支持 */
@supports (width: clamp(300px, 90%, 800px)) {
  .element {
    width: clamp(300px, 90%, 800px);
  }
}

/* 3. 使用 CSS 变量提供回退 */
:root {
  --fluid-width: 90%;
}

@supports (width: clamp(300px, var(--fluid-width), 800px)) {
  .element {
    --fluid-width: min(max(300px, 90%), 800px);
    width: var(--fluid-width);
  }
}

总结对比表

函数类别 核心函数 主要用途 兼容性
尺寸计算 clamp(), min(), max(), calc() 响应式尺寸 优秀
渐变背景 linear-gradient(), radial-gradient() 背景效果 优秀
变换动画 translate(), rotate(), cubic-bezier() 动画效果 优秀
滤镜效果 blur(), drop-shadow() 视觉效果 良好
网格布局 minmax(), repeat() 布局系统 优秀
变量函数 var(), attr(), counter() 动态内容 优秀

现代 CSS 函数大大增强了样式表达能力,减少了 JavaScript 的依赖,是构建现代响应式 Web 应用的重要工具。

js防抖技术:从原理到实践,如何解决高频事件导致的性能难题

在开发应用时,各位程序开发者们是否遇到一个性能问题,就是当用户网络卡顿时,点击一个按钮短时间内没反应后疯狂点击该按钮,频繁向后端发送请求,或者疯狂输入搜索框导致页面卡顿,频繁调整窗口大小导致重排重绘暴增等常见的性能问题。

一、 防抖的定义

此时聪明的你是否能想到一个解决方法,那就是,我们何不如设置一个定时器,当该事件被触发时,我们并不立即执行这个函数,而是等待一小段时间再触发,且当在等待的时间内没有被触发,便执行一次

想象你在坐电梯的时候,当你进入时,电梯不会立即关闭,而是会等待后面的人全部进入后再关闭(把每一次点击想象成按一次电梯按钮,每一次按按钮都会重置等待时间)

此时你就明白了解决频用户频繁发起请求导致的性能问题的解决方法:防抖

防抖 :在事件被频繁执行时,函数并不立即执行,而是在最后一次触发后等待指定的延迟时间,若延迟期间无新的触发,方才执行一次

二、防抖的实现逻辑

我们先假设在html界面中,我们添加了一个button按钮,且将其id命名为btn,此时我们想要为其添加一个点击事件,使其每点击一次便输出一次hello,我们需要写下如下js代码

1.实现具有打印功能的点击事件

const btn = document.getElementById('btn')
function ptn(){
   console.log('hello')
   }
btn.addEventListenner('click',ptn)

但是你会发现,将你带入疯狂点击该按钮的用户,你就会发现,此时前端会疯狂打印hello,若不能解决这个问题则不能解决相似的前端疯狂向后端发送请求的问题。

2.实现延迟打印(?不成功)

那你此时想到了有一个函数setTimeout(),诶,这个函数好像可以起到一种延迟的效果,或许可以添加请求的间隔?让我们看看

const btn = document.getElementById('btn')
function ptn(){
   console.log('hello')
   }
function debounce(fn,wait){
   return function(){
       setTimeout(function(){
          fn()
       
       },wait)
      }

   }   
   
btn.addEventListenner('click',debounce(ptn,1000))

此时你慢慢地点几次,你或许会发现,诶,我好像实现了这个功能,每过一秒才会打印一次hello,但是但是一旦你连续快速地的点击,你就会惊奇的发现。

啊?为什么在我停下来的时候还会继续打印hello,而且好像打印的间隔不可能有一秒

失败原因

没错,因为单纯使用setTimeout而未使用闭包来保持状态(若想了解闭包,请参考往期文章《js中作用域及闭包的概念与基础应用》导致每次点击都创建了一个打印事件,事件函数频繁触发,间隔时间无法保证,这种被称为防抖失效防抖函数未能实现

3.使用闭包解决未能更新定时器的

我们知道,闭包可以起到一个保存外部函数的变量,延迟销毁的作用

使用闭包处理后的代码
const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
   
      clearTimeout(time)
      
      time = setTimeout(function(){
      
         ptn() 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

经过检验后,你发现,你成功通过闭包实现了‘‘hello’’的延迟打印效果,同样的,也可以应用在解决用户大量提交等操作导致的大量计算或布局操作,节省了服务器的算力

扩展

一、添加防抖函数导致this本应的指向改变

上述代码虽然是完成了防抖的基本功能:‘频繁触发,只执行最后一次’,但是你或许不知道的是,这份代码仍有缺陷,试试仔细观察一下这份代码,添加防抖再运行后ptn内的this指向似乎发生了改变

没错,在btn.addEventListener('click',debounce(ptn,1000))的运行时,函数体被btn调用时,其内部的ptn函数在被调用时,是独立调用的,而我们知道,当一个函数被独立调用时,其this是指向window全局的。

但是但是,在我们添加这个防抖函数前,我们是这样实现这个点击事件的btn.addEventListenner('click',ptn),此时ptnaddEventListenner触发,出现隐式绑定,this应该是指向btn的。

而在我们添加防抖函数后,却导致ptn函数的this指向了window全局对象,那我们此时就做了一件很糟糕的事情,显然我们需要将它的this指回给btn

方法一

通过提前保存函数内this指向的btn,使用call函数控制其this指回btn

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
      const _this = this
   
      clearTimeout(time)
      
      time = setTimeout(function(){
         
         ptn.call(_this) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

方法二

通过利用箭头函数没有this对象,其内部的this是指向btn这一特点,使用call函数控制其this指回btn

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(){
   
      clearTimeout(time)
      
      time = setTimeout(() => {
         
         ptn.call(this) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

二、遗漏addEventListener提供的event事件参数

当我们使用addEventListener触发一个函数时,会默认向其内部传入一个事件函数event对象以及其它对象,用于记录事件发生的详情

而我们遗漏了向防抖函数内部接受这个event对象以及其它的对象,以及将其提供给原本被调用的ptn函数,此时我们需要为其接受这个event对象以及其它的对象,以及将其提供给原本被调用的ptn函数。

通过...arg接受及结构向其内部传递

const btn = document.getElementById('btn')

function debounce(ptn,wait){

   var time 
   
   return function(...arg){
   
      clearTimeout(time)
      
      time = setTimeout(() => {
         
         ptn.call(this,...arg) 
         
      },wait)   
   }
   
   }  
   function ptn(){
   console.log('hello')
   
   }
   
btn.addEventListener('click',debounce(ptn,1000))

到此,js防抖技术介绍为止,点赞+关注,后续继续提供实战演示题型

从爬楼梯到算斐波那契,我终于弄懂了递归和动态规划这俩 "磨人精"

最近在代码界摸爬滚打,总被两个词按在地上摩擦 —— 递归和动态规划。这俩货就像数学题里的 "小明",天天换着花样折磨人,今天就来好好扒一扒它们的底裤。

递归:自己调自己的 "套娃大师"

递归这东西,说白了就是函数自己喊自己的名字。就像小时候问妈妈 "我从哪来的",妈妈说 "你是妈妈生的",再问 "妈妈从哪来的",妈妈说 "外婆生的"... 一直问到祖宗十八代,这就是递归的精髓 —— 找规律 + 找出口

比如算个阶乘,用递归(时间复杂度过高)写出来是这样:

function mul(n){

   if(n === 1){ // 出口:问到祖宗了

       return 1

   }

   return n * mul(n-1) 

}

这代码简洁得像诗,但算个斐波那契数列就露馅了。那个 1,1,2,3,5,8... 的数列,递归写法看着简单:

递归的 "中年危机":重复计算让 CPU 原地冒烟

function fb(n){

   if(n === 1 || n === 2){

       return 1

   }

   return fb(n-1) + fb(n-2)

}

这代码算个 n=10 还行,要是算 n=40,能让你喝杯咖啡回来还没出结果。就像你查快递单号,每次都要从快递员刚取件的时候查起,哪怕昨天刚查过。

给递归装个 "备忘录":记忆化搜索救场

比如爬楼梯问题:一次能爬 1 或 2 阶,到第 n 阶有几种走法?

后来我灵机一动,给递归加了个小本本(数组 f):

const f = []
var climbStairs = function (n) {
    if (n === 1 || n === 2) {
        return n
    }
    if (f[n] === undefined) { // 查小本本,没记过才计算
        f[n] = climbStairs(n - 1) + climbStairs(n - 2)
    }
    return f[n]
};

这招叫 "记忆化搜索"(提效),相当于把算过的结果记在通讯录里,下次直接拨号不用重新查号。

后来才发现,这代码就像给老年机装了智能手机的通讯录 —— 思路对但效率不够。全局数组 f 在多组测试用例下会残留历史数据,而且递归调用本身就有函数栈的开销,n 太大时还是扛不住。

彻底换个活法:动态规划的 "自底向上" 哲学

  • 站在已知的角度,通过已知来定位未知 最后改用纯动态规划找到动态方程)写法,直接逆袭:
var climbStairs = function (n){
    const f = []
    // 先搞定已知的1楼和2楼
    f[1] = 1
    f[2] = 2
    // 从3楼开始往上爬,每步都踩在前人的肩膀上
    for(let i = 3;i<=n;i++){
        f[i] = f[i-1] + f[i-2]
    }
    return f[n]
 }

这思路就像盖楼,从 1 层开始一层层往上盖,每一层的建材都直接用前两层的,根本不用回头看。没有递归的函数调用开销,也没有重复计算,效率直接拉满。

总结:三种写法的生存现状

写法 特点 适合场景
纯递归 代码简洁如诗 理解思路用,n≤30
记忆化搜索 加了缓存的递归 教学演示,n≤1000
动态规划 自底向上迭代 实际开发,n多大都不怕

总结:什么时候该套娃,什么时候该记笔记?

  • 递归适合简单问题或调试时用,写起来爽,但容易重复劳动

  • 动态规划适合复杂问题,虽然前期要多写几行,但跑起来飞快

  • 记住:所有动态规划问题,先建个空数组当小本本准没错

现在终于明白,递归是浪漫的诗人,只顾优雅不管效率; 动态规划是务实的会计,每一笔账都记得清清楚楚。 下次再遇到这俩货,我可不会再被它们忽悠了!

柯里化

函数柯里化的含义:将多个参数的函数 转化成 一个一个传入参数的函数。

目的:函数参数复用延迟执行 它使用闭包记住之前传递的参数。

image.png

✅ 使用柯里化(参数复用

我们将函数改造一下,让它先接收“规则”,返回一个专门检查这个规则的函数。

// 柯里化:第一层接收规则,第二层接收内容
function curriedCheck(reg) {
    // 闭包记住了 reg
    return function(txt) {
        return reg.test(txt);
    }
}

// 1. 参数复用:我们先生成一个“专门检查手机号”的函数// 这里我们将 reg 参数固定(复用)在了 checkPhone 函数内部
const checkPhone = curriedCheck(/^1\d{10}$/);

// 2. 以后使用,只需要传内容,不需要再传正则了
checkPhone('13800000001'); // true
checkPhone('13800000002'); // true
checkPhone('13800000003'); // true// 甚至可以再生成一个“专门检查邮箱”的函数const checkEmail = curriedCheck(/@/);
checkEmail('abc@qq.com');

结论: 在这里,正则表达式这个参数被复用了。checkPhone 就像是一个被填入了一半参数的模具,你只需要填入剩下的一半即可。

延迟执行

onClickreact 渲染的时候就会 直接求值执行

react在渲染时,onclick会执行{}中的函数。 如果 onclick={handlerDelete(id)} 那么在渲染的时候直接就执行了这个函数,还没有点击就删除了。

所以使用匿名函数 or 柯里化

匿名函数 onclick={()=> handlerDelete(id)}

柯里化:

image.png

image.png

深入理解 JavaScript 中的 “this”:从自由变量到绑定规则

🧠 深入理解 JavaScript 中的 this:从自由变量到绑定规则

this 是 JavaScript 最容易被误解的概念之一 —— 它不是由函数定义决定的,而是由调用方式决定的。”

在日常开发中,我们经常遇到这样的困惑:

  • 为什么同一个函数,有时 this 指向对象,有时却指向 window
  • 为什么在事件回调里 this 是 DOM 元素,而赋值后调用就变成全局对象?
  • 严格模式下 this 为什么会变成 undefined

本文将结合你可能写过的代码,系统梳理 this 的设计逻辑、绑定规则与常见陷阱,并告诉你:如何真正掌控 this


🔍 一、this 不是“自由变量”——它和作用域无关!

很多初学者会混淆 变量查找this 绑定,认为它们是一回事。其实:

特性 自由变量(如 myName this
查找时机 编译阶段(词法作用域) 执行阶段(动态绑定)
查找依据 函数定义位置(Lexical Scope) 函数调用方式
是否受作用域链影响 ✅ 是 ❌ 否

来看一段典型代码:

'use strict'; // 严格模式
var myName = '极客邦'; // 挂载到 window

var bar = {
    myName: 'time.geekbang.com',
    printName: function () {
        console.log(myName);       // ✅ 自由变量 → '极客邦'(全局)
        console.log(this.myName);  // ❓ this 取决于怎么调用!
    }
};

function foo() {
    let myName = '极客时间';
    return bar.printName;
}

var _printName = foo();
_printName();      // this → undefined(严格模式)
bar.printName();   // this → bar
  • myName 是自由变量,按词法作用域查找 → 总是取全局的 '极客邦'
  • this.myName 的值完全取决于函数如何被调用

💡 关键结论:this 和作用域链毫无关系!它是运行时的“调用上下文”决定的。


🎯 二、this 的五种绑定规则(优先级从高到低)

1️⃣ 显式绑定(Explicit Binding)

使用 call / apply / bind 强制指定 this

function foo() {
    this.myName = '极客时间';
}
let bar = { name: '极客邦' };
foo.call(bar); // this → bar
console.log(bar); // { name: '极客邦', myName: '极客时间' }

最高优先级,直接覆盖其他规则。


2️⃣ 隐式绑定(Implicit Binding)

作为对象的方法调用 → this 指向该对象

var myObj = {
    name: '极客时间',
    showThis: function() {
        console.log(this); // → myObj
    }
};
myObj.showThis(); // 隐式绑定

⚠️ 陷阱:隐式丢失(Implicit Loss)

var foo = myObj.showThis; // 函数引用被赋值
foo(); // 普通函数调用 → this = window(非严格)或 undefined(严格)

这就是为什么 setTimeout(myObj.method, 1000) 会丢失 this


3️⃣ 构造函数绑定(new Binding)

使用 new 调用函数 → this 指向新创建的实例

function CreateObj() {
    this.name = '极客时间';
}
var obj = new CreateObj(); // this → obj

内部机制相当于:

var temObj = {};
temObj.__proto__ = CreateObj.prototype;
CreateObj.call(temObj);
return temObj;

4️⃣ 普通函数调用(Default Binding)

既不是方法,也没用 newcall → 默认绑定

  • 非严格模式this → window(浏览器)或 global(Node)
  • 严格模式this → undefined
function foo() {
    console.log(this); // 非严格 → window;严格 → undefined
}
foo();

🚫 这是 JS 的一个“历史包袱”:作者 Brendan Eich 当年为了快速实现,让普通函数的 this 默认指向全局对象,导致大量意外污染。


5️⃣ 箭头函数(Arrow Function)

没有自己的 this!继承外层作用域的 this

class Button {
    constructor() {
        this.text = '点击';
        document.getElementById('btn').addEventListener('click', () => {
            console.log(this.text); // ✅ 正确指向 Button 实例
        });
    }
}

✅ 箭头函数是解决“回调中 this 丢失”的利器,但不能用于需要动态 this 的场景(如构造函数、对象方法)。


🌐 三、特殊场景:DOM 事件中的 this

<a href="#" id="link">点击我</a>
<script>
document.getElementById('link').addEventListener('click', function() {
    console.log(this); // → <a id="link"> 元素
});
</script>

这是 addEventListener 的规范行为

回调函数中的 this 自动绑定为注册事件的 DOM 元素

但注意:

  • 如果用箭头函数 → this 不再是元素!
  • 如果把函数赋值给变量再调用 → 隐式丢失!

⚠️ 四、为什么 this 的设计被认为是“不好”的?

  1. 违反直觉:函数定义时看不出 this 指向谁。
  2. 容易出错:隐式丢失、全局污染频发。
  3. 依赖调用方式:同一函数,不同调用,this 不同。

正因如此,ES6 引入了 class 和箭头函数,弱化对 this 的依赖


✅ 五、最佳实践建议

场景 推荐做法
对象方法 用普通函数,避免赋值导致隐式丢失
回调函数(如事件、定时器) 用箭头函数,或 .bind(this)
构造函数 用 class 替代传统函数
需要强制指定上下文 用 call / apply / bind
避免全局污染 使用严格模式 + let/const

🔚 结语

this 并不神秘,它只是 JavaScript 动态绑定机制的一部分。理解它的核心在于:

“谁调用了这个函数,this 就是谁。”

掌握五种绑定规则,避开隐式丢失陷阱,你就能在任何场景下准确预测 this 的指向。

最后记住:现代 JavaScript 已经提供了更安全的替代方案(如 class、箭头函数),不必死磕 this —— 但你必须懂它。


📚 延伸阅读

  • 《你不知道的JavaScript(上卷)》—— “this & 对象原型”章节
  • MDN: this
  • ECMAScript 规范:Function Calls

欢迎在评论区分享你踩过的 this 坑! 👇
如果觉得有帮助,别忘了点赞 + 关注~ ❤️


标签:#JavaScript #this #前端 #作用域 #掘金

Python避坑指南:基础玩家的3个"开挂"技巧

刚学会Python基础,写代码还在靠 for+append 凑数?别慌!这几个进阶偏基础的知识点,既能让代码变优雅,又不搞复杂概念,新手也能秒上手~

1. 推导式:一行搞定列表/字典(告别冗余循环)

还在这样写循环添加元素?

  
# 传统写法
nums = [1,2,3,4,5]
even_squares = []
for num in nums:
    if num % 2 == 0:
        even_squares.append(num**2)
print(even_squares)  # 输出: [4, 16]

 

试试列表推导式,一行搞定,逻辑更清晰:

  
# 列表推导式
nums = [1,2,3,4,5]
even_squares = [num**2 for num in nums if num % 2 == 0]
print(even_squares)  # 输出: [4, 16]

 

字典推导式也超实用,快速构建键值对:

fruits = ["apple", "banana", "cherry"]
fruit_len = {fruit: len(fruit) for fruit in fruits}
print(fruit_len)  # 输出: {'apple':5, 'banana':6, 'cherry':6}

 

2. 解包操作:变量交换/多返回值的优雅姿势

交换变量不用临时变量,解包直接拿捏:

a, b = 10, 20
a, b = b, a  # 一行交换,无需temp
print(a, b)  # 输出: 20 10

 

函数多返回值接收更简洁,还能忽略无用值:

def get_user_info():
    return "张三", 25, "北京"

name, age, _ = get_user_info()  # _ 忽略不需要的字段
print(f"姓名:{name},年龄:{age}")  # 输出: 姓名:张三,年龄:25

 

3. F-string:格式化输出的"天花板"

告别繁琐的 % 和 format ,F-string直观又强大:

score = 92.345
name = "李四"
# 直接嵌入变量,支持格式控制
print(f"{name}的成绩:{score:.1f}分")  # 输出: 李四的成绩:92.3分
print(f"及格率:{score/100
❌