普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月9日首页

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

2025年12月9日 00:00

给你一个整数数组 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. 统计特殊三元组

作者 stormsunshine
2025年6月15日 13:09

解法

思路和算法

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

数组 $\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)

作者 endlesscheng
2025年6月15日 12:07

方法一:枚举中间

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

  • 枚举 $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年12月8日首页

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

2025年12月8日 22:27

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 基础

2025年12月8日 22:25

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

参考资料

融资丨「拓元智慧」宣布完成数亿元Pre-A轮系列融资

近日,「拓元智慧」宣布完成数亿元Pre-A轮系列融资,引入上市公司东方精工、星宸科技、金牌家居关联基金德韬资本、石溪资本、数聚乘等多家战略及产业投资方,粤科金融等重量级国资投资平台,鹏城愿景、红鸟启航、领屹投资等科研机构基金。本轮融资资金将主要用于物理空间智能模型的研发投入、赋能模型的物理推理及跨场景迁移能力、构建具身生态并加速相关产品的商业化落地。

物理空间建模难、泛化难,传统模型制约人工智能迈向“物理空间智能”

当前人工智能正处于从“数字智能”迈向“物理智能”的关键转折点。大语言模型虽然在文本推理与知识处理上取得突破,但在理解真实物理空间、进行连续动作规划以及与环境实时交互方面仍然存在根本性缺陷。这类缺陷直接限制了人工智能向具身智能、物理世界智能体、机器人等更高层次能力的跨越。

以一个简单的真实场景为例:给定“门宽 80 cm、桌子宽 50 cm、人的肩宽 55 cm”的描述,当前的语言模型往往会逐项比较数字并认为“都比门窄,因此可以一起通过”,完全忽略两者并排时的组合宽度、旋转带来的投影变化、姿态调整的约束以及物体之间的不可穿透性等基本物理规律。这样的错误不只是知识缺失,而是缺乏真正的物理空间理解能力,凸显了当前 AI 无法成为可靠的物理世界参与者的根本原因。

这些基础能力缺失在真实世界中表现得尤为明显。由于模型无法准确理解空间结构与几何关系,机器人往往在执行简单任务时也会出现“对不准、抓不到、绕不开、走不直”等失败模式。例如,在抓取任务中,机械臂可能因为误判目标位置而多次空抓,或在移动时与桌角、墙面发生轻微碰撞,体现出对距离、可达性和避障条件的误估。在更复杂场景中,模型甚至会生成违反物理规律的行为规划,如要求机械臂穿过障碍物、让移动平台驶向不可通行的窄隙、在倾斜平面上输出不稳定的轨迹等。此外,这些系统高度依赖训练场景,当光照变化、物体位置轻微移动或视角发生偏差时,其性能会显著下降——同一指令在不同场景中的执行结果可能截然不同,表现出缺乏鲁棒性与泛化能力。真实世界中这些直观的失败,不仅不符合物理常识,也严重制约了机器人在开放环境中的实际使用。

导致上述现象的根本原因,源于当前视觉—语言—动作模型(VLA)的技术瓶颈。尽管 VLA 被视为虚拟智能通向物理世界的关键桥梁,但其架构内部仍存在无法回避的缺陷,哪怕引入世界模型也难以彻底解决。其一,VLA 通常将视觉输入先压缩到语言 token 空间,这一过程天然会丢失连续空间中至关重要的几何、拓扑与物理量信息,使模型难以理解精确位置关系,从而在动作控制上产生偏差,甚至输出违背物理约束的操作序列。其二,VLA 的泛化能力极为有限。真实世界具有高度复杂性与多样性,而具身智能又对视角变化、环境布局、物体遮挡及动态条件极度敏感。这些因素耦合在一起,使得 VLA 模型很容易在训练场景中表现良好,却无法迁移到新环境中。一旦背景变化、光照不同或物体位置发生微小偏移,模型的感知—推理—动作链条就可能彻底瓦解。这些技术限制共同导致了当前 AI 在物理空间中能力不足的本质困境。

拓元智慧:研发新一代物理空间智能引擎,持续突破AI模型从数字空间走入物理世界的能力瓶颈

「拓元智慧」是鹏城实验室智算生态构建的首批企业,总部位于深圳。公司以全新的物理空间智能引擎为核心创新动力,率先推出 VWA(Vision-World-Action) 模型,全面突破传统 VLA 模型的局限,真正实现对物理世界的精准理解与高可靠行动。同时,公司自主研发的在线高效适应技术,使模型具备类似“自检”的能力,即插即用、随场景即刻适配,进入全新物理环境,都能快速掌握任务、稳定发挥,实现前所未有的强泛化与高可靠性。

基础模型创新:VWA模型突破VLA模型的物理空间建模局限性

「拓元智慧」提出了一种全新的 VWA 架构,用以替代现有的 VLA 模型,从根本上提升物理空间交互的精度。与传统方法必须将视觉信息压缩到语言 token 空间不同,公司彻底摒弃这一信息损失严重的路线,转而直接在物理空间进行推理与决策。通过构建面向真实世界的 Physical World Model,使模型能够在连续物理空间中进行多步 roll-out、预测未来状态变换,从而在规划、安全评估与稳定控制方面迈出关键一步。

公司推出的物理自回归模型(Physical Autoregressive Model, PAR),让具身智能实现对于物理世界规律的高效学习。该模型通过将视频帧与机器人动作共同编码为“物理token”,使得模型能够以自回归方式逐步预测下一步视频与动作,形成“预测—执行—再预测”的闭环。尤为关键的是,PAR模型在无需进行动作预训练的前提下,即可有效学习物理世界的动态规律,在机器人操作基准ManiSkill的PushCube任务上实现了100%的成功率,并在多项任务中媲美需动作预训练的强基线模型。这一成果显著推进了从大规模视频预训练模型向真实世界机器人操控能力迁移的技术路径,为构建具备通用物理常识的具身智能奠定了重要基础。

在底层推理机制上,公司提出了全新的 Tweedie Framework,显著提升动作控制的准确性;同时引入高效的 Eon 计算机制,大幅增强模型的运行效率与长序列建模能力。两者结合,为构建更可靠、更智能、更具泛化能力的物理空间智能奠定了坚实基础。

在数据层面,「拓元智慧」引入多源且高质量的物理数据,为模型构建真实、稳定、可泛化的物理空间能力奠定基础。(1)具备空间信息的真实人类抓取及自然场景数据:基于真实业务场景采集的数十亿级双目与多目视觉数据,覆盖多种真实环境和多样化任务场景,具备高度一致的空间结构信息与自然连续的人类动作轨迹。相较于现有以仿真或摆拍为主的数据,这类真实任务数据在规模、多样性与真实性上均具有显著优势,并通过丰富的 3D 空间线索,支持对海量物体进行精细的空间理解与语义解析。(2)训练场仿真数据:依托虚实孪生的具身智能训练场,通过高保真 3D 物理环境重建与逼真的物体资产构建,生成大规模物理仿真数据与仿真遥操作数据,为模型提供可控、可扩展、可重复的训练条件。

场景泛化创新:基于物理建模与空间建模解耦的超轻量在线适应技术

针对当前模型普遍缺乏泛化性的关键瓶颈,「拓元智慧」提出了将整体能力解耦为“物理建模”与“空间建模”两大模块的新范式。通过这种解耦方式,模型能够获得高度通用、跨环境稳定的物理建模能力;而真正影响泛化性的部分,仅存在于对具体场景的空间建模上。

这一机制与人类在操控陌生环境中的机器人时的行为高度一致:人类并非天然具备“泛化性”,而是依靠在新环境中快速适应空间布局来完成任务。在我们提出的新模型中,只需对空间建模部分进行轻量级适配,而无需重新调整底层的通用物理建模模块。更重要的是,这种适配所需的数据极少(甚至只需一条示例数据),所涉及的参数规模也极小(例如在数百亿参数的模型中,仅更新约 4000 个参数)。模型便能在新环境中实现在线快速适配,如同进行一次“自检”般简单高效。以家庭机器人为例:一个能够在我家完成家务的机器人,被拿到你家后,无需重新学习物理规律,只需对新的空间布局进行快速建模,就能立即投入使用。

这一能力极大提升了模型在真实世界中的泛化性与可部署性。

核心团队:依托鹏城实验室和中山大学,组建以青年领袖科学家为首的顶尖研发团队

「拓元智慧」由国内外顶尖AI专家团队创办,在人工智能领域具有广泛的国际影响力。依托鹏城实验室和中山大学的两大研究基地,成立了“拓元智慧-中山大学”联合实验室,组建了一支由AI领域青年领袖科学家王广润博士(华为天才少年计划最高级别入选者)、国家级青年人才王可泽博士(吴文俊人工智能科学奖得主)、中大-拓元联合实验室负责人梁小丹博士(阿里巴巴青橙奖得主)领衔的高水平研发团队。

王广润博士,国家海外高层次青年人才基金和华为战略人才基金获得者、拓元智慧首席科学家、中山大学计算机学院青年研究员、博士生导师。曾入选华为天才少年计划的“最高级别”。广润博士曾在英国牛津大学担任Research Fellow,合作导师为英国皇家科学院院士、英国皇家工程院院士Philip H.S. Torr教授。主要从事新一代 AI 架构、大物理模型与世界模型、多模态生成式 AI等方向研究。获吴文俊人工智能优秀博士论文、《Pattern Recognition》最佳论文、全球 AI 华人新星榜等荣誉;在多个国际竞赛中获得金牌;担任多个CCF A会议领域主席。其研究工作曾被用于支撑 LeCun 的相关论文论证。组建"拓元智慧-中山大学"联合实验室,持续探索物理空间智能模型的前沿领域,引领人工智能走向“物理空间智能”的技术变革。研发成果在智能零售场景和具身训练场中实现规模化使用。

王可泽博士,国家级青年人才、国家海外优青获得者、拓元智慧高级算法总监、中山大学计算机学院副教授、博士生导师。曾获吴文俊人工智能自然科学奖、人工智能学会CAAI优秀博士论文奖,入选AI 2000全球最具影响力学者榜单。王可泽博士曾在在美国加州大学洛杉矶分校从事博士后研究,聚焦多模态大模型、多智能体、因果推理和长程规划等相关领域研究。作为国家科技创新2030"因果推理与决策理论"重大项目核心成员,提出基于因果一致性的大模型推理方法,应用于大模型优化和具身长程推理等核心难题场景。在Cell子刊、PAMI、CVPR、ICCV、NeurIPS等会议发表论文50余篇,被引用近4000次。

科研和产业资本联合加注,协同加速拓元智慧战略发展

本轮投资方均在其专注领域拥有深厚的资源背景与战略布局,其联合投资彰显了对拓元智慧技术与发展前景的高度认可。

产业资本方面,公司本轮融资获得多家上市公司及其关联资金与资源的协同支持。

• 东方精工聚焦高端智能装备制造龙头企业。目前,东方精工以“构建具身智能机器人全产业链生态、赋能传统产业智能化升级”为核心,前瞻性布局人工智能+具身智能机器人赛道,已形成覆盖机器人本体制造、多模态大模型智能大脑端研发、应用场景拓展的全产业布局。

• 星宸科技是全球领先的视觉AI SoC芯片设计企业,视觉AI SoC全球市占率第一(出货量第一)、机器人视觉AI SoC市占率位居全球第二。基于“视觉+AI”的核心框架及“感知+计算+连接”的核心能力,专注为智慧视觉、智慧出行、智能机器人、智能家居、智能办公、智能工业等端边侧设备提供 AI SoC 解决方案。

• 德韬资本是金牌家居(603180.SH)及建潘集团的产业投资平台,围绕“泛家居产业互联网生态平台”布局战略投资,聚焦泛家居产业链、人工智能、机器人、智能家居、工业互联网等领域,致力深耕产业提升价值,加强产业科技孵化、赋能服务产业链、培育产业细分龙头、建设泛家居产业生态、打造泛家居产业互联网。目前管理6只基金,以“资本+产业+科技+平台”模式驱动产业发展。na zhe

• 石溪资本由集成电路存储龙头企业与投资团队发起设立,长期聚焦于硬科技等前沿领域的投资,在半导体、人工智能等领域有着广泛布局,通过产业资源对接、技术赋能等方式助力被投企业成长。石溪资本管理多达十余支基金,目前累计投资项目近60个,其中多家企业已成功上市。

国资投资平台与科研机构基金同样高度认可公司的技术先进性与行业地位。

• 粤科金融集团是广东省属国有创投平台,也是国内首家省级科技金融集团。该集团以服务广东科技创新和产业升级为核心,重点布局新一代信息技术、高端装备制造、生物医药、新材料、人工智能、低空经济等战略性新兴产业。目前,粤科系基金规模超1400亿元,形成了涵盖政府引导、市场运作、战略新兴及跨境投资的多元化基金体系,累计为3000多家科技企业提供投融资服务,并推动184家企业实现IPO上市。

• 鹏城愿景基金是由鹏城实验室发起的国家战略科技力量成果转化基金,以”政府引导基金+社会资本+专业投资机构”模式组建,秉持”投早、投小、投科技”理念,聚焦宽带通信、新型网络、人工智能半导体硬科技、数字制造等领域,重点围绕鹏城实验室创新链、产业链上下游布局投资,重点瞄准种子期和初创期项目。

• 红鸟启航基金是由港科大校友与香港科技大学强强联合发起设立的天使投资基金,专注于人工智能及应用、具身智能、脑机接口、新能源/新材料领域前沿领域的天使阶段投资。基金的核心优势在于深度融合香港科技大学顶尖的科研实力,和管理团队深厚的产业+资本市场经验,借助香港科技大学的科研平台,基金直通全球前沿的实验室与创新成果,为基金提供了尖端的项目来源、前瞻性的技术洞察以及强大的专家智库支持。 

• 领屹投资致力于成为全球视角下的推动科技进步和产业升级的新锐投资及服务机构,由港科大校友创立,并与香港科技大学在多方面达成富有成效的合作,在投资基金决策、科技成果转化、创新孵化、产教融合等方面开展全方位互动,形成了“技术+孵化+投资”的闭环协同经验,矢志为科学企业家打造通往创业成功的坚实道路,专注于推动科学技术创新与产业化深度融合。

本轮融资汇聚了多元背景的投资方,形成了独特的协同效应,这种“产业方+国资+科研基金”的组合,将为拓元智慧带来技术、产业链、市场、政策与科研资源的全方位支持。

投资方观点

星宸科技投资团队表示,拓元专注于新一代物理AI技术的研发,其核心目标在于推动人工智能从文本、图像等模态的认知理解,进一步延伸至对三维物理世界的深度感知与交互。该能力被视为实现更高级别人工智能尤其是具身智能的关键路径。拓元技术方向与我司在智能机器人等领域所开发的SoC平台芯片高度互补,具备显著的协同潜力。通过将拓元的物理AI能力与我司专用软硬件深度融合,双方可共同构建“云-边-端”协同的典型应用场景,实现从感知、决策到控制的全链路智能化闭环。

石溪资本管理合伙人朱正表示,拓元独特的“感知-推理-规划”一体化架构,不仅突破了传统Transformer在长序列任务中的算力瓶颈,更通过物理语义对齐技术,首次让AI在复杂环境中实现了“想象式决策”。这种以无损压缩和线性复杂度优化为核心的技术突破,正是我们看好其商业化潜力的关键——让机器真正理解而非仅仅响应世界。

查看更多项目信息,请前往「睿兽分析」。

寻找中国经济破局之路,和讯财经中国2025年会圆满落幕

2025年12月7日,由联办集团、和讯共同主办、财经中国会承办的财经中国2025年会暨第23届财经风云榜在北京圆满落幕。据悉,本届年会以 “寻找中国经济破局之路”为主题,邀请了包括黄奇帆、李扬、吴晓求、姚洋、李稻葵等在内的二十余位国内顶尖经济学者和政策智囊,围绕“十五五”规划展望、宏观经济走向、科技创新、资本市场生态重塑、全球变局下的资产配置等前沿议题展开深度对话。

图:和讯网董事长章知方

和讯网董事长章知方在致辞中表示,2025年以来,以DeepSeek人工智能普及、LABUBU全球走红、《哪吒2》票房创纪录、上证指数突破4000点为代表的现象,凸显出中国在科创与文化产业领域的旺盛活力。但宏观层面仍面临发展不平衡不充分、新旧动能转换艰巨、就业与居民收入增长承压、重点领域风险隐患尚存等多重挑战。

他认为,破局之路,还在解放生产力。要打造技术创新驱动的新质生产力,更要充分调动民营企业和地方政府的积极性。民营企业是吸纳就业的主力军、经济发展的稳定器。如何提升5800万民营企业的生产力和创造力,强化对民营企业的司法保护,让民营企业家敢闯敢干,仍是一个亟待破局的重要课题。

“十五五”规划解读与2026年展望

图:国家创新与发展战略研究会学术委员会常务副主席黄奇帆

有关中国城市化发展,国家创新与发展战略研究会学术委员会常务副主席黄奇帆指出,中国的城市化可以分为两个阶段。一个是1980-2020年,40年改革开放推动了4亿多农民进城落户,使户籍人口城市化率从18%变成了38%,这是1.0版本。现在是2.0版本,目标是进一步推动30个百分点的人口落户,从48%变成78%。黄奇帆认为,如果达到2.0版城市化的目标,最终会实现中国城乡的两个双向融合。

图:十三届全国政协经济委员会副主任、国务院发展研究中心原副主任刘世锦

十三届全国政协经济委员会副主任、国务院发展研究中心原副主任刘世锦指出,在经历了三十多年的高速增长后,中国经济从2010年一季度开始由高速逐步转向中速,经济增长的主要挑战由供给约束转为需求约束,而需求不足主要不是投资和出口不足,而是消费不足。 “十五五”是我国经济增长主要由投资和出口驱动转向创新和消费驱动的关键时期,刘世锦认为因此有必要研究扩大消费、改善民生与经济增长、创新驱动的内在逻辑的实现机制。

图片从左至右:姚洋、邱晓华、梁红、李迅雷

谈及2026年的发展预期,上海财经大学滴水湖高级金融学院院长、北京大学客座教授姚洋认为,只有多管齐下,通过有效手段稳住房地产市场与地方政府的基本盘,中国经济的短期需求才能得到有力支撑,并为恢复增长活力奠定基础。同时,国家统计局原局长、阳光保险集团顾问邱晓华则在分析了支撑经济实现目标的三大因素后,指出了当前经济面临的两大突出问题,一是物价持续低位运行,二是国内有效需求不足,如何对冲房地产投资下降带来的影响,将是下一步需重视的问题。

对于这一问题,中泰国际首席经济学家、中国经济学家论坛副理事长李迅雷指出,关于城乡结构问题、收入结构问题、产业结构问题,不是中国一个国家的问题,全世界各个国家都有,但是我们面临的是房地产周期和结构性问题叠加在一起。对于今后的发展路径,李迅雷提出应坚持科技引领,大力促进高科技产业增长;在此基础上,还要调结构,加大改革的力度。华泰证券机构业务委员会主席梁红则表示,当前经济增长主要源于生产效率提升,这为人民币资产重估及汇率走强奠定了基础。这种增长质量的改善,在中国企业全球化与转型升级的进程中,势必会引发对人民币资产的重新估值,为人民币汇率走强提供坚实的基本面支撑。

从货币转型、人工智能之变到资产配置、资本市场之思

图:中国社会科学院学部委员、国家金融与发展实验室理事长李扬

会上,有关低利率挑战与货币转型议题,中国社会科学院学部委员、国家金融与发展实验室理事长李扬表示,利率下行可能成为未来一段时期我国金融运行的常态。应对低利率的挑战,将成为我国金融业的主要任务之一 。与此同时,李扬认为中国的货币政策开始关注资产价格,中国货币政策的范式已经发生变化。他指出,随着有效需求不足的局面的延续和财政赤字和增发债务的常态化,央行“入市”买卖国债,并借以实施货币政策,今后将更加系统化、常态化。

图:中国社会科学院学部委员、中国社会科学院前副院长蔡昉

在AI时代,就业结构性矛盾该如何应对?中国社会科学院学部委员、中国社会科学院前副院长蔡昉表示,“AI将把结构性就业矛盾变为加强版和新常态,故更好更有力度应对结构性矛盾是AI政策正确方向,既要找准症结、运用合理政策手段,也要实现必要的范式转变。”他指出,仅在模型环节解决“对齐问题”并非出路,不仅向谁看齐、何为圭臬问题难解决,甚至出现“伪对齐”,“AI+”既是应用领域,也是对标基点。正确做法是在技术政策和产业政策层面,通过规制、激励、惩罚、劝善等手段,让AI开发者、实验室、投资者、用户都以特定要求和做法“对标”就业优先战略。

图片从左至右:宋劼、姚新、蔡洪平、段楠、舒文琦

在人工智能发展未来十年图景的相关巅峰对话环节,香港工程院院士、IEEE计算智能学会前主席姚新表示,AI对社会的影响绝不仅仅是一个技术领域的问题,真正对社会范式和真正思维范式是一个极大的冲击。姚新特别指出,以大模型为代表的AI发展路径,已引发出数据使用与回报的经济学新议题。而针对人形机器人当前的发展热潮,与市场普遍持有的乐观态度不同,汉德资本主席兼创始合伙人、前德银投行亚太区主席蔡洪平对此有不同的理解。蔡洪平指出,类似特斯拉FSD这类专注于特定场景(如自动驾驶)的技术,已能通过摄像头、算力与算法实现对环境的有效响应。但是,“如果真的把‘人形’这个不确定的场景、互动的场景、情感的场景,这个精细的场景要变成一个算力算法,我们今天软件都没有,不要说算力算法了,我认为至少十年里面,这个东西还早。”他表示。

对于人工智能发展未来十年图景这一相关议题,京东集团副总裁段楠则表示,目前,AI的多模态技术方向,处在方兴未艾的阶段,涉及到端到端的场景,随着多模态对于视觉、语言的理解,推理和生成的结果越来越佳,并产生颠覆性影响。未来人工智能将从数字世界走向物理世界,一定要基于辅助硬件和真实的环境产生交互,如何持续不断提供辅助,完成危险和重复的任务,解放人类,这也是目前研究的关键。而在谈到AI内容创作趋势上,腾讯云资深专家舒文琦认为,未来内容传播和承载的形态,将向着多模态转变。从报纸、电视到现在的短视频、长视频,甚至于元宇宙,越来越多的多模态将触达到人。舒文琦还提出,针对于企业的营销,AI最大的变化就是从“触达”变成“触动”,以前很多企业花钱去买流量,但是转换率不一定高,但是有了人工智能、智能体以后,能帮助企业将内容触动到其所关注、关心的用户群体里,进而提升转化率。

图片从左至右:王庆、邢自强、洪灏

在全球变局下的资产配置这一巅峰对话环节,重阳投资董事长王庆表示,中国资本市场与实体经济表现并不同步,去年“9·24”成为一个至关重要的拐点。未来行情将转向个股业绩驱动,A股可能进入“慢牛”新阶段。他提出,投资者应吸纳不同视角,构建一个包含“多个独立的Alpha来源”的投资组合。摩根士丹利中国首席经济学家邢自强则表示,历史上每一轮科技革命都必然伴随过度投资,这是规律使然。当前人工智能(AI)热潮亦不例外,但历史表明其最终将对全球生产率产生深远提振。莲华资产管理有限公司管理合伙人洪灏则较为直接表示,“美股肯定是先涨后跌了”。

图:国家一级教授、中国人民大学国家金融研究院院长、中国人民大学原副校长吴晓求

值得一提的是,国家一级教授、中国人民大学国家金融研究院院长、中国人民大学原副校长吴晓求还谈到重构中国资本市场生态链,他认为重构的逻辑就是从过去的以融资者为中心的理念过渡到投资者权益保护。

以先锋榜样推动中国产业经济高质量发展,和讯第23届财经风云榜揭晓

值得注意的是,年会现场还揭晓了第23届财经风云榜,和讯致力于以专业、客观的财经媒体视角观察市场主体,票选出对中国经济及所属行业发展做出突出贡献的企业,通过树立标杆进一步推进中国经济及行业高质量发展。

如今,财经风云榜已连续成功举办二十三届,累计吸引超过千家企业参与评选,是中国财经领域极具公信力的评选之一。依托和讯网二十余年财经媒体积淀的行业公信力,该榜单被业界誉为“中国下一年财经动态的风向标”,往届获奖者均在评奖周期内实现品牌声量与业务创新的双突破。

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

作者 大怪v
2025年12月8日 21:22

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

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

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/…

Model YL 平替,奔驰新一代 GLB 发布,告别 1.3T 发动机

作者 芥末
2025年12月8日 21:00

梅赛德斯-奔驰于今日正式发布了新一代的 GLB 车型,这也是继纯电 CLA 之后,第二款采用 MMA 架构的车型。

MMA 平台是奔驰专为紧凑级车型开发的新一代模块化架构,其最大亮点在于「双模兼容」设计:既能支持纯电驱动,也可搭载轻混内燃机系统。这种灵活性,使奔驰在应对欧洲日益严苛的排放法规的同时,也能更好地满足全球不同市场的多样化需求。

按照规划,新一代 GLB 将于 2026 年初率先登陆欧洲市场。燃油版起售价约为 4.1 万英镑(约合 38.6 万元人民币),纯电版则接近 5 万英镑(约合 47.1 万元人民币)。

更长更舒适

作为目前 MMA 家族中尺寸最大的成员,新一代 GLB 提供五座和七座两种座椅布局。其车身长宽高分别为 4732mm / 1861mm / 1687mm,轴距达到 2889mm,相较前代增加了 60mm。与前代 EQB 相比,驾驶员和前排乘客的最大头部空间增加了 35mm,后排第二排和第三排的乘坐空间分别增加了 64mm 和 10mm。

作为对比,同样拥有三排座椅的特斯拉 Model YL 的长宽高分别为 4976 / 1920 /1668mm,轴距为 3040mm,考虑到 Model YL 的第三排空间已经只是堪堪够用的水平,新一代 GLB 的三排乘坐体验想必称不上有多舒适。

对此,奔驰官方称,「GLB 的三排可舒适容纳身高 168 厘米以下的乘客,配合更大的车门开口和可调节的第二排座椅,进出便利性有所改善。」

不过轴距的增加还是为新一代 GLB 带来了更充裕的腿部和头部空间,车辆的第二排座椅支持纵向滑动,这让车内的装载空间变得极为灵活。

储物方面,新一代 GLB 配备了一个 127 升的前备厢,是奔驰新家族中最大的,足够放下一个小型帐篷或一箱饮料。后备厢在五座/七座状态下分别拥有 540/480 升的容积,放倒后排座椅后,可扩展至的 1715/1605 升。

「浓浓smart 味儿」

外观上,新一代 GLB 延续了平直腰线、方正车顶、外扩轮拱的设计语言,加上黑色塑料防擦条和裸露式下护板,强化了「硬派感」。

电动版与燃油版在前脸细节上有所区别,电动版毫不意外的采用封闭式面板,嵌入 94 颗可编程 LED「星徽」,支持迎宾/送别光效;燃油版则保留传统镀铬星阵格栅并搭配了发光边框。车尾则统一使用集成动态动画效果的贯穿式灯带。

此外,奔驰也没有忘记给 GLB 的天窗玻璃加上 158 颗星星闪闪发光的三叉星辉,这一源自纯电 CLA 的设计语言,正逐步成为奔驰新一代产品的标志性元素。

走进车内,新一代 GLB 换装了源自旗舰 S 级的「Superscreen」一体式三联屏:10.25 英寸仪表盘 + 14 英寸中控屏 + 14 英寸副驾娱乐屏,横向贯穿整个仪表台。

不过与高配 GLC EV 所采用的真·一体化大连屏不同,GLB 的三屏之间仍存在明显边框分隔,豪华感稍显逊色。

如果有关注 smart 精灵 5 EHD 的朋友或许会发现,GLB 的内外饰设计语言似乎和 smart 有些许相似,这并不意外,毕竟两者都有奔驰的全球设计中心参与研发。

▲ smart 精灵 5 EHD 内饰

新一代 GLB 的车机系统此次也升级至了最新版 MBUX 智能交互系统。

新 MBUX 的一大突破,在于深度整合 AI 能力。其语音助手「Hey Mercedes」升级为基于 ChatGPT 的多轮对话系统,具备短期记忆,能理解上下文语境。更关键的是,它还接入了 Google Gemini AI,用于处理复杂的个性化导航请求,比如「找一家附近有充电桩、评分高于 4.5、且提供儿童座椅的餐厅」。

此外,尽管强调触控与语音交互,奔驰却还是「听劝」的重新引入物理音量滚轮和自适应巡航控制实体开关。

告别 1.3T

与 CLA 一样,GLB 将率先推出纯电版本,初期提供两个配置:后驱 GLB 250(260 马力)和四驱 GLB 350 4Matic(349 马力),全系标配 85kWh 电池,CLTC 工况下最高续航约 630 公里。

得益于全面转向 800V 高压平台,新车峰值充电功率可达 320kW,10 分钟即可补充约 260 公里续航。

稍晚些时候,入门级 GLB 200 将加入阵容,搭载 58kWh 电池,续航约 450 公里,功率降至 221 马力。

燃油版车型预计于 2026 年下半年上市,彻底告别饱受争议的 1.3T 发动机,换装全新的 1.5T 四缸轻混动力系统。燃油版共有三种输出版本:134 马力(前驱)、161 马力(前驱)和 188 马力(四驱)。混动系统配备 1.3kWh 电池和 27 马力电机,支持低速纯电行驶。参考技术相同的 CLA 混动车型,WLTC 综合油耗预计在 5.5L/100km 左右。

而在操控与行驶质感上,奔驰没有因为 GLB 的「入门」定位而妥协。

前悬采用优化后的麦弗逊结构,下控制臂由锻造铝合金制成的横向臂与推力臂组成,转向节亦为铸铝材质,在实现轻量化的同时,特别强调高外倾刚度,确保精准的转向响应与低噪音表现。

真正的亮点在于后桥——全新开发的多连杆独立后悬架首次被下放到 B 级车型上。这一通常用于 E 级、S 级等高端车型的高规格底盘结构,显著提升了行驶稳定性、滤震能力与整体高级感。

此外,GLB 全系标配带自适应可调阻尼的悬挂系统。驾驶员可通过 DYNAMIC SELECT 旋钮在「舒适」与「运动」模式间切换。系统通过多个传感器实时监测车辆状态、路况及驾驶风格,并能对每个车轮的减震器进行毫秒级独立调节,实现动态平衡。

奔驰还特别介绍了在 GLB 上首次得到应用的多源热泵系统。

这套源自技术旗舰 VISION EQXX 的成果可以并行利用三个热源:电驱系统和电池产生的废热,以及环境空气中的热量。通过回收这些热能,该系统仅需消耗传统辅助加热器约三分之一的电能,就能达到同等的制热效果,从而显著减少对电量的消耗。

在-7°C 的环境下,GLB 仅需 20 分钟即可将车内加热至舒适温度,速度是上一代车型的两倍,甚至优于同级别的燃油车。更关键的是,能耗却只有上一代的一半左右。

「巩固」而非「颠覆」

目前,特斯拉尚未将 Model YL 引入欧洲市场,因此在该地区,新一代 GLB 确实缺乏直接对标车型。它是当前唯一同时提供纯电与轻混动力、并可选七座布局的豪华品牌紧凑型 SUV。这种「双模+多座」的组合,在法规趋严与家庭用户需求之间找到了一个务实的平衡点。

但在中国市场,本土新势力在智能化、补能效率和成本控制上的优势,使得纯电版 GLB 很难在 30 万–50 万元价格区间形成竞争力。相比之下,燃油版或许更具竞争力,它不仅彻底告别了争议多年的 1.3T 发动机,内饰也同步升级至最新版的大连屏,整体产品力明显提升。

今年前 10 个月,国内市场一共售出了约 2.87 万辆 GLB,月销峰值接近 5000 辆。这一数字虽远不及 GLC,但在豪华品牌整体承压、消费者购车更趋理性的背景下,属于相当稳健的表现。

换言之,新一代 GLB 的真正机会,或许不在于「颠覆」,而在于「巩固」,通过补齐短板、优化体验来稳住现有用户基本盘,在依然认可奔驰品牌的用户心中,继续扮演一个可靠、体面且功能完整的选项。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


权威人士:证券行业总体杠杆区间将保持在合理范围

2025年12月8日 20:47
中国证监会主席吴清近日表示对优质证券机构“适度打开资本空间和杠杆限制”,引发市场热议。从接近监管人士处了解到,对优质机构适当“松绑”是强化分类监管、扶优限劣的方式之一。监管部门将坚持稳中求进的总基调,对优质头部机构开展符合国家战略和政策导向的业务适当优化杠杆率要求,促进证券行业提升资本利用效率,更好服务实体经济,行业总体杠杆区间将保持在合理范围,坚决不搞大干快上。(上证报)

中天火箭:与航天彩虹设立控股子公司

2025年12月8日 20:39
36氪获悉,中天火箭公告,公司拟与航天彩虹共同出资5000万元设立新疆中天火箭人工影响天气技术有限责任公司,其中公司出资4000万元,占比80%。该子公司主要开展增雨防雹、消雾、消霾等减灾防灾综合服务业务、人工影响天气产品的综合销售及人影无人机业务的拓展。此次交易构成关联交易,不涉及重大资产重组。

npm几个实用命令

作者 168169
2025年12月8日 20:17

前言

最近接到公司安全团队的项目风险表格,列出了当前前端项目的一些安全风险,大概有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表单状态管理

作者 dorisrv
2025年12月8日 18:15

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

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


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

❌
❌