阅读视图

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

每日一题-使数组和能被 P 整除🟡

给你一个正整数数组 nums,请你移除 最短 子数组(可以为 ),使得剩余元素的  能被 p 整除。 不允许 将整个数组都移除。

请你返回你需要移除的最短子数组的长度,如果无法满足题目要求,返回 -1 。

子数组 定义为原数组中连续的一组元素。

 

示例 1:

输入:nums = [3,1,4,2], p = 6
输出:1
解释:nums 中元素和为 10,不能被 p 整除。我们可以移除子数组 [4] ,剩余元素的和为 6 。

示例 2:

输入:nums = [6,3,5,2], p = 9
输出:2
解释:我们无法移除任何一个元素使得和被 9 整除,最优方案是移除子数组 [5,2] ,剩余元素为 [6,3],和为 9 。

示例 3:

输入:nums = [1,2,3], p = 3
输出:0
解释:和恰好为 6 ,已经能被 3 整除了。所以我们不需要移除任何元素。

示例  4:

输入:nums = [1,2,3], p = 7
输出:-1
解释:没有任何方案使得移除子数组后剩余元素的和被 7 整除。

示例 5:

输入:nums = [1000000000,1000000000,1000000000], p = 3
输出:0

 

提示:

  • 1 <= nums.length <= 105
  • 1 <= nums[i] <= 109
  • 1 <= p <= 109

🎨 用一次就爱上的图标定制体验:CustomIcons 实战

在前端项目里,图标不是“点缀”,它往往是信息结构与互动线索的关键。如何让图标既统一又可配、既美观又可国际化?这篇文章带你用 @infinilabs/custom-icons 打造一套“可配置、可主题、可国际化”的图标解决方案。

背景

  • Coco AI(开源) 用户需要可以配置自定义 Icon。
  • 多品牌与多区域:同一产品在不同客户、不同区域需要差异化的风格与语言。
  • 设计与工程协作:设计希望图标统一;工程需要灵活调整尺寸、颜色、类型、甚至自定义图片。
  • 运营与配置:希望在管理面板里直接挑选或调整图标,而不是改代码、发版本。

于是,我做了一个轻量、直观、开箱即用的组件库:@infinilabs/custom-icons

适用场景

  • 可视化配置台:在后台面板中为功能、菜单或模块选择与配置图标。
  • 多主题产品:快速切换深色/浅色主题,保证图标在不同背景下的对比度与风格。
  • 国际化应用:在不同语言环境下自动切换文案与控件标签。
  • 自定义品牌:支持上传自定义图片作为图标,满足品牌个性化需求。

主要能力

  • 图标渲染组件:ConfigurableIcon
    • 指定类型(如 lucide)、图标名、颜色与尺寸即可渲染。
    • 支持数据 URL(自定义图片)模式。
  • 图标选择器:IconPicker
    • 一站式选择与配置:类型、名称、尺寸、颜色与图片上传。
    • 可选主题与国际化支持。
    • 可通过 controls 精细开关各子控件。

快速开始

# 使用你熟悉的包管理器安装
pnpm add @infinilabs/custom-icons
# 或
npm i @infinilabs/custom-icons
# 或
yarn add @infinilabs/custom-icons

在项目中引用:

import { useState } from "react";
import { ConfigurableIcon, IconPicker } from "@infinilabs/custom-icons";

export default function Demo() {
  const [config, setConfig] = useState({
    type: "lucide",
    name: "Bot",
    size: 28,
    color: "#1e90ff",
    dataUrl: undefined,
  });

  return (
    <div style={{ padding: 24 }}>
      {/* 渲染当前配置的图标 */}
      <ConfigurableIcon
        type={config.type}
        name={config.name}
        size={config.size}
        color={config.color}
        dataUrl={config.dataUrl}
      />

      {/* 交互式选择与配置 */}
      <IconPicker
        value={config}
        onChange={setConfig}
        configurable
        theme="light"
        locale="zh-CN"
        controls={{
          type: true,
          name: true,
          size: true,
          color: true,
          image: true,
        }}
      />
    </div>
  );
}

如果你需要查看可选的 Lucide 图标名称,选择器旁已内置快捷链接:

基础效果

image.png

组件详解

ConfigurableIcon

用于在任意位置渲染一个图标。

  • 关键属性
    • type: 图标类型(如 lucide 或自定义)
    • name: 图标名称(type=lucide 时为 Lucide 名称)
    • size: 数值尺寸(px)
    • color: 颜色(十六进制或 CSS 颜色)
    • dataUrl: 当使用自定义图片时的 data: URL

示例(自定义图片):

<ConfigurableIcon
  type="custom"
  name="my-logo"
  dataUrl="data:image/png;base64,...."
  size={28}
  color="#1e90ff" // 自定义图片时通常忽略颜色
/>

IconPicker

一个将预览与配置控件整合在一起的选择器。可插在设置面板或表单中,让用户自行挑选或上传。

  • 常用属性

    • value: 当前图标配置对象
    • onChange(next): 配置变化回调
    • configurable: 是否展示配置面板
    • controls: 控件开关集合(type/name/size/color/image 等)
    • theme: light | dark
    • locale: zh-CN | en-US
    • i18n: 文案对象(可覆盖默认文案)
  • 控件开关示例

<IconPicker
  value={config}
  onChange={setConfig}
  configurable
  controls={{
    type: true,
    name: true,
    size: true,
    color: true,
    image: true, // 打开即出现上传控件
  }}
/>
  • 主题与国际化
<IconPicker
  value={config}
  onChange={setConfig}
  configurable
  theme="dark"
  locale="en-US"
/>

进阶示例:面板内批量配置

将多个图标配置成一组,供菜单或卡片模块统一管理:

function IconsPanel() {
  const [items, setItems] = useState([
    { id: 1, config: { type: "lucide", name: "Home", size: 24, color: "#444" } },
    { id: 2, config: { type: "lucide", name: "Settings", size: 24, color: "#444" } },
  ]);

  const updateItem = (id, next) =>
    setItems((prev) =>
      prev.map((it) => (it.id === id ? { ...it, config: next } : it))
    );

  return (
    <div style={{ display: "grid", gap: 16 }}>
      {items.map((it) => (
        <div key={it.id} style={{ padding: 12, border: "1px solid #eee", borderRadius: 8 }}>
          <ConfigurableIcon {...it.config} />
          <IconPicker
            value={it.config}
            onChange={(next) => updateItem(it.id, next)}
            configurable
            theme="light"
            locale="zh-CN"
            controls={{ type: true, name: true, size: true, color: true, image: false }}
          />
        </div>
      ))}
    </div>
  );
}

设计与工程协作建议

  • 设计提供命名规范:例如统一使用 Lucide 的图标名集合,避免随意命名。
  • 管理面板适配:通过 controls 开关不同角色看到的控件(运营只改颜色与大小、开发可修改类型与名称)。
  • 主题变量托管:将颜色与尺寸作为“设计令牌”,统一管理与回收。

常见问题

  • 自定义图片会应用颜色吗?
    • 通常不会;颜色更适用于矢量图标。自定义图片由图片本身决定视觉。
  • 如何选择 Lucide 图标名?
    • 打开 https://lucide.dev/icons/,在选择器里输入对应名称即可。

image.png

小结

@infinilabs/custom-icons 让“图标即配置”的能力落地:从主题与国际化,到自定义图片与统一风格,既能保证设计一致性,又给予业务足够自由度。把它接入你的管理面板或设置页,让图标成为产品的强大表达力,而不是维护负担。

如果你对更多场景(如基于角色的控件可见性、图标库扩展)有想法,欢迎继续交流与共建!

开源共建:github.com/infinilabs/…

【套路】前缀和+哈希表(Python/Java/C++/Go)

前置知识

模运算的世界:当加减乘除遇上取模

提示 1

例如 $\textit{nums}=[11,2,5,7,8,9]$,$p=10$,那么把 $[5,7]$ 去掉,剩余的数字相加等于 $30$,可以被 $p$ 整除。

所有元素的和 $42\bmod 10=2$,而 $(5+7)\bmod 10$ 也等于 $2$。

设所有元素的和为 $x$,去掉的元素和为 $y$。要使 $x-y$ 能被 $p$ 整除,根据前置知识中同余的定义,这等价于满足

$$
y \equiv x \pmod p
$$

提示 2

把 $y$ 用 前缀和 表示,问题转换成:在前缀和数组上找到两个数 $s[\textit{left}]$ 和 $s[\textit{right}]$,满足 $\textit{right}-\textit{left}$ 最小且

$$
s[\textit{right}]-s[\textit{left}]\equiv x \pmod p
$$

根据前置知识,将上式移项,得

$$
s[\textit{right}]-x \equiv s[\textit{left}]\pmod p
$$

上式相当于

$$
((s[\textit{right}]-x)\bmod p+p)\bmod p= s[\textit{left}]\bmod p
$$

也可以写成

$$
(s[\textit{right}]\bmod p-x\bmod p+p)\bmod p= s[\textit{left}]\bmod p
$$

提示 3

遍历 $s$ 的同时,用哈希表 $\textit{last}$ 记录 $s[i]\bmod p$ 最近一次出现的下标,如果 $\textit{last}$ 中包含 $(s[i]\bmod p-x\bmod p+p)\bmod p$,设其对应的下标为 $j$,那么 $[j,i)$ 是一个符合题目要求的子数组。

注意:本题可以移除空子数组,所以要先更新 $\textit{last}$,再更新答案。

枚举所有 $i$,计算符合要求的子数组长度的最小值,就是答案。如果没有符合要求的子数组,则返回 $-1$。

代码实现时,可以把答案初始化成 $\textit{nums}$ 的长度 $n$。如果最后答案等于 $n$,则表示没有符合要求的子数组,因为题目不允许将整个数组都移除。

答疑

:为什么不能用双指针(不定长滑动窗口)做?

:使用双指针需要满足单调性,但是 $s[i]\bmod p$ 并不是单调的,所以不能用双指针。具体请看【基础算法精讲 03】

class Solution:
    def minSubarray(self, nums: List[int], p: int) -> int:
        s = list(accumulate(nums, initial=0))
        x = s[-1] % p
        if x == 0:
            return 0  # 移除空子数组(这行可以不要)

        ans = n = len(nums)
        last = {}
        for i, v in enumerate(s):
            last[v % p] = i
            j = last.get((v - x) % p, -n)  # 如果不存在,-n 可以保证 i-j >= n
            ans = min(ans, i - j)
        return ans if ans < n else -1
class Solution {
    public int minSubarray(int[] nums, int p) {
        int n = nums.length;
        int[] s = new int[n + 1];
        for (int i = 0; i < n; i++) {
            s[i + 1] = (s[i] + nums[i]) % p;
        }
        int x = s[n];
        if (x == 0) {
            return 0; // 移除空子数组(这行可以不要)
        }

        int ans = n;
        Map<Integer, Integer> last = new HashMap<>();
        for (int i = 0; i <= n; i++) {
            last.put(s[i], i);
            // 如果不存在,-n 可以保证 i-j >= n
            int j = last.getOrDefault((s[i] - x + p) % p, -n);
            ans = Math.min(ans, i - j);
        }
        return ans < n ? ans : -1;
    }
}
class Solution {
public:
    int minSubarray(vector<int> &nums, int p) {
        int n = nums.size(), s[n + 1];
        s[0] = 0;
        for (int i = 0; i < n; i++) {
            s[i + 1] = (s[i] + nums[i]) % p;
        }
        int x = s[n];
        if (x == 0) {
            return 0; // 移除空子数组(这行可以不要)
        }

        int ans = n;
        unordered_map<int, int> last;
        for (int i = 0; i <= n; ++i) {
            last[s[i]] = i;
            auto it = last.find((s[i] - x + p) % p);
            if (it != last.end()) {
                ans = min(ans, i - it->second);
            }
        }
        return ans < n ? ans : -1;
    }
};
func minSubarray(nums []int, p int) int {
    n := len(nums)
    s := make([]int, n+1)
    for i, v := range nums {
        s[i+1] = (s[i] + v) % p
    }
    x := s[n]
    if x == 0 {
        return 0 // 移除空子数组(这个 if 可以不要)
    }

    ans := n
    last := map[int]int{}
    for i, v := range s {
        last[v] = i
        if j, ok := last[(v-x+p)%p]; ok {
            ans = min(ans, i-j)
        }
    }
    if ans < n {
        return ans
    }
    return -1
}

也可以不用前缀和数组,一边遍历 $\textit{nums}$ 一边计算前缀和。

class Solution:
    def minSubarray(self, nums: List[int], p: int) -> int:
        x = sum(nums) % p
        if x == 0:
            return 0  # 移除空子数组(这行可以不要)

        ans = n = len(nums)
        s = 0
        last = {s: -1}  # 由于下面 i 是从 0 开始的,前缀和下标就要从 -1 开始了
        for i, v in enumerate(nums):
            s += v
            last[s % p] = i
            j = last.get((s - x) % p, -n)  # 如果不存在,-n 可以保证 i-j >= n
            ans = min(ans, i - j)  # 改成手写 min 会再快一些
        return ans if ans < n else -1
class Solution {
    public int minSubarray(int[] nums, int p) {
        long t = 0;
        for (int v : nums) {
            t += v;
        }
        int x = (int) (t % p);
        if (x == 0) {
            return 0; // 移除空子数组(这行可以不要)
        }

        int n = nums.length;
        int ans = n;
        int s = 0;
        Map<Integer, Integer> last = new HashMap<>();
        last.put(s, -1); // 由于下面 i 是从 0 开始的,前缀和下标就要从 -1 开始了
        for (int i = 0; i < n; i++) {
            s = (s + nums[i]) % p;
            last.put(s, i);
            // 如果不存在,-n 可以保证 i-j >= n
            int j = last.getOrDefault((s - x + p) % p, -n);
            ans = Math.min(ans, i - j);
        }
        return ans < n ? ans : -1;
    }
}
class Solution {
public:
    int minSubarray(vector<int> &nums, int p) {
        int x = reduce(nums.begin(), nums.end(), 0LL) % p;
        if (x == 0) {
            return 0; // 移除空子数组(这行可以不要)
        }

        int n = nums.size(), ans = n, s = 0;
        // 由于下面 i 是从 0 开始的,前缀和下标就要从 -1 开始了
        unordered_map<int, int> last{{s, -1}};
        for (int i = 0; i < n; i++) {
            s = (s + nums[i]) % p;
            last[s] = i;
            auto it = last.find((s - x + p) % p);
            if (it != last.end()) {
                ans = min(ans, i - it->second);
            }
        }
        return ans < n ? ans : -1;
    }
};
func minSubarray(nums []int, p int) int {
    x := 0
    for _, v := range nums {
        x += v
    }
    x %= p
    if x == 0 {
        return 0 // 移除空子数组(这个 if 可以不要)
    }

    n := len(nums)
    ans, s := n, 0
    // 由于下面 i 是从 0 开始的,前缀和下标就要从 -1 开始了
    last := map[int]int{s: -1}
    for i, v := range nums {
        s += v
        last[s%p] = i
        if j, ok := last[(s-x+p)%p]; ok {
            ans = min(ans, i-j)
        }
    }
    if ans < n {
        return ans
    }
    return -1
}

复杂度分析

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

分类题单

如何科学刷题?

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

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

使数组和能被 P 整除

方法一:前缀和

定理一:给定正整数 $x$、$y$、$z$、$p$,如果 $y \bmod p = x$,那么 $(y - z) \bmod p = 0$ 等价于 $z \bmod p = x$。

证明:$y \bmod p = x$ 等价于 $y = k_1 \times p + x$,$(y-z) \bmod p = 0$ 等价于 $y - z = k_2 \times p$,$z \bmod p = x$ 等价于 $z = k_3 \times p + x$,其中 $k_1$、$k_2$、$k_3$ 都是整数,那么给定 $y = k_1 \times p + x$,有 $y - z = k_2 \times p \leftrightarrow z = (k_1 - k_2) \times p + x \leftrightarrow z = k_3 \times p + x$。

定理二:给定正整数 $x$,$y$,$z$,$p$,那么 $(y - z) \bmod p = x$ 等价于 $z \bmod p = (y - x) \bmod p$。

证明:$(y - z) \bmod p = x$ 等价于 $y - z = k_1 \times p + x$,其中 $k_1$ 是整数,经过变换有 $z = y - k_1 \times p - x = k_2 \times p + (y - x) \bmod p - k_1 \times p = (k_2 - k_1) \times p + (y - x) \bmod p$,等价于 $z \bmod p = (y - x) \bmod p$。

记数组和除以 $p$ 的余数为 $x$,如果 $x=0$ 成立,那么需要移除的最短子数组长度为 $0$。

记前 $i$ 个元素(不包括第 $i$ 个元素)的和为 $\textit{f}i$,我们考虑最右元素为 $\textit{nums}[i]$ 的所有子数组,假设最左元素为 $\textit{nums}[j]~(0 \le j \le i)$,那么对应的子数组和为 $\textit{f}{i+1}-\textit{f}j$,对应的长度为 $i-j+1$。由定理一可知,如果剩余子数组和能被 $p$ 整除,那么 $(\textit{f}{i+1}-\textit{f}j) \bmod p = x$。同时由定理二可知,$\textit{f}j \bmod p = (\textit{f}{i+1} - x) \bmod p$。因此当 $\textit{f}{i+1}$ 已知时,我们需要找到所有满足 $\textit{f}j \bmod p = (\textit{f}{i+1} - x) \bmod p$ 的 $\textit{f}_j$($0 \le j \le i$),从中找到最短子数组。

由于需要移除最短子数组,因此对于所有 $f_j$($0 \le j \le i$),只需要保存 $f_j \bmod p$ 对应的最大下标。

有些编程语言对负数进行取余时,余数为负数,因此计算 $f_{i+1} - x$ 除以 $p$ 的余数时,使用 $f_{i+1} - x + p$ 替代。

###Python

class Solution:
    def minSubarray(self, nums: List[int], p: int) -> int:
        x = sum(nums) % p
        if x == 0:
            return 0
        y = 0
        index = {0: -1}
        ans = len(nums)
        for i, v in enumerate(nums):
            y = (y + v) % p
            if (y - x) % p in index:
                ans = min(ans, i - index[(y - x) % p])
            index[y] = i
        return ans if ans < len(nums) else -1

###C++

class Solution {
public:
    int minSubarray(vector<int>& nums, int p) {
        int x = 0;
        for (auto num : nums) {
            x = (x + num) % p;
        }
        if (x == 0) {
            return 0;
        }
        unordered_map<int, int> index;
        int y = 0, res = nums.size();
        for (int i = 0; i < nums.size(); i++) {
            index[y] = i; // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
            y = (y + nums[i]) % p;
            if (index.count((y - x + p) % p) > 0) {
                res = min(res, i - index[(y - x + p) % p] + 1);
            }
        }
        return res == nums.size() ? -1 : res;
    }
};

###Java

class Solution {
    public int minSubarray(int[] nums, int p) {
        int x = 0;
        for (int num : nums) {
            x = (x + num) % p;
        }
        if (x == 0) {
            return 0;
        }
        Map<Integer, Integer> index = new HashMap<Integer, Integer>();
        int y = 0, res = nums.length;
        for (int i = 0; i < nums.length; i++) {
            index.put(y, i); // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
            y = (y + nums[i]) % p;
            if (index.containsKey((y - x + p) % p)) {
                res = Math.min(res, i - index.get((y - x + p) % p) + 1);
            }
        }
        return res == nums.length ? -1 : res;
    }
}

###C#

public class Solution {
    public int MinSubarray(int[] nums, int p) {
        int x = 0;
        foreach (int num in nums) {
            x = (x + num) % p;
        }
        if (x == 0) {
            return 0;
        }
        IDictionary<int, int> index = new Dictionary<int, int>();
        int y = 0, res = nums.Length;
        for (int i = 0; i < nums.Length; i++) {
            // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
            if (!index.ContainsKey(y)) {
                index.Add(y, i);
            } else {
                index[y] = i;
            }
            y = (y + nums[i]) % p;
            if (index.ContainsKey((y - x + p) % p)) {
                res = Math.Min(res, i - index[(y - x + p) % p] + 1);
            }
        }
        return res == nums.Length ? -1 : res;
    }
}

###C

#define MIN(a, b) ((a) < (b) ? (a) : (b))

typedef struct {
    int key;
    int val;
    UT_hash_handle hh;
} HashItem; 

HashItem *hashFindItem(HashItem **obj, int key) {
    HashItem *pEntry = NULL;
    HASH_FIND_INT(*obj, &key, pEntry);
    return pEntry;
}

bool hashAddItem(HashItem **obj, int key, int val) {
    if (hashFindItem(obj, key)) {
        return false;
    }
    HashItem *pEntry = (HashItem *)malloc(sizeof(HashItem));
    pEntry->key = key;
    pEntry->val = val;
    HASH_ADD_INT(*obj, key, pEntry);
    return true;
}

bool hashSetItem(HashItem **obj, int key, int val) {
    HashItem *pEntry = hashFindItem(obj, key);
    if (!pEntry) {
        hashAddItem(obj, key, val);
    } else {
        pEntry->val = val;
    }
    return true;
}

int hashGetItem(HashItem **obj, int key, int defaultVal) {
    HashItem *pEntry = hashFindItem(obj, key);
    if (!pEntry) {
        return defaultVal;
    }
    return pEntry->val;
}

void hashFree(HashItem **obj) {
    HashItem *curr = NULL, *tmp = NULL;
    HASH_ITER(hh, *obj, curr, tmp) {
        HASH_DEL(*obj, curr);  
        free(curr);             
    }
}

int minSubarray(int* nums, int numsSize, int p) {
     int x = 0;
    for (int i = 0; i < numsSize; i++) {
        x = (x + nums[i]) % p;
    }
    if (x == 0) {
        return 0;
    }
    HashItem *index = NULL;
    int y = 0, res = numsSize;
    for (int i = 0; i < numsSize; i++) {
        hashSetItem(&index, y, i); // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
        y = (y + nums[i]) % p;
        if (hashFindItem(&index, (y - x + p) % p)) {
            int val = hashGetItem(&index, (y - x + p) % p, 0);
            res = MIN(res, i - val + 1);
        }
    }
    hashFree(&index);
    return res == numsSize ? -1 : res;
}

###JavaScript

var minSubarray = function(nums, p) {
    let x = 0;
    for (const num of nums) {
        x = (x + num) % p;
    }
    if (x === 0) {
        return 0;
    }
    const index = new Map();
    let y = 0, res = nums.length;
    for (let i = 0; i < nums.length; i++) {
        index.set(y, i); // f[i] mod p = y,因此哈希表记录 y 对应的下标为 i
        y = (y + nums[i]) % p;
        if (index.has((y - x + p) % p)) {
            res = Math.min(res, i - index.get((y - x + p) % p) + 1);
        }
    }
    return res === nums.length ? -1 : res;
};

###go

func minSubarray(nums []int, p int) int {
    sum := 0
    mp := map[int]int{0: -1}
    for _, v := range nums {
        sum += v
    }
    rem := sum%p
    if rem == 0 {
        return 0
    }
    minCount := len(nums)
    sum = 0
    for i := 0; i < len(nums); i++ {
        sum += nums[i]
        tempRem := sum%p
        k := (tempRem - rem + p) % p
        if _, ok := mp[k]; ok {
            minCount = min(minCount, i - mp[k])
        }
        mp[tempRem] = i
    }
    
    if minCount >= len(nums) {
        return -1
    }
    
    return minCount
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。遍历数组 $\textit{nums}$ 需要 $O(n)$ 的时间。

  • 空间复杂度:$O(n)$。保存哈希表需要 $O(n)$ 的空间。

做一题送一题,力扣上不少类似题

首先,这道题的思路和 974 一样(当然有一些变化)。强烈建议对这个问题不熟悉的同学,看一下 974,搞懂以后,再回来看这道题:

974. 和可被 K 整除的子数组


假设 nums 的和除以 P,余数是 mod

如果 mod == 0,答案就是 0

如果 mod != 0,答案变成了找原数组中的最短连续子数组,使得其数字和除以 P,余数也是 mod


由于是求解连续子数组和的问题,很容易想到使用前缀和。

我们可以扫描一遍整个数组,计算到每个元素的前缀和。

假设当前前缀和除以 P 的余数是 curmod,为了找到一段连续子数组对 P 的余数是 mod,我们需要找到一段前缀和,对 P 的余数是 targetmod。其中 targetmod 的求法是:

如果 curmod >= mod,很简单:targetmod = curmod - mod

如果 curmod < mod,我们需要加上一个 Ptargetmod = curmod - mod + P

这样,我们可以保证,当前前缀和减去目标前缀和,剩余的数组对 P 的余数是 mod。我们只需要找最短的这样的数组就好。


最后,为了快速找到一段对 P 的余数为 targetmod 的前缀和,我们使用一个哈希表 table,来存储之前前缀和对 P 的余数和所在的索引。(key 为余数;value 为索引)。

table 在遍历过程中更新,以保证每次在 table 中查找到的,是离当前元素最近的索引,从而保证找到的是“最短”的连续子数组。


我的参考代码(C++):

class Solution {
public:
    int minSubarray(vector<int>& nums, int p) {

        long long sum = 0;
        for(int e: nums) sum += (long long)e;
        long long mod = sum % (long long)p;

        if(mod == 0ll) return 0;

        int res = nums.size();
        unordered_map<long long, int> table;
        table[0ll] = -1;

        sum = 0;
        for(int i = 0; i < nums.size(); i ++){
            sum += (long long)nums[i];
            long long curmod = sum % (long long)p;
            table[curmod] = i;

            long long targetmod = curmod >= mod ? (curmod - mod) : (curmod - mod + p);
            if(table.count(targetmod))
                res = min(res, i - table[targetmod]);
        }
        return res == nums.size() ? -1 : res;
    }
};

觉得有帮助请点赞哇!

mac电脑安装nvm

方案一、常规安装

  1. 下载安装脚本:在终端中执行以下命令来下载并运行 NVM 的安装脚本3:

    bash

    curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.39.5/install.sh | bash
    

  2. 配置环境变量:安装完成后,需要配置环境变量。如果你的终端使用的是 bash,打开或创建~/.bash_profile文件,添加以下内容3:

    bash

    export NVM_DIR="$HOME/.nvm"
    [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # 加载nvm
    [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # 加载bash自动补全(可选)
    

    如果使用的是 zsh,则打开或创建~/.zshrc文件,添加相同内容。然后执行source ~/.bash_profilesource ~/.zshrc使配置生效。

方案二、解决网络问题的安装

如果因为网络原因无法直接访问官方源,可以尝试以下方法:

  1. 通过国内镜像下载安装脚本:可以从 gitee 等国内代码托管平台的镜像下载安装脚本,例如:

    bash

    curl -o- https://gitee.com/cunkai/nvm-cn/raw/master/install.sh | bash
    

  2. 配置 NVM 使用国内镜像:安装完成后,编辑~/.zshrc(或~/.bashrc),添加以下内容来配置 NVM 使用国内的 Node.js 镜像源:

    bash

    export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
    export NVM_IOJS_ORG_MIRROR=https://npmmirror.com/mirrors/iojs
    

    保存后执行source ~/.zshrcsource ~/.bashrc使配置生效。

安装完成后,可以通过nvm -v命令查看 NVM 的版本,以确认是否安装成功。

nvm常用命令

;安装node18.16.0
nvm install 18.16.0

;查看nvm安装的node版本
nvm list

;通过nvm list查看电脑已有的版本号,设置默认的版本
nvm alias default v22.16.0

5 分钟把 Coze 智能体嵌入网页:原生 JS + Vite 极简方案

你已经创建好了 Coze 智能体,现在想快速把它接入一个网页?不用 React、不用 Vue,甚至不用手敲 npm create —— 本文教你用 trae 的 AI 助手 + 原生 HTML/JS,5 分钟搭建一个可运行、可部署、安全调用 Coze OpenAPI 的前端 Demo。

我们将实现:

  • 通过 trae AI 一键生成项目并初始化 Vite
  • 安全注入 Bot ID 和 API Token
  • 调用 Coze 接口实现问答交互

一、用 trae AI 快速搭建项目(无需手动命令)

告别 npm init 和配置文件!我们借助 trae 的右侧 AI 对话栏,全自动完成项目创建。

操作步骤如下:

  1. 打开 trae 平台,进入任意工作区

  2. 在右侧 AI 对话框 中输入:

    创建一个通用的原生HTML/CSS/JS 项目
    
  3. 等待 AI 生成基础结构(通常包含 index.htmlmain.jsstyle.css

  4. 接着在同一对话中继续输入:

    帮我初始化vite配置
    
  5. AI 会自动为你:

    • 创建 vite.config.js
    • 添加 package.json 脚本(如 devbuild
    • 安装 vite 依赖(或提示你运行 npm install

✅ 此时你已拥有一个标准的 Vite 原生 JS 项目,无需任何手动配置!

将项目同步到本地后,执行:

npm run dev

确保页面能正常打开,接下来我们集成 Coze。


二、获取 Coze 智能体凭证

  1. 复制两个关键信息:

    • Bot ID 进入你的智能体,在链接最后那一串就是你的ID,选择复制

    • API Key 点击Web SDK 将其中的token复制下来

image.png

⚠️ 这个 API Key 具有调用权限,请务必保密!

关于智能体具体的创建 juejin.cn/post/757769… 这篇文章里面有,当然智能体发布的时候一定要选择API选项


三、安全注入环境变量

在项目根目录创建 .env.local 文件:

VITE_BOT_ID=your_actual_bot_id
VITE_API_KEY=your_actual_api_key

🔒 Vite 只会暴露以 VITE_ 开头的变量到客户端代码,这是官方推荐的安全做法。


四、编写前端交互逻辑

1. index.html

可以把trae生成的代码删掉用下面这份

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Coze API Demo</title>
  <script src="https://cdn.jsdelivr.net/npm/marked@4.3.0/lib/marked.umd.min.js"></script>
</head>
<body>
  <h1>Coze API Demo 随处智能</h1>
  <input type="text" id="ipt" placeholder="请输入问题">
  <div id="reply">think...</div>
  <script type="module" src="./script.js"></script>
</body>
</html>

在这段代码看起有点不一样

<script src="https://cdn.jsdelivr.net/npm/marked@4.3.0/lib/marked.umd.min.js"></script>

是哪里冒出来的呢?

其实加上这个主要是为了待会我们从智能体那里获取图片展示到网页上,如果不加的话我们只会获得图片的链接,这还要结合待会的js一起使用

2. main.js

const ipt = document.getElementById('ipt');
const reply = document.getElementById('reply');
const endpoint = 'https://api.coze.cn/open_api/v2/chat';
// DOM 2 
ipt.addEventListener('change',async function(event) {
  const prompt = event.target.value;
  console.log(prompt);
  const payload = {
    bot_id: import.meta.env.VITE_BOT_ID,
    user: 'yvo',
    query: prompt,
    chat_history:[],
    stream: false,
    custom_variables: {
      prompt: '你是一个AI助手'
    }
  }
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`
    },
    body: JSON.stringify(payload)
  })
  const data = await response.json();
  console.log(data, '////');
  // reply.innerHTML = data.messages[1].content;
  reply.innerHTML=marked.parse(data.messages.find(item => item.type === 'answer').content);
})

代码分析

  reply.innerHTML=marked.parse(data.messages.find(item => item.type === 'answer').content);

这段代码可能看起来有点突兀,那我们拆开来看首先我们看吧

data.messages.find(item => item.type === 'answer').content

这主要是获取智能体的回答,这时就有人问了一般获取信息不都是使用 .choices[0].message.content来获取吗?

但是coze的智能体返回的结构是不一样的

image.png

看这个结构很容易观察到其实coze智能体返回的结构需要在messages[1].content或type:"answer"才能拿到结果,这就是coze与我们调用一般的llm不一样的地方。

接下来我们继续分析

marked.parse()

将 Markdown 格式的字符串 → 转换成 HTML 字符串

这样浏览器才能正确显示标题、列表、链接、图片等内容。

这也就实现了我们能在页面上获取智能体给我们的图片了。 我们可以删去试试看效果

image.png

我们并没有得到我们想要的只获得了https地址

那加上试试呢?

image.png

成功将照片拿到。

 const payload = {
    bot_id: import.meta.env.VITE_BOT_ID,
    user: 'yvo',
    query: prompt,
    chat_history:[],
    stream: false,
    custom_variables: {
      prompt: '你是一个AI助手'
    }

这段代码好像见的也不多,这段其实就要根据www.coze.cn/open/docs/d… coze的官方文档去使用了


五、启动 & 验证

npm run dev

在浏览器输入问题(如“JavaScript 如何判断数组?”),即可看到 Coze 智能体的实时回复!


七、常见问题

Q:返回 {"code":4101,"msg":"The token you entered is incorrect"}
A:请检查:

  • .env.local 是否命名正确
  • Token 是否正确或过期

结语

通过 trae AI + Vite + Coze OpenAPI,我们用最轻量的方式实现了智能体前端集成。整个过程:

  • 无框架负担
  • 无复杂构建
  • 环境变量安全隔离
  • 代码清晰可维护

一个输入框,一行 API 调用,背后是千行训练数据与万亿参数的智能体在为你思考。
而你,只用了 5 分钟,就把它请进了自己的网页。
这不是魔法——这是新时代前端工程师的日常。

从「似懂非懂」到「了如指掌」:Promise 与原型链全维度拆解

前言

在前端世界里,Promise原型链(Prototype) 是两个看似毫不相干,却又互相影响、甚至能相互解释的重要概念。

很多人学习 Promise 时,会关注它的使用:thencatchfinallyPromise.all 等;
学习原型链时,又会关注 __proto__prototype、构造函数与实例之间的关系。

但鲜有人把 Promise 本身也是一个对象,它也依赖原型链运作 这件事真正联系起来讲透。

本文将以一次完整的 Promise 异步流程为主线,把“原型链 + 状态机 + 微任务”融合讲解,让你完全理解 Promise 到底是怎么在底层“跑”起来的。


一、Promise 为什么是“对象”?

我们常常写:

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve('OK'), 1000)
})

很多人知道 Promise 是“异步解决方案”,但忽略了一个基本事实:

Promise 是一个构造函数(类),你创建的是它的实例。

也就是说:

  • Promise —— 构造函数(带 prototype
  • p —— 实例对象(带 __proto__

打开控制台试试:

console.log(p.__proto__ === Promise.prototype) // true

这里马上就把原型链扯进来了。

🔍 Promise.prototype 上都有啥?

输入:

console.log(Promise.prototype)

你会看到:

then: ƒ then()
catch: ƒ catch()
finally: ƒ finally()
constructor: ƒ Promise()
...

这说明:

所有 Promise 实例都是通过原型链访问 then/catch/finally 的。

也就是说 p.then() 并不是实例自身有,而是:

p ---> Promise.prototype ---> Object.prototype ---> null

这为后文理解 Promise “链式调用”机制奠定基础。


二、原型链视角下,看懂 Promise 的执行流

我们直接看一个你提供的代码精简版:

const p = new Promise((resolve, reject) => {
  console.log(111)
  setTimeout(() => {
    reject('失败1')
  }, 1000)
})

console.log(222)

p.then(data => {
  console.log(data)
}).catch(err => {
  console.log(err)
}).finally(() => {
  console.log('finally')
})

输出顺序:

111
222
失败1
finally

要理解为什么 Promise 能这样执行,必须从两个角度讲:

  • (1)Promise 内部是状态机(pending → fulfilled / rejected)
  • (2)then/catch/finally 是通过原型链挂载的“回调注册器”

我们分开看看。


1)Promise 内部是一个状态机

内部状态(无法手动修改):

状态 描述 何时出现
pending 初始状态 执行 executor 期间
fulfilled resolve 被调用 成功
rejected reject 被调用 失败

也就是说:

new Promise(executor)

执行后:

  • 立即执行 executor
  • executor 只在同步阶段运行
  • 真正的 resolve/reject 回调是“挂起来”,等事件循环驱动

所以你看到:

111(executor 同步执行)
222(外部同步执行)
失败1(异步到点后 reject)
finally(状态 settled 后触发)

2)then/catch/finally:它们不是魔法,是原型链的方法

看看这段链式调用:

p.then(...).catch(...).finally(...)

为什么可以一直“链式”?

因为每次调用 then 都 返回一个新的 Promise 实例

p.then(...) → p2
p2.catch(...) → p3
p3.finally(...) → p4

这几个实例的原型链依然是:

p2.__proto__ === Promise.prototype
p3.__proto__ === Promise.prototype
...

因此:

链式本质 = 每次链式都返回一个新的 Promise 实例,然后继续在原型链上查找 then/catch/finally。

这就是原型链在 Promise 底层的重要性。


三、原型链的类比:Promise 就像“火车头 + 车厢”系统

你提到的类比非常棒,我把它整理成完整模型:

✨ Promise = 火车系统

  • 构造函数(Promise) = 火车制造厂
  • 原型对象(Promise.prototype) = “火车车厢模板”
  • 实例(p) = 火车头
  • then/catch/finally = 可以接在车头后的“车厢类型”

于是我们看到:

p(车头).then(挂一个车厢)
         .then(再挂一节)
         .catch(挂一个处理失败的车厢)
         .finally(挂尾部的清理车厢)

每次挂车厢(调用 then/catch)时,都会生成 新的火车车头(新的 Promise 实例)

整个火车最终沿着轨道(事件循环)开动到终点。

⚠️ 注意:为什么 finally 一定执行?

因为 finally 不关心结果,只关心火车是否开到终点(settled)。


四、Promise 与普通对象原型链的对比

你提供了一个经典例子:

function Person(name, age) {
  this.name = name
  this.age = age
}

Person.prototype.speci = '人类'

let zhen = new Person('白兰地空瓶', 18)
console.log(zhen.speci)

const kong = {
  name: '空瓶',
  hobbies: ['读书', '喝酒']
}

zhen.__proto__ = kong

console.log(zhen.hobbies, zhen.speci)

输出:

人类
['读书','喝酒'] undefined

这个例子非常适合用来对比 Promise 的原型链逻辑。

对比 1:实例可以动态改原型(不推荐)

zhen.__proto__ = kong 改掉了原来的 Person.prototype

所以:

  • 能访问 hobbies:因为来自 kong
  • 不能访问 speci:因为已脱离 Person.prototype

Promise 则不能做这种事

你不能这样做:

p.__proto__ = {}

否则:

p.then is not a function

因为 then/catch/finally 都来自 Promise.prototype。

这反而让我们更清楚地理解:

Promise 的能力几乎全部来自原型链。


五、Promise.all 的底层逻辑:一辆多车头的“联挂火车”

提到 Promise.all,这里正好顺便讲讲它的底层设计。

Promise.all([p1, p2, p3])

机制可以用一个形象类比解释:

  • 假设有三辆火车(p1/p2/p3)
  • Promise.all 创建一辆“总火车头” pAll
  • pAll 盯着三个火车头,只要全部变成 fulfilled,就把所有结果一次性返回
  • 如果有一个 reject,则整个 pAll 变成 rejected(列车脱轨)

也就是说:

Promise.all = 多个 Promise 状态机的并联 + 一个新的总状态机。

为什么它能做到?

答案依旧在原型链:

  • Promise.all 本质是一个静态方法,返回新的 Promise 实例
  • 新的 Promise 实例依然沿用同一套路(prototype → then/catch)

六、用真实工程场景收尾:Promise 原型链为什么重要?

在真实项目里,理解 Promise 的原型机制有三个实际价值:

① debugger 时能看清原型链,定位异步回调来源

你能区分:

  • then 回调从哪里来的?(Promise.prototype.then)
  • promise 链断在哪一层?

② 手写 Promise 时必须实现 then/catch/finally

如果你手写 Promise A+:

MyPromise.prototype.then = function(onFulfilled, onRejected) {}

这里你就必须自己处理链式、状态机、回调队列。

③ 能理解 async/await 的底层依赖 Promise 链式调度

await 会把后续步骤注册到 promise.then 中。

理解 then 的原型链,就能理解 async/await 的机制本质。


七、总结:Promise + 原型链的全景图

// 创建实例
const p = new Promise(executor)

// 原型链:调用能力来自这里
p.__proto__ = Promise.prototype

// 状态机:内部维护 pending → fulfilled/rejected

// then/catch/finally:注册微任务

// 链式调用:每次都返回一个新的 Promise 实例

// Promise.all:多个状态机的并联

一句话总结:

Promise 本质是一个基于“原型链 + 状态机 + 微任务队列”的异步调度框架。

它既是面向对象设计(通过原型链复用方法),又是异步控制核心工具(内部状态机)。

理解二者的融合,你就真正吃透了 Promise。

🧠 深入理解 JavaScript Promise 与 `Promise.all`:从原型链到异步编程实战

在现代 JavaScript 开发中,Promise 是处理异步操作的核心机制之一。ES6 引入的 Promise 极大地简化了“回调地狱”(Callback Hell)问题,并为后续的 async/await 语法奠定了基础。而 Promise.all 则是并发执行多个异步任务并统一处理结果的强大工具。

本文将结合 原型链原理Promise 基础用法实际示例代码,带你系统掌握 Promise 及其静态方法 Promise.all 的使用与底层逻辑。


🔗 一、JavaScript 的面向对象:原型链而非“血缘”

在深入 Promise 之前,我们先厘清一个关键概念:JavaScript 的继承不是基于“类”的血缘关系,而是基于原型(prototype)的链式查找机制

1.1 🏗️ 构造函数与原型对象

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

Person.prototype.speci = '人类';

let zhen = new Person('张三', 18);
console.log(zhen.speci); // 输出: "人类"
  • Person 是构造函数。
  • Person.prototype 是所有 Person 实例共享的原型对象。
  • zhen.__proto__ 指向 Person.prototype
  • Person.prototype.constructor 又指回 Person,形成闭环。

🚂 小比喻:可以把 constructor 看作“车头”,prototype 是“车身”。实例通过 __proto__ 连接到车身,而车身知道自己的车头是谁。

1.2 ⚡ 动态修改原型链(不推荐)

const kong = {
    name: '孔子',
    hobbies: ['读书', '喝酒']
};

zhen.__proto__ = kong;
console.log(zhen.hobbies);     // ✅ 输出: ['读书', '喝酒']
console.log(kong.prototype);   // ❌ undefined!普通对象没有 prototype 属性

⚠️ 注意:

  • 只有函数才有 prototype 属性;
  • 普通对象(如 kong)只有 __proto__,没有 prototype
  • 在这里kong是object的一个实例kong.__prpto__ == object.prototype

💡 虽然可以动态修改 __proto__,但会破坏代码可预测性,影响性能,应避免使用。


⏳ 二、Promise:ES6 的异步解决方案

2.1 🧩 Promise 基本结构

<script>
const p = new Promise((resolve, reject) => {
    console.log(111); // 同步执行
    setTimeout(() => {
        console.log(333);
        // resolve('结果1');  // 成功
        reject('失败1');      // 失败
    }, 1000);
});

console.log(222);
console.log(p, '////////'); // 此时 p 状态仍是 pending
console.log(p.__proto__ == Promise.prototype); // true
</script>

📋 执行顺序分析:

  1. 111 立即输出(executor 函数同步执行)✅
  2. 222 紧接着输出 ✅
  3. p 此时处于 pending(等待) 状态 ⏳
  4. 1 秒后,333 输出,调用 reject('失败1'),状态变为 rejected
  5. .catch() 捕获错误,.finally() 无论成功失败都会执行 🔁

2.2 🎯 Promise 的三种状态

  • ⏳ pending:初始状态,既不是成功也不是失败。
  • ✅ fulfilled:操作成功完成(通过 resolve 触发)。
  • ❌ rejected:操作失败(通过 reject 触发)。

🔒 核心特性:一旦状态改变,就不可逆。这是 Promise 的设计基石。

2.3 🔍 原型关系验证

console.log(p.__proto__ === Promise.prototype); // ✅ true
  • pPromise 的实例。
  • 所有 Promise 实例的 __proto__ 都指向 Promise.prototype
  • Promise.prototype 上定义了 .then(), .catch(), .finally() 等方法。
  • Promise.prototype.__proto__ == object.prototype

🚀 三、Promise.all:并发处理多个异步任务

3.1 ❓ 什么是 Promise.all

Promise.all(iterable) 接收一个可迭代对象(如数组),其中包含多个 Promise。它返回一个新的 Promise:

  • ✅ 全部成功 → 返回一个包含所有结果的数组(顺序与输入一致)。
  • ❌ 任一失败 → 立即 rejected,返回第一个失败的原因。

3.2 💻 使用示例

const task1 = fetch('/api/user');       // 假设返回 { id: 1, name: 'Alice' }
const task2 = fetch('/api/posts');      // 假设返回 [{ title: 'JS' }]
const task3 = new Promise(resolve => setTimeout(() => resolve('done'), 500));

Promise.all([task1, task2, task3])
  .then(([user, posts, msg]) => {
    console.log('全部完成:', user, posts, msg);
  })
  .catch(err => {
    console.error('某个任务失败:', err);
  });

🌐 适用场景:需要同时加载用户信息、文章列表、配置数据等,全部就绪后再渲染页面。

3.3 ⚠️ 错误处理演示

const p1 = Promise.resolve('成功1');
const p2 = Promise.reject('失败2');
const p3 = Promise.resolve('成功3');

Promise.all([p1, p2, p3])
  .then(results => console.log('不会执行'))
  .catch(err => console.log('捕获错误:', err)); // 输出: "失败2"

关键点:只要有一个失败,整个 Promise.all 就失败,其余成功的 Promise 结果会被丢弃。

3.4 🛡️ 替代方案:Promise.allSettled(ES2020)

如果你希望无论成功失败都等待所有任务完成,可以使用 Promise.allSettled

Promise.allSettled([p1, p2, p3])
  .then(results => {
    results.forEach((res, i) => {
      if (res.status === 'fulfilled') {
        console.log(`✅ 任务${i} 成功:`, res.value);
      } else {
        console.log(`❌ 任务${i} 失败:`, res.reason);
      }
    });
  });

✅ 适用于:批量上传、日志收集、非关键资源加载等场景。


📚 四、总结:从原型到实践

概念 说明
🔗 原型链 JS 对象通过 __proto__ 查找属性,constructor 指回构造函数
Promise 表示异步操作的最终完成或失败,具有 pending/fulfilled/rejected 三种状态
🧩 Promise.prototype 所有 Promise 实例的方法来源(.then, .catch 等)
🚀 Promise.all 并发执行多个 Promise,全成功则成功,任一失败则整体失败
🛡️ 最佳实践 使用 Promise.all 提升性能;用 allSettled 处理非关键任务

💭 五、思考题

  1. 🤔 为什么 console.log(p)setTimeout 之前打印时,状态是 pending
  2. 🛠️ 能否通过修改 Promise.prototype.then 来全局拦截所有 Promise 的成功回调?这样做有什么风险?
  3. 📦 如果 Promise.all 中传入空数组 [],结果会是什么?

💡 答案提示

  1. 因为异步任务尚未执行,状态未改变。
  2. 技术上可行,但会破坏封装性、可测试性和团队协作,强烈不推荐
  3. 立即 resolved,返回空数组 [] —— 这是符合规范的!

通过本文,你不仅掌握了 PromisePromise.all 的用法,还理解了其背后的 原型机制异步执行模型。这将为你编写健壮、高效的异步代码打下坚实基础。🌟

Happy Coding! 💻✨

从摄影新手到三维光影师:Three.js 核心要素的故事

当我第一次学习摄影时,老师告诉我一句话:

“你不是在拍东西,而是在拍光。”

后来我学习 Three.js 时突然意识到:
这句话原来依旧成立。

Three.js 不只是一个 3D 引擎,更像是一台虚拟相机。要拍好这张“虚拟的照片”,我们必须掌握三个核心要素:

场景(Scene)

相机(Camera)
灯光与材质(Light & Material)

于是,我把学习过程想象成一个摄影新手成长为三维光影师的故事。

空无一物的影棚 —— Scene 场景

故事从一个空影棚开始。

当我第一次打开 Three.js 时,教程告诉我:

const scene = new THREE.Scene();

这就像摄影师走进了一个空旷的工作室:
没有布景、没有模特、没有灯光,甚至连相机都还没架好, 在影棚这个场景中,摄影师可以在这个场景中放任何的东西:

  • 架好摄像机(Camera 📹)
  • 拍照的物体(Mesh 网格物体)、物体拥有着自己的形状(Geometry几何体)和材质(Material)
  • 摆设好灯光(Light)
  • 也可以是任意的对象 (Object3D)

摄影师往 Scene 里布置道具,而程序员的你往 Scene 里添加各种对象,因此 场景就是一个可以放任何东西的容器

找到你要观看的角度 —— Camera 相机

刚学摄影时,我最常做的事情,就是移动、蹲下、趴着、绕圈……
只为了找到一个“对的角度”。

Three.js 的相机就是你的眼睛。创建相机就像准备拍摄时拿起单反:

const camera = new THREE.PerspectiveCamera(const camera = new THREE.PerspectiveCamera(
  50, // 相机视野角度,摄像机的视野角度越大,摄像机看到的场景就越大,反之越小
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面(近端渲染距离),指定从距离相机多近的位置开始渲染,推荐默认值0.1
  1000 // 远平面(远端渲染距离)指定摄像机从它所在的位置最远能看到多远,太小场景中的远处物体会看不见,太大会浪费资源影响性能,推荐默认值1000
);

// 2.1 设置相机的位置,放在不同的位置看到的风景当然不一样
camera.position.set(5, 10, 10); // x, y, z

camera.lookAt(0, 0, 0); // 设置相机方向(这就是你女朋友让你找最佳角度的原因)

摄影师会说:“我走两步,让模特在背景中更突出。”
程序员会说:

camera.position.z = 3;
camera.lookAt(0, 0, 0)

本质完全一样:
都是在调整观察世界的方式。

让世界真正亮起来 —— Light & Material 灯光与材质

你可以有再漂亮的模特、再好的相机,如果没有光——
一切都会变成漆黑一片。

Three.js 也是如此。你搭了一个完美的 3D 模型,如果没有光,它看起来只是纯黑。

于是我制作“虚拟布光”:

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 5);
scene.add(light);

摄影师打灯,而我在代码里放置光源:

  • DirectionalLight(平行光)= 太阳光
  • PointLight(点光源)= 想象灯泡发光,由点向八方发射
  • SpotLight(聚光灯)= 舞台灯,从上打下来,呈现圆锥体,它离光越远,它的尺寸就越大。这种光源会产生阴影
  • AmbientLight(环境光)= 影棚柔光,环境光没有特定的来源方向,不会产生阴影

同时材质(Material)也等同于现实世界的“被光击中时的反应”:

  • 皮肤 = standard material
  • 金属 = metalness 高
  • 塑料 = roughness 较高
  • 玻璃 = transparent=True + envMap

想要一个皮肤质感的物体?
那么你就得给材质加入 roughness、metalness、normalMap 就像摄影师在打柔光,为人物皮肤创造质感。

光与材质的搭配,就是 Three.js 里的“布光艺术”。

最终章:按下快门 —— Renderer 渲染器

当场景布好、相机调好、灯光到位后——
摄影师要做的就是按下快门。

在 Three.js 里:

renderer.render(scene, camera);

渲染器就是那个“快门”,
真正把世界投射到屏幕上。

摄影师用快门把现实世界的光记录下来;
Three.js 用 GPU 把虚拟世界的光影计算出来。

本质上,两者做的是同一件事:

把真实或虚拟的三维世界,投射成一张二维图像。

import * as THREE from "three";

// 1. 创建场景
const scene = new THREE.Scene();

// 2. 创建相机(透视投影相机)
const camera = new THREE.PerspectiveCamera(
  50, // 相机视野角度,摄像机的视野角度越大,摄像机看到的场景就越大,反之越小
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近平面(近端渲染距离),指定从距离相机多近的位置开始渲染,推荐默认值0.1
  1000 // 远平面(远端渲染距离)指定摄像机从它所在的位置最远能看到多远,太小场景中的远处物体会看不见,太大会浪费资源影响性能,推荐默认值1000
);

// 2.1 设置相机的位置
camera.position.set(5, 10, 10); // x, y, z

camera.lookAt(0, 0, 0); // 设置相机方向(默认看向场景原点)

// 3. 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true }); // 开启抗锯齿,使边缘更平滑
// 3.1 设置渲染器的大小
renderer.setSize(window.innerWidth, window.innerHeight);
// 3.2 将渲染器的canvas内容添加到body
document.body.appendChild(renderer.domElement);

// 4. 创建一个立方体几何体
const geometry = new THREE.BoxGeometry(4, 4, 4); // 宽、高、深

// 为了让光源有效果,我们使用 MeshLambertMaterial 或 MeshPhongMaterial
//  创建材质 MeshLambertMaterial (兰伯特材质) 是一种非光泽材质,会受光照影响,但没有镜面高光
const material = new THREE.MeshLambertMaterial({
  color: 0x00ff00, // 颜色
  // wireframe: true, // 如果需要线框效果可以加上
});

// 6. 创建一个网格模型(网格模型由几何体和材质组成)
// Mesh 构造函数通常只接受一个材质。如果需要多材质,Three.js 有专门的 MultiMaterial 或 Group 来处理
const cube = new THREE.Mesh(geometry, material); // 使用 MeshLambertMaterial

// 6.1 将几何模型添加到场景中
scene.add(cube);

// 6.2 设置相机看向物体(拍摄对象)的位置(默认状态下相机看向的是场景的原点(0,0,0))
camera.lookAt(cube.position);

// 7. 创建光源
const spotLight = new THREE.SpotLight(0xffffff); // 创建聚光灯,颜色为白色
// 7.1 设置光源的位置
spotLight.position.set(0, 20, 20); // 调整光源位置,使其能够照亮立方体
// 7.2 设置光源照射的强度,默认值为1, 越大越亮
spotLight.intensity = 2;
// 7.3 将光源添加到场景中
scene.add(spotLight);

// 8. 为了方便观察 3D 图像,添加三维坐标系对象
const axesHelper = new THREE.AxesHelper(6); // 参数表示坐标系的大小 (x轴红色, y轴绿色, z轴蓝色)
scene.add(axesHelper); // 将坐标系添加到场景中

// 9. 渲染函数
function animate() {
  requestAnimationFrame(animate); // 请求再次执行渲染函数animate,形成循环

  // 让立方体动起来
  cube.rotation.x += 0.01; // 沿x轴旋转
  cube.rotation.y += 0.01; // 沿y轴旋转
  cube.rotation.z += 0.01;

  renderer.render(scene, camera); // 使用渲染器,通过相机将场景渲染出来
}

animate(); // 执行渲染函数,进入无限循环,完成渲染

2025年CSS新特性大盘点

大家好,我是 Immerse,一名独立开发者、内容创作者、AGI 实践者。

关注公众号:沉浸式趣谈,获取最新文章(更多内容只在公众号更新)

个人网站:yaolifeng.com 也同步更新。

转载请在文章开头注明出处和版权信息。

我会在这里分享关于编程独立开发AI干货开源个人思考等内容。

如果本文对您有所帮助,欢迎动动小手指一键三连(点赞评论转发),给我一些支持和鼓励,谢谢!


2025年了,CSS又进化了

去年写过一篇 CSS 新特性盘点,本来以为今年不会有太大变化。结果一看,新东西比去年还多。

这次整理了几个我觉得特别实用的功能,浏览器支持也都不错,可以用起来了。

终于可以动画到 auto 了

之前我们做高度展开动画,基本都是靠 max-height 硬撑。

比如从 0 展开到实际高度,只能写个超大的值,体验很差。

现在可以直接动画到 auto 了:

html {
  interpolate-size: allow-keywords;
}

加上这一行,所有 height: 0 到 height: auto 的过渡都能生效。

或者你也可以用 calc-size() 函数,不需要全局设置:

.content {
  height: 3lh;
  overflow: hidden;
  transition: height 0.2s;

  &.expanded {
    height: calc-size(auto, size);
  }
}

这个功能总算来了。

而且不只是 height,任何接受尺寸的属性都能用,不只是 auto,min-content 这些关键字也行。

目前 Chrome 已经支持,其他浏览器应该也快了。

Popover 和 Invoker

Popover 是个 HTML 属性,给任意元素加上就有开关功能。

配合 Invoker 用起来更爽,不用写 JavaScript 就能控制弹窗。

<button commandfor="menu" command="toggle">
  打开菜单
</button>

<div id="menu" popover>
  菜单内容
</div>

这样就够了,按钮点击自动控制弹窗显示隐藏。

浏览器会自动处理无障碍访问、键盘操作、焦点管理这些细节。

而且还能配合 Anchor Positioning 用,让弹窗自动定位到触发元素旁边。

Popover 已经全浏览器支持,Invoker 目前只有 Chrome,不过有 polyfill 可以用。

CSS 里可以写函数了

CSS 有 calc()、clamp() 这些内置函数,现在我们可以自己写了:

@function --titleBuilder(--name) {
  result: var(--name) " is cool.";
}

然后就能在任何地方调用:

.title::after {
  content: --titleBuilder("CSS");
}

这个功能让 CSS 更像编程语言了。

把复杂逻辑封装到函数里,代码更清爽,也更好维护。

不过目前只有 Chrome 支持,可以先用着,不支持的浏览器会回退到默认值。

if() 函数也来了

CSS 本来就有很多条件逻辑,比如选择器匹配、媒体查询。

但这次的 if() 函数是第一个专门做条件分支的:

.grid {
  display: grid;
  grid-template-columns:
    if(
      media(max-width > 300px): repeat(2, 1fr);
      media(max-width > 600px): repeat(3, 1fr);
      media(max-width > 900px): repeat(auto-fit, minmax(250px, 1fr));
      else: 1fr;
    );
}

看起来像不像 switch 语句?第一个匹配的条件会生效。

条件可以是 media()、supports()、style() 这几种。

把所有逻辑都写在一个属性里,代码可读性好很多。

目前 Chrome 独占,其他浏览器还在路上。

表单输入框自动调整大小

field-sizing 这个属性专门解决表单输入框的问题。

textarea {
  field-sizing: content;
}

加上这一行,textarea 会自动根据内容调整高度。

用户输入多少内容,输入框就有多高,不用手动拖拽了。

在手机上体验特别好,拖拽调整大小本来就很难操作。

这个功能之前都是用 JavaScript 实现,现在 CSS 原生支持了。

Chrome 和 Safari 都能用,Firefox 估计也快了。

select 下拉框终于能自定义样式了

select 元素的外观一直很难自定义,打开后显示的选项更是完全没法控制。

现在可以完全自定义了,只要先开启:

select,
::picker(select) {
  appearance: base-select;
}

然后想怎么改就怎么改,选项的样式、布局、动画都能控制。

目前 Chrome 独占,不过不支持的浏览器会回退到原生样式,完全不影响使用。

text-wrap 让排版更好看

text-wrap: balance 可以让每行文字长度尽量接近:

h1 {
  text-wrap: balance;
}

用在标题上效果特别好,不会出现最后一行只有一个词的情况。

还有个 text-wrap: pretty,专门优化正文排版:

p {
  text-wrap: pretty;
}

浏览器会自动调整断行,避免孤词,让文字看起来更舒服。

balance 已经全浏览器支持,pretty 在 Chrome 和 Safari 能用。

这种优化对用户体验很重要,而且完全不影响功能,可以直接加上。

linear() 实现复杂缓动效果

CSS 的 linear 关键字之前就是匀速动画,很无聊。

但 linear() 函数可以实现超复杂的缓动,比如弹跳效果:

.bounce {
  animation-timing-function: linear(
    0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141 13.6%, 0.25, 0.391, 0.563, 0.765,
    1, 0.891 40.9%, 0.848, 0.813, 0.785, 0.766, 0.754, 0.75, 0.754, 0.766, 0.785,
    0.813, 0.848, 0.891 68.2%, 1 72.7%, 0.973, 0.953, 0.941, 0.938, 0.941, 0.953,
    0.973, 1, 0.988, 0.984, 0.988, 1
  );
}

这种效果用 cubic-bezier() 根本做不出来。

而且已经全浏览器支持了,可以放心用。

有在线工具可以生成这些值,不用自己手写。

shape() 函数画任意图形

CSS 之前有 path() 函数,但语法很难写,而且只能用像素。

shape() 是专门为 CSS 设计的,支持所有单位和自定义属性:

.arrow {
  clip-path: shape(
    evenodd from 97.788201% 41.50201%,
    line by -30.839077% -41.50201%,
    curve by -10.419412% 0% with -2.841275% -3.823154% / -7.578137% -3.823154%,
    smooth by 0% 14.020119% with -2.841275% 10.196965%,
    close
  );
}

可以用在 clip-path 裁剪元素,也能用在 offset-path 做路径动画。

而且可以响应式调整,配合媒体查询和容器查询都没问题。

Chrome 和 Safari 已经支持,Firefox 也在开发中。

attr() 变强了

之前 attr() 只能取字符串,现在可以指定类型了:

<div data-count="42" data-color="#ff0000">
div {
  --count: attr(data-count type(<number>));
  --color: attr(data-color type(<color>));
}

这样可以直接把 HTML 属性当数字或颜色用,方便多了。

目前 Chrome 独占,不过对于不支持的浏览器,可以设置回退值。

reading-flow 解决 Tab 顺序问题

用 Grid 或 Flexbox 重新排列元素后,Tab 键的焦点顺序会乱。

现在可以用 reading-flow 告诉浏览器按照视觉顺序来:

.grid {
  reading-flow: grid-rows;
}

这样焦点就会按照 Grid 的行顺序移动,不会乱跳了。

Flexbox 用 flex-flow,其他布局也有对应的值。

这个功能对无障碍访问很重要,不过目前只有 Chrome 支持。

等其他浏览器跟进之前,最好不要大量重排布局。

值得期待的功能

还有一些功能在开发中,但还没正式发布:

Masonry 布局虽然各浏览器实现不同,但在稳步推进。

Safari 的 random() 函数可以生成随机数,玩起来很有意思。

margin-trim 可以自动去掉容器边缘元素的外边距,Safari 独占中。

sibling-index() 和 sibling-count() 函数在 Chrome 能用,做交错动画很方便。

View Transitions 的 match-element 不用给每个元素起名字了,而且 Firefox 也在开发中。

还有很多其他功能在路上。

别忘了这些已经能用的

Container Queries 和 :has() 这些去年的新功能,现在已经全浏览器支持。

View Transitions、Anchor Positioning、Scroll-Driven Animations 也都在 Safari 上线了。

dvh 这些视口单位也成为标准了。

CSS 现在能做的事情越来越多,写起来也越来越顺手。

参考:frontendmasters.com/blog/what-y…

其他好文推荐

2025 最新!独立开发者穷鬼套餐

Windows 安装 Claude Code 的新姿势,保姆级教程

Claude Code 从入门到精通:最全配置指南和工具推荐

Claude Code 终极配置指南:一行命令搞定各种配置

一个配置文件搞定!Claude Code 多模型智能切换

这个 361k Star 的项目,一定要收藏!

搞定 XLSX 预览?别瞎找了,这几个库(尤其最后一个)真香!

【完整汇总】近 5 年 JavaScript 新特性完整总览

关于 Node,一定要学这个 10+万 Star 项目!

【翻译】使用 React 19 操作构建可复用组件

使用 React 19 Actions 构建可复用的 React 组件,通过 useTransition()useOptimistic() 实现功能。通过实际案例学习如何追踪待处理状态、实现乐观更新,并在 Next.js 应用路由器中暴露动作属性以实现自定义逻辑。

作者:Aurora Scharff

首发于 aurorascharff.no

React 19 Actions 简化了待处理状态、错误、乐观更新和顺序请求的处理。本文将探讨如何在 Next.js App Router 中使用 React 19 Actions 构建可复用组件。我们将利用 useTransition() 追踪过渡状态,使用 useOptimistic() 向用户提供即时反馈,并暴露 action 属性以支持父组件中的自定义逻辑。

React 19 Actions

根据更新后的 React 文档,动作(Actions)是在过渡(Transitions)内部调用的函数。过渡可以更新状态并执行副作用,相关操作将在后台执行,不会阻塞页面上的用户交互。过渡内部的所有动作都会被批量处理,组件仅在过渡完成时重新渲染一次。

Action 可用于自动处理待定状态、错误、乐观更新及顺序请求。在 React 19 表单中使用 <form action={} 属性时,以及向 useActionState() 传递函数时,也会自动创建动作。有关这些 API 的概述,请参阅我的 React 19 速查表或官方文档。

使用 useTransition() 钩子时,您还将获得一个 pending 状态,这是一个布尔值,用于指示过渡是否正在进行。这有助于在过渡过程中显示加载指示器或禁用按钮。

const [isPending, startTransition] = useTransition(); 
const updateNameAction = () => { 
  startTransition(async () => { 
    await updateName(); 
  }) 
})

此外,在钩子版本的 startTransition() 中调用的函数抛出的错误将被捕获,并可通过错误边界进行处理。

Action函数是常规事件处理的替代方案,因此应相应地命名。否则,该函数的使用者将无法明确预期其行为类型。

用例:路由器选择组件

假设我们要构建一个可复用的下拉菜单组件,该组件会将下拉菜单选中的值设置为URL中的参数。其实现方式可能如下所示:

export interface RouterSelectProps { 
  name: string; 
  label?: string; 
  value?: string; 
  options: Array<{ value: string; label: string }>; 
} 

export const RouterSelect = React.forwardRef<HTMLSelectElement, RouterSelectProps>(   
  function Select({ name, label, value, options, ...props }, 
    ref 
) { 
... 
return ( 
  <div> 
    {label && <label htmlFor={name}>{label}</label>} 
      <select 
        ref={ref} 
        id={name} 
        name={name} 
        value={value} 
        onChange={handleChange} 
        {...props} 
      > 
        {options.map((option) => ( 
           <option key={option.value} value={option.value}> 
             {option.label} 
           </option> 
         ))} 
      </select> 
  </div> 
  ) 
}

它可能会这样处理变化:

const handleChange = async ( 
  event: React.ChangeEvent<HTMLSelectElement> 
) => { 
  const newValue = event.target.value; 
  
  // Update URL 
  const url = new URL(window.location.href); 
  url.searchParams.set(name, newValue); 
  
  // Simulate a delay that would occur if the route destination is doing async work 
  await new Promise((resolve) => setTimeout(resolve, 500)); 
  
  // Navigate 
  router.push(url.href, { scroll: false }); 
};

可通过路由器传递 searchParams 来使用:

<RouterSelect
  name="lang" 
  options={Object.entries(languages).map(([value, label]) => { 
    return { value, label, }; 
  })} 
  label="Language" 
  value={searchParams.lang} 
/>

由于我们使用 Next.js 应用路由器,当延迟推送到路由时,下拉框的值不会立即更新,而是等到 router.push() 完成且搜索参数更新后才会刷新。

这会导致糟糕的用户体验:用户必须等待路由推送完成才能看到下拉框的新值,可能因此产生困惑,误以为下拉框功能失效。

使用Action追踪待处理状态

让我们创建一个使用 useTransition() 钩子的 Action 来追踪推送至路由器的状态。

我们将向路由器的推送封装在返回的 startNavTransition() 函数中,该函数将追踪该转场的待处理状态。这将使我们能够知道转场的进展以及何时完成。

  const [isNavPending, startNavTransition] = useTransition(); 
  const handleChange = async ( 
    event: React.ChangeEvent<HTMLSelectElement> 
  ) => { 
    const newValue = event.target.value; 
    startNavTransition(async () => { 
      const url = new URL(window.location.href); 
      url.searchParams.set(name, newValue); 
      await new Promise((resolve) => setTimeout(resolve, 500)); 
      router.push(url.href, { scroll: false }); 
    }); 
  };

现在,我们可以利用 isNavPending 状态在过渡过程中显示加载指示器,并添加 aria-busy 等辅助功能属性。

<div> 
  {label && <label htmlFor={name}>{label}</label>} 
  <select 
    ref={ref} 
    id={name} 
    name={name} 
    aria-busy={isNavPending} 
    value={value} 
    onChange={handleChange} 
    {...props} 
  > 
    {options.map((option) => ( 
      <option key={option.value} value={option.value}> 
        {option.label} 
      </option> 
    ))}
  </select> 
  {isNavPending && 'Pending nav...'} 
</div>

现在,用户将收到关于其与下拉菜单交互的反馈,不会认为它无法正常工作。

然而,下拉菜单仍然无法立即更新。

使用 useOptimistic() 添加乐观更新

此时就需要用到 useOptimistic() 函数。它允许我们立即更新状态,同时仍能追踪过渡的待处理状态。我们可以在过渡内部调用它:

const [optimisticValue, setOptimisticValue] = useOptimistic(value); 

const handleChange = async ( 
  event: React.ChangeEvent<HTMLSelectElement> 
) => { 
  const newValue = event.target.value; 
  startNavTransition(async () => { 
    setOptimisticValue(newValue); 
    const url = new URL(window.location.href); 
    url.searchParams.set(name, newValue); 
    await new Promise((resolve) => setTimeout(resolve, 500)); 
    router.push(url.href, { scroll: false }); 
  }); 
};

在过渡期间,optimisticValue 将作为临时客户端状态,用于立即更新下拉菜单。过渡完成后,optimisticValue 将最终更新为路由器返回的新值。

现在,我们的下拉菜单实现了即时更新,用户在过渡过程中即可看到菜单中的新值。

暴露Action属性

假设作为 RouterSelect 的用户,我们希望在选项变更时执行额外逻辑。例如,可能需要更新父组件中的其他状态或触发副作用。此时可暴露一个在选项变更时执行的函数。

参照 React 文档,我们可以向父组件暴露一个 action 属性。由于暴露的是 Action,命名时应符合规范,以便组件使用者明确预期行为。

具体实现如下:

export interface RouterSelectProps { 
  name: string; 
  label?: string; 
  value?: string; 
  options: Array<{ value: string; label: string }>; 
  setValueAction?: (value: string) => void; 
}

我们可以在handleChange过渡中调用此属性:

const handleChange = async ( 
  event: React.ChangeEvent<HTMLSelectElement> 
) => { 
  const newValue = event.target.value; 
  startNavTransition(async () => { 
    setOptimisticValue(newValue); 
    setValueAction?.(newValue); '
    const url = new URL(window.location.href); 
    url.searchParams.set(name, newValue); 
    await new Promise((resolve) => setTimeout(resolve, 500)); 
    router.push(url.href, { scroll: false }); 
  });
};

我们还应支持 async 函数。这使得操作回调既可以是同步的,也可以是异步的,而无需额外使用 startTransition 来包裹操作中的 await 语句。

export interface RouterSelectProps { 
  ...// other props 
  setValueAction?: (value: string) => void | Promise<void>; 
}

然后只需 await 操作完成,再推送到路由器:

const handleChange = async ( 
  event: React.ChangeEvent<HTMLSelectElement> 
) => { 
  const newValue = event.target.value; 
  startNavTransition(async () => { 
    setOptimisticValue(newValue); 
    await setValueAction?.(newValue); 
    ... // Push to router 
  }); 
};

在父组件中使用 Action 属性

现在,我们可以通过 setValueAction 属性执行状态更新,并且由于命名规范,我们清楚会行为的结果。

例如,如果我们使用 useState() 设置一条消息:

const [message, setMessage] = useState(''); 
return ( 
  <> 
  <div> 
    Message: {message} <br /> 
  </div> 
  <RouterSelect 
    setValueAction={(value) => { 
      setMessage(`You selected ${value}`);
    }}

我们知道,此状态更新将在向路由器推送完成后发生。

此外,若现在需要乐观更新,可调用 useOptimistic()

const [message, setMessage] = useState(''); 
const [optimisticMessage, setOptimisticMessage] = useOptimistic(message); 

return ( 
  <> 
  <div> 
    Message: {message} <br /> 
    Optimistic message: {optimisticMessage} 
  </div> 
  <RouterSelect 
    setValueAction={(value) => { 
      setOptimisticMessage(`You selected ${value}`); 
      setMessage(`You selected ${value}`); 
    }}

我们知道此状态更新将立即发生。

最终的select实现如下所示:

'use client'; 

... 
export interface RouterSelectProps { 
  name: string; 
  label?: string; 
  value?: string | string[]; 
  options: Array<{ value: string; label: string }>; 
  setValueAction?: (value: string) => void | Promise<void>; 
} 

export const RouterSelect = React.forwardRef<HTMLSelectElement, RouterSelectProps>( 
  function Select( 
    { name, label, value, options, setValueAction, ...props }, 
    ref 
  ) { 
    const router = useRouter(); 
    const [isNavPending, startNavTransition] = React.useTransition(); 
    const [optimisticValue, setOptimisticValue] = React.useOptimistic(value); 
    
    const handleChange = async ( 
      event: React.ChangeEvent<HTMLSelectElement> 
    ) => { 
      const newValue = event.target.value; 
      startNavTransition(async () => { 
        setOptimisticValue(newValue); 
        await setValueAction?.(newValue); 
        const url = new URL(window.location.href); 
        url.searchParams.set(name, newValue); 
        await new Promise((resolve) => setTimeout(resolve, 500)); 
        router.push(url.href, { scroll: false }); 
      }); 
    }; 
    
    return ( 
      <div> 
        {label && <label htmlFor={name}>{label}</label>} 
        <select 
          ref={ref} 
          id={name} 
          name={name} 
          value={optimisticValue} 
          onChange={handleChange} 
          {...props} 
        > 
          {options.map((option) => ( 
            <option key={option.value} value={option.value}> 
              {option.label} 
            </option> 
          ))} 
          </select> 
          {isNavPending && 'Pending nav...'} 
      </div> 
    ); 
  } 
);

查看这个StackBlitz,获取一个可运行的示例。

若需查看本文所述模式的更实用、更贴近实际的应用示例,请参阅我Next.js 15 Conferences项目中的Filters.tsx组件。

构建复杂、可重用的组件

在构建更复杂的可复用组件时,我们可能会遇到限制,迫使我们将乐观更新等逻辑移至父组件。

以我尝试的Ariakit示例为例,显示值的生成必须在可复用选择组件外部完成。这意味着我们无法在可复用选择组件内部调用 useOptimistic 。为解决此问题,可暴露 setValueAction 属性,然后在父组件中调用 useOptimistic() 立即更新状态。

通过这种方式,既能保持组件复用性,又允许父组件实现自定义Action逻辑。

关键要点

  • 动作是在过渡中调用的函数,可更新状态并执行副作用。
  • useTransition() 提供待处理状态以追踪过渡进度。
  • useOptimistic() 允许在过渡中立即更新状态。
  • 向可复用组件暴露动作属性,可在父组件中实现自定义逻辑。
  • 在父组件中使用 useOptimistic() 可立即更新状态,同时保持组件的复用性。
  • 动作的命名对向组件使用者传达预期行为至关重要。

结论

在本篇博文中,我们探讨了如何利用 React 19 动作构建可复用组件,追踪过渡状态,采用乐观更新策略,并暴露动作属性以实现自定义逻辑。我们演示了 useTransition() 如何提供待处理状态以优化用户反馈,useOptimistic() 如何实现即时 UI 更新,以及暴露动作属性如何在保持组件复用性的同时允许父组件执行自定义逻辑。

通过遵循动作命名规范并运用 React 的并发特性,我们能够构建出复杂度极低却能提供流畅用户体验的组件。

源码

当我把 proto 打印出来那一刻,我懂了JS的原型链

💬 前言:我本以为我会面向对象,结果我连“对象”都没搞懂

刚开始学 JavaScript 的时候,我以为:

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

const p1 = new Person('小明');
console.log(p1.name); // 小明

这不就是面向对象吗?简单!

直到有一天,我在控制台敲下:

console.log(p1.__proto__);

然后——我的世界崩塌了。

满屏的 [[Prototype]]constructor__proto__……我仿佛掉进了一个无限嵌套的俄罗斯套娃里。

“我是谁?”
“我从哪里来?”
“我要到哪里去?”
——来自一个被原型链逼疯的学生的灵魂三问。

今天,就用一个真实学习者的视角,带你从困惑到理解,一步步揭开 prototype 的神秘面纱。没有高深术语,只有大白话 + 可运行代码 + 我踩过的坑。


🚪 一、为什么需要原型?—— 因为我不想每个对象都背一份方法

假设我们要创建多个学生对象:

❌ 错误写法:每个学生都自带“技能包”

function Student(name) {
  this.name = name;
  // 每个学生都独立拥有一个 sayHello 方法
  this.sayHello = function() {
    console.log(`大家好,我是${this.name}`);
  };
}

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

console.log(s1.sayHello === s2.sayHello); // false → 完全不同的两个函数!

问题来了:如果创建 1000 个学生,就会有 1000 个 sayHello 函数,内存直接爆炸 💥。

这就像学校给每个学生发一本《礼仪手册》,其实大家看的都是同一本书,但每人一本——太浪费了!

✅ 正确姿势:把公共方法放进“共享书架”(prototype)

function Student(name) {
  this.name = name; // 每个学生独有的属性
}

// 所有学生共享的方法,统一挂载到 prototype 上
Student.prototype.sayHello = function() {
  console.log(`大家好,我是${this.name}`);
};

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

console.log(s1.sayHello === s2.sayHello); // true → 同一个函数,只存一份!
s1.sayHello(); // 大家好,我是张三
s2.sayHello(); // 大大家好,我是李四

📌 我的理解

  • prototype 就是构造函数的“共享书架”
  • 实例自己没有的方法,会自动去书架上找
  • 既节省内存,又方便统一管理

这就是原型存在的意义:让对象学会“蹭”!


🔗 二、四大核心概念:别再混淆 prototype 和 proto 了!

刚开始我总分不清 prototype__proto__,后来我画了张图,终于懂了。


1️⃣ 构造函数:创建实例的“模板”

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

它就是一个普通函数,但通常:

  • 首字母大写
  • new 调用

new 的过程可以简化为:

  1. 创建空对象 {};
  2. this 指向它;
  3. 执行函数体;
  4. 返回这个对象。

2️⃣ prototype:构造函数的“共享仓库”

每个函数都有一个 prototype 属性,它是一个对象,用来存放所有实例共享的内容

Student.prototype.species = '人类';
Student.prototype.study = function() {
  console.log(`${this.name}在努力学习`);
};

⚠️ 注意:prototype函数才有的属性


3️⃣ __proto__:实例通往原型的“梯子”

每个对象(包括实例)都有一个 __proto__ 属性(非标准但广泛支持),它指向其构造函数的 prototype

const s1 = new Student('张三');

console.log(s1.__proto__ === Student.prototype); // true

👉 这就是实例能访问到 sayHello 的原因:
s1.sayHello() → 自己没有 → 顺着 __proto__ 找 → 找到 Student.prototype.sayHello

🎯 记住一句话:实例的 __proto__ 指向构造函数的 prototype


4️⃣ constructor:原型的“回老家按钮”

原型对象上有一个 constructor 属性,指向构造函数本身。

console.log(Student.prototype.constructor === Student); // true
console.log(s1.constructor === Student); // true

⚠️ 重要提醒:手动重写 prototype 要修复 constructor!

Student.prototype = {
  sayHello() { console.log('hi') }
};

const s1 = new Student('张三');
console.log(s1.constructor === Student); // false ❌
console.log(s1.constructor === Object); // true → 错了!

// ✅ 修复:
Student.prototype = {
  constructor: Student,
  sayHello() { console.log('hi') }
};

否则后续 instanceof 判断可能出错。


📊 核心关系图(建议收藏)

lQLPJwvG0tJ1vuPNASLNAkSwAikK2ZgDD2YJAF6EAku5AA_580_290.png

📌 再说一遍:实例的 __proto__ 指向构造函数的 prototype,原型的 constructor 指向构造函数

🔍 三、原型查找机制:JS是怎么找到方法的?

当你调用 s1.sayHello() 时,JS 引擎是这样找的:

  1. 先看 s1 自己有没有 sayHello
  2. 没有?那就通过 __proto__Student.prototype 找;
  3. 还没有?继续通过 Student.prototype.__proto__ 找上一级;
  4. 直到找到,或者查到 null

这个链条,就是原型链

🖼️JavaScript 原型链完整关系图

66b94b61f939741c0ca1db2e69984697.png

1. 查找示例

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

Student.prototype.species = '人类';
Student.prototype.study = function() {
  console.log(`${this.name}在学习`);
};

const s1 = new Student('张三');

console.log(s1.name);        // 张三 → 自身属性
console.log(s1.species);     // 人类 → 来自 prototype
console.log(s1.toString());  // [object Object] → 来自 Object.prototype
console.log(s1.abc);         // undefined → 找不到

2. 原型链终点:null

console.log(Object.prototype.__proto__); // null → 终点!

// 验证整个链:
console.log(s1.__proto__);                 // Student.prototype
console.log(s1.__proto__.__proto__);       // Object.prototype
console.log(s1.__proto__.__proto__.__proto__); // null

3. 实例属性可以“屏蔽”原型属性

function Student(name) {
  this.name = name;
  this.species = '外星人'; // 覆盖原型属性
}

Student.prototype.species = '人类';

const s1 = new Student('张三');
console.log(s1.species); // 外星人

delete s1.species;
console.log(s1.species); // 人类 → 删除后重新查找原型

✅ 应用:为个别实例定制行为,不影响全局。


🧬 四、原型式继承:JS的“继承”到底是什么?

传统语言是“类继承”(血缘关系),而 JS 是“委托继承”——你不会,就去问你爸,你爸不会,就去问爷爷。

1. 经典继承实现

// 父类
function Person(name) {
  this.name = name;
}
Person.prototype.greet = function() {
  console.log(`你好,我是${this.name}`);
};

// 子类
function Student(name, grade) {
  Person.call(this, name); // 继承父类实例属性
  this.grade = grade;
}

// 继承父类原型方法
Student.prototype = new Person();
Student.prototype.constructor = Student;

// 扩展子类方法
Student.prototype.study = function() {
  console.log(`${this.name}在读${this.grade}年级`);
};

const s1 = new Student('张三', 3);
s1.greet(); // 你好,我是张三(继承)
s1.study(); // 张三在读3年级(自有)

2. ES6 class 只是语法糖

class Person {
  constructor(name) { this.name = name; }
  greet() { console.log(`你好,我是${this.name}`); }
}

class Student extends Person {
  constructor(name, grade) {
    super(name);
    this.grade = grade;
  }
  study() { console.log(`${this.name}在读${this.grade}年级`); }
}

底层依然是原型链驱动。class 不是新东西,只是让你写得更爽。


💡 五、原型的实际应用

1. 工具类共享方法

function Utils() {}
Utils.prototype.formatDate = function(date) { /* ... */ };

2. 扩展原生对象(谨慎!)

Array.prototype.unique = function() {
  return [...new Set(this)];
};
[1,2,2,3].unique(); // [1,2,3]

⚠️ 注意:生产环境慎用,避免污染全局。

3. 单例模式

function Singleton() {
  if (Singleton.prototype.instance) {
    return Singleton.prototype.instance;
  }
  this.data = '唯一实例';
  Singleton.prototype.instance = this;
}

4. 框架中的应用(如 Vue)

Vue.prototype.$http = axios; // 所有组件都能用 this.$http

⚠️ 六、常见误区

❌ 误区1:混淆 prototype 和 proto

  • prototype:函数才有,是“仓库”
  • __proto__:对象都有,是“梯子”

❌ 误区2:覆盖 prototype 不修 constructor

会导致 instanceof 失效。

✅ 正确做法:永远记得修 constructor!


🏁 七、总结:原型是JS的灵魂

核心要点 说明
🔹 核心价值 共享方法,节省内存
🔹 核心关系 实例.__proto__ === 构造函数.prototype
🔹 查找机制 自身 → 原型链 → null
🔹 继承本质 委托查找,非类继承
🔹 class 本质 原型的语法糖

🌟 最后感悟
学原型的过程,就像在迷宫中找出口。
一开始觉得混乱,但当你画出那张关系图,执行第一段可运行代码,听到“啊哈!”的那一声——
你就真正理解了 JavaScript 的灵魂。

Taro 小程序页面返回传参完整示例

前言

  • 我们在开发的时候,有时候会遇到,A页面跳转到B页面,B页面改一些数据(例如:收藏状态),回到A页面的时候不想刷新A页面,并看到最新的数据状态;
  • 对于以上场景,有以下几种解决方案;

方法一:EventChannel(推荐)

PageA.jsx - 跳转页面

import React, { useState } from 'react'
import { View, Button, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'

const PageA = () => {
  const [receivedData, setReceivedData] = useState(null)

  const goToPageB = () => {
    Taro.navigateTo({
      url: '/pages/pageB/index',
      events: {
        // 监听返回数据
        // ⚠️ 这里监听的事件名 必须和 子页面绑定的事件名称相同
        onReturnData: (data) => {
          console.log('接收到返回数据:', data)
          setReceivedData(data)
        },
        // 可以监听多个事件
        onSelectItem: (item) => {
          console.log('选中的项目:', item)
        }
      }
    })
  }

  return (
    <View className="page-a">
      <Button onClick={goToPageB}>跳转到页面B</Button>
      
      {receivedData && (
        <View className="received-data">
          <Text>接收到的数据:</Text>
          <Text>{JSON.stringify(receivedData)}</Text>
        </View>
      )}
    </View>
  )
}

export default PageA

PageB.jsx - 返回页面

  • 这是
import React, { useState, useEffect } from 'react'
import { View, Button, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'

const PageB = () => {
  // 可以使用 useState 或 useRef 存储 EventChannel
  const [eventChannel, setEventChannel] = useState(null)
  const [inputValue, setInputValue] = useState('')
  const [count, setCount] = useState(0)

  useEffect(() => {
    // 获取 EventChannel
    const channel = Taro.getCurrentInstance().page?.getOpenerEventChannel?.()
    if (channel) {
      setEventChannel(channel)
    }
  }, [])

  const handleReturn = () => {
    if (eventChannel) {
      // 发送数据给上个页面
      eventChannel.emit('onReturnData', {
        inputValue,
        timestamp: Date.now(),
        source: 'pageB'
      })
    }
    
    // 返回上个页面
    Taro.navigateBack()
  }

  const handleSelectItem = (item) => {
    if (eventChannel) {
      eventChannel.emit('onSelectItem', item)
    }
  }
  
  // ---- 页面销毁传递参数 Start ----
  // 若是使用小程序的导航栏的返回按钮,可以在页面销毁的时候,向父页面传递参数
  // 需要注意的是,useUnload 的参数若是依赖于一些数据,需要使用 useCallback 对函数进行缓存
  const handleBack = useCallback(() => {
    if (eventChannel) {
      eventChannel?.emit('onReturnPageA', { name: 'PageA', count })
      console.log('数据发送成功')
    }
  }, [eventChannel, count])

  useUnload(handleBack)
  // ---- 页面销毁传递参数 End ----

  return (
    <View className="page-b">
      <Input
        value={inputValue}
        onInput={(e) => setInputValue(e.detail.value)}
        placeholder="输入要传递的数据"
      />
      
      <Button onClick={handleReturn}>返回并传递数据</Button>
      
      <Button onClick={() => handleSelectItem({ id: 1, name: '选项1' })}>
        选择选项1
      </Button>
      
      <Button onClick={() => setCount(v => v++)}>
        改变count
      </Button>
      
      <Button onClick={() => handleSelectItem({ id: 2, name: '选项2' })}>
        选择选项2
      </Button>
    </View>
  )
}

export default PageB

方法二:使用 Zustand 状态管理

store/index.js

import { create } from 'zustand'

const useAppStore = create((set, get) => ({
  // 页面返回数据
  pageReturnData: null,
  
  // 设置返回数据
  setPageReturnData: (data) => set({ pageReturnData: data }),
  
  // 清除返回数据
  clearPageReturnData: () => set({ pageReturnData: null }),
  
  // 获取并清除返回数据
  getAndClearReturnData: () => {
    const data = get().pageReturnData
    set({ pageReturnData: null })
    return data
  }
}))

export default useAppStore

PageA.jsx - 使用状态管理

import React, { useEffect } from 'react'
import { View, Button, Text } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import useAppStore from '../store'

const PageA = () => {
  const { pageReturnData, clearPageReturnData } = useAppStore()

  // 页面显示时检查返回数据
  useDidShow(() => {
    if (pageReturnData) {
      console.log('接收到返回数据:', pageReturnData)
      // 处理数据后清除
      handleReturnData(pageReturnData)
      clearPageReturnData()
    }
  })

  const handleReturnData = (data) => {
    // 处理返回的数据
    Taro.showToast({
      title: `接收到: ${data.message}`,
      icon: 'success'
    })
  }

  const goToPageB = () => {
    Taro.navigateTo({
      url: '/pages/pageB/index'
    })
  }

  return (
    <View className="page-a">
      <Button onClick={goToPageB}>跳转到页面B</Button>
    </View>
  )
}

export default PageA

PageB.jsx - 设置状态并返回

import React, { useState } from 'react'
import { View, Button, Input } from '@tarojs/components'
import Taro from '@tarojs/taro'
import useAppStore from '../store'

const PageB = () => {
  const [message, setMessage] = useState('')
  const setPageReturnData = useAppStore(state => state.setPageReturnData)

  const handleReturn = () => {
    // 设置要传递的数据
    setPageReturnData({
      message,
      timestamp: Date.now(),
      type: 'user_input'
    })
    
    // 返回上个页面
    Taro.navigateBack()
  }

  return (
    <View className="page-b">
      <Input
        value={message}
        onInput={(e) => setMessage(e.detail.value)}
        placeholder="输入消息"
      />
      
      <Button onClick={handleReturn}>返回并传递消息</Button>
    </View>
  )
}

export default PageB

方法三:自定义 Hook 封装

hooks/usePageReturn.js

import { useState, useEffect } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'

// 全局存储返回数据
let globalReturnData = new Map()

export const usePageReturn = (pageKey) => {
  const [returnData, setReturnData] = useState(null)

  useDidShow(() => {
    const data = globalReturnData.get(pageKey)
    if (data) {
      setReturnData(data)
      globalReturnData.delete(pageKey)
    }
  })

  const setReturnDataForPage = (targetPageKey, data) => {
    globalReturnData.set(targetPageKey, data)
  }

  const clearReturnData = () => {
    setReturnData(null)
  }

  return {
    returnData,
    setReturnDataForPage,
    clearReturnData
  }
}

// 导航并设置返回监听
export const navigateToWithReturn = (url, pageKey, onReturn) => {
  return Taro.navigateTo({
    url,
    events: {
      returnData: (data) => {
        if (onReturn) {
          onReturn(data)
        }
      }
    }
  })
}

使用自定义 Hook

// PageA.jsx
import React from 'react'
import { View, Button } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { usePageReturn } from '../hooks/usePageReturn'

const PageA = () => {
  const { returnData, clearReturnData } = usePageReturn('pageA')

  useEffect(() => {
    if (returnData) {
      console.log('接收到返回数据:', returnData)
      // 处理数据
      clearReturnData()
    }
  }, [returnData])

  const goToPageB = () => {
    Taro.navigateTo({
      url: '/pages/pageB/index?fromPage=pageA'
    })
  }

  return (
    <View>
      <Button onClick={goToPageB}>跳转到页面B</Button>
    </View>
  )
}

export default PageA

最佳实践建议

  1. 简单场景:使用 EventChannel(方法一)
  2. 复杂应用:使用状态管理(方法二)
  3. 多页面复用:封装自定义 Hook(方法三)
  4. 数据量大:避免使用 URL 参数,选择状态管理
  5. 临时数据:使用 EventChannel,自动清理
  6. 持久数据:结合本地存储使用

注意事项

  • EventChannel 只在 navigateTo 时可用,redirectTo 不支持
  • 状态管理要注意及时清理数据,避免内存泄漏
  • 复杂对象传递时注意序列化问题
  • 考虑页面栈的层级关系,避免数据传递错乱

TypeScript的泛型工具集合

TypeScript的泛型工具集合

TypeScript中集合了很多泛型工具,在日常开发中,我们经常会看到这类工具的使用,所以属性这类工具也是必备的。

参考地址:www.typescriptlang.org/docs/handbo…

工具集大概能够分为几类:

  • 对象与属性工具
  • 联合类型工具
  • 函数工具
  • 类工具
  • 字符串工具
  • this工具
  • promise工具

对象与属性工具
Partial<T>

将类型的所有属性设置为可选属性

interface User {
  id: number;
  name: string;
  age: number;
}
const user2: Partial<User> = { id: 2, name: "ErMao" };
Required<T>

将类型的所有属性设置为必填

interface Config {
  port?: number;
  host?: string;
}
const c1: Config = { port: 8080 };
const c2: Required<Config> = { port: 8080, host: "localhost" };
Readonly<T>

将类型的所有属性设置为只读

interface Todo {
  title: string;
  done: boolean;
}
const t: Readonly<Todo> = { title: "Clean", done: false };
// t.done = true // 错误:不能分配到 "done" ,因为它是只读属性
Record<K,T>

用联合类型键映射到统一的值类型。这个工具很特别,可以把类型作为对象的键。

type Status = "success" | "error" | "loading";

const statusMap: Record<Status, string> = {
  success: "成功",
  error: "错误",
  loading: "加载中",
};
Pick<T, K>

从类型 T 中选择一组属性 K

interface User {
  id: number;
  name: string;
  age: number;
}

const u3: Pick<User, "id" | "name"> = { id: 2, name: "ErMao" };
Omit<T, K>

从类型 T 中排除一组属性 K

interface TodoList {
  title: string;
  description: string;
  completed: boolean;
  createdAt: number;
}
type TodoWithoutMeta = Omit<TodoList, "createdAt" | "completed">;
const x: TodoWithoutMeta = { title: "Clean", description: "Room" };

从这些方法中,可以从对象的键数量和属性进行记忆:

多 >>> 少:Partial、Pice 、Omit

少 >>> 多:Required 、Record

属性:Readonly

联合类型工具
Exclude<T, U>

从类型 T 中排除 U 类型的成员

// Exclude<T, U> : 从类型 T 中排除 U 类型的成员
type Status2 = "pending" | "success" | "error";
type NonError = Exclude<Status2, "error">;
NonNullable<T>

从类型 T 中排除 null 和 undefined 类型的成员,和 Exclude 类似。

type MaybeString = string | null | undefined;
type StrictString = NonNullable<MaybeString>;
Extract<T, U>

从类型 T 中提取 U 类型的成员。类似于交集,但是与&交叉类型又有不同。

type S1 = "a" | "b" | "c";
type S2 = "b" | "d";
type O1 = {name: string}
type O2 = {age: number}
type In2 = O1 & O2
const in2: In2 = {name: "ErMao", age: 18}
type In = S1 & S2;
type Intersection = Extract<S1, S2>;
type In3 = Extract<O1 , O2> // never

排除 : Exclude、NonNullable
交集 : Extract

函数工具
Parameters<T>

从函数类型 T 中提取参数类型的元组

function fn(a: number, b: string) {}
type Args = Parameters<typeof fn>;
const valid: Args = [123, "hi"];
ReturnType<T>

获取函数返回类型

function makePoint() {
  return { x: 0, y: 0 };
}
type Point = ReturnType<typeof makePoint>;
const p: Point = { x: 1, y: 2 };
this 工具
ThisParameterType<T>

提取函数显式 this 参数的类型

interface Person {
  name: string;
}
function say(this: Person, msg: string) {
  return `${this.name}: ${msg}`;
}
type ThisT = ThisParameterType<typeof say>;

OmitThisParameter<T>

移除函数显式 this 参数

interface Person {
  name: string;
}
function say2(this: Person, msg: string) {
  return `${this.name}: ${msg}`;
}
const boundSay = say.bind({ name: "Ann" });
type FnNoThis = OmitThisParameter<typeof say2>;
const f: FnNoThis = boundSay;
ThisType<T>

为对象字面量中的 this 指定类型

type ObjectDescriptor<D, M> = {
  data: D;
  methods: M & ThisType<D & M>
};
function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M {
  return Object.assign({}, desc.data, desc.methods);
}
const obj = makeObject({
  data: { x: 0, y: 0 },
  methods: {
    moveBy(dx: number, dy: number) {
      this.x += dx;
      this.y += dy;
    },
  },
});
obj.moveBy(2, 3);
类工具
ConstructorParameters<T>

获取构造函数参数的元组类型

class Box {
  constructor(width: number, height?: number) {}
}
type CtorArgs = ConstructorParameters<typeof Box>;
const args: CtorArgs = [100, 50];
InstanceType<T>

获取构造函数实例的类型

class UserService {
  constructor(public name: string) {}
  greet() {
    return `Hi ${this.name}`;
  }
}
type ServiceInstance = InstanceType<typeof UserService>;
const svc: ServiceInstance = new UserService("Leo");

字符串工具
Uppercase<S>

转成全大写

type U = Uppercase<"hello world">
Lowercase<S>

转成全小写

type L = Lowercase<"HELLO WORLD">
Capitalize<S>

将字符串的第一个字符转换为大写

type Capitalized = Capitalize<"hello world">
Uncapitalize<S>

将字符串的第一个字符转换为小写

type Uncapitalized = Uncapitalize<"Hello World">

Promise 工具
Awaited<T>
type A = Awaited<Promise<string>>
type B = Awaited<Promise<Promise<number>>>
type C = Awaited<boolean | Promise<number>>
async function fetchNum() { return 42 }
type R = Awaited<ReturnType<typeof fetchNum>>

element-plus源码解读1——useNamespace

useNamespace

useNamespace位于packages/hooks/use-namespace, 旨在帮所有组件统一生成类名/变量名,遵循BEM规范

什么是BEM规范?可阅读下面这篇文章blog.csdn.net/fageaaa/art…

element-plus的BEM类名生成函数_bem

const _bem = (
  namespace: string, // 命名空间,通常是el
  block: string, // 块名,例如button
  blockSuffix: string, // 块后缀(可选),用于块的变体
  element: string, // 元素(可选),用__连接
  modifier: string // 修饰符(可选),用--连接
) => {
  let cls = `${namespace}-${block}`
  if (blockSuffix) {
    cls += `-${blockSuffix}`
  }
  if (element) {
    cls += `__${element}`
  }
  if (modifier) {
    cls += `--${modifier}`
  }
  return cls
}

### 1. 参数说明

-   namespace:命名空间,通常是 'el'
-   block:块名,如 'button'
-   blockSuffix:块后缀(可选),用于块的变体
-   element:元素(可选),用 __ 连接
-   modifier:修饰符(可选),用 -- 连接

### 2. 生成规则(按顺序拼接)

-   基础:namespace-block → 'el-button'
-   如果有 blockSuffix:追加 -${blockSuffix} → 'el-button-suffix'
-   如果有 element:追加 __${element} → 'el-button__icon'
-   如果有 modifier:追加 --${modifier} → 'el-button--primary'

el-button组件为例子

const ns = useNamespace('button')

ns.namespace.value  // → 'el'
  • b-Block(块)
const b = (blockSuffix = '') => _bem(namespace.value, block, blockSuffix, '', '')

ns.b()  // el-button
ns.b('group')  // el-button-group
  • e-Element(元素)
const e = (element?: string) => element ? _bem(namespace.value, block, '', element, '') : ''

ns.e('icon')  // el-button__icon
ns.e('text')  // el-button__text
ns.e()  // 返回一个空字符串'', 因为传入的element:string参数是空
  • e-Modifier(修饰符)
const m = (modifier?: string) => modifier ? _bem(namespace.value, block, '', '', modifier) : ''

ns.m('primary')  // el-button--primary
ns.m('small')  // el-button--small
ns.m('disabled')  // el-button--disabled
ns.m()  // '' (空字符串)
  • be-Block+Element (块后缀+元素)
  const be = (blockSuffix?: string, element?: string) =>
    blockSuffix && element
      ? _bem(namespace.value, block, blockSuffix, element, '')
      : ''

ns.be('group', 'item') // el-button-group__item
ns.be('group', '') // ''
ns.be('', 'group')  // ''
  • em-Element+Modifier (元素+修饰符)
  const em = (element?: string, modifier?: string) =>
    element && modifier
      ? _bem(namespace.value, block, '', element, modifier)
      : ''
      
ns.em('icon', 'loading') // el-button__icon--loading
ns.em('text', 'expand') // el-button__text--expand
ns.em('icon', '') // ''
ns.em('', 'loading') // ''
  • bm-Block+Modifier (块后缀+修饰符)
  const bm = (blockSuffix?: string, modifier?: string) =>
    blockSuffix && modifier
      ? _bem(namespace.value, block, blockSuffix, '', modifier)
      : ''
      
ns.bm('group', 'vertical') // el-button-group--vertical
ns.bm('group', '') // ''
ns.bm('', 'primary') // ''
  • bem-Block+Element+Modifier (块后缀+元素+修饰符)
  const bem = (blockSuffix?: string, element?: string, modifier?: string) =>
    blockSuffix && element && modifier
      ? _bem(namespace.value, block, blockSuffix, element, modifier)
      : ''
      
ns.bem('group', 'item', 'active') // el-button-group__item--active
ns.bem('group', 'item', '') // '' 必须三个参数都有值
  • is-State 状态类
  const statePrefix = 'is-'

  const is: {
    (name: string, state: boolean | undefined): string
    (name: string): string
  } = (name: string, ...args: [boolean | undefined] | []) => {
    const state = args.length >= 1 ? args[0]! : true // args[0]! ts的非空断言
    return name && state ? `${statePrefix}${name}` : ''
  }
  
ns.is('loading')  // is-loading
ns.is('loading', true)  // is-loading
ns.is('loading', false)  // ''
ns.is('disabled', true)  // is-disabled
ns.is('disabled', undefined) // ''
  • cssVar-CSS变量(全局命名空间)
  const cssVar = (object: Record<string, string>) => {
    const styles: Record<string, string> = {}
    for (const key in object) {
      if (object[key]) {
        styles[`--${namespace.value}-${key}`] = object[key]
      }
    }
    return styles
  }
  
  ns.cssVar({ color: 'red', size: '10px'}) // {'--el-color': 'red', '--el-size': '10px'}
  • cssVarName-CSS 变量名(全局)
const cssVarName = (name: string) => `--${namespace.value}-${name}`

ns.cssVarName('color')  // → '--el-color'
ns.cssVarName('size')   // → '--el-size'

补充:命名空间与变量名的区别 命名空间:用{}包裹起来的批量的CSS变量+赋值,可以直接绑定到元素的style属性上 变量名:仅仅是一个单独的没有被赋值的变量,需要自己使用

cssVar 的使用场景(批量设置变量值)

<template>
  <div :style="customStyles">
    <!-- 这个 div 会应用这些 CSS 变量 -->
  </div>
</template>

<script setup>
const ns = useNamespace('button')
const customStyles = ns.cssVar({
  color: 'blue',
  fontSize: '16px'
})
// customStyles = { '--el-color': 'blue', '--el-fontSize': '16px' }
</script>

cssVarName 的使用场景(引用已存在的变量)

<template>
  <div :style="{ color: `var(${colorVarName})` }">
    <!-- 使用 cssVarName 获取变量名,然后用 var() 引用 -->
  </div>
</template>

<script setup>
const ns = useNamespace('button')
const colorVarName = ns.cssVarName('color')
// colorVarName = '--el-color'

// 然后在 CSS 或 style 中使用:
// color: var(--el-color)
</script>
  • cssVarBlock-CSS变量(带block)
  const cssVarBlock = (object: Record<string, string>) => {
    const styles: Record<string, string> = {}
    for (const key in object) {
      if (object[key]) {
        styles[`--${namespace.value}-${block}-${key}`] = object[key]
      }
    }
    return styles
  }
  
============
// 步骤 1: 创建命名空间实例,传入 'button' 作为 block
const ns = useNamespace('button')
// 此时 ns 内部保存了 block = 'button'

// 步骤 2: 调用 cssVarBlock
ns.cssVarBlock({ color: 'blue', fontSize: '14px' })

// 步骤 3: cssVarBlock 内部使用闭包中的 block
// 生成:'--el-button-color': 'blue'
// 生成:'--el-button-fontSize': '14px'
===========
ns.cssVarBlock({ color: 'blue', fontSize: '14px' })
// → { '--el-button-color': 'blue', '--el-button-fontSize': '14px' }
  • cssVarBlockName-CSS变量名(带block)
  const cssVarBlockName = (name: string) =>
    `--${namespace.value}-${block}-${name}`

ns.cssVarBlockName('color') // --el-button-color
ns.cssVarBlockName('bgColor') // --el-button-bgColor

深入理解 Async/Await:现代 JavaScript 异步编程的优雅解决方案

在现代 JavaScript 开发中,异步编程是一个无法回避的话题。从早期的回调函数到 Promise,再到 Generator 函数,JavaScript 一直在探索更优雅的异步编程解决方案。而 async/await 的出现,可以说是 JavaScript 异步编程领域的一次重大突破,它让异步代码的书写和阅读变得更加直观和简洁。

什么是 Async 函数?

Async 函数实际上是 Generator 函数的语法糖,但它在多个方面进行了重要优化,使得异步编程变得更加简单和直观。

内置执行器

与 Generator 函数需要额外的执行器(如 co 模块)不同,async 函数内置了执行器,可以像普通函数一样直接调用:

javascript

复制下载

async function fn() {
  return '张三';
}

const result = fn(); // 直接调用,无需额外执行器
console.log(result); // Promise {<fulfilled>: '张三'}

更好的语义

从字面上看,async 和 await 关键字直接表达了异步操作的语义。async 表示函数内部有异步操作,await 表示需要等待一个异步操作的完成。这种直观的表达方式大大提高了代码的可读性。

更广的适用性

await 命令后面不仅可以跟 Promise 对象,还可以跟原始类型的值(数值、字符串、布尔值等),这时这些值会被自动转成立即 resolve 的 Promise 对象:

javascript

复制下载

async function f() {
  const a = await 'hello'; // 等同于 await Promise.resolve('hello')
  const b = await 123;     // 等同于 await Promise.resolve(123)
  return a + b;
}

f().then(console.log); // 'hello123'

返回值是 Promise

async 函数总是返回一个 Promise 对象,这意味着我们可以使用 then 方法链式处理异步操作的结果:

javascript

复制下载

async function fn() {
  return '张三';
}

fn().then(value => {
  console.log(value); // '张三'
});

Async 函数的返回值详解

async 函数的返回值行为有几种不同的情况,理解这些细节对于正确使用 async 函数至关重要。

返回非 Promise 类型的对象

当 async 函数返回一个非 Promise 类型的对象时,返回值会被包装成一个成功状态的 Promise 对象:

javascript

复制下载

async function fn() {
  return '张三';
}

const result = fn();
console.log(result); // Promise {<fulfilled>: '张三'}

fn().then(value => {
  console.log(value); // '张三'
});

抛出错误

当 async 函数内部抛出错误时,返回值是一个失败状态的 Promise:

javascript

复制下载

async function fn() {
  throw new Error('出错了');
}

const result = fn();
console.log(result); // Promise {<rejected>: Error: 出错了}

fn().then(
  value => console.log(value),
  reason => console.log(reason) // Error: 出错了
);

返回 Promise 对象

当 async 函数返回一个 Promise 对象时,该 Promise 对象的状态决定了 async 函数返回的 Promise 状态:

javascript

复制下载

async function fn() {
  return new Promise((resolve, reject) => {
    // resolve('成功了');
    reject('失败了');
  });
}

const result = fn();
console.log(result); // Promise {<rejected>: '失败了'}

fn().then(
  value => console.log(value),
  reason => console.log(reason) // '失败了'
);

Await 表达式的深入理解

await 表达式是 async/await 的核心,它只能在 async 函数内部使用,具有以下几个重要特性。

等待 Promise 完成

await 后面通常跟一个 Promise 对象,它会暂停 async 函数的执行,等待 Promise 完成,然后返回 Promise 的成功值

javascript

复制下载

const p = new Promise((resolve, reject) => {
  resolve('成功了');
  // reject('失败了');
});

async function f1() {
  const result = await p;
  console.log(result); // '成功了'
}
f1();

错误处理

当 await 后面的 Promise 变为拒绝状态时,await 表达式会抛出异常,需要通过 try...catch 结构来捕获:

javascript

复制下载

const p = new Promise((resolve, reject) => {
  reject('失败了');
});

async function f2() {
  try {
    const result = await p;
    console.log(result);
  } catch(err) {
    console.log(err); // '失败了'
  }
}
f2();

等待 Thenable 对象

await 后面不仅可以跟 Promise 对象,还可以跟任何定义了 then 方法的对象(thenable 对象),await 会将其视为 Promise 对象来处理:

javascript

复制下载

class Sleep {
  constructor(timeout) {
    this.timeout = timeout;
  }
  then(resolve, reject) {
    const startTime = Date.now();
    setTimeout(() => {
      resolve(Date.now() - startTime);
    }, this.timeout);
  }
}

(async () => {
  const sleepTime = await new Sleep(1000);
  console.log(sleepTime); // 大约 1000
})();

这种特性使得我们可以创建自定义的异步操作,只要对象实现了 then 方法,就可以与 await 一起使用。

错误处理策略

在 async 函数中,错误处理是一个需要特别注意的方面。

中断执行的问题

默认情况下,任何一个 await 语句后面的 Promise 对象变为 reject 状态,那么整个 async 函数都会中断执行:

javascript

复制下载

async function f() {
  await Promise.reject('出错了');
  await Promise.resolve('hello world'); // 不会执行
}

防止中断执行的策略

有时我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将 await 放在 try...catch 结构里面:

javascript

复制下载

async function f() {
  try {
    await Promise.reject('出错了');
  } catch (e) {
    // 捕获错误,但不中断执行
  }
  return await Promise.resolve('hello world');
}

f().then(v => console.log(v)); // 'hello world'

实际应用场景

文件读取

async/await 在处理多个顺序执行的异步操作时特别有用,比如文件读取:

// 模拟文件读取函数
function read1() {
  return new Promise((resolve, reject) => {
    // 模拟异步文件读取
    setTimeout(() => {
      resolve('文件1的内容');
    }, 1000);
  })
}

function read2() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('文件2的内容');
    }, 1000);
  })
}

function read3() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('文件3的内容');
    }, 1000);
  })
}

async function main() {
  try {
    const result1 = await read1();
    console.log(result1);
    const result2 = await read2();
    console.log(result2);
    const result3 = await read3();
    console.log(result3);
  } catch(err) {
    console.log(err);
  }
}

main();

并发执行优化

虽然上面的例子展示了顺序执行异步操作,但在实际开发中,如果多个异步操作之间没有依赖关系,我们可以使用 Promise.all 来并发执行,提高效率:

javascript

复制下载

async function main() {
  try {
    const [result1, result2, result3] = await Promise.all([
      read1(),
      read2(),
      read3()
    ]);
    console.log(result1, result2, result3);
  } catch(err) {
    console.log(err);
  }
}

Async/Await 与传统异步方案的对比

与 Promise 链的对比

使用传统的 Promise 链:

javascript

复制下载

function fetchData() {
  return fetch('/api/data1')
    .then(response => response.json())
    .then(data1 => {
      return fetch('/api/data2')
        .then(response => response.json())
        .then(data2 => {
          return { data1, data2 };
        });
    });
}

使用 async/await:

javascript

复制下载

async function fetchData() {
  const response1 = await fetch('/api/data1');
  const data1 = await response1.json();
  
  const response2 = await fetch('/api/data2');
  const data2 = await response2.json();
  
  return { data1, data2 };
}

可以看到,async/await 版本的代码更加直观,逻辑更加清晰。

与 Generator 函数的对比

使用 Generator 函数处理异步:

javascript

复制下载

function* fetchData() {
  const response1 = yield fetch('/api/data1');
  const data1 = yield response1.json();
  
  const response2 = yield fetch('/api/data2');
  const data2 = yield response2.json();
  
  return { data1, data2 };
}

// 需要执行器
function run(generator) {
  const iterator = generator();
  
  function iterate(iteration) {
    if (iteration.done) return iteration.value;
    const promise = iteration.value;
    return promise.then(result => iterate(iterator.next(result)));
  }
  
  return iterate(iterator.next());
}

run(fetchData);

使用 async/await:

javascript

复制下载

async function fetchData() {
  const response1 = await fetch('/api/data1');
  const data1 = await response1.json();
  
  const response2 = await fetch('/api/data2');
  const data2 = await response2.json();
  
  return { data1, data2 };
}

// 直接调用
fetchData();

明显可以看出,async/await 方案更加简洁,无需额外的执行器。

最佳实践和注意事项

1. 始终处理错误

在使用 async/await 时,不要忘记错误处理。可以使用 try...catch 结构,或者使用 .catch() 方法:

javascript

复制下载

// 方式一:使用 try...catch
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('获取数据失败:', error);
    throw error; // 或者返回默认值
  }
}

// 方式二:使用 .catch()
fetchData().catch(error => {
  console.error('获取数据失败:', error);
});

2. 避免不必要的 await

不要滥用 await,只有在需要等待异步操作完成时才使用它:

javascript

复制下载

// 不推荐
async function example() {
  const a = await 1; // 不必要的 await
  const b = await 2; // 不必要的 await
  return a + b;
}

// 推荐
async function example() {
  const a = 1;
  const b = 2;
  return a + b;
}

3. 合理使用并发

当多个异步操作之间没有依赖关系时,应该并发执行它们,而不是顺序执行:

// 不推荐 - 顺序执行
async function fetchSequential() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  const comments = await fetchComments();
  return { user, posts, comments };
}

// 推荐 - 并发执行
async function fetchConcurrent() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
  ]);
  return { user, posts, comments };
}

总结

Async/await 是 JavaScript 异步编程的重大进步,它通过更加直观和简洁的语法,让我们能够以近乎同步的方式编写异步代码,同时保持了异步操作的非阻塞特性。

从本质上讲,async 函数是 Generator 函数的语法糖,但它通过内置执行器、更好的语义、更广的适用性和 Promise 返回值等优化,大大提升了开发体验。await 表达式则让我们能够以同步的方式编写异步逻辑,使代码更加清晰易读。

深入理解 JavaScript 原型链:从 Promise.all 到动态原型的实战探索

本文将带你穿越 ES6 异步编程与 JavaScript 面向对象的核心机制,通过一段看似“诡异”的代码,揭示原型链的本质、动态性及其在实际开发中的意义。


引子:一段“奇怪”的代码

先来看这段来自 2.js 的代码:

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

Person.prototype.speci = '人类';

let zhen = new Person('郑总', 18);
console.log(zhen.speci); // 输出:人类

const kong = {
  name: '孔子',
  hobbies: ['读书', '喝酒']
};

zhen.__proto__ = kong;
console.log(zhen.hobbies, zhen.speci); // 输出:['读书', '喝酒'] undefined

你可能会疑惑:

  • 为什么修改 zhen.__proto__ 后,speci 属性就消失了?
  • 这和我们常说的“原型链”有什么关系?
  • 这种操作在真实项目中有用吗?

别急,让我们从 JavaScript 的面向对象本质说起。


一、JavaScript 的面向对象:不是血缘,而是委托

与 Java、C++ 等基于“类继承”的语言不同,JavaScript 的面向对象是基于原型(Prototype)的。它没有“父子类”的血缘概念,只有对象之间的委托关系

核心三要素:

  1. 构造函数(Constructor)
    Person,用于创建实例。
  2. 原型对象(Prototype)
    每个函数都有一个 prototype 属性,指向一个对象,该对象会被实例的 __proto__ 所引用。
  3. 原型链(Prototype Chain)
    当访问一个对象的属性时,若自身没有,则沿着 __proto__ 向上查找,直到 null

✅ 记住:obj.__proto__ === ObjConstructor.prototype

因此,当我们执行:

let zhen = new Person('郑总', 18);

实际上建立了这样的关系:

zhen.__proto__Person.prototypeObject.prototypenull

所以 zhen.speci 能找到 '人类',因为它委托给了 Person.prototype


二、动态原型:运行时改变对象的“行为模板”

关键来了!JavaScript 的原型链是动态可变的

当你写下:

zhen.__proto__ = kong;

你就强行切断了 zhenPerson.prototype 的联系,转而让它委托给 kong 对象。

于是新的原型链变成:

zhen.__proto__ → kong → Object.prototypenull
  • zhen.hobbies → 在 kong 上找到 → ['读书', '喝酒']
  • zhen.specikong 上没有 → 继续找 Object.prototype → 没有 → 返回 undefined

⚠️ 注意:这种操作虽然合法,但性能差且不推荐(现代引擎会优化固定原型链,动态修改会破坏优化)。但在某些特殊场景(如 mock、调试、元编程)中仍有价值。


三、从原型链到异步:Promise.all 与 ES6 的设计哲学

你可能注意到 readme.md 中提到了:

Promise.all Promise es6提供的异步解决方案 实例 proto 指向原型对象

这其实暗示了一个更深层的联系:ES6 的 Promise 也是基于原型链构建的

例如:

const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);

const all = Promise.all([p1, p2]);
console.log(all.__proto__ === Promise.prototype); // true

Promise.all 返回的仍然是一个 Promise 实例,它继承了 Promise.prototype 上的方法(如 .then, .catch)。这体现了 JavaScript 一切皆对象、统一委托模型的设计哲学。

🌟 无论是同步的对象方法,还是异步的 Promise 链,底层都依赖同一个原型机制。


四、思考:原型链 vs 类继承 —— 哪种更灵活?

特性 原型链(JS) 类继承(Java/Python)
运行时修改 ✅ 支持(如 __proto__ ❌ 编译时固定
多继承模拟 ✅ 通过混入(Mixin) ❌ 通常单继承
性能 ⚠️ 动态修改影响优化 ✅ 静态结构易优化
可读性 ❌ 初学者易混淆 ✅ 直观

JavaScript 的原型系统牺牲了一点“直观性”,换来了极致的灵活性。这也是为什么像 Vue、React 等框架能通过原型扩展实现强大的响应式或组件系统。


五、最佳实践建议

  1. 避免直接操作 __proto__
    使用 Object.setPrototypeOf(obj, proto)(仍不推荐),或更好的方式:在创建对象时就确定原型(如 Object.create(proto))。

  2. 理解 constructor 的作用
    Person.prototype.constructor === Person,但如果你重写整个 prototype,记得手动修复:

    Person.prototype = { /* ... */ };
    Person.prototype.constructor = Person; // 修复
    
  3. 善用原型进行方法共享
    将公共方法放在 prototype 上,节省内存:

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

结语:原型链,JavaScript 的灵魂

new Person()Promise.all(),从静态属性到动态委托,原型链是贯穿 JavaScript 语言的核心脉络。它不仅是面试题,更是理解这门语言“为何如此设计”的钥匙。

下次当你看到 __proto__,不要只想到“黑魔法”,而应看到:这是一个对象在运行时寻找答案的旅程

正如孔子曰:“学而不思则罔。”
在 JS 的世界里,用而不悟原型,则码如浮云


pnpm 凭啥吊打 npm/Yarn?前端包管理的 “硬链接魔法”,破解三大痛点

从npm到pnpm——JavaScript包管理工具的演进之路:一场前端工程的“减肥”与“提速”革命

🚀 1. 引言:为什么包管理工具如此重要?

想象一下,你的前端项目是一艘准备远航的巨轮,而那些动辄几百上千的依赖包,就是船上所需的各种物资和零件。如果没有一个高效、可靠的“港口管理员”,这艘船会怎样?

轻则物资堆放混乱,找个零件得翻箱倒柜;重则零件版本不兼容,船还没出海就抛锚了。

JavaScript 这个日新月异的生态中,包管理工具正是扮演着这个至关重要的“港口管理员”角色。它负责依赖的下载、安装、版本控制,确保你的项目能够稳定、高效地运行。

今天,我们不只是回顾历史,而是要进行一场关于 npmYarnpnpm 的“三代同堂”演进分析。我们将看到,每一次工具的迭代,都是前端工程师们对 “更快、更省、更稳” 的不懈追求。

❓ 提出问题:为什么我们需要不断迭代包管理工具?

答案很简单:因为老工具在面对日益庞大和复杂的项目时,已经力不从心了。接下来的故事,就是关于我们如何从“绿皮火车”一路升级到“磁悬浮列车”的历程。

👴 2. npm:包管理的起点与“甜蜜的烦恼”

✨ 2.1. npm的起源和基本功能

npm(Node Package Manager)诞生于 2010 年,是随着 Node.js 一起出现的官方包管理器,可以说是 JavaScript 模块化的奠基人。

它的核心功能简单而强大:

  • 连接庞大的生态: 拥有全球最大的软件包注册表 npmjs.com
  • 核心命令: npm installnpm update,简单粗暴,一键搞定依赖。
  • 版本锁定: 通过 package.json 和后来的 package-lock.json 来管理依赖版本。

⚠️ 2.2. npm的早期问题:“又大又慢的node_modules

npm 早期(尤其是 v2 时代)的依赖管理是 嵌套结构,导致了著名的 “依赖地狱”(Dependency Hell) 。为了解决这个问题,npm v3 引入了 扁平化(Hoisting) 机制。

然而,扁平化虽然解决了“地狱”,却带来了新的“烦恼”:

痛点 描述 形象比喻
安装速度慢 早期 npm 采用串行下载和安装,I/O 操作频繁。 一个人排队去超市买 100 样东西。
磁盘空间浪费 即使 10 个项目都依赖 lodash,每个项目的 node_modules 里都会有一份完整的 lodash 副本。 10 个邻居各自买了一模一样的 10 台电视机。
幽灵依赖 依赖包被提升到根目录,导致项目可以访问未在 package.json 中声明的依赖。 你没买票,却坐上了头等舱,一旦“查票”(依赖升级),你就得露馅。

案例: 想象一个大型 Monorepo 项目,安装一次依赖可能需要 5-10 分钟,而最终生成的 node_modules 文件夹体积轻松突破 5GB。这不仅耗费时间,对 CI/CD 流程也是巨大的负担。

过渡: 面对这些问题,社区开始寻找更快的“跑车”,于是 Yarn 登场了。

🏃 3. Yarn:速度与确定性的改进

✨ 3.1. Yarn的出现背景

2016 年,Facebook(现 Meta)推出了 Yarn,它直接对标 npm 的痛点,喊出了 “更快、更可靠、更安全” 的口号。

🔄 3.2. Yarn如何解决问题

Yarn 的核心改进在于 速度确定性

  1. 速度优化:

    • 并行下载: 告别串行,多个依赖可以同时下载。
    • 离线缓存: 引入全局缓存,如果本地有包,下次安装直接从缓存读取,无需联网。
  2. 确定性保证:

    • yarn.lock 强制引入锁文件,精确记录了依赖树的结构和版本,确保了“在我电脑上能跑,在你电脑上也能跑”的团队协作一致性。

案例: 在一个中型项目中,npm install 可能需要 2 分钟,而 yarn install 往往能缩短到 30 秒 左右,提速效果立竿见影。

⚠️ 3.3. Yarn的局限性:未解决的“肥胖”问题

Yarn 成功解决了速度和确定性问题,但它在 磁盘空间利用率 上,依然沿用了 npm 的扁平化结构,这意味着:

  • node_modules 依然庞大: 尽管安装快了,但每个项目依然要存储一份依赖副本,磁盘空间浪费问题没有根本解决。
  • 幽灵依赖仍在: 扁平化结构是幽灵依赖的温床,项目仍然可能意外地使用到未声明的依赖。

过渡: 既然速度已经够快,下一个目标自然是 “如何让我们的项目更瘦、更安全” 。于是,一个专注于“极致效率”的工具——pnpm 出现了。

🚀 4. pnpm:高效与存储优化的新时代

✨ 4.1. pnpm的起源与核心思想

pnpm(Performant npm)由 Zoltan Kochan 在 2017 年开发,它的核心思想是: “只存一份,多处使用” 。它通过一种巧妙的文件系统操作,彻底解决了困扰前端多年的 磁盘空间浪费幽灵依赖 问题。

🔧 4.2. pnpm如何实现“瘦身”与“提速”

pnpm 的魔法在于它对 node_modules 结构的颠覆:

1. 终极“瘦身”秘诀:内容可寻址存储 + 硬链接

pnpm 在你的电脑上创建了一个 全局内容可寻址存储区(Content-addressable Store) ,所有依赖包的实际文件内容都只在这个地方 存储一份

当你在项目 A 和项目 B 中安装 lodash 时,pnpm 不会复制文件,而是通过 硬链接(Hard Link) 的方式,将全局仓库中的 lodash 文件链接到项目 A 和 B 的 node_modules 中。

  • 硬链接 几乎不占用额外的磁盘空间。
  • 这意味着,你有 100 个项目依赖 lodash,它在你的硬盘上也只占用 一份 空间。

storage_comparison.png

2. 告别“幽灵依赖”:严格的符号链接结构

pnpm 采用了一种 非扁平化node_modules 结构,它通过 符号链接(Symbolic Link) 来严格控制依赖的访问权限。

在 pnpm 的 node_modules 根目录下,你只会看到你 显式声明 的依赖包。这些包实际上是指向一个特殊目录(.pnpm)的符号链接。

// 你的项目根目录下的 node_modules 结构
node_modules/
  .pnpm/  // 实际的依赖文件都在这里,通过硬链接指向全局仓库
  └── my-project -> .pnpm/my-project@1.0.0/node_modules/my-project
  └── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash

这种结构保证了:

  • 安全性: 你只能访问你声明的依赖,彻底杜绝了“幽灵依赖”的隐患。
  • 速度: 由于大量使用了链接而非复制,安装速度比 Yarn 更快,尤其是在 CI/CD 环境中。

📊 4.3. pnpm相对于npm和Yarn的改进总结

特性 npm (v3+) Yarn (v1) pnpm
模块结构 扁平化 (Hoisting) 扁平化 (Hoisting) 嵌套 + 符号链接
磁盘占用 高(多份副本) 中(多份副本) 最低(全局硬链接)
安装速度 慢/中 极快(链接操作)
依赖隔离 不严格(有幽灵依赖) 不严格(有幽灵依赖) 严格(默认杜绝幽灵依赖)
Monorepo支持 Workspaces Workspaces 最佳(内置支持,高效复用)

案例: 在一个拥有 10 个子包的 Monorepo 项目中,pnpm 可以节省 70% 以上 的磁盘空间,并且在 CI/CD 流程中,安装时间可以从 3 分钟缩短到 30 秒以内

过渡: pnpm 几乎是当前包管理工具的“最优解”,但它并非完美无缺。

🚧 5. 当前包管理工具的挑战与问题

❌ 5.1. 通用问题:生态的“隐形炸弹”

  • 生态碎片化: package-lock.jsonyarn.lockpnpm-lock.yaml 互不兼容,团队协作中切换工具容易引发混乱。
  • 安全漏洞: 供应链攻击(Supply Chain Attacks)日益猖獗,依赖链越长,风险越高。

❓ 5.2. pnpm特有问题:学习曲线与兼容性

pnpm 虽好,但也有其“个性”:

  • 学习曲线稍陡: 严格的依赖结构(非扁平化)可能让习惯了 npm/Yarn 扁平结构的开发者感到不适应,尤其是在处理一些老旧的、依赖于“幽灵依赖”特性的库时。
  • 迁移难度: 对于大型老项目,从 npm/Yarn 迁移到 pnpm 可能需要调整部分代码,以修复因幽灵依赖被消除而引发的错误。
  • 文件系统兼容性: 硬链接和符号链接在某些非主流文件系统或 Windows 的 WSL 环境下,可能会遇到一些权限或兼容性问题(不过目前已基本解决)。

未来展望:Bun 这样内置了包管理器的工具正在出现,它们试图将包管理、运行时和构建工具三合一,或许能从根本上解决这些痛点。

💡 6. 总结对比:npm vs Yarn vs pnpm

维度 npm (v3+) Yarn (v1) pnpm
演进定位 奠基者 速度优化者 效率与存储优化者
核心机制 扁平化复制 扁平化复制 + 缓存 硬链接 + 符号链接
磁盘空间 浪费严重 浪费严重 极致节省
安装速度 极快
依赖安全 差(幽灵依赖) 差(幽灵依赖) 优秀(严格隔离)
Monorepo 支持(一般) 支持(较好) 最佳(高效复用)
适用场景 小型、个人项目 中型项目、追求速度 大型/企业级项目、Monorepo

演进路径回顾:

  1. npm: 解决了“有没有”的问题,但带来了“大”和“慢”的问题。
  2. Yarn: 解决了“慢”的问题,但没有解决“大”的问题。
  3. pnpm: 彻底解决了“大”和“幽灵依赖”的问题,同时将“快”推向了极致。

🏆 7. 公司推荐:为什么选择pnpm?

基于以上对比,我的建议非常明确:

对于任何追求工程化、拥有多个项目或正在使用 Monorepo 架构的团队,pnpm 都是当前最值得推荐的包管理工具。

选择 pnpm,你选择的不仅仅是更快的安装速度,更是:

  1. 巨大的成本节约: 节省 CI/CD 运行时间,就是节省金钱。
  2. 提升开发体验: 告别漫长的 npm install 等待,将更多时间投入到业务开发中。
  3. 项目稳定性: 严格的依赖隔离机制,从根本上杜绝了因“幽灵依赖”引发的潜在 Bug。

实施建议:

  • 新项目: 直接使用 pnpm init 启动。
  • 老项目迁移: 建议先在非核心项目尝试,通过 pnpm import 导入 package-lock.jsonyarn.lock,然后运行 pnpm install,并根据报错信息修复因幽灵依赖导致的错误。

结语:

前端工程化的发展,就是不断地在追求极致的效率和稳定性。包管理工具的演进,清晰地展现了这一点。拥抱 pnpm,就是拥抱更高效、更稳定的前端未来。

你还在用 npm 吗?是时候换个“跑鞋”了!

❌