普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月12日技术

我用 Gemini 3 Pro 手搓了一个并发邮件群发神器(附源码)

作者 ErpanOmer
2026年1月12日 12:16

这个周末我失业了🤣。

起因很简单:公司项目原因,我需要给订阅列表里的几千个用户发一封更新通知。 市面上的邮件营销工具(Mailchimp 之类)死贵,还要一个个导入联系人;自己写脚本吧,以前得折腾半天 SMTP 协议、搞定并发限制、处理失败重试……想想就头秃😖。

正好有 Gemini 3 Pro ,代码能力逆天。我就想试探一下它的底线。 结果这一试,我后背发凉。

我本来只想要个 Demo,它直接给了我一个带 连接池 (Connection Pool)、任务队列 (Task Queue)、甚至还顺手写了 服务端渲染 (SSR) 的完整全栈应用。

image.png

以前我写这套东西起码得两天,这次连写带调,2 小时收工

今天复盘一下我是怎么压榨 Antigravity - Gemini 3 Pro 开发出这个 Turbo Mail Sender 的。源码我已经开源到 GitHub 了,文末自取,记得给个 Star😁!


不仅要看得下去,还要骚气点🤔

我给第一个 Prompt 很简单,但也很刁钻:

我要做一个邮件群发工具的单页 UI。要求:

  1. 写邮件(集成 Quill 富文本编辑器)。

  2. 包含监控发送进度(要有一个骚气的进度条和实时日志)。

  3. 用 Tailwind CSS,设计风格要轻色单栏,带点微交互动画。

  4. 直接给我 HTML 单文件就行。

它思考了大概几分钟(网络环境有点差😥)。 它没有给我堆砌 div,它直接甩出了基于 Tailwind 的完整布局,甚至贴心地加上了 @keyframes fadeInUp 入场动画。

看看这个生成的 UI 代码片段,它连 backdrop-filtershadow 的细节都处理好了:

<style>
    @keyframes fadeInUp { from { opacity: 0; transform: translateY(6px);} to { opacity:1; transform: translateY(0); } }
    .log-entry { opacity:0; transform:translateY(6px); animation: fadeInUp 420ms ease forwards; }
    /* 进度条的光泽动画,这审美绝了 */
    @keyframes shimmer { 0% { background-position: -200px 0 } 100% { background-position: 200px 0 } }
</style>

screenshot-20260112-115836.png

最骚的是,它连 JS 里的富文本编辑器初始化都帮我写好了,甚至考虑到了 SSR 的数据回填逻辑。这哪里是 AI,这简直就是自带 3 年经验的前端同事。


从普通的脚本到工程化

UI 画好了,接下来是后端。这里才是见真章的地方。 很多新手写发邮件脚本,就是写个 for 循环调用 sendMail。结果就是发 100 封后 IP 被封,或者内存溢出。

我给 Gemini 的 Prompt 是:

后端用 Express + Nodemailer。注意,我要发送几万封邮件。

  1. 必须有 连接池 (Connection Pooling),不能每次发送都握手。

  2. 必须有 生产者-消费者队列,控制并发数(Concurrency)。

  3. 失败要自动重试。

  4. 给我写出架构级的代码。

如果是以前的 AI,大概率会给我瞎编一个队列。 但它给出的 server.js,让我这个老前端都挑不出毛病。🤔

自动实现了连接池缓存

它自己封装了一个 TransporterCache 类,防止重复创建 SMTP 连接。这波操作直接把性能拉满了。

// server.js 核心片段:连接池
class TransporterCache {
    constructor() {
        this.cache = new Map();
    }
    get(host, port, user, pass) {
        const key = `${user}@${host}`;
        if (this.cache.has(key)) return this.cache.get(key);

        // Gemini 居然知道开启 pool: true 选项
        const transporter = nodemailer.createTransport({
            pool: true, 
            host: host,
            port: Number(port),
            secure: Number(port) === 465,
            auth: { user, pass },
            maxConnections: 3, // 自动限制最大连接数
            rateLimit: 5       // 还有速率限制,讲究
        });
        this.cache.set(key, transporter);
        return transporter;
    }
}

像模像样的任务队列

它没有引入 Redis(因为我要轻量级),而是手搓了一个内存版的 TaskManagerWorker

// server.js 核心片段:消费者 Worker
class Worker {
    constructor() { setInterval(() => this.tick(), 500); }

    tick() {
        // 经典的消费逻辑:控制并发数
        while (taskManager.running < CONFIG.CONCURRENCY && taskManager.queue.length > 0) {
            const id = taskManager.queue.shift();
            const task = taskManager.getTask(id);
            if (task) this.processTask(task);
        }
    }
    // ...发送与重试逻辑
}

看到这段代码时,我意识到:Gemini 不仅仅是在翻译需求,它更懂架构。

20260112-120211.jpg


最后的细节修改

做到这里,工具已经能用了。但我故意刁难了一下 Gemini:

现在的配置(SMTP服务器、端口)每次刷新页面就没了,体验太差。但我不想用数据库。你想办法解决一下。

Gemini 3 Pro 给出的方案是:SSR (服务端渲染) + LocalStorage 双重兜底

它直接修改了 server.js/ 路由,在返回 HTML 之前,把 URL 参数里的配置通过正则替换注入到 HTML 中。不用数据库,却实现了配置持久化的错觉。

// server.js:极其暴力的正则替换 SSR,简单粗暴但有效
if (host) {
    html = html.replace(/id="smtpHost"\s+type="text"\s+value="[^"]*"/,
        `id="smtpHost" type="text" value="${host}"`);
}

说实话,这种正则替换虽然看起来很脏,但在这种微型工具里,不仅省事,而且性能极高。AI 这种能力(知道是小工具就不上重型框架),才是最可怕的。


成果与开源

最终,这个 Turbo Mail Sender 具备了以下能力:

  • 🚀 高并发发送:连接池 + 异步队列。
  • 📊 实时可视化:能看到每一封邮件的发送状态、耗时。
  • 🛡️ 智能重试:遇到网络抖动自动重发。
  • 📂 CSV 导入:支持批量导入收件人。

我把整个项目打包开源了。如果你也需要一个免费、私有、无限制的邮件群发工具,或者你想研究一下 Gemini 3 Pro 生成的代码到底有多工整欢迎来 GitHub 提 👉 Issue

screenshot-20260112-120615.png


以前我们常说全栈工程师。 做完这个项目我觉得,以后可能只有一种工程师,叫 Prompt 工程师

这个项目里,CSS 是 AI 写的,后端逻辑是 AI 写的,重试算法是 AI 写的。 我做了什么? 我负责定义问题,负责Code Review,负责把它们组装起来。怎么利用 AI,在 2 小时内搞定别人 2 天的工作量。

好了,今天就分享到这儿吧😁

在线体验: 👉 Turbo Mail Sender (觉得好用记得点个 Star,孩子想上热榜🤣)

前端算法必备:滑动窗口从入门到很熟练(最长/最短/计数三大类型)

作者 颜酱
2026年1月12日 12:15

前端算法必备:滑动窗口从入门到很熟练(最长/最短/计数三大类型)

📺 推荐视频滑动窗口算法详解 - 视频解释非常清晰,建议先看视频再阅读本文!

📚 相关文档双指针详解 - 滑动窗口是双指针的重要应用

在算法面试中,子串、子数组相关的问题频繁出现,暴力枚举往往因 O(n²) 时间复杂度超时。而滑动窗口算法,凭借其 O(n) 的高效性能,成为解决这类问题的"神兵利器"。本文将从原理本质出发,梳理滑动窗口的分类、适用场景,提炼通用模板,并结合经典例题实战拆解,帮你彻底掌握这一核心算法。

📑 目录

一、滑动窗口核心原理:用单调性压缩遍历维度

滑动窗口的本质,是利用区间的单调性,将原本需要嵌套遍历(O(n²))的连续区间问题,转化为单轮双指针遍历(O(n))。其核心逻辑基于对“窗口状态”的精准把控,通过两个指针(left 左边界、right 右边界)的协同移动,跳过无效区间(剪枝),实现高效枚举。

1.1 先搞懂:暴力枚举的痛点

以“无重复字符的最长子串”为例,暴力思路是枚举所有子串的起点 i 和终点 j(i≤j),检查子串 s[i..j] 是否无重复,最终记录最长长度。这种方式需要遍历所有 i、j 组合,时间复杂度 O(n²),且存在大量无效计算:比如当 s[0..3] 存在重复时,s[0..4]、s[0..5] 等包含该区间的子串必然也重复,无需再检查。

1.2 滑动窗口的核心洞察:区间单调性

滑动窗口能优化的关键,是抓住了「窗口状态的单调性」—— 窗口的状态(如是否含重复、和/积是否满足条件)会随窗口的扩展/缩小呈现单向变化,具体可总结为两条核心规律:

  • 规律1(坏状态的包含性):若窗口 [left, right] 处于“坏状态”(如含重复字符、和≥target、积≥K),则所有包含该窗口的更大窗口 [left, right+1]、[left, right+2]... 必然也是“坏状态”。此时无需继续扩展 right,应移动 left 缩小窗口,跳过无效区间。

  • 规律2(好状态的被包含性):若窗口 [left, right] 处于“好状态”(如无重复、和<target、积<K),则所有被该窗口包含的更小窗口 [left+1, right]、[left+2, right]... 必然也是“好状态”。此时无需缩小窗口,应继续扩展 right 寻找更优解。

1.3 一句话总结原理

滑动窗口通过 right 指针“扩窗口”探索新的区间,通过 left 指针“缩窗口”剔除无效区间,每个元素最多被加入窗口(right 移动)和移出窗口(left 移动)各一次,最终以 O(n) 时间完成所有有效区间的枚举。

1.4 剪枝思想:每次移动指针"干掉"某些组合

🎯 交互演示点击这里查看动态演示 - 通过交互式可视化,直观看到每一步剪掉的组合!

核心思想:滑动窗口和相向指针一样,都通过移动指针来"干掉"某些组合,实现从 O(n²) 到 O(n) 的优化。

1.4.1 理解暴力枚举的搜索空间

以"无重复字符的最长子串"为例,字符串 s = "abcabcbb"

暴力枚举需要检查所有可能的 (i, j) 组合,其中 i ≤ j。这形成了一个矩阵:

所有可能的组合 (i, j),其中 i ≤ j:

      j=0  1  2  3  4  5  6  7
i=0   00 01 02 03 04 05 06 07  ← 第0i=1   -  11 12 13 14 15 16 17  ← 第1i=2   -  -  22 23 24 25 26 27  ← 第2i=3   -  -  -  33 34 35 36 37  ← 第3i=4   -  -  -  -  44 45 46 47  ← 第4i=5   -  -  -  -  -  55 56 57  ← 第5i=6   -  -  -  -  -  -  66 67  ← 第6i=7   -  -  -  -  -  -  -  77  ← 第7行

总共有 N*(N+1)/2 = 8*9/2 = 36 个组合需要检查
时间复杂度:O(n²)

1.4.2 滑动窗口的剪枝策略

剪枝规则1:如果 (left, right) 存在重复字符,则 (left, right+1...end) 都存在重复字符

假设当前 left = 0, right = 3,窗口 [0,3] = "abca" 包含重复字符 'a':

当前状态:left=0, right=3
当前窗口:[0,3] = "abca"(存在重复字符 'a')

矩阵中当前检查的位置:
      j=0  1  2  3  4  5  6  7
i=0   00 01 02 [03] 04 05 06 07  ← 当前检查 (0,3)
i=1   -  11 12 13 14 15 16 17
i=2   -  -  22 23 24 25 26 27
...

剪枝逻辑:
如果 (left, right) 存在重复字符,那么:
- 所有 (left, right+1) ... (left, end) 都包含重复字符
- 因为窗口 [left, right+1] 包含窗口 [left, right],必然也重复

因此,可以剪掉第 left 行的所有后续组合:
      j=0  1  2  3  4  5  6  7
i=0   00 01 02 [03] ✂️ ✂️ ✂️ ✂️ ✂️  ← 剪掉整行!
i=1   -  11 12 13 14 15 16 17
i=2   -  -  22 23 24 25 26 27
...

移动 left++,跳过第0行的所有剩余组合

剪枝规则2:如果 (left, right) 不存在重复字符,则 (left+1...right, right) 也不存在重复字符

当前状态:left=0, right=2
当前窗口:[0,2] = "abc"(不存在重复字符)

矩阵中当前检查的位置:
      j=0  1  2  3  4  5  6  7
i=0   00 01 [02] 03 04 05 06 07  ← 当前检查 (0,2)
i=1   -  11 12 13 14 15 16 17
i=2   -  -  22 23 24 25 26 27
...

剪枝逻辑:
如果 (left, right) 不存在重复字符,那么:
- 所有 (left+1, right) ... (right, right) 都不存在重复字符
- 因为窗口 [left+1, right] 是窗口 [left, right] 的子集

因此,可以继续扩展 right,探索更长的有效窗口
移动 right++,继续探索(不剪枝,但避免重复检查)

1.4.3 剪枝效果可视化

每次移动指针,都会剪掉整行整列,大大减少搜索空间:

字符串:"abcabcbb"
初始:需要检查 36 个组合

第1步:left=0, right=0, 窗口="a"(无重复)
       移动 right++,继续探索
       剩余:36 个组合(未剪枝,但只检查了1个)

第2步:left=0, right=1, 窗口="ab"(无重复)
       移动 right++,继续探索
       剩余:36 个组合(未剪枝,但只检查了2个)

第3步:left=0, right=2, 窗口="abc"(无重复)
       移动 right++,继续探索
       剩余:36 个组合(未剪枝,但只检查了3个)

第4步:left=0, right=3, 窗口="abca"(有重复!)
       移动 left++,剪掉第0行的所有剩余组合(4个组合)
       剩余:36 - 4 = 32 个组合

第5步:left=1, right=3, 窗口="bca"(无重复)
       移动 right++,继续探索
       剩余:32 个组合(未剪枝,但只检查了5个)

... 继续剪枝

最终:只需要检查 O(n) 个组合,而不是 O(n²)

核心思想总结

  1. 移动 left 指针:当窗口存在重复字符时,移动 left++ → 剪掉第 left 行的所有剩余组合
  2. 移动 right 指针:当窗口无重复字符时,移动 right++ → 继续探索(避免重复检查)
  3. 共同点:每次移动指针,都会"干掉"某些组合,避免无效计算

这与相向指针的剪枝思想完全一致:通过移动指针剪掉整行或整列,实现 O(n²) → O(n) 的优化

💡 提示:想要更直观地理解剪枝过程?点击这里体验交互式演示,每一步都能看到被剪掉的组合!

二、滑动窗口的分类:按目标场景划分

滑动窗口的核心逻辑一致,但根据问题目标(求最长、求最短、求计数)的不同,缩窗口的条件和更新答案的时机会有差异。按目标可分为三大类,覆盖绝大多数经典场景:

分类 核心目标 缩窗口条件 更新答案时机 典型问题
类型1:求最长/最大区间 找到满足“好状态”的最长连续区间 窗口进入“坏状态”时,缩 left 至回到“好状态” 缩窗口完成后,每次扩展 right 后更新 无重复字符的最长子串、最长重复子数组
类型2:求最短/最小区间 找到满足“好状态”的最短连续区间 窗口进入“好状态”时,缩 left 至回到“坏状态”(尽可能缩小窗口) 缩窗口过程中,每次缩小 left 后更新 长度最小的子数组、最小覆盖子串
类型3:求计数/统计区间 统计所有满足“好状态”的连续区间个数 窗口进入“坏状态”时,缩 left 至回到“好状态” 缩窗口完成后,累加当前 right 对应的有效区间数(right-left+1) 乘积小于 K 的子数组、找到字符串中所有字母异位词

三、适用场景:3个核心判断标准

并非所有子串/子数组问题都能用滑动窗口,需满足以下 3 个核心条件,缺一不可:

  1. 问题对象是连续区间:滑动窗口仅适用于“连续子串”或“连续子数组”问题,非连续区间(如子序列)不适用。

  2. 窗口状态具有单调性:需满足前文提到的两条规律之一,即扩展/缩小窗口时,状态变化是单向的。反例:“找和为 target 的子数组(含负数值)”,窗口 [left, right] 和为 target 时,扩展 right 可能因负数导致和变小,打破单调性,无法用滑动窗口。

  3. 状态可快速更新:加入 right 元素或移出 left 元素时,窗口的状态(如和、积、字符频率)能在 O(1) 时间内更新,无需重新计算整个窗口状态。

四、通用模板:3类场景统一框架

基于上述分类,提炼出通用模板,只需根据目标调整「缩窗口条件」和「更新答案时机」即可。模板核心步骤:初始化变量 → 扩窗口 → 缩窗口 → 更新答案。

4.0 快速参考表

类型 初始 ans 缩窗口条件 更新答案时机 关键代码
类型1:求最长 0 进入坏状态 缩窗口后,每次扩展 right 后 ans = Math.max(ans, right - left + 1)
类型2:求最短 Infinity 进入好状态 缩窗口过程中 ans = Math.min(ans, right - left + 1)
类型3:求计数 0 进入坏状态 缩窗口后 ans += right - left + 1

4.1 通用模板(TypeScript/JavaScript)

function slidingWindowTemplate<T>(data: T[], targetParam: any): number {
  // 1. 初始化变量
  let left = 0; // 左窗口边界
  let ans = 初始值; // 答案变量(最长→0,最短→Infinity,计数→0)
  let status = 初始状态; // 如对象(字符频率)、sum=0、prod=1

  // 2. 扩窗口:right 遍历所有元素
  for (let right = 0; right < data.length; right++) {
    const rightVal = data[right];
    // 加入右元素,更新状态
    // status.update(rightVal); // 根据具体类型更新

    // 3. 缩窗口:根据目标和当前状态判断是否缩左
    while (缩窗口条件) {
      // 核心差异点:不同类型场景条件不同
      const leftVal = data[left];
      // 移出左元素,更新状态
      // status.remove(leftVal); // 根据具体类型更新
      left++; // 缩小窗口
    }

    // 4. 更新答案:根据类型调整时机
    // 答案更新逻辑
    // 核心差异点:不同类型场景时机不同
  }

  // 5. 处理边界情况(如无满足条件的窗口)
  return 处理后的 ans;
}

4.2 分类型模板细化

类型1:求最长/最大区间

function maxLengthTemplate<T>(data: T[], param: any): number {
  let left = 0;
  let ans = 0; // 最长初始为0
  const status: Record<string, number> = {}; // 对象:记录字符频率

  for (let right = 0; right < data.length; right++) {
    const rightVal = data[right];
    // 更新状态
    status[rightVal as string] = status[rightVal as string] ? status[rightVal as string] + 1 : 1;

    // 缩窗口条件:进入坏状态
    while (坏状态判断) {
      // 如 status[rightVal] > 1(重复字符)
      const leftVal = data[left];
      status[leftVal as string]--;
      left++;
    }

    // 更新答案:缩窗口后,当前窗口是有效最长窗口
    ans = Math.max(ans, right - left + 1);
  }

  return ans;
}

类型2:求最短/最小区间

function minLengthTemplate(data: number[], param: any): number {
  let left = 0;
  let ans = Infinity; // 最短初始为无穷大
  let status = 0; // 如 sumWindow = 0

  for (let right = 0; right < data.length; right++) {
    const rightVal = data[right];
    status += rightVal; // 更新状态

    // 缩窗口条件:进入好状态(尽可能缩小窗口)
    while (好状态判断) {
      // 如 status >= target(和≥目标)
      // 缩窗口时更新答案
      ans = Math.min(ans, right - left + 1);
      const leftVal = data[left];
      status -= leftVal;
      left++;
    }
  }

  // 处理边界:无满足条件的窗口返回0
  return ans !== Infinity ? ans : 0;
}

类型3:求计数/统计区间

function countTemplate(data: number[], param: any): number {
  let left = 0;
  let ans = 0; // 计数初始为0
  let status = 1; // 如 prod = 1

  for (let right = 0; right < data.length; right++) {
    const rightVal = data[right];
    status *= rightVal; // 更新状态

    // 缩窗口条件:进入坏状态
    while (坏状态判断) {
      // 如 status >= K(乘积≥K)
      const leftVal = data[left];
      status /= leftVal;
      left++;
    }

    // 更新答案:当前right对应的有效区间数 = right-left+1
    ans += right - left + 1;
  }

  return ans;
}

五、经典例题实战:逐行拆解

结合模板,拆解 3 类场景的经典例题,帮你理解如何将模板落地到具体问题。

例题1:无重复字符的最长子串(类型1:求最长)

题目描述

给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。

解题思路

  • 窗口状态(坏):窗口内存在重复字符;
  • 状态统计:用对象记录窗口内字符的出现次数;
  • 缩窗口条件:当前加入的字符出现次数>1(进入坏状态);
  • 更新答案:缩窗口完成后,计算当前窗口长度,更新最大值。

剪枝思想:当窗口 [left, right] 存在重复字符时,移动 left++ → 剪掉第 left 行的所有剩余组合 (left, right+1)(left, end),因为这些组合必然也包含重复字符。

代码实现

function lengthOfLongestSubstring(s: string): number {
  let left = 0;
  let ans = 0; // 最长子串长度初始为0
  const window: Record<string, number> = {}; // 对象:记录窗口内字符出现次数

  for (let right = 0; right < s.length; right++) {
    const rightChar = s[right];
    // 加入右字符,更新状态
    window[rightChar] = window[rightChar] ? window[rightChar] + 1 : 1;

    // 缩窗口:当当前字符出现次数>1(坏状态),缩左直到无重复
    while (window[rightChar] > 1) {
      const leftChar = s[left];
      window[leftChar]--; // 移出左字符,更新状态
      left++;
    }

    // 更新答案:当前窗口是无重复的有效窗口,计算长度
    ans = Math.max(ans, right - left + 1);
  }

  return ans;
}

复杂度分析

  • 时间复杂度 O(n):每个字符被 right 加入、left 移出各一次,每个元素最多被访问两次
  • 空间复杂度 O(min(m, n)):m 是字符集大小,窗口内字符数不超过 min(m, n)

为什么是 O(n) 而不是 O(n²)?

关键在于:left 和 right 都只向前移动,不会回退。每个元素最多被:

  • right 访问一次(加入窗口)
  • left 访问一次(移出窗口)

因此总时间复杂度是 O(2n) = O(n)。

易错点分析

  1. ❌ 错误:在 while 循环外更新答案

    // 错误:窗口可能处于坏状态时就更新了答案
    while (window[rightChar] > 1) {
      // ...
    }
    ans = Math.max(ans, right - left + 1); // ❌ 应该在while循环后
    
  2. ❌ 错误:状态更新顺序错误

    // 错误:先移动left,再更新状态
    left++;
    window[leftChar]--; // ❌ 应该先更新状态,再移动left
    
  3. ✅ 正确写法

    // 先更新状态,再移动指针
    window[leftChar]--;
    left++;
    // 然后在while循环后更新答案
    ans = Math.max(ans, right - left + 1);
    

例题2:长度最小的子数组(类型2:求最短)

题目描述

给定一个含有 n 个正整数的数组和一个正整数 target,找出该数组中满足其和 ≥ target 的长度最小的 连续子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。

解题思路

  • 窗口状态(好):窗口和≥target;
  • 状态统计:用 sumWindow 记录窗口内元素和;
  • 缩窗口条件:sumWindow≥target(进入好状态),缩左以寻找更短窗口;
  • 更新答案:缩窗口过程中,每次缩小后计算窗口长度,更新最小值。

剪枝思想:当窗口 [left, right] 的和≥target时,移动 left++ → 剪掉第 left 行的所有剩余组合 (left, right+1)(left, end),因为这些组合的和必然也≥target(数组元素为正数),但长度更长,不是最优解。

代码实现

function minSubArrayLen(target: number, nums: number[]): number {
  let left = 0;
  let ans = Infinity; // 最短长度初始为无穷大
  let sumWindow = 0; // 窗口内元素和

  for (let right = 0; right < nums.length; right++) {
    sumWindow += nums[right]; // 加入右元素,更新和

    // 缩窗口:和≥target时,尽可能缩小窗口
    while (sumWindow >= target) {
      // 缩窗口时更新答案:当前窗口是有效最短窗口候选
      ans = Math.min(ans, right - left + 1);
      sumWindow -= nums[left]; // 移出左元素,更新和
      left++;
    }
  }

  // 处理边界:无满足条件的窗口返回0
  return ans !== Infinity ? ans : 0;
}

复杂度分析

  • 时间复杂度 O(n):每个元素最多被遍历两次(right 加入一次,left 移出一次)
  • 空间复杂度 O(1):仅用常数级变量(left、right、ans、sumWindow)

易错点分析

  1. ❌ 错误:在 while 循环外更新答案

    // 错误:只在while循环外更新,会漏掉一些有效窗口
    while (sumWindow >= target) {
      sumWindow -= nums[left];
      left++;
    }
    ans = Math.min(ans, right - left + 1); // ❌ 应该在while循环内更新
    
  2. ❌ 错误:边界处理缺失

    // 错误:没有处理无满足条件窗口的情况
    return ans; // ❌ 如果ans还是Infinity,应该返回0
    
  3. ✅ 正确写法

    // 在while循环内更新答案(每次缩小窗口时)
    while (sumWindow >= target) {
      ans = Math.min(ans, right - left + 1); // ✅ 在循环内更新
      sumWindow -= nums[left];
      left++;
    }
    // 处理边界:无满足条件的窗口返回0
    return ans !== Infinity ? ans : 0; // ✅ 检查是否更新过
    

例题3:乘积小于 K 的子数组(类型3:求计数)

题目描述

给你一个整数数组 nums 和一个整数 k,统计并返回该数组中乘积小于 k 的连续子数组的个数。

解题思路

  • 窗口状态(坏):窗口乘积≥k;
  • 状态统计:用 prod 记录窗口内元素乘积;
  • 缩窗口条件:prod≥k(进入坏状态),缩左直到乘积<k;
  • 更新答案:缩窗口完成后,当前 right 对应的有效子数组数为 right-left+1(即 [left,right]、[left+1,right]...[right,right])。

剪枝思想:当窗口 [left, right] 的乘积≥k时,移动 left++ → 剪掉第 left 行的所有剩余组合 (left, right+1)(left, end),因为这些组合的乘积必然也≥k(数组元素为正数)。

代码实现

function numSubarrayProductLessThanK(nums: number[], k: number): number {
  // 边界条件:k≤1时,所有正整数乘积≥1,无满足条件的子数组
  if (k <= 1) {
    return 0;
  }
  let left = 0;
  let ans = 0; // 计数初始为0
  let prod = 1; // 窗口内元素乘积

  for (let right = 0; right < nums.length; right++) {
    prod *= nums[right]; // 加入右元素,更新乘积

    // 缩窗口:乘积≥k时,缩左直到乘积<k
    while (prod >= k) {
      prod /= nums[left]; // 移出左元素,更新乘积
      left++;
    }

    // 累加当前right对应的有效子数组数
    // 当窗口 [left, right] 的乘积 < k 时,以 right 结尾的所有子数组都满足条件
    // 即 [left,right]、[left+1,right]...[right,right] 共 right-left+1 个
    ans += right - left + 1;
  }

  return ans;
}

复杂度分析

  • 时间复杂度 O(n):每个元素最多被遍历两次(right 加入一次,left 移出一次)
  • 空间复杂度 O(1):仅用常数级变量(left、right、ans、prod)

易错点分析

  1. ❌ 错误:边界条件未处理

    // 错误:没有处理 k≤1 的情况
    function numSubarrayProductLessThanK(nums, k) {
      let prod = 1;
      // ... 直接开始循环 ❌
    }
    
  2. ❌ 错误:计数逻辑理解错误

    // 错误:每次只加1,没有理解"以right结尾的所有子数组"
    ans += 1; // ❌ 应该是 right - left + 1
    
  3. ✅ 正确理解

    // 当窗口 [left, right] 的乘积 < k 时
    // 以 right 结尾的所有子数组都满足条件:
    // [left,right]、[left+1,right]...[right,right]
    // 共 right - left + 1 个
    ans += right - left + 1; // ✅ 正确
    

六、新手避坑指南

  1. 窗口边界统一:建议全程使用「左闭右闭」或「左闭右开」边界定义,不要混用。本文所有例题均采用「左闭右闭」,窗口长度为 right-left+1。

  2. 状态更新顺序:缩窗口时,需先更新状态(如减 sum、除 prod),再移动 left 指针,避免漏算或多算。

  3. 边界条件处理

    • 求最短时,初始 ans 设为无穷大,最后需判断是否更新过(未更新则返回 0);

    • 乘积问题需注意 k≤1 的情况(正整数乘积最小为 1,直接返回 0);

    • 空字符串/空数组需提前返回 0。

  4. 单调性验证:遇到子串/子数组问题时,先手动模拟 2-3 个案例,确认状态是否满足单调性,再决定是否用滑动窗口。

  5. 更新答案的时机

    • 类型1(求最长):在 while 循环之后更新,确保窗口处于好状态
    • 类型2(求最短):在 while 循环内部更新,每次缩小窗口时都更新
    • 类型3(求计数):在 while 循环之后更新,累加有效区间数
  6. 状态更新顺序

    // ✅ 正确顺序:先更新状态,再移动指针
    window[leftChar]--; // 1. 更新状态
    left++; // 2. 移动指针
    
    // ❌ 错误顺序:先移动指针,再更新状态(会导致状态不一致)
    left++;
    window[leftChar]--; // 此时leftChar已经是下一个字符了!
    
  7. 边界情况检查清单

    • 空字符串/空数组:if (s.length === 0) return 0;
    • 单元素:if (s.length === 1) return 1;
    • 求最短时,检查 ans 是否更新过:return ans !== Infinity ? ans : 0;
    • 乘积问题时,检查 k≤1:if (k <= 1) return 0;

总结

滑动窗口的核心是「用单调性压缩遍历维度」,通过移动指针剪掉整行或整列实现剪枝优化。掌握它的关键在于:

  1. 判断问题是否满足「连续区间+状态单调性+状态可快速更新」;

  2. 根据目标(最长/最短/计数)确定「缩窗口条件」和「更新答案时机」;

  3. 套用通用模板,灵活调整状态统计工具(哈希表/和/积)。

只要抓住这三点,无论是简单的“无重复子串”,还是复杂的“最小覆盖子串”,都能按此逻辑拆解。建议多做几道经典例题,固化模板思维,面试时就能快速反应。

核心要点回顾

  1. 判断标准:连续区间 + 状态单调性 + 状态可快速更新
  2. 剪枝思想:每次移动指针,都会"干掉"某些组合(剪掉整行或整列),实现 O(n²) → O(n) 优化
  3. 三种类型
    • 类型1(求最长):坏状态时缩窗,缩窗后更新答案
    • 类型2(求最短):好状态时缩窗,缩窗过程中更新答案
    • 类型3(求计数):坏状态时缩窗,缩窗后累加有效区间数
  4. 模板步骤:初始化 → 扩窗口 → 缩窗口 → 更新答案
  5. 时间复杂度:O(n),每个元素最多被访问两次
  6. 空间复杂度:O(1) 或 O(min(m,n)),取决于状态统计方式

相关资源

练习题推荐

按难度和类型分类,建议按顺序练习:

基础题(必做)

进阶题(推荐)

扩展题(挑战)

~ 一般我走到这里就回去了,有兴趣就继续~


前端算法必备:双指针从入门到很熟练(快慢指针+相向指针+滑动窗口)

作者 颜酱
2026年1月12日 11:52

前端算法必备:双指针从入门到很熟练(快慢指针+相向指针+滑动窗口)

📑 目录

一、双指针是什么?

核心定义:用两个指针(索引) 遍历数据结构(数组/链表),通过指针的"移动规则"减少遍历次数,将时间复杂度从 O(n²) 优化到 O(n)。

简单说:不用嵌套循环遍历所有可能,而是用两个指针"协作",一次遍历解决问题。

二、双指针的分类

双指针的核心是**"指针的移动规则",不同规则对应不同场景。按照指针的移动方式和相对位置**,主要分为以下3大类:

分类 指针位置 移动方式 核心特点 典型问题
快慢指针 同一端出发 同向移动,速度不同 利用速度差制造相对位置 环检测、找中点、找倒数第k个
左右指针(相向指针) 两端出发 相向移动(向中间靠拢) 利用有序性缩小搜索范围 两数之和、回文判断、盛水容器
同向指针(滑动窗口) 同一端出发 同向移动,维护窗口 利用单调性压缩遍历维度 无重复子串、最小子数组、子数组计数

💡 说明:Vue3 diff 四个指针是左右指针的进阶应用,会在"前端应用场景"部分作为实际案例介绍。

2.1 快慢指针(Fast & Slow Pointers)

快慢指针(Fast & Slow Pointers)本质是用两个步长不同的指针(如快指针走 2 步、慢指针走 1 步)遍历线性数据结构(链表 / 数组),核心解决「环检测、找中点、找倒数第 k 个元素」三类问题,优势是:空间复杂度从 O (n) 降到 O (1),且无需额外容器(如哈希表)。

指针位置:两个指针从同一端(通常是开头) 出发

移动规则:快指针每次走2步,慢指针每次走1步(或其他"速度差")

适用场景:链表/数组的"环形问题"、"找特定位置(中点 倒数k个点)"

核心原理:利用"速度差"制造"相对位置"——比如快指针先到终点,慢指针刚好在中间;或快指针追上慢指针,说明有环。

核心模板

// 快慢指针通用模板
function fastSlowPointer(head: ListNode | null): boolean | ListNode | null {
  let fast = head;
  let slow = head;

  // 关键:循环条件确保快指针可以安全移动
  while (fast && fast.next) {
    fast = fast.next.next; // 快指针走2步
    slow = slow.next; // 慢指针走1步

    // 根据具体问题判断
    // 1. 环检测:if (fast === slow) return true;
    // 2. 找中点:循环结束后 slow 就是中点
    // 3. 找倒数第k个:先让 fast 走 k 步,再一起走
  }

  return false; // 或返回 slow(中点)
}

经典场景+模板+例题

场景1:判断链表是否有环

题目描述: 141. 环形链表

给你一个链表的头节点 head,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

如果链表中存在环,则返回 true。否则,返回 false

示例 1:

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

示例 2:

输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。

示例 3:

输入:head = [1], pos = -1
输出:false
解释:链表中没有环。

解题思路详解:

核心思想:快慢指针(Floyd 判圈算法)

使用两个指针,一个快指针每次移动两步,一个慢指针每次移动一步。如果链表中存在环,快指针最终会追上慢指针(相遇);如果没有环,快指针会先到达链表末尾(null)。

为什么快慢指针能检测环?

  1. 无环情况:快指针会先到达链表末尾,循环结束,返回 false
  2. 有环情况:快指针和慢指针都会进入环内
    • 假设慢指针进入环时,快指针已经在环内某个位置
    • 由于快指针每次比慢指针多走一步,它们之间的距离会逐渐缩小
    • 最终快指针会追上慢指针(相遇),证明有环

数学证明(为什么一定会相遇):

假设:

  • 环外长度为 a(从头节点到环入口的距离)
  • 环长度为 b
  • 慢指针进入环时,快指针在环内距离入口 c 的位置(0 ≤ c < b)

当慢指针进入环时:

  • 慢指针位置:a
  • 快指针位置:a + c
  • 快慢指针距离:c(快指针在慢指针前面 c 步)

由于快指针每次比慢指针多走 1 步,它们之间的距离每次减少 1:

  • 第 1 次移动后:距离变为 c - 1
  • 第 2 次移动后:距离变为 c - 2
  • ...
  • c 次移动后:距离变为 0(相遇)

因此,最多经过 c 次移动(c < b),快慢指针一定会相遇。

代码实现:

function hasCycle(head: ListNode | null): boolean {
  let fast = head,
    slow = head;
  // 快指针需要检查 fast 和 fast.next,避免空指针
  while (fast && fast.next) {
    fast = fast.next.next; // 快指针走2步
    slow = slow.next; // 慢指针走1步
    if (fast === slow) return true; // 相遇则有环
  }
  return false; // 快指针到达末尾,无环
}

关键点解析:

  1. 初始化:快慢指针都从头节点开始
  2. 循环条件fast && fast.next 确保快指针可以安全地移动两步
  3. 移动规则:快指针每次移动 2 步,慢指针每次移动 1 步
  4. 判断相遇:如果 fast === slow,说明两指针相遇,存在环
  5. 无环情况:快指针到达 null,循环结束,返回 false

时间复杂度: O(n),其中 n 是链表中节点的数量

  • 无环:快指针最多遍历 n 个节点
  • 有环:快慢指针最多在环内相遇,时间复杂度仍为 O(n)

空间复杂度: O(1),只使用了两个额外的指针

执行过程示例:

有环链表:1 -> 2 -> 3 -> 4 -> 5 -> 3 (5指向3,形成环)

初始:fast = 1, slow = 1
第1步:fast = 3, slow = 2
第2步:fast = 5, slow = 3
第3步:fast = 4, slow = 4 (相遇!返回 true)
场景2:找链表中间节点

题目描述: 876. 链表的中间结点

给你单链表的头结点 head,请你找出并返回链表的中间结点。

如果有两个中间结点,则返回第二个中间结点。

示例 1:

输入:head = [1,2,3,4,5]
输出:[3,4,5]
解释:链表只有一个中间结点,值为 3。

示例 2:

输入:head = [1,2,3,4,5,6]
输出:[4,5,6]
解释:该链表有两个中间结点,值分别为 3 和 4,返回第二个结点。

解题思路详解:

核心思想:快慢指针

使用两个指针,快指针每次移动两步,慢指针每次移动一步。当快指针到达链表末尾时,慢指针刚好在中间位置。

为什么慢指针在中间?

  • 假设链表长度为 n
  • 快指针移动了 n 步(到达末尾)
  • 慢指针移动了 n/2 步(刚好在中间)

偶数个节点的情况:

  • 如果有 6 个节点,快指针移动 6 步到末尾,慢指针移动 3 步
  • 由于题目要求"两个中间结点返回第二个",所以返回慢指针指向的节点是正确的

代码实现:

function middleNode(head: ListNode | null): ListNode | null {
  let fast = head,
    slow = head;
  // 快指针每次走2步,慢指针每次走1步
  while (fast && fast.next) {
    fast = fast.next.next;
    slow = slow.next;
  }
  return slow; // 慢指针在中间
}

关键点解析:

  1. 循环条件fast && fast.next 确保快指针可以安全地移动两步
  2. 移动规则:快指针移动 2 步,慢指针移动 1 步
  3. 返回结果:慢指针指向的节点就是中间节点

时间复杂度: O(n),其中 n 是链表的节点数,需要遍历链表一次

空间复杂度: O(1),只使用了两个额外的指针

执行过程示例:

链表:1 -> 2 -> 3 -> 4 -> 5

初始:fast = 1, slow = 1
第1步:fast = 3, slow = 2
第2步:fast = 5, slow = 3
fast.next = null,循环结束
返回 slow = 3(中间节点)
场景3:删除倒数第k个节点

题目描述: 19. 删除链表的倒数第 N 个结点

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

示例 1:

输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]

示例 2:

输入:head = [1], n = 1
输出:[]

示例 3:

输入:head = [1,2], n = 1
输出:[1]

解题思路详解:

核心思想:快慢指针 + 虚拟头节点

  1. 快指针先走 n 步:让快指针领先慢指针 n 个位置
  2. 快慢指针同时移动:当快指针到达末尾时,慢指针刚好在倒数第 n+1 个节点
  3. 删除节点:将慢指针的下一个节点删除

为什么慢指针在倒数第 n+1 个节点?

  • 假设链表长度为 L,要删除倒数第 n 个节点
  • 快指针先走 n 步,此时快指针在正数第 n+1 个节点
  • 快慢指针同时移动,当快指针到达末尾(第 L 个节点)时
  • 慢指针移动了 L - n 步,位于第 L - n + 1 个节点
  • L - n + 1 个节点就是倒数第 n + 1 个节点(因为 L - (L - n + 1) + 1 = n + 1

边界情况处理:

  • 如果 fastnull(快指针先走 n 步后为空),说明要删除的是头节点
  • 直接返回 head.next

代码实现:

function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
  let fast = head,
    slow = head;
  // 快指针先跑n步
  for (let i = 0; i < n; i++) {
    fast = fast!.next;
  }
  // 如果快指针为空,说明要删除的是头节点
  if (!fast) return head!.next;
  // 快慢指针一起跑,快指针到终点时,慢指针在倒数第n+1个节点
  while (fast.next) {
    fast = fast.next;
    slow = slow!.next;
  }
  // 删除倒数第n个节点(slow.next)
  slow!.next = slow!.next!.next;
  return head;
}

关键点解析:

  1. 快指针先走 n 步:建立快慢指针之间的相对位置
  2. 边界检查if (!fast) 处理删除头节点的情况
  3. 循环条件fast.next 确保慢指针停在倒数第 n+1 个节点
  4. 删除操作slow.next = slow.next.next 跳过要删除的节点

时间复杂度: O(n),其中 n 是链表的节点数,需要遍历链表一次

空间复杂度: O(1),只使用了两个额外的指针

执行过程示例:

链表:1 -> 2 -> 3 -> 4 -> 5,删除倒数第 2 个节点(4)

初始:fast = 1, slow = 1
快指针先走2步:fast = 3, slow = 1
快慢指针一起移动:
  第1步:fast = 4, slow = 2
  第2步:fast = 5, slow = 3
fast.next = null,循环结束
slow = 3(倒数第3个节点),slow.next = 4(要删除的节点)
删除:slow.next = slow.next.next,即 3.next = 5
结果:1 -> 2 -> 3 -> 5

2.2 左右指针(相向指针,Left & Right Pointers)

🎯 交互演示点击这里查看动态演示 - 通过交互式可视化,直观看到每一步剪掉的组合!

左右指针(Left & Right Pointers)本质是用两个指针从「数组 / 字符串的两端」向中间移动,核心解决「有序数组 / 字符串的双值匹配、区间收缩、回文判断、区间最值」四类问题,优势是:将暴力枚举的 O(n²) 时间复杂度降到 O(n),且空间复杂度 O(1)。

指针位置:两个指针从两端(开头+结尾) 出发

移动规则:根据条件向中间移动(比如"左指针右移"或"右指针左移")

适用场景:有序数组、回文、区间最值

核心原理:利用"两端向中间收缩"的方式,缩小搜索范围,避免遍历所有组合。

满足以下任一条件,直接用左右指针:数据结构是数组 / 字符串(可随机访问),且是「有序」的;问题涉及「两端向中间匹配」(如回文、两数之和);问题涉及「区间收缩 / 滑动窗口」(如去重、子串 / 子数组);要求「O (n) 时间 + O (1) 空间」,且无需处理「环」相关问题。

抖音上有个博主的视频 ,我觉得讲的很好,可以先看完,对相向有个可视化的认识,简单说,每次移动左指针和右指针,在脑海里,就会干掉了某一行或者某列表的组合,以此进行优化,本质是剪枝思想

核心模板

// 左右指针通用模板
function leftRightPointer(arr: number[] | string, target: any): any {
  let left = 0;
  let right = arr.length - 1;

  // 关键:循环条件确保两个指针不会相遇
  while (left < right) {
    // 根据具体问题判断
    const sum = arr[left] + arr[right]; // 或比较 arr[left] 和 arr[right]

    if (满足条件) {
      // 找到答案或更新答案
      return result;
    } else if (需要增大) {
      left++; // 左指针右移
    } else {
      right--; // 右指针左移
    }
  }

  return result;
}

经典场景+模板+例题

场景1:有序数组两数之和

题目描述: 167. 两数之和 II - 输入有序数组

给你一个下标从 1 开始的整数数组 numbers,该数组已按 非递减顺序排列,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1]numbers[index2],则 1 <= index1 < index2 <= numbers.length

以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1index2

你可以假设每个输入 只对应唯一的答案,而且你 不可以 重复使用相同的元素。

示例 1:

输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。

示例 2:

输入:numbers = [2,3,4], target = 6
输出:[1,3]
解释:2 与 4 之和等于目标数 6 。因此 index1 = 1, index2 = 3 。返回 [1, 3] 。

示例 3:

输入:numbers = [-1,0], target = -1
输出:[1,2]
解释:-1 与 0 之和等于目标数 -1 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。

解题思路详解:

核心思想:左右指针向中间收缩

由于数组是有序的,可以利用这个特性:

  • 如果两数之和小于目标值,说明需要更大的数,左指针右移
  • 如果两数之和大于目标值,说明需要更小的数,右指针左移
  • 如果两数之和等于目标值,找到答案

为什么这样移动指针是正确的?

💡 提示:想要更直观地理解剪枝过程?点击这里体验交互式演示,每一步都能看到被剪掉的组合!

这涉及到剪枝优化的核心思想。让我们用矩阵可视化来理解:

第一步:理解暴力枚举的搜索空间

暴力枚举需要检查所有可能的 (i, j) 组合,其中 i < j。这形成了一个矩阵:

所有可能的组合 (i, j),其中 i < j:

      j=0  1  2  3
i=0   -  01 02 03  ← 第0i=1   -  -  12 13  ← 第1i=2   -  -  -  23  ← 第2i=3   -  -  -  -   ← 第3行(空)

总共有 N*(N-1)/2 = 4*3/2 = 6 个组合需要检查
时间复杂度:O(n²)

第二步:双指针的剪枝策略

假设当前 left = 0, right = 3,数组为 [2, 7, 11, 15]target = 9

情况1:sum < target(需要增大和)

当前状态:left=0, right=3
当前和:sum = 2 + 15 = 17 > 9(实际是 > target,但先看 < target 的情况)

假设 sum = 2 + 7 = 9 < target = 10(为了演示):

矩阵中当前检查的位置:
      j=0  1  2  3
i=0   -  [01] 02 03  ← 当前检查 (0,1)
i=1   -  -  12 13
i=2   -  -  -  23

剪枝逻辑:
如果 sum < target,那么:
- 对于固定的 left,所有 numbers[left] + numbers[j](j < right)都 < target
- 因为数组有序,numbers[j] ≤ numbers[right](j < right)
- 所以可以剪掉第 left 行的所有剩余组合

因此,可以剪掉第 left 行的剩余部分:
      j=0  1  2  3
i=0   -  [01] ✂️ ✂️  ← 剪掉整行!
i=1   -  -  12 13
i=2   -  -  -  23

移动 left++,跳过第0行的所有剩余组合

情况2:sum > target(需要减小和)

当前状态:left=0, right=3
当前和:sum = 2 + 15 = 17 > 9

矩阵中当前检查的位置:
      j=0  1  2  3
i=0   -  01 02 [03]  ← 当前检查 (0,3)
i=1   -  -  12 13
i=2   -  -  -  23

剪枝逻辑:
如果 sum > target,那么:
- 对于固定的 right,所有 numbers[i] + numbers[right](i > left)都 > target
- 因为数组有序,numbers[i] ≥ numbers[left](i > left)
- 所以可以剪掉第 right 列的所有剩余组合

因此,可以剪掉第 right 列的剩余部分:
      j=0  1  2  3
i=0   -  01 02 [03]
i=1   -  -  12 ✂️  ← 剪掉整列!
i=2   -  -  -  ✂️  ← 剪掉整列!

移动 right--,跳过第3列的所有剩余组合

第三步:剪枝效果可视化

每次移动指针,都会剪掉整行整列,大大减少搜索空间:

数组:[2, 7, 11, 15]target = 9
初始:需要检查 6 个组合

第1步:left=0, right=3, sum=2+15=17 > 9
       移动 right--,剪掉第3列(2个组合)
       剩余:6 - 2 = 4 个组合

第2步:left=0, right=2, sum=2+11=13 > 9
       移动 right--,剪掉第2列(1个组合)
       剩余:4 - 1 = 3 个组合

第3步:left=0, right=1, sum=2+7=9 = 9
       找到答案!

最终:只需要检查 O(n) 个组合,而不是 O(n²)

数学证明:

假设当前 sum = numbers[left] + numbers[right]

  1. 如果 sum < target

    • 由于数组有序,numbers[left] 是当前左区间的最小值
    • 要增大和,只能让 left++(右移左指针)
    • 如果右移右指针,和会变得更小,不符合要求
    • 剪枝效果:可以剪掉第 left 行的所有剩余组合
  2. 如果 sum > target

    • 由于数组有序,numbers[right] 是当前右区间的最大值
    • 要减小和,只能让 right--(左移右指针)
    • 如果左移左指针,和会变得更大,不符合要求
    • 剪枝效果:可以剪掉第 right 列的所有剩余组合

代码实现:

function twoSum(numbers: number[], target: number): number[] {
  let left = 0,
    right = numbers.length - 1;
  while (left < right) {
    const sum = numbers[left] + numbers[right];
    if (sum === target) {
      // 题目要求下标从1开始
      return [left + 1, right + 1];
    } else if (sum < target) {
      left++; // 太小,左指针右移(增大和)
    } else {
      right--; // 太大,右指针左移(减小和)
    }
  }
  return []; // 未找到(题目保证有解,这里不会执行)
}

关键点解析:

  1. 循环条件left < right 确保两个指针不会相遇
  2. 移动规则:根据和与目标值的大小关系决定移动哪个指针
  3. 返回值:注意题目要求下标从 1 开始,所以返回 [left + 1, right + 1]

时间复杂度: O(n),其中 n 是数组的长度,最多遍历数组一次

空间复杂度: O(1),只使用了两个额外的指针

执行过程示例:

数组:[2, 7, 11, 15]target = 9

初始:left = 0, right = 3
第1次:sum = 2 + 15 = 17 > 9,right--,right = 2
第2次:sum = 2 + 11 = 13 > 9,right--,right = 1
第3次:sum = 2 + 7 = 9 = 9,找到答案,返回 [1, 2]
场景2:盛最多水的容器

题目描述: 11. 盛最多水的容器

给定一个长度为 n 的整数数组 height。有 n 条垂线,第 i 条线的两个端点是 (i, 0)(i, height[i])

找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

返回容器可以储存的最大水量。

说明: 你不能倾斜容器。

示例 1:

输入:[1,8,6,2,5,4,8,3,7]
输出:49
解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49

示例 2:

输入:height = [1,1]
输出:1

解题思路详解:

核心思想:左右指针 + 贪心策略

容器的面积由两个因素决定:

  1. 宽度right - left(两指针之间的距离)
  2. 高度Math.min(height[left], height[right])(较矮的那条边)

贪心策略:移动较矮的边

为什么移动较矮的边?这涉及到剪枝优化的核心思想。

💡 提示:想要更直观地理解剪枝过程?点击这里体验交互式演示,选择"盛最多水的容器"问题,每一步都能看到被剪掉的组合!

让我们用矩阵可视化来理解:

第一步:理解暴力枚举的搜索空间

暴力枚举需要检查所有可能的 (i, j) 组合,其中 i < j。这形成了一个矩阵:

所有可能的组合 (i, j),其中 i < j:

      j=0  1  2  3  4  5  6  7  8
i=0   -  01 02 03 04 05 06 07 08  ← 第0i=1   -  -  12 13 14 15 16 17 18  ← 第1i=2   -  -  -  23 24 25 26 27 28  ← 第2i=3   -  -  -  -  34 35 36 37 38  ← 第3i=4   -  -  -  -  -  45 46 47 48  ← 第4i=5   -  -  -  -  -  -  56 57 58  ← 第5i=6   -  -  -  -  -  -  -  67 68  ← 第6i=7   -  -  -  -  -  -  -  -  78  ← 第7i=8   -  -  -  -  -  -  -  -  -   ← 第8行(空)

总共有 N*(N-1)/2 = 9*8/2 = 36 个组合需要检查
时间复杂度:O(n²)

第二步:双指针的剪枝策略

假设当前 left = 1, right = 3,高度为 height[1] = 8, height[3] = 2

情况1:height[left] < height[right](当前:height[1] = 8 > height[3] = 2,不满足,但先看这个情况)

如果 height[left] < height[right],比如 height[1] = 2, height[3] = 8

当前状态:left=1, right=3
当前面积:S = (3-1) * min(2, 8) = 2 * 2 = 4

矩阵中当前检查的位置:
      j=0  1  2  3  4  5  6  7  8
i=0   -  01 02 03 04 05 06 07 08
i=1   -  -  12 [13] 14 15 16 17 18  ← 当前检查 (1,3)
i=2   -  -  -  23 24 25 26 27 28
i=3   -  -  -  -  34 35 36 37 38
      ...

剪枝逻辑:
如果 height[left] < height[right],那么:
- 所有 (left, left+1) ... (left, right-1) 的面积都 < (left, right)
- 因为宽度更小,高度受限于 height[left](或更小)

因此,可以剪掉第 left 行的剩余部分:
      j=0  1  2  3  4  5  6  7  8
i=0   -  01 02 03 04 05 06 07 08
i=1   -  -  12 [13] ✂️ ✂️ ✂️ ✂️ ✂️ ✂️  ← 剪掉整行!
i=2   -  -  -  23 24 25 26 27 28
i=3   -  -  -  -  34 35 36 37 38
      ...

移动 left++,跳过第1行的所有剩余组合

情况2:height[left] > height[right](当前实际:height[1] = 8 > height[3] = 2

当前状态:left=1, right=3
当前面积:S = (3-1) * min(8, 2) = 2 * 2 = 4

矩阵中当前检查的位置:
      j=0  1  2  3  4  5  6  7  8
i=0   -  01 02 03 04 05 06 07 08
i=1   -  -  12 [13] 14 15 16 17 18  ← 当前检查 (1,3)
i=2   -  -  -  23 24 25 26 27 28
i=3   -  -  -  -  34 35 36 37 38
      ...

剪枝逻辑:
如果 height[left] > height[right],那么:
- 所有 (left+1, right) ... (right-1, right) 的面积都 < (left, right)
- 因为宽度更小,高度受限于 height[right](或更小)

因此,可以剪掉第 right 列的剩余部分:
      j=0  1  2  3  4  5  6  7  8
i=0   -  01 02 03 04 05 06 07 08
i=1   -  -  12 [13] 14 15 16 17 18
i=2   -  -  -  ✂️  24 25 26 27 28  ← 剪掉整列!
i=3   -  -  -  ✂️  34 35 36 37 38  ← 剪掉整列!
      ...

移动 right--,跳过第3列的所有剩余组合

第三步:剪枝效果可视化

每次移动指针,都会剪掉整行整列,大大减少搜索空间:

初始:需要检查 36 个组合

第1步:left=0, right=8, height[0]=1 < height[8]=7
       移动 left++,剪掉第0行(8个组合)
       剩余:36 - 8 = 28 个组合

第2步:left=1, right=8, height[1]=8 > height[8]=7
       移动 right--,剪掉第8列(7个组合)
       剩余:28 - 7 = 21 个组合

第3步:left=1, right=7, height[1]=8 > height[7]=3
       移动 right--,剪掉第7列(6个组合)
       剩余:21 - 6 = 15 个组合

... 继续剪枝

最终:只需要检查 O(n) 个组合,而不是 O(n²)

数学证明:

假设当前左右指针指向的高度为 h[left]h[right],且 h[left] < h[right]

  • 当前面积:S = (right - left) * h[left]
  • 如果移动右指针(较高的边):
    • 新宽度:right - left - 1(减小)
    • 新高度:≤ h[left](受限于较矮的边)
    • 新面积:≤ (right - left - 1) * h[left] < S(一定更小)
    • 剪枝效果:可以剪掉第 left 行的所有剩余组合 (left, left+1)(left, right-1)
  • 如果移动左指针(较矮的边):
    • 新宽度:right - left - 1(减小)
    • 新高度:可能 > h[left](如果新的边更高)
    • 新面积:可能更大
    • 剪枝效果:可以剪掉第 right 列的所有剩余组合 (left+1, right)(right-1, right)

因此,移动较矮的边是更优的选择,同时能剪掉整行或整列,实现 O(n) 时间复杂度。

💡 提示:想要更直观地理解剪枝过程?点击这里体验交互式演示,选择"盛最多水的容器"问题,每一步都能看到被剪掉的组合!

代码实现:

function maxArea(height: number[]): number {
  let left = 0,
    right = height.length - 1,
    max = 0;
  while (left < right) {
    // 计算当前面积:宽度 * 较矮的边
    const area = (right - left) * Math.min(height[left], height[right]);
    max = Math.max(max, area);
    // 移动较矮的边(贪心策略)
    if (height[left] < height[right]) {
      left++; // 左指针右移
    } else {
      right--; // 右指针左移
    }
  }
  return max;
}

关键点解析:

  1. 面积计算(right - left) * Math.min(height[left], height[right])
  2. 移动策略:比较左右两边的高度,移动较矮的那一边
  3. 更新最大值:每次计算面积后,更新 max

时间复杂度: O(n),其中 n 是数组的长度,需要遍历数组一次

空间复杂度: O(1),只使用了两个额外的指针

执行过程示例:

数组:[1,8,6,2,5,4,8,3,7]

初始:left = 0, right = 8, max = 01次:area = (8-0) * min(1,7) = 8 * 1 = 8,max = 8
        height[0] < height[8]left++,left = 12次:area = (8-1) * min(8,7) = 7 * 7 = 49,max = 49
        height[1] > height[8]right--,right = 73次:area = (7-1) * min(8,3) = 6 * 3 = 18,max = 49
        height[1] > height[7]right--,right = 6
...继续移动,最终返回 max = 49
场景3:验证回文串

题目描述: 125. 验证回文串

如果在将所有大写字符转换为小写字符、并移除所有非字母数字字符之后,短语正着读和反着读都一样。则可以认为该短语是一个 回文串

字母和数字都属于字母数字字符。

给你一个字符串 s,如果它是 回文串,返回 true;否则,返回 false

示例 1:

输入: s = "A man, a plan, a canal: Panama"
输出:true
解释:"amanaplanacanalpanama" 是回文串。

示例 2:

输入:s = "race a car"
输出:false
解释:"raceacar" 不是回文串。

示例 3:

输入:s = " "
输出:true
解释:s 是一个空字符串 "" 或者只包含空格,所以它是回文串。

解题思路详解:

核心思想:左右指针向中间收缩,逐字符比较

  1. 预处理:移除所有非字母数字字符,转换为小写
  2. 双指针比较:左右指针分别从两端向中间移动,逐字符比较
  3. 判断回文:如果所有字符都匹配,则是回文串

优化版本(不预处理,边遍历边处理):

可以不用预处理,在遍历过程中跳过非字母数字字符,这样空间复杂度更优。

代码实现(预处理版本):

function isPalindrome(s: string): boolean {
  // 预处理:移除非字母数字字符,转换为小写
  s = s.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
  let left = 0,
    right = s.length - 1;
  while (left < right) {
    if (s[left] !== s[right]) return false;
    left++;
    right--;
  }
  return true;
}

代码实现(优化版本,O(1) 空间):

function isPalindrome(s: string): boolean {
  let left = 0,
    right = s.length - 1;
  while (left < right) {
    // 跳过非字母数字字符
    while (left < right && !/[a-zA-Z0-9]/.test(s[left])) {
      left++;
    }
    while (left < right && !/[a-zA-Z0-9]/.test(s[right])) {
      right--;
    }
    // 比较字符(转换为小写)
    if (s[left].toLowerCase() !== s[right].toLowerCase()) {
      return false;
    }
    left++;
    right--;
  }
  return true;
}

关键点解析:

  1. 预处理:使用正则表达式 /[^a-zA-Z0-9]/g 移除非字母数字字符
  2. 大小写处理:使用 toLowerCase() 统一转换为小写
  3. 循环条件left < right 确保两个指针不会相遇
  4. 字符比较:逐字符比较,发现不匹配立即返回 false

时间复杂度: O(n),其中 n 是字符串的长度,需要遍历字符串一次

空间复杂度:

  • 预处理版本:O(n),需要创建新字符串
  • 优化版本:O(1),只使用了两个额外的指针

执行过程示例:

字符串:"A man, a plan, a canal: Panama"
预处理后:"amanaplanacanalpanama"

初始:left = 0, right = 201次:s[0] = 'a', s[20] = 'a',匹配,left++, right--2次:s[1] = 'm', s[19] = 'm',匹配,left++, right--
...继续比较,所有字符都匹配
最终返回 true

2.3 同向指针(滑动窗口,Sliding Window)

指针位置:两个指针从同一端出发,同向移动

移动规则:维护一个"窗口",根据条件动态调整窗口大小

适用场景:连续子数组/子串问题(固定长度或可变长度)

核心原理:利用窗口状态的单调性,通过扩窗和缩窗跳过无效区间,将 O(n²) 优化到 O(n)。

📖 详细内容:滑动窗口有独立的文档 滑动窗口详解,包含完整的原理、模板和例题。本文只介绍核心思想和与相向指针的相似性。

2.3.1 滑动窗口与相向指针的相似性:剪枝优化

核心相似点:滑动窗口和相向指针都通过移动指针来"干掉"某些组合,实现从 O(n²) 到 O(n) 的优化。

相向指针的剪枝(回顾):

  • 移动左指针 → 剪掉第 left 行的所有剩余组合
  • 移动右指针 → 剪掉第 right 列的所有剩余组合

滑动窗口的剪枝(同样原理):

以"无重复字符的最长子串"为例,字符串 s = "abcabcbb"

第一步:理解暴力枚举的搜索空间

暴力枚举需要检查所有可能的 (i, j) 组合,其中 i ≤ j。这形成了一个矩阵:

所有可能的组合 (i, j),其中 i ≤ j:

      j=0  1  2  3  4  5  6  7
i=0   00 01 02 03 04 05 06 07  ← 第0i=1   -  11 12 13 14 15 16 17  ← 第1i=2   -  -  22 23 24 25 26 27  ← 第2i=3   -  -  -  33 34 35 36 37  ← 第3i=4   -  -  -  -  44 45 46 47  ← 第4i=5   -  -  -  -  -  55 56 57  ← 第5i=6   -  -  -  -  -  -  66 67  ← 第6i=7   -  -  -  -  -  -  -  77  ← 第7行

总共有 N*(N+1)/2 = 8*9/2 = 36 个组合需要检查
时间复杂度:O(n²)

第二步:滑动窗口的剪枝策略

假设当前 left = 0, right = 3,窗口 [0,3] = "abca" 包含重复字符 'a'。

剪枝规则1:如果 (left, right) 存在重复字符,则 (left, right+1...end) 都存在重复字符

当前状态:left=0, right=3
当前窗口:[0,3] = "abca"(存在重复字符 'a')

矩阵中当前检查的位置:
      j=0  1  2  3  4  5  6  7
i=0   00 01 02 [03] 04 05 06 07  ← 当前检查 (0,3)
i=1   -  11 12 13 14 15 16 17
i=2   -  -  22 23 24 25 26 27
...

剪枝逻辑:
如果 (left, right) 存在重复字符,那么:
- 所有 (left, right+1) ... (left, end) 都包含重复字符
- 因为窗口 [left, right+1] 包含窗口 [left, right],必然也重复

因此,可以剪掉第 left 行的所有后续组合:
      j=0  1  2  3  4  5  6  7
i=0   00 01 02 [03] ✂️ ✂️ ✂️ ✂️ ✂️  ← 剪掉整行!
i=1   -  11 12 13 14 15 16 17
i=2   -  -  22 23 24 25 26 27
...

移动 left++,跳过第0行的所有剩余组合

剪枝规则2:如果 (left, right) 不存在重复字符,则 (left+1...right, right) 也不存在重复字符

当前状态:left=0, right=2
当前窗口:[0,2] = "abc"(不存在重复字符)

矩阵中当前检查的位置:
      j=0  1  2  3  4  5  6  7
i=0   00 01 [02] 03 04 05 06 07  ← 当前检查 (0,2)
i=1   -  11 12 13 14 15 16 17
i=2   -  -  22 23 24 25 26 27
...

剪枝逻辑:
如果 (left, right) 不存在重复字符,那么:
- 所有 (left+1, right) ... (right, right) 都不存在重复字符
- 因为窗口 [left+1, right] 是窗口 [left, right] 的子集

因此,可以继续扩展 right,探索更长的有效窗口
移动 right++,继续探索

第三步:剪枝效果可视化

每次移动指针,都会剪掉整行整列,大大减少搜索空间:

字符串:"abcabcbb"
初始:需要检查 36 个组合

第1步:left=0, right=0, 窗口="a"(无重复)
       移动 right++,继续探索
       剩余:36 个组合(未剪枝,但只检查了1个)

第2步:left=0, right=1, 窗口="ab"(无重复)
       移动 right++,继续探索
       剩余:36 个组合(未剪枝,但只检查了2个)

第3步:left=0, right=2, 窗口="abc"(无重复)
       移动 right++,继续探索
       剩余:36 个组合(未剪枝,但只检查了3个)

第4步:left=0, right=3, 窗口="abca"(有重复!)
       移动 left++,剪掉第0行的所有剩余组合(4个组合)
       剩余:36 - 4 = 32 个组合

第5步:left=1, right=3, 窗口="bca"(无重复)
       移动 right++,继续探索
       剩余:32 个组合(未剪枝,但只检查了5个)

... 继续剪枝

最终:只需要检查 O(n) 个组合,而不是 O(n²)

核心思想总结

  1. 相向指针:通过比较两端值,移动指针剪掉整行或整列
  2. 滑动窗口:通过判断窗口状态,移动指针剪掉整行或整列
  3. 共同点:每次移动指针,都会"干掉"某些组合,避免无效计算

分类:根据窗口大小是否固定,可分为:

  • 固定窗口:窗口大小固定,两个指针同时移动
  • 可变窗口:窗口大小可变,根据条件动态调整(这才是真正的"滑动窗口")

📖 详细内容:滑动窗口的完整原理、模板、例题和更多剪枝规则,请参考 滑动窗口详解

三、快速判断:什么时候用哪种双指针?

遇到问题时,按以下决策树快速判断:

问题类型判断
│
├─ 是否涉及"环""特定位置"(中点、倒数第k个)?
│  └─ ✅ 快慢指针
│     - 环形链表检测
│     - 找链表中间节点
│     - 删除倒数第k个节点
│
├─ 是否涉及"有序数组/字符串""两端匹配"?
│  └─ ✅ 左右指针
│     - 两数之和(有序数组)
│     - 回文串判断
│     - 盛水容器(区间最值)
│
└─ 是否涉及"连续子数组/子串"问题?
   └─ ✅ 滑动窗口(同向指针)
      - 固定窗口:窗口大小固定
      - 可变窗口:窗口大小可变
      - 详见 [滑动窗口详解](./03-02-sliding-window.md)

快速记忆口诀

  • 🔄 快慢指针:速度差,找位置(环、中点、倒数k)
  • ↔️ 左右指针(相向指针):两端向中间,有序数组/回文
  • ➡️ 同向指针(滑动窗口):同一端出发,同向移动,维护窗口
    • 通过移动指针剪掉整行或整列,实现 O(n²) → O(n) 优化
    • 详见 滑动窗口详解

四、双指针的学习技巧(必看)

  1. 先记"移动规则":不同分类的核心是"指针怎么动"——快慢指针是"速度差",左右指针是"向中间收缩",滑动窗口是"同向移动维护窗口"

  2. 先刷"模板题":每个分类先做2-3道简单题(比如先刷"环形链表"、"两数之和II"),熟练后再做变种

  3. 注意"边界条件":比如链表的fast && fast.next(避免空指针)、数组的left < right(避免越界)

  4. 多总结"适用场景":看到"环形"、"倒数第k"想快慢指针;看到"有序数组"、"回文"想左右指针;看到"连续子数组/子串"想滑动窗口

  5. 掌握核心模板:每个分类都有固定模板,先背模板再刷题,事半功倍

五、刷题清单:从基础到进阶

Day 1:快慢指针基础

Day 2:快慢指针进阶

Day 3:左右指针基础

Day 4:左右指针进阶

Day 5:固定窗口

Day 6:滑动窗口(进阶)


前端必刷题目(按难度和面试频次)

以下题目按难度和面试频次分类,建议优先刷高频题目:

⭐ 简单-高频(必刷)

面试出现频率:★★★★★ | 难度:简单

⭐⭐ 简单-中频(推荐)

面试出现频率:★★★☆☆ | 难度:简单

⭐⭐⭐ 中等-高频(必刷)

面试出现频率:★★★★★ | 难度:中等

⭐⭐⭐⭐ 中等-中频(推荐)

面试出现频率:★★★☆☆ | 难度:中等

⭐⭐⭐⭐⭐ 困难-高频(进阶)

面试出现频率:★★★★☆ | 难度:困难

放弃也行,我觉得

六、常见错误和避坑点

6.1 快慢指针常见错误

  1. ❌ 空指针检查缺失

    // 错误:没有检查 fast.next
    while (fast) {
      fast = fast.next.next; // 可能报错:Cannot read property 'next' of null
    }
    
    // ✅ 正确:检查 fast 和 fast.next
    while (fast && fast.next) {
      fast = fast.next.next;
    }
    
  2. ❌ 快慢指针初始化错误

    // 错误:快慢指针从不同位置开始(某些场景需要,但大多数不需要)
    let fast = head.next;
    let slow = head;
    
    // ✅ 正确:大多数情况从头节点开始
    let fast = head;
    let slow = head;
    
  3. ❌ 删除节点时未处理头节点

    // 错误:删除倒数第n个节点时,如果删除的是头节点,会出错
    function removeNthFromEnd(head, n) {
      let fast = head,
        slow = head;
      for (let i = 0; i < n; i++) {
        fast = fast.next;
      }
      // 如果 fast 为 null,说明要删除的是头节点,需要特殊处理
      if (!fast) return head.next; // ✅ 必须检查
      // ...
    }
    

6.2 左右指针常见错误

  1. ❌ 循环条件错误

    // 错误:使用 <= 可能导致越界或重复计算
    while (left <= right) {
      // 某些场景下,left === right 时不应该继续
    }
    
    // ✅ 正确:大多数情况使用 <
    while (left < right) {
      // ...
    }
    
  2. ❌ 指针移动方向错误

    // 错误:两数之和问题中,和太大时移动了左指针
    if (sum > target) {
      left++; // ❌ 错误:应该移动右指针
    }
    
    // ✅ 正确:和太大时移动右指针(减小和)
    if (sum > target) {
      right--; // ✅ 正确
    }
    
  3. ❌ 边界情况未处理

    // 错误:没有处理空数组或单元素数组
    function twoSum(numbers, target) {
      let left = 0,
        right = numbers.length - 1;
      // 如果 numbers.length === 0,right = -1,会出错
      // ✅ 应该先检查边界
      if (numbers.length === 0) return [];
    }
    

6.3 固定窗口常见错误

  1. ❌ 窗口初始化错误

    // 错误:没有先计算第一个窗口
    for (let right = 0; right < nums.length; right++) {
      sum += nums[right] - nums[right - k]; // right < k 时会出错
    }
    
    // ✅ 正确:先计算第一个窗口
    let sum = 0;
    for (let i = 0; i < k; i++) {
      sum += nums[i];
    }
    let max = sum;
    for (let right = k; right < nums.length; right++) {
      sum += nums[right] - nums[right - k];
      max = Math.max(max, sum);
    }
    
  2. ❌ 窗口大小检查缺失

    // 错误:没有检查 k 是否大于数组长度
    function maxSubarraySum(nums, k) {
      // 如果 k > nums.length,应该返回错误或特殊值
      if (k > nums.length) return 0; // ✅ 应该检查
    }
    

6.4 通用避坑点

  1. 边界条件处理:空数组、单元素、全相同元素、全负数等
  2. 索引越界:确保指针移动后不会越界
  3. 初始化值:根据问题类型选择合适的初始值(0、Infinity、-Infinity等)
  4. 循环条件:仔细考虑 while vs for< vs <=
  5. 指针移动时机:确保在正确的时机移动指针

总结

双指针是数组/链表问题的"最优解工具",掌握这3类分类+对应的模板,90%的双指针题都能解决。核心是理解"指针的移动规则",然后根据问题特点选择合适的分类。

核心要点回顾

  1. 快慢指针:速度差,解决环检测、找中点、找倒数第k个问题
  2. 左右指针(相向指针):两端向中间,通过移动指针剪掉整行或整列,解决有序数组匹配、回文、区间最值问题
  3. 同向指针(滑动窗口):同一端出发,同向移动,通过移动指针剪掉整行或整列,解决连续子数组/子串问题(详见 滑动窗口详解

学习路径建议

  1. 基础阶段:先掌握快慢指针和左右指针的3-5道模板题
  2. 进阶阶段:学习滑动窗口(详见 滑动窗口详解
  3. 实战阶段:结合前端应用场景,解决实际问题
  4. 总结阶段:整理错题,总结避坑点,形成自己的模板库

相关资源

使用vue3 写法 拖拽节点成功后 配置当前节点自定义属性值

作者 持续前行
2026年1月12日 11:46

在 Vue3 中,当拖拽节点成功后配置自定义属性值,主要有两种方式:一种是在拖拽时通过 startDrag方法直接传入初始属性,另一种是在节点被添加到画布后通过事件监听来动态设置属性。下面是一个清晰的实现方案。

🛠️ 核心实现步骤

1. 拖拽时直接传入属性

在启动拖拽 (startDrag) 时,除了节点类型 (type) 和文本 (text),还可以通过 properties字段直接传入自定义的业务属性 。

// 可拖拽的节点列表定义
const nodeList = ref([
  {
    type: 'approval-node',
    text: '审批节点',
    // 在此处定义该类型节点的默认属性
    properties: {
      nodeName: '默认审批节点',
      assignee: '待指定', // 负责人
      status: 'pending',  // 状态:待处理
      priority: 'medium', // 优先级:中
      createTime: new Date().toLocaleDateString() // 创建时间
    }
  },
  // ... 其他节点类型
]);

// 处理拖拽开始事件
const onDragStart = (event, nodeConfig) => {
  if (!lf.value) return;

  lf.value.dnd.startDrag({
    type: nodeConfig.type,
    text: nodeConfig.text,
    // 关键:传入自定义属性,可以在此动态覆盖或添加属性
    properties: {
      ...nodeConfig.properties,
      createTime: new Date().toLocaleString() // 动态生成更精确的时间
    }
  });
  event.dataTransfer.setData('text/plain', nodeConfig.type);
};

2. 节点添加后动态设置属性

通过监听节点被添加到画布的事件 (node:add),可以获取到节点实例,然后为其设置或更新属性 。

// 在初始化LogicFlow后,设置事件监听
const setupEventListeners = () => {
  if (!lf.value) return;

  // 监听节点添加事件
  lf.value.on('node:add', ({ data }) => {
    // 获取新增节点的模型
    const nodeModel = lf.value.getNodeModelById(data.id);
    
    if (nodeModel) {
      // 使用 setProperties 方法批量更新属性
      nodeModel.setProperties({
        ...nodeModel.properties,
        // 确保关键属性存在,例如为节点设置一个唯一标识
        nodeId: data.id,
        // 或者根据业务逻辑设置状态
        status: nodeModel.properties.status || 'pending'
      });

      // 也可以根据属性值,动态更新节点的视觉样式
      updateNodeStyleByProperties(nodeModel);
    }
  });
};

// 根据属性更新节点样式(例如,根据不同状态显示不同颜色)
const updateNodeStyleByProperties = (nodeModel) => {
  const { status } = nodeModel.properties;

  if (status === 'approved') {
    nodeModel.stroke = '#52c41a';
    nodeModel.fill = '#f6ffed';
  } else if (status === 'rejected') {
    nodeModel.stroke = '#ff4d4f';
    nodeModel.fill = '#fff2f0';
  }
  // 触发视图更新
  nodeModel.updateAttributes({ style: { stroke: nodeModel.stroke, fill: nodeModel.fill } });
};

💡 在自定义节点模型中固化属性逻辑

为了更彻底地管理属性,可以在自定义节点的 Model 中重写 initNodeData方法,确保每个新节点都具备完整的默认属性 。

import { RectNodeModel } from '@logicflow/core';

class CustomNodeModel extends RectNodeModel {
  initNodeData(data) {
    // 调用父类方法初始化基础数据
    super.initNodeData(data);
    
    // 设置节点默认尺寸
    this.width = 120;
    this.height = 60;

    // 核心:合并及设置节点的自定义属性
    this.properties = {
      // 先设置该类型节点的默认属性
      ...this.getDefaultProperties(data.type),
      // 再合并拖拽时传入或数据中已有的属性
      ...data.properties
    };

    // 初始化后立即根据属性更新一次样式
    this.updateStyleByProperties();
  }

  // 定义不同类型节点的默认属性
  getDefaultProperties(nodeType) {
    const defaults = {
      'approval-node': {
        nodeName: '审批节点',
        assignee: '待指定',
        status: 'pending',
        priority: 'medium'
      },
      'start-node': {
        nodeName: '开始节点',
        initiator: 'system',
        autoStart: true
      }
    };
    return defaults[nodeType] || {}; // 返回对应类型的默认属性,若无则返回空对象
  }

  // 定义一个方法,用于在属性变化时更新节点样式
  updateStyleByProperties() {
    const { status, priority } = this.properties;
    // ... 根据 status 或 priority 等属性更新 this.stroke, this.fill 等样式逻辑
  }
}

⚠️ 注意事项

  1. 属性名避免冲突:自定义属性建议使用特定的命名空间,避免与 LogicFlow 节点模型内置属性(如 x, y, text)同名。
  2. 属性序列化:确保 properties中的值是可序列化的数据类型(如字符串、数字、布尔值、普通对象、数组),便于后续数据的保存和传输 。
  3. 事件监听时机:确保事件监听(如 node:add)在 LogicFlow 实例渲染 (lf.render()) 之前设置,否则初始渲染的节点可能无法被捕获。

通过上述方法,你可以灵活地为拖拽创建的节点配置丰富的自定义属性,并能根据这些属性控制节点的外观和行为,更好地满足业务需求。

mathjs简单实现一个数学计算公式及校验组件

作者 HHHHHY
2026年1月12日 11:42

前提需求

PM复述客户需求:需要一个能够提供使用数学公式计算内部数据的功能(要求灵活功能强大一点)。
和后端商量了一下,由前端控制公式输入,后端用js引擎计算(具体啥js引擎 咱不懂)

1、不墨迹看效果

20260112_113358.gif

2、组件代码

FormulaEditor.vue

<template>
  <div>
    <div style="margin: 8px 0; display: flex; gap: 12px;">
      <!-- 函数选择 -->
      <el-select
        v-model="selectedFunction"
        placeholder="插入函数"
        clearable
        @change="handleFunctionSelect"
        style="width: 220px"
      >
        <el-option
          v-for="fn in functionList"
          :key="fn.name"
          :label="`${fn.template}(${fn.label})`"
          :value="fn.name"
        />
      </el-select>

      <!-- 参数选择 -->
      <el-select
        v-model="selectedParam"
        placeholder="插入参数"
        filterable
        clearable
        ref="selectRef"
        @change="insertParam"
        style="width: 150px"
      >
        <el-option
          v-for="param in paramList"
          :key="param.id"
          :label="param.srcName + ' - ' + param.nameCn"
          :value="param.expression??param.srcName"
        />
      </el-select>
      <!-- <el-link type="primary" href="https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Math" target="_blank">查看更多JS数学函数</el-link> -->
    </div>

    <!-- 表达式输入框 -->
    <textarea
      v-model="currentFormula"
      ref="textareaRef"
      rows="4"
      cols="60"
      @input="validateFormula"
      style="font-family: monospace; font-size: 14px; width: 100%;"
    ></textarea>

    <!-- 解析提示 -->
    <div style="margin-top: 6px;" v-if="currentFormula">
      <span v-if="parseError" style="color: red;">❌ {{ parseError }}</span>
      <span v-else style="color: green;">✅ 表达式合法</span>
    </div>
  </div>
</template>

<script setup>
import { ref, watch, computed, nextTick } from 'vue'
import { parse as mathParse } from 'mathjs'

// Element Plus 选择框绑定值
const selectedFunction = ref('')
const selectedParam = ref('')

// 默认函数列表
const defaultFunctions = [
  { name: 'Math.max', template: 'Math.max(x, y)', label: '最大值' },
  { name: 'Math.min', template: 'Math.min(x, y)', label: '最小值' },
  { name: 'Math.pow', template: 'Math.pow(base, exponent)', label: '乘幂' },
  { name: 'Math.sqrt', template: 'Math.sqrt(x)', label: '平方根' },
  { name: 'Math.abs', template: 'Math.abs(x)', label: '绝对值' },
  { name: 'Math.floor', template: 'Math.floor(x)', label: '向下取整' },
  { name: 'Math.ceil', template: 'Math.ceil(x)', label: '向上取整' },
  { name: 'Math.round', template: 'Math.round(x)', label: '四舍五入' },
  { name: 'Math.log', template: 'Math.log(x)', label: '自然对数' },
//   { name: 'Math.sign', template: 'Math.sign(x)', label: '符号函数' },
//   { name: 'Math.cbrt', template: 'Math.cbrt(x)', label: '立方根' },
]

const props = defineProps({
  modelValue: {
    type: String,
    default: '',
  },
  params: {
    type: Array,
    default: () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
  },
  variables: {
    type: Object,
    default: () => ({ a: 10, b: 5, c: 3 })
  }
})

const emit = defineEmits(['update:modelValue'])

const currentFormula = ref('')
const parseError = ref(null)
const result = ref(null)
const textareaRef = ref(null)

const functionList = computed(() => defaultFunctions)
const paramList = computed(() => props.params)



// 插入光标位置文本
const insertAtCursor = (text) => {
  const textarea = textareaRef.value
  if (!textarea) return

  const start = textarea.selectionStart
  const end = textarea.selectionEnd
  const oldVal = currentFormula.value
  currentFormula.value = oldVal.slice(0, start) + text + oldVal.slice(end)

  nextTick(() => {
    textarea.focus()
    textarea.selectionStart = textarea.selectionEnd = start + text.length
  })
}

// 选择函数插入模板
const handleFunctionSelect = (fnName) => {
  const fn = functionList.value.find(f => f.name === fnName)
  if (!fn) return
  insertAtCursor(fn.template)
  selectedFunction.value = ''
}

const selectRef = ref(null)
// 插入参数
const insertParam = (param) => {
  if (!param) return
  insertAtCursor(param)
  selectedParam.value = ''
  setTimeout(() => {
    selectRef.value?.blur()
  })
}

// 表达式校验 + 运行

const validateFormula = () => {
  let expr = currentFormula.value.trim()
  if (expr.startsWith('=')) {
    expr = expr.slice(1)
  }

  try {
    const node = mathParse(expr)

    const usedSymbols = new Set()

    node.traverse(function (node, path, parent) {
      if (node.isSymbolNode) {
        usedSymbols.add(node.name)
      }
    })

    const allowedVars = paramList.value.map(p => p.srcName)

    const allowedFuncs = [
      'abs', 'ceil', 'floor', 'max', 'min', 'pow',
      'round', 'sign', 'sqrt', 'log', 'cbrt'
    ]

    allowedVars.push('Math')

    // 检查未定义变量
    const invalid = Array.from(usedSymbols).filter(
      name => !allowedVars.includes(name) && !allowedFuncs.includes(name)
    )

    if (invalid.length) {
      parseError.value = `未定义的变量:${invalid.join(', ')}`
      result.value = null
    } else {
      parseError.value = null
      result.value = '合法公式'
    }
  } catch (err) {
    parseError.value = `语法错误:${err.message}`
    result.value = null
  }
}


watch(() => props.modelValue, (val) => {
  currentFormula.value = val
  validateFormula()
}, { immediate: true })

watch(currentFormula, (val) => {
  emit('update:modelValue', val)
})
</script>

<style scoped>
</style>

Tips:

内部参数逻辑其实还是有点复杂的,没有贴出来,提供的代码拓展性也是很强的,可以根据需求来调整。

Vue3 provide/inject 跨层级通信:最佳实践与避坑指南

作者 boooooooom
2026年1月12日 11:38

Vue3 provide/inject 跨层级通信:最佳实践与避坑指南

在Vue组件化开发中,组件通信是核心需求之一。对于父子组件通信,props/emit足以应对;对于兄弟组件或简单跨层级通信,EventBus或Pinia可解燃眉之急。但在复杂的组件树结构中(如多层嵌套的表单组件、权限管理组件、业务模块容器),跨层级组件间的通信若仍依赖props层层透传,会导致代码冗余、维护成本激增(即“props drilling”问题)。Vue3提供的provide/inject API,正是为解决跨层级通信痛点而生——它允许祖先组件向所有后代组件注入依赖,无需关心组件层级深度。本文将深入剖析provide/inject的核心特性,结合实际业务场景,总结跨层级通信的最佳实践与避坑指南。

一、核心认知:provide/inject 是什么?

provide/inject 是Vue3内置的一对API,用于实现“祖先组件”与“后代组件”(无论层级多深)之间的跨层级通信,属于“依赖注入”模式。其核心逻辑可概括为:

  • Provide(提供) :祖先组件通过provide API,向所有后代组件“提供”一个或多个响应式数据/方法。
  • Inject(注入) :后代组件通过inject API,“注入”祖先组件提供的数据/方法,直接使用,无需经过中间组件传递。

与props/emit相比,provide/inject 打破了组件层级的限制,避免了props的层层透传;与Pinia相比,它更适合局部模块内的跨层级通信(无需引入全局状态管理),轻量化且灵活。

二、基础用法:组合式API下的核心实现

在Vue3组合式API(尤其是<script setup>语法)中,provide/inject的用法简洁直观,无需额外配置,核心分为“提供数据”和“注入数据”两步。

2.1 基础场景:非响应式数据通信

适用于传递静态数据(如常量配置、固定权限标识等),祖先组件提供数据后,后代组件注入使用。

<!-- 祖先组件:Grandparent.vue (提供数据)-->
<script setup>
import { provide } from 'vue';

// 提供非响应式数据:应用名称、版本号
provide('appName', 'Vue3 Admin');
provide('appVersion', '1.0.0');
</script>

<template>
  <div class="grandparent">
    <h2>祖先组件(提供数据)</h2>
    <Parent /> <!-- 中间组件,无需传递数据 -->
  </div>
</template>

中间组件(Parent.vue)无需任何处理,直接渲染子组件即可:

<!-- 中间组件:Parent.vue -->
<script setup>
import Child from './Child.vue';
</script>

<template>
  <div class="parent">
    <h3>中间组件(无需传递数据)</h3>
    <Child />
  </div>
</template>

后代组件(Child.vue)注入并使用数据:

<!-- 后代组件:Child.vue (注入数据)-->
<script setup>
import { inject } from 'vue';

// 注入祖先组件提供的数据,第二个参数为默认值(可选)
const appName = inject('appName', '默认应用名称');
const appVersion = inject('appVersion', '0.0.0');
</script>

<template>
  <div class="child">
    <h4>后代组件(注入数据)</h4>
    <p>应用名称:{{ appName }}</p>
    <p>版本号:{{ appVersion }}</p>
  </div>
</template>

2.2 核心场景:响应式数据通信

实际业务中,更多需要传递响应式数据(如用户状态、表单数据、权限信息等),确保祖先组件数据更新时,所有注入该数据的后代组件同步更新。实现响应式通信的核心是:provide 提供响应式数据(ref/reactive),inject 直接使用即可保持响应式关联

<!-- 祖先组件:UserProvider.vue (提供响应式数据)-->
<script setup>
import { provide, ref, reactive } from 'vue';

// 1. 响应式数据:用户信息(ref)
const userInfo = ref({
  name: '张三',
  role: 'admin',
  isLogin: true
});

// 2. 响应式数据:权限列表(reactive)
const permissions = reactive([
  'user:list',
  'user:edit',
  'menu:manage'
]);

// 3. 提供响应式数据和修改数据的方法
provide('userInfo', userInfo);
provide('permissions', permissions);
provide('updateUserInfo', (newInfo) => {
  userInfo.value = { ...userInfo.value, ...newInfo };
});
</script>

<template>
  <div class="user-provider">
    <h2>用户状态提供者(响应式)</h2>
    <p>当前用户:{{ userInfo.name }}</p>
    <Button @click="userInfo.value.name = '李四'">修改用户名</Button>
    <DeepChild /> <!-- 深层后代组件 -->
  </div>
</template>

深层后代组件注入并使用响应式数据:

<!-- 深层后代组件:DeepChild.vue -->
<script setup>
import { inject } from 'vue';

// 注入响应式数据和方法
const userInfo = inject('userInfo');
const permissions = inject('permissions');
const updateUserInfo = inject('updateUserInfo');

// 调用注入的方法修改数据
const handleUpdateRole = () => {
  updateUserInfo({ role: 'superAdmin' });
};
</script>

<template>
  <div class="deep-child">
    <h4>深层后代组件(响应式注入)</h4>
    <p>用户名:{{ userInfo.name }}</p>
    <p>角色:{{ userInfo.role }}</p>
    <p>权限列表:{{ permissions.join(', ') }}</p>
    <Button @click="handleUpdateRole">提升为超级管理员</Button>
  </div>
</template>

关键说明:

  • 提供响应式数据时,直接传递ref/reactive对象即可,inject后无需额外处理,自动保持响应式。
  • 建议同时提供“修改数据的方法”(如updateUserInfo),而非让后代组件直接修改注入的响应式数据——符合“单向数据流”原则,便于数据变更的追踪与维护。

三、进阶技巧:优化跨层级通信的核心方案

在复杂业务场景中,仅靠基础用法可能导致“注入key冲突”“数据类型不明确”“全局污染”等问题。以下进阶技巧可大幅提升provide/inject的可用性与可维护性。

3.1 避免key冲突:使用Symbol作为注入key

基础用法中,注入key为字符串(如'userInfo'),若多个祖先组件提供同名key,后代组件会注入最近的一个,容易出现“key冲突”。解决方案:使用Symbol作为注入key,Symbol具有唯一性,可彻底避免同名冲突。

最佳实践:单独创建keys文件,统一管理注入key:

// src/composables/keys.js (统一管理注入key)
export const InjectionKeys = {
  userInfo: Symbol('userInfo'),
  permissions: Symbol('permissions'),
  updateUserInfo: Symbol('updateUserInfo')
};

祖先组件提供数据:

<!-- 祖先组件:UserProvider.vue -->
<script setup>
import { provide, ref } from 'vue';
import { InjectionKeys } from '@/composables/keys';

const userInfo = ref({ name: '张三', role: 'admin' });
const updateUserInfo = (newInfo) => {
  userInfo.value = { ...userInfo.value, ...newInfo };
};

// 使用Symbol作为key提供数据
provide(InjectionKeys.userInfo, userInfo);
provide(InjectionKeys.updateUserInfo, updateUserInfo);
</script>

后代组件注入数据:

<!-- 后代组件:DeepChild.vue -->
<script setup>
import { inject } from 'vue';
import { InjectionKeys } from '@/composables/keys';

// 使用Symbol key注入
const userInfo = inject(InjectionKeys.userInfo);
const updateUserInfo = inject(InjectionKeys.updateUserInfo);
</script>

3.2 类型安全:TS环境下的类型定义

在TypeScript环境中,直接使用inject可能导致“类型不明确”(返回any类型)。解决方案:为inject指定泛型类型,或使用withDefaults辅助函数定义默认值与类型

方案1:指定泛型类型

<!-- 后代组件(TS环境)-->
<script setup lang="ts">
import { inject } from 'vue';
import { InjectionKeys } from '@/composables/keys';

// 定义用户信息类型
interface UserInfo {
  name: string;
  role: string;
  isLogin: boolean;
}

// 指定泛型类型,确保类型安全
const userInfo = inject<Ref<UserInfo>>(InjectionKeys.userInfo);
const updateUserInfo = inject<(newInfo: Partial<UserInfo>) => void>(InjectionKeys.updateUserInfo);
</script>

方案2:使用withDefaults定义默认值与类型(Vue3.3+支持)

<!-- 后代组件(TS环境,Vue3.3+)-->
<script setup lang="ts">
import { inject, withDefaults } from 'vue';
import { InjectionKeys } from '@/composables/keys';

interface UserInfo {
  name: string;
  role: string;
  isLogin: boolean;
}

// withDefaults 同时定义默认值和类型
const injects = withDefaults(
  () => ({
    userInfo: inject<Ref<UserInfo>>(InjectionKeys.userInfo),
    updateUserInfo: inject<(newInfo: Partial<UserInfo>) => void>(InjectionKeys.updateUserInfo)
  }),
  {
    // 为可选注入项设置默认值
    userInfo: () => ref({ name: '匿名用户', role: 'guest', isLogin: false })
  }
);

// 使用注入的数据,类型完全明确
const { userInfo, updateUserInfo } = injects;
</script>

3.3 局部作用域隔离:避免全局污染

provide/inject 的作用域是“当前组件及其所有后代组件”,若在根组件(App.vue)中provide数据,会成为全局可注入的数据,容易导致全局污染。最佳实践:按业务模块划分provide作用域,仅在需要跨层级通信的模块根组件中provide数据

示例:按“用户模块”“订单模块”划分作用域:

  • 用户模块根组件(UserModule.vue):provide用户相关的data/methods,仅用户模块的后代组件可注入。
  • 订单模块根组件(OrderModule.vue):provide订单相关的data/methods,仅订单模块的后代组件可注入。

这样既实现了模块内的跨层级通信,又避免了不同模块间的数据干扰。

3.4 组合式封装:抽离复用逻辑

对于复杂的跨层级通信场景(如包含多个数据、多个方法),可将provide/inject逻辑抽离为组合式函数(composable),实现逻辑复用。

// src/composables/useUserProvider.js (抽离provide逻辑)
import { provide, ref } from 'vue';
import { InjectionKeys } from './keys';

export const useUserProvider = () => {
  // 响应式数据
  const userInfo = ref({
    name: '张三',
    role: 'admin',
    isLogin: true
  });

  const permissions = ref(['user:list', 'user:edit']);

  // 修改数据的方法
  const updateUserInfo = (newInfo) => {
    userInfo.value = { ...userInfo.value, ...newInfo };
  };

  const addPermission = (perm) => {
    if (!permissions.value.includes(perm)) {
      permissions.value.push(perm);
    }
  };

  // 提供数据和方法
  provide(InjectionKeys.userInfo, userInfo);
  provide(InjectionKeys.permissions, permissions);
  provide(InjectionKeys.updateUserInfo, updateUserInfo);
  provide(InjectionKeys.addPermission, addPermission);

  // 返回内部逻辑(供祖先组件自身使用)
  return {
    userInfo,
    permissions
  };
};

祖先组件使用组合式函数:

<!-- 祖先组件:UserModule.vue -->
<script setup>
import { useUserProvider } from '@/composables/useUserProvider';

// 直接调用组合式函数,完成数据提供
const { userInfo } = useUserProvider();
</script>

后代组件抽离注入逻辑:

// src/composables/useUserInject.js (抽离inject逻辑)
import { inject } from 'vue';
import { InjectionKeys } from './keys';

export const useUserInject = () => {
  const userInfo = inject(InjectionKeys.userInfo);
  const permissions = inject(InjectionKeys.permissions);
  const updateUserInfo = inject(InjectionKeys.updateUserInfo);
  const addPermission = inject(InjectionKeys.addPermission);

  // 校验注入项(避免未提供的情况)
  if (!userInfo || !updateUserInfo) {
    throw new Error('useUserInject 必须在 useUserProvider 提供的作用域内使用');
  }

  return {
    userInfo,
    permissions,
    updateUserInfo,
    addPermission
  };
};

后代组件使用:

<!-- 后代组件:DeepChild.vue -->
<script setup>
import { useUserInject } from '@/composables/useUserInject';

// 直接调用组合式函数,获取注入的数据和方法
const { userInfo, updateUserInfo } = useUserInject();
</script>

优势:逻辑抽离后,代码更简洁、可维护性更强,且通过校验可避免“在非提供作用域内注入”的错误。

四、最佳实践:业务场景落地指南

结合实际业务场景,以下是provide/inject跨层级通信的典型应用场景及落地方案。

4.1 场景1:多层嵌套表单组件通信

需求:复杂表单包含多个子表单(如个人信息子表单、地址子表单、银行卡子表单),子表单嵌套层级深,需要共享表单数据、校验状态、提交方法。

落地方案:

  • 在根表单组件(FormRoot.vue)中,用reactive创建表单数据(formData)和校验状态(validateState),提供修改表单数据、校验表单、提交表单的方法。
  • 各子表单组件(FormPersonal.vue、FormAddress.vue等)通过inject注入formData和方法,直接修改自身对应的表单字段,无需通过props传递。
<!-- 根表单组件:FormRoot.vue -->
<script setup>
import { provide, reactive } from 'vue';
import { InjectionKeys } from '@/composables/keys';
import FormPersonal from './FormPersonal.vue';
import FormAddress from './FormAddress.vue';

// 表单数据
const formData = reactive({
  personal: { name: '', age: '' },
  address: { province: '', city: '', detail: '' }
});

// 校验状态
const validateState = reactive({
  personal: { valid: false, message: '' },
  address: { valid: false, message: '' }
});

// 提供数据和方法
provide(InjectionKeys.formData, formData);
provide(InjectionKeys.validateState, validateState);
provide(InjectionKeys.validateForm, (section) => {
  // 校验指定 section(如personal、address)
  if (section === 'personal') {
    validateState.personal.valid = !!formData.personal.name;
    validateState.personal.message = formData.personal.name ? '' : '姓名不能为空';
  }
  // ...其他校验逻辑
});
provide(InjectionKeys.submitForm, () => {
  // 整体校验后提交
  Object.keys(validateState).forEach(key => validateState[key].valid = !!formData[key]);
  if (Object.values(validateState).every(item => item.valid)) {
    console.log('提交表单:', formData);
  }
});
</script>

子表单组件直接注入使用:

<!-- 子表单组件:FormPersonal.vue -->
<script setup>
import { inject } from 'vue';
import { InjectionKeys } from '@/composables/keys';

const formData = inject(InjectionKeys.formData);
const validateState = inject(InjectionKeys.validateState);
const validateForm = inject(InjectionKeys.validateForm);

// 失去焦点时校验
const handleBlur = () => {
  validateForm('personal');
};
</script>

<template>
  <div class="form-personal">
    <h4>个人信息</h4>
    <input 
      v-model="formData.personal.name" 
      @blur="handleBlur"
      placeholder="请输入姓名"
    />
    <span class="error" v-if="!validateState.personal.valid">
      {{ validateState.personal.message }}
    </span>
  </div>
</template>

4.2 场景2:权限管理模块通信

需求:权限管理模块中,根组件获取用户权限列表后,深层嵌套的菜单组件、按钮组件、表单组件需要根据权限动态渲染(如无权限则隐藏按钮)。

落地方案:

  • 在权限模块根组件(PermissionRoot.vue)中,请求用户权限列表,提供权限列表和“判断是否有权限”的工具方法(hasPermission)。
  • 各深层组件(Menu.vue、Button.vue)注入hasPermission方法,根据当前需要的权限标识,动态控制组件显示/隐藏。
// src/composables/usePermission.js (抽离权限相关逻辑)
import { provide, inject, ref } from 'vue';
import { InjectionKeys } from './keys';

// 提供权限逻辑
export const usePermissionProvider = async () => {
  // 模拟请求权限列表
  const fetchPermissions = () => {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve(['menu:user', 'btn:add', 'btn:edit']);
      }, 1000);
    });
  };

  const permissions = ref(await fetchPermissions());

  // 判断是否有权限的工具方法
  const hasPermission = (perm) => {
    return permissions.value.includes(perm);
  };

  provide(InjectionKeys.permissions, permissions);
  provide(InjectionKeys.hasPermission, hasPermission);

  return { permissions, hasPermission };
};

// 注入权限逻辑
export const usePermissionInject = () => {
  const hasPermission = inject(InjectionKeys.hasPermission);

  if (!hasPermission) {
    throw new Error('usePermissionInject 必须在 usePermissionProvider 作用域内使用');
  }

  return { hasPermission };
};

按钮组件使用权限判断:

<!-- 按钮组件:PermissionButton.vue -->
<script setup>
import { usePermissionInject } from '@/composables/usePermission';

const { hasPermission } = usePermissionInject();
const props = defineProps({
  perm: {
    type: String,
    required: true
  },
  label: {
    type: String,
    required: true
  }
});
</script>

<template>
  <Button v-if="hasPermission(props.perm)">
    {{ props.label }}
  </Button>
</template>

五、避坑指南:常见问题与解决方案

使用provide/inject时,容易出现响应式失效、注入失败、数据污染等问题,以下是常见问题的解决方案。

5.1 问题1:注入的数据非响应式

原因:provide时传递的是普通数据(非ref/reactive),或传递的是ref.value(失去响应式关联)。

解决方案:

  • 确保provide的是ref/reactive对象,而非普通值。
  • provide时不要解构ref/reactive对象(如provide('user', userInfo.value) 错误,应提供userInfo本身)。

5.2 问题2:注入失败,返回undefined

原因:

  • 后代组件不在provide的祖先组件作用域内。
  • 注入的key与provide的key不一致(如字符串key大小写错误、Symbol key不匹配)。
  • provide的逻辑在异步操作之后,注入时数据尚未提供。

解决方案:

  • 确保注入组件是provide组件的后代组件。
  • 使用统一管理的Symbol key,避免手动输入错误。
  • 若provide包含异步逻辑,可在祖先组件中等待异步完成后再渲染后代组件(如v-if控制)。

5.3 问题3:多个祖先组件提供同名key,注入混乱

原因:使用字符串key,多个祖先组件提供同名数据,后代组件会注入“最近”的一个,导致预期外的结果。

解决方案:使用Symbol作为注入key,利用Symbol的唯一性避免冲突。

5.4 问题4:后代组件直接修改注入的响应式数据,导致数据流向混乱

原因:违反“单向数据流”原则,多个后代组件直接修改注入的数据,难以追踪数据变更来源。

解决方案:

  • 祖先组件提供“修改数据的方法”,后代组件通过调用方法修改数据,而非直接操作。
  • 若需要严格控制,可使用readonly包装响应式数据后再provide,禁止后代组件直接修改(如provide('userInfo', readonly(userInfo)))。

六、总结:provide/inject 的适用边界与选型建议

provide/inject 是Vue3跨层级通信的优秀解决方案,但并非万能,需明确其适用边界,合理选型:

  • 适用场景:局部模块内的跨层级通信(如复杂表单、权限模块、业务组件容器)、无需全局共享的跨层级数据传递。
  • 不适用场景:全局状态共享(如用户登录状态、全局配置)——建议使用Pinia;简单的父子组件通信——建议使用props/emit。

最佳实践总结:

  1. 使用Symbol key避免冲突,统一管理注入key。
  2. 提供响应式数据时,同时提供修改方法,遵循单向数据流。
  3. 抽离组合式函数(composable)封装provide/inject逻辑,提升复用性与可维护性。
  4. TS环境下做好类型定义,确保类型安全。
  5. 按业务模块划分作用域,避免全局污染。

合理运用provide/inject,可大幅简化复杂组件树的通信逻辑,提升代码的简洁性与可维护性。结合本文的最佳实践与避坑指南,相信能帮助你在实际项目中高效落地跨层级通信方案。

Vue 3 + Three.js 打造轻量级 3D 图表库 —— chart3

2026年1月12日 11:33

大家好,我是 一颗烂土豆

最近在数据可视化领域进行了一些探索,基于 Vue 3Three.js 开发了一款轻量级的 3D 图表库 —— chart3

今天不谈晦涩的代码实现,主要和大家分享一下这个项目的设计初衷目前进展以及未来的规划

💻 在线体验chart3js.netlify.app/

image.png

🌟 愿景 (Vision)

在实际开发中,我们往往面临两难的选择:要么使用传统的 2D 图表库(如 ECharts)通过“伪 3D”来实现效果,但缺乏立体感和自由视角;要么直接使用 Three.js 从零撸,成本高且难以复用。

chart3 的诞生就是为了解决这个问题,它的核心愿景是:

  1. 极简配置:延续 ECharts 的 "Option-based" 配置思维,让前端开发者无需深入了解 WebGL/Three.js 的底层细节,通过简单的 JSON 配置即可生成炫酷的 3D 图表。
  2. 真 3D 体验:全场景 3D 渲染,支持 360 度自由旋转、缩放、平移,提供真实的光影、材质和空间感。
  3. 轻量与现代:完全基于 Vue 3 Composition API 和 TypeScript 构建,模块化设计,无历史包袱。

🚀 现状 (Current Status)

目前项目处于快速迭代阶段,核心引擎已经搭建完毕,并实现了一套可视化的配置系统。你可以通过 在线 Demo 实时调整参数并预览效果。

已支持的功能特性:

  • 基础图表组件
    • 📊 3D 柱状图 (Bar3D):支持多系列、不同颜色的柱体渲染。

ScreenShot_2026-01-12_110024_828.png

  • 🥧 3D 饼图 (Pie3D):支持扇区挤出高度、标签展示。

ScreenShot_2026-01-12_110108_307.png * 📈 3D 折线图 (Line3D):支持管状线条渲染。

ScreenShot_2026-01-12_110046_630.png * 🌌 3D 散点图 (Scatter3D):支持三维空间的数据点分布。

ScreenShot_2026-01-12_110004_262.png

  • 可视化配置系统
    • 数据源 (Data):支持静态数据配置。
    • 主题与配色 (Theme):内置多套配色方案,支持自定义默认颜色。
    • 坐标系 (Coordinate):可实时调整网格的宽度、深度、高度,以及各轴线、刻度、网格线的显示与隐藏。
    • 材质系统 (Material):这是 3D 图表的灵魂。支持实时调节透明度、粗糙度 (Roughness)、金属度 (Metalness),轻松实现玻璃、金属等质感。
    • 灯光系统 (Lighting):支持环境光和方向光的强度与位置调节,营造氛围感。
    • 交互 (Interaction):支持鼠标悬停高亮、HTML 标签 (Label) 自动跟随。

📅 待实现的任务 (Roadmap)

为了让 chart3 真正成为生产可用的图表库,后续还有很多有趣的工作要做:

  • 高级图表开发
    • 🌊 3D 曲面图 (Surface 3D):用于展示复杂的三维函数或地形数据(目前 Demo 中显示为“待开发”)。
    • 🗺️ 3D 地图 (Map 3D):支持 GeoJSON 数据的三维挤出渲染。
  • 性能优化
    • 引入 InstancedMesh 技术,大幅提升大数据量(如 10w+ 散点或柱体)下的渲染性能。
  • 动画系统
    • 实现图表的入场动画(如柱子升起、饼图展开)。
    • 数据更新时的平滑过渡动画。
  • 工程化与文档
    • 完善 API 文档和使用指南。
    • 提供 NPM 包发布,方便项目集成。

🤝 结语

这个项目是我对“数据可视化 x 3D”的一次尝试。

让我们一起把数据变得更酷一点!

从零开始:使用 Docker 部署 React 前端项目完整实战

作者 lcy453
2026年1月12日 11:23

本文记录了我将车辆管理系统前端项目(基于 React + Vite)部署到 Docker 的完整过程,包括 Docker 基础概念、实战步骤、踩坑记录和问题解决方案。


📚 第一部分:Docker 基础概念

1.1 什么是 Docker?

Docker 是一个开源的容器化平台,可以将应用程序及其所有依赖打包到一个标准化的容器中,确保应用在任何环境下都能以相同的方式运行。

简单理解:

  • 传统方式:在服务器上安装 Node.js、Nginx、配置环境变量等,换台服务器又要重新配置一遍
  • Docker 方式:将应用和环境打包成一个"集装箱",在任何安装了 Docker 的机器上都能直接运行

1.2 Docker 核心概念

1.2.1 镜像(Image)

定义: 镜像是一个只读的模板,包含了运行应用所需的所有内容(代码、运行时、库、环境变量、配置文件等)。

类比理解: 镜像就像是一个"软件安装包"或"光盘镜像",它是静态的、不会改变的。

例子:

# 查看本地所有镜像
docker images

# 输出示例:
REPOSITORY      TAG       IMAGE ID       CREATED         SIZE
react-manager   latest    abc123def456   5 minutes ago   50MB
nginx           alpine    xyz789uvw123   2 weeks ago     23MB
node            20-alpine def456abc789   1 month ago     115MB

镜像的命名规则:

  • react-manager:latest = 仓库名:标签
  • latest 是默认标签,通常表示最新版本
  • 也可以用版本号,如 react-manager:1.0.0

1.2.2 容器(Container)

定义: 容器是镜像的运行实例,是一个独立运行的应用环境。

类比理解:

  • 镜像 = 程序安装包(.exe 文件)
  • 容器 = 运行中的程序进程

特点:

  • 一个镜像可以创建多个容器
  • 容器是隔离的,互不影响
  • 容器可以启动、停止、删除、暂停

例子:

# 查看正在运行的容器
docker ps

# 输出示例:
CONTAINER ID   IMAGE                  COMMAND                  PORTS                  NAMES
a1b2c3d4e5f6   react-manager:latest   "nginx -g 'daemon of…"   0.0.0.0:8080->80/tcp   react-manager-app

容器的生命周期:

创建 → 运行 → 停止 → 删除
  ↓      ↓      ↓      ↓
Created → Running → Stopped → Removed

1.2.3 Dockerfile

定义: Dockerfile 是一个文本文件,包含了一系列构建镜像的指令。

类比理解: Dockerfile 就像是一份"菜谱",告诉 Docker 如何一步步制作镜像。

常用指令:

指令 作用 示例
FROM 指定基础镜像 FROM node:20-alpine
WORKDIR 设置工作目录 WORKDIR /app
COPY 复制文件到镜像 COPY package.json ./
RUN 执行命令(构建时) RUN npm install
CMD 容器启动时执行的命令 CMD ["nginx", "-g", "daemon off;"]
EXPOSE 声明容器监听的端口 EXPOSE 80

1.2.4 多阶段构建(Multi-stage Build)

定义: 在一个 Dockerfile 中使用多个 FROM 指令,每个 FROM 开始一个新的构建阶段。

作用: 减小最终镜像大小,只保留运行时需要的文件。

示例:

# 第一阶段:构建阶段(包含 Node.js、npm、源代码)
FROM node:20-alpine AS build
WORKDIR /app
COPY . .
RUN npm install && npm run build

# 第二阶段:运行阶段(只包含 Nginx 和构建产物)
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html

好处:

  • 构建阶段的镜像可能有 500MB(包含 Node.js、node_modules)
  • 最终镜像只有 50MB(只包含 Nginx 和静态文件)

1.2.5 端口映射

定义: 将容器内部端口映射到宿主机端口,使外部可以访问容器内的服务。

语法: -p 宿主机端口:容器端口

示例:

docker run -p 8080:80 react-manager:latest

理解:

  • 容器内 Nginx 监听 80 端口
  • 通过 -p 8080:80 映射到宿主机的 8080 端口
  • 访问 http://localhost:8080 → 实际访问容器内的 80 端口

可视化:

宿主机(你的电脑)               Docker 容器
┌──────────────┐              ┌──────────────┐
│              │              │              │
│ localhost    │   映射关系    │   Nginx      │
│ 8080 端口 ─────┼──────────→  │   80 端口    │
│              │              │              │
└──────────────┘              └──────────────┘

1.2.6 数据卷(Volume)和挂载

定义: 将宿主机的文件或目录挂载到容器内,实现数据持久化。

语法: -v 宿主机路径:容器路径

示例:

# 挂载配置文件
docker run -v /my/nginx.conf:/etc/nginx/nginx.conf nginx:alpine

# 挂载数据目录
docker run -v /my/data:/app/data myapp:latest

1.3 Docker vs 虚拟机

特性 Docker 容器 虚拟机
启动速度 秒级 分钟级
资源占用 轻量(MB 级) 重量(GB 级)
性能 接近原生 有性能损耗
隔离性 进程级隔离 操作系统级隔离
可移植性 跨平台 受限于虚拟化技术

可视化对比:

虚拟机架构:
┌─────────────────────────────────────┐
│ 应用A  │ 应用B  │ 应用C              │
├────────┼────────┼────────────────────┤
│ OSOSOS                 │  ← 每个虚拟机都有完整的操作系统
├────────┴────────┴────────────────────┤
│ 虚拟机管理器(Hypervisor)              │
├─────────────────────────────────────┤
│ 宿主机操作系统                        │
└─────────────────────────────────────┘

Docker 容器架构:
┌─────────────────────────────────────┐
│ 应用A  │ 应用B  │ 应用C              │
├────────┴────────┴────────────────────┤
│ Docker Engine                        │  ← 共享宿主机的操作系统内核
├─────────────────────────────────────┤
│ 宿主机操作系统                        │
└─────────────────────────────────────┘

🎯 第二部分:项目部署实战

2.1 项目背景

项目: 车辆管理系统前端 技术栈: React 18 + Vite 7 + TypeScript + Antd 目标: 使用 Docker 容器化部署前端应用,并配置 Nginx 反向代理连接本地后端

2.2 部署架构设计

┌─────────────────────────────────────────────────────┐
│ 浏览器                                                │
│ http://localhost:8080                               │
└───────────────────┬─────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────┐
│ Docker 容器                                          │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Nginx (80端口)                                   │ │
│ │ ┌─────────────┐  ┌──────────────────────────┐  │ │
│ │ │ 静态文件     │  │ 反向代理                  │  │ │
│ │ │ /index.html │  │ /api/* → 宿主机:3000      │  │ │
│ │ │ /assets/*   │  │                          │  │ │
│ │ └─────────────┘  └──────────────────────────┘  │ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────┬─────────────────────────────┘
                        │ (通过 host.docker.internal)
                        ▼
┌─────────────────────────────────────────────────────┐
│ 宿主机(Mac)                                          │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 后端服务(Node.js)                                │ │
│ │ localhost:3000                                   │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

2.3 准备工作

2.3.1 安装 Docker

  1. 下载 Docker Desktop for Mac
  2. 安装并启动
  3. 验证安装:
docker --version
# 输出: Docker version 24.x.x, build xxxxx

2.3.2 配置 Docker 镜像源(重要!)

由于网络原因,拉取 Docker 官方镜像可能会超时,需要配置国内镜像源。

操作步骤:

  1. 打开 Docker Desktop
  2. 点击右上角 Settings(设置) → Docker Engine
  3. 修改配置:
{
  "builder": {
    "gc": {
      "defaultKeepStorage": "20GB",
      "enabled": true
    }
  },
  "registry-mirrors": [
    "https://docker.m.daocloud.io",
    "https://docker.1panel.live",
    "https://dockerpull.com"
  ]
}
  1. 点击 Apply & Restart

2.4 编写 Dockerfile

创建 Dockerfile 文件:

# ============================================
# 第一阶段: 构建前端项目
# ============================================
FROM node:20-alpine AS build

# 设置工作目录
WORKDIR /app

# 复制 package.json 和 package-lock.json
# 单独复制依赖文件可以利用 Docker 缓存层,提高构建速度
COPY package*.json ./

# 安装依赖
RUN npm install

# 复制所有项目文件
COPY . .

# 构建生产版本
RUN npm run build

# ============================================
# 第二阶段: 使用 Nginx 部署
# ============================================
FROM nginx:alpine

# 从构建阶段复制打包后的文件到 Nginx 静态目录
COPY --from=build /app/dist /usr/share/nginx/html

# 复制自定义 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf

# 声明容器监听 80 端口
EXPOSE 80

# 启动 Nginx(前台运行)
CMD ["nginx", "-g", "daemon off;"]

关键点说明:

  1. 使用 Node.js 20 而非 18

    • 原因: Vite 7 依赖 Node.js 的 crypto.hash API,需要 Node.js 18.20+ 或 20+
    • 我最初使用 node:18-alpine 遇到了构建错误,升级到 node:20-alpine 解决
  2. 使用 alpine 版本

    • alpine 是一个轻量级 Linux 发行版
    • node:20-alpine 只有 115MB,而 node:20 有 900MB+
    • nginx:alpine 只有 23MB,而 nginx:latest 有 140MB+
  3. 多阶段构建的好处

    • 构建阶段需要 Node.js、npm、源代码、node_modules(可能 500MB+)
    • 运行阶段只需要 Nginx 和构建产物 dist(可能 10MB)
    • 最终镜像大小约 50MB,大幅减小
  4. COPY 顺序优化

    • 先 COPY package*.json,再 RUN npm install
    • 如果只修改了代码,没有修改依赖,Docker 会使用缓存的 npm install 层
    • 大幅提升重复构建速度

2.5 编写 Nginx 配置

创建 nginx.conf 文件:

server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # ========================================
    # SPA 路由配置(重要!)
    # ========================================
    # React Router 使用 BrowserRouter 时,
    # 刷新页面会向服务器请求 /users/list 等路径,
    # 但服务器上没有这些文件,会返回 404。
    # try_files 指令会先尝试查找文件,找不到就返回 index.html,
    # 让前端路由接管
    location / {
        try_files $uri $uri/ /index.html;
    }

    # ========================================
    # 静态资源缓存配置(可选,优化性能)
    # ========================================
    location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
        expires 30d;
        add_header Cache-Control "public, max-age=2592000";
    }

    # ========================================
    # API 反向代理配置(重要!)
    # ========================================
    # 前端请求 /api/users/login
    # → Nginx 转发到 http://host.docker.internal:3000/users/login
    #
    # 关键点:
    # 1. location /api/ (结尾有斜杠)
    # 2. proxy_pass http://host.docker.internal:3000/ (结尾有斜杠)
    # 这样会自动去掉 /api 前缀
    location /api/ {
        proxy_pass http://host.docker.internal:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

核心知识点:

2.5.1 host.docker.internal 的作用

在 Docker 容器内部,localhost 指向容器本身,而不是宿主机。

问题场景:

  • 后端服务运行在宿主机的 localhost:3000
  • 容器内的 Nginx 无法通过 http://localhost:3000 访问

解决方案:

  • Docker Desktop 提供了特殊域名 host.docker.internal
  • 在容器内指向宿主机的 IP 地址
  • 使用 http://host.docker.internal:3000 即可访问宿主机的 3000 端口

2.5.2 Nginx 路径重写规则

配置 1(错误):

location /api {
    proxy_pass http://host.docker.internal:3000;
}
  • 请求 /api/users/login
  • 转发到 http://host.docker.internal:3000/api/users/login
  • 后端收到 /api/users/login(保留了 /api 前缀)

配置 2(正确):

location /api/ {
    proxy_pass http://host.docker.internal:3000/;
}
  • 请求 /api/users/login
  • 转发到 http://host.docker.internal:3000/users/login
  • 后端收到 /users/login(去掉了 /api 前缀)

规则总结:

  • locationproxy_pass 都以 / 结尾 → 自动去掉匹配的前缀
  • locationproxy_pass 任一不以 / 结尾 → 保留完整路径

2.6 构建镜像

在项目根目录执行:

docker build -t react-manager:latest .

命令解析:

  • docker build: 构建镜像命令
  • -t react-manager:latest: 指定镜像名称和标签
  • .: 构建上下文路径(当前目录)

构建过程输出:

[+] Building 125.3s (14/14) FINISHED
 => [internal] load build definition from Dockerfile
 => [internal] load .dockerignore
 => [internal] load metadata for docker.io/library/nginx:alpine
 => [internal] load metadata for docker.io/library/node:20-alpine
 => [build 1/6] FROM docker.io/library/node:20-alpine
 => [build 2/6] WORKDIR /app
 => [build 3/6] COPY package*.json ./
 => [build 4/6] RUN npm install          (耗时最长,约 60s)
 => [build 5/6] COPY . .
 => [build 6/6] RUN npm run build        (约 20s)
 => [stage-1 1/3] FROM docker.io/library/nginx:alpine
 => [stage-1 2/3] COPY --from=build /app/dist /usr/share/nginx/html
 => [stage-1 3/3] COPY nginx.conf /etc/nginx/conf.d/default.conf
 => exporting to image
 => => naming to docker.io/library/react-manager:latest

验证镜像构建成功:

docker images | grep react-manager

输出:

react-manager   latest    abc123def456   2 minutes ago   50MB

2.7 运行容器

docker run -d -p 8080:80 --name react-manager-app react-manager:latest

命令解析:

  • docker run: 运行容器命令
  • -d: 后台运行(detached 模式)
  • -p 8080:80: 端口映射,宿主机 8080 → 容器 80
  • --name react-manager-app: 给容器命名
  • react-manager:latest: 使用的镜像

验证容器运行:

docker ps

输出:

CONTAINER ID   IMAGE                    PORTS                  NAMES
a1b2c3d4e5f6   react-manager:latest     0.0.0.0:8080->80/tcp   react-manager-app

2.8 访问应用

打开浏览器,访问:

http://localhost:8080

成功看到车辆管理系统界面! 🎉


🐛 第三部分:问题排查与解决

问题 1: 镜像拉取超时

错误信息:

ERROR: failed to solve: nginx:alpine: failed to resolve source metadata:
unexpected status from HEAD request: 403 Forbidden

原因: Docker 官方镜像源在国内访问受限。

解决方案: 配置国内镜像源(见 2.3.2 章节)。


问题 2: npm run build 报错 - crypto.hash is not a function

错误信息:

error during build:
[vite:build-html] crypto.hash is not a function

原因: Vite 7 需要 Node.js 18.20+ 或 20+,而 node:18-alpine 是 18.17 版本,不支持 crypto.hash API。

解决方案: 将 Dockerfile 中的基础镜像从 node:18-alpine 改为 node:20-alpine

知识点:

  • Node.js 18.0-18.19: 不支持 crypto.hash
  • Node.js 18.20+: 支持 crypto.hash
  • Node.js 20+: 完全支持

问题 3: 页面访问正常,但 API 请求 404

错误信息:

Request failed with status code 404
AxiosError: Request failed with status code 404

调试过程:

  1. 打开浏览器开发者工具 Network 面板
  2. 发现请求 http://localhost:8080/api/users/login 返回 404
  3. 检查容器日志:
    docker logs react-manager-app
    
    输出:
    2024/01/12 10:30:45 [error] 7#7: *1 connect() failed (111: Connection refused)
    while connecting to upstream, client: 172.17.0.1,
    server: localhost, request: "GET /api/users/login HTTP/1.1"
    

原因分析:

我的本地开发环境使用 Vite 的 proxy 配置:

// vite.config.ts
proxy: {
  '/api': {
    target: 'http://localhost:3000',
    changeOrigin: true,
    rewrite: path => path.replace(/^\/api/, '')  // 去掉 /api 前缀
  }
}

这意味着:

  • 前端请求 /api/users/login
  • Vite 代理会去掉 /api,转发到后端的 /users/login

但我最初的 Nginx 配置是:

location /api {
    proxy_pass http://host.docker.internal:3000;
}

这会导致:

  • 前端请求 /api/users/login
  • Nginx 转发到 http://host.docker.internal:3000/api/users/login
  • 后端路由是 /users/login,所以返回 404

解决方案: 修改 Nginx 配置,添加路径重写:

location /api/ {
    proxy_pass http://host.docker.internal:3000/;
}

关键变化:

  • location /apilocation /api/
  • proxy_pass http://host.docker.internal:3000proxy_pass http://host.docker.internal:3000/

重新构建和运行:

docker stop react-manager-app
docker rm react-manager-app
docker build -t react-manager:latest .
docker run -d -p 8080:80 --name react-manager-app react-manager:latest

问题解决! ✅


📝 第四部分:常用操作命令总结

4.1 镜像操作

# 查看所有镜像
docker images

# 构建镜像
docker build -t 镜像名:标签 .

# 删除镜像
docker rmi 镜像名:标签

# 删除所有未使用的镜像
docker image prune -a

# 查看镜像详细信息
docker inspect 镜像名:标签

# 查看镜像构建历史
docker history 镜像名:标签

4.2 容器操作

# 运行容器
docker run -d -p 宿主机端口:容器端口 --name 容器名 镜像名:标签

# 查看运行中的容器
docker ps

# 查看所有容器(包括已停止的)
docker ps -a

# 停止容器
docker stop 容器名

# 启动已停止的容器
docker start 容器名

# 重启容器
docker restart 容器名

# 删除容器
docker rm 容器名

# 强制删除运行中的容器
docker rm -f 容器名

# 删除所有已停止的容器
docker container prune

4.3 日志和调试

# 查看容器日志
docker logs 容器名

# 实时查看日志
docker logs -f 容器名

# 查看最近 100 行日志
docker logs --tail 100 容器名

# 进入容器内部(交互式 shell)
docker exec -it 容器名 sh

# 在容器内执行单个命令
docker exec 容器名 ls /usr/share/nginx/html

# 查看容器资源占用
docker stats 容器名

# 查看容器详细信息
docker inspect 容器名

4.4 文件操作

# 从容器复制文件到宿主机
docker cp 容器名:/path/in/container /path/on/host

# 从宿主机复制文件到容器
docker cp /path/on/host 容器名:/path/in/container

# 查看容器内文件
docker exec 容器名 cat /etc/nginx/conf.d/default.conf

4.5 清理命令

# 停止所有运行中的容器
docker stop $(docker ps -q)

# 删除所有容器
docker rm $(docker ps -aq)

# 删除所有镜像
docker rmi $(docker images -q)

# 清理所有未使用的资源(镜像、容器、网络、缓存)
docker system prune -a

# 查看 Docker 磁盘占用
docker system df

🚀 第五部分:优化与最佳实践

5.1 使用 .dockerignore

创建 .dockerignore 文件,避免将不必要的文件复制到镜像中:

node_modules
dist
.git
.gitignore
.env
.env.local
README.md
*.log
.DS_Store

好处:

  • 减小构建上下文大小
  • 加快构建速度
  • 减小镜像体积

5.2 使用 Docker Compose

对于更复杂的部署,可以使用 Docker Compose。

创建 docker-compose.yml:

version: '3.8'

services:
  frontend:
    build: .
    container_name: react-manager-app
    ports:
      - "8080:80"
    restart: unless-stopped
    environment:
      - NODE_ENV=production

使用命令:

# 构建并启动
docker-compose up -d

# 查看日志
docker-compose logs -f

# 停止并删除
docker-compose down

# 重新构建并启动
docker-compose up -d --build

5.3 环境变量管理

如果需要在不同环境使用不同配置:

# Dockerfile
FROM nginx:alpine
ARG API_URL=http://localhost:3000
ENV VITE_API_URL=$API_URL
# ...
# 构建时传递环境变量
docker build --build-arg API_URL=https://api.production.com -t react-manager:prod .

5.4 健康检查

在 Dockerfile 中添加健康检查:

HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --quiet --tries=1 --spider http://localhost:80 || exit 1

查看健康状态:

docker ps
# STATUS 列会显示 healthy 或 unhealthy

5.5 使用版本标签

# 使用语义化版本号
docker build -t react-manager:1.0.0 .
docker build -t react-manager:latest .

# 运行时指定版本
docker run -d -p 8080:80 react-manager:1.0.0

好处:

  • 可以回滚到特定版本
  • 避免 latest 标签带来的不确定性

📊 第六部分:部署前后对比

6.1 传统部署方式

# 在服务器上操作
sudo apt-get install nginx nodejs npm
git clone https://github.com/xxx/react-manager.git
cd react-manager
npm install
npm run build
sudo cp -r dist/* /var/www/html/
sudo vim /etc/nginx/sites-available/default
sudo systemctl restart nginx

问题:

  • 需要手动安装依赖
  • 环境不一致可能导致"本地能跑,服务器不能跑"
  • 更新麻烦,需要重复操作
  • 难以回滚

6.2 Docker 部署方式

# 在任何安装了 Docker 的机器上
docker run -d -p 8080:80 react-manager:latest

优势:

  • 一键部署,环境一致
  • 易于扩展(启动多个容器实现负载均衡)
  • 易于回滚(切换镜像版本)
  • 隔离性好,不污染宿主机环境

🎓 第七部分:学习总结

7.1 掌握的核心概念

  1. 镜像与容器的关系: 镜像是模板,容器是实例
  2. Dockerfile 语法: FROM、WORKDIR、COPY、RUN、CMD、EXPOSE
  3. 多阶段构建: 减小镜像体积的关键技术
  4. 端口映射: 如何让外部访问容器内服务
  5. 容器网络: host.docker.internal 访问宿主机
  6. Nginx 反向代理: 路径重写规则

7.2 实战技能

  1. ✅ 编写 Dockerfile 构建前端项目镜像
  2. ✅ 配置 Nginx 处理 SPA 路由
  3. ✅ 配置 Nginx 反向代理连接后端
  4. ✅ 使用 Docker 命令管理镜像和容器
  5. ✅ 排查和解决常见部署问题
  6. ✅ 优化镜像体积和构建速度

7.3 遇到的坑与解决

问题 原因 解决方案
镜像拉取超时 国内网络限制 配置国内镜像源
crypto.hash 错误 Node.js 版本太低 升级到 Node.js 20
API 请求 404 Nginx 未去掉 /api 前缀 修改 proxy_pass 配置
无法访问宿主机后端 容器内 localhost 指向容器本身 使用 host.docker.internal

7.4 下一步学习方向

  1. Docker Compose: 管理多容器应用
  2. Docker 网络: bridge、host、overlay 等网络模式
  3. Docker 数据卷: 持久化存储
  4. CI/CD 集成: 结合 GitHub Actions 自动构建镜像
  5. Kubernetes: 容器编排,适用于大规模部署
  6. 镜像仓库: 使用 Docker Hub 或私有仓库管理镜像

📚 参考资料


✅ 总结

通过这次实战,我成功将 React 前端项目部署到 Docker 容器中,并配置了 Nginx 反向代理连接本地后端。整个过程涉及到:

  1. Docker 基础概念的理解(镜像、容器、Dockerfile)
  2. 多阶段构建优化镜像体积
  3. Nginx 配置处理 SPA 路由和 API 代理
  4. 问题排查和解决能力的提升

Docker 让部署变得简单、可重复、可移植,是现代应用开发的必备技能。这次实战让我对 Docker 有了更深入的理解,也为后续学习容器编排(Kubernetes)打下了基础。

最重要的收获: 遇到问题时,通过查看日志(docker logs)、进入容器调试(docker exec)、对比本地配置,能够系统性地排查和解决问题。这种问题解决思路比具体的技术知识更有价值。


nextjs学习9:数据获取fetch、缓存与重新验证

2026年1月12日 10:35

在 Next.js 中如何获取数据呢?

Next.js 优先推荐使用原生的 fetch 方法,因为 Next.js 拓展了原生的 fetch 方法,为其添加了缓存和更新缓存(重新验证)的机制。

这样做的好处在于可以自动复用请求数据,提高性能。坏处在于如果你不熟悉,经常会有一些“莫名奇妙”的状况出现……

服务端使用 fetch

Next.js 拓展了原生的 fetch Web API,可以为服务端的每个请求配置缓存(caching)和重新验证( revalidating)行为。

你可以在服务端组件、路由处理程序、Server Actions 中搭配 async/await 语法使用 fetch。

基本用法

// app/page.js
async function getData() {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos')
  if (!res.ok) {
    // 由最近的 error.js 处理
    throw new Error('Failed to fetch data')
  }
  return res.json()
}

export default async function Page() {
  const data = await getData()
  return <main>{JSON.stringify(data)}</main>
}

默认缓存

默认情况下,Next.js 会自动缓存服务端 fetch 请求的返回值(背后用的是数据缓存(Data Cache))。

// fetch 的 cache 选项用于控制该请求的缓存行为
// 默认就是 'force-cache', 平时写的时候可以省略
fetch('https://...', { cache: 'force-cache' })

但这些情况默认不会自动缓存:

  1. 在 Server Action 中使用的时候
  2. 在定义了非 GET 方法的路由处理程序中使用的时候

简单的来说,在服务端组件和只有 GET 方法的路由处理程序中使用 fetch,返回结果会自动缓存。

logging 配置项

在写代码之前,先让我们修改下 next.config.mjs 的配置:

const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true
    }
  }
};

export default nextConfig;

目前 logging 只有这一个配置,用于在开发模式下显示 fetch 请求和缓存日志。

服务端组件使用

第一种在服务端组件中使用,修改 app/fetch/page.tsx,代码如下:

async function getData() {
  // 接口每次调用都会返回一个随机的猫猫图片数据
  const res = await fetch('https://api.thecatapi.com/v1/images/search')
  if (!res.ok) {
    throw new Error('Failed to fetch data')
  }

  return res.json()
}

export default async function Page() {
  const data = await getData()

  return <img src={data[0].url} width="300" />
}

在开发模式下,为了方便调试,可以使用浏览器的硬刷新(Command + Shift + R)清除缓存,此时数据会发生更改(cache: SKIP)。普通刷新时因为会命中缓存(cache: HIT),数据会保持不变。

image.png

命中缓存时 6ms 就返回了。

image.png

不命中缓存,需要912ms。

运行 npm run build && npm run start 开启生产版本。因为 fetch 请求的返回结果被缓存了,无论是否硬刷新,图片数据都会保持不变。

路由处理程序 GET 请求

第二种在路由处理程序中使用,新建 app/api/cats/route.js,代码如下:

export async function GET() {
  const res = await fetch('https://dog.ceo/api/breeds/image/random')
  
  const data = await res.json()
  return Response.json({ data })
}

开发模式下,浏览器硬刷新的时候会跳过缓存,普通刷新的时候则会命中缓存。可以看到第一次硬刷新的时候,请求接口时间为 5418ms,后面普通刷新的时候,因为使用缓存中的数据,数据返回时间都是 0ms 左右。

image.png

运行 npm run build && npm run start 开启生产版本,因为 fetch 请求的返回结果被缓存了,无论是否硬刷新,接口数据都会保持不变。

重新验证

在 Next.js 中,清除数据缓存并重新获取最新数据的过程就叫做重新验证(Revalidation)。

Next.js 提供了两种方式重新验证:

一种是基于时间的重新验证(Time-based revalidation) ,即经过一定时间并有新请求产生后重新验证数据,适用于不经常更改且新鲜度不那么重要的数据。

一种是按需重新验证(On-demand revalidation) ,根据事件手动重新验证数据。按需重新验证又可以使用基于标签(tag-based)和基于路径(path-based)两种方法重新验证数据。适用于需要尽快展示最新数据的场景。

基于时间的重新验证

使用基于时间的重新验证,你需要在使用 fetch 的时候设置 next.revalidate 选项(以秒为单位):

fetch('https://...', { next: { revalidate: 3600 } })

或者通过路由段配置项进行配置,使用这种方法,它会重新验证该路由段所有的 fetch 请求。

那什么是路由段呢?

image.png

在这张图中,/dashboard/settings由三段组成:

  • /:根段(Root Segment)
  • dashboard:段(Segment)
  • settings:叶段(Leaf Segment)

路由段配置选项可以配置页面、布局、路由处理程序的行为。比如我们使用 fetch 的时候可以单独配置某个请求的 revalidate ,借助路由段配置,我们可以配置这个路由下所有 fetch 请求的 revalidate

所以可以这么设置:

// layout.jsx | page.jsx | route.js
export const revalidate = 3600

按需重新验证

使用按需重新验证,在路由处理程序或者 Server Action 中通过路径( revalidatePath) 或缓存标签 revalidateTag 实现。

revalidatePath

新建 app/api/revalidatePath/route.js,代码如下:

import { revalidatePath } from 'next/cache'
 
export async function GET(request) {
  const path = request.nextUrl.searchParams.get('path')
 
  if (path) {
    revalidatePath(path)
    return Response.json({ revalidated: true, now: Date.now() })
  }
 
  return Response.json({
    revalidated: false,
    now: Date.now(),
    message: 'Missing path to revalidate',
  })
}

在上面的例子中,访问/api/cats页面内容都不变的,因为被缓存了,现在我如果访问下/api/revalidatePath?path=/api/cats,因为这个接口里面有revalidatePath(path),所以会更新/api/cats这个接口的数据,当再次访问/api/cats时,内容就变了。

注意:在开发模式下,用 revalidatePath 确实更新了对应路径上的 fetch 缓存结果。但如果大家部署到生产版本,你是发现 revalidatePath 只对页面生效,对路由处理程序并不生效。

这是因为 /api/cats 被静态处理了(不同于页面的静态渲染),静态处理表示响应内容在 npm run build 构建阶段生成并固化,部署后直接返回缓存的响应,无需实时计算。

首先你要将 /api/cats 转为动态处理(响应内容在用户每次请求时实时生成,不提前固化,每次请求都执行处理程序逻辑),然后才能测试 revalidatePath 的效果。

但是转为动态处理,比如使用 cookies 等函数,又会触发 Next.js 的自动逻辑,让 fetch 请求退出缓存。

简而言之,如果你想在生产环境测试 revalidatePath 对路由处理程序的影响,你需要多做一些配置:

// 路由动态处理, 每次请求都会返回新的内容
export const revalidate = 0
// fetch 强制缓存,这里有进行了强制缓存
export const fetchCache = 'force-cache'

export async function GET() {
  const res = await fetch('https://dog.ceo/api/breeds/image/random')
  
  const data = await res.json()
  return Response.json({ data, now: Date.now() })
}
revalidateTag

Next.js 有一个路由标签系统,可以跨路由实现多个 fetch 请求重新验证。具体这个过程为:

  1. 使用 fetch 的时候,设置一个或者多个标签标记请求
  2. 调用 revalidateTag 方法重新验证该标签对应的所有请求
// app/page.js
export default async function Page() {
  const res = await fetch('https://...', { next: { tags: ['collection'] } })
  const data = await res.json()
  // ...
}

在这个例子中,为 fetch 请求添加了一个 collection标签。在 Server Action 中调用 revalidateTag,就可以让所有带 collection 标签的 fetch 请求重新验证。

// app/actions.js
'use server'
 
import { revalidateTag } from 'next/cache'
 
export default async function action() {
  revalidateTag('collection')
}

客户端使用路由处理程序

如果你需要在客户端组件中获取数据,可以在客户端调用路由处理程序。

路由处理程序会在服务端被执行,然后将数据返回给客户端,适用于不想暴露敏感信息给客户端(比如 API tokens)的场景。

如果你使用的是服务端组件,无须借助路由处理程序,直接获取数据即可。

拿捏年终总结:自动提取GitLab提交记录

2026年1月12日 10:29

一、脚本功能概述

这是一个用于自动提取GitLab提交记录的Node.js脚本,专为年终总结设计。它可以:

  1. 根据指定的时间范围批量获取GitLab提交记录
  2. 过滤掉合并提交,只保留实际代码变更
  3. 按项目分组展示提交记录
  4. 生成Markdown格式的提交汇总报告

二、核心模块解析

1. 环境变量读取模块

javascript

function readEnvFile(envPath) {
  const content = fs.readFileSync(envPath, 'utf8');
  const lines = content.split(/\r?\n/).filter(Boolean);
  const env = {};
  for (const line of lines) {
    if (line.trim().startsWith('#')) continue;
    const idx = line.indexOf('=');
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim();
    const value = line.slice(idx + 1).trim();
    env[key] = value;
  }
  return env;
}

功能说明:读取.env配置文件,解析为键值对。

配置说明

env

# GitLab服务器地址
GITLAB_URL=https://your.gitlab.server.com

# GitLab访问令牌(从GitLab个人设置中获取)
GITLAB_TOKEN=your_gitlab_access_token

# 可选:作者用户名(用于过滤提交)
GITLAB_AUTHOR_USERNAME=your_username

# 可选:指定项目ID(多个用逗号分隔)
GITLAB_PROJECT_IDS=123,456,789

2. 命令行参数解析模块

javascript

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i++) {
    const arg = argv[i];
    if (arg.startsWith('--')) {
      const [k, v] = arg.split('=');
      args[k.slice(2)] = v;
    }
  }
  return args;
}

功能说明:解析命令行参数,支持--since--until参数。

3. 时间范围处理模块

javascript

function ensureIsoRange(sinceInput, untilInput) {
  const sinceIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(sinceInput);
  const untilIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(untilInput);
  if (sinceIsDateOnly && untilIsDateOnly) {
    const { since } = toIsoRangeDayStartEnd(sinceInput);
    const { until } = toIsoRangeDayStartEnd(untilInput);
    return { since, until };
  }
  const since = new Date(sinceInput).toISOString();
  const until = new Date(untilInput).toISOString();
  return { since, until };
}

功能说明:将用户输入的时间范围转换为ISO标准格式,支持日期格式和完整时间格式。

4. API请求模块

javascript

function requestJson(urlStr, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(urlStr);
    const { protocol, hostname, port, pathname, search } = u;
    const lib = protocol === 'https:' ? https : http;
    const options = {
      hostname,
      port: port || (protocol === 'https:' ? 443 : 80),
      path: `${pathname}${search}`,
      method: 'GET',
      headers,
    };
    const req = lib.request(options, (res) => {
      const { statusCode, headers: resHeaders } = res;
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        if (statusCode >= 200 && statusCode < 300) {
          try {
            const json = JSON.parse(body);
            resolve({ json, headers: resHeaders, statusCode });
          } catch (e) {
            reject(new Error(`Invalid JSON ${statusCode}: ${body.slice(0, 200)}`));
          }
        } else {
          reject(new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

功能说明:发送HTTP/HTTPS请求,返回JSON格式的响应。

5. GitLab API调用模块

javascript

async function fetchAllCommits(baseUrl, token, id, since, until, author) {
  const collected = [];
  let page = 1;
  for (;;) {
    const params = { since, until, per_page: 100, page, with_stats: false, author };
    const { commits, nextPage } = await fetchCommitsPage(baseUrl, token, id, params);
    collected.push(...commits);
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return collected;
}

功能说明:分页获取GitLab提交记录,支持作者过滤。

6. 提交记录过滤模块

javascript

function filterNonMerge(commits) {
  const filtered = [];
  for (const commit of commits) {
    const { parent_ids } = commit;
    const nonMerge = Array.isArray(parent_ids) ? parent_ids.length <= 1 : true;
    if (nonMerge) filtered.push(commit);
  }
  return filtered;
}

功能说明:过滤掉合并提交,只保留实际代码变更的提交。

7. 报告生成模块

javascript

function buildMarkdown(range, author, grouped) {
  const { since, until } = range;
  const { username, name } = author;
  const lines = [];
  lines.push(`# 提交汇总`);
  lines.push(`- 作者: ${name || username || ''}`);
  lines.push(`- 时间范围: ${since}${until}`);
  for (const project of grouped.projects) {
    const { name: projName } = project.meta;
    lines.push(`\n项目: ${projName}`);
    const commits = project.commits;
    for (const commit of commits) {
      lines.push(formatCommitLine(project.meta, commit));
    }
  }
  return `${lines.join('\n')}\n`;
}

功能说明:生成Markdown格式的提交汇总报告。

三、使用方法

  1. 安装依赖:无需额外依赖,使用Node.js内置模块。

  2. 配置.env文件:根据实际情况修改.env文件中的配置。

  3. 运行脚本

    bash

    node fetch_commits.js --since=2025-01-01 --until=2025-12-31
    node fetch_commits.js --since=2025-06-01 --until=2026-01-11 --author=你的提交用户名
    
  4. 查看报告:脚本会生成commits.md文件,包含指定时间范围内的提交记录。

四、完整代码 同级创建.env即可使用

javascript

const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');

function readEnvFile(envPath) {
  const content = fs.readFileSync(envPath, 'utf8');
  const lines = content.split(/\r?\n/).filter(Boolean);
  const env = {};
  for (const line of lines) {
    if (line.trim().startsWith('#')) continue;
    const idx = line.indexOf('=');
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim();
    const value = line.slice(idx + 1).trim();
    env[key] = value;
  }
  return env;
}

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i++) {
    const arg = argv[i];
    if (arg.startsWith('--')) {
      const [k, v] = arg.split('=');
      args[k.slice(2)] = v;
    }
  }
  return args;
}

function toIsoRangeDayStartEnd(dateStr) {
  const start = new Date(`${dateStr}T00:00:00.000Z`);
  const end = new Date(`${dateStr}T23:59:59.999Z`);
  return { since: start.toISOString(), until: end.toISOString() };
}

function ensureIsoRange(sinceInput, untilInput) {
  const sinceIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(sinceInput);
  const untilIsDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(untilInput);
  if (sinceIsDateOnly && untilIsDateOnly) {
    const { since } = toIsoRangeDayStartEnd(sinceInput);
    const { until } = toIsoRangeDayStartEnd(untilInput);
    return { since, until };
  }
  const since = new Date(sinceInput).toISOString();
  const until = new Date(untilInput).toISOString();
  return { since, until };
}

function requestJson(urlStr, headers = {}) {
  return new Promise((resolve, reject) => {
    const u = new URL(urlStr);
    const { protocol, hostname, port, pathname, search } = u;
    const lib = protocol === 'https:' ? https : http;
    const options = {
      hostname,
      port: port || (protocol === 'https:' ? 443 : 80),
      path: `${pathname}${search}`,
      method: 'GET',
      headers,
    };
    const req = lib.request(options, (res) => {
      const { statusCode, headers: resHeaders } = res;
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        if (statusCode >= 200 && statusCode < 300) {
          try {
            const json = JSON.parse(body);
            resolve({ json, headers: resHeaders, statusCode });
          } catch (e) {
            reject(new Error(`Invalid JSON ${statusCode}: ${body.slice(0, 200)}`));
          }
        } else {
          reject(new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`));
        }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

function buildApiUrl(base, pathStr, query = {}) {
  const u = new URL(pathStr, base);
  const entries = Object.entries(query).filter(([, v]) => v !== undefined && v !== null);
  for (const [k, v] of entries) {
    u.searchParams.set(k, String(v));
  }
  return u.toString();
}

async function fetchProjectMeta(baseUrl, token, id) {
  const url = buildApiUrl(baseUrl, `/api/v4/projects/${encodeURIComponent(id)}`);
  const headers = { 'PRIVATE-TOKEN': token };
  const { json } = await requestJson(url, headers);
  const { name, path_with_namespace, web_url } = json;
  return { id, name, path_with_namespace, web_url };
}

async function fetchCommitsPage(baseUrl, token, id, params) {
  const url = buildApiUrl(
    baseUrl,
    `/api/v4/projects/${encodeURIComponent(id)}/repository/commits`,
    params
  );
  const headers = { 'PRIVATE-TOKEN': token };
  const { json, headers: resHeaders } = await requestJson(url, headers);
  const { ['x-next-page']: nextPage, ['x-page']: page, ['x-total-pages']: totalPages } = resHeaders;
  return { commits: json, nextPage, page, totalPages };
}

async function fetchAllCommits(baseUrl, token, id, since, until, author) {
  const collected = [];
  let page = 1;
  for (;;) {
    const params = { since, until, per_page: 100, page, with_stats: false, author };
    const { commits, nextPage } = await fetchCommitsPage(baseUrl, token, id, params);
    collected.push(...commits);
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return collected;
}

function filterNonMerge(commits) {
  const filtered = [];
  for (const commit of commits) {
    const { parent_ids } = commit;
    const nonMerge = Array.isArray(parent_ids) ? parent_ids.length <= 1 : true;
    if (nonMerge) filtered.push(commit);
  }
  return filtered;
}

function formatCommitLine(project, commit) {
  const { short_id, title, message, committed_date, author_name, author_email } = commit;
  const main = (title || message || '').replace(/\r?\n/g, ' ');
  const ts = formatDateLocal(committed_date);
  return `- ${ts} | ${short_id} | ${main} | ${author_name} <${author_email}>`;
}

function pad2(n) {
  return String(n).padStart(2, '0');
}

function formatDateLocal(iso) {
  const d = new Date(iso);
  const y = d.getFullYear();
  const m = pad2(d.getMonth() + 1);
  const day = pad2(d.getDate());
  const hh = pad2(d.getHours());
  const mm = pad2(d.getMinutes());
  const ss = pad2(d.getSeconds());
  return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
}

function buildMarkdown(range, author, grouped) {
  const { since, until } = range;
  const { username, name } = author;
  const lines = [];
  lines.push(`# 提交汇总`);
  lines.push(`- 作者: ${name || username || ''}`);
  lines.push(`- 时间范围: ${since}${until}`);
  for (const project of grouped.projects) {
    const { name: projName } = project.meta;
    lines.push(`\n项目: ${projName}`);
    const commits = project.commits;
    for (const commit of commits) {
      lines.push(formatCommitLine(project.meta, commit));
    }
  }
  return `${lines.join('\n')}\n`;
}

async function fetchMembershipProjects(baseUrl, token) {
  const headers = { 'PRIVATE-TOKEN': token };
  const projects = [];
  let page = 1;
  for (;;) {
    const url = buildApiUrl(baseUrl, '/api/v4/projects', {
      membership: true,
      simple: true,
      per_page: 100,
      page,
      order_by: 'last_activity_at',
    });
    const { json, headers: resHeaders } = await requestJson(url, headers);
    for (const item of json) {
      const { id, name, path_with_namespace, web_url } = item;
      projects.push({ id, name, path_with_namespace, web_url });
    }
    const nextPage = resHeaders['x-next-page'];
    if (!nextPage) break;
    page = parseInt(nextPage, 10);
    if (!Number.isFinite(page) || page <= 0) break;
  }
  return projects;
}

async function resolveAuthorQuery(baseUrl, token, username, override) {
  if (override) return override;
  if (!username) return null;
  const url = buildApiUrl(baseUrl, '/api/v4/users', { username });
  const headers = { 'PRIVATE-TOKEN': token };
  const { json } = await requestJson(url, headers);
  if (Array.isArray(json) && json.length > 0) {
    const { name } = json[0];
    return name || username;
  }
  return username;
}

function filterByAuthorName(commits, authorName) {
  if (!authorName) return commits;
  const out = [];
  for (const commit of commits) {
    const { author_name } = commit;
    if (author_name === authorName) out.push(commit);
  }
  return out;
}

async function main() {
  const cwd = process.cwd();
  const envPath = path.join(cwd, '.env');
  const env = readEnvFile(envPath);
  const {
    GITLAB_URL,
    GITLAB_TOKEN,
    GITLAB_AUTHOR_USERNAME,
  } = env;
  const args = parseArgs(process.argv);
  const { since: sinceRaw, until: untilRaw, author: authorArg } = args;
  if (!GITLAB_URL || !GITLAB_TOKEN || !sinceRaw || !untilRaw) {
    process.stderr.write(
      '缺少必要配置或参数。需要 GITLAB_URL, GITLAB_TOKEN, --since=YYYY-MM-DD, --until=YYYY-MM-DD\n'
    );
    process.exit(1);
  }
  const { since, until } = ensureIsoRange(sinceRaw, untilRaw);
  const desiredAuthor = authorArg || 'zhouzb';
  const authorQuery = await resolveAuthorQuery(GITLAB_URL, GITLAB_TOKEN, GITLAB_AUTHOR_USERNAME, desiredAuthor);
  const authorInfo = { username: GITLAB_AUTHOR_USERNAME, name: desiredAuthor };
  let metas = [];
  if (env.GITLAB_PROJECT_IDS) {
    const ids = env.GITLAB_PROJECT_IDS.split(',').map((s) => s.trim()).filter(Boolean);
    for (const id of ids) {
      const meta = await fetchProjectMeta(GITLAB_URL, GITLAB_TOKEN, id);
      metas.push(meta);
    }
  } else {
    metas = await fetchMembershipProjects(GITLAB_URL, GITLAB_TOKEN);
  }
  const grouped = { projects: [] };
  for (const meta of metas) {
    const { id } = meta;
    const all = await fetchAllCommits(GITLAB_URL, GITLAB_TOKEN, id, since, until, authorQuery || undefined);
    const filtered = filterByAuthorName(filterNonMerge(all), desiredAuthor);
    if (filtered.length > 0) grouped.projects.push({ meta, commits: filtered });
  }
  const md = buildMarkdown({ since, until }, authorInfo, grouped);
  fs.writeFileSync(path.join(cwd, 'commits.md'), md, 'utf8');
}

main().catch((e) => {
  const { message } = e;
  process.stderr.write(`${message}\n`);
  process.exit(1);
});

AI 写的代码有 48% 在"胡说八道":那些你 npm install 的包,可能根本不存在

2026年1月12日 10:18

摘要:研究显示,AI 生成的代码中有 48% 存在"幻觉"——引用了根本不存在的包、API 或方法。更可怕的是,黑客已经开始利用这个漏洞:他们注册 AI 经常"幻觉"出来的假包名,等你 npm install,恶意代码就进了你的项目。这种攻击叫"Slopsquatting",已经影响了 44 万个包依赖。本文带你深入了解这个 AI 时代的新型安全危机。


01. 那个让我后背发凉的 Bug

上周,我在 Code Review 时发现了一个奇怪的 import:

import { validateEmail } from "email-validator-pro"

我没见过这个包,于是去 npm 上搜了一下。

搜索结果:0 个匹配。

我问写这段代码的同事:"这个包是哪来的?"

他说:"Cursor 自动补全的啊,我看着挺专业的就用了。"

我又问:"你 npm install 过吗?"

他愣了一下:"好像……没有?代码能跑啊。"

我看了一眼 package.json,果然没有这个依赖。代码之所以能跑,是因为另一个包里恰好有个同名的函数被导出了。

这次我们运气好。

但如果这个"不存在的包"真的被人注册了呢? 如果里面藏着恶意代码呢? 如果我们真的 npm install 了呢?

这不是假设。这正在发生。


02. AI 代码幻觉:48% 的代码在"胡说八道"

2.1 什么是 AI 代码幻觉?

AI 代码幻觉(AI Code Hallucination)是指 AI 生成的代码中包含:

  • 不存在的包import xxx from 'fake-package'
  • 不存在的 APIresponse.data.nonExistentMethod()
  • 不存在的方法array.filterMap() (JavaScript 没有这个方法)
  • 错误的参数fs.readFile(path, 'utf-8', callback, extraParam)
  • 虚构的配置项{ enableTurboMode: true } (没有这个选项)

2.2 有多严重?

2025 年的研究数据让人触目惊心:

AI 代码幻觉统计(2025年研究):

样本量:576,000 个代码样本
测试模型:16 个主流 LLM

关键发现:
├─ 48% 的 AI 生成代码包含某种形式的幻觉
├─ 440,000 个包依赖是"幻觉"出来的(不存在)
├─ 58% 的幻觉包名会重复出现(AI 会反复犯同样的错)
├─ 开源模型幻觉率:22%
├─ 商业模型幻觉率:5%(好一些,但仍然存在)
└─ 45% 的 AI 生成应用包含可利用的 OWASP 漏洞

将近一半的 AI 代码在"胡说八道"。

2.3 为什么 AI 会"幻觉"?

// AI 幻觉的产生机制
interface HallucinationCause {
  cause: string
  explanation: string
  example: string
}

const hallucinationCauses: HallucinationCause[] = [
  {
    cause: "训练数据过时",
    explanation:
      "AI 的训练数据可能是 1-2 年前的,很多新包它不知道,很多旧包已经改名或废弃",
    example: "推荐使用已经废弃的 request 库,而不是 axios",
  },
  {
    cause: "模式匹配过度泛化",
    explanation:
      "AI 看到 'email' + 'validator' 就觉得应该有个 'email-validator' 包",
    example: "生成 import { validate } from 'email-validator-pro' // 不存在",
  },
  {
    cause: "混淆不同语言/框架",
    explanation:
      "把 Python 的库名用在 JavaScript 里,或者把 React 的 API 用在 Vue 里",
    example: "在 Node.js 里 import pandas // 这是 Python 的库",
  },
  {
    cause: "自信地编造",
    explanation: "AI 不会说'我不知道',它会自信地给出一个看起来合理的答案",
    example: "生成一个完整的、看起来很专业的、但完全虚构的 API 调用",
  },
  {
    cause: "私有代码库盲区",
    explanation: "AI 没见过你公司的内部代码,但会根据命名规律'猜测'",
    example: "猜测你公司有个 @company/utils 包,但实际上叫 @company/common",
  },
]

03. Slopsquatting:黑客的"钓鱼"新玩法

3.1 什么是 Slopsquatting?

Slopsquatting = Slop(AI 生成的垃圾内容)+ Squatting(抢注)

简单来说:黑客注册 AI 经常"幻觉"出来的假包名,等你上钩。

Slopsquatting 攻击流程:

第一步:研究 AI 幻觉模式
├─ 用各种 LLM 生成大量代码
├─ 收集所有"幻觉"出来的包名
└─ 找出重复率最高的(58% 会重复)

第二步:抢注假包名
├─ 在 npm / PyPI 上注册这些包名
├─ 包内容看起来正常(躲避审查)
└─ 但藏有恶意代码

第三步:等待受害者
├─ 开发者用 AI 生成代码
├─ AI "幻觉"出这个包名
├─ 开发者 npm install
└─ 恶意代码进入项目

第四步:获利
├─ 窃取环境变量(API Key、密码)
├─ 植入后门
├─ 加密勒索
└─ 供应链攻击(感染下游项目)

3.2 真实案例

2025 年,安全研究人员发现了一个大规模的 Slopsquatting 攻击:

案例:huggingface-cli 事件

背景:
├─ Hugging Face 是最流行的 AI 模型平台
├─ 官方 CLI 工具叫 huggingface-hub
└─ 但 AI 经常"幻觉"出 huggingface-cli 这个名字

攻击:
├─ 黑客注册了 huggingface-cli 包
├─ 包内容:正常的 CLI 功能 + 隐藏的数据窃取代码
├─ 窃取内容:HF_TOKEN(Hugging Face API 密钥)
└─ 影响:数千个项目被感染

发现过程:
├─ 安全研究人员在分析 AI 幻觉模式时发现
├─ 该包已被下载数万次
└─ 大部分下载来自 AI 辅助开发的项目

3.3 规模有多大?

Slopsquatting 威胁规模(2025-2026):

已发现的恶意包:
├─ npm:3,000+ 个疑似 Slopsquatting 包
├─ PyPI:1,500+ 个疑似 Slopsquatting 包
└─ 其他包管理器:数量不详

潜在攻击面:
├─ 440,000 个 AI 幻觉包名可被利用
├─ 58% 的幻觉包名会重复出现(高价值目标)
└─ 每天有数百万次 AI 辅助的包安装

受影响的开发者:
├─ 97% 的开发者不会验证 AI 推荐的包是否存在
├─ 大部分人直接复制 AI 生成的 import 语句
└─ 很少有人检查 package.json 里的陌生依赖

04. 更可怕的:AI 生成的"合成漏洞"

除了幻觉包名,AI 还会生成一种全新的安全威胁:合成漏洞(Synthetic Vulnerabilities)

4.1 什么是合成漏洞?

合成漏洞是指:只存在于 AI 生成代码中的安全漏洞,人类程序员通常不会写出这种代码。

// 人类程序员写的代码(有漏洞,但是常见模式)
const userId = req.params.id
const user = await db.query(`SELECT * FROM users WHERE id = ${userId}`)
// SQL 注入漏洞,但 SAST 工具能检测到

// AI 生成的代码(合成漏洞,工具检测不到)
const userId = req.params.id
const sanitizedId = userId.replace(/[^0-9]/g, "") // 看起来做了过滤
const user = await db.query(`SELECT * FROM users WHERE id = ${sanitizedId}`)
// 问题:如果 userId 是 "1 OR 1=1",过滤后变成 "111"
// 不是注入了,但逻辑完全错误,可能返回错误的用户数据
// 传统 SAST 工具检测不到这种"逻辑漏洞"

4.2 合成漏洞的特点

// 合成漏洞 vs 传统漏洞
interface VulnerabilityComparison {
  aspect: string;
  traditional: string;
  synthetic: string;
}

const comparison: VulnerabilityComparison[] = [
  {
    aspect: "来源",
    traditional: "人类程序员的常见错误",
    synthetic: "AI 的独特错误模式"
  },
  {
    aspect: "可检测性",
    traditional: "SAST/DAST 工具能检测大部分",
    synthetic: "传统工具检测不到"
  },
  {
    aspect: "模式",
    traditional: "已知的漏洞模式(OWASP Top 10)",
    synthetic: "全新的、未分类的漏洞模式"
  },
  {
    aspect: "修复难度",
    traditional: "有成熟的修复方案",
    synthetic: "需要理解 AI 的"思维方式"才能修复"
  },
  {
    aspect: "复现性",
    traditional: "相同输入产生相同漏洞",
    synthetic: "AI 可能每次生成不同的漏洞代码"
  }
];

4.3 研究数据

合成漏洞研究(2025年,50万+代码样本):

发现:
├─ AI 生成的代码比人类代码有更多高危漏洞
├─ AI 会复制训练数据中的不安全编码模式
├─ AI 会"幻觉"出不存在的抽象层和框架
└─ 这些"幻觉框架"创造了全新的攻击面

具体数据:
├─ 45% 的 AI 生成应用包含 OWASP 漏洞
├─ AI 代码的高危漏洞密度是人类代码的 1.5 倍
├─ 30% 的合成漏洞无法被传统 SAST 工具检测
└─ 修复 AI 代码漏洞的时间比修复人类代码多 40%

05. 如何保护自己?

5.1 代码审查清单

// AI 代码审查清单
const aiCodeReviewChecklist = {

  // 1. 依赖检查
  dependencies: [
    "每个 import 的包是否真实存在?",
    "包名拼写是否正确?(typosquatting 风险)",
    "包是否来自官方源?",
    "包的下载量和维护状态如何?",
    "包的最近更新时间?(太新可能是恶意包)"
  ],

  // 2. API 检查
  apis: [
    "调用的 API 是否真实存在?",
    "参数数量和类型是否正确?",
    "返回值类型是否符合预期?",
    "是否使用了已废弃的 API?"
  ],

  // 3. 安全检查
  security: [
    "是否有 SQL 注入风险?",
    "是否有 XSS 风险?",
    "敏感数据是否正确处理?",
    "权限检查是否完整?",
    "是否有硬编码的密钥或密码?"
  ],

  // 4. 逻辑检查
  logic: [
    "边界情况是否处理?",
    "错误处理是否完善?",
    "代码逻辑是否符合需求?",
    "是否有"看起来对但实际错"的代码?"
  ]
};

5.2 工具推荐

防护 AI 代码幻觉的工具:

依赖检查:
├─ npm audit / yarn audit(基础检查)
├─ Snyk(更全面的漏洞扫描)
├─ Socket.dev(专门检测供应链攻击)
└─ deps.dev(Google 的依赖分析工具)

代码扫描:
├─ SonarQube(传统 SAST)
├─ Semgrep(可自定义规则)
├─ CodeQL(GitHub 的代码分析)
└─ AI 专用扫描器(2026年新出的工具)

实时防护:
├─ IDE 插件:在 import 时检查包是否存在
├─ Git Hooks:提交前自动检查依赖
├─ CI/CD 集成:构建时扫描
└─ 运行时监控:检测异常行为

5.3 最佳实践

AI 辅助开发安全最佳实践:

1. 永远不要盲目信任 AI 生成的代码
   ├─ 每个 import 都要验证
   ├─ 每个 API 调用都要查文档
   └─ 每段逻辑都要理解

2. 使用锁文件
   ├─ package-lock.json / yarn.lock
   ├─ 锁定依赖版本
   └─ 防止依赖被篡改

3. 定期审计依赖
   ├─ 每周运行 npm audit
   ├─ 检查新增的依赖
   └─ 移除不需要的依赖

4. 使用私有镜像
   ├─ 公司内部 npm 镜像
   ├─ 只允许白名单包
   └─ 阻止未知包安装

5. 代码审查流程
   ├─ AI 生成的代码必须人工审查
   ├─ 重点检查依赖和安全相关代码
   └─ 使用自动化工具辅助

06. 给不同角色的建议

6.1 如果你是个人开发者

个人开发者防护指南:

立即做:
├─ 安装 Socket.dev 或类似的 IDE 插件
├─ 每次 npm install 前检查包是否存在
├─ 养成查文档的习惯(不要只信 AI)
└─ 定期运行 npm audit

习惯养成:
├─ AI 生成代码后,先读一遍再用
├─ 看到陌生的包名,先去 npm 搜一下
├─ 不确定的 API,查官方文档确认
└─ 保持怀疑态度

6.2 如果你是团队 Leader

团队安全策略:

流程层面:
├─ 建立 AI 代码审查规范
├─ 要求所有 AI 生成代码必须标注
├─ 重点审查依赖变更的 PR
└─ 定期安全培训

工具层面:
├─ CI/CD 集成依赖扫描
├─ 使用私有 npm 镜像
├─ 配置依赖白名单
└─ 自动化安全检查

文化层面:
├─ 鼓励质疑 AI 生成的代码
├─ 奖励发现安全问题的人
├─ 分享 AI 代码踩坑经验
└─ 建立安全意识

6.3 如果你是安全工程师

安全工程师行动指南:

短期:
├─ 研究 AI 代码幻觉模式
├─ 建立 AI 代码专用扫描规则
├─ 监控公司代码库中的可疑依赖
└─ 培训开发团队

中期:
├─ 开发 AI 代码专用安全工具
├─ 建立 AI 代码安全基线
├─ 与 AI 工具厂商合作改进
└─ 参与行业安全标准制定

长期:
├─ 研究合成漏洞的检测方法
├─ 建立 AI 代码安全知识库
├─ 推动 AI 编程工具的安全改进
└─ 培养 AI 安全专业人才

07. 写在最后

AI 编程工具是把双刃剑。

它可以让你的效率提升 10 倍,也可以让你的项目在不知不觉中被植入恶意代码。

48% 的 AI 代码在"胡说八道"。

这不是危言耸听,这是研究数据。

440,000 个幻觉包名等着被利用。

这不是未来威胁,这是正在发生的攻击。

作为程序员,我们需要:

  1. 保持警惕:AI 生成的代码不是"免检产品"
  2. 验证一切:每个包、每个 API、每段逻辑
  3. 使用工具:让自动化工具帮你把关
  4. 持续学习:了解最新的安全威胁和防护方法

最后,送给所有程序员一句话:

"AI 可以帮你写代码,但只有你能为代码的安全负责。"

"那个你随手 npm install 的包,可能正在窃取你的 API Key。"

在 AI 时代,安全意识比任何时候都重要。

保持警惕,保护好自己。


💬 互动时间:你遇到过 AI 代码幻觉吗?你的团队有什么防护措施?评论区聊聊!

觉得有用的话,点赞 + 在看 + 转发,让更多程序员朋友看到~


本文作者是一个差点被 AI 幻觉坑了的程序员。关注我,一起在 AI 时代保持安全意识。

在 Vue3 中使用 LogicFlow 更新节点名称

作者 持续前行
2026年1月12日 09:57

在 Vue3 中更新 LogicFlow 节点名称有多种方式,下面我为你详细介绍几种常用方法。

🔧 核心更新方法

1. 使用 updateText方法(推荐)

这是最直接的方式,通过节点 ID 更新文本内容:

<template>
  <div>
    <div ref="container" style="width: 100%; height: 500px;"></div>
    <button @click="updateNodeName">更新节点名称</button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import LogicFlow from '@logicflow/core';
import '@logicflow/core/dist/style/index.css';

const container = ref(null);
const lf = ref(null);
const selectedNodeId = ref('');

onMounted(() => {
  lf.value = new LogicFlow({
    container: container.value,
    grid: true,
  });

  // 示例数据
  lf.value.render({
    nodes: [
      {
        id: 'node_1',
        type: 'rect',
        x: 100,
        y: 100,
        text: '原始名称'
      }
    ]
  });

  // 监听节点点击,获取选中节点ID
  lf.value.on('node:click', ({ data }) => {
    selectedNodeId.value = data.id;
  });
});

// 更新节点名称
const updateNodeName = () => {
  if (!selectedNodeId.value) {
    alert('请先点击选择一个节点');
    return;
  }

  const newName = prompt('请输入新的节点名称', '新名称');
  if (newName) {
    // 使用 updateText 方法更新节点文本
    lf.value.updateText(selectedNodeId.value, newName);
  }
};
</script>

2. 通过 setProperties方法更新

这种方法可以同时更新文本和其他属性:

// 更新节点属性,包括名称
const updateNodeWithProperties = () => {
  if (!selectedNodeId.value) return;

  const newNodeName = '更新后的节点名称';
  
  // 获取节点当前属性
  const nodeModel = lf.value.getNodeModelById(selectedNodeId.value);
  const currentProperties = nodeModel.properties || {};
  
  // 更新属性
  lf.value.setProperties(selectedNodeId.value, {
    ...currentProperties,
    nodeName: newNodeName,
    updatedAt: new Date().toISOString()
  });
  
  // 同时更新显示文本
  lf.value.updateText(selectedNodeId.value, newNodeName);
};

🎯 事件监听与交互方式

1. 双击编辑模式

实现双击节点直接进入编辑模式:

// 监听双击事件
lf.value.on('node:dblclick', ({ data }) => {
  const currentNode = lf.value.getNodeModelById(data.id);
  const currentText = currentNode.text?.value || '';
  
  const newText = prompt('编辑节点名称:', currentText);
  if (newText !== null) {
    lf.value.updateText(data.id, newText);
  }
});

2. 右键菜单编辑

结合 Menu 插件实现右键菜单编辑:

import { Menu } from '@logicflow/extension';
import '@logicflow/extension/lib/style/index.css';

// 初始化时注册菜单插件
lf.value = new LogicFlow({
  container: container.value,
  plugins: [Menu],
});

// 配置右键菜单
lf.value.extension.menu.setMenuConfig({
  nodeMenu: [
    {
      text: '编辑名称',
      callback: (node) => {
        const currentText = node.text || '';
        const newText = prompt('编辑节点名称:', currentText);
        if (newText) {
          lf.value.updateText(node.id, newText);
        }
      }
    },
    {
      text: '删除',
      callback: (node) => {
        lf.value.deleteNode(node.id);
      }
    }
  ]
});

💡 自定义节点名称编辑

对于自定义节点,可以重写文本相关方法:

import { RectNode, RectNodeModel } from '@logicflow/core';

class CustomNodeModel extends RectNodeModel {
  // 自定义文本样式
  getTextStyle() {
    const style = super.getTextStyle();
    return {
      ...style,
      fontSize: 14,
      fontWeight: 'bold',
      fill: '#1e40af',
    };
  }
  
  // 初始化节点数据
  initNodeData(data) {
    super.initNodeData(data);
    // 确保文本格式正确
    this.text = {
      x: data.x,
      y: data.y + this.height / 2 + 10,
      value: data.text || '默认节点'
    };
  }
}

// 注册自定义节点
lf.value.register({
  type: 'custom-node',
  view: RectNode,
  model: CustomNodeModel
});

🚀 批量更新与高级功能

1. 批量更新多个节点

// 批量更新所有节点名称
const batchUpdateNodeNames = () => {
  const graphData = lf.value.getGraphData();
  const updatedNodes = graphData.nodes.map(node => ({
    ...node,
    text: `${node.text}(已更新)`
  }));
  
  // 重新渲染
  lf.value.render({
    nodes: updatedNodes,
    edges: graphData.edges
  });
};

// 按条件更新节点
const updateNodesByCondition = () => {
  const graphData = lf.value.getGraphData();
  const updatedNodes = graphData.nodes.map(node => {
    if (node.type === 'rect') {
      return {
        ...node,
        text: `矩形节点-${node.id}`
      };
    }
    return node;
  });
  
  lf.value.render({
    nodes: updatedNodes,
    edges: graphData.edges
  });
};

2. 实时保存与撤销重做

// 监听文本变化并自动保存
lf.value.on('node:text-update', ({ data }) => {
  console.log('节点文本已更新:', data);
  saveToBackend(lf.value.getGraphData());
});

// 实现撤销重做功能
const undo = () => {
  lf.value.undo();
};

const redo = () => {
  lf.value.redo();
};

// 启用历史记录
lf.value = new LogicFlow({
  container: container.value,
  grid: true,
  history: true, // 启用历史记录
  historySize: 100 // 设置历史记录大小
});

⚠️ 注意事项与最佳实践

  1. 文本对象格式:LogicFlow 中文本可以是字符串或对象格式 {value: '文本', x: 100, y: 100}
  2. 更新时机:确保在 lf.render()之后再进行更新操作
  3. 错误处理:更新前检查节点是否存在
  4. 性能优化:批量更新时考虑使用防抖
// 安全的更新函数
const safeUpdateNodeName = (nodeId, newName) => {
  if (!lf.value) {
    console.error('LogicFlow 实例未初始化');
    return false;
  }
  
  const nodeModel = lf.value.getNodeModelById(nodeId);
  if (!nodeModel) {
    console.error(`节点 ${nodeId} 不存在`);
    return false;
  }
  
  try {
    lf.value.updateText(nodeId, newName);
    return true;
  } catch (error) {
    console.error('更新节点名称失败:', error);
    return false;
  }
};

这些方法涵盖了 Vue3 中 LogicFlow 节点名称更新的主要场景,你可以根据具体需求选择合适的方式。

【开源项目推荐】Biome:让前端代码质量工具链快到飞起来

2026年1月12日 09:40

这是"开源项目推荐"系列的第 2 篇文章,我们将为你介绍那些值得关注、值得使用的优秀开源项目。

为什么推荐这个项目?

在日常开发中,你是否遇到过这样的困境:

每次保存文件都要等待几秒钟的格式化和代码检查 运行一次 lint 需要喝杯茶才能等到结果 团队成员的 Prettier 和 ESLint 配置冲突,合并代码成了噩梦 CI/CD 流水线因为代码检查步骤太慢,拖慢了整个部署流程

今天推荐的项目 Biome,恰好解决了这些痛点——它是一个用 Rust 编写的现代化前端工具链,免费、开源、极速、优雅,堪称前端工具链的"速度之王"。

项目概览

项目信息 详情
项目名称 Biome
GitHub 仓库 biomejs/biome
开源协议 MIT License / Apache 2.0
核心技术 Rust + JavaScript/TypeScript
项目状态 活跃维护中,快速迭代
适用场景 JavaScript/TypeScript 项目、代码格式化、静态分析、CI/CD

核心亮点

1. 令人咋舌的性能表现

Biome 的性能数据足以让任何开发者眼前一亮:

  • 格式化:比 Prettier 快 25 倍,比 parallel-prettier 快 20 倍
  • 代码检查:比 ESLint 快 15 倍,即使在单线程模式下也快 4 倍
  • 扩展性:性能与核心数呈线性扩展,M1 Max 上可达 100 倍速度提升

这不是实验室的理想环境数据,而是基于真实项目的基准测试结果。在你的日常开发中,这意味着每次保存可能只需要几十毫秒,而不是几秒钟。

2. 零配置的极致简化

Biome 的设计哲学是"合理的默认值"。你不需要:

  • 编写复杂的 ESLint 配置文件
  • 调整 Prettier 和 ESLint 的冲突规则
  • 安装和协调多个 npm 包
  • 担心不同工具之间的行为不一致

只需一行命令安装,然后立即使用:

npm install @biomejs/biome
npx biome check --write .

3. 单体架构的优雅设计

传统前端工具链是"拼装车":ESLint 负责检查,Prettier 负责格式化,各自解析各自的语法树。Biome 采用单体架构,一次解析,多次复用。

功能 传统工具链 Biome
代码解析 每个工具独立解析 一次解析,共享语法树
配置管理 多个配置文件 一个 biome.json
规则冲突 经常出现 统一管理,零冲突
性能 线性叠加 协同优化

4. 原生 Rust 的速度优势

Biome 直接编译成原生二进制文件,消除了 JavaScript 运行时的开销:

  • 瞬间启动:没有 Node.js 的初始化延迟
  • 高效内存:编译时的内存管理,无 GC 突然暂停
  • CPU 友好:机器码直接执行,充分利用现代 CPU
  • 跨平台:无需 Node.js 即可运行,部署更灵活

5. 智能并行化

你的开发服务器有 16 个核?Biome 会全部用起来。ESLint 默认单线程,而 Biome 从设计之初就拥抱并行化。

在 CI/CD 流水线中,这意味着你的代码检查步骤可能从几分钟缩短到几十秒,显著提升反馈速度。

技术栈分析

依赖库/技术 作用 优势
Rust 核心实现 原生性能、内存安全、零成本抽象
rowan 语法树库 高效的树形数据结构,支持增量更新
biome_rowan 自定义语法树 针对前端语言优化的语法表示
biome_cli 命令行界面 用户友好的交互体验
biome_lsp 语言服务器协议 编辑器集成支持

实际应用场景

从项目架构可以看出,Biome 支持多种典型场景:

代码质量门禁:在 CI/CD 流水线中作为必检步骤,快速反馈代码问题 大型项目重构:自动化格式化和修复,保证代码风格一致 实时开发体验:保存时自动格式化和检查,几乎无感知延迟 多语言项目:同时支持 JavaScript、TypeScript、JSON、CSS 等多种语言 Monorepo 管理:高效的并行处理,适合大型代码库

使用建议

适合使用的项目

✅ JavaScript/TypeScript 项目 ✅ 追求极致性能的团队 ✅ 大型代码库或 Monorepo ✅ CI/CD 流水线需要快速反馈 ✅ 希望统一工具链配置的团队

需要注意的地方

⚠️ 规则生态还在快速发展,可能不如 ESLint 丰富 ⚠️ 高度定制的规则需求可能需要等待功能完善 ⚠️ 目前不支持 TypeScript 类型检查(推荐配合 TypeScript 使用) ⚠️ 迁移现有项目需要时间适配

快速上手

# 1. 安装 Biome
npm install @biomejs/biome

# 2. 初始化配置(可选)
npx @biomejs/biome init

# 3. 检查并修复代码
npx @biomejs/biome check --write .

# 4. 在 CI 中使用(只检查,不修改)
npx @biomejs/biome ci .

更详细的步骤请参考 快速开始安装与设置

项目评价

优势

✅ 开源免费,MIT/Apache 双协议,无商业限制 ✅ 性能极致,显著提升开发效率 ✅ 零配置开箱即用,学习成本低 ✅ 单体架构,避免工具链冲突 ✅ Rust 编写,内存安全和稳定性有保障 ✅ 活跃的社区和快速的版本迭代

潜在不足

⚠️ 规则生态相对较新,插件数量少于 ESLint ⚠️ TypeScript 类型检查能力有限 ⚠️ 特定领域的规则可能需要等待社区贡献 ⚠️ 文档和最佳实践还在完善中

总结

Biome 是一个重新定义前端工具链性能标准的项目。它没有试图成为"全能型"解决方案,而是专注于将代码格式化和检查做得更快、更简单、更统一。

Rust 的性能优势、单体架构的设计理念、零配置的使用体验,这些让 Biome 成为了前端工具链领域的一股清流。如果你正在被传统工具链的慢速和复杂性困扰,Biome 值得你花时间尝试。

它可能不会立即改变你的开发方式,但节省下来的每一秒钟,都是对开发效率和体验的投资。

告别手写礼簿!一款开源免费的电子红白喜事礼簿系统!

作者 Java陈序员
2026年1月12日 09:31

大家好,我是 Java陈序员

无论是儿女结婚的喜宴,还是亲友离世的白事,礼金记账都是绕不开的环节。

传统手写礼簿,不仅考验书写速度和细心程度,还面临着“记重了、算错了、丢了账本”的风险,既费人力又不省心。

而市面上的电子记账工具,要么依赖网络,要么数据存在云端,总担心隐私泄露。

今天,给大家推荐一款纯本地运行的电子礼簿系统,不用连网、不用注册、数据加密存储、安全又好用,红白喜事都适配!

项目介绍

gift-book —— 一款纯本地、零后端、完全本地运行的单页 Web 应用,旨在为各类红白喜事提供一个现代化、安全、高效的礼金(份子钱)管理解决方案。

功能特色

  • 无需联网:纯 HTML 单页应用,不依赖服务器,单页 Web 应用拔网线也能正常记账,数据 100% 存储在本地设备
  • 数据金融级加密保护:全量数据采用 AES-256 加密落库,管理密码通过 SHA-256 哈希保护,即使设备丢失、文件被拷贝,数据也无法破解
  • 秒级记账:姓名、金额、渠道(微信/支付宝/现金)全键盘操作,回车即录,支持实时检测重名、重复金额,并提供语音播报核对功能
  • 双色主题:内置 “喜庆红”(喜事)、“肃穆灰”(白事)两套皮肤,完美适配不同场景的氛围需求
  • 双屏互动:支持开启副屏页面,实时投射数据到外接屏幕/电视,副屏自动开启隐私模式,且支持自定义上传展示收款码
  • 专业级报表与归档:内置专业 PDF 引擎,生成的电子礼簿支持自定义字体、封面图、背景纹理,支持导出加密数据文件,跨设备可全量恢复
  • 开箱即用:普通用户免部署,无需安装任何环境,双击即可运行,同时可部署到服务器上,通过浏览器在线访问

快速上手

gift-book 由纯静态文件组成,无需安装任何环境。

1、打开下载地址,下载 Windows 预编译应用(gift-book.exe)

https://github.com/jingguanzhang/gift-book/releases

2、双击运行 gift-book.exe

3、初始化:创建新事项

设置事项名称及管理密码(请务必牢记,丢失无法找回)。

4、记账:录入数据

5、归档:活动结束后,务必导出 Excel 或 PDF 文件到电脑,微信收藏或云盘永久保存

功能体验

  • 礼金录入

  • 副屏

  • 礼簿

  • 礼金统计详情

本地开发

需要依赖代码编辑器(推荐 VS Code)和浏览器(Chrome/Edge)。

1、克隆或下载项目源码

git clone https://github.com/jingguanzhang/gift-book.git

2、在 VS Code 中打开项目代码

3、代码目录结构

gift-book
├── index1.html             # v1.1 专业版主入口(核心代码均内嵌于此,方便单文件分发)
├── index.html              # v1.0 基础版主入口
├── static/                 # 静态资源目录
    ├── tailwindcss.js      # 样式引擎
    ├── xlsx.full.min.js    # Excel 导出库
    ├── pdf-lib.min.js      # PDF 生成引擎
    ├── crypto-js.min.js    # 加密库
    └── fontkit & .ttf      # 字体文件(用于 PDF 生成)
└── guest-screen.html       # 副屏显示页面

4、右键 index.html 并选择 "Open with Live Server" 运行程序

需要在 VS Code 中提前安装插件 Live Server.

5、部署上线:无需编译,直接将所有文件上传至 GitHub Pages、Vercel、Nginx 或任何静态文件服务器即可

可以说,gift-book 这款纯本地电子礼簿,没有复杂的操作门槛,没有数据泄露的顾虑,只用简单的方式把账记准、记清、存好。快去试试吧~

项目地址:https://github.com/jingguanzhang/gift-book

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


LeetCode 274. H 指数:两种高效解法全解析

作者 Wect
2026年1月12日 09:30

在科研成果评价领域,H 指数是一个非常经典的指标,而 LeetCode 274 题正是围绕 H 指数的计算展开。这道题看似简单,但背后藏着两种思路迥异的高效解法。今天我们就来深入剖析这道题,把两种解法的逻辑、实现和优劣讲透。

一、题目回顾与 H 指数定义

首先明确题目要求:给定一个整数数组 citations,其中 citations[i] 表示研究者的第 i 篇论文被引用的次数,计算并返回该研究者的 H 指数。

核心是理解 H 指数的定义(划重点):一名科研人员的 H 指数是指他至少发表了 h 篇论文,并且这 h 篇论文每篇的被引用次数都大于等于 h。如果存在多个可能的 h 值,取最大的那个。

举个例子帮助理解:若 citations = [1,3,1],H 指数是 1。因为研究者有 3 篇论文,其中至少 1 篇被引用 ≥1 次,而要达到 h=2 则需要至少 2 篇论文被引用 ≥2 次(实际只有 1 篇3次,不满足),所以最大的 h 是 1。

二、解法一:计数排序思路(时间 O(n),空间 O(n))

先看第一种解法的代码,这是一种基于计数排序的优化方案,适合对时间效率要求较高的场景。


function hIndex_1(citations: number[]): number {
  const ciLen = citations.length;
  const count = new Array(ciLen + 1).fill(0);
  for (let i = 0; i < ciLen; i++) {
    if (citations[i] > ciLen) {
      count[ciLen]++;
    } else {
      count[citations[i]]++;
    }
  }
  let total = 0;
  for (let i = ciLen; i >= 0; i--) {
    total += count[i];
    if (total >= i) {
      return i;
    }
  }
  return 0;
};

2.1 核心思路

H 指数的最大值不可能超过论文总数 n(因为要至少 h 篇论文,h 最多等于论文数)。所以对于引用次数超过 n 的论文,我们可以统一视为引用次数为 n(不影响 H 指数的计算)。

基于这个特点,我们可以用一个计数数组 count 统计每个引用次数(0 到 n)对应的论文数量,然后从后往前累加计数,找到第一个满足「累加总数 ≥ 当前引用次数」的数值,这个数值就是最大的 H 指数。

2.2 步骤拆解(以 citations = [3,0,6,1,5] 为例)

  1. 初始化变量:论文总数 ciLen = 5,计数数组 count 长度为 ciLen + 1 = 6,初始值全为 0(count = [0,0,0,0,0,0])。

  2. 统计引用次数分布:遍历 citations 数组,将每篇论文的引用次数映射到 count 中:

     最终`count` 含义:引用 0 次的 1 篇、1 次的 1 篇、3 次的 1 篇、5 次及以上的 2 篇。
    
    • 3 ≤ 5 → count[3]++ → count = [0,0,0,1,0,0]

    • 0 ≤ 5 → count[0]++ → count = [1,0,0,1,0,0]

    • 6 > 5 → count[5]++ → count = [1,0,0,1,0,1]

    • 1 ≤ 5 → count[1]++ → count = [1,1,0,1,0,1]

    • 5 ≤ 5 → count[5]++ → count = [1,1,0,1,0,2]

  3. 倒序累加找 H 指数:从最大可能的 h(即 ciLen=5)开始,累加 count[i](表示引用次数 ≥i 的论文总数),直到累加和 ≥i:

    • i=5:total = 0 + 2 = 2 → 2 < 5 → 继续

    • i=4:total = 2 + 0 = 2 → 2 < 4 → 继续

    • i=3:total = 2 + 1 = 3 → 3 ≥ 3 → 满足条件,返回 3

最终结果为 3,符合预期(3 篇论文被引用 ≥3 次:3、6、5)。

2.3 优缺点

优点:时间复杂度 O(n),只需要两次遍历数组,效率极高;空间复杂度 O(n),仅需一个固定长度的计数数组。

缺点:需要额外的空间存储计数数组,对于论文数量极少的场景,空间开销不明显,但思路相对排序法更难理解。

三、解法二:排序思路(时间 O(n log n),空间 O(1))

第二种解法是基于排序的思路,逻辑更直观,容易理解,也是很多人首先会想到的方案。


function hIndex(citations: number[]): number {
  // 思路:逆序排序
  citations.sort((a, b) => b - a);
  let res = 0;
  for (let i = 0; i < citations.length; i++) {
    if (citations[i] >= i + 1) {
      res = i + 1;
    }
  }
  return res;
};

3.1 核心思路

将引用次数数组逆序排序(从大到小),此时排序后的数组第 i 个元素(索引从 0 开始)表示第 i+1 篇论文的引用次数。如果该元素 ≥ i+1,说明前 i+1 篇论文的引用次数都 ≥ i+1,此时 H 指数至少为 i+1。遍历完数组后,最大的这个 i+1 就是最终的 H 指数。

3.2 步骤拆解(同样以 citations = [3,0,6,1,5] 为例)

  1. 逆序排序数组:排序后 citations = [6,5,3,1,0]

  2. 遍历数组找最大 h:初始化 res = 0,依次判断每个元素:

    • i=0:citations[0] = 6 ≥ 0+1=1 → res = 1

    • i=1:citations[1] = 5 ≥ 1+1=2 → res = 2

    • i=2:citations[2] = 3 ≥ 2+1=3 → res = 3

    • i=3:citations[3] = 1 ≥ 3+1=4 → 不满足,res 不变

    • i=4:citations[4] = 0 ≥ 4+1=5 → 不满足,res 不变

  3. 返回结果:最终 res = 3,与解法一结果一致。

3.3 优缺点

优点:逻辑直观,容易理解和实现;空间复杂度低,若允许原地排序(如 JavaScript 的 sort 方法),空间复杂度为 O(log n)(排序的递归栈空间),否则为 O(1)。

缺点:时间复杂度由排序决定,为 O(n log n),对于大规模数据(如论文数量极多),效率不如解法一。

四、两种解法对比与适用场景

解法 时间复杂度 空间复杂度 核心优势 适用场景
计数排序法 O(n) O(n) 时间效率极高,两次线性遍历 大规模数据,对时间要求高
逆序排序法 O(n log n) O(1) 逻辑直观,空间开销小 小规模数据,追求代码简洁易读

五、常见易错点提醒

  1. 混淆 H 指数的定义:容易把「至少 h 篇论文 ≥h 次」写成「h 篇论文 exactly h 次」,导致判断条件错误(如之前有同学把解法一的 total ≥ i 写成 total === i)。

  2. 排序方向错误:解法二必须逆序排序(从大到小),若正序排序会导致逻辑混乱,无法正确统计。

  3. 忽略边界情况:如 citations = [0](H 指数 0)、citations = [100](H 指数 1),需确保两种解法都能覆盖这些场景。

六、总结

LeetCode 274 题的两种解法各有优劣:计数排序法以空间换时间,适合大规模数据;逆序排序法逻辑简洁,适合小规模数据。理解这两种解法的核心在于吃透 H 指数的定义——「至少 h 篇论文 ≥h 次引用」,所有的逻辑都是围绕这个定义展开的。

建议大家在练习时,先尝试自己实现逆序排序法(容易上手),再深入理解计数排序法的优化思路,通过对比两种解法的差异,加深对「时间复杂度」和「空间复杂度」权衡的理解。

vue优雅的适配无障碍

2026年1月12日 09:21

为了在 Vue 项目中如何优雅地适配无障碍功能,以下是完整的实现方案:

1. 创建无障碍指令

a11y.js 指令文件

// 无障碍常见属性常量定义
const A11Y_ATTRS = {
  ROLE: 'role',
  TABINDEX: 'tabindex',
  LABEL: 'aria-label',
  LABELLEDBY: 'aria-labelledby',
  DESCRIBEDBY: 'aria-describedby',
  LIVE: 'aria-live',
  HIDDEN: 'aria-hidden',
  DISABLED: 'aria-disabled'
}
const A11Y_ROLES = {
  BUTTON: 'button',
  LINK: 'link',
  IMAGE: 'img',
  HEADING: 'heading'
}

/**
 * 设置元素的 ARIA 属性
 * @param {HTMLElement} el - DOM 元素
 * @param {Object} binding - 指令绑定对象
 */
function setAriaAttributes (el, binding) {
  const { value } = binding
  if (value.tabIndex !== undefined) {
    el.setAttribute(A11Y_ATTRS.TABINDEX, value.tabIndex)
  }
  // 基础 ARIA 属性设置
  if (value.role) {
    el.setAttribute(A11Y_ATTRS.ROLE, value.role)
  }
  if (value.label) {
    el.setAttribute(A11Y_ATTRS.LABEL, value.label)
  }
  if (value.labelledBy) {
    el.setAttribute(A11Y_ATTRS.LABELLEDBY, value.labelledBy)
  }
  if (value.describedBy) {
    el.setAttribute(A11Y_ATTRS.DESCRIBEDBY, value.describedBy)
  }
  if (value.live) {
    el.setAttribute(A11Y_ATTRS.LIVE, value.live)
  } 
  if (value.hidden !== undefined) {
    el.setAttribute(A11Y_ATTRS.HIDDEN, value.hidden)
  }
  if (value.disabled !== undefined) {
    el.setAttribute(A11Y_ATTRS.DISABLED, value.disabled)
  }
  // 特殊角色处理
  if (value.role === A11Y_ROLES.HEADING && value.level) {
    el.setAttribute('aria-level', value.level)
  }
  // 确保按钮和链接添加 tabindex
  if (value.role === A11Y_ROLES.BUTTON || value.role === A11Y_ROLES.LINK) {
    if (!el.hasAttribute('tabindex')) {
      el.setAttribute('tabindex', '0')
    }
  }
  // 图片必须有 alt 文本
  if (value.role === A11Y_ROLES.IMAGE && !el.hasAttribute('alt')) {
    console.warn('Accessibility warning: Image elements should have alt text', el)
  }
}

/**
 * 动态更新 ARIA 属性
 * @param {HTMLElement} el - DOM 元素
 * @param {Object} value - 新值
 */
function updateAriaAttributes (el, value) {
  // 移除旧属性
  Object.values(A11Y_ATTRS).forEach(attr => {
    el.removeAttribute(attr)
  })
  // 设置新属性
  setAriaAttributes(el, { value })
}
export default {
  install (Vue, options = {}) {
    Vue.directive('a11y', {
      inserted (el, binding) {
        setAriaAttributes(el, binding)
      },
      update (el, binding) {
        if (binding.value !== binding.oldValue) {
          updateAriaAttributes(el, binding.value)
        }
      },
      componentUpdated (el, binding) {
        if (binding.value !== binding.oldValue) {
          updateAriaAttributes(el, binding.value)
        }
      }
    })
  }
}

2. 注册指令

main.js 中注册指令:

// src/main.js
import Vue from 'vue'
import App from './App.vue'
import a11yDirective from './directives/a11y'

Vue.directive('a11y', a11yDirective)

new Vue({
  render: h => h(App)
}).$mount('#app')

3. 使用指令

基本用法

<template>
  <!-- 按钮 -->
  <button v-a11y="{ role: 'button', label: '提交表单' }">提交</button>
  
  <!-- 标题 -->
  <h1 v-a11y="{ role: 'heading', level: 1 }">页面标题</h1>
  
  <!-- 图片 -->
  <img 
    v-a11y="{ role: 'img', label: '公司Logo' }" 
    src="logo.png" 
    alt="公司Logo"
  >
  
  <!-- 自定义交互元素 -->
  <div 
    v-a11y="{ role: 'button', label: '关闭弹窗' }"
    @click="closeModal"
  >
    ×
  </div>
</template>

动态属性

<template>
  <div>
    <button 
      v-a11y="{
        role: 'button',
        label: buttonLabel,
        disabled: isDisabled
      }"
      :disabled="isDisabled"
      @click="handleClick"
    >
      {{ buttonText }}
    </button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isDisabled: false,
      buttonText: '点击我',
      buttonLabel: '这是一个可点击的按钮'
    }
  },
  methods: {
    handleClick() {
      this.isDisabled = true
      this.buttonLabel = '按钮已被点击,请等待'
    }
  }
}
</script>

4. 高级功能扩展

焦点管理指令

// src/directives/focus-manager.js
export default {
  inserted(el, binding) {
    if (binding.value) {
      el.focus()
    }
  },
  update(el, binding) {
    if (binding.value && !binding.oldValue) {
      // 使用 setTimeout 确保在 DOM 更新后执行
      setTimeout(() => {
        el.focus()
      }, 0)
    }
  }
}

实时区域更新

<template>
  <div 
    v-a11y="{ live: 'polite' }"
    aria-live="polite"
  >
    {{ notificationText }}
  </div>
</template>

<script>
export default {
  data() {
    return {
      notificationText: ''
    }
  },
  methods: {
    showNotification(message) {
      this.notificationText = message
    }
  }
}
</script>

5. 最佳实践

  1. 语义化 HTML:优先使用原生语义化元素(如 <button> 而不是 <div>

  2. 标签关联

    <template>
      <div>
        <label id="username-label">用户名</label>
        <input 
          v-a11y="{ labelledBy: 'username-label' }"
          type="text"
          aria-labelledby="username-label"
        >
      </div>
    </template>
    
  3. 状态管理

    <template>
      <button
        v-a11y="{
          role: 'button',
          label: expanded ? '收起菜单' : '展开菜单',
          expanded: expanded
        }"
        :aria-expanded="expanded"
        @click="toggleMenu"
      >
        {{ expanded ? '▼' : '►' }}
      </button>
    </template>
    
  4. 测试验证

    • 使用 Chrome 开发者工具的 Lighthouse 进行无障碍测试
    • 在 Android 设备上实际测试 TalkBack 功能

6. 全局混入常用方法

// src/mixins/a11y.js
export default {
  methods: {
    // 为动态内容提供无障碍通知
    a11yNotify(message, priority = 'polite') {
      const liveRegion = document.getElementById('a11y-live-region')
      if (liveRegion) {
        liveRegion.setAttribute('aria-live', priority)
        liveRegion.textContent = message
      } else {
        console.warn('无障碍实时区域未找到')
      }
    },
    
    // 管理焦点
    a11yFocus(elementId) {
      const el = document.getElementById(elementId)
      if (el) {
        el.focus()
      }
    }
  }
}

总结

通过封装 v-a11y 指令,我们可以:

  1. 统一管理所有无障碍属性
  2. 提供动态更新能力
  3. 内置常见模式的最佳实践
  4. 方便地扩展新功能
  5. 保持代码整洁和可维护性

这种方案既满足了 TalkBack 的基本需求,又能灵活应对各种复杂场景,是 Vue 项目中实现无障碍功能的优雅解决方案。

深入理解CSS transform矩阵:从底层原理到实战应用

作者 ycgg
2026年1月12日 08:48

在前端可视化开发中,CSS transform 是实现元素动效、图形变换的核心属性。我们常用的 translaterotatescaleskew 等方法,本质上都是浏览器对「矩阵运算」的封装。而直接使用 matrix/matrix3d 矩阵,不仅能解锁更灵活的自定义变换效果,更能帮我们看透变换的底层逻辑。本文将从 2D 矩阵核心、参数解析、实战应用到 3D 进阶,全面拆解 transform 矩阵的用法与原理。

一、核心基础:2D 变换矩阵 matrix(a,b,c,d,e,f)

CSS 中的 matrix(a,b,c,d,e,f) 是对「2D 齐次矩阵」的简化封装。数学上,2D 图形变换需依赖 3×3 齐次矩阵(目的是让平移操作可通过矩阵乘法实现),而 matrix 的 6 个参数,恰好对应该矩阵的前 2 行,第三行固定为 [0,0,1],无需开发者手动设置。

1. 矩阵与参数的数学对应关系

CSS 矩阵与数学矩阵的映射关系如下,清晰揭示了参数的底层逻辑:

CSS matrix(a,b,c,d,e,f) ↔

\begin{bmatrix} a & c & e \\ % 第一行:控制X轴方向变换 b & d & f \\ % 第二行:控制Y轴方向变换 0 & 0 & 1 % 第三行:齐次坐标固定项 \end{bmatrix}

2. 参数的通俗解析(附计算公式)

元素上任意一点 (x, y)(相对于元素自身坐标系),经矩阵变换后的新坐标 (x', y') 由以下公式计算:

x=a×x+c×y+ex' = a \times x + c \times y + e

y=b×x+d×y+fy' = b \times x + d \times y + f

基于公式,6 个参数的作用可精准拆解,结合场景更易理解:

参数 核心作用 通俗解释与示例
a X轴缩放 + 旋转关联 纯缩放:a=2 表示X轴放大2倍;旋转时与 cosθ(θ为旋转角度)联动
b Y轴倾斜 + 旋转关联 纯倾斜:b=0.5 表示Y轴向X轴倾斜;旋转时与 sinθ 联动
c X轴倾斜 + 旋转关联 纯倾斜:c=0.5 表示X轴向Y轴倾斜;旋转时与 -sinθ 联动
d Y轴缩放 + 旋转关联 纯缩放:d=2 表示Y轴放大2倍;旋转时与 cosθ 联动
e X轴平移(像素/百分比) 纯平移:e=50 表示元素向右平移50px,不受缩放、旋转影响
f Y轴平移(像素/百分比) 纯平移:f=50 表示元素向下平移50px

3. 基准态:单位矩阵

当矩阵为 matrix(1,0,0,1,0,0) 时,代入公式可得 x'=xy'=y,元素无任何变换。这是所有变换的基准态,任何复杂变换都是基于单位矩阵修改参数实现的。

二、实战:基础变换的矩阵实现

所有基础变换(平移、缩放、倾斜、旋转)都是矩阵的特例,我们可通过矩阵还原基础变换的底层逻辑,也可直接用矩阵实现等价效果。

1. 纯平移:仅修改 e/f 参数

需求:元素向右平移50px、向下平移30px,等价于 translate(50px, 30px)

.box {
  transform: matrix(1, 0, 0, 1, 50, 30); /* 单位矩阵基础上修改e、f */
}

2. 纯缩放:仅修改 a/d 参数

需求:X轴放大1.5倍、Y轴缩小0.8倍,等价于 scale(1.5, 0.8)

.box {
  transform: matrix(1.5, 0, 0, 0.8, 0, 0);
}

3. 纯倾斜:修改 b 或 c 参数

需求:X轴向Y轴倾斜30°(倾斜角度φ满足 tanφ = ctan30°≈0.577),等价于 skewX(30deg)

.box {
  transform: matrix(1, 0, 0.577, 1, 0, 0);
}

4. 纯旋转:a/b/c/d 联动

旋转θ角的矩阵公式为:matrix(cosθ, sinθ, -sinθ, cosθ, 0, 0)。需求:元素顺时针旋转30°(cos30°≈0.866sin30°=0.5),等价于 rotate(30deg)

.box {
  transform: matrix(0.866, 0.5, -0.5, 0.866, 0, 0);
}

5. 复合变换:多规则叠加(核心易错点)

矩阵乘法不满足交换律,变换顺序直接影响结果。浏览器处理多个 transform 函数时,会从右到左应用矩阵。

需求:先缩放1.2倍 → 再旋转30° → 最后X轴平移50px。

/* 方法1:基础函数组合(浏览器自动转矩阵) */
.box {
  transform: translate(50px) rotate(30deg) scale(1.2);
}

/* 方法2:手动计算复合矩阵(结果等价) */
.box {
  transform: matrix(1.039, 0.598, -0.598, 1.039, 50, 0);
}

注意:translate(50px) rotate(30deg)rotate(30deg) translate(50px) 效果完全不同——前者平移的是旋转后的坐标系,后者平移的是原始坐标系。

6. 自定义非线性变换:矩阵的独特价值

基础变换组合无法实现的复杂效果,可通过矩阵直接定义。例如:X轴缩放1.5倍 + Y轴倾斜0.3 + 右移20px。

.box {
  width: 100px;
  height: 100px;
  background: red;
  transform: matrix(1.5, 0.3, 0, 1, 20, 0);
}

三、进阶:3D 变换矩阵 matrix3d()

3D 变换依赖 4×4 齐次矩阵,matrix3d() 接收16个参数,对应矩阵的「列优先」排列(新手最易出错的点,与直观的行优先相反)。

1. 矩阵结构与参数规则

matrix3d(m11,m12,m13,m14, m21,m22,m23,m24, m31,m32,m33,m34, m41,m42,m43,m44) 对应矩阵:

[m11m21m31m41m12m22m32m42m13m23m33m43m14m24m34m44]\begin{bmatrix} m11 & m21 & m31 & m41 \\ m12 & m22 & m32 & m42 \\ m13 & m23 & m33 & m43 \\ m14 & m24 & m34 & m44 \\ \end{bmatrix}

2. 常用 3D 变换的简化矩阵

无需记忆全部16个参数,核心场景可简化:

  • 纯3D平移:matrix3d(1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1) → 等价于 translate3d(x,y,z)
  • 纯3D缩放:matrix3d(sx,0,0,0, 0,sy,0,0, 0,0,sz,0, 0,0,0,1) → 等价于 scale3d(sx,sy,sz)
  • 绕X轴旋转:matrix3d(1,0,0,0, 0,cosθ,-sinθ,0, 0,sinθ,cosθ,0, 0,0,0,1) → 等价于 rotateX(θ)

3. 实战:3D卡片翻转

.card {
  width: 200px;
  height: 200px;
  transition: transform 0.5s;
}
.card:hover {
  /* 绕Y轴旋转180° + Z轴平移10px(突出效果) */
  transform: matrix3d(
    -1,0,0,0,   /* 第一列:cos180°=-1 */
    0,1,0,0,    /* 第二列:Y轴无旋转 */
    0,0,1,0,    /* 第三列:Z轴无旋转 */
    0,0,10,1    /* 第四列:Z轴平移10px */
  );
  /* 等价于 transform: rotateY(180deg) translateZ(10px) */
}

四、关键补充:transform-origin 的影响

transform-origin 定义「变换原点」,默认值为元素中心 50% 50%,它会改变矩阵变换的坐标系原点:

  • 旋转、缩放时,原点决定了变换的中心点(如 transform-origin: 0 0 表示绕左上角变换);
  • 矩阵中的 e/f(平移参数)是相对于变换原点的,而非元素左上角。
.box {
  width: 100px;
  height: 100px;
  transform-origin: 0 0; /* 变换原点设为左上角 */
  transform: matrix(0.866, 0.5, -0.5, 0.866, 0, 0); /* 绕左上角旋转30° */
}

五、验证矩阵正确性的方法

借助浏览器开发者工具可快速验证矩阵计算结果:

  1. 打开 Chrome/Firefox 开发者工具,选中目标元素;
  2. 切换到「Computed」面板,找到 transform 属性;
  3. 面板会显示浏览器解析后的矩阵值,可与手动计算结果对比验证。

六、总结

CSS transform 矩阵是前端可视化的底层核心,其本质是通过结构化参数封装线性变换规则:

  1. 2D 矩阵 matrix(a,b,c,d,e,f) 对应 3×3 齐次矩阵,核心公式决定了参数对坐标的影响,基础变换是矩阵的特例;
  2. 矩阵乘法无交换律,变换顺序直接影响结果,浏览器按「右到左」顺序解析多个变换函数;
  3. matrix 可实现基础变换组合无法达成的自定义效果,matrix3d 则适配 3D 场景;
  4. transform-origin 影响变换坐标系,是调试矩阵效果的关键细节。

掌握矩阵不仅能帮我们看透 transform 的底层逻辑,更能解锁前端动效与可视化的无限可能,从容应对复杂变换需求。

Zustand 入门:React Native 状态管理的正确用法

作者 wayne214
2026年1月12日 08:34

一、Zustand 是什么,适合什么场景

Zustand 是一个轻量级、基于 hooks 的状态管理库,核心特点是:

  • 无 Provider(无需 Context 包裹)
  • API 极简(create + hooks)
  • 按需订阅(避免无关组件重渲染)
  • 对 React Native 友好(无额外平台依赖)
  • 可渐进式引入

非常适合以下 RN 场景:

  • 中小规模应用
  • RN Hybrid / Module 化工程
  • UI 状态 + 业务状态混合管理
  • 替代部分 Redux 的场景

二、安装

yarn add zustand
# 或
npm install zustand

React Native 无需额外配置。


三、最基础用法(核心必会)

1. 创建 Store

// store/useCounterStore.ts
import { create } from 'zustand';

type CounterState = {
  count: number;
  inc: () => void;
  dec: () => void;
};

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set((state) => ({ count: state.count - 1 })),
}));

2. 在组件中使用

import React from 'react';
import { View, Text, Button } from 'react-native';
import { useCounterStore } from './store/useCounterStore';

export default function Counter() {
  const count = useCounterStore((state) => state.count);
  const inc = useCounterStore((state) => state.inc);

  return (
    <View>
      <Text>Count: {count}</Text>
      <Button title="+" onPress={inc} />
    </View>
  );
}

关键点

  • selector 模式useStore(state => state.xxx)
  • 只订阅使用到的字段,避免全量刷新

四、推荐的工程化写法(重要)

❌ 不推荐

const store = useStore();

这样会导致任意状态变更都触发重渲染


✅ 推荐:拆分 selector

const count = useCounterStore((s) => s.count);
const inc = useCounterStore((s) => s.inc);

或:

const { count, inc } = useCounterStore(
  (s) => ({ count: s.count, inc: s.inc })
);

五、Zustand 在 React Native 中的常见模式

1. 全局 UI 状态(Loading / Modal)

type UIState = {
  loading: boolean;
  showLoading: () => void;
  hideLoading: () => void;
};

export const useUIStore = create<UIState>((set) => ({
  loading: false,
  showLoading: () => set({ loading: true }),
  hideLoading: () => set({ loading: false }),
}));
const loading = useUIStore((s) => s.loading);

2. 业务状态(登录信息)

type User = {
  id: string;
  name: string;
};

type AuthState = {
  user?: User;
  login: (u: User) => void;
  logout: () => void;
};

export const useAuthStore = create<AuthState>((set) => ({
  user: undefined,
  login: (user) => set({ user }),
  logout: () => set({ user: undefined }),
}));

3. 异步 Action(非常自然)

type ListState = {
  list: string[];
  loading: boolean;
  fetchList: () => Promise<void>;
};

export const useListStore = create<ListState>((set) => ({
  list: [],
  loading: false,
  fetchList: async () => {
    set({ loading: true });
    const res = await fetch('https://example.com/list');
    const data = await res.json();
    set({ list: data, loading: false });
  },
}));

RN 中无需 thunk / saga。


六、性能优化(RN 场景非常关键)

1. 使用 shallow 避免对象对比

import { shallow } from 'zustand/shallow';

const { count, inc } = useCounterStore(
  (s) => ({ count: s.count, inc: s.inc }),
  shallow
);

2. 将高频 UI 状态拆分 Store

store/
 ├── useAuthStore.ts
 ├── useUIStore.ts
 ├── useListStore.ts

避免一个大 Store。


七、持久化(AsyncStorage)

RN 常用:zustand + persist

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

export const useAuthStore = create(
  persist(
    (set) => ({
      token: '',
      setToken: (token: string) => set({ token }),
      clearToken: () => set({ token: '' }),
    }),
    {
      name: 'auth-storage',
      storage: {
        getItem: AsyncStorage.getItem,
        setItem: AsyncStorage.setItem,
        removeItem: AsyncStorage.removeItem,
      },
    }
  )
);

八、Zustand vs Redux Toolkit(RN 实战视角)

维度 Zustand Redux Toolkit
学习成本 极低
样板代码 极少
Provider 不需要 必须
异步 原生支持 thunk / saga
DevTools
大型团队 一般 更适合

个人建议

  • RN 业务页面、模块级状态:Zustand
  • 复杂全局状态、多人协作:RTK
  • 二者可以共存

九、常见坑位总结

  1. 不要整 store 订阅
  2. 不要把所有状态塞进一个 store
  3. RN 中慎用大对象(列表分页要拆分)
  4. persist + AsyncStorage 要注意冷启动恢复时机

Underscore.js 整体设计思路与架构分析

作者 Anita_Sun
2026年1月12日 07:50

源码分析: bgithub.xyz/lessfish/un…

官网中所带注释的源码:

整体分析:underscorejs.org/docs/unders…

模块分析:underscorejs.org/docs/module…

核心架构模式

模块结构

Underscore.js 采用了 立即执行函数表达式 (IIFE) 作为核心模块结构,创建了一个封闭的作用域,避免了全局变量污染:

这种设计方式能够让 Underscore.js :

  • 支持多种模块系统(CommonJS、AMD、全局变量)
  • 提供 noConflict 方法,避免命名冲突
  • 在不同环境中(浏览器、Node.js)正常工作
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? 
  // 场景1:CommonJS 环境(Node.js)
  module.exports = factory() :
  typeof define === 'function' && define.amd ? 
  // 场景2:AMD 环境(如 RequireJS)
  define('underscore', factory) :
  // 场景3:无模块化的浏览器全局环境(兜底)
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, (function () {
    var current = global._; // 保存当前全局的 _ 变量
    var exports = global._ = factory(); // 把 underscore 挂载到全局 _
    // 解决命名冲突的 noConflict 方法
    exports.noConflict = function () { global._ = current; return exports; };
  }()));
}(this, (function () {
  // 核心实现...
})));

双模式 API 设计

Underscore.js 同时支持两种调用方式:

函数式调用

_.map([123], function(num) { return 
num * 2; });

面向对象调用(链式)

_([123]).map(function(num) { return 
num * 2; }).value();

这种设计通过以下核心构造函数实现:

function _$1(obj) {
  if (obj instanceof _$1) return obj;
  if (!(this instanceof _$1)) return new 
  _$1(obj);
  this._wrapped = obj;
}

方法挂载机制

函数定义与收集

Underscore.js 首先将所有功能实现为独立函数,然后通过 allExports 对象统一收集:

var allExports = {
  __proto__: null,
  VERSION: VERSION,
  restArguments: restArguments,
  isObject: isObject,
  // ... 其他函数
};

方法挂载

通过 mixin 方法,将所有函数同时挂载到构造函数和原型链上:

function mixin(obj) {
  each(functions(obj), function(name) {
    var func = _$1[name] = obj[name];
    _$1.prototype[name] = function() {
      var args = [this._wrapped];
      push.apply(args, arguments);
      return chainResult(this, func.apply
      (_$1, args));
    };
  });
  return _$1;
}

// 执行挂载
var _ = mixin(allExports);

这种设计使得:

  • 所有函数既可以通过 _.func() 方式调用
  • 也可以通过_().func() 链式调用

数组方法集成

Underscore.js 还集成了原生数组的方法,分为两类:

变更方法(Mutator)

pop/push/reverse/shift/sort/splice/unshift 这些方法的核心是修改原数组(比如 push 往原数组加元素,shift 从原数组删第一个元素),执行后原数组本身变了,方法返回值只是 “操作结果”(比如 pop 返回删除的元素),而非新数组。

// 假设包装类实例:_([1,2,3])
const arrWrapper = _([1,2,3]);

// 调用mutator方法push
arrWrapper.push(4);
console.log(arrWrapper._wrapped); // [1,2,3,4](原数组被修改)
each(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(name) {
  // 从数组原型(ArrayProto是Array.prototype的简写)获取对应的原生方法
  var method = ArrayProto[name];

  // 为包装类原型添加当前遍历的方法(如pop/push等)
  _$1.prototype[name] = function() {
    // 获取包装类实例中包裹的原始数组(_wrapped是包装类存储原始数据的核心属性)
    var obj = this._wrapped;

    // 仅当原始数组不为null/undefined时执行(避免空指针错误)
    if (obj != null) {
      // 调用原生数组方法,将当前方法的参数透传给原生方法,直接修改原数组
      // apply作用:绑定方法执行上下文为原始数组obj,参数以数组形式传递
      method.apply(obj, arguments);

      // 特殊边界处理:修复shift/splice清空数组后可能残留的索引0问题
      // 原因:部分场景下shift/splice把数组清空(length=0)后,obj[0]仍可能残留undefined
      // 删除索引0可保证空数组的结构完全干净,符合原生数组的预期行为
      if ((name === 'shift' || name === 'splice') && obj.length === 0) {
        delete obj[0];
      }
    }

    // 返回链式调用结果:保证调用该方法后仍能继续调用包装类的其他方法
    // chainResult会根据是否开启链式调用,返回包装类实例(this)或修改后的原数组(obj)
    return chainResult(this, obj);
  };
});

function chainResult(instance, obj) {
    return instance._chain ? _$1(obj).chain() : obj;
}

function chain(obj) {
    var instance = _$1(obj);
    instance._chain = true;
    return instance;
}

访问方法(Accessor)

concat/join/slice 这些方法的核心是返回新结果,原数组完全不变(比如 concat 拼接后返回新数组,原数组还是原样;slice 截取后返回新子数组)。

// 假设包装类实例:_([1,2,3])
const arrWrapper = _([1,2,3]);

// 2. 调用accessor方法concat
const newWrapper = arrWrapper.concat([5,6]);
console.log(arrWrapper._wrapped); // [1,2,3,4](原数组仍不变)
console.log(newWrapper._wrapped); // [1,2,3,4,5,6](新结果)
// 批量为自定义数组包装类的原型挂载数组非可变方法(不会修改原数组,返回新值)
// 目标:让自定义包装类实例调用这些方法时,获取原生方法的返回结果,并保持链式调用特性
each(['concat', 'join', 'slice'], function(name) {
  // 从数组原型(ArrayProto)中获取对应的原生方法(如Array.prototype.concat)
  // ArrayProto是Array.prototype的简写,常见于Underscore/Lodash等工具库
  var method = ArrayProto[name];

  // 为自定义数组包装类(_$1)的原型挂载当前遍历的方法
  _$1.prototype[name] = function() {
    // 获取包装类实例中包裹的原始数组/值(_wrapped是包装类存储原始数据的核心属性)
    var obj = this._wrapped;

    // 仅当原始数据不为null/undefined时执行原生方法(避免空指针错误)
    if (obj != null) {
      // 调用原生方法,透传参数并接收返回值
      // 核心差异:这类方法不修改原数组,而是返回新值,因此需要用新值覆盖obj
      obj = method.apply(obj, arguments);
    }

    // 返回链式调用结果:将新的返回值(obj)传入chainResult,保证链式调用的正确性
    // 若开启链式则返回包装类实例,未开启则返回新的数组/值
    return chainResult(this, obj);
  };
});

链式调用实现

Underscore.js 的链式调用是其一大特色,通过以下机制实现:

调用 _.chain() 后,所有方法执行完都会通过 chainResult 返回「新的包装对象」(而非原始数据),因此可以继续调用原型上的方法;直到调用 value() 方法(需补充实现),取出 _wrapped 里的原始数据,结束链式。

举个栗子

看下链式调用如何工作:

// 链式调用示例:过滤出大于2的数,再乘以2,最后获取结果
var finalResult = _.chain([1, 2, 3, 4])
  .filter(function(x) { return x > 2; })
  .map(function(x) { return x * 2; })
  .value();

console.log(finalResult); // 输出:[6, 8]

代码实现

下面看下核心代码是怎么实现的吧 ~

// 核心包装类 _$1(对应 Underscore 的 _ 函数)
function _$1(obj) {
  // 如果是 _$1 实例,直接返回
  if (obj instanceof _$1) return obj;
  // 如果不是实例,创建实例并存储原始数据
  if (!(this instanceof _$1)) return new _$1(obj);
  this._wrapped = obj; // 存储被包装的原始数据(数组/对象)
  this._chain = false; // 链式标记,默认关闭
}

// 结束链式:获取最终结果
_$1.prototype.value = function() {
  return this._wrapped; // 取出包装对象里的原始数据
};

function chain(obj) {
  var instance = _$1(obj);
  instance._chain = true; // 开启链式标记
  return instance;
}

function chainResult(instance, obj) {
  // 关键判断:如果开启链式,返回新的包装对象(继续链式);否则返回原始数据
  return instance._chain ? _$1(obj).chain() : obj;
}

// 模拟 Underscore 的 each/functions 工具函数(简化版)
function each(arr, callback) {
  for (var i = 0; i < arr.length; i++) callback(arr[i], i);
}
function functions(obj) {
  return Object.keys(obj).filter(key => typeof obj[key] === 'function');
}

function mixin(obj) {
  each(functions(obj), function(name) {
    var func = _$1[name] = obj[name]; // 挂载到 _$1 静态方法
    _$1.prototype[name] = function() {
      // 1. 构造参数:第一个参数是包装的原始数据 this._wrapped,后续是方法入参
      var args = [this._wrapped];
      push.apply(args, arguments);
      // 2. 执行工具函数,得到结果
      var result = func.apply(_$1, args);
      // 3. 调用 chainResult,决定返回包装对象(链式)还是原始数据
      return chainResult(this, result);
    };
  });
  return _$1;
}

// 挂载常用工具方法(模拟 Underscore 的 filter/map)
mixin({
  filter: function(arr, fn) {
    return arr.filter(fn);
  },
  map: function(arr, fn) {
    return arr.map(fn);
  }
});

// 给 _$1 原型挂载 chain 方法(对应用户代码里的 instance.chain())
_$1.prototype.chain = function() {
  return chain(this._wrapped);
};

核心设计特点

函数式编程风格

Underscore.js 采用函数式编程范式,提供了大量高阶函数:

  • 纯函数 :如 map 、 filter 等,不修改原数据,避免污染原数据

  • 函数工厂 :如 tagTester 、 createPredicateIndexFinder 等。会返回一个新函数的函数,用于复用函数逻辑,减少重复代码

    // 函数工厂:生成检测特定类型的函数
    const tagTester = function(tag) {
      // 返回新函数(检测类型)
      return function(obj) {
        return Object.prototype.toString.call(obj) === `[object ${tag}]`;
      };
    };
    
    // 生产具体的检测函数
    _.isArray = tagTester('Array');
    _.isObject = tagTester('Object');
    _.isFunction = tagTester('Function');
    
    // 使用
    console.log(_.isArray([1,2])); // true
    console.log(_.isObject({a:1})); // true
    
  • 函数组合 :如 compose 函数,将多个函数组合成一个新函数,执行顺序为 “从右到左”,前一个函数的输出作为后一个函数的输入

    // 函数组合核心实现
    _.compose = function(...funcs) {
      return function(...args) {
        // 从右到左执行函数
        return funcs.reduceRight((result, func) => [func.apply(this, result)], args)[0];
      };
    };
    
    // 示例:先过滤大于2的数,再乘以2,最后求和
    const filterBig = arr => _.filter(arr, x => x > 2);
    const double = arr => _.map(arr, x => x * 2);
    const sum = arr => _.reduce(arr, (a, b) => a + b, 0);
    
    // 组合函数:sum(double(filterBig(arr)))
    const process = _.compose(sum, double, filterBig);
    
    console.log(process([1,2,3,4])); // (3,4)→[6,8]→14
    
  • 函数柯里化 :如 partial 函数,将多参数函数拆解为单参数函数链,可分步传参,延迟执行。

    // 柯里化核心实现(简化版)
    _.partial = function(func, ...fixedArgs) {
      return function(...remainingArgs) {
        // 合并固定参数和剩余参数,执行原函数
        return func.apply(this, fixedArgs.concat(remainingArgs));
      };
    };
    
    // 示例:固定乘法的第一个参数为2(创建“乘以2”的函数)
    const multiply = (a, b) => a * b;
    const double = _.partial(multiply, 2);
    
    // 分步传参:先传2,后传3/4
    console.log(double(3)); // 6
    console.log(double(4)); // 8
    

跨环境兼容性

Underscore.js 设计了完善的跨环境兼容机制,核心是 先检测、后适配、再降级 的策略:

  • 环境检测 :自动检测运行环境(浏览器、Node.js)
  • 特性检测 :检测原生方法是否存在
  • 优雅降级 :当原生方法不可用时,使用自定义实现
  • IE 兼容性 :特别处理了 IE < 9 的兼容性问题

性能优化

Underscore.js 在设计中融入了多种性能优化策略:

  • 缓存 :如 memoize 函数,缓存计算结果

    // memoize 核心实现(简化版)
    _.memoize = function(func, hashFunction) {
      const cache = {}; // 缓存容器
      hashFunction = hashFunction || function(args) {
        return args[0]; // 默认用第一个参数作为缓存key
      };
    
      return function(...args) {
        const key = hashFunction.apply(this, args);
        // 缓存存在则直接返回,否则执行函数并缓存
        if (!cache.hasOwnProperty(key)) {
          cache[key] = func.apply(this, args);
        }
        return cache[key];
      };
    };
    
    // 示例:缓存斐波那契计算结果(避免重复递归)
    const fib = _.memoize(function(n) {
      return n < 2 ? n : fib(n - 1) + fib(n - 2);
    });
    
    console.log(fib(10)); // 55(首次计算,缓存结果)
    console.log(fib(10)); // 55(直接取缓存,无需计算)
    
  • 延迟执行 :如 debounce 、 throttle 函数

    • debounce(防抖) :延迟执行函数,若短时间内重复触发,重置延迟(如搜索框输入、窗口 resize);
    • throttle(节流) :限制函数在指定时间内仅执行一次(如滚动事件、按钮点击)。
    // 防抖核心实现(简化版)
    _.debounce = function(func, wait) {
      let timeoutId;
      return function(...args) {
        clearTimeout(timeoutId); // 重置延迟
        timeoutId = setTimeout(() => {
          func.apply(this, args);
        }, wait);
      };
    };
    
    // 示例:搜索框输入后500ms执行搜索
    const search = _.debounce(function(keyword) {
      console.log('搜索:', keyword);
    }, 500);
    
    // 快速输入时,仅最后一次输入后500ms执行
    search('a');
    search('ab');
    search('abc'); // 仅执行这一次
    
  • 惰性求值 :通过链式调用实现 链式调用时,并非每一步都立即计算,而是延迟到最后一步 value () 才执行最终计算,减少中间临时数据的生成:

    // 惰性求值示例:链式调用仅在value()时执行最终逻辑
    const result = _.chain([1,2,3,4])
      .filter(x => x > 2) // 暂存逻辑,不立即执行
      .map(x => x * 2)    // 暂存逻辑,不立即执行
      .value();           // 执行所有逻辑,返回结果 [6,8]
    
  • 原生方法优先 :当原生方法可用时,优先使用原生方法,JavaScript 原生方法(如 Array.prototype.mapObject.keys)由引擎底层实现(C++),比纯 JS 实现快得多。

可扩展性

Underscore.js 设计了良好的扩展机制:

  • mixin 方法 :允许用户添加自定义函数,可将自定义函数挂载到 Underscore 原型上,支持链式调用:

    // 示例:自定义一个“求平方和”的方法,通过mixin挂载
    _.mixin({
      sumOfSquares: function(arr) {
        return _.reduce(arr, (sum, x) => sum + x * x, 0);
      }
    });
    
    // 直接调用 + 链式调用都支持
    console.log(_.sumOfSquares([1,2,3])); // 1+4+9=14
    
    const result = _.chain([1,2,3])
      .filter(x => x > 1) // [2,3]
      .sumOfSquares()     // 4+9=13
      .value();
    console.log(result); // 13
    
  • 自定义 iteratee :允许用户自定义迭代器行为

    // 示例:自定义迭代器,处理对象数组的特定属性
    const users = [
      { name: '张三', age: 20 },
      { name: '李四', age: 30 }
    ];
    
    // 自定义迭代器:提取age属性并判断是否大于25
    const ageIterator = user => user.age > 25;
    const result = _.filter(users, ageIterator);
    console.log(result); // [{ name: '李四', age: 30 }]
    
  • 模板系统 :支持自定义分隔符、变量插值规则,适配不同场景

    // 示例:自定义模板分隔符(默认是<% %>,改为{{ }})
    _.templateSettings = {
      evaluate: /{{(.+?)}}/g,    // 执行代码:{{ code }}
      interpolate: /{{=(.+?)}}/g // 插值:{{= value }}
    };
    
    // 使用自定义模板
    const template = _.template('Hello {{= name }}! {{ if (age > 18) { }}成年{{ } else { }}未成年{{ } }}');
    const html = template({ name: '张三', age: 20 });
    console.log(html); // Hello 张三! 成年
    

缺点

性能层面的损耗

链式调用的额外开销

Underscore 链式调用依赖每次方法调用创建新的 _$1 包装对象,且需通过 value() 触发最终计算:

  • 对象创建成本:每一步链式操作(如 map()/filter())都会实例化新的包装对象,频繁操作大型数据集时,内存分配和垃圾回收开销显著;
  • 原型链查找损耗:包装对象的方法挂载在 _$1.prototype 上,每次调用需遍历原型链,效率低于原生方法的直接调用;
  • 对比示例_.chain([1,2,3]).map(x=>x*2).value() 比原生 [1,2,3].map(x=>x*2) 多了「包装对象创建→原型链查找→结果重新包装」三层开销。

具体函数实现的效率瓶颈

  • 类型检测冗余:早期版本未优先使用原生 Array.isArray(),而是通过 Object.prototype.toString.call() 做类型判断,效率比原生 API 低 30% 以上;
  • 遍历策略不优:统一用通用遍历逻辑处理数组 / 对象,未针对数组使用更高效的 for 循环(而非 for...in),对象遍历未优先用 Object.keys() 过滤原型属性;
  • 高阶函数调用栈开销map/filter 等方法的迭代器需通过 optimizeCb 封装闭包,每次迭代都会产生函数调用栈损耗,而原生方法由引擎内联优化,无此开销。

闭包与内存占用

Underscore 基于 IIFE 封装核心逻辑,闭包会长期持有内部变量(如 _ 构造函数、mixin 缓存、工具函数):

  • 即使仅使用 _.isArray() 一个方法,整个闭包内的所有变量也无法被垃圾回收,造成内存冗余;
  • 非模块化环境下,全局挂载的 _ 变量常驻内存,进一步增加无意义的内存占用。

API 设计层面:一致性与易用性缺陷

双模式调用的混淆性

同时支持「函数式调用(_.map())」和「对象链式调用(_().map())」,带来双重问题:

  • 学习成本高:新手需理解两种模式的底层差异(如链式模式依赖 _wrapped 包装数据,函数式模式直接传参);
  • 行为不一致风险:部分方法在两种模式下参数传递有细微差异(如 _.reduce() 链式调用时 this 指向包装对象,函数式调用时需手动传 context)。

参数与行为的不一致性

  • 参数顺序混乱_.reduce(collection, iteratee, [accumulator]) 与原生 Array.prototype.reduce(callback, [initialValue]) 参数顺序相反,用户切换使用时易出错;
  • 边界处理不统一:对 null/undefined/ 空对象的处理逻辑混乱(如 _.map(null) 返回 []_.keys(null) 抛出错误);
  • 可选参数模糊_.defaults()/_.extend() 对默认值、浅拷贝的规则未明确标注,导致相同输入可能产生不同预期结果。

功能覆盖的冗余与缺失

  • 冗余覆盖:部分方法(如 _.each())仅对原生方法做简单封装,无额外价值却增加调用层级;
  • 核心功能缺失:早期版本无原生的深拷贝方法(_.cloneDeep() 为后期补充),需手动嵌套 _.extend() 实现,易用性差。

生态兼容层面:与现代开发体系脱节

模块化适配严重不足

  • 无原生 ES 模块支持:仅通过 IIFE 兼容 CommonJS/AMD,无法直接使用 import { map } from 'underscore' 按需导入;
  • 树摇(Tree-shaking)失效:模块结构设计导致现代打包工具(Webpack/Rollup)无法移除未使用的函数,即使仅用 _.isArray(),也会打包整个库(约 5KB),而原生 Array.isArray() 无体积成本;
  • 对比 Lodash-eslodash-es/map 可按需导入,体积仅几百字节,Underscore 无此能力。

现代语法与工具链适配差

  • 旧语法的陈旧性:依赖构造函数 + 原型链实现包装对象(_$1.prototype[name] = ...),与 ES6 class 语法脱节,现代开发者可读性差;
  • 箭头函数冲突:链式调用依赖 this 指向包装对象,而箭头函数的词法 this 会导致 this._wrapped 报错,增加使用复杂度;
  • 框架集成不契合:在 React/Vue 等现代框架中,其函数式风格与框架响应式系统(如 Vue 的 ref/reactive)适配性差,不如 Lodash/Ramda 灵活。

类型系统支持缺失

  • 无内置 TypeScript 类型:完全依赖第三方 @types/underscore,存在类型覆盖不全(如链式调用返回类型推断错误)、版本不匹配(库更新后类型定义滞后)等问题;
  • 类型安全不足:方法参数 / 返回值无类型约束,运行时易因类型错误导致 bug,而现代库(如 Lodash)原生支持 TS 类型。

扩展性与维护层面:原型链设计的硬伤

扩展机制的局限性

  • 原型污染风险mixin 方法直接挂载函数到 _$1.prototype,若自定义方法名与内置方法冲突(如自定义 map),会覆盖原生逻辑,导致意外行为;
  • 无结构化插件系统:扩展方式仅依赖 mixin,无法像 Vue/React 那样通过插件注册、生命周期管理复杂扩展,生态扩展性差。

代码结构与维护成本

  • 闭包嵌套复杂:核心逻辑通过多层闭包封装,早期版本包含大量 “魔法逻辑”(如 optimizeCb 优化迭代器),代码可读性极低;
  • 测试覆盖不全面:虽有基础测试用例,但跨环境(如旧版 IE)、边界场景(如空值 / 超大数组)的测试覆盖不足,修复 bug 易引入新问题;
  • 兼容负担重:为适配 IE6+ 等老旧环境,保留大量冗余的兼容代码,无法精简核心逻辑。

功能设计层面:能力不足与场景覆盖不全

异步操作支持缺失

  • 无原生支持 Promise/async/await,处理异步数据流(如接口请求→数据处理)时,需手动封装 _.map + Promise.all,代码冗余;
  • 防抖 / 节流函数(debounce/throttle)仅支持同步逻辑,无法处理异步回调的时序问题。

对象操作能力有限

  • 浅拷贝局限_.extend()/_.defaults() 仅支持浅拷贝,深度拷贝需手动实现或依赖第三方扩展,而原生 structuredClone() 或 Lodash _.cloneDeep() 已原生支持;
  • 对象遍历低效:无针对嵌套对象的遍历方法(如 _.deepKeys),处理复杂对象需多层嵌套调用。

函数式特性不完整

  • 柯里化 / 组合能力弱:仅通过 _.partial() 模拟柯里化(无法自动柯里化多参数函数),_.compose() 仅支持同步函数组合,无异步组合能力;
  • 惰性求值不彻底:链式调用虽有惰性特征,但仅在 value() 时执行,无法像 Ramda 那样实现 “按需计算”,处理超大数据集时效率低。

安全性层面:潜在的风险隐患

原型污染风险

早期版本的 _.extend()/_.defaults() 未过滤 __proto__ 属性,若传入包含 __proto__: { evil: true } 的用户输入,会修改 Object.prototype,导致全局原型污染:

// 原型污染示例(旧版本 Underscore)
const obj = {};
_.extend(obj, { __proto__: { test: 123 } });
console.log({}.test); // 123(全局原型被污染)

模板注入隐患

_.template() 方法默认使用 eval 执行模板中的代码,若未过滤用户输入的模板字符串,易引发代码注入攻击:

// 模板注入风险
const userInput = "{{= alert('XSS') }}";
const template = _.template(userInput);
template(); // 执行恶意代码

时代适配层面:原生 ES6+ 的全面替代

核心功能被原生方法覆盖

ES6+ 引入的原生 API 完全覆盖 Underscore 核心能力,且性能更优(引擎级优化):

Underscore 方法 原生替代方案 优势
_.map() Array.prototype.map() 无包装对象开销,引擎内联优化
_.keys() Object.keys() 原生实现,效率更高
_.extend() Object.assign() 原生支持,无需额外依赖
_.debounce() 浏览器原生 requestIdleCallback(或框架内置) 更贴合现代浏览器调度机制

旧语法与现代开发习惯脱节

  • 依赖 arguments 对象处理参数(如 _.partial()),而现代 JS 已支持剩余参数(...args),代码更简洁;
  • 构造函数 + 原型链的实现方式,与现代开发者熟悉的 class 语法相悖,学习和维护成本高。

核心总结

Underscore.js 的所有缺点本质是 “早期设计无法适配现代 JavaScript 生态”

  1. 性能层面:链式调用、闭包、低效实现带来多维度损耗,无法与原生引擎优化的 API 竞争;
  2. 生态层面:模块化、类型系统、现代工具链适配不足,无法满足现代工程化开发需求;
  3. 功能层面:异步、深拷贝、函数式特性的缺失,无法覆盖复杂业务场景;
  4. 安全 / 维护层面:原型污染、代码复杂、测试不足,增加生产环境风险。
❌
❌