普通视图

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

Claude Skills 新手笔记

2026年3月22日 23:54

开篇:这份笔记能帮你解决什么问题

如果你正在用 Claude(或任何大模型)做写作、总结、审阅、编码、整理资料等重复性工作,你很快会遇到两个痛点:

  • 同一类任务,每次都要把背景、规则、格式重新讲一遍;
  • 输出质量容易波动:有时很稳,有时跑偏,难以复用。

Claude Skills 可以把“可重复任务的知识 + 流程 +资源”打包成可复用的能力单元,让模型在需要时按需加载,从而更稳定地完成特定工作。这篇文章会用最小规范、加载机制、以及一个完整的中文示例,带你快速建立可落地的理解。

1. Claude Skills 是什么

Claude Skills 可以理解成给 Claude 增加“专项能力包”的一种方式:把一类可重复任务的知识、流程和资源打包成一个可复用单元,让 Claude 在需要时再加载它,从而更稳定地完成特定工作。

Anthropic 官方 skills 仓库和 Agent Skills 官方说明都把 Skill 描述为一个由说明、脚本和资源组成的目录;最核心的入口文件是 SKILL.md

从最小结构看,一个 Skill 至少是这样:

my-skill/
└── SKILL.md

在此基础上,还可以按需添加:

  • scripts/:可执行脚本
  • references/:参考资料或规范文档
  • assets/:模板、图片、字体等资源文件

这些目录都是可选的,不是每个 Skill 都需要。

Skill 和一次性的临时提示词最大的区别,在于它是可复用、可维护、可分享的。Anthropic 官方公开仓库本身就是以“一个个独立 Skill 目录”的形式组织示例,目的是展示 Claude Skills 能覆盖的不同任务模式。

2. Skills 有什么作用

Skills 的价值,不是单纯“让 Claude 更聪明”,而是让 Claude 在某一类任务上更稳定、更一致、更像一个带经验的专职助手。官方说明里提到,Skills 可以用来处理专业文档、组织内部工作流、创意任务、技术任务以及企业流程。

实际使用里,Skills 通常有四类明显作用。

第一类,是封装专业知识和组织规范。

如果某项任务总要遵守固定规则,比如品牌语气、技术规范、法务措辞、团队输出格式,把这些内容写进 Skill 后,就不用每次重新解释一遍。Agent Skills 官方也明确把“专门知识”和“特定工作流”列为 Skill 的核心用途。

第二类,是沉淀可重复工作流。

很多工作并不难在某一步,而是难在“每次都要从头再走一遍流程”。Skill 可以把输入整理、澄清问题、输出结构、检查清单这些步骤固定下来,让 Claude 更稳定地重复执行。官方 skills 仓库对 Skills 的定义就是:教 Claude 以可重复的方式完成特定任务。

第三类,是让输出更稳定、更可控。

对于写作、总结、审阅、结构化交付物这类任务,Skill 可以约束 Claude 按固定结构输出、使用固定语气、遵守边界条件。

第四类,是按需加载,减少上下文浪费。

Agent Skills 官方说明把这套机制叫作 progressive disclosure。代理在启动时只读取每个 Skill 的名称和描述;当某个任务与描述匹配时,才会把完整 SKILL.md 读入上下文;如果正文还引用了脚本或其他资源,再进一步按需读取或执行。这样既能节省上下文,又能在需要时获得更强的专门能力。

3. Skill 的最小规范与加载机制

如果只讲最小可用版本,一个 Skill 的门槛非常低:一个目录,加一个 SKILL.md 文件就够了。Anthropic 官方仓库 README 和 Agent Skills 官方文档都明确这么写。

SKILL.md 里最关键的是 YAML frontmatter。按照 Agent Skills 官方规范,最小必需字段是:

  • name
  • description

其中:

  • name 是 Skill 的唯一标识,要求使用小写字母、数字和连字符
  • description 用自然语言说明这个 Skill 做什么,以及何时应该触发

Agent Skills 规范另外还支持可选字段,例如 license。GitHub 上的相关 issue 也显示,Claude 端对 frontmatter 的可接受字段有明确限制,随意添加常见字段如 version、author 可能导致导入或验证失败。

一个最小的 SKILL.md 通常长这样:

---
name: my-skill
description: 用一句话描述这个 Skill 做什么,以及什么时候应该使用它。
---

# Instructions

在这里写模型需要遵循的规则与流程。

这里最值得重视的是 description。它不只是简介,也是 Skill 是否会被正确命中的主要触发入口。Agent Skills 官方说明里明确提到,代理启动时先读取的就是 name + description;只有描述匹配任务,才会继续加载正文。

所以,一个好 Skill 的关键,往往不只是正文写得多详细,而是 description 是否把“做什么”和“什么时候用”写清楚了。

4. skill-creator 是什么

skill-creator 是 Anthropic 官方 skills 仓库中的一个“元 Skill”。它不是直接帮你做业务任务的,而是专门帮助你创建、改进、测试和迭代别的 Skill。官方 skill 页面对它的定位就是:一个用于创建新 Skill 并持续改进它们的 Skill。

简单说:普通 Skill 是“解决问题”的;skill-creator 是“帮你把解决问题的方法做成 Skill”的。

它的核心价值有两层。

第一层,是把“写 Skill”这件事流程化。

官方说明里给出的基本循环是:先确定 Skill 要做什么,再写草稿,再设计测试提示,再让 Claude 带着 Skill 跑测试,再基于结果迭代,最后继续扩大测试范围。

第二层,是把“触发效果”也纳入优化目标。

skill-creator 不只关心正文写得对不对,还强调后续可以继续优化 Skill 的描述,让它在真正需要时更容易触发。

如果你是第一次做 Skill,skill-creator 最大的帮助不是“替你写几段文字”,而是把你拉回到一条更工程化的路径上:先澄清意图,再做最小版本,再用真实提示测试,再迭代。

5. skill-creator 如何帮助你创建 Skill

把官方思路拆开来看,skill-creator 实际上在帮助你完成四件事。

5.1 澄清意图

官方说明强调,第一步不是立刻开始写,而是先弄清楚:

  • 这个 Skill 到底要解决什么问题
  • 用户会怎么提这个需求
  • 需要哪些输入才能产出好结果
  • 希望输出成什么格式

这些问题看上去基础,但它们直接决定 description 是否能触发,以及正文是否能落地。

5.2 生成最小可用版本

skill-creator 倾向于先做一个能用的初稿,而不是一开始做成大而全的系统。官方建议里也有类似思路:先写 draft,再用几个真实测试提示验证。

这一步最重要的是三件事:

  • 取一个清晰的名字
  • 写一个覆盖真实触发语境的 description
  • 在正文里给出规则、默认假设和输出格式

5.3 用真实提示测试

官方 skill-creator 页面建议,在写完草稿后准备 2 到 3 个真实测试提示,先跑起来看结果。它甚至给了 evals.json 的结构示例,用来记录测试 prompt 和期望输出。

这说明一个很重要的事实:Skill 不是“写完就结束”,而是要靠真实触发场景来验证。

5.4 继续迭代 description 和正文

官方特别强调两类容易出问题的地方。

  • 一类是 description 写得太弱,导致该触发时不触发。
  • 另一类是正文规则太模糊,导致即使触发了,输出仍然不稳定。

所以,Skill 的迭代通常也优先围绕这两点展开:

  • description 是否覆盖真实使用语境
  • 输出格式、规则和检查点是否足够稳定

6. 最简单示例:创建一个“个人介绍 Skill”(中文版本)

下面给一个最小、最实用的例子:做一个属于你自己的 Skill,让 Claude 在你要求“写我的自我介绍”“帮我生成个人简介”“写个人主页介绍”时,优先按你固定的人设、语气和格式来写。

这个例子的目标不是让 Claude “永久记住你”,而是把你的资料、偏好和输出规则封装成一个可复用 Skill。这样的做法,符合官方对 Skills 的定义:把某类可重复任务的说明和资源封装起来,在相关任务发生时再加载。

6.1 第一步:把需求告诉 skill-creator

你可以直接在 Claude Code 里这样说(中文示例):

使用 skill-creator 技能帮我创建一个 personal-profile(个人介绍)skill。

它需要在不同场景下帮我写自我介绍:
- 一句话简介(很短的 bio)
- 正式的职业介绍(用于主页/简历/演讲嘉宾介绍)
- 更口语的社交介绍

当我提出类似这些需求时触发:
- 写我的自我介绍
- 给我一段个人简介
- 帮我写一个个人主页介绍
- 给我一个演讲嘉宾介绍

我的基本信息:
- 姓名:萧凡
- 方向:产品、AI 工作流、自动化
- 风格:清晰、温暖、专业
- 默认输出语言:中文;如果我明确要求英文,再输出英文

这段提示的好处,是把最关键的四类信息一次交代清楚:

  • Skill 做什么
  • 什么时候触发
  • 有哪些输出模式
  • 你的基础资料是什么

如图,已使用 skill-creator 将 Skill 打包成一个 .skill 文件。你可以将该文件分享出去,或将 personal-profile.skill 放到对应环境的 skills 目录下。

如果想查看里面的内容可以在文件后添加后缀名.zip,解压之后得到完整的结构

6.2 手工创建一个最小可用的 Skill(目录 + SKILL.md

如果你不想先用 skill-creator 生成 .skill 文件,也可以直接在本地“纯手工”创建一个 Skill:新建一个目录,并写好 SKILL.md 即可。

最小目录结构如下:

personal-profile/
└── SKILL.md

你可以把这个目录放到你自己的 skills 搜索路径下(不同运行环境/工具的默认路径可能不同),之后在对话或运行时按需加载。

下面是一版可以直接参考的最简 SKILL.md 示例:

---
name: personal-profile
description: 为“萧凡”撰写自我介绍与个人简介,包括:一句话简介、正式职业介绍、社交口吻介绍、个人主页简介、演讲嘉宾介绍等。当用户提出“写我的自我介绍/个人简介/主页介绍/嘉宾介绍”等相似需求时,使用此 skill。默认输出中文;若用户明确要求英文,再输出英文。
---

# 个人介绍(Personal Profile)

除非用户提供更新信息,否则以下资料视为默认事实来源。

## 核心资料
- 姓名:萧凡
- 方向:产品、AI 工作流、自动化
- 默认语言:中文
- 备选语言:英文
- 语气:清晰、温暖、专业

## 写作规则
- 表达具体、自然,避免夸张与空话。
- 句子尽量短,读起来顺。
- 如果用户没有提供场景,先给一版“通用中等长度”的介绍。
- 如果用户指定平台/场景(如:领英、个人主页、演讲、社交媒体),按场景调整长度与语气。
- 如果关键背景缺失:先给一版合理默认稿,再提出 2-3 个精炼追问。

## 输出模式
### 1)一句话简介
用于头像旁/签名档等,1-2 句。

### 2)正式职业介绍
用于个人主页、简历摘要、演讲嘉宾页等。

### 3)更口语的社交介绍
用于社交/轻松场景。

## 默认输出格式
当用户没有指定格式时,按以下顺序返回:
1. 标准版自我介绍
2. 一句话简介
3. 更口语一点的版本

这版示例有几个优点:

  • 符合最小规范:有 name、有 description、有正文。
  • 把“何时使用”尽量写进了 description(利于触发)。
  • 没有引入多余复杂度:对“自我介绍”这种任务,通常不需要脚本也能很好用。

7. 为什么这个最小示例已经够用了

对于“自我介绍型 Skill”这类文本任务,真正决定效果的通常只有三件事:

  • 资料是否清楚
  • description 是否覆盖真实触发语境
  • 输出规则是否稳定

只要这三点写清楚,一个只有 SKILL.md 的 Skill 往往就已经足够实用。Agent Skills 官方文档也明确说明,Skill 可以从纯文本说明起步,再按需要扩展到脚本和资源。

只有当你遇到下面这些情况,才需要进一步引入 scripts/、references/ 或 assets/:

  • 需要从外部数据源拉取内容
  • 需要把某些重复操作自动化
  • 需要依赖复杂模板或品牌资源
  • 需要对输出做更机械、更稳定的处理

8. 使用时会怎么触发

这个 Skill 安装好之后,你以后就可以直接说:

写一个我的三句话自我介绍

或者:

帮我写一个放在个人主页上的简介

或者:

给我一个英文的 speaker bio

之所以它更容易在这些场景里起作用,是因为代理在判断是否要调用某个 Skill 时,先看的就是 Skill 的名称和描述。你的用户表达越贴近 description 中列出的真实语境,这个 Skill 越容易被正确加载。

9. 工程实践:如何组织个人级和项目级 Skills

如果你在 Claude Code 里长期使用类似能力,比较合理的组织方式是区分“个人范围”和“项目范围”。Anthropic 官方关于 Claude Code subagents 的文档明确给出了两类存放位置:项目级放在 .claude/agents/,用户级放在 ~/.claude/agents/,并且项目级优先于用户级。

落到 Skills 的组织上,一个很实用的思路是:

个人级 vs 项目级 Skills 组织与优先级

  • 个人 Skill:放在个人环境里,服务你的跨项目偏好
  • 项目 Skill:跟着仓库走,服务项目特有的术语、规范、流程
  • 当两者冲突时:优先采用项目级约束

这不是官方标准文本里的强制规则,但非常符合实际协作逻辑。

10. 写 Skill 时最容易忽略的两个点

第一个,是把 description 写得太短。

很多人会把描述写成一句非常笼统的话,比如“help with writing bio”。这种写法太弱,Claude 不容易判断何时应该触发。更好的做法,是把任务类型和典型触发场景一起写进去。

第二个,是一开始就把 Skill 写得太复杂。

官方 skill-creator 的流程更鼓励先做 draft、先跑测试、再迭代,而不是上来就堆很多脚本和资源。Claude Code 官方关于 subagents 的文档也明确建议:先让 Claude 生成一个版本,再改成适合你自己的版本。


结尾:从“会用”到“可复用”

Claude Skills 的关键,不在于把提示词写得更长,而在于把高频任务的知识、流程和边界沉淀成可以复用的单元。

如果你刚开始上手,最推荐的路径是:先挑一个你最常做的任务(比如写简介、写周报、做文档审阅),用最小结构把它做成一个 Skill,准备 2-3 个真实提示去测试,然后优先迭代 description(能不能触发)输出规则(稳不稳定)

当这些“可重复”的能力慢慢积累起来,你获得的就不只是更快的输出,而是一套能持续复利的个人工作流资产。


🧪

这里是言萧凡的 AI 编程实验室

我会在这里持续记录和分享 AI 工具、编程实践,以及那些值得沉淀下来的高效工作方法。

不只聊概念,也尽量分享能直接上手、能够复用的经验。

希望这间小小的实验室,能陪你一起探索、实践和成长。

2026 年,一起进步。

2026 年前端面试问什么

作者 hpoenixf
2026年3月22日 23:51

背景

由于所在的外企撤出中国,我再次开始了面试之旅。这次我没有选择大厂和小公司,而是主要聚焦在外企和中厂。经过一段时间的面试,我发现 2026 年的前端面试已经发生了显著的变化,特别是 AI 相关的内容占比大幅提升。

面试内容分布

根据我的面试经历,2026 年前端面试的内容分布大致如下(本人接近十年工作经验,仅供参考):

  • coding 20% (LeetCode 算法题和手写代码各一半吧)
  • 八股文:20%(主要是 React Fiber 等核心原理)
  • 项目经历:30%
  • 系统设计:10%(如设计一个支付页面)
  • AI相关问题:20%(这是 2026 年的新重点)

可以看到,相比几年前,AI 相关的内容已经成为面试的重要组成部分。

常见 AI 面试问题

在我面试的过程中,几乎每家公司都会问到以下问题:

  1. 你的日常 AI 工作流是什么?
  2. 如何保证 AI 生成代码的质量?
  3. 你使用哪些 AI 工具?各自的优势是什么?

下面我会详细讲解我的答案。

我的 AI 工作流

我将 AI 工作流分为五个阶段,每个阶段都有明确的目的和技术方案:

1. 需求前:Context 优化

目的:让 AI 充分理解项目上下文,提供高质量的代码生成

技术方案

  • 生成并维护项目 Rule 文件,定义代码规范和架构约束
  • 配置 MCP 服务,提供项目特定的上下文
  • 配置 Skills,为特定任务提供专业知识
  • 生成 Onboarding 文档和 README,帮助 AI 快速理解项目
  • 设定清晰的输入输出规范
  • 定期更新项目总结文档,保持 AI 对项目状态的同步

2. 需求分析:定义问题和约束

目的:明确需求,分析技术方案,设定实现步骤

技术方案

  • 使用 AI 进行需求拆解和分析
  • 让 AI 识别潜在的技术风险和约束
  • 生成详细的实现步骤和 TODO 列表
  • 评估不同技术方案的优劣

3. 需求实现:AI 生成代码

目的:高效生成高质量代码

技术方案

  • UI to Code:从设计稿直接生成组件代码
  • 组件生成:生成可复用的 React 组件
  • 逻辑实现:生成业务逻辑和状态管理代码
  • 测试生成:自动生成单元测试和集成测试
  • 任务拆分:将大任务拆分为小任务,逐步实现
  • 设计优先:让 AI 先设计架构,再实现细节

4. 需求验证:自动验证

目的:确保代码质量和功能正确性

技术方案

  • 静态检查:ESLint、TypeScript、Prettier
  • 自动测试:Jest、React Testing Library、Playwright
  • CI Pipeline:GitHub Actions、GitLab CI
  • 代码审查:AI 辅助的 Code Review

5. 上线与优化:持续优化

目的:持续改进代码质量和 AI 使用效率

技术方案

  • Code Review 反馈:收集团队对 AI 生成代码的反馈
  • 线上监控:监控 AI 生成代码的运行表现
  • Prompt 优化:根据反馈优化 AI 提示词
  • 知识库更新:将最佳实践沉淀到知识库

AI 代码质量保障体系

面试官通常会追问:如何保证 AI 生成的代码质量?我的答案是建立四个阶段的质量保障体系:

开发前:规范与架构约束

  • 制定详细的代码规范(Rule 文件)
  • 定义架构约束和设计模式
  • 配置 AI 的上下文和知识库
  • 设定代码生成的边界条件

开发中:静态质量检查

  • 高质量的 Prompt:清晰、具体、包含上下文
  • 充分的上下文:提供相关代码、文档、历史记录
  • 可复用的 Skill:沉淀常见任务的最佳实践
  • 实时反馈:及时纠正 AI 的错误方向

高质量 Prompt 的要素

一个好的 Prompt 应该包含:

  1. 明确的目标:要实现什么功能
  2. 具体的约束:技术栈、代码规范、性能要求
  3. 充分的上下文:相关代码、接口定义、业务逻辑
  4. 期望的输出:代码、文档、测试等
  5. 质量要求:类型安全、错误处理、可访问性

提交时:自动化测试与 Code Review

  • 运行完整的测试套件
  • 执行静态代码分析
  • AI 辅助的 Code Review
  • 人工 Review 关键代码

运行时:监控与反馈

  • 错误监控和告警
  • 性能指标追踪
  • 用户行为分析
  • 持续优化迭代

我使用的 AI 工具栈

面试官通常会问你使用哪些 AI 工具。我的回答是:

  1. Cursor:主力 IDE,集成了 AI 编程助手

    • 用于:日常编码、代码补全、重构、生成测试
    • 优势:深度集成开发环境,理解项目上下文
    • 使用频率:每天 80% 的编码时间
  2. Claude / GPT-4:用于复杂问题的分析和方案设计

    • 用于:架构设计、技术方案评估、复杂问题分析
    • 优势:强大的推理能力,能够处理复杂的上下文
    • 使用场景:需求分析、技术选型、疑难问题解决
  3. ChatGPT:学习和快速查询

    • 用于:新技术学习、API 查询、快速问答
    • 优势:响应快速,适合碎片化学习

每个工具都有其适用场景,关键是要知道什么时候用什么工具。

如何看待 AI 与程序员的关系

这是面试官经常会问的一个开放性问题。我的回答是:

AI 是放大器,不是替代品

  • AI 让优秀的工程师更加高效,但不能让不合格的工程师变得合格
  • AI 擅长执行明确的任务,但不擅长理解模糊的需求
  • AI 可以生成代码,但不能做出架构决策
  • AI 可以提供建议,但不能承担责任

我的使用原则

  1. AI 负责执行,人负责决策

    • 架构设计、技术选型由人来做
    • 具体实现、测试生成由 AI 来做
  2. AI 负责初稿,人负责精修

    • AI 生成代码的初稿
    • 人进行 Review 和优化
  3. AI 负责重复,人负责创新

    • 重复性的 CRUD、样板代码由 AI 生成
    • 创新性的解决方案由人来设计
  4. 持续学习,保持竞争力

    • AI 在进化,我们也要进化
    • 学习如何更好地使用 AI
    • 学习 AI 无法替代的能力(架构、业务理解、团队协作)

其他面试内容

Coding(20%)

约 70% 的公司会有 coding 环节。速度很关键,如果你在这一环节耗时太多(超过 20 分钟),你的面试大概率就失败了。

LeetCode 算法题(10%):

  • 数组和字符串操作(高频)
  • DFS 和 BFS(中频)
  • 动态规划基础题(低频)
  • 难度:Medium 为主,偶尔有 Easy 或 Hard

手写代码(10%):

  • 手写 Promise、Promise.all、Promise.race
  • 手写数组拍平
  • 手写promise并发

准备建议

  • LeetCode 刷 100-150 题即可,重点是高频题
  • 手写代码要能讲清楚原理,不是背答案
  • 可以使用 AI 帮你理解算法原理,但要自己手写实现

八股文(20%)

主要集中在 React 核心原理,这部分是外企和中厂都很看重的:

React Fiber 架构(高频必考):

  • Fiber 是什么?为什么需要 Fiber?
  • Fiber 的工作原理:双缓冲、时间切片
  • Fiber 的两个阶段:render 阶段和 commit 阶段
  • 优先级调度机制
  • 可中断渲染的实现原理

Hooks 原理(高频):

  • useState 的实现机制:链表结构、闭包
  • useEffect 的执行时机和清理机制
  • useCallback 和 useMemo 的区别和使用场景
  • 自定义 Hook 的设计原则
  • Hooks 的规则和原因(为什么不能在条件语句中使用)

并发特性(中频):

  • Concurrent Mode 的原理和优势
  • Suspense 的使用场景和实现原理
  • Transitions 和 useTransition 的应用
  • 自动批处理(Automatic Batching)

状态管理(中频):

  • Redux vs Zustand 的对比
  • 什么时候需要全局状态管理
  • Context API 的性能问题和优化
  • 服务端状态管理(React Query / SWR)

性能优化(高频):

  • React.memo、useMemo、useCallback 的使用场景和区别
  • 虚拟列表的实现原理
  • 代码分割和懒加载
  • 如何分析和优化 React 应用的性能

准备建议

  • 不要死记硬背,要理解原理
  • 准备好代码示例,能够现场讲解
  • 可以让 AI 帮你梳理知识点,但要自己消化理解
  • 关注 React 19 的新特性(Server Components、Actions 等)

项目经历(30%)

这是面试的重头戏,也是最能展示你能力的部分。建议准备 3-5 个项目,覆盖不同维度:

  1. 技术深度项目:展示你对某个技术的深入理解

  2. 项目管理项目:展示你的规划和推动能力

  3. 失败的项目:展示你如何应对挫折

  4. 团队协作项目:展示你的沟通和协作能力

回答框架(CARL 模型)

每个项目准备好 CARL 模型的回答:

  • Context(背景):项目背景、面临的挑战、为什么重要
  • Action(行动):你具体做了什么、如何做的、为什么这样做
  • Result(结果):最终的成果、量化的数据、业务影响
  • Learning(收获):学到了什么、如何应用到后续工作

示例:技术深度项目

Context:
公司的管理后台有一个包含 10 万条数据的表格,用户反馈滚动卡顿,
体验很差。传统的分页方案不满足产品需求,需要支持无限滚动。

Action:
1. 性能分析:使用 React DevTools Profiler 定位性能瓶颈
2. 技术调研:对比 react-window、react-virtualized 等方案
3. 方案设计:选择 react-window + 自定义 hooks 实现虚拟滚动
4. 实现细节:
   - 动态行高计算
   - 滚动位置保持
   - 数据预加载
   - 搜索和筛选优化
5. 测试验证:性能测试、兼容性测试

Result:
- 首屏渲染时间从 3 秒降低到 0.3 秒(提升 90%)
- 滚动帧率从 20fps 提升到 60fps
- 内存占用从 500MB 降低到 50MB(降低 90%)
- 用户满意度从 60% 提升到 95%

Learning:
- 深入理解了浏览器渲染机制
- 学会了使用 Performance API 进行性能分析
- 认识到性能优化要基于数据,而不是猜测
- 虚拟化是处理大数据渲染的有效方案

系统设计(10%)

这是外企比较看重的部分。

常见题目

  • 设计一个支付页面
  • 设计一个图片上传和裁剪系统
  • 设计一个实时协作编辑器
  • 设计一个电商购物车系统
  • 设计一个新闻推荐系统

答题框架(RADIO 原则)

  1. Requirements(需求分析)

    • 功能需求:核心功能有哪些?
    • 非功能需求:性能、可用性、国际化等
  2. Architecture(架构设计)

    • 前端架构:组件结构、状态管理
    • 后端架构:API 设计、数据库设计
    • 关键组件及其交互
  3. Data Model(数据模型)

    • 数据结构设计
    • 数据流设计
    • 状态管理方案
  4. Integration(集成方案)

    • API 接口设计
    • 第三方服务集成
    • 前后端通信协议
  5. Optimization(优化方案)

    • 性能优化:缓存、懒加载、CDN
    • 可扩展性:负载均衡、分库分表
    • 可靠性:容错、降级、监控
  6. Deep Dive(深入讨论)

    • 根据面试官的兴趣深入某个技术点

示例:设计一个支付页面

1. Requirements
   - 功能:支持多种支付方式(信用卡、支付宝、微信)
   - 安全:PCI DSS 合规,敏感信息加密
   - 性能:3 秒内完成支付流程
   - 可用性:99.9% 可用性

2. Architecture
   - 前端:React + TypeScript + Tailwind CSS
   - 状态管理:Zustand
   - 表单验证:React Hook Form + Zod
   - 支付 SDK:集成第三方支付网关

3. Data Model
   - 订单信息:订单号、金额、商品信息
   - 支付信息:支付方式、支付状态、交易流水号
   - 用户信息:用户 ID、收货地址

4. Integration
   - POST /api/orders/create - 创建订单
   - POST /api/payments/process - 处理支付
   - GET /api/payments/status - 查询支付状态
   - Webhook 接收支付结果通知

5. Optimization
   - 性能:预加载支付 SDK、使用 CDN
   - 安全:HTTPS、CSP、输入验证、防重放攻击
   - 可靠性:支付失败重试、超时处理、降级方案

6. Deep Dive
   - 如何防止重复支付?使用幂等性设计
   - 如何处理支付超时?轮询 + Webhook 双保险
   - 如何保证支付安全?Token 化、加密传输、风控系统

答题技巧

  1. 先问清楚需求:不要上来就开始设计,先问面试官关于规模、重点等问题

    • 预期的用户规模是多少?
    • 重点关注哪个方面?(性能、安全、可扩展性)
    • 是前端系统设计还是全栈系统设计?
  2. 画图辅助说明:在白板或纸上画出架构图、数据流图

    • 组件结构图
    • 数据流图
    • 系统架构图
  3. 从高层到细节:先讲整体架构,再深入某个模块

    • 不要一开始就陷入实现细节
    • 根据面试官的反馈调整深度
  4. 讨论权衡取舍:展示你的思考深度

    • 方案 A 的优势和劣势
    • 为什么选择方案 B
    • 在什么情况下会选择方案 C

准备资源

  • 《System Design Interview》by Alex Xu
  • YouTube: Grokking the System Design Interview
  • 前端系统设计博客和文章

总结

2026 年的前端面试已经发生了显著变化,AI 工作流成为了重要的考察点。但本质上,面试官想要的仍然是:

  1. 扎实的基础:JavaScript、React、工程化
  2. 解决问题的能力:分析问题、设计方案、实现落地
  3. 持续学习的能力:拥抱新技术、适应变化
  4. 工程素养:代码质量、团队协作、项目管理
  5. AI 时代的新能力:高效使用 AI、保证质量、持续优化

我的核心观点

AI 只是新增的一个维度,它让优秀的工程师更加高效,但不能替代工程师的核心能力。在 AI 时代,我们需要:

  • 更强的架构能力:AI 能生成代码,但不能设计架构
  • 更深的业务理解:AI 能实现需求,但不能理解业务
  • 更好的判断力:AI 能提供方案,但不能做出决策
  • 更高的工程素养:AI 能写代码,但不能保证质量

最后的建议

面试是一个展示自己的机会,也是一个学习的机会。每次面试后,我都会:

  • 记录面试中的问题和自己的回答
  • 分析哪些地方回答得好,哪些地方需要改进
  • 补充不会的知识点
  • 优化下次面试的策略

希望这篇文章能帮助到正在准备面试的你。如果你有任何问题或想要交流面试经验,欢迎在评论区留言!

祝大家都能找到满意的工作!

昨天 — 2026年3月22日技术

阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM

作者 泯泷
2026年3月22日 23:28

本章目标

这一章要先把“机器全貌”搭出来。读完以后,你应该能回答:

  1. JSVMJSVMP、编译器、解释器之间是什么关系?
  2. 一段普通 JavaScript 进入系统后,会依次经历哪些形态?
  3. 为什么本项目选择寄存器机,而不是栈机?
  4. registerslotenv 为什么必须分离?

先看整机:一段源码在系统里的生命周期

flowchart LR
    A["Source<br/>var x = 40 + 2"] --> B["AST<br/>语法树"]
    B --> C["IR<br/>线性执行步骤"]
    C --> D["Bytecode<br/>数字协议"]
    D --> E["Runtime<br/>解释器循环"]
    E --> F["Result<br/>执行结果"]

这条流水线说明了一件事:JSVMP 不是“把源码塞进一段混淆代码里”,而是把源码翻译成另一套执行协议,再由内嵌虚拟机解释执行。

更精确地说,JSVMP = 编译期翻译 + 运行时重放语义


为什么 VM 的核心,其实只是一个状态机

先看最小解释器骨架:

function run(program) {
  const regs = []
  const code = program.bytecode
  let pc = 0

  while (pc < code.length) {
    const op = code[pc++]

    switch (op) {
      case OPCODES.LOAD_CONST:
        // ... 读操作数,写寄存器 ...
        break
      case OPCODES.BINARY:
        // ... 取寄存器,做运算,写回结果 ...
        break
      case OPCODES.RETURN:
        // ... 结束并返回 ...
        break
    }
  }
}

这段代码足以暴露 VM 的三件基础事实:

  • pc 负责指出“下一条指令从哪里开始读”。
  • regs 负责保存表达式求值过程中的中间结果。
  • switch(op) 负责把数字协议还原成真实动作。

从架构角度看,VM 的本质并不神秘。真正的难点在于:编译器输出的协议,必须和这个状态机逐项对齐。


为什么 AST 之后还要有 IR 这一层

先看同一段代码在两种表示下的差异:

源码

var x = 40 + 2;
__result = x;

AST 视角:强调“结构”

{
  "type": "VariableDeclaration",
  "declarations": [
    {
      "id": { "name": "x" },
      "init": {
        "type": "BinaryExpression",
        "left": { "type": "NumericLiteral", "value": 40 },
        "right": { "type": "NumericLiteral", "value": 2 }
      }
    }
  ]
}

IR 视角:强调“顺序”

load_const   r0, 40
load_const   r1, 2
binary       r2, r0, r1, +
init_slot    slot0, r2
load_slot    r3, slot0
store_global "__result", r3

两者都重要,但职责不同:

表示层 擅长表达什么 不擅长表达什么
AST 源码的嵌套结构 线性执行顺序
IR 逐步执行的动作序列 高层语法层次

这也是本系列教程把“AST -> IR”单独拿出来讲的原因。


为什么这里选择寄存器机,而不是栈机

同样是计算 40 + 2,两类 VM 的指令风格完全不同。

栈机:中间结果隐含在栈顶

PUSH 40
PUSH 2
ADD

寄存器机:中间结果显式落在目标位

LOAD_CONST r0, 40
LOAD_CONST r1, 2
BINARY     r2, r0, r1, +

本项目选择寄存器机,不是因为它“更高级”,而是因为它更贴合 lowering 的输出习惯:

  • AST 展平之后会自然产生大量临时值。
  • 这些临时值在寄存器模型里可以拥有稳定编号。
  • 当控制流、函数调用、对象访问逐步加入后,寄存器式 IR 更容易检查和调试。

两种模型的对比

维度 栈机 寄存器机
中间结果位置 隐含在栈顶 显式写在目标寄存器
指令长度 通常更短 通常更长
可读性 需要追踪栈变化 直接看到数据流向
调试体验 更依赖心算 更适合打印状态

为什么变量不能直接“住在寄存器里”

从执行角度看,表达式结果和变量绑定是两类完全不同的东西。

概念 作用 生命周期
Register 保存临时计算结果 通常只覆盖当前表达式
Slot 保存变量绑定对应的位置 伴随作用域存活
Env 管理一组 slot,并串成作用域链 伴随函数/块级作用域存活

可以把它们理解成三种不同的存储设施:

  • register 是桌面便签,适合临时放中间结果。
  • slot 是编号抽屉,适合保存变量绑定。
  • env 是整组抽屉组成的文件柜,负责向外层作用域链接。

这组分层会直接决定后面如何实现闭包与提升。


编译期和运行时为什么必须保持同构

编译器在 lowering 阶段会算出一个变量应该如何被访问:

load_slot dst=r4 depth=1 slot=0

这条指令其实已经携带了运行时假设:

  • 当前函数的环境不是目标环境。
  • 需要沿着 env.parent 向外走 1 层。
  • 到达目标环境后,从 slot0 读取值。

因此,编译器里的作用域分析和运行时里的环境链必须描述同一件事。它们不是“相似”,而是“同构”。

一旦两者对不齐,就会出现这类问题:

  • 编译期认为变量在外层,运行时却找错了层级。
  • 编译期把某个绑定当成可读,运行时却仍处于未初始化状态。

从最小示例看整机如何第一次跑通

教程第一步对应的配套文件是:

  • docs/examples/tutorial-jsvm/01-handwritten-register-vm.js

它只做一件事:用 LOAD_CONSTBINARYRETURN 三种指令跑通 40 + 2

const program = {
  constants: [40, 2],
  bytecode: [
    OPCODES.LOAD_CONST, 0, 0,
    OPCODES.LOAD_CONST, 1, 1,
    OPCODES.BINARY, 2, 0, 1, BINARY_OPS.ADD,
    OPCODES.RETURN, 2,
  ],
}

这个例子之所以重要,不在于它功能多,而在于它第一次把下面四个零件同时摆上桌面:

  1. 指令协议
  2. 运行时状态
  3. 字节码输入
  4. 返回出口

后面的章节,都是在这个最小框架上逐步补语义能力。


本章小结

这一章真正要建立的是“坐标系”:

  • JSVMP 是一条完整的编译执行流水线,不是单点技巧。
  • AST、IR、Bytecode、Runtime 各自负责不同层次的问题。
  • 寄存器机更适合承载 lowering 之后的线性步骤。
  • register / slot / env 的边界,是后续所有运行时语义的基础。

带着这套坐标再进入下一章,指令集就不再只是“列一张 opcode 表”,而会成为连接编译器与运行时的协议层。

【节点】[SampleTexture3D节点]原理解析与实际应用

作者 SmalBox
2026年3月22日 22:39

【Unity Shader Graph 使用与特效实现】专栏-直达

Sample Texture 3D 节点是 Unity Shader Graph 中用于处理三维纹理资源的核心节点。在实时渲染和视觉效果制作中,3D 纹理作为一种体积数据存储格式,能够为着色器提供丰富的三维空间信息,从而实现更加复杂和真实的材质表现。与传统的 2D 纹理不同,3D 纹理在三个维度上都存储了颜色数据,这使得它特别适合用于体积渲染、程序化纹理生成、噪声函数模拟等高级渲染技术。

在现代游戏开发和实时图形应用中,3D 纹理的使用越来越广泛。从简单的体积雾效到复杂的医学可视化,从动态的云层模拟到逼真的材质表面细节,3D 纹理都为开发者提供了强大的工具。Sample Texture 3D 节点正是访问和操作这些 3D 纹理数据的关键入口,它允许着色器在三维空间中进行精确的纹理采样,获取任意位置的纹理颜色值。

理解 Sample Texture 3D 节点的工作原理和正确使用方法,对于掌握高级着色器编程技术至关重要。本节点不仅提供了基本的纹理采样功能,还包含了多种采样模式和配置选项,使其能够适应不同的渲染需求和性能约束。无论是初学者还是有经验的着色器开发者,都需要深入理解这个节点的各个方面,才能充分发挥 3D 纹理在项目中的潜力。

描述

Sample Texture 3D 节点的主要功能是从 3D 纹理资源中采样颜色数据,并返回一个包含 RGBA 四个通道的 Vector 4 类型值。这个节点是 Shader Graph 中处理体积纹理的核心组件,它通过三维 UV 坐标系统来定位和获取纹理中的特定位置数据。

3D 纹理的基本概念

3D 纹理,也称为体积纹理,是一种在三个维度上存储数据的三维数组。与 2D 纹理使用 U 和 V 两个坐标轴不同,3D 纹理增加了第三个坐标轴 W,形成了一个完整的三维纹理空间。这种结构使得 3D 纹理能够表示体积数据,比如医学成像中的 CT 扫描数据、科学可视化中的标量场,或者游戏中的体积雾和云层效果。

在 Unity 中,3D 纹理通过 Texture3D 类来表示,每个纹理元素(texel)都包含颜色信息。当使用 Sample Texture 3D 节点采样时,系统会根据提供的三维 UV 坐标,在纹理的三个维度上进行插值计算,最终返回平滑的颜色值。

节点的工作机制

Sample Texture 3D 节点的采样过程涉及多个步骤。首先,节点接收三维 UV 坐标输入,这个坐标定义了在纹理空间中的采样位置。每个坐标分量的范围通常是[0,1],对应纹理的整个体积范围。然后,节点根据连接的纹理资源和采样器状态,执行实际的纹理查找操作。

采样过程中,节点会考虑纹理的过滤设置。如果启用了纹理过滤,系统会在多个 mip 级别之间进行插值,或者在相邻纹理元素之间进行线性插值,以获得更加平滑的采样结果。这对于避免在动态相机移动时出现明显的纹理闪烁或锯齿现象非常重要。

UV 坐标系统

3D 纹理的 UV 坐标系统使用三个分量(X,Y,Z)或(U,V,W)来表示三维空间中的位置。理解这个坐标系统对于正确使用 Sample Texture 3D 节点至关重要:

  • U 坐标对应纹理的宽度方向(X 轴)
  • V 坐标对应纹理的高度方向(Y 轴)
  • W 坐标对应纹理的深度方向(Z 轴)

每个坐标分量的取值范围通常是 0 到 1,其中 0 表示纹理空间的起点,1 表示终点。超出这个范围的坐标值会根据纹理的包裹模式进行处理,比如重复、钳制或镜像。

采样器状态的重要性

采样器状态定义了纹理采样的具体参数,包括过滤模式、包裹模式和各向异性过滤级别等。通过 Sampler State 节点,开发者可以精确控制纹理采样的行为:

  • 过滤模式决定了当纹理被放大或缩小时如何插值纹理元素
  • 包裹模式定义了当 UV 坐标超出[0,1]范围时的处理方式
  • 各向异性过滤改善了在倾斜角度观察纹理时的视觉质量

正确配置采样器状态对于获得高质量的渲染结果至关重要,特别是在处理动态相机和复杂场景时。

Note

如果在包含自定义函数节点或子图的图形中遇到此节点的纹理采样错误,请升级到 Shader Graph 版本 10.3 或更高版本。这个版本修复了与自定义着色器代码集成时可能出现的兼容性问题,确保了节点在各种使用场景下的稳定性。

创建节点菜单类别

采样 3D 纹理节点位于创建节点菜单的 输入 > 纹理类别下。这个分类反映了节点在 Shader Graph 中的基本作用——作为着色器的输入数据源之一。

节点菜单结构

在 Shader Graph 的创建节点菜单中,纹理相关的节点被组织在统一的类别下,方便开发者快速找到所需的纹理操作节点。Sample Texture 3D 节点与其他纹理采样节点(如 Sample Texture 2D、Sample Texture 2D Array 等)并列,构成了完整的纹理处理工具集。

访问方式

开发者可以通过多种方式在 Shader Graph 中添加 Sample Texture 3D 节点:

  • 在图形编辑器的空白区域右键点击,从上下文菜单中选择创建节点,然后导航至输入 > 纹理 > Sample Texture 3D
  • 使用搜索功能直接输入"Sample Texture 3D"来快速定位节点
  • 通过拖拽 Project 窗口中的 Texture3D 资源到图形编辑器中,自动创建对应的采样节点

与其他纹理节点的关系

理解 Sample Texture 3D 节点在纹理节点家族中的位置很重要:

  • 与 Sample Texture 2D 节点相比,3D 版本增加了对深度维度的支持
  • 与 Sample Texture 2D Array 节点相比,3D 纹理提供连续的三维数据而不是离散的切片
  • 与 Sample Texture Cube 节点相比,3D 纹理使用线性坐标系统而不是方向向量

这种分类和组织方式帮助开发者根据具体的纹理类型和用途选择合适的采样节点。

兼容性

采样 3D 纹理节点支持以下渲染管线:

内置渲染管线 通用渲染管线 (URP) 高清渲染管线 (HDRP)

跨渲染管线兼容性

Sample Texture 3D 节点在设计时考虑了跨渲染管线的兼容性,确保在不同的渲染架构下都能正常工作。这种兼容性是通过抽象的着色器代码生成和运行时条件编译实现的。

在内置渲染管线中,节点生成的代码使用传统的 HLSL 纹理采样函数。在 URP 和 HDRP 中,则使用各自渲染管线特定的宏和函数,以确保与管线架构的深度集成。

功能一致性

尽管底层实现可能因渲染管线而异,但 Sample Texture 3D 节点在不同管线中提供了基本一致的用户体验和功能集。这意味着开发者可以在不同项目间迁移着色器时,无需重写 3D 纹理相关的逻辑。

性能考虑

在不同渲染管线中,3D 纹理采样的性能特征可能有所不同:

  • 在内置渲染管线中,性能主要取决于硬件和驱动程序的支持
  • 在 URP 中,针对移动平台和低端硬件进行了优化
  • 在 HDRP 中,支持更高质量的各向异性过滤和 mip 映射

开发者应该根据目标平台和性能要求,在不同渲染管线中测试 3D 纹理的使用效果。

特定功能支持

某些高级功能可能在特定渲染管线中有所差异:

  • HDRP 可能支持 3D 纹理的流式加载和细节级别控制
  • URP 可能对 3D 纹理的分辨率有更严格的限制以保持性能
  • 内置渲染管线可能提供更多的低级控制选项

了解这些差异有助于在不同项目中做出合理的技术选择。

默认设置下,此节点只能连接到 Shader Graph 的片段上下文中的块节点。要在 Shader Graph 的顶点上下文中采样纹理,请将 Mip 采样模式(Mip Sampling Mode) 设置为 LOD

输入

采样 3D 纹理节点具有以下输入端口:

名称 类型 绑定 描述
Texture Texture 3D 要采样的 3D 纹理资源。
UV Vector 3 用于采样纹理的 3D UV 坐标。
Sampler 采样器状态 默认采样器状态 用于采样纹理的采样器状态和设置。
LOD Float LOD 采样纹理时使用的特定 mip。注意LOD 输入端口仅在 Mip 采样模式LOD 时显示。有关更多信息,请参见其他节点设置

Texture 输入详解

Texture 输入端口接受 Texture3D 类型的资源,这是 Unity 中专门用于表示三维纹理的数据类型。在连接纹理资源时,开发者需要考虑几个重要因素:

  • 纹理格式:确保 3D 纹理使用支持的颜色格式,如 RGBA32、RGBAHalf 或 RFloat 等
  • 纹理尺寸:3D 纹理的内存占用随尺寸立方增长,需要平衡质量与性能
  • 导入设置:在纹理导入器中正确配置压缩设置、mipmap 生成和读写权限

Texture 输入端口的连接方式很灵活:

  • 可以直接从 Project 窗口拖拽 Texture3D 资源到端口
  • 可以通过 Expose 属性将纹理作为材质参数暴露出来
  • 可以在运行时通过脚本动态替换纹理资源

UV 输入详解

UV 输入端口接收 Vector 3 类型的坐标值,定义在 3D 纹理空间中的采样位置。正确生成和使用 UV 坐标是 3D 纹理应用的关键:

  • 对象空间 UV:使用模型顶点位置作为 UV 坐标,适合体积效果和对象内部的纹理映射
  • 世界空间 UV:使用世界坐标系中的位置,适合环境效果和全局纹理
  • 自定义 UV:通过数学节点生成特定的坐标模式,用于程序化效果

UV 坐标的生成通常涉及空间变换和缩放操作:

// 示例:将世界位置转换为合适的UV坐标
UV = (WorldPosition - VolumeOrigin) / VolumeSize

这种转换确保纹理正确地对齐到目标体积区域。

Sampler 输入详解

Sampler 输入端口连接 Sampler State 节点,该节点定义了纹理采样的详细参数。采样器状态包含三个主要设置:

  • 过滤模式(Filter Mode):
    • Point:最近邻采样,产生像素化效果
    • Bilinear:线性滤波,平滑的纹理过渡
    • Trilinear:在 mip 级别间插值,更高质量的过滤
  • 包裹模式(Wrap Mode):
    • Repeat:纹理在坐标超出范围时重复
    • Clamp:坐标被钳制在纹理边缘
    • Mirror:纹理镜像重复
  • 各向异性级别(Anisotropic Level):
    • 改善倾斜角度观察时的纹理质量
    • 更高的值提供更好的质量但消耗更多性能

LOD 输入详解

LOD(Level of Detail)输入端口在 Mip 采样模式设置为 LOD 时出现,允许显式指定使用的 mip 级别:

  • mip 级别 0 是原始分辨率的纹理
  • 每增加一级 mip 级别,纹理分辨率减半
  • 负值或小数值可以在 mip 级别间进行插值

LOD 控制对于实现特定的渲染效果非常有用:

  • 在顶点着色器中采样纹理时使用固定 mip 级别
  • 实现自定义的细节级别过渡逻辑
  • 创建风格化的像素化或模糊效果

其他节点设置

采样 3D 纹理节点有一些额外的设置,您可以从图形检查器(Graph Inspector)访问:

名称 类型 描述
Mip Sampling Mode 下拉菜单 选择采样 3D 纹理节点用于计算纹理 mip 级别的采样模式。
Standard 渲染管线自动计算并选择纹理的 mip。
LOD 渲染管线允许你在节点上为纹理设置明确的 mip。无论像素间的 DDX 或 DDY 计算如何,纹理始终使用该 mip。将 Mip 采样模式设置为LOD,以将节点连接到顶点上下文中的 Block 节点。有关 Block 节点和上下文的更多信息,请参见 Master Stack

Mip 采样模式详解

Mip 采样模式设置决定了节点如何选择和使用纹理的 mipmap 级别。mipmap 是纹理的预计算缩小版本,用于改善远处表面的视觉质量和渲染性能。

Standard 模式

在 Standard 模式下,渲染管线根据屏幕空间的导数自动计算合适的 mip 级别:

  • 系统使用 DDX 和 DDY 指令计算纹理坐标在屏幕空间的变化率
  • 基于变化率选择能够避免混叠的 mip 级别
  • 在纹理细节与屏幕像素密度匹配时提供最佳质量

Standard 模式适用于大多数常规用途,特别是在片段着色器中使用时:

  • 自动适应不同的观察距离和角度
  • 提供自然的细节过渡
  • 优化内存带宽使用

LOD 模式

LOD 模式允许开发者显式控制使用的 mip 级别,这在某些特定场景下非常有用:

  • 顶点着色器采样:顶点着色器中没有屏幕空间导数信息,需要固定 mip 级别
  • 程序化效果:需要精确控制纹理细节级别来实现特定艺术效果
  • 性能优化:在远处表面使用低 mip 级别减少内存访问

使用 LOD 模式时,开发者需要手动管理 mip 级别的选择:

// 示例:基于距离动态计算LOD
LOD = max(0, log2(Distance / ReferenceDistance))

这种控制虽然增加了复杂性,但也提供了更大的灵活性。

设置选择的实践指导

选择合适的 Mip 采样模式需要考虑具体的使用场景:

  • 对于常规的表面纹理和材质效果,使用 Standard 模式
  • 在顶点着色器中采样纹理时,使用 LOD 模式并设置固定 mip 级别
  • 对于需要特殊 mip 控制的自定义效果,使用 LOD 模式

理解这两种模式的差异和适用场景,有助于开发出既美观又高效的着色器。

输出

采样 3D 纹理节点具有以下输出端口:

名称 类型 描述
RGBA Vector 4 纹理样本的完整 RGBA Vector 4 颜色值。
R Float 纹理样本的红色 (x) 分量。
G Float 纹理样本的绿色 (y) 分量。
B Float 纹理样本的蓝色 (z) 分量。
A Float 纹理样本的透明度 Alpha (w) 分量。

RGBA 输出详解

RGBA 输出端口提供完整的四通道颜色值,这是最常用的输出形式:

  • 包含完整的颜色和透明度信息
  • 可以直接连接到基础颜色、发射颜色等材质属性
  • 适合大多数标准的纹理应用场景

在使用 RGBA 输出时,需要注意颜色空间的一致性:

  • 在线性颜色空间中工作的纹理需要正确的伽马校正
  • HDR 纹理可能包含超出[0,1]范围的值
  • 透明度通道可能用于各种效果,而不仅仅是传统透明度

各分量输出详解

单独的颜色分量输出为特定的效果和优化提供了便利:

R 通道(红色)

红色通道通常用于存储高度、密度或强度信息:

  • 在高度图中表示表面高程
  • 在遮罩纹理中表示特定区域的强度
  • 在数据纹理中存储标量值

G 通道(绿色)

绿色通道可以存储辅助数据或作为多用途通道:

  • 在法线贴图中与红色通道一起编码法线信息
  • 在多层材质中表示第二层的强度
  • 存储辅助的标量参数

B 通道(蓝色)

蓝色通道的用途与具体应用相关:

  • 在三通道法线贴图中编码法线的 Z 分量
  • 在特殊效果纹理中存储第三组数据
  • 作为额外的遮罩或参数通道

A 通道(Alpha)

Alpha 通道虽然传统上用于透明度,但在 3D 纹理中可能有多种用途:

  • 存储透明度信息用于体积渲染
  • 编码第四组数据参数
  • 作为选择性混合的遮罩

输出选择策略

选择合适的输出端口取决于具体的应用需求:

  • 对于完整的颜色纹理,使用 RGBA 输出
  • 当只需要单通道数据时,使用对应的分量输出以减少不必要的计算
  • 在复杂的材质网络中,可能同时使用多个分量输出

理解每个输出端口的特性和用途,有助于构建更加高效和灵活的着色器图形。

示例图形用法

在以下示例中,Sample Texture 3D 节点采样了一个 3D 分形噪声纹理资源。它的输入 UV 坐标来自一个位置节点,设置为对象(Object) 空间。

基础设置示例

这个基础示例演示了 Sample Texture 3D 节点的典型用法:

Sample Texture 3D 节点需要 Vector 3 作为其 UV 坐标输入,而不是 Vector 2,因为纹理资源在虚拟的 3D 空间中作为一个体积存在。该节点使用默认的采样器状态,因为没有连接采样器状态节点。

这个特定的 3D 纹理资源将其纹理数据存储在 Alpha 通道中,因此 Sample Texture 3D 节点使用其 A 输出端口作为主栈片元上下文中基础颜色块(Base Color Block)节点的输入:

高级应用示例

除了基础用法,Sample Texture 3D 节点还支持多种高级应用场景:

体积雾效

使用 3D 噪声纹理创建动态的体积雾效:

  • 通过时间变量动画化 UV 坐标,实现雾的流动效果
  • 结合深度信息控制雾的密度随距离变化
  • 使用多个 octave 的噪声叠加增加细节

程序化材质

利用 3D 纹理生成复杂的程序化材质:

  • 将对象空间位置映射到 3D 纹理坐标
  • 结合多种 3D 纹理创建分层材质效果
  • 使用数学节点动态修改纹理采样参数

科学可视化

将科学数据存储为 3D 纹理进行实时可视化:

  • 将体数据(如 CT、MRI)导入为 3D 纹理
  • 使用传输函数将标量值映射为颜色
  • 实现实时的体积切割和剖面查看

性能优化技巧

在实际项目中使用 Sample Texture 3D 节点时,性能考虑很重要:

  • 使用合适分辨率的纹理平衡质量与内存使用
  • 在可能的情况下使用压缩纹理格式
  • 考虑使用纹理流式加载管理内存
  • 对于静态效果,预计算纹理数据到较低维度

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

利好打工人,openclaw不是企业提效工具,而是个人助理

作者 华洛
2026年3月22日 22:34

最近,openclaw(小龙虾)火的非常彻底。

朋友圈在刷,社群在讨论,社交媒体上到处可见"我用小龙虾做了什么"的分享。

它仿佛在一夜之间就成了AI圈的当红炸子鸡,各种评测、教程、玩法层出不穷。热度和关注度拉满,不谈它,似乎就显得落伍了。

不少领导也都刷到了关于小龙虾的文章或者视频,许多企业里也传来了要大家用小龙虾来实现降本增效的声音。

但今天,我可能要泼一盆冷水:就目前来看,OpenClaw对企业的降本增效几乎毫无用处。

主要原因有三点:

  1. 定位不符:它更像助理,而非企业要的提效工具。
  2. 能力错配:其能力与企业岗位的真实需求不匹配。
  3. ROI失衡:对企业而言,投入产出比严重不合理。

一、定位不符:它是助理,不是“提效工具”

小龙虾的定位更偏向于助理,而不是企业想要的提效。

我们需要先厘清“提效”和“助理”的区别。两者固然都要求结果质量达标,但核心逻辑不同:

提效,追求的是单位时间产出的提升。活还是员工干,但是干的更快了,原本要一个小时才能干完的事情,用了小龙虾只需要40分钟就可以完成了,那么就提效了20分钟。

助理,追求的是解放个人劳动力。活我不干了,交给小龙虾自动或者半自动完成了,我坐在一边喝着茶,唠着嗑,最后验收结果就行。

企业想要什么?显然是前者——让员工更高效地工作,而非让他们“喝茶唠嗑”。

我们再看OpenClaw的核心能力:

  • 能接入飞书、钉钉、企微、微信等办公软件。
  • 内置智能体引擎,可规划任务。
  • 拥有系统级操作权限。

简单说就是能操作电脑、能自动规划任务、能最终发消息通知主人,这些能力也是导致小龙虾如今爆火的核心。

看起来,它似乎能被“培养”成一名数字员工,协助完成任务,实现提效。

但实际使用之后,我们会发现,OpenClaw执行给定任务的速度极慢。

例如:让小龙虾自动上架商品、同步库存,然后处理一下订单和退货消息,这个工作流程执行下来比真实员工要慢几倍的时间。

这是因为小龙虾内置的智能体流程,就像我之前文章里讲的:只要是智能体架构的设计,都会出现响应速度慢的情况。

特别是当智能体中间的某个流程需要完整分析和输出时,这些时间损耗都是要前端用户等待的。

任务执行慢,效率反而不如人工,这首先就无法满足企业的提效需求。

二、能力错配:解决不了企业的核心任务

小龙虾的真实工作能力与企业岗位需求和任职要求严重不匹配

企业对员工工作成果最关心两点:交付速度交付质量

能不能更快的、更好的交付工作成果。是我们评估一个员工是否比其他员工优秀的评判标准。

上一部分我们已经知道了小龙虾的交付速度是不如员工自己做来的快。

这部分我们看下交付质量的问题:

目前,大众用OpenClaw做什么?

资料整理、发送邮件、滴滴打车、联网查资料、定时提醒等等

我们会发现小龙虾做的事情更多的是:工作中琐碎、简单的边角事务,而不是某个岗位的主要工作任务

例如:要求他总结飞书某个群里的最后50条信息,然后把总结信息发送给某个人,这个任务小龙虾3分钟左右可以完成。

但是如果要让他对几个excel分析、整理、搭配公式处理复杂的excel时,我们会发现小龙虾的能力不足以完美的解决工作场景中的复杂问题。

当然,这里有一部分是因为AI的能力问题,读者可能会觉得AI能力早晚会提升的,这个问题可以解决。

但是这里其实还有另外一个容易让人忽视的问题:

有很多任务员工很难用人话把要做的事情给AI描述清楚

原因通常有两个:

  1. 任务复杂,自然语言词汇量匮乏,很难精确的描述任务。
  2. 任务面临的情况有非常多,员工个人的经验可能都不足以处理所有的问题,甚至是遇到问题临时处理。

最终,这导致OpenClaw目前完全无法胜任企业内任何一个专业岗位的核心工作。

三、ROI严重失衡

企业是算经济账的,OpenClaw的账目前很难算平。

1. tokens消耗成本高

对企业而言,月薪几千可以雇一个每天工作8小时的大学生,但可能“养不起”一个同样工作时长的OpenClaw。

每一次调用、每一个需求都要消耗Tokens,用量上去后,成本呈指数级攀升。

经过测试,openclaw的tokens消耗,在每天10小时工作处理复杂任务的情况下,每天消耗的tokens轻松过亿。

并且这个费用没有编辑效应,用的越多付费越多。

而企业想要的是边际成本趋近于零的工具。很显然,按量计费的AI模式跟这个需求天然矛盾。

2. 数据安全与泄密风险

企业可以和员工签竞业协议、签保密协议,员工泄密了有法律追责。

但OpenClaw一旦泄密,数据传到了哪里、存储在何处、是否会被恶意引导攻击,企业几乎无从知晓和追责。

目前OpenClaw暴露看板平台上暴露到公网的 OpenClaw 实例,已经超过二十八万。

安全问题不容忽视啊。

那么,来做个选择题吧,如果你是老板,面对两个“员工”会怎么选

A员工:按工作量计费、工作结果无法保障、可能存在泄密,就连返工修改也要跟你算钱,但是一旦出问题他一点责任不担。

B员工:一口价,但是可以加班,一个稿子改8遍也没关系,出问题还可以担责。

相信大家都有自己的选择。

结语

小龙虾和claude code等AI编程工具不一样。

AI编程工具场景较为单一,同时有无数的训练资料,当下又有非常多的团队致力于解决AI编程的问题。

这最终导致AI编程的可用性是非常高的,高到足以作为一个生产力工具来代替部分真实员工。

而小龙虾是一个通用的个人助手工具, 个人助手的重点是得有个人让它当助手

我当然是希望openclaw能够发展进化成一个像“贾维斯”一样的完美个人助手。

这类助手被包装成按包月收费的产品,打工人就像现在每个月缴电话费一样,按月为自己的“贾维斯”充值。

届时我是肯定愿意为我的“贾维斯”充值的,让它帮我解决各类琐事,解放我的精力去做更多高价值的事情。

毕竟没有人努力工作是为了一直工作的,对吧。

我是华洛,关注我学习更多AI落地的实战经验与技巧。

加油,共勉。

☺️你好,我是华洛,All in AI多年,专注于AI在产品侧的应用以及企业AI员工的设计。

专栏文章

# 多写点skill吧,写的越多这行业死的越快。

# 聊聊我们公司的AI应用工程师每天都干啥?

# SEO还没死,GEO之战已经开始

# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐

# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐

# 聊一下MCP,希望能让各位清醒一点吧🧐

# 实战派!百万PV的AI产品如何搭建RAG系统?

# 团队落地AI产品的全流程

# 5000字长文,AI时代下程序员的巨大优势!

实测 Claude 多 Agent 开发:项目经理开局摸鱼,我成了救火队员

作者 yyt_
2026年3月22日 22:25

最近玩了一下 Claude 的多 Agent 协作功能 —— 通过接入 Tmux 分屏同时拉起项目经理、前端、后端三个角色,让它们组成一个团队帮我做一个完整的博客系统。使用过程中踩了不少坑,记录一下真实感受。

屏幕录制 2026-03-22 222232.gif

安装方法(windows)

我电脑是windows,这里只介绍windows安装tmux方法

先要开启WSL,进入 WSL,执行:

sudo apt update
sudo apt install tmux -y

安装之后输入tmux,下面有绿色的条就是成功了

为了鼠标可以在不同agent窗口中进行点击 编辑 tmux 配置:

nano ~/.tmux.conf
set -g mouse on
set -g base-index 1

然后配置Claude开启多智能体团队功能

进入

~/.claude/settings.json

settings.json

{
  "env": {
    "ANTHROPIC_AUTH_TOKEN": "375c1cd4195447ea83b3b5c31ab09006.x7gXXB9BuNtcJjPw",
    "ANTHROPIC_BASE_URL": "https://open.bigmodel.cn/api/anthropic",
    "API_TIMEOUT_MS": "3000000",
    "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1,
    "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"  --改这个
  },
  "permissions": {
    "allow": [
      "Bash(ask *)",
      "Bash(ccb-ping *)",
      "Bash(pend *)"
    ],
    "deny": []
  },
  "teammateMode": "tmux" --改这个
}
  • CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1"开启多智能体团队功能(实验性开关)
  • teammateMode: "tmux":指定用 tmux 来管理每个 agent 的独立会话

项目要先运行WSL,然后再运行tmux,再在里面启动claude

下发指令

给claude指令:

帮我创建一个 Agent Team,包含项目经理、前端工程师、后端工程师,等待我发布项目指令。

4abf27553eab0240675330c37578e827.png

我是让ai做一个博客系统,给出需求之后,项目经理进行规划,做了梳理功能、确定技术栈、设计数据库表、输出接口文档。

image.png

中间出现了WSL 里的服务连不上 Windows 上的 MySQL,手动解决了一下。 其他都是模型自动完成的。

开发过程中,后端自动帮我集成了一个数据库可视化管理工具,能直接在浏览器里查看表里的数据、新增编辑删除记录,还能清晰看到文章、分类、标签之间的关联关系。

image.png

进度方面是前端更快一些,不足的是项目经理在输出文档之后就没说过话了(可恶既然摸鱼,下次玩一定给pm加个kpi考核),正常好像应该是项目经理去push前后端进度吧,但是我这个team不太规范,是前后端一直在跟架构师去沟通。

image.png

验收, 前端打开页面,出现解决了下面问题

  • .tsx 写成了 .ts,导致页面直接报错
  • 引入了 @tanstack/react-query-devtools 却没装依赖,服务起不来
  • 代码里直接 import { AxiosResponse } from 'axios',新版 axios 根本不导出这个类型,直接 SyntaxError

前端生成的页面马马虎虎,然后分类这个标签切换不过去。

image.png

后端方面,设计的接口有点难评价,基本都跑不通。

image.png

总结:

我觉得之后要加一个任务完成之后的验收环节,整个开发流程还是不太规范。项目经理前期规划做得尚可,但后续全程缺位,没有推动进度、没有协调矛盾;前后端各自为战,遇到问题直接找我,缺少中间的统筹和监督,导致代码出现很多低级 bug,接口也无法正常联动,最后还是得自己手动排查、修改。

Hello 算法:复杂问题的应对策略

作者 灵感__idea
2026年3月22日 20:55

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

常言道:大事化小,小事化了。

这不仅是解决生活问题的策略,算法领域同样用途广泛,就是“分治”。

辨别分治

分治,“分而治之”,是一种非常重要的算法策略。常基于递归实现,它包括“分”和“治”两个步骤。

  1. :递归地将原问题分解为两个或多个子问题,直至到达最小子问题。
  2. :从已知解的最小子问题开始,将子问题的解进行合并,从而构建出原问题的解。

一个问题是否适合使用分治,通常有以下判断依据。

  1. 可以分解:原问题可以分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
  2. 子问题独立:子问题之间没有重叠,互不依赖,可以独立解决。
  3. 子问题的解可合并:原问题的解可通过合并子问题的解得来。

归并排序

“归并排序”是分治策略的典型应用之一。它满足以上三个判断依据。

  1. 可以递归地将数组划分为两个子数组。
  2. 每个子数组都可以独立进行排序。
  3. 两个有序子数组可以合并为一个有序数组。

处理流程如下:

微信图片_2026-03-22_204709_487.jpg

image

核心代码

/* 移动一个圆盘 */
function move(src, tar) {
    // 从 src 顶部拿出一个圆盘
    const pan = src.pop();
    // 将圆盘放入 tar 顶部
    tar.push(pan);
}

/* 求解汉诺塔问题 f(i) */
function dfs(i, src, buf, tar) {
    // 若 src 只剩下一个圆盘,则直接将其移到 tar
    if (i === 1) {
        move(src, tar);
        return;
    }
    // 子问题 f(i-1) :将 src 顶部 i-1 个圆盘借助 tar 移到 buf
    dfs(i - 1, src, tar, buf);
    // 子问题 f(1) :将 src 剩余一个圆盘移到 tar
    move(src, tar);
    // 子问题 f(i-1) :将 buf 顶部 i-1 个圆盘借助 src 移到 tar
    dfs(i - 1, buf, src, tar);
}

/* 求解汉诺塔问题 */
function solveHanota(A, B, C) {
    const n = A.length;
    // 将 A 顶部 n 个圆盘借助 B 移到 C
    dfs(n, A, B, C);
}

提升效率

分治不仅可以用来解决问题,还能帮助提升算法效率。

在排序算法中,快速、归并、堆排序,相较于选择、冒泡、插入排序,速度更快,就是因为采用了分治。

其底层逻辑是什么?

可以从“操作数量”和“并行计算”两方面来讨论。

  1. 操作数量

以“冒泡排序”为例,处理一个长度为 n 的数组需要 O(n²) 时间。

假设将数组从中点处分为两个子数组,则划分需要 O(n) 时间,排序每个子数组需要 O((n/2)²) 时间,合并两个子数组需要 O(n) 时间。

总体如下图:

微信图片_20260322204722_142.jpg

最终,就是比较 n² 和 n²/2 + 2n,进一步简化,当满足 n(n-4) > 0,即n大于4时,划分后的操作数量更少,排序效率应该更高。

  1. 并行计算

分治生成的子问题是相互独立的,因此通常可以并行解决

也就是说,分治不仅可以降低算法的时间复杂度,还有利于操作系统的并行优化。

并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,显著减少总体运行时间。

认识汉诺塔

什么是“汉诺塔”?

微信图片_20260322204727_143.jpg

给定三根柱子,记为 A、B 和 C 。

起始状态下,柱子 A 上套着 n 个圆盘,它们从上到下按照从小到大的顺序排列。

我们的任务是要把这 n 个圆盘移到柱子 C 上,并保持它们的原有顺序不变。在移动圆盘的过程中,需要遵守以下规则。

  1. 圆盘只能从一根柱子顶部拿出,从另一根柱子顶部放入。
  2. 每次只能移入一个圆盘。
  3. 小圆盘必须时刻位于大圆盘之上。

我们将规模为 i 的汉诺塔问题记作f(i) 。例如 f(3) 代表将 3 个圆盘从 A 移动至 C。

求解流程:

  1. 基本情况,只有一个盘子,直接从A到C即可;
  2. 两个盘子,由于需要保证顺序,就需要借助B,先将上面的小圆盘从 A 移至 B ,再将大圆盘从 A 移至 C ,最后将小圆盘从 B 移至 C。
  3. 三个盘子,事情开始变得复杂一些。

但是,因为已知 f(1) 和 f(2) 的解,所以我们可从分治角度思考,将A顶部的两个圆盘看作一个整体,执行下图所示的步骤。这样三个圆盘就被顺利地从 A 移至 C 了。

微信图片_20260322204732_144.jpg

  1. 令 B 为目标柱、C 为缓冲柱,将两个圆盘从 A 移至 B 。
  2. 将 A 中剩余的一个圆盘从 A 直接移动至 C 。
  3. 令 C 为目标柱、A 为缓冲柱,将两个圆盘从 B 移至 C 。

至此,我们可总结出解决汉诺塔问题的分治策略:将原问题f(n) 划分为子问题 f(n-1) 和 f(1) ,f(n-1) 可以通过相同的方式进行递归划分,直至达到最小子问题 f(1),f(1) 的解是已知的,只需一次移动操作即可。

常见应用

除了“汉诺塔”,分治的用途还很多。

  • 寻找最近点对:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后找出跨越两部分的最近点对。
  • 大整数乘法:例如 Karatsuba 算法,它将大整数乘法分解为几个较小的整数的乘法和加法。
  • 矩阵乘法:例如 Strassen 算法,它将大矩阵乘法分解为多个小矩阵的乘法和加法。
  • 求解逆序对:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以利用分治的思想,借助归并排序进行求解。

可以看出,分治是一种 “润物细无声” 的算法思想,隐含在各种算法与数据结构之中。

小结

本篇为大家呈现了“分治”策略的概念、代码和应用,但只作为一个引子,建立初步印象,更多内容,望各位自行拓展学习,欢迎评论交流。

更多好文第一时间接收,可关注公众号:“前端说书匠”

前端工程化 + AI 赋能,从需求到运维一条龙怎么搭 ❓❓❓

作者 Moment
2026年3月22日 19:01

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

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

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

image.png

企业级前端工程化的本质,是把"人肉重复、靠经验兜底"的开发方式,收敛成可复用、可度量、可演进的一套体系。从零搭建前端时,先想清楚要解决什么、要什么结果,再选工具和流程,会少走很多弯路。

工程化主要针对三类问题:

image.png

把这三块从"人治"变成"机制",工程化才算真正落地。

落到团队层面,能带来几件事。流程上,标准化、能自动化的尽量自动化,关键环节可以借 AI 提效,结果上,开发成本下来、迭代速度上去,代码质量和可维护性提高,bug 和线上风险更容易被提前拦住。这些都不是单点工具能完成的,需要从需求到上线的整条链路一起设计。

接下来我们按常见阶段展开,依次是需求与规范、开发与联调、测试与优化、构建与部署、运维与监控。每个阶段会写目标、推荐流程、常用工具、典型场景,以及适合用 AI 或自动化做得更好的地方。

前端工程化总览

整条链路可以概括为五个阶段,从需求规范到运维监控依次串联。整体追求三个结果,稳(高可用、可回滚)、快(敏捷交付、自动化流水线)、省(低成本工具链、资源复用),下面用一张流程图把阶段关系画清楚。

20260225100028

各阶段侧重不同。需求规范阶段重在建立统一标准、预防潜在风险、提升协作效率,常见动作包括需求与接口规范、文档沉淀与知识库、以及用 AI 做文档自动化。开发联调阶段和测试优化阶段共同指向高效协作、减少阻塞、保障代码质量,前者覆盖基础框架与脚手架、组件与物料库管理、工程化工具链、前后端接口联调与 Mock,后者覆盖单元与 E2E 自动化测试、性能与体积优化、合规与安全扫描、埋点与数据上报。构建部署阶段和运维监控阶段则共同强调高效交付、稳定发布、灵活回滚,构建部署侧重构建与打包优化、CI/CD 部署方案、灰度发布与一键回滚,运维监控侧重性能与可用性监控、异常与错误追踪、用户行为与转化分析、大屏可视化与告警,目标是实时感知风险、快速定位原因、持续优化体验。

下图是同一套阶段与目标的示意,便于对照查阅。

image.png

需求规范阶段

需求规范阶段是整条链路的起点。先把这一步打牢,后面的开发联调、测试优化才不会一路踩坑。这里要做的事,本质上是把团队里各自为战的习惯和经验,沉淀成一套大家都认可的统一标准,既预防潜在风险,又减少日常协作里的摩擦。为了方便梳理,可以把这一阶段拆成三块,对应代码与接口、文档与知识库,以及用 AI 做文档自动化。

下图是这一阶段的手绘示意,可以当作后文三小节的导航来对照着看。

image.png

需求与接口规范

落到开发这侧,最直观的感受就是,大家写出来的代码和提交流程要像是一个团队,而不是各写各的。第一步是把代码规范和协作流程统一,用一套约定来消除协作摩擦。代码这一块,可以用 ESLintPrettier 配合 Husky 去强制约束代码风格,缩进、命名这些细节交给工具,提交前自动跑一遍,不通过就推不上去,讨论就能更多回到设计和实现本身。

协作流程方面,建议一开始就说清楚 Git 分支策略(例如简化版 Git Flow)和 Commit 信息格式,例如用 Commitizen 这样的工具来规范提交说明。久而久之,提交历史会变成一本可以查账的项目日记,谁在什么时间、因为什么调整了哪些代码,一目了然。

这里有两类问题,最好在一开始就通过规范挡住。一类是随手写 fix bugupdate 这类没有信息量的提交信息,事后谁也看不出当时改动的动机。另一类是没经过 Code Review 就把改动直接合进主分支,质量风险一路带到线上。有些团队会要求,所有人都基于 master 拉分支开发,在 testuatrelease 这些共享环境分支以及 master 上都禁止直接 push,只能通过合并请求进入,这样一旦出问题,也能顺着合并记录快速定位到具体改动。

文档沉淀与知识库

文档沉淀这块,目标是打破信息孤岛,让新人靠看文档也能尽量还原当时的需求背景和取舍过程。需求如果只散落在聊天记录里,过一阵子连原作者自己都很难说清楚当时为什么要这么定。比较实用的做法,是用语雀、飞书文档把业务需求拆成技术方案,把功能边界和验收标准写清楚,再准备一套固定的需求文档模板,背景、原型、接口定义这些模块都预留好位置,后面类似需求直接套用,既省事又不容易漏。

接口和设计的配合,同样可以通过工具来固化。可以用 Apifox 维护接口文档,后端接口还没完全就绪时,前端先基于 Mock 数据开发,不必干等。与此同时,联动 Figma、即时设计这类工具里的设计稿标注,让 API 与设计稿保持同步,很多本来要靠口头解释的细节,直接在文档和设计稿里就能对齐。

AI 赋能文档自动化

如果完全手写,一份中等复杂度的技术文档,往往要花上两到四个小时,写着写着还容易走神。现在可以把这种重复性工作交给 AI。例如用 Writely(飞书 AI),输入 PRD 里的关键词(例如"用户管理系统"),让它先生成一份大致合理的技术文档目录和示例代码片段,你再根据实际业务补充细节、删掉不适用的部分。

实际体验下来,传统纯手写从零到一可能要两到四个小时,而让 AI 先搭好骨架、再人工完善,大多数情况下半小时左右就能收工。这样的方式尤其适合需求说明、接口说明、技术方案骨架这类重复度很高的文档,一方面整体结构更统一,另一方面也把时间留给那些必须由人来判断的业务决策和权衡。

开发联调阶段

开发联调阶段是前端工程化真正动手写代码、跑起来的那一段,目标很清晰,就是高效协作、减少阻塞、保障代码质量,让前后端和设计之间尽量无缝衔接。下面按基础框架、物料复用、工程化流水线、前后端协作四块来说,最后补几条联调时容易踩的坑。

image.png

基础框架搭建

框架选型决定了团队未来几年的技术底座,选好了能少踩很多坑。轻量一点、迭代快的项目,可以用 Vue 3ReactVite,开发体验好、上手也快,Vite 后续会集成 Rust 实现的 Rolldown,生产构建会更快。业务比较复杂、偏中后台或需要 SSR 的,可以看 Next.jsRsbuild 等,Next.js 开发环境已支持 Turbopack,大仓冷启和 HMR 更猛。超大体量或需要兼容现有 Webpack 生态的,可以看 Rust 系的 Rspack。运行时除了 Node.js,也可按需选 Bun 做脚本和工具链。要是还有小程序、H5 等多端需求,可以看 TaroUni-App 这类跨端方案,一套代码多端跑。

选完框架,最好再准备一套模板仓库,新项目直接基于模板拉,而不是每次从零配。例如预置好 ESLintPrettier 的脚手架,或者用 Next.jsRsbuild 等自带的脚手架快速生成项目,再按需加权限、数据流等。如果团队里会有多个应用、共享组件库或公共包需要一起维护,可以提前考虑是否采用 Monorepo 架构(例如用 pnpm workspace、TurborepoNx 等),把多包放在一个仓库里统一依赖和构建,能减少后期拆仓、版本对齐的折腾。这一步也可以交给 AI 省时间,例如在 Cursor 里输入"创建 NextJs + TypeScript 项目",让它生成基础配置。

物料库管理

组件、工具函数、页面模板如果能复用,重复开发会少很多。有条件的团队会做企业自研组件库,常见两条路。一条是在 Ant DesignElement Plus 这类开源组件库上做二次封装,贴合自家业务和设计规范,再用 Bit 这类工具管理组件版本和依赖,甚至支持私有化部署。

另一条是,如果团队已经在用 Tailwind CSS,并且用过 shadcn/ui 这类"拷贝即用"的组件方案,可以在现有基础上做二开,例如统一品牌色、间距和圆角等设计 token,把常用变体收拢成团队约定,再补一份内部文档(哪些组件可直接用、哪些改过、使用示例和注意事项),这样既保留 Tailwind 的灵活度,又有一致的设计和可维护的物料沉淀。Tailwind CSS v4 已发布,构建更快、配置更简单,新项目可以直接上 v4。工具函数这块,用 lodashdayjs 等成熟库即可,不必自己造。

AI 在这块也能帮上忙。例如即时 AI 可以把 Figma 设计稿转成 VueReact 组件代码,减少从设计到代码的重复劳动。CodeGeeX 可以根据组件的 Props 描述自动生成单元测试用例。当然,小团队或小公司不一定要自建组件库和物料体系,先把业务跑稳、再按需沉淀组件和模板,会更现实。

工程化系统

工程化系统说白了就是通过工具链把创建项目、检查、构建、部署串成一条流水线,减少人工操作。创建项目阶段,现在普遍用 Vite 创建 VueReact 项目(create-vite 或各框架官方模板),或用 Next.js 自带的脚手架起手,预置好规范与配置即可。到了持续集成和部署,可以用 GitHub ActionsGitLab CI 在提交后自动跑代码检查、构建和部署,或者用 Jenkins 做更复杂的多环境流水线。如果希望需求、开发、部署都在一个平台里完成,可以选阿里云效这类一站式 DevOps 平台,功能全、上手相对简单,也支持私有化部署,不少团队的实际项目就是用云效搭的流水线。

前后端协作

前后端联调最容易出问题的地方,往往是接口约定不一致、文档滞后、环境对不齐。接口文档建议用 ApifoxApidog 这类工具维护,支持 OpenAPI 规范、自动 Mock 和接口测试。很多平台还能根据接口文档自动生成前端的请求代码和 TypeScript 类型,文档一改、类型跟着变,减少手写接口定义。后端接口还没好时,前端可以先用 Mock.jsFaker.js 生成贴近真实的测试数据,或者用 MSW(Mock Service Worker)在浏览器层做请求拦截,配合 TypeScript 做类型安全的 Mock,适合单测和本地联调。全栈都是 TypeScript 的项目,还可以考虑 tRPC 或更轻量的 Hono RPC,前后端共享类型定义,服务端改接口、客户端立刻有类型提示,无需单独维护一份接口文档和类型。Hono RPChc 客户端加 Zod 校验即可实现类型安全,适合前后端同仓或协作紧密的团队。

当接口多了、前端需要聚合多个接口或按需拉字段时,可以加一层 BFF(Backend For Frontend),用 Node.js 中间层(例如 NestJSMidway.jsExpress)聚合多接口,或者用 GraphQL(如 Apollo Server)让前端按需定制响应字段。BFF 可以由后端团队维护,也可以由前端团队自建,实现真正的接口层解耦。

接口文档若能通过统一协议进到开发环境里,前后端对接会轻松很多。可以把后端的 OpenAPI 规范用 MCP(Model Context Protocol)暴露出来,例如用 OpenAPI MCP Server 把接口定义转成 MCP 的 tools、resources,在 Cursor、VS Code 等 IDE 里配置好 MCP 后,就能在写代码时直接读到最新接口文档、让 AI 按文档生成请求代码或类型,避免文档和实现脱节。

阿里云、腾讯云等也有 OpenAPI MCP Server,适合把云产品 API 接到 IDE。自建后端可以用 @reapi/mcp-openapi、FastMCP 的 from_openapi() 等从 OpenAPI 规范生成 MCP 服务,前后端共用同一份文档,联调时接口变更能更快同步到前端。

AI 也能参与进来,例如 ApifoxAI 可以根据接口文档自动生成 Mock 规则和测试用例,CodeGeeX 可以根据现有 RESTful 接口生成一层 GraphQL 包装代码,减少手写胶水代码。

联调时还有几点值得注意。一是接口变更要及时同步,用 Apifox 这类工具把最新接口定义推给前端,或通过 OpenAPI 自动生成类型,避免文档和实现各说各的。二是开发、测试、生产环境要隔离,用 .env.development.env.production 等把配置拆开,别在本地写死生产地址。三是依赖版本要锁死,用 pnpm 等包管理器严格锁定依赖,能少很多"在我机器上是好的"这类问题。

测试优化阶段

测试优化阶段的目标很明确,就是提前暴露风险、保障线上稳定、提升用户体验,用分层测试把核心场景兜住,减少漏测和线上事故。从人工点点点到自动化、再配合 AI 生成用例,测试效率会明显上去。

20260226091000

自动化测试

建议按单元测试、E2E、视觉回归三层建体系,而不是一上来就全押 E2E。单元测试负责验证组件逻辑和工具函数,用 JestVitestReact Testing Library 即可,VitestVite 同源、冷启和 HMR 更快,适合在每次提交时跑。组件层若要在真实浏览器里跑,可用 Vitest 的 Browser Mode 配 Playwright 驱动。例如下面这段,用 render 渲染按钮组件、screen.getByRole 找到按钮并模拟点击,再断言传入的 onClick 被调用了一次,用来保证点击回调不会丢。

test("Button 点击触发事件", async () => {
  const handleClick = vi.fn<[], void>();
  render(<MyButton onClick={handleClick} />);
  await userEvent.click(screen.getByRole("button"));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

E2E 测试覆盖真实用户路径,在浏览器里跑完整流程。Playwright 支持 Chromium、WebKit、Firefox 多端,自带录制回放,适合做跨浏览器回归。Cypress 的可视化调试和时间旅行对复杂交互(例如购物车、多步表单)很友好,按团队习惯二选一或搭配用即可。

视觉回归测试解决的是"功能没坏、但界面悄悄变了"的问题。改了一处样式或依赖升级导致组件渲染异常,单测和 E2E 不一定能发现,视觉回归通过对比页面或组件的截图,先拍一份基准快照,后续每次跑用例时再拍一张,和基准做像素或区域对比,有差异就报出来,由人确认是预期改动还是误伤。可以用 BackstopJS 在本地或 CI 里跑,配置好要截的 URL 或组件,生成基准后纳入版本管理,以后每次 PR 自动跑一遍对比。组件库或设计系统也可以用 ChromaticPercy 这类托管服务,和 Storybook 结合,每个 Story 自动做视觉回归。适合对 UI 稳定性要求高的首页、关键流程页和公共组件,基准图多了之后要注意维护,避免无关改动带来大量噪点。

AI 也能参与测试用例的生成和验证。一类是依据行为数据生成脚本,例如 Testin AI 分析用户行为日志,把高频操作转成 E2E 用例,先覆盖核心路径再补边缘场景。另一类是让 AI 直接连上真实浏览器做调试和验证,例如 Chrome 官方的 Chrome DevTools MCP,在 Cursor、Claude 等里配置好 MCP 后,AI 可以调 DevTools 能力做性能追踪、网络与 Console 排查、DOM 与样式检查、表单与用户行为模拟,并在浏览器里实时验证改动的效果,相当于"边写代码边在真机里跑一遍"。和 Playwright MCP 搭配时,Playwright 负责 UI 自动化与用例执行,DevTools MCP 补足性能与运行时观测,适合做智能回归和 Core Web Vitals 等自动化检查。

性能优化

测试通过之后,还要保证页面秒开、交互不卡。可以给自己定一个简单目标,例如首屏可交互 FCP 控制在 1.5 秒内、首次输入延迟 FID 在 100ms 以内。性能检测方面,用 Lighthouse CI 把跑分集成进 CI 流水线,分数低于阈值(例如 90)就拦掉合并,避免性能劣化代码进主干。真实用户数据用 Google Analytics 4 或阿里云 ARMS 采集 Web Vitals,看线上实际表现而不是只看本机。

优化手段按资源、代码、分发来拆。资源上,构建阶段自动压缩图片,例如用 vite-plugin-imagemin 在打包时处理;代码上,用 React.lazySuspense 做路由级懒加载,首屏只拉当前路由需要的 chunk。分发上,静态资源扔到阿里云 OSS 再挂 CDN,利用全球节点做加速。AI 也能参与,例如阿里云 ARMS 的智能诊断会根据性能数据推荐优化项(如未压缩图片列表),部分构建工具已支持基于预测的 Tree Shaking 策略,进一步剔除无效代码。

合规与安全

合规与安全要从代码和数据两头抓,避免法律风险和用户隐私问题。代码侧,用 SonarQube 做静态扫描,揪出 XSS、SQL 注入等常见漏洞。依赖侧,用阿里云安全中心等扫描已知漏洞(例如 Log4j、老旧版本的 lodash),有风险就升级或替换。隐私合规方面,用腾讯云合规助手这类工具检查隐私政策是否满足 GDPR、个保法等要求。日志里对手机号、身份证号等做脱敏,例如通过 log4js 等插件的过滤规则自动打码,避免敏感数据落盘。

AI 可以辅助安全扫描,例如用大模型扫描代码里的敏感信息(如硬编码的 API 密钥)。部分 AI 代码助手能自动把不安全写法替换成更安全的实现(如将 eval 改为 Function),适合在 Code Review 前跑一遍。

数据埋点

埋点做得好,产品迭代才有数据支撑,否则容易变成"盲人摸象"。埋点大致分无埋点和自定义埋点。若注重隐私或希望数据自托管,可以用 Umami 这类开源方案,无 Cookie、符合 GDPR,脚本轻量(约 2KB),支持页面浏览与自定义事件,可 Docker 自建或使用官方云,适合中小站点和不想依赖第三方统计的场景。

无埋点还可用 GrowingIO 等方案自动采集页面点击、曝光等事件,接入简单、覆盖面大。自定义埋点用神策数据等 SDK 在关键行为(如按钮点击、表单提交)上手动上报,灵活、可针对业务做分析。数据进来之后,用 Metabase 这类开源 BI 做 SQL 自助分析,或用阿里 DataV 做大屏展示核心指标(如 DAU、转化率)。AI 也能参与,例如 GrowingIO 的智能推荐会根据用户路径建议高价值埋点事件,神策的聚类分析能自动识别用户分群(如高流失风险用户),方便做精细化运营。

测试与优化阶段还有几点容易踩坑。一是别盲目追求 100% 测试覆盖率,优先把核心链路(登录、支付等)兜住,再按需补边缘场景。二是性能优化别撒胡椒面,内部管理后台等低频页面不必死磕,把资源留给用户高频访问的页面。三是埋点必须拿到用户授权,禁止收集设备 ID、IMEI 等敏感信息,否则会踩数据隐私的雷。

构建部署阶段

构建与部署阶段是前端工程化的交付出口,目标是高效交付、稳定发布、灵活回滚,让代码从开发环境到生产环境顺畅流转。下面按构建优化、部署方案、灰度与回滚三块说。

20260226094131

构建优化

构建工具在技术选型阶段通常已经定好了(例如 ViteWebpack 5RspackNext.js),这里侧重在既定工具上的优化策略。Vite 新版本已接入 Rust 实现的 Rolldown 做生产打包,构建耗时明显下降。选 Next.js 的可以用 Turbopack 做开发和生产构建,冷启和增量构建更快。Rspack 等 Rust 系方案在大仓下同样有优势。

优化时先把 Tree Shaking 开满,在库和业务里合理配置 sideEffects: false,让打包器删掉未引用代码。代码拆分用动态 importReact.lazy 把非首屏做成按需加载,再用 manualChunks 把大依赖(如 monaco-editor、图表库)单独拆包,避免首屏 chunk 过大。产物体积可用 rollup-plugin-visualizervite-plugin-perfsee 做分析,一眼看出谁在占空间。线上传输用 vite-plugin-compression 做 Gzip 或 Brotli,Nginx 侧开 gzip_static 即可。

部分构建工具已支持基于 AI 的智能缓存和构建日志分析,自动推荐合并重复 Chunk、优化依赖顺序等,可在 CI 里跑一遍看报告。

部署方案

部署从手动发包走向一键发布、多环境隔离,才能做到分钟级回滚。静态资源托管最常见,用阿里云 OSS 挂 CDN 按量付费、支持缓存刷新,或选托管平台:Vercel 和 Git 深度集成、推分支即发布,适合 Next.js。Cloudflare Pages 边缘节点多、免费带宽大,已支持 Docker 和 @opennextjs/cloudflare 跑 Next,还有 Workers AI 做边缘推理。Netlify 在组合式架构和 CMS 集成上比较顺手。需要极快 git 部署、少建站过程的可以看 Deno Deploy,代码直传边缘、无需拉机子做长构建,适合接口或中间层。

需要跑 Node 或做 SSR 的,用 Docker 多阶段构建把镜像压到几十 MB,再配合 Kubernetes(如阿里云 ACK)做集群。不想管机器的用 Serverless,阿里云函数计算、Vercel Edge Functions 等按需执行、边缘就近跑。

AI 也能参与,例如 GitLab Code Suggestions 可根据项目生成 DockerfileCI 脚本,观测云等能根据资源负载推荐扩缩容策略。

灰度与回滚

发布要可控,灰度把风险压到最小,回滚要能快速切回去。灰度本质是流量逐步切到新版本,常见做法有 Nginx 按 IP 或 Cookie 分流,先给 5%~10% 用户上新版,观察一段时间再放量。阿里云 EDAS 支持全链路灰度,应用和数据库都能隔离。云原生 API 网关也支持蓝绿、金丝雀发布,按比例或规则切流量。除了流量灰度,还可以用特性开关(Feature Flags),在代码里用开关控制功能是否露出,用 ConfigCat、LaunchDarkly 等或自研,发版和上线解耦,随时可关。

灰度期间要有可观测,接 Prometheus、Grafana 或现有监控,盯错误率、响应时间,一旦超阈值(例如错误率 >0.5%)自动回滚或告警。回滚要提前准备好,在 GitLab CI 或 GitHub Actions 里做基于版本 Tag 的回滚脚本,出问题一键切回上一版。静态资源用 OSS 版本控制保留历史,回滚时改 CDN 回源即可。

AI 也能参与,例如阿里云 AHAS 可根据历史流量推荐灰度比例,Sentry 等可在错误率突增时自动触发回滚或通知,减少人工判断时间。

运维监控阶段

运维与监控是前端工程化的最后一道防线,目标是实时感知风险、快速定位原因、持续优化体验,让线上系统稳定、用户行为可观测。下面按性能监控、异常监控、用户行为分析、可视化与告警四块说,最后补一版低成本与大型企业的工具链参考,以及几条容易踩的坑。

20260226095212

性能监控

性能监控要保障 Web Vitals 等核心体验指标达标,并持续发现瓶颈。核心指标用 Google Analytics 4 或阿里云 ARMS 等采集真实用户数据(RUM),关注 LCP(最大内容绘制)、INP(交互到下一帧,已逐步替代 FID)、CLS(累计布局偏移)等,可配合 web-vitals 库在端上采集后上报。除了平台自动采集,关键链路可以加自定义性能埋点,例如在页面加载完成后取 performance.timing 算出加载耗时并上报,方便按页面或版本对比。下面示例在 load 事件后计算从导航开始到加载结束的耗时,并通过自有 SDK 上报,用于做首屏性能趋势分析。

const timing: PerformanceTiming = performance.timing;
const loadTime: number = timing.loadEventEnd - timing.navigationStart;
SDK.report({ type: "page_load", duration: loadTime });

资源侧可以看 CDN 日志分析请求成功率、缓存命中率(如阿里云 CDN)。接口耗时用 SkyWalking、Zipkin 或 OpenTelemetry 做链路追踪,约定 P99 等目标(例如 500ms 以内)。Sentry 等已支持与 OpenTelemetry 对接,前端错误和接口链路可以串成一条 trace,排查时从页面一路跟到后端。AI 也能参与,例如阿里云 ARMS 智能诊断会关联 JS 错误与接口超时,给出根因建议。New Relic 等可根据历史数据预测流量峰值,辅助提前扩容。

异常监控

异常监控要争取分钟级发现线上问题,把 MTTR(平均修复时间)压下去。错误追踪用 Sentry 捕获前端 JS 错误、自动聚合相似问题,并支持 SourceMap 解析还原源码位置。国内团队也可以用支持微信、钉钉实时告警的国产方案,和现有协作习惯对齐。日志分析用阿里云 SLS 做 Nginx 访问日志的实时分析,快速发现 5xx 突增等异常。自建可选 Loki 配 Grafana,资源占用比传统 ELK 小,用 LogQL 查"近 1 小时 404 TOP10"这类问题很顺手。

AI 可以辅助降噪和归因,例如 Sentry 的智能聚类能把大量错误归成少量根因(如未捕获的 TypeError)。基于 Elasticsearch Machine Learning 或类似能力可以做日志模式异常检测,例如发现突然出现大量非常规 UA 或异常请求路径,提前发现爬虫或攻击。

用户行为分析

用户行为数据用来驱动产品优化和转化率提升。无埋点用 GrowingIO 等自动采集页面点击、跳转、停留时长,并生成热力图。自定义分析用神策等做事件与漏斗(如注册流程各步转化)。关键业务节点需要自定义埋点时,在按钮或流程节点上打点上报事件和业务参数,例如下单按钮点击时上报商品 ID 和价格,便于后续做转化和营收分析。下面示例在购买按钮点击时上报事件名和业务字段,接入方替换成实际 SDK 即可。

document.getElementById("buy-button")?.addEventListener("click", () => {
  SDK.track("purchase_click", { product_id: "123", price: 299 });
});

AI 能参与分析结论的产出,例如神策的智能路径分析用户流失点并给出优化建议,GrowingIO 可根据行为聚类生成推荐或运营策略参数。

可视化与告警

监控数据要通过大屏和告警变成可执行的决策。可视化用 Grafana 做自定义监控面板,或用阿里云 DataV 搭实时运维大屏。告警用 Prometheus 配 Alertmanager 配置阈值(如 CPU 使用率 >80% 持续 5 分钟),告警事件通过钉钉、飞书机器人推到协作群,并支持 @ 负责人。AI 可以用于智能阈值和降噪,例如根据历史数据动态计算合理阈值(如凌晨自动放宽延迟阈值),或把重复告警合并成一条,减少告警风暴。

工具链参考

中小团队、预算有限时,可以组合:监控用 Prometheus 自建 + Grafana,告警接微信或钉钉。日志用 Loki 替代 ELK,资源消耗更低。再搭配阿里云 ARMS 免费版做基础性能分析、或开源组件的异常检测能力,整体月成本可控。对高可用要求高、数据量大的团队,可以用阿里云 ARMS 做全链路、SLS 做 PB 级日志,配合 DataV 大屏和自研或第三方 AI 分析平台。

运维监控还有几点要注意。一是避免过度监控,只采核心业务相关指标,否则存储和告警成本都会上去。二是告警要设静默期,同一类告警在 30 分钟内不重复推送,减少告警疲劳。三是日志必须脱敏,避免原始敏感数据泄露。

从零实现一个 Vite 自动路由插件

作者 楠木top
2026年3月22日 17:53

基于约定优于配置的思想,用 Vite 插件机制 + fast-glob 实现零配置自动路由注册。


背景与动机

在 Vue3 项目里,手动维护路由表是一件重复且容易出错的事:

// 每新增一个页面都要改这里 😩
const routes = [
  { path: '/', component: () => import('./pages/index.vue') },
  { path: '/about', component: () => import('./pages/about.vue') },
  { path: '/user/:id', component: () => import('./pages/user/[id].vue') },
  // ...
]

页面一多,这个文件就变成了负担。能不能让工具自动做这件事?

答案是:可以,用 Vite 插件


方案选型

在动手之前,我考虑了三种方案:

方案 A:import.meta.glob(纯运行时)

Vite 内置支持 glob 导入:

const pages = import.meta.glob('./pages/**/*.vue')

问题在于它是运行时行为,需要在业务代码里手动处理路径到路由的映射,侵入业务层,不够优雅。

方案 B:自定义 Vite 插件 + 虚拟模块 ✅

插件在构建阶段扫描文件系统,生成一个虚拟模块 virtual:auto-routes,业务代码只需:

import { routes } from 'virtual:auto-routes'

完全透明,零侵入,支持热更新。这是主流方案(vite-plugin-pagesunplugin-vue-router 都是这个思路)。

方案 C:直接用现成库

vite-plugin-pages 开箱即用,但失去了定制空间,也失去了理解底层机制的机会。

最终选方案 B,自己实现,完全可控。


核心机制:Vite 虚拟模块

Vite 插件通过两个钩子实现虚拟模块:

resolveId(id) {
  // 拦截特定模块 id,返回一个内部标识
  if (id === 'virtual:auto-routes') return '\0virtual:auto-routes'
},

load(id) {
  // 对内部标识返回动态生成的代码字符串
  if (id === '\0virtual:auto-routes') {
    return generateCode(pagesDir)
  }
}

\0 前缀是 Vite/Rollup 的约定,表示这是一个内部虚拟模块,不会被其他插件误处理。

generateCode 的输出就是一段普通的 JS 字符串,Vite 会把它当作真实模块编译:

import Page0 from '/src/pages/index.vue'
import Page1 from '/src/pages/about.vue'
import Page2 from '/src/pages/user/[id].vue'

export const routes = [
  { path: '/', name: 'index', component: Page0 },
  { path: '/about', name: 'about', component: Page1 },
  { path: '/user/:id', name: 'user-:id', component: Page2 },
]

文件扫描:为什么选 fast-glob

最初用 Node 内置的 fs.readdirSync 递归实现,能跑,但代码冗长:

// 40 行递归,处理目录、过滤扩展名、拼接路径...
function scanPages(dir, base = '') {
  const entries = fs.readdirSync(dir, { withFileTypes: true })
  for (const entry of entries) {
    if (entry.isDirectory()) { /* 递归 */ }
    else if (entry.name.endsWith('.vue')) { /* 处理 */ }
  }
}

换成 fast-glob 之后:

const files = fg.sync('**/*.vue', { cwd: pagesDir, onlyFiles: true })

一行搞定,且:

  • 自动忽略隐藏文件和 node_modules
  • 性能更好(并发 I/O + 优化的目录遍历)
  • Vite 本身已依赖 fast-glob,无需额外安装

路径到路由的映射规则

约定优于配置,映射规则简单直观:

文件路径 路由 path
pages/index.vue /
pages/about.vue /about
pages/user/index.vue /user
pages/user/[id].vue /user/:id
pages/blog/[slug]/edit.vue /blog/:slug/edit

实现核心就是一个字符串转换:

const segments = file
  .replace(/\.vue$/, '')          // 去掉扩展名
  .split('/')
  .map(s =>
    s === 'index' ? '' :          // index 段消除
    s.replace(/\[(\w+)\]/g, ':$1') // [id] → :id
  )

const routePath = '/' + segments.filter(Boolean).join('/')

热更新支持

开发时新增或删除页面文件,路由应该自动更新,不需要重启 dev server。

通过 configureServer 钩子拿到 Vite 的内部 watcher:

configureServer(server) {
  const { watcher, moduleGraph, ws } = server

  watcher.on('add', onFileChange)
  watcher.on('unlink', onFileChange)

  function onFileChange(file) {
    const mod = moduleGraph.getModuleById('\0virtual:auto-routes')
    if (!mod) return

    // 让虚拟模块缓存失效
    moduleGraph.invalidateModule(mod)

    // 发送 HMR update 信号,只重载路由模块,不刷新整页
    // 相比 full-reload,页面状态(表单、滚动位置等)得以保留
    ws.send({
      type: 'update',
      updates: [{
        type: 'js-update',
        path: '\0virtual:auto-routes',
        acceptedPath: '\0virtual:auto-routes',
        timestamp: Date.now(),
      }],
    })
  }
}

Vite 的 watcher 底层是 chokidar,已内置,无需额外依赖。


页面私有组件:按需导入而非全局注册

页面组件有两种归属:

  • 全局组件:放 src/components/,整个项目复用
  • 页面私有组件:放在页面目录下的 components/,只有当前页面用
src/pages/
  user/
    [id].vue
    components/
      UserAvatar.vue    ← 私有组件,不应注册路由

插件通过 fast-glob 的 ignore 规则跳过所有 components/ 目录:

const files = fg.sync('**/*.vue', {
  cwd: pagesDir,
  onlyFiles: true,
  ignore: ['**/components/**'],  // 任意层级的 components 目录均忽略
})

组件的自动导入交给 unplugin-vue-components 处理,它会扫描模板里实际用到的组件,编译时自动插入 import,用不到的不打包,tree-shaking 完全有效。

<!-- 直接用,无需手动 import -->
<UserAvatar :user="user" />

404 页面

插件在生成路由表时,检测 pages/404.vue 是否存在:

  • 存在 → 用它作为 404 页面
  • 不存在 → 内联一个最简提示兜底

/:pathMatch(.*)* 是 Vue Router 的通配符写法,永远追加在路由表末尾:

const has404 = fg.sync('404.vue', { cwd: pagesDir }).length > 0
const notFoundRoute = has404
  ? `{ path: '/:pathMatch(.*)*', component: NotFound }`
  : `{ path: '/:pathMatch(.*)*', component: { template: '<div>404 Not Found</div>' } }`

最终效果

项目结构:

src/
  components/          ← 全局组件,unplugin-vue-components 按需导入
  pages/
    index.vue          ← /
    about.vue          ← /about
    404.vue            ← 404 兜底
    user/
      [id].vue         ← /user/:id
      components/
        UserAvatar.vue ← 私有组件,不注册路由

业务代码只需:

import { routes } from 'virtual:auto-routes'

const router = createRouter({
  history: createWebHistory(),
  routes,
})

新增页面文件 → 路由自动出现,删除 → 自动消失。全程不需要碰路由配置文件。


总结

关键点 选择 理由
实现方式 Vite 插件虚拟模块 零侵入,构建时生成,无运行时开销
文件扫描 fast-glob Vite 已内置,简洁高性能
热更新 HMR update 信号 只重载路由模块,保留页面状态
路由约定 文件路径即路由 直观,符合 Next.js/Nuxt 用户习惯
私有组件 ignore components/ 不污染路由表,配合 unplugin 按需导入
404 处理 检测 404.vue + 兜底 约定优先,无文件时自动降级

整个插件核心代码不到 100 行,覆盖了虚拟模块、文件扫描、动态路由、热更新、私有组件隔离、404 兜底六个能力。这也是 Vite 插件体系的魅力所在:用少量代码撬动强大的构建能力。

Signals 跨框架收敛:TC39 提案、Solid、Angular、Preact 的实现差异与调度策略对比

2026年3月22日 17:42

前端框架搞响应式搞了十年,最后殊途同归——大家都在写 Signals。Solid 从第一天就是 Signals 架构,Preact 半路加了 @preact/signals,Angular 在 v16 直接官宣 signal(),连 TC39 都坐不住了,要把 Signals 塞进语言规范。

它们长得像,骨子里是一回事吗?

Angular Signals:渐进式改造的务实路线

Angular 的 Signals 实现跟 Solid 的哲学截然不同。Solid 是"一切皆 Signal"的激进路线,Angular 走的是"Signal 是一个新选项,跟已有体系共存"的渐进路线。

Angular 的调度:微任务 + 组件树协调

Angular 的 Signal 更新不是同步的,也不是简单的事件循环批量。它走的是微任务批量 + 组件树自上而下协调的路线。

具体流程是:signal.set() 只做脏标记,不立即重新计算 computed。在微任务队列中安排一次变更检测,从根组件开始自上而下遍历组件树,遇到标记为脏的组件才检查其 Signal 依赖、重新计算 computed、更新 DOM。

const name = signal('Alice')
const greeting = computed(() => `Hello, ${name()}`)

name.set('Bob')
name.set('Charlie')
name.set('Dave')
// → 三次 set 合并成一次变更检测,greeting 只算一次 → "Hello, Dave"

这种策略的好处是:无论在事件处理器、setTimeout 还是 Promise 回调中,多次 set 都会被自动合并,不需要手动 batch。代价是更新延迟到微任务——如果你在 set 之后立即读 DOM,拿到的是旧值。Angular 选择这个策略,是因为要兼容已有的组件树生命周期。

effect() 的克制态度

Angular 对 effect() 的态度很谨慎——官方文档明确说这是"逃生舱",能不用就不用。体现在 API 设计上,effect() 必须在注入上下文中创建:

@Component({ /* ... */ })
export class MyComponent {
  count = signal(0)

  constructor() {
    effect(() => console.log('count:', this.count()))  // 构造函数中有注入上下文
  }

  someMethod() {
    // 普通方法中没有注入上下文,直接调 effect() 会报错
    // 需要手动传入 injector:
    // effect(() => { ... }, { injector: this.injector })
  }
}

这是有意为之的摩擦。

三大实现的调度策略对比

调度策略的差异是这三个框架 Signals 实现的核心分水岭。同样一段状态更新代码,在三个框架中执行时机和顺序可能完全不同。

同步、异步、还是混合

事件触发 → set signal

  Solid:同步执行(事件内自动 batch)
    → batch 结束 → 同步 flush 所有 effect

  Angular:异步调度(微任务)
    → set → 标记脏 → queueMicrotask → 变更检测 → 更新

  Preact:混合模式
    → 直接绑定:同步更新文本节点(绕过 VDOM)
    → .value 读取:组件级调度(通过 VDOM diff)

把这三种策略放到同一个场景下看更直观。假设一个表单有 10 个字段,用户触发了一次"全部重置":

Solid:事件处理器内自动 batch,10 次 set 合并,依赖这些字段的 effect 只执行一次。换成 setTimeout 调用就需要手动 batch,否则触发 10 次更新。

Angular:10 次 set 都只是标记脏,在下一个微任务中统一做一次变更检测。

Preact:如果用了直接绑定(JSX 中传 signal 对象),10 个文本节点同步更新,不触发组件 re-render。如果用了 .value,需要 batch 包裹,否则组件可能 re-render 多次。

菱形依赖:Glitch-free 怎么保证

响应式系统有一个经典难题——菱形依赖。当一个 computed 依赖的多个上游共享同一个源头时,更新顺序不对就会出现错误的中间态:

const a = signal(1)
const b = computed(() => a.value * 2)       // b = 2
const c = computed(() => a.value * 3)       // c = 3
const d = computed(() => b.value + c.value) // d = 5

// a 变为 2 时,d 应该等于 4 + 6 = 10
// 但如果 d 在 b 更新后、c 更新前被计算 → d = 4 + 3 = 7(错误的中间态)

依赖关系形成了一个菱形:a 分叉到 b 和 c,再汇聚到 d。三个框架都解决了这个问题,方式不同。

Solid 用拓扑排序——按依赖图的层级顺序执行更新,保证 dbc 都更新后才重新计算。这是 push 模型下的经典解法。

Angular 用 pull-based 惰性求值——d 只在被读取时才重新计算,读取时会先递归检查 bc 是否需要更新。读 d 之前先把上游全拉到最新,天然不会出现中间态。

Preact 也是 pull-based 模型,额外加了版本号机制——每个 signal 有一个单调递增的版本号,computed 在求值时通过比较版本号判断依赖是否已经更新过了。

Push vs Pull 的本质差异

这三个框架表面上都叫"Signals",底层的推拉模型配比其实不一样:Push 模型的特点是"源头变了就主动通知下游"。

Pull 模型反过来,"有人读的时候才去检查上游有没有变"。

实际上三个框架都是混合模型:computed 用 pull(惰性),effect 用 push(主动)。区别在于配比和默认倾向——Solid 更偏 push,它的编译器会生成细粒度的 effect 来驱动 DOM 更新;Angular 更偏 pull,变更检测时才从模板"拉取" signal 的值。

设计权衡:为什么调度无法统一

TC39 提案留白调度的原因

TC39 提案不做调度,这不是疏忽,是刻意为之。设想一下:如果 TC39 强制规定"所有 effect 在微任务中执行",Solid 的同步更新场景就没法做了;如果规定同步执行,Angular 的组件树协调又会被打破;如果规定用 requestAnimationFrame,动画场景合适了,表单交互又会有延迟感。

调度策略跟框架的渲染管线是一体两面。

强行统一调度,就像要求所有快递公司用同一种分拣流程——京东的自营仓和菜鸟的网格仓,底层逻辑根本不一样。

各方案的边界条件

每种实现都有碰壁的地方,了解这些边界在选型时比看 API 有用得多。

Solid 的 async/await 困境:纯运行时依赖收集的固有限制——await 会让 JavaScript 引擎挂起当前函数并清空调用栈,恢复时全局追踪栈上的 observer 已经不在了。

createEffect(async () => {
  const val = count()      // 这里的依赖能追踪到
  await fetch('/api')
  const val2 = other()     // await 之后追踪上下文丢失,other 变化不会触发此 effect
})

这不是 bug,是机制决定的。Solid 官方建议在 effect 中把所有 signal 读取放在第一个 await 之前,或者用 createResource 处理异步场景。

Angular 的双系统心智负担:Signal 和 RxJS Observable 并存。虽然提供了 toSignal()toObservable() 做桥接,但团队中一半人习惯用 Observable 处理异步流、另一半人用 Signal 处理同步状态,代码风格容易分裂。在一个真实的 Angular 16+ 项目中(比如一个中后台系统),你可能会看到同一个 service 里 BehaviorSubjectsignal() 混用,维护起来很头疼。

Preact 的直接绑定局限:直接绑定模式只对文本内容生效。需要根据 signal 值动态切换 CSS 类名、控制元素显隐、或者传递 props 给子组件时,还是得走 .value 路线触发组件 re-render。也就是说,性能最优的路径覆盖面有限,复杂 UI 逻辑中很难全程使用。

从"框架特性"到"语言能力"还有多远

TC39 Signals 提案要真正落地到浏览器,还有几道坎要过。

JS 引擎级优化的想象空间

一旦 Signals 成为语言原语,JS 引擎可以做目前用户态代码做不到的优化。

依赖图可以用引擎内部的数据结构表示,不需要 JavaScript 对象和 Set 的开销。computed 的缓存失效检查可以在 JIT 层面优化,减少属性查找。垃圾回收也可以更智能地处理不再被引用的 signal 和它们的订阅关系——目前框架实现中,忘记清理的 effect 订阅是常见的内存泄漏来源。

这些优化在用户态框架中是不可能实现的。这也是 TC39 提案最大的远期价值——不是统一 API,而是打开引擎级优化的大门。

框架间共享依赖图

如果 TC39 Signals 落地,一个有意思的可能性是:不同框架的组件可以共享同一个响应式依赖图。

// 未来场景:一个页面同时用了 Solid 和 Angular 组件
const sharedState = new Signal.State({ user: null })

// Solid 组件读取 sharedState → 注册 Solid 的调度器
// Angular 组件读取 sharedState → 注册 Angular 的调度器
// sharedState 变化时,两个框架各自按自己的方式更新

这对微前端场景有实际价值。目前不同框架间传递状态要走 CustomEvent、全局变量或者额外的状态管理层。有了标准 Signals,跨框架的响应式状态共享就变成了原生能力,不需要中间层。

对现有框架的迁移成本

三个框架的迁移难度差异明显。

Solid 的 createSignalcreateMemo 跟 TC39 的 Signal.State / Signal.Computed 语义最接近,换成标准 API 的薄封装就行,兼容成本最低。

Angular 需要把 signal()computed() 的底层实现从自研切换到标准 Signals,上层 API 保持不变。工作量集中在框架内部,对应用代码几乎透明。

Preact Signals 的情况最微妙——它的双路径模式(直接绑定 vs .value 读取)是在自己的 signal 实现上做的深度定制。标准 API 没有"把 signal 对象直接当值用"这个能力,Preact 需要在标准 Signals 之上额外包装一层,复杂度比另外两家高。

从多仓到 Monorepo 的渐进式迁移:Git 历史保留、依赖收敛与缓存调优

2026年3月22日 17:35

迁移之前,我们团队的日常是这样的:改一个公共组件,要在 3 个仓库之间反复 npm link;改完之后走 npm publish 发版,再挨个去下游仓库 npm update;结果经常碰到版本范围匹配出错——^1.2.0 悄悄拉到了 1.3.0,类型对不上,排查半天才发现是另一个同事昨天发的 minor 版本搞的。

这是我们团队 8 个前端仓库并行开发两年之后的真实状况。每次跨仓改动,光是 npm link 和版本对齐就能吃掉半天。终于有一天,Tech Lead 在周会上拍板:"我们迁 Monorepo 吧。"

三个月,无数个坑,8 个仓库最终合进了一个 pnpm workspace + Turborepo 的 Monorepo。

Git 历史迁移:git filter-repo 才是正解

迁移 Monorepo 最纠结的一个决定:要不要保留 Git 历史?

直接把代码复制过来建新仓库最省事,但 git blame 就废了。对于一个有两年历史的项目来说,git blame 几乎是排查问题时的第一反应——"这行代码谁在什么场景下写的"。丢掉历史,等于未来排查问题时少了一个重要线索。

方案对比:subtree merge vs filter-repo

最开始我们试了 git subtree add --prefix=packages/shared-components,看起来很美,但踩了两个坑:历史记录是"拍扁"的,所有 commit 混在主仓库时间线里,git log --follow 对重命名的文件跟踪不了;如果子仓库有 merge commit,合进来之后历史图会变成一团乱麻。

最终选了 git filter-repo。这个工具能在保留完整历史的前提下,批量重写文件路径。

迁移脚本的核心流程

每个仓库的迁移分三步:克隆源仓库到临时目录,用 filter-repo 给所有文件路径加上目标前缀(比如 src/Button.tsx 变成 packages/shared-components/src/Button.tsx,commit 历史中的路径也会同步修改),然后在 monorepo 里把改写后的历史 merge 进来。

#!/bin/bash
# migrate-repo.sh — 单个仓库的历史迁移

REPO_URL=$1        # 源仓库地址
TARGET_DIR=$2      # 目标路径,如 packages/shared-components
BRANCH=${3:-main}
TEMP_DIR=$(mktemp -d)

git clone --single-branch --branch "BRANCH""BRANCH""REPO_URL" "$TEMP_DIR"
cd "$TEMP_DIR"

# 重写所有 commit 中的文件路径,加上目标目录前缀
git filter-repo --to-subdirectory-filter "$TARGET_DIR" --force

cd /path/to/monorepo
git remote add temp-migrate "$TEMP_DIR"
git fetch temp-migrate
git merge temp-migrate/"$BRANCH" --allow-unrelated-histories \
  -m "chore: migrate $TARGET_DIR with full git history"
git remote remove temp-migrate
rm -rf "$TEMP_DIR"

这里有个容易忽略的细节:--allow-unrelated-histories 是必须的。每个源仓库的 commit 树和 monorepo 完全独立,没有共同祖先,Git 默认会拒绝这种合并。

迁移顺序决定了过程的平稳度

我们按依赖拓扑排序,从叶子节点开始:design-tokens 和 eslint-config(零依赖)先进,然后是 shared-utilsshared-components,最后是三个应用。

为什么这个顺序很重要?因为每合进一个仓库,我们都会跑一次 pnpm install 和 tsc --build 来验证当前状态是否正常。如果先合应用层,它依赖的 shared-components 还没进来,类型检查和构建都会挂。从叶子节点开始,每一步合进来的仓库都能在当前 monorepo 里正常构建,出了问题也能立刻定位是哪个仓库的迁移引入的。

我们中间有一次没按顺序,把 app-admin 提前合了进来。结果 pnpm install 时它依赖的 @xxx/shared-components 在 workspace 里找不到,pnpm 直接去 npm registry 拉了线上旧版本,构建倒是过了,但类型对不上——线上版本还没有我们本地最新加的几个 props。排查了一个多小时才意识到是顺序的问题。

迁完 8 个仓库后,monorepo 的 commit 数量从 0 涨到了 4000+,用 git log --oneline | wc -l 验证总数,和各仓库之和对得上。随便挑几个文件跑 git blame,能看到原始仓库的 commit hash、作者和日期,说明历史完整保留了。

跨仓依赖收敛:从 npm 包到 workspace 协议

历史搬完了,代码都在一个仓库里了,但各个 package 的 package.json 还在引用 npm 上的包。要把这些改成 pnpm workspace 的内部引用。

workspace 结构和依赖替换

先在根目录建 pnpm-workspace.yaml,声明 packages/* 和 apps/* 两个目录。然后批量把所有内部包的版本号替换为 workspace:*

// 替换前
{ "@xxx/shared-components": "^1.3.0", "@xxx/utils": "^2.1.0" }
// 替换后
{ "@xxx/shared-components": "workspace:*", "@xxx/utils": "workspace:*" }

workspace:* 告诉 pnpm:这个包就在本地 workspace 里,不要去 npm registry 找。开发时直接引用源码或构建产物,改了立刻生效,不需要发版。发布时 pnpm 会自动把 workspace:* 替换成实际版本号。

外部依赖版本不一致——最耗时的部分

8 个仓库各自装了两年依赖,同一个包的版本五花八门。比如 React:shared-components 用的 ^18.2.0app-admin 是 ^18.0.0app-h5 居然还停在 ^17.0.2app-mini 则是 ^18.3.0

pnpm workspace 对这种情况还算宽容——每个 package 可以有自己的依赖版本。但版本一致性直接影响 Turborepo 的缓存命中率(后面会展开讲),所以我们用 pnpm overrides 强制统一了关键依赖:

// monorepo 根目录 package.json
{
  "pnpm": {
    "overrides": {
      "react": "^18.3.1",
      "react-dom": "^18.3.1",
      "typescript": "~5.4.0",
      "lodash": "npm:lodash-es@^4.17.21"
    }
  }
}

pnpm overrides 像一把大锤——不管子 package 声明的是什么版本,最终安装的都是 overrides 指定的。

这里踩了一个坑:app-h5 从 React 17 直接拉到 18.3.1 之后,用了 ReactDOM.render 的入口文件控制台疯狂报 warning。React 18 要求换成 createRoot,连带着一些依赖 ReactDOM.render 的第三方库(我们用的一个老版本富文本编辑器)也得升级。这部分额外花了两天,如果一开始就列出每个仓库的 React 大版本差异,可以提前评估工作量。

TypeScript 项目引用

代码和依赖都在一起了,但 TypeScript 还不知道怎么跨 package 做类型检查。需要给每个子包配 composite: true 和 references,在根目录的 tsconfig.json 里把所有子项目串起来。

// packages/shared-components/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true
  },
  "references": [
    { "path": "../design-tokens" },
    { "path": "../shared-utils" }
  ]
}

配好之后,tsc --build 会按依赖顺序增量编译整个 monorepo,只重新编译有变更的包和它的下游。根目录的 tsconfig.json 自己不编译任何文件("files": []),纯粹用来声明子项目拓扑关系。

远程缓存命中率:从 30% 到 85% 的调优过程

Turborepo 的本地缓存在单人开发时够用,但团队协作时需要远程缓存——我在 A 分支构建过的包,你在 B 分支如果没改过,应该能直接复用。我们接入自建 HTTP 缓存服务器后,初始命中率只有 30%。70% 的构建任务在重复劳动,完全没发挥出缓存的价值。

元凶一:环境变量泄漏(30% → 50%)

Turborepo 默认会把一些环境变量算进 hash。CI 环境有 CI=trueNODE_ENV=production,本地没有,hash 自然不一样,缓存永远命中不了。

解法是在 turbo.json 里显式声明哪些环境变量影响构建:

{
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "env": ["VITE_API_BASE", "VITE_APP_VERSION"]
    }
  }
}

只有这些变量参与 hash 计算,CI 和本地之间 CIGITHUB_SHA 之类的差异不再影响缓存。这一步改完命中率直接从 30% 跳到 50%。

元凶二:生成文件污染 inputs(50% → 65%)

我们的构建流程会从 OpenAPI spec 自动生成 src/generated/api-types.ts。这个文件在 src/** 的 glob 范围内,每次生成即使内容没变,文件时间戳也会更新,Turborepo 就认为 inputs 变了。

解法是把代码生成拆成独立的 Turborepo 任务:

{
  "tasks": {
    "codegen": {
      "inputs": ["openapi.yaml"],
      "outputs": ["src/generated/**"]
    },
    "build": {
      "dependsOn": ["codegen", "^build"],
      "inputs": ["src/**", "!src/generated/**", "tsconfig.json"],
      "outputs": ["dist/**"]
    }
  }
}

codegen 和 build 各管各的缓存。openapi.yaml 没变就不重新生成,src 没变就不重新构建,两者互不干扰。

元凶三:锁文件变动的连锁反应(65% → 80%)

pnpm-lock.yaml 是 Turborepo 默认的全局 input。任何人装了个新依赖,锁文件一变,全部包的缓存全部失效。

这个问题比较棘手。锁文件确实影响构建结果——间接依赖版本变了,构建产物可能不同。但大部分时候,改动只影响一两个包,不应该让整个 monorepo 的缓存全部作废。

我们的妥协方案是把 pnpm-lock.yaml 从 globalDependencies 里拿掉,只保留真正全局的配置(如 tsconfig.base.json)。代价是可能出现间接依赖变化导致的构建差异未被检测到,但我们用 CI 的集成测试兜底——每天凌晨跑一次全量构建,如果有问题第二天早上能看到。

这是一个不完美的 trade-off。

最后 5%:缓存服务的存储策略(80% → 85%)

剩下的 miss 大多来自缓存过期。自建缓存服务用的 S3 存储,默认 TTL 7 天。但像 design-tokenseslint-config 这种几个月都不变的基础包,7 天一过缓存没了又得重新构建。

我们按包的变更频率设了不同的 TTL:design-tokens 和 eslint-config 30 天,shared-utils 14 天,shared-components 7 天(变得比较频繁),其他默认 3 天。再加上 LRU 淘汰策略,S3 bucket 限制在 50GB 以内,命中率稳定在 83%-87%。

迁移之后踩的坑

坑一:monorepo 的 CI 从 5 分钟膨胀到 25 分钟

8 个仓库合成一个之后,CI 从原来每个仓库 3-5 分钟,变成全量跑 25 分钟。原因是 CI 默认 pnpm install 装所有依赖,turbo build 构建所有包——哪怕这次 PR 只改了 app-admin 的一个按钮颜色。

解法是用 Turborepo 的 --filter 配合 Git diff,只跑受影响的包:

bash复制

turbo build test --filter='...[origin/main]'

这行命令的意思是:找出相对于 main 分支有文件变动的包,以及依赖这些包的下游包,只对它们跑 build 和 test。改了 app-admin 的按钮颜色,就只构建 app-admin,3 分钟搞定。改了 shared-components,会自动触发所有引用它的应用一起构建。

改完之后,80% 的 PR 的 CI 时间回到了 3-8 分钟,只有改公共包的 PR 才需要 15 分钟左右。

坑二:IDE 卡到怀疑人生

8 个仓库的代码放到一个 VS Code workspace 里,TypeScript Language Server 直接吃满 4GB 内存,输入一个字符要等 2-3 秒才有自动补全。

两个办法缓解:

第一,在 .vscode/settings.json 里做减法。关掉 typescript.preferences.includePackageJsonAutoImports(这个功能会扫描所有 node_modules 来生成 import 建议),把 node_modulesdist.turbo.next 目录加到 files.watcherExclude 和 search.exclude 里,减少文件系统监听的压力。

第二,靠 TypeScript 的 Project References。开了 composite: true 之后,TS Server 不会一次性加载全部子项目的源码,而是按需加载——打开 app-admin 的文件时只加载它直接依赖的 shared-components 和 shared-utils 的类型声明(.d.ts),不加载其他应用的代码。内存占用从 4GB 降到了 1.5GB 左右,自动补全延迟也回到了可接受的范围。

坑三:新人 onboarding 成本翻倍

仓库有 4000+ commit 历史,pnpm install 要装 2000+ 个包(8 个项目的依赖加起来),turbo build 第一次全量构建要跑 8-10 分钟。新人第一天 clone 下来,面对这个规模会有点懵——"我只负责 admin 后台,为什么要装移动端 H5 的依赖?"

我们最终沉淀了一套 onboarding 流程:

  1. 用 git clone --depth=1 浅克隆,不拉 4000 条历史,clone 时间从 3 分钟降到 20 秒
  2. 根目录放了一个 setup.sh,一键完成 pnpm install + turbo build 全量构建,同时填充本地 Turborepo 缓存
  3. 之后日常开发只需要 pnpm turbo build --filter=@xxx/app-admin 构建自己负责的应用,增量构建通常 10 秒以内

另外在根目录的 README.md 里画了一张包依赖关系图(用 mermaid 生成),新人看一眼就知道 app-admin 依赖了哪些内部包,改了 shared-utils 会影响哪些应用。

迁移三个月后的数据对比

指标 迁移前(8 个仓库) 迁移后(Monorepo)
跨仓改动耗时 半天(npm link + 发版 + 更新) 10 分钟(改完直接引用)
CI 平均时长 3-5 分钟/仓库,但跨仓要手动触发多个 3-8 分钟(单包),15 分钟(公共包)
版本冲突频率 每周 2-3 次 基本消失(workspace 协议 + overrides)
依赖安装时间 每个仓库各装一遍,总计 15 分钟+ 一次 pnpm install,3 分钟
新人上手时间 1 天(配 8 个仓库的开发环境) 半天(一个仓库,一个 setup 脚本)

回头看,最值得的不是构建速度的提升,而是跨仓改动的心理负担没了。以前改公共组件要发版、要通知下游、要确认版本号,现在就是正常提交代码,CI 自动帮你验证所有下游是否兼容。

最坑的部分是 React 17 → 18 的升级,和远程缓存命中率的调优。这两个加起来占了迁移总工作量的一半。如果你的团队也在考虑迁 Monorepo,建议先花一天时间梳理所有仓库的核心依赖版本差异,提前评估升级工作量——这个信息决定了你应该给迁移留多少 buffer。

@tencent-weixin/openclaw-weixin 源码ContextToken 持久化改造:实现微信自定义消息发送能力

作者 毛骗导演
2026年3月22日 16:55

概述

在 OpenClaw 微信插件的开发过程中,一个核心挑战是如何实现可靠的出站消息发送(Outbound Messaging)。微信后端 API 要求每条出站消息都必须携带一个 context_token,这个令牌是通过 getupdates 接口在接收消息时返回的。原始实现将 contextToken 仅存储在内存中,导致每次网关重启或使用 CLI 命令时,出站消息发送都会失败。

本文将详细介绍如何通过引入持久化的 Context Token 存储机制,解决这一问题,从而实现稳定可靠的自定义消息发送能力。


问题背景

微信 API 的 Context Token 机制

微信的消息协议设计了一个重要的安全机制:context_token。这个令牌具有以下特点:

  1. 按消息发放:每次调用 getupdates 接口获取新消息时,服务器会为该对话返回一个 context_token
  2. 发送时必须携带:调用 sendmessage 接口发送消息时,必须将收到的 context_token 原样回传
  3. 用于会话关联:微信后端通过 context_token 来关联对话上下文,确保消息发送的合法性

原始实现的局限性

在改造之前,contextToken 仅以简单的内存 Map 形式存储:

// 原始实现 - 仅内存存储
const contextTokenStore = new Map<string, string>();

export function setContextToken(accountId: string, userId: string, token: string): void {
  const k = `${accountId}:${userId}`;
  contextTokenStore.set(k, token);  // 仅内存存储,进程结束即丢失
}

export function getContextToken(accountId: string, userId: string): string | undefined {
  return contextTokenStore.get(`${accountId}:${userId}`);
}

这种实现方式导致了以下问题:

场景 问题描述
网关重启 插件进程重启后,内存中的 contextToken 全部丢失,无法发送消息
CLI 命令 openclaw message send 命令会重新加载插件,无法访问之前的内存状态
首次出站 如果没有收到过该用户的消息,就没有 contextToken,无法主动发送消息

错误示例

当尝试在没有 contextToken 的情况下发送消息时,系统会抛出错误:

Error: sendWeixinOutbound: contextToken is required

或者:

Error: sendMessageWeixin: contextToken is required

解决方案:持久化 Context Token 存储

架构设计

为了解决上述问题,我们设计了一个双层存储架构

┌─────────────────────────────────────────────────────────────┐
│                    Context Token 存储架构                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────────┐      ┌──────────────────────────┐    │
│  │  In-Memory Cache │      │  Persistent Storage      │    │
│  │  (Map)           │◄────►│  (FileSystem)            │    │
│  │                  │      │                          │    │
│  │  - 快速访问       │      │  - 进程间共享             │    │
│  │  - 运行时缓存     │      │  - 重启后恢复             │    │
│  │  - 毫秒级读取     │      │  - CLI 可访问            │    │
│  └──────────────────┘      └──────────────────────────┘    │
│           ▲                          ▲                     │
│           │                          │                     │
│           └──────────┬───────────────┘                     │
│                      │                                     │
│              ┌───────┴───────┐                            │
│              │  Token Store  │                            │
│              │   Manager     │                            │
│              └───────────────┘                            │
│                      │                                     │
│           ┌──────────┼──────────┐                         │
│           ▼          ▼          ▼                         │
│      ┌────────┐ ┌────────┐ ┌────────┐                    │
│      │ set()  │ │ get()  │ │clear() │                    │
│      └────────┘ └────────┘ └────────┘                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

存储路径设计

持久化文件存储在用户主目录下的 OpenClaw 配置目录中:

~/.openclaw/openclaw-weixin/context-tokens/
├── {accountId-1}/
│   ├── user1_im_wechat.json
│   ├── user2_im_wechat.json
│   └── ...
├── {accountId-2}/
│   ├── user3_im_wechat.json
│   └── ...
└── ...

每个文件对应一个 (accountId, userId) 组合,存储该对话的最新 contextToken


核心代码实现

1. 持久化存储模块:context-token-store.ts

这是整个持久化机制的基础模块,负责与文件系统交互。

import fs from "node:fs";
import path from "node:path";

import { resolveStateDir } from "./state-dir.js";
import { logger } from "../util/logger.js";

// ---------------------------------------------------------------------------
// Persistent Context Token Store
// ---------------------------------------------------------------------------

/**
 * Context token persistence for outbound messaging.
 * 
 * The Weixin API requires a context_token for every outbound message, which is
 * issued per-message by the getupdates API. This store persists the latest
 * contextToken to disk so that outbound messages can be sent even after the
 * gateway restarts or when using CLI commands.
 * 
 * Storage path: ~/.openclaw/openclaw-weixin/context-tokens/{accountId}/{userId}.json
 */

interface ContextTokenData {
  token: string;
  updatedAt: string;
}

function resolveContextTokensDir(): string {
  return path.join(resolveStateDir(), "openclaw-weixin", "context-tokens");
}

function resolveContextTokenPath(accountId: string, userId: string): string {
  // Sanitize userId for filesystem safety (replace @ and other special chars)
  const safeUserId = userId.replace(/[^a-zA-Z0-9_-]/g, "_");
  return path.join(resolveContextTokensDir(), accountId, `${safeUserId}.json`);
}

1.1 保存 Token:persistContextToken

/**
 * Persist a context token to disk.
 * Called when an inbound message is received with a new context_token.
 */
export function persistContextToken(accountId: string, userId: string, token: string): void {
  try {
    const filePath = resolveContextTokenPath(accountId, userId);
    const dir = path.dirname(filePath);
    
    // 确保目录存在(递归创建)
    fs.mkdirSync(dir, { recursive: true });
    
    const data: ContextTokenData = {
      token,
      updatedAt: new Date().toISOString(),
    };
    
    // 写入 JSON 文件,格式化便于调试
    fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
    
    // 设置文件权限为仅所有者可读写(安全考虑)
    try {
      fs.chmodSync(filePath, 0o600);
    } catch {
      // best-effort
    }
    
    logger.debug(`persistContextToken: saved token for ${accountId}:${userId}`);
  } catch (err) {
    logger.error(`persistContextToken: failed to save token: ${String(err)}`);
  }
}

关键点说明

  • 路径安全处理userId 可能包含特殊字符(如 @),通过正则替换为下划线确保文件系统安全
  • 递归目录创建:使用 fs.mkdirSync(dir, { recursive: true }) 确保多级目录自动创建
  • 权限控制:设置 0o600 权限,仅允许文件所有者可读写,保护敏感 token 数据
  • 错误处理:采用 "best-effort" 策略,即使持久化失败也不影响主流程

1.2 加载 Token:loadPersistedContextToken

/**
 * Load a persisted context token from disk.
 * Returns undefined if no token exists or loading fails.
 */
export function loadPersistedContextToken(accountId: string, userId: string): string | undefined {
  try {
    const filePath = resolveContextTokenPath(accountId, userId);
    
    if (!fs.existsSync(filePath)) {
      return undefined;
    }
    
    const raw = fs.readFileSync(filePath, "utf-8");
    const data = JSON.parse(raw) as ContextTokenData;
    
    // 验证 token 格式
    if (typeof data.token === "string" && data.token.trim()) {
      logger.debug(`loadPersistedContextToken: loaded token for ${accountId}:${userId}`);
      return data.token;
    }
    
    return undefined;
  } catch (err) {
    logger.debug(`loadPersistedContextToken: failed to load token: ${String(err)}`);
    return undefined;
  }
}

关键点说明

  • 防御性编程:文件不存在、JSON 解析失败、token 格式不正确时都返回 undefined
  • 格式验证:确保加载的 token 是非空字符串

1.3 清除 Token:clearPersistedContextToken

/**
 * Clear a persisted context token (e.g., on session timeout or logout).
 */
export function clearPersistedContextToken(accountId: string, userId: string): void {
  try {
    const filePath = resolveContextTokenPath(accountId, userId);
    
    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
      logger.debug(`clearPersistedContextToken: cleared token for ${accountId}:${userId}`);
    }
  } catch (err) {
    logger.error(`clearPersistedContextToken: failed to clear token: ${String(err)}`);
  }
}

1.4 批量加载:loadAllPersistedContextTokens

/**
 * Load all persisted context tokens for an account.
 * Returns a map of userId -> token.
 */
export function loadAllPersistedContextTokens(accountId: string): Map<string, string> {
  const result = new Map<string, string>();
  
  try {
    const accountDir = path.join(resolveContextTokensDir(), accountId);
    
    if (!fs.existsSync(accountDir)) {
      return result;
    }
    
    const files = fs.readdirSync(accountDir);
    
    for (const file of files) {
      if (!file.endsWith(".json")) continue;
      
      // Convert filename back to userId (approximate, since we sanitized it)
      const safeUserId = file.slice(0, -5); // remove .json
      const filePath = path.join(accountDir, file);
      
      try {
        const raw = fs.readFileSync(filePath, "utf-8");
        const data = JSON.parse(raw) as ContextTokenData;
        
        if (typeof data.token === "string" && data.token.trim()) {
          // Store with the safe userId - the actual lookup will use the same sanitization
          result.set(safeUserId, data.token);
        }
      } catch {
        // Skip invalid files
      }
    }
    
    logger.debug(`loadAllPersistedContextTokens: loaded ${result.size} tokens for ${accountId}`);
  } catch (err) {
    logger.debug(`loadAllPersistedContextTokens: failed to load tokens: ${String(err)}`);
  }
  
  return result;
}

2. 双层存储管理:inbound.ts

在持久化存储之上,我们构建了一个双层存储管理器,协调内存缓存和持久化存储的交互。

import { logger } from "../util/logger.js";
import { generateId } from "../util/random.js";
import type { WeixinMessage, MessageItem } from "../api/types.js";
import { MessageItemType } from "../api/types.js";
import {
  persistContextToken,
  loadPersistedContextToken,
  loadAllPersistedContextTokens,
} from "../storage/context-token-store.js";

// ---------------------------------------------------------------------------
// Context token store (in-process cache + persistent storage)
// ---------------------------------------------------------------------------

/**
 * contextToken is issued per-message by the Weixin getupdates API and must
 * be echoed verbatim in every outbound send. 
 * 
 * This store uses both in-memory cache and persistent storage:
 * - In-memory: fast access during gateway runtime
 * - Persistent: allows outbound messaging after gateway restart or via CLI
 * 
 * Storage path: ~/.openclaw/openclaw-weixin/context-tokens/{accountId}/{userId}.json
 */
const contextTokenStore = new Map<string, string>();

function contextTokenKey(accountId: string, userId: string): string {
  return `${accountId}:${userId}`;
}

2.1 存储 Token:setContextToken

/** 
 * Store a context token for a given account+user pair.
 * Persists to disk for CLI/outbound access after restart.
 */
export function setContextToken(accountId: string, userId: string, token: string): void {
  const k = contextTokenKey(accountId, userId);
  logger.debug(`setContextToken: key=${k}`);
  
  // 1. 写入内存缓存(快速访问)
  contextTokenStore.set(k, token);
  
  // 2. 同时持久化到磁盘(跨进程共享)
  persistContextToken(accountId, userId, token);
}

设计要点

  • 双写策略:每次设置 token 时,同时更新内存和磁盘
  • 以内存为准:内存缓存是运行时权威数据源
  • 磁盘为备份:磁盘存储用于进程重启后的恢复

2.2 获取 Token:getContextToken

/** 
 * Retrieve the cached context token for a given account+user pair.
 * Falls back to persisted storage if not in memory.
 */
export function getContextToken(accountId: string, userId: string): string | undefined {
  const k = contextTokenKey(accountId, userId);
  
  // 1. 首先检查内存缓存
  const cached = contextTokenStore.get(k);
  if (cached !== undefined) {
    logger.debug(`getContextToken: key=${k} found=in-memory storeSize=${contextTokenStore.size}`);
    return cached;
  }
  
  // 2. 内存未命中,尝试从持久化存储加载
  const persisted = loadPersistedContextToken(accountId, userId);
  if (persisted !== undefined) {
    // 回填到内存缓存,加速后续访问
    contextTokenStore.set(k, persisted);
    logger.debug(`getContextToken: key=${k} found=persisted storeSize=${contextTokenStore.size}`);
    return persisted;
  }
  
  logger.debug(`getContextToken: key=${k} found=false storeSize=${contextTokenStore.size}`);
  return undefined;
}

缓存策略

  • L1 缓存(内存):纳秒级访问速度
  • L2 缓存(磁盘):毫秒级访问速度
  • 回填机制:从磁盘加载后自动回填到内存,形成缓存预热

2.3 预加载 Token:preloadContextTokens

/**
 * Pre-load all persisted context tokens for an account into memory.
 * Called when an account starts to enable immediate outbound messaging.
 */
export function preloadContextTokens(accountId: string): void {
  const tokens = loadAllPersistedContextTokens(accountId);
  for (const [safeUserId, token] of tokens) {
    // Use the safe userId as the key (it was sanitized for filesystem)
    const k = `${accountId}:${safeUserId}`;
    contextTokenStore.set(k, token);
  }
  logger.info(`preloadContextTokens: loaded ${tokens.size} tokens for ${accountId}`);
}

使用场景

  • 网关启动时预加载所有历史 token
  • 账号重新连接时恢复会话状态
  • 确保重启后立即具备消息发送能力

3. 网关集成:channel.ts

持久化存储机制需要在网关生命周期中正确集成,才能发挥作用。

3.1 导入依赖

import path from "node:path";

import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk";

import {
  registerWeixinAccountId,
  loadWeixinAccount,
  saveWeixinAccount,
  listWeixinAccountIds,
  resolveWeixinAccount,
  triggerWeixinChannelReload,
  DEFAULT_BASE_URL,
} from "./auth/accounts.js";
import type { ResolvedWeixinAccount } from "./auth/accounts.js";
import { assertSessionActive } from "./api/session-guard.js";
import { getContextToken, preloadContextTokens } from "./messaging/inbound.js";
import { logger } from "./util/logger.js";
// ... 其他导入

3.2 出站消息发送

async function sendWeixinOutbound(params: {
  cfg: OpenClawConfig;
  to: string;
  text: string;
  accountId?: string | null;
  contextToken?: string;
  mediaUrl?: string;
}): Promise<{ channel: string; messageId: string }> {
  const account = resolveWeixinAccount(params.cfg, params.accountId);
  const aLog = logger.withAccount(account.accountId);
  
  // 验证会话状态
  assertSessionActive(account.accountId);
  
  if (!account.configured) {
    aLog.error(`sendWeixinOutbound: account not configured`);
    throw new Error("weixin not configured: please run `openclaw channels login --channel openclaw-weixin`");
  }
  
  // 关键验证:必须有 contextToken
  if (!params.contextToken) {
    aLog.error(`sendWeixinOutbound: contextToken missing, refusing to send to=${params.to}`);
    throw new Error("sendWeixinOutbound: contextToken is required");
  }
  
  const result = await sendMessageWeixin({ 
    to: params.to, 
    text: params.text, 
    opts: {
      baseUrl: account.baseUrl,
      token: account.token,
      contextToken: params.contextToken,
    }
  });
  
  return { channel: "openclaw-weixin", messageId: result.messageId };
}

3.3 出站消息配置

export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
  // ... 其他配置
  
  outbound: {
    deliveryMode: "direct",
    textChunkLimit: 4000,
    
    // 发送文本消息
    sendText: async (ctx) => {
      const result = await sendWeixinOutbound({
        cfg: ctx.cfg,
        to: ctx.to,
        text: ctx.text,
        accountId: ctx.accountId,
        // 从存储中获取 contextToken
        contextToken: getContextToken(ctx.accountId!, ctx.to),
      });
      return result;
    },
    
    // 发送媒体消息
    sendMedia: async (ctx) => {
      const account = resolveWeixinAccount(ctx.cfg, ctx.accountId);
      const aLog = logger.withAccount(account.accountId);
      assertSessionActive(account.accountId);
      
      if (!account.configured) {
        aLog.error(`sendMedia: account not configured`);
        throw new Error(
          "weixin not configured: please run `openclaw channels login --channel openclaw-weixin`",
        );
      }

      const mediaUrl = ctx.mediaUrl;

      if (mediaUrl && (isLocalFilePath(mediaUrl) || isRemoteUrl(mediaUrl))) {
        let filePath: string;
        if (isLocalFilePath(mediaUrl)) {
          filePath = resolveLocalPath(mediaUrl);
          aLog.debug(`sendMedia: uploading local file ${filePath}`);
        } else {
          aLog.debug(`sendMedia: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
          filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
          aLog.debug(`sendMedia: remote image downloaded to ${filePath}`);
        }
        
        // 获取 contextToken 用于媒体发送
        const contextToken = getContextToken(account.accountId, ctx.to);
        const result = await sendWeixinMediaFile({
          filePath,
          to: ctx.to,
          text: ctx.text ?? "",
          opts: { baseUrl: account.baseUrl, token: account.token, contextToken },
          cdnBaseUrl: account.cdnBaseUrl,
        });
        return { channel: "openclaw-weixin", messageId: result.messageId };
      }

      // 回退到纯文本发送
      const result = await sendWeixinOutbound({
        cfg: ctx.cfg,
        to: ctx.to,
        text: ctx.text ?? "",
        accountId: ctx.accountId,
        contextToken: getContextToken(ctx.accountId!, ctx.to),
      });
      return result;
    },
  },
  
  // ... 其他配置
};

3.4 网关启动时预加载

gateway: {
  startAccount: async (ctx) => {
    logger.debug(`startAccount entry`);
    if (!ctx) {
      logger.warn(`gateway.startAccount: called with undefined ctx, skipping`);
      return;
    }
    const account = ctx.account;
    const aLog = logger.withAccount(account.accountId);
    aLog.debug(`about to call monitorWeixinProvider`);
    aLog.info(`starting weixin webhook`);

    ctx.setStatus?.({
      accountId: account.accountId,
      running: true,
      lastStartAt: Date.now(),
      lastEventAt: Date.now(),
    });

    if (!account.configured) {
      aLog.error(`account not configured`);
      ctx.log?.error?.(
        `[${account.accountId}] weixin not logged in — run: openclaw channels login --channel openclaw-weixin`,
      );
      ctx.setStatus?.({ accountId: account.accountId, running: false });
      throw new Error("weixin not configured: missing token");
    }

    ctx.log?.info?.(`[${account.accountId}] starting weixin provider (${DEFAULT_BASE_URL})`);

    const logPath = aLog.getLogFilePath();
    ctx.log?.info?.(`[${account.accountId}] weixin logs: ${logPath}`);

    // ═══════════════════════════════════════════════════════════════════
    // 关键:启动时预加载持久化的 context tokens
    // 这使得网关重启后立即具备出站消息发送能力
    // ═══════════════════════════════════════════════════════════════════
    preloadContextTokens(account.accountId);

    return monitorWeixinProvider({
      baseUrl: account.baseUrl,
      cdnBaseUrl: account.cdnBaseUrl,
      token: account.token,
      accountId: account.accountId,
      config: ctx.cfg,
      runtime: ctx.runtime,
      abortSignal: ctx.abortSignal,
      setStatus: ctx.setStatus,
    });
  },
  
  // ... 其他网关方法
}

4. 消息发送实现:send.ts

最后,我们来看实际的消息发送实现,它依赖于前面构建的 contextToken 机制。

import type { ReplyPayload } from "openclaw/plugin-sdk";
import { stripMarkdown } from "openclaw/plugin-sdk";

import { sendMessage as sendMessageApi } from "../api/api.js";
import type { WeixinApiOptions } from "../api/api.js";
import { logger } from "../util/logger.js";
import { generateId } from "../util/random.js";
import type { MessageItem, SendMessageReq } from "../api/types.js";
import { MessageItemType, MessageState, MessageType } from "../api/types.js";
import type { UploadedFileInfo } from "../cdn/upload.js";

function generateClientId(): string {
  return generateId("openclaw-weixin");
}

/**
 * Convert markdown-formatted model reply to plain text for Weixin delivery.
 * Preserves newlines; strips markdown syntax.
 */
export function markdownToPlainText(text: string): string {
  let result = text;
  // Code blocks: strip fences, keep code content
  result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
  // Images: remove entirely
  result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
  // Links: keep display text only
  result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
  // Tables: remove separator rows, then strip leading/trailing pipes and convert inner pipes to spaces
  result = result.replace(/^\|[\s:|-]+\|$/gm, "");
  result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) =>
    inner.split("|").map((cell) => cell.trim()).join("  "),
  );
  result = stripMarkdown(result);
  return result;
}

4.1 构建消息请求

/** Build a SendMessageReq containing a single text message. */
function buildTextMessageReq(params: {
  to: string;
  text: string;
  contextToken?: string;
  clientId: string;
}): SendMessageReq {
  const { to, text, contextToken, clientId } = params;
  const item_list: MessageItem[] = text
    ? [{ type: MessageItemType.TEXT, text_item: { text } }]
    : [];
  return {
    msg: {
      from_user_id: "",
      to_user_id: to,
      client_id: clientId,
      message_type: MessageType.BOT,
      message_state: MessageState.FINISH,
      item_list: item_list.length ? item_list : undefined,
      context_token: contextToken ?? undefined,  // 关键:传递 context_token
    },
  };
}

/** Build a SendMessageReq from a reply payload (text only; image send uses sendImageMessageWeixin). */
function buildSendMessageReq(params: {
  to: string;
  contextToken?: string;
  payload: ReplyPayload;
  clientId: string;
}): SendMessageReq {
  const { to, contextToken, payload, clientId } = params;
  return buildTextMessageReq({
    to,
    text: payload.text ?? "",
    contextToken,
    clientId,
  });
}

4.2 发送文本消息

/**
 * Send a plain text message downstream.
 * contextToken is required for all reply sends; missing it breaks conversation association.
 */
export async function sendMessageWeixin(params: {
  to: string;
  text: string;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, opts } = params;
  
  // 严格检查:没有 contextToken 拒绝发送
  if (!opts.contextToken) {
    logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`);
    throw new Error("sendMessageWeixin: contextToken is required");
  }
  
  const clientId = generateClientId();
  const req = buildSendMessageReq({
    to,
    contextToken: opts.contextToken,
    payload: { text },
    clientId,
  });
  
  try {
    await sendMessageApi({
      baseUrl: opts.baseUrl,
      token: opts.token,
      timeoutMs: opts.timeoutMs,
      body: req,
    });
  } catch (err) {
    logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
    throw err;
  }
  
  return { messageId: clientId };
}

4.3 发送媒体消息

/**
 * Send one or more MessageItems (optionally preceded by a text caption) downstream.
 * Each item is sent as its own request so that item_list always has exactly one entry.
 */
async function sendMediaItems(params: {
  to: string;
  text: string;
  mediaItem: MessageItem;
  opts: WeixinApiOptions & { contextToken?: string };
  label: string;
}): Promise<{ messageId: string }> {
  const { to, text, mediaItem, opts, label } = params;

  const items: MessageItem[] = [];
  if (text) {
    items.push({ type: MessageItemType.TEXT, text_item: { text } });
  }
  items.push(mediaItem);

  let lastClientId = "";
  for (const item of items) {
    lastClientId = generateClientId();
    const req: SendMessageReq = {
      msg: {
        from_user_id: "",
        to_user_id: to,
        client_id: lastClientId,
        message_type: MessageType.BOT,
        message_state: MessageState.FINISH,
        item_list: [item],
        context_token: opts.contextToken ?? undefined,  // 传递 context_token
      },
    };
    try {
      await sendMessageApi({
        baseUrl: opts.baseUrl,
        token: opts.token,
        timeoutMs: opts.timeoutMs,
        body: req,
      });
    } catch (err) {
      logger.error(
        `${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`,
      );
      throw err;
    }
  }

  logger.debug(`${label}: success to=${to} clientId=${lastClientId}`);
  return { messageId: lastClientId };
}

4.4 发送图片消息

/**
 * Send an image message downstream using a previously uploaded file.
 * Optionally include a text caption as a separate TEXT item before the image.
 *
 * ImageItem fields:
 *   - media.encrypt_query_param: CDN download param
 *   - media.aes_key: AES key, base64-encoded
 *   - mid_size: original ciphertext file size
 */
export async function sendImageMessageWeixin(params: {
  to: string;
  text: string;
  uploaded: UploadedFileInfo;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, uploaded, opts } = params;
  
  // 同样需要 contextToken
  if (!opts.contextToken) {
    logger.error(`sendImageMessageWeixin: contextToken missing, refusing to send to=${to}`);
    throw new Error("sendImageMessageWeixin: contextToken is required");
  }
  
  logger.debug(
    `sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`,
  );

  const imageItem: MessageItem = {
    type: MessageItemType.IMAGE,
    image_item: {
      media: {
        encrypt_query_param: uploaded.downloadEncryptedQueryParam,
        aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
        encrypt_type: 1,
      },
      mid_size: uploaded.fileSizeCiphertext,
    },
  };

  return sendMediaItems({ to, text, mediaItem: imageItem, opts, label: "sendImageMessageWeixin" });
}

4.5 发送视频和文件消息

/**
 * Send a video message downstream using a previously uploaded file.
 * VideoItem: media (CDN ref), video_size (ciphertext bytes).
 * Includes an optional text caption sent as a separate TEXT item first.
 */
export async function sendVideoMessageWeixin(params: {
  to: string;
  text: string;
  uploaded: UploadedFileInfo;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, uploaded, opts } = params;
  if (!opts.contextToken) {
    logger.error(`sendVideoMessageWeixin: contextToken missing, refusing to send to=${to}`);
    throw new Error("sendVideoMessageWeixin: contextToken is required");
  }

  const videoItem: MessageItem = {
    type: MessageItemType.VIDEO,
    video_item: {
      media: {
        encrypt_query_param: uploaded.downloadEncryptedQueryParam,
        aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
        encrypt_type: 1,
      },
      video_size: uploaded.fileSizeCiphertext,
    },
  };

  return sendMediaItems({ to, text, mediaItem: videoItem, opts, label: "sendVideoMessageWeixin" });
}

/**
 * Send a file attachment downstream using a previously uploaded file.
 * FileItem: media (CDN ref), file_name, len (plaintext bytes as string).
 * Includes an optional text caption sent as a separate TEXT item first.
 */
export async function sendFileMessageWeixin(params: {
  to: string;
  text: string;
  fileName: string;
  uploaded: UploadedFileInfo;
  opts: WeixinApiOptions & { contextToken?: string };
}): Promise<{ messageId: string }> {
  const { to, text, fileName, uploaded, opts } = params;
  if (!opts.contextToken) {
    logger.error(`sendFileMessageWeixin: contextToken missing, refusing to send to=${to}`);
    throw new Error("sendFileMessageWeixin: contextToken is required");
  }
  
  const fileItem: MessageItem = {
    type: MessageItemType.FILE,
    file_item: {
      media: {
        encrypt_query_param: uploaded.downloadEncryptedQueryParam,
        aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
        encrypt_type: 1,
      },
      file_name: fileName,
      len: String(uploaded.fileSize),
    },
  };

  return sendMediaItems({ to, text, mediaItem: fileItem, opts, label: "sendFileMessageWeixin" });
}

数据流全景图

以下是改造后的完整数据流:

┌─────────────────────────────────────────────────────────────────────────────┐
│                           入站消息流程 (Inbound)                              │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌──────────────┐
  │ Weixin API   │  getupdates 返回消息 + context_token
  └──────┬───────┘
         │
         ▼
  ┌──────────────┐
  │  monitor.ts  │  解析消息,提取 context_token
  └──────┬───────┘
         │
         ▼
  ┌──────────────┐     ┌─────────────────┐
  │  inbound.ts  │────►│ 内存 Map 缓存    │
  │ setContextToken    └─────────────────┘
  └──────┬───────┘              │
         │                       │
         │              ┌────────▼────────┐
         │              │ 持久化存储      │
         └─────────────►│ (JSON 文件)     │
                        └─────────────────┘


┌─────────────────────────────────────────────────────────────────────────────┐
│                           出站消息流程 (Outbound)                             │
└─────────────────────────────────────────────────────────────────────────────┘

  ┌──────────────────┐
  │  发送请求来源     │
  │  - AI 自动回复    │
  │  - CLI 命令      │
  │  - 定时任务      │
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │   channel.tsgetContextToken(accountId, userId)
  │   sendText()     │
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐     命中?    ┌─────────────────┐
  │   inbound.ts     │─────────────►│ 返回内存缓存    │
  │  getContextToken │              └─────────────────┘
  └────────┬─────────┘
           │ 未命中
           ▼
  ┌──────────────────┐     存在?    ┌─────────────────┐
  │ context-token-   │─────────────►│ 加载 + 回填缓存 │
  │ store.ts         │              │ 返回 token      │
  │ loadPersisted... │              └─────────────────┘
  └────────┬─────────┘
           │ 不存在
           ▼
  ┌──────────────────┐
  │  抛出错误         │
  │ "contextToken    │
  │  is required"    │
  └──────────────────┘
           │
           ▼ (token 存在)
  ┌──────────────────┐
  │    send.ts       │  构建请求 + context_token
  │ sendMessageWeixin│
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │    api.ts        │  HTTP POST sendmessage
  │ sendMessageApi   │
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │   Weixin API     │  消息发送成功
  └──────────────────┘

使用场景示例

场景 1:AI 自动回复

当用户发送消息触发 AI 响应时,流程如下:

// 1. 用户发送消息
const inboundMsg = await getUpdates();  // 返回 { ..., context_token: "abc123" }

// 2. 存储 contextToken
setContextToken("account-1", "user@im.wechat", "abc123");
// 同时写入:
// - 内存: contextTokenStore.set("account-1:user@im.wechat", "abc123")
// - 磁盘: ~/.openclaw/.../account-1/user_im_wechat.json

// 3. AI 生成回复
const reply = await ai.generateResponse(inboundMsg.body);

// 4. 发送回复(自动获取 contextToken)
await sendText({
  to: "user@im.wechat",
  text: reply,
  accountId: "account-1",
  // getContextToken("account-1", "user@im.wechat") 自动返回 "abc123"
});

场景 2:网关重启后恢复

// 网关启动
async function startAccount(ctx) {
  // 预加载所有持久化的 token
  preloadContextTokens("account-1");
  // 从磁盘加载 ~/.openclaw/.../account-1/*.json
  // 恢复到内存缓存
  
  // 现在可以立即发送消息,即使没有收到新消息
  await sendText({
    to: "user@im.wechat",
    text: "网关已重启,服务恢复正常",
    accountId: "account-1",
    // getContextToken 会从内存缓存返回之前持久化的 token
  });
}

场景 3:CLI 命令发送消息

# 使用 openclaw agent 命令(在网关会话中)
openclaw agent --session-id <session-id> --message "Hello" --deliver

# 由于 contextToken 已持久化,即使 CLI 重新加载插件也能获取到 token

总结

通过引入持久化的 Context Token 存储机制,我们解决了微信插件在出站消息发送方面的核心限制:

改进点 改造前 改造后
存储位置 仅内存 内存 + 磁盘
网关重启 Token 丢失,无法发送 从磁盘恢复,立即可用
CLI 命令 无法获取 Token 从磁盘读取 Token
首次出站 必须等待入站消息 使用历史 Token 即可发送
可靠性

核心设计原则

  1. 双写策略:内存和磁盘同时更新,确保数据一致性
  2. 分层缓存:内存优先,磁盘兜底,兼顾速度和可靠性
  3. 预加载机制:启动时批量恢复,避免冷启动延迟
  4. 防御性编程:所有文件操作都有错误处理,单点故障不影响整体
  5. 安全第一:敏感数据设置严格的文件权限(0o600)

代码文件清单

文件路径 职责
src/storage/context-token-store.ts 持久化存储实现
src/messaging/inbound.ts 双层存储管理器
src/channel.ts 网关集成和出站发送
src/messaging/send.ts 消息发送实现

这套机制确保了 OpenClaw 微信插件在各种场景下都能稳定可靠地发送消息,为 AI 助手与微信用户的交互提供了坚实的基础。

每日一题-判断矩阵经轮转后是否一致🟢

2026年3月22日 00:00

给你两个大小为 n x n 的二进制矩阵 mattarget 。现 以 90 度顺时针轮转 矩阵 mat 中的元素 若干次 ,如果能够使 mat 与 target 一致,返回 true ;否则,返回 false

 

示例 1:

输入:mat = [[0,1],[1,0]], target = [[1,0],[0,1]]
输出:true
解释:顺时针轮转 90 度一次可以使 mat 和 target 一致。

示例 2:

输入:mat = [[0,1],[1,1]], target = [[1,0],[0,1]]
输出:false
解释:无法通过轮转矩阵中的元素使 equal 与 target 一致。

示例 3:

输入:mat = [[0,0,0],[0,1,0],[1,1,1]], target = [[1,1,1],[0,1,0],[0,0,0]]
输出:true
解释:顺时针轮转 90 度两次可以使 mat 和 target 一致。

 

提示:

  • n == mat.length == target.length
  • n == mat[i].length == target[i].length
  • 1 <= n <= 10
  • mat[i][j]target[i][j] 不是 0 就是 1

枚举四种情况进行比较即可

作者 qyaaaa
2021年6月6日 12:52
public boolean findRotation(int[][] mat, int[][] target) {
        int n = mat.length;
        boolean b1 = true,b2 = true,b3 = true,b4 = true;
        for(int i = 0;i < n;i++){
            for(int j = 0;j < n;j++){
                //旋转90度
                if(mat[n - j - 1][i] != target[i][j]){
                    b1 = false;
                }
                //旋转180度
                if (mat[n - i - 1][n - j - 1] != target[i][j]){
                    b2 = false;
                }
                //旋转270度
                if (mat[j][n- i- 1] != target[i][j]){
                    b3 = false;
                }
                //旋转360度
                if (mat[i][j] != target[i][j]){
                    b4 = false;
                }
            }
        }
        return b1 || b2 || b3 || b4;
    }

[Python] 判断顺时针转0度,90度,180度和270度是否一样

作者 himymBen
2021年6月6日 12:50

解题思路

该用户太懒了见代码

代码

###python3

class Solution:
    def findRotation(self, mat: List[List[int]], target: List[List[int]]) -> bool:
        def rotate(matrix):
            m,n = len(matrix),len(matrix[0])
            new = [[0] * m for _ in range(n)]
            for i in range(m):
                for j in range(n):
                    new[j][m-1-i] = matrix[i][j]
            return new
        
        if mat == target:
            return True
        
        for i in range(3):
            mat = rotate(mat)
            if mat == target:
                return True
        return False

两种方法:先旋转再比较 / 直接比较(Python/Java/C++/Go)

作者 endlesscheng
2021年6月6日 12:34

方法一:先旋转再比较

枚举 $\textit{mat}$ 旋转 $0,1,2,3$ 次,判断旋转后的 $\textit{mat}$ 是否等于 $\textit{target}$。

旋转方阵有原地算法,见 48. 旋转图像我的题解

class Solution:
    # 48. 旋转图像
    def rotate(self, matrix: List[List[int]]) -> None:
        n = len(matrix)
        for i, row in enumerate(matrix):
            for j in range(i + 1, n):  # 遍历对角线上方元素,做转置
                row[j], matrix[j][i] = matrix[j][i], row[j]
            row.reverse()  # 行翻转

    def findRotation(self, mat: List[List[int]], target: List[List[int]]) -> bool:
        for _ in range(4):
            if mat == target:
                return True
            self.rotate(mat)
        return False
class Solution {
    public boolean findRotation(int[][] mat, int[][] target) {
        for (int i = 0; i < 4; i++) {
            if (Arrays.deepEquals(mat, target)) {
                return true;
            }
            rotate(mat);
        }
        return false;
    }

    // 48. 旋转图像
    public void rotate(int[][] matrix) {
        int n = matrix.length;
        for (int i = 0; i < n; i++) {
            int[] row = matrix[i];
            for (int j = i + 1; j < n; j++) { // 遍历对角线上方元素,做转置
                int tmp = row[j];
                row[j] = matrix[j][i];
                matrix[j][i] = tmp;
            }
            for (int j = 0; j < n / 2; j++) { // 遍历左半元素,做行翻转
                int tmp = row[j];
                row[j] = row[n - 1 - j];
                row[n - 1 - j] = tmp;
            }
        }
    }
}
class Solution {
    // 48. 旋转图像
    void rotate(vector<vector<int>>& matrix) {
        int n = matrix.size();
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) { // 遍历对角线上方元素,做转置
                swap(matrix[i][j], matrix[j][i]);
            }
            ranges::reverse(matrix[i]); // 行翻转
        }
    }

public:
    bool findRotation(vector<vector<int>>& mat, vector<vector<int>>& target) {
        for (int i = 0; i < 4; i++) {
            if (mat == target) {
                return true;
            }
            rotate(mat);
        }
        return false;
    }
};
// 48. 旋转图像
func rotate(matrix [][]int) {
n := len(matrix)
for i, row := range matrix {
for j := i + 1; j < n; j++ { // 遍历对角线上方元素,做转置
row[j], matrix[j][i] = matrix[j][i], row[j]
}
slices.Reverse(row) // 行翻转
}
}

func findRotation(mat, target [][]int) bool {
for range 4 {
if slices.EqualFunc(mat, target, slices.Equal[[]int]) {
return true
}
rotate(mat)
}
return false
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$,其中 $n$ 是 $\textit{mat}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(1)$。

方法二:直接比较

顺时针旋转 $90^\circ$ 后,位于 $(i,j)$ 的元素去哪了?

根据 48 题 我的题解,结论如下:

$$
(i,j)\xrightarrow{旋转\ 90^\circ} (j,n-1-i) \xrightarrow{旋转\ 90^\circ} (n-1-i,n-1-j) \xrightarrow{旋转\ 90^\circ} (n-1-j,i)
$$

所以对于 $\textit{mat}[i][j]$,它需要比较四个位置上的值:

  • 旋转 $0$ 次:比较 $\textit{target}[i][j]$。
  • 旋转 $1$ 次:比较 $\textit{target}[j][n-1-i]$。
  • 旋转 $2$ 次:比较 $\textit{target}[n-1-i][n-1-j]$。
  • 旋转 $3$ 次:比较 $\textit{target}[n-1-j][i]$。

如果对于某个旋转次数,所有的比较都为真,那么返回 $\texttt{true}$。否则返回 $\texttt{false}$。

class Solution:
    def findRotation(self, mat: List[List[int]], target: List[List[int]]) -> bool:
        ok = (1 << 4) - 1  # ok = [True] * 4
        for i, row in enumerate(mat):
            for j, x in enumerate(row):
                if x != target[i][j]:
                    ok &= ~(1 << 0)  # ok[0] = False
                if x != target[j][-1 - i]:
                    ok &= ~(1 << 1)  # ok[1] = False
                if x != target[-1 - i][-1 - j]:
                    ok &= ~(1 << 2)  # ok[2] = False
                if x != target[-1 - j][i]:
                    ok &= ~(1 << 3)  # ok[3] = False
                if ok == 0:  # 所有的 ok[i] 都是 False
                    return False
        return True  # 至少有一个 ok[i] 是 True
class Solution {
    public boolean findRotation(int[][] mat, int[][] target) {
        int n = mat.length;
        int ok = (1 << 4) - 1; // boolean[] ok = {true, true, true, true};
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                int x = mat[i][j];
                if (x != target[i][j]) {
                    ok &= ~(1 << 0); // ok[0] = false
                }
                if (x != target[j][n - 1 - i]) {
                    ok &= ~(1 << 1); // ok[1] = false
                }
                if (x != target[n - 1 - i][n - 1 - j]) {
                    ok &= ~(1 << 2); // ok[2] = false
                }
                if (x != target[n - 1 - j][i]) {
                    ok &= ~(1 << 3); // ok[3] = false
                }
                if (ok == 0) { // 所有的 ok[i] 都是 false
                    return false;
                }
            }
        }
        return true; // 至少有一个 ok[i] 是 true
    }
}
class Solution {
public:
    bool findRotation(vector<vector<int>>& mat, vector<vector<int>>& target) {
        int n = mat.size();
        int ok = (1 << 4) - 1; // bool ok[4] = {true, true, true, true}
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                int x = mat[i][j];
                if (x != target[i][j]) {
                    ok &= ~(1 << 0); // ok[0] = false
                }
                if (x != target[j][n - 1 - i]) {
                    ok &= ~(1 << 1); // ok[1] = false
                }
                if (x != target[n - 1 - i][n - 1 - j]) {
                    ok &= ~(1 << 2); // ok[2] = false
                }
                if (x != target[n - 1 - j][i]) {
                    ok &= ~(1 << 3); // ok[3] = false
                }
                if (ok == 0) { // 所有的 ok[i] 都是 false
                    return false;
                }
            }
        }
        return true; // 至少有一个 ok[i] 是 true
    }
};
func findRotation(mat, target [][]int) bool {
n := len(mat)
ok := 1<<4 - 1 // ok := [4]bool{true, true, true, true}
for i, row := range mat {
for j, x := range row {
if x != target[i][j] {
ok &^= 1 << 0 // ok[0] = false
}
if x != target[j][n-1-i] {
ok &^= 1 << 1 // ok[1] = false
}
if x != target[n-1-i][n-1-j] {
ok &^= 1 << 2 // ok[2] = false
}
if x != target[n-1-j][i] {
ok &^= 1 << 3 // ok[3] = false
}
if ok == 0 { // 所有的 ok[i] 都是 false
return false
}
}
}
return true // 至少有一个 ok[i] 是 true
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$,其中 $n$ 是 $\textit{mat}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

OpenSpec:AI 写代码,先立规矩再动手

作者 JacksonChen
2026年3月22日 15:28

做开发的朋友应该都有这体验:用AI写代码初期贼快,可需求藏在聊天记录里,改需求、加功能时AI写的代码要么跑偏,要么和现有代码揉成一团,最后越改越乱。而OpenSpec就是来解决这个问题的——它是轻量级的规范驱动开发工具,让人和AI先敲定“要做什么”,再动手写代码。

核心就两点:openspec/specs/ 存当前系统的规范真相,openspec/changes/ 放待开发的变更提案,所有修改先提提案、定规范,再落地,全程可追溯、可评审。

核心工作流:四步走,简单不绕弯

不用记复杂步骤,核心就四步,全程AI能帮你生成大部分文件,不用手动敲:

  1. 写提案:告诉AI要加什么功能,AI自动生成变更文件夹,包含提案、任务清单、规范变更;
  2. 评审对齐:核对规范,和AI反复打磨,直到需求完全明确;
  3. 落地开发:AI按敲定的规范写代码,逐项完成任务;
  4. 归档更新:功能上线后,把提案归档,规范自动合并到主目录,更新系统真相。

实操例子:加个登录二次验证,5分钟定好规范

不用讲虚的,以给项目加登录OTP二次验证为例,看OpenSpec怎么用,全程用AI+几条命令搞定:

1. 初始化(项目首次用,一步到位)

# 全局安装
npm install -g @fission-ai/openspec@latest
# 进入项目初始化
cd 你的项目
openspec init

选上你常用的AI工具(比如Cursor、vscode),工具会自动配置好快捷命令,项目根目录会多出openspec/文件夹。

2. 让AI生成变更提案

对着Cursor/Claude敲命令(不同工具命令稍不同,核心一致):

/openspec:proposal 给登录加OTP二次验证

AI会自动在openspec/changes/下生成add-2fa/文件夹,里面包含:

  • proposal.md:说明为什么加、要实现什么效果;
  • tasks.md:拆分好的开发任务(建表、写后端接口、改前端UI);
  • specs/auth/spec.md:规范变更(只写新增/修改的内容,不是全量重写)。

如果想加验收条件,直接跟AI说:“给OTP验证加个场景:输错3次锁定5分钟”,AI会自动更新规范和任务。

3. 让AI按规范写代码

敲命令启动开发,AI会按tasks.md逐项完成,还会标记进度:

/openspec:apply add-2fa

4. 上线后归档,更新系统规范

openspec archive add-2fa --yes

此时add-2fa的规范会合并到openspec/specs/auth/spec.md,后续谁看代码,都能从规范里清楚知道“为什么这么写”。

适合的场景:这些情况用,效率拉满

  • 已有项目的功能迭代(1→n),尤其是跨模块修改
  • 团队协作开发,需统一需求、追溯变更
  • 团队混用多款 AI 编码工具,需统一输出标准
  • 长期维护项目,需要可同步更新的 “活文档”

不适合的场景:别硬用,避免白忙活

  • 0→1 的小 demo / 临时项目,需求简单
  • 单人极短期开发,需求全在本地无需追溯
  • 需求频繁无固定方向的快速迭代
  • 小改动,要不然时间都花在生成文档上面了

总结

OpenSpec不是为了增加开发步骤,而是把“需求模糊”的坑提前填上——让AI和人在编码前达成共识,避免后续返工。它最适合做长期维护、团队协作的已有项目,能让AI编码的优势保留,同时解决“AI写的代码没章法、难维护”的问题。

如果你的项目正被AI编码的“不可控”困扰,不妨试试OpenSpec,先从一个小功能迭代开始,体验下“规范先行”的开发方式。

JavaScript动态导入与代码分割:优化应用加载性能的终极方案

作者 bluceli
2026年3月22日 15:12

在现代前端开发中,随着应用规模的不断增长,打包后的文件体积也越来越大。用户首次访问页面时需要下载大量的JavaScript代码,导致加载时间过长,严重影响用户体验。JavaScript动态导入(Dynamic Import)与代码分割(Code Splitting)技术应运而生,成为优化应用加载性能的终极方案。

什么是动态导入

动态导入是ES2020引入的特性,它允许我们在运行时按需加载JavaScript模块。与传统的静态导入不同,动态导入返回一个Promise,这使得我们可以在需要的时候才加载模块,而不是在应用启动时就加载所有代码。

// 静态导入 - 应用启动时立即加载
import { heavyFunction } from './heavyModule';

// 动态导入 - 按需加载
button.addEventListener('click', async () => {
  const { heavyFunction } = await import('./heavyModule');
  heavyFunction();
});

代码分割的原理

代码分割是将应用代码拆分成多个小块(chunks),然后按需加载这些块的技术。现代打包工具如Webpack、Vite等都支持代码分割,它们会分析代码中的动态导入语句,自动将对应的模块打包成独立的文件。

// 路由级别的代码分割
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
      </Routes>
    </Suspense>
  );
}

实际应用场景

1. 路由懒加载

这是最常见的代码分割场景,将不同路由对应的组件拆分成独立的代码块,用户访问哪个路由就加载对应的代码。

// React Router v6 示例
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Profile = lazy(() => import('./Profile'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </Suspense>
  );
}

2. 组件懒加载

对于一些不常用的组件,如模态框、复杂表单等,可以采用懒加载策略。

import { lazy, useState } from 'react';

const HeavyModal = lazy(() => import('./HeavyModal'));

function App() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <button onClick={() => setShowModal(true)}>
        打开复杂模态框
      </button>
      {showModal && (
        <Suspense fallback={<div>加载中...</div>}>
          <HeavyModal onClose={() => setShowModal(false)} />
        </Suspense>
      )}
    </div>
  );
}

3. 功能模块按需加载

对于一些功能性的模块,如富文本编辑器、图表库等,可以在用户真正需要使用时才加载。

class RichTextEditor {
  async initialize() {
    if (!this.editor) {
      // 动态加载富文本编辑器
      const { default: Editor } = await import('quill');
      this.editor = new Editor(this.container, {
        theme: 'snow'
      });
 });
  }
}

性能优化技巧

1. 预加载策略

虽然动态导入可以减少初始加载体积,但用户点击时才加载会导致延迟。我们可以使用预加载策略来平衡性能和用户体验。

// 鼠标悬停时预加载
const LazyComponent = lazy(() => import('./LazyComponent'));

function App() {
  const [shouldLoad, setShouldLoad] = useState(false);

  return (
    <div>
      <button 
        onMouseEnter={() => setShouldLoad(true)}
        onClick={() => setShouldLoad(true)}
      >
        加载组件
      </button>
      {shouldLoad && (
        <Suspense fallback={<div>加载中...</div>}>
          <LazyComponent />
        </Suspense>
      )}
    </div>
  );
}

2. 错误边界处理

动态导入可能会失败,我们需要添加错误处理机制。

import { lazy, Suspense } from 'react';

const LazyComponent = lazy(() => 
  import('./LazyComponent').catch(() => ({
    default: () => <div>组件加载失败</div>
  }))
);

function App() {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

3. 优先级控制

对于重要的代码块,可以使用webpack的魔法字符串来控制加载优先级。

// 高优先级预加载
import(/* webpackPrefetch: true */ './importantModule');

// 低优先级预加载
import(/* webpackPreload: true */ './lowPriorityModule');

监控与分析

为了确保代码分割策略的有效性,我们需要监控代码块的加载情况。

// 监控动态导入性能
async function trackDynamicImport(modulePath) {
  const startTime = performance.now();
  
  try {
    const module = await import(modulePath);
    const loadTime = performance.now() - startTime;
    
    // 发送监控数据
    analytics.track('dynamic_import_loaded', {
      module: modulePath,
      loadTime,
      size: module.__webpack_chunk_size__
    });
    
    return module;
  } catch (error) {
    analytics.track('dynamic_import_failed', {
      module: modulePath,
      error: error.message
    });
    throw error;
  }
}

最佳实践总结

  1. 合理拆分:不要过度拆分,过多的HTTP请求反而会影响性能
  2. 预加载策略:根据用户行为预测,提前加载可能需要的代码
  3. 错误处理:为动态导入添加完善的错误处理机制
  4. 性能监控:持续监控代码块的加载性能,优化分割策略
  5. 用户体验:使用加载指示器和骨架屏提升用户体验

动态导入与代码分割是现代前端性能优化的重要手段,通过合理运用这些技术,我们可以显著提升应用的加载速度和用户体验。在实际项目中,需要根据具体场景选择合适的策略,并通过持续监控和优化来达到最佳效果。

Threejs实现 3D 看房效果

2026年3月22日 15:11

要实现一个 3D 看房效果,可以使用 Three.js 创建一个虚拟的 3D 场景,并通过加载全景图片或 3D 模型来模拟房间的外观。以下是一个完整的实现方案,结合代码逐步讲解如何实现这一功能。


实现步骤

  1. 创建基础场景和相机
    使用 Three.js 创建一个基础的 3D 场景,并设置透视相机。
  2. 加载全景图片
    使用立方体贴图(CubeTexture)加载六张全景图片(上下左右前后),形成一个球形环境。
  3. 添加交互控制
    使用 OrbitControls 或类似库,允许用户通过鼠标拖动、缩放等方式查看房间。
  4. 优化渲染性能
    确保场景的渲染性能良好,并适配不同分辨率的设备。

示例代码

以下是实现 3D 看房效果的完整代码示例:

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

// Three.js 相关变量声明
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let renderer: THREE.WebGLRenderer
let controls: OrbitControls
const canvas = ref<HTMLCanvasElement>()

onMounted(() => {
  initScene()
  animate()
})

function initScene() {
  if (!canvas.value) return

  // 获取画布尺寸
  const width = canvas.value.clientWidth
  const height = canvas.value.clientHeight

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

  // 创建透视相机
  camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
  camera.position.set(0, 0, 0)

  // 创建 WebGL 渲染器
  renderer = new THREE.WebGLRenderer({ canvas: canvas.value, antialias: true })
  renderer.setSize(width, height)
  renderer.setPixelRatio(window.devicePixelRatio)

  // 加载全景图片
  const loader = new THREE.CubeTextureLoader()
  const texture = loader.load([
    '/src/assets/img/right.jpg', // 右
    '/src/assets/img/left.jpg',  // 左
    '/src/assets/img/top.jpg',   // 上
    '/src/assets/img/bottom.jpg',// 下
    '/src/assets/img/front.jpg', // 前
    '/src/assets/img/back.jpg',  // 后
  ])
  scene.background = texture

  // 添加轨道控制器
  controls = new OrbitControls(camera, renderer.domElement)
  controls.enableDamping = true
  controls.dampingFactor = 0.05
  controls.minDistance = 1
  controls.maxDistance = 10
  controls.enablePan = false // 禁用平移
}

function animate() {
  requestAnimationFrame(animate)
  controls.update() // 更新控制器
  renderer.render(scene, camera)
}
</script>

<template>
  <div>
    <canvas ref="canvas" class="canvas"></canvas>

  </div>

</template>

<style scoped>
.canvas {
  display: block;
  width: 100%;
  height: 100vh; /* 占满整个视口高度 */
}
</style>


代码说明

1. 创建基础场景

  • 使用 THREE.Scene 创建一个空的 3D 场景。
  • 设置透视相机 THREE.PerspectiveCamera,模拟人眼的视角。
  • 使用 THREE.WebGLRenderer 渲染场景,并将其绑定到 HTML 的 <canvas> 元素上。

2. 加载全景图片

  • 使用 THREE.CubeTextureLoader 加载六张图片(上下左右前后),这些图片共同构成了一个立方体贴图。
  • 将加载的贴图设置为场景的背景 (scene.background),从而形成一个球形的全景环境。

3. 添加交互控制

  • 使用 OrbitControls 提供交互功能,允许用户通过鼠标拖动旋转视角,或通过滚轮缩放。
  • 配置控制器参数:
    • enableDamping: 启用阻尼效果,使旋转更平滑。
    • minDistancemaxDistance: 限制用户与场景的距离。
    • enablePan: 禁用平移,避免用户移动到房间外部。

4. 动画循环

  • 使用 requestAnimationFrame 创建一个动画循环,持续更新控制器并重新渲染场景。

5.几何体的缩放操作

cube1.geometry.scale(10, 10, -10)
  • cube1.geometry
    这是 cube1 的几何体对象。geometry 是 Three.js 中用于定义物体形状的属性。
  • .scale(x, y, z)
    scale 方法用于对几何体进行缩放。它接受三个参数:
    • x: 在 X 轴方向上的缩放比例。
    • y: 在 Y 轴方向上的缩放比例。
    • z: 在 Z 轴方向上的缩放比例。

在这段代码中:
- X 轴方向放大 10 倍。
- Y 轴方向放大 10 倍。
- Z 轴方向缩小 10 倍(负值表示反向缩放)。

作用

  1. 调整几何体大小
    通过缩放几何体,可以动态改变物体的尺寸。这对创建不同大小的物体或响应用户交互非常有用。
  2. 反向缩放
    Z 轴方向使用了负值(-10),这不仅会缩放几何体,还会反转 Z 轴的方向。这种操作通常用于镜像效果或调整模型的方向。

注意事项

  1. 全景图片要求
    • 六张图片需要是无缝拼接的,确保在立方体的每个面之间没有明显的接缝。
    • 图片分辨率应足够高,以保证清晰度,但也不能过大,以免影响加载速度。
  2. 性能优化
    • 在移动端设备上,降低渲染分辨率或减少贴图质量以提高性能。
    • 使用 renderer.setPixelRatio(window.devicePixelRatio) 确保在高分辨率屏幕上显示清晰。
  3. 支持更多功能
    • 热点区域:可以在房间中添加可点击的热点区域,用于展示更多信息或切换视角。
    • 动态光照:如果需要更真实的场景,可以添加光源和阴影效果。
  4. 直接修改几何体
    geometry.scale() 直接修改了几何体的数据,因此会影响所有使用该几何体的物体。如果需要单独缩放某个物体,建议使用 object.scale.set(),而不是直接修改几何体。
  5. 性能影响
    修改几何体可能会影响渲染性能,尤其是在复杂场景中。如果频繁调整几何体,可能会导致性能下降。
  6. 负值缩放的影响
    使用负值缩放可能会导致法线方向反转,从而影响光照和阴影效果。如果发现光照异常,可以通过重新计算法线来修复:

❌
❌