阅读视图

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

OpenClaw 深度技术解析

OpenClaw 深度技术解析:如何用插件化网关架构统一 30+ 消息渠道的 AI 助手

一个本地优先、隐私掌控、模型无关的个人 AI 助理平台——从架构哲学到实现细节的全面剖析


引言:AI 助手的"孤岛困境"

2026 年的今天,AI 助手已经无处不在。但一个矛盾越来越突出:AI 越来越强大,却被困在越来越多的"孤岛"上

你的 ChatGPT 只能在 OpenAI 的界面使用;你的 Claude 只能在 Anthropic 的网页里对话;而你日常沟通的战场——WhatsApp、Telegram、Slack、Discord、飞书、微信——这些才是你真正的工作流所在,AI 却无法融入。

想象一个场景:你在 Telegram 上和朋友聊到一个技术问题,想让 AI 帮忙分析一下?切换到另一个 App。你在 Slack 的工作群里收到一个紧急需求,想让 AI 起草回复?再切换到另一个 App。你在 Discord 的技术社区看到一个有趣的讨论,想让 AI 总结一下?又切换了一次。

OpenClaw 正是为解决这个问题而生的开源项目。它的愿景很简单也很大胆:一个 AI 助手,存在于你使用的所有消息平台上,数据完全留在你自己的设备上

本文将从架构哲学、核心机制到工程实践,全面剖析 OpenClaw 如何实现这一愿景。


一、项目全景:定位与设计哲学

1.1 三个核心设计原则

OpenClaw 的设计围绕三个坚定的原则展开:

Local-First(本地优先):Gateway 默认绑定 127.0.0.1,所有会话数据、配置、Agent 状态全部存储在本地 ~/.openclaw/ 目录下。没有云服务器,没有数据上传,用户对自己的 AI 助手拥有完全的掌控权。

Single-User(单用户助理):这不是一个多租户 SaaS 平台,而是一个专属于你的个人 AI 助理。这种定位极大地简化了架构——不需要用户管理、权限隔离、计费系统,可以把所有工程资源聚焦在做好"一个人的 AI 助手"这件事上。

Model-Agnostic(模型无关):不绑定任何特定的 LLM 提供商。Anthropic Claude、OpenAI GPT、Google Gemini、AWS Bedrock 甚至本地模型都可以作为推理引擎,且支持自动故障转移。

1.2 技术栈概览

分类 选型 版本
运行时 Node.js (ESM) ≥22.12.0
主语言 TypeScript ^5.9.3
包管理 pnpm (Monorepo) 10.23.0
HTTP 框架 Hono + Express 4.11.9 / ^5.2.1
WebSocket ws ^8.19.0
Schema 验证 Zod + TypeBox + Ajv ^4.3.6 / 0.34.48 / ^8.17.1
构建工具 tsdown ^0.20.3
测试框架 Vitest (V8 覆盖率) ^4.0.18
代码规范 Oxlint + Oxfmt ^1.43.0 / 0.28.0
Web UI Lit (Web Components) ^3.3.2
Agent SDK @mariozechner/pi-coding-agent 0.52.9
向量数据库 sqlite-vec 0.1.7-alpha.2

值得一提的是项目采用日历版本号(CalVer)2026.2.6-3,格式为 YYYY.M.D-patch,让用户一眼就能判断版本的时效性。


二、整体架构:网关即控制面

2.1 "轴辐式"架构

OpenClaw 的核心架构思想可以用一句话概括:Gateway as Control Plane(网关即控制面)

这是一个经典的 Hub-and-Spoke(轴辐式) 设计。Gateway 作为中心枢纽,所有消息渠道、AI Agent、客户端应用都通过 WebSocket 连接到它:

                        客户端层
    ┌──────────────────────────────────────────────┐
    │  macOS App │ iOS Node │ Android │ CLI │ Web  │
    └───────────────────────┬──────────────────────┘
                            │ WebSocket
    ┌───────────────────────▼──────────────────────┐
    │             Gateway 控制面 (Core)              │
    │  ┌──────────┬────────────┬─────────────────┐  │
    │  │ WebSocket│ HTTP Server│ Plugin Registry  │  │
    │  │ Server   │ (Hono)     │ (渠道/工具/钩子) │  │
    │  └────┬─────┴─────┬──────┴────────┬────────┘  │
    │  ┌────▼───────────▼───────────────▼────────┐  │
    │  │         Gateway Runtime State            │  │
    │  │ Session │ Config │ Health │ Cron │ Nodes │  │
    │  └─────────────────────────────────────────┘  │
    └───────────────────────┬──────────────────────┘
                            │
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
    ┌────────┐      ┌──────────────┐      ┌──────────┐
    │ Channel│      │   Pi Agent   │      │   LLM    │
    │ Plugins│      │  嵌入式运行器  │      │ 提供商    │
    │ (30+)  │      │              │      │ 故障转移  │
    └───┬────┘      └──────────────┘      └──────────┘
        ▼
    ┌──────────────────────────────────────────────┐
    │ WhatsApp │ Telegram │ Slack │ Discord │ 30+  │
    └──────────────────────────────────────────────┘

2.2 为什么选择中心网关?

在分布式系统盛行的今天,选择中心化的 Gateway 看似"不够先进"。但对于个人 AI 助手这个场景,这是一个极其务实的决策:

单一状态源(Single Source of Truth):所有会话状态集中在 Gateway 管理,彻底避免了分布式一致性问题。当你在 Telegram 发了一条消息,然后切换到 Slack 继续聊,Gateway 能保证你的上下文是连贯的。

协议统一:无论消息来自 WhatsApp 的 Baileys SDK 还是 Discord 的 Carbon API,一旦进入 Gateway,就统一使用内部 JSON-RPC 协议处理。AI Agent 完全不需要知道消息来自哪个渠道。

部署简化:每台主机只运行一个 Gateway 实例,拥有所有资源的独占控制权。没有服务发现、没有负载均衡、没有容器编排——openclaw start 就能启动一切。

2.3 六层分层架构

从宏观视角看,整个系统可以划分为六个清晰的层次:

层级 职责 关键组件
接入层 与各消息平台对接 Baileys、grammY、@slack/bolt、Carbon 等
网关层 消息路由、会话管理、事件分发 WebSocket RPC、HTTP API、Hook 系统
路由层 决定"谁来处理这条消息" 多级路由优先级、身份链接、广播组
Agent 层 AI 推理、工具调用、技能执行 Pi Agent 运行时、沙箱、Canvas
回复层 响应格式化、流式输出、分块策略 ReplyDispatcher、打字指示器
基础设施层 配置、存储、日志、安全 JSON5 + Zod、SQLite + sqlite-vec

三、插件化一切:OpenClaw 的架构基石

3.1 Plugin Registry:全局注册表

如果说 Gateway 是 OpenClaw 的心脏,那 Plugin Registry 就是它的血管系统。几乎所有的扩展能力——渠道、工具、钩子、HTTP 路由、CLI 命令、AI 提供商——都通过统一的插件注册表管理:

export type PluginRegistry = {
  plugins: PluginRecord[];              // 插件元信息
  tools: PluginToolRegistration[];      // Agent 工具
  hooks: PluginHookRegistration[];      // 事件钩子
  channels: PluginChannelRegistration[];// 通道插件
  providers: PluginProviderRegistration[]; // AI 提供商
  gatewayHandlers: GatewayRequestHandlers; // Gateway RPC 方法
  httpHandlers: PluginHttpRegistration[];  // HTTP 处理器
  httpRoutes: PluginHttpRouteRegistration[];// HTTP 路由
  cliRegistrars: PluginCliRegistration[];  // CLI 命令
  services: PluginServiceRegistration[];   // 后台服务
  commands: PluginCommandRegistration[];   // 命令定义
  diagnostics: PluginDiagnostic[];         // 诊断信息
};

这个设计有三个精妙之处:

全局唯一性保证:使用 Symbol.for("openclaw.pluginRegistryState") 实现全局单例。即使在 ESM 模块被多次加载的场景下(这在 Node.js monorepo 中很常见),也能保证 Registry 的唯一性。

冲突检测:Gateway 方法和 HTTP 路由的注册会自动检测重复,避免两个插件注册同名方法导致的隐性 bug。

隔离的 Plugin API:每个插件通过 createApi() 工厂方法获得独立的 API 接口,防止插件之间互相干扰。

3.2 三种插件类型

OpenClaw 将插件分为三种类型,各有不同的加载机制:

内置插件(Bundled):直接编译进核心的 7 个消息渠道(Telegram、WhatsApp、Discord、Slack、Signal、iMessage、WebChat),以及核心工具和钩子。

扩展插件(Extensions):位于 extensions/ 目录下的 30+ 个独立 npm 包,每个都有标准结构:

extensions/<plugin-name>/
├── package.json              # 依赖和元数据
├── openclaw.plugin.json      # 插件清单 (注册声明)
├── index.ts                  # 入口 (导出注册函数)
├── src/                      # 源码
└── README.md

工作区插件(Workspace):用户自定义的插件,放在 ~/.openclaw/plugins/ 目录下,支持热加载。

3.3 Plugin API:插件的"全能工具箱"

每个插件在注册时会获得一个 OpenClawPluginApi 实例,它几乎可以做任何事情:

export type OpenClawPluginApi = {
  id: string;
  name: string;
  runtime: PluginRuntime;
  logger: PluginLogger;
  
  // 注册能力
  registerTool(tool, opts?): void;             // 注册 Agent 工具
  registerHook(events, handler, opts?): void;  // 注册事件钩子
  registerHttpHandler(handler): void;          // 注册 HTTP 处理器
  registerHttpRoute(params): void;             // 注册 HTTP 路由
  registerChannel(registration): void;         // 注册消息渠道
  registerProvider(provider): void;            // 注册 AI 提供商
  registerGatewayMethod(method, handler): void;// 注册 Gateway RPC 方法
  registerCli(registrar, opts?): void;         // 注册 CLI 命令
  registerService(service): void;              // 注册后台服务
  registerCommand(command): void;              // 注册命令
  on(hookName, handler, opts?): void;          // 类型安全的事件监听
};

这种"注册一切"的设计意味着,一个插件可以同时:注册一个新的消息渠道、为 Agent 添加几个专属工具、暴露一个 HTTP webhook、注册两个 CLI 命令、挂载一个定时任务。插件的能力边界只取决于它的注册行为,而非其类型标签。


四、渠道系统:统一 30+ 消息平台的工程挑战

4.1 ChannelPlugin:多适配器组合契约

统一 30+ 消息平台是 OpenClaw 最具工程挑战的部分。每个平台都有截然不同的认证方式(Bot Token / OAuth / QR 码扫描)、消息格式(Markdown / HTML / 富文本)、能力差异(按钮 / 嵌入 / 纯文字)和速率限制。

OpenClaw 的解法是一个精心设计的多适配器组合契约

export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknown> = {
  id: ChannelId;                              // 渠道唯一标识
  meta: ChannelMeta;                          // 元信息
  capabilities: ChannelCapabilities;          // 能力声明
  config: ChannelConfigAdapter<ResolvedAccount>;  // 配置
  setup?: ChannelSetupAdapter;                // 安装向导
  pairing?: ChannelPairingAdapter;            // 设备配对
  security?: ChannelSecurityAdapter;          // 安全策略
  groups?: ChannelGroupAdapter;               // 群组管理
  outbound?: ChannelOutboundAdapter;          // 出站消息
  gateway?: ChannelGatewayAdapter;            // Gateway 方法
  streaming?: ChannelStreamingAdapter;        // 流式输出
  threading?: ChannelThreadingAdapter;        // 消息线程
  messaging?: ChannelMessagingAdapter;        // 消息收发
  heartbeat?: ChannelHeartbeatAdapter;        // 心跳检测
  agentTools?: ChannelAgentToolFactory;       // 渠道专属工具
  // ... 更多适配器
};

这个设计的核心亮点是可选适配器(Optional Adapters)。所有适配器字段均标记为 ?,渠道只需实现其支持的功能。例如,SMS 渠道不需要 streamingthreading;Telegram 不需要 pairing;而 iMessage 可能需要特殊的 setup 流程。

通过泛型 <ResolvedAccount, Probe, Audit>,不同渠道的账号体系、健康探测、审计日志都能获得类型安全的支持。

4.2 能力声明:让系统知道渠道能做什么

每个渠道通过 capabilities 字段声明自己的能力。这让 Gateway 可以智能地适配行为——如果渠道不支持 Markdown,就自动转换为纯文本;如果渠道有消息长度限制,就自动分块发送;如果渠道支持按钮,就可以渲染交互式操作。

4.3 渠道全景

按类别看,OpenClaw 目前支持的渠道涵盖了几乎所有主流消息生态:

类别 渠道 实现方式
主流即时通讯 WhatsApp、Telegram、Signal、iMessage Baileys、grammY、signal-cli、AppleScript
协作平台 Slack、Discord、Microsoft Teams、Google Chat @slack/bolt、@buape/carbon、Graph API
亚太平台 飞书/Lark、LINE、Zalo 飞书 API、LINE SDK、Zalo API
去中心化协议 Matrix、Nostr matrix-js-sdk、nostr-tools
自托管方案 Mattermost、Nextcloud Talk REST API
直播/社交 Twitch、BlueBubbles Twitch IRC、BlueBubbles API

7 个内置 + 30+ 个扩展,OpenClaw 真正实现了"一个 AI 助手,到处可用"。


五、消息处理全链路:从入站到回复

5.1 完整消息处理流水线

当一条消息从任意渠道进入系统时,它会经历七个精心编排的处理阶段:

① Channel Monitor     接收外部消息,格式归一化
        ↓
② Routing Engine      路由解析 → 决定哪个 Agent 处理
        ↓
③ Session Manager     构建会话键 → 加载或创建会话
        ↓
④ Gate Checks         门控检查:命令拦截、提及检测、防抖、去重
        ↓
⑤ Agent Runtime       AI 推理 + 工具调用 + 技能执行
        ↓
⑥ Reply Dispatcher    回复分发:分块、格式化、打字指示器
        ↓
⑦ Channel Outbound    渠道适配,调用平台 API 发送

这条流水线的关键设计原则是渠道无关性:从第②步到第⑥步,系统完全不关心消息来自哪个渠道,也不关心回复要发到哪里。这种解耦意味着新增渠道只需实现入站和出站适配器,核心逻辑一行不改。

5.2 路由引擎:七级优先级匹配

路由引擎是网关的"交通枢纽",决定每条入站消息由哪个 Agent 处理。它采用从精确到模糊的七级优先级匹配:

优先级从高到低:
1. binding.peer         → 精确匹配(peer.kind + peer.id)
2. binding.peer.parent  → 父级匹配(适用于线程消息)
3. binding.guild        → Discord Guild 匹配
4. binding.team         → Slack Team 匹配
5. binding.account      → 账号级匹配
6. binding.channel      → 渠道级匹配(任意账号)
7. default              → 默认 Agent(配置或 "main")

这种多级路由让用户可以精细地控制消息分配:特定的 Telegram 联系人走"编程助手"Agent,Slack 工作群走"工作助理"Agent,其余全部走默认的通用 Agent。

5.3 会话键:精细化隔离的秘密

会话管理采用复合键设计,格式为 agent:<agentId>:<channel>:<type>:<id>

agent:main:telegram:direct:123456           → Telegram 私聊
agent:main:discord:group:789012             → Discord 群组
agent:main:slack:group:C001:thread:T002     → Slack 线程

更精妙的是 DM 作用域(dmScope) 配置:

模式 效果
main 所有 DM 共享同一个会话
per-peer 按对方 ID 隔离
per-channel-peer 按渠道 + 对方 ID 隔离
per-account-channel-peer 按账号 + 渠道 + 对方 ID 隔离

配合**身份链接(Identity Links)**机制,同一个人在不同平台上的身份可以关联起来,实现跨渠道的会话上下文共享。

5.4 门控系统:四重过滤

在路由和会话解析之后、Agent 执行之前,消息还需要通过四道"门控":

命令拦截(Command Gate):检测 /reset/help 等控制命令,直接处理而不进入 Agent。

提及检测(Mention Gate):在群组场景中,只有明确提及(@mention)AI 的消息才会触发处理。

防抖(Debouncer):用户快速连续发送多条消息时,合并为一次处理。

去重(Deduplication):基于 idempotencyKey 防止同一消息被重复处理——这在 Webhook 场景中尤为重要。


六、Agent 运行时:嵌入式设计的精妙

6.1 为什么选择嵌入式?

大多数 AI 助手框架采用子进程方式运行 Agent——主进程通过 stdin/stdout 或 HTTP 与 Agent 进程通信。OpenClaw 做了一个不同的选择:将 Pi Coding Agent SDK 以库的形式直接嵌入运行

import { createAgentSession, SessionManager, SettingsManager } 
  from "@mariozechner/pi-coding-agent";

这带来了三个关键优势:

  1. 更低延迟:无需跨进程 IPC,工具调用和事件流可以在毫秒级完成
  2. 更简单的生命周期:不需要管理子进程的启动、崩溃恢复和资源回收
  3. 更灵活的集成:可以直接访问 Gateway 的内存状态,如会话、配置、渠道信息

6.2 执行流程

Agent 的一次完整执行经历以下步骤:

Gateway RPC: agent 请求
  → 参数校验 (validateAgentParams)
    → 幂等性检查 (idempotencyKey 去重)
      → 会话解析 (获取 sessionKey 和 sessionId)
        → 队列串行化 (同一会话内排队)
          → 运行 Pi Agent (runEmbeddedPiAgent)
            → 流式事件订阅 (subscribeEmbeddedPiSession)
              → Gateway 广播 (向所有客户端推送)
                → 回复分发 (ReplyDispatcher → 渠道投递)

6.3 队列化串行执行

一个关键的设计决策是:同一会话内的 Agent 执行严格串行

为什么?因为 AI Agent 的执行涉及对话上下文的读写——两个并发执行可能会交叉读写同一个 transcript,导致上下文混乱。通过队列化,OpenClaw 保证了每个会话在任一时刻只有一个 Agent 运行。

但这不意味着用户必须等待。OpenClaw 提供了三种队列模式:

模式 行为 适用场景
steer 将排队消息注入当前运行中的 Agent Turn 用户追加信息修正方向
followup 等待当前 Turn 结束后启动新 Turn 常规多轮对话
collect 批量收集排队消息后一次性处理 群组消息聚合

6.4 Transcript Compaction:优雅的上下文管理

LLM 都有上下文窗口限制。当对话历史超过限制时,OpenClaw 不是简单地截断,而是执行对话压缩(Transcript Compaction)——使用 AI 对过早的对话做摘要,保留关键信息的同时释放 token 空间。

会话数据以 JSONL 格式追加写入 ~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl,既保证了写入性能,又支持流式恢复。


七、Gateway WebSocket 协议:自研 RPC 的设计考量

7.1 协议结构

Gateway 使用自研的 WebSocket JSON-RPC 协议,支持 90+ 方法。协议定义采用 Protocol-First 方式,所有 Schema 集中在 src/gateway/protocol/schema/ 下:

protocol/schema/
├── agent.ts              # Agent 请求/响应
├── channels.ts           # 渠道操作
├── config.ts             # 配置管理
├── cron.ts               # 定时任务
├── devices.ts            # 设备管理
├── frames.ts             # WebSocket 帧格式
├── sessions.ts           # 会话操作
├── snapshot.ts           # 状态快照
└── wizard.ts             # 引导向导

三种帧类型:

// 请求帧:客户端 → Gateway
{ type: "req", method: "agent", params: {...}, id: "uuid" }

// 响应帧:Gateway → 客户端
{ type: "res", id: "uuid", result: {...} }
{ type: "res", id: "uuid", error: { code: "...", message: "..." } }

// 事件帧:Gateway → 客户端(服务端推送)
{ type: "event", event: "agent", payload: {...} }

7.2 跨端类型一致性

一个值得关注的工程实践是:协议的 JSON Schema 定义可以自动生成 Swift 模型代码(通过 protocol:gen:swift 脚本)。这确保了 TypeScript 后端与 Swift 前端(macOS/iOS)之间的类型一致性,避免了手动同步 Schema 带来的漂移风险。

7.3 广播优化

当 Gateway 需要向所有连接的客户端推送事件时,它实现了两个关键优化:

慢客户端丢弃(dropIfSlow):如果某个客户端的 WebSocket 缓冲区积压过多,后续低优先级事件会被丢弃而非排队,防止一个慢客户端拖慢整个系统。

状态版本去重(stateVersion):通过版本号机制,客户端可以跳过中间状态,直接应用最新状态,减少不必要的渲染。


八、安全模型:多层纵深防御

8.1 五层安全策略

作为一个接入 30+ 消息平台的本地服务,安全是 OpenClaw 的生命线。它设计了五层纵深防御:

第一层 — 网络隔离

  • Gateway 默认绑定 127.0.0.1,不对外暴露
  • 远程访问通过 Tailscale Serve/Funnel(端到端加密)或 SSH 隧道实现

第二层 — 多因素认证

  • Token 认证:Authorization: Bearer <token>
  • 密码认证:连接握手时验证
  • Tailscale 身份:通过 Tailscale 头部自动验证
  • 设备绑定令牌:node/operator 角色的设备令牌
  • 回环直连:来自 localhost 的请求可跳过认证

第三层 — 角色权限

  • 三种角色:nodeoperatoradmin
  • 细粒度权限范围:operator.readoperator.writeoperator.approvalsoperator.admin

第四层 — DM 配对策略

  • dmPolicy="pairing" 模式下,陌生人首次联系需要配对确认
  • 基于 allowlist 的白名单管控

第五层 — 沙箱执行

  • main 会话:完全信任,所有工具可用
  • 非 main 会话:Docker 沙箱,禁止浏览器控制、系统命令等敏感操作

九、向量记忆:内置的知识检索系统

9.1 无外部依赖的向量搜索

OpenClaw 内置了基于 SQLite + sqlite-vec 的向量搜索系统,无需安装 Elasticsearch、Pinecone 或任何外部向量数据库。

数据库 Schema 清晰地分为三层:

-- 文件追踪:知道哪些文件被索引过
CREATE TABLE files (
  path TEXT PRIMARY KEY,
  hash TEXT, mtime INTEGER, size INTEGER, source TEXT
);

-- 文本块 + 嵌入向量:知识的最小单元
CREATE TABLE chunks (
  id TEXT PRIMARY KEY,
  path TEXT, source TEXT,
  start_line INTEGER, end_line INTEGER,
  hash TEXT, model TEXT, text TEXT,
  embedding BLOB,
  updated_at INTEGER
);

-- 全文搜索:FTS5 作为向量搜索的补充
CREATE VIRTUAL TABLE chunks_fts USING fts5(text, content=chunks);

-- 嵌入缓存:避免重复计算
CREATE TABLE embedding_cache (
  provider TEXT, model TEXT,
  provider_key TEXT, hash TEXT,
  embedding BLOB, dims INTEGER
);

9.2 混合搜索策略

检索时采用向量搜索 + 全文搜索的混合策略:

  1. 用户查询 → 通过 Embedding 模型生成向量
  2. 向量 → sqlite-vec 余弦相似度搜索 → 语义匹配结果
  3. 同时 → FTS5 全文检索 → 关键词匹配结果
  4. 两组结果合并排序,综合语义和词汇匹配的优势

十、跨平台客户端与设备节点

10.1 四端覆盖

OpenClaw 不只是一个后端服务,它提供了完整的跨平台客户端:

平台 语言 形态 特色
macOS Swift 菜单栏常驻应用 Gateway 自动发现、IPC、语音唤醒
iOS Swift (SwiftUI) 原生 App 离线节点、消息推送
Android Kotlin 原生 App 设备节点注册
Web Lit (Web Components) 浏览器控制台 聊天、配置、日志、调试

Apple 平台之间通过 OpenClawKit 共享核心代码,包括 Gateway 通信协议、聊天 UI 组件和 Canvas 工具。

10.2 设备节点架构

一个创新的设计是设备节点(Device Nodes)。每个运行 OpenClaw 客户端的设备都可以注册为 Gateway 的一个"节点",AI 助手可以调度设备上的能力——在 macOS 上打开浏览器、在 iOS 上发送通知、在 Android 上执行特定操作。

设备配对通过 DM 配对机制安全关联,确保只有授权设备才能加入网关网络。

10.3 Web UI 设计系统

Web 控制台使用 Lit (Web Components) 构建,具备完善的设计系统:

  • 字体:Space Grotesk (正文) + JetBrains Mono (等宽)
  • 主题:深色/浅色双主题,通过 CSS 变量切换
  • 配置表单:基于 JSON Schema 动态生成,支持 GUI 模式和原始 JSON 模式
  • Markdown 渲染:marked + DOMPurify + 200 项 LRU 缓存

十一、工程实践与代码质量

11.1 Monorepo 管理

项目使用 pnpm workspace 管理 monorepo,核心代码与扩展插件松耦合:

# pnpm-workspace.yaml
packages:
  - 'extensions/*'
  - 'packages/*'
  - 'apps/*'
  - 'ui'

11.2 构建系统

tsdown 承担构建任务,配置了 6 个构建入口:

export default defineConfig([
  { entry: "src/index.ts" },                    // 核心库
  { entry: "src/entry.ts" },                    // CLI 入口
  { entry: "src/infra/warning-filter.ts" },     // 警告过滤
  { entry: "src/plugin-sdk/index.ts", outDir: "dist/plugin-sdk" },
  { entry: "src/extensionAPI.ts" },             // 扩展 API
  { entry: ["src/hooks/bundled/*/handler.ts"] }, // Hook 处理器
]);

11.3 测试体系

七套 Vitest 配置覆盖了从单元到端到端的完整测试光谱:

测试类型 配置文件 说明
单元测试 vitest.config.ts 核心逻辑,V8 覆盖率阈值 70%
集成测试 vitest.unit.config.ts 模块间交互
E2E 测试 vitest.e2e.config.ts Gateway 协议端到端
扩展测试 vitest.extensions.config.ts 插件测试
实时测试 vitest.live.config.ts 真实 API Key 在线测试
Gateway 测试 vitest.gateway.config.ts Gateway 专项
Docker 测试 Shell 脚本 容器化隔离测试

测试文件采用 Colocated(就近放置) 策略:*.test.ts 与源码放在同一目录,降低认知负担。

11.4 代码规范

  • Oxlint + Oxfmt:比 ESLint + Prettier 更快的 Rust 原生方案
  • 严格 TypeScript:避免 any,充分利用类型系统
  • 文件大小控制:单个文件控制在 500-700 行以内
  • GitHub Actions CI:完整的 PR 检查流水线

十二、技术创新与改进方向

12.1 五个独特创新

  1. 统一渠道抽象:通过 ChannelPlugin 多适配器契约,将 30+ 种差异巨大的消息协议统一为一致的内部模型——这在开源 AI 助手项目中前所未有

  2. 嵌入式 Agent 架构:将 Pi Agent SDK 以库方式嵌入而非子进程,实现毫秒级工具调用和零序列化开销的事件流

  3. Protocol-First 多端开发:JSON Schema 协议定义 → 自动生成 Swift 模型代码,保证 TypeScript 后端与 Swift 前端的类型一致性

  4. 智能会话管理:per-peer 隔离、跨渠道身份链接、自动压缩、每日重置、空闲过期——一套灵活的策略组合

  5. 插件化一切:从渠道到工具到 CLI 命令到 HTTP 路由,所有扩展点通过统一的 Plugin API 暴露,真正做到了"不修改核心代码即可扩展一切"

12.2 值得关注的挑战

挑战 影响 可能的改进方向
Gateway 单点故障 Gateway 宕机导致所有渠道中断 引入 watchdog 进程或分布式部署方案
代码规模 ~2,500 个 TypeScript 文件,新贡献者学习曲线陡峭 改进文档、增加架构示意图
配置复杂度 JSON5 配置项繁多 openclaw onboard 向导已在改善
部分依赖不稳定 sqlite-vec@alphabaileys@rc 建立 API 变更监控和快速适配机制
Node.js 版本要求高 ≥22.12.0 限制了部分环境 长期来看问题会自然消解

总结

OpenClaw 展示了一个本地优先、多渠道统一、模型无关的个人 AI 助手该如何设计和实现。它的价值不仅在于解决了一个真实的工程问题——"让 AI 助手存在于所有消息平台",更在于提供了一套可复用的架构模式:

  • Gateway 控制面模式将复杂的多渠道消息路由简化为清晰的轴辐式架构
  • 多适配器组合契约展示了如何优雅地抽象 30+ 种异构协议
  • 插件化一切的设计哲学证明了统一注册表可以管理从渠道到 CLI 命令的所有扩展点
  • 嵌入式 Agent 方案为低延迟 AI 应用提供了子进程之外的另一种选择

对于希望构建个人 AI 助手、研究多渠道消息系统架构、或学习大型 TypeScript 项目工程实践的开发者来说,OpenClaw 是一个极具深度的开源宝藏。

ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)

前言

Echarts作为一款功能强大的数据可视化库,具备丰富的图表类型、配置化开发的易用性、高度可定制的视觉效果、优秀的响应式设计和交互体验,以及对大数据量的性能优化能力,广泛应用于企业管理系统、数据分析平台等场景。

但每创建一个图表都要处理初始化、销毁、resize 适配通用逻辑,项目如果体积大起来,创建和维护就变得特别麻烦,我们直接看官网对于图表的创建的快速上手:快速上手 - 使用手册 - Apache ECharts

image.png

如果要调整图表的自适应大小还要:

image.png

最后每次离开页面还要销毁实例,实在是太麻烦了。。。

如果可以把Echarts这些烦人的重复性步骤封装起来,只传我需要的自定义配置参数就很方便了

其实封装起来的原理很简单,就是换汤不换药,把最重要的芯子挖空就行,然后用到的时候再把芯子装回去,装不同额芯子就能实现不一样的效果,这样免去了从头到尾创建的过程,用起来十分方便,而且维护起来只用维护一个组件就好了。

使用教程

到底有多方便?直接上食用方法

在template里使用组件

先把ECharts组件写进components里封装再在要用到的页面使用

<ECharts                                               
    width="600px"                      <!-- 图表容器宽度,支持像素值或百分比 -->
    height="400px"                     <!-- 图表容器高度,支持像素值或百分比 -->
    element="salaryChart"               <!-- 图表元素 ID(每个图表唯一) -->
    :option="salaryChartOption"         <!-- 图表配置选项,包含数据、样式等 -->
    :function-type="1"                  <!-- 功能类型:0=无交互,1=点击+高亮,2=点击+对话框,12=两者都有 -->
    @chart-event="handleChartEvent"     <!-- 图表事件处理函数,接收点击事件参数 -->
/>

在js配置参数

js里就直接写对应的导入、配置参数、点击事件就好了

这里的option配置参数具体参考官方文档的option配置项写法 Documentation - Apache ECharts

点击事件参考官方的 事件与行为 - 概念篇 - 使用手册 - Apache ECharts

import ECharts from '@/components/ECharts.vue';

// 薪资分布图表配置
const salaryChartOption = {
  title: {
    text: '员工薪资分布',
    left: 'center'
  },
  tooltip: {
    trigger: 'axis',
    axisPointer: {
      type: 'shadow'
    }
  },
  grid: {
    left: '3%',
    right: '4%',
    bottom: '3%',
    containLabel: true
  },
  xAxis: {
    type: 'category',
    data: sampleData.map(item => item.name),
    axisLabel: {
      rotate: 45
    }
  },
  yAxis: {
    type: 'value',
    name: '薪资(元)'
  },
  series: [
    {
      name: '薪资',
      type: 'bar',
      data: sampleData.map(item => item.salary),
      itemStyle: {
        color: '#188df0'
      },
      emphasis: {
        itemStyle: {
          color: '#2378f7'
        }
      }
    }
  ]
};

// 处理图表事件
const handleChartEvent = (params: any) => {
  console.log('图表事件:', params);
  showMessage(`你点击了:${params.name || params.data.name}`, 'success');
};

组件封装

ECharts 组件封装的完整过程可概括为:

  1. 首先搭建组件基础结构,包括模板、脚本和样式;

  2. 接着定义类型和 Props 配置,确保类型安全和使用灵活性;

  3. 然后实现图表实例管理,包括创建、配置和销毁;通过响应式更新机制,实现图表配置的自动更新;添加事件处理与交互,支持与父组件的通信;在生命周期管理中,确保图表正确初始化和清理;

  4. 通过性能优化措施,提升组件性能;

  5. 暴露公共方法,支持更灵活的操作;最后添加错误处理与日志,增强组件健壮性。

1. 组件基础结构搭建

核心目标 :创建组件的基本框架,包括模板、脚本和样式

实现细节 :

  • 模板部分 :使用 div 作为图表容器,通过 ref 获取 DOM 元素引用,设置动态宽高样式
  • 脚本部分 :采用 Vue 3 的
  • 样式部分 :使用 scoped 样式,确保样式隔离,设置基本容器样式和过渡效果
<template>
  <div ref="chartRef" :style="{ height: height, width: width }" class="echarts-container" />
</template>

<script setup lang="ts">
// 后续逻辑实现
</script>

<style scoped>
.echarts-container {
  position: relative;
  box-sizing: border-box;
  min-width: 300px;
  min-height: 300px;
  transition: width 0.3s ease, height 0.3s ease;
}
</style>

2. 与 Props 配置

核心目标 :定义组件的属性类型和默认值,确保类型安全和使用灵活性。

实现细节 :

  • 类型定义 :使用 interface Props 定义组件属性类型,包含宽高、配置项、主题等
  • 默认值设置 :通过 withDefaults(defineProps(), {...}) 设置默认值
  • 类型导入 :导入 ECharts 相关类型(如 EChartsOption 、 ECElementEvent )
// 定义props
interface Props {
  width?: string | number;
  height?: string | number;
  option: EChartsOption;
  functionType?: number;
  debounceDelay?: number;
  theme?: string | null;
  initOpts?: EChartsInitOpts;
  autoResize?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  width: '100%',
  height: '400px',
  functionType: 0,
  debounceDelay: 300,
  theme: null,
  initOpts: () => ({
    devicePixelRatio: window.devicePixelRatio || 1,
    renderer: 'canvas'
  }),
  autoResize: true
});

3. 图表实例管理

核心目标 :创建和管理 ECharts 实例,确保实例的正确初始化和销毁

实现细节 :

  • 实例存储 :使用 let chartInstance: ECharts | null = null 存储图表实例
  • DOM 引用 :使用 const chartRef = ref<HTMLElement | null>(null) 获取图表容器元素
  • 实例创建 :在 initChart 函数中使用 echarts.init() 创建实例
  • 实例销毁 :在组件卸载和重新初始化时使用 chartInstance.dispose() 销毁实例
const chartRef = ref<HTMLElement | null>(null);
let chartInstance: ECharts | null = null;

// 初始化图表
const initChart = async (): Promise<void> => {
  try {
    // 清理现有实例
    if (chartInstance) {
      chartInstance.dispose();
      chartInstance = null;
    }

    // 确保元素存在
    if (!chartRef.value) {
      throw new Error('图表容器元素不存在');
    }

    // 初始化图表实例
    chartInstance = echarts.init(
      chartRef.value,
      props.theme,
      props.initOpts
    );
    
    // 后续配置...
  } catch (error) {
    console.error('ECharts: 图表初始化失败', error);
    emit('error', error as Error);
  }
};

4. 响应式更新机制

核心目标 :实现图表配置的自动更新,当 props 变化时图表能相应调整。

实现细节 :

  • 配置监听 :使用 watch 监听 option 变化,自动调用 setOption 更新图表
  • 主题监听 :监听 theme 变化,触发重新初始化图表
  • 尺寸监听 :监听 width 和 height 变化,调用 resize 方法调整图表大小
  • 深度监听 :对 option 使用 deep: true 确保嵌套属性变化也能被检测到
// 监听配置变化
watch(
  () => props.option,
  (newOption) => {
    if (newOption && chartInstance) {
      chartInstance.setOption(newOption, true);
    }
  },
  { deep: true, immediate: false }
);

// 监听主题变化
watch(
  () => props.theme,
  () => {
    initChart();
  }
);

// 监听尺寸变化
watch(
  [() => props.width, () => props.height],
  () => {
    resize();
  }
);

5. 事件处理与交互

核心目标 :实现图表的事件绑定和处理,支持与父组件的交互。

实现细节 :

  • 事件定义 :使用 defineEmits 定义组件可触发的事件(如 chart-event 、 init 、 error )
  • 事件绑定 :在 bindEvents 函数中根据 functionType 绑定不同的点击事件
  • 事件处理 :实现 handleClickEvent 和 handleDialogEvent 处理具体事件逻辑
  • 事件传递 :通过 emit 将事件参数传递给父组件
const emit = defineEmits<{
  'chart-event': [params: ECElementEvent];
  'init': [instance: ECharts];
  'error': [error: Error];
}>();

// 绑定事件
const bindEvents = (): void => {
  if (!chartInstance) return;

  // 移除现有事件监听
  chartInstance.off('click');

  // 根据 functionType 绑定不同事件
  if (props.functionType === 1 || props.functionType === 12) {
    chartInstance.on('click', handleClickEvent);
  } else if (props.functionType === 2 || props.functionType === 12) {
    chartInstance.on('click', handleDialogEvent);
  }
};

6. 生命周期管理

核心目标 :在组件的生命周期不同阶段执行相应的操作,确保图表正确初始化和清理

实现细节 :

  • 组件挂载 :在 onMounted 中初始化图表并添加窗口 resize 事件监听
  • 延迟初始化 :使用 setTimeout 确保 DOM 完全加载后再初始化图表
  • 组件卸载 :在 onBeforeUnmount 中清理事件监听器、定时器和销毁图表实例
onMounted(async () => {
  // 延迟初始化,确保 DOM 完全加载
  setTimeout(async () => {
    await initChart();
  }, 100);

  // 添加窗口 resize 事件监听
  if (props.autoResize) {
    window.addEventListener('resize', debouncedResize);
  }
});

onBeforeUnmount(() => {
  // 清理窗口 resize 事件监听
  if (props.autoResize) {
    window.removeEventListener('resize', debouncedResize);
  }

  // 清理定时器
  if (resizeTimer) {
    clearTimeout(resizeTimer);
  }

  // 销毁图表实例
  if (chartInstance) {
    chartInstance.dispose();
    chartInstance = null;
  }
});

7. 性能优化措施

核心目标 :通过优化手段提升组件性能,减少不必要的计算和渲染。

实现细节 :

  • 防抖处理 :实现 debounce 函数处理窗口 resize 事件,避免频繁触发
  • 合理初始化 :只在必要时重新初始化图表(如主题变化)
  • 资源清理 :在组件卸载时彻底清理资源,防止内存泄漏
  • 条件执行 :在事件绑定和方法调用前检查实例是否存在
// 防抖函数
const debounce = <T extends (...args: any[]) => any>(
  func: T,
  delay: number
): ((...args: Parameters<T>) => void) => {
  let timer: ReturnType<typeof setTimeout> | null = null;
  return (...args: Parameters<T>) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      func(...args);
      timer = null;
    }, delay);
  };
};

// 防抖处理的 resize 函数
const debouncedResize = debounce(resize, props.debounceDelay);    

8. 公共方法暴露

核心目标 :将图表实例的方法暴露给父组件,支持更灵活的操作。

实现细节 :

  • 方法定义 :实现常用的图表操作方法(如 resize 、 setOption 、 dispatchAction 等)
  • 方法暴露 :使用 defineExpose 将这些方法暴露给父组件
  • 实例获取 :提供 getInstance 方法,允许父组件直接获取 ECharts 实例
// 重新渲染图表
const resize = (): void => {
  if (chartInstance) {
    chartInstance.resize();
  }
};

// 获取图表实例
const getInstance = (): ECharts | null => {
  return chartInstance;
};

// 设置图表配置
const setOption = (option: EChartsOption, notMerge?: boolean): void => {
  if (chartInstance) {
    chartInstance.setOption(option, notMerge);
  }
};

// 暴露方法
defineExpose({
  resize,
  getInstance,
  setOption,
  dispatchAction,
  clear,
  showLoading,
  hideLoading
});

完整封装代码(直接CV可食用)

通过集中处理初始化、销毁、resize 适配等通用逻辑,实现代码复用,避免重复编写实现细节;提升维护性,修改时只需更新组件代码,所有使用处自动受益;保证接口一致性,团队成员可通过统一的 props 和事件快速集成;同时便于功能扩展(如防抖处理、错误捕获)和提升代码可读性,使父组件更专注于业务逻辑和图表配置,最终实现更高效、可靠的数据可视化方案。

<template>
  <div ref="chartRef" :style="{ height: height, width: width }" class="echarts-container" />
</template>

<script setup lang="ts">
import {ref, watch, onMounted, onBeforeUnmount, computed} from 'vue';
import * as echarts from 'echarts';
import type {ECharts, EChartsOption, ECElementEvent, EChartsInitOpts} from 'echarts';

// 定义props
interface Props {
  width?: string | number;
  height?: string | number;
  option: EChartsOption;
  functionType?: number;
  debounceDelay?: number;
  theme?: string | null;
  initOpts?: EChartsInitOpts;
  autoResize?: boolean;
}

const emit = defineEmits<{
  'chart-event': [params: ECElementEvent];
  'init': [instance: ECharts];
  'error': [error: Error];
}>();

// 暴露方法将在所有函数定义后添加

const props = withDefaults(defineProps<Props>(), {
  width: '100%',
  height: '400px',
  functionType: 0,
  debounceDelay: 300,
  theme: null,
  initOpts: () => ({
    devicePixelRatio: window.devicePixelRatio || 1,
    renderer: 'canvas'
  }),
  autoResize: true
});

const chartRef = ref<HTMLElement | null>(null);
let chartInstance: ECharts | null = null;
const resizeTimer: ReturnType<typeof setTimeout> | null = null;

// 计算宽度和高度
const computedWidth = computed(() => {
  return typeof props.width === 'number' ? `${props.width}px` : props.width;
});

const computedHeight = computed(() => {
  return typeof props.height === 'number' ? `${props.height}px` : props.height;
});

// 防抖函数
const debounce = <T extends (...args: any[]) => any>(
  func: T,
  delay: number
): ((...args: Parameters<T>) => void) => {
  let timer: ReturnType<typeof setTimeout> | null = null;
  return (...args: Parameters<T>) => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      func(...args);
      timer = null;
    }, delay);
  };
};

// 初始化图表
const initChart = async (): Promise<void> => {
  try {
    console.log('ECharts: 开始初始化图表');

    // 清理现有实例
    if (chartInstance) {
      chartInstance.dispose();
      chartInstance = null;
    }

    // 确保元素存在
    if (!chartRef.value) {
      throw new Error('图表容器元素不存在');
    }

    // 检查元素尺寸
    const { offsetWidth, offsetHeight } = chartRef.value;
    if (offsetWidth === 0 || offsetHeight === 0) {
      throw new Error('图表容器尺寸为0,请检查容器样式');
    }

    console.log('ECharts: 图表容器尺寸', { width: offsetWidth, height: offsetHeight });

    // 初始化图表实例
    chartInstance = echarts.init(
      chartRef.value,
      props.theme,
      props.initOpts
    );

    console.log('ECharts: 图表实例创建成功', chartInstance);

    // 绑定事件
    bindEvents();

    // 设置图表配置
    if (props.option) {
      chartInstance.setOption(props.option, true);
      console.log('ECharts: 图表配置设置成功');
    }

    // 触发初始化完成事件
    emit('init', chartInstance);

    console.log('ECharts: 图表初始化完成');
  } catch (error) {
    console.error('ECharts: 图表初始化失败', error);
    emit('error', error as Error);
  }
};

// 绑定事件
const bindEvents = (): void => {
  if (!chartInstance) return;

  // 移除现有事件监听
  chartInstance.off('click');

  // 根据 functionType 绑定不同事件
  if (props.functionType === 1 || props.functionType === 12) {
    chartInstance.on('click', handleClickEvent);
  } else if (props.functionType === 2 || props.functionType === 12) {
    chartInstance.on('click', handleDialogEvent);
  }
};

// 处理点击事件
const handleClickEvent = (params: ECElementEvent): void => {
  console.log('ECharts: 点击事件触发', params);

  // 高亮点击的数据点
  if (chartInstance && params.seriesIndex !== undefined && params.dataIndex !== undefined) {
    chartInstance.dispatchAction({
      type: 'highlight',
      seriesIndex: params.seriesIndex,
      dataIndex: params.dataIndex
    });
  }

  // 触发自定义事件
  emit('chart-event', params);
};

// 处理对话框事件
const handleDialogEvent = (params: ECElementEvent): void => {
  console.log('ECharts: 对话框事件触发', params);
  emit('chart-event', params);
};

// 重新渲染图表
const resize = (): void => {
  if (chartInstance) {
    chartInstance.resize();
    console.log('ECharts: 图表尺寸调整');
  }
};

// 防抖处理的 resize 函数
const debouncedResize = debounce(resize, props.debounceDelay);

// 获取图表实例
const getInstance = (): ECharts | null => {
  return chartInstance;
};

// 设置图表配置
const setOption = (option: EChartsOption, notMerge?: boolean): void => {
  if (chartInstance) {
    chartInstance.setOption(option, notMerge);
    console.log('ECharts: 手动设置图表配置');
  }
};

// 触发图表动作
const dispatchAction = (action: echarts.Action): void => {
  if (chartInstance) {
    chartInstance.dispatchAction(action);
    console.log('ECharts: 触发图表动作', action);
  }
};

// 清空图表
const clear = (): void => {
  if (chartInstance) {
    chartInstance.clear();
    console.log('ECharts: 清空图表');
  }
};

// 显示加载动画
const showLoading = (type?: string, options?: echarts.LoadingOption): void => {
  if (chartInstance) {
    chartInstance.showLoading(type, options);
    console.log('ECharts: 显示加载动画');
  }
};

// 隐藏加载动画
const hideLoading = (): void => {
  if (chartInstance) {
    chartInstance.hideLoading();
    console.log('ECharts: 隐藏加载动画');
  }
};

// 暴露方法
defineExpose({
  resize,
  getInstance,
  setOption,
  dispatchAction,
  clear,
  showLoading,
  hideLoading
});

// 监听配置变化
watch(
  () => props.option,
  (newOption) => {
    if (newOption && chartInstance) {
      console.log('ECharts: 图表配置变化,更新图表');
      chartInstance.setOption(newOption, true);
    }
  },
  { deep: true, immediate: false }
);

// 监听主题变化
watch(
  () => props.theme,
  () => {
    console.log('ECharts: 图表主题变化,重新初始化图表');
    initChart();
  }
);

// 监听尺寸变化
watch(
  [() => props.width, () => props.height],
  () => {
    console.log('ECharts: 图表尺寸变化,调整图表');
    resize();
  }
);

onMounted(async () => {
  console.log('ECharts: 组件挂载');

  // 延迟初始化,确保 DOM 完全加载
  setTimeout(async () => {
    await initChart();
  }, 100);

  // 添加窗口 resize 事件监听
  if (props.autoResize) {
    window.addEventListener('resize', debouncedResize);
    console.log('ECharts: 添加窗口 resize 事件监听');
  }
});

onBeforeUnmount(() => {
  console.log('ECharts: 组件卸载');

  // 清理窗口 resize 事件监听
  if (props.autoResize) {
    window.removeEventListener('resize', debouncedResize);
  }

  // 清理定时器
  if (resizeTimer) {
    clearTimeout(resizeTimer);
  }

  // 销毁图表实例
  if (chartInstance) {
    chartInstance.dispose();
    chartInstance = null;
  }
});
</script>

<style scoped>
.echarts-container {
  position: relative;
  box-sizing: border-box;
  min-width: 300px;
  min-height: 300px;
  transition: width 0.3s ease, height 0.3s ease;
}
</style>

谢谢观看!

shadcn/ui,给你一个真正可控的UI组件库

当“代码所有权”成为一种奢侈,shadcn/ui 却把每一行组件源码都交到你手中。

你有没有遇到过这种情况:设计师拿着界面稿说:“这个按钮,圆角再大点,阴影再柔和点。”你点头答应,回头面对代码,却要翻文档、查方案、小心翼翼地写覆盖样式,只为改一个按钮的外观。

直到 shadcn/ui 出现,这一切变了。这个不用 npm install,却让无数 React 开发者着迷的项目,正在用全新的方式定义我们写界面的体验。


一、独特哲学:把源码交给你,而不是一个“黑箱”

传统UI库(如Ant Design、MUI)的运作方式像一个“黑箱”:

// 你安装的是一个压缩的包
npm install @mui/material

// 使用它,但无法轻易修改它
import { Button } from '@mui/material';

shadcn/ui 则采用了一种革命性的方法:

# 不是安装包,而是复制源码
npx shadcn-ui@latest add button

# 结果:完整的button.tsx文件出现在你的项目中
# src/components/ui/button.tsx

这种差异意味着什么? 当组件代码就在你的components/ui目录下时,你可以:

  • 直接修改任何样式细节
  • 调整组件的内部逻辑
  • 查看完整的实现,没有隐藏的“魔法”
  • 拥有100%的代码所有权

二、核心优势:为什么开发者爱不释手?

1. 极致的定制自由

想象一下:产品经理要求把按钮的悬停效果改成渐变色。传统方式可能需要查找主题覆盖文档、编写自定义CSS、担心样式冲突。而使用shadcn/ui,你只需要:

// 直接打开 button.tsx 修改
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = "default", size = "default", ...props }, ref) => {
    return (
      <button
        className={cn(
          buttonVariants({ variant, size, className }),
          // 直接在这里添加你的渐变效果
          "hover:bg-gradient-to-r hover:from-blue-500 hover:to-purple-600"
        )}
        ref={ref}
        {...props}
      />
    )
  }
)

2. AI编程的最佳搭档

在AI编码助手普及的今天,shadcn/ui的设计理念显得尤为前瞻:

  • 传统组件库的问题:AI无法“看到”node_modules中的组件实现,只能基于有限的文档给出建议。
  • shadcn/ui的优势:AI可以直接阅读、理解和修改你项目中的组件源码。你可以直接说:“帮我把这个对话框的动画时间从300ms改为200ms”,AI会精准地找到并修改对应的代码行。

3. 按需引入,极致轻量

传统UI库常常有“全量引入”的问题,即使你只用了一个按钮,也可能打包进整个库的基础样式。

shadcn/ui的解决方案:只添加你真正需要的组件。每个组件都是独立的,没有隐藏的依赖。

组件 文件大小 依赖关系
Button ~5KB 零运行时依赖
Dialog ~8KB 仅依赖Radix UI
Data Table ~15KB 依赖TanStack Table

三、技术架构:现代前端技术栈的集大成者

  • 基于 Radix UI 的无障碍基础:所有交互组件(如对话框、下拉菜单)都基于 Radix UI 构建,提供开箱即用的键盘导航、完整的屏幕阅读器兼容性,并遵循WAI-ARIA标准。
  • 深度集成 Tailwind CSS:样式系统完全基于Tailwind CSS,保证了设计的一致性、可维护性,并提升了开发效率。
  • TypeScript 优先:所有组件都使用TypeScript编写,提供完整的类型安全、智能的IDE自动补全和自文档化的Props接口。

四、实战指南:五分钟快速上手

第一步:创建项目

# 使用Next.js(推荐)
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app

第二步:初始化 shadcn/ui

npx shadcn-ui@latest init

CLI会引导你完成配置:选择样式系统、配置主题颜色、设置组件目录位置。

第三步:添加你的第一个组件

# 添加一个按钮
npx shadcn-ui@latest add button
# 添加一个卡片
npx shadcn-ui@latest add card
# 添加一个对话框
npx shadcn-ui@latest add dialog

第四步:立即使用

// 在app/page.tsx中
import { Button } from "@/components/ui/button"

export default function Home() {
  return (
    <div className="p-8">
      <Button variant="default" size="lg">
        这是我的第一个shadcn/ui按钮
      </Button>
    </div>
  )
}

五、考虑与权衡:它适合你的项目吗?

适合的场景:

  • 需要高度定制UI的品牌应用
  • 长期维护的大型项目
  • 无障碍访问有要求的产品
  • 使用AI编程助手的开发团队
  • 追求极致性能和包体积优化的应用

需要考虑的点:

  • 更新维护:当官方发布更新时,你需要手动合并到项目中
  • 设计责任:更多的自由也意味着更多的设计决策
  • 团队学习:需要熟悉TypeScript和Tailwind CSS

与传统UI库的对比:

特性 传统UI库 (如MUI) shadcn/ui
代码所有权 使用方,不可修改源码 完全拥有,可任意修改
定制方式 通过主题配置和CSS覆盖 直接修改组件源码
包大小 通常较大(即使按需导入) 只包含实际使用的组件
学习曲线 学习库特定的API和主题系统 学习实际的React/Tailwind代码
AI友好度 较差(AI看不到实现) 极佳(AI可直接操作源码)

七、社区生态:不只是React

虽然最出名的是React版本,但shadcn/ui的理念已经扩展到其他框架。社区维护了 Vue 3版本 (shadcn-vue),提供相似的开发体验。同时,社区也贡献了多种开箱即用的模板,如仪表盘模板、登录/注册页面、电商组件等。


写在最后

shadcn/ui 的出现,回应了前端开发中一个长期被忽视的需求:开发者对UI组件的完全控制权。它不仅仅是一个工具集合,更是一种开发哲学的体现——相信开发者有能力、也应该有权利直接控制他们所使用的每一个组件。

毕竟,在这个强调“开发者体验”的时代,还有什么比“这代码完全属于我”更好的体验呢?

我写了个 Claude Code Skill,再也不用手动切图传 COS 了

切图、压缩、传 COS、复制链接、粘回代码。一个页面 5 张图就要重复 5 遍。我受够了,写了个 Skill 把这活儿交给 AI。

起因:每天在 Figma 和 COS 控制台之间反复横跳

我做小程序开发,日常跟设计师对接。每次拿到 Figma 设计稿,写代码之前得先处理图片:

  1. Figma 里一张张切图导出
  2. 打开 TinyPNG 网站,把 PNG 拖进去压缩
  3. 登录腾讯云 COS 控制台,上传
  4. 复制 CDN 链接,粘到代码里
  5. 下一张,重复

一个页面 5 张图,这套操作就要走 5 遍。说实话,这活儿谁干谁烦。

后来我发现 Claude Code 有个 Skill 机制,可以教它执行自定义流程。花了2小时折腾出来一个 image-auto-upload Skill,现在图片这块基本不用我操心了。

效果直接看下面的图片

image.png

截图里能看到整个链路:

  1. 左上角是 Figma 设计稿
  2. 左侧终端里 Claude Code 在自动识别图片素材
  3. 中间是tinypng压缩 + 上传 COS 的过程
  4. 右上角 COS 控制台里文件已经上传成成功
  5. 右下角是小程序里的最终效果

我就输入了一句"根据 Figma 设计稿实现这个页面(自行切图上传替换)",它自己全办了。

Skill 是个什么东西

简单说,Skill 就是一个文件夹,放在 .claude/skills/ 下面,里面告诉 Claude 怎么完成某个特定任务。

Claude Code 本身能读代码、写代码、跑命令,但它不知道你们项目的图片要传到哪个 COS Bucket,不知道 PNG 要先压缩,不知道上传路径的前缀规则。这些"项目私货",你得自己教它。Skill 就是干这个的。

目录结构长这样:

.claude/skills/image-auto-upload/
├── SKILL.md              # 给 Claude 看的操作手册
├── README.md             # 给人看的使用文档
├── resources/            # 放待上传的图片
│   └── README.md
└── scripts/              # 执行脚本
    ├── upload_images.cjs  # Node.js 上传脚本
    └── compress_png.py    # Python 压缩脚本

最核心的是 SKILL.md。Claude 读这个文件来理解:什么时候该用这个技能、具体怎么操作、调什么脚本、结果怎么展示。

SKILL.md 怎么写

这个文件写得好不好,直接决定 Claude 能不能正确干活。我拆开讲讲。

触发场景

## 触发场景

### 场景 1:Figma 设计稿实现
当根据 Figma 设计稿实现页面时,如果设计稿中包含图片素材:
1. 列出可上传的图片素材(图片名称、类型、用途)
2. 等待用户确认需要上传哪些图片
3. 执行上传并返回 CDN URL

### 场景 2:直接上传
当用户直接告知需要上传的图片或图片文件夹时,直接执行上传流程。

两种触发方式。一种是实现 Figma 设计稿时 Claude 自己发现有图片,会先问你要不要上传;另一种是你直接说"上传图片",它立刻执行。

我一开始只写了第二种,后来发现配合 Figma Agent 用的时候,Claude 不知道该主动处理图片,还得我手动提醒。加上第一种之后就顺畅了。

处理流程

将图片放入 resources/ 文件夹
      │
      ▼
  是图片文件?(png/jpg/jpeg/webp/gif/svg)
      │
  ┌───┴───┐
 YES     NO → 跳过
  │
  ▼
  是 PNG 格式?
  ┌───┴───┐
 YES     NO
  │       │
  ▼       │
自动压缩   │
  │       │
  └───┬───┘
      │
      ▼
 上传到 Tencent COS
      │
      ▼
  返回 CDN URL
      │
      ▼
 自动清理源文件

几个设计上的考虑:

  • PNG 先过 TinyPNG API 压缩再传,JPG/WebP 直接传。PNG 压缩率一般在 60%-80%,省的流量还是挺可观的
  • .DS_Store 之类的杂文件自动跳过,不用手动清理
  • 传完自动删源文件。一开始我没加这个,resources 目录越来越大,后来加了自动清理

输出格式

| 图片名称 | CDN URL |
|----------|---------|
| icon.png | https://xxx.cos.ap-nanjing.myqcloud.com/applet/icon.png |
| bg.jpg   | https://xxx.cos.ap-nanjing.myqcloud.com/applet/bg.jpg |

上传完用表格展示结果,方便直接复制 URL。

两个脚本,一个压缩一个传

整个 Skill 靠两个脚本干活。

PNG 压缩(compress_png.py)

import tinify

def compress_image(input_path, output_path=None):
    tinify.key = TINYPNG_API_KEY  # 从 .env.skills 读取

    input_size = input_file.stat().st_size
    source = tinify.from_file(str(input_file))
    source.to_file(str(output_path))  # 压缩后覆盖原文件
    output_size = output_path.stat().st_size

    compression_ratio = round((1 - output_size / input_size) * 100, 2)
    return { "compression_ratio": compression_ratio }

逻辑很直白:读文件、调 TinyPNG API、写回去覆盖原文件。TinyPNG 免费额度每月 500 次,个人项目完全够用。

COS 上传(upload_images.cjs)

const COS = require('cos-nodejs-sdk-v5');

// 上传单个文件(分片上传)
function uploadFile(filePath, key) {
  // 1. 初始化分片上传
  cos.multipartInit({ Bucket, Region, Key }, (err, data) => {
    // 2. 上传分片
    cos.multipartUpload({ ... Body: fs.createReadStream(filePath) }, () => {
      // 3. 完成上传
      cos.multipartComplete({ ... });
    });
  });
}

// 上传整个文件夹(递归子目录)
async function uploadFolder(folderPath, prefix) {
  for (const file of files) {
    if (!isImageFile(file)) continue;  // 跳过非图片
    compressPng(file);                  // PNG 先压缩
    await uploadFile(file, key);        // 传到 COS
    fs.unlinkSync(file);               // 删源文件
  }
}

用了 COS SDK 的分片上传,支持递归子目录。上传成功删源文件,失败的留着方便重试。

密钥放哪

COS 密钥和 TinyPNG API Key 放在项目根目录的 .env.skills 里:

# .env.skills(记得加 .gitignore)
COS_BUCKET=your-bucket-name
COS_REGION=ap-nanjing
COS_UPLOAD_PREFIX=applet
TINYPNG_API_KEY=your-tinypng-api-key

这个文件不进 Git,密钥不会泄露。

实际用起来是什么感觉

配合 Figma 用(我最常用的方式)

我项目里还有个 figma-designer Agent,配置里写了一条:

遇到图片,先自动切到本地,然后调用 skills 进行压缩上传 COS,然后替换代码

所以流程是这样的:

  1. 我在 Figma 里选中要实现的页面
  2. 在 Claude Code 里说"根据 Figma 设计稿实现这个页面"
  3. Claude 通过 Figma MCP 读设计稿,识别出图片素材
  4. 自动导出图片 → 压缩 PNG → 传 COS → 拿到 CDN URL
  5. 用 URL 替换代码里的图片引用,生成 Vue 组件

我只管最后看一眼代码对不对。中间图片那一堆事,完全不用管。

说实话第一次跑通的时候还挺意外的,没想到几个工具串起来能这么顺。

手动传几张图

有时候设计师单独丢几张图过来,不走 Figma 流程:

我:帮我上传 resources 里的图片

Claude:发现 resources 目录下有 3 张图片:
- banner.png (PNG,将自动压缩)
- icon-success.png (PNG,将自动压缩)
- photo.jpg (JPG,直接上传)

正在处理...

[banner.png] 正在压缩 PNG... 压缩完成 (72% reduction)
[banner.png] 上传完成
[icon-success.png] 正在压缩 PNG... 压缩完成 (65% reduction)
[icon-success.png] 上传完成
[photo.jpg] 上传完成

========== 上传完成 ==========
成功: 3 个文件
失败: 0 个文件

| 图片名称 | CDN URL |
|----------|---------|
| banner.png | https://xxx.cos.../applet/banner.png |
| icon-success.png | https://xxx.cos.../applet/icon-success.png |
| photo.jpg | https://xxx.cos.../applet/photo.jpg |

源文件已自动清理。

批量传一整套图

比如一次性传一套图标:

resources/
├── icons/
│   ├── home.png
│   ├── cart.png
│   └── user.png
└── backgrounds/
    ├── login-bg.jpg
    └── main-bg.jpg

上传后目录结构原样保留:

applet/icons/home.png
applet/icons/cart.png
applet/icons/user.png
applet/backgrounds/login-bg.jpg
applet/backgrounds/main-bg.jpg

这个我踩过坑。一开始上传脚本不支持子目录,所有文件都平铺到根路径下,文件名一冲突就覆盖了。后来加了递归目录支持才解决。

想自己搞一个?照着来

1. 建目录

mkdir -p .claude/skills/image-auto-upload/{scripts,resources}

2. 装依赖

# PNG 压缩
pip install tinify

# COS 上传
npm install cos-nodejs-sdk-v5

3. 配环境变量

项目根目录建 .env.skills

COS_BUCKET=your-bucket-name
COS_REGION=ap-nanjing
COS_UPLOAD_PREFIX=your-prefix
TINYPNG_API_KEY=your-tinypng-api-key

TinyPNG API Key 去 tinypng.com/developers 申请,免费的,每月 500 次额度。

4. 写 SKILL.md

这步最花时间,也最值得花时间。几个经验:

  1. 触发场景写清楚,不然 Claude 不知道什么时候该用
  2. 流程用流程图或步骤列表,别写大段文字
  3. 命令给完整的,能直接复制执行的那种
  4. 定义好输出格式,不然每次返回的结果格式都不一样

我第一版 SKILL.md 写得太简略,Claude 经常漏步骤。后来补了流程图和具体命令,就稳定多了。

5. 加 .gitignore

echo ".env.skills" >> .gitignore
echo ".claude/skills/image-auto-upload/resources/*" >> .gitignore
echo "!.claude/skills/image-auto-upload/resources/README.md" >> .gitignore

到底省了多少事

列个对比吧:

操作 手动 用 Skill
切图导出 一张张从 Figma 导出 自动识别导出
PNG 压缩 开 TinyPNG 网站拖拽 自动调 API
上传 COS 登控制台手动传 脚本自动传
复制链接 一个个复制 URL 表格直接给
替换代码 手动粘贴 自动替换
5 张图耗时 大概 10 分钟 30 秒左右

代码量也不大,Python 脚本 136 行,Node.js 脚本 290 行。写 SKILL.md 反而花的时间更多,因为要反复调试 Claude 的理解是否准确。

说到底,Skill 就是把你脑子里"这个项目图片该怎么处理"的经验,写成 Claude 能看懂的文档。教一次,后面就不用再操心了。

如果你项目里也有类似的重复操作,可以试试写个 Skill。不一定是图片上传,任何有固定流程的事情都行。

深入理解滑块验证码:那些你不知道的防破解机制

你是否遇到过这样的尴尬:明明自己是个真人,却被验证码折磨得怀疑人生?据统计,传统图文验证码的用户放弃率高达40%。但你知道吗?滑块验证码背后藏着一套精密的防破解机制,它就像是一位经验丰富的安检员,在毫秒之间通过你的"微表情"判断你是不是真人。

📋 目录

为什么滑块验证码能取代传统验证码?

还记得那个被折磨到怀疑人生的时刻吗?扭曲的字母、模糊的图像、"请点击所有包含红绿灯的图片"……传统验证码就像是一个故意刁难你的门卫,而滑块验证码则更像是一位观察入微的心理学家。

根据 Journal of Information Security and Applications 2024 年的研究数据显示,滑块验证码的用户完成率比传统验证码高出35%,而破解难度却提升了2.3倍。这种"双赢"是怎么做到的?

滑块验证码的演进史

第一代:纯位置验证(2012-2015)
   └─ 只验证滑块最终位置是否正确
   └─ 弱点:容易被脚本直接设置位置

第二代:时间窗口验证(2015-2018)
   └─ 增加完成时间检测
   └─ 弱点:可以通过延时模拟

第三代:轨迹分析(2018-2021)
   └─ 分析拖动过程中的轨迹点
   └─ 弱点:轨迹可被录制重放

第四代:行为指纹(2021-至今)
   └─ 多维度行为特征分析
   └─ 机器学习辅助判断
   └─ 当前主流方案

现在的滑块验证码早已不是简单的"拖动到位"那么简单。它背后运行着一套复杂的行为分析系统,就像是你去面试时,HR不仅看你的简历,还会观察你的肢体语言、语速变化、甚至微表情。

第一道防线:位置验证

这是最基础的一层防护,就像是你去公司面试需要到达正确的楼层一样。看似简单,但这里面也有门道。

原理说明

服务器生成验证码时,会随机产生一个目标位置坐标 (targetX, targetY),并存储在服务端(通常配合Redis设置过期时间)。前端需要将滑块拖动到这个位置附近(允许一定的误差范围)。

// 服务端生成验证码示例(Node.js)
const crypto = require('crypto');

function generateCaptcha() {
  // 生成随机目标位置(假设滑槽宽度为300px)
  const targetX = Math.floor(Math.random() * 250) + 20; // 20-270之间
  
  // 生成唯一token
  const token = crypto.randomBytes(16).toString('hex');
  
  // 存储到Redis,设置5分钟过期
  await redis.setex(`captcha:${token}`, 300, JSON.stringify({
    targetX,
    createdAt: Date.now()
  }));
  
  return { token, targetX };
}

关键细节

误差容忍度:通常允许 ±5px 的误差范围。太小会导致用户体验差,太大会降低安全性。

坐标加密:前端不应直接知道目标位置。正确的做法是让后端返回一个加密的目标位置,或者使用图片背景上的缺口位置作为参照。

// 错误做法 ❌
const targetX = 156; // 前端硬编码或从接口明文获取

// 正确做法 ✅
// 后端返回一张带有缺口的背景图
// 缺口位置就是目标位置,前端不需要知道具体数值
// 验证时后端对比前端提交的坐标与缺口位置

第二道防线:轨迹非线性检测

这是滑块验证码最精妙的地方。就像人的笔迹一样,每个人的拖动轨迹都是独一无二的,而机器人的"笔迹"往往过于工整。

什么是非线性轨迹?

人类拖动滑块时,轨迹是这样的:

开始 ────╲    ╱────╲      ╱──── 结束
            ╲  ╱      ╲    ╱
             ╲╱        ╲──╱

而机器人的"完美"轨迹是这样的:

开始 ─────────────────────────── 结束

实现原理

我们需要采集拖动过程中的轨迹点,然后分析这些点的分布特征。

// 前端轨迹采集
class TrajectoryCollector {
  constructor() {
    this.trajectory = [];
    this.startTime = null;
  }

  start() {
    this.startTime = Date.now();
    this.trajectory = [];
  }

  record(x, y) {
    const timestamp = Date.now() - this.startTime;
    this.trajectory.push({ x, y, t: timestamp });
  }

  getTrajectory() {
    return this.trajectory;
  }
}

// 使用示例
const collector = new TrajectoryCollector();

slider.addEventListener('mousedown', () => {
  collector.start();
});

slider.addEventListener('mousemove', (e) => {
  if (isDragging) {
    collector.record(e.clientX, e.clientY);
  }
});

非线性检测算法

// 服务端轨迹分析(Node.js)
function analyzeTrajectory(trajectory) {
  // 1. 计算相邻点的偏差
  const deviations = [];
  for (let i = 1; i < trajectory.length; i++) {
    const prev = trajectory[i - 1];
    const curr = trajectory[i];
    
    // 计算角度偏差
    if (i > 1) {
      const prev2 = trajectory[i - 2];
      const angle1 = Math.atan2(prev.y - prev2.y, prev.x - prev2.x);
      const angle2 = Math.atan2(curr.y - prev.y, curr.x - prev.x);
      const deviation = Math.abs(angle2 - angle1);
      deviations.push(deviation);
    }
  }

  // 2. 统计偏差特征
  const avgDeviation = deviations.reduce((a, b) => a + b, 0) / deviations.length;
  const maxDeviation = Math.max(...deviations);
  
  // 3. 判断是否为线性
  // 人类拖动通常会有明显的方向变化(手抖、调整等)
  // 机器人通常是直线或平滑曲线
  const isLinear = avgDeviation < 0.1 && maxDeviation < 0.3;
  
  return {
    isLinear,
    score: isLinear ? 0 : Math.min(100, avgDeviation * 100),
    details: { avgDeviation, maxDeviation, pointCount: trajectory.length }
  };
}

为什么这很有效?

根据 "The robustness of behavior-verification-based slider CAPTCHAs"(Journal of Information Security and Applications, 2024)的研究,简单的自动化脚本很难模拟出真实的非线性轨迹。即使使用贝塞尔曲线模拟,也会在某些特征上露出马脚。

第三道防线:速度变化分析

人类拖动滑块的速度不是恒定的,就像你开车一样:启动时慢、中途加速、快到位时减速。而机器人往往会以恒定速度"行驶"。

速度曲线特征

速度
 │
 │       ╱╲
 │      ╱  ╲
 │     ╱    ╲
 │    ╱      ╲
 │   ╱        ╲
 │  ╱          ╲___
 │ ╱                ╲
 └─────────────────────── 时间
  慢→快→慢→调整→完成

速度分析算法

function analyzeSpeed(trajectory) {
  const speeds = [];
  
  for (let i = 1; i < trajectory.length; i++) {
    const prev = trajectory[i - 1];
    const curr = trajectory[i];
    
    const distance = Math.sqrt(
      Math.pow(curr.x - prev.x, 2) + Math.pow(curr.y - prev.y, 2)
    );
    const timeDiff = curr.t - prev.t;
    
    if (timeDiff > 0) {
      speeds.push(distance / timeDiff);
    }
  }

  // 分析速度变化特征
  const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
  const variance = speeds.reduce((sum, speed) => {
    return sum + Math.pow(speed - avgSpeed, 2);
  }, 0) / speeds.length;
  
  // 速度变化方差过小说明是匀速运动(机器人特征)
  const isConstantSpeed = variance < 0.5;
  
  // 检查是否有明显的加速-减速过程
  let hasAccelDecel = false;
  if (speeds.length > 10) {
    const firstHalf = speeds.slice(0, Math.floor(speeds.length / 2));
    const secondHalf = speeds.slice(Math.floor(speeds.length / 2));
    
    const avgFirst = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
    const avgSecond = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
    
    // 前半段和后半段有明显差异(加速后减速)
    hasAccelDecel = Math.abs(avgFirst - avgSecond) > avgSpeed * 0.3;
  }

  return {
    isConstantSpeed,
    hasAccelDecel,
    score: (!isConstantSpeed && hasAccelDecel) ? 100 : 50,
    details: { avgSpeed, variance, speeds: speeds.length }
  };
}

实战技巧

速度阈值设置

  • 过快(< 100ms):可能是脚本直接设置位置
  • 过慢(> 10s):可能是人工打码或低质量脚本
  • 推荐完成时间:500ms - 3000ms
// 综合时间检查
function checkTimeWindow(trajectory) {
  const totalTime = trajectory[trajectory.length - 1].t;
  
  if (totalTime < 100) {
    return { valid: false, reason: 'Too fast - likely automated' };
  }
  if (totalTime > 10000) {
    return { valid: false, reason: 'Too slow - possible manual farm' };
  }
  
  return { valid: true, duration: totalTime };
}

第四道防线:加速度模式识别

加速度是比速度更深一层的特征。人类手的肌肉反应是有物理惯性的,而程序生成的运动往往忽略这一点。

加速度曲线特征

人类的加速度曲线应该符合物理规律:

  • 启动时需要克服静摩擦力(加速度大)
  • 匀速阶段加速度接近0
  • 制动时加速度为负值
  • 整个过程有轻微的抖动(肌肉震颤)
function analyzeAcceleration(trajectory) {
  const accelerations = [];
  
  // 先计算速度
  const speeds = [];
  for (let i = 1; i < trajectory.length; i++) {
    const prev = trajectory[i - 1];
    const curr = trajectory[i];
    const distance = Math.sqrt(
      Math.pow(curr.x - prev.x, 2) + Math.pow(curr.y - prev.y, 2)
    );
    const timeDiff = curr.t - prev.t;
    if (timeDiff > 0) {
      speeds.push({
        speed: distance / timeDiff,
        time: curr.t
      });
    }
  }

  // 计算加速度(速度的变化率)
  for (let i = 1; i < speeds.length; i++) {
    const speedDiff = speeds[i].speed - speeds[i - 1].speed;
    const timeDiff = speeds[i].time - speeds[i - 1].time;
    if (timeDiff > 0) {
      accelerations.push(speedDiff / timeDiff);
    }
  }

  // 分析加速度特征
  const positiveAccel = accelerations.filter(a => a > 0).length;
  const negativeAccel = accelerations.filter(a => a < 0).length;
  const nearZeroAccel = accelerations.filter(a => Math.abs(a) < 0.1).length;
  
  // 合理的加速度分布应该是:先正(加速)、后接近0(匀速)、最后负(减速)
  const total = accelerations.length;
  const firstThird = accelerations.slice(0, Math.floor(total / 3));
  const lastThird = accelerations.slice(Math.floor(total * 2 / 3));
  
  const avgFirst = firstThird.reduce((a, b) => a + b, 0) / firstThird.length;
  const avgLast = lastThird.reduce((a, b) => a + b, 0) / lastThird.length;
  
  // 正常情况:前半段加速度为正,后半段为负
  const hasNaturalPattern = avgFirst > 0.05 && avgLast < -0.05;

  return {
    hasNaturalPattern,
    score: hasNaturalPattern ? 100 : 30,
    details: {
      positiveRatio: positiveAccel / total,
      negativeRatio: negativeAccel / total,
      avgFirstPhase: avgFirst,
      avgLastPhase: avgLast
    }
  };
}

第五道防线:时间窗口控制

这就像是我们给验证过程设置了一个"有效期"。验证码token生成后,如果在极短时间内就提交验证,或者拖了很久才提交,都可能是异常行为。

时间窗口策略

// 服务端时间窗口验证
async function verifyTimeWindow(token, clientTimestamp) {
  const captchaData = await redis.get(`captcha:${token}`);
  if (!captchaData) {
    return { valid: false, reason: 'Token expired or invalid' };
  }

  const data = JSON.parse(captchaData);
  const serverTime = Date.now();
  const createdAt = data.createdAt;
  
  // 检查token是否在有效期内(5分钟)
  if (serverTime - createdAt > 5 * 60 * 1000) {
    return { valid: false, reason: 'Token expired' };
  }

  // 检查客户端提交时间是否合理(防重放攻击)
  const timeOnClient = clientTimestamp - createdAt;
  if (timeOnClient < 200) { // 小于200ms,太快了
    return { valid: false, reason: 'Suspiciously fast completion' };
  }
  if (timeOnClient > 4 * 60 * 1000) { // 超过4分钟
    return { valid: false, reason: 'Suspiciously slow completion' };
  }

  return { valid: true };
}

实战演示:企业级实现方案

说了那么多理论,现在来上硬菜。这是一个基于 Node.js + Redis 的企业级滑块验证码实现方案,参考了 GitHub 上热门的 kartikmehta8/captcha 项目架构。

技术栈

  • Node.js >= 16: 服务端运行环境
  • Express: Web框架
  • Redis >= 6: 状态存储和限流
  • Canvas: 图片生成
  • Joi: 参数校验

项目结构

captcha-service/
├── src/
│   ├── config/
│   │   └── index.js          # 配置文件
│   ├── controllers/
│   │   └── captcha.js        # 验证码控制器
│   ├── services/
│   │   ├── captcha.js        # 核心服务逻辑
│   │   └── validator.js      # 行为分析器
│   ├── utils/
│   │   ├── image.js          # 图片生成工具
│   │   └── crypto.js         # 加密工具
│   └── app.js                # 应用入口
├── package.json
└── README.md

核心代码实现

1. 验证码生成服务

// src/services/captcha.js
const crypto = require('crypto');
const { createCanvas } = require('canvas');
const redis = require('../config/redis');

class CaptchaService {
  constructor() {
    this.width = 300;
    this.height = 150;
    this.sliderWidth = 50;
    this.sliderHeight = 50;
    this.tolerance = 5; // 误差容忍度 ±5px
  }

  // 生成验证码
  async generate() {
    const token = crypto.randomBytes(16).toString('hex');
    
    // 随机生成滑块目标位置(留出边距)
    const targetX = Math.floor(Math.random() * (this.width - this.sliderWidth - 40)) + 20;
    const targetY = Math.floor(Math.random() * (this.height - this.sliderHeight - 40)) + 20;

    // 生成背景图和滑块图
    const { bgImage, sliderImage } = await this.generateImages(targetX, targetY);

    // 存储验证码数据到Redis(5分钟过期)
    const captchaData = {
      targetX,
      targetY,
      createdAt: Date.now(),
      attempts: 0
    };
    await redis.setex(`captcha:${token}`, 300, JSON.stringify(captchaData));

    return {
      token,
      bgImage: bgImage.toString('base64'),
      sliderImage: sliderImage.toString('base64'),
      sliderWidth: this.sliderWidth,
      sliderHeight: this.sliderHeight
    };
  }

  // 生成图片
  async generateImages(targetX, targetY) {
    const canvas = createCanvas(this.width, this.height);
    const ctx = canvas.getContext('2d');

    // 绘制背景(随机噪点 + 干扰线)
    this.drawBackground(ctx);

    // 创建滑块形状(圆形缺口)
    const sliderCanvas = createCanvas(this.sliderWidth, this.height);
    const sliderCtx = sliderCanvas.getContext('2d');

    // 绘制滑块槽
    this.drawSliderSlot(ctx, targetX, targetY);

    // 提取滑块区域
    this.extractSlider(sliderCtx, ctx, targetX, targetY);

    return {
      bgImage: canvas.toBuffer('image/png'),
      sliderImage: sliderCanvas.toBuffer('image/png')
    };
  }

  drawBackground(ctx) {
    // 填充背景色
    ctx.fillStyle = '#f0f0f0';
    ctx.fillRect(0, 0, this.width, this.height);

    // 添加噪点
    for (let i = 0; i < 100; i++) {
      ctx.fillStyle = `rgba(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 0.3)`;
      ctx.fillRect(Math.random() * this.width, Math.random() * this.height, 2, 2);
    }

    // 添加干扰线
    ctx.strokeStyle = 'rgba(100, 100, 100, 0.2)';
    for (let i = 0; i < 5; i++) {
      ctx.beginPath();
      ctx.moveTo(Math.random() * this.width, Math.random() * this.height);
      ctx.lineTo(Math.random() * this.width, Math.random() * this.height);
      ctx.stroke();
    }
  }

  drawSliderSlot(ctx, x, y) {
    ctx.globalCompositeOperation = 'destination-out';
    ctx.beginPath();
    ctx.arc(x + this.sliderWidth / 2, y + this.sliderHeight / 2, this.sliderWidth / 2, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalCompositeOperation = 'source-over';

    // 添加高亮边框
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(x + this.sliderWidth / 2, y + this.sliderHeight / 2, this.sliderWidth / 2, 0, Math.PI * 2);
    ctx.stroke();
  }

  extractSlider(sliderCtx, bgCtx, x, y) {
    // 从背景中提取滑块区域
    const imageData = bgCtx.getImageData(x, 0, this.sliderWidth, this.height);
    sliderCtx.putImageData(imageData, 0, 0);
  }
}

module.exports = new CaptchaService();

2. 行为分析验证器

// src/services/validator.js
class BehaviorValidator {
  constructor() {
    // 各维度权重配置
    this.weights = {
      trajectory: 0.3,    // 轨迹非线性
      speed: 0.25,        // 速度变化
      acceleration: 0.25, // 加速度模式
      timeWindow: 0.2     // 时间窗口
    };

    // 阈值配置
    this.thresholds = {
      minTrajectoryPoints: 10,    // 最少轨迹点数
      maxLinearDeviation: 0.15,   // 最大线性偏差
      minSpeedVariance: 0.5,      // 最小速度方差
      minCompletionTime: 200,     // 最小完成时间(ms)
      maxCompletionTime: 10000    // 最大完成时间(ms)
    };
  }

  // 综合验证
  async validate(trajectory, finalX, finalY, captchaData, clientTimestamp) {
    const results = {
      position: this.validatePosition(finalX, finalY, captchaData),
      trajectory: this.validateTrajectory(trajectory),
      speed: this.validateSpeed(trajectory),
      acceleration: this.validateAcceleration(trajectory),
      timeWindow: this.validateTimeWindow(captchaData.createdAt, clientTimestamp, trajectory)
    };

    // 计算综合得分
    const totalScore = Object.keys(this.weights).reduce((sum, key) => {
      return sum + (results[key].score * this.weights[key]);
    }, 0);

    // 位置验证必须通过
    const isValid = results.position.valid && totalScore >= 70;

    return {
      valid: isValid,
      score: Math.round(totalScore),
      details: results
    };
  }

  // 位置验证
  validatePosition(x, y, captchaData) {
    const xDiff = Math.abs(x - captchaData.targetX);
    const yDiff = Math.abs(y - captchaData.targetY);
    const tolerance = 5;

    const valid = xDiff <= tolerance && yDiff <= tolerance;

    return {
      valid,
      score: valid ? 100 : 0,
      details: { xDiff, yDiff, targetX: captchaData.targetX, targetY: captchaData.targetY }
    };
  }

  // 轨迹验证
  validateTrajectory(trajectory) {
    if (trajectory.length < this.thresholds.minTrajectoryPoints) {
      return {
        valid: false,
        score: 0,
        reason: `Too few trajectory points: ${trajectory.length}`
      };
    }

    // 计算轨迹非线性度
    const deviations = [];
    for (let i = 2; i < trajectory.length; i++) {
      const p1 = trajectory[i - 2];
      const p2 = trajectory[i - 1];
      const p3 = trajectory[i];

      const angle1 = Math.atan2(p2.y - p1.y, p2.x - p1.x);
      const angle2 = Math.atan2(p3.y - p2.y, p3.x - p2.x);
      const deviation = Math.abs(angle2 - angle1);
      deviations.push(deviation);
    }

    const avgDeviation = deviations.reduce((a, b) => a + b, 0) / deviations.length;
    const isLinear = avgDeviation < this.thresholds.maxLinearDeviation;

    // 非线性度越高,得分越高(人类特征)
    const score = Math.min(100, avgDeviation * 200);

    return {
      valid: !isLinear,
      score,
      details: { avgDeviation, pointCount: trajectory.length, isLinear }
    };
  }

  // 速度验证
  validateSpeed(trajectory) {
    const speeds = [];
    for (let i = 1; i < trajectory.length; i++) {
      const prev = trajectory[i - 1];
      const curr = trajectory[i];
      const distance = Math.sqrt(
        Math.pow(curr.x - prev.x, 2) + Math.pow(curr.y - prev.y, 2)
      );
      const timeDiff = curr.t - prev.t;
      if (timeDiff > 0) {
        speeds.push(distance / timeDiff);
      }
    }

    if (speeds.length === 0) {
      return { valid: false, score: 0, reason: 'No speed data' };
    }

    const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
    const variance = speeds.reduce((sum, speed) => {
      return sum + Math.pow(speed - avgSpeed, 2);
    }, 0) / speeds.length;

    const isConstantSpeed = variance < this.thresholds.minSpeedVariance;

    // 检查是否有加速-减速过程
    let hasAccelDecel = false;
    if (speeds.length > 10) {
      const mid = Math.floor(speeds.length / 2);
      const firstHalf = speeds.slice(0, mid);
      const secondHalf = speeds.slice(mid);
      const avgFirst = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
      const avgSecond = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
      hasAccelDecel = Math.abs(avgFirst - avgSecond) > avgSpeed * 0.2;
    }

    const score = (!isConstantSpeed && hasAccelDecel) ? 100 : 
                  (!isConstantSpeed || hasAccelDecel) ? 70 : 30;

    return {
      valid: !isConstantSpeed,
      score,
      details: { variance, hasAccelDecel, avgSpeed, isConstantSpeed }
    };
  }

  // 加速度验证
  validateAcceleration(trajectory) {
    const speeds = [];
    for (let i = 1; i < trajectory.length; i++) {
      const prev = trajectory[i - 1];
      const curr = trajectory[i];
      const distance = Math.sqrt(
        Math.pow(curr.x - prev.x, 2) + Math.pow(curr.y - prev.y, 2)
      );
      const timeDiff = curr.t - prev.t;
      if (timeDiff > 0) {
        speeds.push({ speed: distance / timeDiff, time: curr.t });
      }
    }

    if (speeds.length < 2) {
      return { valid: false, score: 0, reason: 'Insufficient data' };
    }

    const accelerations = [];
    for (let i = 1; i < speeds.length; i++) {
      const speedDiff = speeds[i].speed - speeds[i - 1].speed;
      const timeDiff = speeds[i].time - speeds[i - 1].time;
      if (timeDiff > 0) {
        accelerations.push(speedDiff / timeDiff);
      }
    }

    if (accelerations.length === 0) {
      return { valid: false, score: 0, reason: 'No acceleration data' };
    }

    // 分析加速度模式
    const total = accelerations.length;
    const firstThird = accelerations.slice(0, Math.floor(total / 3));
    const lastThird = accelerations.slice(Math.floor(total * 2 / 3));

    const avgFirst = firstThird.reduce((a, b) => a + b, 0) / firstThird.length || 0;
    const avgLast = lastThird.reduce((a, b) => a + b, 0) / lastThird.length || 0;

    // 正常模式:前半段加速(正加速度),后半段减速(负加速度)
    const hasNaturalPattern = avgFirst > 0.03 && avgLast < -0.03;

    const score = hasNaturalPattern ? 100 : 
                  (avgFirst > 0 || avgLast < 0) ? 60 : 20;

    return {
      valid: hasNaturalPattern,
      score,
      details: { avgFirst, avgLast, hasNaturalPattern }
    };
  }

  // 时间窗口验证
  validateTimeWindow(createdAt, clientTimestamp, trajectory) {
    const serverTime = Date.now();
    
    // 检查Redis中的token是否在有效期
    if (serverTime - createdAt > 5 * 60 * 1000) {
      return { valid: false, score: 0, reason: 'Token expired' };
    }

    // 检查客户端声称的完成时间
    const claimedDuration = clientTimestamp - createdAt;
    if (claimedDuration < this.thresholds.minCompletionTime) {
      return { valid: false, score: 0, reason: 'Suspiciously fast' };
    }
    if (claimedDuration > this.thresholds.maxCompletionTime) {
      return { valid: false, score: 0, reason: 'Suspiciously slow' };
    }

    // 验证轨迹时间和声称时间是否一致(防篡改)
    if (trajectory.length > 0) {
      const trajectoryDuration = trajectory[trajectory.length - 1].t;
      const timeDiff = Math.abs(trajectoryDuration - claimedDuration);
      if (timeDiff > 1000) { // 相差超过1秒,可能造假
        return { valid: false, score: 0, reason: 'Time mismatch' };
      }
    }

    return { valid: true, score: 100, details: { duration: claimedDuration } };
  }
}

module.exports = new BehaviorValidator();

3. Express控制器

// src/controllers/captcha.js
const captchaService = require('../services/captcha');
const behaviorValidator = require('../services/validator');
const redis = require('../config/redis');
const Joi = require('joi');

const verifySchema = Joi.object({
  token: Joi.string().required(),
  x: Joi.number().required(),
  y: Joi.number().required(),
  trajectory: Joi.array().items(
    Joi.object({
      x: Joi.number().required(),
      y: Joi.number().required(),
      t: Joi.number().required()
    })
  ).required(),
  clientTimestamp: Joi.number().required()
});

class CaptchaController {
  // 获取验证码
  async getCaptcha(req, res) {
    try {
      // 限流检查(可选)
      const clientIp = req.ip;
      const rateKey = `rate:${clientIp}`;
      const requestCount = await redis.incr(rateKey);
      if (requestCount === 1) {
        await redis.expire(rateKey, 60); // 1分钟过期
      }
      if (requestCount > 10) {
        return res.status(429).json({ error: 'Too many requests' });
      }

      const captcha = await captchaService.generate();
      res.json(captcha);
    } catch (error) {
      console.error('Generate captcha error:', error);
      res.status(500).json({ error: 'Failed to generate captcha' });
    }
  }

  // 验证验证码
  async verifyCaptcha(req, res) {
    try {
      // 参数校验
      const { error, value } = verifySchema.validate(req.body);
      if (error) {
        return res.status(400).json({ error: error.details[0].message });
      }

      const { token, x, y, trajectory, clientTimestamp } = value;

      // 获取存储的验证码数据
      const captchaDataStr = await redis.get(`captcha:${token}`);
      if (!captchaDataStr) {
        return res.status(400).json({ 
          valid: false, 
          error: 'Captcha expired or invalid' 
        });
      }

      const captchaData = JSON.parse(captchaDataStr);

      // 检查尝试次数
      captchaData.attempts = (captchaData.attempts || 0) + 1;
      if (captchaData.attempts > 3) {
        await redis.del(`captcha:${token}`);
        return res.status(400).json({ 
          valid: false, 
          error: 'Too many attempts' 
        });
      }
      await redis.setex(`captcha:${token}`, 300, JSON.stringify(captchaData));

      // 执行综合验证
      const validationResult = await behaviorValidator.validate(
        trajectory, x, y, captchaData, clientTimestamp
      );

      if (validationResult.valid) {
        // 验证通过,删除token并颁发访问token
        await redis.del(`captcha:${token}`);
        
        // 生成临时访问token(用于后续业务请求)
        const accessToken = require('crypto').randomBytes(32).toString('hex');
        await redis.setex(`access:${accessToken}`, 600, 'verified');

        res.json({
          valid: true,
          score: validationResult.score,
          accessToken
        });
      } else {
        res.json({
          valid: false,
          score: validationResult.score,
          reason: validationResult.details,
          remainingAttempts: 3 - captchaData.attempts
        });
      }
    } catch (error) {
      console.error('Verify captcha error:', error);
      res.status(500).json({ error: 'Verification failed' });
    }
  }
}

module.exports = new CaptchaController();

4. 前端集成示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>滑块验证码演示</title>
    <style>
        .captcha-container {
            position: relative;
            width: 300px;
            margin: 50px auto;
            user-select: none;
        }
        .captcha-bg {
            width: 300px;
            height: 150px;
            border-radius: 4px;
        }
        .slider-track {
            position: relative;
            width: 300px;
            height: 40px;
            margin-top: 10px;
            background: #e0e0e0;
            border-radius: 20px;
        }
        .slider-btn {
            position: absolute;
            left: 0;
            top: 0;
            width: 40px;
            height: 40px;
            background: #fff;
            border: 1px solid #ccc;
            border-radius: 50%;
            cursor: pointer;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .slider-btn::before {
            content: '→';
            font-size: 18px;
            color: #666;
        }
        .slider-text {
            position: absolute;
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            color: #999;
            font-size: 14px;
        }
        .success {
            background: #52c41a !important;
            border-color: #52c41a !important;
        }
        .success::before {
            content: '✓';
            color: white;
        }
        .failed {
            background: #ff4d4f !important;
            border-color: #ff4d4f !important;
        }
    </style>
</head>
<body>
    <div class="captcha-container">
        <img id="bgImage" class="captcha-bg" alt="验证码背景">
        <div class="slider-track">
            <div id="sliderBtn" class="slider-btn"></div>
            <div class="slider-text">拖动滑块完成验证</div>
        </div>
    </div>

    <script>
        class SliderCaptcha {
            constructor() {
                this.token = null;
                this.trajectory = [];
                this.startTime = null;
                this.isDragging = false;
                this.sliderBtn = document.getElementById('sliderBtn');
                this.bgImage = document.getElementById('bgImage');
                this.trackWidth = 260; // 可拖动范围
                
                this.init();
            }

            async init() {
                await this.loadCaptcha();
                this.bindEvents();
            }

            async loadCaptcha() {
                try {
                    const response = await fetch('/api/captcha');
                    const data = await response.json();
                    
                    this.token = data.token;
                    this.bgImage.src = `data:image/png;base64,${data.bgImage}`;
                    this.sliderData = data;
                } catch (error) {
                    console.error('Failed to load captcha:', error);
                }
            }

            bindEvents() {
                this.sliderBtn.addEventListener('mousedown', this.onDragStart.bind(this));
                document.addEventListener('mousemove', this.onDragMove.bind(this));
                document.addEventListener('mouseup', this.onDragEnd.bind(this));

                // 移动端触摸事件
                this.sliderBtn.addEventListener('touchstart', this.onDragStart.bind(this));
                document.addEventListener('touchmove', this.onDragMove.bind(this));
                document.addEventListener('touchend', this.onDragEnd.bind(this));
            }

            onDragStart(e) {
                this.isDragging = true;
                this.startTime = Date.now();
                this.trajectory = [];
                this.startX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
                this.sliderStartLeft = this.sliderBtn.offsetLeft;
            }

            onDragMove(e) {
                if (!this.isDragging) return;

                const clientX = e.type.includes('touch') ? e.touches[0].clientX : e.clientX;
                const deltaX = clientX - this.startX;
                let newLeft = this.sliderStartLeft + deltaX;

                // 限制范围
                newLeft = Math.max(0, Math.min(newLeft, this.trackWidth));
                this.sliderBtn.style.left = newLeft + 'px';

                // 记录轨迹点
                const timestamp = Date.now() - this.startTime;
                this.trajectory.push({
                    x: newLeft,
                    y: 0, // 简化处理,假设Y不变
                    t: timestamp
                });
            }

            async onDragEnd(e) {
                if (!this.isDragging) return;
                this.isDragging = false;

                const finalX = this.sliderBtn.offsetLeft;
                const finalY = 0;

                try {
                    const response = await fetch('/api/captcha/verify', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            token: this.token,
                            x: finalX,
                            y: finalY,
                            trajectory: this.trajectory,
                            clientTimestamp: Date.now()
                        })
                    });

                    const result = await response.json();
                    this.handleResult(result);
                } catch (error) {
                    console.error('Verification failed:', error);
                    this.reset();
                }
            }

            handleResult(result) {
                if (result.valid) {
                    this.sliderBtn.classList.add('success');
                    document.querySelector('.slider-text').textContent = '验证成功';
                    console.log('验证通过,得分:', result.score);
                    
                    // 可以在这里触发后续业务逻辑
                    if (result.accessToken) {
                        localStorage.setItem('captchaToken', result.accessToken);
                    }
                } else {
                    this.sliderBtn.classList.add('failed');
                    document.querySelector('.slider-text').textContent = 
                        `验证失败,还剩${result.remainingAttempts || 0}次机会`;
                    
                    setTimeout(() => {
                        this.reset();
                    }, 1500);
                }
            }

            reset() {
                this.sliderBtn.style.left = '0px';
                this.sliderBtn.classList.remove('success', 'failed');
                document.querySelector('.slider-text').textContent = '拖动滑块完成验证';
                this.loadCaptcha(); // 重新加载验证码
            }
        }

        // 初始化
        new SliderCaptcha();
    </script>
</body>
</html>

部署运行

# 1. 安装依赖
npm install express redis canvas joi

# 2. 启动Redis
redis-server

# 3. 启动服务
node src/app.js

# 4. 访问测试
open http://localhost:3000

绕过与反制:攻防实战

说了那么多防御,我们也来看看攻击者是怎么想的。知己知彼,才能百战不殆。

常见的绕过方案

1. Puppeteer自动化破解

这是最基础的自动化方案,使用无头浏览器模拟人类操作。

// 攻击者视角(仅用于了解防御策略)
const puppeteer = require('puppeteer');

async function crackCaptcha() {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  
  await page.goto('http://target.com');
  
  // 获取滑块元素
  const slider = await page.$('.slider-btn');
  const sliderBox = await slider.boundingBox();
  
  // 模拟人类拖动(贝塞尔曲线)
  await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
  await page.mouse.down();
  
  // 使用贝塞尔曲线模拟非线性轨迹
  const targetX = sliderBox.x + 150; // 假设目标位置
  const steps = 50;
  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    // 贝塞尔曲线公式
    const x = sliderBox.x + (targetX - sliderBox.x) * (3 * t * t - 2 * t * t * t);
    const y = sliderBox.y + Math.sin(t * Math.PI) * 10; // 添加Y轴扰动
    await page.mouse.move(x, y);
    await page.waitForTimeout(10 + Math.random() * 20); // 随机延迟
  }
  
  await page.mouse.up();
}

防御策略

  • 检测 navigator.webdriver 属性
  • 分析轨迹的随机性(贝塞尔曲线过于平滑)
  • 检查鼠标事件的真实性
// 前端检测Puppeteer
function detectAutomation() {
  const indicators = [
    navigator.webdriver,
    window.callPhantom,
    window._phantom,
    window.Buffer,
    window.emit
  ];
  
  if (indicators.some(i => i)) {
    console.log('检测到自动化工具');
    return false;
  }
  return true;
}

2. AI视觉破解

使用计算机视觉技术识别缺口位置,然后直接拖动到位。

防御策略

  • 随机缺口形状(不只是圆形)
  • 干扰背景图案
  • 动态生成的缺口边缘

3. CAPTCHA农场(人工打码)

这是最难防御的攻击方式。攻击者雇佣真人手动完成验证码,然后出售验证token。

CAPTCHA农场流程:
1. 攻击者从农场购买验证token
2. 农场工人登录系统,手动完成验证
3. token被转卖给攻击者使用

防御策略

  • 轨迹相似度分析(同一工人的轨迹模式相似)
  • 设备指纹绑定(token只能在一台设备使用)
  • 地理位置分析(检测异常登录地点)
  • 行为关联分析(短时间大量相似轨迹)

进阶思考:对抗CAPTCHA农场

根据 2025 年 Multimedia Systems 的研究 "CAPTCHA farm detection and user authentication via mouse-trajectory similarity measurement",可以通过轨迹相似度来识别同一操作者的多次操作。

轨迹相似度算法

// 轨迹相似度计算(DTW算法简化版)
function calculateTrajectorySimilarity(traj1, traj2) {
  // 1. 归一化轨迹
  const normalized1 = normalizeTrajectory(traj1);
  const normalized2 = normalizeTrajectory(traj2);

  // 2. 计算DTW距离
  const dtwDistance = dynamicTimeWarping(normalized1, normalized2);

  // 3. 转换为相似度得分
  const similarity = 1 / (1 + dtwDistance);

  return similarity;
}

function normalizeTrajectory(trajectory) {
  // 归一化到0-1范围
  const xs = trajectory.map(p => p.x);
  const ys = trajectory.map(p => p.y);
  
  const minX = Math.min(...xs);
  const maxX = Math.max(...xs);
  const minY = Math.min(...ys);
  const maxY = Math.max(...ys);

  return trajectory.map(p => ({
    x: (p.x - minX) / (maxX - minX),
    y: (p.y - minY) / (maxY - minY),
    t: p.t / trajectory[trajectory.length - 1].t
  }));
}

function dynamicTimeWarping(seq1, seq2) {
  const n = seq1.length;
  const m = seq2.length;
  const dtw = Array(n + 1).fill(null).map(() => Array(m + 1).fill(Infinity));
  dtw[0][0] = 0;

  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= m; j++) {
      const cost = Math.sqrt(
        Math.pow(seq1[i - 1].x - seq2[j - 1].x, 2) +
        Math.pow(seq1[i - 1].y - seq2[j - 1].y, 2)
      );
      dtw[i][j] = cost + Math.min(dtw[i - 1][j], dtw[i][j - 1], dtw[i - 1][j - 1]);
    }
  }

  return dtw[n][m];
}

企业级防御体系

┌─────────────────────────────────────────────────────────┐
│                  企业级验证码防御体系                     │
├─────────────────────────────────────────────────────────┤
│  第一层: 基础验证                                        │
│  ├─ 位置验证                                            │
│  ├─ 时间窗口控制                                         │
│  └─ 尝试次数限制                                         │
├─────────────────────────────────────────────────────────┤
│  第二层: 行为分析                                        │
│  ├─ 轨迹非线性检测                                       │
│  ├─ 速度变化分析                                         │
│  └─ 加速度模式识别                                       │
├─────────────────────────────────────────────────────────┤
│  第三层: 智能风控                                        │
│  ├─ 设备指纹识别                                         │
│  ├─ 轨迹相似度聚类                                       │
│  └─ 异常行为模式识别                                     │
├─────────────────────────────────────────────────────────┤
│  第四层: 业务联动                                        │
│  ├─ 风险评分系统                                         │
│  ├─ 动态难度调整                                         │
│  └─ 二次验证触发                                         │
└─────────────────────────────────────────────────────────┘

总结

滑块验证码就像是一场没有硝烟的战争。你以为只是简单地"拖动一下",实际上背后是工程师们精心设计的五道防线在默默工作:

📝 核心要点回顾

  1. 位置验证:最基础的坐标校验,但要注意加密传输和误差容忍度

  2. 轨迹非线性检测:人类的"手抖"反而成了安全特征,机器过于完美的直线运动会被识破

  3. 速度变化分析:人类有加速-减速过程,机器人往往是匀速运动

  4. 加速度模式识别:符合物理规律的加速度曲线才是真正的"人类签名"

  5. 时间窗口控制:太快了是脚本,太慢了可能是人工打码,500ms-3s是"黄金时间"

💡 实战经验

  • 不要只依赖一层防护:单一检测很容易被绕过,多层检测叠加才能有效防御
  • 用户体验与安全性的平衡:阈值设置太严会误伤真实用户,太松则失去防护意义
  • 持续对抗:攻击者在进步,防御策略也要不断更新
  • 日志与监控:记录每次验证的详细数据,用于后续分析和模型优化

🔮 未来趋势

  • 机器学习融合:用AI对抗AI,通过行为模式训练识别模型
  • 多模态验证:结合点击、滑动、键盘操作等多维度行为
  • 无感验证:在用户无感知的情况下完成验证(如Google的reCAPTCHA v3)
  • 隐私保护:减少对用户行为的侵入式采集,保护用户隐私

滑块验证码看似简单,实则深藏不露。下次当你顺滑地完成一个滑块验证时,不妨想一想:这一秒钟,有多少代码在为你保驾护航,又有多少攻击者正在为突破这道防线而绞尽脑汁。

技术的攻防,永无止境。作为开发者,我们要做的,就是在便利性和安全性之间找到最佳平衡点。


参考链接

探索关于Spec 和Skills 的一些实战运用-Kiro篇

前两天刷掘金,看到一篇名为: 2025 Vibe Coding 元年:AI 编程的技术突破全景 的文章,这篇文章不仅详细剖析了近来编程领域可能的发展趋势,还意外地让我接触到了两个新的概念:Spec(规范)和Skills(技能)。在AI飞速发展的今天,这两个概念的引入显得尤为重要,它们不仅预示着编程规范的进一步标准化,也为技能提升提供了全新的路径。为了进一步理解这两个概念的实际应用,我动手进行了实践操作,下面就是我对这次探索的总结与思考。

或许我们可以实现:赋能 20%的的重要环节,提升整体 80%的效能???

一、你在用什么编辑器?

我的AI编辑器在今年一年内就换了三个,最开始是VScode, 然后是 Trae,现在使用的是Kiro,这一个月使用下来,还是蛮顺滑的,推荐 👍。

最开始是然.png

作为claude模型的金主,亚马逊的 kiro拥有claude 的最新模型:

有资料显示:截至2026年1月,亚马逊已向Anthropic(开发 Claude 人工智能模型的公司)累计投资80亿美元(2023年9月首投12.5亿,2024年3月追加27.5亿,2024年11月再投40亿)

截屏2026-02-04 16.49.07.png

下面的示例都是基于这个Kiro 编辑器,当然其他编辑器也有类似的功能,就需要自行探索了,工具本身就是服务于我们的工作,使用什么工具更顺手就用哪个喽!


二、先来了解几个常用的概念

SpecSkills 的关系

位置图片左.png

1. Spec‌标准性能评估组织,负责解释: 做什么事?

核心理念‌:采用“先计划,后执行”的流程,要求AI在编写代码前,先将任务理解、技术方案和详细拆解形成结构化文档(Doc和Tasks),经人工确认后再生成代码。 ‌

主要作用‌:将传统“黑盒直出”的编码模式变为“白盒化”阶段流程,通过引入“需确认”的缓冲阶段,大幅减少因理解偏差导致的无效工作和返工,提升代码生成的准确性。 ‌

适用场景‌:适用于从0到1的开发、技术方案原型产出、团队内部跨角色(如产品、前后端)沟通协作等场景。

就像项目需求文档、任务清单,大白话讲:就是你给我一个文件,告诉我这个项目怎么做

20260205-105320.jpeg

2. Skills 中文解释“技能”,就是教我"怎么做事"的规则手册。

就像我每次和AI对话时,总有一些特定的规则前提,不同的规则对应不同的prompt,根据关键词来触发我使用哪个内置的prompt,也可以设置每次对话都引入,比如下图:

就像你教一.png

3. 两者的关系

类比 1: 做菜 🍳

类比的步骤.png

关系:用烹饪技巧(Skills)来完成菜谱(Spec) 一个技巧可以用在很多菜谱上,一个菜谱需要用到多个技巧

类比 2: 盖房子 🏗️

类比的步骤 (1).png

关系: 按照施工规范(Skills)来实现建筑图纸(Spec) 规范是通用的,可以盖很多房子 图纸是具体的,只针对这一栋房子


4. 实际的工作流程-理论

假设场景:我们要开发字母拼写游戏

1. 你有一个 Spec(需求文档)📋 "创建字母拼写游戏"

  • 需要游戏组件
  • 需要游戏接口
  • 需要状态管理

2. 我使用 Skills(规则)来实现

创建组件时 → 应用 "React 组件生成 Skill"

✅ 按规范创建文件结构

✅ 按模板生成代码

✅ 遵循命名规范

添加接口时 → 应用 "API 接口生成 Skill"

✅ 检查是否重复

✅ 添加类型定义

✅ 添加完整注释

完成后 → 应用 "代码检查 Skill"

✅ 检查逻辑问题

✅ 检查性能问题

✅ 检查是否有重复代码

3. 完成 Spec✅ 字母拼写游戏开发完成 📊 对比表格

维度 Skills(技能) Spec(规格)
是什么 做事的方法 要做的事情
作用 教我怎么做 告诉我做什么
范围 通用的 具体的
时效 长期有效 一次性的
数量 少量(3-10个) 很多(每个功能一个)
位置 .kiro/steering/ .kiro/specs/
例子 "组件创建规范" "创建登录页面"
复用 可以复用到其他项目 只针对当前功能

三、下面就让我们用实际的AI编辑器来试试上面能力-实践:

在上面我们讨论了他们之间的关系,并使用示例图的形式展现出来,下面就是具体的实践示例。

首先是-省流最小示例:

.kiro/steering/
  ├── react-component-generator.md  ← "怎么创建组件"
  ├── api-service-generator.md      ← "怎么添加接口"
  └── code-review.md                ← "怎么检查代码"
Spec 的例子(具体需求)
.kiro/specs/
  └── letter-spelling-game/
      └── requirements.md           ← "做一个字母拼写游戏"   

接下来是使用Kiro的具体操作演示步骤:

1. 先来看 Spec

(1)发送指令前,先选择Spec模式:

截屏2026-02-04 17.08.35.png

(2)提出我的需求

prompt: 我想创建一个课堂互动小游戏,根据字母拼单词,帮我实现。

截屏2026-02-04 17.23.49.png

(3)生成具体的需求文档

提交提示词,生成具体的需求文档:

截屏2026-02-04 17.25.58.png

生成完成:

截屏2026-02-04 17.27.14.png

下面的文档我只截取了头部和尾部的部分,大家可以自己动手尝试一下:

# 需求文档

## 简介

课堂拼写游戏是一个教育类互动应用,旨在帮助学生通过游戏化的方式学习和练习英语单词拼写。系统会提供一组打乱顺序的字母,学生需要将这些字母重新排列组成正确的单词。

## 术语表

- **游戏系统(Game_System)**: 管理游戏流程、题目生成和答案验证的核心系统
- **题目(Question)**: 包含目标单词和打乱字母的游戏问题
- **单词库(Word_Bank)**: 存储可用于游戏的单词集合
- **学生(Student)**: 参与游戏的用户
- **回合(Round)**: 一次完整的答题过程,从显示题目到提交答案
- **得分系统(Scoring_System)**: 计算和记录学生表现的系统

## 需求

### 需求 1:题目生成

**用户故事:** 作为教师,我希望系统能够自动生成拼写题目,以便学生进行练习。

#### 验收标准

1. 当游戏开始时,游戏系统应当从单词库中随机选择一个单词
2. 当选择单词后,游戏系统应当将单词的字母打乱顺序
3. 当打乱字母时,游戏系统应当确保打乱后的字母顺序与原单词不同
4. 当生成题目时,游戏系统应当同时保存原始单词作为正确答案

### 需求 2:答案提交与验证

**用户故事:** 作为学生,我希望能够提交我的答案并立即知道是否正确,以便获得即时反馈。

#### 验收标准

1. 当学生输入答案时,游戏系统应当接受字母输入
2. 当学生提交答案时,游戏系统应当验证答案是否与正确单词完全匹配
3. 如果答案正确,那么游戏系统应当显示成功提示并增加得分
4. 如果答案错误,那么游戏系统应当显示错误提示并允许重试
5. 当验证答案时,游戏系统应当忽略大小写差异
..................
..................
..................
### 需求 12:响应式设计

**用户故事:** 作为教师,我想要游戏在不同设备上都能正常显示,以便在各种教学环境中使用。

#### 验收标准

1. 当游戏在大屏幕显示时(1920x1080或更高),游戏系统应当优化布局以充分利用屏幕空间
2. 当游戏在平板设备显示时,游戏系统应当调整字母按钮大小和间距以适应触摸操作
3. 当游戏在小屏幕设备显示时(手机),游戏系统应当采用垂直布局并调整字体大小
4. 当屏幕方向改变时,游戏系统应当在500毫秒内重新布局界面元素
5. 当游戏系统检测到触摸设备时,游戏系统应当增大可点击区域至少44x44像素以符合可访问性标准

(4)根据自己的需求进行微调

现在我们已经有了一个完整的需求文档以及具体的生成规则和验收流程,我们需要根据自己的具体需求进行调试,假设你还有其他的需求,也可以再添加到文档中,目的是为后续生成code 提供一个标准和依据。

然后我们就看到有一个这个 "Move to design phase" 意思就是进入设计阶段:

b8f72f95-4eae-43ec-831d-2ed632db2a78.png

(5)Move to design phase 进入设计阶段

现在它帮我生成了一个项目设计文档:

截屏2026-02-04 17.38.11.png

详细内容如下,当然在此步骤你依然可以对文档进行修改:

# 设计文档

![截屏2026-02-04 17.43.01.png](https://p9-juejin-sign.byteimg.com/tos-cn-i-k3u1fbpfcp/cc90dcbae9db4d05aaaad4bae399b9cf~tplv-k3u1fbpfcp-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg546W5pyI5pm056m6:q75.awebp?rk3s=f64ab15b&x-expires=1771226286&x-signature=9oE41Kqh%2BOwq0P%2BVKa5jwkpZ1dU%3D)
## 概述

课堂拼写游戏是一个基于 Web 的互动教育应用,采用客户端-服务器架构。系统的核心功能包括:随机生成拼写题目、验证学生答案、管理单词库、跟踪学习进度。设计强调简洁性、可扩展性和教育效果。

## 架构

系统采用三层架构:

1. **表示层(Presentation Layer)**: 负责用户界面渲染和用户交互
2. **业务逻辑层(Business Logic Layer)**: 处理游戏逻辑、答案验证、得分计算
3. **数据访问层(Data Access Layer)**: 管理单词库和用户数据的持久化

### 架构图


┌─────────────────────────────────────┐
│      表示层 (UI Components)          │
│  - 游戏界面                          │
│  - 单词库管理界面                     │
│  - 得分显示                          │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│      业务逻辑层 (Game Engine)        │
│  - QuestionGenerator                │
│  - AnswerValidator                  │
│  - ScoringSystem                    │
│  - HintProvider                     │
└──────────────┬──────────────────────┘
               │
┌──────────────▼──────────────────────┐
│      数据访问层 (Data Layer)         │
│  - WordBankRepository               │
│  - ScoreRepository                  │
│  - LocalStorage / Database          │
└─────────────────────────────────────┘


## 组件和接口

### 1. QuestionGenerator(题目生成器)

负责从单词库中选择单词并生成打乱字母的题目。

**接口:**

```typescript
interface QuestionGenerator {
  // 根据难度级别生成新题目
  generateQuestion(difficulty: DifficultyLevel): Question

  // 验证生成的题目是否有效
  validateQuestion(question: Question): boolean
}


**核心方法:**

- `selectWord(difficulty: DifficultyLevel): string` - 根据难度从单词库随机选择单词
- `shuffleLetters(word: string): string` - 打乱单词字母顺序
- `ensureDifferent(original: string, shuffled: string): string` - 确保打乱后与原单词不同

### 2. AnswerValidator(答案验证器)

验证学生提交的答案是否正确。

**接口:**

```typescript
interface AnswerValidator {
  // 验证答案是否正确(忽略大小写)
  validate(userAnswer: string, correctAnswer: string): boolean

  // 验证输入是否只包含字母
  validateInput(input: string): boolean
}
 

**核心方法:**

- `normalize(text: string): string` - 标准化文本(转小写、去空格)
- `isAlphabetic(text: string): boolean` - 检查是否只包含字母

### 3. ScoringSystem(得分系统)

计算和管理学生得分。

**接口:**

```typescript
interface ScoringSystem {
  // 计算答题得分
  calculateScore(isCorrect: boolean, hintsUsed: number, timeSpent: number): number

  // 更新总分
  updateTotalScore(score: number): void

  // 获取当前统计信息
  getStatistics(): GameStatistics
}
 

**得分规则:**

- 基础分:正确答案 10 分
- 提示惩罚:每使用一次提示减 2 分
- 时间奖励:快速答题(< 10 秒)额外 +3 分
- 最低分:每题最低 1 分

..........

(6)生成测试任务列表

截屏2026-02-04 17.44.28.png

现在有两个选项,一个是保持可选任务(更快的MVP),我选择的是: "将所有任务设为必须"

(7)任务完成

此时我们需要的需求文档,设计文档和测试文档都已经全了,里面告诉我们接下来项目具体细节是啥,那么后面我们就可以拿着这些文档喂给我们的项目,让他去设计页面了~

截屏2026-02-04 17.48.56.png


总结:

以上就是Spec的整个流程,以我的使用感受来看,这个Spec比较适用于从头开始的新项目,或者是从头开始一个完整的新需求,如果你是只是修修补补,还是建议使用Vibe模式,更轻便,更简洁。


2. 接下来是Skills

(1)手动创建定制化于自己项目的skill

我让Kiro帮我生成一个关于接口API接入的编写规范

截屏2026-02-06 15.24.26.png

截屏2026-02-06 15.24.46.png

这就是根据我当前项目全面扫描后,根据以后的规则生成的一个接偶编写规范

截屏2026-02-06 15.27.45.png

如果你有自己的规则,可以添加新的规则到这个文档中:

如下,我需要添加关于重复接口的检查

截屏2026-02-06 15.35.56.png

更新后的结果:

截屏2026-02-06 15.36.58.png


发起思考???

除了添加AI接口规范文档,我们想想平常自己哪方面的工作比较耗费时间,并且大部分是有一定规则,我们就可以通过skill的方式,一次性让AI了解我们的复杂的诉求,比如:“代码优化”、“创建新组件” “原型还原” 都可以是一套完整的规则,且也可以随着使用来完善你的规则,渐进明细,让他成为一个你靠谱的工作伙伴。

除了可以自己创建,现在市场上也有很多开源的Skills,可以供大家选择:

鱼皮大佬的AI导航网站的推荐 Skills

这 6 个超神的 SKills,在 GitHub 上杀疯了

除了工作需要,例如我们需要一个生成旅游攻略,我的要求如下:

我想生成一个旅游攻略的skill,根据我的输入城市,推荐该城市或者这个城市周边的5个景点,景点可以是: 古建筑、 博物馆、 非物质文化遗产 、遗址 、 著名的4A景点 或者是 老街 、 民俗、演出、大型文艺园区(例如:吴桥杂技大世界)、大型的情景剧演出 以及和人文相关的等 , 给出景点附近的华住集团的酒店参考,给3个,给出景点和景点之间的距离,单位km,  我默认是从北京自驾出发,如果行程过远(超过600km) ,提醒我是否要更换交通工具,如果筛选出的景点,在当前的时间季节不是最好的季节,或者可能不开放,也请提示我是否更换景点,或者更换城市,

生成后,我可以根据自己的想法,再进行修改:

帮我更新这个skill ,如果我提出去 某个城市旅游,请提问我旅游的天数,以及每天推荐一款该城市的著名小吃,或者 这个城市的主打的小吃,我刚刚添加了一些常住的酒店,帮我一起更新

下面是我生成的skill.md文件:

---
inclusion: manual
---

# 🗺️ 人文旅游攻略生成器

**触发条件:提示词包含"旅游"、"攻略"、"景点"、"出行"、"自驾"等关键词,或直接输入城市名称**

## 基本设定

- 出发地:北京(默认自驾)
- 当前日期:根据实际日期判断季节
- 旅游天数:**需先询问用户**
- 景点数量:根据天数动态调整(每天1-2个景点)
- 酒店品牌:华住集团旗下(全季、汉庭、桔子、水晶、CitiGO、漫心、花间堂、海友、禧玥等)
- 每个景点推荐3个附近华住酒店
- 每日小吃:每天推荐1款当地著名小吃或主打美食

## 景点筛选范围

优先推荐以下类型的人文景点(非自然风光类):

1. **古建筑**:古城、古镇、寺庙、祠堂、城楼、古塔等
2. **博物馆**:省级/市级博物馆、专题博物馆、纪念馆等
3. **非物质文化遗产**:传统技艺体验地、非遗展示馆等
4. **历史遗址**:古战场、古都遗址、考古遗址公园等
5. **4A/5A景区**:以人文类为主的高等级景区
6. **老街/古街**:历史文化街区、传统商业街等
7. **民俗体验**:民俗村、传统集市、地方特色活动等
8. **演出/剧场**:大型情景剧演出、实景演出(如《又见平遥》《印象系列》)
9. **大型文艺园区**:吴桥杂技大世界、横店影视城等主题文化园区
10. **其他人文景点**:名人故居、革命纪念地、宗教文化场所等

## 输出格式

```markdown
# 🗺️ [城市名] 人文旅游攻略

📍 出发地:北京
🚗 交通方式:自驾(或建议更换的交通方式)
📅 出行日期:[当前日期]
🌤️ 当地季节/天气提示:[季节信息]

---

## ⚠️ 出行提醒(如有)

> [距离超过600km的提醒]
> [季节/开放时间提醒]

---

## 📋 景点总览

| 序号 | 景点名称 | 类型 | 距北京(km) | 景点间距(km) |
|------|---------|------|-----------|-------------|
| 1 | xxx | xxx | xxx | - |
| 2 | xxx | xxx | xxx | 距景点1: xx |
| 3 | xxx | xxx | xxx | 距景点2: xx |
| 4 | xxx | xxx | xxx | 距景点3: xx |
| 5 | xxx | xxx | xxx | 距景点4: xx |

---

## 🏛️ 景点详情

### 1. [景点名称]
- **类型**:古建筑 / 博物馆 / 非遗 / 遗址 / 4A景区 / 老街 / 民俗 / 演出 / 文艺园区
- **地址**:xxx
- **门票**:xxx元(或免费)
- **开放时间**:xx:xx - xx:xx
- **推荐游览时长**:x小时
- **亮点**:一句话描述核心看点
- **季节提示**:⚠️(如当前季节不适宜或可能不开放,给出提示)

**🏨 附近华住酒店推荐:**

| 酒店名称 | 品牌 | 距景点(km) | 参考价格 |
|---------|------|-----------|---------|
| xxx | 全季/汉庭/桔子等 | x.x | ¥xxx/晚 |
| xxx | xxx | x.x | ¥xxx/晚 |
| xxx | xxx | x.x | ¥xxx/晚 |

---

(景点2-5 同上格式)

---

## 🛣️ 推荐行程路线

**Day 1**:北京出发  景点1  景点2
🏨 住宿:xxx酒店(品牌 | ¥xxx/晚)
🍜 今日小吃:**[小吃名称]**  [一句话介绍,如"西安回民街必吃,皮薄馅大汤汁鲜"] | 📍 推荐店铺:[店名]

**Day 2**:景点3  景点4
🏨 住宿:xxx酒店(品牌 | ¥xxx/晚)
🍜 今日小吃:**[小吃名称]**  [一句话介绍] | 📍 推荐店铺:[店名]

**Day 3**:景点5  返程
🍜 今日小吃:**[小吃名称]**  [一句话介绍] | 📍 推荐店铺:[店名]
...........

现在我们来尝试一下这个旅游的skill:

截屏2026-02-09 14.53.33.png

截屏2026-02-09 14.53.45.png

截屏2026-02-09 14.53.57.png

确实跟我自己搜索的差不多,除了清真寺和纪晓岚文化园我没有计划在内,其他几个还是值得一去的!内容确实包含了我需要的具体的景点信息,酒店信息,美食推荐以及景点和景点的相差公里等方面,还算符合我的要求,后面可以再根据自己的喜好调整即可,基本上是直接可用的状态。


回归主题,接下来我们看一下在工作中怎么配置?

你想让这个skill在什么时候加载?

1. 始终加载(默认,每次对话自动加载,不需要关键词)

  inclusion: always

什么时候生效?

  • 你在聊天框里问 AI 任何问题
  • 你让 AI 帮你写代码、改代码
  • 你让 AI 解释某段代码
  • Hook 触发后 AI 开始工作(因为 Hook 本质也是一次对话)
  • 你用 SpecAI 帮你做功能设计和实现

换句话说,任何你和 AI 之间的交互,AI 都会带着这些规则。它不是一个检查动作,而是 AI 的"常识大脑🧠"。


2. 匹配文件时加载

inclusion: fileMatch
fileMatchPattern: "src/services/**/*.ts"

什么时候生效?

只有当对话中涉及到匹配的文件时,AI 才能看到。比如你可以设置只有读取 services/**/*.ts 文件时才注入 xxxxx 规则


3. 手动引用时加载(聊天中用 # 引用)

inclusion: manual

什么时候生效?

你在聊天框里用 # 手动引用时才注入


那么此时又有一个问题:如果我是自己手动修改的bug,并没有使用AI,但我又想让他帮我检查一下,我此时要怎么办?

提示词如下:

请帮我code-review 当前代码 

或者以下几种都可以:

帮我检查代码 /  帮我审查代码  /  帮我优化代码

这样AI会看你当前激活的编辑器文件(就是你正在看的那个 tab),然后结合 steering 规则来审查。它检查的是那一个文件的全部内容,

那么如果你只想让他检查某个方法或者某个部分,

提示词这样说:

帮我 code-review 这个文件的 onMounted 部分

那么我想让他帮我精确只检查本次改动的部分,也就是git改动的所有文件,且只检查改动的部分的上下文,(虽然问题比较苛刻,但是有真实使用场景的,哈哈哈哈😂 ),那么提示词可以这样说:

帮我 code-review,用 git diff 查看本次改动,只检查我修改的部分和上下文,不要动没改的代码,有问题直接修

现在这样写提示词好长,每次都要写这么长好麻烦,那么我能点个按钮,就实现这个能力不?

结论是: 当然可以

下面引入一个新东西:Hooks

四、 什么是Hooks ?

KiroHooks 是一种自动化机制 — 当IDE 里发生特定事件时,自动触发 AI 执行某个动作。你可以把它理解为"事件监听器"。

1. 工作原理

一个 Hook 由两部分组成:

  • when(触发条件):什么时候触发?比如你保存了一个文件、创建了新文件、提交了消息等

  • then(执行动作):触发后做什么?比如让 AI 帮你检查代码、或者跑一个命令

2. 支持的触发事件

  1. fileEdited — 你保存文件时触发
  2. fileCreated — 你创建新文件时触发
  3. fileDeleted — 你删除文件时触发
  4. promptSubmit — 你在聊天框发消息时触发
  5. agentStopAI 执行完任务后触发
  6. userTriggered — 你手动点击按钮触发

3. hook规则文件的位置:

efcfcd69-e9e6-4732-a502-7b42d9a3c761.png

{
  "enabled": true,
  "name": "hook的名字",  // 它的名字
  "description": "描述",  // 这里是你对于当前hook 的规则描述
  "version": "1",
  "when": {
    "type": "userTriggered" // 触发时机:手动触发
  },
  "then": {
    "type": "askAgent",
    "prompt": "你可以自己写你的规则"  // 这里是你触发它的规则,比如你让让他按照什么规则做什么
  }
}

举个例子🌰:

比如你想实现"每次保存 .vue.ts 文件时,AI 自动按照你的 code-review-checklist 检查代码并修复问题",Hook 长这样:

 {
  "name": "代码审查",
  "version": "1.0.0",
  "description": "保存文件时自动按照审查规范检查代码问题并修复",
  "when": {
    "type": "fileEdited",
    "patterns": ["**/*.vue", "**/*.ts"]
  },
  "then": {
    "type": "askAgent",
    "prompt": "检查刚刚编辑的文件,按照 code-review-checklist 中的规范,重点检查:1. 定时器是否在 onUnmounted 中清除 2. 异步请求是否有 try-catch 3. 是否存在内存泄漏风险 4. 是否有防重复提交机制。如果发现问题,直接修复。"
  }
}
 

4. 如何触发:

这个 Hook 的效果就是:你每次保存 .vue.ts 文件,AI 会自动读取你改的文件,按照你项目里已有的审查规范检查,发现问题就帮你修。


再举个例子来callback上面的问题🌰:

我想要真正写完一个功能后,在提交git仓库前再检查,也就是上面的问题:我们想在完成了一段工作后,进行阶段性的检查,且不要全文检查,只针对修改部分的上下文,且实现只是点点按钮就能检查。

配置规则如下:

{
  "enabled": true,
  "name": "代码审查与修复",
  "description": "手动触发,按照 code-review-checklist 规范检查当前打开的文件,发现问题直接修复",
  "version": "1",
  "when": {
    "type": "userTriggered"
  },
  "then": {
    "type": "askAgent",
    "prompt": "执行以下步骤:\n\n1. 运行 `git diff` 获取本次所有未提交的改动\n2. 只关注我修改的代码及其上下文(修改行的前后相关逻辑),不要审查文件中未改动的部分\n3. 对每个改动,按照 code-review-checklist.md 规范检查:\n   - 新增的 setInterval/setTimeout 是否有对应的清除\n   - 新增的异步请求是否有 try-catch 和 loading 状态管理\n   - 新增的 watch 是否有防重复机制\n   - 修改部分是否存在空值风险(缺少可选链、数组判空等)\n   - 新增的按钮事件是否有防重复提交\n   - 修改部分的状态管理是否一致\n4. 只报告和修复改动部分引入的问题,已有代码即使不完美也不要动\n5. 修复后简要说明:改了哪个文件的哪部分、问题是什么、怎么修的"
  }
}

相比上面的自动规则,这个手动的hook如何触发?:

Kiro编辑器的最左侧找到这个方框中的图标,点击后找到你的Hooks 列表,在列表里找到你想要执行的 hook ,点击右侧的执行按钮即可解放双手!

3c5e7380-9ba1-4ee3-8dc0-32941b4d61a5.png

触发后它会变成一个执行中的状态:

18e2ee33-b198-4b87-aae1-b05ebf020da3.png

执行结束:

368d2360-e2de-4964-a9cc-39804281a553.png

小结:

现在是不是有种解放双手的快感?😂 我们用SkillsHook 联动,实现AI写代码自动检查是否符合我们的规范,在提交代码前检查当前的修改是否会对原逻辑造成伤害,大大减少了bug的产出。


关于AI你有哪些好用的工具也欢迎评论区留言,一起来探索!

前端向架构突围系列 - 状态数据设计 [8 - 4]:有限状态机 (FSM) 在复杂前端逻辑中的应用

写在前面

想象一下,你正在开发一个“自动驾驶”系统。

你绝不会用 if (isAccelerating && !isBraking) 来控制汽车。你会定义清晰的状态:DrivingBrakingParked。因为在 Parked(停车)状态下,踩油门是不应该有反应的。

前端业务逻辑其实就是一套交互系统。

遗憾的是,大部分前端代码都在用“零散的布尔值”来模拟状态,这导致逻辑极难测试,且隐藏着巨大的状态冲突风险。

真正优雅的架构,应该把逻辑抽象成一张

image.png


一、 为什么布尔值会导致“状态爆炸”?

假设你有一个极其简单的“搜索框”。

你定义了三个变量:loading (加载中)、error (报错)、results (数据)。

理论上这只有 3 个变量,但它们的组合可能有 23=82^3 = 8 种。

  • loading: true, error: true —— 这代表什么?一边报错一边加载?
  • loading: false, error: false, results: [] —— 这是初始状态?还是没搜到结果?

随着业务逻辑增加(比如增加了“取消请求”、“重试”、“分步校验”),布尔值的组合会指数级增长。这就是 “状态爆炸”


二、 有限状态机 (FSM) 的核心概念

有限状态机不是一个库,而是一个数学模型。它包含四个要素:

  1. State (状态): 你的应用当前在哪?(如:idleloadingsuccessfailure)。同一时间只能处在一个状态。
  2. Event (事件): 发生了什么?(如:SEARCHCANCELRESOLVE)。
  3. Transition (转换): 状态改变的规则。例如:在 loading 状态下收到 CANCEL 事件,转为 idle
  4. Action (动作): 转换时触发的副作用。例如:进入 loading 状态时,发起 API 请求。

三、 实战:用 XState 终结逻辑乱麻

在 JS 生态中,XState 是状态机的集大成者。我们来看如何重构一个复杂的“文件上传”逻辑。

3.1 定义状态图

与其写一堆 if/else,不如先把图画出来:

import { createMachine, interpret } from 'xstate';

const uploadMachine = createMachine({
  id: 'upload',
  initial: 'idle', // 初始:闲置
  states: {
    idle: {
      on: { SUBMIT: 'validating' } // 收到提交事件 -> 进入校验
    },
    validating: {
      on: {
        VALID_SUCCESS: 'uploading', // 校验成功 -> 开始上传
        VALID_FAIL: 'error'         // 校验失败 -> 报错
      }
    },
    uploading: {
      on: {
        FINISH: 'success',
        FAIL: 'error',
        CANCEL: 'idle' // 上传中途取消 -> 回到闲置
      }
    },
    success: {
      type: 'final' // 终态
    },
    error: {
      on: { RETRY: 'uploading' } // 报错后可以重试
    }
  }
});

3.2 架构层面的收益

  • 防御性编程:success 状态下,哪怕用户疯狂点击 SUBMIT 按钮,状态机也会自动忽略这个事件,因为 success 状态下没有定义处理 SUBMIT 的转换。
  • 逻辑可视化: XState 提供可视化工具,你可以直接把代码生成的图发给产品经理确认:“你看,这是不是我们要的业务流程?”

四、 什么时候该引入状态机?

并不是所有的组件都需要状态机。作为架构师,你需要识别**“高价值逻辑”**。

4.1 推荐使用场景:

  1. 多步骤流程: 注册流程、结账链路、多步表单。
  2. 复杂权限交互: 比如一个按钮,根据登录状态、用户等级、账户余额、活动是否开始,有五六种显示逻辑。
  3. 核心支付/上传: 绝不允许出现非法状态转换的场景。
  4. 游戏逻辑或复杂动效: 状态转换之间有严格的时间顺序。

4.2 什么时候不用?

简单的开关(Toggle)、单纯的 CRUD 列表展示,直接用 useState 或 TanStack Query 就足够了。


五、 状态机 vs. 状态管理库

这是很多人的误区。状态机不是 Redux 的替代品。

  • Redux/Zustand 是“仓库”:负责数据。
  • XState 是“大脑”:负责逻辑。

架构模式: 你可以在 Zustand 里面跑一个 XState。XState 负责计算当前应该是哪个状态,然后把最终的结果(如当前是哪个 Tab,要显示哪个文案)更新到 Zustand 中供全局使用。


结语:迈向“可预测”的架构

我们在第八阶段完成了对数据流的全面重构:

  1. 哲学上: 选择了适合业务的模式(Redux/Atomic)。
  2. 性能上: 理解了细粒度更新(Signals)的内核。
  3. 结构上: 剥离了 API 数据(TanStack Query)。
  4. 逻辑上: 用状态机(FSM)替代了布尔值地狱。

至此,你的前端应用已经像一台精密运行的瑞士钟表:每一滴水的流向(数据)都是可追踪的,每一个齿轮的转动(逻辑)都是可预测的。

Next Step:

下一个阶段,我们将跳出纯代码层面,进入**《第九阶段:前端工程化体系与全链路质量保障》 。 我们将探讨:如何搭建一套让 50 人的团队协作而不打架的 Monorepo 体系?如何设计自动化的 CI/CD 流程,让性能劣化和 Bug 在合并代码前就被击毙? 第一篇,我们将聊聊《架构的地基:基于 Turborepo 与 pnpm 的现代 Monorepo 企业级实战》**。

Sass实现,蛇形流动布局

效果展示

image.png

image.png

使用Sass构建智能流程图式网格布局系统

项目背景

在民宿康养微实训管理平台的学生模块中,我们需要实现一个直观的流程图式界面,用于展示实训任务的执行步骤。传统的CSS布局难以满足这种复杂的连线需求,因此我们设计了一套基于Sass的智能网格布局系统。

核心设计思路

1. 蛇形流动布局

系统采用蛇形流动设计,奇数行从左到右,偶数行从右到左,形成自然的视觉引导路径。

2. 伪元素连线技术

利用CSS伪元素(::before, ::after)动态生成连接线和箭头,避免额外的DOM元素。

3. 数学计算驱动

通过Sass的数学函数和循环,实现布局的自动化计算。

关键技术实现

Sass变量定义

$grid-columns: 4;                    // 网格列数
$max-grid-items: 30;                 // 最大网格项数
$gap-y: 34px;                        // 垂直间距
$line-ab-top: 20px;                  // 连线顶部偏移

智能位置计算

@for $i from 1 through $max-grid-items {
  $row: math.ceil(math.div($i, $grid-columns));           // 计算行号
  $col_in_row: $i - ($row - 1) * $grid-columns;           // 计算列号
  $direction: if($row % 2 == 1, $col_in_row, $grid-columns - $col_in_row + 1); // 蛇形方向
}

四种连线模式处理

1. 单行中间项(水平右向)
// 水平虚线 + 右箭头
&::after { border-top: 1px dashed #626c85; }
&::before { content: $arrow-right-svg; }
2. 双行中间项(水平左向)
// 水平虚线 + 左箭头(反向)
&::after { left: calc((100% - $item-inner-width - $line-gap) * -1); }
&::before { content: $arrow-left-svg; }
3. 单行最右侧(垂直向下转弯)
// 垂直弯曲线 + 下箭头
&::after { 
  height: calc(100% + $gap-y);
  border-radius: 0 12px 12px 0;
  border-left: none;
}
4. 双行最左侧(垂直向上转弯)
// 垂直弯曲线 + 上箭头
&::after {
  height: calc(100% + $gap-y);
  border-radius: 12px 0 0 12px;
  border-right: none;
}

技术亮点

1. Data URI图标嵌入

使用Data URI格式嵌入SVG箭头,减少HTTP请求:

$arrow-right-svg: url("data:image/svg+xml;utf8,<svg>...</svg>");

2. 响应式计算

所有尺寸都基于变量计算,便于维护和调整:

width: calc(100% - $item-inner-width - ($line-gap * 2));

3. 边界条件处理

自动识别首尾项,避免多余的连线:

&:last-of-type::after { content: none; }
&:last-of-type::before { content: none; }

Vue组件集成

模板结构

<template>
  <div class="bg-[#016cff1a] p-20">
    <div class="lk-grid pl-40!">
      <div v-for="item in 15" :key="item" class="grid-item">
        <div class="inner">展示{{ item }}</div>
      </div>
    </div>
  </div>
</template>

样式作用域

使用scoped属性确保样式隔离,避免全局污染。

性能优化考虑

  1. 伪元素性能:CSS伪元素比额外DOM元素性能更好
  2. 计算缓存:Sass编译时完成所有计算,运行时无性能开销
  3. 图标优化:内联SVG避免图标加载延迟

扩展性设计

系统支持通过修改变量轻松调整:

  • 修改$grid-columns改变列数
  • 调整$max-grid-items支持更多步骤
  • 更改颜色变量适配不同主题

总结

这套Sass网格布局系统成功解决了复杂流程图的可视化需求,通过数学计算和CSS伪元素的巧妙结合,实现了高度可定制且性能优良的布局方案。该技术方案可以广泛应用于工作流、进度跟踪、步骤引导等场景。

技术栈:Vue 3 + Sass + CSS Grid + 伪元素技术 适用场景:流程图、工作流、步骤引导、进度展示

完整代码

<style lang="scss" scoped>
@use 'sass:math';
$grid-columns: 7;
$max-grid-items: 30; //线条滚动的个数
$gap-y: 34px;
$line-ab-top: 20px;
// 右箭头:Data URI 格式(直接复制到 content 中使用)
$arrow-right-svg: url("data:image/svg+xml;utf8,<svg viewBox='0 0 24 24' fill='%23626c85' xmlns='http://www.w3.org/2000/svg'><path d='M8 5v14l11-7z'/></svg>");
// 左箭头:Data URI 格式(直接复制到 content 中使用)
$arrow-left-svg: url("data:image/svg+xml;utf8,<svg  viewBox='0 0 24 24' fill='%23626c85' xmlns='http://www.w3.org/2000/svg'><path d='M16 19l-7-7 7-7v14z'/></svg>");
$arrow-width: 24px;
$arrow-height: 24px;

$item-height: 116px;
$item-inner-width: 50px;

// 线段留下的缝隙
$line-gap: 10px;

@mixin grid-container() {
  display: grid;
  grid-template-columns: repeat($grid-columns, 1fr);
  row-gap: $gap-y;
}

@mixin grid-item() {
  height: $item-height;
  width: 100%;
  position: relative;
  // outline: 1px solid #ff0000;
}
@mixin grid-items-layout() {
  // 先线性布局,给每一个item确定位置
  @for $i from 1 through $max-grid-items {
    $row: math.ceil(math.div($i, $grid-columns)); //确定几行
    $col_in_row: $i - ($row - 1) * $grid-columns; //确定在第几行的第几列
    // 使用 % 操作符进行模运算判断奇偶性
    $direction: if($row % 2 == 1, $col_in_row, $grid-columns - $col_in_row + 1); //单数正x,双数反x
    &:nth-child(#{$i}) {
      grid-column: #{$direction};
      grid-row: #{$row};

      // 伪元素,绘制线段箭头
      // 初始化
      &:last-of-type::after {
        content: none;
      }
      &:last-of-type::before {
        content: none;
      }

      // 单行,除了左右侧的
      @if $i % ($grid-columns * 2) > 0 and $i % ($grid-columns * 2) < $grid-columns {
        // 线段
        &::after {
          content: '';
          position: absolute;
          top: $line-ab-top;
          left: calc($item-inner-width + $line-gap);
          display: block;
          width: calc(
            100% - $item-inner-width - ($line-gap * 2)
          ); //占满就是100% - $item-inner-width;但我们左右留个缝隙
          border-top: 1px dashed #626c85;
        }
        // 箭头
        &::before {
          content: $arrow-right-svg;
          position: absolute;
          // top: calc($line-ab-top - ($arrow-height/2));
          // right: calc(10px - ($arrow-width/2));
          top: $line-ab-top;
          right: $line-gap; //留的缝隙
          width: $arrow-width;
          height: $arrow-height;
          transform: translate(50%, -50%);
        }
      }
      // 双行,除了最左侧的
      @if $i % ($grid-columns * 2) > $grid-columns and $i % ($grid-columns * 2) < $grid-columns * 2
      {
        &:after {
          content: '';
          position: absolute;
          top: $line-ab-top;
          left: calc((100% - $item-inner-width - $line-gap) * -1);
          display: block;
          width: calc(
            100% - $item-inner-width - ($line-gap * 2)
          ); //占满就是100% - $item-inner-width;但我们左右留个缝隙
          border-top: 1px dashed #626c85;
        }
        &::before {
          content: $arrow-left-svg;
          position: absolute;
          left: calc(-100% + $line-gap + $item-inner-width);
          top: $line-ab-top;
          width: $arrow-width;
          height: $arrow-height;
          transform: translate(-50%, -50%);
        }
      }
      // 单行最右侧的
      @if $i % ($grid-columns * 2) == $grid-columns {
        &::after {
          content: '';
          position: absolute;
          top: $line-ab-top;
          left: calc($item-inner-width + $line-gap);
          display: block;
          width: 50%; //这个看着改
          height: calc(100% + $gap-y);
          border-radius: 0 12px 12px 0; //弧度看着改
          border: 1px dashed #626c85;
          border-left: none;
        }
        &::before {
          content: $arrow-left-svg;
          position: absolute;
          left: calc($item-inner-width + $line-gap);
          bottom: calc(($gap-y + $line-ab-top) * -1);
          width: $arrow-width;
          height: $arrow-height;
          transform: translate(-50%, 50%);
        }
      }
      // 双行最左侧
      @if $i % ($grid-columns * 2) == 0 {
        $box-width: 30%; //看着改
        &::after {
          content: '';
          position: absolute;
          top: $line-ab-top;
          left: calc(($box-width + $line-gap) * -1);
          display: block;
          width: $box-width; //这个看着改
          height: calc(100% + $gap-y);
          border-radius: 12px 0 0 12px; //弧度看着改
          border: 1px dashed #626c85;
          border-right: none;
        }
        &::before {
          content: $arrow-right-svg;
          position: absolute;
          left: calc($line-gap * -1);
          bottom: calc(($gap-y + $line-ab-top) * -1);
          width: $arrow-width;
          height: $arrow-height;
          transform: translate(-50%, 50%);
        }
      }
    }
  }
}
.lk-grid {
  @include grid-container();
  .grid-item {
    @include grid-item();
    @include grid-items-layout();
    .inner {
      width: $item-inner-width;
      height: 50px;
      background-color: #016cff;
    }
  }
}
</style>
<template>
  <div class="bg-[#016cff1a] p-20">
    <div class="lk-grid pl-40!">
      <div v-for="item in 15" :key="item" class="grid-item">
        <div class="inner">展示{{ item }}</div>
      </div>
    </div>
  </div>
</template>

前端视角下认识 AI Agent 和 LangChain

AI Agent 和 传统应用的区别

我们假设要开发一个对话机器人的应用。

对于传统对话机器人开发:核心在于后端业务逻辑、数据库设计、API 接口等技术栈。

而对于 AI Agent 对话机器人开发:核心变成了如何与大模型对话、如何优化提示词、如何管理对话流程。

核心在于 AI Agent 中,大模型是驱动这个程序核心,这也是为什么说 AI 改变了传统应用的开发模式。

AI Agent 的构成

现在大家常用一个经典公式来概括它的核心构成:

AI Agent = LLM(大脑) + Memory(记忆) + Planning(规划) + Tools(工具)

image.png

让我们来逐一拆解这四个核心组件:

  • LLM (大语言模型): Agent 的核心引擎,充当"大脑"的角色。它负责理解用户意图,进行推理、分析和决策。所有复杂的逻辑判断和语言理解,都由它来完成。
  • Planning (规划): Agent 的"思考框架"。当面对复杂任务时(比如"规划旅行"),Agent 需要将其分解成一系列可执行的小步骤(1. 查天气 → 2. 查酒店 → 3. 查景点 → 4. 规划行程)。这种任务分解和规划能力是 Agent 自主性的关键。
  • Memory (记忆): Agent 的"笔记本"。它能记住之前的交互历史、任务的中间结果,甚至过去的成功经验和失败教训。这使得 Agent 在多轮对话和长期任务中能保持上下文连贯,而不是只有"七秒钟记忆的金鱼脑袋"。
  • Tools (工具): Agent 的"双手",是它与现实世界交互的桥梁。无论是 API 调用(如查询天气)、数据库查询,还是近期热门的 MCP Server 概念,这些都属于工具的范畴。

为什么你需要一个 AI 开发框架

在传统开发中,不同的编程语言都有自己的“神器”:

  • Java 开发者会用 Spring 框架
  • Go 程序员会选择 Gin 框架
  • Python 开发者青睐 Django/Flask

那么在 AI 应用开发领域,什么是我们的“趁手兵器”呢?

目前 AI Agent 开发框架中最耀眼的一颗星,毫无疑问就是 LangChain。今天,就让我们一起来揭开它的神秘面纱!

 LangChain 是什么?一个生动比喻告诉你答案

我们使用一个例子,来说明 LangChain 为什么是 AI Agent 开发中必不可少的内容。

没有 LangChain 时,调用不同的大模型可能需要这样:

以下是调用 OpenAI 的 Nodejs 代码

import OpenAI from 'openai';

// 初始化客户端
const openai = new OpenAI({
  apiKey: 'your-openai-api-key',
});

// 调用 ChatGPT
async function callOpenAI() {
  try {
    const completion = await openai.chat.completions.create({
      model: "gpt-3.5-turbo",
      messages: [
        { role: "system", content: "You are a helpful assistant." },
        { role: "user", content: "Hello!" }
      ],
      temperature: 0.7,
    });

    console.log(completion.choices[0].message.content);
    return completion;
  } catch (error) {
    console.error('Error calling OpenAI:', error);
  }
}


// 调用 Claude
import Anthropic from '@anthropic-ai/sdk';

// 初始化客户端
const anthropic = new Anthropic({
  apiKey: 'your-anthropic-api-key',
});

async function callClaude() {xxx}

// 调用函数
callClaude();

而你使用 LangChain 后:

import { ChatOpenAI } from "@langchain/openai";
import { ChatAnthropic } from "@langchain/anthropic";
import 'dotenv/config';

// 注:import 'dotenv/config';作用是自动从项目根目录的 `.env` 文件中读取环境变量
// 例如 .env 文件中有配置 OPENAI_API_KEY = xxx, 那么我们就可以用 process.env.OPENAI_API_KEY 获取到值

// 统一的使用方式
const openai_llm = new ChatOpenAI({
  modelName: "gpt-4",
  temperature: 0.7,
  apiKey: process.env.OPENAI_API_KEY, // 从环境变量读取
});

const claude_llm = new ChatAnthropic({
  modelName: "claude-3-opus-20240229", // Claude 需要具体版本
  temperature: 0.7,
  apiKey: process.env.ANTHROPIC_API_KEY, // 从环境变量读取
});

// 调用方式完全一致!
async function callModels() {
  try {
    // 单个调用
    const response1 = await openai_llm.invoke("你好");
    const response2 = await claude_llm.invoke("Hello");
    
    console.log("GPT-4 回复:", response1.content);
    console.log("Claude 3 回复:", response2.content);
    
    return { response1, response2 };
  } catch (error) {
    console.error("调用模型时出错:", error);
    throw error;
  }
}

// 调用函数
callModels();

看到区别了吗?LangChain 就像是一个万能翻译器,无论底层用的是哪个大模型,都为你提供统一的 API 接口。

展示一下deepseek用langchain的调用方式:

js 版本, 注意下载 @langchain/core,@langchain/openai 包。

import { ChatOpenAI } from "@langchain/openai";

const apiKey = "sk-xx";

const llm = new ChatOpenAI({
  model: "deepseek-chat",
  temperature: 0,
  apiKey,
  configuration: {
    baseURL: "https://api.deepseek.com",
  },
  // other params...
});

const run = async () => {
  try {
    const response = await llm.invoke([
      {
        role: "user",
        content: "I love programming.",
      },
    ]);
    console.log("response:", response);
  } catch (error) {
    console.error("Error:", error);
  }
};

run();

拆解 LangChain:名字里藏着什么秘密?

Lang + Chain = 语言模型 + 链式调用

这个名字精准地描述了它的核心思想:

  • Lang:代表大语言模型(Large Language Model)
  • Chain:代表将不同组件像链条一样连接起来

合起来就是:通过链式架构将大模型能力连接到实际应用中。

前端开发者的福音

好消息是,LangChain 原生支持 TypeScript!这意味着前端开发者可以用自己熟悉的 JavaScript/TypeScript 来构建 AI 应用。

LangChain 还解决了哪些大模型的问题

大模型虽然强大,但也有一些“与生俱来”的局限性:

1. 信息过时问题

“今天北京的天气怎么样?”

大模型很可能会告诉你它不知道,因为它的训练数据截止到某个特定日期。这种时效性强的问题,单纯的大模型很难给出准确答案。

2. 无法联网

“帮我查一下特斯拉最新的股价”

大模型不能实时访问互联网(虽然有些模型后期增加了联网功能,但这通常是基于 LangChain 等技术实现的扩展)。

3. 私有知识盲区

“我们公司的产品定价策略是什么?”

大模型没有学习过你公司的内部文档,自然无法回答这类问题。

4. 无法调用外部服务

“帮我订一张明天去上海的机票”

大模型本身没有订票能力,它需要调用第三方的机票预订 API。

LangChain 如何解决这些问题?

针对上述问题,LangChain 提供了完整的解决方案:

解决方案矩阵

大模型的问题 LangChain 的解决方案 实际应用场景
信息过时 实时数据检索 新闻查询、股票价格、天气信息
无法联网 网络搜索工具 最新资讯、实时数据获取
私有知识盲区 文档加载与向量检索 企业内部知识库、专业文档问答
不能调用 API 工具调用集成 订票、支付、查询等实际业务

统一的多模型支持

LangChain 支持几乎所有主流大模型:

  • OpenAI GPT 系列
  • Anthropic Claude 系列
  • Google Gemini
  • 开源模型(Llama、Qwen等)

而且调用方式完全统一,让你可以轻松切换或同时使用多个模型。

关于可视化平台的思考

有些同学可能会问:“现在不是有 Coze、Dify 这类可视化 AI 应用搭建平台吗?为什么还要学编程去做大模型应用?”

这个问题问得很好!作为前端开发者,你一定很清楚低代码/无代码平台的优缺点:

可视化平台的优势

  • 上手快,无需编码
  • 快速原型验证
  • 适合简单场景

可视化平台的局限

  • 定制化能力有限
  • 复杂逻辑实现困难
  • 难以集成到现有系统
  • 性能和扩展性受限

就像你不会只用低代码平台来做复杂的前端项目一样,在需要深度定制和复杂集成的 AI 应用场景中,掌握 LangChain 这样的编程框架是必不可少的。

讲完 Langchain 是什么了,接下来我们了解下 AI Agent 开发我们常见遇到的 4 个场景,帮助我们了解到后续开发 AI Agent 可能遇到的架构是什么。

AI Agent 开发常见的 4 个场景

场景一:纯Prompt场景

纯Prompt场景是最基础、最直接的AI应用方式,就像使用搜索引擎一样简单:你输入问题(Prompt),AI直接返回答案。示意图如下:

image.png

// 纯Prompt场景的简单实现
import { ChatOpenAI } from "@langchain/openai";

const llm = new ChatOpenAI({
  modelName: "gpt-4",
  temperature: 0.7,
});

// 直接调用
const response = await llm.invoke("帮我写一首关于春天的诗");
console.log(response.content);

场景二:Agent + Function Call

什么是Agent + Function Call?

Agent是能够自主规划、决策和执行的智能体,Function Call让AI能够调用外部工具和API,真正"动手做事"。

简单举例,我们查询天气时,ai 肯定不知道当天天气是什么,此时,ai 可以调用一个函数,这个函数是我们后端自己写的,当问及查询天气的时候,就让大模型调我们的查询天气 api,然后将结果给大模型,最后大模型返回给用户。

image.png

场景三:RAG(检索增强生成)

简单来说,比如你们要做一个公司内部文档的 AI Agent,当你问这个 AI Agent 问题的时候,很多问题都是跟公司内部信息有关,这个大模型肯定是不知道的,所以我们提前把公司各种资料转化为大模型认识的格式(向量),保存在数据库中,当大模型回答有关公司问题的时候,先去我们的数据库查相关内容,然后找到相关信息,再把之前用户提问的问题增强一下。

增强是指,把从向量数据库搜索的一些资料也附带在问题中,最终大模型基于这些资料和用户的问题回答。

image.png

场景四:Fine-tuning(微调训练)

微调也很好理解,之前 RAG 是把我们公司数据库里的信息附带着给大模型,让大模型知道一些背景信息,而微调,是直接把数据库里的资料给大模型训练,让大模型学习到。

最后用户问问题的时候,就不需要 RAG 的模式,还去我们本地的数据库搜索了,直接就能回答跟公司相关的问题了。这就是微调!

image.png

其实目前实现 AI Agent 大部分就是上面四种类型 + 在可视化平台拖拉拽的方式。

vue3使用jsx语法详解

虽然最早是由 React 引入,但实际上 JSX 语法并没有定义运行时语义,并且能被编译成各种不同的输出形式。如果你之前使用过 JSX 语法,那么请注意 Vue 的 JSX 转换方式与 React 中 JSX 的转换方式不同,因此你不能在 Vue 应用中使用 React 的 JSX 转换。与 React JSX 语法的一些明显区别包括:

  • 可以使用 HTML attributes 比如 class 和 for 作为 props - 不需要使用 className 或 htmlFor
  • 传递子元素给组件 (比如 slots) 的方式不同

添加的配置

1️⃣ tsconfig

{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "vue"
  }
}

2️⃣ vite.config.ts

import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

export default {
  plugins: [vue(), vueJsx()]
}

代码演示

vue文件

<script setup lang="tsx">
import { computed, defineComponent, ref } from 'vue'

const count = ref(0)

// 1. 定义一个 JSX 片段或小组件
const RenderHeader = () => (
  <header>
    <h2>这是 JSX 渲染的标题</h2>
    <p>当前计数: {count.value}</p>
  </header>
)

// 2. 这是一个返回 VNode 的计算属性。搭配 component 使用
const renderContent = computed(() => {
  return count.value > 5 ? (
    <span>已达到上限</span>
  ) : (
    <button onClick={() => count.value++}>增加</button>
  )
})

// 3. 普通组件, setup返回一个渲染函数
const Bbb = defineComponent({
  name: 'Bbb',
  setup() {
    return () => <div>11111</div>
  },
})
</script>

<template>
  <RenderHeader />
  <component :is="renderContent" />
  <Bbb />
</template>

注意:lang的值是 tsx

tsx文件

// 函数式组件
export default () => {
  return <div class={styles.name}>hello world</div>
}

export const Aaa = defineComponent({
  setup() {
    const t = ref(Date.now())
    // 返回渲染函数
    return () => <div>aaa {t.value}</div>
  },
})

样式方案选型

使用 JSX/TSX,CSS ModulesTailwind CSS 是更好的搭档。Scoped CSS 是专为 Template 设计的。

在 vue文件 中,使用 CSS Modules

<style module>
.header {
  color: blue;
}

.content {
  color: green;
}

.bbb {
  color: red;
}
</style>

eslint

要在 vue文件 中使用tsx,应添加 configureVueProject 的配置

configureVueProject({ scriptLangs: ['ts', 'tsx'] })

export default defineConfigWithVueTs(
  {
    name: 'app/files-to-lint',
    files: ['**/*.{ts,mts,tsx,vue}'],
  },

  globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),

  pluginVue.configs['flat/essential'],
  vueTsConfigs.recommended,
  skipFormatting,
)

参考

Vue实现大屏获取当前所处城市及当地天气(纯免费)

之前做过的大屏项目一般是在内网环境中使用,所以一般顶部不牵扯展示城市和天气。但是这次做的是放在外网的,所以简单实现一下展示城市名称和当前实时天气信息。

获取当前所处位置

获取当前定位可以通过浏览器原生的Geolocation API获取经纬度信息。

function getLocation() {
return new Promise((resolve, reject) => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
resolve({lat, lon});
console.log('当前位置:', lat, lon);
},
(error) => {
console.error('无法获取位置:', error.message);
reject(error);
}
);
}
})
}

需要注意,Geolocation API是异步的,所以这里用 Promise 进行了简单的封装。另外这里只能获取到经纬度,无法获取到所在城市名称,需要通过逆地理编码的方式实现。

逆地理编码

目前国内主要是御三家提供逆地理编码的服务,但是都需要进行付费,不过唯一可以庆幸的是各家都有免费额度

如果你的大屏部署在外网,国外的Nominatim是完全免费的,咱们这边目前是无法访问的。官网地址也放一个nominatim.openstreetmap.org

这里我以腾讯的逆地理编码服务为例子简单写一下:

async function getCityName(lat, lon) {
const sig = CryptoJs.MD5(`/ws/geocoder/v1/?key=${你的key}&location=${lat},${lon}${你的签名}`).toString();
const {result} = await request.get(`/tencent/ws/geocoder/v1/?key=${你的key}&location=${lat},${lon}&sig=${sig}`);
return `${result.address_component.city}-${result.address_component.district}`
}

这里需要代理一下,我这里的代理如下:

'/tencent': {
    target: 'https://apis.map.qq.com',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/tencent/, '')
},

获取天气

天气信息目前咱们这边有和风天气,提供了一定的免费额度。

但是我在网上找到一个外面的,但是咱们这边能访问到的Open-Meteo,官方地址也放一下open-meteo.com

特点如下:

  • 无需注册、无 API Key、无调用限制
  • 支持全球经纬度实时天气 + 未来7天预报
  • 数据来源:欧洲中期天气预报中心(ECMWF)等权威机构

唯一不好的一点在于返回的都是英文数据,需要认为的转成中文。

// 部分天气中英文映射
const WEATHER_CODE_MAP = {
0: { en: 'Clear sky', zh: '晴' },
1: { en: 'Mainly clear', zh: '晴转多云' },
2: { en: 'Partly cloudy', zh: '多云' },
3: { en: 'Overcast', zh: '阴' },
45: { en: 'Fog', zh: '雾' },
48: { en: 'Depositing rime fog', zh: '冻雾' },
51: { en: 'Drizzle: Light', zh: '小雨' },
53: { en: 'Drizzle: Moderate', zh: '中雨' },
55: { en: 'Drizzle: Dense', zh: '大雨' },
61: { en: 'Rain: Slight', zh: '小雨' },
63: { en: 'Rain: Moderate', zh: '中雨' },
65: { en: 'Rain: Heavy', zh: '大雨' },
71: { en: 'Snow fall: Slight', zh: '小雪' },
73: { en: 'Snow fall: Moderate', zh: '中雪' },
75: { en: 'Snow fall: Heavy', zh: '大雪' },
95: { en: 'Thunderstorm', zh: '雷阵雨' },
96: { en: 'Thunderstorm with slight hail', zh: '雷阵雨伴小冰雹' },
99: { en: 'Thunderstorm with heavy hail', zh: '雷阵雨伴大冰雹' }
};
// 获取天气
async function getWeather(lat, lon) {
const result = await request.get(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current_weather=true`);
const code = result.current_weather.weathercode;
return WEATHER_CODE_MAP[code] || {en: 'Unknown', zh: '未知'};
}

这个是不需要代理的,因为Open-Meteo本身支持CORS

总结

获取经纬度和城市信息,以及天气信息在技术上没什么难度。

主要的问题点在于很多东西都是付费的,自己玩玩还好,但要是真的上线正式环境一定要防备被别人恶意刷爆。

之前我还见过一个大屏上实时展示天气状况,卫星地图上前面有一层蒙版展示。如果是下雨的话有雨滴打在屏幕上的感觉,效果非常不错,回头实现完分享给大家。

欢迎关注我的公众号李剑一,分享更多的干货,畅聊圈内八卦。

fetch-event-source源码解读

SSE协议规范

SSE协议实际上是在 HTTP 之上定义了一套严格的数据组织规范,是属于应用层的协议。

  • 固定请求头类型:必须是 Content-Type: text/event-stream
  • 固定文本格式:数据必须以 field: value\n 的形式返回
  • 固定结束符:消息之间必须以 \n\n 分隔。
{
    id: '', // 消息唯一标识,实现“断点续传”的关键
    event: '',  // 事件类型,缺失则默认触发 onmessage
    data: '', // 消息内容
    retry: undefined,  // 重连时间
}

fetchEventSource 执行流程

fetch-event-source底层是使用 fetch 配合 ReadableStream 实现的

1.建立 Fetch 长连接

fetch 读取到的是 Uint8Array(原始二进制字节码),需要手动转换为 SSE 字段。

自动补全 Accept: text/event-stream,确保后端返回正确格式。

2.流式读取

通过 response.body.getReader() 开启循环读取字节流。

3.字节解码与“缓冲区”拼装

网络传输中,数据包可能在任何地方断开(例如:data: hello 被切成了 data: hello)。 源码中维护了一个 buffer (Uint8Array) 和一个 position (当前偏移量),这是该库最核心的逻辑。

  • 缓冲区逻辑:库内部维护一个 buffer 字符串。新到的片段会拼接到 buffer 后。

    • 扫描 buffer 中是否存在 \n\n
    • 如果有,说明消息完整,切出来进行解析。
    • 如果没有,继续等待下一个数据块。

4.字段解析与分发

一旦识别出完整的消息块,它会按行拆分,解析出 data:, event:, id: 等字段,并手动调用你在配置中传入的 onmessage, onopen 等回调函数。

核心方法解析

1、fetchEventSource

export interface FetchEventSourceInit extends RequestInit {
    headers?: Record<string, string>,
    onopen?: (response: Response) => Promise<void>,
    onmessage?: (ev: EventSourceMessage) => void;
    onclose?: () => void;
    onerror?: (err: any) => number | null | undefined | void,
    openWhenHidden?: boolean;
    fetch?: typeof fetch; // 要使用的 Fetch 函数。默认为 window.fetch
}
export function fetchEventSource(input: RequestInfo, {
    signal: inputSignal,
    headers: inputHeaders,
    onopen: inputOnOpen,
    onmessage,
    onclose,
    onerror,
    openWhenHidden,
    fetch: inputFetch,
    ...rest
}: FetchEventSourceInit) {
    return new Promise<void>((resolve, reject) => {
        // 复制请求头,确保 accept 为 text/event-stream。
        const headers = { ...inputHeaders };
        if (!headers.accept) {
            headers.accept = EventStreamContentType;
        }

        // 当前请求的 AbortController
        let curRequestController: AbortController;
        function onVisibilityChange() {
            curRequestController.abort(); // 页面隐藏时中断请求
            if (!document.hidden) {
                create(); // 页面恢复可见时重新建立连接
            }
        }

        // 如果openWhenHidden为false,则监听文档可见性变化事件
        if (!openWhenHidden) {
            document.addEventListener('visibilitychange', onVisibilityChange);
        }

        // 资源清理
        let retryInterval = DefaultRetryInterval;
        let retryTimer = 0;
        function dispose() {
            // 清理事件监听
            document.removeEventListener('visibilitychange', onVisibilityChange);
            // 清理定时器和中断请求
            window.clearTimeout(retryTimer);
            // 中断当前请求
            curRequestController.abort();
        }

        // 如果外部传入了 abort 信号,响应中断并清理资源。
        inputSignal?.addEventListener('abort', () => {
            dispose();
            resolve(); // 不要浪费资源在重试上
        });

        // 使用传入的 fetch 实现(若有),否则使用全局的 window.fetch。
        const fetch = inputFetch ?? window.fetch;
        // 使用传入的 onopen 回调(用于验证/处理响应),没有则使用默认的 content-type 校验函数。
        const onopen = inputOnOpen ?? defaultOnOpen;
        async function create() {
            // 为本次请求创建 AbortController,用于后续中止当前请求(可见性变化或外部 abort)。
            curRequestController = new AbortController();
            try {
                // 发起 fetch 请求,合并剩余 init 配置、headers,并将当前 controller 的 signal 传入以便可中止。
                const response = await fetch(input, {
                    ...rest,
                    headers,
                    signal: curRequestController.signal,
                });

                await onopen(response);
                
                // 链式处理响应流:
                // getMessages返回按行组装成EventSourceMessage对象的online函数
                // 第一个参数是id,如果有id就写入headers中,用于在下次重连发送last-event-id
                // 第二个参数是retry,如果有retry就更新retryInterval,用于下次重连等待时间
                // 第三个参数是onmessage回调,用于处理完整的EventSourceMessage消息

                // getLines负责把响应体字节流按行切分,调用上面的online函数
                // getBytes负责从response.body中读取响应体的字节流,并把每块传入getLines进行处理,直到结束或终止
                await getBytes(response.body!, getLines(getMessages(id => {
                    if (id) {
                        // store the id and send it back on the next retry:
                        headers[LastEventId] = id;
                    } else {
                        // don't send the last-event-id header anymore:
                        delete headers[LastEventId];
                    }
                }, retry => {
                    retryInterval = retry;
                }, onmessage)));

                onclose?.(); // 流正常结束后,调用可选的 onclose 回调
                dispose(); // 清理(移除可见性监听、清除定时器、abort 当前 controller 等资源)
                resolve(); // 完成外部 Promise(表示工作完成,不再重试)。
            } catch (err) {
                // 只有在不是主动中止的情况下才考虑重试(如果是主动 abort,就不重试)
                if (!curRequestController.signal.aborted) {
                    try {
                        // 调用用户的 onerror 回调,允许其返回一个重试间隔(毫秒);若未提供或返回 undefined,则使用当前的 retryInterval(来自服务器 retry 字段或默认值)
                        const interval: any = onerror?.(err) ?? retryInterval;
                        window.clearTimeout(retryTimer); // 清除之前可能存在的重试定时器。
                        retryTimer = window.setTimeout(create, interval);
                    } catch (innerErr) {
                        dispose(); // 清理资源。
                        reject(innerErr); // 拒绝外部 Promise,结束整个流程并向调用者报告错误
                    }
                }
            }
        }

        create();
    });
}

2、 getBytes() :循环读取流信息

函数接收两个参数:stream和onChunk

  • stream:代表一个可读取的二进制数据流
  • onChunk: 是一个回调函数,每当从流中读取到一块数据时,就会调用这个函数,并将读取到的数据作为参数传递给这个函数。
export async function getBytes(stream: ReadableStream<Uint8Array>, onChunk: (arr: Uint8Array) => void) {
    const reader = stream.getReader(); // 创建一个流的阅读器 reader
    let result: ReadableStreamDefaultReadResult<Uint8Array>;
    while (!(result = await reader.read()).done) { // 循环读取数据块,直到流结束
        onChunk(result.value); // 对每个数据块调用回调, onChunk是 getLines()方法的返回值
    }
}

3、 getLines() :将字节块解析为EventSource行信息

接收一个回调函数 onLine 作为参数,并返回一个新的函数 onChunk。

  • onLine:每当检测到一行数据时就会调用它

    规定以`\r`、`\n` 或 `\r\n`作为一行结束的标志
    规定以`\n\n`或`  \r\n\r\n ` 作为一个消息结束的标志
    
  • onChunk:用于处理传入的字节块。逐个解析传入的字节块,找到数据中的行结束符。将字节块解析为 EventSource 行缓冲区,并在检测到完整行时调用 onLine 回调。

它解决了这样一个问题:网络传输的数据可能不是一行一行到达的,而是分块到达的,甚至一行可能被拆成多个块。这个函数负责把这些块拼起来,遇到换行符(\r、\n 或 \r\n)就认为是一行,然后把这一行交给 onLine 处理。

// 模拟字节块的返回
// 块1:data: hello\r\ndata: wo
// 块2:rld\r\n\r\nevent: update\r\ndata: 123\r\n
export function getLines(onLine: (line: Uint8Array, fieldLength: number) => void) {
    let buffer: Uint8Array | undefined;
    let position: number; // 当前读取位置
    let fieldLength: number; // 当前行中有效“字段”部分的长度
    let discardTrailingNewline = false; // 标记是否需要跳过紧跟在\r后的\n

    // 返回一个函数,处理每个字节块
    return function onChunk(arr: Uint8Array) {
        if (buffer === undefined) { // 初始化buffer、position、fieldLength,如果未定义也就是意味着这是第一次调用或者前一个缓存区已完全处理完毕
            buffer = arr;
            position = 0;
            fieldLength = -1;
        } else {
            // 如果buffer已定义(既正在处理一个较大的数据块或连续的数据块),将新的数据块arr追加到现有的buffer后面,主要处理前一个字节处理完还有剩余字节的情况
            buffer = concat(buffer, arr);
        }

        const bufLength = buffer.length;
        let lineStart = 0; // 当前行的起始位置
        while (position < bufLength) { // 遍历buffer,使用position指针来追踪当前读取的位置
            if (discardTrailingNewline) { // 如果设置了discardTrailingNewline标志,则跳过行结束符之后的新行字符,如果上次遇到\r,这次要跳过\n
                if (buffer[position] === ControlChars.NewLine) {
                    lineStart = ++position; // 跳过\n
                }
                
                discardTrailingNewline = false;
            }
            
            // 查找本行的结束符
            let lineEnd = -1;
            for (; position < bufLength && lineEnd === -1; ++position) {
                switch (buffer[position]) {
                    case ControlChars.Colon:
                        if (fieldLength === -1) { // 记录第一个冒号的位置
                            fieldLength = position - lineStart;
                        }
                        break;
                    case ControlChars.CarriageReturn: // \r
                        discardTrailingNewline = true; // 标记下次要跳过\n
                    case ControlChars.NewLine: // \n
                        lineEnd = position; // 行结束
                        break;
                }
            }

            if (lineEnd === -1) {
                // 没找到行结束符,等下一个字节块
                break;
            }

            // 取出完整的一行,调用 onLine,onLine是 getMessages()方法的返回值
            onLine(buffer.subarray(lineStart, lineEnd), fieldLength); // 获取完整的行,并调用onLine回调函数,处理这一行数据
            lineStart = position; // 下一行的起始位置
            fieldLength = -1; // 更新 fieldLength 为 -1,准备处理下一行的 field 部分
        }

        if (lineStart === bufLength) {
            buffer = undefined; // 全部处理完
        } else if (lineStart !== 0) {
             // 还有未处理的内容
            buffer = buffer.subarray(lineStart); // 把 buffer 变成还没处理完的部分,丢弃已经处理过的内容。这样下次新数据块到来时,可以直接拼接到剩余部分后面。
            position -= lineStart; // 更新 position 指针,保证它指向新的 buffer 的正确位置。其实可以直接置为0,因为新的 buffer 是从 lineStart 开始的,但是这样写更通用一些。防止极端情况下 position 指向错误。
        }
    }
}

4、getMessages():把 EventSource 行组装成完整的 SSE 消息对象

接收三个回调为参数:onIdonRetryonMessage,并返回一个新的函数onLine

  • onId:回调,在每次检测到消息 ID 时调用,传递 ID 字符串作为参数
  • onRetry:回调,在每次检测到重试时间时调用,传递重试时间的数值作为参数
  • onMessage:回调,在每次消息结束时调用,传递完整的消息对象作为参数
  • onLine:处理每一行的数据
export function getMessages(
    onId: (id: string) => void,
    onRetry: (retry: number) => void,
    onMessage?: (msg: EventSourceMessage) => void
) {
    let message = newMessage(); // 初始化一个空消息对象
    const decoder = new TextDecoder(); // 用于把字节数组解码为字符串。

    // 返回一个函数,每当解析出一行 EventSource 行时就会调用,(由 getLines 传入)
    return function onLine(line: Uint8Array, fieldLength: number) {
        if (line.length === 0) { // 如果是空行表示消息结束
            onMessage?.(message); // 调用 onMessage 回调,将完整的消息对象传递出去
            message = newMessage(); // 重置 message 对象
        } else if (fieldLength > 0) { // 如果这一行包含有效数据(即不是注释或空行),继续处理。
            // 解析字段名(field)和字段值(value)
            // 字段名是从行开头到冒号前的部分,字段值是冒号后面,可能有一个空格(协议允许),所以判断是否有空格决定偏移量。
            const field = decoder.decode(line.subarray(0, fieldLength));
            const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
            const value = decoder.decode(line.subarray(valueOffset));

            switch (field) {
                case 'data':
                    // 如果字段名是 data,把 value 加到 message.data 上
                    // 如果已经有 data,追加一行(\n),否则直接赋值
                    message.data = message.data
                        ? message.data + '\n' + value
                        : value; 
                    break;
                case 'event':
                    // 如果字段名是 event,设置消息的事件类型
                    message.event = value;
                    break;
                case 'id':
                    // 如果字段名是 id,设置消息的 id,并调用 onId 回调。
                    onId(message.id = value);
                    break;
                case 'retry':
                    // 如果字段名是 retry,尝试解析为整数,合法则设置消息的 retry 并调用 onRetry 回调
                    const retry = parseInt(value, 10);
                    if (!isNaN(retry)) { // per spec, ignore non-integers
                        onRetry(message.retry = retry);
                    }
                    break;
            }
        }
    }
}

源码流程详细图解

whiteboard_exported_image.png

总结

SSE 的本质是:一种基于文本行的、约定俗成的 HTTP Body 消费方式。 原生 API 是将这种消费方式『硬件化』在了浏览器里, 而 fetch-event-source 则是用 JS 工具将其『软件化』实现了一遍。

前端性能杀手竟然不是JS?图片优化才是绝大多数人忽略的"降本增效"方案

咱们先聊个扎心的事实:

你花三周时间把 Webpack 配置调到极致,Code Splitting 拆得比饺子馅还细,Tree Shaking 摇得比筛子还勤,结果首屏加载时间从 3.2 秒降到了 2.9 秒。你正准备庆祝的时候,产品经理给首屏 Banner 换了一张"超清大图",一下子又回到了 4.1 秒。

这不是段子,这是字节、阿里、腾讯的前端天天遇到的真实场景。

图片才是现代 Web 应用里最重的资源,没有之一。

但绝大多数前端工程师对图片优化的认知还停留在"用 WebP"、"开启 CDN 压缩"这种表层操作。真正能把图片优化做到极致的,往往是那些理解浏览器渲染机制、懂网络协议、会写 JavaScript 运行时优化的"杂家"。 今天这篇文章,咱们就从 JavaScript 的视角来重新审视图片优化,用代码把那些模糊的"最佳实践"变成可落地的工程方案。

第一层:懒加载不是设个 loading="lazy" 就完事了

原生懒加载的局限性

很多人以为图片懒加载就是这样:

<img src="photo.jpg" loading="lazy">

浏览器确实会帮你延迟加载,但这个策略完全不受你控制。浏览器决定什么时候加载,你只能接受。

真正的懒加载策略应该是:在图片进入视口前 200-500px 就开始预加载,这样用户滚动到位置时图片已经准备好了,既节省了带宽又保证了体验。

JavaScript 接管控制权

这时候 JavaScript 的 IntersectionObserver API 就派上用场了:

// 创建一个观察器,提前 200px 开始加载
const lazyObserver = new IntersectionObserver(
(entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;
      
      const img = entry.target;
      const realSrc = img.dataset.src;
      
      // 开始加载真实图片
      img.src = realSrc;
      
      // 加载完成后停止观察
      img.onload = () => {
        img.classList.add('loaded');
        lazyObserver.unobserve(img);
      };
    });
  },
  {
    // 关键参数:提前 200px 触发
    rootMargin: '200px 0px'
  }
);

// 批量观察所有待加载图片
document.querySelectorAll('img[data-src]').forEach(img => {
  lazyObserver.observe(img);
});

工作流程图:

用户滚动页面
     ↓
图片距离视口还有 200px
     ↓
IntersectionObserver 触发回调
     ↓
JavaScript 将 data-src 赋值给 src
     ↓
浏览器开始下载图片
     ↓
用户滚动到图片位置时
     ↓
图片已经加载完成 ✅

这种方式在电商网站的商品列表页特别有效。以某头部电商平台为例,他们的商品图在列表中使用 1px 占位符,滚动到距离视口 300px 时才开始加载真图,首屏图片请求数从 50 张降到 12 张,首屏渲染时间直接砍半。

降级策略

但问题来了:老浏览器不支持 IntersectionObserver 怎么办?

答案是渐进增强:

// 检测 API 支持情况
if ('IntersectionObserver' in window) {
  // 使用高级策略
  lazyObserver.observe(img);
} else {
  // 降级到原生懒加载
  img.loading'lazy';
  img.src = img.dataset.src;
}

第二层:根据设备和网络动态选择图片

屏幕分辨率不等于图片尺寸

很多人以为响应式图片就是写几个 srcset:

<img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w">

但这只考虑了屏幕宽度,没考虑 DPR(设备像素比)。iPhone 14 Pro 的屏幕宽度是 393px,但 DPR 是 3,实际需要的图片宽度是 393 × 3 = 1179px

用 JavaScript 动态计算才是正解:

function calculateOptimalImageWidth() {
// 获取设备像素比,默认为 1
const dpr = window.devicePixelRatio || 1;

// 获取视口宽度,限制最大值避免过大
const viewportWidth = Math.min(window.innerWidth, 1920);

// 计算实际需要的物理像素宽度
const physicalWidth = Math.ceil(viewportWidth * dpr);

return physicalWidth;
}

// 使用示例
const heroImage = document.querySelector('.hero-banner');
const optimalWidth = calculateOptimalImageWidth();

// 向 CDN 请求对应尺寸的图片
heroImage.src = `https://cdn.example.com/hero.jpg?w=${optimalWidth}`;

Network Information API:根据网络降级

现在进阶一步:根据用户的网络状况动态调整图片质量

function getImageQuality() {
// 检测 Network Information API 支持
const connection = navigator.connection || 
                     navigator.mozConnection || 
                     navigator.webkitConnection;

if (!connection) return80; // 默认质量

// 用户开启了流量节省模式
if (connection.saveData) {
    console.log('用户开启省流模式,降低图片质量');
    return40;
  }

// 根据网络类型调整质量
const effectiveType = connection.effectiveType;

const qualityMap = {
    'slow-2g'30,
    '2g'30,
    '3g'60,
    '4g'80,
    '5g'90
  };

return qualityMap[effectiveType] || 80;
}

// 完整的图片加载策略
function loadSmartImage(imageElement) {
const width = calculateOptimalImageWidth();
const quality = getImageQuality();

const imageUrl = new URL(imageElement.dataset.src);
  imageUrl.searchParams.set('w', width);
  imageUrl.searchParams.set('q', quality);

  imageElement.src = imageUrl.toString();

console.log(`加载图片: 宽度=${width}px, 质量=${quality}`);
}

真实场景:

某短视频 App 的移动端 Web 版,用户在地铁里用 4G 浏览时,图片质量默认 80%;一进隧道切到 3G,立刻降到 60%;用户主动开启省流模式,直接降到 40%。这套策略让他们的图片流量消耗降低了 35% ,用户投诉"费流量"的工单减少了一半。

第三层:解码优先级,别让图片阻塞渲染

图片解码是性能杀手

很多人不知道的冷知识:浏览器下载图片和解码图片是两回事

一张 500KB 的 JPEG,下载可能只要 200ms,但解码可能要 800ms。如果你在首屏同时加载 10 张大图,解码会完全阻塞主线程,导致页面卡顿。

异步解码救命

JavaScript 提供了 decoding 属性来控制解码策略:

// 首屏关键图片:同步解码,优先显示
const heroImage = document.querySelector('.hero');
heroImage.decoding'sync';     // 立即解码
heroImage.fetchPriority'high'; // 高优先级下载

// 非关键图片:异步解码,不阻塞渲染
const thumbnails = document.querySelectorAll('.thumbnail');
thumbnails.forEach(img => {
  img.decoding'async';         // 异步解码
  img.fetchPriority'low';      // 低优先级
});

解码策略对比:

同步解码 (sync):
下载图片 → 阻塞主线程 → 解码完成 → 渲染页面
    ↓
主线程被占用,页面卡顿 ❌

异步解码 (async):
下载图片 → 不阻塞 → 后台解码 → 解码完成后渲染
    ↓
主线程继续执行,页面流畅 ✅

预加载图片获取尺寸

还有一个高级技巧:在插入 DOM 前预加载图片,提前获取宽高比,避免 CLS(累积布局偏移)

async function preloadImageWithDimensions(src) {
returnnewPromise((resolve, reject) => {
    const img = new Image();
    
    img.onload = () => {
      resolve({
        element: img,
        width: img.naturalWidth,
        height: img.naturalHeight,
        aspectRatio: img.naturalWidth / img.naturalHeight
      });
    };
    
    img.onerror = reject;
    img.src = src;
  });
}

// 使用示例
const imageData = await preloadImageWithDimensions('/photo.jpg');

// 提前设置宽高比,避免布局偏移
const container = document.querySelector('.image-container');
container.style.aspectRatio = imageData.aspectRatio;

// 图片已经加载完成,直接插入
container.appendChild(imageData.element);

这招在动态生成内容的场景特别有用,比如用户上传头像、生成分享海报等,可以完全避免"图片加载后页面突然跳动"的问题。

第四层:客户端压缩,上传前就优化

为什么要在前端压缩图片?

传统思路是:用户上传 → 服务端压缩 → 存储到 CDN。

但这有几个问题:

  1. 用户上传 10MB 原图,流量浪费
  2. 服务端要处理大量压缩任务,CPU 成本高
  3. 用户要等服务端处理完才能看到预览

更好的方案是:前端直接压缩,上传压缩后的图片

Canvas API + OffscreenCanvas

JavaScript 的 Canvas API 可以实现客户端压缩:

async function compressImageOnClient(file, maxWidth = 1920) {
// 使用 createImageBitmap 读取文件
const bitmap = await createImageBitmap(file);

// 计算缩放比例
const scale = Math.min(1, maxWidth / bitmap.width);
const newWidth = Math.floor(bitmap.width * scale);
const newHeight = Math.floor(bitmap.height * scale);

// 使用 OffscreenCanvas 处理,不阻塞主线程
const canvas = new OffscreenCanvas(newWidth, newHeight);
const ctx = canvas.getContext('2d');

// 绘制缩放后的图片
  ctx.drawImage(bitmap, 00, newWidth, newHeight);

// 转换为 WebP 格式,质量 0.8
const blob = await canvas.convertToBlob({
    type'image/webp',
    quality0.8
  });

return blob;
}

// 用户上传图片时触发
document.querySelector('#upload').addEventListener('change'async (e) => {
const file = e.target.files[0];

console.log(`原始文件: ${(file.size / 10241024).toFixed(2)} MB`);

// 前端压缩
const compressed = await compressImageOnClient(file, 1920);

console.log(`压缩后: ${(compressed.size / 10241024).toFixed(2)} MB`);
console.log(`压缩率: ${((1 - compressed.size / file.size) * 100).toFixed(1)}%`);
  
  // 上传压缩后的图片
  uploadToServer(compressed);
});

实测数据(iPhone 拍摄的照片):

原始文件: 8.3 MB (4032 × 3024, JPEG)
     ↓
前端压缩 (1920px, WebP, quality=0.8)
     ↓
压缩后: 0.6 MB
压缩率: 92.8% ✅

Web Worker 优化

如果要处理多张图片,可以用 Web Worker 避免阻塞主线程:

// imageCompressor.worker.js
self.addEventListener('message', async (e) => {
const { file, maxWidth } = e.data;

const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxWidth / bitmap.width);

const canvas = new OffscreenCanvas(
    Math.floor(bitmap.width * scale),
    Math.floor(bitmap.height * scale)
  );

const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);

const blob = await canvas.convertToBlob({
    type: 'image/webp',
    quality: 0.8
  });

// 发送回主线程
  self.postMessage({ blob, originalSize: file.size });
});

// 主线程使用
const worker = new Worker('imageCompressor.worker.js');

worker.postMessage({ file: uploadedFile, maxWidth: 1920 });

worker.onmessage = (e) => {
const { blob, originalSize } = e.data;
const ratio = ((1 - blob.size / originalSize) * 100).toFixed(1);
console.log(`压缩完成,节省 ${ratio}% 流量`);

  uploadToServer(blob);
};

某社交平台用了这套方案后,用户上传图片的流量成本降低了 87% ,服务端 CPU 使用率降低了 60%。

第五层:Cache API 让图片真正"只加载一次"

HTTP 缓存的局限

浏览器的 HTTP 缓存很好,但有个问题:缓存策略完全由服务端控制,而且在隐私模式下会失效。

更激进的方案是用 Cache API 手动管理图片缓存:

const IMAGE_CACHE_NAME'image-cache-v1';

// 缓存图片
asyncfunction cacheImage(url) {
const cache = await caches.open(IMAGE_CACHE_NAME);

// 检查是否已缓存
const cached = await cache.match(url);
if (cached) {
    console.log(`命中缓存: ${url}`);
    return cached;
  }

// 未缓存,立即下载
console.log(`下载并缓存: ${url}`);
const response = await fetch(url);

// 只缓存成功的响应
if (response.ok) {
    await cache.put(url, response.clone());
  }

return response;
}

// 加载图片时使用缓存
asyncfunction loadImageWithCache(imgElement) {
const url = imgElement.dataset.src;

const response = await cacheImage(url);
const blob = await response.blob();

// 创建 Object URL 显示图片
  imgElement.src = URL.createObjectURL(blob);
}

缓存清理策略

Cache API 不会自动清理,需要手动控制缓存大小:

async function cleanOldCache(maxSize = 50 * 1024 * 1024) { // 50MB
const cache = await caches.open(IMAGE_CACHE_NAME);
const requests = await cache.keys();

let totalSize = 0;
const items = [];

// 统计每个缓存项的大小和时间
for (const request of requests) {
    const response = await cache.match(request);
    const blob = await response.blob();
    
    items.push({
      request,
      size: blob.size,
      url: request.url
    });
    
    totalSize += blob.size;
  }

// 超出限制,删除最早的缓存
if (totalSize > maxSize) {
    console.log(`缓存超限: ${(totalSize / 10241024).toFixed(2)} MB`);
    
    // 按时间排序,删除旧的
    items.sort((a, b) => a.url.localeCompare(b.url));
    
    let cleaned = 0;
    for (const item of items) {
      if (totalSize - cleaned < maxSize) break;
      
      await cache.delete(item.request);
      cleaned += item.size;
      console.log(`删除缓存: ${item.url}`);
    }
  }
}

// 定期清理
setInterval(cleanOldCache, 5 * 60 * 1000); // 每 5 分钟检查一次

真实效果:

某新闻 App 的 PWA 版本,使用 Cache API 后:

  • 二次访问图片加载时间从 800ms 降到 50ms
  • 离线状态下依然能浏览已访问过的图片
  • 用户流量消耗降低 70%

完整的图片优化工作流

把上面的技术组合起来,就是一套完整的图片优化系统:

1. 用户滚动页面
    ↓
2. IntersectionObserver 触发(提前 200px)
    ↓
3. JavaScript 检测网络状况(Network Info API)
    ↓
4. 计算最优尺寸和质量(DPR + 网络类型)
    ↓
5. 检查 Cache API 是否有缓存
    ↓
6. 如果有缓存 → 直接使用
   如果无缓存 → 向 CDN 请求
    ↓
7. 下载完成后存入 Cache API
    ↓
8. 设置 decoding='async' 异步解码
    ↓
9. 图片显示,避免 CLS

性能对比:优化前 vs 优化后

以某电商平台的商品详情页为例:

指标 优化前 优化后 提升
首屏图片请求数 18 张 6 张 ↓ 67%
图片总大小 4.2 MB 0.8 MB ↓ 81%
首屏渲染时间 3.8 秒 1.2 秒 ↓ 68%
CLS 评分 0.25 0.02 ↓ 92%
二次访问加载时间 2.1 秒 0.3 秒 ↓ 86%

这些数据不是实验室跑出来的,是真实线上环境、千万级 PV 验证过的。

写在最后

图片优化不是"换个格式"或"开个 CDN"那么简单,它是一套完整的运行时策略系统:

  1. 延迟加载:IntersectionObserver 精准控制
  2. 动态选择:根据设备和网络调整尺寸和质量
  3. 解码优化:async decoding 避免阻塞
  4. 客户端压缩:前端直接处理,节省流量和服务器成本
  5. 缓存管理:Cache API 手动控制,离线可用

这些技术的共同点是:JavaScript 掌握了主动权,不再被动依赖浏览器或 CDN 的默认行为。

但更重要的是,要理解为什么这么做

浏览器只关心字节数、解码时间、布局稳定性和渲染时机。你的每一行代码,都应该为这四个目标服务。

记住:性能不是锦上添花,性能本身就是功能。用户不会夸你的代码写得优雅,但会直接感受到你的页面是快是慢。

而图片优化,往往是性能优化中 ROI 最高的那个环节。

开源 Claude Code + Codex + 面板 的未来vibecoding平台

一句话介绍

CodeMoss =

  • 多AI联动:Claude Code + Codex + Gemini + OpenCode + ......
  • 多端使用:客户端 + Jetbrains + Vscode + 移动端
  • 多周边集成:AI面板 + AI记忆 + Superpowers + OpenSpec + Spec-kit + ...

说了这么多功能,直接放实机图更容易理解

image.png

image.png


总之一句话:CodeMoss 目标打造 下一代的vibecoding 入口

开源地址(感谢你的Star和推荐,这将让更多人用到)

github.com/zhukunpengl…


详细介绍

对话过程页面

image.png

侧边栏GIT模块

image.png

侧边栏文件管理模块

真的可以编辑哦~

image.png

面板模式

这不是普通的面板哦~,是真的可以并行执行任务,有完整交互的AI面板哦~

image.png

image.png

侧边栏展示

支持claude code + codex 多cli数据共同展示

image.png

终端展示

image.png

支持多平台

支持Mac 和 window 多平台


下载安装体验

功能太多了,就不赘述了,大家可以下载之后自行探索

下载地址(纯开源,无商业,放心食用):www.codemoss.ai/download


未来迭代

目前虽然能用,但是细节打磨的还不满意,我至少会每天迭代一个版本,先迭代100个版本,欢迎大家使用提出问题

开源地址(感谢你的Star和推荐,这将让更多人用到)

github.com/zhukunpengl…

再次声明:本项目完全开源,0商业,使用过程全程无广,请放心食用

在 VS Code中,vue2-vuex 使用终于有体验感增强的插件了。

Vuex Helper

适用于 Vuex 2 的 VS Code 插件,提供 跳转定义代码补全悬浮提示 功能。支持 State, Getters, Mutations 和 Actions。

引言

在 AI 时代,为什么要搞一个老掉牙的 vue2 的 vuex 增强插件?可以想象,现在起步应该都会是 vue3 或者 react 的框架。但老项目永远不会少,除非下定决心去重构,否则永远都要面对老项目,那在vscode中,遇到 vue2 项目的调试过程中,vuex 的跳转定义永远是我开发与迭代时遇到的痛点,AI 给了我机会,让我无需在繁重的业务需求之外,额外耗费太多的时间去学习插件怎么使用,而直接上手去把我的思路交予实现。感谢 AI,让我有能力去完成一些平时不可及的小事情。

功能特性

1. 跳转定义 (Go to Definition)

从组件中直接跳转到 Vuex Store 的定义处。

演示:跳转定义

jump_definition.gif

  • 支持: this.$store.state/getters/commit/dispatch
  • Map 辅助函数: mapState, mapGetters, mapMutations, mapActions
  • 命名空间: 完美支持 Namespaced 模块及其嵌套。

2. 智能代码补全 (Intelligent Code Completion)

智能提示 Vuex 的各种 Key 以及组件中映射的方法。

演示:智能补全

auto_tips_and_complete_for_var.gif

auto_tips_and_complete_for_func.gif

  • 上下文感知: 在 dispatch 中提示 Actions,在 commit 中提示 Mutations。
  • 命名空间过滤: 当使用 mapState('user', [...]) 时,会自动过滤并仅显示 user 模块下的内容。
  • 组件映射方法: 输入 this. 即可提示映射的方法(例如 this.increment 映射自 ...mapMutations(['increment']))。
  • 语法支持: 支持数组语法和对象别名语法 (例如 ...mapActions({ alias: 'name' }))。

3. 悬浮提示与类型推导 (Hover Information & Type Inference)

无需跳转即可查看文档、类型详情。

演示:悬浮文档

hover_info_and_type_inference.gif

  • JSDoc 支持: 提取并显示 Store 定义处的 /** ... */ 注释文档。
  • State 类型: 在悬浮提示中自动推导并显示 State 属性的类型 (例如 (State) appName: string)。
  • 详细信息: 显示类型(State/Mutation等)及定义所在的文件路径。
  • 映射方法: 支持查看映射方法的 Store 文档。

4. Store 内部调用 (Store Internal Usage)

同样支持在 Vuex Store 内部 代码补全、跳转、悬浮提示。

演示:Store 内部 代码补全、跳转、悬浮提示

internal_usage.gif

  • 模块作用域: 当在模块文件(如 user.js)中编写 Action 时,commitdispatch 的代码补全会自动过滤并仅显示当前模块的内容。

同样支持在 Vuex Store 内部 代码补全、跳转、悬浮提示。

支持的语法示例

  • 辅助函数 (Helpers):
    ...mapState(['count'])
    ...mapState('user', ['name']) // 命名空间支持
    ...mapActions({ add: 'increment' }) // 对象别名支持
    ...mapActions(['add/increment'])
    
  • Store 方法:
    this.$store.commit("SET_NAME", value);
    this.$store.dispatch("user/updateName", value);
    
  • 组件方法:
    this.increment(); // 映射自 mapMutations
    this.appName; // 映射自 mapState
    

使用要求

  • 使用 Vuex 的 Vue 2 项目。
  • Store 入口位于 src/store/index.jssrc/store/index.ts(支持自动探测)。
  • 若无法自动找到,请在设置中配置 vuexHelper.storeEntry

配置项

  • vuexHelper.storeEntry: 手动指定 Store 入口文件路径。支持:
    • 别名路径: @/store/index.js (需在 jsconfig/tsconfig 中配置)
    • 相对路径: src/store/index.js
    • 绝对路径: /User/xxx/project/src/store/index.js

更新日志

0.0.1

初始版本,支持功能:

  • 全面支持 State, Getters, Mutations, Actions
  • 支持命名空间过滤 (Namespace Filtering)
  • 支持 JSDoc 悬浮文档显示

「九九八十一难」第一难:前端数据mock指南(TS + VUE)

Vue3 + TypeScript 项目中使用 Mock 数据指南

背景

产品:这里有个需求,计划月底上线,你们评估下开发时间,保证月底能上线现网。

测试:我需要一周的测试时间,包括功能测试、性能测试、兼容性测试等。

UI:我需要一周时间,包括页面布局、交互设计、颜色方案等。

后端:我需要两周时间,包括数据库设计、接口开发、业务逻辑实现等。

前端:我走?

产品:你想想办法。

前端:我可以牺牲自己的开发时间,通过接口mock来并行开发,需要后端提前提供接口文档,我同步进行页面开发和逻辑实现。

为了需求正常上线,无私的前端又为自己找了个加班的机会。

前言

在前端开发过程中,我们经常会遇到后端接口尚未完成,但前端需要提前开发页面和功能的情况。 这时,使用 Mock 数据就成为了一种非常有效的解决方案。 本文将介绍如何在 Vue3 + TypeScript 项目中搭建和使用 Mock 数据。

什么是 Mock 数据

概念

Mock 数据是指在开发过程中,为了模拟后端接口返回的数据,而创建的虚假数据。

作用

  1. 并行开发:前端可以与后端同时开发,不需要等待后端接口完成

  2. 独立测试:可以模拟各种边界情况和错误场景

  3. 性能测试:可以模拟大量数据,测试前端性能

  4. 演示效果:在没有后端服务的情况下,也能展示完整的功能

优势

  1. 提高开发效率:减少等待后端接口的时间

  2. 增强代码健壮性:可以测试各种异常情况

  3. 改善团队协作:明确接口规范,减少沟通成本

  4. 简化测试流程:可以快速模拟各种场景

环境搭建

安装依赖

在 Vue3 + TypeScript 项目中,需要安装以下依赖:

# 安装 mockjs 库
npm install mockjs --save-dev

# 安装 vite-plugin-mock 插件
npm install vite-plugin-mock --save-dev

# 安装 @types/mockjs 类型定义(可选但推荐)
npm install --save-dev @types/mockjs

配置 Vite

vite.config.ts 文件中配置 vite-plugin-mock 插件:

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path";
import { viteMockServe } from "vite-plugin-mock";

export default defineConfig({
  plugins: [
    vue(),
    viteMockServe({
      // mock 文件存放路径
      mockPath: "./mock",
      // 启用 mock 功能
      enable: true,
      // 显示请求日志
      logger: true,
    }),
  ],
  resolve: {
    alias: {
      "@": resolve(__dirname, "./src"),
    },
  },
});

配置 TypeScript

tsconfig.app.json 文件中配置路径别名,确保 TypeScript 能够正确解析 @/ 路径:

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "paths": {
      "@/*": ["./src/*"]
    },
    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

配置 Mock 数据

快速开始 - 最简单的用例

模拟第一个简单接口

第一步:创建最简单的 Mock 接口

mock 文件夹下创建一个简单的 hello.ts 文件:

// mock/hello.ts
import { MockMethod } from "vite-plugin-mock";

export default [
  {
    url: "/api/hello",
    method: "get",
    response: () => {
      return {
        code: 200,
        message: "success",
        data: "Hello, Mock!",
      };
    },
  },
] as MockMethod[];
第二步:在组件中调用
<template>
  <div>
    <h2>{{ message }}</h2>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import axios from "axios";

const message = ref("");

onMounted(async () => {
  try {
    const response = await axios.get("/api/hello");
    message.value = response.data.data;
  } catch (error) {
    console.error("请求失败:", error);
  }
});
</script>

运行结果:页面显示 "Hello, Mock!"


中级用例 - 简单数据列表

这个用例展示如何返回一个简单的数据列表。

创建 Mock 接口

mock 文件夹下创建 products.ts 文件:

// mock/products.ts
import { MockMethod } from "vite-plugin-mock";

export default [
  {
    url: "/api/products",
    method: "get",
    response: () => {
      return {
        code: 200,
        message: "success",
        data: [
          { id: 1, name: "商品1", price: 99 },
          { id: 2, name: "商品2", price: 199 },
          { id: 3, name: "商品3", price: 299 },
        ],
      };
    },
  },
] as MockMethod[];
在组件中调用
<template>
  <div>
    <h2>商品列表</h2>
    <ul>
      <li v-for="product in products" :key="product.id">
        {{ product.name }} - ¥{{ product.price }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import axios from "axios";

interface Product {
  id: number;
  name: string;
  price: number;
}

const products = ref<Product[]>([]);

onMounted(async () => {
  try {
    const response = await axios.get("/api/products");
    products.value = response.data.data;
  } catch (error) {
    console.error("获取商品列表失败:", error);
  }
});
</script>

运行结果:页面显示商品列表,包含3个商品的信息。


进阶用例 - 用户数据管理

接下来,我们创建一个更完整的贴合业务场景的用例。

创建 Mock 文件

创建一个 user.ts 文件来模拟用户相关的接口:

// mock/user.ts
import { MockMethod } from "vite-plugin-mock";

export default [
  {
    url: "/api/user/list", // 接口路径
    method: "get", // 请求方法
    response: ({ query }: { query: Record<string, string> }) => {
      // 模拟分页数据
      const page = parseInt(query.page) || 1;
      const limit = parseInt(query.limit) || 10;
      const total = 100;

      // 生成模拟数据
      const list = [];
      for (let i = 0; i < limit; i++) {
        const index = (page - 1) * limit + i;
        if (index < total) {
          list.push({
            id: index + 1,
            name: `用户${index + 1}`,
            age: Math.floor(Math.random() * 30) + 18,
            email: `user${index + 1}@example.com`,
            createdAt: new Date().toISOString(),
          });
        }
      }

      return {
        code: 200,
        message: "success",
        data: {
          list,
          total,
          page,
          limit,
        },
      };
    },
  },
  {
    url: "/api/user/detail",
    method: "get",
    response: ({ query }: { query: Record<string, string> }) => {
      const id = query.id;

      return {
        code: 200,
        message: "success",
        data: {
          id,
          name: `用户${id}`,
          age: Math.floor(Math.random() * 30) + 18,
          email: `user${id}@example.com`,
          createdAt: new Date().toISOString(),
          address: "北京市朝阳区",
          phone: "13800138000",
        },
      };
    },
  },
  {
    url: "/api/user/create",
    method: "post",
    response: ({ body }: { body: Record<string, any> }) => {
      return {
        code: 200,
        message: "success",
        data: {
          id: Math.floor(Math.random() * 10000),
          ...body,
        },
      };
    },
  },
] as MockMethod[];

调用 Mock 数据

mock数据的调用使用 axios 实现,本文暂不做过多覆盖。

创建 API 服务

src/api 目录下创建 user.ts 文件,定义 API 调用函数:

// src/api/user.ts
import axios from "axios";

/**
 * 获取用户列表
 * @param params 分页参数
 * @returns Promise 响应数据
 */
export const getUserList = (params: { page: number; limit: number }) => {
  return axios.get("/api/user/list", { params });
};

/**
 * 获取用户详情
 * @param id 用户ID
 * @returns Promise 响应数据
 */
export const getUserDetail = (id: number) => {
  return axios.get("/api/user/detail", { params: { id } });
};

/**
 * 创建用户
 * @param data 用户数据
 * @returns Promise 响应数据
 */
export const createUser = (data: {
  name: string;
  age: number;
  email: string;
}) => {
  return axios.post("/api/user/create", data);
};

在组件中使用

在 Vue 组件中使用 API 服务调用 Mock 数据:

<template>
  <div class="user-list">
    <h2>用户列表</h2>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <ul>
        <li v-for="user in userList" :key="user.id">
          {{ user.name }} - {{ user.age }}岁 - {{ user.email }}
        </li>
      </ul>
      <div class="pagination">
        <button @click="changePage(1)" :disabled="currentPage === 1">
          首页
        </button>
        <button
          @click="changePage(currentPage - 1)"
          :disabled="currentPage === 1"
        >
          上一页
        </button>
        <span>第 {{ currentPage }} 页,共 {{ totalPages }} 页</span>
        <button
          @click="changePage(currentPage + 1)"
          :disabled="currentPage === totalPages"
        >
          下一页
        </button>
        <button
          @click="changePage(totalPages)"
          :disabled="currentPage === totalPages"
        >
          末页
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { getUserList } from "@/api/user";

// 响应式数据
const userList = ref<any[]>([]);
const loading = ref(false);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);

// 计算属性
const totalPages = computed(() => {
  return Math.ceil(total.value / pageSize.value);
});

/**
 * 获取用户列表数据
 */
const fetchUserList = async () => {
  loading.value = true;
  try {
    const response = await getUserList({
      page: currentPage.value,
      limit: pageSize.value,
    });
    userList.value = response.data.data.list;
    total.value = response.data.data.total;
  } catch (error) {
    console.error("获取用户列表失败:", error);
  } finally {
    loading.value = false;
  }
};

/**
 * 切换页码
 * @param page 页码
 */
const changePage = (page: number) => {
  currentPage.value = page;
  fetchUserList();
};

// 组件挂载时获取数据
onMounted(() => {
  fetchUserList();
});
</script>

常见用例和最佳实践

常见用例

  1. 分页数据:模拟带分页的列表数据

  2. 详情数据:模拟单个资源的详细信息

  3. 表单提交:模拟创建、更新操作

  4. 错误场景:模拟各种错误状态码和错误信息

  5. 文件上传:模拟文件上传接口

最佳实践

  1. 目录结构清晰:按模块组织 mock 文件

  2. 数据结构一致:与后端接口保持一致的数据结构

  3. 模拟真实场景:包括正常、异常、边界等各种场景

  4. 使用 TypeScript:为 mock 数据添加类型定义

  5. 注释完善:为复杂的 mock 逻辑添加注释

  6. 定期更新:根据后端接口变化及时更新 mock 数据

故障排除提示

  1. mock 数据不生效
  • 检查 vite.config.ts 中的 mockPath 配置是否正确

  • 检查 mock 文件是否在正确的目录下

  • 检查接口路径是否匹配

  1. TypeScript 类型错误
  • 确保安装了 @types/mockjs 类型定义

  • 为 mock 数据添加正确的类型注解

  1. 生产环境泄露
  • 确保在生产环境中禁用 mock 功能
  1. 性能问题
  • 避免在 mock 函数中执行复杂的计算

  • 对于大量数据,考虑使用分页或虚拟滚动

常见问题

1. 路径别名问题

问题:找不到模块 "@/api/user" 或其相应的类型声明。

原因:虽然在 vite.config.ts 中配置了路径别名,但在 tsconfig.app.json 中没有配置相应的 paths

解决方案:在 tsconfig.app.json 中添加 paths 配置:

{
  "compilerOptions": {
    // ... 其他配置
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

2. 接口实现不完整

问题:调用某个 API 函数时返回 404 错误。

原因:在 mock 文件中没有实现对应的接口。

解决方案:确保所有 API 调用都有对应的 mock 接口实现。

3. 类型定义缺失

问题:使用 mockjs 时缺少类型定义。

原因:没有安装 @types/mockjs 类型定义文件。

解决方案:安装类型定义文件:

npm install --save-dev @types/mockjs

替代方案比较

Mock.js vs JSON Server

  • Mock.js:专注于数据模拟,功能强大,支持各种数据类型和随机数据生成

  • JSON Server:快速创建 RESTful API,基于 JSON 文件,适合简单场景

Mock.js vs MSW (Mock Service Worker)

  • Mock.js:在构建工具层面拦截请求,配置简单

  • MSW:在浏览器层面拦截请求,支持 Service Worker,更接近真实网络请求

Mock.js vs 手写本地存储

  • Mock.js:功能完整,支持各种 HTTP 方法和场景

  • 手写本地存储:简单直接,适合非常简单的场景

注意事项

  1. 环境隔离:确保 mock 功能只在开发和测试环境启用

  2. 数据安全:不要在 mock 数据中使用真实的敏感信息

  3. 接口一致性:与后端保持接口规范一致,避免后期大量修改

  4. 代码管理:将 mock 相关代码与业务代码分离,便于维护

  5. 性能考虑:避免生成过多数据,影响前端性能

  6. 测试覆盖:确保真实接口对接后,进行完整的回归测试

总结

Mock 数据是前端开发中的重要工具,它可以帮助我们提高开发效率,增强代码健壮性,改善团队协作。

「九九八十一难,难难皆是修行」

pgadmin的导出图实现,还在搞先美容后拍照再恢复?

PostgreSQL18的pgadmin中有一个ERDTool.jsx有1132行,这个体量理论上说非常庞大,但做过现实工程的都知道,其实只能算重组件中的mini尺寸了。pgadmin功能并不算有多丰富,怎么还是做成这样呢,当然不是维护团队不会拆分,毕竟还是做了199个jsx的。

首先映入眼帘的是registerEvents对19个EventBus的监听,这东西让人倒吸一口凉气,其中最醒目的莫过于this.eventBus.registerListener(ERD_EVENTS.DOWNLOAD_IMAGE, this.onImageClick),名字就不对劲,实现能如何呢?那么看一下这个onImageClick()

onImageClick() {
    this.setLoading(gettext('Preparing the image...'));

    /* Move the diagram temporarily to align it to top-left of the canvas so that when
     * taking the snapshot all the nodes are covered. Once the image is taken, repaint
     * the canvas back to original state.
     * Code referred from - zoomToFitNodes function.
     */
    this.diagramContainerRef.current?.classList.add('ERDTool-html2canvasReset');
    const margin = 10;
    let nodesRect = this.diagram.getEngine().getBoundingNodesRect(this.diagram.getModel().getNodes());
    let linksRect = this.diagram.getBoundingLinksRect();

    // Check what is to the most top left - links or nodes?
    let topLeftXY = {
      x: nodesRect.getTopLeft().x,
      y: nodesRect.getTopLeft().y
    };
    if(topLeftXY.x > linksRect.TL.x) {
      topLeftXY.x = linksRect.TL.x;
    }
    if(topLeftXY.y > linksRect.TL.y) {
      topLeftXY.y = linksRect.TL.y;
    }
    topLeftXY.x -= margin;
    topLeftXY.y -= margin;

    let canvasRect = this.canvasEle.getBoundingClientRect();
    let canvasTopLeftOnScreen = {
      x: canvasRect.left,
      y: canvasRect.top
    };
    let nodeLayerTopLeftPoint = {
      x: canvasTopLeftOnScreen.x + this.diagram.getModel().getOffsetX(),
      y: canvasTopLeftOnScreen.y + this.diagram.getModel().getOffsetY()
    };
    let nodesRectTopLeftPoint = {
      x: nodeLayerTopLeftPoint.x + topLeftXY.x,
      y: nodeLayerTopLeftPoint.y + topLeftXY.y
    };

    let prevTransform = this.canvasEle.querySelector('div').style.transform;
    this.canvasEle.childNodes.forEach((ele)=>{
      ele.style.transform = `translate(${nodeLayerTopLeftPoint.x - nodesRectTopLeftPoint.x}px, ${nodeLayerTopLeftPoint.y - nodesRectTopLeftPoint.y}px) scale(1.0)`;
    });

    // Capture the links beyond the nodes as well.
    const linkOutsideWidth = linksRect.BR.x - nodesRect.getBottomRight().x;
    const linkOutsideHeight = linksRect.BR.y - nodesRect.getBottomRight().y;
    this.canvasEle.style.width = this.canvasEle.scrollWidth + (linkOutsideWidth > 0 ? linkOutsideWidth : 0) + margin + 'px';
    this.canvasEle.style.height = this.canvasEle.scrollHeight + (linkOutsideHeight > 0 ? linkOutsideHeight : 0) + margin + 'px';

    setTimeout(()=>{
      let width = this.canvasEle.scrollWidth + 10;
      let height = this.canvasEle.scrollHeight + 10;
      let isCut = false;
      /* Canvas limitation - https://html2canvas.hertzen.com/faq */
      if(width >= 32767){
        width = 32766;
        isCut = true;
      }
      if(height >= 32767){
        height = 32766;
        isCut = true;
      }
      toPng(this.canvasEle, {width, height, pixelRatio: this.state.preferences.image_pixel_ratio || 1})
        .then((dataUrl)=>{
          DownloadUtils.downloadBase64UrlData(dataUrl, `${this.getCurrentProjectName()}.png`);
        }).catch((err)=>{
          console.error(err);
          let msg = gettext('Unknown error. Check console logs');
          if(err.name) {
            msg = `${err.name}: ${err.message}`;
          }
          pgAdmin.Browser.notifier.alert(gettext('Error'), msg);
        }).then(()=>{
          /* Revert back to the original CSS styles */
          this.diagramContainerRef.current.classList.remove('ERDTool-html2canvasReset');
          this.canvasEle.style.width = '';
          this.canvasEle.style.height = '';
          this.canvasEle.childNodes.forEach((ele)=>{
            ele.style.transform = prevTransform;
          });
          this.setLoading(null);
          if(isCut) {
            pgAdmin.Browser.notifier.alert(gettext('Maximum image size limit'),
              gettext('The downloaded image has exceeded the maximum size of 32767 x 32767 pixels, and has been cropped to that size.'));
          }
        });
    }, 1000);
  }

这种百行级函数,存在合理性且不论,无论如何它都不应该叫xxClick了,毕竟谁敢相信它所有的代码都是为了完成一个导出png功能?

当然只笼统的说它完成了「一个功能」,那也是委屈它了,这函数实质上究竟做了什么?

  • 状态管理: setLoading。
  • DOM 劫持: 直接操作样式和类名。
  • 复杂的几何计算: 处理包围盒(Bounding Box)。
  • IO 操作: 生成图片并触发下载。
  • 异常处理: 恢复状态弹出 notifier 警告。

每一项都是焦点,如果换成Java,这段代码还能再套上10个trycatch膨胀到500行,当然如果换成Java必然能规矩许多,不至于如此粗糙。

当然这函数远不止违法单一原则那么简单,几何计算中 margin = 10 和屏幕坐标转换逻辑非常硬核且粗糙,几乎宣判了这块UI已经不可更改了,同时否定了缩放/偏移变化,非常容易出 off-by-one 错误,已经消耗极大了,不想做复杂只想简单实现也可以克隆DOM做一个离屏渲染,还不需要关心什么margin偏移。

toPng还是html-to-image的,这种场景用这个本身就如同儿戏,而且既然都做这么复杂了,哪怕直接再补上一套原生代码,手动绘制,全丢这函数里,不用任何库,这段代码也不会更丑了。

检测到图片到了浏览器 canvas 限制,就直接剪裁+警告,不做一个执行前popup确认和zoom,可以说有些不可理喻了,现实中这个警告几乎不可能弹出来,因为符合的这个逻辑时,其占用的原始内存将达到惊人的 4GB,做这种巨型 DOM 树时UI会进行密集的像素计算。而且计算是同步的,会直接锁死浏览器主线程!程序早已卡死,一行代码都别想执行了。

还有setTimeout为什么 1000ms?为什么不是 500 或 2000?这是典型的“等它渲染完”的 hack,因为修改 transform / width/height 后,浏览器需要时间重排/重绘,html2canvas才能捕获正确内容。用requestAnimationFrame 循环检查或MutationObserver / ResizeObserver来检测实际变化完成不好么?

最后还是回到名字上,一个函数如果叫“xx点击”,它就没有资格去负责“计算并导出32767像素的位图”。

不过综合来说,这东西整体上也算勉强还行了,毕竟它是一个最终节点组件,而且基本上不太可能被依赖,只是功能性问题,不像它旁边那个1560行有15个useEffect的ResultSet.jsx,那都不能叫组件了,那是试图给React塞一个子系统,等PostgreSQL20发布,估计就没人敢改它了。还有用1300行的FormInput.tsx管理着FormIcon、StyledGrid、FormInput、InputSQL、FormInputSQL等子组件的超级组件,这几乎是想做一套扩展UI库。

不要在简历上写精通 Vue3?来自面试官的真实劝退

image.png

最近在面试,说实话,每次看到 精通 这俩字,我这心里就咯噔一下。不是我不信你,是这俩字太重了。这不仅仅是自信,这简直就是给面试官下战书😥。

你写 熟悉,我问你 API 怎么用,能干活就行。

你写 精通,那我身体里的胜负欲瞬间就被你点燃了:既然你都精通了,那咱们就别聊怎么写代码了,咱们聊聊尤雨溪写这行代码时在想啥吧😒。

结果呢?三个问题下去,我看对面兄弟的汗都下来了,我都不好意思再问。

今天真心给大伙提个醒,简历上这 精通 二字,就是个巨大的坑,谁踩谁知道。

来,我给你们复盘一下,什么叫面试官眼里的精通。

你别只背八股文

我上来通常先问个简单的热身:

Vue3 到底为啥要用 Proxy 换掉 Object.defineProperty?

大部分人张口就来:因为 defineProperty 监听不到数组下标,还监听不到对象新增属性。Proxy 啥都能拦,所以牛逼。

这话错没错?没错。

但这只是 60 分的回答,属于背诵全文🤔。

敢写精通的,你得这么跟我聊:

老哥,其实数组和新增属性那都是次要的。最核心的痛点是 性能,特别是初始化时候的性能。

Vue2 那个 defineProperty 是上来就得递归,把你对象里里外外每一层都给劫持了。对象一深,初始化直接卡顿。

Vue3 的 Proxy 是 惰性的。你访问第一层,我劫持第一层;你访问深层,我再临时去劫持深层。我不访问,我就不干活。

而且,这里面还有个 this 指向 的坑。Vue3 源码里用 Reflect.get 传了个 receiver 参数进去,就是为了保证有继承关系时,this 能指对地方,不然依赖收集就乱套了。

能力 Vue2(defineProperty) Vue3(Proxy)
监听对象新增/删除
监听数组索引/length
一次性代理整个对象
性能上限 ❌ 越大越慢 ✅ 更平滑
Map / Set ⚠️ 部分支持
实现复杂度

你要能说到 懒劫持Reflect 的 receiver 这一层,我才觉得你可能看过源码🙂‍↔️。

Diff 算法别光扯最长递增子序列

第二个问题,稍微上点强度:

Vue3 的 diff 算法快在哪?

别一上来就跟我背什么最长递增子序列,那只是最后一步。

你得从 编译阶段 开始聊。

Vue2 是个老实人,数据变了,它就把整棵树拿来从头比到尾,哪怕你那是个静态的写死的 div,它也要比一下。

Vue3 变聪明了,它搞了个 动静分离

在编译的时候,它就给那些会变的节点打上了标记,叫 PatchFlag。这个是文本变,那个是 class 变,都记好了。

等到真要 diff 的时候,Vue3 直接无视那些静态节点,只盯着带标记的节点看。

这就好比老师改卷子,以前是从头读到尾,现在是只看你改过的错题。这效率能一样吗?

这叫 靶向更新。能扯出这个词,才算摸到了 Vue3 的门道。

Ref 的那些坑说一说?

最后问个细节,看你平时踩没踩过坑:

Ref 在模板里不用写 .value,在 reactive 里也不用写。那为啥有时候在 Map 里又要写了呢?

很多人这就懵了:啊?不都是自动解包吗?

精通 的人会告诉我:

Vue 的自动解包是有底线的。

模板里那是亲儿子待遇,帮你解了。

reactive 对象里那是干儿子待遇,get 拦截器里帮你解了。

但是 MapSet 这种数据结构,Vue 为了保证语义不乱,是不敢乱动的。你在 Map 里存个 ref,取出来它还是个 ref,必须得手写 .value。👇

const count = ref(0)

const map = new Map()
map.set('count', count)

map.get('count')        // 拿到的是 ref 对象
map.get('count').value // 这是正确取值

Map / Set / WeakMap 不是 Vue 的响应式代理对象

这种细枝末节,没在真实项目里被毒打过,是很难注意到的。


面试其实就是一场 心理博弈

你写 精通,我对你的预期就是 行业顶尖。你答不上来,落差感太强,直接挂。

你写 熟练掌握 或者 有丰富实战经验,哪怕你答出上面这些深度的 50%,我都觉得这小伙子爱钻研,是个惊喜🥱。

在这个行业里,精通 真的不是终点,而是一个无限逼近的过程。

我自己写了这么多年代码,现在简历上也只敢写 熟练🤷‍♂️。

精通 换成 实战案例 吧,比如 我在项目中重写了虚拟列表,或者 我给 Vue 生态贡献过 PR

这比那两个干巴巴的汉字,有力一万倍。

听哥一句劝,Flag 别乱搞,Offer 自然就会来😒。

你们说呢?

Suggestion.gif

单点登录(SSO)系统

一、整体架构设计(核心原则)

先明确整体流程和核心约束,确保 Cookies 仅存储在认证中心域名下:

deepseek_mermaid_20260209_441885.png

核心约束:

  • SSO 认证中心域名 下存储登录态 Cookie(如 sso_token);
  • 各业务系统前端不存储任何登录态 Cookie,仅在内存 /localStorage 存储临时业务 token;
  • 跨域登录态通过「授权码模式」传递,避免 Cookie 跨域问题。

二、各角色职责与提供的服务

1. 前端(Vue3):核心职责是「无 Cookie 登录态管理 + 跨域认证跳转」

核心职责
职责项 具体操作 技术实现
登录态检测 初始化时检测当前是否有有效业务 token,无则跳转认证中心 路由守卫(beforeEach)
认证跳转 拼接认证中心地址 + 业务系统回调地址,跳转至 SSO 登录页 动态拼接 URL 参数
授权码处理 认证中心重定向回业务系统时,解析 URL 中的授权码 URLSearchParams
临时 token 管理 存储业务后端返回的临时 token(内存 /localStorage),无 Cookie Pinia/Vuex + 内存变量
接口请求拦截 所有接口请求携带临时 token(Header 中),无 Cookie 传递 Axios 拦截器
登出处理 跳转认证中心登出接口,清除本地临时 token 跳转 SSO 登出地址 + 清除本地存储
前端提供的服务
  • 标准化的认证跳转组件(可复用的 SSO 登录跳转逻辑);
  • 统一的 token 管理工具(Pinia/Vuex 模块,封装 token 增删查);
  • 跨域认证回调处理页面(callback.vue);
  • 无 Cookie 的接口请求封装(Axios 拦截器)。

2. 后端:核心职责是「授权码校验 + 业务 token 生成 + 跨域认证接口」

核心职责(分「认证中心后端」和「业务系统后端」)
角色 职责项 具体操作
认证中心后端 登录接口 验证用户名密码,生成 sso_token,存储至认证中心 Cookie(仅本域名)
认证中心后端 授权码生成 验证业务系统合法性,生成一次性授权码,重定向回业务系统
认证中心后端 授权码校验 接收业务后端的校验请求,验证授权码有效性,返回 sso_token
认证中心后端 登出接口 清除认证中心 Cookie 中的 sso_token,并重定向至各业务系统登出页
业务系统后端 授权码兑换 接收前端的授权码,调用认证中心接口校验,获取 sso_token
业务系统后端 业务 token 生成 基于 sso_token 生成业务系统专属临时 token(JWT),返回前端
业务系统后端 接口鉴权 校验前端携带的业务 token,无 Cookie 校验
后端提供的服务
  • 认证中心:登录 / 登出 / 授权码生成 / 授权码校验接口;
  • 业务系统:授权码兑换接口、业务 token 校验接口、统一鉴权拦截器;
  • 跨域配置:允许业务系统前端跨域调用认证中心接口(CORS 配置);
  • 安全策略:Cookie 的 HttpOnly/Secure/SameSite 配置,防止 CSRF/XSS。

3. 运维:核心职责是「域名 / 网络配置 + 安全策略 + 部署运维」

核心职责
职责项 具体操作 技术实现
域名规划 独立的认证中心域名(如sso.yourdomain.com),与业务系统域名隔离 DNS 解析配置
HTTPS 配置 所有域名强制 HTTPS(Cookie 的 Secure 属性要求) Nginx 配置 + SSL 证书部署
跨域配置 Nginx 层面配置 CORS,允许业务系统跨域访问认证中心 Nginx 的 add_header Access-Control-*
Cookie 安全配置 确保认证中心 Cookie 仅在本域名生效,禁止跨域携带 Nginx / 后端双重配置 Cookie 属性
部署架构 认证中心服务高可用部署,业务系统与认证中心网络互通 负载均衡(LB)+ 集群部署
日志监控 监控认证中心登录 / 登出日志,排查跨域认证问题 ELK/Prometheus + Grafana
运维提供的服务
  • 独立的 SSO 认证中心域名及 SSL 证书部署;
  • 各业务系统域名与认证中心域名的 DNS 解析;
  • Nginx 层面的 HTTPS 强制跳转、CORS 配置、Cookie 安全配置;
  • 认证中心服务的高可用部署(集群 / 负载均衡);
  • 日志监控系统(认证中心登录日志、跨域访问日志);
  • 安全策略配置(WAF 防护、接口限流、Cookie 防篡改)。

三、关键安全注意事项

  1. 认证中心 Cookie 必须配置:HttpOnly=true(防止 XSS)、Secure=true(仅 HTTPS)、SameSite=Strict(禁止跨域携带);
  2. 授权码必须是一次性、短期有效(如 5 分钟),防止复用;
  3. 业务系统临时 token 建议短期有效(如 2 小时),前端定期静默刷新(调用业务后端刷新接口,再调用认证中心校验 sso_token);
  4. 所有接口必须 HTTPS,防止 token 明文传输。

总结

  1. 前端(Vue3) :核心是「无 Cookie 登录态管理」,通过路由守卫跳转认证中心,解析授权码兑换临时 token,接口请求携带 token(Header);
  2. 后端:认证中心负责生成 sso_token 并存储至自身 Cookie,业务系统负责校验授权码、生成业务临时 token;
  3. 运维:核心是域名隔离、HTTPS 配置、Cookie 安全策略,确保仅认证中心存储 Cookie,杜绝跨域 Cookie 风险。
❌