阅读视图

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

Everything Claude Code:让我把 AI 编程效率再翻一倍的东西

Hi~大家好呀,我是清汤饺子。

先说个让我差点砸键盘的场景。

我打开一个新的 Claude Code session,准备继续前天没写完的功能。

Claude 热情地跟我打招呼:嗨!很高兴再次见到你,有什么我可以帮你?

我说:继续前天的任务。

Claude:好的!请问你想做什么?

我:就是那个功能模块啊,前天做到一半的那个。

Claude:好的!请问你想做什么功能?

我:……你刚才不是说了"再次见到我吗"?

Claude:哦,那只是客气话,我的记忆撑不过一个 session。

我:……行吧。

这个对话你是不是也似曾相识?

是不是也想问 AI:你礼貌吗?

一、我的痛点:AI 每次都是"新人"

Claude Code 的 memory 功能我深度用过——CLAUDE.md 配了、项目规范配了、技术栈配了。

但它只能记住"静态上下文",记不住"动态进度" :上次做到哪了、上次做了什么决定、上次遇到了什么问题。

更崩溃的是——有时候 Claude 会"选择性失忆"。明明配置了 memory,它偏偏没触发。有一次我让它帮我重构一个模块,它完全忽略了我们的代码规范,输出了一套我完全不认识的风格。

我开始认真想:有没有一套系统,能让 AI 的"记忆"真正 work?

然后我发现了 Everything Claude Code。

二、ECC 是什么

GitHub 110K+ stars,Anthropic Hackathon 冠军作品。

作者是 affaan-m,做了 10 个月每天高强度在真实项目里打磨出来的。定位不是"配置文件合集",而是:一套完整的 AI Agent 性能优化系统

ECC 官方有一张对比表,说清了它的核心价值:

Without ECC With ECC
AI 不了解团队的代码模式 AI 通过 rules 和 skills 学习团队规范
测试靠手动写,覆盖率不稳定 TDD 流程内置,测试先行,覆盖率透明
安全漏洞靠人工 review AgentShield 实时扫描,102 条规则自动拦截
团队没有统一的代码标准 skills 和 agents 全团队共享
每个 session 从零开始 Continuous Learning 跨 session 积累

这张表说清楚了 ECC 解决的问题。但光看表感受不深,我用了两个月,说说具体是什么体验。

三、GitHub App:把 commit 历史变成团队规范

这是 ecc.tools 最让我惊喜的功能。

ECC 提供一个 GitHub App(免费安装),它的工作方式是这样的:

  1. 在你的仓库安装 ECC Tools GitHub App
  2. 在任意 issue 下评论 /ecc-tools analyze
  3. ECC 自动分析你的 commit 历史、代码模式、团队规范
  4. 自动生成一个 Pull Request,把这些历史转成 skills 和 defaults

翻译成人话:你的 git 提交记录里藏着团队多年的工程经验,ECC 自动把这些经验提取出来,变成 AI 可以复用的规范。

这个 PR 不是直接合并的——你审核、修改、确认之后才生效。完全可控。

我试了一下,第一次跑完它生成了大约 30 条 rules,覆盖了我们的 commit message 规范、分支命名规则、还有 API 错误处理的一些惯用模式。

最厉害的是:这个 PR 里的内容是专门针对你这个仓库的,不是通用模板。ECC 读的是你真实的 git 历史,提取的是你团队真实在用的规范。

四、Token 优化:让 AI 跑得更快

Context window 是有限的,AI 跑着跑着就开始"失忆"前面的内容。ECC 有几个实用的省 tokens 方法,都是踩坑踩出来的经验。

  • 模型选择:大多数日常任务用 Sonnet 4.5 就够了,复杂任务(跨 5+ 文件、架构决策、安全关键代码)升 Opus,重复性劳动降级到 Haiku 当 worker。类比一下:能用摩托车拉的不用卡车,卡车油耗高,还不好停。

  • 工具替换:Claude 默认用 grep 或 ripgrep 搜索代码,tokens 消耗大。换 mgrep,平均节省 50%——就像从手动档换成自动挡,不改变目的地,但脚不酸了。

  • 后台进程:不需要 AI 实时处理输出的任务,用 tmux 丢后台,不占用 context。这就像让 AI 同时处理多项任务——实际上它是把不重要的任务先寄存起来。

  • 模块化代码库:文件越小(几百行 vs 几千行),AI 消耗的 tokens 越少,出错率也越低。

五、Memory 持久化:AI 不再是"金鱼"

这是 ECC 最打动我的功能,也是它和"普通配置文件"的本质区别所在。

原生 Claude Code memory 只能存"静态模板"——项目规范、技术栈、代码风格。但它存不住"动态进度" :上次做到哪了、遇到了什么问题、做了什么决策。

ECC 的解法是把三个 Hook 串联起来,形成完整的记忆链条。

第一棒:SessionComplete Hook,session 结束时自动存档

Session 结束时,Claude 自动把当前状态写入 .tmp 文件——完成的任务、遇到的阻塞、关键决策、下次继续需要的信息,全都存下来。

第二棒:SessionStart Hook,新 session 开始时自动恢复

新 session 开始时,Claude 自动读取上次的 .tmp 文件。它会主动问:"检测到上次有未完成的任务,要继续吗?"

第三棒:PreCompact Hook,提前预警该整理了

在你积累了很多上下文的时候,提前提示你"该整理一下了",避免等到 AI 开始"失忆"才后悔。

三个 hook 串联起来,实现的是:跨 session 真正零手动干预的连续记忆。我第一次用这套组合的感觉是——Claude 终于不是"金鱼"了,甚至有点像一个记性比我还好的 senior。

六、Continuous Learning:让 AI 从错误中进化

核心问题:同一个错误,AI 犯一次两次三次,永远记不住。

解法:告诉 Claude "记住它",它把这个模式自动写入 skills,下次遇到类似场景自动调用。

触发方式有两种:

自动:session 结束时运行 /learn,自动提取这次 session 里发现的有效模式。

手动:中途解决了什么非平凡的问题,马上 /learn 即时提取。

我连续三次让 Claude 帮我写 API 接口,它第三次就自己学会了"我们项目里 API 文件放哪里、命名规范是什么、错误处理用什么模式"。

这感觉就像养成了一个会自动学习的好习惯——不用催,它自己就记住了

七、验证与安全

AI 执行命令是有风险的。Prompt injection、未经授权的文件修改、"AI 误删整个 node_modules"这种事,社区里见过太多了。

解法:ECC 提供了 AgentShield——一个独立的安全扫描工具,102 条规则、1282 个测试用例、98% 覆盖率,采用 Red Team / Blue Team / Auditor 三层 Pipeline。

这阵容,比很多公司的安全团队都专业。

效果:扫描输出分级展示,critical 问题直接标红。

运行效果是这样的:

$ npx ecc-agentshield scan ./CLAUDE.md

 CRITICAL  Unrestricted file system access via Bash tool
 WARNING    No rate limiting on external API calls
 WARNING    Missing secret detection guardrail
 PASS       Tool permissions properly scoped
 PASS       Destructive action confirmation required
 PASS       No prompt injection vectors detected

Security Score: 72/1001 critical, 2 warnings, 3 passed
Full report saved to ./agentshield-report.json

我之前差点让 Claude 把整个 node_modules 删了——它问都没问我直接动手。幸好当时没执行,不然一天白干。有 AgentShield,那种"先斩后奏"的命令直接被拦截,连求情的机会都不给

八、技术原理

看完 GitHub 仓库,我发现 ECC 比"配置文件合集"要系统得多。它的核心不是某一个功能,而是一套层次化的 Agent 优化架构

1. 五类组件:底层基础设施

ECC 的仓库由五类组件构成,每一类解决不同层次的问题:

  • Agents(智能体):30+ 专业子代理,负责特定领域的任务执行。比如 code-reviewer 专门做 code review,build-error-resolver 专门修编译错误,chief-of-staff 专门做任务规划和进度管理。每一个 agent 只做一件事,做得很专注。

  • Skills(技能):可复用的任务模式库,分两类——

    • 语言生态:TypeScript、Python、Go、Rust、Java、PHP、Perl、Kotlin、C++ 等,每个语言有对应的 patterns 和 conventions
    • 垂直领域:django、laravel、springboot、pytorch 等框架完整技能栈,覆盖从开发到部署的全流程
  • Commands(命令):斜杠命令是快速触发技能的入口,比如 /plan 触发任务规划、/tdd 启动 TDD 流程、/learn 即时提取好模式。命令和技能联动,构成了 ECC 的交互层。

  • Rules(规则):始终遵循的约束,放置在 .claude/rules/ 目录下。AI 每个 session 都会读取,是最低层次的"铁律"。Rules 不同于 Skills——Skills 是告诉 AI"怎么做",Rules 是告诉 AI"绝对不能怎么做"。

  • Hooks(钩子):挂在 Session 生命周期上的自动化脚本,这是 ECC 最具特色的设计。每个 Hook 有明确的触发时机:

    • PreToolUse:工具执行前触发,比如拦截危险命令
    • PostToolUse:工具执行后触发,比如自动格式化、自动运行 lint
    • SessionComplete:session 结束时触发,自动存档
    • SessionStart:session 开始时触发,自动恢复上下文
    • PreCompact:上下文即将溢出前触发,提前预警

2. SKILL.md:技能自动发现的秘密

ECC 的 Skills 不是靠手动调用的,而是靠 description 字段自动触发

每个 Skill 文件(Markdown 格式)顶部有一段 YAML metadata:

---
name: tdd-workflow
description: Use when the user wants to do test-driven development - sets up TDD flow with RED first
---

当 AI 判断当前任务符合 description 的条件时,自动激活对应 Skill,整个过程不需要你做任何事情。

这意味着 ECC 的 Skills 系统本质上是上下文感知的——AI 根据当前任务状态自动匹配最佳实践,而不是等你一步步指示。

3. SQLite 状态存储:持久化的秘密

ECC 用 SQLite 作为状态存储数据库,记录:

  • 已安装的 Skills 列表和版本
  • Session 历史摘要
  • Continuous Learning 的演化记录
  • 各平台(Claude Code / Codex / Cursor / OpenCode)的配置状态

这让 ECC 具备"状态记忆"——不是每次从零开始,而是知道"上次装了什么、上次做了什么、哪里出了问题"。支持增量更新,不用每次全量重装。

4. Continuous Learning 的技术实现

ECC 的 Continuous Learning 不是靠"更长的 context window",而是靠自动提取 + 写入 Skills 目录

  1. AI 在 session 中发现一个有效的模式(比如"这个项目的 API 错误处理用 Result type")
  2. 自动把这个模式写入 ~/.everything-claude-code/skills/
  3. 下次遇到类似场景,Skills 触发,模式复用

本质是把隐性知识显性化,把单次经验变成可复用资产。这解决了 AI "同类错误重复犯" 的根本问题。

5. AgentShield 的技术实现

AgentShield 不是简单的"危险命令黑名单",而是一套多层次安全扫描机制

  • Hook 层:在命令执行前拦截,扫描 rm -rfcurl | bashgit push --force 等危险模式
  • CVE 数据库:集成了常见漏洞数据库,扫描依赖包是否有已知漏洞
  • Sandbox 隔离:危险操作在隔离环境执行,不直接影响主项目

6. 安装架构:Manifest 驱动

ECC 的安装不是"一键全装",而是Manifest 驱动的选择性安装

./install.sh --profile full        # 全量安装
./install.sh typescript            # 只装 TypeScript 相关
./install.sh --target cursor python  # 只给 Cursor 装 Python 生态

install-plan.jsinstall-apply.js 负责解析 Manifest,按需安装。SQLite 状态存储记录"装了什么",支持增量更新。ECC_HOOK_PROFILE 环境变量还可以控制 Hook 的严格程度(minimal / standard / strict)。

GitHub 仓库:github.com/affaan-m/ev…

九、和 Superpowers + OpenSpec 的关系

这三个工具解决的问题正好互补:

工具 解决的问题
OpenSpec 需求对齐——先签字再动手
Superpowers 工程纪律——TDD、task 分解、子 Agent 编排
ECC 性能和记忆——Token 优化、Memory 持久化、安全扫描

OpenSpec 在最上游——它管的是做什么

Superpowers 在中游——它管的是怎么做

ECC 在底层——它管的是怎么跑得更好

三个一起用,才是完整的 AI 编程工作流。


写在最后

ECC 解决了一个根本问题:AI 不是"真的智能",它是"真的没有记忆"

110K+ stars 说明这套方法论经过了大量开发者的验证。我用了两个月,最大的感受是——AI 编程终于有点像"和一个靠谱的同事合作",而不是"和一个热情的实习生搏斗"——热情是热情,但每次都要我来收拾残局。

当然它不是银弹。配置成本不低,学习曲线陡峭,踩坑也需要时间。如果你每天用 AI 写代码,这点投入值得;如果只是偶尔用用,原生体验可能就够了——省下的配置时间够你手动写好几屏代码了

你被 AI coding 的"失忆症"困扰过吗?有没有什么土办法?

欢迎评论区聊聊,看看大家都有什么奇葩经历,互相种草避坑。

如果觉得有帮助,点个赞收藏一下,我会更有动力更新下一期。

也欢迎关注我的公众号「清汤饺子」,获取更多技术干货!

排列算法完全指南 - 从全排列到N皇后,一套模板搞定所有排列问题

上篇文章我们聊了回溯算法中的组合/子集问题,本文将聚焦于LeetCode上面的:46(全排列)、47(全排列II)、22(括号生成)、51(N皇后)、37(解数独),以及剑指offer - 38(去重排列),这几道非常经典的题目,带领大家彻底拿下排列算法。

排列 VS 组合

基本概念

很多人容易把 排列组合 混为一谈,但实际上它们有本质上的区别。它们的核心用一句话概括:

  • 组合(Combination):选出来就行,顺序不重要 [1,2] 和 [2,1] 是同一个。
  • 排列(Permutation):顺序很重要,[1,2] 和 [2,1] 是两个不同的结果。

通用模板

组合/回溯的通用模板

在本文开始之前,我们先回忆一下组合/回溯的通用模板:

function backtrack(路径, 选择列表) {
    if (满足结束条件) {
        result.push([...路径]); // 存放结果
        return;
    }

    for (选择 in 选择列表) {
        // 1. 做选择(前序遍历)
        路径.push(选择);
        
        // 2. 进入下一层决策树(递归)
        backtrack(新的路径, 新的选择列表);
        
        // 3. 撤销选择(后序遍历)
        路径.pop();
    }
}

排列的通用模板

const used = new Array(nums.length).fill(false);  // 关键1:used数组
const backtrack = (path) => {
    if (满足结束条件) {
        result.push([...path]);
        return;
    }
    
    // 关键2:每次都从0开始遍历
    for (let i = 0; i < nums.length; i++) {
        // 关键3:跳过已使用的元素
        if (used[i]) continue;
        
        // 做选择
        path.push(nums[i]);
        used[i] = true;
        
        // 递归
        backtrack(path);
        
        // 撤销选择
        path.pop();
        used[i] = false;
    }
};

排列 vs 组合的模板对比

对比维度 组合模板 排列模板
参数 backtrack(start, path) backtrack(path)
遍历起点 for (let i = start; i < n; i++) for (let i = 0; i < n; i++)
去重方式 用 start 控制不回头 用 used 数组标记
空间结构 不需要额外数组 需要 used 数组
结果数量 C(n, k) P(n, k) = n!

为什么排列要每次都从0开始?

  • 因为 [1,2] 和 [2,1] 是两个不同的结果
  • 第一个位置可以选任何元素,第二个位置也可以选任何元素(除了已选的)

为什么需要 used 数组?

防止同一个元素被重复选取(排列不允许重复使用同一个元素)

下面的所有题都是在这个模版的基础上增加剪枝条件!

排列问题的入门(LeetCode 46)

题目

给定一个没有重复数字的序列,返回其所有可能的全排列。

示例:nums = [1,2,3] 输出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]

思路解析

排列问题的核心: 只要顺序不同,那就是不同的结果。比如第一个位置可以选1、2、3中的任意一个;选完第一个后,第二个位置只能在剩下的数中选。

图解过程

flowchart TD
    Start(("[]"))
    
    Start -->|"选1"| A1["[1]"]
    Start -->|"选2"| A2["[2]"]
    Start -->|"选3"| A3["[3]"]
    
    A1 -->|"选2"| B1["[1,2]"]
    A1 -->|"选3"| B2["[1,3]"]
    
    A2 -->|"选1"| C1["[2,1]"]
    A2 -->|"选3"| C2["[2,3]"]
    
    A3 -->|"选1"| D1["[3,1]"]
    A3 -->|"选2"| D2["[3,2]"]
    
    B1 -->|"选3"| E1["[1,2,3]"]
    B2 -->|"选2"| E2["[1,3,2]"]
    C1 -->|"选3"| E3["[2,1,3]"]
    C2 -->|"选1"| E4["[2,3,1]"]
    D1 -->|"选2"| E5["[3,1,2]"]
    D2 -->|"选1"| E6["[3,2,1]"]

代码实现

var permute = function(nums) {
    const result = [];
    const used = new Array(nums.length).fill(false);
    
    const backtrack = (path) => {
        // 结束条件:找到一个完整排列
        if (path.length === nums.length) {
            result.push([...path]);
            return;
        }
        
        // 每次都从0开始,尝试所有未使用的元素
        for (let i = 0; i < nums.length; i++) {
            if (used[i]) continue;  // 已使用的跳过
            
            // 做选择
            path.push(nums[i]);
            used[i] = true;
            
            // 递归
            backtrack(path);
            
            // 撤销选择
            path.pop();
            used[i] = false;
        }
    };
    
    backtrack([]);
    return result;
};

全排列 II(LeetCode 47)—— 有重复元素的全排列

题目

给定一个可能包含重复数字的序列,返回所有不重复的全排列。

示例:nums = [1,1,2] 输出: [[1,1,2], [1,2,1], [2,1,1]]

注意:不能有重复的排列。

思路解析

这题和上诉 46 题解析思路基本一致,唯一一点需要注意:当数组有重复元素时,需要去重,例如:两个 1 互换位置,但它们值相同,需要去重:

  • 排序:让相同的元素挨在一起。
  • 剪枝:在排列模板的基础上,增加同层去重判断。

代码实现

var permuteUnique = function(nums) {
    const result = [];
    const used = new Array(nums.length).fill(false);
    nums.sort((a, b) => a - b); // 排序是去重的前提
    
    const backtrack = (path) => {
        if (path.length === nums.length) {
            result.push([...path]);
            return;
        }
        
        for (let i = 0; i < nums.length; i++) {
            // 基础剪枝:已使用过的跳过
            if (used[i]) continue;
            
            // 去重剪枝:同一层相同元素跳过
            if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue;
            
            path.push(nums[i]);
            used[i] = true;
            backtrack(path);
            path.pop();
            used[i] = false;
        }
    };
    
    backtrack([]);
    return result;
};

字符串的排列(剑指Offer 38)

题目

输入一个字符串,打印出该字符串中字符的所有排列。可以以任意顺序返回这个字符串数组,但不能有重复元素。

示例:s = "abc" 输出:["abc","acb","bac","bca","cab","cba"]

思路解析

这题和上述 LeetCode 47 题完全一样,只是输入从数组变成了字符串。

代码实现

var permutation = function(s) {
    const result = [];
    const arr = s.split('');
    const used = new Array(arr.length).fill(false);
    arr.sort(); // 排序去重
    
    const backtrack = (path) => {
        if (path.length === arr.length) {
            result.push(path.join(''));
            return;
        }
        
        for (let i = 0; i < arr.length; i++) {
            if (used[i]) continue;
            // 去重:同一层相同字符跳过
            if (i > 0 && arr[i] === arr[i - 1] && !used[i - 1]) continue;
            
            path.push(arr[i]);
            used[i] = true;
            backtrack(path);
            path.pop();
            used[i] = false;
        }
    };
    
    backtrack([]);
    return result;
};

括号生成(LeetCode 22)—— 特殊的排列问题

题目

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且有效的括号组合。

示例:n = 3 输出: [ "((()))", "(()())", "(())()", "()(())", "()()()" ]

思路解析

这题虽然看起来并不是传统的排列问题,但它本质上也是一个排列问题:

  • 选择列表:( 和 ) 两个字符。
  • 约束条件:左括号数量不超过 n,右括号数量不超过左括号数量。
  • 不需要 used 数组,因为括号可以重复使用。

排列模板的变体

const backtrack = (path, left, right) => {
    // 结束条件
    if (left === n && right === n) { // TODO }
    
    // 选择1:选左括号
    if (left < n) {
        backtrack(path + '(', left + 1, right);
    }
    
    // 选择2:选右括号
    if (right < left) {
        backtrack(path + ')', left, right + 1);
    }
};

代码实现

var generateParenthesis = function(n) {
    const result = [];
    
    const backtrack = (path, left, right) => {
        // 结束条件:左右括号都用完了
        if (left === n && right === n) {
            result.push(path);
            return;
        }
        
        // 剪枝1:左括号数量不能超过 n
        if (left < n) {
            backtrack(path + '(', left + 1, right);
        }
        
        // 剪枝2:右括号数量不能超过左括号数量
        if (right < left) {
            backtrack(path + ')', left, right + 1);
        }
    };
    
    backtrack('', 0, 0);
    return result;
};

N皇后(LeetCode 51)—— 二维棋盘上的排列问题

题目

n 皇后问题研究的是如何将 n 个皇后放置在 n × n 的棋盘上,并且使皇后彼此之间不能相互攻击(即任意两个皇后都不能处于同一行、同一列或同一斜线上)。给你一个整数 n,返回所有不同的 n 皇后问题的解决方案。

示例:n = 4 输出: [ [".Q..", // 解法 1 "...Q", "Q...", "..Q."],

["..Q.", // 解法 2 "Q...", "...Q", ".Q.."] ]

思路解析

N皇后问题可以看作一个特殊的排列问题:

  • 每行只能放一个皇后,所以我们可以用 row 来表示当前处理到第几行
  • 每列也只能放一个皇后,所以我们需要记录哪些列已经被占用
  • 还需要考虑两个斜线方向

排列模板的变体

const backtrack = (row) => {
    if (row === n) {
        // 找到一个有效解
    }
    
    for (let col = 0; col < n; col++) {
        if (isValid(row, col)) {
            board[row] = col;  // 相当于做选择
            backtrack(row + 1); // 递归下一行
            board[row] = -1;    // 撤销选择
        }
    }
};

代码实现

var solveNQueens = function(n) {
    const result = [];
    // board 是一维数组,索引表示行,值表示该行皇后所在的列
    const board = new Array(n).fill(-1);
    
    // 检查在 (row, col) 放置皇后是否合法
    const isValid = (row, col) => {
        for (let i = 0; i < row; i++) {
            // 检查列冲突
            if (board[i] === col) return false;
            // 检查对角线冲突:行差 === 列差
            if (Math.abs(row - i) === Math.abs(col - board[i])) return false;
        }
        return true;
    };
    
    // 将 board 转换成题目要求的字符串数组格式
    const formatBoard = () => {
        return board.map(col => {
            const row = new Array(n).fill('.');
            row[col] = 'Q';
            return row.join('');
        });
    };
    
    const backtrack = (row) => {
        // 结束条件:所有行都放置了皇后
        if (row === n) {
            result.push(formatBoard());
            return;
        }
        
        // 在当前行尝试每一列
        for (let col = 0; col < n; col++) {
            if (!isValid(row, col)) continue; // 剪枝
            
            // 做选择:在 (row, col) 放置皇后
            board[row] = col;
            // 递归到下一行
            backtrack(row + 1);
            // 撤销选择
            board[row] = -1;
        }
    };
    
    backtrack(0);
    return result;
};

N皇后 vs 全排列:

维度 全排列 N皇后
选择列表 所有未使用的数字 所有列
递归参数 path row
约束条件 不能重复选 列、对角线不冲突
结束条件 path.length === n row === n

解数独(LeetCode 37)—— 排列问题的终极形态

题目

编写一个程序,通过填充空格来解决数独问题。数独的解法需遵循如下规则:

  1. 数字 1-9 在每一行只能出现一次
  2. 数字 1-9 在每一列只能出现一次
  3. 数字 1-9 在每一个 3x3 宫格内只能出现一次

思路解析

解数独是排列问题的终极形态,它结合了:

  • 行的排列约束:每行数字不能重复。
  • 列的排列约束:每列数字不能重复。
  • 宫的排列约束:每个 3x3 宫格内数字不能重复。

排列模板的变体

const backtrack = () => {
    for (let i = 0; i < 9; i++) {
        for (let j = 0; j < 9; j++) {
            if (board[i][j] === '.') {
                for (let num = 1; num <= 9; num++) {
                    if (isValid(i, j, num)) {
                        board[i][j] = num;
                        if (backtrack()) return true;
                        board[i][j] = '.';
                    }
                }
                return false;
            }
        }
    }
    return true;
};

代码实现

var solveSudoku = function(board) {
    const isValid = (row, col, num) => {
        const numStr = num.toString();
        
        // 检查行
        for (let i = 0; i < 9; i++) {
            if (board[row][i] === numStr) return false;
        }
        
        // 检查列
        for (let i = 0; i < 9; i++) {
            if (board[i][col] === numStr) return false;
        }
        
        // 检查 3x3 宫格
        const boxRow = Math.floor(row / 3) * 3;
        const boxCol = Math.floor(col / 3) * 3;
        for (let i = 0; i < 3; i++) {
            for (let j = 0; j < 3; j++) {
                if (board[boxRow + i][boxCol + j] === numStr) return false;
            }
        }
        
        return true;
    };
    
    const backtrack = () => {
        for (let i = 0; i < 9; i++) {
            for (let j = 0; j < 9; j++) {
                // 找到空白格
                if (board[i][j] === '.') {
                    // 尝试填入 1-9
                    for (let num = 1; num <= 9; num++) {
                        if (isValid(i, j, num)) {
                            // 做选择
                            board[i][j] = num.toString();
                            // 递归
                            if (backtrack()) return true;
                            // 撤销选择
                            board[i][j] = '.';
                        }
                    }
                    return false; // 1-9 都不行,说明之前的选择有问题
                }
            }
        }
        return true; // 所有格子都填满了
    };
    
    backtrack();
    return board;
};

排列问题的核心套路

做完这几道题,我们会发现它们其实都是排列思想的变形。

各题模板对比

题目 递归参数 选择列表 剪枝条件 特殊点
46.全排列 path 所有未使用元素 used[i] 无重复,基础模板
47.全排列II path 所有未使用元素 used[i] + 同层去重 需要排序 + 去重
剑指38.字符串排列 path 所有未使用字符 used[i] + 同层去重 和47一样
22.括号生成 path, left, right 左括号/右括号 left < n 和 right < left 不固定长度,约束特殊
51.N皇后 row 所有列 列冲突 + 对角线冲突 每行一个皇后,用一维数组记录
37.解数独 无(全局遍历) 1-9 行+列+宫格约束 最复杂的排列问题

解题要点

  • 排列用 used:排列问题都要用 used 数组记录哪些元素已用。
  • 去重先排序:有重复元素时,排序 + 同层去重。
  • 约束是剪枝:不合法的情况提前 continue 或 return。
  • 递归深度是条件:路径长度等于元素个数时,收获结果。

结语

希望这篇文章能帮大家彻底搞懂排列算法。下次遇到"所有排列"、"全排列"、"N皇后"这类问题,别忘了拿出这个万能模板试一试!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

微信 ClawBot 接入本地 AI Agent 的实现原理

1. 前言

我们知道微信最近推出了微信 ClawBot,用于在微信中与 OpenClaw 收发消息。掘金签约作者群里的一位大佬说:

01.jpg

受到启发,我就去研究了一下微信半公开的官方文档后发现,我们确实也可以通过用微信 ClawBot 接入任何 AI Agent

至于为什么说官方文档是半公开呢,因为官方暂时还没有公开的文档地址,但又可以通过某些渠道看到(怎么可以看到,本文最后揭晓)。

本文就将带你一步步实现如何通过微信 ClawBot 接入自己开发的 AI Agent。其实我们只需要做三件事:

  1. 扫码登录,拿到微信 ClawBot 的身份凭证;
  2. 长轮询等待消息,一有消息立刻获取;
  3. 把消息交给本地 Agent 处理,再把回复发回微信。

第一步,扫码登录。

2. 扫码登录

根据微信 ClawBot 文档的要求,我们先要获取一个二维码,等用户用微信扫描并确认后,服务端就会返回一个 bot_token 的通信凭证,后续所有请求都必须带着这个 token。这个跟我们平时的开发是一样,我们登录之后才能进行操作。

2.1 拉取二维码

首先微信 ClawBot 的接口地址是:

BASE_URL = "https://ilinkai.weixin.qq.com"

其次,登录的第一步是向服务端请求二维码。我们根据微信 ClawBot 的文档可以知道请求的接口是:

ilink/bot/get_bot_qrcode?bot_type=3

请求的 HTTP 方式是 GET。值得注意的是参数 bot_type 在微信 ClawBot 的文档中只出现了在获取二维码的时候,值是 3,而其他枚举值的情况,文档中并没有说明。

因为要用到 HTTP 的 GET 请求,所以我们需要封装一个 GET 请求的方法:

import json
import urllib.request
import urllib.error

# 省略...

def _get(url: str, headers: dict = {}, timeout: int = 35) -> dict:
    """发送 GET 请求"""
    req = urllib.request.Request(url, headers=headers, method="GET")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"HTTP {e.code} GET {url}: {e.read().decode(errors='replace')}") from e

接着我们封装拉取二维码的请求函数:

def fetchQRCode():
    base = BASE_URL.rstrip("/") + "/"
    url = base + "ilink/bot/get_bot_qrcode?bot_type=3"
    resp = _get(url)                      # GET 请求
    qrcode_raw = resp.get("qrcode")       # 服务端用于轮询的标识
    qrcode_url = resp.get("qrcode_img_content")   # 可扫描的二维码链接
    return qrcode_raw, qrcode_url

微信 ClawBot 服务端返回的 qrcode_img_content 是一个可以直接扫码的链接。我们在终端里可以通过安装 qrcode 库把它打印成 ASCII 二维码或者直接打印链接让用户打开链接,通过手机微信进行扫码。字段 qrcode 则是服务端的二维码标识,用于后续轮询二维码的状态,是否已经被扫码等。

我们测试一下上述代码:

print(fetchQRCode())

打印结果如下:

('50189c0db1817eb74a2bfc11e4ccdb35', 'https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=50189c0db1817eb74a2bfc11e4ccdb35&bot_type=3')

我们打开上述链接在浏览器打开是一个微信二维码。

image.png

2.2 轮询扫码状态

二维码生成后,我们每隔一秒向微信 ClawBot 提供的二维码状态查询接口进行请求,直到用户完成确认。接着我们封装一个轮询扫码状态的请求接口函数:

def pollQRStatus(qrcode_raw):
    base = BASE_URL.rstrip("/") + "/"
    poll_url = base + f"ilink/bot/get_qrcode_status?qrcode={urllib.parse.quote(qrcode_raw)}"
    deadline = time.time() + 480    # 最多等 8 分钟
    # 这个是微信 ClawBot 规定的,没得解析
    headers = { "iLink-App-ClientVersion": "1" }

    while time.time() < deadline:
        try:
            s = _get(poll_url, headers)
        except Exception as e:
            print(f"  [轮询错误] {e}", flush=True)
            time.sleep(2)
            continue

        status = s.get("status", "wait")

        if status == "wait":
            # 还没扫,继续等,打一个点表示进度
            sys.stdout.write(".")
            sys.stdout.flush()

        elif status == "scaned":
            # 已经扫了,等用户在微信里点确认
            print("\n👀 已扫码,请在微信中点击确认...", flush=True)

        elif status == "confirmed":
            # ✅ 用户点了确认,登录成功!
            token      = s.get("bot_token", "")
            account_id = s.get("ilink_bot_id", "")
            # 账号 ID 规范化:把 @ 和 . 换成 -,例如 abc@im.wechat → abc-im-wechat
            account_id = account_id.replace("@", "-").replace(".", "-")
            real_base  = s.get("baseurl") or BASE_URL
            print(f"\n✅ 登录成功!account_id={account_id}", flush=True)
            return {"token": token, "account_id": account_id, "base_url": real_base}

        elif status == "expired":
            raise RuntimeError("二维码已过期,请重新运行程序。")
        # 每次轮询后(除非已返回成功)都会休眠 1 秒,避免高频请求对服务端造成压力
        time.sleep(1)
    raise RuntimeError("登录超时(8分钟),请重试。")

上述函数主要是实现了根据状态码处理不同情况。服务端返回的 JSON 中包含 status 字段,表示当前二维码的状态。我们根据其值进行分支处理:

status == "wait"

  • 表示二维码尚未被扫描。
  • 在终端打印一个点 .(不换行),表示程序仍在等待,给用户视觉反馈。

status == "scaned"

  • 表示用户已经扫描了二维码,但尚未在微信中点击“确认”。
  • 打印提示信息 👀 已扫码,请在微信中点击确认...,告知用户当前进度。

status == "confirmed"

  • 成功状态:用户已确认,登录成功。
  • 从响应中提取 bot_tokenilink_bot_id(机器人唯一标识)、baseurl(可选的后端地址)。
  • 并且对 account_id 进行规范化处理,将 @ 和 . 替换为 -,例如 abc@im.wechat 变为 abc-im-wechat
  • 打印成功信息,并返回一个包含 tokenaccount_idbase_url 的字典,供上层保存和后续请求使用。

status == "expired"

  • 二维码已过期(我们这里设置 8 分钟未扫描或确认即为过期)。
  • 抛出 RuntimeError,提示用户重新运行程序获取新二维码。

最后,每次轮询后(除非已返回成功)都会休眠 1 秒,避免高频请求对服务端造成压力。

2.3 实现登录并保存 token 到本地

我们在上面实现了拉取二维码的函数 fetchQRCode 和轮询等待扫码确认的函数 pollQRStatus,我们就可以实现一个登录函数 login 将整个流程串联起来了。实现如下:

def login() -> dict:
    """
    扫码登录,返回 {"token": "...", "account_id": "...", "base_url": "..."}
    """
    # ── 第 1 步:拉取二维码 ──
    [qrcode_raw, qrcode_url] = fetchQRCode() 

    if not qrcode_raw:
        raise RuntimeError(f"获取二维码失败")

    # ── 第 2 步:在终端打印二维码 ──
    print("\n请用微信扫描下方二维码:\n", flush=True)
    try:
        import qrcode                          # pip install qrcode[pil]
        qr = qrcode.QRCode(version=1, border=1)
        qr.add_data(qrcode_url)
        qr.make(fit=True)
        qr.print_ascii(invert=True)            # 用 ASCII 字符在终端渲染,尺寸最小
    except ImportError:
        # 没有安装 qrcode 库时,直接打印链接,用浏览器打开也能扫
        print(f"  {qrcode_url}\n", flush=True)

    # ── 第 3 步:轮询等待扫码 ──
    print("等待扫码...", flush=True)
    return pollQRStatus(qrcode_raw) 

上述登录函数最后返回的数据结构如下:

{
'token': 'd5b3973bb743@im.bot:060000215ac9e1ce7116aeb48b3d998c5b2e4e', 
'account_id': 'd5b3973bb743-im-bot',
'base_url': 'https://ilinkai.weixin.qq.com
'}

为了下次启动不用重新扫码,我们把 token 和 account_id 保存到文件 .weixin_token.json 中:

# token 的本地文件路径
TOKEN_FILE  = Path(__file__).parent / ".weixin_token.json"   # 保存登录后的 token

# 保存 token 和账号
def save_token(data: dict) -> None:
    """把 token 信息保存到本地文件,下次启动不用重新扫码。"""
    TOKEN_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), "utf-8")
    TOKEN_FILE.chmod(0o600)    # 仅当前用户可读,保护 token 安全

这样,下次运行程序时,如果文件存在就直接加载,跳过登录流程。所以我们还需要有一个从本地文件读取上次保存的 token 和账号的函数。实现如下:

def load_token() -> Optional[dict]:
    """从本地文件读取上次保存的 token(如果有的话)。"""
    if TOKEN_FILE.exists():
        try:
            return json.loads(TOKEN_FILE.read_text("utf-8"))
        except Exception:
            pass
    return None

所以整个主流程就是:

def main():
    """
    程序入口,只做两件事:
      1. 登录(拿 token)
      2. 调 run_monitor() 开始监听
    """

    # 优先读取上次保存的 token,有就跳过扫码
    creds = load_token()

    if not creds:
        print("=== 微信扫码登录 ===", flush=True)
        creds = login()
        save_token(creds)
        print(f"[✓] token 已保存到 {TOKEN_FILE}", flush=True)

    token      = creds["token"]
    account_id = creds["account_id"]
    base_url   = creds.get("base_url", BASE_URL)
    print(f"\n[启动] account={account_id}  base={base_url}", flush=True)

    # 登录完成,进入消息监听循环
    # todo run_monitor()

登录完成之后,接下来就是进入消息监听循环了。

3. 长轮询监听循环

拿到 token 后我们就需要发起一个长轮询的 HTTP 接口请求,微信 ClawBot 服务端会“憋着”不返回,直到有新消息或超时(约 35 秒)才返回。这个接口就是:

ilink/bot/getupdates

它采用 POST 方式请求,所以我们需要封装一个 POST 请求的方法,并且微信 ClawBot 的所有 POST 的请求都需要一个通用请求头,通用请求头的要求如下:

Header 说明
Content-Type application/json
AuthorizationType 固定值 ilink_bot_token
Authorization Bearer <token>(登录后获取)
X-WECHAT-UIN 随机 uint32 的 base64 编码

所以我们先封装一个每次请求都需要带的 HTTP 请求头的函数:

def _headers(token: Optional[str] = None) -> dict:
    """
    构造每次请求都需要带的 HTTP 请求头。
    - AuthorizationType: 固定值,告诉服务端这是 bot token 认证
    - Authorization: 登录拿到的 token,未登录时不带
    - X-WECHAT-UIN: 随机数的 base64,模拟微信客户端标识
    """
    h = {
        "Content-Type": "application/json",
        "AuthorizationType": "ilink_bot_token",
        # 随机 uint32 → 十进制字符串 → base64,与原版协议一致
        "X-WECHAT-UIN": base64.b64encode(
            str(struct.unpack(">I", os.urandom(4))[0]).encode()
        ).decode(),
    }
    if token:
        h["Authorization"] = f"Bearer {token}"
    return h

接着我们封装一个通用 POST 请求函数:

def _url(path: str) -> str:
    """拼接完整 URL,确保 BASE_URL 末尾有斜杠。"""
    base = BASE_URL.rstrip("/") + "/"
    return base + path
    
def _post(path: str, body: dict, token: Optional[str] = None, timeout: int = 15) -> dict:
    """
    发送 POST JSON 请求,返回解析后的响应字典。
    所有与微信后端的通信都走这个函数。
    """
    data = json.dumps(body).encode("utf-8")
    req  = urllib.request.Request(
        _url(path),
        data    = data,
        headers = {**_headers(token), "Content-Length": str(len(data))},
        method  = "POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"HTTP {e.code} /{path}: {e.read().decode(errors='replace')}") from e

所有与微信 ClawBot 后端的通信都走这个 _POST 函数。

接着我们封装长轮询接收消息函数:

def getUpdates(token: str, buf: str = "", timeout: int = 35) -> dict:
    """
    长轮询接口:向服务端发请求,服务端"憋着"不回,直到有新消息或超时才返回。

    参数:
      buf     - 上次返回的游标,传给服务端表示"从这里继续",首次传空字符串
      timeout - 等待秒数,服务端通常在 35 秒内有消息就返回,无消息就返回空

    返回值里的重要字段:
      msgs            - 新消息列表(可能为空)
      get_updates_buf - 新游标,下次请求要带上它
    """
    try:
        return _post(
            "ilink/bot/getupdates",
            body    = {"get_updates_buf": buf, "base_info": {"channel_version": "mini-bridge-1.0"}},
            token   = token,
            timeout = timeout + 5,    # 客户端超时比服务端多 5 秒,避免误判
        )
    except (TimeoutError, OSError) as e:
        if "timed out" in str(e).lower():
            # 超时是正常现象,不是错误,直接返回空结果,继续下一轮
            return {"ret": 0, "msgs": [], "get_updates_buf": buf}
        raise

上述 getUpdates 函数返回两个重要的字段 msgs(消息列表)和 get_updates_buf(游标),消息列表我们很好理解,但游标我们需要了解一下。

什么是游标?

游标就是一个字符串,每次 getUpdates 返回时都会同时返回一个新的游标,表示“下一次请求从这里开始”。我们可以把游标持久化到文件 .weixin_buf.txt 中,这样即使程序重启,也能接着之前的位置继续收消息,不会漏掉中间的消息。

游标存储的本地文件路径设置如下:

# 游标存储的本地文件路径
BUF_FILE    = Path(__file__).parent / ".weixin_buf.txt"      # 保存消息游标(断点续传)

根据微信 ClawBot 的官方文档我们可以知道 getUpdates 接口返回的字段如下:

字段 类型 说明
ret number 返回码,0 = 成功
errcode number? 错误码(如 -14 = 会话超时)
errmsg string? 错误描述
msgs WeixinMessage[] 消息列表(结构见下方)
get_updates_buf string 新的同步游标,下次请求时回传
longpolling_timeout_ms number? 服务端建议的下次长轮询超时(ms)

根据上述的资料我们就可以实现对 getUpdates 接口的长轮询监听循环了,实现如下:

def run_monitor(token: str) -> None:
    """
    长轮询监听循环
    """

    # ── 加载上次的消息游标(断点续传)──
    # 游标让服务端知道"从哪条消息开始",程序重启后不会漏掉中途的消息
    buf = BUF_FILE.read_text("utf-8").strip() if BUF_FILE.exists() else ""
    if buf:
        print("[✓] 从上次游标恢复", flush=True)
    print("[监听中] 等待微信消息...\n", flush=True)

    fail_count = 0    # 连续失败计数,失败太多就暂停一会儿

    while True:

        # ── 第一件事:等消息 ──
        try:
            resp = getUpdates(token, buf=buf)
        except Exception as e:
            # 网络抖动或服务端异常,失败超过 3 次才真正暂停
            fail_count += 1
            print(f"[错误] getUpdates 失败 ({fail_count}/3): {e}", flush=True)
            if fail_count >= 3:
                print("[退避] 连续失败 3 次,等待 30 秒后重试...", flush=True)
                fail_count = 0
                time.sleep(30)
            else:
                time.sleep(2)
            continue

        fail_count = 0

        # 服务端返回了业务错误码,打印后稍等再重试
        if resp.get("ret", 0) != 0 or resp.get("errcode", 0) != 0:
            print(f"[服务端错误] {resp}", flush=True)
            time.sleep(2)
            continue

        # 更新并持久化游标(下次重启可以从这里接着取消息)
        new_buf = resp.get("get_updates_buf", "")
        if new_buf:
            buf = new_buf
            BUF_FILE.write_text(buf, "utf-8")

上述实现 run_monitor 函数目前能长轮询监听微信 ClawBot 服务器消息的接收。主要功能如下:

  • 首先加载之前保存的消息游标(buf)实现断点续传。

  • 进入监听循环:

    1. 调用 getUpdates(token, buf) 获取消息(可能阻塞直到有消息或超时)。
    2. 如果调用失败(异常),则增加失败计数,连续失败 3 次后休眠 30 秒再重试;否则休眠 2 秒后继续。
    3. 如果返回的业务错误码非 0,则打印错误并休眠 2 秒后继续。
    4. 成功获取响应后,提取 get_updates_buf 新游标,更新 buf 并持久化到文件,以便下次重启恢复

我们知道上述 getUpdates 接口还返回了 msgs 消息列表,我们需要遍历返回的消息列表,提取文本,交给本地 AI Agent 进行处理。

4. 处理返回的消息

根据微信 ClawBot 的官方文档我们可以知道 getUpdates 接口返回的 msgs 消息列表字段结构如下:

字段 类型 说明
seq number? 消息序列号
message_id number? 消息唯一 ID
from_user_id string? 发送者 ID
to_user_id string? 接收者 ID
create_time_ms number? 创建时间戳(ms)
session_id string? 会话 ID
message_type number? 1 = USER, 2 = BOT
message_state number? 0 = NEW, 1 = GENERATING, 2 = FINISH
item_list MessageItem[]? 消息内容列表
context_token string? 会话上下文令牌,回复时需回传

然后字段 item_list(消息内容列表)的字段结构又如下:

字段 类型 说明
type number 1 TEXT, 2 IMAGE, 3 VOICE, 4 FILE, 5 VIDEO
text_item { text: string }? 文本内容
image_item ImageItem? 图片(含 CDN 引用和 AES 密钥)
voice_item VoiceItem? 语音(SILK 编码)
file_item FileItem? 文件附件
video_item VideoItem? 视频
ref_msg RefMessage? 引用消息

根据上述资料我们就可以处理微信 ClawBot 服务器返回的消息了。处理如下:

def run_monitor(token: str) -> None:

    # 省略...

    while True:

        # 省略...

        # ── 第二件事 + 第三件事:处理每条消息,回复用户 ──
        for msg in resp.get("msgs") or []:

            # 只处理用户发来的消息(message_type=1),忽略 bot 自己发的(=2)
            if msg.get("message_type") != 1:
                continue

            from_user = msg.get("from_user_id", "")
            ctx_token = msg.get("context_token", "")  # ← 必须原样回传给 send_message

            # 从消息的 item_list 里找 type=1(文本)的那一项
            text = ""
            for item in msg.get("item_list") or []:
                if item.get("type") == 1:               # type=1 是文本消息
                    text = (item.get("text_item") or {}).get("text", "")
                    break

            if not text.strip():
                continue    # 非文本消息(图片、语音等)暂不处理

            print(f"[收到] {from_user}: {text[:60]}", flush=True)

实现也很简单,遍历 msgs 消息列表,然后再从消息的 item_list 里找 type=1(文本)的那一项。而非文本消息(图片、语音等)我们暂不处理,先跑通主流程再说。

经过上述处理后我们就拿到了微信 ClawBot 服务器返回的文本消息了,我们接着就把它交给本地 Agent 进行处理。

5. 接入本地 AI Agent

前面的步骤已经实现了本地接收到微信 ClawBot 发来的信息了,现在就需要接入一个本地 AI Agent 来处理微信用户发来的信息了。接入本地 AI Agent 也很简单,我们前面的文章已经实现了一个 Agent Loop,我们直接使用就可以了。

我们定义一个函数 askAgent,用它来管理每个用户的对话历史,并将用户的新消息交给 Agent 处理:

# ── 导入本地 Agent ──
from agent import agent_loop, SYSTEM as AGENT_SYSTEM

# 每个微信用户维护一份独立的对话历史,key 是用户 ID
_sessions: dict[str, list] = {}

def askAgent(user_id: str, user_text: str) -> str:
    """
    把用户的消息交给 Agent 处理,返回 Agent 的回复文本。

    - 每个用户有自己独立的对话历史(_sessions),实现多用户隔离
    - agent_loop 会循环调用大模型直到得到最终回复
    """
    # 第一次对话时,初始化这个用户的历史,带上系统提示词
    if user_id not in _sessions:
        _sessions[user_id] = [{"role": "system", "content": AGENT_SYSTEM}]

    # 把用户这条消息追加到历史
    _sessions[user_id].append({"role": "user", "content": user_text})

    # 交给 Agent 处理,agent_loop 会直接修改传入的列表(追加 assistant 回复)
    try:
        reply = agent_loop(_sessions[user_id])
        return reply or "(无回复)"
    except Exception as e:
        return f"[Agent 出错] {e}"

我们上述函数 ask_agent 实现了把用户的消息交给 Agent 处理,然后返回 Agent 的回复文本,并且还实现每个用户有自己独立的对话历史,实现了多用户隔离。

接下来就是把 Agent 的回复发回微信。

6. 把 Agent 的回复发回微信

回复消息的接口是 ilink/bot/sendmessage,它最重要的参数是 context_token,这个 token 是从收到的消息里原样取出的,服务端依靠它来将回复与对话关联起来(类似于会话 ID)。我们来实现一个 sendMessage 函数进行发送信息:

def sendMessage(token: str, to_user_id: str, text: str, context_token: str) -> None:
    """
    向微信用户发送一条文本消息。

    重要:context_token 必须原样从收到的消息里取出并回传,
    服务端靠它把回复和对话关联起来。没有它,消息发不出去。
    """
    _post(
        "ilink/bot/sendmessage",
        token = token,
        body  = {
            "msg": {
                "from_user_id" : "",                            # bot 发送,留空
                "to_user_id"   : to_user_id,                   # 发给谁
                "client_id"    : f"mini-{secrets.token_hex(8)}",  # 本次消息的唯一ID,防重复
                "message_type" : 2,                            # 2 = BOT 消息
                "message_state": 2,                            # 2 = 消息已完成(非流式)
                "item_list"    : [{"type": 1, "text_item": {"text": text}}],  # type=1 是文本
                "context_token": context_token,                # ← 关键!必须带上
            },
            "base_info": {"channel_version": "mini-bridge-1.0"},
        },
    )

这里我们固定使用 message_type=2(机器人消息)、message_state=2(已完成,非流式)。client_id 是消息的唯一标识,用于去重,这里随机生成即可。

7. 整合运行

现在我们就可以把登录、收消息、处理消息、发消息串起来了,整合运行。

def run_monitor(token: str) -> None:
    """
    长轮询监听循环:持续等待微信消息,收到后交给 Agent 处理并回复。

    整个循环做三件事:
      1. 调 getUpdates() 等消息(服务端"憋着",有消息才返回)
      2. 遍历返回的消息列表,提取文本,交给 ask_agent() 得到回复
      3. 调 sendMessage() 把回复发回给用户

    参数:
      token - 登录后拿到的 bot_token,每次请求都要带上
    """

    # ── 加载上次的消息游标(断点续传)──
    # 游标让服务端知道"从哪条消息开始",程序重启后不会漏掉中途的消息
    buf = BUF_FILE.read_text("utf-8").strip() if BUF_FILE.exists() else ""
    if buf:
        print("[✓] 从上次游标恢复", flush=True)
    print("[监听中] 等待微信消息...\n", flush=True)

    fail_count = 0    # 连续失败计数,失败太多就暂停一会儿

    while True:

        # ── 第一件事:等消息 ──
        try:
            resp = getUpdates(token, buf=buf)
        except Exception as e:
            # 网络抖动或服务端异常,失败超过 3 次才真正暂停
            fail_count += 1
            print(f"[错误] getUpdates 失败 ({fail_count}/3): {e}", flush=True)
            if fail_count >= 3:
                print("[退避] 连续失败 3 次,等待 30 秒后重试...", flush=True)
                fail_count = 0
                time.sleep(30)
            else:
                time.sleep(2)
            continue

        fail_count = 0

        # 服务端返回了业务错误码,打印后稍等再重试
        if resp.get("ret", 0) != 0 or resp.get("errcode", 0) != 0:
            print(f"[服务端错误] {resp}", flush=True)
            time.sleep(2)
            continue

        # 更新并持久化游标(下次重启可以从这里接着取消息)
        new_buf = resp.get("get_updates_buf", "")
        if new_buf:
            buf = new_buf
            BUF_FILE.write_text(buf, "utf-8")

        # ── 第二件事 + 第三件事:处理每条消息,回复用户 ──
        for msg in resp.get("msgs") or []:

            # 只处理用户发来的消息(message_type=1),忽略 bot 自己发的(=2)
            if msg.get("message_type") != 1:
                continue

            from_user = msg.get("from_user_id", "")
            ctx_token = msg.get("context_token", "")  # ← 必须原样回传给 send_message

            # 从消息的 item_list 里找 type=1(文本)的那一项
            text = ""
            for item in msg.get("item_list") or []:
                if item.get("type") == 1:               # type=1 是文本消息
                    text = (item.get("text_item") or {}).get("text", "")
                    break

            if not text.strip():
                continue    # 非文本消息(图片、语音等)暂不处理

            print(f"[收到] {from_user}: {text[:60]}", flush=True)

            # 第二件事:把文本交给 Agent,得到回复
            reply = askAgent(from_user, text)
            print(f"[回复] {reply[:60]}", flush=True)

            # 第三件事:把 Agent 的回复发回微信
            try:
                send_message(token, from_user, reply, ctx_token)
                print("[✓] 已发送", flush=True)
            except Exception as e:
                print(f"[✗] 发送失败: {e}", flush=True)

最后,在主函数中,我们读取或登录获取 token,然后启动 run_monitor

def main():
    """
    程序入口,只做两件事:
      1. 登录(拿 token)
      2. 调 run_monitor() 开始监听
    """

    # 优先读取上次保存的 token,有就跳过扫码
    creds = load_token()
    # 不存在 token 就扫码登录
    if not creds:
        print("=== 微信扫码登录 ===", flush=True)
        creds = login()
        save_token(creds)
        print(f"[✓] token 已保存到 {TOKEN_FILE}", flush=True)

    token      = creds["token"]
    account_id = creds["account_id"]
    base_url   = creds.get("base_url", BASE_URL)
    print(f"\n[启动] account={account_id}  base={base_url}", flush=True)

    # 登录完成,进入消息监听循环
    run_monitor(token)

在运行前我们需要安装一下相关依赖。

requirements.txt 内容如下:

openai==2.24.0
itchat-uos>=1.3.10
qrcode_terminal == 0.8.0

然后执行:

pip install -r requirements.txt

接着我们运行上述代码结果显示如下:

image.png

接着我们使用微信扫码结果显示如下:

image.png

我们点击按钮继续,这时可以看到终端显示如下:

image.png

微信端显示如下:

image.png

聊天栏显示:

c4a8f799d97ee7c9a29b75fa359f5263.jpg

这时我们就可以通过微信 ClawBot 和我们本地自己写的 Agent 进行通讯了。比如我们之前实现的一个可以读取本地文件的 AI Agent,我们创建一个测试文件 test.txt,写上以下内容:

通过本文,我们完整实现了一个基于微信 ClawBot 协议的机器人

然后在微信 ClawBot 中输入:帮我读取 test.txt 的文件内容,显示如下

image.png

终端内容显示如下:

image.png

8. 总结与扩展

通过本文,我们完整实现了一个基于微信 ClawBot 协议的机器人,它能够:

  • 通过扫码登录
  • 长轮询接收消息
  • 调用任意本地 AI Agent 处理消息
  • 将回复发回给微信用户

整个程序的核心代码不到 200 行,却涵盖了微信 ClawBot 协议的关键点。你可以在此基础上轻松扩展:

  • 支持多轮对话:通过会话历史管理,我们已经实现了多轮对话的基础。
  • 支持图片、语音:解析消息中的 item_list,识别图片或语音,调用相应的 AI 模型(如图像识别、语音转文字)。
  • 支持命令识别:在文本中检测特定前缀(如 /help),触发不同功能。
  • 接入更强大的 Agent:例如集成 LangChain 实现复杂工作流、接入 Ollama 或 vLLM 等本地推理框架运行开源大模型、或增加联网搜索、RAG(检索增强生成)等能力。

最重要的是,这套方法不依赖任何第三方中间件,完全基于微信官方 ClawBot 协议,相对稳定可靠。你只需要一个微信账号,就能让你的 AI 助手 7×24 小时在线。

希望本文能帮你打开一扇窗,让你在微信这个庞大的社交平台上,用自己的 AI 能力创造更多有趣的应用。动手试一试吧,你会发现过程比想象中简单许多!

我是 程序员Cobyte,欢迎添加 v: icobyte,学习交流 AI 全栈。

最后怎么查看微信 ClawBot 的官方文档,可以通过 npm 安装 @tencent-weixin/openclaw-weixin-cli@tencent-weixin/openclaw-weixin 包,然后在 node_modules 目录中找对应的包里面有源码和文档。当然微信团队不公开可能后续会随时改变策略,所以须谨慎评估风险。

认识 Service Worker

Service Worker 是一个运行在浏览器后台的 JavaScript 脚本,独立于网页主线程,是构建渐进式 Web 应用(PWA)的核心技术。它作为浏览器与网络之间的代理,可以实现离线访问、消息推送、后台同步等原生应用般的功能。

Service Worker 的主要特点:

  • 独立线程:不会阻塞页面渲染,也无法直接操作 DOM。

  • 网络代理:可以拦截页面发出的所有网络请求,并决定如何响应

  • 事件驱动:生命周期由一系列事件(install、activate、fetch 等)驱动

  • 需要 HTTPS:出于安全考虑,Service Worker 只在 HTTPS(或 localhost)环境下生效

if ("serviceWorker" in navigator) {
  const swUrl = new URL("./sw.js", window.location.href);
  const swScope = new URL("./", window.location.href).pathname;

  navigator.serviceWorker
    .register(swUrl, { scope: swScope })
    .then((reg) => console.log("SW registered", reg.scope))
    .catch((err) => console.error("SW registration failed", err));

  navigator.serviceWorker.ready.then(() => {
    console.log("Service Worker 已就绪");
  });

  navigator.serviceWorker.oncontrollerchange = () => {
    console.log("Service Worker 已激活");
  };
}

async function fetchData() {
  const resultDiv = document.getElementById("result");
  resultDiv.textContent = "正在请求中...";

  try {
    const response = await fetch(
      "https://jsonplaceholder.typicode.com/posts/1"
    );

    if (!response.ok) {
      throw new Error(`HTTP 错误!状态码:${response.status}`);
    }

    const data = await response.json();
    resultDiv.textContent = "请求成功!\n\n" + JSON.stringify(data, null, 2);
  } catch (error) {
    resultDiv.textContent = "请求失败:" + error.message;
    console.error("错误:", error);
  }
}

new URL() 是 JavaScript 内置的构造函数,用于解析和构建 URL 地址。它返回一个 URL 对象,该对象包含 URL 的各个组成部分(如协议、主机名、路径、查询参数等),并提供了方便的属性和方法来操作 URL。

new URL() 构造函数接受两个参数:要解析的 URL 地址和可选的基 URL 地址。如果省略了基 URL 地址,则默认为 undefined

如果 new URL()传入的第一个参数 url 是相对路径,则必须提供 base(基准 URL)来解析成完整地址。

如果传人的第一个参数 url 是绝对路径,则 base 参数将被忽略。

new URL(url);
new URL(url, base);
// 使用传入的相对 url 和基 url 构造一个绝对 url
const swUrl = new URL("./sw.js", window.location.href);

serviceWorker.register()scope 参数指定了 Service Worker 能够控制的范围,也就是它能拦截哪些路径下的网络请求。

如果不指定 scope,Service Worker 默认控制脚本所在目录及其子目录。例如,如果脚本在 /js/sw.js,则默认控制 /js/ 及更深的路径(如 /js/page/)。

设置 scope: "/" 表示整个网站(同源下所有页面)都会被该 Service Worker 拦截。如果设置 scope: "/admin/",则只有以 /admin/ 开头的页面和请求会被控制。

scope 不能超出脚本所在的路径层级。即 Service Worker 只能控制与其同目录或子目录的范围,不能控制父目录(除非脚本位于根目录,才能设置 scope: "/")。

navigator.serviceWorker.register(swUrl, { scope: swScope });

oncontrollerchange 事件在当前页面所关联的 Service Worker 控制器(controller)发生变化时触发。

navigator.serviceWorker.oncontrollerchange = () => {
  console.log("Service Worker 已激活");
};

Service Worker 有 4 个生命周期,该生命周期由浏览器管理,主要分为以下阶段:

  1. 注册(Registration):页面通过 JavaScript 告诉浏览器 Service Worker 脚本的位置。

  2. 安装(Install):首次注册或版本更新时触发,适合预缓存静态资源。

  3. 激活(Activate):安装成功后触发,可用于清理旧缓存、接管页面。

  4. 空闲/终止:当没有事件需要处理时,浏览器会终止 Service Worker 以节省内存,下次需要时再唤醒。

sw.js 文件内容如下:

self.addEventListener("install", () => {
  console.log("安装");
  self.skipWaiting(); // 跳过等待,直接激活
});

self.addEventListener("activate", (event) => {
  console.log("激活");
  event.waitUntil(self.clients.claim()); // 立即接管所有页面
});

self.addEventListener("fetch", (event) => {
  console.log("Service Worker 拦截到请求:", event.request.url);
  event.respondWith(fetch(event.request));
});

fetchXMLHttpRequest 的一个很大区别是,fetch 可以在 Service Worker 中使用。

self.skipWaiting() 的作用是让新安装的 Service Worker 跳过 waiting 阶段,尽快进入 active 状态 。

正常流程里,新的 SW 安装后会先 waiting ,要等旧 SW 不再控制任何页面才会激活。

调用 self.skipWaiting() 后,新 SW 不用等那么久,会尝试立即激活。

clients.claim() 让“已激活 SW 立刻接管页面”。

event.waitUntil() 的作用是: 告诉浏览器“这个事件还有异步任务没完成,请先别结束” 。

监听 fetch 事件的作用是:拦截当前 Service Worker 作用域内的所有网络请求,并决定如何返回响应 。

event.respondWith(fetch(event.request)) 把请求原样转发到网络,再把网络结果返回给页面。

event.respondWith()的作用是: 在 fetch 事件里接管这次请求,并指定返回给页面的响应。

总结

Service Worker 是运行在浏览器后台的独立线程脚本,作为浏览器与网络之间的代理,可用于离线访问、消息推送和后台同步等功能。

  • 核心特点:独立线程、事件驱动、可拦截网络请求、仅在 HTTPS(或 localhost)下生效。

  • 注册与路径:通过 navigator.serviceWorker.register() 注册,常用 new URL() 构造 sw.js 路径;scope 用于限定控制范围,不能超出脚本所在目录。

  • 生命周期:四个阶段 —— 注册(Registration)、安装(Install,可用于预缓存)、激活(Activate,可清理旧缓存并接管页面)、空闲/终止(节省资源,按需唤醒)。

  • 常用 API 与模式

    • self.skipWaiting()(跳过 waiting,快速激活)、
    • clients.claim()(激活后立即接管页面)、
    • event.waitUntil()(延长事件直至异步任务完成)、
    • fetch 事件中使用 event.respondWith() 拦截并返回响应。
  • 实践示例:文中示例的 sw.js 展示了 installactivatefetch 的基本写法,以及在 fetch 中将请求透传到网络 event.respondWith(fetch(event.request)) 的简单用法。

以上要点概览了 Service Worker 的作用、注册方式、作用范围、生命周期与常见实现模式,便于快速上手和理解在实践中如何拦截与处理请求。

参考

Using Service Workers

分享一个THREE.JS中无限滚动的技巧

分享一个THREE.JS中无限滚动的技巧

最近在学习three.js发现了一个无限滚动的效果感觉还挺好看的故此来分享一下

本篇代码使用了@react-three/fiber

01.gif

1. 如何布局

首先我们准备好我们需要的图片资源,然后直接加载到页面中心

function ImageTube() {

    const imageUrls = useMemo(() => [
        "/tube/im1.jpg",
        "/tube/im3.jpg",
        "/tube/im2.jpg",
        "/tube/im4.jpg",
        "/tube/im5.jpg",
        "/tube/im6.jpg",
        "/tube/im7.jpg",
        "/tube/im8.jpg",
        "/tube/im9.jpg",
    ], []);

    const textures = useTexture(imageUrls);


    return Array.from({length: imageUrls.length}).map((_, index) => {

        return (
            <mesh key={index}>
                <planeGeometry args={[1, 1]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })
}

20260328172459

PlaneGeometry 参数定义表

参数索引 参数名 类型 默认值 功能描述
args[0] width float 1.0 平面宽度:沿 X 轴的长度。
args[1] height float 1.0 平面高度:沿 Y 轴的长度。
args[2] widthSegments int 1 水平分段:沿宽度方向将平面切分成多少个面片。
args[3] heightSegments int 1 垂直分段:沿高度方向将平面切分成多少个面片。

1.1 上下分开排列布局!

目前我们已经把我们所有的图片信息都加载到画面中了,下一步我们让这些图片依次从上到下排列!

核心是利用meshposition属性来改变定位,举个例子🌰

return Array.from({length: imageUrls.length}).map((_, index) => {

        const tileW = 0.8;
        const tileH = 1.0;

        // 定义对角线的边界
        const startX = -width / 2 + tileW / 2;     // 屏幕左侧
        const startY = height / 2 - tileH / 2;    // 屏幕上方
        const endX = width / 2 - tileW / 2;      // 屏幕右侧
        const endY = -height / 2 + tileH / 2;   // 屏幕下方

        const count = imageUrls.length;
        const t = index / (count - 1);

        const x = startX + (endX - startX) * t;
        const y = startY + (endY - startY) * t;
        const z = 0;


        return (
            <mesh key={index} position={[x,y,z]}>
                <planeGeometry args={[tileW, tileH]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })

20260328174354

可以很直观的看到我们已经完成了一个对角线排列的图片布局!

1.2 水平球形排列

经过上面的练习我们已经知道了可以通过position来排列,那么接下来我们把图片按照效果图那样排列一下!主要是用到了球坐标公式

{x=radius×cos(θ)z=radius×sin(θ)y=height\begin{cases} x = \text{radius} \times \cos(\theta) \\ z = \text{radius} \times \sin(\theta) \\ y = \text{height} \end{cases}
变量 名称 含义 作用
theta (θ\theta) 弧度 (Radians) 物体在圆周上的角度位置。 决定物体在圆圈的“几点钟方向”。
radius (rr) 半径 物体距离圆心的距离。 决定圆阵的大小(圆柱体的粗细)。
x X 坐标 水平轴位置。 决定物体的左右分布。
z Z 坐标 深度轴位置。 决定物体的远近分布(产生 3D 深度感)。
 return Array.from({length: imageUrls.length}).map((_, index) => {

        const count = imageUrls.length;
        // const y = (index - (count - 1) / 2) * ySpacing;

        const radius = 4;
        const theta = (index / count - 1) * Math.PI * 2;
        const x = Math.cos(theta) * radius;
        const z = Math.sin(theta) * radius;


        return (
            <mesh key={index} position={[x, 0, z]}>
                <planeGeometry args={[tileW, tileH]}/>
                <meshBasicMaterial map={textures[index]} toneMapped={false} side={DoubleSide}/>
            </mesh>
        );

    })

02.gif

1.3 引入Group上下排列

上面我们已经成功的实现了一个水平球形排列,接下来我们要把这九张图重复上下排列!这时候就有一个问题了,目前我们的xz的计算很简单就是代入公式直接求答案,如果要上下排列那么就不再是一个单纯的圆形而是一个圆柱体形的排列,复杂度上了一个档次所以我介绍一个新的组件Group!也就是组的概念,在这个组下的所有元素都受到这个Group的影响!也就是position属性也是先根据Group再计算自身的


    const radius = 4;
    const tileW = 0.8;
    const tileH = 1.0;
    const ySpacing = 2.7;

    const rows = 5;
    const cols = 12;

    const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number;  }> = [];
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const y = (rowIndex - (rows - 1) / 2) * ySpacing;
            const baseRow = rowIndex % rows;
            out.push({rowIndex, y, baseRow});
        }
        return out;
    }, [rows, ySpacing]);


return (
        <group>
            {rowPositions.map(({rowIndex, y, baseRow}) => (
                <group key={rowIndex} position={[0, y, 0]}>

                    {Array.from({length: cols}).map((_, col) => {
                        const theta = (col / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
                    })}

                </group>
            ))}
        </group>
    )

20260330143425

成功完成五行十二列的布局!

2. 自滚动

目前的布局我们基本上实现了,接下来添加一个自旋转效果,说到自动的视图变化那肯定就要用到useFrame

useFrame 是 R3F 中最重要的 Hook,它允许你在每一帧(通常是每秒 60 次)执行代码。它是实现 动画物理模拟实时交互 的核心入口。

    useFrame((_state, dt) => {
        ...
        ...
        👆每一帧自动运行
    });

举个例子!我们直接在useFrame中控制grouprotation.y


 const rowGroupRefs = useRef<Array<Object3D | null>>([]);

 useFrame((_state, dt) => {
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            // const baseRow = rowIndex % rows;
            rowObj.rotation.y  += 0.0065;
        }
 })

{rowPositions.map(({rowIndex, y, baseRow}) => (
                <group
                    ...
                    ...
                    ref={(obj) => {
                        rowGroupRefs.current[rowIndex] = obj;
  }}>
...
...
...  

03

这样已经实现了一个基本的自滚动效果!

2.1 交错排列

现在的滚动效果可以发现每一行每一列都是对齐的有点过去生硬了,我们加一个偏移量


 const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number; rowOffset: number }> = [];
        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            ...
            ...
            + const rowOffset = baseRow % 2 === 0 ? 0 : 0.5;
            out.push({rowIndex, y, baseRow, rowOffset});
        }
        return out;
}, [rows, ySpacing]);


{Array.from({length: cols}).map((_, col) => {
                        const theta = ((col + rowOffset) / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
})}

04.gif

2.2 和鼠标滚轮结合

现在基本的自转已经实现了,但是我们的目标是和鼠标滚轮结合还要有一个上下的滚动效果!

首先我们来实现一个上下滚动的效果,根据我们 1.3 学到的经验我们应该在这些外层再使用一个Group,并且控制这个Groupposition.y


  const rowGroupRefs = useRef<Array<Object3D | null>>([]);
  + const groupRef = useRef<Object3D>(null);
  + const scrollCurrent = useRef(0);


    useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            // const baseRow = rowIndex % rows;
            rowObj.rotation.y += 0.0065;
        }


    })


  <group ref={groupRef}>
            {rowPositions.map(({rowIndex, y, baseRow,rowOffset}) => (
                <group>
                    ...
                    ...
                <group>    
   </group>

scrollTargetRef为外部组件传递进来

function App() {

    const tubeScrollTarget = useRef(0);

    const onWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
        tubeScrollTarget.current += event.deltaY * 0.002;
    }, []);

    return (
        <div className="sceneRoot" onWheel={onWheel}>

            <Canvas
                camera={{position: [0, 0, 6.5], fov: 50}}
                onCreated={({camera}) => {
                    camera.lookAt(0, 0, 0);
                }}
            >

                {/*<OrbitControls enableDamping dampingFactor={0.05}/>*/}
                <ambientLight intensity={0.5}/>
                <directionalLight position={[5, 5, 5]} intensity={1}/>

                <Environment preset="studio" blur={10.5}/>
                <GridPlane targetCenterUv={targetCenterUv}/>
               
                <ImageTube2 scrollTargetRef={tubeScrollTarget}/>

            </Canvas>
        </div>
    )
}

05.gif

okkk,现在和鼠标滚轮事件结合可以上下滚动我们的列表了,但是现在还有点呆板的是我们绕y轴旋转的列表还没有和滚轮事件结合,我们的目标是在鼠标滚动的时候我们的旋转列表应该进行一个加速滚动!

这个时候我们需要用到另一个公式

📈 动态旋转位移叠加公式

该公式负责在 useFrame 中更新全局旋转角度,结合了自动巡航与用户交互。

  1. 公式定义 Total_Angle_Increment = (Base_Auto_Speed + User_Input_Velocity) * Delta_Time

  2. 参数拆解

  • baseSpeed: 静态常量,控制背景自动旋转的快慢。
  • spinVelocityRef: 动态变量,承载用户交互产生的动能(惯性)。
  • scaledDt: 时间缩放因子,确保跨设备速度一致性。
  1. 运行逻辑
  2. 每一帧读取当前的“自转力”和“手动推力”。
  3. 将两者求和,得到当前的总瞬时速度
  4. 根据这一帧经过的时间计算出应该转过的弧度增量
  5. 累加到 angle.current,从而驱动所有 meshrotation.y

首先我们得速率spinVelocityRef依然要和鼠标滚轮事件绑定,同时区分一下鼠标滚动得方向tubeNaturalDir

    const onWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
        tubeScrollTarget.current += event.deltaY * 0.002;
      + tubeSpinVelocity.current += event.deltaY * 0.004;

        if (event.deltaY < 0) tubeNaturalDir.current = -1;
        else if (event.deltaY > 0) tubeNaturalDir.current = 1;
    }, []);


 <ImageTube
    scrollTargetRef={tubeScrollTarget}
  + spinVelocityRef={tubeSpinVelocity}
    naturalDirRef={tubeNaturalDir}
    ...
    ... />

然后在我们得ImageTube中接收这个速率并且做一个递增处理


 useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        ...
        ...

    })

接下来我们算一个旋转速度比例系数


 useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

      + rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            0.12;
      // 根据方向不同产生不同方向得力
      + const scaledDt = dt * rotationSpeedScale.current;
        ...
        ...

    })

有了速率和速度比例系数之后我们就可以套进公式算出来我们应该滚动得距离了


useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

06

3. 无限滚动!

拖拖拉拉才进入本章得主题,那就是实现一个无限滚动得效果,如果大家之前用css做过类似得效果得话其实是能了解到所谓得无限滚动都是障眼法,是通过瞬时改变值来实现得,也就是把滚动了一定距离得值直接改到起始位置!

在我们这个上下滚动得例子中很明显我们得滚动距离最大是rows * ySpacing / 2 ,同样由于我们支持上下自由滚动所以我们最起码需要

const repeatCount = 3;
const totalRows = rows * repeatCount;

okok来实际看下代码,之前所有关于rows得地方都需要改变成totalRows

function ImageTube({
                        baseSpeedRef,
                        tubeAngleRef,
                        naturalDirRef,
                        scrollTargetRef,
                        spinVelocityRef,
                        rotationSpeedScaleLerpRef,
                        rotationSpeedScaleTargetRef,
                    }: {
    baseSpeedRef: React.MutableRefObject<number>;
    tubeAngleRef: React.MutableRefObject<number>;
    naturalDirRef: React.MutableRefObject<number>;
    scrollTargetRef: React.MutableRefObject<number>;
    spinVelocityRef: React.MutableRefObject<number>;
    rotationSpeedScaleTargetRef: React.MutableRefObject<number>;
    rotationSpeedScaleLerpRef: React.MutableRefObject<number>;
}) {

    const groupRef = useRef<Object3D>(null);
    const rowGroupRefs = useRef<Array<Object3D | null>>([]);
    const scrollCurrent = useRef(0);
    const rotationSpeedScale = useRef(1);
    const angle = useRef(0);

    const imageUrls = useMemo(() => [
        "/tube/im1.jpg",
        "/tube/im3.jpg",
        "/tube/im2.jpg",
        "/tube/im4.jpg",
        "/tube/im5.jpg",
        "/tube/im6.jpg",
        "/tube/im7.jpg",
        "/tube/im8.jpg",
        "/tube/im9.jpg",
    ], []);


    const textures = useTexture(imageUrls);

    const radius = 4;
    const tileW = 0.8;
    const tileH = 1.0;
    const ySpacing = 2.7;

    const rows = 5;
    const cols = 12;

    const loopHeight = rows * ySpacing;
    const repeatCount = 3;
    const totalRows = rows * repeatCount;

    const rowPositions = useMemo(() => {
        const out: Array<{ rowIndex: number; y: number; baseRow: number; rowOffset: number }> = [];
        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const y = (rowIndex - (totalRows - 1) / 2) * ySpacing;
            const baseRow = rowIndex % rows;
            const rowOffset = baseRow % 2 === 0 ? 0 : 0.5;
            out.push({rowIndex, y, baseRow, rowOffset});
        }
        return out;
    }, [totalRows, ySpacing]);


    useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

    return (
        <group ref={groupRef}>
            {rowPositions.map(({rowIndex, y, baseRow, rowOffset}) => (
                <group
                    key={rowIndex}
                    position={[0, y, 0]}
                    ref={(obj) => {
                        rowGroupRefs.current[rowIndex] = obj;
                    }}>

                    {Array.from({length: cols}).map((_, col) => {
                        const theta = ((col + rowOffset) / cols) * Math.PI * 2;
                        const x = Math.cos(theta) * radius;
                        const z = Math.sin(theta) * radius;
                        const ry = -(theta + Math.PI / 2);
                        const texIndex = (baseRow * cols + col) % imageUrls.length;

                        return (
                            <mesh
                                key={col}
                                position={[x, 0, z]}
                                rotation={[0, ry, 0]}
                            >
                                <planeGeometry args={[tileW, tileH]}/>
                                <meshBasicMaterial map={textures[texIndex]} toneMapped={false} side={DoubleSide}/>
                            </mesh>
                        );
                    })}

                </group>
            ))}
        </group>
    )

}

然后我们依然在useFrame中处理我们得瞬时逻辑!


useFrame((_state, dt) => {

        scrollCurrent.current += (scrollTargetRef.current - scrollCurrent.current) * 0.12;
        // 当超过最大滚动距离得时候,我们瞬间改变滚动得高度
        if (scrollCurrent.current > loopHeight / 2) {
            scrollCurrent.current -= loopHeight;
            scrollTargetRef.current -= loopHeight;
        } else if (scrollCurrent.current < -loopHeight / 2) {
            scrollCurrent.current += loopHeight;
            scrollTargetRef.current += loopHeight;
        }

        const damping = 0.92;
        spinVelocityRef.current *= Math.pow(damping, dt * 60);
        spinVelocityRef.current = Math.max(-2.0, Math.min(2.0, spinVelocityRef.current));

        rotationSpeedScale.current +=
            (1.0 - rotationSpeedScale.current) *
            rotationSpeedScaleLerpRef.current;

        const scaledDt = dt * rotationSpeedScale.current;

        const baseSpeed = naturalDirRef.current * baseSpeedRef.current;
        angle.current += (baseSpeed + spinVelocityRef.current) * scaledDt;

        tubeAngleRef.current = angle.current;

        const group = groupRef.current;
        if (!group) return;
        group.position.y = -scrollCurrent.current;

        for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
            const rowObj = rowGroupRefs.current[rowIndex];
            if (!rowObj) continue;
            rowObj.rotation.y = angle.current;
        }


    })

07.gif

4. 结语

为什么总觉得成功只差一步!

参考资料

Reactive Depth: Building a Scroll-Driven 3D Image Tube with React Three Fiber

如果想转 AI 全栈?推荐你学一下 Langchain!

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

上个月月底,我去参加了一场在深圳举办的线下聚会,现场人很多,几乎称得上爆满,分享具体讲了什么我其实没有认真听完,但有一个现象让我印象特别深。

我发现,现场已经有很多并非技术出身的人在真实地使用 AI 做开发,有人是产品经理,有人甚至没有完整的软件工程背景,但他们一样能借助 Claude CodeCursor 这类 AI 编辑器,把一个产品从想法推进到可运行的形态。

只要你真正用过这类工具,你就会知道它们强在哪里,很多时候你不必先把所有代码写完,只要把问题、目标和约束说清楚,模型就能替你完成相当大一部分工作,它不光是在替你补几行代码,更是在把你的想法翻译成可执行的过程。

这件事带来的冲击其实很直接,不是只有程序员才能做产品了,而是谁更会拆问题、谁更会组织上下文、谁更会调度 AI,谁就更有机会把事情做成。

所以,真正需要警惕的从来不是 AI 会不会写代码,而是你是否还停留在只会发一个 chat.completions 请求、然后等它吐一段文本的阶段,因为当 AI 开始参与真实任务时,竞争点已经不再只是会不会调模型,而是你能不能把模型接进系统、接进流程、接进业务,最后让它稳定地把事做完。

也正因为如此,这套文档不会停留在教你调用一下 LLM API 这一层,它想解决的是更往前一步的问题,当 AI 不再只是聊天,而是真正进入你的产品、流程和工程系统里时,你到底该怎么设计它、约束它、组织它、编排它。

从会调模型到能改整条 Agent 链路

理想状态大概是,你不再满足于发完请求就收一段文本,而是能把一条真正可执行的 Agent 链路说清楚,别人问起来,你也知道该动哪一层、从哪下手改。

这里不会拿概念填空来凑篇幅,那些词你多半已经见过。更值得花时间的是落地之后一定会撞上的事,比如上下文该留什么、该砍什么,模型才既记得住关键信息,又不会被历史拖垮。工具怎么写、Function Call 怎么接,才能少空转、少胡编,多把事办完。结构化输出怎么定,业务里才能当真数据用,而不是靠正则和运气硬接。

再往后,中间件、护栏、运行时、上下文工程各自兜的是哪一类坑,MCP 这类协议又该摆在协作架构的哪一层。人机协同、多 AgentSubagentsHandoffsSkillsRouter、自定义工作流,听起来多,其实都是在不同复杂度下选一条路。至于 CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 这些名字,背下来没多大用,有用的是它们背后控制流怎么画、推理预算该多给还是该省。

章节一路跟下来,术语和框架名自然会熟,但更值得带走的是一种手感。某类任务该用简单的 Agent 循环还是上图式编排,某段流程要不要上人审、要不要拆角色,某一步老是失败时,该补护栏、补记忆、补工具描述,还是干脆换一套推理策略。能分清这些,比多记十个 API 名字实在得多。

真正花时间的是把系统搭稳

网上讲 AI 开发的内容已经很多,常见的却两头偏,一头概念讲得热闹,回到工程里不知道该动哪只手,另一头 demo 复制粘贴能跑,一进真实业务就开始散。

第一次把结果跑出来的时候,你往往还觉得挺顺。你很快会发现,真正难的从来不是让它第一次跑起来,而是:

  • 为什么这个 Agent 一到复杂任务就开始乱
  • 为什么多轮之后上下文越来越脏
  • 为什么工具明明接了,模型还是不会正确调用
  • 为什么结构化输出看起来像 JSON,实际上却根本不稳定
  • 为什么接了很多能力,系统却越来越难控、越来越难测、越来越难上线

这套文档想把这一串问号拆开来看。重点不单是让模型答得更聪明,而是让你看清一个能进生产环境的系统底下有几层、每层在扛什么,出事该往哪一层摸,而不是遇事就把锅甩给模型不够聪明。

如何学习

按章节顺序读就行,不是要你迷信目录,而是后面的例子会默认你已经看过前面的概念,跳太狠容易半路卡住。

开头一大段都在打基础,裸调模型哪里别扭、LangChain 在补什么、Function Call、消息结构、工具怎么接、先跑一个最简单的 Agent、再加上会话记忆和结构化输出。拆开看是很多篇,合起来就是在说一件事,模型是怎么被接进一条可执行的链路里的。

再往后会硬一些,主要对付"能跑"和"敢上线"之间的差距,中间件、护栏、运行时、上下文工程、MCP、人机协同、多 Agent,以及 SubagentsHandoffsSkillsRouter、自定义工作流之类。名字多,你不用全记住,先有个印象,知道这些多半是在管权限、管边界、管出事以后谁来兜底。

后面才轮到规划、反思、试探、回退这类话题。CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 当几种不同的走法看就好,定义背了也没多大用。有用的是下面这些判断,心里过一遍比抄名词强:

  • 什么场景下值得多给一点推理预算
  • 什么场景下应该尽快落工具、少走内耗
  • 什么任务适合先规划后执行
  • 什么任务反而应该边做边修正
  • 什么情况下多想一步是收益,什么情况下只是成本

快收尾的时候会把长期记忆和 harness 拉出来,把执行、状态、持久化、审计、可观测性这些零散提过的东西并到一块,方便你对照真实环境里一般长什么样。

20260329233412

整体就是这样,先把基础概念和常见拼法摸熟,再啃工程和协作里那些让人心里发虚的部分,最后在控制流和收尾方式上收个口。

适合谁、怎么读

你若是写 React、做业务、跟需求,模型 API 也碰过,却越来越觉得卡不在页面上,而在模型怎么接、工具怎么配、多步任务怎么串,这一路的写法就是按这个感觉排的。

做过聊天框、demo,想再往"能办事"那边挪一步,也会对上号。别人做出来的像助手,自己的还在一问一答里打转,这类落差在这里会当成工程问题拆,而不是甩一句模型不够聪明。

还有一种情况,文章东一篇西一篇看过,记忆、工具调用、Agent 都见过词,就是拼不出一张图。按章节往下翻,多半能把那些散点接回一条线。

读法上可以松一点,不必一次啃完。过完一章,想想自己项目里有没有同款糟心事,有的话最小改动可以先动哪一步。理论不用第一遍就全吃透,能慢慢把问题和章节里的招对上,就已经在读对路了。

构建无障碍组件之Carousel Pattern

Carousel Pattern 详解:构建无障碍轮播组件

轮播(Carousel)是一种按顺序展示一组内容项(称为幻灯片)的组件。本文基于 W3C WAI-ARIA Carousel Pattern 规范,详解如何构建无障碍的轮播组件。

一、Carousel 的定义与核心概念

1.1 什么是 Carousel

Carousel(也称为幻灯片或图片轮播器)具有以下特征:

  • 展示一组称为**幻灯片(Slide)**的内容项
  • 通常一次显示一个幻灯片,通过控制按钮切换
  • 可以自动轮播,也可以手动控制
  • 幻灯片可以包含任何类型的内容,图片轮播最为常见

1.2 核心术语

术语 说明
Slide 轮播中的单个内容容器
Rotation Control 停止/启动自动轮播的交互控件
Next Slide Control 显示下一张幻灯片的控件(通常为箭头样式)
Previous Slide Control 显示上一张幻灯片的控件(通常为箭头样式)
Slide Picker Controls 选择特定幻灯片的控件组(通常为圆点样式)
┌─────────────────────────────────────────────────────────────┐
│  Carousel (role="region" + aria-roledescription)            │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                                                     │    │
│  │              ┌─────────────────┐                    │    │
│  │              │    Slide 1      │                    │    │
│  │              │  ┌───────────┐  │                    │    │
│  │              │  │           │  │                    │    │
│  │              │  │  Image /  │  │  <-- Current       │    │
│  │              │  │  Content  │  │      Slide         │    │
│  │              │  │           │  │                    │    │
│  │              │  └───────────┘  │                    │    │
│  │              └─────────────────┘                    │    │
│  │                                                     │    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  Slide 2 (hidden)    │   Slide 3 (hidden)   │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │                                                     │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│     <-- Prev        o O O        Next -->                   │
│           (Slide Picker / Dots Navigation)                  │
│                                                             │
│                    [ Pause/Play ]                           │
└─────────────────────────────────────────────────────────────┘

1.3 无障碍挑战

轮播组件如果没有正确实现,会对无障碍体验造成严重影响:

  • 屏幕阅读器用户困惑:如果不可见的幻灯片没有被正确隐藏,用户可能在不知情的情况下从幻灯片 1 跳转到幻灯片 2 的内容
  • 自动轮播干扰:自动轮播可能打断屏幕阅读器用户的浏览流程
  • 键盘导航困难:如果轮播没有正确处理焦点管理,键盘用户可能无法有效控制轮播

二、WAI-ARIA 角色与属性

2.1 基本角色

轮播区域使用 role="region" 标记为地标区域,并通过 aria-roledescription="carousel" 提供额外的角色描述:

<section
  role="region"
  aria-roledescription="carousel"
  aria-label="产品展示">
  <!-- 轮播内容 -->
</section>

2.2 幻灯片属性

每个幻灯片具有以下属性:

<div
  role="group"
  aria-roledescription="slide"
  aria-label="第 1 张,共 3 张">
  <!-- 幻灯片内容 -->
</div>

2.3 幻灯片可见性

使用 aria-hidden 控制幻灯片的可见性:

  • aria-hidden="true":幻灯片不可见(不在视口内)
  • aria-hidden="false":幻灯片可见(当前显示的幻灯片)
<!-- 当前显示的幻灯片 -->
<div
  role="group"
  aria-roledescription="slide"
  aria-label="第 1 张,共 3 张"
  aria-hidden="false">
  <img
    src="slide1.jpg"
    alt="产品图片 1" />
</div>

<!-- 隐藏的幻灯片 -->
<div
  role="group"
  aria-roledescription="slide"
  aria-label="第 2 张,共 3 张"
  aria-hidden="true">
  <img
    src="slide2.jpg"
    alt="产品图片 2" />
</div>

2.4 控制按钮属性

上一张/下一张按钮

使用 aria-controls 属性指向被控制的幻灯片容器 ID,让辅助技术用户了解按钮会影响哪个区域的内容:

<button
  aria-label="上一张"
  aria-controls="carousel-slides"></button>
<button
  aria-label="下一张"
  aria-controls="carousel-slides"></button>
轮播控制按钮(停止/启动)

使用 aria-pressed 表示按钮的按下状态,false 表示轮播正在运行,点击后会停止:

<button
  aria-label="停止轮播"
  aria-pressed="false"
  aria-controls="carousel-slides"></button>
幻灯片选择器(圆点导航)

幻灯片选择器使用 role="tab" 模式,每个圆点按钮都是一个 tab,通过 aria-selected 表示当前选中的幻灯片:

<div
  role="tablist"
  aria-label="幻灯片选择">
  <button
    role="tab"
    aria-label="第 1 张"
    aria-selected="true"
    aria-controls="slide-1"></button>
  <button
    role="tab"
    aria-label="第 2 张"
    aria-selected="false"
    aria-controls="slide-2"></button>
  <button
    role="tab"
    aria-label="第 3 张"
    aria-selected="false"
    aria-controls="slide-3"></button>
</div>

三、键盘交互规范

3.1 基本键盘交互

按键 功能
Tab / Shift + Tab 在轮播的交互元素之间移动焦点
Space / Enter 激活按钮(上一张、下一张、停止/启动)
方向键(可选) 如果幻灯片选择器使用 Tab 模式,可用方向键切换幻灯片

3.2 自动轮播的键盘行为

  • 当轮播中的任何元素获得键盘焦点时,自动轮播必须停止
  • 轮播不会自动恢复,除非用户明确激活旋转控制

3.3 Tab 顺序

  • 旋转控制按钮(如果存在)必须是轮播内部 Tab 顺序中的第一个元素
  • 这确保辅助技术用户可以轻松找到控制按钮

四、鼠标交互规范

4.1 悬停行为

  • 当鼠标悬停在轮播上时,自动轮播必须停止
  • 鼠标移出后,可以恢复自动轮播(根据设计决定)

4.2 点击行为

  • 点击上一张/下一张按钮切换幻灯片
  • 点击幻灯片选择器跳转到特定幻灯片
  • 点击旋转控制按钮停止/启动自动轮播

五、实现方式

5.1 基础轮播结构

<section
  class="carousel"
  aria-roledescription="carousel"
  aria-label="产品展示">
  <!-- 旋转控制按钮 -->
  <button
    class="rotation-control"
    aria-label="停止轮播"
    aria-pressed="false"
    aria-controls="carousel-slides"></button>

  <!-- 幻灯片容器 -->
  <div
    id="carousel-slides"
    class="carousel-slides">
    <div
      role="group"
      aria-roledescription="slide"
      aria-label="第 1 张,共 3 张"
      aria-hidden="false"
      class="slide active">
      <img
        src="slide1.jpg"
        alt="产品图片 1" />
      <div class="slide-content">
        <h2>产品标题 1</h2>
        <p>产品描述...</p>
        <a href="/product1">了解更多</a>
      </div>
    </div>

    <div
      role="group"
      aria-roledescription="slide"
      aria-label="第 2 张,共 3 张"
      aria-hidden="true"
      class="slide">
      <img
        src="slide2.jpg"
        alt="产品图片 2" />
      <div class="slide-content">
        <h2>产品标题 2</h2>
        <p>产品描述...</p>
        <a href="/product2">了解更多</a>
      </div>
    </div>

    <div
      role="group"
      aria-roledescription="slide"
      aria-label="第 3 张,共 3 张"
      aria-hidden="true"
      class="slide">
      <img
        src="slide3.jpg"
        alt="产品图片 3" />
      <div class="slide-content">
        <h2>产品标题 3</h2>
        <p>产品描述...</p>
        <a href="/product3">了解更多</a>
      </div>
    </div>
  </div>

  <!-- 导航按钮 -->
  <button
    class="prev-btn"
    aria-label="上一张"
    aria-controls="carousel-slides"></button>
  <button
    class="next-btn"
    aria-label="下一张"
    aria-controls="carousel-slides"></button>

  <!-- 幻灯片选择器 -->
  <div
    class="slide-picker"
    role="tablist"
    aria-label="幻灯片选择">
    <button
      role="tab"
      aria-label="第 1 张"
      aria-selected="true"
      aria-controls="slide-1"></button>
    <button
      role="tab"
      aria-label="第 2 张"
      aria-selected="false"
      aria-controls="slide-2"></button>
    <button
      role="tab"
      aria-label="第 3 张"
      aria-selected="false"
      aria-controls="slide-3"></button>
  </div>
</section>

5.2 关键实现要点

幻灯片可见性管理:

  • 当前显示的幻灯片:aria-hidden="false"
  • 隐藏的幻灯片:aria-hidden="true"
  • 使用 CSS 控制显示/隐藏(如 opacitydisplayvisibility

自动轮播控制:

  • 键盘焦点进入轮播区域时,必须停止自动轮播
  • 鼠标悬停在轮播上时,必须停止自动轮播
  • 提供停止/启动按钮,让用户控制自动轮播

幻灯片选择器状态:

  • 当前幻灯片对应的按钮:aria-selected="true"
  • 其他按钮:aria-selected="false"

六、常见应用场景

6.1 产品图片展示

<section
  aria-roledescription="carousel"
  aria-label="产品图片">
  <div
    role="group"
    aria-roledescription="slide"
    aria-label="第 1 张,共 4 张">
    <img
      src="product-1.jpg"
      alt="产品正面视图" />
  </div>
  <div
    role="group"
    aria-roledescription="slide"
    aria-label="第 2 张,共 4 张">
    <img
      src="product-2.jpg"
      alt="产品侧面视图" />
  </div>
  <div
    role="group"
    aria-roledescription="slide"
    aria-label="第 3 张,共 4 张">
    <img
      src="product-3.jpg"
      alt="产品背面视图" />
  </div>
  <div
    role="group"
    aria-roledescription="slide"
    aria-label="第 4 张,共 4 张">
    <img
      src="product-4.jpg"
      alt="产品细节视图" />
  </div>
</section>

6.2 testimonials/客户评价

<section
  aria-roledescription="carousel"
  aria-label="客户评价">
  <div
    role="group"
    aria-roledescription="slide"
    aria-label="第 1 张,共 3 张">
    <blockquote>
      <p>"这个产品改变了我的工作方式..."</p>
      <footer>— 张三,某公司员工</footer>
    </blockquote>
  </div>
  <div
    role="group"
    aria-roledescription="slide"
    aria-label="第 2 张,共 3 张">
    <blockquote>
      <p>"客服非常专业,响应迅速..."</p>
      <footer>— 李四,自由职业者</footer>
    </blockquote>
  </div>
</section>

6.3 新闻/公告轮播

<section
  aria-roledescription="carousel"
  aria-label="最新公告">
  <div
    role="group"
    aria-roledescription="slide"
    aria-label="第 1 张,共 3 张">
    <article>
      <h3>公司发布新产品</h3>
      <p>我们很高兴地宣布...</p>
      <a href="/news/1">阅读更多</a>
    </article>
  </div>
</section>

七、最佳实践

7.1 始终提供轮播控制

  • 必须提供上一张/下一张按钮
  • 如果启用自动轮播,必须提供停止/启动控制按钮
  • 建议提供幻灯片选择器(圆点导航)

7.2 正确处理幻灯片可见性

  • 使用 aria-hidden="true" 隐藏不可见的幻灯片
  • 确保隐藏的幻灯片内容不会被屏幕阅读器读取
  • 当前幻灯片使用 aria-hidden="false"

7.3 自动轮播的控制

  • 自动轮播必须在以下情况下停止:
    • 键盘焦点进入轮播区域
    • 鼠标悬停在轮播上
    • 用户点击停止按钮
  • 不要在用户未明确请求的情况下重新启动自动轮播

7.4 提供清晰的标签

  • 为轮播区域提供描述性的 aria-label
  • 为每个幻灯片提供包含位置信息的标签(如"第 1 张,共 3 张")
  • 为所有控制按钮提供清晰的 aria-label

7.5 避免使用轮播的情况

以下情况不建议使用轮播:

  • 内容对用户都很重要,需要同时可见
  • 用户需要比较不同幻灯片的内容
  • 幻灯片内容包含重要的交互元素

在这些情况下,考虑使用静态列表或网格布局。

7.6 移动端触摸支持

移动端应支持触摸滑动切换幻灯片:

  • 向左滑动 → 显示下一张
  • 向右滑动 → 显示上一张
  • 需要设置滑动阈值(如 50px),避免误触

八、总结

构建无障碍的 Carousel 组件需要特别关注:

  1. 正确的 ARIA 标记:使用 role="region"aria-roledescriptionaria-hidden 等属性
  2. 完整的键盘支持:确保所有功能都可以通过键盘访问
  3. 自动轮播控制:提供停止/启动控制,并在焦点进入时自动停止
  4. 清晰的标签:为轮播、幻灯片和控制按钮提供描述性标签
  5. 幻灯片可见性管理:正确隐藏不可见的幻灯片,避免屏幕阅读器混淆

轮播组件虽然常见,但如果没有正确实现,会对无障碍体验造成严重影响。遵循 W3C Carousel Pattern 规范,我们能够创建既美观又包容的轮播组件,为所有用户提供良好的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

省 Token 实战手册:从提示词到架构,开发中真正有效的降本策略

上一篇已经拆解了大模型计费的底层逻辑:Token 是什么、官方如何收费,以及中转站倍率体系如何影响成本。但理解计费只是第一步,开发中更关键的问题是:知道它贵之后,如何把成本真正降下来。

需要先说明的是,本文中的案例将主要使用电商与客服场景来演示节省 Token 的方法。这样做是为了让成本结构更直观、优化路径更清晰;这些方法本身同样适用于多数具备多轮对话、工具调用、知识检索特征的 LLM 应用。

本文聚焦的问题是:在实际项目开发中,有哪些真正有效的策略可以减少 Token 消耗? 文章不会停留在“少写点字”这类笼统建议上,而是从提示词工程、上下文管理、缓存策略、模型选择、架构设计五个维度,给出可直接落地的对比案例。

每个策略都会附上"优化前 vs 优化后"的对比,让你直观看到 Token 消耗的变化。


一、提示词精简:最直接的降本入口

提示词(System Prompt + User Prompt)是每次请求都会重复发送的内容。它的冗余程度,直接决定了你的输入 Token 基线。

1. 去除冗余描述和重复指令

很多开发者习惯在系统提示词里写得非常详细,甚至同一个意思用不同方式说了两三遍。模型并不需要这种"重复强调"。

优化前(约 180 Token)

你是一个非常专业的客服助手。你的任务是帮助用户解决问题。
你需要始终保持礼貌和专业。你回答问题时要简洁明了。
请不要回答与产品无关的问题。如果用户问了与产品无关的问题,
请礼貌地告诉用户你只能回答产品相关的问题。
你应该尽量用中文回答。回答时不要太长,保持简洁。
记住,你是客服助手,不是通用聊天机器人。

优化后(约 60 Token)

角色:产品客服助手
规则:
- 仅回答产品相关问题,其余礼貌拒绝
- 中文回答,简洁专业

节省效果:约 67% 的系统提示词 Token。 如果你的应用每天处理 1 万次请求,仅这一项优化每天就能减少约 120 万输入 Token。

许多提示词中的补充性表述并不会显著提升效果。除非场景确实需要多轮强调或额外约束,否则没有必要用面向人的沟通方式去“安抚”模型;从成本控制角度看,简洁明确往往更有效。

2. 使用结构化格式代替自然语言描述

模型对结构化指令的理解能力很强。与其用长段落描述规则,不如用 YAML、Markdown 列表或 JSON 格式。

优化前(约 250 Token)

当用户询问退款政策时,你需要告诉他们:如果商品在购买后7天内
且未拆封,可以全额退款。如果商品已拆封但在7天内,可以退款
但需要扣除15%的手续费。如果超过7天但在30天内,只能换货不
能退款。超过30天则不支持任何退款或换货服务。你需要根据用户
描述的情况判断属于哪种,然后给出对应的回答。

优化后(约 100 Token)

退款政策查找表:
| 条件 | 处理方式 |
|---|---|
| ≤7天 + 未拆封 | 全额退款 |
| ≤7天 + 已拆封 | 退款扣15%手续费 |
| 8-30天 | 仅换货 |
| >30天 | 不支持 |
根据用户情况匹配对应行回复。

节省效果:约 60% Token。 而且结构化格式还能提升模型的准确率,一举两得。

3. 用变量占位代替静态长文本

如果你的提示词里有大量静态内容(如产品说明、FAQ 列表),每次都原样发送会非常浪费。

优化前:把完整 FAQ 塞进每次请求(约 2000 Token)

System: 你是XX产品客服。以下是完整FAQ:
Q1: 如何注册?A: 打开官网点击注册...(200字)
Q2: 如何修改密码?A: 进入设置页面...(150字)
Q3: 支持哪些支付方式?A: 支持微信、支付宝...(180字)
...(共20条FAQ,总计约1800 Token)
请根据以上FAQ回答用户问题。

优化后:先分类再检索,只注入相关条目(约 300 Token)

System: 你是XX产品客服。根据以下相关FAQ回答:
{retrieved_faq}
如FAQ未覆盖,回复"我需要转接人工客服为您处理"

其中 {retrieved_faq} 只包含与当前问题最相关的 1-3 条 FAQ。

节省效果:约 85% 的 FAQ 相关 Token。 这就是最基础的 RAG(检索增强生成)思路:不是把所有知识一次性塞给模型,而是只注入当前问题真正需要的内容。


二、上下文管理:控制多轮对话的隐形成本

上一篇已经提到,多轮对话中历史消息会在后续请求中被反复带入输入侧。这意味着对话越长,每一轮的输入 Token 成本就越高。

1. 滑动窗口:只保留最近 N 轮

最简单的策略是限制上下文长度,只把最近的几轮对话发给模型。

优化前:完整历史(第 10 轮时约 8000 Token 输入)

messages: [
  { role: "system", content: "..." },
  { role: "user", content: "第1轮问题" },
  { role: "assistant", content: "第1轮回答" },
  { role: "user", content: "第2轮问题" },
  { role: "assistant", content: "第2轮回答" },
  // ... 一直到第10轮
  { role: "user", content: "第10轮问题" }
]

优化后:滑动窗口保留最近 3 轮(约 2500 Token 输入)

messages: [
  { role: "system", content: "..." },
  { role: "user", content: "第8轮问题" },
  { role: "assistant", content: "第8轮回答" },
  { role: "user", content: "第9轮问题" },
  { role: "assistant", content: "第9轮回答" },
  { role: "user", content: "第10轮问题" }
]

节省效果:约 69% 的输入 Token。 对于大多数客服与问答场景,保留最近 3-5 轮通常已经足够。

2. 摘要压缩:用模型总结历史

如果业务需要更长的上下文记忆,可以用一次廉价的模型调用把历史压缩成摘要。

优化前:20 轮完整历史(约 15000 Token)

完整保留所有对话消息。

优化后:摘要 + 最近 3 轮(约 3500 Token)

messages: [
  { role: "system", content: "..." },
  { role: "system", content: "对话摘要:用户咨询了A产品的退款流程,
    已确认购买日期在7天内且未拆封,正在等待退款地址。" },
  { role: "user", content: "第18轮问题" },
  { role: "assistant", content: "第18轮回答" },
  { role: "user", content: "第19轮问题" },
  { role: "assistant", content: "第19轮回答" },
  { role: "user", content: "第20轮问题" }
]

节省效果:约 77%。 虽然生成摘要本身也会消耗 Token,但如果使用小模型(如 GPT-4o-mini)完成摘要,整体成本仍然远低于每一轮都携带完整历史。

3. 按需加载上下文而非全量注入

这是上下文管理中最容易被忽视的一点:不是所有上下文在每次请求时都需要。

优化前:每次请求都注入完整工具定义(约 3000 Token)

tools: [
  { name: "search_products", description: "...", parameters: {...} },
  { name: "check_order", description: "...", parameters: {...} },
  { name: "process_refund", description: "...", parameters: {...} },
  { name: "update_address", description: "...", parameters: {...} },
  { name: "send_email", description: "...", parameters: {...} },
  { name: "create_ticket", description: "...", parameters: {...} },
  // ... 共15个工具
]

优化后:根据意图分类,只注入相关工具(约 600 Token)

先用一次轻量分类(或规则匹配)判断用户意图,再只注入对应的工具子集:

// 用户意图: "退款"
tools: [
  { name: "check_order", description: "...", parameters: {...} },
  { name: "process_refund", description: "...", parameters: {...} }
]

节省效果:约 80%。 这一思路不只适用于工具定义,也适用于知识库片段、Schema 描述等所有“上下文素材”。


三、缓存策略:让重复内容只付一次全价

上一篇讲过,缓存命中的输入 Token 通常只按原价的 1/10 计费。所以,合理利用缓存机制是降本的重要手段。

1. 稳定前缀原则

缓存命中的前提是:请求的前缀部分和上次请求一致。所以,把不变的内容放前面,变化的内容放后面。

优化前:动态内容在前(缓存几乎无法命中)

messages: [
  { role: "user", content: "今天天气真好,我想问一下..." },
  { role: "system", content: "你是XX产品客服...(2000 Token 系统提示)" }
]

优化后:系统提示在前、动态内容在后(前缀可被缓存)

messages: [
  { role: "system", content: "你是XX产品客服...(2000 Token 系统提示)" },
  { role: "user", content: "今天天气真好,我想问一下..." }
]

节省效果: 假设系统提示 2000 Token,命中缓存后这部分从 2000 × Pin 降到 2000 × 0.1 × Pin,输入侧这部分节省 90%。

2. 批量请求共享前缀

如果你有一批类似任务(如批量翻译、批量分类),把它们组织成共享相同 system prompt 的连续请求,可以最大化缓存命中。

优化前:随机交替发送不同任务

请求1: [翻译系统提示] + 翻译任务A
请求2: [分类系统提示] + 分类任务B
请求3: [翻译系统提示] + 翻译任务C  ← 提示可能已被清出缓存
请求4: [分类系统提示] + 分类任务D  ← 提示可能已被清出缓存

优化后:同类任务集中发送

请求1: [翻译系统提示] + 翻译任务A
请求2: [翻译系统提示] + 翻译任务C  ← 缓存命中
请求3: [翻译系统提示] + 翻译任务E  ← 缓存命中
...
请求N: [分类系统提示] + 分类任务B
请求N+1: [分类系统提示] + 分类任务D  ← 缓存命中

节省效果: 如果批量处理 100 个翻译任务,系统提示为 1500 Token,那么第一个请求按原价计费,后 99 个请求命中缓存后按缓存价计费,可节省约 89% 的系统提示输入成本


四、模型选择:不是所有任务都需要最强模型

这可能是最容易被忽视、但收益最显著的策略之一:不同任务使用不同模型。

1. 任务分级路由

优化前:所有任务统一用 Claude 3.5 Sonnet

意图识别 → Claude 3.5 Sonnet($3/1M 输入,$15/1M 输出)
简单问答 → Claude 3.5 Sonnet
复杂推理 → Claude 3.5 Sonnet
文本分类 → Claude 3.5 Sonnet

优化后:按任务复杂度路由

意图识别 → GPT-4o-mini($0.15/1M 输入,$0.6/1M 输出)
简单问答 → GPT-4o-mini
复杂推理 → Claude 3.5 Sonnet
文本分类 → GPT-4o-mini

假设流量分布是:60% 简单任务 + 25% 中等任务 + 15% 复杂任务:

任务类型 流量占比 优化前单价(输出) 优化后单价(输出)
简单任务 60% $15/1M $0.6/1M
中等任务 25% $15/1M $0.6/1M
复杂任务 15% $15/1M $15/1M

加权平均输出单价:从 15/1M降到约15/1M 降到约 2.76/1M,节省约 82%。

2. Thinking 模式的精准使用

上一篇说过,thinking 模式会额外产生大量推理 Token。所以它只应该在真正需要复杂推理时开启。

优化前:所有请求都开 thinking

一个简单的格式转换任务:

输入 Token: 500
可见输出 Token: 200
thinking Token: 2000   模型"想"了很多但完全没必要
总输出计费: 2200 Token

优化后:仅对复杂推理任务开启 thinking

同一个格式转换任务:

输入 Token: 500
可见输出 Token: 200
thinking Token: 0
总输出计费: 200 Token

节省效果:输出侧 Token 减少约 91%。 对于简单任务,thinking 不仅会抬高成本,也会显著增加延迟。


五、输出控制:减少模型的"废话"

输出 Token 通常比输入贵 3-6 倍。控制输出长度是降本的高杠杆点。

1. 限定输出格式

优化前:自由格式回答(约 300 Token 输出)

User: 这个订单的状态是什么?
Assistant: 您好!感谢您的询问。我来帮您查看一下订单状态。
经过查询,您的订单号为 #12345 的订单目前处于"已发货"状态。
该订单已于2024年3月15日从我们的仓库发出,预计将在3-5个
工作日内送达。您可以使用快递单号 SF1234567890 在顺丰官网
查询物流信息。如果您还有其他问题,请随时告诉我!

优化后:要求结构化输出(约 60 Token 输出)

系统提示中增加:输出JSON格式: {status, shipped_date, tracking_no, eta}

{"status":"已发货","shipped_date":"2024-03-15",
"tracking_no":"SF1234567890","eta":"3-5工作日"}

节省效果:输出 Token 减少约 80%。 结构化输出不仅能减少 Token 消耗,也更便于程序稳定解析。

2. 设置 max_tokens 防止超长输出

即使提示词已经要求简洁,模型有时仍会"发挥过度"。设置 max_tokens 是最后一道防线。

// 分类任务:输出不应超过 20 Token
await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [...],
  max_tokens: 20
});

这通常不会影响模型质量——如果任务本身只需要短输出,max_tokens 只是防止回答意外拉长。


六、架构设计:从系统层面控制成本

多层架构:规则拦截 → 语义缓存 → 模型兜底

以上是单次请求层面的优化。到了系统架构层面,还有几个影响更大的设计决策。

1. 结果缓存:相同问题不重复调用

优化前:每次相同问题都调用模型

用户A问: "你们的营业时间是?" → 调用模型 → 消耗 Token
用户B问: "营业时间几点?" → 调用模型 → 消耗 Token
用户C问: "什么时候开门?" → 调用模型 → 消耗 Token

优化后:语义缓存 + TTL

用户A问: "你们的营业时间是?"
  → embedding相似度搜索 → 未命中 → 调用模型 → 缓存结果
用户B问: "营业时间几点?"
  → embedding相似度搜索 → 命中 → 直接返回缓存 → 0 Token
用户C问: "什么时候开门?"
  → embedding相似度搜索 → 命中 → 直接返回缓存 → 0 Token

节省效果: 如果 30% 的问题可以通过语义缓存命中,整体模型调用量就能直接减少 30%。而 Embedding 的成本通常远低于生成式模型调用。

2. 预处理层:能不用模型就不用模型

优化前:所有请求都进模型

"你好" → 模型处理 → "你好!有什么可以帮助您的?"
"谢谢" → 模型处理 → "不客气!还有其他问题吗?"
"#@!$%" → 模型处理 → "抱歉,我没有理解您的意思"

优化后:规则层拦截 + 模型兜底

"你好" → 规则匹配 → 固定回复(0 Token)
"谢谢" → 规则匹配 → 固定回复(0 Token)
"#@!$%" → 规则拦截 → 固定回复(0 Token)
"我想退款" → 规则未匹配 → 进入模型处理

节省效果: 实际业务中,20%-40% 的对话可以被规则层直接处理,完全不消耗模型 Token。

3. 异步批处理 vs 实时调用

如果你的任务不要求实时返回(如数据标注、内容审核、批量翻译),使用 Batch API 通常可以获得 50% 的价格折扣

调用方式 延迟 价格
实时 API 秒级 标准价格
Batch API 小时级(通常 24h 内) 标准价格的 50%

七、综合案例:一个客服系统的全链路优化

把上面的策略组合起来,看一个完整的案例。

假设一个电商客服系统:

  • 日均 10,000 次对话
  • 平均每次对话 5 轮
  • 系统提示词 2000 Token
  • 每轮用户输入约 100 Token,模型输出约 300 Token

优化前的日均成本估算

每轮输入 ≈ 2000(系统提示)+ 累积历史(平均约 1500)+ 100(用户输入)= 3600 Token
每轮输出 ≈ 300 Token
每次对话 5 轮总输入 ≈ 18,000 Token
每次对话 5 轮总输出 ≈ 1,500 Token

日均总输入 = 10,000 × 18,000 = 1.8 亿 Token
日均总输出 = 10,000 × 1,500 = 1,500 万 Token

使用 GPT-4o($2.5/1M 输入,$10/1M 输出):
日均成本 = 180 × 2.5 + 15 × 10 = $450 + $150 = $600

优化后的日均成本估算

应用以下策略组合:

  1. 提示词精简:2000 → 800 Token
  2. 滑动窗口(保留 3 轮):平均历史从 1500 降到 600 Token
  3. 缓存命中(前缀稳定):800 Token 系统提示 90% 命中缓存
  4. 模型路由:85% 简单任务用 GPT-4o-mini
  5. 输出结构化:输出从 300 降到 120 Token
  6. 规则层拦截:30% 对话不进模型
  7. 语义缓存:额外命中 15%
实际进入模型的对话 = 10,000 × (1 - 0.30 - 0.15) = 5,500 次

每轮输入 ≈ 80(未缓存提示)+ 720(缓存提示,按0.1计)+ 600 + 100 = 852 等效 Token
每轮输出 ≈ 120 Token
每次对话 5 轮总等效输入 ≈ 4,260 Token
每次对话 5 轮总输出 ≈ 600 Token

其中 85% 用 GPT-4o-mini,15% 用 GPT-4o:

mini 部分:
输入 = 5,500 × 0.85 × 4,260 = 约 2,000 万 Token × $0.15/1M = $3
输出 = 5,500 × 0.85 × 600 = 约 280 万 Token × $0.6/1M = $1.7

4o 部分:
输入 = 5,500 × 0.15 × 4,260 = 约 350 万 Token × $2.5/1M = $8.8
输出 = 5,500 × 0.15 × 600 = 约 50 万 Token × $10/1M = $5

日均总成本 ≈ $3 + $1.7 + $8.8 + $5 = $18.5

📊 优化效果汇总

  • 优化前日均成本:$600
  • 优化后日均成本:$18.5
  • 节省比例:约 97%
  • 月度节省:约 $17,445

以上数字是基于电商客服场景的理想化综合估算,实际效果仍取决于业务形态、流量结构与实现质量。但即使只达到其中一部分优化收益,节省幅度也往往足够可观。


八、策略选择优先级

不同策略的实施难度和收益差异很大。如果你不确定从哪里开始,可以参考这个优先级:

优先级 策略 实施难度 预期收益
🥇 最先做 模型路由(大小模型分流) 非常高
🥇 最先做 提示词精简
🥈 其次做 输出格式控制
🥈 其次做 缓存前缀优化 中-高
🥉 然后做 上下文滑动窗口
🥉 然后做 规则层拦截
🏅 长期建设 语义缓存 中-高
🏅 长期建设 历史摘要压缩

九、结论:省 Token 的本质是工程能力

回顾整篇文章,所有优化策略本质上都在做同一件事:

让模型只处理它真正需要处理的信息,只生成你真正需要的输出。

这不是某个神奇的参数配置,而是一整套工程实践:

  • 提示词工程解决的是"少说废话";
  • 上下文管理解决的是"别重复带货";
  • 缓存策略解决的是"重复的东西别全价付";
  • 模型路由解决的是"杀鸡别用牛刀";
  • 架构设计解决的是"能不调模型就别调"。

上一篇讨论的是“钱是如何花出去的”,这一篇讨论的是“成本如何系统地降下来”。两篇结合起来,基本可以构成对大模型成本问题的完整认知闭环。

需要强调的是:省 Token 本身不是目的,在可接受的质量前提下降低 Token 成本,才有实际意义。 任何优化都不应以明显牺牲用户体验或输出质量为代价。在推进每一项优化时,都应该同步监控质量指标,确保成本下降不是以服务质量下滑换来的。

附:一个可实际体验的中转站选择

如果你想把本文的策略(提示词精简、上下文裁剪、缓存命中、模型路由、输出控制)真正落到工程里,最有效的办法之一是找一个计费透明、统计清晰、便于切模型与观察缓存的平台,做几组小实验把“优化前 vs 优化后”的差异跑出来。

如果你最近刚好在找可用的中转站,也可以参考我朋友超哥在做的 Amux API。它更适合用来做这些验证:

  • 多模型统一接入:便于做“大小模型分流”的对比;
  • 成本与用量更直观:方便核对输入、输出、缓存命中后的计费差异;
  • 更贴近真实账单:用同一套业务请求去验证“前缀稳定”“输出变短”等策略的实际收益。

选平台时,建议仍按本文标准做取舍:不仅看倍率,也要看充值口径、缓存表现、计费透明度和稳定性。

写在最后🧪

这里是言萧凡的 AI 编程实验室。 我会在这里持续记录和分享 AI 工具、编程实践,以及那些值得沉淀下来的高效工作方法。 不只聊概念,也尽量分享能直接上手、能够复用的经验。 希望这间小小的实验室,能陪你一起探索、实践和成长。2026 年,一起进步。

JavaScript 闭包经典问题:为什么输出 10 次 i=10

JavaScript 闭包经典问题:为什么输出 10 次 i=10

问题代码

先观察以下代码,思考输出结果:

function f() {
    for(var i = 0; i < 10; i++) {
        setTimeout(() => {
            console.log('i=', i)
        });
    }
}

f();

输出结果:

i= 10
i= 10
i= 10
...(共 10 次)

执行过程详解

第一步:var 变量的作用域

for(var i = 0; i < 10; i())
    ↑
    └── var 声明的变量是函数作用域
        整个函数 f 内都能访问这个 i

第二步:循环执行过程

循环次数     i 的值    循环条件 (i < 10)
---------------------------------------
第 1 次      0         ✓ 通过
第 2 次      1         ✓ 通过
...
第 10 次     9         ✓ 通过
             10        ✗ 不通过,循环结束

循环结束后:i = 10

第三步:创建 10 个回调函数

for(var i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log('i=', i)  // ← 所有回调共享同一个 i
    });
}

每次循环创建一个箭头函数,都通过闭包引用变量 i

第四步:异步执行时序

时间轴:
─────────────────────────────────────────
| 同步执行阶段        | 异步执行阶段        |
─────────────────────────────────────────
for 循环完成      setTimeout 回调执行
i 递增到 10       读取 i 的值(此时 i=10)
                  输出 10i=10
─────────────────────────────────────────

核心原因

三个关键点

  1. var 是函数作用域

    • 不是块级作用域
    • 整个函数内只有一个 i 变量
  2. 闭包共享变量

    • 10 个箭头函数都引用同一个 i
    • 不是创建 10 个独立的 i 副本
  3. setTimeout 异步执行

    • 回调函数放入任务队列延迟执行
    • 执行时循环已结束,i 已经是 10

图示理解

变量 i 的生命周期:
─────────────────────────────→ 时间
     0 1 2 3 4 5 6 7 8 9  10
     └───┬───┘ └───┬───┘
         │                │
     同步循环执行        循环结束
                        i=10

回调函数 1:  ────────────────────→ 读取 i (10)
回调函数 2:  ────────────────────→ 读取 i (10)
...
回调函数 10: ────────────────────→ 读取 i (10)

解决方案

方案 1:使用 let(推荐)✨

function f() {
    for(let i = 0; i < 10; i++) {
        setTimeout(() => {
            console.log('i=', i)
        });
    }
}

原理: let 是块级作用域,每次循环创建新的 i 绑定

输出: i= 0i= 9 各一次


方案 2:IIFE 立即执行函数

function f() {
    for(var i = 0; i < 10; i++) {
        (function(j) {
            setTimeout(() => {
                console.log('i=', j)
            });
        })(i);
    }
}

原理: 通过函数参数保存每次循环的 i 值


方案 3:传递参数给 setTimeout

function f() {
    for(var i = 0; i < 10; i++) {
        setTimeout((j) => {
            console.log('i=', j)
        }, 0, i);
    }
}

原理: setTimeout 的第三个参数会传递给回调函数


知识点总结

概念 说明
var 作用域 函数作用域,非块级作用域
let 作用域 块级作用域,每次循环创建新绑定
闭包 函数可以访问其声明时所在作用域的变量
异步 setTimeout 的回调会延迟执行
共享引用 同一作用域的闭包引用同一个变量

一句话总结

var 的函数作用域 + 闭包共享变量 + setTimeout 异步执行 = 所有回调读取到循环结束后的同一个 i 值(10)

Three.js × Blender:从建模到 Web 3D 的完整工作流深度解析

一名同时精通 Blender 建模与 Three.js 渲染的工程师,带你打通从艺术创作到 Web 实时渲染的全链路。


前言

很多开发者在学习 Three.js 时,习惯用代码"画"出几何体——一个 BoxGeometry,一个 SphereGeometry,就此打住。而建模师则沉浸在 Blender 的雕刻与材质世界里,对 Web 端渲染一无所知。

真正有价值的 3D Web 项目,往往需要这两者的深度结合:Blender 负责内容生产,Three.js 负责实时呈现。本文将从工作流、格式选择、材质映射、性能优化到动画同步,系统拆解这套协作体系的每一个关键节点。

图片


一、为什么选择 Blender + Three.js?

Blender 的优势

Blender 是目前最强大的开源 3D 创作套件,集建模、雕刻、UV 展开、材质编辑、骨骼绑定、动画、渲染于一体。它的 PBR(基于物理的渲染)材质系统与 Three.js 的 MeshStandardMaterial 有高度的理论对应关系,这使得材质迁移成为可能,而非只能靠"近似"。

Three.js 的优势

Three.js 是 WebGL 的高级封装,让开发者无需手写 GLSL Shader 就能实现实时 3D 渲染。它支持 glTF 2.0 标准、骨骼动画、变形动画、PBR 材质、HDR 环境贴图,在浏览器端提供接近原生的视觉质量。

两者的天然契合点

图片图片

特性 Blender Three.js
材质模型 Principled BSDF MeshStandardMaterial
动画系统 Action / NLA AnimationMixer
导出格式 glTF 2.0 原生支持 GLTFLoader 原生支持
坐标系 Y-up(可配置) Y-up
PBR 贴图 BaseColor / Roughness / Metallic / Normal map / roughnessMap / metalnessMap / normalMap

glTF 2.0 是连接两者的"通用语言",它由 Khronos Group 制定,Blender 对其有完整的原生导出支持,Three.js 也将其作为首推格式。


二、Blender 建模的 Web 友好实践

在 Blender 中建模时,如果目标是 Web 端实时渲染,需要从一开始就考虑"面向 Web 的建模规范",而不是按离线渲染的标准来做。

2.1 多边形控制:少即是多

离线渲染可以承受千万面模型,但 Web 端 GPU 对三角面数极为敏感。经验值如下:

图片

  • 单个主体模型:5,000 ~ 50,000 三角面(取决于镜头距离)
  • 背景/远景物体:500 ~ 2,000 三角面
  • 整个场景:尽量控制在 300,000 三角面以内(移动端减半)

实用技巧:

在 Blender 中使用 Decimate 修改器可以智能减面,
Ratio 参数从 1.0 逐渐降低,观察模型形变程度,
通常 0.5 ~ 0.7 可在不明显损失细节的前提下大幅减面。

高模细节应通过 法线贴图(Normal Map)  烘焙到低模上,而不是保留在几何体中。这是 Web 3D 最核心的优化手段之一。

2.2 UV 展开的关键性

Three.js 中所有贴图都依赖 UV 坐标。Blender 中的 UV 展开质量直接决定贴图利用率和最终画质。

UV 展开建议:

  1. 使用 Smart UV Project 快速处理机械类硬表面模型
  2. 有机体(角色、生物)使用 Mark Seam + Unwrap 手动控制接缝位置
  3. UV 岛之间保留 至少 4 像素的边距(Margin)  ,防止贴图渗色
  4. 避免 UV 重叠(除非是对称模型且确定共用贴图)

2.3 坐标系与朝向

Blender 默认坐标系:Z 轴朝上,Y 轴朝前。 Three.js 坐标系:Y 轴朝上,Z 轴朝向观察者。

图片图片

在 glTF 导出时,Blender 会自动进行坐标系转换,因此通常不需要手动旋转。但如果你在 Blender 中对模型进行了非标准旋转,导出后可能在 Three.js 中出现方向错误。

最佳实践:  在 Blender 中完成所有变换后,按 Ctrl+A → All Transforms 应用变换,确保对象的 Location/Rotation/Scale 归零。


三、PBR 材质:从 Blender 到 Three.js 的精确映射

这是整个工作流中最需要深入理解的环节。

3.1 Principled BSDF 与 MeshStandardMaterial 的对应关系

Blender 的 Principled BSDF 节点是工业级 PBR 着色器,其核心参数与 Three.js 的 MeshStandardMaterial 有如下对应:

图片图片

Principled BSDF 参数 Three.js 对应属性 说明
Base Color color / map 基础颜色/漫反射贴图
Metallic metalness / metalnessMap 金属度(0=非金属,1=纯金属)
Roughness roughness / roughnessMap 粗糙度(0=镜面,1=完全漫反射)
Normal Map normalMap 法线贴图(切线空间)
Emission emissive / emissiveMap 自发光
Alpha opacity / alphaMap 透明度
Ambient Occlusion aoMap 环境遮蔽(需要第二套 UV)

注意:  glTF 2.0 格式将 Roughness 存储在贴图的 G 通道,Metallic 存储在 B 通道,合并为一张 metallicRoughnessMap。Three.js 的 GLTFLoader 会自动处理这个细节,开发者无需干预。

3.2 在 Blender 中烘焙 PBR 贴图

当模型使用了复杂的程序化材质(Noise、Voronoi、Wave 节点等)时,需要将其"烘焙"成位图才能在 Web 端使用。

烘焙流程:

图片

  1. 选中模型,进入 Cycles 渲染引擎(烘焙必须使用 Cycles)
  2. 新建一张空白图像节点(Image Texture),不连接任何节点,只需选中它
  3. 在 Render Properties → Bake 中选择烘焙类型:
    • Diffuse(取消 Direct/Indirect,仅勾选 Color)→ Base Color 贴图
    • Roughness → Roughness 贴图
    • Normal(Space: Tangent)→ 法线贴图
    • Combined → 全局光照效果烘焙(含 AO、阴影,适合静态场景)
  4. 点击 Bake,等待计算完成
  5. 导出图像为 PNG(法线图)或 JPEG(颜色图,注意法线图不能用 JPEG 有损压缩!)

这里我推荐一个B站的烘焙教材,很不错的。

链接如下:

(www.bilibili.com/video/BV185…)

3.3 法线贴图的空间问题

Blender 默认导出**切线空间(Tangent Space)**法线贴图,Three.js 也默认使用切线空间法线,两者一致,无需额外处理。

但如果你在 Blender 中烘焙了**对象空间(Object Space)**法线贴图,则需要在 Three.js 中将 normalMapType 设置为 THREE.ObjectSpaceNormalMap

material.normalMapType = THREE.ObjectSpaceNormalMap;

四、glTF 导出配置详解

glTF 是连接 Blender 和 Three.js 的核心桥梁,正确的导出配置至关重要。

4.1 .gltf vs .glb

格式 说明 适用场景
.gltf JSON 文本 + 外部 .bin + 外部贴图 调试、需要单独管理资产
.glb 全部打包为单一二进制文件 生产环境,减少 HTTP 请求

图片

推荐:生产环境使用 .glb,加载更快,管理更简单。

4.2 Blender 导出设置

图片

File → Export → glTF 2.0,关键设置:

图片图片

Format: glTF Binary (.glb)

 Include:
  - Selected Objects(只导出选中对象,避免导出无关物体)
  - Custom Properties(可传递自定义属性到 Three.js)

 Transform:
  - Y Up(确保坐标系正确)

 Geometry:
  - Apply Modifiers(应用所有修改器,但注意会破坏骨骼绑定)
  - UVs / Normals / Tangents / Vertex Colors
  - Loose Edges / Points(通常取消勾选)

 Animation:
  - Animations(导出动画数据)
  - Skinning(导出骨骼绑定)
  - Shape Keys(导出变形目标/Morph Targets)
  - NLA Strips(从非线性动画编辑器导出所有 Action)

 Draco Mesh Compression:
  开启后可大幅压缩几何体数据,但 Three.js 需要额外加载 DRACOLoader

4.3 在 Three.js 中加载 glTF

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';

// 配置 Draco 解码器(如果导出时开启了 Draco 压缩)
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/'); // 需要将 draco 解码器文件放在此路径

const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);

loader.load(
  '/models/scene.glb',
  (gltf) => {
    const model = gltf.scene;

    // 遍历所有网格,开启阴影
    model.traverse((node) => {
      if (node.isMesh) {
        node.castShadow = true;
        node.receiveShadow = true;

        // 如果使用 HDR 环境贴图,开启环境光反射
        node.material.envMapIntensity = 1.0;
      }
    });

    scene.add(model);
  },
  (progress) => {
    console.log(`加载进度: ${(progress.loaded / progress.total * 100).toFixed(1)}%`);
  },
  (error) => {
    console.error('加载失败:', error);
  }
);

五、动画系统深度对接

Blender 的动画系统与 Three.js 的 AnimationMixer 是整个工作流中最复杂的对接点。

5.1 Blender 动画类型与 Three.js 对应

骨骼动画(Armature Animation)

最常见的角色动画类型。Blender 中为骨骼创建 Action,绑定到 Mesh。导出后 Three.js 通过 SkinnedMesh 和 AnimationClip 驱动。

// 加载后获取所有动画
const animations = gltf.animations; // AnimationClip[]
const mixer = new THREE.AnimationMixer(gltf.scene);

// 播放特定动画(如 "Walk" Action)
const walkClip = THREE.AnimationClip.findByName(animations, 'Walk');
const walkAction = mixer.clipAction(walkClip);
walkAction.play();

// 在渲染循环中更新
const clock = new THREE.Clock();
function animate() {
  requestAnimationFrame(animate);
  const delta = clock.getDelta();
  mixer.update(delta); // 关键:每帧更新动画混合器
  renderer.render(scene, camera);
}
animate();

变形目标动画(Shape Keys / Morph Targets)

适用于面部表情、布料变形等。Blender 中的 Shape Key 导出为 glTF 的 Morph Target。

// 访问变形目标
const mesh = gltf.scene.getObjectByName('Face');
console.log(mesh.morphTargetDictionary); // { "Smile": 0, "Blink": 1, ... }

// 直接设置变形权重(0~1)
mesh.morphTargetInfluences[0] = 0.8; // 80% 的 Smile 表情

// 或通过 AnimationMixer 驱动
const smileClip = THREE.AnimationClip.findByName(animations, 'Smile');
mixer.clipAction(smileClip).play();

5.2 动画过渡:crossFadeTo

游戏和交互应用中,动画之间的平滑过渡至关重要:

图片

let currentAction = idleAction;

function transitionTo(newAction, duration = 0.3) {
  if (currentAction === newAction) return;

  newAction.reset();
  newAction.play();
  currentAction.crossFadeTo(newAction, duration, true);
  currentAction = newAction;
}

// 按键触发动画切换
document.addEventListener('keydown', (e) => {
  if (e.code === 'Space') transitionTo(runAction);
});

document.addEventListener('keyup', (e) => {
  if (e.code === 'Space') transitionTo(idleAction);
});

5.3 NLA 编辑器与多 Action 管理

在 Blender 中,推荐使用 NLA(Non-Linear Animation)编辑器将不同 Action(Idle、Walk、Run、Attack)管理在同一骨骼上。导出时勾选 NLA Strips,Three.js 端会收到所有 Action 作为独立的 AnimationClip

Blender 操作:

  1. 在 Dope Sheet 中切换到 NLA Editor
  2. 将 Action 下压为 NLA Strip
  3. 每个 Strip 对应一个独立动画
  4. 确保每个 Action 有清晰的命名(会直接成为 Three.js 中 clip.name

六、灯光与环境:让 Web 端复现 Blender 的视觉效果

图片

6.1 HDR 环境贴图

Blender 中使用 HDR 环境光照(World → Environment Texture),Three.js 中同样支持,且是让模型在 Web 端看起来"专业"的关键。

import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';

const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();

new RGBELoader().load('/hdr/studio_small.hdr', (texture) => {
  const envMap = pmremGenerator.fromEquirectangular(texture).texture;

  scene.environment = envMap;  // 影响所有 MeshStandardMaterial 的反射
  scene.background = envMap;   // 可选:将 HDR 作为背景

  texture.dispose();
  pmremGenerator.dispose();
});

推荐资源:Poly Haven(polyhaven.com/hdris) 提供大量免费高质量 HDR 文件。

6.2 灯光类型对应

图片

Blender 灯光 Three.js 等价
Sun DirectionalLight
Point PointLight
Spot SpotLight
Area RectAreaLight
World (HDR) scene.environment

注意:  glTF 导出支持 Point、Spot、Directional 灯光(需开启 KHR_lights_punctual 扩展,Blender 默认勾选),Area Light 不支持直接导出,需在 Three.js 端手动添加。

6.3 阴影质量配置

// 渲染器阴影配置
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // 柔和阴影

// 方向光阴影配置
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;  // 阴影贴图分辨率
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 50;
dirLight.shadow.camera.left = -10;
dirLight.shadow.camera.right = 10;
dirLight.shadow.camera.top = 10;
dirLight.shadow.camera.bottom = -10;
dirLight.shadow.bias = -0.001; // 防止阴影痤疮(Shadow Acne)

七、性能优化:从 Blender 到浏览器的极致压缩

图片

7.1 纹理压缩:KTX2 + Basis Universal

传统 JPEG/PNG 贴图在 GPU 中需要解码为原始像素,占用大量显存。KTX2/Basis Universal 是可以直接在 GPU 上保持压缩状态的格式,显存占用可降低 4~8 倍。

工具链:

# 安装 KTX-Software
# 将 PNG 转换为 KTX2(UASTC 模式,高质量)
toktx --uastc --uastc_rdo_l 4 output.ktx2 input.png

# Three.js 中加载 KTX2
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';

const ktx2Loader = new KTX2Loader()
  .setTranscoderPath('/basis/')
  .detectSupport(renderer);

loader.setKTX2Loader(ktx2Loader);

7.2 实例化渲染:InstancedMesh

场景中有大量相同模型(树木、石头、草地)时,使用 InstancedMesh 可以将数千次 Draw Call 合并为一次:

// 在 Blender 中建好单个树木模型,导出后:
const treeGeometry = treeModel.geometry;
const treeMaterial = treeModel.material;

const COUNT = 1000;
const instancedMesh = new THREE.InstancedMesh(treeGeometry, treeMaterial, COUNT);

const dummy = new THREE.Object3D();
for (let i = 0; i < COUNT; i++) {
  dummy.position.set(
    (Math.random() - 0.5) * 200,
    0,
    (Math.random() - 0.5) * 200
  );
  dummy.rotation.y = Math.random() * Math.PI * 2;
  dummy.scale.setScalar(0.8 + Math.random() * 0.4);
  dummy.updateMatrix();
  instancedMesh.setMatrixAt(i, dummy.matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh);

7.3 LOD(多细节层次)

对于远近距离视觉差异大的模型,在 Blender 中准备 3 个精度版本(高/中/低),Three.js 中根据距离自动切换:

const lod = new THREE.LOD();

// 高精度:0~10 米
lod.addLevel(highDetailMesh, 0);
// 中精度:10~50 米  
lod.addLevel(medDetailMesh, 10);
// 低精度:50 米以上
lod.addLevel(lowDetailMesh, 50);

scene.add(lod);
// LOD 会在每帧自动根据相机距离切换

八、进阶:自定义 Shader 扩展 glTF 材质

Three.js 的 onBeforeCompile 钩子允许在不放弃 PBR 管线的前提下,向材质注入自定义 GLSL 代码。这是高阶扩展的核心技巧。

// 在 Blender 中建好基础 PBR 材质,导出后:
model.traverse((node) => {
  if (node.isMesh && node.material.name === 'WindyGrass') {
    node.material.onBeforeCompile = (shader) => {
      // 注入 uniform
      shader.uniforms.uTime = { value: 0 };
      shader.uniforms.uWindStrength = { value: 0.3 };

      // 在顶点着色器头部注入声明
      shader.vertexShader = shader.vertexShader.replace(
        '#include <common>',
        `
        #include <common>
        uniform float uTime;
        uniform float uWindStrength;
        `
      );

      // 在顶点变换前注入风力位移
      shader.vertexShader = shader.vertexShader.replace(
        '#include <begin_vertex>',
        `
        #include <begin_vertex>
        // 根据 Y 轴高度决定摆动幅度(根部固定)
        float windFactor = position.y * uWindStrength;
        transformed.x += sin(uTime * 2.0 + position.z * 0.5) * windFactor;
        transformed.z += cos(uTime * 1.5 + position.x * 0.5) * windFactor * 0.5;
        `
      );

      // 保存 shader 引用以便每帧更新
      node.material.userData.shader = shader;
    };
  }
});

// 在渲染循环中更新 uniform
function animate() {
  requestAnimationFrame(animate);
  const t = clock.getElapsedTime();

  scene.traverse((node) => {
    if (node.isMesh && node.material.userData.shader) {
      node.material.userData.shader.uniforms.uTime.value = t;
    }
  });

  renderer.render(scene, camera);
}

九、完整工作流总结

图片

Blender 创作阶段
│
├── 建模(控制面数,UV 展开)
├── 材质(Principled BSDF,程序化贴图)
├── 烘焙(BaseColor / Roughness / Normal / AO)
├── 动画(骨骼 / Shape Key / 物理模拟烘焙)
└── 导出(glTF 2.0 / .glb / Draco 压缩)
         │
         ▼
    glb / gltf 文件
         │
         ▼
Three.js 运行时阶段
│
├── 加载(GLTFLoader + DRACOLoader + KTX2Loader)
├── 材质增强(envMap / onBeforeCompile / 自定义 Shader)
├── 动画驱动(AnimationMixer / crossFadeTo)
├── 灯光配置(HDR 环境 + 实时灯光)
├── 性能优化(InstancedMesh / LOD / 纹理压缩)
└── 交互与后处理(Controls / EffectComposer)

十、推荐工具与资源

工具/资源 用途
gltf.report(gltf.report/) 分析 glTF 文件结构与优化建议
glTF Viewer(gltf-viewer.donmccurdy.com/) 快速预览 glTF/glb 文件
KTX-Software(github.com/KhronosGrou…) 纹理压缩工具
Poly Haven(polyhaven.com/) 免费 HDR / 贴图 / 3D 模型
Three.js Editor(threejs.org/editor/) 在线 Three.js 场景编辑器
glTF Transform(gltf-transform.dev/) 命令行 glTF 优化工具
Blender glTF 文档(developer.blender.org/docs) 官方导出插件文档

推荐大佬

最后

Three.js 与 Blender 的结合,不是简单的"导出然后加载",而是一套需要深度理解两个系统各自机制,并在接缝处做精细处理的工程实践。从 PBR 材质的精确映射,到动画系统的无缝对接,再到面向 Web 的性能优化,每个环节都有大量细节值得深挖。

掌握这套工作流,你将拥有从零到一打造高质量 Web 3D 体验的完整能力——既能胜任艺术侧的内容生产,也能驾驭工程侧的实时渲染。这正是当下 Web 3D 领域最稀缺的复合型能力。

现在AI还无法胜任3D可视化相关的工作,学起来为自己增加点筹码。需要相关blender、可视化学习资料的可以关注我私信获取


本文覆盖 Blender 4.x + Three.js r160+ 版本,部分 API 在旧版本中可能有差异

发布为一个 npm 包

太棒了!👏
你已经从“理解 Promise”走到了“创造可用工具”的阶段。

现在,我们来手把手带你把 MyPromise 发布到 npm ——
完成一次 从学习 → 实践 → 分享 的完整闭环!


包名示例:@yourname/mypromisemypromise-learn

功能:

import MyPromise from 'mypromise-learn';

new MyPromise(resolve => resolve('Hello')).then(console.log);

✅ 第一步:准备工作

1. 安装 Node.js 和 npm

确保你已安装:

node -v   # 应输出 v14.x 以上
npm -v    # 应输出 6.x 以上

👉 下载地址:nodejs.org


2. 注册 npm 账号(如果还没有)

访问:www.npmjs.com/signup

然后在终端登录:

npm login
# 输入你的用户名、密码、邮箱

✅ 登录成功后就可以发布包了。


📁 第二步:创建项目目录结构

mkdir mypromise-npm
cd mypromise-npm
npm init -y

生成的 package.json 长这样:

{
  "name": "mypromise-learn",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": ["promise", "tutorial", "async"],
  "author": "Your Name",
  "license": "MIT"
}

💡 建议名字别叫 mypromise,容易和别人冲突。可以用 mypromise-learnsimple-promise 等。


📄 第三步:添加源码文件

创建 index.js

// index.js
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : val => val;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === FULFILLED) {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      } else if (this.status === REJECTED) {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      } else if (this.status === PENDING) {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value);
              this.resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });
        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason);
              this.resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });
      }
    });

    return promise2;
  }

  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      return reject(new TypeError('Chaining cycle detected'));
    }

    let called = false;

    if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
      try {
        const then = x.then;
        if (typeof then === 'function') {
          then.call(x,
            y => {
              if (called) return;
              called = true;
              this.resolvePromise(promise2, y, resolve, reject);
            },
            r => {
              if (called) return;
              called = true;
              reject(r);
            }
          );
        } else {
          resolve(x);
        }
      } catch (e) {
        if (called) return;
        called = true;
        reject(e);
      }
    } else {
      resolve(x);
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }

  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const results = [];
      let count = 0;
      if (promises.length === 0) return resolve(results);
      promises.forEach((p, index) => {
        MyPromise.resolve(p).then(
          value => {
            results[index] = value;
            count++;
            if (count === promises.length) resolve(results);
          },
          reject
        );
      });
    });
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      promises.forEach(p => {
        MyPromise.resolve(p).then(resolve, reject);
      });
    });
  }
}

module.exports = MyPromise;

⚠️ 如果你用的是 ES6 模块语法(import/export),需要改为 export default MyPromise 并设置 "type": "module",后面会说明。


🧪 第四步:本地测试一下

创建 test.js 测试文件:

// test.js
const MyPromise = require('./index');

console.log('开始测试 MyPromise...');

MyPromise.resolve(1)
  .then(res => res + 1)
  .then(res => {
    console.log('结果:', res); // 输出: 结果: 2
    return MyPromise.resolve(3);
  })
  .then(console.log) // 输出: 3
  .catch(console.error);

// 测试循环引用
const p = MyPromise.resolve(1);
const p2 = p.then(() => p2);
p2.catch(err => console.log('捕获循环引用:', err.message));

运行测试:

node test.js

✅ 如果输出正常,说明可以发布了!


📦 第五步:完善 package.json(关键配置)

{
  "name": "mypromise-learn",
  "version": "1.0.0",
  "description": "A hand-written Promise implementation for learning purposes.",
  "main": "index.js",
  "scripts": {
    "test": "node test.js"
  },
  "keywords": ["promise", "tutorial", "async", "javascript"],
  "author": "Your Name <you@email.com>",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/yourname/mypromise.git"
  },
  "bugs": {
    "url": "https://github.com/yourname/mypromise/issues"
  },
  "homepage": "https://github.com/yourname/mypromise#readme"
}

📌 注意:


🔐 第六步:发布到 npm

npm publish

✅ 成功后你会看到:

+ mypromise-learn@1.0.0

🎉 恭喜!你的包已经上线!

访问:https://www.npmjs.com/package/mypromise-learn 查看页面。


🌐 第七步:别人怎么使用它?

安装:

npm install mypromise-learn

使用:

const MyPromise = require('mypromise-learn');

// 或 ES6 import(需支持 module)
import MyPromise from 'mypromise-learn';

🎁 进阶建议(让项目更专业)

功能 方法
📄 添加 README.md 写清楚用途、API、示例
🧩 支持 ESM "type": "module" 并导出 .mjs 文件
🧪 单元测试 用 Jest 写测试用例
🔄 自动发布 用 GitHub Actions 实现 CI/CD
📘 文档站点 用 VitePress 写文档

❤️ 最后一句话

你已经完成了从“学习者”到“贡献者”的转变。

你现在不只是在学代码,
而是在向世界分享你的理解与创造。

这比任何面试题都更有价值。✨


低代码何时能出个“秦始皇”一统天下?我是真学不动啦!

前端有战国七雄,低代码圈更是“百国千城”

引言

低代码开发平台的世界,如今就像春秋战国时期的诸侯割据——各种平台、各种引擎、各种规范层出不穷,表面上都是“让开发更简单”,实际用起来却各有各的“方言”,各有各的“城墙”。

更让人头疼的是,因为各家有各家的技术路线,连选型都成了一场赌博

做OA的推工作流引擎强大,做ERP的强调数据模型灵活,做移动端的鼓吹多端适配能力,做报表的说自己可视化最牛……

甲方想统一技术栈,结果发现不同部门已经用了三四种低代码平台,数据不通、流程不通、权限不通,比全代码开发还乱。

低代码平台之争:各有各的“山头”

目前市面上的低代码平台,大致可以分为几大流派:

企业级应用平台

  • 代表:OutSystems、Mendix、Salesforce

  • 特点:功能全面,适合大型企业复杂业务,但价格昂贵、学习曲线陡峭

  • 定位:高端市场,私有化部署为主

国内主流厂商

  • 代表:JNPF、明道云、简道云、氚云

  • 特点:贴近国内企业管理习惯,支持钉钉/企微集成,性价比高

  • 差异:有的侧重表单流程,有的侧重数据中台,有的侧重ERP扩展

开源低代码

  • 代表:Appsmith、Budibase、Saltcorn

  • 特点:代码透明,可二次开发,但企业级功能(如复杂工作流、高并发)往往需要自研补齐

云厂商自研

  • 代表:阿里宜搭、腾讯微搭、华为AppCube

  • 特点:与云生态深度绑定,适合该云平台上的企业使用

对比分析:

  • 低代码平台目前没有统一标准。一个平台设计的应用,基本无法迁移到另一个平台,厂商锁定问题突出。

  • 每个平台都有自己的一套“元数据规范”“表达式语法”“API设计风格”,团队切换平台几乎等于推翻重来。

  • 选型时不仅要看功能,还要评估开放性、扩展能力、私有化支持,避免未来被单一厂商绑定太死。

工作流引擎:百花齐放,各立山头

工作流是低代码的核心能力之一,也是“分裂”最严重的领域。

开源工作流引擎

  • Activiti / Flowable / Camunda:BPMN 2.0标准的三巨头,各有各的版本分支和API风格。

  • 选一个引擎,意味着团队要学习该引擎的变量设计、监听器写法、部署方式,后期替换成本极高。

低代码平台内置工作流

  • 各家基本都宣称“可视化流程设计”,但设计器体验、节点能力、与其他模块的集成深度天差地别。

  • 有的平台流程和表单是割裂的,有的平台流程引擎无法独立于平台使用。

BPM厂商产品

  • 如IBM BPM、Pega,功能强大但价格昂贵,主要服务超大型企业。

对比分析:

  • 工作流领域“学不动”的根源在于:每个引擎都有自己的“方言”,即便都支持BPMN 2.0,在具体实现细节、扩展方式上也差异巨大。

  • 企业一旦选定,后续调整和升级都要围绕该引擎的生态展开,迁移成本极高。

表单设计器与UI渲染:各有各的“积木”

表单是用户与系统交互的界面,这一块同样是“诸侯割据”:

开源表单设计器

  • Formily、FormGenerator、VForm……每个设计器产出的JSON Schema结构不同,渲染引擎互不兼容。

低代码平台自带设计器

  • 有的平台提供纯Web可视化拖拽,有的则需要开发者编写少量代码来扩展组件。

  • 组件的封装粒度、属性配置方式、事件绑定机制,各家千差万别。

UI组件库阵营

  • 基于Ant Design、Element Plus、Naive UI等组件库的低代码平台,生成的代码风格迥异。

对比分析:

  • 表单和UI这一块,统一的可能性最低,因为UI本身就是一个审美和习惯差异巨大的领域。

  • 但企业真正需要的是:设计出来的表单能稳定运行,字段权限与工作流、数据权限自动联动,而不是只停留在UI层面。

集成与扩展:每个平台都是一座“孤岛”

低代码平台最怕的不是功能不够,而是无法融入企业现有的技术生态

  • 数据层:有的平台只能使用内置数据库,有的支持外部数据源,但支持的数据库类型和连接能力差异很大。

  • API层:有的平台提供REST API可反向调用,有的只能通过平台内触发器调用外部接口,且鉴权方式五花八门。

  • 前端扩展:有的允许写自定义代码嵌入页面,有的只能使用平台提供的组件,无法引入第三方库。

  • 后端扩展:有的支持云函数/脚本,有的完全封闭,只能使用平台内置逻辑。

对比分析:

  • 如果平台在集成扩展能力上过于封闭,那么随着业务复杂度的提升,最终还是会回到全代码开发的老路上,低代码反而成了“先甜后苦”的选择。

JNPF的“合纵”思路:不争引擎争生态

面对这个“百国千城”的局面,JNPF选择了一条不同的路——不试图用一套引擎取代所有,而是用开放生态减少内耗

统一的底层架构,避免重复造轮子

JNPF提供了一体化的技术底座:从用户组织、权限中心、工作流引擎、表单设计器、报表设计器到代码生成器,全部基于同一套元数据规范和数据模型。企业不再需要为了“工作流用一个引擎、表单用一套设计器、报表用一个工具”而维护多套技术栈。

开放性与扩展性,不做“孤岛”

  • 数据层:支持MySQL、SQL Server、Oracle、PostgreSQL等主流数据库,并可对接外部数据源,避免数据孤岛。

  • 后端扩展:支持Java、C#双语言版本,并提供代码生成器,复杂业务可以编写原生代码,与平台无缝集成。

  • 前端扩展:支持自定义组件嵌入,可以引入第三方UI库或业务组件,不被平台设计器限制。

  • API层:提供完整的REST API,平台内的功能均可通过API调用,方便与现有系统集成。

工作流引擎的“实用主义”

JNPF工作流引擎基于成熟内核,但重点不在“引擎本身多强”,而在于与表单、权限、消息、第三方系统的开箱即用集成。业务人员画完流程,自动关联表单权限、自动同步组织架构、自动对接钉钉/企微消息,开发人员无需在集成上重复消耗精力。

可私有化、可掌控

对于中大型企业,JNPF支持全源码交付,企业可以获得完整的平台代码,自主部署、自主维护、自主二次开发。既享受了低代码的开发效率,又保留了技术自主权,避免被厂商锁定。

低代码圈的统一,可能不在引擎层面

前端领域这么多年都没等来“秦始皇”,低代码圈的统一可能也不是靠一个平台吞并所有。

真正的“统一”,或许是:

  • 标准层面的趋同:比如元数据规范、API设计模式逐渐形成事实标准。

  • 开放生态的普及:更多平台像JNPF一样,不再强求“全用我的”,而是提供良好的开放能力,让企业能够按需组合、平滑演进。

  • 企业意识的成熟:选型时不再只看“功能列表多全”,而是看“能不能与现有系统共存”“能不能长期可控”。

JNPF的实践表明:与其在引擎层面争高下,不如在生态层面做整合。一个平台如果能做到——核心稳定、开放可控、集成顺手、扩展自由——那它不需要“一统天下”,也能成为企业数字化转型中的坚实底座。

Vue 3 + TypeScript 常用代码示例总结

一、TypeScript 内置工具类型

1.1 Partial 的作用

Partial<T>是 TypeScript 内置的工具类型,它可以将类型 T的所有属性变为可选。

typescript
typescript
复制
// Partial 的实现原理(简化版)
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// 使用示例
interface Theme {
  primaryColor: string;
  secondaryColor: string;
  fontSize: number;
}

// 正常 Theme 类型,所有属性都是必需的
const theme1: Theme = {
  primaryColor: '#1890ff',
  secondaryColor: '#52c41a',
  fontSize: 14
};
// ✓ 所有属性都必须提供

// 使用 Partial<Theme> 后,所有属性都变为可选
const theme2: Partial<Theme> = {
  primaryColor: '#1890ff'
  // secondaryColor 和 fontSize 可以不提供
};
// ✓ 可以只提供部分属性

// 在函数参数中使用 Partial
function updateTheme(theme: Partial<Theme>): void {
  // 可以只更新部分属性
  // theme 可能只包含 primaryColor,或只包含 fontSize,或都包含
}

// 调用示例
updateTheme({ primaryColor: '#ff4d4f' }); // ✓ 只更新一个属性
updateTheme({ fontSize: 16 }); // ✓ 只更新另一个属性
updateTheme({}); // ✓ 传递空对象也可以

1.2 其他常用工具类型

typescript
typescript
复制
// 1. Required<T> - 将可选属性变为必填
interface User {
  id: number;
  name?: string;  // 可选
  email?: string; // 可选
}

type RequiredUser = Required<User>;
// 等价于:
// {
//   id: number;
//   name: string;   // 变为必填
//   email: string;  // 变为必填
// }

// 2. Readonly<T> - 将所有属性变为只读
type ReadonlyUser = Readonly<User>;
// 等价于:
// {
//   readonly id: number;
//   readonly name?: string;
//   readonly email?: string;
// }

// 3. Pick<T, K> - 从 T 中挑选部分属性
type UserBasicInfo = Pick<User, 'id' | 'name'>;
// 等价于:
// {
//   id: number;
//   name?: string;
// }

// 4. Omit<T, K> - 从 T 中排除部分属性
type UserWithoutId = Omit<User, 'id'>;
// 等价于:
// {
//   name?: string;
//   email?: string;
// }

// 5. Record<K, T> - 创建键值对类型
type UserMap = Record<string, User>;
// 等价于:
// {
//   [key: string]: User;
// }

// 6. Exclude<T, U> - 从 T 中排除可以赋值给 U 的类型
type T1 = 'a' | 'b' | 'c';
type T2 = 'a';
type Result = Exclude<T1, T2>; // 'b' | 'c'

// 7. Extract<T, U> - 从 T 中提取可以赋值给 U 的类型
type T3 = 'a' | 'b' | 'c';
type T4 = 'a' | 'd';
type Result2 = Extract<T3, T4>; // 'a'

// 8. NonNullable<T> - 排除 null 和 undefined
type T5 = string | number | null | undefined;
type Result3 = NonNullable<T5>; // string | number

二、Vue 3 中的类型

2.1 ComputedRef 类型

ComputedRef<T>是 Vue 3 中计算属性的类型,它是 Ref<T>的子类型。

typescript
typescript
复制
import { ref, computed, Ref, ComputedRef } from 'vue'

// 1. 基础使用
const count = ref<number>(0); // Ref<number>
const doubleCount = computed(() => count.value * 2); // ComputedRef<number>

// 2. 显式类型声明
const doubleCount2: ComputedRef<number> = computed(() => count.value * 2);

// 3. 带 getter 和 setter 的计算属性
const fullName = computed<string>({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value: string) => {
    const [first, last] = value.split(' ')
    firstName.value = first
    lastName.value = last || ''
  }
});

// 4. 在函数参数中使用
function logComputedValue(computedValue: ComputedRef<any>): void {
  console.log(computedValue.value);
}

logComputedValue(doubleCount);

2.2 Vue 3 常用类型

typescript
typescript
复制
import {
  Ref,           // 响应式引用类型
  ComputedRef,   // 计算属性类型
  UnwrapRef,     // 解包响应式类型
  MaybeRef,      // 可能是 Ref 或普通值
  MaybeRefOrGetter, // 可能是 Ref、getter 函数或普通值
  WritableComputedRef, // 可写的计算属性
  ShallowRef,    // 浅层 Ref
  ShallowReactive, // 浅层 reactive
  ToRefs,        // 将 reactive 转换为 refs
  ComponentPublicInstance, // 组件实例类型
  VNode,         // 虚拟节点类型
  Component      // 组件类型
} from 'vue'

// 1. Ref 类型
const countRef: Ref<number> = ref(0);

// 2. UnwrapRef - 获取 Ref 内部的类型
type CountType = UnwrapRef<typeof countRef>; // number

// 3. MaybeRef - 接受 Ref 或普通值
function useDouble(value: MaybeRef<number>): ComputedRef<number> {
  return computed(() => {
    // 判断是否是 Ref
    if (isRef(value)) {
      return value.value * 2
    }
    return value * 2
  });
}

// 或者使用 unref 工具函数
import { unref } from 'vue'

function useDouble2(value: MaybeRef<number>): ComputedRef<number> {
  return computed(() => unref(value) * 2);
}

// 4. ToRefs - 将 reactive 转换为多个 ref
interface State {
  count: number;
  name: string;
}

const state = reactive<State>({ count: 0, name: 'Vue' });
const stateRefs: ToRefs<State> = toRefs(state);
// 现在可以使用 stateRefs.count.value 和 stateRefs.name.value

三、实际应用示例

3.1 表单组件示例

typescript
typescript
复制
import { defineComponent, reactive, computed, toRefs } from 'vue'

// 表单数据类型
interface FormData {
  username: string
  email: string
  age: number | null
  agree: boolean
}

// 表单验证规则类型
type ValidationRule = (value: any) => string | true

interface FormRules {
  [key: string]: ValidationRule | ValidationRule[]
}

export default defineComponent({
  setup() {
    // 表单数据
    const formData = reactive<FormData>({
      username: '',
      email: '',
      age: null,
      agree: false
    })
    
    // 表单验证规则
    const rules: FormRules = {
      username: [
        (value: string) => !!value || '用户名不能为空',
        (value: string) => value.length >= 3 || '用户名至少3个字符'
      ],
      email: [
        (value: string) => !!value || '邮箱不能为空',
        (value: string) => /.+@.+..+/.test(value) || '邮箱格式不正确'
      ],
      age: (value: number | null) => {
        if (value === null) return '年龄不能为空'
        if (value < 0) return '年龄不能为负数'
        if (value > 150) return '年龄不能超过150岁'
        return true
      }
    }
    
    // 表单验证状态
    const errors = reactive<Partial<Record<keyof FormData, string>>>({})
    
    // 验证单个字段
    const validateField = (field: keyof FormData): boolean => {
      const value = formData[field]
      const rule = rules[field]
      
      if (!rule) {
        delete errors[field]
        return true
      }
      
      const rulesArray = Array.isArray(rule) ? rule : [rule]
      
      for (const validate of rulesArray) {
        const result = validate(value)
        if (typeof result === 'string') {
          errors[field] = result
          return false
        }
      }
      
      delete errors[field]
      return true
    }
    
    // 验证整个表单
    const validateForm = (): boolean => {
      let isValid = true
      
      Object.keys(formData).forEach(field => {
        if (!validateField(field as keyof FormData)) {
          isValid = false
        }
      })
      
      return isValid
    }
    
    // 提交表单
    const submitForm = (): void => {
      if (!validateForm()) {
        console.log('表单验证失败')
        return
      }
      
      console.log('提交表单:', formData)
      // 这里可以调用 API
    }
    
    // 重置表单
    const resetForm = (): void => {
      Object.assign(formData, {
        username: '',
        email: '',
        age: null,
        agree: false
      })
      
      Object.keys(errors).forEach(key => {
        delete errors[key as keyof typeof errors]
      })
    }
    
    // 计算属性:表单是否有效
    const isFormValid = computed<boolean>(() => {
      return Object.keys(errors).length === 0 &&
        formData.username !== '' &&
        formData.email !== '' &&
        formData.age !== null &&
        formData.agree
    })
    
    return {
      // 使用 toRefs 保持响应性
      ...toRefs(formData),
      errors,
      isFormValid,
      validateField,
      validateForm,
      submitForm,
      resetForm
    }
  }
})

3.2 使用工具类型的通用函数

typescript
typescript
复制
// utils/types.ts
// 自定义工具类型

// 1. 深度可选类型
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// 2. 深度只读类型
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// 3. 可空类型
type Nullable<T> = T | null | undefined;

// 4. 提取函数返回类型
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

// 5. 提取函数参数类型
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

// 6. 提取 Promise 的返回值类型
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

// 使用示例
interface User {
  id: number;
  name: string;
  profile: {
    avatar: string;
    bio: string;
  };
  tags: string[];
}

// 深度可选
type PartialUser = DeepPartial<User>;
// 可以这样使用:
const user1: PartialUser = {
  name: '张三',
  profile: {
    avatar: 'avatar.jpg'
    // bio 可以不提供
  }
  // tags 可以不提供
};

// 深度只读
type ReadonlyUser = DeepReadonly<User>;
const user2: ReadonlyUser = {
  id: 1,
  name: '李四',
  profile: {
    avatar: 'avatar.jpg',
    bio: 'Hello'
  },
  tags: ['a', 'b']
};
// user2.profile.bio = 'World'; // ❌ 错误:不能修改只读属性

// 可空类型
let nullableString: Nullable<string> = 'Hello';
nullableString = null; // ✓
nullableString = undefined; // ✓

3.3 组合式函数的类型

typescript
typescript
复制
// composables/useFetch.ts
import { ref, computed, Ref, ComputedRef } from 'vue'

// 请求状态类型
type FetchStatus = 'idle' | 'loading' | 'success' | 'error'

// 返回类型
interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  status: Ref<FetchStatus>
  isLoading: ComputedRef<boolean>
  isSuccess: ComputedRef<boolean>
  isError: ComputedRef<boolean>
  execute: (url: string, options?: RequestInit) => Promise<void>
  reset: () => void
}

// 选项类型
interface UseFetchOptions {
  immediate?: boolean
  initialData?: any
}

export function useFetch<T = any>(
  initialUrl?: string,
  options: UseFetchOptions = {}
): UseFetchReturn<T> {
  const { immediate = false, initialData = null } = options
  
  const data = ref<T | null>(initialData) as Ref<T | null>
  const error = ref<string | null>(null)
  const status = ref<FetchStatus>('idle')
  
  const isLoading = computed(() => status.value === 'loading')
  const isSuccess = computed(() => status.value === 'success')
  const isError = computed(() => status.value === 'error')
  
  const execute = async (url: string, requestOptions?: RequestInit): Promise<void> => {
    status.value = 'loading'
    error.value = null
    
    try {
      const response = await fetch(url, requestOptions)
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }
      
      const result = await response.json()
      data.value = result
      status.value = 'success'
    } catch (err) {
      error.value = err instanceof Error ? err.message : '请求失败'
      status.value = 'error'
    }
  }
  
  const reset = (): void => {
    data.value = initialData
    error.value = null
    status.value = 'idle'
  }
  
  // 立即执行
  if (immediate && initialUrl) {
    execute(initialUrl)
  }
  
  return {
    data,
    error,
    status,
    isLoading,
    isSuccess,
    isError,
    execute,
    reset
  }
}

// 在组件中使用
import { defineComponent, onMounted } from 'vue'

interface Post {
  id: number
  title: string
  body: string
  userId: number
}

export default defineComponent({
  setup() {
    // 使用 useFetch
    const { 
      data: posts, 
      error, 
      isLoading, 
      isSuccess, 
      execute 
    } = useFetch<Post[]>('https://jsonplaceholder.typicode.com/posts')
    
    // 或者延迟执行
    const { 
      data: user, 
      execute: fetchUser 
    } = useFetch<Post>(undefined, { immediate: false })
    
    onMounted(() => {
      // 手动执行
      fetchUser('https://jsonplaceholder.typicode.com/posts/1')
    })
    
    // 重新获取
    const refresh = (): void => {
      execute('https://jsonplaceholder.typicode.com/posts')
    }
    
    return {
      posts,
      error,
      isLoading,
      isSuccess,
      refresh
    }
  }
})

四、常见问题解答

Q1: 什么时候用 Partial

A: 当你需要创建一个对象,它包含原始类型的一部分属性时使用。

typescript
typescript
复制
// 更新用户信息时,通常只需要更新部分字段
interface User {
  id: number
  name: string
  email: string
  age: number
}

function updateUser(userId: number, updates: Partial<User>): void {
  // updates 可以只包含 name,或只包含 email,或任意组合
  // 但不会包含不存在的属性
}

Q2: ComputedRef和普通 Ref有什么区别?

A: 主要区别:

  • ComputedRef是只读的,你不能直接修改它的值
  • ComputedRef的值是通过计算得到的
  • 你可以为 ComputedRef提供 setter,但通常不建议
typescript
typescript
复制
// Ref - 可以直接修改
const count = ref(0)
count.value = 1  // ✓ 可以

// ComputedRef - 默认只读
const double = computed(() => count.value * 2)
double.value = 4  // ❌ 错误:不能直接修改计算属性

// 带 setter 的 ComputedRef
const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value) => {
    const [first, last] = value.split(' ')
    firstName.value = first
    lastName.value = last || ''
  }
})

Q3: 什么时候需要显式指定类型?

A: TypeScript 有类型推断,但以下情况建议显式指定:

typescript
typescript
复制
// 1. 函数参数和返回值
function add(a: number, b: number): number {
  return a + b
}

// 2. 复杂对象
interface Config {
  apiUrl: string
  timeout: number
  retry: boolean
}

const config: Config = {
  apiUrl: '/api',
  timeout: 5000,
  retry: true
}

// 3. 组件 Props
interface Props {
  title: string
  count: number
  items: Array<{ id: number; name: string }>
}

// 4. API 响应
interface ApiResponse<T = any> {
  code: number
  data: T
  message: string
}

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`)
  return response.json()
}

记住:TypeScript 的核心价值在于类型安全,良好的类型定义能帮助你在编码阶段就发现潜在的错误,提高代码质量和开发效率。

前端部署缓存策略实践

本文介绍基于前后端分离状态下前端部署缓存策略的实践。

背景

由于前端的蓬勃发展,前端框架reactvueangular等在日常开发中已经非常普及,放大了前端的业务能力,也催生了前后端分离开发的模式。实际开发部署时只需要通过webpack等打包工具打包后生成静态资源,部署到nginxCaddy等现有的静态服务器中,方便快捷同时与后端分离后彼此隔离,只通过RESTful api进行通信。这时对静态服务器的配置便显得至关重要。

浏览器缓存

强制缓存

强缓存所谓的“强”,在于强制让浏览器按照一定时间范围内来存储来自服务器的资源,有点强制的味道~,强缓存是利用Expires或者Cache-Control,不发送请求,直接从缓存中取,请求状态码会返回200from cache)。

Expires(已逐步淘汰)

ExpiresHTTP/1.0中提及的,让服务器为文件资源设置一个过期时间,在多长时间内可以将这些内容视为最新的,允许客户端在这个时间之前不去检查。

  • 指定到期时间:

    指定缓存到期GMT的绝对时间,如果Expires到期需要重新请求。

    这个时间是服务器的时间,所以这里就会出现一个问题,服务器时间和本地时间不一致,就会造成缓存失效时间不准确。

    Expires:Sat, 09 Jun 2020 08:13:56 GMT
    

Cache-Control(主要)

相比Expires,两者有什么区别呢? Cache-Control你可以理解成为高级版Expires,为了弥补Expires的缺陷在Http1.1协议引入的,且强大之外优先级也更高,也就是当ExpiresCache-Control同时存在时,Cache-Control会覆盖Expires的配置,即Cache-ControlHttp 1.1 ) > ExpiresHttp 1.0 )。

Cache-ControlExpires比具备更多的属性,其中包括如下:

  • no-cache :可以在本地缓存,可以在代理服务器缓存,需要先验证才可使用缓存。
  • no-store :禁止浏览器缓存,只能通过服务器获取。
  • max-age :设置资源的过期时间(效果与Expires一样)。

示例:

// 设置缓存时间为1年
Cache-Control: max-age=31536000
Expires:Sat, 09 Jun 2020 08:13:56 GMT //同时设置两个,Expires会失效

这意味着浏览器可以缓存一年的时间,无需请求服务器,同时如果同时声明ExpiresCache-ControlExpires将失效。

用户对浏览器的操作

Cache-Control no-cachemax-age=0的区别你按浏览器刷新与强制刷新的区分。

  • Ctrl + F5(强制刷新):request header多了cache-control: no-cache(重新获取请求)。
  • F5(刷新)/ctrl+R刷新:request header多了cache-control: max-age=0(需要先验证才可使用缓存,Expires无效)。

协商缓存

协商缓存,就没有强缓存那么霸道,协商缓存需要客户端和服务端两端进行交互,通过服务器告知浏览器缓存是否可用,并增加缓存标识,“有事好好商量”,两者都会互相协商。 协商缓存,其实就是服务器与浏览器交互过程,一般有两个回合,而协商主要有以下几种方式:

Last-ModifiedHttp 1.0

  • 第一回合:当浏览器第一次请求服务器资源时,服务器通过Last-Modified来设置响应头的缓存标识,把资源最后修改的时间作为值写入,再将资源返回给浏览器。
  • 第二回合:第二次请求时,浏览器会带上If-Modified-Since请求头去访问服务器,服务器将If-Modified-Since中携带的时间与资源修改的时间对比,当时间不一致时,意味更新了,服务器会返回新资源并更新Last-Modified,当时间一致时,意味着资源没有更新,服务器会返回304状态码,浏览器将从缓存中读取资源。

示例:

//response header 第一回合
Last-Modified: Wed, 21 Oct 2019 07:28:00 GMT

//request header 第二回合
If-Modified-Since: Wed, 21 Oct 2019 07:29:00 GMT

EtagHttp 1.1

MDN中提到ETag之间的比较,使用的是强比较算法,即只有在每一个字节都相同的情况下,才可以认为两个文件是相同的,而这个hash值,是由对文件的索引节、大小和最后修改时间进行Hash后得到的,而且要注意的是分布式系统不适用,同时需要注意的是Etag的组装不同类型的服务器可能不同比如nginxEtag可能是长的这样ETag: "5f3498d1-b0063"

  • 第一回合:也是跟上文一样,浏览器去请求服务器资源,不过这次不是通过Last-Modified了,而是用Etag来设置响应头缓存标识。Etag是由服务端生成的,然后浏览器会将Etag与资源缓存。
  • 第二回合: 浏览器会将Etag放入If-None-Match请求头中去访问服务器,服务器收到后,会对比两端的标识,当两者不一致时,意味着资源更新,会从服务器的响应读取资源并更新Etag,浏览器将从缓存中读取资源,当两者一致时,意味着资源没有更新,服务器会返回304状态码,浏览器将从缓存中读取资源。

示例:

//response header 第一回合
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

//request header 第二回合
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

对比完Last-ModifiedEtag,我们可以很显然看到,协商缓存每次请求都会与服务器发生“关系”,第一回合都是拿数据和标识,而第二回合就是浏览器“咨询”服务器是否资源已经更新的过程。

同时,如果以上两种方式同时使用,Etag优先级会更高,即Etag(Http 1.1) > Last-Modified(Http 1.0)。

缓存状态码

状态码200 OKfrom cache

这是浏览器没有跟服务器确认,直接用了浏览器缓存,性能最好的,没有网络请求,那么什么情况会出现这种情况?一般在Expires或者Cache-Control中的max-age头部有效时会发生。

状态码304 Not Modified

是浏览器和服务器“交流”了,确定使用缓存后,再用缓存,也就是第二节讲的通过EtagLast-Modified的第二回合中对比,对比两者一致,则意味资源不更新,则服务器返回304状态码。

状态码200

以上两种缓存全都失败,也就是未缓存或者缓存未过期,需要浏览器去获取最新的资源,效率最低 一句话:缓存是否过期用:Cache-Controlmax-age), Expires,缓存是否有效用:Last-ModifiedEtag

静态文件

前面介绍了浏览器缓存的一些方式,而随着前端工程化,前端可以做到通过打包工具(webpack等)来进行前端代码打包,生成一个可以直接部署到静态网站的源码。下面来看下通过打包后前端文件情况(以我的实际项目为例):

dist.jpg

  • 由以上的截图可以看到,每次发布新的版本打包出来的文件入口的index.html和前端配置文件config.js或者path.js的文件名是不变的,而动态生成的jspng文件会自动添加不同的hash值是动态的。

  • 结合上文讲到的浏览器缓存的内容可以知道如果不单独配置这些固定名称的文件的缓存策略的话由于不同服务器不同浏览器的实现不同极有可能会被缓存而导致发布新版本后页面失效的问题。

解决方案

上文已经提到前端打包生成的动态文件会根据内容自动生成对应hash和文件名,每次发布新版本这些文件的更新会命中协商缓存或者强缓存失效的规则,能形成破坏缓存的效果,达到发布新版本内容更新的效果,而入口文件index.htmlconfig.jspath.js等可能会因为浏览器默认的缓存策略不能破坏缓存,达不到发布新版本内容即更新的效果(尤其是index.html这个整个服务的入口如果不更新则会造成对应的引入文件均不可用而使页面崩溃)。

针对这种情况,需要在服务器端针对上面提到的index.htmlconfig.jspath.js等文件单独配置缓存策略,由截图中绿色框中的部分可以看出这些文件都不大,所有这里的缓存策略可以是配置强制缓存并且强制不让这些文件缓存Cache-control: no-store,让浏览器每次都重新从服务器拉取最新的文件。

nginx为例,nginx.conf配置如下:

# server下增加如下配置
location ~* (.html|config.js|path.js)$ {
    # 通过gx_http_headers_module提供的add_header方法配置强缓存并任何情况下不可缓存
    add_header Cache-Control no-store;
}

来匹配我们上文中提到的三种静态文件,这里附上nginxlocation匹配规则

  • 增加以上配置后对nginx进行重启(nginx -s reload)。
  • 第一次增加配置后建议用户进行强制刷新或者清除浏览器缓存后再使用。

验证方法

增加以上配置后可以通过两个途径进行验证:

  • 桌面端的浏览器(以Chrome为例):
    • 浏览器访问之前部署的服务uri路径。
    • F12打开开发者工具,切换到network选项,刷新或者强制刷新查看如下图: chrome-network.jpg
  • linux下或者windows PS命令行:
    • 执行curl http://ip:port来发送对应的请求。
    • 控制台会显示如下图:

curl.png

总结

前端工程化的现在给我们发布服务很大的便利,浏览器缓存是优化网站性能的利器同时也会带来一些问题,制定好缓存策略至关重要,现在前端通过打包生成的文件能很好地破坏原有缓存,但也有例外,本实践通过对静态资源的缓存策略来保证发布新版本后用户及时获得最新更新页面。

参考资料

react 设计哲学 | 严格模式

前言

官方介绍 详细解释了在 React 18+ 环境下严格模式的行为(包括双重渲染和双重 Effect)

组件 “双闪” 现象

当你使用 react 18+ 开发环境中使用 useEffect 开发的过程中是否遇到过定时器跑倍数或者内存泄露的问题?

例如: 下面的代码,你觉得页面中定时器显示的值应该是多少?

import { useState, useEffect } from 'react'

function TimerFunction() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    console.log('[Effect] 开启定时器')
    setInterval(() => {
      setCount((c) => c + 1)
    }, 1000)
  }, [])

  return <div>计数:{count}</div>
}

export default TimerFunction

直接揭晓答案,在严格模式下,当组件一加载,后台有两个定时器在跑,count 的值会成倍的增加。这里也是初学者容易困惑的地方,在严格模式下,组件会模拟 挂载 -> 卸载 -> 重新挂载 的过程。这里相当于初始化的时候页面挂载了两个定时器,所以也就解释了为什么看到的 count 的值是成倍增加的。

为什么要这样做?

react 团队之在 react 18 中强制加入严格模式,这是由于很多开发者在非严格模式下测试时,觉得“首次加载没有问题,就忽略了清理函数”,导致用户在当前页面停留时间过长,切换的页面越多,电脑越卡。而这种 bug 往往又很难发现,往往到了生产环境,用户反馈 “页面卡顿” 你才会意识到内存泄露了。

所以记得添加清理函数哦!!

import { useState, useEffect } from 'react'

function TimerFunction() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    console.log('[Effect] 开启定时器')
    const timer = setInterval(() => {
      setCount((c) => c + 1)
    }, 1000)

    // 这是函数组件的“生命周期清理”
    return () => {
      console.log('[Cleanup] 清理定时器')
      clearInterval(timer)
    }
  }, [])

  return <div>计数:{count}</div>
}

export default TimerFunction

我应该开启它吗?

答案是肯定的,虽然这样做会让你的log翻倍,但是可以提前帮你排查代码的逻辑漏洞和潜在风险,同时着严格模式在构建的过程中自动失效,所有不用担心生产环境影响用户的性能。

结余

react 的严格模式更像是对开发者的一种 “善意的怀疑”,借用苏格拉底的一句话,“我唯一知道的,就是我一无所知”

Axios二次封装及API 调用框架

项目代码其实是两部分,一部分是基于 Axios 的 HTTP 请求封装;一部分是API 基础封装与管理,参考了后端接口,类,抽像类的设计思路。

☛ 基于 Axios 的 HTTP 请求封装

该文件提供了基于 Axios 的 HTTP 请求封装,主要功能包括:
  1. 请求头管理:自动添加 Content-Type、CSRF 令牌、认证令牌等基础请求头
  2. 双实例设计:分别创建 read 和 write 两个 Axios 实例,用于不同类型的请求
  3. 拦截器配置:为请求和响应添加拦截器,可用于统一处理请求和响应
  4. 统一 API 方法:封装了 apiFunc 函数,支持 GET、POST、PUT、DELETE 等请求方法
  5. 读写分离:提供 requestRead 和 requestWrite 两个函数,分别用于读操作和写操作
  6. 错误类型定义:统一定义了常见错误类型,便于错误处理
使用示例:
// 读操作示例
import { requestRead } from './axios/basic/axios'

const response = await requestRead({
  method: 'get',
  url: '/api/users',
 data: { page: 1, pageSize: 10 }
})

// 写操作示例
import { requestWrite } from './axios/basic/axios'

const response = await requestWrite({
 method: 'post',
 url: '/api/users',
 data: { name: 'John', age: 30 }
})
注意事项:
  • GET 和 DELETE 请求的参数会自动转换为 URL 查询参数
  • POST 和 PUT 请求的参数会作为请求体发送
  • 所有请求都会自动添加必要的请求头,包括认证令牌和 CSRF 令牌
  • 支持自定义 axios 配置,会与默认配置合并

☛ 基于 Axios 的 HTTP 请求封装

该文件提供了一套完整的 API 调用框架,主要功能包括:
  1. 端点配置管理:通过 EndpointConfig 类标准化 API 端点配置
  2. 通用请求处理:封装了 request 方法,支持缓存、错误处理等高级功能
  3. CRUD 操作:提供了 list、page、add、update、delete、getById 等通用方法
  4. 缓存机制:实现了基于内存的请求缓存,提高重复请求的响应速度
  5. 错误处理:统一的错误类型定义和错误信息处理
  6. 加载状态管理:自动处理请求的加载状态显示
核心组件:
  • EndpointConfig:API 端点配置类,用于创建标准化的 API 端点配置
  • ApiBase:API 基础抽象类,提供通用的 API 操作方法
使用示例:
// 1. 定义 API 端点配置
const userEndpoints: IBaseApiEndpoints = {
  list: new EndpointConfig('/api/users', {
    method: 'get',
    requestType: 'read',
    cacheable: true
  }),
  add: new EndpointConfig('/api/users', {
    method: 'post',
    requestType: 'write'
  }),
  // 其他端点配置...
}

// 2. 创建 API 实例
class UserApi extends ApiBase {
  constructor() {
    super(userEndpoints)
  }
}

// 3. 使用 API 实例
const userApi = new UserApi()

// 获取用户列表
const users = await userApi.list({ page: 1, pageSize: 10 })

// 添加新用户
await userApi.add({ name: 'John', email: 'john@example.com' })

// 更新用户信息
await userApi.update(1, { name: 'John Doe' })

// 删除用户
await userApi.delete([1, 2, 3])

// 获取用户详情
const user = await userApi.getById(1)
特性说明:
  • 支持读写分离(read/write)
  • 自动处理加载状态显示
  • 统一的错误处理和提示
  • 可配置的缓存机制
  • 标准化的 API 端点配置

欢迎下载源码 使用,如觉得有用麻烦您点个赞。

深度图d3绘制交互逻辑

深度图和价格图都是基于D3.js库来绘制的svg图形

D3.js (Data-Driven Documents) 是一个用于操作文档的JavaScript库,它可以通过使用HTML, SVG 和 CSS等技术在网页上动态生成数据可视化。下面跟着以下步骤实现深度图的绘制:

1.数据集定义

首先我们需要定义好我们要展示的数据,看下将要用到的数据集:liquidity表示y轴数据,token0Price表示x轴数据

[ {    "tick" : -887200,    "liquidity" : "2516871586768523698747878",    "token0Price" : "0.000000000000000000000000000000000000002960192591918355544122581114448831",    "token1Price" : "337815857904011940012765396015654000000",    "timestamp" : 1691676253239  }, {    "tick" : -610800,    "liquidity" : "2516871586768523698747878",    "token0Price" : "0.000000000000000000000000002982766743656641268864955452810751",    "token1Price" : "335259202593253190167580281.8803577",    "timestamp" : 1691676253239  },  ......  ]

2.设置画布

我们需要定义好将要使用的画布大小、内边距等属性。我们将会创建一个宽度为400像素,高度为200像素,且四周留有{ top: 20, right: 2, bottom: 20, left: 0 }像素的空白的画布

const margins = { top: 20, right: 2, bottom: 20, left: 0 };
const width = 400
const height = 200

3.定义比例尺

根据数据集中的数值范围,我们需要创建与之对应的比例尺。使用scaleLinear()函数分别创建x轴和y轴的比例尺:

使用domain()方法来设置比例尺的输入域(即数据范围),使用range()方法来确定输出范围(在画布上的位置)。

  // 计算好绘制面积
  const [innerHeight, innerWidth] = useMemo(() => {
    return [      height - margins.top - margins.bottom,      width - margins.left - margins.right,    ];
  }, [width, height, margins]);
 
 // 创建X轴、Y轴比例尺
 const scales = {
      xScale: scaleLinear()
        .domain([
          getPriceOnTick(computedCurrentPrice * zoomLevel.initialMin),
          getPriceOnTick(computedCurrentPrice * zoomLevel.initialMax),
        ])
        .range([0, innerWidth]),
      yScale: scaleLinear()
        .domain([
          0,
          max(formattedData, (d) => {
            return yAccessor(d); // Y轴上的最大值
          }),
        ])
        .range([innerHeight, 0]),
    };

4.创建面积图

现在我们需要根据流动性值,来绘制生成面积图。可以使用d3.area()来创建一个面积图:

该函数创建的面积图会使用series数据集合中的值,将x轴和y轴上的各个点用曲线连接起来;使用.curve()方法指定了折线的形状,curveStepAfter 是d3提供的一种曲线类型定义。

import { area, curveStepAfter } from 'd3';
export const xAccessor = (d) => {
  return d.price0;  //x轴取值
};
export const yAccessor = (d) => {
  return d.activeLiquidity; // y轴取值
};

/**
 * 
 * @param 
     xScale: x轴比例尺
     yScale: y轴比例尺
     series: 数据集合
     fill  :面积填充颜色
     xValue :xAccessor
     yValue :yAccessor
 * @returns 
 */
export const Area = ({ xScale, yScale, series, xValue, yValue, fill }) => {
  const chartArea =
    xScale && yScale
      ? area()
          .curve(curveStepAfter)
          .x((d) => {
            return xScale(xValue(d));
          })
          .y0(yScale(0))
          .y1((d) => {
            return yScale(yValue(d));
          })(
          series.filter((d) => {
            const value = xScale(xValue(d));
            return value > 0;
          })
        )
      : null;
  return useMemo(() => {
    return <path fill={fill} d={chartArea} />;
  }, [fill, series, xScale, xValue, yScale, yValue]);
};

5.设置X坐标轴

我们需要添加坐标轴和数字标签,以便更好地显示数据。

深度图,我们只需要设置X坐标轴,并通过调用g元素上的.call()方法向画布上添加了这些坐标轴。我们还使用.transform()方法将坐标轴移动到正确的位置,使用.ticks(number)设置显示刻度的数量,并使用.tickFormat()自定义格式化坐标轴数据,.attr()可以像css一样设置刻度的样式

//X坐标轴
export const AxisBottom = ({ xScale, innerHeight, offset = 0 }) => {
  return useMemo(() => {
    if (xScale) {
      return (
        <g transform={`translate(0, ${innerHeight + offset})`}>
          <Axis
            axisGenerator={axisBottom(xScale)
              .ticks(6)
              .tickFormat((d) => {
                return formatD3value(d);
              })}
          />
        </g>
      );
    }
    return null;
  }, [innerHeight, offset, xScale]);
};

const Axis = ({ axisGenerator }) => {
  const axisRef = (axis) => {
    axis &&
      select(axis)
        .call(axisGenerator)
        .call((g) => {
          return g.select('.domain').remove();
        })
        .call((g) => {
          // 移除刻度上的锯齿
          return g.selectAll('.tick line').attr('display', 'none');
        })
        .call((g) => {
          return g
            .selectAll('.tick text')
            .attr('transform', `translate(${0},${2})`)
            .attr('fill', '#BDBDBD')
            .attr('font-size', '8px');
        });
  };

  return <g ref={axisRef} />;
};

6.绘制左右两根旗子

这里需要根据path来绘制,需要手动一点点调试样式

// 旗杆本身的path路径
export const brushHandlePath = (height) => {
  return [
    // handle
    `M 0 0`, // move to origin
    `v ${height}`, // vertical line
    // 'm 2 0', // move 1px to the right
    // `V 0`, // second vertical line
    `M 0 1`, // move to origin
    // head
    'h 10', // horizontal line
    'q 1 0, 1 1', // rounded corner
    'v 22', // vertical line
    'q 0 1 -1 1', // rounded corner
    'h -10', // horizontal line
    `z`, // close path
  ].join(' ');
};
// 旗杆头部填充的两根白色竖条的路径
export const brushHandleAccentPath = () => {
  return [
    'M 0 -3', // move to origin
    'm 3 7', // move to first accent
    'v 18', // vertical line
    'M 0 -3', // move to origin
    'm 8 7', // move to second accent
    'v 18', // vertical line
    'z',
  ].join(' ');
};
// 使用上面填好的path路径
const Handle = ({ color, d }) => {
  return (
    <path
      d={d}
      stroke={color}
      strokeWidth="2.5"
      fill={color}
      cursor="ew-resize"
      pointerEvents="none"
    />
  );
};

7.一切就绪,组装各UI模块

此时UI层面已经大功告成,就可以各模块组合看效果了

    // svg : 整个画布布局宽度、高度设置
     <svg  
        width="100%"
        height="100%"
        viewBox={`0 0 ${width} ${height}`}
        style={{ overflow: 'visible' }}
      >
       <defs>
       
          // brushDomain 就是两根旗子选中的范围,算出两个旗子的范围之差,然后截取出高亮的面积图
          {brushDomain && (
            // mask to highlight selected area
            <clipPath id={`${id}-chart-area-mask`}>
              <rect
                fill="white"
                x={xScale(brushDomain[0])}
                y={0}
                width={xScale(brushDomain[1]) - xScale(brushDomain[0])}
                height={innerHeight}
              />
            </clipPath>
          )}
        </defs>
        <g transform={`translate(${margins.left},${margins.top})`}>
          <g clipPath={`url(#${id}-chart-clip)`}>
            // 这是面积图
            <Area
              series={series}
              xScale={xScale}
              yScale={yScale}
              xValue={xAccessor}
              yValue={yAccessor}
              // fill="#78DE9D"
              fill="var(--okd-color-green-200)"
            />
            // 这里又绘制了一次面积图,原因是:两根旗子之间选中的流动性需要高亮,所以需要再绘制一个高亮颜色的面积图,并且 clipth = {`url(#${id}-chart-area-mask)`} 与上面的cliptath就对应上了。
            {brushDomain && (
              // duplicate area chart with mask for selected area
              <g clipPath={`url(#${id}-chart-area-mask)`}>
                <Area
                  series={series}
                  xScale={xScale}
                  yScale={yScale}
                  xValue={xAccessor}
                  yValue={yAccessor}
                  fill="var(--okd-color-green-500)"
                />
              </g>
            )}
            // 表示当前价格的一条竖线
            <Line value={current} xScale={xScale} innerHeight={innerHeight} />
            // X轴坐标轴
            <AxisBottom xScale={xScale} innerHeight={innerHeight} />
          </g>
           // 放大缩小
          <ZoomOverlay width={innerWidth} height={height} ref={zoomRef} />
          // 两根旗子
          <Brush
            id={id}
            xScale={xScale}
            interactive
            brushLabelValue={brushLabels}
            brushExtent={brushDomain ?? (xScale && xScale.domain())}
            innerWidth={innerWidth}
            innerHeight={innerHeight}
            setBrushExtent={onBrushDomainChange}
            westHandleColor="#31BD65"
            eastHandleColor="#31BD65"
          />
        </g>
      </svg>

8.静态部分完成,旗子可以动起来了

需要使用d3.brushX()函数:一维画笔X-尺寸。同样有brushY()函数:一维画笔Y-尺寸(价格图使用这个方法)

用法:d3.brushX();

参数:该函数不接受任何参数。 返回值:此函数沿x轴返回新创建的一维笔刷

  1、d3设置一维画笔
  brushBehavior.current = brushX()
      .extent([
        [Math.max(0 + BRUSH_EXTENT_MARGIN_PX, xScale(0)), 0],
        [innerWidth - BRUSH_EXTENT_MARGIN_PX, innerHeight],
      ]) //  extent 设置可刷取的范围
      .handleSize(30) // 设置brush柄的大小、默认为6
      .on('brush end', brushed); // 滑动结束事件,brushed里可以定义业务回调
    brushBehavior.current(select(brushRef.current)); // 选中的元素
    

2、画笔动作完成后,处理数据
  const onBrushDomainChange = useCallback((domain, mode) => {
    let leftRangeValue = Number(domain[0]);
    let rightRangeValue = Number(domain[1]);
    if (leftRangeValue <= 0) {
      leftRangeValue = 1 / 10 ** 18;
    }
    if (rightRangeValue > 1e35) {
      rightRangeValue = 1e35;
    }
    setLocalBrushExtent([leftRangeValue, rightRangeValue]);

    // 拖拽柱子时,handle:单根拖拽, drag:两个一起拖拽
    if (mode === 'handle' || mode === 'drag') {
      const { minPrice, maxPrice } = priceRange;

      // 左侧价格变化(minPrice)
      if (!compareIsEqualPrice(minPrice, leftRangeValue)) {
        // transformPriceOnTickPoint 把价格转化在整tick点上
        const { price, tick } = transformPriceOnTickPoint(
          leftRangeValue,
          false
        );
        // 改变下方价格输入框的值,以及和下单器询价联动
        onLeftRangeInput(tick, price);
      }

      //右侧价格变化(maxPrice)
      if (!compareIsEqualPrice(maxPrice, rightRangeValue)) {
        const { price, tick } = transformPriceOnTickPoint(
          rightRangeValue,
          mode === 'handle'
        );
        onRightRangeInput(tick, price);
      }
      updatePros({
        hasChangePrice: true,
      });
    }

    // 初始化时、点击重置价格区间时
    if (mode === 'reset' || mode === 'init') {
      if (leftRangeValue > 0) {
        const { price, tick } = transformPriceOnTickPoint(
          leftRangeValue,
          false
        );
        onLeftRangeInput(tick, price);
      }

      if (rightRangeValue > 0) {
        const { price, tick } = transformPriceOnTickPoint(
          rightRangeValue,
          mode !== 'init'
        );
        onRightRangeInput(tick, price);
      }
    }
    // 汇率反转时
    if (mode === 'reverse') {
      if (leftRangeValue > 0) {
        setPriceRange(PRICE_TYPE.MIN_PRICE, leftRangeValue);
      }
      if (rightRangeValue > 0) {
        setPriceRange(PRICE_TYPE.MAX_PRICE, rightRangeValue);
      }
    }
  });

  3、onRightRangeInput(以右侧价格为例) 价格输入框、下单器开始联动起来  
  const onRightRangeInput = (tickValue: number, priceValue: number) => {
    let rightInputTick = tickValue;
    let rightInputPrice = priceValue;
    
    const currentLeftRangeTick = isReverse
      ? -tickRange.tickUpper
      : tickRange.tickLower;
      // 判断滑动左右两个旗子的tick是否相同,如果是增加一个tickSpacing
    if (rightInputTick == currentLeftRangeTick) {
      rightInputTick += tickSpacing;
      rightInputPrice = getPriceByTick(
        rightInputTick,
        token0Precisions!,
        token1Precisions!
      );
    }

    // 更新价格范围-最大的价格
    uniV3SubscribeStore.setPriceRange(PRICE_TYPE.MAX_PRICE, rightInputPrice);
    
    // 判断是否是反转,决定更新tick的范围
    const tickType = isReverse ? TICK_TYPE.TICK_LOWER : TICK_TYPE.TICK_UPPER;
    // 更新tick
    uniV3SubscribeStore.setTickRange(
      tickType,
      isReverse ? -rightInputTick : rightInputTick
    );
    judgeOneSidedLiquidity(); // 判断单边流动性, 下单器是否投资单币、双币
    debounceV3ReceiveInfo(); // 根据最终的tick范围,开始询价
  };    

9.总结

以上就是深度图从UI层一步步的绘制,再到滑动旗杆改变价格,再计算得出tick范围,最终在下单器询价的整体流程

小程序双模式(文件 / 照片)上传组件封装与解析

在小程序业务开发中,上传功能是高频场景,而部分业务需要同时支持文件上传(如 PDF、文档)和照片上传(如图片凭证)两种模式。

基于完整的业务代码,从 WXML 结构、JS 逻辑、WXSS 样式三个维度,深度解析一个支持双模式切换、带上传 / 预览 / 删除、容错处理完善的小程序上传组件。该组件直接复用即可落地,适配绝大多数表单类业务(如用印申请、资料提交等)。

image.png

image.png

通过wx:if根据uploadMode切换文件 / 照片上传 UI,同时实现切换按钮、上传区域、删除 / 预览功能。

js模式切换、文件上传、照片上传、删除 / 预览、异常处理全流程逻辑,代码注释详细,直接复用。

<view class="seal_wrapper">
  <view class="items_wrap">
    <!-- 标题栏:带必填标识 -->
    <view class="items_titles">
      <text>*</text>用印文件原件
    </view>
    <!-- 模式切换开关:文件/照片 -->
    <view class="upload-switch">
      <view class="upload-switch-item {{uploadMode === 'file' ? 'active' : ''}}" bind:tap="setUploadMode" data-mode="file">文件</view>
      <view class="upload-switch-item {{uploadMode === 'image' ? 'active' : ''}}" bind:tap="setUploadMode" data-mode="image">照片</view>
    </view>
    <!-- 上传内容区域:根据模式渲染 -->
    <view class="items_input">
      <!-- 文件上传模式 -->
      <view class="file-uploader" wx:if="{{uploadMode === 'file'}}">
        <!-- 已上传文件:显示文件名+删除按钮 -->
        <view class="file-item" wx:if="{{fileUrl}}">
          <view class="file-name">{{fileName || '已上传文件'}}</view>
          <image class="file-del" src="/img/close.png" mode="aspectFit" bind:tap="removeFile" />
        </view>
        <!-- 未上传文件:显示上传入口 -->
        <view class="file-add" bind:tap="chooseFile" wx:if="{{!fileUrl}}">
          <image class="file-add-icon" src="/img/add.png" mode="aspectFit"/>
          <view class="file-add-text">上传文件</view>
        </view>
      </view>

      <!-- 照片上传模式 -->
      <view class="images-grid" wx:if="{{uploadMode === 'image'}}">
        <!-- 已上传照片:显示图片+删除+预览 -->
        <view class="img-item" wx:if="{{images && images.length}}">
          <image class="img" src="{{images[0]}}" mode="aspectFit" bind:tap="previewImage" data-idx="0"/>
          <image class="img-del" src="/img/close.png" mode="aspectFit" bind:tap="removeImage" data-idx="0"/>
        </view>
        <!-- 未上传照片:显示上传入口 -->
        <view class="img-add" bind:tap="chooseImages">
          <image class="img-add-icon" src="/img/add.png" mode="aspectFit"/>
        </view>
      </view>
    </view>
  </view>
</view>

Page({
  data: {
    // 默认选中文件上传模式
    uploadMode: 'file',
    fileUrl: '', // 已上传文件的服务端链接
    fileName: '', // 已上传文件名称
    images: [] // 已上传照片链接数组
  },

  /**
   * 切换上传模式(文件/照片)
   */
  setUploadMode(e) {
    const mode = e?.currentTarget?.dataset?.mode || ''
    // 仅允许切换到合法模式
    if (mode !== 'file' && mode !== 'image') return
    this.setData({ uploadMode: mode })
  },

  /**
   * 删除已上传文件
   */
  removeFile() {
    this.setData({
      fileUrl: '',
      fileName: ''
    })
  },

  /**
   * 选择并上传文件(文档)
   */
  chooseFile() {
    wx.chooseMessageFile({
      count: 1, // 单次上传1个文件
      type: 'file', // 类型为文件
      success: (res) => {
        const files = res?.tempFiles || []
        const file = files[0] || null
        if (!file?.path) return

        // 保存文件名称
        this.setData({ fileName: file.name || '' })

        // 上传中loading
        wx.showLoading({ title: '上传中...' })
        // 调用上传接口
        wx.uploadFile({
          filePath: file.path,
          name: 'file', // 服务端接收的文件字段名
          url: api.ImgUpload(), // 替换为你的图片上传接口
          formData: {
            // 附加参数:用户身份标识
            OpenID: wx.getStorageSync('openId'),
            MID: wx.getStorageSync('mid'),
          },
          success: (resu) => {
            // 解析服务端返回(捕获JSON解析异常)
            let data = null
            try {
              data = JSON.parse(resu.data)
            } catch (e) {
              data = null
            }
            // 上传成功:保存文件链接
            if (data && data.code === 200 && data.data) {
              this.setData({ fileUrl: data.data })
              return
            }
            // 上传失败:提示错误信息
            wx.showToast({
              title: data?.msg || '上传失败',
              icon: 'none'
            })
          },
          fail: (err) => {
            console.error('文件上传失败:', err)
            wx.showToast({ title: '上传失败', icon: 'none' })
          },
          complete: () => {
            wx.hideLoading() // 无论成功失败,关闭loading
          }
        })
      },
      fail: (err) => {
        console.error('选择文件失败:', err)
      }
    })
  },

  /**
   * 预览照片
   */
  previewImage(e) {
    const idx = e?.currentTarget?.dataset?.idx || 0
    const urls = this.data.images || []
    if (!urls.length) return
    // 调用小程序原生预览API
    wx.previewImage({
      urls,
      current: urls[idx] || urls[0]
    })
  },

  /**
   * 删除已上传照片
   */
  removeImage(e) {
    const idx = e?.currentTarget?.dataset?.idx || 0
    const urls = [...(this.data.images || [])] // 浅拷贝避免直接修改原数据
    if (!urls.length) return
    urls.splice(idx, 1) // 删除对应索引的图片
    this.setData({ images: urls })
  },

  /**
   * 选择并上传照片
   */
  chooseImages() {
    wx.chooseMedia({
      count: 1, // 单次上传1张照片
      mediaType: ['image'], // 仅选择图片
      sourceType: ['album', 'camera'], // 支持相册/相机
      success: (res) => {
        const files = res?.tempFiles || []
        const tempFilePath = files[0]?.tempFilePath || ''
        if (!tempFilePath) return

        wx.showLoading({ title: '上传中...' })
        wx.uploadFile({
          filePath: tempFilePath,
          name: 'file',
          url: api.ImgUpload(), // 替换为你的图片上传接口
          formData: {
            OpenID: wx.getStorageSync('openId'),
            MID: wx.getStorageSync('mid'),
          },
          success: (resu) => {
            let data = null
            try {
              data = JSON.parse(resu.data)
            } catch (e) {
              data = null
            }
            if (data && data.code === 200 && data.data) {
              // 照片仅支持单张,直接覆盖数组
              this.setData({ images: [data.data] })
              return
            }
            wx.showToast({
              title: data?.msg || '上传失败',
              icon: 'none'
            })
          },
          fail: (err) => {
            console.error('照片上传失败:', err)
            wx.showToast({ title: '上传失败', icon: 'none' })
          },
          complete: () => {
            wx.hideLoading()
          }
        })
      },
      fail: (err) => {
        console.error('选择照片失败:', err)
      }
    })
  }
})



/* 外层容器:避免样式污染 */
.seal_wrapper .items_wrap {
  width: 100%;
  padding: 30rpx 0;
  border-bottom: 1rpx solid #f0f0f0;
}
/* 最后一项去掉下边框 */
.seal_wrapper .items_wrap:last-child {
  border-bottom: none;
}
/* 标题样式 */
.seal_wrapper .items_titles {
  font-size: 32rpx;
  margin-bottom: 20rpx;
  color: #222;
}
/* 必填红色星号 */
.seal_wrapper .items_titles text {
  color: #ff4d4f;
  margin-right: 8rpx;
}
/* 输入/上传区域 */
.seal_wrapper .items_input {
  width: 100%;
}
/* 模式切换容器 */
.seal_wrapper .upload-switch{
  display: flex;
  gap: 16rpx;
  margin: 8rpx 0 16rpx;
}
/* 切换项样式 */
.seal_wrapper .upload-switch-item{
  padding: 10rpx 26rpx;
  border-radius: 999rpx;
  background: #f3f4f6;
  color: #666;
  font-size: 26rpx;
  line-height: 1.4;
}
/* 选中态样式 */
.seal_wrapper .upload-switch-item.active{
  background: #e8f1ff;
  color: #2f7cff;
  font-weight: 700;
}
/* 文件上传容器 */
.seal_wrapper .file-uploader{
  width: 100%;
}
/* 未上传文件:虚线边框+居中 */
.seal_wrapper .file-add{
  width: 100%;
  height: 96rpx;
  border-radius: 16rpx;
  border: 2rpx dashed #d8d8d8;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14rpx;
}
/* 上传图标 */
.seal_wrapper .file-add-icon{
  width: 44rpx;
  height: 44rpx;
  opacity: 0.7;
}
/* 上传文字 */
.seal_wrapper .file-add-text{
  font-size: 28rpx;
  color: #666;
}
/* 已上传文件:背景色+弹性布局 */
.seal_wrapper .file-item{
  width: 100%;
  min-height: 96rpx;
  border-radius: 16rpx;
  background: #f9fafb;
  padding: 18rpx;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
/* 文件名:超出省略 */
.seal_wrapper .file-name{
  flex: 1;
  min-width: 0;
  font-size: 28rpx;
  color: #333;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  padding-right: 16rpx;
}
/* 删除按钮 */
.seal_wrapper .file-del{
  width: 36rpx;
  height: 36rpx;
  background: rgba(0,0,0,0.3);
  border-radius: 18rpx;
  padding: 4rpx;
  box-sizing: border-box;
}
/* 照片网格布局 */
.seal_wrapper .images-grid{
  display: flex;
  flex-wrap: wrap;
  gap: 18rpx;
}
/* 已上传照片容器 */
.seal_wrapper .img-item{
  width: 160rpx;
  height: 160rpx;
  border-radius: 16rpx;
  overflow: hidden;
  position: relative;
}
/* 照片 */
.seal_wrapper .img{
  width: 100%;
  height: 100%;
}
/* 照片删除按钮 */
.seal_wrapper .img-del{
  position: absolute;
  top: 6rpx;
  right: 6rpx;
  width: 36rpx;
  height: 36rpx;
  background: rgba(0,0,0,0.3);
  border-radius: 18rpx;
  padding: 4rpx;
  box-sizing: border-box;
}
/* 未上传照片:虚线边框+居中 */
.seal_wrapper .img-add{
  width: 160rpx;
  height: 160rpx;
  border-radius: 16rpx;
  border: 2rpx dashed #d8d8d8;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* 上传图标 */
.seal_wrapper .img-add-icon{
  width: 54rpx;
  height: 54rpx;
  opacity: 0.7;
}

最新版vue3+TypeScript开发入门到实战教程之Pinia详解

概述

Pinia 是 Vue.js 的官方状态管理库,可以把它看作是 Vuex 的升级版。它提供了更简洁的 API 和更好的 TypeScript 支持,已经成为 Vue 生态中推荐的状态管理方案。Pinia基本三要素:

  • store ,数据,用户自定义数据存储在store
  • getters,获取数据或进行加工后的数据,类似计算属性computed
  • actions,修改数据的方法

Pinia存储读取数据的基本方法

  • 安装Pinia,npm install pinia
  • 在main.ts引入Pinia,创建引用实例
  • 创建Fish组件,数据name,price,site
  • 创建store文件夹,创建useFishStore,存储Fish组件数据 文件结构目录 在这里插入图片描述 main.ts代码:
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')

Fis组件代码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
</script>

useFishStore.ts代码

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  })
})

运行效果 在这里插入图片描述

Pinia修改数据的三种方法

  • 直接修改
  • 通过$patch方法修改
  • 通过actions修改

直接修改数据

Fish组件

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
  store.name += '~';
  store.price += 10;
  store.site+='!'

}
</script>

修改效果如下: 在这里插入图片描述

通过$patch方法修改

Fish组件源码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
   store.$patch({
    name: '带鱼',
    price: 300,
    site:'海里'
  });
}
</script>

修改效果如图: 在这里插入图片描述

通过actions修改

useFishStore增加actions,添加方法changeFish

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  }
})

Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
  store.changeFish({
    name: '带鱼',
    price: 300,
    site: '海里'
  });
}
</script>

运行效果如下: 在这里插入图片描述

Pinia函数storeToRefs应用

在Fish引用useFishStore,从useFishStore()直接解析数据,会丢失响应式,需要使用toRefs转换,但toRefs会将所有成员变成响应式对象。storeToRefs只会将数据转换成响应式对象。 Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia'
console.log(toRefs(useFishStore()));
console.log(storeToRefs(useFishStore()));
let { name, price, site } = storeToRefs(useFishStore());

function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

运行效果如图,注意控制台打印的日志: 在这里插入图片描述

Getters用法

类似组件的 computed,对state 数据进行派生计算。state数据发生改变,调用getters函数。 useFishStore.ts代码:

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  },
  getters: {
    changeprice():number {
      return this.price * 20;
    },
    changesite():string {
      return this.name+'在'+this.site+'游泳'
    }
  }
})

注意changeprice():number,ts语法检查,函数返回类型为number。 Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}新价格:{{ changeprice }}</h2>
    <h2>位置:{{ site }}新位置:{{ changesite }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia'
console.log(toRefs(useFishStore()));
console.log(storeToRefs(useFishStore()));
let { name, price, site,changeprice,changesite } = storeToRefs(useFishStore());

function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

运行效果如图: 在这里插入图片描述

$subscribe用法

subscribe订阅信息,当数据发生变化,回调subscribe订阅信息,当数据发生变化,回调subscribe函数设定的回调函数,该函数有两个参数:一是事件信息,一是修改后的数据数据。 $subscribe用于两组件的数据通信,Fish组件数据发生变化时,通知Cat组件。

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  }
})

Fish组件:

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { storeToRefs } from 'pinia'
let store = useFishStore()
let { name, price, site } = storeToRefs(store);
function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

Cat组件

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { ref } from 'vue';
let name = ref('');
let price = ref(0);
let site=ref('')
let store = useFishStore();
store.$subscribe((mutate, state) => {
  console.log(mutate);
  console.log(state);
  name.value = state.name;
  price.value = state.price;
  site.value = state.site;
});

</script>

效果如图: 在这里插入图片描述 注意控制台打印的数据

Pinia组合式写法

组合式是vue3中新语法,有以下优势,

  • 轻松提取和组合业务逻辑
  • 使用所有 Vue 组合式 API(ref、computed、watch、生命周期等)
  • 逻辑可以聚合在一起,而不是分散在不同配置项中
import { defineStore } from 'pinia'
import { computed, ref } from 'vue';
export const useFishStore = defineStore('fish', () => {
  let name = ref('鲫鱼');
  let price = ref(10);
  let site = ref('河里');
  function changeFish(fish: any) {
    console.log(fish)
    name.value = fish.name;
    price.value = fish.price;
    site.value = fish.site;
  }
  let calcPrice = computed(() => {
    return price.value * 2;

  })
  return { name, price,site,changeFish,calcPrice };

})

Fish组件

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}新价格:{{ calcPrice }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { storeToRefs } from 'pinia'
let store = useFishStore()
let { name, price, site ,calcPrice} = storeToRefs(store);
function changeFish() {
  store.changeFish({ name: '带鱼', price: 11, site: '海里' })

}
</script>

运行效果 在这里插入图片描述

❌