普通视图

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

CDN 与缓存策略

作者 Csvn
2026年4月22日 09:28

引言

在现代前端开发中,性能优化是提升用户体验的关键环节。CDN(内容分发网络)和合理的缓存策略,能够显著减少资源加载时间,降低服务器压力,是前端性能优化的两大核心手段。本文将深入探讨 CDN 的工作原理、缓存策略的设计要点,以及如何在实际项目中应用这些技术。

一、CDN 基础原理

什么是 CDN

CDN(Content Delivery Network)是一种分布式的网络架构,通过将静态资源缓存到全球各地的边缘节点,让用户从距离最近的节点获取资源,从而降低延迟、提升访问速度。

CDN 工作流程

用户请求 → DNS 解析 → 选择最优节点 → 返回资源
              ↓
         节点有缓存?→ 直接返回
              ↓
         回源站获取 → 缓存到节点 → 返回给用户

常见 CDN 服务商

  • 国内:阿里云 CDN、腾讯云 CDN、网宿科技
  • 国际:Cloudflare、AWS CloudFront、Akamai

二、缓存策略核心概念

1. 浏览器缓存机制

浏览器缓存主要通过 HTTP 响应头来控制:

# 强缓存
Cache-Control: max-age=31536000, public
Expires: Wed, 21 Apr 2027 07:00:00 GMT

# 协商缓存
ETag: "33a64df551425fcc55e4d42a1459b3"
Last-Modified: Wed, 21 Apr 2026 07:00:00 GMT

2. Cache-Control 详解

Cache-Control 是最常用的缓存控制头,支持多个指令:

// 常见指令说明
max-age=31536000    // 缓存 1 年(秒)
public              // 可被任何缓存存储
private             // 仅用户浏览器可缓存
no-cache            // 使用前需验证
no-store            // 不缓存任何内容
must-revalidate     // 过期后必须验证

3. 强缓存 vs 协商缓存

类型 特点 适用场景
强缓存 直接从本地读取,不请求服务器 静态资源(JS、CSS、图片)
协商缓存 向服务器验证是否过期 动态内容、HTML 页面

三、实际代码示例

1. Nginx 缓存配置

server {
    listen 80;
    server_name example.com;
    
    # 静态资源 - 强缓存 1 年
    location ~* .(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        # 添加版本号到文件名
        rewrite ^/(.*).[0-9a-f]{8}.(.*)$ /$1.$2 last;
    }
    
    # HTML 文件 - 协商缓存
    location ~* .html$ {
        expires -1;
        add_header Cache-Control "no-cache, must-revalidate";
    }
    
    # API 接口 - 不缓存
    location /api/ {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

2. 资源版本管理

通过文件名哈希实现长期缓存:

// Webpack 配置
module.exports = {
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js'
  }
};

// Vite 配置
export default {
  build: {
    rollupOptions: {
      output: {
        entryFileNames: `[name].[hash].js`,
        chunkFileNames: `[name].[hash].js`,
        assetFileNames: `[name].[hash].[ext]`
      }
    }
  }
};

3. Service Worker 缓存

// sw.js
const CACHE_NAME = 'v1';
const urlsToCache = [
  '/',
  '/static/js/main.js',
  '/static/css/main.css',
  '/images/logo.png'
];

// 安装:缓存资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(urlsToCache))
  );
});

// 拦截请求:优先从缓存读取
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        if (response) {
          return response; // 返回缓存
        }
        return fetch(event.request)
          .then(response => {
            // 克隆响应并缓存
            const responseClone = response.clone();
            caches.open(CACHE_NAME)
              .then(cache => cache.put(event.request, responseClone));
            return response;
          });
      });
  );
});

四、CDN 最佳实践

1. 资源分类策略

// 资源分类缓存策略
const cacheStrategies = {
  // 核心资源 - 短缓存
  'index.html': { maxAge: 0, mustRevalidate: true },
  
  // 静态资源 - 长缓存 + 版本控制
  'js/*.js': { maxAge: 31536000, immutable: true },
  'css/*.css': { maxAge: 31536000, immutable: true },
  'images/*': { maxAge: 31536000, immutable: true },
  
  // API 数据 - 不缓存或短缓存
  'api/*': { maxAge: 0, noStore: true }
};

2. 缓存失效策略

方案一:文件名哈希(推荐)

<!-- 文件名包含哈希,内容变化时哈希改变 -->
<script src="/js/main.a1b2c3d4.js"></script>
<link rel="stylesheet" href="/css/style.e5f6g7h8.css">

方案二:查询参数

<script src="/js/main.js?v=20260422"></script>

方案三: CDN 刷新

# 阿里云 CDN 刷新接口
curl -X POST "https://cdn.aliyuncs.com/?Action=RefreshCdnObject&ObjectPath=https://example.com/js/main.js"

3. 性能优化技巧

// 预加载关键资源
<link rel="preload" href="/fonts/main.woff2" as="font">
<link rel="preload" href="/js/vendor.js" as="script">

// 预获取后续可能需要的资源
<link rel="prefetch" href="/js/page2.js">

// 资源提示
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="preconnect" href="https://cdn.example.com">

五、监控与优化

1. 性能指标监控

// 使用 Performance API 监控资源加载
window.addEventListener('load', () => {
  const entries = performance.getEntriesByType('resource');
  entries.forEach(entry => {
    console.log(`${entry.name}: ${entry.duration}ms`);
  });
  
  // 计算资源加载时间
  const loadTime = performance.timing.loadEventEnd - 
                   performance.timing.navigationStart;
  console.log(`Total load time: ${loadTime}ms`);
});

2. 缓存命中率统计

// 通过响应头判断缓存状态
fetch('/api/data')
  .then(response => {
    const cacheStatus = response.headers.get('X-Cache');
    console.log(`Cache status: ${cacheStatus}`); // HIT or MISS
  });

总结

CDN 和缓存策略是前端性能优化的基石。通过合理配置:

  1. 静态资源使用强缓存 + 文件名哈希
  2. 动态内容使用协商缓存
  3. 关键资源使用预加载
  4. 持续监控缓存命中率和加载性能

记住:好的缓存策略 = 正确的 Cache-Control + 合理的资源版本管理 + 完善的失效机制。

不用死磕高并发,也能扛住流量:简单实用的系统设计思路

作者 LeonGao
2026年4月22日 09:22

一个被"高并发"吓到的程序员

小张最近很焦虑。

公司产品要做推广,预计会带来 10 倍的流量增长。他开始疯狂刷技术博客,看到的全是:

  • "如何设计能抗住亿级并发"
  • "分布式缓存实战"
  • "一致性哈希在负载均衡中的应用"

越看越慌。"我现在的系统连 1000 QPS 都跑不满,真的能扛住吗?要不要现在就重构?"

他的老板知道了,说了一句话: "先别慌,我们先看看现在能扛多少。"

压测结果出来了:现有系统能抗住 5000 QPS。而他们预估的最高峰值,是 2000 QPS。

小张长舒一口气。


01 为什么新手会被"高并发"吓到?

三个主要原因:

原因一:信息过载

打开技术博客,10 篇里有 8 篇在讲高并发优化。好像不提"亿级并发",就不是正经技术文章。

原因二:混淆场景

大厂分享的架构,是为日活千万级设计的。你一个小系统,非要照搬这套,不是杀鸡用牛刀,是杀鸡用屠龙刀。

原因三:不知道"够用"是什么标准

没有量化目标,就不知道什么算"扛住了"。于是倾向于过度准备。


02 先搞清楚:你真的需要处理高并发吗?

在谈高并发之前,先回答这几个问题:

你的流量是多少?

日活用户 预估峰值 QPS 典型场景
< 1000 < 100 内部工具、小众产品
1000-10000 100-1000 成长型产品
10000-100000 1000-5000 成熟产品
100000+ 5000+ 大流量产品

你的请求特征是什么?

  • 读多写少:社交 feed、信息流 → 优先考虑缓存
  • 写多读少:日志系统、传感器数据 → 优先考虑写入吞吐量
  • 读写均衡:电商、交易系统 → 需要综合优化

你的 SLA 要求是什么?

  • 99% 可用 = 每天最多 14 分钟不可用
  • 99.9% 可用 = 每天最多 1.5 分钟不可用

结论:如果你的日活不过万,峰值 QPS 不过千,先别急着搞高并发。先确保系统稳定、数据不丢,比什么都强。


03 简单实用的系统设计思路

思路一:先让单机能扛住

❌ 错误做法

"分布式才是趋势,我直接上微服务集群。"

✅ 正确做法

先压测单机性能,找到瓶颈点,优化到单机扛不住为止。

实操步骤

  1. 1.用 wrk 或 ab 压测当前系统
  2. 2.观察 CPU、内存、IO 使用率
  3. 3.定位瓶颈:数据库?代码逻辑?网络?
  4. 4.针对瓶颈优化

一个经验法则:90% 的性能问题,优化 1-2 个瓶颈就能解决。常见的瓶颈:

  • 数据库慢查询(加索引、优化 SQL)
  • 串行逻辑(改并行)
  • 同步阻塞(改异步)

思路二:用好缓存这张牌

缓存是最简单、最有效的性能优化手段。

什么时候用缓存?

  • 数据读多写少
  • 一致性要求不高(允许短暂不一致)
  • 数据量不会无限增长

怎么用?

三级缓存策略

  1. 1.本地缓存(进程内):热点数据、防抖
  2. 2.分布式缓存(Redis/Memcache):跨进程共享
  3. 3.CDN 缓存:静态资源、页面缓存

一个常见问题:缓存雪崩

大量缓存同时失效,导致大量请求打到数据库。

解决方案

  • 缓存过期时间加随机值
  • 保证数据库能扛住缓存失效的情况
  • 用分布式锁保护数据库

思路三:异步处理非核心流程

❌ 错误做法

"所有流程都同步处理,这样才能保证一致性。"

✅ 正确做法

识别核心流程和非核心流程,非核心流程异步化。

实操例子:用户下单

同步部分(必须成功):

  1. 1.库存扣减
  2. 2.订单创建
  3. 3.支付扣款

异步部分(可以延迟):

  1. 1.发送通知
  2. 2.更新推荐系统
  3. 3.数据分析报表
  4. 4.积分计算

异步化的好处

  • 降低接口响应时间
  • 削峰填谷
  • 提高系统吞吐量

异步化工具选择

  • 消息队列:Kafka、RabbitMQ、RocketMQ
  • 轻量级:Redis 队列、Delayed Job

思路四:数据库才是大多数系统的瓶颈

很多团队花大量时间优化代码,却忽视了数据库。

优化数据库的优先级

优先级 优化项 投入产出比
P0 加索引 ⭐⭐⭐⭐⭐
P1 慢查询优化 ⭐⭐⭐⭐
P2 连接池配置 ⭐⭐⭐
P3 分库分表 ⭐⭐

加索引的正确姿势

sql
-- 看执行计划
EXPLAIN SELECT * FROM orders WHERE user_id = 123;

-- 加索引
CREATE INDEX idx_user_id ON orders(user_id);

不要做的事

  • 上来就分库分表(99% 的系统不需要)
  • 在索引列上做函数运算
  • 用 LIKE '%xxx%' 查询

04 性能优化优先级对比

优先级 优化方向 适用场景 投入产出比
1 单机优化 所有场景 ⭐⭐⭐⭐⭐
2 缓存 读多写少 ⭐⭐⭐⭐
3 异步化 非核心流程 ⭐⭐⭐⭐
4 数据库优化 有慢查询 ⭐⭐⭐⭐
5 水平扩展 单机已达上限 ⭐⭐⭐
6 架构重构 业务复杂度上升 ⭐⭐

核心原则:按优先级来,别跳级。


05 给新手的行动指南

第一步:量化当前系统能力

用压测工具测出:

  • 单机 QPS 上限
  • 平均响应时间
  • 错误率

推荐工具

  • wrk / ab:HTTP 压测
  • mysqlslap:数据库压测
  • redis-benchmark:缓存压测

第二步:设置容量目标

基于业务预估,设置目标:

  • 目标 QPS 是多少?
  • 响应时间 SLA 是什么?
  • 可用性要求是多少?

第三步:识别瓶颈,逐步优化

从优先级高的开始:

  1. 1.优化数据库慢查询
  2. 2.增加缓存
  3. 3.异步化非核心流程
  4. 4.水平扩展

第四步:建立监控和告警

优化后要能观测效果:

  • 关键指标:QPS、RT、错误率
  • 资源指标:CPU、内存、IO
  • 业务指标:订单量、转化率

06 总结

高并发不是洪水猛兽。大多数系统的问题,不是扛不住高并发,而是没做好基本功。

记住三句话:

1. 先测再优化,别拍脑袋。
2. 缓存是银弹,用对地方才是。
3. 数据库是根本,优化索引最有效。

下次再听到"高并发"三个字,先别慌。问自己:

  • 我现在的系统能扛多少?
  • 我的目标是多少?
  • 差距有多大?

差距大,再考虑复杂方案。差距小,单机优化 + 缓存 + 异步 就够了。

简单实用,永远优于过度设计。


如果你觉得这篇文章有用,欢迎转发给需要的朋友。

别再一上来就分层:新手最容易做错的系统设计决定

作者 LeonGao
2026年4月22日 09:21

一个真实的场景

上周五下午,新人小李接到任务:"做一个用户注册功能"。

他的第一反应是打开画图工具,开始画架构图——Controller 层、Service 层、Repository 层、Cache 层、消息队列层……

周一 code review,他的"六层架构"被主管打了回来。不是因为分层本身有问题,而是:这个功能连数据库都还没建好。


01 为什么新手喜欢一上来就分层?

你可能也和小李一样——刚学完设计模式、架构原则,满脑子都是"高内聚低耦合"、"分层架构"、"可扩展性"。

这不是你的错。市面上太多教程都在教"标准答案",却很少告诉你:架构是解决实际问题后的自然结果,不是起点。

三个常见的思维误区:

误区一:把分层当目的

分层只是工具,不是目标。很多小系统,单机单库就能跑,何必非要搞七层架构?

误区二:把"大厂做法"当教条

你以为 BAT 的架构师都爱分层?错了。他们是因为用户量级、业务复杂度逼得不得不分。对于日活 1000 的系统,Monolith(单体)就是最优解。

误区三:先画图再想问题

拿到需求就画架构图,结果画完之后才发现:核心问题没解决,技术债务倒欠了一堆。


02 新手最容易犯的 5 个系统设计错误

错误 1:还没理解需求就开始画架构图

❌ 错误做法

"这个功能用微服务还是单体?我先画个架构图吧。"

✅ 正确做法

先问自己三个问题:

  • 这个功能要解决什么业务问题?
  • 当前最大的瓶颈是什么?(性能?一致性?可用性?)
  • 预计用户量和增长曲线是什么?

核心原则:架构服务于业务,而不是业务服从于架构。


错误 2:为"可能的需求"做过度设计

❌ 错误做法

"万一以后要支持多租户呢?我现在就把租户 ID 加上。"

✅ 正确做法

"先把这个需求解决。等真正需要多租户时,再重构也来得及。"

核心原则:YAGNI(You Aren't Gonna Need It)——你不需要它。

过度设计的代价:

  • 开发时间翻倍
  • 代码复杂度上升
  • 维护成本增加
  • 后来者读代码时骂你

错误 3:滥用缓存或完全不用缓存

❌ 错误做法 A

"缓存能提升性能,我给所有接口都加上缓存!"

❌ 错误做法 B

"缓存太复杂了,我们不用,直接查库吧。"

✅ 正确做法

先确定数据特征:

  • 数据更新频率如何?
  • 一致性要求多高?
  • 缓存失效后的雪崩风险大吗?

再决定是否缓存、缓存策略是什么。

核心原则:缓存是双刃剑,用对地方才是利器。


错误 4:把"扩展性"挂在嘴边,却不知道扩展什么

❌ 错误做法

"这个设计没有扩展性,换一种方式吧。"
"什么叫扩展性?""呃……就是以后能加功能。"

✅ 正确做法

先识别具体的扩展场景:

  • 用户量从 1 万增长到 100 万?
  • 要支持新的支付渠道?
  • 要接入新的数据源?

再评估当前设计能否支撑这些场景。

核心原则:脱离具体场景谈扩展性,都是耍流氓。


错误 5:忽视数据层设计

❌ 错误做法

"先把代码写完,数据库后面再优化。"

✅ 正确做法

把数据模型设计放在编码之前:

  • 核心实体是什么?关系如何?
  • 索引如何设计?查询模式是什么?
  • 数据量预估多大?要不要分库分表?

核心原则:大多数系统,瓶颈都在数据层。把数据设计好,事半功倍。


03 正误对比速查表

场景 ❌ 错误做法 ✅ 正确做法 原因
接到新需求 立即画架构图 先分析业务问题和瓶颈 架构是解决方案,不是起点
性能优化 上来就加缓存/异步 先定位瓶颈点,再针对性优化 盲目优化是浪费
考虑扩展性 设计"万能架构" 识别具体扩展场景,按需设计 YAGNI 原则
技术选型 选"大厂标配"技术栈 根据团队能力和业务需求选型 合适的才是最好的
数据库设计 最后再考虑 编码前先设计数据模型 数据层是大多数系统的瓶颈

04 给新手的行动指南

第一步:先问问题,再画图

拿到需求后,先回答这 5 个问题:

  1. 1.核心业务流程是什么?  用文字描述,不要画图。
  2. 2.当前最大的风险是什么?  性能、一致性、可用性、还是团队能力?
  3. 3.用户量和增长预期是什么?  决定技术选型的量级。
  4. 4.有哪些现成的轮子可以用?  不要重复造轮子。
  5. 5.最简单的可行方案是什么?  先跑通,再优化。

第二步:用最简单的方案先跑起来

"Perfect is the enemy of good."
完美是优秀的敌人。

先让系统跑起来,在实际运行中发现问题,比纸上谈兵强一百倍。

第三步:识别真正的瓶颈再优化

性能问题就像生病——你得先知道哪里疼,才能对症下药。

推荐工具:

  • APM 工具:Skywalking、Pinpoint
  • 数据库慢查询:MySQL slow log、Explain 分析
  • 系统瓶颈:top、vmstat、iostat

第四步:让设计决策有据可依

每个设计决策都应该能回答:

  • 解决了什么问题?
  • 有什么代价/风险?
  • 如果错了,怎么回滚?

05 总结

系统设计不是画图大赛。真正的高手,是能用最简单的方案解决实际问题的人。

记住三句话:

1. 先理解问题,再设计方案。
2. 简单优于复杂,能跑优于完美。
3. 用数据说话,用监控验证。

下次接到设计任务时,别急着打开画图工具。先问自己:我真的理解要解决的问题了吗?


你的第一个设计决定,应该是"不做什么",而不是"做什么"。


如果这篇文章对你有帮助,欢迎转发给需要的朋友。关注公众号,回复"系统设计",送你一份我整理的系统设计入门资料包。

总篇:异步组件加载的演进之路

作者 禅思院
2026年4月22日 09:11

总篇:异步组件加载的演进之路:从基础拆分到企业级防御体系的完整认知

在现代前端架构中,组件异步加载已从性能优化的“可选技巧”演变为支撑应用规模与稳定性的“核心基础设施”。然而,从简单的 defineAsyncComponent 到能够抵御网络抖动、实现灰度发布、支持多架构融合的企业级异步加载方案,中间横亘着一道巨大的认知与实践鸿沟。本系列文章将完整呈现这段演进之路,为你构建健壮、可观测、面向未来的前端应用提供一套从“术”到“道”的完整蓝图。

在这里插入图片描述

一、 问题的演变:当“加载中”成为体验瓶颈

起初,我们拥抱异步加载,因为它解决了最直观的问题:减少首屏体积,提升加载速度。一句 defineAsyncComponent(() => import('./HeavyComponent.vue')) 配合一个旋转的 Loading 图标,似乎就完成了使命。

但随着应用复杂度飙升、第三方组件激增、用户体验要求苛刻,简单的异步加载暴露出其脆弱的一面,演变为一系列工程挑战:

  1. 可靠性危机:弱网环境下加载超时,用户面对“无限旋转”不知所措;CDN资源偶发不可用,导致页面局部“开天窗”,刷新成为唯一解。
  2. 体验割裂:多组件并行加载时网络拥塞,交互卡顿;“加载中”状态千篇一律,缺乏对等待时间的预估和心理安抚。
  3. 运维黑盒:线上哪些组件加载最慢、失败率最高?为何某次发布后加载时间激增?缺乏数据,优化就像“蒙眼狂奔”。
  4. 协作与架构冲突:微前端架构下,主应用与子应用的异步加载策略如何统筹?SSR场景下,异步组件又该如何优雅降级?

这些问题让我们意识到,真正的挑战远非“实现异步加载”,而在于构建一套具备弹性、可观测、可管控的异步资源加载与治理体系。

二、 解决之道的三层演进

面对上述挑战,解决方案必须体系化。我们认为,一个成熟的企业级异步加载方案应经历三个层次的演进:

层级一:增强的加载器(解决“可用性”)

这是对基础能力的第一次加固。目标是在不改变大架构的前提下,显著提升单点组件的加载成功率与用户体验。 • 核心特征:智能重试(区分错误类型、指数退避)、友好的超时处理、丰富的加载/错误状态反馈(AsyncLoading / AsyncFallback 组件)。

• 价值:快速止血,在用户侧建立“系统正在努力恢复”的认知,重建信任。

• 局限:仍是“点”状优化,缺乏全局视野和联动能力。

层级二:全局资源管理器(解决“可控性”)

当应用内有成百上千个异步组件时,我们需要一个“大脑”进行统一调度。 • 核心特征:全局缓存策略(TTL+LRU)、优先级调度队列(区分用户交互触发与预加载)、内存保护机制(防重复加载、缓存上限)。

• 价值:从全局视角优化资源利用,避免拥塞,实现内存可控。这是从“工具”到“基础设施”的关键一跃。

• 技术实现:需要中心化的状态管理、复杂的调度算法以及对浏览器 API(如 IntersectionObserver, requestIdleCallback)的深度利用。

层级三:可观测的防御体系(解决“可演进性”)

最高级别的方案,将异步加载视为一个需要持续监控、分析、优化的动态系统。 • 核心特征:全链路监控(加载耗时、成功率、缓存命中率)、自动化告警、安全的渐进式发布与回滚能力、与 APM 系统集成。

• 价值:实现数据驱动的性能优化闭环,支持在复杂的微前端、SSR 等架构下安全、平稳地落地与迭代,形成技术壁垒。

• 架构思维:这要求方案设计之初就充分考虑可测试性、可度量性以及与公司现有运维体系的融合。

这三个层级并非互相取代,而是层层递进、相互增强的关系。层级一确保每个组件可靠;层级二确保系统整体高效、稳定;层级三确保整个体系可持续优化、安全演进。

三、 系列导航:你将获得的完整拼图

本系列文章将按照上述演进逻辑,分为中、下两篇,为你由浅入深地揭开每一层的技术细节、设计权衡与实战代码。

🛠️ 中篇:《构建弹性的异步组件:从智能重试到全局缓存管理》

(对应“层级一”与“层级二”) • 你将获得:一套可直接集成的、包含智能重试、友好反馈、全局缓存与调度能力的 useAsyncComponent React/Vue Hook 或 Composable 实现。

• 深度解析:

1.  错误恢复艺术:如何区分网络错误与代码错误,并设计指数退避的重试机制?
2.  体验设计哲学:AsyncLoading 与 AsyncFallback 组件如何超越“旋转图标”,进行心理安抚与信任重建?
3.  缓存治理实战:如何设计兼顾效率与内存安全的全局缓存策略(TTL+LRU淘汰)?
4.  调度优化:如何利用 IntersectionObserver 实现精准的懒加载,用 requestIdleCallback 进行无害预加载?

• 适合读者:所有前端开发者,尤其适合正在为应用加载体验和稳定性所困的工程师。

🏗️ 下篇:《打造可观测的异步加载防御体系:从监控告警到跨架构治理》

(对应“层级三”) • 你将获得:一套涵盖监控度量、安全发布、多架构适配的企业级异步加载治理方案蓝图。

• 深度解析:

1.  可观测性建设:如何设计监控指标,并与Sentry、Prometheus等系统集成,搭建告警机制?
2.  安全上线策略:如何通过功能开关(Feature Flag)实现灰度发布,并建立自动化回滚能力?
3.  复杂架构适配:方案如何无缝接入微前端架构(qiankun)?SSR/SSG场景下的同构渲染如何实现?
4.  高级优化与未来:依赖去重、Service Worker 离线缓存、Navigation Preload API 等高级实践,以及与 Vue 3 Suspense 的融合演进。

• 适合读者:前端架构师、技术负责人、效能工程师,以及对构建高可用、可观测前端基础设施感兴趣的开发者。

四、 为什么这个系列值得你期待?

  1. 完整的认知框架:不仅提供代码片段,更提供一套分析、设计和演化异步加载方案的思维模型(三层演进)。
  2. 真实场景驱动:所有方案均源于应对真实生产环境挑战的提炼,包含大量边界条件处理和实践踩坑经验。
  3. 强烈的可操作性:从中篇“开箱即用”的增强组件,到下篇可供技术决策参考的架构蓝图,不同阶段的团队都能找到直接价值。
  4. 面向未来的视野:探讨了与微前端、SSR、Suspense等前沿架构和标准的结合,确保方案的长期生命力。

性能与体验是前端技术的核心价值,而资源的异步加载是承载这一价值的基石。 本系列文章旨在帮助你,将这块基石从粗糙的毛石,打磨成支撑庞大、复杂应用建筑的钢筋混凝土结构。

敬请关注后续文章,我们将一同深入这段从“基础拆分”到“企业级防御体系”的精彩技术旅程。

【中篇预告】:在下一篇文章中, 我们将进入实战,直接从一行“脆弱”的 defineAsyncComponent 代码开始,一步步将其改造为具备智能重试、全局缓存和友好反馈的“弹性组件”。你将获得完整的、可复用的核心代码实现,敬请关注《中篇:构建弹性的异步组件》。”

2026年前端开发工程师转型AI Agent开发工程师全指南

2026年4月22日 08:58

前端已死,这个传说已经流传了不止5年,2026年可能它真的要升天了~

2026年,随着大模型技术的成熟与落地,AI Agent(智能体)已成为继移动互联网之后的下一个超级风口。与此同时,传统前端开发工程师的处境并不乐观,日益缩减的岗位HC与裁员潮,令无数前端开发者无比焦虑。在这样的处境下,我想最有效的生存之道就是转型做AI Agent工程师(打不过就加入😂)。

本文将深度剖析前端工程师转型AI Agent开发的必要性、可行性及完整路径,通过对比技术栈、分析核心优势、构建知识图谱,为处于职业焦虑中的前端开发者提供一份清晰的“逃生”与“进阶”地图。


一、前端开发工程师现在的处境

不用回避这个问题:前端工程师的处境在 2023 年之后开始变得严峻,到2026年已经到了基本无法逆转的地步。

  • 需求萎缩与裁员潮:随着低代码/无代码平台的普及以及AI生成代码(如GitHub Copilot 、Cursor、Claude Code等)的成熟,初级和中级的CRUD(增删改查)前端需求大幅减少。大厂纷纷缩减前端编制,无数前端工程师被纳入裁员名单,再就业难度显著增加。
  • 技术内卷严重:框架层出不穷(React, Vue, Svelte, Solid...),但业务场景趋于同质化。单纯掌握UI渲染、状态管理和组件库已无法构建核心壁垒,薪资增长停滞甚至倒挂。
  • 价值边缘化:在“降本增效”的大背景下,前端往往被视为“美工”或“页面组装工”,难以深入核心业务逻辑,话语权减弱。

可见,前端岗位大幅缩减的情况下,求职人数却在不断增加,这个剪刀差在短期内不太可能逆转。


二、AI Agent 技术现在什么水平

AI Agent 这个概念已经存在好多年了,但真正可用的、能落地的 Agent,是从 2023 年之后才开始出现的。

早期的 AI 应用主要是问答式交互:你问,它答,然后结束。Agent 的核心区别在于自主决策和工具调用。一个 Agent 可以接受一个模糊的目标,自己拆解步骤,调用外部工具(搜索、代码执行、数据库查询),根据中间结果调整策略,最终交付结果。

这件事在 GPT-4 发布后开始变得真实可行。2024 年以来,国内外主要模型厂商(OpenAI、Anthropic、阿里、百度、腾讯、字节)都在大力推进 Function Calling 和 Tool Use 能力,这是 Agent 能真正"动手"的基础。发展到如今,AI Agent走向各行各业基本已成为事实。


三、国内 AI Agent 开发的需求现状

1. 需求的真实分布

大厂内部工具:腾讯、阿里、字节、华为都在大力建设内部 AI 基础设施,需要能开发和维护 Agent 系统的工程师。这类岗位薪资高,竞争也激烈。

垂直行业落地:金融(智能投研、风控)、医疗(病历分析、问诊辅助)、法律(合同审查、案例检索)、教育(个性化学习)——这些行业的公司正在把 AI Agent 集成进核心业务流程。这里的需求量可能比大厂更大,竞争也相对没那么激烈。

企业服务和 SaaS:帮助传统企业用 AI 改造内部流程,这是目前增长最快的需求来源之一。很多中小企业不需要顶尖算法工程师,需要的是能用现有工具快速搭出可用 Agent 系统的工程师。

创业公司:2024-2025 年 AI 原生应用爆发,大量创业公司需要既懂 Agent 开发又能快速交付产品的工程师。这里的机会多,但风险也大。

2. 薪资水平

根据 2025 年初的市场数据,国内 AI Agent 开发工程师(1-3 年 AI 经验)的薪资大致在:

  • 北京/上海:25k-45k/月
  • 深圳/杭州:20k-38k/月
  • 其他城市:15k-30k/月

相比同年限的前端工程师,平均高出 30%-50%。这个差距在短期内还会持续扩大。


四、两种工程师的技术栈对比

这是转行前最需要搞清楚的问题:我现在会什么,缺什么,要补什么。

1. 前端工程师的技术栈

  • 核心语言:JavaScript / TypeScript / NodeJS
  • 框架:React / Vue / Next.js / Nuxt.js...
  • 工程化:Webpack / Vite / ESBuild
  • 状态管理:Redux / Zustand / Pinia...
  • 网络请求:Fetch / Axios / SWR / React Query
  • UI:Ant Design / Element Plus / Tailwind CSS...
  • 测试:Jest / Vitest / Cypress / Playwright
  • 部署:Vercel / Nginx / Docker(基础)
  • 其他:WebSocket、Canvas/WebGL

2. AI Agent 开发工程师的技术栈

  • 核心语言:Python / TypeScript
  • LLM 接入:OpenAI API / 阿里百炼 / 文心一言 API...
  • Agent 框架:LangChain / LangGraph / AutoGen / CrewAI
  • 用户界面:现有技术栈都行,主流是以Next.js为主
  • RAG 技术:
    • 向量数据库(Chroma / Weaviate / Milvus
    • 文档处理(LlamaIndex / Unstructured
    • Embedding 模型(text-embedding-ada-002 / BGE
  • Prompt 工程:Few-shot / Chain-of-Thought / ReAct / 结构化输出
  • 工具开发:Function Calling / MCP 协议 / Skills / Tool Schema 设计
  • 数据处理:pandas / numpy / 基础 SQL
  • 部署运维:FastAPI / Docker / 基础 K8s / 流式响应
  • 评估调优:Tracing(LangSmith / Phoenix)/ A/B 测试 / 幻觉检测
  • 产品理解:对话流设计 / 用户体验 / 错误处理

3. AI Agent主流框架的现状

框架 语言 特点 适合场景
LangChain Python/JS 生态最全,组件多 快速原型、学习入门
LlamaIndex Python 专注 RAG 和知识检索 知识库类应用
AutoGen Python 微软出品,多 Agent 对话 多 Agent 协作
CrewAI Python 角色化 Agent 团队 任务分工类场景
LangGraph Python 状态机式 Agent 流程 复杂工作流
Dify Python/低代码 国产,可视化编排 快速交付、企业内部
阿里百炼 / 腾讯元器 托管平台 国内合规,部署简单 国内商业落地

说实话,这个领域的框架更新速度非常快,今天学的东西半年后可能要重学。但核心概念(Memory、Tool、Planning、RAG)是稳定的,框架只是把这些概念包装成不同的 API。

4. 转型差距在哪里

维度 前端工程师现状 AI Agent 需要 差距
主力语言 JS/TS Python(主)+ TS(辅) 需补 Python
API 调用 REST/GraphQL 熟练 LLM API + 流式响应 容易迁移
状态管理 组件/全局状态 Agent 状态、Memory 概念迁移
数据处理 前端展示为主 pandas/SQL 处理数据 需补
部署 静态/SSR 为主 后端服务、FastAPI 需补
领域知识 UI/UX Prompt 工程、RAG、向量检索 需系统学习
调试方式 DevTools LLM Tracing、Prompt 调试 思维转换

差距没有很多人想的那么大,但也不是三五个月就能完全跨越的。

收藏关注博主,博主将在后续推出免费完整的AI Agent技术栈教程,助力你快速转型。


五、前端工程师转行的真实优势

1. TypeScript 不需要重学

很多 AI 应用的前端层、工作流可视化界面、低代码 Agent 编排工具,都是用 TypeScript 写的。LangChain.js、Vercel AI SDK、OpenAI 官方 SDK 都有完整的 TypeScript 支持。这不是"转型友好",这是前端工程师在这个领域有直接上手能力。

2. 流式数据处理

LLM 的输出是流式的,前端工程师对 async/awaitReadableStreamSSEWebSocket 都很熟。

3. 产品意识

Agent的核心是与人或环境的交互。AI Agent 的失败案例里,技术不行只是一部分。更常见的是:做出来的东西没人用。对话流不自然、错误提示让用户看不懂、交互设计反直觉。前端工程师长期在这个维度工作,这种对"用户会怎么用"的直觉,通常是需要长期培养的,前端工程师面向用户,有天然的优势,往往也是其他类型的开发工程师欠缺的。

4. 全栈路径更短

大多数有点年份的前端工程师都碰过 Node.js,Next.js 的 API Routes、BFF 层,从这里延伸到 FastAPI + Python 后端,比让一个纯后端工程师从零理解前端用户需求要容易得多。

5. API 集成是本能反应

前端工程师接 API 是日常,REST 请求、数据格式转换、错误处理、loading 状态管理——这些能力直接迁移到 LLM API 集成。Function Calling 的本质就是 LLM 告诉你调哪个 API,你来真正执行,这个思维方式前端工程师完全不陌生。

6. 可视化与Debug优势:

Agent的推理过程是黑盒,需要强大的可视化监控(如Trace链路追踪)。前端工程师可以利用自己的技能构建强大的Agent调试台和监控面板,这在团队中是不可或缺的价值。

7. 快速学习与适应力:

前端领域技术迭代极快,不少前端人已经培养了极强的新技术适应能力。面对日新月异的Agent框架(LangChain, AutoGen,Dify等),前端人能更快上手。


六、怎么转:一个务实的技术路径

我不会告诉你"三个月速成 AI Agent 工程师",因为这不现实,看到这种标题要警惕。但一个有 3 年以上经验的前端工程师,认真学 6-12 个月是可以具备入门 AI Agent 开发能力的。

1. 第一阶段:打地基(1-3 个月)

目标:能读懂 AI Agent 代码,能调通基本的 LLM API。

(1)Python 基础 如果你的 Python 基础为零,先花 3-4 周过一遍 Python 基础语法。推荐 Python for JavaScript Developers 这类专门为 JS 开发者写的教程,跳过那些你已经懂的概念,直接看差异。

重点掌握:

  • 类型系统(int/str/list/dict/dataclass
  • 虚拟环境(venv / conda
  • 文件 IO 和 JSON 处理
  • HTTP 请求(requests / httpx
  • async/await(和 JS 差不多)

(2)LLM API 调用 注册一个 API Key(国内可以用阿里百炼、月之暗面 Kimi 或 DeepSeek,价格便宜,调用方式和 OpenAI 兼容),用 Python 写 10 个以上的小脚本:

  • 基础补全(Chat Completions)
  • 流式输出(Streaming)
  • Function Calling(重点)
  • 结构化输出(JSON Mode / Pydantic)
  • 多轮对话(消息历史管理)

不要急着上框架。在没搞懂 raw API 之前就套 LangChain,会让你不知道框架帮你做了什么,出了问题也不知道从哪里调。

2. 第二阶段:核心能力(3-6 个月)

目标:能独立开发一个完整的 Agent 应用,有 RAG,有工具调用,能部署。

(1)Prompt 工程 这是很多技术背景的人容易忽略的部分,但实际上是最影响 Agent 质量的因素。需要系统学习:

  • System Prompt 设计原则
  • Few-shot 示例的选择和排布
  • Chain-of-Thought(让模型先推理再回答)
  • ReAct 模式(Reasoning + Acting,Agent 的基础范式)
  • 结构化输出的 Schema 设计
  • 防注入和边界处理

(2)RAG(检索增强生成) RAG 是 90% 的企业 AI 应用都要用到的技术,原理不复杂:把文档切片,转成向量存到数据库,用户提问时检索相关片段,塞进 Prompt。

需要动手做:

  • LlamaIndexLangChain 搭一个本地知识库问答系统
  • 理解文档切分策略(chunk size / overlap)对结果的影响
  • ChromaFAISS 做向量存储
  • 实验不同的 Embedding 模型(BGE-M3 是目前中文效果较好的开源选项)

(3)Agent 框架 选一个框架认真学,不要贪多。推荐:

  • LangGraph:状态机式的流程控制,适合复杂 Agent,国内外企业落地使用最多
  • Dify:如果你想快速出活,Dify 的可视化编排非常适合原型验证

(4)FastAPI + 部署 用 FastAPI 把你的 Agent 包成一个 HTTP 服务,用 Docker 打包,部署到云服务器(阿里云 ECS 或腾讯云)。这个过程不复杂,但一定要亲手做一遍。

3. 第三阶段:深化和落地(6-12 个月)

目标:能主导一个 Agent 项目的设计和开发,具备一定的架构判断力。

(1)多 Agent 系统

  • AutoGenCrewAI 的多 Agent 编排
  • 理解 Agent 间通信和任务分工的设计模式
  • 实践 Supervisor-Worker 架构

(2)评估和调优 Agent 的质量很难用传统的单元测试来衡量,这里有一套专门的方法:

  • LangSmithPhoenix 做 LLM Tracing
  • 构建测试数据集,自动评估 Agent 输出质量
  • 幻觉检测和事实核查

(3)MCP 协议 Anthropic 推出的 Model Context Protocol(MCP)正在成为 Agent 工具集成的标准协议。理解并能开发 MCP Server,是 2025 年往后的重要技能点。前端工程师对 JSON-RPC 风格的协议上手很快。

(4)选一个垂直行业深入 Agent 开发的差异化竞争力往往在领域知识,而不只是技术。选一个你有背景或感兴趣的行业(金融、教育、法律、医疗、电商),深入了解它的业务逻辑,把 Agent 技术和领域知识结合起来,这是最难被替代的组合。

4. 路径规划总览

月份 阶段 核心任务 里程碑
M1 打地基 (第1个月) Python 基础语法
LLM API 调用(10+ 小脚本)
能读写基础 Python
调通 Function Calling
M2-M3 打地基 (第2-3个月) Prompt 工程系统学习
LangChain 入门
能写高质量 System Prompt
完成第一个 Agent 原型
M4-M5 核心能力 (第4-5个月) RAG 技术(本地知识库项目)
FastAPI + Docker 部署
完整的 RAG 应用上线
有公网可访问的服务
M6 核心能力 (第6个月) LangGraph 深入
选定目标行业,做行业调研
完成一个多步骤 Agent
有明确的方向
M7-9 深化落地 (第7-9个月) 多 Agent 系统实践
MCP 协议学习与实践
完成一个真实项目(可以是开源贡献)
有 GitHub 项目可以展示
M10-12 求职准备 (第10-12个月) 评估调优体系
参加社区、积累案例
能描述完整的 Agent 系统设计
拿到第一个 AI Agent 相关 offer

七、完整知识图谱

AI Agent 开发工程师知识体系
│
├── 编程语言基础
│   ├── Python(核心)
│   │   ├── 语法基础、类型系统
│   │   ├── 异步编程(asyncio)
│   │   ├── 数据处理(pandas、numpy)
│   │   └── 包管理(pip、poetry、uv)
│   └── TypeScript(辅助)
│       ├── LangChain.js
│       ├── Vercel AI SDK
│       └── 前端 AI 集成
│
├── LLM 基础
│   ├── 主流模型了解(GPT-4o / Claude / Gemini / Qwen / DeepSeek)
│   ├── API 调用(Chat Completions / Function Calling / Streaming)
│   ├── Token 、Temperature、Top-P、Context Window
│   ├── 模型选择(成本 vs 能力 vs 速度)
│   └── 国内合规部署(阿里百炼 / 腾讯混元 / 百度千帆)
│
├── Prompt 工程
│   ├── System Prompt 设计
│   ├── Few-shot Learning
│   ├── Chain-of-Thought
│   ├── ReAct 框架
│   ├── 结构化输出(JSON Schema / Pydantic)
│   └── 防注入 / 边界处理
│
├── RAG(检索增强生成)
│   ├── 文档处理(PDF / Word / 网页抓取)
│   ├── 文档切分策略
│   ├── Embedding 模型(text-embedding-ada-002 / BGE-M3)
│   ├── 向量数据库(Chroma / Milvus / Weaviate / PgVector)
│   ├── 语义检索 + 关键词检索混合
│   └── Reranking(重排序)
│
├── Agent 框架与工具
│   ├── LangChain(工具链 / 通用)
│   ├── LangGraph(状态机 / 复杂流程)
│   ├── LlamaIndex(RAG / 知识检索)
│   ├── AutoGen(多 Agent 对话)
│   ├── CrewAI(角色化 Agent 团队)
│   ├── Dify(可视化编排 / 低代码)
│   └── MCP 协议(工具集成标准)
│
├── Agent 设计模式
│   ├── 单 Agent(ReAct)
│   ├── 多 Agent(Supervisor / Worker)
│   ├── 规划型 Agent(Plan-and-Execute)
│   ├── 反思型 Agent(Reflexion)
│   ├── Memory 管理(短期 / 长期 / 向量记忆)
│   └── Tool 设计(Schema / 错误处理 / 幂等性)
│
├── 后端与部署
│   ├── FastAPI(REST / WebSocket / SSE)
│   ├── Docker 容器化
│   ├── 云服务部署(阿里云 / 腾讯云 / AWS)
│   ├── 流式响应处理
│   └── 基础数据库(PostgreSQL / Redis)
│
├── 评估与调优
│   ├── LLM Tracing(LangSmith / Phoenix / Arize)
│   ├── 评估指标设计(准确率 / 幻觉率 / 延迟)
│   ├── 测试数据集构建
│   ├── A/B 测试
│   └── 成本优化(Token 压缩 / 缓存)
│
└── 产品与工程
    ├── 对话流设计
    ├── 错误处理和降级策略
    ├── 用户反馈收集
    ├── 安全性(Prompt 注入防护)
    └── 观测性(日志 / 监控 / 告警)

八、几个需要面对的真实问题

1. 完全不懂机器学习可以做 AI Agent 开发吗?

可以。AI Agent 工程师和 AI 算法工程师(训练模型的那些人)是两条不同的路。做 Agent 开发不需要自己训练模型,也不需要深入理解 Transformer 的数学原理。你需要的是知道如何用好这些模型——就像前端工程师不需要写浏览器内核,但需要熟悉浏览器的工作方式。

当然,了解基本的 AI 概念(温度参数、上下文窗口、向量化、微调 vs 提示词工程)是有必要的。这些内容不需要数学背景,花一两周时间就能掌握。

2. 转行期间如何保持收入?

不要一下子辞职去全职学习,这对大多数人来说压力太大,容易学崩。更务实的方式是:

  • 工作日继续做前端,周末和下班后学 AI Agent
  • 在现有工作中找机会用 AI 工具提效,积累一些实际案例
  • 接一些 AI 相关的外包需求(Dify 搭建、LLM API 集成),有收入的同时积累项目经验
  • 等具备一定能力后,在招聘时优先找"需要前端技能的 AI 相关岗位",比如 AI 产品的前端开发(中间过渡岗位)

3.年龄问题

如果你是 30 岁以上的前端工程师,可能对转行有更多顾虑。我的看法是:AI Agent 领域目前就是一片新市场,年龄的劣势比在成熟领域小得多。这个领域里没有"10 年经验的资深 AI Agent 工程师",大家都是从头学起。相反,有业务经验和工程判断力的工程师,往往能更快理解如何把 Agent 技术用到实际场景,这是工作经验带来的优势。

4. 需不需要考证书?

国内目前的 AI 相关证书含金量参差不齐,我倾向于不太推荐为了"考证"而考证。更值钱的是:

  • 有可以展示的 GitHub 项目
  • 在 Hugging Face / ModelScope 上发布过模型或应用
  • 在垂直社区(掘金、知乎技术专栏)写过有质量的技术文章
  • 参与过开源项目(LangChain、Dify 等都有活跃的中文社区)

九、最后说几句

我不打算用"AI 时代来临,把握机遇"之类的话来收尾。

真实的情况是:AI Agent 开发现在确实是一个好时机,但它不是保证,不是捷径,也不是"学了就能赚大钱"的魔法。它是一个技术方向,像当年的移动端开发、云原生一样,早进场的人有一定优势,但最终还是靠真实的能力说话。

对前端工程师来说,转行的逻辑很清楚:你现有的技能在这个新领域里有直接价值,需要补的东西是可以学到的,方向的需求是真实的。

值不值得转,只有你自己知道。但如果你已经在认真想这件事,那基本上已经回答了一半。

7.响应式系统比对:手写一个响应式状态库并应用在 React 上

作者 Cobyte
2026年4月22日 08:58

前言

我们通过第一篇文章总结出的结论是,基于依赖追踪的响应式系统的本质是在读取数据的时候收集依赖,在更新数据的时候触发依赖,在后续的文章中我们又知道了所谓的依赖收集在发布订阅模式中就是订阅者进行订阅操作,触发依赖则是发布者进行发布操作,那么基于此原理,我们只需要把数据的读写进行分离再结合发布订阅模式那么可以实现数据响应式了。为了实现数据读写劫持,Vue 中不同的版本采用了不同的 JavaScript 原生 API,具体就是 Vue2 中采用了 Object.defineProperty,Vue3 中采用了 Proxy + Object.defineProperty(ref 本质上是通过 Object.defineProperty 实现的,class 的 getter 方式只是一个语法糖)。同时我们在第一篇文章的也介绍到了可以通过沙箱模式实现数据的读写分离,从而实现数据的响应式,那么在这篇文章中就让我们通过沙箱模式来实现一个数据响应式系统,并把它应用到 React 上吧。

通过沙箱模式实现代理

JS 沙箱我们或多或少都接触过,只是可能我们不了解不多,接触过也不知道。在计算机领域中,沙箱技术(Sandbox)是一种用于隔离正在运行程序的安全机制,其目的是限制不可信进程或不可信代码运行时的访问权限。比如说我们如果开发过微信小程序,我们就有比较深刻的体验,很多在浏览器端可以访问的 API,在小程序上都不可以使用,这是因为小程序上的 JavaScript 代码被运行在一个 JS 沙箱中了,从而限制了一些访问权限,还有一些微前端框架的实现也是通过 JS 沙箱的机制来实现的,还有我们的 Vue 中的模板其实也是运行在一个 JS 沙箱中。

我们这里对 JS 沙箱的各种实现不过作过多深入的解析,JS 沙箱的本质是创建一个独立的运行环境,然后可以暴露一些方法给外部环境访问,然后当外部环境访问这些沙箱中暴露的方法时,在沙箱内部就可以对这些方法进行一些操作了。那么利用这个特点,那么我们就可以创建一个沙箱环境,在沙箱内部创建一个对象,然后暴露一个可以让外部环境访问该对象的方法和一个修改该对象的方法,这样我们就可以在访问该对象的时候进行依赖收集,修改该对象的时候进行依赖触发,从实现了该对象的代理了。

那么根据目的以及功能的不同创建一个 JS 沙箱环境的方式也有很多,其中比较简单一种方式就使用闭包或IIFE(立即执行函数表达式)来实现。通过闭包可以创建一个独立的作用域,然后暴露一些公开的方法,用于与外部环境进行通信。

// 创建作用域沙箱环境
function createSandbox(value) {
  // 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
  const context = {
    value
  }
  // 创建一个外部环境可以访问 context 对象的方法
  function getter() {
    return context.value
  }
  // 创建一个外部环境可以修改 context 对象的方法
  function setter(val) {
    context.value = val
  }

  // 暴露外部环境可以访问 context 对象的方法
  return [getter, setter]
}

通过上述的方式,我们仅仅只是创建一个作用域沙箱,并不是一个独立的运行环境,但通过它可以实现我们想要代理一个对象的读写功能了。

const [count, setCount] = createSandbox(0)
// 访问对象的值
console.log('访问对象的值:', count())
// 修改对象的值
setCount(2)
console.log('修改后的值', count())

打印结果如下:

01.png

那么根据上文我们就可以在访问沙箱作用域中的对象的时候进行依赖收集,修改该对象的时候进行依赖触发,从实现了该对象的读写代理了。

通过发布订阅模式实现数据响应式

同时通过上文对我们前面所学的知识的总结我们又知道了所谓的依赖收集在发布订阅模式中就是订阅者进行订阅操作,触发依赖则是发布者进行发布操作,那么基于此原理,我们只需要把数据的读写进行分离再结合发布订阅模式那么可以实现数据响应式了。

通过前面文章的学习我们知道实现发布订阅模式需要一个变量来存储订阅者,那么在这里我们可以把这个变量设置在 context 对象中。

function createSandbox(value) {
  // 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
  const context = {
    value,
+    observers: null, // 存储订阅者的变量
  }
}

然后在访问 context 对象的 value 值的时候我们可以去判断存不存在订阅者,如果存在就存储到 observers 变量中,同时为了去重,我们把 observers 设置成 Set 类型。同时根据前面文章我们知道需要一个全局订阅者中间变量,这样我们在判断存不存在订阅者的时候就方便很多了,在这里我们把这个全局订阅者中间变量命名为 Listener

代码迭代如下:

+ // 全局订阅者中间变量
+ let Listener
function createSandbox(value) {
  // 创建一个与外部环境隔离的对象变量,并且接收一个外部环境传进来的值作为初始值 value
  const context = {
    value,
    observers: null, // 存储订阅者的变量
  }
  // 创建一个外部环境可以访问 context 对象的方法
  function getter() {
+      // 进行订阅者添加
+      if (Listener) {
+          if (!context.observers) {
+              context.observers = new Set([Listener])
+          } else {
+              context.observers.add(Listener)
+          }
+      }
      return context.value 
  }
  // 省略...
}

通过上述迭代我们就实现订阅者的订阅,那么很自然的接下来迭代实现的功能就是触发依赖了,也就是发布者进行发布。实现也很简单,具体就是把存储订阅者的变量的订阅者全部通知一次。

代码迭代如下:

function createSandbox(value) {
  // 省略...
  // 创建一个外部环境可以访问 context 对象的方法
  function setter(val) {
    context.value = val
+    // 把存储订阅者的变量的订阅者全部通知一次
+    context.observers.forEach(fn => fn());
  }
}

这样我们就可以进行测试了:

const [count, setCount] = createSandbox(0)
// 订阅者小明
Listener = () => {
    console.log(`计算结果是:${count()}`)
}
// 初始化
Listener()
Listener = null
// 更改计算
setCount(2)

打印结果如下:

02.png

我们可以看到成功打印了如期的结果。

至此,我们就通过发布订阅模式实现数据响应式。

实现响应式副作用函数

根据我们前面所学的知识,我们知道不管是 Vue 还是 Mobx 都存在响应式副作用函数,例如 Vue3 中的 effect,Mobx 中的 autorun。那么这里我们实现一个满足上面响应式数据需求的副作用函数,其实它们的实现原理都是一致的。首先需要传递一个需要观察的函数,从发布订阅模式角度理解,这个函数就是一个订阅者,然后把这个函数赋值到一个中间变量上,然后执行这个函数,进行初始化,本质是在触发响应式数据的依赖收集。

function createEffect(fn) {
  // 把需要观察的函数赋值到一个中间变量中去
  Listener = fn
  // 初始化
  fn()
  Listener = null
}

接着我们就可以测试了

const [count, setCount] = createSandbox(0)
createEffect(() => {
  console.log(`计算结果是:${count()}`)
})
setCount(2)

打印结果如下:

02.png

我们可以看到成功打印了如期的结果。

我们通过前面的学习,我们知道不管 Mobx 还是 Vue 中的订阅者中介上都存在一个调度器的参数,在 Mobx 中是 Reaction 中的 onInvalidate 参数,在 Vue3 中则是 ReactiveEffect 的 scheduler 参数,它们的主要作用是在触发依赖的时候,如果存在调度器则调用调度器,从而改变程序的执行顺序。

在这里我们也可以给我们的手写的数据响应式系统简单实现一个调度器,其实很简单,我们给 createEffect 函数传递第二个参数作为调度器,那么当触发依赖的时候,就会去执行第二个参数,而不会执行第一个参数。

-function createEffect(fn) {
+function createEffect(fn, onInvalid) {
  // 把需要观察的函数赋值到一个中间变量中去
-  Listener = fn
+  Listener = {
+    fn,
+    onInvalid
+  }
  // 初始化
  fn()
  Listener = null
}

接着我们需要修改我们的触发依赖部分的代码

function createSandbox(value) {
  // 省略...
  // 创建一个外部环境可以访问 context 对象的方法
  function setter(val) {
    context.value = val
    // 把存储订阅者的变量的订阅者全部通知一次
-    context.observers.forEach(fn => fn())
+    // 如果存在调度器则执行调度器函数
+    context.observers.forEach(o => o.onInvalid ? o.onInvalid() : o.fn())
  }
}

接着我们就可以测试了

const [count, setCount] = createSandbox(0)
createEffect(() => {
  console.log(`计算结果是:${count()}`)
}, () => {
  console.log(`我是调度器,更新的时候先执行调度器`)
})
setCount(2)

打印结果如下:

03.png

应用到 React 上

我们有了前面的 Mobx 和 Vue3 数据响应式库 @vue/reactivity 应用在 React 上的经验,我们再来把我们的上面实现的数据响应式系统应用到 React 上也是非常容易的。我们通过前面的学习知道 Mobx 是通过 observer 函数实现与 React 进行链接结合的,那么我们也在这里实现一个类似 observer 函数则可,为了跟我们上面的副作用函数名称有关联,我们把这个函数命名为 createRenderEffect。根据我们前篇所学的知识知道 observer 是一个高阶函数,所以我们初步把 createRenderEffect 的基础架构搭建出来。

function createRenderEffect(baseComponent) {
  return (props) => {
    return baseComponent(props)
  }
}

通过前面学习我们知道需要通过 React 中的 useRef 来保存订阅者中介类的实例对象,而我们这里并没有实现订阅者中介类,所以我们只需要保存我们上面 createEffect 中的字面量的订阅者中介即可。代码实现如下:

function createRenderEffect(baseComponent) {
  return (props) => {
      const [, setState] = useState()
      const adm = useRef()
      let renderResult
      if (!adm.current) {
        // 保存字面量的订阅者中介
        adm.current = { 
            fn: baseComponent, 
            onInvalid: () => {
                setState(Symbol())
            }
        }
      }
      Listener = adm.current
      renderResult = Listener.fn(props)
      Listener = null
      return renderResult    
  }
}

同时为了顾名思义,我们将上面实现响应式数据的函数 createSandbox 重新命名为 createSignal

// 创建作用域沙箱环境
-function createSandbox(value) {
+function createSignal(value) {
  // 省略...
}

接着我们就可以测试了

const [count, setCount] = createSignal(1)

const TimerView = createRenderEffect(({ count }) => <span>this counter is: {count()}</span>)

function App() {
  return (
    <TimerView count={count}></TimerView>
  );
}

setInterval(() => {
  setCount(count() + 1)
}, 1000)

打印结果如下:

tutieshi_550x220_5s.gif

我们可以看到如期打印了结果,说明我们成功手写了一个数据响应式系统,并且应用到了 React 上。

总结

在本文章中我们成功通过沙箱模式实现了对数据的代理,再通过发布订阅模式实现了数据的响应式,再结合我们前面所学的知识成功把我们的数据响应式系统应用到了 React 上。可能有细心的同学就会发现了我们的所谓的手写响应式状态库,其实就是 SolidJS 的数据响应式的实现原理。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

鸿蒙开发速通(一)

2026年4月22日 08:48

ArkTS

在ArkTS中,类的字段必须在声明的时候或是在构造函数中显示初始化。类似于typescriptstrictPropertyInitialization模式。

在ArkTS中必不可少的是Struct。在日常开发中会经常用到,主要处理界面。

页面和组件

在默认生成的页面代码是这样的:

@Entry                  // 1
@ComponentV2            // 2
struct Index {          // 3
  @Local message: string = 'Hello World';   // 4

  build() {
    RelativeContainer() {    // 5
      Text(this.message)     // 6
        .id('HelloWorld')    // 7
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {     // 8
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
  1. @Entry:装饰器,表示这是一个页面(入口组件)
  2. @ComponentV2:表示自定义组件,更重要的是在组件内部使用状态管理V2版本。@Component对应的就是状态管理V1。现在推荐使用的是状态管理V2,不过参考代码大部分都是V1的。本文中使用的都是状态管理的V2版本。
  3. Struct,自定义组件。自定义组件就是@ComponentStruct的组合。
  4. @Local,组件内部的状态,这里是message。在用户点击了界面上的Hello World文本之后会显示message的内容。
  5. RelativeContainer,相对布局。在alignRules里定义了布局的规则。鸿蒙使用声明式UI来实现组件开发和布局。
  6. Text,是文本组件。
  7. id('xxxxxx'),属性方法,后面出现的fontSize()alignRules()也是属性方法。
  8. onClick,事件方法,用来响应Text组件的点击事件。

自定义组件

如上所述,鸿蒙试用报告声明式UI开发组件。

一个简单的例子:

Column() {
    Text("Hello Bro")
    // ...
}

这里包含了一个Column布局和一个Text组件。

正式自定义一个简单的组件:

@Preview
@ComponentV2
export struct ThemedButton {
  @Param message: string = "Hello ThemedButton";

  build() {
    Row() {
      Text(this.message);
    }
    .justifyContent(FlexAlign.Center)
    .alignItems(VerticalAlign.Center)
    .width('100%')
    .height(60)
    .padding(20)
    .borderColor(Color.Blue)
    .borderWidth(1)
    .borderRadius(30)
  }
}

看起来效果是这样的:

QQ_1775389837230.png

在自定义组件的时候可以给组件加一个@Preview的注解,这样可以在IDE的Previewer里看到组件的效果。这样就不需要反复的运行项目才能看到效果了。

@Param是状态管理的一部分,会在后面的状态管理节点细讲。

组件作为参数

使用@BuildParam装饰参数,参数的类型是:() => void。参数可以根据要传入的组件定义。 完成代码:

@Preview
@ComponentV2
export struct Card {
  // ...略...
  @BuilderParam renderContent?: () => void; // 1

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      Column() {
        if (this.renderContent) {
          this.renderContent()  // 2
        } else {
          Text(this.message)
            .height(20)
            .width(300)
            .align(Alignment.Center)
        }
      }
      // ...略...
    }
    // ...略...
  }
}
  1. 使用@BuildParam装饰要传入的组件,类型是() => void
  2. 在组件的build方法中使用传入的组件。

在其他组件中这样使用:

build() {
    // ...略...
    Column() {
      Card({ title: 'Task pool', message: this.message}) {
        this.renderTaskpool()  // *
      }
    }
  // ...略...

在标记星号的行是一个尾随闭包。换成常规写法是:

Card({ title: 'Task pool', message: this.message, renderContent: this.renderTaskpool()})

复用样式

把几个标准样式放在一起可以定义一个可以复用的样式。

定义一个全局样式

@Styles
function themedBorder() {
  .borderWidth(1)
  .borderColor(Color.Gray)
}

定义一个组件内样式


@Styles
matchParent() {
  .width('100%')
  .height('100%')
}

使用的方法都一样,和标准的属性方法一样:

Column() {
  Text(this.message)
    .height(20)
    .width(300)
    .align(Alignment.Center)
}
.matchParent()
.themedBorder()

renderProps

HOC

组件的生命周期

文档在这里

组件的生命周期: aboutToAppear -> build -> onDidBuild -> aboutToDisappear

  • aboutToAppearbuild方法之前执行,在其中执行初始化组件的任务。不要执行耗时的任务。可以修改状态变量,会在build中起作用。
  • aboutToDisappear在组件销毁前执行,可以在其中执行资源的回收。不可以修改状态变量。

页面的生命周期

页面就是有@Entry装饰的自定义组件。

所以上面说到的自定义组件的生命周期方法都会被调用。额外的还增加了三个生命周期方法: onPageShowonPageHideonBackPress。最后的生命周期方法调用是这样的:

1775396635578_d.png

弹窗

List

Grid

布局

Stack

层叠布局,看起来是这样式儿的

image.png

代码是这样的:

Stack({ alignContent: Alignment.BottomEnd }) { 
  Text('Layer 1')
    .width('100%')
    .height('100%')
    .backgroundColor('#FFE66D')
    .textAlign(TextAlign.Center)
    .fontColor(Color.Black)

  Text('Layer 2')
    .width(150)
    .height(150)
    .backgroundColor('#FF6B6B')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)

  Text('Layer 3')
    .width(80)
    .height(80)
    .backgroundColor('#4ECDC4')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)
}
.width('100%')
.height(200)
.borderRadius(8)
.margin({ top: 20, bottom: 12 })

把颜色,大小等属性方法都删掉,看看关于Stack最重要的部分:

Stack({ alignContent: Alignment.BottomEnd }) {   ///*
  Text('Layer 1')

  Text('Layer 2')

  Text('Layer 3')
}

这里最重要的就是在初始化Stack的时候的参数:alignContentAlignemnt枚举有几个不同的值,分别制定了Stack内的组件的排列顺序。如图:

Flex

和H5的flex基本类似,只是写法换了一下。比如,flex最核心的flex direction和justify content和align items的作用都一样。只是给定值的时候用了鸿蒙自定义的枚举值。

Flex({
  direction: this.flexDirection,
  justifyContent: this.justifyContent,
  alignItems: this.alignItems
}) {
  Text('Item 1')
    .width(80)
    .height(80)
    .backgroundColor('#FF6B6B')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)

  Text('Item 2')
    .width(80)
    .height(80)
    .backgroundColor('#4ECDC4')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)

  Text('Item 3')
    .width(80)
    .height(80)
    .backgroundColor('#45B7D1')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)
}
.width('100%')
.height(200)
.backgroundColor('#F7F7F7')
.padding(12)

Column和Row

ColumnRow就是Flex这个布局的语法糖。

Column就是FlexDirection的值为Column的时候,Row也一样。

RelativeContainer

这个布局用好了有神奇功效。可以把布局的嵌套减少,提高渲染效率。

看代码:

RelativeContainer() {
  // 左上角元素
  Row()
    // 略
    .id('topLeft')
    .alignRules({        ///*
      top: { anchor: '__container__', align: VerticalAlign.Top },
      left: { anchor: '__container__', align: HorizontalAlign.Start }
    })
    
    // 略
}

RelativeContainer里的组件定位依赖的是相对于哪个组件的定位规则。本例中使用的是anchor: '__container__',也就是相对于容器定位,具体的定位规则是align: VerticalAlign.Top。在父容器的上部分。本例只要给出topleft就可以。

当然,可以相对定位的组件可以是父容器,也可以是容器内地其他组件。或者是参考边界辅助线等。更多可以参考这里

GridRow/GridCol

这个擅长解决不同屏幕尺寸的适配问题。文档在这里

先看效果:

image.png

在折叠屏展开的时候,只显示一行,8列。在折叠屏折叠之后显示2行,4列。

代码:

GridRow({ /// 1
  breakpoints: {  /// 2
    value: ['320vp', '600vp', '840vp', '1440vp',
      '1600vp'], // 表示在保留默认断点['320vp', '600vp', '840vp']的同时自定义增加'1440vp', '1600vp'的断点,实际开发中需要根据实际使用场景,合理设置断点值实现一次开发多端适配。
    reference: BreakpointsReference.WindowSize  /// 3
  },
  columns: { /// 4
    xs: 2, // 窗口宽度落入xs断点上,栅格容器分为2列。
    sm: 4, // 窗口宽度落入sm断点上,栅格容器分为4列。
    md: 8, // 窗口宽度落入md断点上,栅格容器分为8列。
    lg: 12, // 窗口宽度落入lg断点上,栅格容器分为12列。
    xl: 12, // 窗口宽度落入xl断点上,栅格容器分为12列。
    xxl: 12 // 窗口宽度落入xxl断点上,栅格容器分为12列。
  },
}) {
  ForEach(this.bgColors, (color: ResourceColor, index?: number | undefined) => {
    GridCol({ span: 1 }) { // 所有子组件占一列。
      Row() {
        Text(`${index}`)
      }.width('100%').height('50vp')
    }.backgroundColor(color)
  })
}
.height(200)
.border({ color: 'rgb(39,135,217)', width: 2 })
.onBreakpointChange((breakPoint) => {
  this.currentBreakpoint = breakPoint
})
  1. 基本的结构就是外面是GridRow里面是GridCol
  2. breakpoints,也就是断点。其实更适合叫触发点。这里的value定义了一个数组。这里的值定义了屏幕宽度的触发点。屏幕的宽度到了某个值的范围后就会触发一个动作。这个动作在columns定义。
  3. GridRow监听的是哪个组件的宽度,这里是Window的宽度。
  4. columns定义的就是每个宽度对应要显示几列。比如屏幕宽度在xs的时候显示两列,sm宽度显示4列,等。也可以直接给定列数值,那么不管屏幕的宽度如何变化列数也就只显示给定的列数。

默认情况

  1. API version 20之前,columns显示12列。没有设置columns的话,任何断点都是显示12列。
  2. API version 20之后,columns默认值为{ xs: 2, sm: 4, md: 8, lg: 12, xl: 12, xxl: 12 }

初识UIAbility

一个应用可以包含一个或者多个UIAbility。一个UIAbility可以在最近任务中作为一个任务显示。一个Ability可以包含一组界面。所以,使用Ability也可以达到避免加载不必要的资源的效果。

Ability的配置

配置文件module.json5在:

project
└── entry
    └── src
        └── main
            └── ets
                └── module.json5

默认的看起来是这样的:

{
  "abilities": [
    {
      "name": "EntryAbility",
      "srcEntry": "./ets/entryability/EntryAbility.ets",
      "exported": true,
      // 其他略
    }
  ]
}

在这个默认的配置文件中,已经标示了默认生成的Ability的文件位置,名称等。

UIAbility的基本使用

新建一个UIAbility

我们修改已经有的Todo App,把todo详情改成在一个Ability里显示。

在DevEco Studio中,点New->Ability就可以新建一个Ability。这个Ability就叫DetailAbility。也可以手动新建一个,不过要自己在module.json5里添加配置。具体方法可以参考上一节。

新建好之后就可以修改之前nav跳转到Detail页面的代码,这里就要唤起新建的这个Ability了:

.onClick(() => {
  this.isShowFloatingButton(false)
  // this.navPathStack.pushPathByName('detail', item) // 这里是nav跳转的

    const want: Want = {
      deviceId: '',
      bundleName: 'com.example.myapplication',
      abilityName: 'DetailAbility',
      parameters: {
        todo: JSON.stringify(item.toModel())
      }
    };

  (this.getUIContext().getHostContext() as common.UIAbilityContext).startAbility(want)  // 使用startAbility唤起`DetailAbility`
})

使用startAbility唤起DetailAbility的时候,还需要一个Want类型的参数。在这里指定了打开的Ability在哪里是谁。deviceId是空的,说明这个Ability在同一个设备

再新建一个DetailPage,目前这个页面只显示了Detail Page文本。稍后更改这个页面。

注意,这一步不做页面显示白板。在entry/src/main/resources/profile/main_pages.json文档添加新建的这个页面。

{
  "src": [
    "pages/Index",
    "pages/DetailPage"
  ]
}

Ability加载的页面都需要在这里添加配置。

但是,这个打不开Detail页面,默认的加载路径不是Detail页面:

onWindowStageCreate(windowStage: window.WindowStage): void {
  // Main window is created, set main page for this ability
  hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

  windowStage.loadContent('pages/Index', (err) => {  /// ***
    if (err.code) {
      hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
      return;
    }
    hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
  });
}

windowStage.loadContent方法中第一个参数就是页面的位置,自动生成的时候给的是:pages/Index, 但是我们要打开的是Detail页面。修改页面位置为pages/DetailPage就可以打开了。

效果是这样的:

image.png

一个Ability,对应在任务栏显示一个任务。这里有两个,默认的一个和新建的DetailAbility。

显示todo详情

现在要使用Want中的parameters了,这里传递了一个todo。但是转成了JSON字符串。而且传递的是一个Model实例。

DetailAbilityonCreate中可以拿到这个want的实例,并从parameters中拿到这个json串。反序列化并使用:

const todo = want?.parameters?.['todo'];

if (!todo) {
  hilog.error(DOMAIN, 'testTag', 'todo is null');
  return;
}

const todoInfo = new TodoViewModel( JSON.parse(todo as string) as TodoModel);
AppStorageV2.connect<TodoViewModel>(TodoViewModel, 'todo_detail', () => todoInfo)

在使用的时候得到model实例,并转成viewmodel后使用。

注意,在DetailPage页面的onPageHide生命周期回调中需要关闭这个Ability:

onPageHide(): void {
  (this.getUIContext().getHostContext() as common.UIAbilityContext).terminateSelf()
}

否则,没有办法在点击其他的todo的时候显示对应的todo的title。详情查看后面的UIAbility数据同步章节。

Ability的生命周期

image.png

onCreate

onWindowStageCreate

这里注意,加载页面是在onWindowStageCreate这个方法进行的:

onWindowStageCreate(windowStage: window.WindowStage): void {
  hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

  windowStage.loadContent('pages/Index', (err) => { /// *
    if (err.code) {
      hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
      return;
    }
    hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
  });
}

onWindowStageCreate方法中使用windowStage.loadContent('pages/Index', ()=>{})加载了页面。

也可以在这个方法里订阅获焦/失焦、切到前台/切到后台、前台可交互/前台不可交互等事件。详细文档在这里

onForeground

这是Ability的UI可见之前的最后一个回调,在这里申请需要的系统资源。

import { UIAbility } from '@kit.AbilityKit';
// ···

export default class EntryAbility extends UIAbility {
// ···

  onForeground(): void {
    // 申请系统需要的资源,或者重新申请在onBackground()中释放的资源
  }

// ···
}

onBackground

在Ability的UI完全不见之后,触发onBackground回调。开发者可以在这个回调中释放不需要的系统资源,比如停止定位功能等。

onWindowStageWillDestroy

Ability在销毁之前,系统触发这个回调。这个时候WindowStage还没有销毁,还可以用。

onWindowStageWillDestroy(windowStage: window.WindowStage): void {
  // 释放通过windowStage对象获取的资源
  // 在onWindowStageWillDestroy()中注销WindowStage事件订阅(获焦/失焦、切到前台/切到后台、前台可交互/前台不可交互)
  try {
    if (windowStage) {
      windowStage.off('windowStageEvent');
    }
  } catch (err) {
    let code = (err as BusinessError).code;
    let message = (err as BusinessError).message;
    hilog.error(DOMAIN, 'testTag', `Failed to disable the listener for windowStageEvent. Code is ${code}, message is ${message}`);
  }
}

onWindowStageDestroy

在这里WindowStage还是没有销毁。可以在这里释放UI资源。

onDestroy

UIAbility的最后一个生命周期回调。可以在这里做最后的资源释放,清理等工作。

onNewWant

在Ability实例已经创建,再次调用方法启动这个Ability的时候会触发这个回调。可以在这里更新加载的资源或者数据。

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
// ···

export default class EntryAbility extends UIAbility {
// ···

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam) {
    // 更新资源、数据
  }
}

UIAbility内数据同步

UIAblity和它内部的页面同步数据的方法有两种,一个是使用AppStorageV2(当然PersistenceV2也可以)和事件的方式。

使用AppStorageV2

export default class DetailAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    const todo = want?.parameters?.['todo'];

    if (!todo) {
      hilog.error(DOMAIN, 'testTag', 'todo is null');
      return;
    }

    const todoInfo = new TodoViewModel(JSON.parse(todo as string) as TodoModel);
    AppStorageV2.connect<TodoViewModel>(TodoViewModel, 'todo_detail', () => todoInfo)
  }
  
  // 其他略
  
}

把want参数的序列化的字符串解析出来后,转成viewmodel然后通过AppStorage实现App全局共享,这样在DetailPage就可以拿到了。

使用EventHub事件的方式

使用EventHubemit方法发出事件,使用EventHubon接收事件。用完之后可以使用off方法取消该事件订阅。

在Ability里接收,在页面发出。

接收:

import { hilog } from '@kit.PerformanceAnalysisKit';
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
// ···

const DOMAIN = 0x0000;
const TAG: string = '[EventAbility]';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 获取eventHub
    let eventhub = this.context.eventHub;
    // 执行订阅操作
    eventhub.on('event1', this.eventFunc);
    eventhub.on('event1', (data: string) => {
      // 触发事件,完成相应的业务操作
    });
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onCreate');
  }

  eventFunc(argOne: object, argTwo: object): void {
    hilog.info(DOMAIN, TAG, '1. ' + `${argOne}, ${argTwo}`);
    return;
  }

// ···
}

发出:

import { common } from '@kit.AbilityKit';

@Entry
@Component
struct EventHubPage {
  private context = this.getUIContext().getHostContext() as common.UIAbilityContext;

  eventHubFunc(): void {
    // 不带参数触发自定义“event1”事件
    this.context.eventHub.emit('event1');
    // 带1个参数触发自定义“event1”事件
    this.context.eventHub.emit('event1', 1);
    // 带2个参数触发自定义“event1”事件
    this.context.eventHub.emit('event1', 2, 'test');
    // 开发者可以根据实际的业务场景设计事件传递的参数
  }

  build() {
    Column() {
      List({ initialIndex: 0 }) {
        ListItem() {
          Row() {
            // ···
          }
          .onClick(() => {
            this.eventHubFunc();
            this.getUIContext().getPromptAction().showToast({
              message: 'EventHubFuncA'
            });
          })
        // ···
        }

        ListItem() {
          Row() {
            // ···
          }
          .onClick(() => {
            this.context.eventHub.off('event1');
            this.getUIContext().getPromptAction().showToast({
              message: 'EventHubFuncB'
            });
          })
        // ···
        }
      }
    // ···
    }
    // ···
  }
}

Ability之间跳转

文档在这里

Ability的冷启动和热启动:

  • 冷启动,就是这个Ability在内存中不存在。这次启动需要完成Ability的初始化和启动的动作。
  • 热启动,这个Ability已经存在于内存中,但是不可见。启动的时候不需要在执行初始化的逻辑,只会触发onNewWant生命周期方法。

启动一个Ability

const want: Want = {
  deviceId: '', // 1
  bundleName: 'com.example.myapplication',
  moduleName: 'entry',  // 2
  abilityName: 'DetailAbility',
  parameters: {
    todo: JSON.stringify(item.toModel())
  }
};
(this.getUIContext().getHostContext() as common.UIAbilityContext).startAbility(want)

接收这些want的数据

  1. deviceId,这里是空,表示本设备
  2. 指定moduleName,非必需。
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  const todo = want?.parameters?.['todo'];

  if (!todo) {
    hilog.error(DOMAIN, 'testTag', 'todo is null');
    return;
  }

  const todoInfo = new TodoViewModel(JSON.parse(todo as string) as TodoModel);
  AppStorageV2.connect<TodoViewModel>(TodoViewModel, 'todo_detail', () => todoInfo)
}

启动一个Ability并获得返回结果

这次使用startAbilityForResult,这个方法可以打开一个Ability并获得返回结果。它返回一个Promise,所以获取返回结果需要用到then,或者使用await

代码:

// 略

@Entry
@Component
struct MainPage {
  private context = this.getUIContext().getHostContext() as common.UIAbilityContext;

  build() {
    Column() {
      List({ initialIndex: 0, space: 8 }) {

        // ···

        ListItem() {
          Row() {
            // ···
          }
          .onClick(() => {
            let context = this.getUIContext().getHostContext() as common.UIAbilityContext; // UIAbilityContext
            const RESULT_CODE: number = 1001;   // 1
            let want: Want = {
              deviceId: '', // deviceId为空表示本设备
              bundleName: 'com.samples.uiabilityinteraction',
              moduleName: 'entry', // moduleName非必选
              abilityName: 'FuncAbilityA',
              parameters: {
                // 自定义信息
                // app.string.main_page_return_info资源文件中的value值为'来自EntryAbility MainPage页面'
                info: $r('app.string.main_page_return_info')
              }
            };
            context.startAbilityForResult(want).then((data) => { // 2
              if (data?.resultCode === RESULT_CODE) {
                // 解析被调用方UIAbility返回的信息
                let info = data.want?.parameters?.info;
                hilog.info(DOMAIN_NUMBER, TAG, JSON.stringify(info) ?? '');
                if (info !== null) {
                  this.getUIContext().getPromptAction().showToast({
                    message: JSON.stringify(info)
                  });
                }
              }
              hilog.info(DOMAIN_NUMBER, TAG, JSON.stringify(data.resultCode) ?? '');
            }).catch((err: BusinessError) => { // 3
              hilog.error(DOMAIN_NUMBER, TAG, `Failed to start ability for result. Code is ${err.code}, message is ${err.message}`);
            });
          })
        }

        // ···
      }
    // ···
    }
    // ···
  }
}
  1. 设置一个返回结果的唯一标识,别接受了别的Ability的返回结果。
  2. 开始一个Ability并处理返回值:startAbilityForResult(want).then((data) => {}
  3. 处理异常。

但是被打开的Ability怎么把结果返回回去呢,使用terminateSelfWithResult()方法。代码:

const RESULT_CODE: number = 1001; // FuncAbilityA返回的结果
let abilityResult: common.AbilityResult = {
  resultCode: RESULT_CODE,
  want: {
    bundleName: 'com.samples.uiabilityinteraction',
    moduleName: 'entry', // moduleName非必选
    abilityName: 'FuncAbilityA',
    parameters: {
      // app.string.ability_return_info资源文件中的value值为'来自FuncAbility Index页面'
      info: $r('app.string.ability_return_info')
    },
  },
};
context.terminateSelfWithResult(abilityResult, (err) => {
  if (err.code) {
    hilog.error(DOMAIN_NUMBER, TAG, `Failed to terminate self with result. Code is ${err.code}, message is ${err.message}`);
    return;
  }
});

状态管理

状态管理上,有V1和V2两个版本。推荐使用的是V2版本。在具体的开发中结合MVVM模式使用。

V1

V2

以下的装饰器都只能在ComponentV2装饰的组件内部使用。@ObservedV2@Trace除外。

@Local

只在组件内部使用。

@Entry
@ComponentV2
struct Index {
  // 点击的次数
  @Local count: number = 0;
  @Local message: string = 'Hello';
  @Local flag: boolean = false;

  build() {
    Column() {
      Text(`${this.count}`)
      Text(`${this.message}`)
      Text(`${this.flag}`)
      Button('change Local')
        .onClick(() => {
          // 当@Local装饰简单类型时,能够观测到对变量的赋值
          this.count++;
          this.message += ' World';
          this.flag = !this.flag;
        })
    }
  }
}

@Param

接收付组件传入的值。

// 子组件
@ComponentV2
struct Child {
  @Param count: number = 0;         /// 1
  @Require @Param message: string;
  @Require @Param flag: boolean;

  build() {
    Column() {
      Text(`Param ${this.count}`)
      Text(`Param ${this.message}`)
      Text(`Param ${this.flag}`)
    }
  }
}

// 父组件
@Entry
@ComponentV2
struct Index {
  // 点击的次数
  @Local count: number = 0;
  @Local message: string = 'Hello';
  @Local flag: boolean = false;

  build() {
    Column() {
      Text(`Local ${this.count}`)
      Text(`Local ${this.message}`)
      Text(`Local ${this.flag}`)
      Button('change Local')
        .onClick(() => {
          // 对数据源的更改会同步给子组件
          this.count++;
          this.message += ' World';
          this.flag = !this.flag;
        }) 
      Child({   /// 2
        count: this.count,
        message: this.message,
        flag: this.flag
      })
    }
  }
}

@Event

接收父组件传入的方法,用来更新父组件的数据源。

// 子组件
@ComponentV2
struct Child {
  @Param title: string = '';
  @Param fontColor: Color = Color.Black;
  @Event changeFactory: (x: number) => void = (x: number) => {}; /// 1

  build() {
    Column() {
      Text(`${this.title}`)
        .fontColor(this.fontColor)
      Button('change to Title Two')
        .onClick(() => {
          this.changeFactory(2); /// 2
        })
      Button('change to Title One')
        .onClick(() => {
          this.changeFactory(1);
        })
    }
  }
}

// 父组件
@Entry
@ComponentV2
struct Index {
  @Local title: string = 'Title One';
  @Local fontColor: Color = Color.Red;

  build() {
    Column() {
      Child({
        title: this.title,
        fontColor: this.fontColor,
        changeFactory: (type: number) => { /// 3
          if (type == 1) {
            this.title = 'Title One';
            this.fontColor = Color.Red;
          } else if (type == 2) {
            this.title = 'Title Two';
            this.fontColor = Color.Green;
          }
        }
      })
    }
  }
}
  1. 在子组件定义一个@Event回调。
  2. 在子组件调用这个回调。
  3. 在父组件传递一个箭头函数给子组件定义的。

在父组件给出的定义中,子组件调用这个回调的时候可以修改父组件的titlefontColor两个数据。

@ObservedV2和@Trace

用于监视对象内部属性的变化。

在上面描述的装饰器中,如果是一个对象,那么只能监听到赋值的变化。如果是一个数组等数据集合,一般只能监听到集合内部API的调用引起的变化。如果修改了数据某个对象的属性的值,是无法监听到的,也就是这样的修改不会出现在界面上。

这就需要用到@ObservedV2@Trace的装饰器组合。

  1. @ObservedV2装饰类。
  2. @Trace装饰需要观察的属性。

注意@ObservedV2装饰的类要在new出来之后才有观察变化的能力。

代码:

@ObservedV2  /// 1
class Son {
  @Trace public age: number = 100; /// 2
}

class Father {
  public son: Son = new Son();  /// 3
}

@Entry
@ComponentV2
struct Index {
  father: Father = new Father();   /// 4

  build() {
    Column() {
      // 当点击改变age时,Text组件会刷新
      Text(`${this.father.son.age}`)  /// 5
        .onClick(() => {
          this.father.son.age++;  /// 6
        })
    }
  }
}
  1. 使用@ObservedV2装饰类。
  2. 使用@Trace装饰需要监听的属性age
  3. @Observed装饰的类在另一个类力使用。不是使用的必要步骤,只是说明监听的穿透力强
  4. 初始化需要监听的类。必须初始化才可以监听到变化。
  5. 如果属性的值发生变化,而且被监听到了,那么在界面上显示对应的变化。
  6. 在点击事件中更改@Trace装饰的属性。

@Provider / @Consumer

跨组件双向同步数据。遇到同名的时候,使用组件树上最近的那个。

定义的时候是这样的:

  1. @Provider(aliasName?: string) varName : varType = initValuealiasName为空的时候使用属性名作为aliasName
  2. @Consumer(aliasName?: string) varName : varType = initValuealiasName,如果为空就是属性名,是@Provider@Consumer关联的唯一key值。
@ComponentV2
struct Parent {
  // 未定义aliasName, 使用属性名'str'作为aliasName
  @Provider() str: string = 'hello';
}

@ComponentV2
struct Child {
  // 定义aliasName为'str',使用aliasName去寻找
  // 能够在Parent组件上找到, 使用@Provider的值'hello'
  @Consumer('str') str: string = 'world';
}
  1. 这两个需要在同一个组件树中,在这个组件树的不同层级双向同步数据。
  2. 如果@Consumer找不到对应的@Provider,则使用本地的默认值。

@Monitor

装饰一个方法,可以在装饰器参数指定监视的状态。被监听的状态变化的时候触发Monitor装饰的方法。


import { hilog } from '@kit.PerformanceAnalysisKit';

@Entry
@ComponentV2
struct Index {
  @Local message: string = 'Hello World';
  @Local name: string = 'Tom';
  @Local age: number = 24;

  @Monitor('message', 'name')
  onStrChange(monitor: IMonitor) {
    monitor.dirty.forEach((path: string) => {
      hilog.info(0xFF00, 'testTag', '%{public}s',
        `${path} changed from ${monitor.value(path)?.before} to ${monitor.value(path)?.now}`);
    });
  }

  build() {
    Column() {
      Button('change string')
        .onClick(() => {
          this.message += '!';
          this.name = 'Jack';
        })
    }
  }
}

也可以监听被@Trace装饰的属性的变化:

监听@Trace装饰的属性
@Monitor('info')
infoChange(monitor: IMonitor) {
  hilog.info(0xFF00, 'testTag', '%{public}s', `info change`);
}

@Monitor('info.name') ///*
infoPropertyChange(monitor: IMonitor) {
  hilog.info(0xFF00, 'testTag', '%{public}s', `info name change`);
}

属性name@Trace装饰的时候可以被@Monitor监听到。

监听多个
@Monitor('region', 'job') /// *
onChange(monitor: IMonitor) {
  monitor.dirty.forEach((path: string) => {
    hilog.info(0xFF00, 'testTag', '%{public}s',
      `${path} change from ${monitor.value(path)?.before} to ${monitor.value(path)?.now}`);
  })
}
可以在@ObservedV2装饰的类中使用

@Computed

装饰一个get属性,从一个或者多个获取到一个最终值。避免多个状态变化是多次计算。只做读,不做写

@Computed
get sum() {
  return this.count1 + this.count2 + this.count3;
}

@Type

你的类需要被序列化,这时你的类里面还定义了一个属性,这个属性的类型也是一个类。为了不在序列化的时候丢失这个属性的类型可以用@Type来装饰属性。

注意:

  1. 构造函数不包含参数
  2. 一般配合PersistenceV2一起使用
class Sample {
  private data: number = 0;
}

@ObservedV2
class Info {
  @Type(Sample)
  @Trace public sample: Sample = new Sample(); // 正确用法
}

@AppStorageV2

整个app的状态管理,可以跨UIAbility共享数据。

定义:

AppStorageV2.connect(/* 参数 */)

代码:

AppStorageV2.connect<ThemeModel>(ThemeModel, 'app_theme', () => new ThemeModel(initialColorModel))
connect

这个方法用来创建或者获取存储的数据。

connect的参数。第一个是存放的类型。第二个可以是key,也可以是类型的默认构造器。如果第二个不是默认构造器,或者第二个参数不合法,那么第二个必须是默认构造器。

remove

使用remove删除指定的key对应的数据

keys

使用keys可以得到AppStorageV2中的全部key。

注意AppStorageV2只支持class类型。只能在UI线程使用。不支持collections.Setcollections.Map等类型。

@PersistenceV2

整个app的状态管理,另外带有持久化功能。

AppStorageV2一样,只是这个可以持久化存储。在第二次打开app的时候对应的状态不会丢失。

MVVM

App的代码组织模式。

简单的应用可以使用这样的模式来实现。

基本的组成有:

  1. Model层,负责管理数据。
  2. ModelView层,连接Model和View,负责管理UI的状态和业务逻辑。通过监控Model数据的变化,处理业务逻辑并将数据更新到View层。
  3. UI层,展示数据和与用户交互。

另外还有对数据库或者服务器操作的repository层。

示例代码在这里。作为一个Todo类App的Model层,这里定义了TodoModelTodoListModel

Model

TodoModel中,只负责持有数据。在TodoListModel中则需要借助repository处理本地SQLite的数据。入:

import { TodoModel } from './TodoModel';
import { TodoRepository } from '../libs/repository';

export class TodoListModel {
  private _todos: TodoModel[] = []; /// 1
  private _repo?: TodoRepository;   /// 2

  // 略

  async loadTodoList() {
    if (!this._repo) {
      this._repo = await TodoRepository.getInstance(this._context)
    }

    const todoList = await this._repo?.queryAll()  /// 3
    this._todos = todoList ?? []
  }

  // 其他略
}
  1. TodoListModel的数据,一组TodoModel实例。
  2. TodoRepository,用来管理本地数据库的数据。
  3. 使用repository获取数据。

ModelView

import { TodoModel } from '../model/TodoModel'

@ObservedV2
export class TodoViewModel {
  id: number = 0;
  createdAt: number = Date.now();
  updatedAt: number = Date.now();
  @Trace title: string = ''; // 标题
  @Trace description: string = ''; // 描述
  @Trace completed: boolean = false; // 完成状态
  @Trace version: number = 0; // 版本(用于乐观锁)
  
  // 其他略
}  

这里就使用了@ObservedV2@Trace来监听数据的变化。

import { Type } from '@kit.ArkUI';
import { TodoListModel } from '../model/TodoListModel';
import { TodoModel } from '../model/TodoModel';
import { TodoViewModel } from './TodoViewModel';

@ObservedV2
export class TodoListViewModel {
  @Trace todoList: TodoViewModel[] = [];
  private _todoListModel?: TodoListModel

  // TODO: Add error message for display
  
  // 略

  async addTodo(title: string, description: string = '') {
    const todo: TodoModel = new TodoModel({ title, description });
    await this._todoListModel?.addTodo(todo);
    this.todoList.push(new TodoViewModel(todo));
  }
 
  // 其他略
}

TodoListViewModel的Model数据就是_todoListModel,并使用@Trace进行深度监听。

UI / View

@Entry
@ComponentV2
struct Index {
  // 略
  
  @Local todoList: TodoListViewModel = new TodoListViewModel(this.getUIContext().getHostContext())
  
  // 其他略
  
}

在视图中把TodoListViewModel的示例使用@Local装饰。这样就把ViewModel这个模式的各个要素串联到了一起。

导航

这里介绍Stack导航和Tab导航。

Navigation

Navigation四件套:

  1. Navigation组件,这个必不可少。
  2. NavPathStack实例。实际控制导航到哪里。
  3. 页面映射关系。这个定义在一个@Builder装饰的方法里。
  4. NavDestination。导航的目标“页”中最外面的组件就是NavDestination

代码如下:

// ...

@Entry
@ComponentV2
struct Index {
  navStack: NavPathStack = new NavPathStack()  // 1

  build() {
    Navigation(this.navStack) {   // 2
      Column() {
        ThemedButton({ message: '弹窗' })
          .padding(10)
          .onClick(() => {
            this.navStack.pushPath({ name: 'Pops' }) // 3
          })
      }
      // ...
    }
    .navDestination(this.pageMap)  // 4
  }

  @Builder
  pageMap(name: string) {  // 5
    if (name === "Pops") {
      PopupSamples()
    }
  }
}

目标页:

@Preview
@ComponentV2
export struct PopupSamples {
  build() {
    NavDestination() {  、、 6
      Column() {
        Text('PopupSamples')
      }
    }
  }
}
  1. NavPathStack,作为页面的成员初始化。
  2. Navigation,这就应该出现了,在布局顶层。并把NavPathStack的实例作为成员传入。这两个就关联在一起了。
  3. 使用NavPathStack成员执行导航。
  4. Navigation的属性方法中配置可以导航的页面。
  5. 定义页面地图。映射导航的名称和对应的组件。

更多高级内容稍后补充。。。

Tab

@Preview
@ComponentV2
export struct TabsSamples {
  build() {
    NavDestination() {
      Tabs({barPosition: BarPosition.End}) { // 1
        TabContent() {                       // 2
          Text('Tab 1')
        }
        .tabBar("Tab 1")                     // 3

        TabContent() {
          Text('Tab 2')
        }
        .tabBar("Tab 2")
      }
    }
  }
}

Tab布局的实现需要三件套: TabsTabContenttabBar属性方法。

上面的代码中:

  1. Tabs,Tab布局的总体设置都在这里。比如在上面的例子中配置tab bar的位置在底部。
  2. TabContent,每个tab的内容容器。上例的内容为一个Text组件。
  3. tabBar,在这配置tab按钮

访问网络

首先,可以使用如下代码实现一个极简的服务器,get请求后可以返回一个json串来验证鸿蒙网络请求正确与否。

如下的bash命令直接在terminal运行即可跑起来一个server。

node版本:

node -e "require('http').createServer((req, res) => { res.writeHead(200, {'Content-Type': 'application/json'}); res.end(JSON.stringify({status: 'ok'})); }).listen(8000); console.log('Server running on port 8000')"

python:

python3 -c "from http.server import HTTPServer, BaseHTTPRequestHandler; import json; class S(BaseHTTPRequestHandler): do_GET = do_POST = lambda s: (s.send_response(200), s.send_header('Content-Type', 'application/json'), s.end_headers(), s.wfile.write(json.dumps({'status': 'ok'}).encode())); HTTPServer(('0.0.0.0', 8000), S).serve_forever()"

也可以直接使用这个地址:

https://jsonplaceholder.typicode.com/posts/1

使用http模块

async sendHttpRequest() {
  this.httpLoading = true
  this.httpResponseText = ''

  try {
    const httpRequest = http.createHttp() /// 1
    const url = 'https://jsonplaceholder.typicode.com/posts/1'

    const response = await httpRequest.request(url, { /// 2
      method: http.RequestMethod.GET,
      header: {
        'Content-Type': 'application/json'
      },
      connectTimeout: 60000,
      readTimeout: 60000
    })

    if (response.responseCode === 200) {
      this.httpResponseText = JSON.stringify(JSON.parse(response.result as string), null, 2)
    } else {
      this.httpResponseText = `请求失败: HTTP ${response.responseCode}`
    }

    httpRequest.destroy()  /// 3
  } catch (error) {
    this.httpResponseText = `请求出错: ${JSON.stringify(error)}`
  } finally {
    this.httpLoading = false
  }
}
  1. 使用http.createHttp()新建一个http请求实例:httpRequest
  2. 使用httpRequest请求服务器,并配置http method,请求的url地址等。
  3. 最后要销毁httpRequest

使用Axios访问网络:

async sendAxiosRequest() {
  this.axiosLoading = true
  this.axiosResponseText = ''

  try {
    const response:AxiosResponse<string> = await axios.get('https://jsonplaceholder.typicode.com/posts/1', {
      headers: {
        'Content-Type': 'application/json'
      },
      timeout: 60000
    })   /// 1

    this.axiosResponseText = JSON.stringify(response.data, null, 2)
  } catch (error) {
    this.axiosResponseText = `请求出错: ${JSON.stringify(error)}`
  } finally {
    this.axiosLoading = false
  }
}

使用axios就简单多了。直接使用axios.get请求。

数据存储

SQLite, 四个读连接,一个写连接。

代码:


import { relationalStore, ValuesBucket } from '@kit.ArkData'
import { Context } from '@kit.AbilityKit'
import { TodoModel } from '../model/TodoModel'

export const DB_NAME = 'todo_db.db'

export const DB_VERSION = 1

export const CREATE_TABLE_SQL = `
  CREATE TABLE IF NOT EXISTS todo_info (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT,
    completed INTEGER DEFAULT 0,
    version INTEGER DEFAULT 1,
    createdAt INTEGER,
    updatedAt INTEGER
  )
`

export const STORE_CONFIG: relationalStore.StoreConfig = {
  name: DB_NAME,
  securityLevel: relationalStore.SecurityLevel.S1
}

export class TodoRepository {
  private static instance: TodoRepository
  private rdbStore: relationalStore.RdbStore | null = null
  private context: Context | null = null

  static async getInstance(context?: Context): Promise<TodoRepository> {
    if (!TodoRepository.instance) {
      if (!context) {
        throw new Error('Context must be provided for first initialization')
      }
      TodoRepository.instance = new TodoRepository()
      await TodoRepository.instance.init(context)
    }
    return TodoRepository.instance
  }

  private async init(context: Context): Promise<void> {
    this.context = context
    this.rdbStore = await relationalStore.getRdbStore(context, STORE_CONFIG)
    await this.rdbStore.executeSql(CREATE_TABLE_SQL)
  }

  async queryAll(): Promise<TodoModel[]> {
    if (!this.rdbStore) {
      this.rdbStore = await relationalStore.getRdbStore(this.context!, STORE_CONFIG)
    }

    const resultSet = await this.rdbStore.querySql('SELECT * FROM todo_info ORDER BY createdAt DESC')
    const list: TodoModel[] = []
    while (resultSet.goToNextRow()) {
      const todo = new TodoModel()
      todo.id = resultSet.getLong(resultSet.getColumnIndex('id'))
      todo.title = resultSet.getString(resultSet.getColumnIndex('title'))
      todo.description = resultSet.getString(resultSet.getColumnIndex('description'))
      todo.completed = resultSet.getLong(resultSet.getColumnIndex('completed')) === 1
      todo.createdAt = resultSet.getDouble(resultSet.getColumnIndex('createdAt'))
      todo.updatedAt = resultSet.getDouble(resultSet.getColumnIndex('updatedAt'))

      list.push(todo)
    }
    resultSet.close()
    return list
  }

  async getTodoById(id: number): Promise<TodoModel | null> {
    if (!this.rdbStore) {
      this.rdbStore = await relationalStore.getRdbStore(this.context!, STORE_CONFIG)
    }

    const predicates = new relationalStore.RdbPredicates('todo');
    predicates.equalTo('id', id);

    const columns = ['id', 'title', 'description', 'completed', 'version', 'createdAt', 'updatedAt'];
    const result = await this.rdbStore.query(predicates, columns);

    if (result.goToNextRow()) {
      const todo = TodoModel.fromDatabase({
        id: result.getDouble(result.getColumnIndex('id')),
        title: result.getString(result.getColumnIndex('title')),
        description: result.getString(result.getColumnIndex('description')),
        completed: result.getDouble(result.getColumnIndex('completed')),
        version: result.getDouble(result.getColumnIndex('version')),
        createdAt: result.getDouble(result.getColumnIndex('createdAt')),
        updatedAt: result.getDouble(result.getColumnIndex('updatedAt'))
      });
      result.close();
      return todo;
    }

    result.close();
    return null;
  }

  async insert(todo: TodoModel): Promise<void> {
    if (!this.rdbStore) {
      this.rdbStore = await relationalStore.getRdbStore(this.context!, STORE_CONFIG)
    }

    const valueBucket: ValuesBucket = {
      // id: todo.id,
      title: todo.title,
      description: todo.description,
      completed: todo.completed ? 1 : 0,
      createdAt: todo.createdAt
    }
    await this.rdbStore.insert('todo_info', valueBucket)
  }

  async updateStatus(todo: TodoModel): Promise<void> {
    const updateStatement = 'UPDATE todo_info SET ' + todo.toKeyValuePairs() + ` WHERE id = ${todo.id}}`

    if (!this.rdbStore) {
      this.rdbStore = await relationalStore.getRdbStore(this.context!, STORE_CONFIG)
    }

    await this.rdbStore.executeSql(updateStatement)
  }

  async deleteTodo(id: number): Promise<boolean> {
    if (!this.rdbStore) {
      throw new Error('数据库未初始化');
    }

    const predicates = new relationalStore.RdbPredicates('todo_info');
    predicates.equalTo('id', id);

    const affectedRows = await this.rdbStore.delete(predicates);
    return affectedRows > 0;
  }
}

通知和推送

异步编程

有两种方式实现,一个是异步并发使用promise和async/await实现。依靠单线程的事件循环。

另外一种就是多线程并发。使用TaskPool和Worker实现。

异步并发

使用Promise或者async/await实现。如:

const promise: Promise<number> = new Promise((resolve: Function, reject: Function) => {
  setTimeout(() => {
    const randomNumber: number = Math.random();
    if (randomNumber > 0.5) {
      resolve(randomNumber);
    } else {
      reject(new Error('Random number is too small'));
    }
  }, 1000);
})

Async/Await

async function myAsyncFunction(): Promise<string> {
  const result: string = await new Promise((resolve: Function) => {
    setTimeout(() => {
      resolve('Hello, world!');
    }, 3000);
  });
  console.info(result); // 输出: Hello, world!
  return result;
}

多线程并发

多线程并发可以使用taskpoolWorker实现。

Taskpool

底层原理是Actor模型。开发中可以使用TaskPool或者Worker实现。详细的文档可以看这里

简言之,Actor模型的多个线程之间不同享内存,一个线程就是一个Actor。多个线程需要通信则互发消息。

taskpool的典型用法,看代码:

import { taskpool } from '@kit.ArkTS';
import { sleep } from '../utils';

@Concurrent  // => 1
async function generateNumber(ms: number): Promise<number> {
  await sleep(ms);
  return Math.random();
}

@ComponentV2
export struct AsyncSamples {
  @Local message: string = 'Async Samples';

  aboutToAppear(): void {
    const task = new taskpool.Task(generateNumber, 2000); // => 2
    taskpool.execute(task).then((result) => { // => 3
      console.log('Task result', result);
    });
  }

  build() {
    NavDestination() {
      Column() {
        Text(this.message)
      }
      // ...
    }
  }
}

注意:这里只说鸿蒙文档的典型用法,其他用法在后面会提到。

上面的代码,首先引入taskpool

  1. 定义一个并发方法,通过@Concurrent这个装饰器装饰一个方法实现。这个方法可以是一个async方法,也可以不是。
  2. 用定义好的并发方法新建一个taskpool.Task实例。如果这个并发方法需要一个参数,那么在定义Task的时候在第二个参数给出。
  3. 使用taskpool.execute执行前一步定义好的task。在then里获取执行的结果,并更新组件的状态。task的结果就显示在界面上了。

在定义并发方法的时候,方法内部使用的只能是局部变量,入参和import引入的变量。

taskpool也可以这样:

function someFunc(param: string) {
    //...
}

taskpool.execute(someFunc, param).then((ret) => { // 定期执行可以使用executePeriodically
    // ...
});

Worker

Worker实现三步:

  1. 新建一个新的文件放worker的代码。
  2. 新建worker.ThreadWorker实例。
  3. 宿主现场和子线程之间互传消息。

具体实现如下:

c343b933-91db-42c3-b2d6-becd4eda75b3.png

点了Start worker之后开始执行线程代码。每次更新progress bar的进度,一直到达到百分之百。

代码如下:

import { MessageEvents, ThreadWorkerGlobalScope, worker } from '@kit.ArkTS';

const workerPort: ThreadWorkerGlobalScope = worker.workerPort; /// 1

let progressTimer: number | null = null
let currentProgress: number = 0

workerPort.onmessage = (e: MessageEvents) => {  /// 2
  const message:string = e.data

  if (message=== 'START') {
    currentProgress = 0

    // 清除旧定时器
    if (progressTimer) {
      clearInterval(progressTimer)
    }

    // 每 300ms 发送进度
    progressTimer = setInterval(() => {
      currentProgress += 2  // 每次增加 2%

      // 发送进度回主线程
      workerPort.postMessage({                  /// 3
        type: 'PROGRESS_UPDATE',
        value: currentProgress
      })

      // 完成时清理
      if (currentProgress >= 100) {
        if (progressTimer) {
          clearInterval(progressTimer)
          progressTimer = null
        }
        workerPort.postMessage({ type: 'COMPLETED' })
      }
    }, 300)
  }

  if (message=== 'STOP') {
    if (progressTimer) {
      clearInterval(progressTimer)
      progressTimer = null
    }
  }
}
  1. 初始化workerPost实例,这是线程互通的关键。
  2. 接受主线程的消息,根据STARTSTOP开始或者停止发送消息。
  3. 在本线程给主线程发送消息:workerPort.postMessage

在UI:

ThemedButton({
  message: this.progressText, handleClick: () => {
    let workerInstance = new worker.ThreadWorker('entry/ets/workers/worker.ets'); /// 1
    
    workerInstance.postMessage('START');             /// 2
    workerInstance.onmessage = ((e: MessageEvents) => {   /// 3
      const data: WorkerMessage = e.data as WorkerMessage;
      if (data.type === 'PROGRESS_UPDATE') {
        this.progressValue = data.value;
        this.status = 'running';
      } else if (data.type === 'COMPLETED') {
        this.progressValue = 100;
        this.status = 'completed';
      }
    });
  }
})
  1. 使用定义好的worker.ets文件新建worker实例。
  2. 给子线程发送消息:workerInstance.postMessage()。这里通知子线程开始执行。
  3. 接收子线程发送过来的消息,并更新界面。

注意:在处理worker文件的时候有些注意事项,请看这里

TaskpoolWorker的对比,看这里

timedatectl Cheatsheet

Basic Status

Check the current time, date, time zone, and sync state.

Command Description
timedatectl Show current time, time zone, and NTP status
timedatectl status Show the same formatted status explicitly
timedatectl show Show status in key-value format
timedatectl show --property=Timezone --value Print the current time zone only
timedatectl show --property=NTPSynchronized --value Print whether the clock is synchronized

Time Zones

List and change time zone settings.

Command Description
timedatectl list-timezones List all available time zones
timedatectl list-timezones | grep -i berlin Filter the time zone list
sudo timedatectl set-timezone Europe/Berlin Set the system time zone
sudo timedatectl set-timezone Etc/UTC Switch the system back to UTC
timedatectl show --property=Timezone --value Confirm the current time zone in scripts

NTP Synchronization

Enable, disable, and inspect time synchronization.

Command Description
sudo timedatectl set-ntp true Enable automatic time sync
sudo timedatectl set-ntp false Disable automatic time sync
timedatectl timesync-status Show the current NTP server, offset, and poll interval
timedatectl show-timesync Show timesync details in key-value format
timedatectl show --property=NTP --value Show whether NTP is enabled

Set Time and Date

Disable NTP first when setting the clock manually.

Command Description
sudo timedatectl set-ntp false Turn off NTP before manual changes
sudo timedatectl set-time '2026-04-21 14:30:00' Set both date and time
sudo timedatectl set-time '14:30:00' Set the time only
sudo timedatectl set-time '2026-04-21' Set the date only
sudo timedatectl set-ntp true Re-enable NTP after manual changes

RTC and Hardware Clock

Control whether the RTC uses UTC or local time.

Command Description
timedatectl show --property=LocalRTC --value Check whether the RTC uses local time
sudo timedatectl set-local-rtc 0 Set the RTC to UTC
sudo timedatectl set-local-rtc 1 Set the RTC to local time
sudo timedatectl set-local-rtc 1 --adjust-system-clock Switch RTC mode and adjust the system clock
timedatectl Confirm the RTC in local TZ status line

Script-Friendly Output

Extract single values for shell scripts and automation.

Command Description
timedatectl show --property=Timezone --value Get the current time zone
timedatectl show --property=LocalRTC --value Get the RTC mode
timedatectl show --property=CanNTP --value Check whether NTP is supported
timedatectl show --property=NTP --value Check whether NTP is enabled
timedatectl show --property=NTPSynchronized --value Check whether the clock is synchronized

Remote and Container Use

Run timedatectl against another host or a local container.

Command Description
timedatectl -H user@server status Check time settings on a remote host over SSH
timedatectl -H root@server set-timezone UTC Change the remote host time zone
timedatectl -M mycontainer status Check time settings in a local container
timedatectl -M mycontainer show --property=Timezone --value Print the container time zone only

Quick Fixes

Use these when timedatectl does not behave as expected.

Command Description
sudo timedatectl set-ntp false Fix Automatic time synchronization is enabled before set-time
timedatectl timesync-status Check which server is syncing the clock
timedatectl --no-pager Print directly without opening a pager
sudo date -s '2026-04-21 14:30:00' Set the clock on non-systemd systems where timedatectl does not work

Related Guides

Use these articles for deeper explanations and step-by-step instructions.

Guide Description
How to Set or Change the Time Zone in Linux Change the system time zone with timedatectl, tzdata, or /etc/localtime
date Command in Linux Read and set the system clock with the traditional date command
journalctl Cheatsheet Inspect time sync and service logs from the systemd journal

每日一题-距离字典两次编辑以内的单词🟡

2026年4月22日 00:00

给你两个字符串数组 queries 和 dictionary 。数组中所有单词都只包含小写英文字母,且长度都相同。

一次 编辑 中,你可以从 queries 中选择一个单词,将任意一个字母修改成任何其他字母。从 queries 中找到所有满足以下条件的字符串:不超过 两次编辑内,字符串与 dictionary 中某个字符串相同。

请你返回 queries 中的单词列表,这些单词距离 dictionary 中的单词 编辑次数 不超过 两次 。单词返回的顺序需要与 queries 中原本顺序相同。

 

示例 1:

输入:queries = ["word","note","ants","wood"], dictionary = ["wood","joke","moat"]
输出:["word","note","wood"]
解释:
- 将 "word" 中的 'r' 换成 'o' ,得到 dictionary 中的单词 "wood" 。
- 将 "note" 中的 'n' 换成 'j' 且将 't' 换成 'k' ,得到 "joke" 。
- "ants" 需要超过 2 次编辑才能得到 dictionary 中的单词。
- "wood" 不需要修改(0 次编辑),就得到 dictionary 中相同的单词。
所以我们返回 ["word","note","wood"] 。

示例 2:

输入:queries = ["yes"], dictionary = ["not"]
输出:[]
解释:
"yes" 需要超过 2 次编辑才能得到 "not" 。
所以我们返回空数组。

 

提示:

  • 1 <= queries.length, dictionary.length <= 100
  • n == queries[i].length == dictionary[j].length
  • 1 <= n <= 100
  • 所有 queries[i] 和 dictionary[j] 都只包含小写英文字母。

暴力(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2022年10月30日 07:58

对于每个 $q = \textit{queries}[i]$,遍历 $\textit{dictionary}$ 中的字符串 $s$,判断 $q$ 和 $s$ 是否至多有两个位置上的字母不同。

class Solution:
    def twoEditWords(self, queries: List[str], dictionary: List[str]) -> List[str]:
        ans = []
        for q in queries:
            for s in dictionary:
                if sum(x != y for x, y in zip(q, s)) <= 2:
                    ans.append(q)
                    break
        return ans
class Solution {
    public List<String> twoEditWords(String[] queries, String[] dictionary) {
        List<String> ans = new ArrayList<>();
        for (String q : queries) {
            for (String s : dictionary) {
                int cnt = 0;
                for (int i = 0; i < s.length() && cnt <= 2; i++) {
                    if (q.charAt(i) != s.charAt(i)) {
                        cnt++;
                    }
                }
                if (cnt <= 2) {
                    ans.add(q);
                    break;
                }
            }
        }
        return ans;
    }
}
class Solution {
public:
    vector<string> twoEditWords(vector<string>& queries, vector<string>& dictionary) {
        vector<string> ans;
        for (auto& q : queries) {
            for (auto& s : dictionary) {
                int cnt = 0;
                for (int i = 0; i < s.size() && cnt <= 2; i++) {
                    if (q[i] != s[i]) {
                        cnt++;
                    }
                }
                if (cnt <= 2) {
                    ans.push_back(q);
                    break;
                }
            }
        }
        return ans;
    }
};
char** twoEditWords(char** queries, int queriesSize, char** dictionary, int dictionarySize, int* returnSize) {
    char** ans = malloc(queriesSize * sizeof(char*));
    *returnSize = 0;

    for (int i = 0; i < queriesSize; i++) {
        char* q = queries[i];
        for (int j = 0; j < dictionarySize; j++) {
            char* s = dictionary[j];
            int cnt = 0;
            for (int k = 0; s[k] && cnt <= 2; k++) {
                if (q[k] != s[k]) {
                    cnt++;
                }
            }
            if (cnt <= 2) {
                ans[(*returnSize)++] = q;
                break;
            }
        }
    }

    return ans;
}
func twoEditWords(queries, dictionary []string) (ans []string) {
for _, q := range queries {
next:
for _, s := range dictionary {
cnt := 0
for i := range s {
if q[i] != s[i] {
cnt++
if cnt > 2 {
continue next
}
}
}
ans = append(ans, q)
break
}
}
return
}
var twoEditWords = function(queries, dictionary) {
    const ans = [];
    for (const q of queries) {
        for (const s of dictionary) {
            let cnt = 0;
            for (let i = 0; i < s.length && cnt <= 2; i++) {
                if (q[i] !== s[i]) {
                    cnt++;
                }
            }
            if (cnt <= 2) {
                ans.push(q);
                break;
            }
        }
    }
    return ans;
};
impl Solution {
    pub fn two_edit_words(queries: Vec<String>, dictionary: Vec<String>) -> Vec<String> {
        let mut ans = vec![];
        for q in queries {
            for s in &dictionary {
                let mut cnt = 0;
                for (a, b) in q.bytes().zip(s.bytes()) {
                    if a != b {
                        cnt += 1;
                        if cnt > 2 {
                            break;
                        }
                    }
                }
                if cnt <= 2 {
                    ans.push(q);
                    break;
                }
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(qdn)$,其中 $q$ 是 $\textit{queries}$ 的长度,$d$ 是 $\textit{dictionary}$ 的长度,$n$ 是 $\textit{queries}[i]$ 的长度。题目保证所有字符串长度相等。
  • 空间复杂度:$\mathcal{O}(1)$。返回值不计入。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

随便做

作者 qian-li-ma-8
2022年10月30日 00:08

解题思路

代码

###python3

class Solution:
    def twoEditWords(self, queries: List[str], dictionary: List[str]) -> List[str]:
        def check(x,y):
            t=0
            for i in range(len(x)):
                if x[i]!=y[i]:
                    t+=1
            return t<=2
        covered=set()
        lst=[]
        for i in dictionary:
            for j in range(len(queries)):
                t=queries[j]
                if j not in covered and check(t,i):
                    covered.add(j)
                    lst.append(j)
        lst.sort()
        return [queries[i] for i in lst]
昨天 — 2026年4月21日技术

鼠标跟随倾斜动效

作者 Mh
2026年4月21日 23:28

前言

最近在 gsap 上看到一个有趣的动效(Cursor-driven perspective tilt),于是决定自己实现一下,下面将介绍实现的过程,希望你能喜欢。

202604111231046.gif

观察动效

  1. 卡片的倾斜角度会随着鼠标的移入在 x 轴和 y 轴上向内进行倾斜。
  2. 卡片上的文字是悬浮在卡片,给人一种悬空在空中的错觉。

技术拆解

要实现这种 3D 的效果,在 css 中你首先想到的是什么?

在 CSS 中有三个属性实现 3D 效果至关重要。它们分别是 perspective、transform-style: preserve-3dtransform: rotateX() rotateY()。下面将详细的介绍他们在 3D 动效中的作用。

  1. perspective (透视/视距):它是 3D 的灵魂,如果没有它,你看到的效果看起来只像是在平面上进行拉伸和缩放。你可以理解它是3维空间中的z轴,定义观察者距离 z = 0平面的距离。通常设定在父容器上,数值越小(如500px),透视畸变越强烈(近大远小极度明显);数值越大(如 2000px),效果越平缓。
  2. transform-style: preserve-3d :它的作用是告诉子元素(文字层)也要保持在 3D 空间中,这样我们看到的容器的内容是有深度的,同时也可以在侧面看到元素与元素之间的距离。当父元素设置了transform-style: preserve-3d 的时候,同时子元素需要设置 transform: translateZ()。
  3. transform: rotateX() rotateY():这个属性相信大家都知道,这也是这次动效能实现的关键。rotateX 控制卡片绕水平轴转动,rotateY 控制卡片绕垂直轴转动。

总结一下

如果把 CSS 3D 比作一场电影:

  • perspective 是摄影机,决定了画面的纵深感。
  • transform-style: preserve-3d 是舞台搭建,决定了演员(元素)能不能在台前幕后来回走动,而不是画在背景板上。
  • transform: rotate / translate 是演员的动作,决定了物体怎么摆放和移动。

效果展示

如果你已经理解了上面属性,相信实现效果只是时间的问题,下面我就提前剧透一下效果吧!同时在浏览器中为你演示各个的属性的具体效果,让你更加深刻的理解上面的属性。

试想一下,如果没有设置 perspective 属性会怎么样呢?

为了更好的演示,我会将卡片绕着它的y轴固定旋转30度。然后对比设置了 perspective 属性和没有设置 perspective 的效果如下。

image.png

在对比了设置 perspective 的作用后,接下来为你演示 transform-style: preserve-3d 的效果,为了更好的演示,接下来调整一下卡片在y轴的旋转角度为-80度,同时对子元素设置 transform: translateZ(50px); 将背景调整为白色,让文字和背景不会重合。对比效果如下:

image.png

从上面的效果可以看出,设置了 transform-style: preserve-3d 的文字和背景卡片是分离的,没有设置 transform-style: preserve-3d 的文字被拍扁在卡片上面。

注意事项: 当容器设置了 transform-style: preserve-3d; 的时候,不能再设置 overflow: hidden; 不然 transform-style: preserve-3d; 不会生效。

经过上面的对比可以帮助我们更好的理解每个属性在具体场景中的使用,下面就使用 vue3 去实现具体的功能。

代码拆解

完整代码

<template>
  <div class="container">
    <div 
      class="card"
      ref="cardRef"
      :style="cardStyle"
      @mousemove="handleMouseMove"
      @mouseleave="handleMouseLeave"
    >
      <div class="content">
        <span>ANIMATION</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue';

const cardRef = ref(null);

// 存储旋转角度
const transform = reactive({
  rotateX: 0,
  rotateY: 0
});

// 计算最终的 CSS 样式
const cardStyle = computed(() => {
  const scale = 1;
  return {
    transform: `rotateX(${transform.rotateX}deg) rotateY(${transform.rotateY}deg)`,
    transition: 'transform 0.5s ease-out'
  };
});

const handleMouseMove = (e) => {
  if (!cardRef.value) return;

  const rect = cardRef.value.getBoundingClientRect();
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;
  
  // 计算鼠标距离中心点的偏移量 (-1 到 1)
  const percentX = (e.clientX - centerX) / (rect.width / 2);
  const percentY = (e.clientY - centerY) / (rect.height / 2);

  const deg = 25; // 最大旋转角度
  transform.rotateY = percentX * deg;
  transform.rotateX = -percentY * deg; // 取反是因为鼠标向上移动时图片应向下倾斜
};

const handleMouseLeave = () => {
  transform.rotateX = 0;
  transform.rotateY = 0;
};
</script>

<style scoped>
.container {
  /* 3D 透视的关键 */
  perspective: 1000px; 
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100vh;
  background-color: #0f0f0f;
}

.card {
  position: relative;
  width: 320px;
  height: 200px;
  background: linear-gradient(135deg, #6ee7b7, #3b82f6);
  border-radius: 20px;
  transform-style: preserve-3d;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  /* overflow: hidden; */
}

.content {
  font-family: 'Arial Black', sans-serif;
  font-size: 2.5rem;
  color: #000;
  /* 让文字在 3D 空间悬浮 */
  transform: translateZ(50px); 
  pointer-events: none;
}
</style>

简要分析:

  1. 绑定事件:鼠标移入卡片触发 mousemove 事件,设置卡片旋转。鼠标移除触发 mouseleave 事件将旋转的角度置为0。
  2. 样式动态计算:动态绑定 style,通过计算属性实时更新旋转的角度。
  3. 计算偏移量: 这里主要利用鼠标当前的位置减去卡片中心点计算出偏移距离,然后再除以卡片宽高的一半,等到一个-1到1的偏移值。
  4. 角度映射:通过得到的偏移值乘以 deg (25度),刚好可以映射到对应的角度,比如鼠标移动到最左边,卡片正好偏转 -25度。

优化补充

下面是一些优化的建议,有兴趣的同学可以自己实现一下:

  1. 增加光影变化,跟随鼠标移动的卡片增加渐变层的光影,让整体更加真实。
  2. mousemove 在移动端不支持,增加移动端的支持。

Claude Code 源码分析 — Tool/MCP/Skill 可扩展工具系统

作者 幺风
2026年4月21日 20:54

Claude Code 源码分析系列文章:

本文基于项目实际源码,深入分析 Claude Code 的工具系统架构。涵盖 Tool 类型定义、工具注册与发现、执行管道、并发控制、MCP 协议集成、Skill/Command 体系及 SkillTool 模型驱动调用的完整链路。


一、架构总览

Claude Code 的工具系统是一个三层可扩展架构:内置工具 (Built-in Tools) 提供文件读写、Bash 执行等基础能力;MCP 工具 通过标准协议接入外部服务;Skill/Command 提供用户可定义的高级行为模板。三者通过统一的 Tool 接口抽象,共享同一套注册、发现、权限、执行管道。

flowchart TD
    subgraph 定义层
        direction LR
        A["Tool&lt;Input, Output, P&gt;<br/>src/Tool.ts"]
        B["buildTool() 工厂<br/>应用默认值"]
    end

    subgraph 注册层
        direction LR
        C["getAllBaseTools()<br/>61+ 内置工具"]
        D["MCP Client<br/>mcp__server__tool"]
        E["Skill Loader<br/>managed/user/project"]
    end

    subgraph 过滤层
        direction LR
        F["filterToolsByDenyRules()"]
        G["getTools() — isEnabled 过滤"]
        H["assembleToolPool() — 合并去重"]
    end

    subgraph 执行层
        direction LR
        I["runToolUse() — 入口"]
        J["checkPermissionsAndCallTool()<br/>9 阶段管道"]
        K["StreamingToolExecutor<br/>并发 + 有序产出"]
    end

    A --> B
    B --> C
    D --> H
    E --> H
    C --> F --> G --> H
    H --> I --> J
    J --> K

核心设计原则:

  1. 统一接口:所有工具(内置 / MCP / Skill)都实现同一个 Tool 泛型接口
  2. 权限前置:工具执行前必须通过多层权限检查(配置规则 → Hooks → 用户确认)
  3. 并发安全:通过 isConcurrencySafe 标记控制工具并发策略
  4. 可扩展:MCP 协议和 Skill 目录允许用户自行扩展工具集

二、Tool 类型系统

源码位置:src/Tool.ts

Tool 类型系统是整个工具架构的基石,由四个核心类型组成。

2.1 Tool<Input, Output, P> — 核心泛型

Tool 是所有工具的统一接口,定义了约 40 个属性和方法:

// src/Tool.ts:362
export type Tool<
  Input extends AnyObject = AnyObject,
  Output = unknown,
  P extends ToolProgressData = ToolProgressData,
> = {
  // ========= 身份标识 =========
  name: string                                                 // 唯一名称,如 "Bash", "mcp__ide__getDiagnostics"
  aliases?: string[]                                           // 别名,兼容旧名称
  userFacingName: (input: Input) => string                     // UI 显示名
  userFacingNameBackgroundColor?(                              // UI 显示名称的颜色
    input: Partial<z.infer<Input>> | undefined,
  ): keyof Theme | undefined

  // ========= Schema =========
  inputSchema: Input                                           // Zod schema,用于输入验证
  outputSchema?: z.ZodType<unknown>                            // 输出类型(可选)

  // ========= 元信息 =========
  description(
    input: z.infer<Input>,
    options: {
      isNonInteractiveSession: boolean
      toolPermissionContext: ToolPermissionContext
      tools: Tools
    },
  ): Promise<string>                                           // 工具描述(送入 system prompt)
  prompt(options: {
    getToolPermissionContext: () => Promise<ToolPermissionContext>
    tools: Tools
    agents: AgentDefinition[]
    allowedAgentTypes?: string[]
  }): Promise<string>                                          // 使用提示
  searchHint?: string                                          // 工具搜索的额外匹配词

  // ========= 能力标记 =========
  isEnabled(): boolean                                         // 当前环境是否可用
  isReadOnly(): boolean                                        // 是否只读(影响权限策略)
  isDestructive?(): boolean                                    // 是否破坏性操作
  isConcurrencySafe(input: z.infer<Input>): boolean            // 是否可并发执行
  isSearchOrReadCommand?(input: z.infer<Input>): {             // 是否查询类工具
    isSearch: boolean
    isRead: boolean
    isList?: boolean
  }                                                             
  isOpenWorld?(input: z.infer<Input>): boolean                 // 输入是否来自外部

  // ========= 执行 =========
  call(
    args: z.infer<Input>,
    context: ToolUseContext,
    canUseTool: CanUseToolFn,
    parentMessage: AssistantMessage,
    onProgress?: ToolCallProgress<P>,
  ): Promise<ToolResult<Output>>              // 核心执行函数

  // ========= 权限 =========
  async checkPermissions(
    input: z.infer<Input>,
    context: ToolUseContext,
  ): Promise<PermissionResult>                  // 权限检查

  validateInput?(
    input: Input,
    context: ToolUseContext,
  ): Promise<ValidationResult>                  // 业务级输入校验

  // ========= 中断策略 =========
  interruptBehavior?(): 'cancel' | 'block'

  // ========= UI 渲染 =========
  renderToolUseMessage(props): React.ReactNode
  renderToolResultMessage(props): React.ReactNode
  renderToolUseProgressMessage?(props): React.ReactNode

  // ========= 高级特性 =========
  maxResultSizeChars?: number              // 结果截断阈值
  strict?: boolean                         // 严格模式
  isMcp?: boolean                          // 标记为 MCP 工具
  isLsp?: boolean                          // 标记为 LSP 工具
  shouldDefer?: boolean                    // 延迟加载(工具搜索时才启用)
  alwaysLoad?: boolean                     // 始终加载
  mcpInfo?: { serverName: string; toolName: string }  // MCP 来源信息
  maxResultSizeChars: number               // 工具最长输出,超出原始内容存储到本地文件,返回特定提示词及压缩后的结果。默认50_000

  backfillObservableInput?(                // 对输入做浅拷贝供 hooks 观察
    input: Input,
  ): Record<string, unknown> | undefined

  // ...
}

2.2 ToolResult — 工具返回值

工具执行完成后返回的统一结构:

// src/Tool.ts:321-336
export type ToolResult<T> = {
  data: T                          // 实际输出数据
  newMessages?: (                  // 注入额外消息到对话流
    | UserMessage
    | AssistantMessage
    | AttachmentMessage
    | SystemMessage
  )[]
  contextModifier?: (              // 修改后续工具的上下文
    context: ToolUseContext,
  ) => ToolUseContext
  mcpMeta?: {                      // MCP 元数据
    _meta?: Record<string, unknown>
    structuredContent?: Record<string, unknown>
  }
}

contextModifier 是一个精妙的设计:工具可以通过返回值修改后续执行的上下文。例如 EnterPlanModeTool 执行后可通过 contextModifier 切换权限模式。

2.3 ToolUseContext — 执行上下文

// src/Tool.ts:158
export type ToolUseContext = {
  options: {
    commands: Command[]           // 可用命令列表
    tools: Tools                  // 可用工具列表
    mcpClients: MCPServerConnection[]   // MCP 连接
    mcpResources: Record<string, ServerResource[]>
    mainLoopModel: string         // 主循环模型
    thinkingConfig: ThinkingConfig  // 思考模式
    agentDefinitions: AgentDefinitionsResult
    maxBudgetUsd?: number
    querySource?: QuerySource    
    refreshTools?: () => Tools    // 动态刷新工具列表
    // ...
  }
  abortController: AbortController     // 中止控制器
  readFileState: FileStateCache        // 文件状态缓存
  getAppState(): AppState              // 读取全局状态
  setAppState(f: (prev: AppState) => AppState): void  // 修改全局状态
  requestPrompt?: PermissionRequestFn  // 请求用户权限
  // ...
}

2.4 ToolPermissionContext — 权限上下文

// src/Tool.ts:123-138
export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode                    // 'default' | 'plan' | ...
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  alwaysAllowRules: ToolPermissionRulesBySource   // 白名单规则
  alwaysDenyRules: ToolPermissionRulesBySource    // 黑名单规则
  alwaysAskRules: ToolPermissionRulesBySource     // 始终询问规则
  isBypassPermissionsModeAvailable: boolean
  isAutoModeAvailable?: boolean
  strippedDangerousRules?: ToolPermissionRulesBySource
  shouldAvoidPermissionPrompts?: boolean
  awaitAutomatedChecksBeforeDialog?: boolean
  prePlanMode?: PermissionMode
}>

DeepImmutable 保证权限上下文在传递过程中不可被意外修改,是安全性的重要保障。

2.5 buildTool() — 工厂函数与默认值

// src/Tool.ts (TOOL_DEFAULTS)
const TOOL_DEFAULTS = {
  isEnabled: () => true,
  isConcurrencySafe: () => false,    // 默认不可并发
  isReadOnly: () => false,
  isDestructive: () => false,
  isOpenWorld: () => false,
  // ...
}
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
  return {
    ...TOOL_DEFAULTS,
    userFacingName: () => def.name,
    ...def,
  } as BuiltTool<D>
}

buildTool() 将用户定义与默认值合并,确保每个工具都有完整的接口实现。工具作者只需关注核心逻辑(nameinputSchemacall()等),其余属性自动填充。

2.6 工具查找辅助函数

// src/Tool.ts:348
export function toolMatchesName(
  tool: { name: string; aliases?: string[] },
  name: string,
): boolean {
  return tool.name === name || (tool.aliases?.includes(name) ?? false)
}

export function findToolByName(
  tools: Tools,
  name: string,
): Tool | undefined {
  return tools.find(t => toolMatchesName(t, name))
}

别名机制允许工具改名时保持向后兼容(如旧版工具名 → 新名称映射)。


三、工具注册与发现

源码位置:src/tools.ts

工具注册是一条多级过滤管道:从全量工具列表开始,逐步筛选出当前环境可用的工具集。

3.1 getAllBaseTools() — 全量工具清单

// src/tools.ts:191
export function getAllBaseTools(): Tools {
  return [
    AgentTool,
    TaskOutputTool,
    BashTool,
    // 嵌入式搜索工具可用时跳过 Glob/Grep
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
    ExitPlanModeV2Tool,
    FileReadTool,
    FileEditTool,
    FileWriteTool,
    NotebookEditTool,
    WebFetchTool,
    TodoWriteTool,
    WebSearchTool,
    TaskStopTool,
    AskUserQuestionTool,
    SkillTool,
    EnterPlanModeTool,
    // 条件加载 —— 基于环境变量
    ...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
    ...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
    ...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
    // 条件加载 —— 基于 feature flag
    ...(WebBrowserTool ? [WebBrowserTool] : []),
    ...(isTodoV2Enabled()
      ? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool]
      : []),
    ...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
    ...(SleepTool ? [SleepTool] : []),
    ...cronTools,
    ...(RemoteTriggerTool ? [RemoteTriggerTool] : []),
    BriefTool,
    // 测试专用
    ...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
    ListMcpResourcesTool,
    ReadMcpResourceTool,
    ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
  ]
}

工具按以下维度条件加载:

条件类型 示例 机制
Feature Flag WebBrowserToolSleepTool feature('FLAG_NAME') + 运行时 require()
环境变量 ConfigToolTungstenTool process.env.USER_TYPE === 'ant'
运行时检测 GlobToolGrepToolTaskCreateTool hasEmbeddedSearchTools()isTodoV2Enabled()

3.2 过滤管道

flowchart LR
    A["getAllBaseTools()<br/>30+ 工具"] --> B["filterToolsByDenyRules()<br/>配置黑名单"]
    B --> C["isEnabled() 检查<br/>环境可用性"]
    C --> D["assembleToolPool()<br/>合并 MCP 工具"]

filterToolsByDenyRules() — 配置级黑名单

// src/tools.ts:260
export function filterToolsByDenyRules<T extends {
  name: string
  mcpInfo?: { serverName: string; toolName: string }
}>(tools: readonly T[], permissionContext: ToolPermissionContext): T[] {
  return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}

该函数不仅匹配工具精确名称,还支持 MCP 前缀规则:配置 mcp__server 可以一次性屏蔽整个 MCP Server 的所有工具。

getTools() — 完整过滤链

// src/tools.ts:269-325
export const getTools = (permissionContext: ToolPermissionContext): Tools => {
  // 1. 简单模式:只保留 Bash/Read/Edit
  if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
    const simpleTools: Tool[] = [BashTool, FileReadTool, FileEditTool]
    return filterToolsByDenyRules(simpleTools, permissionContext)
  }

  // 2. 排除特殊工具(MCP Resources、Synthetic Output)
  const specialTools = new Set([
    ListMcpResourcesTool.name,
    ReadMcpResourceTool.name,
    SYNTHETIC_OUTPUT_TOOL_NAME,
  ])
  const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))

  // 3. 应用黑名单过滤
  let allowedTools = filterToolsByDenyRules(tools, permissionContext)

  // 4. REPL 模式下隐藏被 REPL 包装的原始工具
  if (isReplModeEnabled()) {
    const replEnabled = allowedTools.some(
      tool => toolMatchesName(tool, REPL_TOOL_NAME),
    )
    if (replEnabled) {
      allowedTools = allowedTools.filter(
        tool => !REPL_ONLY_TOOLS.has(tool.name),
      )
    }
  }

  // 5. isEnabled() 检查
  const isEnabled = allowedTools.map(_ => _.isEnabled())
  return allowedTools.filter((_, i) => isEnabled[i])
  // 这段代码给我看懵了,不知道是不是AI写的,可以简写为 allowedTools.filter(_ => _.isEnabled())
}

3.3 assembleToolPool() — 内置工具与 MCP 工具合并

// src/tools.ts:343-365
export function assembleToolPool(
  permissionContext: ToolPermissionContext,
  mcpTools: Tools,
): Tools {
  const builtInTools = getTools(permissionContext)
  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)

  // 分区排序:内置工具在前,MCP 工具在后
  // 保证 prompt cache 稳定性
  const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
  return uniqBy(
    [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
    'name',
  )
}

排序策略的关键考量:API 端对 system prompt 做了 cache 分段(claude_code_system_cache_policy),将 cache 断点放在最后一个内置工具之后。如果用简单的全局排序,MCP 工具会穿插到内置工具之间,导致每次 MCP 工具变化都使所有下游 cache key 失效。分区排序 + uniqBy 保证:

  1. 内置工具始终形成稳定的前缀块
  2. 同名冲突时内置工具优先(uniqBy 保留第一个)
  3. MCP 工具在后缀块中独立排序

四、工具执行管道

源码位置:src/services/tools/toolExecution.ts

工具执行是一条 9 阶段的异步管道,从模型返回的 tool_use 块开始,到工具结果注入对话流结束。

4.1 runToolUse() — 管道入口

// src/services/tools/toolExecution.ts:337
export async function* runToolUse(
  toolUse: ToolUseBlock,
  assistantMessage: AssistantMessage,
  canUseTool: CanUseToolFn,
  toolUseContext: ToolUseContext,
): AsyncGenerator<Message> {
  const toolName = toolUse.name

  // 1. 在当前工具列表中查找
  let tool = findToolByName(toolUseContext.options.tools, toolName)

  // 2. 回退:从全量工具列表中按别名查找
  if (!tool) {
    const fallbackTool = findToolByName(getAllBaseTools(), toolName)
    if (fallbackTool && fallbackTool.aliases?.includes(toolName)) {
      tool = fallbackTool
    }
  }

  // 3. 工具未找到 → 返回错误消息
  if (!tool) {
    yield createToolResultMessage(/* error: tool not found */)
    return
  }

  // 4. 中止检查
  if (toolUseContext.abortController.signal.aborted) {
    yield createToolResultMessage(/* error: aborted */)
    return
  }

  // 5. 委托给 streamedCheckPermissionsAndCallTool
  for await (const update of streamedCheckPermissionsAndCallTool(
      tool,
      toolUse.id,
      toolInput,
      toolUseContext,
      canUseTool,
      assistantMessage,
      messageId,
      requestId,
      mcpServerType,
      mcpServerBaseUrl,
    )) {
      yield update
    }
}

别名回退机制是一个防御性设计:即使工具被重命名或从当前列表中移除,模型仍可能使用旧名称调用。通过 aliases 字段实现平滑迁移。

4.2 checkPermissionsAndCallTool() — 9 阶段管道

flowchart TD
    A["① Zod safeParse<br/>类型校验"] --> B["② validateInput<br/>业务校验"]
    B --> C["③ 推测性分类<br/>(仅 Bash)"]
    C --> D["④ 剥离内部字段<br/>(安全防御)"]
    D --> E["⑤ backfillObservableInput<br/>浅拷贝供 hooks"]
    E --> F["⑥ PreToolUse Hooks<br/>外部拦截"]
    F --> G["⑦ resolveHookPermission<br/>综合决策"]
    G --> H{"权限通过?"}
    H -->|是| I["⑧ tool.call()<br/>实际执行"]
    H -->|否| J["⑨ 权限拒绝处理<br/>PermissionDenied Hooks"]
    I --> K["PostToolUse Hooks"]

    style A fill:#e8f5e9
    style F fill:#fff3e0
    style I fill:#e1f5fe
    style J fill:#ffebee

阶段 ①:Zod Schema 校验

// src/services/tools/toolExecution.ts:615
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  // 返回格式化的 Zod 错误信息
  yield createToolResultMessage(/* schema validation error */)
  return
}

所有工具的 inputSchema 都是 Zod schema,在执行前自动校验输入类型。模型生成的 JSON 如果不符合 schema(如缺少必填字段、类型不匹配),会直接返回错误消息给模型。

阶段 ②:业务级校验

// src/services/tools/toolExecution.ts:683
const validationError = tool.validateInput?.(parsedInput.data, toolUseContext)
if (validationError) {
  yield createToolResultMessage(/* validation error */)
  return
}

validateInput 提供了 schema 之外的业务校验。例如 BashTool 可以在这里检查命令是否包含危险操作,FileEditTool 可以验证文件路径是否在工作目录范围内。

阶段 ③:推测性分类(Bash 专用)

// src/services/tools/toolExecution.ts:740-752
// 仅对 Bash 工具启动并行分类检查
const speculativeResult = startSpeculativeClassifierCheck(/*...*/)

在等待用户权限确认的同时,对 Bash 命令预分类(安全/危险),减少用户感知延迟。

阶段 ④:内部字段剥离(安全防御)

// src/services/tools/toolExecution.ts:761-773
// Defense-in-depth: 剥离 _simulatedSedEdit 等内部字段
// 防止模型注入内部控制参数

这是一个纵深防御措施:即使模型在输入中包含了内部控制字段,也会在执行前被清除。

阶段 ⑤:backfillObservableInput

// src/services/tools/toolExecution.ts:784-793
const observableInput = tool.backfillObservableInput?.(parsedInput.data)

创建输入的浅拷贝,供 PreToolUse hooks 观察。这样 hooks 可以读取完整的工具输入,但无法修改原始数据。

Hooks机制参考官方文档

阶段 ⑥:PreToolUse Hooks

// src/services/tools/toolExecution.ts:800-862
for await (const hookResult of runPreToolUseHooks(/*...*/)) {
  // hookResult 可能包含:
  // - hookPermissionResult: 权限决策
  // - hookUpdatedInput: 修改后的输入
  // - preventContinuation: 阻止继续
  // - stop: 停止
  // - additionalContext: 附加上下文
}

PreToolUse hooks 是用户自定义的 shell 脚本,在工具执行前运行。它们可以:

  • 修改输入:例如在文件路径前添加前缀
  • 注入上下文:向对话流添加额外信息
  • 阻止执行:返回权限拒绝
  • 完全停止:取消整个工具调用

阶段 ⑦:权限综合决策

// src/services/tools/toolExecution.ts:921-931
const resolved = await resolveHookPermissionDecision(
  hookPermissionResult,
  tool,
  processedInput,
  toolUseContext,
  canUseTool,
  assistantMessage,
  toolUseID,
)
const permissionDecision = resolved.decision
processedInput = resolved.input

综合三个来源的权限判断:Hook 的决策、推测性分类结果、配置文件规则,得出最终的权限决定。

用户授权也是在此过程中拉起的

claude-code源码分析-Tool-MCP-Skill可扩展工具系统_2026-04-20-17-23-15.png

阶段 ⑧/⑨:执行或拒绝

权限拒绝时创建错误消息,并触发 PermissionDenied hooks。权限通过后调用 tool.call(),执行完成后运行 PostToolUse hooks。

如果最终获取的权限不为allow,则会构建一些消息告诉模型并返回,比如上诉截图里我选择了No,则:

// src/services/tools/toolExecution.ts:1064-1071
resultingMessages.push({
  message: createUserMessage({
    content: messageContent, // "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."
    imagePasteIds: rejectImageIds,
    toolUseResult: `Error: ${errorMessage}`,
    sourceToolAssistantUUID: assistantMessage.uuid,
  }),
})

模型收到消息后则会走对话中止流程,参考核心对话循环

// 执行路径 (src/services/tools/toolExecution.ts:1207)
const result = await tool.call(
  callInput,
  {
    ...toolUseContext,
    toolUseId: toolUseID,
    userModified: permissionDecision.userModified ?? false,
  },
  canUseTool,
  assistantMessage,
  progress => { // 更新工具执行过程
    onToolProgress({
      toolUseID: progress.toolUseID,
      data: progress.data,
    })
  },
)

// 注入 newMessages
resultingMessages.push({
  message: createUserMessage({
    content: contentBlocks,
    imagePasteIds: allowImageIds,
    toolUseResult:
      toolUseContext.agentId && !toolUseContext.preserveToolUseResults
        ? undefined
        : toolUseResult,
    mcpMeta: toolUseContext.agentId ? undefined : mcpMeta,
    sourceToolAssistantUUID: assistantMessage.uuid,
  }),
  // 处理 contextModifier,修改后续上下文
  contextModifier: toolContextModifier
    ? {
        toolUseID: toolUseID,
        modifyContext: toolContextModifier,
      }
    : undefined,
})

// 运行 PostToolUse hooks
for await (const hookResult of runPostToolUseHooks(/*...*/)) {
  //...
}

五、StreamingToolExecutor 并发执行

源码位置:src/services/tools/StreamingToolExecutor.ts

当模型在一个响应中返回多个 tool_use 块时,StreamingToolExecutor 负责决定哪些工具可以并行执行、哪些必须串行排队。

5.1 TrackedTool 状态模型

TrackedTool通过执行工具的isConcurrencySafe获得当前工具是否支持并行。

// src/services/tools/StreamingToolExecutor.ts:21-32
type TrackedTool = {
  id: string                         // tool_use block ID
  block: ToolUseBlock                // 工具调用块
  assistantMessage: AssistantMessage  // 所属的 assistant 消息
  status: ToolStatus                 // 'pending' | 'executing' | 'done' | 'error'
  isConcurrencySafe: boolean         // 并发安全标记
  promise?: Promise<void>            // 执行 Promise
  results?: Message[]                // 执行结果
  pendingProgress: Message[]         // 进度消息缓冲
  contextModifiers?: Array<          // 上下文修改器
    (context: ToolUseContext) => ToolUseContext
  >
}

5.2 并发策略:canExecuteTool()

// src/services/tools/StreamingToolExecutor.ts:129-135
private canExecuteTool(isConcurrencySafe: boolean): boolean {
  // 当前执行中的工具,支持并发的工具,即使执行中的工具不为0,也返回true;串行时仅当前执行中的工具为0时返回true;
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0 ||
    (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
  )
}
// 此函数会在每次executeTool结束后调用,直到队列中所有tools都不为`queued`
private async processQueue(): Promise<void> {
  for (const tool of this.tools) {
    // 如果当前工具非排队,直接轮询
    if (tool.status !== 'queued') continue
    if (this.canExecuteTool(tool.isConcurrencySafe)) {
      // 执行工具
      await this.executeTool(tool)
    } else {
      if (!tool.isConcurrencySafe) break
    }
  }
}
private async executeTool(tool: TrackedTool): Promise<void> {
  // ...
  const promise = collectResults()
  tool.promise = promise

  // 每个工具执行结束都再次调用`processQueue`
  void promise.finally(() => {
    void this.processQueue()
  })
}


Claude Code的并发设计并不复杂,支持并行的工具会在第一次执行的时候全部推入processQueue执行队列,不支持并行的工具会在最后一个并行工具执行完后再推入processQueue队列,直至所有工具执行完。

  • 典型的并发安全工具:GlobToolGrepToolFileReadToolWebSearchTool
  • 典型的非并发安全工具:BashToolFileEditToolFileWriteTool

5.3 有序结果产出

即使工具并发执行,结果仍然按原始 tool_use 块的顺序产出:

// StreamingToolExecutor 的 processQueue 逻辑
// tools 数组维持原始顺序
// 每个 tool 执行完成后检查是否可以产出结果
// 只有前序工具都完成后,当前工具的结果才会被 yield

这保证了对话流中的消息顺序与模型生成的工具调用顺序一致。

5.4 兄弟中止机制

// src/services/tools/StreamingToolExecutor.ts:48
// siblingAbortController: 当一个 Bash 工具出错时,中止所有兄弟工具

private getAbortReason(tool: TrackedTool): string {
  // 检查中止原因:
  // - 用户中断 (user_interrupted)
  // - 兄弟工具错误 (sibling_error)
  // - 流式回退 (streaming_fallback)
}

当同一批次中的一个 BashTool 执行失败时,其他尚未完成的工具会被中止。中止原因被记录到 createSyntheticErrorMessage() 中,产出一个合成的错误消息告知模型。

// src/services/tools/StreamingToolExecutor.ts:153
private createSyntheticErrorMessage(
  toolUseId: string,
    reason: 'sibling_error' | 'user_interrupted' | 'streaming_fallback',
    assistantMessage: AssistantMessage,
): Message {
  // 生成描述性错误消息,让模型理解为什么工具被取消
}

六、MCP 系统

源码位置:src/services/mcp/client.tssrc/tools/MCPTool/MCPTool.ts

MCP (Model Context Protocol) 是 Anthropic 定义的标准协议,允许外部服务向 Claude 提供工具、资源和提示。Claude Code 实现了完整的 MCP 客户端。

6.1 四种传输协议

// src/services/mcp/client.ts
// 支持四种 MCP 传输方式
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
// WebSocketTransport 也被支持
import { WebSocketTransport } from '../../utils/mcpWebSocketTransport.js'

传输方式 适用场景 配置方式
stdio 本地进程 command + args
SSE HTTP 长连接 url (带 /sse 后缀)
StreamableHTTP HTTP 流式 url (非 /sse)
WebSocket 全双工 url (ws:// 或 wss://)

6.2 MCPTool 模板 — 工具包装

MCP工具也是通过buildTool进行包装,方便实现

// src/tools/MCPTool/MCPTool.ts:27-77
export const MCPTool = buildTool({
  isMcp: true,
  // 以下属性在 mcpClient.ts 中被运行时覆盖
  name: 'mcp',                           // → mcp__<server>__<tool>
  async description() { return DESCRIPTION },  // → MCP 服务端描述
  async prompt() { return PROMPT },
  get inputSchema() {
    return inputSchema()  // z.object({}).passthrough()
  },
  async call() { return { data: '' } },       // → 实际 MCP 调用
  async checkPermissions(): Promise<PermissionResult> {
    return { behavior: 'passthrough', message: 'MCPTool requires permission.' }
  },
  maxResultSizeChars: 100_000,
  userFacingName: () => 'mcp',                 // → 实际工具名
  //...
})

MCPTool 本身是一个空壳模板。关键属性(namedescriptioncallinputSchema)在 mcpClient.tsconnectToServer() 中被运行时覆盖。

6.3 工具命名约定

// src/services/mcp/mcpStringUtils.ts:39
export function getMcpPrefix(serverName: string): string {
  return `mcp__${normalizeNameForMCP(serverName)}__`
}
export function buildMcpToolName(serverName: string, toolName: string): string {
  return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`
}

命名格式:mcp__<serverName>__<toolName>

例如:

  • mcp__ide__getDiagnostics — IDE MCP Server 的诊断工具
  • mcp__filesystem__readFile — 文件系统 MCP Server 的读文件工具

这种命名方式使得 filterToolsByDenyRules() 可以通过前缀 mcp__ide 一次性屏蔽整个 Server 的所有工具。

6.4 连接管理

flowchart TD
    A["getMcpToolsCommandsAndResources<br/>(useManageMCPConnections.ts:894)"] --> B["connectToServer<br/>(mcp/client.ts:596)"]
    B --> C["onConnectionAttempt<br/>更新 appState<br/>(useManageMCPConnections.ts:310)"]
    C --> D["useMergedTools<br/>(REPL.tsx:1034)"]
    D --> E["assembleToolPool<br/>合并工具"]

当Claude Code启动后就会进行MCP的连接,连接成功后通过更新appState使REPL进行工具合并。

核心连接逻辑在connectToServer

// src/services/mcp/client.ts:596
// memoize 确保同一个 server 只建立一次连接
export const connectToServer = memoize(async function connectToServer(
  serverConfig: MCPServerConfig,
  // ...
): Promise<MCPServerConnection> {
  // 1. 选择传输方式
  // 2. 建立连接
  // 3. 获取工具列表
  // 4. 为每个工具创建 MCPTool 实例(覆盖模板属性)
  // 5. 返回连接对象
})

关键常量:

  • DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000(约 27.8 小时)— MCP 工具默认超时
  • MAX_MCP_DESCRIPTION_LENGTH = 2048 — 描述截断阈值

6.5 MCP 工具与内置工具的合并

MCP 工具在 assembleToolPool() 中与内置工具合并(见 3.3 节)。

这意味着如果一个 MCP 工具的名称与内置工具冲突,内置工具会胜出。


七、Command 命令系统

源码位置:src/types/command.tssrc/commands.tssrc/utils/processUserInput/processSlashCommand.tsx

Command(命令)是 Claude Code 的用户交互入口,用户通过在终端输入 /command 触发各种操作。Command 系统独立于 Tool 系统——Tool 由模型调用,而 Command 由用户直接调用。两者通过 SkillTool 产生交集:Command既可通过 / 触发,也可被模型通过 SkillTool 调用。我们经常说的Skill也是Command的一种实现。

7.1 Command 类型体系

// src/types/command.ts:205
export type Command = CommandBase &
  (PromptCommand | LocalCommand | LocalJSXCommand)

Command 由公共基类 CommandBase 和三种具体类型的联合组成。

CommandBase — 公共属性

// src/types/command.ts:175-203
export type CommandBase = {
  availability?: CommandAvailability[]    // 可用环境('claude-ai' | 'console')
  description: string                     // 命令描述
  isEnabled?: () => boolean               // 运行时启用条件(默认 true)
  isHidden?: boolean                      // 是否从补全/帮助中隐藏
  name: string                            // 唯一标识(如 'clear'、'config')
  aliases?: string[]                      // 别名(如 clear 的别名 ['reset', 'new'])
  whenToUse?: string                      // 模型调用时机描述
  disableModelInvocation?: boolean        // 是否禁止模型调用
  userInvocable?: boolean                 // 用户是否可通过 / 触发
  loadedFrom?: LoadedFrom                 // 来源标记
  immediate?: boolean                     // 是否立即执行(不等待队列)
  // ...
}

LoadedFrom — 来源标记

// src/skills/loadSkillsDir.ts:67-74
type LoadedFrom =
  | 'commands_DEPRECATED'  // 旧版 .claude/commands/
  | 'skills'              // .claude/skills/
  | 'plugin'              // 插件目录
  | 'managed'             // managed skills (系统管理)
  | 'bundled'             // 内置打包
  | 'mcp'                 // MCP 协议提供

三种命令类型

类型 触发方式 返回值 典型示例
LocalCommand 用户 /command 文本结果 /clear/compact/files
LocalJSXCommand 用户 /command React 组件 /config/status/help
PromptCommand 用户 /skill 或模型 SkillTool 注入对话流 用户定义的 .md 技能

LocalCommand — 纯文本命令:

// src/types/command.ts:74-78
type LocalCommand = {
  type: 'local'
  supportsNonInteractive: boolean          // 是否支持非交互模式
  load: () => Promise<LocalCommandModule>  // 懒加载实现
}

// LocalCommandModule 接口
type LocalCommandModule = {
  call: (args: string, context: LocalJSXCommandContext) => Promise<LocalCommandResult>
}

// 返回值三种形态
type LocalCommandResult =
  | { type: 'text'; value: string }        // 普通文本输出
  | { type: 'compact'; compactionResult: CompactionResult }  // 压缩操作
  | { type: 'skip' }                       // 静默执行

LocalJSXCommand — UI 渲染命令:

// src/types/command.ts:144
type LocalJSXCommand = {
  type: 'local-jsx'
  load: () => Promise<LocalJSXCommandModule>
}

// 调用签名
type LocalJSXCommandCall = (
  onDone: LocalJSXCommandOnDone,           // 完成回调
  context: ToolUseContext & LocalJSXCommandContext,
  args: string,
) => Promise<React.ReactNode>              // 返回 JSX 渲染到终端

onDone 回调控制命令完成后的行为:

// src/types/command.ts:117-126
type LocalJSXCommandOnDone = (
  result?: string,
  options?: {
    display?: 'skip' | 'system' | 'user'  // 结果展示方式
    shouldQuery?: boolean                   // 是否继续查询模型
    metaMessages?: string[]                 // 注入隐藏消息
    nextInput?: string                      // 下一轮自动输入
    submitNextInput?: boolean               // 是否自动提交
  },
) => void

PromptCommand — 提示词命令(即 Skill):

// src/types/command.ts:25-57
type PromptCommand = {
  type: 'prompt'
  progressMessage: string                  // 加载时的进度消息
  contentLength: number                    // 内容长度(用于 token 估算)
  argNames?: string[]                      // 参数名列表
  allowedTools?: string[]                  // 限制可用工具
  model?: string                           // 指定模型
  context?: 'inline' | 'fork'             // 执行上下文 inline为当前对话执行  fork为子agent里执行
  agent?: string                           // fork 时使用的 agent 类型
  effort?: EffortValue                     // 推理深度
  paths?: string[]                         // 条件触发的文件路径 glob
  getPromptForCommand(                     // 生成 prompt 内容
    args: string,
    context: ToolUseContext,
  ): Promise<ContentBlockParam[]>
}

7.2 命令注册机制

所有内置命令通过 COMMANDS() 工厂函数注册,采用 memoize 确保只初始化一次:

// src/commands.ts:258
const COMMANDS = memoize((): Command[] => [
  addDir,
  agents,
  branch,
  clear,           // type: 'local'
  compact,         // type: 'local'
  config,          // type: 'local-jsx'
  help,            // type: 'local-jsx'
  status,          // type: 'local-jsx'
  // ... 更多内置命令
  // 条件加载
  ...(webCmd ? [webCmd] : []),
  ...(voiceCommand ? [voiceCommand] : []),
  ...(process.env.USER_TYPE === 'ant' ? INTERNAL_ONLY_COMMANDS : []),
])

每个内置命令是一个简洁的描述符对象,实现代码通过 load() 懒加载:

// src/commands/clear/index.ts
const clear = {
  type: 'local',
  name: 'clear',
  description: 'Clear conversation history and free up context',
  aliases: ['reset', 'new'],
  supportsNonInteractive: false,
  load: () => import('./clear.js'),     // 懒加载
} satisfies Command

// src/commands/config/index.ts
const config = {
  aliases: ['settings'],
  type: 'local-jsx',
  name: 'config',
  description: 'Open config panel',
  load: () => import('./config.js'),
} satisfies Command

7.3 命令发现:getCommands()

getCommands() 是最终的命令列表组装函数,合并来自多个来源的命令:

// src/commands.ts:451-471
const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
  const [
    { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills },
    pluginCommands,
    workflowCommands,
  ] = await Promise.all([
    getSkills(cwd),           // Skill 目录
    getPluginCommands(),      // 插件命令
    getWorkflowCommands?.(cwd) ?? Promise.resolve([]),  // 工作流命令
  ])

  return [
    ...bundledSkills,         // 内置打包技能
    ...builtinPluginSkills,   // 内置插件技能
    ...skillDirCommands,      // 技能目录
    ...workflowCommands,      // 工作流
    ...pluginCommands,        // 插件
    ...pluginSkills,          // 插件技能
    ...COMMANDS(),            // 内置命令(最后)
  ]
})

最终过滤和动态技能合并:

// src/commands.ts:478-519
export async function getCommands(cwd: string): Promise<Command[]> {
  const allCommands = await loadAllCommands(cwd)
  const dynamicSkills = getDynamicSkills()  // 运行时动态发现的技能

  // 过滤:可用性 + 启用状态
  const baseCommands = allCommands.filter(
    _ => meetsAvailabilityRequirement(_) && isCommandEnabled(_),
  )

  // 动态技能去重后插入到内置命令之前
  const builtInNames = new Set(COMMANDS().map(c => c.name))
  const insertIndex = baseCommands.findIndex(c => builtInNames.has(c.name))
  return [
    ...baseCommands.slice(0, insertIndex),
    ...uniqueDynamicSkills,
    ...baseCommands.slice(insertIndex),
  ]
}

合并优先级(先出现的同名命令胜出):bundledSkills > builtinPluginSkills > skillDirCommands > workflowCommands > pluginCommands > pluginSkills > COMMANDS()

7.4 斜杠命令执行流程

用户输入 /command args 后的完整处理链路:

flowchart TD
    A["用户输入 /command args"] --> B["processUserInput()"]
    B --> C{"以 / 开头?"}
    C -->|是| D["processSlashCommand()"]
    C -->|否| E["processTextPrompt()<br/>普通对话"]
    D --> F["parseSlashCommand()<br/>解析命令名和参数"]
    F --> G{"找到命令?"}
    G -->|否| H["返回 Unknown skill 错误"]
    G -->|是| I["getMessagesForSlashCommand()"]
    I --> J{"command.type"}
    J -->|local| K["command.load().call()<br/>同步执行,返回文本"]
    J -->|local-jsx| L["command.load().call(onDone)<br/>渲染 JSX,等待 onDone"]
    J -->|prompt| M{"context === 'fork'?"}
    M -->|是| N["executeForkedSlashCommand()<br/>子 Agent 执行"]
    M -->|否| O["getMessagesForPromptSlashCommand()<br/>展开 prompt 注入对话"]

local 类型的执行

// src/utils/processUserInput/processSlashCommand.tsx:860-949
case 'local': {
  const mod = await command.load()
  const result = await mod.call(args, context)

  if (result.type === 'skip') {
    return { messages: [], shouldQuery: false, command }
  }
  // 结果包装为 <local-command-stdout> 标签
  return {
    messages: [
      userMessage,
      createCommandInputMessage(
        `<local-command-stdout>${result.value}</local-command-stdout>`,
      ),
    ],
    shouldQuery: false,     // local 命令不触发模型查询
    command,
  }
}

local-jsx 类型的执行

// src/utils/processUserInput/processSlashCommand.tsx:732-859
case 'local-jsx': {
  return new Promise<SlashCommandResult>(resolve => {
    const onDone: LocalJSXCommandOnDone = (result, options) => {
      // 根据 display 选项决定结果展示方式
      // 'skip' → 无消息
      // 'system' → 系统消息
      // 'user' → 用户消息
      resolve({ messages, shouldQuery: options?.shouldQuery ?? false, command })
    }

    // 懒加载并执行,返回 JSX 渲染到终端
    command.load()
      .then(mod => mod.call(onDone, { ...context, canUseTool }, args))
      .then(jsx => {
        setToolJSX({
          jsx,
          shouldHidePromptInput: true,  // 隐藏输入框
          showSpinner: false,
          isLocalJSXCommand: true,
        })
      })
  })
}

prompt 类型的展开

// src/utils/processUserInput/processSlashCommand.tsx:1114-1262
async function getMessagesForPromptSlashCommand(command, args, context) {
  // 1. 调用 getPromptForCommand 生成内容
  const result = await command.getPromptForCommand(args, context)

  // 2. 构造元信息
  const metadata = formatCommandLoadingMetadata(command, args)

  // 3. 解析允许的工具列表
  const additionalAllowedTools = parseToolListFromCLI(command.allowedTools ?? [])

  // 4. 组装消息序列
  const messages = [
    createUserMessage({ content: metadata, uuid }),              // 元数据
    createUserMessage({ content: result, isMeta: true }),        // 技能内容(隐藏)
    ...attachmentMessages,                                       // 附件
    createAttachmentMessage({                                    // 权限声明
      type: 'command_permissions',
      allowedTools: additionalAllowedTools,
      model: command.model,
    }),
  ]
  return {
    messages,
    shouldQuery: true,          // prompt 命令触发模型查询
    allowedTools: additionalAllowedTools,
    model: command.model,
    effort: command.effort,
    command,
  }
}

local/local-jsx 不同,prompt 类型的 shouldQuerytrue——内容注入对话流后会触发模型响应。


八、Skill 技能系统

源码位置:src/skills/loadSkillsDir.ts

Skill(技能)是 PromptCommand 的具体实现形式,允许用户通过 Markdown 文件定义可复用的 prompt 模板。Skill 是 Command 系统与 Tool 系统的桥梁——它以 Command 的身份被用户 / 调用,也可以通过 SkillTool 被模型主动调用。

8.1 Skill 加载流程

flowchart TD
    A["getSkillDirCommands()"] --> B["并行加载 5 个来源"]

    B --> C["Managed Skills<br/>~/.claude/skills/managed/"]
    B --> D["User Skills<br/>~/.claude/skills/"]
    B --> E["Project Skills<br/>.claude/skills/ (各层)"]
    B --> F["Additional Skills<br/>additionalSkillPaths"]
    B --> G["Legacy Skills<br/>.claude/commands/ (已废弃)"]

    C --> H["parseSkillFile()"]
    D --> H
    E --> H
    F --> H
    G --> H

    H --> I["解析 Frontmatter"]
    I --> J["createSkillCommand()"]
    J --> K["去重 + 分离条件技能"]

getSkillsPath() — 路径解析

// src/skills/loadSkillsDir.ts:78-94
export function getSkillsPath(
  source: SettingSource | 'plugin',
  dir: 'skills' | 'commands',
): string {
  switch (source) {
    case 'policySettings':
      return join(getManagedFilePath(), '.claude', dir)
    case 'userSettings':
      return join(getClaudeConfigHomeDir(), dir)
    case 'projectSettings':
      return `.claude/${dir}`
    case 'plugin':
      return 'plugin'
    default:
      return ''
  }
}

8.2 Skill 文件格式 (Frontmatter)

一个完整的 Skill Markdown 文件:

---
name: Review PR
description: Review a pull request for code quality
allowed-tools: Bash, FileReadTool, GrepTool
arguments: pr_number
when_to_use: when the user asks to review a PR
model: opus
effort: high
context: fork
userInvocable: true
disable-model-invocation: false
---

Review the pull request #$ARGUMENTS and provide feedback on:
1. Code quality
2. Potential bugs
3. Performance issues

解析源码参考parseSkillFrontmatterFields(src/skills/loadSkillsDir.ts:185)

字段 类型 说明
name string UI 显示名
description string 技能描述
allowed-tools string[] 限制可用工具
arguments string[] 参数名列表
when_to_use string 模型自动调用的触发条件
model string 指定使用的模型
effort string 推理深度
context 'fork' 在子 agent 中执行
userInvocable boolean 是否可通过 / 触发
disable-model-invocation boolean 禁止模型自动调用
shell string Bash 工具使用的 shell

8.3 变量替换

Skill 内容支持以下变量:

  • $ARGUMENTS — 用户传入的参数
  • ${CLAUDE_SKILL_DIR} - skills存放路径
  • ${CLAUDE_SESSION_ID} - 当前sessionId

相关处理在src/skills/loadSkillsDir.ts: 270中,函数为createSkillCommand

8.4 条件技能 (Conditional Skills)

通过 paths 字段实现文件路径匹配的条件技能:

// src/types/command.ts:50-52
export type PromptCommand = {
  // ...
  paths?: string[]
  // ...
}

条件技能只在模型操作过匹配路径的文件后才变为可见。例如,配置 paths: ["*.test.ts"] 的测试技能只有在模型读取或编辑了测试文件后才会出现在可用技能列表中。项目级技能(projectSettings 来源)天然隔离于项目目录内。


九、SkillTool —— 模型驱动的技能调用

源码位置:src/tools/SkillTool/SkillTool.ts

SkillTool 是一个特殊的内置工具,它让模型可以主动调用用户定义的 Skill,而不仅仅是通过用户输入 / 命令触发。

9.1 工作机制

sequenceDiagram
    participant M as 模型
    participant ST as SkillTool
    participant CMD as Command System
    participant A as Agent/Inline

    M->>ST: tool_use: { skill: "review-pr", args: "123" }
    ST->>CMD: getAllCommands(context)
    CMD-->>ST: [PromptCommand, LocalCommand, ...]
    ST->>ST: validateInput — 查找匹配的 PromptCommand
    alt executionContext === 'fork'
        ST->>A: executeForkedSkill() → runAgent()
    else inline
        ST->>ST: processPromptSlashCommand 注入 prompt 到对话流
    end
    ST-->>M: ToolResult

9.2 getAllCommands() — 命令发现

// src/tools/SkillTool/SkillTool.ts:81-94
async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
  // 1. 获取 MCP 提供的技能
  const mcpSkills = context.getAppState().mcp.commands.filter(
    cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
  )

  // 2. 没有 MCP 技能时直接返回本地命令
  if (mcpSkills.length === 0) {
    return getCommands(getProjectRoot())
  }

  // 3. 合并本地命令和 MCP 技能,本地优先
  const localCommands = await getCommands(getProjectRoot())
  return uniqBy([...localCommands, ...mcpSkills], 'name')
}

合并顺序是 localCommands 在前,mcpSkills 在后。uniqBy 保留第一个,所以本地命令优先于同名 MCP 技能。

9.3 validateInput — 技能存在性校验

SkillToolvalidateInput 确保:

  1. 请求的技能名称存在于可用命令列表中
  2. 目标命令是 PromptCommand 类型(非 local/local-jsx)
  3. 技能没有设置 disableModelInvocation: true

如果校验失败,返回描述性错误消息,模型可以据此调整调用。

9.4 executeForkedSkill() — 子 Agent 执行

当 Skill 的 frontmatter 设置 executionContext: 'fork' 时,技能在隔离的子 Agent 中运行:

// src/tools/SkillTool/SkillTool.ts:122-200+
async function executeForkedSkill(
  skillName: string,
  prompt: string,
  context: ToolUseContext,
  allowedTools?: string[],
  model?: string,
  effort?: string,
): Promise<ToolResult<string>> {
  // 1. 构造子 agent 的配置
  // 2. 通过 runAgent() 在独立上下文中执行
  // 3. 跟踪分析事件
  // 4. 返回子 agent 的结果
}

Fork 执行的优势:

  • 隔离性:子 Agent 有独立的上下文,不污染主对话
  • 工具限制:可以通过 allowedTools 限制子 Agent 可用的工具
  • 模型选择:可以为特定技能指定不同的模型

9.5 inline 整合回主会话

inline模式调用getMessagesForPromptSlashCommand,参考7.4节


十、系统集成场景

10.1 端到端流程:模型调用内置工具

sequenceDiagram
    participant U as 用户
    participant Q as query.ts
    participant API as Claude API
    participant STE as StreamingToolExecutor
    participant TE as toolExecution.ts
    participant T as BashTool

    U->>Q: "列出当前目录文件"
    Q->>API: messages + tools
    API-->>Q: tool_use: { name: "Bash", input: { command: "ls" } }
    Q->>STE: addTool(toolUseBlock)
    STE->>STE: canExecuteTool? → true
    STE->>TE: runToolUse()
    TE->>TE: findToolByName("Bash")
    TE->>TE: safeParse(input)
    TE->>TE: checkPermissions()
    TE->>U: 请求权限确认
    U-->>TE: 允许
    TE->>T: call({ command: "ls" })
    T-->>TE: { data: "file1.ts\nfile2.ts" }
    TE-->>STE: yield toolResultMessage
    STE-->>Q: yield 有序结果
    Q->>API: tool_result + continue
    API-->>Q: "当前目录包含 file1.ts 和 file2.ts"
    Q-->>U: 显示响应

10.2 端到端流程:MCP 工具调用

sequenceDiagram
    participant U as 用户
    participant Q as query.ts
    participant API as Claude API
    participant TE as toolExecution.ts
    participant MCP as MCP Client
    participant S as External MCP Server

    U->>Q: "获取代码诊断"
    Q->>API: messages + tools (含 mcp__ide__getDiagnostics)
    API-->>Q: tool_use: { name: "mcp__ide__getDiagnostics" }
    Q->>TE: runToolUse()
    TE->>TE: findToolByName("mcp__ide__getDiagnostics")
    Note over TE: 找到运行时覆盖的 MCPTool 实例
    TE->>TE: checkPermissions() → passthrough
    TE->>MCP: tool.call() (被 mcpClient.ts 覆盖)
    MCP->>S: MCP protocol call
    S-->>MCP: result
    MCP-->>TE: { data: "diagnostics..." }
    TE-->>Q: yield toolResultMessage
    Q->>API: tool_result + continue

10.3 端到端流程:Skill 调用

sequenceDiagram
    participant U as 用户
    participant Q as query.ts
    participant API as Claude API
    participant SK as SkillTool
    participant RA as runAgent()

    U->>Q: "review PR #42"
    Q->>API: messages + tools (含 SkillTool with skill list)
    API-->>Q: tool_use: { name: "Skill", input: { skill: "review-pr", args: "42" } }
    Q->>SK: runToolUse()
    SK->>SK: validateInput — 查找 "review-pr"
    SK->>SK: executionContext === 'fork'?
    alt fork
        SK->>RA: executeForkedSkill()
        RA-->>SK: 子 agent 结果
    else inline
        SK-->>Q: 注入 prompt 到 messages
        Q->>API: 带 skill prompt 的新请求
    end

十一、设计洞察

11.1 统一接口的力量

所有工具——无论是核心的 BashTool、外部的 MCP 工具、还是用户定义的 Skill——都实现同一个 Tool<Input, Output, P> 接口。这意味着:

  • 权限系统只需实现一次,自动应用于所有工具
  • StreamingToolExecutor 的并发控制对所有工具类型透明
  • 新增工具类型无需修改执行管道

10.2 多层权限的纵深防御

权限检查不是一个单点决策,而是一条贯穿整个执行管道的防线:

配置文件 deny 规则 → filterToolsByDenyRules(注册时过滤)
    → Zod schema 校验 → validateInput 业务校验
        → PreToolUse hooks(用户自定义拦截)
            → resolveHookPermissionDecision(综合决策)
                → canUseTool(运行时权限)
                    → 内部字段剥离(防注入)

即使某一层被绕过,后续层仍然提供保护。

10.3 并发安全的简洁模型

用一个布尔值 isConcurrencySafe 就实现了完整的并发控制:

  • 全部安全 → 全并发(如多个 GlobTool 查询)
  • 任一不安全 → 排队执行(如 FileEditTool 必须独占)
  • 兄弟中止 → Bash 失败时取消同批次工具

这比复杂的锁机制更易理解、更少出错。

10.4 Skill 系统的渐进式复杂度

Skill 系统展现了优秀的渐进式设计:

  • 最简形式:一个 Markdown 文件,内容即 prompt → 零配置
  • 中等复杂:添加 frontmatter 控制工具、模型、参数 → 声明式配置
  • 高级用法executionContext: fork 在子 Agent 中运行 → 完全隔离

用户可以从最简单的形式开始,按需增加复杂度。

唯杰地图CAD图层加高性能特效扩展包发布

作者 vjmap
2026年4月21日 20:46

前言

前段时间我们发布了 WebCAD 平台(vjmap.com/app/webcad/),解决了“在 Web 端打开和编辑 CAD 图纸”这件事。

这次发布 唯杰地图扩展包 vjmapext,不是重复造一个平台,而是补上另一块能力:把 CAD 绘制、编辑和高性能渲染,以插件方式接入你现有的 vjmap 项目

一句话定位

vjmapextvjmap 的 CAD 绘图扩展层,核心入口是 MapCadLayer
它不是一个独立平台,而是一个可嵌入的能力组件:你把它加到地图里,就有了 CAD 级别的图元绘制、编辑、标注、动画和扩展能力。

官方文档入口:


image-20260421201114628

WebCAD 和 vjmapext 是什么关系

可以把它理解成“平台 + 插件能力”的分工:

  • WebCAD:更像开箱即用的平台能力,适合直接在线打开和编辑图纸。
  • vjmapext:更像开发者工具箱,适合把 CAD 能力嵌入你的业务系统里,和业务流程、绘制、数据、界面一起做深度集成。

所以它们不是替代关系,而是互补关系。
如果你要“直接编辑用”,WebCAD 合适;如果你要“接入自己的系统基于vjmap开发并持续迭代”,vjmapext 更合适。


vjmapext 能做什么

从业务角度看,最常见的是下面几类能力组合。

1) 在 CAD 底图上做业务标注和交互、自动成图

你可以打开 CAD 图作为底图,然后叠加区域、点位、路径、文字和图例,把静态图变成可交互业务图。

这类能力常见于:

  • 园区设施管理;

  • 工厂设备点巡检;

  • 室内平面图上的状态展示。

  • 自动成图功能

  • 图形的绘制,编辑

    image-20260421201553891

image-20260421203845263

2) 浏览模式与编辑模式

vjmapext 支持只读浏览和编辑模式切换,便于做“查看”和“编辑”的显示不同。
例如在只读模式下仍允许关键对象可选中,用于查看属性、定位问题。
可以在编辑模式下对数据方便进行编辑,在浏览模式下对数据进行数据查看效果展示。

image-20260421201649396

image-20260421203944672

3) 动画与特效

  • CPU 动画:拖尾、弹簧、关键帧、闪烁、线段渐现
  • GPU FX:点/线预设效果,支持较大数量级渲染
  • FX 与 CAD 实体可绑定,实体移动后特效可跟随
  • 支持性能相关控制(比如更新频率、渲染策略)
  • 支持shadertoy上面的shader支持复制过来使用

如果你要做态势图、告警图、运行状态图,这块会非常好用。
可以直接参考这个示例页:
vjmap.com/app/demoext…

image-20260421201452090

image-20260421204023218

安装

依赖与环境(必读)

  • vjmapext 不能脱离 vjmap 单独使用。 必须先具备 vjmap 运行环境(地图 SDK、底图样式、服务与 vjmap.Map 等),再使用本库。
  • npm 工程:请同时安装 vjmapvjmapext(或已安装满足版本要求的 vjmap)。仅安装 vjmapext、未安装/未加载 vjmap 时无法正常工作。
  • 本包发布内容:以 package.jsonfiles 为准,一般为 dist 下的 vjmapext.min.js(UMD) 与类型声明;

npm 安装

npm install vjmapext

快速接入(最小工程骨架)

import { MapCadLayer } from "vjmapext";

const mapcad = new MapCadLayer({
  locale: "zh",
  mode: "edit",
  defaultColor: 0x7fd3ff,
});

map.addControl(mapcad);
mapcad.createUI({ theme: "dark" });

建议启动前先配置这 6 项:

  1. modeeditbrowse
  2. drawingDefaults:颜色、线宽、图层默认值;
  3. shortcuts:是否覆盖默认快捷键;
  4. 字体:有文字实体时先 loadFont()
  5. 交互:是否启用捕捉及捕捉模式;
  6. 持久化:先确定 toJSON/fromJSON 存储位置。

SDK 功能详解(含作用说明)

1)命令系统

核心 API:

  • executeCommand(name, opts?)
  • repeatLastCommand()
  • getLastCommandName()

内置命令覆盖(40+):

  • 绘图:LINEPLINEPOINTCIRCLEARCELLIPSESPLINEFREEHANDRECTPOLYDLINEREVCLOUDHATCH
  • 编辑:MOVECOPYERASEMIRROROFFSETSCALESTRETCHBREAKTRIMEXTENDFILLETARRAYEXPLODEDRAWORDER
  • 标注:DIMLINEARDIMALIGNEDDIMANGLEDIMRADIUSMLEADER
  • 文字:TEXTTEXTEDITMTEXT
  • 块与导入:BLOCKINSERTIMPORTSVG

作用描述(详细):

  • 把 CAD 操作抽象成统一命令后,业务系统只需绑定命令,不需要重复造编辑逻辑;
  • 多模式命令(如 CIRCLEARC)可通过关键字切换子流程,减少多命令拆分维护成本;
  • 命令可以统一挂到工具栏、右键菜单、快捷键与业务流程引导页面;
  • 命令执行链可接日志,形成“用户操作轨迹”。

逐命令功能说明(精简版):

  • 绘图命令

  • LINE:按点创建直线段,适合轴线、连线、边界基础绘制。

  • PLINE:连续多段线,支持闭合和回退点,适合轮廓线与路径线。

  • POINT:创建点实体,常用于定位点、控制点、设备锚点。

  • CIRCLE:圆绘制,支持圆心半径、直径、两点、三点、切线等模式。

  • ARC:圆弧绘制,支持多构造方式,适合弧形边界和连接段。

  • ELLIPSE:椭圆绘制,支持中心法/轴端点法,常用于设备包络或符号。

  • SPLINE:样条曲线,适合平滑边界、自由曲线表达。

  • FREEHAND:自由手绘,适合快速草绘与现场标记。

  • RECTPOLY:矩形/多边形绘制,适合区域框选、面状边界初稿。

  • ARROW:箭头绘制,用于流程方向、流向标识。

  • HATCH:填充封闭区域,适合功能分区、材质区、风险区高亮。

  • TOHATCH:将封闭图形转换为填充对象,便于后处理。

  • DLINE:双线绘制,适合道路、墙体、管廊边界等平行线对象。

  • REVCLOUD:修订云线,常用于审图圈改、问题标注。

  • 文字命令

  • TEXT:单行文字,适合点位名、编号、简短说明。

  • TEXTEDIT:编辑既有文字内容,适合在线修正文案。

  • MTEXT:多行文字,适合批注段落、说明块。

  • 标注命令

  • DIMLINEAR:线性标注,输出水平或垂直尺寸。

  • DIMALIGNED:对齐标注,沿对象方向标注真实长度。

  • DIMANGLE:角度标注,适合角点控制与转角校核。

  • DIMRADIUS:半径标注,适合圆/弧尺寸表达。

  • MLEADER:多重引线,适合复杂构件说明与指向标注。

  • 编辑命令

  • MOVE:整体平移对象到新位置。

  • COPY:复制对象,适合重复布置。

  • ERASE:删除对象。

  • MIRROR:镜像对象,适合对称图形快速生成。

  • OFFSET:平行偏移对象,适合生成内外边界。

  • SCALE:按比例缩放对象。

  • STRETCH:局部拉伸对象几何。

  • BREAK:打断对象,生成断开段。

  • TRIM:按边界修剪超出部分。

  • EXTEND:按边界延伸对象到交界处。

  • FILLET:圆角连接两对象,生成平滑转角。

  • ARRAY:阵列复制,适合规则分布对象。

  • EXPLODE:分解复合对象(块、多段线等)为基础实体。

  • DRAWORDER:调整前后绘制顺序,控制遮挡与可见层次。

  • 块与导入命令

  • BLOCK:将一组对象定义为块,便于复用和规范化管理。

  • INSERT:插入块引用,支持重复放置。

  • PASTECLIP:粘贴剪贴板对象,提升跨区域编辑效率。

  • IMPORTSVG:导入 SVG 并转为可编辑对象,便于外部图标/图形接入。

命令使用建议:

  • 前台工具栏通常优先暴露 LINE/PLINE/CIRCLE/MOVE/COPY/ERASE/TRIM/EXTEND/DIMLINEAR/TEXT
  • 审图类页面建议增加 REVCLOUD/MLEADER/TEXTEDIT
  • 模板化制图建议优先启用 BLOCK/INSERT/ARRAY
  • 若是存量图改造,命令层要与 MapData + Hider + exportDwg 一起设计。

示例:


2)输入系统(InputManager)

输入能力:

  • 点输入(坐标点采集)
  • 选集输入(单选、框选、多选)
  • 数值输入(长度、半径等)
  • 关键字输入(命令子模式切换)
  • 字符串输入(文字命令等)

作用描述(详细):

  • 输入统一后,所有命令交互行为一致,降低用户学习成本;
  • 对开发者来说,命令只管业务逻辑,输入边界(取消、确认、回退)交给统一系统处理;
  • 输入与预览联动后,用户在确认前就能看到结果,减少误提交;
  • 是“可编辑能力稳定性”的底座。

示例:


3)对象捕捉(Snap)与夹点编辑(Grip)

捕捉能力:

  • 端点、中点、圆心、交点、最近点等模式;
  • 支持模式组合与开关控制;
  • 在命令点输入阶段实时生效。

夹点能力:

  • 选中实体后显示可编辑夹点;
  • 拖拽夹点修改几何;
  • 可与撤销重做联动。

作用描述(详细):

  • 捕捉解决“线上操作精度不足”问题;
  • 夹点解决“局部改图要重画”问题;
  • 两者配合,能在网页端做可用的精修工作流,而不是仅展示级编辑。

示例:


4)实体存储与选择管理

核心 API:

  • addEntity(entity)
  • deleteEntity(id)
  • getEntities()
  • getSelectedEntities()
  • clearSelection()

作用描述(详细):

  • 实体层统一管理后,渲染层、属性面板、事件系统都能共享同一数据源;
  • 选择集明确后,编辑命令可避免“误改全部对象”;
  • 可在业务系统里按选择集做批处理(改颜色、改图层、改属性);
  • 是批量编辑、批量审查、批量导出的前置基础。

示例:


5)撤销重做(Undo/Redo)

核心 API:

  • undo()
  • redo()

作用描述(详细):

  • 在线编辑可回退,用户才敢进行复杂操作;
  • 支持和快捷键联动,操作习惯接近桌面 CAD;
  • 可用于审图流程中的“试改-对比-还原”。

示例:


6)标注体系

相关命令:

  • DIMLINEAR
  • DIMALIGNED
  • DIMANGLE
  • DIMRADIUS
  • MLEADER

作用描述(详细):

  • 标注能力决定图纸可审核性,不只是视觉增强;
  • 统一标注命令可把尺寸、角度、说明纳入标准编辑流;
  • 对工程协同来说,标注是交底、复核、验收的核心数据表达。

示例:


7)文字与字体管理

相关能力:

  • 命令:TEXTTEXTEDITMTEXT
  • API:loadFont(url, name?)

作用描述(详细):

  • 文字是图纸语义信息的重要组成;
  • 字体加载可避免线上渲染错位或替换字体导致排版变化;
  • 文字编辑能力可直接承接审图意见修订流程。

示例:


8)块(Block)能力

相关能力:

  • 命令:BLOCKINSERT
  • 数据:块定义、块引用
  • 序列化:块信息可随文档保存恢复

作用描述(详细):

  • 块能力是减少重复绘制和统一规范的核心;
  • 适合设备符号、标准构件、图例模板等复用对象;
  • 可建立企业标准块库,提升制图一致性。

示例:


9)序列化与绘图默认值

核心 API:

  • toJSON()
  • fromJSON(doc)
  • setDrawingDefaults(partial)
  • getDrawingDefaults()

作用描述(详细):

  • 支持“保存当前进度 -> 跨会话继续编辑”;
  • 支持“模板化初始化图纸”;
  • 支持团队统一绘图规范(图层、线宽、颜色);
  • 是多人协作和版本回放的基础。

10)MapData 数据联动

核心 API:

  • queryMapEntities(opts)
  • queryMapEntitiesByLayer(layer, entType, extra?)
  • featuresToEntities(featureCollection, opts?)
  • createMapDataHider()

标准链路:

  1. 按条件查询后端 DWG 实体;
  2. Feature 转 SDK 实体;
  3. 隐藏原图被接管对象;
  4. 前端叠加编辑;
  5. 最终导出。

作用描述(详细):

  • 不需要一次性迁移历史图纸;
  • 可在原图基础上做增量改造;
  • 可把“后端存量数据”接入“前端可编辑流程”;
  • 适合传统项目数字化升级。

示例:


11)DWG 导出交付

核心 API:

  • exportDwg(opts)
  • setExportDwgCallback(cb)

常见组合:

  • exportDwg({ hider })
  • onBeforeUpdate 导出前加工
  • deleteFromSource 导出时清理源对象

作用描述(详细):

  • 打通“在线编辑 -> DWG 文件交付”;
  • 保持与传统 CAD 工具链衔接;
  • 减少人工二次整理步骤。

12)渲染与性能机制

可核验机制:

  • 三源分桶:hot/cold/dynamic
  • 增量更新与脏标记刷新
  • 渲染缓存复用
  • styleOnly 样式快路径

作用描述(详细):

  • 高频操作时减少全量刷新;
  • 大图场景下更稳定;
  • 只改样式时避免几何重建;
  • 给后续性能调优提供结构基础。

13)FX 特效层

能力点:

  • 批量添加特效对象;
  • 质量档位调节;
  • 指标与事件输出;
  • CAD 实体绑定。

作用描述(详细):

  • 可用于状态表达(告警、流向、活跃度);
  • 可根据设备性能动态降级;
  • 可通过指标事件接入监控系统;
  • 是“可编辑图纸 + 运行态表达”组合能力的关键层。

示例:

14)UI、事件与扩展能力

相关能力:

  • createUI(options) / getUI()
  • eventBus 事件总线
  • loadPlugin(plugin) 与插件生命周期

作用描述(详细):

  • UI 能力让你快速构建可用工作台;
  • 事件体系让 CAD 编辑流程可接入业务日志、审批、统计;
  • 插件机制支持“先上线核心,再按模块扩展”;
  • 有利于长期维护与团队协作开发。

示例:


独立开发复盘:我用 Uni-app + Strapi v5 肝了一个“会上瘾”的打卡小程序

作者 知航驿站
2026年4月21日 20:06

大家好,我是一名独立开发者。最近利用业余时间,我从零到一开发并上线了一款目标打卡/习惯养成类的小程序。

今天这篇文章,不仅是想向大家推荐一下我的心血之作,更想从创作灵感核心技术实现代码细节以及无数次踩坑的角度,和大家深度复盘一下整个项目的历程。如果你也想尝试用 Uni-app + Strapi 搞全栈独立开发,这篇“避坑指南 + 技术解析”绝对不容错过!


307c9942d27117ec00e7781976431a56.jpg

1fa52da0afde72c0faf8e72dc49c1c29.jpg

💡 创作灵感与产品心得:为什么还要做一个打卡应用?

市面上的打卡应用多如牛毛,为什么我还要自己造轮子? 其实原因很简单:我觉得现有的工具太“冷冰冰”了,缺乏足够的情绪反馈。

打卡/坚持习惯本身就是一件反人性的事情,如果工具只是一个无情的“待办列表”,那用户很容易就会放弃。因此,在产品设计之初,我定下了几个核心基调:

  1. 克制与聚焦:我限制了每天最多只能创建 12 个任务,到达 10 个时会温馨警告。目标泛滥等于没有目标。
  2. 正向反馈拉满:任务完成不能只是打个勾,必须要有“爽感”。我加入了物理震动、纸屑爆裂动画(撒花)、以及 3D 翻转的徽章解锁系统。
  3. 互助与抄作业:很多时候我们不知道该养成什么习惯,所以我做了一个“社区广场”(瀑布流布局),看到别人优秀的习惯,可以直接“一键 Copy”到自己的计划中。

🛠 技术选型:单兵作战的效率最优解

作为独立开发者,开发效率是第一生产力。我选择了这套组合拳:

  • 前端:Uni-app (Vue 3) + Tailwind CSS
    • Vue 3 的 Composition API 逻辑复用非常爽。
    • 结合原子化 CSS(如 Tailwind/UnoCSS),极大提升了切图速度,摆脱了起 class 名字的内耗。
  • 后端:Strapi v5 (Headless CMS)
    • 绝对的效率神器!不用手写繁琐的 CRUD 接口,建好模型直接生成 RESTful API。
    • 自带强大的 Admin 后台,数据管理极度舒适,让我能把 80% 的精力全放在前端交互和产品体验上。

💻 核心技术点与代码实现

1. 极致的微交互:让打卡“爽”起来

为了让用户点下“完成”的那一刻有真实的成就感,我结合了 CSS 动画和原生的触觉反馈:

// 核心打卡逻辑片段
const handleCheckIn = async (task) => {
  // 1. 触发 Haptic 震动反馈 (重震动带来物理按压感)
  uni.vibrateShort({ type: 'heavy' });
  
  // 2. 触发微动效:按钮自身的弹跳 + 全局撒花特效
  task.isBouncing = true; 
  uni.$emit('trigger-particle-confetti'); // 呼叫全局纸屑动画组件
  
  try {
    await api.completeTask(task.id);
    // 3. 检查是否触发徽章解锁
    checkBadgeUnlock(task);
  } catch (e) {
    // 错误处理...
  }
}

在徽章解锁时,我还写了一个 3D 翻牌效果(利用 CSS transform: rotateY 配合 animate-flip-y),让徽章展示更有仪式感。

2. Strapi 关系模型 Hack:如何优雅地记录“徽章解锁时间”?

在后端的开发中,我遇到了一个经典问题:多对多关联表的额外字段怎么存? User 和 Badge 是多对多关系,但在 Strapi 原生模型中,中间表无法轻易添加像 unlockedAt 这样的字段。

我的解法: 直接在 User Schema 中扩展一个轻量级的 JSON 字段 badge_unlock_records

// apps/api/src/extensions/users-permissions/strapi-server.ts
// 扩展 Strapi 默认的 User Schema
export default (plugin) => {
  plugin.contentTypes.user.attributes = {
    ...plugin.contentTypes.user.attributes,
    // 原生多对多关联
    badges: {
      type: 'relation',
      relation: 'manyToMany',
      target: 'api::badge.badge',
    },
    // 💡 Hack: 用 JSON 字段记录具体的解锁元数据
    badge_unlock_records: {
      type: 'json',
      // 数据结构示例: { "badge_id_1": "2023-10-01T12:00:00Z" }
    }
  };
  return plugin;
};

这样既保留了原生关系(方便在 Admin 面板查看),又解决了业务上的元数据存储需求。

3. 社区广场的“真”瀑布流与分页

社区页面的卡片高度是不固定的,传统的 Grid 布局会留下大片空白。我通过维护左右两列的数据数组,实现了原生的瀑布流效果:

// 瀑布流计算核心逻辑
const leftColumn = ref([]);
const rightColumn = ref([]);
let leftHeight = 0;
let rightHeight = 0;

const appendToMasonry = (items) => {
  items.forEach(item => {
    // 估算卡片高度 (基于内容长度)
    const estimatedHeight = calculateHeight(item);
    
    // 哪边矮往哪边塞
    if (leftHeight <= rightHeight) {
      leftColumn.value.push(item);
      leftHeight += estimatedHeight;
    } else {
      rightColumn.value.push(item);
      rightHeight += estimatedHeight;
    }
  });
};

配合 onReachBottom 触底事件,以及自己封装的 wd-loadmore 状态组件,整个信息流刷起来非常丝滑。


🚧 吐血踩坑录:那些让我熬夜的 Bug

全栈开发最怕的就是遇到莫名其妙的兼容性和环境问题。以下这几个坑,价值好几百根头发:

坑一:iOS 13 下 Swiper 圆角失效问题

症状:在旧版 iOS 中,给 <swiper> 设了 border-radiusoverflow: hidden,但里面的图片滑动时依然会无视圆角溢出。 解法:这是 transform 堆叠上下文导致的渲染 Bug。不仅要给 swiper 和 image 都加上圆角类名,还必须强制加上 transform: translateY(0);

<!-- 💡 注意 style 中的 transform 是精髓 -->
<swiper class="rounded-[5px]" style="transform: translateY(0);">
  <swiper-item>
    <image class="rounded-[5px]" src="..." />
  </swiper-item>
</swiper>

坑二:小程序下渐变文字(bg-clip-text)直接消失

症状:想用 Tailwind 的 bg-clip-text text-transparent 做炫酷的渐变文字,结果在微信小程序/iOS上文字直接隐身了。 解法:小程序对 <text> 标签的背景裁剪支持极差。如果要用,必须把 <text> 换成 <view> 标签来写文字,或者老老实实退回到纯色文本。

坑三:Strapi v5 生产环境部署大坑

  1. 插件报错:初始化 v5 时,报 Middleware plugin::email.rateLimit not found解法:手动执行 pnpm add @strapi/email 安装缺失依赖。
  2. RTK Query 压缩报错:打包上线后 Admin 面板报 Cannot read properties of undefined (reading 'merge')。原因是 Vite 压缩把 RTK Query 的方法名压没了。 解法:在 src/admin/vite.config.ts 中关闭 minify,并清理缓存!
// src/admin/vite.config.ts
export default {
  build: {
    minify: false, // 💡 必须设为 false
  },
};

(执行 npx rimraf .strapi build 清除缓存后再 build)


结语

从一行代码都没有,到完整的前后端链路打通;从构思微交互,到处理数据备份(云端同步 + 剪贴板文本导出);这个过程虽然辛苦,但当看到产品真正跑起来,有人开始用它记录生活时,一切都值了。

目前小程序已经上线,欢迎大家在微信搜索 简行一周 体验!

如果你对文章中的技术点感兴趣,或者在用 Uni-app / Strapi 的时候也遇到了头疼的问题,欢迎在评论区留言交流,我一定知无不言!

最后,如果你觉得这篇文章对你有启发,求个点赞 + 收藏,这对我这个独立开发者是莫大的鼓励!🚀

6e051cfd4b1574ab6dc9e48938b739d7.png

Opus 4.7 使用体验

2026年4月21日 18:26

大家好,我是 uni-app 的核心开发 笨笨狗吞噬者,欢迎关注我的微信公众号 前端笨笨狗

感想

不得不说,ai 发展的实在太快了,去年我入职 uni-app,一年中的大部分时间还是在手搓代码,就是网上说的 古法编程,一方面是对 ai 的能力仍有质疑,觉得对于框架维护,ai 不懂,另一方面是体验了一些模型,感觉能力不是很强。公司这边也是大力支持使用 ai,同事甚至一天提交了一百多个 commit

上周我在家用 gpt-5.4ai 写了 https://github.com/uni-toolkit/uni-toolkit/tree/main/packages/vite-plugin-component-insight 插件,我写好了 md 文件之后,就去打王者荣耀去了,大概个把小时之后,它已经写出来,并且按照我的要求先用原生微信小程序做测试和验证,然后再用 uni-app 项目做了测试和验证,这个活以往我可能要做大概一半天,现在再看看,我基本上什么编码工作都没有做(简单修改了一下 readme)。

现在我的工作,基本上百分之八九十都是交给 ai 编写或者作决策,比如我需要修复框架的问题,我会告诉 ai,现在的问题是什么,我的修复思路是什么,具体该修改什么文件,等它改完了之后,我再来 review,看下代码修改是否合理。

有时候真不明白要我干啥了,emmmmmm....

skill

前段时间,我创建了 https://github.com/uni-toolkit/skills 项目,用来存放针对 uni-app 主包瘦身的 skills。今天就想着来再试试 Opus 4.7 的能力,同时也验证下我的 skills 是否正确,能否给开发者提供足够合理的建议,下面分享下我的测试流程

前置工作

我一般使用 ai 做某个任务的习惯时,先写个 md 文件,比如我现在的需求是验证 uniapp-subpackage-node-modules 的正确性, md 文件如下:

# 需求

验证 `uniapp-subpackage-node-modules` skill 的正确性

## 步骤一

### 需求

主包中加一个页面,创建两个分包页面,主包可以分别跳转到分包页面。页面要有一个组件示例,每个组件或者页面都要添加 rattail 工具函数的示例

### 注意事项

- 页面不要太复杂
- 使用的 rattail 版本为 1.0.8 
- 工具函数可以选一部分简单的,一部分复杂的
- rattail 官网是 https://rattail.varletjs.org/zh/getting-started.html

## 步骤二

### 需求

`https://github.com/uni-toolkit/skills/tree/main/skills/uniapp-subpackage-node-modules` 这个是要验证的skill,你把skill应用到这个项目中,同时跑一下 skill,把你觉得项目应该修改的地方写成一个 md 文件到项目的根目录

### 注意事项

- 不要修改项目的原逻辑,仅安装skill,应用skill,输出分析信息

## 步骤三

参考上述分析得到的日志,如果这个项目可以优化,你可以根据 skill ,优化下这个项目产物生成

### 注意事项

- 不要添加无关依赖和逻辑
- 分包用到的 rattail 函数应该打包到分包中,注意验证这个

指挥 ai

md 文件的内容写的差不多之后,我会让 ai 按照步骤进行工作,分步骤的目的是防止 ai 一下修改出来太多东西看不过来(吐血经历,之前让 aihttps://github.com/uni-toolkit/uni-toolkit/tree/main/packages/vite-plugin-component-insight,review 累死人)

我这个项目是主包分包都用到了 rattail 工具库的函数,hx 5.04 版本开始,hx 项目或者 cli 项目支持分包引用的 node_modules 依赖打包到分包中,我想分包用到的函数打包到分包中,ai 应用了 uniapp-subpackage-node-modules 之后,

skill.jpg

输出了一份详细的诊断报告(大概两百多字,本文仅展示部分内容)

# Skill 分析报告:`uniapp-subpackage-node-modules`

> 本文件由 `uniapp-subpackage-node-modules` skill 应用于当前项目后自动输出。
> 目的:验证 skill 的正确性,分析项目在「分包 node_modules 归属」上的现状与风险,**不直接改动项目原逻辑**。

- Skill 源:<https://github.com/uni-toolkit/skills/tree/main/skills/uniapp-subpackage-node-modules>
- 已安装到:`.cursor/skills/uniapp-subpackage-node-modules/`
  - `SKILL.md`
  - `references/node-modules-subpackage-rules.md`
  - `agents/openai.yaml`

---

## 一、诊断结论(TL;DR)

- **属于问题域**:是。项目确实存在「分包 js 未进入分包 `vendor.js`、仍全部留在主包 `common/vendor.js`」现象。
- **是否满足 uni-app 内置分包优化前提****否**。命中 3 条直接不符合项:
  1. `manifest.json` 的 `mp-weixin` **未开启** `optimization.subPackages`
  2. `packageA`、`packageB` 根目录 **没有独立的 `package.json`**
  3. 目标依赖 `rattail` **被主包 + 多个分包同时引用**,属于「共享依赖」,按规则不适合进入单分包 `vendor.js`
- **更像是哪类原因****配置未开启 + 依赖划分不合理**(双重原因)。即便开了配置,当前 `rattail` 的引用链也会把它拉回主包,这是符合官方优化规则的正常结果。

---

ai 很好地分析出了可优化点,并且我们还可以让它根据分析日志做保守修改

res.jpg

修改前,rattail 的函数都被打包到了 common/vendor.js

before.jpg

修改后,分包的内容都被打包到了分包中

after.jpg

RainbowKit快速集成多链钱包连接,我如何从“连不上”到“丝滑切换”

作者 竹林818
2026年4月21日 18:02

背景

上个月,我接手了一个多链DeFi聚合器前端的迭代任务。项目原本只支持以太坊主网,现在产品经理要求快速接入Arbitrum、Polygon和Optimism。核心需求很明确:用户进来,点一个按钮就能连接MetaMask、Coinbase Wallet等主流钱包,并且能在不同链之间无缝切换,查看不同链上的资产和协议。

时间紧,任务重。我评估了一下,自己从零实现一套完整的钱包连接、状态管理、链切换和错误处理逻辑,至少得花上一周,而且后续维护成本高。团队里之前用过wagmi,但主要是基础连接。这次我决定试试RainbowKit,因为它号称是“wagmi的最佳实践封装”,开箱即用,而且UI组件很漂亮。我的目标是在一天内搞定基础的多链连接框架。

问题分析

一开始,我的想法很简单:照着RainbowKit官方文档,安装、配置、把ConnectButton组件一扔,不就完事了?但现实很快给了我一巴掌。

我按照基础教程配好了,按钮是出来了,也能弹出钱包选择框。但第一个问题马上就来了:用户连接后,我需要在应用的其他地方(比如导航栏显示地址、资产页面)获取当前的连接状态和账户信息。我本能地想用wagmi的useAccount等hook,但发现状态有时不同步。点击断开连接后,UI上偶尔还会显示已连接的状态。

第二个问题是链的切换。我配置了多个链,但用户从MetaMask里手动切换了网络(比如从Ethereum切到Polygon),我的应用界面有时感知不到,还是显示旧链的信息,导致后续的合约调用全错在错误的链上。

我意识到,RainbowKit虽然封装了复杂性,但它和底层wagmi的状态流、以及和用户钱包扩展程序的实时通信,需要更细致的配置才能稳定工作。这不是“配完即走”,而是需要理解它们之间如何协同。

核心实现

第一步:项目初始化与依赖安装

首先,我创建了一个新的React + TypeScript项目(如果已有项目,则跳过创建)。RainbowKit需要wagmi作为底层依赖,并且需要配置对应的链信息。

# 创建新项目
npx create-react-app my-web3-app --template typescript
cd my-web3-app

# 安装核心依赖
npm install @rainbow-me/rainbowkit wagmi viem @tanstack/react-query

这里有个关键点:RainbowKit依赖于@tanstack/react-query(旧称react-query)来进行高效的状态管理和缓存。即使你不直接使用它,也必须安装,否则会报错。

第二步:配置Provider与支持的链

这是核心配置环节。我需要在应用的根组件(通常是index.tsxApp.tsx)外包一层RainbowKit和wagmi的Provider。重点在于wagmiConfig的生成,这里需要定义项目支持哪些链。

我决定先支持四个链:Ethereum, Polygon, Arbitrum, Optimism。

// App.tsx
import React from 'react';
import './App.css';
import '@rainbow-me/rainbowkit/styles.css'; // 导入RainbowKit默认样式
import {
  getDefaultConfig,
  RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import {
  mainnet,
  polygon,
  arbitrum,
  optimism,
} from 'wagmi/chains';
import {
  QueryClientProvider,
  QueryClient,
} from '@tanstack/react-query';

// 1. 初始化QueryClient
const queryClient = new QueryClient();

// 2. 配置Wagmi
const config = getDefaultConfig({
  appName: 'MyMultiChainDeFiApp',
  projectId: 'YOUR_PROJECT_ID', // 需要去WalletConnect Cloud申请
  chains: [mainnet, polygon, arbitrum, optimism], // 明确声明支持的链
  ssr: false, // 如果不是Next.js等SSR框架,设为false
});

function App() {
  return (
    // 3. 用Provider层层包裹
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          {/* 你的应用组件 */}
          <div className="App">
            <h1>我的多链DeFi聚合器</h1>
            {/* 其他内容 */}
          </div>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;

这里有个大坑projectId不能乱填。RainbowKit使用WalletConnect v2协议,这个ID必须从WalletConnect Cloud网站免费注册并创建一个项目来获取。如果随便写一个字符串,钱包连接(尤其是WalletConnect和Coinbase Wallet)会静默失败,控制台错误信息也不明显,我排查了好久。

第三步:使用ConnectButton并获取全局状态

现在,我可以在任何子组件中使用RainbowKit提供的ConnectButton和wagmi的hooks了。我创建了一个Header.tsx组件来放置连接按钮,并展示连接状态。

// components/Header.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useChainId, useSwitchChain } from 'wagmi';

export const Header = () => {
  // 使用wagmi的hooks获取全局状态
  const { address, isConnected, chain } = useAccount();
  const chainId = useChainId();
  const { switchChain } = useSwitchChain();

  return (
    <header>
      <nav>
        <div>我的DeFi应用</div>
        <div>
          {isConnected ? (
            <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
              {/* 显示当前网络 */}
              <span>网络: {chain?.name || `未知 (ID: ${chainId})`}</span>
              {/* 显示缩短的地址 */}
              <span>
                {address?.slice(0, 6)}...{address?.slice(-4)}
              </span>
              {/* RainbowKit提供的完整功能按钮 */}
              <ConnectButton showBalance={false} />
              {/* 一个自定义的链切换示例 */}
              <button onClick={() => switchChain({ chainId: polygon.id })}>
                切换到Polygon
              </button>
            </div>
          ) : (
            <ConnectButton />
          )}
        </div>
      </nav>
    </header>
  );
};

注意这个细节useAccountuseChainId等hook的状态,与ConnectButton组件内部的状态是自动同步的,因为它们共享同一个wagmi配置。这就是为什么我们可以在应用任何地方可靠地获取连接信息。ConnectButton本身已经包含了连接、切换钱包、切换网络、查看详情、断开连接等所有功能的UI和逻辑。

第四步:处理链切换与状态同步

为了让应用能实时响应用户在钱包里手动切换网络的操作,我需要监听链的变化并更新UI。wagmi的useAccount返回的chain对象,以及useChainId hook,都是响应式的。但为了在链切换时执行一些副作用(比如更新合约实例、重新获取链上数据),我使用了useEffect

// components/AssetDashboard.tsx
import { useEffect } from 'react';
import { useAccount, useChainId } from 'wagmi';

export const AssetDashboard = () => {
  const { chain, isConnected } = useAccount();
  const chainId = useChainId();

  useEffect(() => {
    if (!isConnected) return;

    console.log(`链已切换至: ${chain?.name} (ID: ${chainId})`);
    
    // 这里可以执行链切换后的副作用:
    // 1. 更新当前链的RPC Provider
    // 2. 更新合约实例的地址(如果不同链合约地址不同)
    // 3. 重新获取该链上的用户资产数据
    // 4. 更新UI上关于链的提示信息

    // 例如,重新获取资产
    fetchAssetsForChain(chainId);

  }, [chainId, isConnected, chain]); // 依赖chainId,当它变化时触发

  const fetchAssetsForChain = async (currentChainId: number) => {
    // 模拟根据链ID获取资产的函数
    console.log(`获取链 ${currentChainId} 上的资产...`);
    // ... 实际的数据获取逻辑
  };

  return (
    <div>
      <h2>资产总览</h2>
      <p>当前网络: <strong>{chain?.name || '未连接'}</strong></p>
      {/* 资产列表 */}
    </div>
  );
};

这里有个坑chain对象可能为undefined(例如钱包连接了但未授权任何账户,或者是一些边缘情况)。所以在使用chain.namechain.id时,最好使用可选链操作符?.或做空值判断,否则会导致页面渲染错误。

完整代码

以下是一个简化但可运行的核心集成示例,将所有关键部分放在一起。

// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
// App.tsx
import './App.css';
import '@rainbow-me/rainbowkit/styles.css';
import {
  getDefaultConfig,
  RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import {
  mainnet,
  polygon,
  arbitrum,
  optimism,
} from 'wagmi/chains';
import {
  QueryClientProvider,
  QueryClient,
} from '@tanstack/react-query';
import { Header } from './components/Header';
import { AssetDashboard } from './components/AssetDashboard';

const queryClient = new QueryClient();

// 注意:请替换为你在 WalletConnect Cloud 申请的 projectId
const projectId = 'YOUR_WALLETCONNECT_PROJECT_ID_HERE';

const config = getDefaultConfig({
  appName: 'MultiChainDemo',
  projectId: projectId,
  chains: [mainnet, polygon, arbitrum, optimism],
  ssr: false,
});

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <div className="App">
            <Header />
            <main>
              <AssetDashboard />
              {/* 你的其他页面组件 */}
            </main>
          </div>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// components/Header.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount } from 'wagmi';

export const Header = () => {
  const { isConnected, address, chain } = useAccount();

  return (
    <header style={{ padding: '1rem', borderBottom: '1px solid #ccc', display: 'flex', justifyContent: 'space-between' }}>
      <h1>多链DeFi演示</h1>
      <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
        {isConnected && (
          <>
            <div>
              网络: <strong>{chain?.name}</strong>
            </div>
            <div>
              地址: <code>{address?.slice(0, 8)}...{address?.slice(-6)}</code>
            </div>
          </>
        )}
        <ConnectButton />
      </div>
    </header>
  );
};
// components/AssetDashboard.tsx
import { useEffect } from 'react';
import { useAccount, useChainId } from 'wagmi';

export const AssetDashboard = () => {
  const { chain, isConnected } = useAccount();
  const chainId = useChainId();

  useEffect(() => {
    if (!isConnected) {
      console.log('钱包未连接');
      return;
    }
    // 当链ID变化时,执行数据更新逻辑
    console.log(`[副作用] 检测到链变化,当前链ID: ${chainId}, 名称: ${chain?.name}`);
    // 在实际项目中,这里应调用一个函数来更新该链的资产数据
  }, [chainId, isConnected, chain]);

  return (
    <div style={{ padding: '2rem' }}>
      <h2>资产仪表板</h2>
      <p>这个组件会监听链切换。打开控制台查看日志。</p>
      <div>
        <p><strong>连接状态:</strong> {isConnected ? '已连接' : '未连接'}</p>
        <p><strong>当前网络:</strong> {chain?.name || 'N/A'}</p>
        <p><strong>链ID:</strong> {chainId || 'N/A'}</p>
      </div>
    </div>
  );
};

踩坑记录

  1. WalletConnect ProjectId 无效导致静默失败:这是最大的坑。我一开始随便写了个字符串,MetaMask能连(因为它不走WalletConnect),但Coinbase Wallet和WalletConnect二维码死活没反应,控制台也没有明显错误。后来在RainbowKit的GitHub issue里看到,必须去WalletConnect Cloud创建项目获取真实ID。解决后一切正常。
  2. 链ID不匹配导致切换失败:我自定义了一个测试链,它的id我设成了12345。当我调用switchChain({ chainId: 12345 })时,钱包弹窗提示切换,但RainbowKit内部状态没更新。后来发现,getDefaultConfigchains数组必须包含这个链的定义,并且id要和钱包里添加的网络ID完全一致。本质是RainbowKit/wagmi需要知道你打算切换到的链的详细信息(RPC URL、区块浏览器等)。
  3. Hydration错误(Next.js场景):在Next.js项目中,如果SSR开启,需要在getDefaultConfig里设置ssr: true,并且确保与钱包相关的组件只在客户端渲染(用useEffecttypeof window !== 'undefined'判断),否则会因为服务端和客户端初始渲染内容不一致而报错。虽然我这次是Create React App,但这是常见的坑。
  4. 样式冲突:RainbowKit会注入一些全局样式,如果和你项目的现有CSS(比如用了CSS-in-JS库或重置样式表)冲突,可能会导致弹窗位置错乱或样式怪异。解决方法是检查元素,用更高特异性的CSS规则覆盖,或者利用RainbowKit提供的主题定制功能来适配。

小结

通过这次集成,我最大的收获是:RainbowKit + wagmi 确实能极大加速Web3前端连接层的开发,但“开箱即用”不等于“无需理解”。清晰配置支持的链、妥善管理WalletConnect ProjectId、理解状态hook的响应式原理,是保证多链连接稳定丝滑的关键。下一步,我可以深入研究RainbowKit的主题定制,让UI完全融入项目设计,并探索如何与更复杂的多链合约读写逻辑结合。

新手小白学前端day4: 半小时彻底搞懂Position

2026年4月21日 17:48

Day4 学习文档:Position 定位实战

1. 今天要掌握什么

Day4 的目标是把“元素放哪儿”这件事彻底搞明白:

  • 理解 relativeabsolutefixedsticky 的区别
  • 知道每种定位在什么场景下最合适
  • 能做出 3 个常见交互:吸顶导航、角标、回到顶部按钮

2. 大白话理解 Position

可以把页面想象成一张地图,普通元素按“排队规则”从上到下放置。
position 就是告诉浏览器:这个元素是否要“偏离原队列”。

  • static:默认值,老老实实排队
  • relative:还在队列里,但允许“微调位置”
  • absolute:脱离队列,贴着某个参考盒子定位
  • fixed:脱离队列,直接贴着屏幕定位
  • sticky:平时排队,滚动到阈值后吸附

2.1 两个必须懂的基础词:viewport 和文档流


一、文档流(Normal Flow)

大白话:就是网页里的元素“排队”的方式,默认情况下,它们按照你在HTML里写的顺序,一个接一个地自动摆放。

  • 块级元素(比如 <div><p><h1>):就像地铁里的车厢,每个独占一整节,竖着排,上一个在顶上,下一个在底下,不会并排。
  • 行内元素(比如 <span><a><strong>):就像排队买票的人,大家并排站在一起,从左到右,一行不够了就自动换到下一行。

一句话:文档流就是“正常排队”,你什么都不改,它就这么排。


二、viewport(视口)

大白话:就是你手机或电脑屏幕上用来显示网页的那个矩形区域

  • 在电脑上,视口就是浏览器窗口的内部(不包括工具栏、地址栏)。
  • 在手机上,视口比较特殊:因为手机屏幕窄,早期网页都是为电脑设计的(宽度至少960px),如果手机用真实屏幕宽度(比如375px)去显示,网页会被挤得乱七八糟。所以手机浏览器默认用一个虚拟的宽视口(通常是980px)来加载网页,然后缩放显示。这就导致你看不清字,需要手动放大。

**<meta name="viewport"> 标签的作用**:告诉手机浏览器:“别用那个虚拟宽视口了,就用我的实际屏幕宽度来布局”。常见写法:

<meta name="viewport" content="width=device-width, initial-scale=1.0">

意思是:

  • width=device-width:把视口宽度设成手机屏幕的实际宽度(比如375px)。
  • initial-scale=1.0:不缩放,1个CSS像素对应1个屏幕物理像素。

一句话:viewport 就是“你看网页的那个框框”,移动端必须设置那个meta标签,否则网页会像缩小的蚂蚁。


三、二者关系

  • 文档流 是元素在视口内的排列方式。
  • 视口 是容纳文档流的容器大小。

举个例子:你把一排积木(文档流)放在一个盒子里(视口)。盒子宽了,积木可能并排;盒子窄了,积木可能换行。

移动端如果不设置 viewport,盒子(视口)会默认宽980px,导致你的积木(元素)看起来很迷你,用户必须双指缩放才能看清。设置了之后,盒子宽度就是手机屏幕宽度,你按正常尺寸写CSS就行。

希望这样解释你彻底明白了。如果还有模糊的地方,可以继续问。


3. 四种核心定位方式(更直观版)

先记一句总口诀:

  • relative站在原地,可微调
  • absolute脱离队伍,找爹定位
  • fixed钉在屏幕,不跟页面走
  • sticky先正常滚,后吸顶

3.1 position: relative(站在原地,可微调)

生活类比:
你在排队时,脚还在原位置,但身体可以往前后左右挪一点。

你会看到的效果:

  • 这个元素原本位置还占着,不会让后面的元素补上来
  • 可以用 top/right/bottom/left 微调显示位置

最常见用途(不是“挪自己”,而是“给孩子当参照物”):

  • 给卡片加 position: relative,让内部角标用 absolute 对齐这张卡片

一句话判断:
需要一个“定位锚点”时,先上 relative

3.2 position: absolute(脱离队伍,找爹定位)

生活类比:
一个人离开队伍,跑去贴着“最近的定位锚点”站位。

你会看到的效果:

  • 元素脱离文档流,原位置会被其他元素“顶上来”
  • 它会找最近的、position 不是 static 的祖先作为参照
  • 如果没找到,就相对页面定位(常见“跑飞”)

典型场景:

  • 卡片角标、关闭按钮、图片文案浮层

一句话判断:
想让元素“贴着某个盒子角落”时,用 absolute + 父级 relative

3.3 position: relativeabsolute 的关系(大白话)

3.3.1 relative(相对定位)

大白话:元素原本在文档流里占着位置(排队占位),你可以通过 top/left/right/bottom 让它相对于自己的原位置挪动一下。挪动后,原来的坑位依然空着(其他元素不会挤过来)。

  • 常用场景:给绝对定位的父元素做“参照物”。
3.3.2 absolute(绝对定位)

大白话:元素完全脱离文档流(不再排队,也不再占位),其他元素会忽略它,直接填补它原来的位置。它的位置参考系是最近的那个设置了 position(非 static)的祖先元素。如果找不到这样的祖先,就参考视口(但严格说是初始包含块,可以理解为视口)。

3.3.3 它们最经典的配合:父 relative,子 absolute

这是为了让子元素相对于父元素进行绝对定位。

例子

<div class="parent" style="position: relative;">
  <div class="child" style="position: absolute; top: 0; right: 0;">角标</div>
</div>
  • 父元素 relative:不挪动自己,只是建立一个定位参照系
  • 子元素 absolute:相对于父元素左上角定位,top:0; right:0 就贴在父元素右上角。

为什么父元素不用 absolute 因为父元素如果 absolute 也会脱离文档流,破坏布局。用 relative 最安全(它不脱离文档流)。

3.3.4 对比表格
特性 relative absolute
是否脱离文档流 ❌ 不脱离,原位置保留 ✅ 脱离,原位置被其他元素占据
定位参照物 自身原本的位置 最近的非 static 祖先(如果没有,则视口)
能否用 top/left 移动 能,相对于自身原位置 能,相对于参照物
常见用途 作为绝对定位的容器、微调位置 浮层、角标、弹出菜单
3.3.5 一个帮你记忆的生活类比
  • **relative**:就像你站在排队的位置上,可以稍微往前探一点身子,但你的脚还在原地(别人不能占你的位)。
  • **absolute**:你从队伍里走出来,站在某个参照物(比如墙壁)旁边。你的原位置立刻被后面的人占了。
  • relative + 子 absolute:你对墙壁(父元素)说:“我要站在你右上角”。墙壁说:“好,我原地不动,你相对于我站。”
3.3.6 一个特殊但重要的点

如果某个祖先元素设置了 transformperspectivefilter 等属性,它会成为 absolute 的参照物(类似 relative),这有时会导致预期外的定位。


总结一句话

  • relative 是“相对自己原位置微调,不脱队”。
  • absolute 是“脱队去找最近的带定位的祖先,没有就找视口”。
  • 组合使用时,父 relative 给子 absolute 当参照物。

3.4 position: fixed(钉在屏幕,不跟页面走)

生活类比:
把便签纸贴在你的手机屏幕上,不管页面怎么滑,便签都在同一个位置。

你会看到的效果:

  • 元素固定在视口(viewport)上
  • 页面滚动时它不动
  • 也脱离文档流,可能遮挡内容

典型场景:

  • 回到顶部按钮
  • 悬浮客服入口

一句话判断:
想“永远在屏幕可见区域”就用 fixed

3.5 position: sticky(先正常滚,后吸顶)

生活类比:
便利贴一开始贴在文档某一行,滚动到顶部后它被“吸”住,不再继续上去。

你会看到的效果:

  • 在阈值前,它和普通元素一样参与文档流
  • 达到阈值(如 top: 0)后,像 fixed 一样吸附
  • 常需要配 z-index 和背景色,避免被内容盖住

典型场景:

  • 吸顶导航
  • 左侧目录跟随

一句话判断:
想要“滚动到某点才固定”,选 sticky


4. 你当前页面里的实战映射

4.1 吸顶导航(sticky)

.topbar {
  position: sticky;
  top: 0;
  z-index: 10;
}

解释:导航滚动到页面顶部后会“钉住”。

4.2 角标(relative + absolute)

.badge-card {
  position: relative;
}

.badge {
  position: absolute;
  top: -10px;
  right: -10px;
}

解释:角标相对卡片定位,而不是相对整个页面乱跑。

4.3 回到顶部按钮(fixed)

.back-top {
  position: fixed;
  right: 16px;
  bottom: 16px;
}

解释:无论页面滚动到哪里,按钮都固定在屏幕右下角。


5. 常见坑(重点)

  1. absolute 位置跑偏
  • 原因:父元素没设 position: relative
  1. sticky 不生效
  • 常见原因:没写 top;或父容器有 overflow 限制
  1. fixed 挡住内容
  • 解决:给主内容留底部空间,或调整按钮位置
  1. 层级覆盖异常
  • 解决:配合 z-index 控制层级(前提是元素有定位)

6. 提交前自测清单

  • 顶部导航滚动时可吸附
  • 角标稳定在卡片右上角
  • 回到顶部按钮始终固定在右下角
  • 手机宽度下无明显遮挡或溢出
  • 你能说清这四种定位的适用场景

7. 今天的学习产出模板(可复制到 README)

## Day4 学习总结(Position)

### 我做了什么
- 实现 sticky 吸顶导航
- 实现 relative + absolute 角标
- 实现 fixed 回到顶部按钮

### 我学会了什么
- 四种常见定位方式的差异
- absolute 的参照物查找规则
- sticky 的触发条件

### 我遇到的问题
- (填写你今天遇到的问题)

### 我如何解决
- (填写解决过程)

8. CSS 选择器速记(结合 .fixed-demo

你在 Day4 页面里看到这段:

.fixed-demo {
  position: fixed;
  top: 12px;
  right: 12px;
}

.fixed-demo 里的 . 是什么?

. 表示 class 选择器,意思是:

  • 选中所有 class="fixed-demo" 的元素
  • 给这些元素应用样式

对应 HTML:

<div class="fixed-demo">我是 fixed 对照条</div>

常见选择器符号(新手高频)

  • .demo:class 选择器(最常用)
  • #demo:id 选择器(页面唯一)
  • div:标签选择器
  • A B:后代选择器(A 内任意层级的 B)
  • A > B:子代选择器(A 的直接子元素 B)

一句话记忆

  • . 找 class
  • # 找 id
  • 空格找后代
  • > 找亲儿子(直接子元素)

9. HTML 多个 class:class="card badge-card"

它是什么意思?

class="card badge-card" 表示这个元素同时拥有两个 class

  • card
  • badge-card

在 CSS 里,只要选择器匹配,就会同时应用:

.card { ... }
.badge-card { ... }

为什么要这样写?

常见原因是“组合样式”:

  • card:通用卡片外观(白底、边框、圆角、内边距)
  • badge-card:额外加 position: relative,给角标当定位参照物

这样拆分后,别的卡片可以只复用 card,不必复制一堆样式。

如果两个规则冲突了怎么办?

如果 .card.badge-card 都写了同一个属性(比如 padding),最终生效取决于:

  1. **选择器优先级(specificity)**更高者优先
  2. 优先级相同,通常 后写的规则覆盖先写的规则(同文件内从上到下)

CSS 里还有一种“链式 class”(可选进阶)

.card.badge-card {
  /* 必须同时有 card 和 badge-card 才会命中 */
}

这和 HTML 写多个 class 是配套的,用来表达“只在特定组合下生效”的样式。

更系统的解释请看下一节:## 10. CSS 优先级 + 链式选择器(进阶但很有用)


10. CSS 优先级 + 链式选择器(进阶但很有用)

10.1 链式选择器是什么?

链式选择器(也叫“复合选择器”的一种常见写法):

.card.badge-card { }

含义:元素必须同时满足

  • class="card"
  • 也有 class="badge-card"

所以它比单独的 .card 更“挑剔”,命中范围更小。

对比:

<section class="card">A</section>
<section class="card badge-card">B</section>
  • .card:A 和 B 都会命中
  • .card.badge-card:只有 B 命中

10.2 链式选择器会不会让优先级变高?

会。链式 class 相当于把多个 class 条件叠在一起,通常比单个 class 更具体

直觉记忆:

  • .card:1 个 class
  • .card.badge-card:2 个 class(更具体)

所以在冲突时,.card.badge-card 往往更容易赢过 .card

10.3 CSS 优先级(specificity)到底比什么?

当两条规则都设置了同一个属性(比如 padding),浏览器要决定用哪条,会先看 优先级,再看 书写顺序(同优先级时,后写覆盖先写)。

新手最常用的优先级直觉(从低到高):

  1. 标签选择器(如 divsection
  2. class 选择器(如 .card
  3. id 选择器(如 #intro
  4. 行内样式style="...",一般不推荐大面积使用)
  5. **!important**(尽量别当常规武器)

说明:真实计算比这个更细(还会统计选择器里 class/id/标签的数量),但上面的顺序足够你日常排错。

10.4 两个很容易踩坑的点

  1. 以为“写在后面就一定赢” 不一定。如果对方选择器优先级更高,你写在后面也可能无效。
  2. 链式选择器写错 .card .badge-card(中间有空格)是后代选择器,不是链式选择器。
    .card.badge-card(中间没空格)才是“同时有两个 class”。

10.5 结合你 Day4 页面的一个判断练习

假设同时存在:

.card { padding: 16px; }
.badge-card { padding: 24px; }
.card.badge-card { padding: 20px; }

对一个 class="card badge-card" 的元素:

  • 三个规则都匹配
  • 最终 padding 通常会落在 .card.badge-card(更具体)

如果你发现结果不符合预期,打开 DevTools 看 Computed 面板,能看到最终生效规则与被覆盖原因。


11. border-radius: 999px 是什么“magic”?

它不是魔法,而是一个常见技巧:
把圆角半径写得非常大,让浏览器自动夹到可用最大值。

11.1 为什么写 999px 也不会“溢出”?

浏览器会做限制:圆角不可能超过元素几何允许的范围。
所以你写 999px,实际会被“裁到最大可行圆角”。

效果通常是:

  • 长条按钮 -> 胶囊形(两端很圆)
  • 接近正方形的按钮 -> 接近圆形

11.2 和 border-radius: 50% 有什么区别?

  • 999px:给“很大固定值”,常用于按钮、标签,尺寸变化时也容易保持圆润
  • 50%:按元素自身尺寸比例计算,常用于正方形头像变圆

一句话理解:
999px 更像“我要尽可能圆”,50% 更像“按比例圆”。

11.3 在你 Day4 页面里的实际用途

你的“回到顶部”按钮如果用了:

.back-top {
  border-radius: 999px;
}

它会变成胶囊风格,更像悬浮操作按钮。

11.4 常见使用场景

  • 悬浮按钮(回到顶部、客服入口)
  • 小标签(Tag / Badge)
  • 导航中的胶囊按钮

11.5 小提醒

border-radius 只影响“圆角外观”,不影响元素布局流。
你仍需要配合 paddingline-heightwidth/height 去控制按钮最终形态。


附录:完整 index.html 代码

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Day4 Position 练习</title>
    <style>
      *, *::before, *::after {
        box-sizing: border-box
      }

      .fixed-demo {
        position: fixed;
        top: 0;
        right: 20px;
        z-index: 100;
        background: yellow;
        padding: 12px 16px;
        border-bottom: 1px solid #e5e5e5;
      }

      .pre-scroll {
        height: 280px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: #475569;
        background: linear-gradient(180deg, #dbeafe 0%, #eff6ff 100%);
        border-bottom: 1px solid #bfdbfe;
      }

      .topbar {
        position: sticky;
        top: 0;
        z-index: 10;
        background: #ffffff;
        border-bottom: 1px solid #e5e7eb;
      }

      .topbar-inner {
        max-width: 960px;
        margin: 0 auto;
        padding: 12px 16px;
        display: flex;
        justify-content: space-between;
        align-items: center;
      }

      .topbar a {
        text-decoration: none;
        color: #1d4ed8;
        margin-left: 10px;
      }

      .back-top {
        position:fixed;
        right: 16px;
        bottom: 16px;
        border: 1px solid #1d4ed8;
        border-radius: 999px;
        background: #2563eb;
        color: #fff;
        text-decoration: none;
        padding: 10px 14px;
      }

      .back-top:hover {
        background: #1d4ed8;
      }

      .filler {
        min-height: 1200px;
      }

      .container {
        /* 设置了最大宽度,并且居中显示,并且有内边距 */
        max-width: 960px;
        margin: 0 auto;
        padding: 24px 16px 80px;
      }

      /* 卡片样式 */
      .card {
        background: #fff;
        border: 1px solid #e5e7eb;
        border-radius: 10px;
        padding: 16px;
        margin-bottom: 16px;
      }

      /* 角标卡片样式 */
      .badge-card {
        position: relative;
      }

      /* 角标样式 */
      .badge {
        /* 绝对定位,相对于父元素 */
        position: absolute;
        /* 距离父元素上边10px */
        top: -10px;
        /* 距离父元素右边10px */
        right: -10px;
        /* 背景颜色 */
        background: #ef4444;
        /* 文字颜色 */
        color: #fff;
        /* 字体大小 */
        font-size: 12px;
        /* 内边距 */
        padding: 4px 8px;
        /* 圆角 */
        border-radius: 999px;
      }
    </style>
  </head>
  <body>
    <div class="fixed-demo">我是fixed(一直钉在屏幕右上角)</div>

    <section class="pre-scroll">
      <p>先向下滚动,再观察 topbar 何时开始吸顶</p>
    </section>

    <header class="topbar">
      <div class="topbar-inner">
        <strong>Day4 Position 实战</strong>
        <nav>
          <a href="#intro">介绍</a>
          <a href="#absolute-relative">角标</a>
          <a href="#sticky-fixed">笔记</a>
        </nav>
      </div>
    </header>

    <main class="container">
      <section class="card" id="intro">
        <h1>Day4 Position 练习页面</h1>
        <p>本日目标:掌握 relative / absolute / fixed / sticky 的使用场景。</p>
      </section>
      <section  class="card badge-card" id="absolute-relative">
        <span class="badge">NEW</span>
        <h2>absolute + relative 角标示例</h2>
        <p>父元素用 <code>position: relative</code>,角标用 <code>position: absolute</code></p>
      </section>
      <section class="card" id="sticky-fixed">
        <h2>sticky + fixed 说明</h2>
        <p>顶部导航使用 <code>position: sticky</code>,滚动到顶部后保持可见。</p>
        <p>右下角按钮使用 <code>position: fixed</code>,始终固定在视口位置。</p
      </section>
      <section class="filler"></section>

      <a href="#intro" class="back-top">回到顶部</a>
    </main>
    
  </body>

</html>
❌
❌