阅读视图

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

日志诊断 Skill:用 AI + MCP 一键解决BUG|得物技术

一、概述

做后端开发,调 BUG 有一个让人头疼的固定流程:打开日志平台,输入 traceId 或关键词,搜日志;从几十上百条日志里,找到关键的那几条;把日志里的类名、方法名复制出来,去 IDE 里找对应代码;结合代码逻辑,判断哪里出了问题;如果一次找不准,回去再搜日志,再翻代码……

这个过程相对固定,但非常耗时间。每次 BUG 定位,光在日志平台和 IDE 之间来回切换,就能消耗掉大半的时间。

最开始在去年 Q3 想到这个问题的时候,脑子里浮现的第一个方案是:用 Cursor + MCP,把日志平台接进来,再挂一个代码知识库,让 AI 帮我查日志。但这个方案有缺陷 —— 日志查询是「动态的」,它依赖环境、应用、时间范围,没办法静态预置。此外,这样处理没有办法做到比较丝滑地读代码、改代码。

后来开始用 Claude Code,接触到了 Skill 的概念:可以在项目里定义一套自定义命令,描述 AI 应该怎么执行这个命令的每个步骤,于是整个思路变得清晰了。

日志平台有 MCP,Claude Code 有 Skill,两者结合,就能让 AI 自动完成「查日志 → 找关键信息 → 扫描代码 → 定位问题」这整个闭环。然后在 PM 的帮助下,才有了 /log-diagnosis 这个 Skill。

二、日志平台 MCP 是什么

MCP 原理

日志平台推出了基于 MCP(Model Context Protocol)协议的日志查询服务,让 Claude 可以直接调用日志平台的能力,无需人工在日志平台上手动查询。

MCP 本质上是一种标准化的「工具调用协议」,Claude Code 通过 SSE(Server-Sent Events)长连接与 MCP Server 通信,实时获取日志数据。

MCP 环境对照

核心 MCP 工具

鉴权流程

secretKey(日志平台后管申请)
    ↓ acquireTokenTool
accessToken(1小时有效,最多同时存在5个)
    ↓ 携带 accessToken
logsQuery / logSqlQuery / countLogTool ...

secretKey 申请地址:进入日志管理后台 → 日志权限 → 我的应用 → 生成密钥。

三、/log-diagnosis Skill 是什么

Skill 工作原理

log-diagnosis 是一个运行在 Claude Code 里的自定义诊断命令。Claude Code 支持通过 .claude/skills/ 目录定义自定义技能(Skill),以 Markdown 文件描述行为规范,Claude 在收到对应命令时会自动加载并执行。你只需要把 traceId 或告警信息告诉它,剩下的全部交给 AI。完整执行链路如下:

用户输入 /log-diagnosis {环境} {代码分支} {诉求}
    ↓
Claude 加载 .claude/skills/log-diagnosis/SKILL.md
    ↓
读取 .diagnosis/config.json 获取当前环境配置
    ↓
检查 accessToken 是否过期,过期则自动刷新
    ↓
从 traceId 计算日志时间范围(取第9-16位16进制时间戳)
    ↓
调用日志平台 MCP 分页拉取全量日志(最多20页,不遗漏)
    ↓
切换到指定代码分支,结合日志关键词检索代码
    ↓
综合分析:上游日志 + 当前服务日志 + 代码逻辑 → 根因
    ↓
生成诊断报告(飞书文档 or 本地 Markdown)
    ↓
恢复原始代码分支

两种诊断入口

核心能力

  • Token 自动管理:accessToken 过期自动刷新,无需手动维护;
  • 分页全量拉取:自动分页拉完所有日志,禁止只查第一页就下结论(最多 20 页);
  • 跨服务分析:自动识别上下游服务,拉取关联服务日志交叉验证;
  • 代码联动:日志里出现的类名/方法名,直接在代码里精确定位。

queryString 语法规则

# 格式
{field} {操作符} "{值}" {连接符} {field} {操作符} "{值}"
# 操作符
=  : 精确匹配
≈  : 模糊匹配(like)
# 连接符
AND / OR / NOT
# 示例
trace_id"a1b2c3d4e5f6789012345678abcdef01"
trace_id"xxx" AND log_level = "ERROR"
endpoint ≈ "/api/your-endpoint" AND log_level"ERROR"
message ≈ "timeout"

注意:时间范围只通过 start/end 参数控制,不要写在 queryString 中。

四、安装与配置

安装日志平台 MCP

Claude Code

在 Claude Code 命令行中执行,按需安装对应环境:

# 测试环境
claude mcp add --transport sse dw-log-mcp-t1 https://{your-t1-aigw-domain}/api/v1/mcp/log-mcp/sse
# 预发环境
claude mcp add --transport sse dw-log-mcp-pre https://{your-pre-aigw-domain}/api/v1/mcp/log-mcp/sse
# 生产环境
claude mcp add --transport sse dw-log-mcp-prd https://{your-prd-aigw-domain}/api/v1/mcp/log-mcp/sse

安装后重启 Claude Code,执行 /mcp 确认连接状态正常。

Cursor

  1. 打开 Cursor Setting;

  2. 点击 Tools & MCP,添加 MCP Server;

  3. 添加 URL,MCP Server 名称任意。

建议按需安装 MCP Server,避免额外消耗 token,示例配置:

{
  "mcpServers": {
    "dw-log-mcp-t1": {
      "url": "https://{your-t1-aigw-domain}/api/v1/mcp/log-mcp/sse"
    },
    "dw-log-mcp-pre": {
      "url": "https://{your-pre-aigw-domain}/api/v1/mcp/log-mcp/sse"
    },
    "dw-log-mcp-prd": {
      "url": "https://{your-prd-aigw-domain}/api/v1/mcp/log-mcp/sse"
    },
    "dw-log-mcp-oversea-prd": {
      "url": "https://{your-oversea-aigw-domain}/api/v1/mcp/log-mcp/sse"
    }
  }
}

4. 返回设置,就可以看到已经连接上。

安装 /log-diagnosis Skill

将 log-diagnosis 目录放到项目的对应目录下:

Claude Code

your-project/
└── .claude/
    └── skills/
        └── log-diagnosis/
            ├── SKILL.md        # 技能行为规范(核心)
            ├── README.md       # 使用说明
            └── reference.md   # 附录:时间脚本、queryString 示例等

Cursor

your-project/
└── .cursor/
    └── skills/
        └── log-diagnosis/
            ├── SKILL.md        # 技能行为规范(核心)
            ├── README.md       # 使用说明
            └── reference.md   # 附录:时间脚本、queryString 示例等

配置 .diagnosis/config.json

首次运行会自动引导创建 (直接调用 /log-diagnosis,Skill 会一步步指示你给出 secret key),也可手动在项目根目录创建 .diagnosis/config.json:

your-project/
└── .cursor/
    └── skills/
        └── log-diagnosis/
            ├── SKILL.md        # 技能行为规范(核心)
            ├── README.md       # 使用说明
            └── reference.md   # 附录:时间脚本、queryString 示例等

字段说明:

secretKey:唯一需要人工填写的字段,在日志平台后管申请;

accessToken:首次使用时由 AI 自动调用 acquireTokenTool 获取,过期自动刷新;

accessTokenExpireAt:从 acquireTokenTool 返回值自动填充;

fields:调用 logFields 工具自动获取。

五、使用方式

命令格式:

/log-diagnosis {环境} {代码分支(可选)} {诉求描述}

参数说明:

  • {环境}:T1 / PRE / PRD(按实际环境标识填写);
  • {代码分支}:可选,留空则使用当前分支;
  • {诉求描述}:包含 traceId 或告警信息的问题描述,用自然语言书写即可。

示例:

# 用 traceId 定位接口异常
/log-diagnosis T1 feature/your-branch trace_id: "your-trace" 为什么最终没有返回数据
# 用告警信息分析错误原因
/log-diagnosis PRD master 告警详情:【接口:YourService/yourMethod】【业务码:10002000】【业务码消息:系统异常,请稍后重试】帮我分析问题可能性

一行命令,AI 全程接管,几分钟内给出根因分析。

六、实战案例:一个隐蔽的 SQL BUG

背景

某搜索接口在测试环境反馈没有返回数据。拿到 traceId,直接执行:

/log-diagnosis T1 feature/your-branch trace_id: "your-trace" 为什么最终没有返回数据

← 就这一句话,接下来全部交给 AI。

AI 自动拉取日志

Skill 触发后,AI 自动完成:

  • 从 traceId 推算出日志时间范围(2026-02-27 全天);
  • 检查 accessToken 已过期,自动刷新;
  • 调用日志平台 MCP,分 2 页拉取完整日志,共 73 条。

请求入参(从日志自动提取):

{
  "assembleByOrg": true,
  "channelType": "MANUAL",
  "orderNo": "your-order-no",
  "status": 1,
  "ticketNo": "your-ticket-no"
}

AI 还原完整调用链路

AI 自动识别出关键节点:resultList is empty,SQL 查询返回了空结果。问题在 DB 层,而不在业务逻辑层。

AI 提取组装后的查询 DTO

从日志中提取到 toSearchDTO 组装结果:

{
  "channelType": "MANUAL",
  "customerTag": 1,
  "deliveryMode": "某配送方式",
  "orderStatus": "8010",
  "orderType": "0",
  "productCategoryIds": [29],
  "status": 1,
  "ticketSource": 67,
  "ticketTypeId": 5802
}

AI 从日志中提取实际执行的 SQL 发现根因

ORM 框架在日志中打印了实际执行的 SQL,AI 直接读取并分析:

SELECT a.id, a.pid, a.name, a.mode, a.status, a.org_id, a.org_ids,
       a.ticket_group_id, a.tenant_id, a.is_del, a.channel_types
FROM your_type_table a
LEFT JOIN your_relation_table b
    ON b.tenant_id = 1 AND a.id = b.type_id AND b.type = 3 AND b.is_del = 0
WHERE a.tenant_id = 1 AND a.mode = 2 AND a.is_del = 0
  AND a.status = 1
  AND (a.channel_types IS NULL OR a.channel_types = '' OR FIND_IN_SET('MANUAL', a.channel_types) > 0)
  AND (b.root_id is null or b.root_id in (29))
  AND (a.order_types IS NULL OR a.order_types = '' OR FIND_IN_SET('0', a.order_types) > 0)
  AND (a.order_statuses IS NULL OR a.order_statuses = '' OR FIND_IN_SET('8010', a.order_statuses) > 0)
  AND (a.delivery_modes IS NULL OR a.delivery_modes = '' OR FIND_IN_SET('某配送方式', a.delivery_modes) > 0)
  AND (a.ticket_sources IS NULL OR a.ticket_sources = '' OR FIND_IN_SET(67, a.ticket_sources) > 0)
  AND (a.customer_tag IS NULL OR a.customer_tag = 1)   ← BUG 在此

AI 发现:其他字段都处理了 IS NULL 和 = ''(空字符串代表 “不限制”)两种情况,唯独 customer_tag 只判断了 IS NULL,遗漏了空字符串 '' 的情况。

SQL 语义对比:

-- 其他字段(正确):IS NULL 和 '' 都处理了
AND (a.order_types IS NULL OR a.order_types'' OR FIND_IN_SET('0', a.order_types) > 0)
AND (a.delivery_modes IS NULL OR a.delivery_modes'' OR FIND_IN_SET('某配送方式', a.delivery_modes) > 0)
AND (a.ticket_sources IS NULL OR a.ticket_sources'' OR FIND_IN_SET(67, a.ticket_sources) > 0)
-- customer_tag(遗漏了 = '' 的判断)← BUG
AND (a.customer_tag IS NULL OR a.customer_tag1)

DB 中现有的数据,customer_tag 字段都存的是空字符串(未配置),按业务语义本应匹配所有请求,却因为这个遗漏被全部过滤掉了。

AI 定位代码,给出修复方案

AI 在代码中直接找到对应的 MyBatis Mapper XML:

<!-- 问题代码 -->
<if test="customerTag != null">
    and (a.customer_tag IS NULL OR a.customer_tag = #{customerTag})
</if>
<!-- 修复后 -->
<if test="customerTag != null">
    and (a.customer_tag IS NULL OR a.customer_tag = '' OR a.customer_tag = #{customerTag})
</if>

效率对比

这个 BUG 的隐蔽性在于:SQL 语法正确,逻辑上也「看起来」没问题——只有对比了其他字段的写法,才能发现 customer_tag 独自遗漏了空字符串的处理。这类细节差异,人工排查很容易忽略,AI 反而很擅长。

七、诊断效率关键点

  • 有 traceId 时优先用 traceId 拉日志,可精准获取单次请求的完整链路,比关键词搜索精确得多;
  • 关注关键日志节点:toSearchDTO finished / search begins / resultList is empty / search finished 等,快速判断数据在哪一层丢失;
  • SQL 打印日志(ORM 框架输出)是黄金线索,直接反映最终执行的查询条件,AI 能从中发现肉眼难以察觉的差异;
  • 分页必须拉完:日志平台一次只返回部分数据,AI 会严格执行分页直到取完,确保不遗漏关键日志。

八、总结

核心思路:用「协议 + 规范」让 AI 接管固定流程:

这篇文章的本质,是一次对重复性工程劳动的自动化尝试。调 BUG 的过程——查日志、提取关键信息、找代码、分析原因——逻辑固定,步骤繁琐,但并不需要太多创造性思维。这类工作恰好是 AI 最擅长接管的。

实现这个闭环,靠的是两个关键组合:

  • MCP:让 AI 能够调用外部系统(日志平台),突破了「AI 只能处理静态上下文」的限制,实现了对动态数据的实时获取。
  • Skill:给 AI 一份行为规范,告诉它每一步该怎么做、先做什么后做什么、遇到什么情况怎么处理,把「一次性对话」变成「可复用的工程化能力」。

两者缺一不可。只有 MCP,AI 能查日志但不知道怎么系统地分析;只有 Skill,AI 有流程但没有数据来源。组合起来,才形成了真正可落地的闭环。

值得借鉴的地方:

识别「固定流程」是自动化的起点:不是所有工作都适合 AI 接管,但凡是「步骤固定、信息来源明确、输出格式可预期」的工作,都值得尝试用 Skill + MCP 的方式来自动化。排查 BUG 是一个典型,类似的还有:代码审查、性能分析报告生成、告警巡检等。

Skill 的本质是「给 AI 写操作手册」:Skill 文件不是在「训练模型」,而是在给 AI 一份清晰的 SOP。写得越细、约束越明确(比如「禁止只查第一页就下结论」「必须分页拉完所有数据」),AI 的执行质量越稳定。这和写给人看的文档本质上是一回事。

AI 擅长发现「横向对比」类的 BUG:本文的案例揭示了一个有意思的规律:AI 在处理「同类字段逻辑不一致」这类问题时,表现往往比人工更好。原因在于 AI 没有「先入为主」的经验偏见,不会因为「这段代码看起来没问题」就跳过,它会对所有字段做同等的审查。

最后说一句:AI 时代,工程师的核心竞争力不只是「能写代码」,更是「能把自己的经验和流程转化成可复用的 AI 能力」。/log-diagnosis 是一次小小的尝试,但背后的思路,值得在更多场景里延伸。

往期回顾

1.Redis 自动化运维最佳实践|得物技术

2.Claude在得物App数仓的深度集成与效能演进

3.Claude Code + OpenSpec 正在加速 AICoding 落地:从模型博弈到工程化的范式转移|得物技术

4.大禹平台:流批一体离线Dump平台的设计与应用|得物技术

5.基于 Cursor Agent 的流水线 AI CR 实践|得物技术

文 /阿程

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

震惊!字符串还能这么玩!

效果预览

一个充满趣味的文字交互效果:文字像绳子一样串联在一起,你可以拖拽末端的文字,像拉绳子一样把整个文字串拉乱。按 F 键还能触发"解开"动画,让文字在重力作用下自然散落。

预览地址:liush.top/textString/

截屏2026-04-02 22.24.49.png

核心原理

这个效果主要基于 Verlet 积分 物理模拟算法,配合 距离约束 来保持文字之间的连接关系。

1. 数据结构定义

每个字符都是一个物理粒子,包含位置、速度、锁定状态等属性:

typescript
interface Letter {
  ch: string;      // 字符内容
  w: number;       // 字符宽度
  x: number;       // 当前位置 X
  y: number;       // 当前位置 Y
  ox: number;      // 原始位置 X(约束目标)
  oy: number;      // 原始位置 Y(约束目标)
  px: number;      // 上一帧位置 X(用于计算速度)
  py: number;      // 上一帧位置 Y
  readingIdx: number;  // 在阅读顺序中的索引
  locked: boolean;     // 是否锁定(锁定则不受物理影响)
}

2. 蛇形排列算法

为了让文字像绳子一样有自然的串联顺序,但视觉上又是正常的阅读顺序,使用了蛇形(Zig-Zag)排列

typescript
function buildZigzagMapping(maxWidth: number) {
  // 先按行分组
  const lineIndices: number[][] = [];
  // ... 分行逻辑
  
  // 奇数行反转,形成蛇形
  for (let li = 0; li < lineIndices.length; li++) {
    const reversed = needFlip ? li % 2 === 0 : li % 2 === 1;
    if (reversed) {
      stringOrder.push(...[...lineIndices[li]].reverse());
    } else {
      stringOrder.push(...lineIndices[li]);
    }
  }
  return stringOrder;
}

这样,物理连接顺序是蛇形的,但每个字符记录了自己的 readingIdx,渲染时放在正确的视觉位置。

3. Verlet 积分物理模拟

核心物理循环使用 Verlet 积分,相比欧拉积分更稳定:

typescript
// Verlet integration
for (let i = 0; i < letters.length; i++) {
  const l = letters[i];
  if (l.locked || isDragged(i)) continue;
  
  // 计算速度(当前位置 - 上一帧位置)
  const vx = (l.x - l.px) * DAMPING;  // 阻尼系数 0.97
  const vy = (l.y - l.py) * DAMPING;
  
  // 更新上一帧位置
  l.px = l.x;
  l.py = l.y;
  
  // 应用速度和重力
  l.x += vx;
  l.y += vy + GRAVITY;  // 重力 0.15
}

4. 距离约束(绳子效果)

这是保持"绳子"感觉的关键。每帧迭代多次,强制相邻字符保持固定距离:

typescript
// Distance constraints
for (let iter = 0; iter < ITERATIONS; iter++) {  // 迭代 12 次
  for (let i = 0; i < letters.length - 1; i++) {
    const a = letters[i], b = letters[i + 1];
    
    // 计算两字符中心点距离
    const dist = Math.hypot(bx - ax, by - ay);
    const diff = (dist - restLengths[i]) / dist;
    
    // 根据锁定状态调整位置
    // 一个锁定:只移动另一个
    // 都未锁定:各移动一半
    // 都锁定:跳过
  }
}

5. 碰撞检测

防止非相邻字符重叠:

typescript
// Letter-letter collision
const RADIUS = 8;
for (let i = 0; i < letters.length; i++) {
  for (let j = i + 1; j < letters.length; j++) {
    if (Math.abs(i - j) === 1) continue; // 跳过相邻的(由距离约束处理)
    // 圆形碰撞检测,分离重叠字符
  }
}

6. 交互逻辑

拖拽:使用 Pointer Events API 实现鼠标/触摸拖拽

typescript
const handlePointerDown = (e: PointerEvent) => {
  const idx = els.indexOf(e.target as HTMLSpanElement);
  if (idx === -1 || letters[idx].locked) return;
  
  drags.set(e.pointerId, {
    idx,
    offsetX: e.clientX - rect.left - letters[idx].x,
    offsetY: e.clientY - rect.top - letters[idx].y,
  });
};

解锁传播:拖拽一个字符时,如果拉得足够远,会"拉断"连接,解锁相邻字符:

typescript
const dist = Math.hypot(dx, dy);
if (dist > restLengths[i] + UNLOCK_THRESHOLD) {
  a.locked = false;  // 解锁!
}

关键技术点总结

技术 用途
Verlet 积分 稳定的位置-based 物理模拟
距离约束迭代 保持绳子般的连接感
蛇形排列 物理顺序与视觉顺序分离
Pointer Events 统一的鼠标/触摸交互
固定时间步长 120Hz 物理模拟保证一致性

参考

参考实现:pushmatrix.github.io/textstring/


有需要代码可以留言或私信我

AI 的「词元」:Token 到底是什么?

一、Token 是什么?

Token,直译为「词元」或「令牌」,是大语言模型(LLM)处理文本的基本单位。

你输入的文字,在进入模型之前,会先经过一个叫做 Tokenizer(分词器) 的程序,把文本切碎成一个个 Token。这些 Token 再被转换为数字 ID,模型才能「读懂」它们。

一个 Token ≠ 一个字。Token 可以是:

  • 一个完整的英文单词:hello → 1 token
  • 一个英文词的一部分:unbelievableun + believ + able = 3 tokens
  • 一个中文汉字(有时): → 1 token
  • 一个标点符号:, → 1 token
  • 几个空格: → 1 token

二、Tokenizer 是怎么工作的?

目前主流的分词算法有三种:

1. BPE(Byte Pair Encoding,字节对编码)

最常见的方法,被 GPT 系列、LLaMA、Mistral 等广泛使用。

核心思想:从单个字节出发,反复合并出现频率最高的相邻字节对,直到达到预设词表大小。

举例

语料:"aaabdaaabac"
初始:a a a b d a a a b a c
第1轮合并最频繁的 "aa":aa a b d aa a b a c
第2轮合并 "aa":aaa b d aaa b a c
...
最终词表中就会有 "aa"、"aaa" 这样的合并单元

2. WordPiece

Google BERT 系列使用。与 BPE 类似,但合并标准是「最大化语言模型似然」,而非纯粹频率。

对中文的处理:mBERT 会将中文字符逐字切分,"你好"["你", "好"],基本保持 1 字 = 1 token。

3. SentencePiece

Google T5、mT5 使用,也被 LLaMA、BLOOM 等采用。

最大特点:不依赖语言的空格分词习惯,把原始文本当作字节流处理,天然支持中日韩等语言,无需预处理。


三、各大模型 Token 大比拼

同一句话,不同模型切法不同

我们拿这句话做实验:

「人工智能正在改变世界」

模型/分词器 大致 Token 数 说明
GPT-3(p50k) 约 14–18 个 旧版 BPE,中文多走 UTF-8 字节,1 字≈2-3 token
GPT-4 / GPT-4o(cl100k) 约 7–9 个 优化后的 BPE,CJK 词表扩充
Claude 3.x 约 7–10 个 Anthropic 自研 tokenizer,中文效率与 GPT-4 相近
LLaMA 2 约 10–14 个 SentencePiece,中文支持一般
LLaMA 3 约 7–9 个 词表从 32K 扩展到 128K,大幅改善 CJK
Qwen2.5 / 通义千问 约 7–8 个 针对中文优化的 BPE,接近 1 字 1 token
DeepSeek-V3 约 7–8 个 自研 tokenizer,中文友好
Gemini 1.5 约 8–11 个 Google SentencePiece 衍生,多语言均衡

结论:国产大模型(Qwen、DeepSeek)和经过优化的新版国际模型(GPT-4、LLaMA 3)对中文都相当友好,而早期 GPT-3 对中文极不友好——同样的内容要消耗多 2-3 倍的 token。


英文 Token 效率对比

用句子 "The quick brown fox jumps over the lazy dog" 来测试:

模型 Token 数
GPT-4o(cl100k) 9
Claude 3(Anthropic) 9
LLaMA 3(128K 词表) 9
GPT-3(p50k) 9
BERT(WordPiece) 10

英文差距不大,主要是常见单词基本都在词表里,直接 1 词 1 token。


四、Token 为什么重要?

1. 直接决定 API 费用

所有大模型的 API 计费都以 Token 为单位。

关键洞察:如果你的业务场景大量涉及中文,使用中文友好的模型(Qwen、DeepSeek)不仅价格低,而且 token 效率更高,双重节省!


2. 决定上下文窗口(Context Window)

所谓「上下文长度」,本质上就是模型一次能处理多少个 Token。

模型 上下文窗口
GPT-4o 128K tokens
Claude 3.5 Sonnet 200K tokens
Claude 3.7 Sonnet 200K tokens
Gemini 1.5 Pro 1M tokens(实验版 2M)
Gemini 2.0 Flash 1M tokens
LLaMA 3.1(70B) 128K tokens
DeepSeek-V3 128K tokens
Qwen2.5-72B 128K tokens

200K tokens ≈ 约 15 万汉字 ≈ 一部长篇小说。


3. 影响模型的「注意力范围」

Token 越多,模型计算的注意力(Attention)矩阵越大,计算量以平方级增长。这也是为什么长上下文模型推理慢、成本高的根本原因。


五、一个有趣的实验:数 Token

你可以亲自去 OpenAI 的 Tokenizer 工具 数数看。

下面是几个有趣的例子(GPT-5 分词器):

"Hello, world!"4 tokens
"你好,世界!"4 tokens
"1+1=2"5 tokens
"😀"1 tokens(emoji 用多个字节表示)
"GPT"1 token(常见词直接收录)
"ChatGPT"2 tokens:"Chat" + "GPT"
"Supercalifragilistic"6 tokens

六、Token 与中文的特殊关系

中文处理是大模型 tokenizer 设计中的一大挑战。

为什么早期模型对中文「不友好」?

早期 GPT-3 的 tokenizer 词表主要基于英文语料训练。中文汉字不在词表里,就会被拆成 UTF-8 字节来表示。一个中文字符在 UTF-8 编码下占 3 个字节,因此变成 3 个 token。

对比

"人工智能" (4个汉字)
  GPT-3:    8  token(每字≈2 token)
  GPT-4:    4-5  token(CJK 词表扩充)
  Qwen:     4  token(1字≈1 token)

这意味着:同样的中文内容,在 GPT-3 上的 token 成本是 Qwen 的 2 倍

国产模型的优势

以 Qwen(通义千问)为例,阿里在训练 tokenizer 时专门加入了大量中文语料,词表中收录了常见汉字和常用词组,实现了接近 1:1 的字-token 比例。

DeepSeek 同样如此,其 tokenizer 词表约 100K,中文字符基本都有专属 token。



总结

AI 在发展,token的计算也在不断优化,本文提到的token数仅供参考!

Token 是 AI 语言模型的「DNA」——一切理解与生成,都从这个最小单位开始。

维度 核心要点
是什么 文本被切分后的最小处理单元,≠ 字词
怎么切 BPE / WordPiece / SentencePiece 三大算法
中文效率 新模型(GPT-5、Qwen、DeepSeek)已接近 1字1token
为什么重要 决定 API 费用、上下文长度、推理速度
开发技巧 提前计算、精简 prompt、善用缓存、选合适模型

从监听失败到实时更新:我在NFT铸造项目中搞定合约事件监听的全过程

背景

上个月,我接了一个NFT铸造平台的前端开发。项目有个核心需求:用户点击“铸造”按钮后,前端需要实时显示铸造成功的交易,并立刻更新用户的NFT持有数量。这听起来是个典型的“监听智能合约事件”场景。

我一开始觉得这很简单——用 ethers.jscontract.on 不就搞定了吗?但实际开发中,我遇到了各种幺蛾子:页面切换时监听没取消导致内存泄漏、用户切换钱包网络后监听器还在老链上工作、甚至有时候事件根本触发不了。用户反馈说“铸造成功了但页面没反应”,这体验实在太差。我不得不停下来,系统性地解决这个监听问题。

问题分析

我最开始的实现确实很 naive。在React组件里,我直接用了 ethers.jscontract.on('Transfer', callback)

useEffect(() => {
  const contract = new ethers.Contract(address, abi, provider);
  
  const handleTransfer = (from, to, tokenId, event) => {
    console.log('NFT转移了!', tokenId.toString());
    // 更新UI状态...
  };
  
  contract.on('Transfer', handleTransfer);
  
  return () => {
    contract.off('Transfer', handleTransfer);
  };
}, []);

这个方案有三个明显问题:

  1. 网络切换问题:当用户从以太坊主网切换到Polygon时,contract 实例还是基于旧网络的provider,监听自然失效
  2. 组件生命周期问题:虽然我写了清理函数,但有时候组件卸载和重新挂载的速度太快,off 可能没执行到位
  3. 状态同步问题:监听回调里更新React状态时,如果组件已经卸载,会报“内存泄漏”警告

更麻烦的是,我们的DApp支持多链(以太坊、Polygon、Arbitrum),用户随时可能切换网络。我需要一个能自动处理网络切换、能优雅清理、并且与React状态管理无缝集成的方案。

核心实现

放弃 ethers.js,拥抱 wagmi + viem

经过一番调研和试错,我决定用 wagmi + viem 这套现代Web3开发组合。wagmi 提供了完善的React Hooks,而 viem 是类型安全、模块化的以太坊库。最重要的是,wagmiuseWatchContractEvent Hook 看起来就是为这个场景设计的。

但这里有个坑:wagmi 的文档虽然不错,但关于事件监听的部分例子不多,特别是处理实时UI更新和错误处理的实战案例很少。我得自己摸索。

实现基础监听

首先,我配置了 wagmi 的客户端,支持多链:

// wagmi.config.ts
import { createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';

export const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  connectors: [injected()],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

然后在React组件中,我这样使用 useWatchContractEvent

import { useWatchContractEvent } from 'wagmi';

function NFTMintComponent() {
  const { address } = useAccount();
  
  useWatchContractEvent({
    address: '0x742d35Cc6634C0532925a3b844Bc9e...', // NFT合约地址
    abi: nftContractAbi,
    eventName: 'Transfer',
    args: { to: address }, // 只监听转给当前用户的事件
    onLogs: (logs) => {
      console.log('监听到新的Transfer事件:', logs);
      // 这里更新UI状态
    },
  });
  
  return (
    // UI组件
  );
}

这个基础版本已经比最初的 ethers.js 方案好多了:wagmi 会自动处理网络切换,当用户切换链时,监听会自动重新建立到新链上。

处理事件去重和状态更新

但很快我发现新问题:同一个交易的事件有时会被触发多次。这是因为区块链节点可能推送重复的事件,或者组件重新渲染导致监听重新建立。

我需要去重逻辑。每个事件都有唯一的 transactionHashlogIndex,可以用它们组合成唯一ID:

import { useCallback, useRef } from 'react';
import { useWatchContractEvent, Log } from 'wagmi';

function useUniqueContractEvents(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onUniqueLogs: (logs: Log[]) => void;
}) {
  const processedIds = useRef<Set<string>>(new Set());
  
  const handleLogs = useCallback((logs: Log[]) => {
    const uniqueLogs = logs.filter(log => {
      const id = `${log.transactionHash}-${log.logIndex}`;
      if (processedIds.current.has(id)) {
        return false;
      }
      processedIds.current.add(id);
      return true;
    });
    
    if (uniqueLogs.length > 0) {
      options.onUniqueLogs(uniqueLogs);
    }
  }, [options.onUniqueLogs]);
  
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: handleLogs,
  });
}

然后在组件中使用这个自定义Hook:

function NFTMintComponent() {
  const [mintedTokens, setMintedTokens] = useState<number[]>([]);
  
  useUniqueContractEvents({
    address: nftContractAddress,
    abi: nftContractAbi,
    eventName: 'Transfer',
    onUniqueLogs: (logs) => {
      // 从事件数据中提取tokenId
      const newTokenIds = logs.map(log => 
        Number(log.args.tokenId)
      );
      
      // 批量更新状态,减少重新渲染
      setMintedTokens(prev => [...prev, ...newTokenIds]);
      
      // 显示成功提示
      showNotification(`成功铸造 ${newTokenIds.length} 个NFT!`);
    },
  });
  
  return (
    <div>
      已铸造的NFT: {mintedTokens.join(', ')}
    </div>
  );
}

处理组件卸载和错误边界

还有一个重要问题:如果监听过程中RPC节点连接失败怎么办?或者组件卸载时如何确保监听完全清理?

wagmiuseWatchContractEvent 在组件卸载时会自动清理,但错误处理需要我们自己加:

import { useWatchContractEvent, usePublicClient } from 'wagmi';
import { useEffect } from 'react';

function useRobustContractEvent(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onLogs: (logs: Log[]) => void;
  onError?: (error: Error) => void;
}) {
  const publicClient = usePublicClient();
  
  // 先获取历史事件,避免遗漏
  useEffect(() => {
    const fetchPastEvents = async () => {
      try {
        const logs = await publicClient.getLogs({
          address: options.address,
          event: parseAbiItem(`event ${options.eventName}(address indexed from, address indexed to, uint256 indexed tokenId)`),
          fromBlock: 'latest', // 实际项目中可能需要更大的范围
        });
        
        if (logs.length > 0) {
          options.onLogs(logs);
        }
      } catch (error) {
        console.error('获取历史事件失败:', error);
        options.onError?.(error as Error);
      }
    };
    
    fetchPastEvents();
  }, [options.address, options.eventName, publicClient]);
  
  // 实时监听新事件
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: options.onLogs,
    onError: options.onError,
  });
}

这个方案结合了历史事件查询和实时监听,确保不会遗漏任何事件。即使实时监听暂时断开,也能通过轮询历史事件来弥补。

完整代码

下面是一个完整的、可直接运行的NFT铸造页面组件:

// NFTMintPage.tsx
import React, { useState, useCallback, useRef } from 'react';
import { useWatchContractEvent, useAccount, usePublicClient, useWriteContract } from 'wagmi';
import { parseAbiItem, Log } from 'viem';
import { showNotification } from './notification';

// NFT合约ABI片段
const nftContractAbi = [
  {
    name: 'Transfer',
    type: 'event',
    inputs: [
      { name: 'from', type: 'address', indexed: true },
      { name: 'to', type: 'address', indexed: true },
      { name: 'tokenId', type: 'uint256', indexed: true },
    ],
  },
  {
    name: 'mint',
    type: 'function',
    stateMutability: 'payable',
    inputs: [],
    outputs: [],
  },
] as const;

const NFT_CONTRACT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc9e...';

function useUniqueContractEvents(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onUniqueLogs: (logs: Log[]) => void;
  onError?: (error: Error) => void;
}) {
  const processedIds = useRef<Set<string>>(new Set());
  const publicClient = usePublicClient();
  
  // 获取历史事件
  React.useEffect(() => {
    const fetchPastEvents = async () => {
      try {
        const logs = await publicClient.getLogs({
          address: options.address,
          event: parseAbiItem(`event ${options.eventName}(address indexed from, address indexed to, uint256 indexed tokenId)`),
          fromBlock: 'latest',
          toBlock: 'latest',
        });
        
        const uniqueLogs = logs.filter(log => {
          const id = `${log.transactionHash}-${log.logIndex}`;
          return !processedIds.current.has(id);
        });
        
        if (uniqueLogs.length > 0) {
          uniqueLogs.forEach(log => {
            processedIds.current.add(`${log.transactionHash}-${log.logIndex}`);
          });
          options.onUniqueLogs(uniqueLogs);
        }
      } catch (error) {
        console.error('获取历史事件失败:', error);
        options.onError?.(error as Error);
      }
    };
    
    fetchPastEvents();
  }, [options.address, options.eventName, publicClient]);
  
  // 实时监听
  const handleLogs = useCallback((logs: Log[]) => {
    const uniqueLogs = logs.filter(log => {
      const id = `${log.transactionHash}-${log.logIndex}`;
      if (processedIds.current.has(id)) {
        return false;
      }
      processedIds.current.add(id);
      return true;
    });
    
    if (uniqueLogs.length > 0) {
      options.onUniqueLogs(uniqueLogs);
    }
  }, [options.onUniqueLogs]);
  
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: handleLogs,
    onError: options.onError,
  });
}

export function NFTMintPage() {
  const { address } = useAccount();
  const [mintedTokens, setMintedTokens] = useState<number[]>([]);
  const [isMinting, setIsMinting] = useState(false);
  
  const { writeContractAsync } = useWriteContract();
  
  // 监听Transfer事件
  useUniqueContractEvents({
    address: NFT_CONTRACT_ADDRESS,
    abi: nftContractAbi,
    eventName: 'Transfer',
    onUniqueLogs: (logs) => {
      // 只处理转给当前用户的事件
      const relevantLogs = logs.filter(log => 
        log.args.to?.toLowerCase() === address?.toLowerCase()
      );
      
      if (relevantLogs.length > 0) {
        const newTokenIds = relevantLogs.map(log => 
          Number(log.args.tokenId)
        );
        
        setMintedTokens(prev => {
          const combined = [...prev, ...newTokenIds];
          // 去重排序
          return [...new Set(combined)].sort((a, b) => a - b);
        });
        
        showNotification(`🎉 成功收到 ${newTokenIds.length} 个NFT!`);
      }
    },
    onError: (error) => {
      console.error('事件监听出错:', error);
      showNotification('事件监听连接不稳定,请刷新页面', 'warning');
    },
  });
  
  // 铸造函数
  const handleMint = async () => {
    if (!address) {
      showNotification('请先连接钱包', 'error');
      return;
    }
    
    try {
      setIsMinting(true);
      
      await writeContractAsync({
        address: NFT_CONTRACT_ADDRESS,
        abi: nftContractAbi,
        functionName: 'mint',
        value: 0.01n * 10n ** 18n, // 假设铸造价格是0.01 ETH
      });
      
      showNotification('交易已提交,请等待确认...', 'info');
    } catch (error: any) {
      console.error('铸造失败:', error);
      showNotification(`铸造失败: ${error.shortMessage || error.message}`, 'error');
    } finally {
      setIsMinting(false);
    }
  };
  
  return (
    <div className="nft-mint-page">
      <h1>NFT铸造平台</h1>
      
      <div className="mint-section">
        <button 
          onClick={handleMint}
          disabled={isMinting || !address}
        >
          {isMinting ? '铸造中...' : '铸造NFT (0.01 ETH)'}
        </button>
        
        {!address && (
          <p className="hint">请先连接钱包</p>
        )}
      </div>
      
      <div className="tokens-section">
        <h2>你的NFT ({mintedTokens.length}个)</h2>
        
        {mintedTokens.length === 0 ? (
          <p>还没有NFT,点击上方按钮铸造</p>
        ) : (
          <div className="token-list">
            {mintedTokens.map(tokenId => (
              <div key={tokenId} className="token-card">
                NFT #{tokenId}
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

踩坑记录

在实际开发中,我遇到了几个具体的坑,这里记录下来:

  1. 事件重复触发:最开始没有做去重,发现同一个铸造交易会触发2-3次事件更新。原因是节点推送可能重复,且组件重渲染会重新建立监听。解决方案就是用 transactionHash + logIndex 做唯一标识去重。

  2. 网络切换后监听不更新:虽然 wagmi 理论上应该自动处理,但我发现切换到某些测试网时,监听器还在旧链上。后来发现是因为我硬编码了RPC URL,没有用 wagmiusePublicClient。改用 usePublicClient() 后,网络切换就正常了。

  3. TypeScript类型错误viem 对类型要求很严格,事件参数的访问方式从 log.args[2] 变成了 log.args.tokenId。一开始我按老习惯写,类型检查报错。需要仔细看ABI定义,用正确的属性名访问。

  4. 内存泄漏警告:在监听回调中直接更新状态,如果组件卸载得快,会报“Can't perform a React state update on an unmounted component”。我加了 useRef 来跟踪组件挂载状态,但后来发现 wagmi 的 Hook 已经处理了这个问题,主要是我自己的 setState 调用时机不对。最终方案是把状态更新包装在条件判断里。

小结

经过这次折腾,我最大的收获是:现代Web3前端开发中,用 wagmi + viem 这套组合能省去很多底层细节的麻烦。事件监听这种看似简单的功能,实际上要考虑网络切换、错误处理、性能优化等多个方面。现在这套方案已经在生产环境稳定运行,用户反馈“铸造后立刻能看到NFT”的体验很好。

如果想进一步优化,可以考虑加上事件监听的状态指示器(比如显示“正在监听事件...”),或者实现离线事件队列,等网络恢复后一并处理。不过对于大多数DApp来说,现在的方案已经足够可靠了。

uni-app 编译小程序原生组件时疑似丢属性,可以给官方提 PR 了

前言

大家好,我是 uni-app 的核心开发 笨笨狗吞噬者。不少开发者都遇到过某些小程序原生组件在原生项目能跑,但是写到 uni-app 中就用不了,今天来分析下背后的原因以及原理,以后再遇到类似的问题,你也可以给 uni-app 仓库提 pr。

问题

我们以微信小程序的 official-account-publish 组件为例,看下错误的产物和正确的产物都是什么样的,

测试代码

<template>
  <official-account-publish1
    topic="和coco一起做好事"
  ></official-account-publish1>
</template>

错误产物

<official-account-publish
  wx:if="{{d}}"
  u-i="1d1772a4-1"
  bind:__l="__l"
  u-p="{{d}}"
></official-account-publish>

正确产物

<official-account-publish topic="和coco一起做好事"></official-account-publish>

true.jpg

可以看到错误的产物上面多了不少属性,比如 u-iu-p,而正常产物是没有这些的

原理

其实这个差异的核心,不在于 official-account-publish 组件本身,而在于编译阶段它有没有命中小程序的 isCustomElement

uni-app 在编译小程序模板时,会先给当前平台准备一份原生组件名单。这个名单通常就在对应平台包的 compiler/options 里维护,比如微信小程序这一份就放在平台编译配置里 github.com/dcloudio/un…

wx.jpg

除此之外,项目侧还可以通过 manifest.json 里的 nativeTags 再补一份,最后一起参与 isCustomElement 判断

也就是说,编译器遇到一个标签时,并不是简单看它长得像不像组件,而是先看它在不在这份名单里

如果命中了,说明它是小程序原生组件,那就按原生标签处理,模板产物会尽量保留原样,像 topic 这类属性也会直接落到最终产物里,不会额外补组件运行时需要的标记

如果没有命中,编译器就会把它当成普通 vue 组件继续处理。这时候就会进入组件那套编译分支,给节点补上 u-iu-pbind:__l 这一类运行时字段,用来做组件实例关联、props 同步这些事情,所以最终产物看起来就“不像原生组件了”

所以这个问题本质上就是一句话:official-account-publish 应该走小程序 isCustomElement 分支,而不是走 vue 组件分支。一旦判断错了,生成结果就会立刻不一样

这类问题的修复思路通常也很直接,如果某个小程序原生组件没有被正确识别,除了补平台内置名单,更常见的做法就是在项目配置里把它加到 nativeTags,让编译器把它当成原生标签处理

比如:

// manifest.json
{
  "mp-weixin": {
    "nativeTags": ["official-account-publish"]
  }
}

加完之后,这个标签就会进入 isCustomElement 的判断范围,编译时不再走 vue 组件分支,生成结果也就会回到小程序原生组件该有的样子

交流群

我建了一个微信群(非官方),大家可以在群里和我沟通交流 uniapp 开发遇到的问题、uniapp 的源码等问题。

mmqrcode1775031270633.png

在 Windows 上安装 uv(高性能 Python 包管理器)

1. 使用 PowerShell 安装(官方推荐)

这是最直接的方式。打开 PowerShell,运行以下命令:

PowerShell

powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
  • 注意:安装完成后,你可能需要重启终端(Powershell 或 VS Code 内置终端)才能让 uv 命令生效。

2. 使用包管理器安装

如果你电脑上已经安装了常见的 Windows 包管理器,可以用对应的命令:

  • pip (如果你已经有 Python 环境):

    Bash

    pip install uv
    
  • Winget (Windows 自带):

    Bash

    winget install astral-sh.uv
    
  • Scoop:

    Bash

    scoop install uv
    

3. 验证安装

安装完成后,输入以下命令检查是否成功:

PowerShell

uv --version

常用命令快速上手

安装好后,你可以尝试以下操作来感受它的速度:

  • 创建虚拟环境uv venv
  • 激活环境.venv\Scripts\activate
  • 安装包uv pip install flask(比原生 pip 快得多)
  • 运行脚本uv run python_file.py

小提示:如果你在 VS Code 中使用,建议安装 Python 扩展,它现在能很好地识别 uv 创建的虚拟环境。

🔍 React 面试官眼中的“秘密武器”:深度剖析 useRef

🔍 React 面试官眼中的“秘密武器”:深度剖析 useRef

在 React 的面试战场上,useRef 绝对是那个看似不起眼,实则能决定你能否进入下一轮的“关键先生”。很多候选人能熟练使用 useStateuseEffect,但当被问及“如何在不触发重渲染的情况下保存数据”或“为什么我的定时器关不掉”时,往往哑口无言。

今天,我们就通过两段典型的实战代码,揭开 useRef 的神秘面纱,让你在面试中不仅能写出代码,更能讲出原理。

📌 核心考点:useRef 是什么?

在深入代码前,你需要向面试官传达两个核心概念:

  1. 它是一个“盒子”useRef 返回一个普通的 JavaScript 对象,这个对象在组件的整个生命周期内保持引用稳定(始终是同一个对象)。
  2. 它是“非响应式”的:修改 .current 属性不会触发组件的重新渲染。这是它与 useState 最根本的区别。

💡 场景一:DOM 的直接掌控者

面试官提问: “如何在组件挂载后,让输入框自动获得焦点?”

这通常是考察 useRef 最经典的入门题。我们来看第一段代码:

import { useEffect, useRef, useState } from "react"

export default function App() {
  const [count, setCount] = useState(0);
  console.log('变了'); // 每次count变化,组件重渲染,这句都会执行
  
  const inputRef = useRef(null); // 创建一个ref容器
  
  useEffect(() => {
    // 在这里操作DOM
    inputRef.current.focus(); // 让input获得焦点
    console.log('222', inputRef.current); // 打印真实的DOM节点
  }, [])
  
  // 注意看这里:在渲染函数体中,inputRef.current 是 null
  console.log('111', inputRef.current); 
  
  return (
    <>
      <input ref={inputRef}/> {/* 将ref绑定到DOM上 */}
      <button type="button" onClick={() => setCount(count+1)}>count ++</button>
    </>
  )
}

面试解析要点:

  1. 同步与异步:请留意代码中的 console.log
    • 111:在函数体直接打印,此时 React 还在“画”界面,DOM 节点尚未生成,所以值为 null
    • 222:在 useEffect 中打印,此时 React 已经把界面“挂载”到页面上,inputRef.current 已经被赋值为真实的 DOM 元素。
  2. 执行时机useEffect 在浏览器完成渲染后执行,这保证了我们操作的 DOM 是真实存在的。
  3. 价值体现:这是 useRef 最直观的用途——打破 React 的虚拟 DOM 抽象层,直接操作真实 DOM

⏳ 场景二:跨越渲染的“持久化”存储

面试官提问: “React 函数组件每次渲染都会重新执行函数,如果我在一个定时器里需要保存状态,或者想在点击按钮时清除上一次的定时器,该怎么办?”

这时候,如果只用普通变量,每次重渲染变量都会被重置;如果用 useState,清除定时器的逻辑会变得复杂且容易出错。第二段代码展示了 useRef 的高级用法:

import { useRef, useState, useEffect } from 'react';

export default function App() {
    let intervalId = useRef(null); // 用ref来保存定时器ID
    const [count, setCount] = useState(0);
    
    function start() {
        // 即使组件多次渲染,intervalId 这个“盒子”始终是同一个
        intervalId.current = setInterval(() => {
            console.log('tick~~~');
        }, 1000);
        console.log(intervalId); // 打印 { current: 123 }
    }
    
    function stop() {
        // 从“盒子”里取出ID并清除
        clearInterval(intervalId.current);
    }

    // 仅用于演示:当count变化时,查看ref的值
    useEffect(() => {
        console.log(intervalId.current); // 只要定时器开着,这里会一直打印ID
    }, [count])
    
    return (
        <>
           <button onClick={start}>开始</button>
           <button onClick={stop}>停止</button>
           <button type="button" onClick={() => setCount(count+1)}>count ++</button>
        </>
    )
}

面试解析要点:

  1. 闭包陷阱的解药:在 start 函数中,我们将 setInterval 返回的 ID 存入了 intervalId.current。无论组件因为 count 变化重渲染多少次,intervalId 对象本身不会变,变的只是它里面的 current 值。
  2. 清理资源stop 函数能够准确获取到最新的定时器 ID 并清除它。如果不用 useRef,而用普通变量,stop 函数将无法访问到 start 函数内部的变量(作用域隔离)。
  3. 非响应式优势:我们将定时器 ID 存入 ref,并不希望它触发页面刷新。useRef 完美地充当了一个“默默奉献的存储柜”角色。

📝 总结:面试官想听到什么?

当被问及 useRef 时,请务必构建以下回答框架:

  1. 定义:它是用来创建一个在组件生命周期内持久化且引用稳定的对象。
  2. 两大用途
    • 访问 DOM:通过 ref 属性附加到元素上。
    • 存储可变值:存储定时器 ID、上一次的 props、第三方库实例等不需要触发视图更新的数据。
  3. useState 的区别ref 的变化是同步的且不触发重渲染;state 的变化是异步的且一定会触发重渲染。
  4. 避坑指南:不要试图用 useRef 替代状态管理,因为它的变化 React “看不见”,UI 不会自动更新。

掌握这些,你就能在面试中自信地告诉面试官:useRef** 不仅仅是一个获取 DOM 的工具,它是连接函数组件渲染周期与可变状态的桥梁。**

Vue3 的 v-model 双向绑定,90% 的人都用错了?(附 2026 最新避坑指南)

你的表单数据绑定了却不动?自定义组件 v-model 写了就是不生效?
而用 Vue3 正确的 v-model 写法一行代码搞定双向绑定,支持多字段同步、自定义事件、TS 完美兼容——再也不用手动写 $emit('input').sync 修饰符

如果你受够了:

  • 输入框改了值,页面没反应
  • 自定义组件传值像“猜谜游戏”
  • Vue2 转 Vue3 后 v-model 突然失效
  • 团队里有人写 :value + @input,有人写 v-model,代码风格混乱

那么,这篇 2026 年最新实操指南,就是为你写的——
不用翻文档,所有代码模板直接复制粘贴,今天就能写出零 bug 的双向绑定


一、先搞懂:Vue3 的 v-model,到底“新”在哪?

很多从 Vue2 过来的开发者,还在用老思维写 v-model,结果频频翻车。
Vue3 对 v-model 做了三大升级

特性 Vue2 Vue3
绑定属性 固定为 value 可自定义(如 titlecount
触发事件 input 统一为 update:xxx
多绑定支持 不支持 一个组件可绑多个 v-model
语法糖 需配合 .sync 原生支持,无需额外修饰符

一句话总结Vue3 的 v-model = 更灵活 + 更统一 + 更少代码


二、核心干货:v-model 3 大场景实战(附可运行模板)

场景1:基础表单绑定(覆盖 80% 日常开发)

适用于 <input><textarea><select>、复选框等。

【实操代码】(直接复制)

<template>
  <div class="form-demo">
    <!-- 文本输入 -->
    <input v-model="username" placeholder="账号" />
    
    <!-- 密码 -->
    <input v-model="password" type="password" placeholder="密码" />
    
    <!-- 多行文本 -->
    <textarea v-model="bio" placeholder="个人简介"></textarea>
    
    <!-- 复选框(布尔值) -->
    <label>
      <input type="checkbox" v-model="agree" />
      同意用户协议
    </label>

    <!-- 实时预览 -->
    <div class="preview">
      账号:{{ username }}<br/>
      密码:{{ password }}<br/>
      简介:{{ bio }}<br/>
      已同意:{{ agree ? '✅' : '❌' }}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const username = ref('')
const password = ref('')
const bio = ref('')
const agree = ref(false)
</script>

避坑提醒v-model自动忽略元素上的 valuechecked 属性,不要混用


场景2:自定义组件 v-model(组件通信必备)

让自定义组件像原生表单一样使用 v-model

1. 创建组件:MyInput.vue

<template>
  <div class="my-input">
    <span>自定义:</span>
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      placeholder="请输入..."
    />
  </div>
</template>

<script setup>
// 必须叫 modelValue!
const props = defineProps(['modelValue'])
// 必须 emit update:modelValue!
const emit = defineEmits(['update:modelValue'])
</script>

2. 父组件使用

<template>
  <MyInput v-model="customText" />
  <p>输入内容:{{ customText }}</p>
</template>

<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const customText = ref('')
</script>

效果:父组件 v-model="customText" → 子组件 modelValue 接收 → 输入时触发 update:modelValue → 父组件自动更新!


场景3:多 v-model 绑定(复杂表单神器)

一个组件同时绑定多个双向数据,比如姓名 + 年龄 + 邮箱。

父组件

<template>
  <UserForm 
    v-model:name="user.name"
    v-model:age="user.age"
    v-model:email="user.email"
  />
  <pre>{{ user }}</pre>
</template>

<script setup>
import { reactive } from 'vue'
import UserForm from './UserForm.vue'

const user = reactive({
  name: '',
  age: 0,
  email: ''
})
</script>

子组件:UserForm.vue

<template>
  <div>
    <input v-model="nameProxy" placeholder="姓名" />
    <input v-model.number="ageProxy" type="number" placeholder="年龄" />
    <input v-model="emailProxy" type="email" placeholder="邮箱" />
  </div>
</template>

<script setup>
const props = defineProps(['name', 'age', 'email'])
const emit = defineEmits(['update:name', 'update:age', 'update:email'])

// 使用计算属性代理,让 v-model 在子组件内也能用
import { computed } from 'vue'
const nameProxy = computed({
  get: () => props.name,
  set: (val) => emit('update:name', val)
})
const ageProxy = computed({
  get: () => props.age,
  set: (val) => emit('update:age', val)
})
const emailProxy = computed({
  get: () => props.email,
  set: (val) => emit('update:email', val)
})
</script>

优势:父组件只需写 v-model:xxx,逻辑清晰,维护成本极低!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:绑定非响应式数据

// 错误 
let text = '' // 普通变量
// v-model="text" → 修改无效!

// 正确 
const text = ref('') // 响应式

坑2:自定义组件命名不规范

// 错误(Vue2 写法)
defineProps(['value'])
defineEmits(['input'])

// 正确(Vue3 标准)
defineProps(['modelValue'])
defineEmits(['update:modelValue'])

坑3:v-model:value 混用

<!-- 错误  -->
<input v-model="msg" :value="defaultValue" />

<!-- 正确  -->
<input v-model="msg" />
<!-- 或初始化时:const msg = ref(defaultValue) -->

四、进阶技巧:用 TS 让 v-model 更安全

// MyInput.vue (TypeScript 版)
<script setup lang="ts">
interface Props {
  modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
}>()
</script>

类型检查 + 智能提示,杜绝拼写错误!


五、谁在用 Vue3 的 v-model?

  • 字节跳动:所有内部表单系统强制使用多 v-model 模式
  • 腾讯文档:协作编辑组件通过 v-model:content 实时同步
  • Nuxt 3 官方模板:表单示例全部采用 Composition API + v-model
  • Vue 官方团队:在 RFC 中明确表示 “v-model 是未来组件通信的核心”

结语:双向绑定,本该如此优雅

Vue3 的 v-model 不是“小改动”,而是对组件通信范式的重新定义
当你能用 v-model:titlev-model:count 一行搞定复杂交互,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Pinia 比 Vuex 好用 10 倍?Vue3 状态管理终于不折磨人了!(新手复制即用)

还在为 Vuex 的 statemutationactionmodule 四件套头疼?
而用 Pinia一行代码定义状态,直接修改数据,无需 commit,TS 完美支持,刷新页面还能自动持久化——小项目 5 分钟搞定,大项目维护成本直降 60%!

如果你受够了:

  • 写个计数器要建 3 个文件
  • 改个状态要绕 commit('SET_COUNT', 1) 半天
  • 调试时找不到数据在哪被改了
  • 刷新页面状态全丢,还得手动存 localStorage

那么,这篇手把手实操指南,就是为你写的——
不用看文档,所有代码模板直接复制粘贴,今天就能替换掉 Vuex


一、先说清:为什么 Pinia 是 Vue3 的“官方亲儿子”?

Vuex 是 Vue2 时代的产物,设计时没考虑 Composition API 和 TypeScript。
Pinia 由 Vue 核心团队打造,专为 Vue3 而生,直接解决 Vuex 所有痛点:

痛点 Vuex Pinia
配置复杂度 需创建 store/index.js + modules 一个文件就是一个仓库
修改状态 必须通过 mutation(commit 直接 this.count++
TS 支持 弱,需额外类型声明 原生完美支持
代码体积 ~10KB ~5KB(更轻)
调试体验 多层嵌套难追踪 DevTools 一目了然

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 使用 Pinia,Vuex 已成历史。


二、核心干货:Pinia 3 步上手(附可运行模板)

第一步:安装(1 行命令)

# 推荐 pnpm
pnpm add pinia

# 或 npm / yarn
npm install pinia
yarn add pinia

第二步:全局注册(2 行代码)

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // ← 只需引入这个
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // ← 注册
app.mount('#app')

避坑提醒不需要像 Vuex 那样写 new Store({}) 或分模块配置!


第三步:创建并使用仓库(核心!直接复制)

1. 创建仓库:src/store/counterStore.js

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // state:直接返回对象(响应式)
  state: () => ({
    count: 0,
    name: 'Pinia测试'
  }),

  // getters:计算属性(自动缓存)
  getters: {
    doubleCount: (state) => state.count * 2
  },

  // actions:同步/异步方法(直接修改 state!)
  actions: {
    increment() {
      this.count++ // 不用 commit!
    },
    async incrementAsync() {
      await new Promise(r => setTimeout(r, 1000))
      this.count++
    }
  }
})

2. 在组件中使用

<template>
  <div>
    <h3>{{ counterStore.name }}</h3>
    <p>当前:{{ counterStore.count }}</p>
    <p>2倍:{{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">+1</button>
    <button @click="counterStore.incrementAsync">异步+1</button>
  </div>
</template>

<script setup>
// 引入 + 实例化(关键!)
import { useCounterStore } from '@/store/counterStore'
const counterStore = useCounterStore() // ← 必须实例化!
</script>

效果:状态、计算属性、方法全部自动暴露,无需 mapStatemapActions


三、实战避坑:90% 新手都会踩的 3 个致命错误

坑1:只引入不实例化,导致 undefined

// 错误
import { useCounterStore } from '@/store/counterStore'
console.log(useCounterStore.count) // 报错!

// 正确
const counterStore = useCounterStore()
console.log(counterStore.count) // 正常

坑2:在组件里直接改状态,破坏可维护性

// 不推荐(小型 demo 可以,项目别这么干)
counterStore.count = 999

// 推荐(统一走 actions,便于调试和复用)
counterStore.increment()

坑3:多个仓库用相同 ID,数据互相污染

// 错误
defineStore('user', { ... })
defineStore('user', { ... }) // ID 重复!

// 正确
defineStore('user', { ... })
defineStore('cart', { ... }) // ID 唯一

四、进阶技巧:一行代码实现状态持久化(刷新不丢)

默认 Pinia 状态刷新就没了?用官方插件 pinia-plugin-persistedstate,轻松搞定!

1. 安装插件

pnpm add pinia-plugin-persistedstate

2. 配置插件

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // ← 启用插件
app.use(pinia)
app.mount('#app')

3. 仓库开启持久化

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: { ... },
  persist: true // ← 就这一行!
})

效果count 自动存入 localStorage,刷新页面依然保留!


五、谁在用 Pinia?

  • 字节跳动:抖音 Web 端、飞书文档全面采用 Pinia
  • 腾讯:微信开放平台、腾讯文档 Vue3 项目标配
  • Nuxt 3:官方默认状态管理方案
  • Vue 官方生态:Vue Router、VitePress 示例均使用 Pinia

结语:状态管理,本该如此简单

Pinia 的价值,不只是“替代 Vuex”,而是让状态管理回归本质:直观、可维护、可扩展
当你不再为写 mutation 而烦恼,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

救命!Vue3 的 Composition API,居然能让我少写 80% 冗余代码?(新手也能直接抄)

你的 Vue 组件里是不是还在 datamethodscomputedwatch 之间来回跳转?
而用 Composition API一个 setup 函数搞定所有逻辑,代码量直降 80%,逻辑清晰到实习生都能看懂

如果你受够了:

  • Options API 里找某个变量要翻半天
  • 相同逻辑(比如表单校验)在多个组件里复制粘贴
  • 面试被问 “Vue3 和 Vue2 区别” 只能答“Proxy 更快”
  • 想复用逻辑却只能靠 Mixin(然后陷入命名冲突地狱)

那么,这篇手把手实操指南,就是为你写的——
不用死记硬背,所有代码模板直接复制粘贴,今天就能用上


一、先澄清一个误区:Composition API 不是“花里胡哨”,是真能救急

很多新手觉得:“Options API 能用,为啥换?”
但真相是:Options API 在复杂组件中,逻辑天然割裂

举个真实例子:写一个带防抖搜索 + 加载状态 + 错误提示的搜索框

  • Options API 写法

    • data 里定义 keyword, loading, error
    • methods 里写 search(), debounce()
    • watch 里监听 keyword 触发搜索
    • mounted 里可能还要初始化默认值
      同一个功能,散落在 4 个地方!
  • Composition API 写法

    const { keyword, loading, error, search } = useSearch()
    

一行代码,逻辑内聚,复用无痛

大厂现状:字节、腾讯、阿里内部 Vue3 项目 100% 强制使用 Composition API,面试必考。


二、核心干货:Composition API 3 个必学用法(附可运行模板)

1. script setup:所有逻辑的“入口”,一次搞定所有

这是 Vue3 官方推荐的写法,无需 return,自动暴露所有变量和方法

实操代码模板(直接复制到项目)

<template>
  <div>
    <input v-model="username" placeholder="请输入账号" />
    <button @click="login" :disabled="isLoading">
      {{ isLoading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

<script setup>
// 1. 定义响应式数据(替代 data)
import { ref } from 'vue'
const username = ref('')       // 响应式字符串
const isLoading = ref(false)   // 响应式布尔值

// 2. 定义方法(替代 methods)
const login = () => {
  isLoading.value = true
  // 模拟登录请求
  setTimeout(() => {
    console.log('登录成功,账号:', username.value)
    isLoading.value = false
  }, 1000)
}
// 3. 无需 return!<script setup> 自动暴露
</script>

避坑提醒:只有普通 setup() 函数才需要手动 return<script setup> 不用!


2. ref vs reactive:响应式数据的“两大神器”,别再用混了

记住口诀:**简单数据用 **ref复杂对象用 reactive

场景 推荐 API 修改方式 模板中使用
字符串、数字、布尔值 ref count.value = 1 {{ count }}
对象、数组 reactive user.name = 'Tom' {{ user.name }}

ref 实操示例】

import { ref } from 'vue'
const count = ref(0)

const increment = () => {
  count.value++ // 必须加 .value!
}

【reactive实操示例】

import { reactive } from 'vue'
const user = reactive({
  name: '',
  age: 0,
  hobbies: []
})

const updateUser = () => {
  user.name = 'Alice' // 直接修改,不加 .value
  user.hobbies.push('coding')
}

关键技巧:用 toRefs 解构 reactive 对象,保持响应式

import { reactive, toRefs } from 'vue'
const user = reactive({ username: '', password: '' })

// 解构后仍响应式
const { username, password } = toRefs(user)
username.value = 'test' // 有效!

3. 生命周期钩子:按需引入,不用写空方法

Vue3 生命周期需显式导入,更灵活,且避免无用代码。

【常用生命周期对照表】

Vue2 Vue3
mounted onMounted
updated onUpdated
beforeUnmount onBeforeUnmount

【实操示例:页面加载后请求数据】

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

const list = ref([])

onMounted(async () => {
  const res = await axios.get('/api/user/list')
  list.value = res.data
})
</script>

避坑提醒:生命周期钩子必须在 <script setup>setup() 内部调用,不能在外部!


三、实战避坑:90% 的人都会踩的 3 个致命错误

坑1:忘记给 ref.value,导致响应式失效

// 错误
const count = ref(0)
count = 1 // 页面不会更新!

// 正确
count.value = 1

坑2:用 reactive 创建简单数据

// 错误
const count = reactive(0) // reactive 只接受对象/数组
count = 1 // 响应式丢失!

// 正确
const count = ref(0)

坑3:为了“规范”强行封装,把简单逻辑搞复杂

正确姿势:只有跨组件复用的逻辑才封装成 Hook,否则直接写!


四、进阶技巧:用自定义 Hook 复用逻辑,效率拉满!

把重复代码(如表单校验、请求封装、本地存储)抽成 Hook,多个组件直接引入,少写 80% 代码

【实战示例:封装通用表单校验 Hook】

第一步:创建 hooks/useForm.js

// hooks/useForm.js
import { ref } from 'vue'

export const useForm = (rules) => {
  const form = ref({})
  const errors = ref({})

  const validate = () => {
    let isValid = true
    for (const key in rules) {
      const rule = rules[key]
      if (!form.value[key] && rule.required) {
        errors.value[key] = rule.message
        isValid = false
      } else {
        errors.value[key] = ''
      }
    }
    return isValid
  }

  return { form, errors, validate }
}

第二步:在组件中使用

<script setup>
import { useForm } from '@/hooks/useForm'

const { form, errors, validate } = useForm({
  username: { required: true, message: '请输入账号' },
  password: { required: true, message: '请输入密码' }
})

const login = () => {
  if (validate()) {
    console.log('提交数据:', form.value)
  }
}
</script>

<template>
  <div>
    <input v-model="form.username" />
    <span v-if="errors.username" class="error">{{ errors.username }}</span>
    
    <input type="password" v-model="form.password" />
    <span v-if="errors.password" class="error">{{ errors.password }}</span>
    
    <button @click="login">登录</button>
  </div>
</template>

效果:以后任何表单,只需 3 行代码引入,校验逻辑自动生效!


五、谁在用 Composition API?

  • 字节跳动:抖音 Web 端全量 Vue3 + Composition API
  • 腾讯文档:协同编辑组件基于自定义 Hook 构建
  • 阿里云控制台:复杂表单系统 100% 使用 useXXX 模式
  • Vue 官方生态:Pinia、Vue Router 4 全面拥抱 Composition

结语:少写代码,才是高级程序员的终极追求

Composition API 的价值,不只是“新语法”,而是用函数式思维组织逻辑,让代码可读、可测、可复用
当你不再为找变量翻遍整个文件,你就知道——这波升级,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端必懂!一文搞懂 WebAssembly:Web/Electron/RN 全通用,你天天用的软件,底层都靠它

对于前端开发者而言,WebAssembly(简称 Wasm)或许是一个"熟悉又陌生"的名词。

image.png

偶尔能够在技术文章中看到,却很少在日常开发中用到。

但事实上,它在 ElectronReact Native 等主流跨平台框架中,Wasm 现在已经成为了突破前端性能瓶颈的主要手段。

Wasm 到底是啥?

很多时候大家会把 Wasm 当成一种编程语言,其实这是一个常见误区。

Wasm 不是编程语言,而是一种二进制字节码格式,是 W3C 推荐的第四种 Web 核心技术(与 HTML、CSS、JavaScript 并列)。

image.png

用最直白的话来说:

Wasm 是开发者用 C/C++、Rust、Go 等语言编写高性能代码,再通过编译工具将其编译成 .wasm 二进制文件。

前端开发者无需关心底层实现,只需像调用 npm 包一样,通过Js加载并调用其中的功能。

核心优势很直接,就是"接近原生的性能"。

由于是二进制格式,解析速度比Js快 5-10 倍,运行速度可达原生代码的 70%~90%

而且同时具备安全沙箱(运行在隔离环境,不直接访问系统资源)、跨平台(一次编译,多端通用)、体积小(二进制文件比Js体积小得多)的特点。

这里需要注意:Wasm 不是来替代Js的,而是和Js合作的

  • Js 负责 DOM 操作、UI 交互、网络请求等灵活场景。
  • Wasm 负责计算密集型、CPU 高负载任务(如 3D 渲染、图像处理、加密、大数据计算)。

Wasm 在跨平台框架中使用

Wasm 不是只能在浏览器中运行。

无论是桌面端的 Electron,还是移动端的 React Native,都能完美支持 Wasm,甚至比在浏览器中使用更自由、更灵活。

Wasm 的运行是不依赖具体的浏览器环境,只要有对应的运行时(如 V8 引擎、Wasm3 引擎),就能在任何平台运行。

而主流跨平台框架,早已内置或支持集成 Wasm 运行时。

Electron使用

Electron 的架构是"Chromium + Node.js",而 Chromium 内核本身就原生支持 WebAssembly

image.png

因此在 Electron 中使用 Wasm,和在浏览器中几乎没有区别。

// 加载并调用 Wasm 模块(以加法功能为例)
async function loadWasm() {
  // 1. 加载编译好的 .wasm 文件(和前端资源放在同一目录)
  const res = await fetch("/add.wasm");
  const bytes = await res.arrayBuffer();
  
  // 2. 编译 + 实例化 Wasm 模块
  const { instance } = await WebAssembly.instantiate(bytes);
  
  // 3. 直接调用 Wasm 暴露的方法,和调用 npm 包一致
  const result = instance.exports.add(10, 20);
  console.log("Wasm 计算结果:", result); // 输出 30
}

// 执行调用
loadWasm();

实际上 VS Code、Figma、剪映专业版等主流 Electron 桌面应用,都大量使用 Wasm 处理核心计算逻辑。

比如 Figma 的矢量图形引擎、剪映的视频解码,都是通过 C++ 编译成 Wasm 实现的,既保证了性能,又实现了跨平台兼容。

React Native使用

由于 React Native(RN)本身不依赖浏览器环境,无法直接使用浏览器的 Wasm 运行时。

但可以使用 react-native-webassembly(简洁易用)和 wasm3(轻量引擎)插件就能在 RN 中调用 Wasm 模块。

// 安装依赖
// yarn add react-native-webassembly 或 npx expo install react-native-webassembly

// 配置 metro.config.js
module.exports = {
  resolver: {
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.wasm'], // 新增 .wasm 后缀
  },
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineSourceMap: false,
      },
    }),
  },
};

// 调用 Wasm
import React, { useEffect } from 'react';
import { View, Text } from 'react-native';
import WebAssembly from 'react-native-webassembly';
// 导入本地 .wasm 文件(需放在项目可访问目录)
import addWasm from './add.wasm';

const WasmDemo = () => {
  useEffect(() => {
    // 加载并调用 Wasm
    const runWasm = async () => {
      const { instance } = await WebAssembly.instantiate(addWasm);
      const result = instance.exports.add(20, 30);
      console.log("RN 中 Wasm 计算结果:", result); // 输出 50
    };
    runWasm();
  }, []);

  return (
    <View>
      <Text>React Native + WebAssembly 示例</Text>
    </View>
  );
};

export default WasmDemo;

注意:RN 中使用 Wasm 时,需确保项目支持新架构(部分旧版本 RN 可能存在兼容问题)。

总结

其实可以把 Wasm 理解为一个"不挑平台、不挑框架的超级高性能工具包"。

当你在 Web、Electron、RN 等平台开发时,遇到 JS 无法承载的计算密集型任务(如图像处理、3D 渲染、加密、AI 推理),就可以考虑引入Wasm。

🚨 还在用 rem) 做大屏适配?用 vfit.js 一键搞定,告别改稿8版的噩梦!

 导读:欢迎来到《vfit.js 大屏适配指南》系列第 1 篇。在这个系列中,我们将带你彻底告别大屏适配的折磨,打通"痛点-解法-实战-优化-落地"的完整闭环。

上周,群里一个做政务大屏的兄弟心态崩了:"地图和图表用 rem 调了一整周,好不容易对齐了。结果去交付现场一看,客户的指挥中心用的是3840×2160 的超宽带鱼屏,整个页面全乱套了……"

底下瞬间刷出几十条"太真实了"。

做大屏开发的你,是不是也经常遭遇这些"社死时刻"?

  •  政务指挥中心:设计稿 1920×1080,现场屏幕长宽比极其奇葩,ECharts 图表直接挤成一团。
  • 工业监控大屏:用 rem 算来算去,DataV 的飞线动效死活偏了3 个像素。
  • 数据驾驶舱:老板在 iPad 竖屏打开大屏链接,质问你"为什么右边全白了?"

如果你中招了,请立刻停下手里写了一半的媒体查询!这篇指南,就是你的救命稻草!


🛑 为什么传统的适配方案,在大屏上必死无疑?

说到底,很多人没搞懂一个核心:可视化大屏,根本不是普通网页!

做普通后台管理系统,内容像"水流",用 flex、grid 响应式排版就行。
但做智慧城市、数字孪生这种大屏,页面是一幅"静态油画"。

📌 标题必须死死钉在正中间;
📌 3D 地图必须霸占绝对 C 位;
📌 两侧的数据面板哪怕字小点,也绝不能换行或错位。

所有元素的相对位置,必须焊死!

我们来看看你以前用的方案,为什么会翻车。

1️⃣ rem 方案:万恶的"单位转换地狱"

算比例、转 px,每次窗口一动就要重算。最要命的是,像 ECharts、高德地图、Three.js 这些第三方可视化库,底层全认 px!你用 rem,等于给自己挖了一个永远填不满的兼容坑。

2️⃣ vw/vh 方案:控制不住的"高度变形"

车联网驾驶舱时,宽度用 vw 撑满了,一旦屏幕比例从 16:9 变成 16:10,高度用 vh 就会拉伸,你的圆形仪表盘直接变成"椭圆",客户看着直摇头。


💡 终极解法:Scale 等比缩放,为什么你没早点用?

既然大屏是一幅画,那最完美的适配逻辑就是:把这幅画当成一个整体,等比例放大缩小!

就像在 PPT 里拖拽图片的对角线一样,不改变内部任何尺寸,只改变整体视野

  • • 设计稿是 px,你就写 px:零转换成本,所见即所得。
  • • 可视化库完美兼容:ECharts、DataV 闭眼用,再也不用担心偏移。
  • • 极致性能:利用 GPU 硬件加速,大屏不卡顿。

但手动写 Scale 有个巨坑:你要自己算比例、监听窗口、处理绝对定位失真……


🚀 登场:vfit.js,把你从加班中拯救出来

为了解决 Scale 方案的最后一公里痛点,vfit.js 诞生了。
这是一个专为 Vue 3 可视化大屏打造的轻量级适配神器。

不管你是做公安大屏、还是工厂看板,只需 3 行代码:

import { createApp } from 'vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css'

const app = createApp(App)

// 告诉它设计稿尺寸,剩下交给 vfit
app.use(createFitScale({
  designWidth1920,   
  designHeight1080,  
  scaleMode'auto'    
}))

app.mount('#app')

就这么简单!代码一交,按时下班。


🎁 互动福利:大屏避坑资料包

你以前做大屏遇到过最奇葩的屏幕尺寸是多少?
👇 在评论区吐槽你的经历 👇

🔥 福利时间
关注公众号,后台回复【大屏模板】,即可免费领取:

1. vfit.js 开箱即用 Vue3 工程模板(带 ECharts 示例)

2. 大屏常见奇葩分辨率适配速查表

官方资源直达:


🔗 推荐阅读与下期预告

📚 推荐深度阅读

在开始源码探索前,强烈推荐先阅读这两份权威指南:


🔜 下期预告:别急着复制,先懂底层!

今天我们明确了:Scale 是大屏适配的唯一真理
但作为高级前端,只会调包可不行。万一在嵌套 iframe 的工业后台里失效了怎么办?

下一篇: 《02 - 5分钟看懂 vfit.js 大屏适配源码:政务/工业看板防变形黑科技,就这50行!》
我们将扒开 vfit 的底裤,带你搞懂 ResizeObserver 和 GPU 缩放的核心原理。我们下期见!

紧急安全警报:Axios npm 包被投毒事件详解与防护指南

🚨 事件概述

2026 年 3 月 31 日,安全研究机构 StepSecurity 披露了一起震惊开源社区的重大安全事件:主流 JavaScript 库 Axios 的两个 npm 版本(1.14.1 和 0.30.4)被恶意植入远程控制代码

由于 Axios 是全球使用最广泛的 HTTP 客户端库,周下载量超过 3 亿次,此次供应链攻击的影响范围极其巨大,几乎所有使用 Node.js 的项目都可能受到影响。

⏰ 攻击时间线(北京时间)

timeline
    title Axios 供应链攻击事件时间线
    section 3 月 30 日
        23:59 : 攻击者发布<br/>plain-crypto-js@4.2.1
    section 3 月 31 日
        00:00 : 劫持维护者账号<br/>发布被投毒的 Axios 版本
        00:05 : Socket.dev 检测到<br/>异常依赖包
        04:00 : npm 官方下架<br/>所有恶意包
        08:00 : 安全机构公开披露<br/>事件详情

🔍 攻击手法深度解析

1️⃣ 账号劫持

攻击者成功劫持了 Axios 核心维护者 "jasonsaayman" 的 npm 账号,并将账号邮箱替换为匿名的 ProtonMail 地址。这一操作使得攻击者能够完全控制包的发布流程。

2️⃣ 绕过 CI/CD 审核

正常情况下,npm 包的发布需要通过 GitHub Actions 自动化流程进行构建和验证。但攻击者利用维护者权限,直接通过 npm CLI 手动上传被污染的版本,绕过了所有自动化安全检查。

3️⃣ 虚假依赖注入

这是整个攻击中最狡猾的部分。攻击者并没有直接修改 Axios 源码,而是采用了更隐蔽的手法:

{
  "dependencies": {
    "axios": "^1.14.1",
    "plain-crypto-js": "4.2.1"  // ← 恶意依赖包
  }
}

plain-crypto-js@4.2.1 是一个从未在 Axios 代码中被引用的虚假依赖包,它唯一的作用就是执行 postinstall 脚本。

4️⃣ 双重伪装策略

为了规避安全检测,攻击者提前 18 小时发布了两个版本的伪装包:

版本号 类型 作用
plain-crypto-js@4.2.0 干净版本 用于打掩护,降低安全工具警惕
plain-crypto-js@4.2.1 恶意版本 携带木马脚本,执行攻击

这种策略使得恶意包看起来像是"已有包的正常更新",而非"全新可疑包"。

5️⃣ 自动执行机制

当开发者执行 npm install axios 命令时,会发生以下连锁反应:

# 开发者执行的命令
npm install axios

# 实际发生的过程
├── 安装 axios@1.14.1 (被投毒版本)
├── 自动安装 plain-crypto-js@4.2.1 (恶意依赖)
└── 触发 postinstall 脚本
    └── 执行 setup.js (恶意脚本)
        └── 连接 C2 服务器 (sfrclak.com)
            └── 下载并运行跨平台木马

💀 恶意行为分析

感染流程

一旦触发恶意脚本,会根据操作系统类型执行不同的攻击载荷:

Windows 系统

# 创建隐藏的 PowerShell 窗口
VBScript → 隐藏 cmd.exe → 保存木马到 %TEMP%\6202033.ps1

# 持久化驻留
复制到:%PROGRAMDATA%\wt.exe
伪装成:Windows Terminal 可执行文件

macOS 系统

# 藏匿位置
/Library/Caches/com.apple.act.mond

# 伪装方式
伪装成:macOS 系统缓存进程

Linux 系统

# 直接执行
/tmp/ld.py

# 后台驻留
nohup python3 /tmp/ld.py &

恶意功能

木马成功后会执行以下操作:

  1. 连接远程指挥服务器 - 域名:sfrclak.com
  2. 窃取敏感信息 - 环境变量、API 密钥、配置文件
  3. 下载额外载荷 - 根据系统架构下载更多恶意程序
  4. 建立持久后门 - 保持后台运行,长期潜伏
  5. 自我清理 - 删除恶意脚本,伪造干净的配置文件

🎯 影响范围评估

高危项目

以下类型的项目风险最高:

  • 使用 axios@1.14.1 或 0.30.4 的所有项目
  • OpenClaw("龙虾")AI 智能体工具用户
  • React/Vue 前端项目
  • Node.js 后端服务
  • CI/CD 工具和自动化脚本
  • MCP Server 和各种 AI 编程工具

传播途径

graph LR
    A[开发者] --> B[npm install axios]
    B --> C[安装被投毒版本]
    C --> D[自动执行恶意脚本]
    D --> E[连接 C2 服务器]
    E --> F[下载木马程序]
    F --> G[系统被完全控制]
    
    H[AI 编程工具] --> I[自动安装依赖]
    I --> C

特别警示:AI 编程工具风险

2026 年流行的 AI 编程工具(如 Claude Code、Codex CLI、OpenClaw 等)大幅扩大了 npm 的攻击面:

  • 🔴 自动安装依赖 - AI 可能在你不知情的情况下安装被投毒的包
  • 🔴 高系统权限 - AI 工具通常有文件读写、命令执行权限
  • 🔴 难以审计 - 你可能连自己安装了什么都不清楚

正如社区所言:"你自己不写 npm 命令,AI 替你写了,你可能连自己装了什么都不知道。"

🛡️ 紧急处置方案

第一步:立即自查

# 检查项目中是否使用了 axios
npm list axios

# 或使用 pnpm
pnpm list axios

# 查看详细版本
npm list axios --depth=0

如果看到以下版本,立即采取行动

  • axios@1.14.1
  • axios@0.30.4

第二步:紧急卸载

# 立即卸载被投毒版本
npm uninstall axios

# 删除 node_modules 和锁文件(可选但推荐)
rm -rf node_modules package-lock.json
# Windows PowerShell:
# Remove-Item -Recurse -Force node_modules, package-lock.json

# 重新安装安全版本
npm install axios@latest

第三步:检查失陷迹象

Windows 系统

# 检查可疑文件
Test-Path "$env:PROGRAMDATA\wt.exe"
Test-Path "$env:TEMP\6202033.ps1"

# 检查网络连接
netstat -ano | findstr sfrclak.com

macOS 系统

# 检查可疑目录
ls -la /Library/Caches/com.apple.act.mond

# 检查异常进程
ps aux | grep -i "act.mond"

Linux 系统

# 检查恶意脚本
ls -la /tmp/ld.py

# 检查 Python 进程
ps aux | grep ld.py

# 检查网络连接
netstat -tulpn | grep sfrclak.com

第四步:重置凭证

如果你确认安装了被投毒的版本,必须立即重置所有敏感凭证:

  • 🔑 所有 API 密钥(云服务、数据库、第三方服务)
  • 🔑 SSH 密钥和访问令牌
  • 🔑 数据库密码
  • 🔑 管理员账户密码
  • 🔑 任何存储在环境变量中的敏感信息

因为木马具备窃取环境变量的能力,即使你已经卸载了恶意包,之前泄露的信息也需要全部更换

🔒 长期防护策略

1. 锁定依赖版本

package.json 中避免使用模糊版本范围:

{
  "dependencies": {
    "axios": "1.13.0"     // ✅ 确切版本
    // 而不是 "axios": "^1.13.0"  ❌
  }
}

2. 禁用自动脚本执行

# 全局配置
npm config set ignore-scripts true

# 或在 .npmrc 文件中添加
ignore-scripts=true

3. 启用 npm 审计

# 安装时自动审计
npm audit

# 自动修复可修复的问题
npm audit fix

# 强制修复(可能破坏兼容性)
npm audit fix --force

4. 使用安全工具

# 安装 socket-security 等安全工具
npm install -g socket-security

# 使用 Snyk 进行持续监控
npm install -g snyk
snyk test

5. 实施依赖审查流程

对于企业级项目,建议:

  • ✅ 使用私有 npm 镜像(如 Verdaccio)
  • ✅ 实施依赖包白名单制度
  • ✅ 定期生成 SBOM(软件物料清单)
  • ✅ 使用 Sigstore 等签名验证机制

6. AI 编程工具使用规范

如果你使用 AI 编程工具:

  • ⚠️ 审查所有自动安装的依赖
  • ⚠️ 不要给 AI 过高的系统权限
  • ⚠️ 定期检查 node_modules 内容
  • ⚠️ 在隔离环境中运行 AI 生成的代码

📊 技术细节补充

恶意域名信息

  • C2 服务器: sfrclak.com
  • 注册时间: 2026 年 3 月 30 日
  • 注册商: 匿名注册服务

恶意包哈希值

供安全工具检测使用:

plain-crypto-js@4.2.1:
SHA-256: [已移除,避免传播]

axios@1.14.1 (被投毒版本):
SHA-256: [已移除,避免传播]

axios@0.30.4 (被投毒版本):
SHA-256: [已移除,避免传播]

网络特征

安全设备可以监控以下网络请求:

POST https://sfrclak.com/api/gateway
User-Agent: node-fetch/1.0 (+https://github.com/bitinn/node-fetch)
Content-Type: application/json

🎓 事件启示

供应链安全的脆弱性

这次事件再次暴露了现代软件供应链的脆弱性:

  1. 单点故障 - 一个维护者账号被劫持,影响数亿用户
  2. 信任链断裂 - 我们信任的知名库也可能被投毒
  3. 自动化风险 - CI/CD 流程被绕过,缺乏多层验证
  4. 依赖传递 - 你的依赖的依赖也可能有问题

开源安全的新挑战

随着 AI 编程工具的普及,攻击面正在急剧扩大:

  • 🤖 AI 自动决策 - AI 可能选择安装不安全的依赖
  • 🤖 权限放大 - AI 的高权限使得攻击后果更严重
  • 🤖 审计困难 - 自动生成的代码更难追溯和审查

开发者的责任

作为开发者,我们需要:

  • ✅ 保持安全意识,不盲目信任任何依赖
  • ✅ 实施最小权限原则
  • ✅ 建立完善的依赖管理和审计流程
  • ✅ 关注安全动态,及时响应漏洞预警

📝 总结

关键要点

  1. 受影响版本: axios@1.14.1axios@0.30.4
  2. 攻击手法: 劫持维护者账号 + 虚假依赖 + 自动执行脚本
  3. 影响范围: 周下载量 3 亿+,全平台受影响
  4. 恶意行为: 远程控制木马 + 信息窃取 + 持久化驻留
  5. 处置方案: 立即自查 → 紧急卸载 → 检查失陷 → 重置凭证

行动清单

  • 检查所有项目的 axios 版本
  • 如果中招,立即卸载并重装安全版本
  • 检查系统是否有失陷迹象
  • 重置所有可能泄露的凭证
  • 更新 package.json 锁定版本号
  • 配置 npm 忽略自动脚本
  • 安装安全审计工具
  • 学习 AI 编程工具安全使用规范

🔗 参考资料

  1. StepSecurity 官方报告:链接
  2. npm 安全公告:链接
  3. Socket.dev 检测分析:链接
  4. GitHub Issue 讨论:链接

2026年,你敢信一些知名开源库都还不会正确使用防抖节流吗

摘要:防抖(debounce)和节流(throttle)是前端开发中高频使用的性能优化技巧,也是八股文的经典。但许多开发者甚至知名开源库的维护者都在误用它们。本文通过分析 vben 和 vue-office 两个热门项目的真实 PR,揭示常见的使用误区,并给出最佳实践。


Leader 大群 @ 我:"CSV 预览在 Mac 触控板上滑得太快了"

2026.3.16,那是一个普通的工作日,我正在专注地敲代码,突然 Leader 在公司大群里 @ 我:

image.png

"CSV 文件预览在 Mac 下触控板左右移动的速度好快,谁能调整一下?"

我心里一紧,我们的项目,用的是 vue-office 组件库预览office,我打开源码 debug了一下——居然是 throttle 的用法有问题。组件库里每次滚动事件都创建一个新的 throttle 函数,然后立即执行,根本没有节流效果。触控板的高频滚动事件直接穿透了,导致表格左右飞快移动。

项目中patch后,直接提了 PR。

这件事也让我想起了一年前给 vue-vben-admin 提的另一个类似 PR——那次是远程搜索的 debounce 用错了,也是每次输入都创建新实例

  • vue-vben-admin:一个拥有 32K+ Star 的现代化 Vue3 管理后台
  • vue-office:一个拥有 6K+ Star 的 Office 文件预览组件库

vben的项目维护者直接回复 "nice catch",

image.png

让我不禁思考:防抖和节流看似简单,但连这些知名库的资深开发者都会踩坑,说明这背后一定有什么容易忽视的细节。

本文就来复盘这两个案例,聊聊这些常见的使用误区。


vue-vben-admin 的远程搜索

问题案例

<template>
  <Input @search="useDebounceFn(onSearch, 300)" />
</template>

问题分析

核心错误@search 每次被调用时,都创建了一个新的 debounce 函数实例,然后立即执行它。

这意味着:

  1. 用户输入 "h",调用 debounceOptionsFn,创建 debounce A,立即执行 A,发起请求
  2. 用户继续输入 "he",再次调用 debounceOptionsFn,创建 debounce B,立即执行 B,再次发起请求
  3. 用户输入 "hel",再次调用,创建 debounce C,立即执行 C,又发起请求...

每个 debounce 实例都是全新的,内部的定时器逻辑完全没有机会发挥作用——防抖函数永远不会被触发(指延迟后的触发),而是每次都被立即执行。


vue-office 的 Excel 滚动优化

问题案例

vue-office 在处理 Excel 表格滚动时,想要使用 throttle 来优化性能,但代码存在类似的问题:

// ❌ 错误:在事件监听中直接使用 throttle
if (/Firefox/i.test(window.navigator.userAgent)) throttle(moveY(evt.detail), 50);
if (temp === tempX) throttle(moveX(deltaX), 50);
if (temp === tempY) throttle(moveY(deltaY), 50);

问题分析

这个错误的模式与上面的 debounce 案例如出一辙:

  1. 每次滚动事件触发,都创建一个新的 throttle 函数
  2. 新创建的 throttle 函数立即执行,没有任何节流效果

更严重的是,如果这是一个高频滚动场景,不断创建新的 throttle 函数还会带来内存泄漏的风险。


框架中的正确使用方式

Vue 组合式 API

<script setup>
import { debounce } from 'lodash-es'
import { onUnmounted } from 'vue'

// 在组件级别创建,保持引用稳定
const debouncedSearch = debounce(async (query) => {
  const results = await api.search(query)
  items.value = results
}, 300)

// 绑定到事件
function onInput(value) {
  debouncedSearch(value)
}

// 组件卸载时清理
onUnmounted(() => {
  debouncedSearch.cancel()
})
</script>

React Hooks

import { useMemo, useEffect } from 'react'
import { debounce } from 'lodash-es'

function SearchComponent() {
  // ✅ 使用 useMemo 保持 debounce 函数引用稳定
  const debouncedSearch = useMemo(
    () => debounce((query) => {
      api.search(query)
    }, 300),
    [] // 空依赖,只在组件挂载时创建
  )

  // 组件卸载时清理
  useEffect(() => {
    return () => {
      debouncedSearch.cancel()
    }
  }, [debouncedSearch])

  return (
    <input onChange={(e) => debouncedSearch(e.target.value)} />
  )
}

原生 JavaScript

import { throttle } from 'lodash-es'

class ScrollHandler {
  constructor() {
    // 在构造函数中创建,确保引用稳定
    this.throttledScroll = this.handleScroll.bind(this)
    this.throttledScroll = throttle(this.throttledScroll, 100)
    
    window.addEventListener('scroll', this.throttledScroll)
  }

  handleScroll() {
    // 处理滚动逻辑
  }

  destroy() {
    window.removeEventListener('scroll', this.throttledScroll)
    this.throttledScroll.cancel()
  }
}

结语

防抖和节流看起来简单,但实际使用中却暗藏陷阱。我在审查 vue-vben-admin 和 vue-office 代码时的经历告诉我:即使是经验丰富的开发者和知名开源项目,也可能在这些"基础"概念上栽跟头。

从那以后,我在代码审查时会多问一句写代码时多想一想函数引用,也养成了检查 debounce/throttle 使用模式的习惯。希望本文能帮助你写出更健壮、性能更优的代码。

如果你在项目中发现了类似的防抖节流误用,或者有其他最佳实践想分享,欢迎交流讨论!


参考资源


这篇文章记录了我发现并修复两个知名开源项目防抖节流问题的经历。如果你也遇到过类似的坑,欢迎在评论区聊聊你的故事。

kotlin安卓项目配置app横屏等方式

大家好,我的开源项目PakePlus可以将网页/Vue/React项目打包为桌面/手机应用并且小于5M只需几分钟,官网地址:pakeplus.com

在 Kotlin Android 项目中配置 App 固定横屏,在 AndroidManifest.xml 中为 Activity 添加 screenOrientation 属性:

<activity
    android:name=".MainActivity"
    android:screenOrientation="landscape"
    android:configChanges="orientation|screenSize">
</activity>

在 Android 中,screenOrientation 属性支持以下值:

常用方向

说明
unspecified 默认值,由系统选择方向
landscape 固定横屏(宽 > 高)
portrait 固定竖屏(高 > 宽)
reverseLandscape 反向横屏(屏幕倒转180度)
reversePortrait 反向竖屏(上下颠倒)
sensorLandscape 横屏,但允许根据传感器旋转180度
sensorPortrait 竖屏,但允许根据传感器旋转180度

LRU 缓存实现详解:双向链表 + 哈希表

LRU 缓存实现详解:双向链表 + 哈希表

摘要:本文深入剖析使用“双向链表 + 哈希表”实现 LRU(Least Recently Used)缓存的标准方法。从核心思想到代码实现,再到边界处理与复杂度分析,完整展示一个 O(1) 时间复杂度的 LRU 缓存如何工作。


1. 概述

LRU(最近最少使用)是一种常见的缓存淘汰策略:当缓存容量达到上限时,优先淘汰最长时间未被访问的数据。每次访问(读或写)都会将对应数据标记为“最近使用”。

为了支持 getput 操作均为 O(1) 时间复杂度,必须同时满足:

  • 快速查找:给定 key,能在 O(1) 时间内找到对应的数据。
  • 快速维护顺序:能够 O(1) 地将任意数据移动到“最近使用”的位置,并且 O(1) 删除“最久未使用”的数据。

数据结构组合哈希表 + 双向链表 完美达成上述要求。

为什么不能用数组或单向链表?

  • 数组移动元素 O(N)
  • 单向链表删除尾部需要遍历到前驱 O(N)
  • 双向链表 + 哈希表完美 O(1)

2. 核心数据结构

2.1 双向链表节点

每个节点存储键、值以及前驱和后继指针。其中存储 key 是为了在淘汰节点时能从哈希表中删除对应的键。

class ListNode {
  constructor(key, value) {
    this.key = key; // 存储 key 是为了淘汰时能从哈希表删除
    this.value = value;
    this.prev = null;   // 前驱指针
    this.next = null;   // 后继指针
  }
}

2.2 LRU 缓存类成员

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;   // 最大容量
    this.size = 0;              // 当前存储的节点数
    this.map = new Map();       // 哈希表:key -> 节点引用
    
    // 虚拟头尾节点(哨兵),简化边界操作
    this.head = new ListNode(0, 0);
    this.tail = new ListNode(0, 0);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }
}

为什么使用虚拟头尾?

  • 避免对空指针的判断(如 if (node.prev) ...
  • 插入头部时,head.next 一定存在;删除尾部时,tail.prev 一定存在
  • 统一代码逻辑,减少边界错误

2.3 链表状态示意图

初始状态:

head <-> tail

插入一个有效节点后:

head <-> node1 <-> tail

多个节点按最近使用顺序排列:头部是最近使用的,尾部是最久未使用的。


3. 辅助方法(链表操作)

所有辅助方法的时间复杂度均为 O(1)

3.1 _addToHead(node):在头部插入节点

_addToHead(node) {
  node.prev = this.head;
  node.next = this.head.next;
  this.head.next.prev = node;
  this.head.next = node;
}

执行步骤(假设当前 head <-> A <-> ...):

  1. node.prev = this.head
  2. node.next = this.head.next (即 A)
  3. this.head.next.prev = node (A 的前驱指向 node)
  4. this.head.next = node (head 的后继指向 node)

结果:head <-> node <-> A <-> ...

3.2 _removeNode(node):删除任意节点

_removeNode(node) {
  node.prev.next = node.next;
  node.next.prev = node.prev;
}

原理:让 node 的前驱直接指向 node 的后继,node 的后继直接指向 node 的前驱,从而将 node 从链表中移除。node 自身的指针无需修改(因为节点即将被丢弃或移动)。

3.3 _moveToHead(node):将已有节点移到头部

_moveToHead(node) {
  this._removeNode(node);
  this._addToHead(node);
}

先删除,再插入头部,使该节点成为“最近使用”节点。

3.4 _removeTail():删除尾部真实节点(最久未使用)

_removeTail() {
  const tailNode = this.tail.prev;   // 虚拟 tail 的前一个才是真正的尾节点
  this._removeNode(tailNode);
  return tailNode;
}

返回被删除的节点,以便从哈希表中删除其键。


4. 主要操作实现

4.1 get(key)

get(key) {
  if (!this.map.has(key)) return -1;
  const node = this.map.get(key);
  this._moveToHead(node);   // 标记为最近使用
  return node.value;
}

流程

  • 哈希表查找 → O(1)
  • 不存在则返回 -1
  • 存在则移动节点到链表头部 → O(1)
  • 返回节点值

4.2 put(key, value)

put(key, value) {
  if (this.map.has(key)) {
    // 情况1:key 已存在 → 更新值并移到头部
    const node = this.map.get(key);
    node.value = value;
    this._moveToHead(node);
  } else {
    // 情况2:key 不存在 → 创建新节点
    const newNode = new ListNode(key, value);
    this.map.set(key, newNode);
    this._addToHead(newNode);
    this.size++;

    if (this.size > this.capacity) {
      // 淘汰最久未使用的节点
      const removed = this._removeTail();
      this.map.delete(removed.key);
      this.size--;
    }
  }
}

情况2详细步骤

  1. 创建新节点,存入哈希表
  2. 插入链表头部(成为最近使用)
  3. 缓存大小 +1
  4. 若超过容量:删除尾部真实节点,并从哈希表中删除其键,大小 -1

注意:先插入新节点,再淘汰旧节点,确保淘汰的一定是最久未使用的。


5. 完整代码

class ListNode {
  constructor(key, value) {
    this.key = key;
    this.value = value;
    this.prev = null;
    this.next = null;
  }
}

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.size = 0;
    this.map = new Map();
    this.head = new ListNode(0, 0);
    this.tail = new ListNode(0, 0);
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }

  _addToHead(node) {
    node.prev = this.head;
    node.next = this.head.next;
    this.head.next.prev = node;
    this.head.next = node;
  }

  _removeNode(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
  }

  _moveToHead(node) {
    this._removeNode(node);
    this._addToHead(node);
  }

  _removeTail() {
    const tailNode = this.tail.prev;
    this._removeNode(tailNode);
    return tailNode;
  }

  get(key) {
    if (!this.map.has(key)) return -1;
    const node = this.map.get(key);
    this._moveToHead(node);
    return node.value;
  }

  put(key, value) {
    if (this.map.has(key)) {
      const node = this.map.get(key);
      node.value = value;
      this._moveToHead(node);
    } else {
      const newNode = new ListNode(key, value);
      this.map.set(key, newNode);
      this._addToHead(newNode);
      this.size++;
      if (this.size > this.capacity) {
        const removed = this._removeTail();
        this.map.delete(removed.key);
        this.size--;
      }
    }
  }
}

6. 执行示例

假设 capacity = 2

操作 链表状态(头部最近) 哈希表内容 说明
put(1, 1) head <-> 1 <-> tail {1→node1} 插入节点1
put(2, 2) head <-> 2 <-> 1 <-> tail {1→node1, 2→node2} 插入节点2,成为最近使用
get(1) head <-> 1 <-> 2 <-> tail {1→node1, 2→node2} 访问1,1移到头部
put(3, 3) head <-> 3 <-> 1 <-> tail {1→node1, 3→node3} 容量满,淘汰尾部的2
get(2) 不变 不变 返回 -1

7. 边界情况处理

情况 处理方式
capacity ≤ 0 通常题目保证 capacity ≥ 1;若需处理,可在构造时抛出错误或使所有 put 无效
get 不存在的 key 返回 -1
put 更新已存在的 key 更新 value,移到头部,不改变 size
put 时容量已满 先插入新节点,再淘汰尾部节点(确保淘汰的是最久未使用的)
链表中只有一个有效节点 虚拟头尾仍然存在,所有辅助方法正常工作
重复 put 同一 key 且值不变 依然执行 _moveToHead,更新使用顺序

8. 复杂度分析

  • 时间复杂度

    • get:O(1)(哈希查找 + 链表移动)
    • put:O(1)(哈希查找/插入 + 链表操作)
    • 所有辅助方法均为 O(1)
  • 空间复杂度

    • O(capacity),哈希表存储最多 capacity 个节点,链表同样存储 capacity 个节点。

9. 与“纯 Map”实现的对比

维度 双向链表 + 哈希表 纯 Map 版本(依赖插入顺序)
实现原理 手动维护链表顺序,完全可控 利用 Map 的键顺序特性
代码复杂度 较高(约60行) 极低(约20行)
时间复杂度 严格 O(1) 也是 O(1),但淘汰时需获取迭代器
空间开销 每个节点额外存储两个指针 无额外指针
可移植性 任何支持哈希表和指针的语言均可实现 依赖特定语言特性(如 JavaScript 的 Map 顺序)

结论:虽然纯 Map 版本更简洁,但双向链表 + 哈希表是标准、通用的实现,更能体现 LRU 的核心思想。


10. 常见问题

Q1:为什么必须用双向链表?单向链表不行吗?
A:单向链表删除一个节点时,如果只有该节点的引用,无法 O(1) 获得它的前驱。双向链表可以通过 node.prev 直接修改前驱的 next 指针,实现 O(1) 删除。

Q2:节点中为什么要存储 key?
A:淘汰尾部节点时,需要通过 removed.key 从哈希表中删除对应的键。如果节点不存 key,则无法知道删除哈希表中的哪个条目。

Q3:虚拟头尾节点占用额外空间,会影响容量计算吗?
A:不影响。size 只统计实际存储的数据节点,虚拟节点不计入容量。

Q4:如果 capacity = 0 怎么办?
A:可以规定构造时抛出异常,或者在 put 方法中直接返回(不存储任何数据)。实际工程中通常不会允许容量为0的缓存。

Q5:能否用其他数据结构代替双向链表?
A:可以使用有序字典(如 Python 的 OrderedDict 或 Java 的 LinkedHashMap),但这些本质上也是哈希表+双向链表的封装。手写双向链表更能理解底层机制。


11. 总结

  • LRU 缓存的核心是 哈希表 提供 O(1) 查找,双向链表 提供 O(1) 顺序维护。
  • 虚拟头尾节点极大简化了链表边界操作。
  • 所有操作(get / put)时间复杂度 O(1),空间复杂度 O(capacity)。
  • 该实现不依赖特定语言特性,具有很好的可移植性和教学意义。

最新版vue3+TypeScript开发入门到实战教程之插槽slot详解

插槽概述

Slot,可翻译中文为插槽、空槽、钥匙槽。以下为官方定义Solt(插槽)是 Vue 提供的一种内容分发机制,允许父组件向子组件指定位置注入内容。简单理解为大门样式已经设计好,钥匙空槽预留,使用大门的人可以按装指纹锁、物理锁等锁。 Slot插槽分三种类型

  • 默认插槽
  • 具名插槽
  • 作用于插槽

默认插槽

概述

默认插槽是具名插槽的一个特例,实际类型应分成两类:

  • 具名插槽
  • 作用于插槽

默认插槽实例

  • 创建Fish,Fish组件提供标题、尾部,中间插槽内容由使用者提供
  • 创建App组件,引用Fish组件

App组件代码

<template>
  <div class="app">
    <Fish>
      <div>游泳的鲫鱼</div>
    </Fish>
     <Fish>
      <template>
        <div>会飞的鱼</div>
      </template>
    </Fish>
    <Fish>
      <template v-slot:default>
        <div>跃龙门的鲤鱼</div>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

Fish组件代码

<template>
  <div class="fish">
    <h2>头部</h2>
    <slot></slot>
    <h2>底部</h2>
  </div>

</template>

运行效果: 在这里插入图片描述 注意中间位置,会飞的鱼没有显示出来,默认插槽不需要使用template标签,若使用,必须给标签设置默认名称。

具名插槽

概述

在Fish组件中,可能会有很多个插槽, 如顶部、中部都可以设置一个插槽。使用名称来区分插槽:

  • 给slot设置名称
  • template标签设置slot名称

具名插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content">默认数据</slot>
    <slot name="footer">
      <h2>底部</h2>
    </slot>
  </div>
</template>
<script setup lang="ts">
defineProps(['title']);
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
      <Fish title="飞鱼">
      <template v-slot:footer>
        <h2>鲤鱼跃龙门</h2>
      </template>
      <template v-slot:content>
        <div>会飞的鱼</div>
      </template>
    </Fish>
     <Fish title="草鱼">
      <template #content>
        <div>吃草的鱼</div>
      </template>
      <template v-slot:footer>
        <h2>很爱水草</h2>
      </template>
      </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行效果 在这里插入图片描述

  • 引用第一个Fish组件时,当不使用名称为footer的插槽时,它显示默认值
  • 引用第二个组件说明,template显示位置取决于所选Slot
  • 引用第三个组件说明,v-slot:可用#缩写,如v-slot:content缩写成#content

作用域插槽

概述

插槽实际分两类,一是具名插槽,一是作用域插槽,两者区别:

  • 具名插槽数据与显示都在使用者
  • 作用域插槽的数据是在被引用的组件当中,使用者只负责显示数据
  • 通过slot标签可以将数据传递给template

作用域插槽实例

Fish组件代码

<template>
  <div class="fish">
    <h2>{{title}}</h2>
    <slot name="content" :data="fishs">默认数据</slot>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';

defineProps(['title']);
let fishs = reactive([
  { name: '鲫鱼', price: 10 },
  { name: '草鱼', price: 33 },
  { name: '娃娃鱼', price: 88 },
])
</script>

App组件代码

<template>
  <div class="app">
     <Fish title="飞鱼">
      <template v-slot:content="params">
        <ul>
          <li v-for="item in params.data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </li>
        </ul>
      </template>
    </Fish>
    <Fish title="草鱼">
      <template #content="{data}">
          <h4 v-for="item in data">
            鱼:{{ item.name }}--价格:{{ item.price }}
          </h4>
      </template>
    </Fish>
  </div>
</template>
<script setup lang="ts">
import Fish from './view/Fish.vue';
</script>

运行代码查看效果 在这里插入图片描述 核心代码:

  • 传递数据:slot传递数据<slot name="content" :data="fishs">默认数据</slot>
  • 传递数据:template 接收数据<template v-slot:content="params">。 注意param结构赋值data

总结

类型语法特点使用场景
默认插槽<slot />一个组件只有一个主要内容区域
具名插槽<slot name="header" />

本周 GitHub 趋势观察:为什么前端热榜越来越像“AI 工具市场”?

你以为自己在看前端热榜,点进去却像走进了 AI 工具超市。 这不是错觉,而是开发范式正在从“写功能”转向“编排能力”。

过去一周,我重新刷了 GitHub Trending(JavaScript / TypeScript / Python)和 GitHub Changelog,一个信号越来越清晰:前端圈的流量入口,正在被 AI 工具链重写。 image.png

今天最稀缺的,不是会写页面的人,而是会设计工作流的人。

01 这周榜单发生了什么:前端标签下,AI 项目在“占位”

先看 JavaScript 周榜,最吸引眼球的不是 UI 框架,而是 AI Coding 相关仓库:

  • affaan-m/everything-claude-code:本周约 +23,500 stars
  • gsd-build/get-shit-done:本周约 +5,066 stars
  • jarrodwatts/claude-hud:本周约 +2,931 stars
  • Mintplex-Labs/anything-llm:本周约 +668 stars

TypeScript 周榜同样明显:

  • shareAI-lab/learn-claude-code:本周约 +7,776 stars
  • thedotmack/claude-mem:本周约 +3,938 stars
  • Yeachan-Heo/oh-my-claudecode:本周约 +8,991 stars

这说明一件事:前端这个分类,已经不只承载页面工程,它正在承载开发者生产力本身。

榜单还是那个榜单,主角已经换了剧本。

02 为什么会这样:不是前端变了,是“价值密度”变了

过去前端项目的爆点,常见于组件库、脚手架、可视化方案。现在爆点逐渐迁移到三件事:Agent、上下文、自动化流程。

背后有三个推力。

第一,开发瓶颈从“写不出来”变成“协同太慢”。 代码不再是唯一瓶颈,需求理解、上下文切换、代码评审、知识传递,才是吞噬效率的黑洞。能减少这些摩擦的工具,更容易被市场追捧。

第二,AI 工具天然具备“演示传播力”。 一个工具仓库只要能展示“5 分钟做完过去 1 小时的事”,传播链路就会爆发。相比之下,纯工程优化往往很难形成同等冲击。

第三,平台级信号已经给出方向。 GitHub 在 4 月 1 日的更新里,把 Copilot cloud agent 推向“先研究、先规划、再编码”:

  • 可以先在分支产出改动,不必立刻开 PR
  • 可以先生成 implementation plan,再动代码
  • 可以做 codebase deep research,再给答案

这不是一个功能点,而是工作流层面的迁移。

当平台开始重写流程,个人就很难继续只优化手速。

03 对前端工程师意味着什么:角色正在从“实现者”升级为“系统设计者”

很多人问:这是不是“前端被替代”的前奏? 我更愿意把它理解成一次角色重排。

Before:前端的主战场

  • 页面实现
  • 交互细节
  • 性能调优
  • 工程规范

After:前端的新增战场

  • 设计“人 + AI”协作链路
  • 管理上下文(文档、约束、规范、记忆)
  • 把零散脚本变成可复用流程
  • 用可观测指标评估 AI 产出质量

你会发现,真正拉开差距的不是谁调用了更多模型,而是谁能把团队经验沉淀成“可执行系统”。

把重复交给系统,把判断留给自己。

04 这波趋势里,前端人该怎么占位

别只追“哪个仓库今天又涨了多少星”,更要看结构性机会。

一个更有效的行动顺序是:

  • 先把你的高频任务拆成流程图(需求分析、搭架子、联调、提测)
  • 再用 AI 工具做“单点替换”(先替换最耗时的一环)
  • 然后补上约束层(代码规范、评审规则、回滚策略)
  • 最后把有效实践文档化,沉淀成团队资产

核心不是“你会不会用某个 AI 工具”,而是“你能不能把团队方法沉淀成系统能力”。

会写代码是门槛,会设计系统才是护城河。

05 写在最后

如果你最近也在刷 GitHub 热榜,应该已经感受到这种变化: 前端没有消失,前端只是从“页面工种”走向“生产力中枢”。

下一阶段,比拼的不是手速,而是抽象能力、流程设计能力和协作效率。

最后留个问题: 你觉得未来 12 个月,前端工程师最该补的一门能力,是 AI 编码技巧,还是 AI 工作流设计?欢迎在评论区聊聊你的真实观察。

MutationObserver:DOM界的“卧底”,暗中观察每个风吹草动

你想知道页面上的某个元素什么时候被偷偷改了吗?比如有个熊孩子脚本悄悄改了你的广告位,或者某个懒加载图片终于加载完了?今天我们就来请一位“卧底”——MutationObserver,让它24小时盯着DOM树,任何变化都逃不过它的眼睛。

前言

假设你开了一家便利店,店里装了监控。你想知道:什么时候有人进来?什么时候货架上的商品被拿走了?什么时候价格标签被换了?普通的监控只能录像,但你需要的是“智能警报”——一有变化就通知你。

这就是MutationObserver的活。它是浏览器提供的一个API,专门用来监听DOM树的变化:节点增删、属性修改、文本内容改变……统统能抓到。而且它不会像setInterval那样一直轮询,性能好得多。

一、MutationObserver是啥?

MutationObserver是一个构造函数,用来创建一个观察者对象。你可以给它指定一个回调函数,然后让它去“盯”某个DOM节点。一旦这个节点或它的子孙节点发生变化,回调函数就会被触发。

// 创建一个观察者实例,传入回调
const observer = new MutationObserver((mutationsList, observer) => {
  for (let mutation of mutationsList) {
    console.log(mutation.type, '发生了变化');
  }
});

// 指定要观察的节点
const targetNode = document.getElementById('watch-me');

// 开始观察
observer.observe(targetNode, {
  attributes: true,    // 观察属性变化
  childList: true,     // 观察子节点增删
  subtree: true,       // 观察所有后代节点
  characterData: true  // 观察文本内容变化
});

// 某天不想观察了
// observer.disconnect();

二、能观察到哪些变化?

配置选项决定了你关心哪些“风吹草动”:

  • attributes:属性变了(比如classstylesrc被改)
  • childList:子节点被增删(添加或删除元素、文本节点)
  • characterData:文本节点的内容变了
  • subtree:是否监听后代节点(默认false,只监听目标节点)
  • attributeFilter:只监听特定属性,比如['class', 'src']
  • attributeOldValue:是否记录旧属性值
  • characterDataOldValue:是否记录旧文本值

三、实战:监听广告位有没有被篡改

很多网站会在页面上放广告,但有些恶意脚本会偷偷把广告位换成自己的内容。用MutationObserver可以第一时间发现并报警。

<div id="ad-container">
  <img src="real-ad.jpg" alt="官方广告">
</div>
const adContainer = document.getElementById('ad-container');

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      // 子节点被改了
      console.warn('⚠️ 广告位内容被篡改!');
      // 可以上报服务器,或者恢复内容
    } else if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
      console.warn('⚠️ 广告图片被替换了!');
    }
  });
});

observer.observe(adContainer, {
  childList: true,
  subtree: true,
  attributes: true,
  attributeFilter: ['src', 'href']
});

四、实战:监听输入框内容变化(代替input事件?)

input事件已经能监听输入框变化,但MutationObserver可以监听更底层的文本节点变化,比如通过JS直接修改.valueinput事件可能不触发,但MutationObserver可以。

<input id="username" type="text">
const input = document.getElementById('username');

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
      console.log('输入框的值被改了,新值:', input.value);
    }
  });
});

observer.observe(input, {
  attributes: true,
  attributeFilter: ['value']
});

注意:这种方式监听value属性变化,只对通过JS设置.value有效,用户手动输入不会触发(因为用户输入不改变value属性,而是改变元素的defaultValue和内部状态)。所以实际中监听输入框还是input事件更合适。这里只是演示能力。

五、实战:监听动态加载的图片,做懒加载

很多懒加载库用IntersectionObserver,但如果你想知道图片什么时候被添加到DOM,可以用MutationObserver。

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    mutation.addedNodes.forEach((node) => {
      if (node.nodeType === 1 && node.tagName === 'IMG') {
        console.log('新图片出现了:', node.src);
        // 可以在这里做懒加载初始化
      }
    });
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

六、性能注意事项

MutationObserver虽然比轮询好,但也不能滥用。以下几点要注意:

  1. 不要观察整个document:如果你observe(document.body, { subtree: true, childList: true, attributes: true }),那页面上的任何变化都会触发回调,频繁执行可能影响性能。尽量把观察范围缩小到具体容器。

  2. 回调里不要做太重的操作:MutationObserver的回调是在微任务中执行的,如果里面操作DOM或者计算太多,会阻塞后续渲染。

  3. 及时disconnect:如果不再需要观察,记得调用disconnect()释放资源。

  4. 使用takeRecords():在disconnect之前,可以调用observer.takeRecords()取出尚未处理的变化记录。

七、与旧API对比:Mutation Events的悲惨往事

很久以前,浏览器有一套Mutation Events(比如DOMNodeInsertedDOMAttrModified等)。它们的问题很多:

  • 性能差,每次变化都同步触发,容易导致重入和崩溃
  • 不支持批量观察
  • 被标记为废弃

MutationObserver是它们的完美替代,异步、批量、性能好。

八、总结:MutationObserver就是你的“鹰眼”

  • 它能监听DOM树的各种变化:属性、子节点、文本内容。
  • 配置灵活,可以精确到特定属性或是否包含后代。
  • 异步回调,批量返回变化记录,性能优秀。
  • 应用场景:监听动态内容加载、检测第三方脚本篡改、实现数据绑定(比如某些MVVM库的底层)、与React/Vue的虚拟DOM配合调试等。

有了MutationObserver,你就可以在DOM变化时第一时间响应,像一个隐形的守护者。明天我们将进入Web Storage的世界,看看localStorage、sessionStorage和IndexedDB怎么帮你把数据存到用户浏览器里。

如果你觉得今天的“卧底”够犀利,点个赞让更多人看到。我们明天见!

❌