普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月24日掘金 前端

Nginx 反向代理 WebSocket 和 SSE 的踩坑

2026年3月24日 13:49

Nginx 反向代理 WebSocket 和 SSE 的踩坑

项目上了 Nginx 反向代理之后,HTTP 接口全部正常,WebSocket 却连不上,SSE 推送也收不到消息。控制台没有报错,Network 面板看着像是连上了,数据就是不过来。先给结论:WebSocket 和 SSE 都不是标准的 HTTP 请求-响应模型,Nginx 默认配置会把它们当成普通 HTTP 处理,要么握手失败,要么连接被提前关掉。 两者的解法不同,不能混为一谈。

WebSocket 反向代理:三行配置解决 90% 的问题

最小可用配置只需要三行,把 UpgradeConnection 头透传给后端:

location /ws {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

proxy_http_version 1.1 这行容易被忽略。Nginx 代理后端时默认用 HTTP/1.0,而 HTTP/1.0 压根不支持 Upgrade 机制,所以必须显式声明。$http_upgrade 是 Nginx 内置变量,值就是客户端发来的 Upgrade 头内容。

配好之后可以用 wscat 快速验证:通过 Nginx 代理地址连接 wscat -c ws://your-domain.com/ws,能发消息能收消息就说明配置生效了。

超时问题:连上了但过一会儿自动断

大部分人配好 WebSocket 之后会遇到第二个坑——连接建立了,过 60 秒没有数据传输就自动断开。

原因是 Nginx 的 proxy_read_timeout 默认 60 秒。对普通 HTTP 请求来说,60 秒没响应大概率是后端挂了,断开合理。但 WebSocket 连接可能几分钟才有一次消息,60 秒的超时就太短了。一个直接的做法是把 proxy_read_timeoutproxy_send_timeout 调到 3600 秒,但这不是最优解。更靠谱的做法是让应用层做心跳保活——WebSocket 协议本身支持 Ping/Pong 帧,服务端每 30 秒发一个 ws.ping(),超时计时器就会被重置。这样 proxy_read_timeout 保持默认 60 秒都行,还能及时检测到真正的死连接。无脑调大超时反而会让死连接长时间占用资源。

下面是 Node.js 服务端心跳的核心逻辑,每 30 秒向所有活跃连接发送协议级 Ping 帧,客户端会自动回复 Pong,Nginx 感知到数据传输就不会断连:

const wss = new WebSocket.Server({ port: 3000 });
wss.on('connection', (ws) => {
    const heartbeat = setInterval(() => {
        if (ws.readyState === ws.OPEN) ws.ping();
    }, 30000);
    ws.on('close', () => clearInterval(heartbeat));
});

Connection 头的条件判断

有些教程把 Connection 头写死为 "upgrade",如果这个 location 只处理 WebSocket 请求没问题。但如果普通 HTTP 和 WebSocket 请求共用同一个路径前缀,写死就容易出事——我在一个项目里踩过这个坑,前端 fetch 请求和 WebSocket 用了同一个路径前缀 /api,写死 Connection "upgrade" 导致普通接口偶尔返回 502。

解决方案是在 http 块里用 map 做条件判断:当客户端请求携带 Upgrade 头时,Connection 设为 upgrade;普通请求没有该头,则回退为 close。这样同一个 location 就能同时服务 WebSocket 和普通 HTTP 请求:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /api {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

SSE 反向代理:关缓冲、关压缩、调超时

SSE 看起来比 WebSocket 简单——毕竟就是个长连接的 HTTP 响应,不涉及协议升级。但 Nginx 对 SSE 的干扰点更多,也更隐蔽。

第一坑:proxy_buffering 吃掉实时性

这是 SSE 最常见的坑。Nginx 默认开启 proxy_buffering,会把后端的响应数据攒到缓冲区,攒够一定量(默认 4K 或 8K,取决于系统页大小)才发给客户端。普通接口无所谓,SSE 要的就是"服务端写一条、客户端立刻收到一条",缓冲直接破坏了实时性。

表现很有迷惑性:连接建立成功,后端日志显示事件已发送,但前端 EventSourceonmessage 迟迟不触发,过几秒突然一口气收到一堆消息。排查时抓包看 Nginx 到客户端的响应,会发现数据是批量到达的而非逐条到达。

解法很简单,在 SSE 的 location 里关闭代理缓冲,同时关闭 proxy_cache 防止响应被缓存:

location /sse {
    proxy_pass http://backend:3000;
    proxy_buffering off;
    proxy_cache off;
}

第二坑:gzip 压缩阻塞数据流

如果全局开了 gzip on,SSE 的数据流也会被压缩。gzip 算法需要攒够一定量的数据才能输出一个压缩块,效果和 proxy_buffering 一样——消息被攒着了。

这个坑隐蔽得很。我曾经在一个内部监控系统(Nginx 1.22 + Node.js 18)上排查 SSE 延迟,proxy_buffering 早就关了,后端日志确认消息已发出,但前端就是 3-5 秒才收到一批。翻了大半天配置,最后发现是全局 gzip on 藏在一个 include 的公共配置文件里。SSE 消息通常很短,几十到几百字节,压缩收益几乎为零,延迟代价却很大。在 SSE 的 location 里加一行 gzip off 就解决了。

第三坑:超时断连

SSE 和 WebSocket 一样面临超时问题。服务端长时间没有事件要推,Nginx 的 proxy_read_timeout 到了就会断开连接。配置思路类似——可以调大超时,也可以让服务端定时发心跳注释。

SSE 协议规范里约定以冒号开头的行是注释,客户端的 EventSource 不会触发 onmessage,天然适合做保活。

app.get('/sse', (req, res) => {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    const heartbeat = setInterval(() => {
        res.write(': heartbeat\n\n');
    }, 15000);
    req.on('close', () => clearInterval(heartbeat));
});

把上面三个坑点的配置合在一起,就是 SSE 完整的 Nginx 配置。

location /sse {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    chunked_transfer_encoding off;
    proxy_read_timeout 86400s;
}

生产环境的边界情况

连接数限制

每个 WebSocket/SSE 连接都占用一个文件描述符。Nginx 的 worker_connections 默认值是 1024,同时在线 500 个 WebSocket 用户就可能打满(Nginx 自身也需要连接对接后端,一个客户端连接对应一个上游连接,实际容量要折半)。

系统层面需要同步调整,否则 Nginx 配置再大也会被 OS 限制挡住。worker_rlimit_nofile 控制 Nginx worker 进程的文件描述符上限,需要大于等于 worker_connections。系统级的 ulimit 也必须配合调高,否则 Nginx 启动时拿不到足够的文件描述符:

# nginx.conf 主配置
worker_processes auto;
worker_rlimit_nofile 65535;

events {
    worker_connections 65535;
    multi_accept on;
}

系统级文件描述符限制需要在 /etc/security/limits.conf 中设置,确保 Nginx 进程用户有足够的配额:

# /etc/security/limits.conf
nginx soft nofile 65535
nginx hard nofile 65535

改完后用 ulimit -n 确认生效,再 nginx -s reload。可以通过 cat /proc/<nginx_worker_pid>/limits 验证 worker 进程实际拿到的限制值。

多层代理的头丢失

生产环境经常不止一层代理:客户端 → CDN/SLB → Nginx → 后端。经过多层转发,UpgradeConnection 这些逐跳(hop-by-hop)头会被中间层剥掉,WebSocket 握手到了 Nginx 时已经丢失了关键头信息,后端收到的是一个普通 HTTP 请求。

表现为:开发环境直连 Nginx 一切正常,上了生产经过负载均衡器就连不上 WebSocket,返回 400 或 502。

解法分两步。第一步,确认前置代理(SLB/CDN)支持 WebSocket 透传并开启了相关选项,阿里云 SLB 需要在监听配置里勾选"开启 WebSocket",AWS ALB 原生支持但 CLB 需要用 TCP 监听。第二步,在 Nginx 层用 proxy_set_header 显式补上可能丢失的头,而不是依赖客户端传过来的值:

location /ws {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade websocket;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

注意这里 Upgrade 直接写死 websocket 而不是用 $http_upgrade 变量,因为变量值可能已经被前置代理清空了。

Nginx reload 断连接

执行 nginx -s reload 时,Nginx 会优雅关闭旧的 worker 进程。

如果没配 worker_shutdown_timeout,旧 worker 会一直等,直到所有长连接自然断开,导致 reload 后系统里同时跑着新旧两套 worker,内存持续上涨。配了超时(比如 30 秒),reload 时所有 WebSocket/SSE 用户会在 30 秒后被强制断开。

两种策略都不完美,实际操作中建议:

# nginx.conf 主配置
worker_shutdown_timeout 60s;

把超时设为 60 秒,给正在传输数据的连接留够缓冲时间。同时客户端必须实现自动重连——WebSocket 用库自带的 reconnect 机制,SSE 的 EventSource 本身就有自动重连能力(断开后默认 3 秒重试)。这样 reload 造成的断连对用户来说只是一次短暂的闪断,几秒后自动恢复。在需要频繁改配置的场景下,可以考虑用 upstream 的灰度策略,先切走流量再 reload,彻底避免断连。

各配置项速查

配置项 WebSocket SSE 默认值 建议值
proxy_http_version 1.1(必须) 1.1(推荐) 1.0 1.1
proxy_set_header Upgrade $http_upgrade 不需要
proxy_set_header Connection $connection_upgrade ''
proxy_buffering 默认即可 off(必须) on
gzip 默认即可 off(必须) on
proxy_read_timeout 心跳间隔×2 心跳间隔×2 60s 60-3600s
worker_connections 按最大连接数设 按最大连接数设 1024 65535
worker_shutdown_timeout 建议设置 建议设置 无限制 60s

IndexedDB实战:浏览器端离线存储与同步方案

2026年3月24日 13:48

IndexedDB实战:浏览器端离线存储与同步方案

上个月我们组接了个需求:给一个外勤巡检系统做离线支持。巡检员在信号差的工地拍照、填表单,数据先存本地,等有网了再同步上去。听起来不复杂对吧?localStorage 存一下不就完了?

我一开始也是这么想的。

这时候就不得不请出 IndexedDB 了。这东西 API 丑得让人想哭,但在浏览器端做离线存储,它几乎是唯一的正经选择。

离线数据模型设计:别偷懒,状态机是必须的

数据能存下来只是第一步。真正让我掉头发的是:怎么设计数据模型,才能在离线和在线之间无缝切换?

每条记录都要带同步状态

这个原则我们是踩了坑之后才确立的。一开始我们就存原始业务数据,等网络恢复了遍历一遍全量上传。结果发现:已经同步过的数据又传了一遍,同步失败的数据没有重试机制,用户改了已同步的数据不知道该怎么处理。

后来给每条记录加了 syncStatus 字段,整个世界清净了。

const record = {
  id: crypto.randomUUID(),       // 客户端生成 UUID,避免跟服务端 ID 冲突
  title: '5号楼电梯年检',          // 业务字段
  photos: [],                    // 存的是 Blob 引用,实际图片存在单独的 object store
  result: 'passed',
  syncStatus: 'pending',         // pending → syncing → synced / failed
  syncAttempts: 0,               // 重试次数,用于指数退避
  lastSyncError: null,           // 最近一次失败原因,方便排查
  localUpdatedAt: Date.now(),    // 本地最后修改时间
  serverUpdatedAt: null          // 服务端确认时间
}

这里有个容易忽略的细节:idcrypto.randomUUID() 在客户端生成,而不是等服务端返回自增 ID。原因是离线状态下你拿不到服务端 ID,如果用临时 ID 后续还得做一次 ID 映射,非常麻烦。

syncStatus 这个字段其实是个状态机:

  创建/修改
     ↓
  pending ──触发同步──→ syncing ──成功──→ synced
                          │                  ↑
                          失败               │
                          ↓                  │
                        failed ──重试──→ syncing

为什么要用状态机而不是简单的布尔值 isSynced: true/false?因为布尔值无法表达"正在同步中"这个中间态。如果用户在同步过程中又改了数据,你需要知道当前这条记录是"正在传"还是"还没传"。布尔值做不到。我们早期用布尔值时出过一个 bug:同步请求还在飞,用户又改了表单,改完 isSynced 被设回 false,紧接着之前的请求返回成功又把它设成 true,导致用户的最新修改永远没同步上去。状态机彻底解决了这个问题。

同步策略:三种方案,各有各的坑

离线数据攒够了,网络恢复了,怎么把数据送上去?这里有三种常见思路。

方案二:Background Sync API

这是我个人觉得设计得最优雅的方案。通过 Service Worker 注册一个同步任务,浏览器会在"合适的时机"自动触发,哪怕用户关掉了页面。主线程只需要把数据写入 IndexedDB 然后注册一个 sync tag,剩下的交给 Service Worker 在后台完成:

// 主线程:保存数据并注册同步任务
async function saveAndSync(record) {
  const db = await openDB('InspectionDB', 1)
  await db.put('reports', record)
  const registration = await navigator.serviceWorker.ready
  await registration.sync.register('sync-reports')
}

// service-worker.js:监听 sync 事件,执行实际同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-reports') {
    event.waitUntil(doSync()) // 浏览器会等 doSync() 完成
  }
})

doSync() 的内部逻辑和方案一基本一样:从 IndexedDB 捞 pending 记录,逐条推送。

这个方案的坑在于兼容性。截至 2026 年 3 月,Background Sync 基本只有 Chromium 系浏览器(Chrome、Edge)支持,Firefox 和 Safari 都没实现。如果你的用户群里有大量 iOS 用户,这个方案等于白搭。

我们的做法是把 Background Sync 当增强手段:支持就用,不支持就降级到方案一的 online 事件监听。

方案三:定时轮询 + 手动触发

最不"优雅"但最稳的方案。封装一个 SyncScheduler 类,每 30 秒检查一次有没有待同步记录,同时监听 online 事件做即时触发。核心就三件事:定时器轮询、网络恢复即时触发、明确离线时跳过。

class SyncScheduler {
  constructor(db, interval = 30000) {
    this.db = db
    this.interval = interval
    this.timer = null
  }

  start() {
    this.timer = setInterval(() => this.trySync(), this.interval)
    window.addEventListener('online', () => this.trySync())
  }

  async trySync() {
    if (!navigator.onLine) return
    const pending = await this.db.getAllFromIndex('reports', 'by_status', 'pending')
    if (pending.length === 0) return
    // 逐条同步,逻辑同方案一
  }
}

这里 trySync 先检查 navigator.onLine 再查库,避免离线时做无意义的 IndexedDB 查询。stop() 方法用于页面卸载时清理定时器,防止内存泄漏。这个方案没什么花哨的技巧,但在生产环境里反而最让人放心,用户也喜欢看到一个"同步"按钮——给他们确定感。

三个方案我们最终是混着用的:Background Sync 做第一优先级,online 事件做第二优先级,定时轮询做兜底。手动同步按钮作为用户最后的"救命稻草"。

冲突处理:离线同步绕不过的硬骨头

两个巡检员同时离线编辑了同一条报告,回到有网的时候同步上去,服务端收到两个不同版本,听谁的?

策略一:Last Write Wins(最后写入胜出)

最简单——谁的时间戳新,就用谁的。服务端拿 incoming.localUpdatedAt 和已有记录比较,新的覆盖旧的,旧的直接拒绝。实现成本几乎为零。

function handleSync(incoming) {
  const existing = db.findById(incoming.id)
  if (!existing || incoming.localUpdatedAt > existing.localUpdatedAt) {
    db.save(incoming)
    return { status: 'accepted' }
  }
  return { status: 'rejected', reason: 'stale' }
}

问题也很明显:先提交的人的修改会被静默覆盖掉,他完全不知道自己的数据被人"踩"了。对于巡检系统这种场景,一条报告被覆盖可能意味着安全隐患被忽略。所以这个策略只适合数据覆盖后果不严重的场景,比如草稿自动保存。

策略二:服务端仲裁 + 冲突提示

同步的时候带上一个版本号。如果服务端发现版本号对不上,就拒绝写入,把冲突抛给客户端让用户自己决定。客户端在请求体里带上 expectedVersion(即"我修改时基于的版本号"),服务端比对后如果版本不匹配,就返回冲突状态和最新的服务端数据:

async function syncRecord(record) {
  const res = await fetch('/api/reports/sync', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...record, expectedVersion: record.version })
  })
  const result = await res.json()
  if (result.conflict) {
    await db.put('reports', {
      ...record, syncStatus: 'conflict', serverVersion: result.serverData
    })
    showConflictResolver(record, result.serverData) // 弹窗让用户对比选择
  }
}

拿到冲突后,客户端把 syncStatus 设为 conflict,同时把服务端版本缓存到 serverVersion 字段。然后弹一个对比界面,左右两栏分别展示"我的版本"和"服务端版本",让用户逐字段选择保留哪个。用户选完后生成一个合并版本,带着新版本号重新提交。这套流程对用户有一定打扰,但对于巡检报告这类数据准确性要求高的场景,宁可多问一句也不能静默丢数据。

策略三:CRDT(无冲突数据类型)

CRDT 的思路是从数据结构层面消除冲突:每个客户端的操作都设计成可交换、可结合的,合并时不需要协调。举个最简单的例子——G-Counter(增长计数器)。假设要统计某个巡检点的检查次数,两个巡检员 A 和 B 各自离线期间分别检查了 3 次和 2 次:

A 的本地计数器: { A: 3, B: 0 }
B 的本地计数器: { A: 0, B: 2 }

合并规则:对每个节点取 max → { A: 3, B: 2 } → 总数 = 5

不管 A 和 B 的数据以什么顺序到达服务端,合并结果都是 5,不需要冲突处理。这对计数器、集合添加这类操作确实很优雅。

但问题在于,我们的巡检表单是"一个表单一堆字段"——检查结果、备注、整改意见、签名……这些字段是整体覆盖式更新,不是可累加的操作。你没法对"备注从'正常'改成'有裂缝'"和"备注从'正常'改成'需复检'"做 max 合并,因为文本字段没有偏序关系。要让 CRDT 处理这种任意字段的表单编辑,你得把每个字段拆成独立的 Last-Writer-Wins Register,再组合成一个 Map CRDT,实现复杂度直接起飞,而且最终效果和策略二的逐字段冲突对比差不多,还不如让用户自己选。

生产环境的架构拼图

跑了三个月之后整个离线同步系统的架构大致是这样的:

┌──────────────────────────────────────────────────┐
│                  用户操作层                        │
│   表单提交 / 拍照上传 / 列表查看                    │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│               离线数据管理层                       │
│  CRUD API(idb) + 状态机管理 + 配额监控/清理        │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│                IndexedDB                          │
│  reports 表 + attachments 表                      │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│              同步调度层                            │
│  Background Sync → online 事件 → 定时轮询         │
│  并发控制(3路) + 指数退避重试                      │
└────────────────────┬─────────────────────────────┘
                     │
              ┌──────▼──────┐
              │  服务端 API  │
              │ 版本号校验   │
              │ 冲突检测     │
              └─────────────┘

跑了三个月,稳定服务了 200 多个巡检员的日常使用。期间收集到的数据:| 指标 | 数值 | |------|------| | 单用户日均离线记录 | 1530 条 | | 平均单条记录大小(含图片引用) | 28KB(图片 Blob 另算) | | 单用户 IndexedDB 平均占用 | 45MB | | 同步成功率(首次尝试) | 94.7% | | 重试后最终同步成功率 | 99.6% | | 冲突发生率 | 0.3%(大部分是同一巡检点被两人同时检查) | | 清理后平均释放空间 | 每周约 12MB/用户 |

剩下 0.4% 同步始终失败的,基本都是网络极端不稳导致的超时,最后靠用户手动点同步按钮解决。

如果你也在做类似的离线功能,附一下我们实际的迭代过程供参考:

阶段 时间 方案 触发升级的事件
V1 第1~2周 idb + online 事件 + Last Write Wins 能跑通基本流程
V2 第3周 加入定时轮询兜底 发现工地 WiFi 频繁假在线,online 事件不触发同步
V3 第5周 加入版本号冲突检测 两个巡检员覆盖了同一条报告,甲方投诉数据丢失
V4 第7周 加入 Background Sync + 降级策略 用户反馈关掉页面后数据没同步,第二天才发现
V5 第9周 加入配额监控和自动清理 一个巡检员的手机浏览器 IndexedDB 爆了

总共花了大约两个月从最简单的 V1 演进到当前的混合方案。每次升级都是被线上问题推着走的,没有一次是"提前设计"出来的。

JavaScript 深拷贝与浅拷贝详解

2026年3月24日 13:32

前言

在 JavaScript 开发中,拷贝对象是一个非常常见的操作。
但很多时候你以为“复制成功了”,其实只是复制了引用,这就会引发很多 Bug。

所以必须搞清楚:

  • 什么是浅拷贝
  • 什么是深拷贝
  • 常见实现方式有哪些

一、浅拷贝是什么?

浅拷贝只会复制对象的第一层属性。
如果属性值还是对象,那么复制的仍然是引用地址。

const obj1 = {
  name: 'Tom',
  info: {
    age: 18
  }
};

const obj2 = { ...obj1 };

obj2.info.age = 20;

console.log(obj1.info.age); // 20

说明obj1.infoobj2.info指向同一个对象。


二、常见浅拷贝方式

1)展开运算符

const obj2 = { ...obj1 };

2)Object.assign

const obj2 = Object.assign({}, obj1);

这两种都属于浅拷贝。


三、深拷贝是什么?

深拷贝会递归复制对象的每一层,生成完全独立的新对象。

const obj1 = {
  name: 'Tom',
  info: {
    age: 18
  }
};

const obj2 = JSON.parse(JSON.stringify(obj1));

obj2.info.age = 20;

console.log(obj1.info.age); // 18

四、JSON 深拷贝的问题

虽然:

JSON.parse(JSON.stringify(obj))

很常见,但它有局限:

  • 不能拷贝函数

  • 不能拷贝 undefined

  • 不能拷贝Symbol

  • 不能处理循环引用

  • DateRegExp 会丢失信息


五、手写一个简单深拷贝

function deepClone(obj) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  const result = Array.isArray(obj) ? [] : {};

  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      result[key] = deepClone(obj[key]);
    }
  }

  return result;
}

六、总结

浅拷贝和深拷贝最核心的区别是:

  • 浅拷贝:只拷贝第一层
  • 深拷贝:递归拷贝所有层级

实际开发中要根据场景选择,不要把浅拷贝误当成深拷贝。

JavaScript call、apply、bind 详解

2026年3月24日 13:27

前言

callapplybind是 JavaScript 中用来显式改变this的三个常用方法。 它们既是开发中常见工具,也是面试高频考点。


一、为什么需要 call、apply、bind?

有时候我们希望一个函数在执行时,this指向指定对象。

例如:

function say() {
  console.log(this.name);
}

const obj = {
  name: 'Tom'
};

如果直接调用:

say();

此时this不会指向obj。所以我们需要:

say.call(obj);

二、call

call会立即执行函数,并且第一个参数就是指定的this

function say(age) {
  console.log(this.name, age);
}

const obj = { name: 'Tom' };

say.call(obj, 18); // Tom 18

三、apply

applycall类似,也会立即执行函数。区别在于参数形式不同:

  • call:一个一个传参数

  • apply :参数放在数组里传

function say(age, city) {
  console.log(this.name, age, city);
}

const obj = { name: 'Tom' };

say.apply(obj, [18, 'Beijing']); // Tom 18 Beijing

四、bind

bind不会立即执行函数,而是返回一个新的函数,这个新函数的

this已经被绑定好了。

function say(age) {
  console.log(this.name, age);
}

const obj = { name: 'Tom' };

const fn = say.bind(obj, 18);
fn(); // Tom 18

五、三者区别总结

相同点

  • 都可以改变函数执行时的 this

不同点

call

  • 立即执行
  • 参数逐个传

apply

  • 立即执行
  • 参数数组传

bind

  • 不立即执行
  • 返回新函数

六、常见使用场景

1)借用方法

const arrLike = { 0: 'a', 1: 'b', length: 2 };
const arr = Array.prototype.slice.call(arrLike);

console.log(arr); // ['a', 'b']

2)绑定 this

const obj = {
  name: 'Tom',
  say() {
    console.log(this.name);
  }
};

setTimeout(obj.say.bind(obj), 1000);

七、总结

callapplybind的核心作用就是:

显式指定函数执行时的 this。

记忆口诀:

  • call :立即调用,参数逐个传

  • apply:立即调用,参数数组传

  • bind:不立即调用,返回新函数

JS 异步:Event-Loop+async/await

作者 不甜情歌
2026年3月24日 13:24

前言

在前端开发中,是不是经常被JS的异步代码绕晕?明明写的代码顺序一样,运行结果却大相径庭?其实核心原因就在于——JS是单线程语言,而异步操作全靠「Event-Loop(事件循环)」来调度。今天就结合具体代码案例,从进程线程、V8引擎到Event-Loop、async/await,一步步拆解,让你搞懂JS异步的底层逻辑~

一、先搞基础:进程 vs 线程

在聊JS异步之前,我们得先分清两个容易混淆的概念:进程和线程,这是理解后续内容的基石~

  • 进程 :简单来说,进程就是CPU运行指令、加载和保存上下文所需的“容器”,是程序运行的独立单位。比如你打开浏览器,每多开一个Tab页,就相当于多开启了一个进程,每个进程之间相互独立,互不干扰。

  • 线程 :线程是进程内的执行单元,是CPU实际执行指令的“最小单位”。一个进程可以包含多个线程,这些线程共享进程的资源,协同完成任务。

举个浏览器的例子:每个浏览器Tab进程中,会包含多个核心线程,其中和我们JS相关的有3个:

  1. 渲染线程:负责渲染页面(HTML、CSS渲染);

  2. JS引擎线程:负责执行JS代码;

  3. HTTP请求线程:负责发送网络请求。

这里有个关键知识点⚠️:因为JS代码可以修改DOM(比如document.write、appendChild),如果JS引擎线程和渲染线程同时运行,会导致页面渲染混乱,所以JS引擎线程和渲染线程是互斥的——也就是说,JS代码执行时,渲染线程会暂停,等JS执行完,渲染线程才会继续工作。这也是为什么有时候JS代码写得太复杂,页面会出现“卡顿”的原因~

二、V8引擎:JS单线程的“幕后推手”

我们写的JS代码,最终是由V8引擎来执行的。而V8引擎在执行JS代码时,默认只开启一个JS引擎线程——这就意味着,JS代码只能“自上而下、依次执行”,同一时间只能做一件事。

那问题来了:如果JS遇到耗时操作(比如setTimeout、网络请求、读取文件),难道要一直等着操作完成,再继续执行后续代码吗?这样会导致页面卡死,用户体验直接拉胯!

为了解决这个问题,JS引入了「异步机制」:单线程处理代码时,遇到同步任务,就立即执行;遇到异步任务,不等待、不阻塞,而是把它暂时存放到“任务队列”中,等JS引擎线程空闲时,再去执行任务队列中的异步任务。

三、核心重点:Event-Loop 事件循环

Event-Loop(事件循环)就是JS处理异步任务的“调度器”,它的执行流程决定了所有同步、异步代码的运行顺序。我们先明确两个核心概念:微任务宏任务——所有异步任务,都会被分到这两个队列中。

3.1 微任务 vs 宏任务

微任务和宏任务的区别,在于它们的“优先级”:微任务优先级高于宏任务,会先于宏任务执行。

  • 微任务(优先级高)

    • Promise.then()、Promise.catch()、Promise.finally()

    • process.nextTick()(Node.js环境,浏览器不支持)

    • MutationObserver(监听DOM变化的API)

  • 宏任务(优先级低)

    • 整个script脚本(最外层的同步代码,属于宏任务的开端)

    • setTimeout()、setInterval()

    • AJAX请求、I/O操作(比如读取文件)

    • UI渲染(页面渲染操作)

    易错点提醒⚠️:很多人会误以为“setTimeout(fn, 0)”会立即执行,其实不然——setTimeout的延迟时间是“最小延迟”,不是“精确延迟”,即使设为0,也会被放入宏任务队列,等待同步代码、微任务全部执行完毕后,才会执行。

3.2 Event-Loop 执行顺序

记住这个顺序,就能搞定80%的异步代码输出题,结合后面的代码案例理解更透彻👇:

  1. 先执行同步代码(最外层script脚本,属于宏任务),执行过程中遇到异步任务,就分别存入微任务队列、宏任务队列;

  2. 同步代码执行完毕后,清空微任务队列(所有微任务依次执行,执行过程中产生的新微任务,也会在本次微任务队列中执行完毕);

  3. 微任务全部执行结束后,若有需要(如DOM发生变化),浏览器会进行页面渲染

  4. 渲染完成后,从宏任务队列中取出第一个宏任务执行(执行该宏任务的过程中,遇到同步、异步任务,重复步骤1-2);

  5. 重复步骤1-4,形成“循环”,这就是Event-Loop。

3.3 代码实操:搞懂Event-Loop执行顺序

结合你给出的第一段代码,我们一步步拆解执行过程,看看为什么输出结果是「1 2 7 3 5 4 6」:

console.log(1); // 同步代码:输出1
new Promise((resolve) => {
  console.log(2); // Promise构造函数内是同步代码:输出2
  resolve()
})
.then(() => {
  console.log(3); // 微任务:存入微任务队列
  setTimeout(() => {
    console.log(4); // 宏任务:存入宏任务队列(延迟0ms)
  }, 0)
})
setTimeout(() => {
  console.log(5); // 宏任务:存入宏任务队列(延迟0ms)
  setTimeout(() => {
    console.log(6); // 宏任务:存入宏任务队列(延迟0ms)
  }, 0)
}, 0)
console.log(7); // 同步代码:输出7

执行步骤拆解👇:

  1. console.log(1):同步,输出「1」;

  2. new Promise:构造函数内是同步代码,console.log(2),输出「2」;调用resolve(),将then回调存入微任务队列(记为微1);

  3. 遇到setTimeout(延迟0ms):宏任务,存入宏任务队列(记为宏1);

  4. console.log(7):同步,输出「7」;

  5. 同步代码执行完毕,开始清空微任务队列:执行微1(then回调),console.log(3),输出「3」;遇到setTimeout(延迟0ms),宏任务,存入宏任务队列(记为宏2);

  6. 微任务队列清空,渲染页面(本次无明显渲染);

  7. 执行宏任务队列第一个宏任务(宏2):console.log(5),输出「5」;遇到setTimeout(延迟0ms),宏任务,存入宏任务队列(记为宏3);

  8. 宏2执行完毕,再次检查微任务队列(无新微任务),执行下一个宏任务(宏2):console.log(4),输出「4」;

  9. 宏3执行完毕,检查微任务队列(无),执行下一个宏任务(宏3):console.log(6),输出「6」。

所以最终输出顺序就是:1 2 7 3 5 4 6 ✅

3.4 再练一题:巩固Event-Loop

再看这段代码👇:

console.log(1);

setTimeout(() => {
  console.log(2);
  setTimeout(() => {
    console.log(3)
  }, 1000)
}, 0)

setTimeout(() => {
  console.log(4)
}, 2000)
console.log(5);

执行步骤拆解👇:

  1. 执行同步代码:console.log(1) → 输出 1

  2. 遇到第一个 setTimeout(..., 0):延迟 0ms 后,把回调(输出 2 + 嵌套定时器)推入宏任务队列

  3. 遇到第二个 setTimeout(..., 2000):延迟 2000ms 后,把回调(输出 4)推入宏任务队列

  4. 执行同步代码:console.log(5) → 输出 5

  5. 同步代码执行完毕,开始处理宏任务队列

    • 取出第一个宏任务:执行 → 输出 2
    • 执行中遇到嵌套的 setTimeout(..., 1000):延迟 1000ms 后,把回调(输出 3)推入宏任务队列;
  6. 此时宏任务队列里,只有「延迟 2000ms 的输出 4」在等待

  7. 时间流逝:

    • 1000ms 到:输出3 被推入宏任务队列 → 立刻执行 → 输出 3
    • 再等 1000ms(总计 2000ms):输出4 被推入宏任务队列 → 执行 → 输出 4

最终输出顺序:1 → 5 → 2 → 3 → 4

宏任务队列是先进先出,为什么 3 比 4 先输出?

关键点:两个定时器不是同时入队

  • 输出 4 的定时器:一开始就设定了 2000ms 延迟,2000ms 后才入队;
  • 输出 3 的定时器:等第一个宏任务执行完(瞬间完成),才设定 1000ms 延迟,1000ms 后就入队执行。1000ms < 2000ms,所以 3 必然比 4 先执行,和宏任务队列顺序无关。

JavaScript 中setTimeout的延迟时间是回调函数加入宏任务队列的等待时间,而非执行时间;宏任务队列遵循先进先出,但不同定时器的回调不是同时入队,谁的延迟时间先耗尽,谁就先入队先执行。

四、进阶:async/await 异步语法糖

async/await 是ES7引入的异步语法,本质是Promise的“语法糖”,让异步代码写起来更像同步代码,可读性大大提升。

4.1 async/await 核心规则

  • async关键字:函数前面加async,等同于函数内部自动返回一个Promise实例对象。比如: async function fn() { return 1; // 等同于 return Promise.resolve(1); } fn().then(res => console.log(res)); // 输出1

  • await关键字:必须跟async配合使用,不能单独使用;如果await后面接的不是Promise对象,await就无法“约束”它,会直接执行后续代码;如果await后面接Promise对象,会“暂停”当前async函数的执行,等待Promise状态变为resolved(成功)或rejected(失败),再继续执行后续代码。

  • 关键原理:await fn() 之所以能“当成同步看待”,核心是——await会把它后续的代码(当前async函数内,await后面的所有代码),挤到微任务队列中,等await后面的Promise执行完成后,再执行这个微任务。

4.2 代码实操:async/await 执行顺序

先看这段基础代码,理解async/await和Promise的关联:

function a() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('a'); // 宏任务
      resolve()
    }, 1000)
  })
}
function b() {
  console.log('b'); // 同步
}

// 案例1:Promise.then写法
a().then(() => {
  b()
})
console.log('hello'); // 同步

输出顺序:hello → a → b(同步代码先执行,a()是Promise,then回调是微任务,等待a()的宏任务执行完,再执行微任务b())

再看async/await写法,对比差异:

function a() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('a'); // 宏任务
      resolve()
    }, 1000)
  })
}
function b() {
  console.log('b'); // 同步
}

async function foo() {
  setTimeout(() => {
    console.log('c'); // 宏任务(延迟1500ms)
  }, 1500)

  await a()  // 等待a()的Promise resolve,后续代码进入微任务
  b()
  console.log('hello');
}

foo()

执行步骤拆解👇:

  1. 调用foo(),执行async函数内部代码;

  2. 遇到setTimeout(延迟1500ms):宏任务,存入宏任务队列(记为宏A);

  3. 遇到await a():a()返回Promise,里面有setTimeout(延迟1000ms,宏任务,记为宏B);此时foo()暂停执行,等待宏B执行完毕、Promise resolve;

  4. 同步代码执行完毕(此时foo()暂停,无其他同步代码),检查微任务队列(无),执行宏任务队列;

  5. 先执行宏B(延迟1000ms):console.log('a'),输出「a」;调用resolve(),此时await等待结束,将foo()后续的代码(b()、console.log('hello'))存入微任务队列;

  6. 宏B执行完毕,检查微任务队列,执行微任务:b()输出「b」,console.log('hello')输出「hello」;

  7. 微任务执行完毕,执行下一个宏任务(宏A,延迟1500ms):console.log('c'),输出「c」。

最终输出顺序:a → b → hello → c ✅

4.3 综合案例:async/await + Promise + setTimeout

console.log('script start'); // 同步
async function async1() {
  await async2() // 等待async2()执行,后续代码进入微任务
  console.log('async1 end'); // 微任务
}
async function async2() {
  console.log('async2 end'); // 同步(async函数内,await前的代码是同步)
}
async1()
setTimeout(() => {
  console.log('setTimeout'); // 宏任务
}, 0)
new Promise((resolve, reject) => {
  console.log('promise'); // 同步
  resolve()
})
  .then(() => {
    console.log('then1'); // 微任务
  })
  .then(() => {
    console.log('then2'); // 微任务(then1执行完后存入)
  }); 
console.log('script end'); // 同步

输出顺序:script start → async2 end → promise → script end → async1 end → then1 → then2 → setTimeout

💡 关键提醒:async函数内,await前面的代码是同步执行的;await后面的代码会被放入微任务队列,和Promise.then的微任务优先级相同,按顺序执行。

五、总结:异步核心知识点梳理

1. JS是单线程,由V8引擎的JS引擎线程执行,与渲染线程互斥;

2. 异步任务分为微任务(优先级高)和宏任务(优先级低);

3. Event-Loop执行顺序:同步代码 → 微任务队列 → 页面渲染 → 宏任务队列(循环);

4. async/await是Promise语法糖,await会将后续代码放入微任务队列,等待Promise resolve后执行。

JavaScript 作用域与作用域链详解

2026年3月24日 13:19

前言

在 JavaScript 中,作用域是非常基础但又非常重要的知识点。
闭包、变量查找、函数执行、模块化等内容,都和作用域密切相关。

本文主要讲清楚:

  • 什么是作用域
  • JavaScript 有哪些作用域
  • 什么是作用域链
  • 变量查找规则是什么

一、什么是作用域?

作用域可以理解为:

变量和函数可以被访问的范围。

也就是说,一个变量不是在任何地方都能访问,它的可访问范围由作用域决定。

例如:

let a = 10;

function test() {
  let b = 20;
  console.log(a); // 可以访问
}

test();
// console.log(b); // 报错

这里:

  • a 在全局作用域中

  • b 在函数作用域中

  • 函数内部可以访问全局变量

  • 全局不能访问函数内部变量


二、JavaScript 中的几种作用域

1)全局作用域

定义在函数外部的变量,通常属于全局作用域。

let name = 'Tom';

function say() {
  console.log(name);
}

全局作用域中的变量,在当前脚本中通常都可以访问。


2)函数作用域

在函数内部声明的变量,只能在函数内部访问。

function test() {
  let age = 18;
  console.log(age);
}

test();
// console.log(age); // 报错

3)块级作用域

使用letconst声明的变量,会形成块级作用域。

{
  let a = 1;
  const b = 2;
}

// console.log(a); // 报错
// console.log(b); // 报错

ifforwhile{}都可以形成块级作用域。


三、var、let、const 的作用域区别

var

  • 没有块级作用域
  • 只有全局作用域和函数作用域
if (true) {
  var a = 10;
}

console.log(a); // 10

let / const

  • 有块级作用域
if (true) {
  let b = 20;
}

// console.log(b); // 报错

四、什么是作用域链?

当在当前作用域中查找某个变量时,如果找不到,就会去上一级作用域查找,直到全局作用域。
这个逐级向上查找的过程,就叫做 作用域链


五、作用域链示例

let a = 1;

function outer() {
  let b = 2;

  function inner() {
    let c = 3;
    console.log(“结果”,a, b, c);
  }

  inner();
}

outer();

输出:

结果,1 2 3

查找过程:

  • c:先在inner 自己内部找,找到

  • binner找不到,去outer 找,找到

  • ainner找不到,outer 找不到,去全局找,找到

这就是作用域链。


六、作用域链的本质

作用域链的本质是:

函数在定义时,就已经确定了它能访问哪些外部变量。

注意,是定义时,不是调用时。

这也是闭包能成立的基础。


七、总结

作用域决定了变量的可访问范围,作用域链决定了变量的查找路径。

重点记住:

  • 全局作用域
  • 函数作用域
  • 块级作用域
  • 变量查找是逐级向上找
  • 找不到最终会报错

JavaScript this 指向详解

2026年3月24日 13:10

前言

在 JavaScript 中,this是一个非常高频、也非常容易让人混乱的知识点。
很多初学者会发现:同样是一个函数,在不同场景下调用this的值居然不一样。

比如:

  • 普通函数里的this

  • 对象方法里的this

  • 构造函数里的this

  • 箭头函数里的this

  • callapplybind改变this指向

本文就来系统讲清楚:JavaScript 中 this 到底指向谁。


一、this 是什么?

this不是在函数定义时决定的,而是在函数调用时决定的

也就是说:

谁调用这个函数,this 通常就指向谁。

先看一个简单例子:

const obj = {
  name: 'Tom',
  say() {
    console.log(this.name);
  }
};

obj.say(); // Tom

这里say()是被obj调用的,所以this指向obj


二、普通函数中的 this

1)浏览器非严格模式下

function test() {
  console.log(this);
}

test();

在浏览器非严格模式下,普通函数直接调用时,

this指向window


2)严格模式下

'use strict';

function test() {
  console.log(this);
}

test();

严格模式下,普通函数直接调用时,

thisundefined


三、对象方法中的 this

如果函数作为对象的方法调用,那么this指向这个对象。

const obj = {
  name: 'Alice',
  say() {
    console.log(this.name);
  }
};

obj.say(); // Alice

注意,真正决定this的,不是函数写在哪,而是怎么调用

const obj = {
  name: 'Alice',
  say() {
    console.log(this.name);
  }
};

const fn = obj.say;
fn(); // 浏览器非严格模式下通常是 undefined 或 window.name

这里fn()已经不是通过obj调用了,所以this不再指向obj


四、构造函数中的 this

当函数通过new调用时,this指向新创建的实例对象。

function Person(name) {
  this.name = name;
}

const p = new Person('Tom');
console.log(p.name); // Tom

这里this指向p


五、箭头函数中的 this

箭头函数没有自己的this,它的this取决于外层作用域

const obj = {
  name: 'Tom',
  say: () => {
    console.log(this.name);
  }
};

obj.say();

这里箭头函数不会绑定obj,而是继承外层的this


如果在浏览器全局环境下,通常指向window


一个更容易理解的例子

const obj = {
  name: 'Tom',
  say() {
    const fn = () => {
      console.log(this.name);
    };
    fn();
  }
};

obj.say(); // Tom

这里箭头函数fn继承了say()this,也就是obj


六、事件中的 this

在 DOM 事件中,普通函数里的this一般指向触发事件的元素。

button.onclick = function () {
  console.log(this); // button 元素
};

如果写成箭头函数:

button.onclick = () => {
  console.log(this);
};

那么这里的this不再指向按钮,而是继承外层作用域。


七、call、apply、bind 改变 this

JavaScript 提供了三种显式改变this的方法:

  • call
  • apply
  • bind
function say() {
  console.log(this.name);
}

const obj = { name: 'Tom' };

say.call(obj);  // Tom
say.apply(obj); // Tom

bind 不会立即执行,而是返回一个新的函数:

const fn = say.bind(obj);
fn(); // Tom

八、this 指向总结

可以简单记住下面几条:

  1. 普通函数直接调用:浏览器非严格模式下指向 window

  2. 对象方法调用:指向调用它的对象

  3. 构造函数调用:指向新实例

  4. 箭头函数:没有自己的 this ,继承外层

  5. call/apply/bind:可以显式指定 this


九、总结

this的核心不是“函数定义在哪”,而是:

函数是如何被调用的。

只要抓住这句话,再结合几种常见场景,this就不会再那么抽象了。

AI协同写作应用-TipTap基础功能

2026年3月24日 12:46

前言

系列教程和源码在飞书文档编写。

本章概述

在本章中,我们将快速上手 Tiptap,从零开始创建一个功能完整的富文本编辑器。你将学会如何安装、配置和使用 Tiptap 的基础功能。

学习目标:

  • 创建一个新的前端项目
  • 安装 Tiptap 及其依赖
  • 创建第一个可用的编辑器
  • 使用 StarterKit 快速添加功能
  • 理解基本配置选项
  • 添加简单的工具栏

前置知识:

  • Node.js 和 npm/pnpm 基础
  • HTML、CSS、JavaScript 基础
  • 基础的命令行操作

预计学习时间: 30-45 分钟


1. 环境准备

1.1 检查 Node.js 版本

Tiptap 需要 Node.js 16+ 版本。

# 检查 Node.js 版本
node --version
# 应该显示 v16.0.0 或更高版本

# 检查 npm 版本
npm --version

如果版本过低,请访问 nodejs.org 下载最新的 LTS 版本。

1.2 选择包管理器

本教程推荐使用 pnpm,它比 npm 更快、更节省磁盘空间。

# 安装 pnpm(如果还没有)
npm install -g pnpm

# 验证安装
pnpm --version

当然,你也可以使用 npm 或 yarn:

# 使用 npm
npm install

# 使用 yarn
yarn add

💡 提示: 本教程的所有命令都使用 pnpm,如果你使用其他包管理器,请相应替换命令。


2. 创建项目

2.1 使用 Vite 创建项目

我们使用 Vite 创建一个 React + TypeScript 项目。

# 创建项目
pnpm create vite tiptap-demo --template react-ts

# 进入项目目录
cd tiptap-demo

# 安装依赖
pnpm install

为什么选择 Vite?

  • ⚡ 极快的启动速度
  • 🔥 热更新(HMR)快速
  • 📦 开箱即用的 TypeScript 支持
  • 🛠️ 现代化的构建工具

2.2 项目结构

创建完成后,项目结构如下:

tiptap-demo/
├── node_modules/
├── public/
├── src/
│   ├── App.css
│   ├── App.tsx
│   ├── main.tsx
│   └── vite-env.d.ts
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts

2.3 启动开发服务器

pnpm dev

打开浏览器访问 http://localhost:5173,你应该能看到 Vite 的欢迎页面。


3. 安装 Tiptap

3.1 安装核心包

pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit

包的说明:

包名 版本 大小 说明
@tiptap/react ^2.x ~15KB React 集成包,提供 Hooks 和组件
@tiptap/pm ^2.x ~200KB ProseMirror 核心依赖
@tiptap/starter-kit ^2.x ~30KB 常用扩展集合(15+ 扩展)

总大小: ~245KB(未压缩),~80KB(gzip 压缩后)

3.2 验证安装

检查 package.json 文件,应该能看到:

{
  "dependencies": {
    "@tiptap/pm": "^2.x.x",
    "@tiptap/react": "^2.x.x",
    "@tiptap/starter-kit": "^2.x.x",
    "react": "^18.x.x",
    "react-dom": "^18.x.x"
  }
}

4. 创建第一个编辑器

4.1 清理默认代码

首先,清理 Vite 生成的默认代码。

修改 src/App.tsx

// src/App.tsx
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
    </div>
  )
}

export default App

修改 src/App.css

/* src/App.css */
.app {
  max-width: 900px;
  margin: 0 auto;
  padding: 2rem;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

h1 {
  margin-bottom: 2rem;
  color: #333;
}

4.2 创建编辑器组件

创建 src/Tiptap.tsx 文件:

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

function Tiptap() {
  const editor = useEditor({
    extensions: [
      StarterKit,
    ],
    content: '<p>Hello World! 🌍</p>',
  })

  return <EditorContent editor={editor} />
}

export default Tiptap

代码解析:

  1. 导入必要的模块

    import { useEditor, EditorContent } from '@tiptap/react'
    import StarterKit from '@tiptap/starter-kit'
    
    • useEditor: React Hook,用于创建编辑器实例
    • EditorContent: React 组件,用于渲染编辑器
    • StarterKit: 包含 15+ 个常用扩展
  2. 创建编辑器实例

    const editor = useEditor({
      extensions: [StarterKit],
      content: '<p>Hello World! 🌍</p>',
    })
    
    • extensions: 配置编辑器使用的扩展
    • content: 初始内容(HTML 格式)
  3. 渲染编辑器

    return <EditorContent editor={editor} />
    
    • EditorContent 组件接收编辑器实例并渲染

4.3 在 App 中使用

修改 src/App.tsx

// src/App.tsx
import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

4.4 添加基础样式

src/App.css 中添加编辑器样式:

/* src/App.css */

/* ... 之前的样式 ... */

/* 编辑器容器样式 */
.tiptap {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  padding: 1rem;
  min-height: 200px;
  outline: none;
  background-color: white;
}

/* 编辑器获得焦点时的样式 */
.tiptap:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

/* 段落样式 */
.tiptap p {
  margin: 0.75rem 0;
  line-height: 1.6;
}

/* 第一个段落不需要上边距 */
.tiptap p:first-child {
  margin-top: 0;
}

/* 最后一个段落不需要下边距 */
.tiptap p:last-child {
  margin-bottom: 0;
}

4.5 测试编辑器

保存所有文件,浏览器应该自动刷新。你应该能看到:

  • 一个带边框的编辑区域
  • 初始内容 "Hello World! 🌍"
  • 可以输入、删除文字
  • 可以使用快捷键(Ctrl+B 加粗、Ctrl+Z 撤销等)

测试清单:

  • ✅ 输入文字
  • ✅ 删除文字
  • ✅ 换行(按 Enter)
  • ✅ 撤销(Ctrl+Z)
  • ✅ 重做(Ctrl+Shift+Z)
  • ✅ 加粗(Ctrl+B)
  • ✅ 斜体(Ctrl+I)

5. 理解 StarterKit

5.1 StarterKit 包含的扩展

StarterKit 是一个扩展集合,包含了最常用的 15+ 个扩展:

Nodes(节点):

  • Document - 文档根节点
  • Paragraph - 段落
  • Text - 文本
  • Heading - 标题(H1-H6)
  • Blockquote - 引用块
  • CodeBlock - 代码块
  • BulletList - 无序列表
  • OrderedList - 有序列表
  • ListItem - 列表项
  • HardBreak - 硬换行
  • HorizontalRule - 水平分割线

Marks(标记):

  • Bold - 加粗
  • Italic - 斜体
  • Strike - 删除线
  • Code - 行内代码

Extensions(功能):

  • History - 撤销/重做
  • Dropcursor - 拖放光标
  • Gapcursor - 间隙光标

5.2 测试 StarterKit 功能

让我们测试一下这些功能。修改初始内容:

const editor = useEditor({
  extensions: [StarterKit],
  content: `
    <h1>欢迎使用 Tiptap</h1>
    <p>这是一个<strong>功能强大</strong>的<em>富文本编辑器</em>。</p>
    <h2>主要特性</h2>
    <ul>
      <li>支持多种文本格式</li>
      <li>可扩展的架构</li>
      <li>优秀的性能</li>
    </ul>
    <blockquote>
      <p>Tiptap 让编辑器开发变得简单而有趣。</p>
    </blockquote>
    <pre><code>const editor = useEditor({ ... })</code></pre>
  `,
})

现在你应该能看到:

  • 标题(H1、H2)
  • 加粗和斜体文字
  • 无序列表
  • 引用块
  • 代码块

5.3 自定义 StarterKit

你可以禁用某些扩展或自定义配置:

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      // 禁用某些扩展
      heading: false,
      
      // 自定义扩展配置
      bulletList: {
        HTMLAttributes: {
          class: 'my-bullet-list',
        },
      },
      
      // 自定义标题级别
      heading: {
        levels: [1, 2, 3],  // 只允许 H1、H2、H3
      },
    }),
  ],
  content: '<p>Hello World!</p>',
})

6. 添加工具栏

现在让我们添加一个简单的工具栏,让用户可以点击按钮来格式化文字。

6.1 创建工具栏组件

修改 src/Tiptap.tsx

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import './Tiptap.css'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <div className="editor-container">
      {/* 工具栏 */}
      <div className="toolbar">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          ↶ 撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          ↷ 重做
        </button>
      </div>
      
      {/* 编辑器 */}
      <EditorContent editor={editor} />
    </div>
  )
}

export default Tiptap

代码解析:

  1. 空值检查

    if (!editor) return null
    

    首次渲染时编辑器可能为 null,需要检查。

  2. Commands 链式调用

    editor.chain().focus().toggleBold().run()
    
    • chain(): 开始链式调用
    • focus(): 让编辑器获得焦点
    • toggleBold(): 切换加粗状态
    • run(): 执行命令链
  3. 检查激活状态

    editor.isActive('bold')
    

    用于高亮当前激活的按钮。

  4. 检查命令可用性

    editor.can().undo()
    

    用于禁用不可用的按钮。

6.2 添加工具栏样式

创建 src/Tiptap.css 文件:

/* src/Tiptap.css */

.editor-container {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
  background-color: white;
}

/* 工具栏样式 */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  padding: 0.75rem;
  background-color: #f9fafb;
  border-bottom: 1px solid #e5e7eb;
}

.toolbar button {
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background-color: white;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  color: #374151;
  transition: all 0.2s;
}

.toolbar button:hover:not(:disabled) {
  background-color: #f3f4f6;
  border-color: #9ca3af;
}

.toolbar button.is-active {
  background-color: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.toolbar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.toolbar .divider {
  width: 1px;
  background-color: #e5e7eb;
  margin: 0 0.25rem;
}

/* 编辑器内容样式 */
.editor-container .tiptap {
  padding: 1rem;
  min-height: 300px;
  outline: none;
  border: none;
}

.editor-container .tiptap:focus {
  box-shadow: none;
}

/* 标题样式 */
.tiptap h1 {
  font-size: 2rem;
  font-weight: 700;
  margin: 1.5rem 0 1rem;
  line-height: 1.2;
}

.tiptap h2 {
  font-size: 1.5rem;
  font-weight: 600;
  margin: 1.25rem 0 0.75rem;
  line-height: 1.3;
}

.tiptap h3 {
  font-size: 1.25rem;
  font-weight: 600;
  margin: 1rem 0 0.5rem;
  line-height: 1.4;
}

/* 列表样式 */
.tiptap ul,
.tiptap ol {
  padding-left: 1.5rem;
  margin: 0.75rem 0;
}

.tiptap li {
  margin: 0.25rem 0;
}

/* 引用块样式 */
.tiptap blockquote {
  border-left: 3px solid #3b82f6;
  padding-left: 1rem;
  margin: 1rem 0;
  color: #6b7280;
  font-style: italic;
}

/* 代码块样式 */
.tiptap pre {
  background-color: #1f2937;
  color: #f9fafb;
  padding: 1rem;
  border-radius: 6px;
  margin: 1rem 0;
  overflow-x: auto;
}

.tiptap code {
  background-color: #f3f4f6;
  color: #ef4444;
  padding: 0.2rem 0.4rem;
  border-radius: 3px;
  font-size: 0.9em;
  font-family: 'Courier New', monospace;
}

.tiptap pre code {
  background-color: transparent;
  color: inherit;
  padding: 0;
}

/* 水平分割线样式 */
.tiptap hr {
  border: none;
  border-top: 2px solid #e5e7eb;
  margin: 2rem 0;
}

6.3 测试工具栏

保存文件后,你应该能看到:

  • 一个漂亮的工具栏
  • 点击按钮可以格式化文字
  • 激活的按钮会高亮显示
  • 不可用的按钮会被禁用

测试步骤:

  1. 选中一些文字
  2. 点击 "B" 按钮,文字应该变粗
  3. 按钮应该高亮显示
  4. 再次点击,文字恢复正常

7. 基本配置选项

7.1 常用配置

const editor = useEditor({
  // 扩展配置
  extensions: [StarterKit],
  
  // 初始内容
  content: '<p>Hello World!</p>',
  
  // 是否可编辑
  editable: true,
  
  // 是否自动获取焦点
  autofocus: false,
  
  // 事件回调
  onUpdate: ({ editor }) => {
    console.log('内容已更新', editor.getHTML())
  },
  
  onCreate: ({ editor }) => {
    console.log('编辑器已创建')
  },
  
  onFocus: ({ editor }) => {
    console.log('编辑器获得焦点')
  },
  
  onBlur: ({ editor }) => {
    console.log('编辑器失去焦点')
  },
})

7.2 配置选项说明

选项 类型 默认值 说明
extensions Extension[] 必需 编辑器使用的扩展数组
content string | JSONContent '' 初始内容(HTML 或 JSON)
editable boolean true 是否可编辑
autofocus boolean | 'start' | 'end' false 自动获取焦点
onUpdate function - 内容更新时触发
onCreate function - 编辑器创建时触发
onFocus function - 获得焦点时触发
onBlur function - 失去焦点时触发

8. 完整源码

📄 src/Tiptap.tsx

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import './Tiptap.css'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <div className="editor-container">
      <div className="toolbar">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          ↶ 撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          ↷ 重做
        </button>
      </div>
      
      <EditorContent editor={editor} />
    </div>
  )
}

export default Tiptap

📄 src/App.tsx

import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

9. 本章总结

在本章中,我们学习了:

✅ 环境准备

  • 检查 Node.js 版本
  • 安装包管理器(pnpm)
  • 创建 Vite 项目

✅ 安装 Tiptap

  • 安装核心包(@tiptap/react、@tiptap/pm、@tiptap/starter-kit)
  • 理解包的作用和大小

✅ 创建编辑器

  • 使用 useEditor Hook
  • 渲染 EditorContent 组件
  • 添加基础样式

✅ StarterKit

  • 包含 15+ 个常用扩展
  • 自定义配置
  • 禁用特定扩展

✅ 添加工具栏

  • Commands 链式调用
  • 检查激活状态
  • 检查命令可用性
  • 添加工具栏样式

✅ 基本配置

  • 常用配置选项
  • 事件回调

🎯 关键知识点

1. useEditor Hook

const editor = useEditor({
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
})

2. Commands 链式调用

editor.chain().focus().toggleBold().run()

3. 检查状态

editor.isActive('bold')
editor.can().undo()

10. 下一步

现在你已经创建了第一个 Tiptap 编辑器!接下来我们将:

第 3 章:Tiptap 与 React 集成

  • 深入理解 useEditor Hook
  • 使用 EditorProvider
  • 实现自动保存
  • 处理 Next.js SSR

第 4 章:框架集成 - Vue/其他

  • Vue 集成
  • Angular 集成
  • Vanilla JavaScript

准备好继续学习了吗?🚀


11. 练习题

练习 1:添加更多按钮

在工具栏中添加以下按钮:

  • H3 标题
  • 引用块(Blockquote)
  • 代码块(CodeBlock)
  • 水平分割线(HorizontalRule)
💡 提示
<button
  onClick={() => editor.chain().focus().toggleBlockquote().run()}
  className={editor.isActive('blockquote') ? 'is-active' : ''}
>
  引用
</button>

练习 2:添加字符计数

在编辑器下方显示当前字符数。

💡 提示
const characterCount = editor.state.doc.textContent.length

<div className="character-count">
  {characterCount} 字符
</div>

练习 3:实现只读模式

添加一个切换按钮,可以切换编辑器的可编辑状态。

💡 提示
const [editable, setEditable] = useState(true)

useEffect(() => {
  if (editor) {
    editor.setEditable(editable)
  }
}, [editor, editable])

12. 常见问题

Q1: 为什么编辑器是 null?

A: 首次渲染时,编辑器还未初始化。解决方案:

if (!editor) return null

Q2: 如何获取编辑器内容?

A: 使用 getHTML()getJSON() 方法:

const html = editor.getHTML()
const json = editor.getJSON()

Q3: 如何设置编辑器内容?

A: 使用 setContent() 方法:

editor.commands.setContent('<p>新内容</p>')

Q4: 快捷键不工作?

A: 确保编辑器有焦点:

editor.chain().focus().toggleBold().run()

13. 扩展阅读


【JavaScript面试题-算法与数据结构】手写一个 LRU(最近最少使用)缓存类,支持 `get` 和 `put` 操作,要求时间复杂度 O(1)

2026年3月24日 12:27

一、数据结构设计

为了实现 get 和 put 操作的时间复杂度 O(1) ,我们组合了两种数据结构:

  1. 哈希表Map 或普通对象)

    • 存储键到双向链表节点的映射。
    • 作用:通过 key 直接定位到节点,实现 O(1) 的查找。
  2. 双向链表Node 类实现)

    • 维护所有节点的 使用顺序:链表头部(head 之后)是最近使用的节点,链表尾部(tail 之前)是最久未使用的节点。
    • 作用:在 O(1) 时间内完成节点的 移动到头部删除尾部 等操作。

此外,使用两个 哨兵节点(伪头 head 和伪尾 tail),避免处理链表为空或只有一个节点时的边界条件,使插入和删除操作更简洁。

以下是 JavaScript 手写的 LRU 缓存类,使用哈希表 + 双向链表实现,确保 get 和 put 操作时间复杂度为 O(1):

javascript

class LRUCache {
    /**
     * @param {number} capacity 缓存容量
     */
    constructor(capacity) {
        this.capacity = capacity;
        this.cache = new Map(); // 键 → 节点
        // 创建哨兵头尾节点,简化边界处理
        this.head = new Node(0, 0);
        this.tail = new Node(0, 0);
        this.head.next = this.tail;
        this.tail.prev = this.head;
    }

    /**
     * 获取键对应的值,并将该节点移动到链表头部(最近使用)
     * @param {number} key
     * @return {number}
     */
    get(key) {
        if (!this.cache.has(key)) {
            return -1;
        }
        const node = this.cache.get(key);
        this._moveToHead(node);
        return node.value;
    }

    /**
     * 插入或更新键值对,并将节点置于头部(最近使用)
     * 若容量超限,删除尾部节点(最久未使用)
     * @param {number} key
     * @param {number} value
     * @return {void}
     */
    put(key, value) {
        if (this.cache.has(key)) {
            // 已存在:更新值并移到头部
            const node = this.cache.get(key);
            node.value = value;
            this._moveToHead(node);
        } else {
            // 不存在:新建节点
            if (this.cache.size === this.capacity) {
                // 容量已满,删除尾部节点(最久未使用)
                const tailNode = this.tail.prev;
                this._removeNode(tailNode);
                this.cache.delete(tailNode.key);
            }
            const newNode = new Node(key, value);
            this.cache.set(key, newNode);
            this._addToHead(newNode);
        }
    }

    /**
     * 将节点从原位置移除,并添加到头部
     * @param {Node} node
     */
    _moveToHead(node) {
        this._removeNode(node);
        this._addToHead(node);
    }

    /**
     * 从链表中移除节点
     * @param {Node} node
     */
    _removeNode(node) {
        const prev = node.prev;
        const next = node.next;
        prev.next = next;
        next.prev = prev;
    }

    /**
     * 将节点插入到哨兵头节点之后(头部)
     * @param {Node} node
     */
    _addToHead(node) {
        node.prev = this.head;
        node.next = this.head.next;
        this.head.next.prev = node;
        this.head.next = node;
    }
}

/**
 * 双向链表节点
 */
class Node {
    constructor(key, value) {
        this.key = key;
        this.value = value;
        this.prev = null;
        this.next = null;
    }
}

使用示例

javascript

const lru = new LRUCache(2);
lru.put(1, 1);      // 缓存: {1=1}
lru.put(2, 2);      // 缓存: {1=1, 2=2}
console.log(lru.get(1)); // 返回 1,并移动 1 到头部 → 缓存顺序: 2,1
lru.put(3, 3);      // 容量已满,删除尾部 2 → 缓存: {1=1, 3=3}
console.log(lru.get(2)); // 返回 -1 (未找到)
lru.put(4, 4);      // 容量已满,删除尾部 1 → 缓存: {3=3, 4=4}
console.log(lru.get(1)); // 返回 -1
console.log(lru.get(3)); // 返回 3
console.log(lru.get(4)); // 返回 4

复杂度说明

  • get: 哈希表查找 O(1) + 链表移动 O(1) → 总体 O(1)
  • put: 哈希表插入/更新 O(1) + 可能删除尾部 O(1) + 链表操作 O(1) → 总体 O(1)

关于通过react使用hooks进行数据状态处理

2026年3月24日 12:09

你想了解在 React 中如何使用 Hooks 来处理数据状态,这是 React 函数组件开发中最核心也最基础的知识点之一。

一、React Hooks 处理状态的核心用法

React 提供了一系列内置 Hooks,其中最常用的是 useState(基础状态管理)和 useReducer(复杂状态管理),下面我会从基础到进阶,结合示例代码讲解它们的使用方式。

1. 基础状态管理:useState

useState 是最基础的 Hook,用于管理组件中的简单状态(如数字、字符串、布尔值、简单对象/数组)。

核心语法

import { useState } from 'react';

// 初始化状态:[状态变量, 更新状态的方法] = useState(初始值)
const [state, setState] = useState(initialValue);

完整示例(处理表单+简单数据)

import { useState } from 'react';

function UserInfo() {
  // 1. 单个简单状态(布尔值)
  const [isShow, setIsShow] = useState(false);
  
  // 2. 对象类型状态(用户信息)
  const [user, setUser] = useState({
    name: '',
    age: 0
  });

  // 3. 数组类型状态(爱好列表)
  const [hobbies, setHobbies] = useState(['读书']);

  // 更新简单状态
  const toggleShow = () => {
    setIsShow(!isShow);
  };

  // 更新对象状态(注意:需要解构原有对象,避免覆盖)
  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setUser({
      ...user, // 解构原有状态,保留未修改的属性
      [name]: name === 'age' ? Number(value) : value // 类型转换
    });
  };

  // 更新数组状态(不可直接修改原数组,需生成新数组)
  const addHobby = () => {
    setHobbies([...hobbies, '运动']); // 解构原数组 + 新增元素
  };

  return (
    <div>
      <button onClick={toggleShow}>{isShow ? '隐藏' : '显示'}</button>
      
      {isShow && (
        <div>
          <div>
            <input
              name="name"
              value={user.name}
              onChange={handleInputChange}
              placeholder="输入姓名"
            />
            <input
              name="age"
              type="number"
              value={user.age}
              onChange={handleInputChange}
              placeholder="输入年龄"
            />
          </div>

          <div>
            <p>爱好:{hobbies.join(', ')}</p>
            <button onClick={addHobby}>添加爱好</button>
          </div>
        </div>
      )}
    </div>
  );
}

export default UserInfo;

关键说明

  • useState 的初始值只会在组件首次渲染时生效,后续更新不会重新执行;
  • 更新状态的方法(如 setUser)是异步的,若需要基于前一次状态更新,建议使用函数式写法:
// 推荐:函数式更新(确保拿到最新的状态)
setUser(prevUser => ({ ...prevUser, age: prevUser.age + 1 }));
  • 对于对象/数组类型的状态,不能直接修改原数据(React 状态是不可变的),必须生成新的对象/数组。
2. 复杂状态管理:useReducer

当状态逻辑复杂(如多个状态关联、状态更新规则多),或组件内状态操作频繁时,useReduceruseState 更易维护(类似 Redux 的核心思想)。

核心语法

import { useReducer } from 'react';

// 1. 定义 reducer 函数:(当前状态, 动作) => 新状态
function reducer(state, action) {
  switch (action.type) {
    case 'UPDATE_NAME':
      return { ...state, name: action.payload };
    case 'INCREMENT_AGE':
      return { ...state, age: state.age + 1 };
    case 'RESET':
      return { name: '', age: 0 };
    default:
      throw new Error('未知的 action 类型');
  }
}

function ComplexState() {
  // 2. 初始化 useReducer:[状态, 分发动作的方法] = useReducer(reducer, 初始状态)
  const [state, dispatch] = useReducer(reducer, { name: '', age: 0 });

  return (
    <div>
      <p>姓名:{state.name}</p>
      <p>年龄:{state.age}</p>

      <button onClick={() => dispatch({ type: 'UPDATE_NAME', payload: '张三' })}>
        设置姓名
      </button>
      <button onClick={() => dispatch({ type: 'INCREMENT_AGE' })}>
        年龄+1
      </button>
      <button onClick={() => dispatch({ type: 'RESET' })}>
        重置
      </button>
    </div>
  );
}

export default ComplexState;

关键说明

  • reducer 是纯函数(无副作用、相同输入必返回相同输出),只负责状态计算;
  • dispatch 用于触发状态更新,通过 action(包含 typepayload)告诉 reducer 要执行的操作;
  • 适合场景:表单多字段联动、购物车状态(加减商品、计算总价)、步骤条状态等。
3. 衍生 Hooks:useState + useEffect 处理异步数据

实际开发中,状态常来自接口请求,此时需要结合 useEffect(处理副作用)来获取并更新状态:

import { useState, useEffect } from 'react';

function FetchData() {
  const [data, setData] = useState(null); // 接口数据
  const [loading, setLoading] = useState(true); // 加载状态
  const [error, setError] = useState(null); // 错误状态

  useEffect(() => {
    // 异步获取数据
    const fetchData = async () => {
      try {
        const res = await fetch('https://api.example.com/user');
        const result = await res.json();
        setData(result);
      } catch (err) {
        setError('数据请求失败:' + err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []); // 空依赖数组:仅首次渲染执行

  if (loading) return <div>加载中...</div>;
  if (error) return <div>{error}</div>;

  return (
    <div>
      <h3>用户数据:</h3>
      <p>姓名:{data?.name}</p>
      <p>邮箱:{data?.email}</p>
    </div>
  );
}

export default FetchData;

二、状态管理的最佳实践

  1. 状态拆分:将相关的状态聚合,无关的状态拆分(比如把“用户信息”和“页面开关”拆成两个 useState);
  2. 避免过度状态:能通过计算得到的值(如“是否成年 = age >= 18”)不需要定义为状态,直接在渲染时计算即可;
  3. 状态提升:如果多个组件需要共享状态,将状态提升到它们的共同父组件中;
  4. 复杂全局状态:若跨组件/跨页面共享状态,可使用 useContext + useReducer,或第三方库(如 Redux Toolkit、Zustand)。

总结

  1. 基础场景用 useState:处理单个/简单类型的状态(数字、字符串、简单对象/数组),核心是“不可变更新”;
  2. 复杂场景用 useReducer:状态逻辑多、多状态关联时,用 reducer 统一管理状态更新规则,更易维护;
  3. 异步状态结合 useEffect:接口请求等异步操作放在 useEffect 中,配合 useState 管理加载、数据、错误状态。

掌握这几个核心 Hooks 的用法,就能覆盖 React 函数组件中绝大部分的状态处理场景了。

AJAX vs Fetch API:Promise 与异步 JavaScript 怎么用?

作者 im_AMBER
2026年3月24日 12:02

今天在学习promise的时候,看到一些比较早的教程,其中提到有一个重要的概念就是AJAX

尽管也许现代的做法更常见的是用Fetch API ,但是我也可以了解一下旧版实现里的做法,也能够帮助理解早期的异步 API,理解老项目的代码是如何做的。

关于异步JS(Promise)的前置知识,有关细节补充可阅读文档:异步 JavaScript 简介

我理解为promise的出现是异步编程中防止传统回调嵌套函数写法(回调地狱)。promise是现代 JavaScript 异步编程的基础。

常常见到的await async等其实是一种语法糖,使得写法简洁易读,并且有关try catch 错误异常的捕获和管理会比较方便(对比于原先采用catch统一管理错误的办法...)。这样的写法看起来是同步代码的长相,其实底层是异步编程。

早期异步Web API: XMLHttpRequest(AJAX)

AJAX全称为Asynchronous JavaScript and XML(异步JavaScript和XML),是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。

它通过在后台与服务器进行少量数据交换,使得网页可以实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。

示例:

const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
  log.textContent = "";
  const xhr = new XMLHttpRequest();
  xhr.addEventListener("loadend", () => {
    log.textContent = `${log.textContent}完成!状态码:${xhr.status}`;
  });
  xhr.open(
    "GET",
    "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
  );
  xhr.send();
  log.textContent = `${log.textContent}请求已发起\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
  log.textContent = "";
  document.location.reload();
});
<button id="xhr">点击发起请求</button>
<button id="reload">重载</button>

<pre readonly class="event-log"></pre>

点击“点击发起请求”按钮来发送一个请求。我们将创建一个新的 XMLHttpRequest 并监听它的 loadend 事件。loadend 事件在请求完成时总会触发,无论成功还是失败。如果需要区分成功和失败,可以分别监听 load(成功)和 error(失败)事件。

而我们的事件处理程序则会在控制台中输出一个“完成!”的消息和请求的状态代码。

AJAX的工作原理基于一系列现有的互联网标准,主要包括以下几个方面:

  • XMLHttpRequest对象:这是AJAX的核心,它提供了在网页加载后从服务器请求数据的能力。
  • JavaScript/DOM:用于动态显示和交互的信息。
  • CSS:用于定义数据的样式。
  • XML:作为数据传输的格式,尽管现在JSON格式更为常用。

XMLHttpRequest

XMLHttpRequest API 使 web 应用能够通过 JavaScript 向 web 服务器发起 HTTP 请求并接收响应。这使得网站能够仅更新页面中的部分内容(使用服务器返回的数据),而无需跳转至全新页面。这种做法有时也被称为 AJAX

Fetch API 是取代 XMLHttpRequest API 的更灵活、更强大的方案。

Fetch API 使用 promise 替代事件机制处理异步响应,对 service worker 支持良好,并支持 HTTP 的高级特性,如跨源资源共享控制

基于这些优势,现代 web 应用通常采用 Fetch API 替代 XMLHttpRequest

XMLHttpRequest 用于在后台与服务器交换数据。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。XMLHttpRequest(XHR)对象用于与服务器交互。通过 XMLHttpRequest 可以在不刷新页面的情况下请求特定 URL,获取数据。这允许网页在不影响用户操作的情况下,更新页面的局部内容。

AJAX能允许网页在不影响用户操作的情况下,与服务器进行数据交换和更新。例如Google地图、新浪微博等,依托核心还是XMLHttpRequest。

实现AJAX

通常需要以下几个步骤:

  1. 创建XMLHttpRequest对象:这是所有AJAX请求的起点。

  2. 发送请求到服务器:使用*open()send()*方法,可以指定请求的类型(如GET或POST),URL以及是否异步。

  3. 处理服务器响应:通过监听onreadystatechange事件,可以在请求的不同阶段执行不同的操作。当readyState属性变为4,且status属性表示请求成功时,可以处理响应数据。

  4. 更新网页内容:使用JavaScript操作DOM,可以根据服务器的响应更新网页的特定部分。

跨域问题和解决方法

在使用AJAX时,可能会遇到跨域问题,即浏览器出于安全考虑,限制了来自不同源的HTTP请求。解决跨域问题的方法包括:

CORS(Cross-Origin Resource Sharing):通过服务器设置适当的HTTP响应头,可以允许特定的外部域访问资源。

JSONP(JSON with Padding):通过动态创建*

AJAX的优势和注意事项

AJAX的主要优势在于提高了用户体验,通过异步更新可以减少等待时间,使得Web应用程序更加快速和响应。然而,也需要注意一些问题,例如:

浏览器兼容性:不同浏览器对AJAX的支持程度可能不同,需要进行充分的测试。

用户体验:需要合理设计用户界面,以便在数据加载过程中给予用户适当的反馈。

网络延迟:应考虑到网络延迟对用户体验的影响,并采取相应的优化措施。

总的来说,AJAX技术使得Web开发进入了一个新的阶段,它允许开发者创建出更加动态和交互性强的网页应用。


使用Fetch API与Promise

如何使用 Promise

MDN的教程已经讲解的非常好了,我们一起来跟着学一学,现代使用Fetch API 的做法。

在基于 Promise 的 API 中,异步函数会启动操作并返回一个 Promise 对象。

首先,Promise 有三种状态:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用 fetch() 返回 Promise 时的状态,此时请求还在进行中。
  • 已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的 then() 处理函数被调用。
  • 已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的 catch() 处理函数被调用。

注意,这里的“成功”或“失败”的含义取决于所使用的 API:例如,fetch() 认为服务器返回一个错误(如 404 Not Found)时请求成功,但如果网络错误阻止请求被发送,则认为请求失败。

有时我们用已敲定(settled)这个词来同时表示已兑现(fulfilled)和已拒绝(rejected)两种情况。

如果一个 Promise 已敲定,或者如果它被“锁定”以跟随另一个 Promise 的状态,那么它就是已解决(resolved)的。

(关于术语:Let's talk about how to talk about promises


然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
  console.log(`已收到响应:${response.status}`);
});

console.log("已发送请求……");
  1. 调用 fetch() API,并将返回值赋给 fetchPromise 变量。
  2. 紧接着,输出 fetchPromise 变量,输出结果应该像这样:Promise { <state>: "pending" }。这告诉我们有一个 Promise 对象,它有一个 state属性,值是 "pending""pending" 状态意味着操作仍在进行中。
  3. 将一个处理函数传递给 Promise 的 then() 方法。当(如果)获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的 Response 对象。
  4. 输出一条信息,说明我们已经发送了这个请求。
Promise { <state>: "pending" }
已发送请求……
已收到响应:200

与之前的 XMLHttpRequest 不同的是,事件处理程序并不是添加在 XMLHttpRequest 的对象中,我们这一次将处理程序传递到返回的promise对象的then方法里面。

Promise链

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
  const jsonPromise = response.json();
  jsonPromise.then((json) => {
    console.log(json[0].name);
  });
});

等等!还记得上一篇文章吗?我们好像说过,**在回调中调用另一个回调会出现多层嵌套的情况?我们是不是还说过,这种“回调地狱”使我们的代码难以理解?**这不是也一样吗,只不过变成了用 then() 调用而已?

当然如此。但 Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

官方教程划重点:Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

所以以上代码等价于:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data[0].name);
  });

我们需要在尝试读取请求之前检查服务器是否接受并处理了该请求。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,就抛出一个错误:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  });

错误捕获

const fetchPromise = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  })
  .catch((error) => {
    console.error(`无法获取产品列表:${error}`);
  });

catch处理函数的输出错误。

  • 注意fetch() 只有在网络层面失败时才会进入 catch。服务器返回 404 或 500 状态码时,Promise 依然是 fulfilled 状态,需要通过 response.ok 手动判断。

合并使用多个promise

有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all() 方法。它接收一个 Promise 数组,并返回一个单一的 Promise。

Promise.all()

Promise.all()返回的 Promise:

  • 当且仅当数组中所有的 Promise 都被兑现时,才会通知 then() 处理函数并提供一个包含所有响应的数组,数组中响应的顺序与被传入 all() 的 Promise 的顺序相同。
  • 会被拒绝——如果数组中有任何一个 Promise 被拒绝。此时,catch() 处理函数被调用,并提供被拒绝的 Promise 所抛出的错误。
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((responses) => {
    for (const response of responses) {
      console.log(`${response.url}${response.status}`);
    }
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

promise.all用于批量处理不是相互依赖的promise,这样提高了效率,但是弊端是只有全部成功才会成功,如果有一个失败(rejected)则所有all包含在内的promise都不能被兑现。此时错误会用catch抛出。

Promise.any()

有时,你可能需要一组 Promise 中的某一个 Promise 的兑现,而不关心是哪一个。在这种情况下,你需要 Promise.any()

这就像 Promise.all(),不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝。

在这种情况下,我们无法预测哪个获取请求会先被兑现。

const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((response) => {
    console.log(`${response.url}${response.status}`);
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

async 和 await

async function fetchProducts() {
  try {
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

const promise = fetchProducts();
promise.then((data) => console.log(data[0].name));

这里我们调用 await fetch(),我们的调用者得到的并不是 Promise,而是一个完整的 Response 对象,就好像 fetch() 是一个同步函数一样。

我们甚至可以使用 try...catch 块来处理错误,就像我们在写同步代码时一样。

但请注意,这个写法只在异步函数中起作用。异步函数总是返回一个 Promise。也就意味着async 函数总是返回一个 Promise。即使你返回一个普通值,它也会被自动包装成 Promise。

小结与更多Promise

Promise 是现代 JavaScript 异步编程的基础。它避免了深度嵌套回调,使表达和理解异步操作序列变得更加容易,并且它们还支持一种类似于同步编程中 try...catch 语句的错误处理方式。

asyncawait 关键字使得从一系列连续的异步函数调用中建立一个操作变得更加容易,避免了创建显式 Promise 链,并允许你像编写同步代码那样编写异步代码。

Promise 在所有现代浏览器的最新版本中都可以使用;唯一会出现支持问题的地方是 Opera Mini 和 IE11 及更早的版本。

在这篇文章中,我们没有涉及到所有的 Promise 功能,只是介绍了最有趣和最有用的那一部分。随着你开始学习更多关于 Promise 的知识,你会遇到更多有趣的特性。

许多现代 Web API 是基于 Promise 的,包括 WebRTCWeb Audio API媒体捕捉与媒体流等等。

从零开发一个微信记账小程序,零依赖、附完整源码

2026年3月24日 11:48

本文记录了「简记账」微信小程序的完整开发过程。从需求分析、架构设计到各页面实现,9个技术亮点一一拆解。适合有微信小程序基础的开发者阅读,也适合想找轻量级项目练手的同学。


一、为什么做这个小程序?

市面上的记账 App 动辄要注册账号、开通会员、同步云端——对于只想记个午饭钱的人来说,太重了。

于是我给自己定了一个极简原则:打开即用,不登录,不注册,记一笔只需 3 步

最终做出来的「简记账」是这样的:

  • 首页:余额卡片 + 一键记收入/记支出
  • 统计页:本月收支汇总 + 分类排行
  • 设置页:CSV 数据导出 + 一键清空

零 npm 依赖,纯原生微信小程序 API,包体积极小。

绠€璁拌处鎴浘1.png


二、项目结构

简记账/
├── app.js              # 全局数据服务层(核心)
├── app.json            # 路由 + tabBar 配置
├── app.wxss            # 全局通用样式
├── pages/
│   ├── index/          # 首页(记账 + 流水)
│   ├── stats/          # 统计页
│   └── settings/       # 设置页
└── images/             # tabBar 图标

结构很干净。没有 components 目录,没有 utils 工具库,不引入任何第三方包。


三、架构设计:app.js 作为数据服务层

这是整个项目最关键的设计决策。

微信小程序里各页面之间共享数据,常见做法有两种:

  1. 每个页面自己读写 Storage
  2. 把 Storage 操作统一封装在 app.js,页面通过 getApp() 调用

我选了第二种。好处是:页面完全不感知存储细节,未来如果从本地存储升级到云数据库,只改 app.js 就够了,页面代码零改动。

数据结构

每一条记账记录长这样:

{
  id: Date.now(),              // 时间戳作唯一 ID,够用
  type: 'income' | 'expense',
  amount: 58.5,                // 数字,不是字符串
  note: '午餐',                // 用户输入,默认为分类名
  category: 'food',            // 分类 key
  icon: '🍜',                  // emoji 图标
  categoryIcon: 'food',        // CSS 类名(用于背景色)
  date: '2026-03-23T10:30:00Z' // ISO 8601,方便计算
}

五个核心方法

// app.js
App({
  onLaunch() {
    this.checkLocalStorage()
  },

  // 初始化:确保 key 存在
  checkLocalStorage() {
    const transactions = wx.getStorageSync('transactions')
    if (!transactions) {
      wx.setStorageSync('transactions', [])
    }
  },

  // 新记录插到数组头部,保证最新在前
  saveTransaction(transaction) {
    let transactions = wx.getStorageSync('transactions') || []
    transactions.unshift(transaction)
    wx.setStorageSync('transactions', transactions)
    return true
  },

  getTransactions() {
    return wx.getStorageSync('transactions') || []
  },

  // 按 id 过滤,重写全量数组
  deleteTransaction(id) {
    let transactions = wx.getStorageSync('transactions') || []
    transactions = transactions.filter(t => t.id !== id)
    wx.setStorageSync('transactions', transactions)
  },

  // 月度统计:按年月筛选后累加
  getMonthlyStats() {
    const transactions = this.getTransactions()
    const now = new Date()
    let income = 0, expense = 0

    transactions.forEach(t => {
      const date = new Date(t.date)
      if (date.getMonth() === now.getMonth() &&
          date.getFullYear() === now.getFullYear()) {
        if (t.type === 'income') income += t.amount
        else expense += t.amount
      }
    })

    return { income, expense, balance: income - expense }
  },

  // 分类汇总
  getStatsByCategory(type) {
    const transactions = this.getTransactions()
    const now = new Date()
    const stats = {}

    transactions.forEach(t => {
      if (t.type !== type) return
      const date = new Date(t.date)
      if (date.getMonth() !== now.getMonth()) return
      stats[t.category] = (stats[t.category] || 0) + t.amount
    })

    return stats
  }
})

为什么用同步 API(Sync 系列)?

异步 API 需要写回调或 Promise,代码层层嵌套。记账这种轻量场景,数据量小,同步读写完全够用,而且代码清晰很多,不会有回调地狱。


四、首页:记账弹窗的设计细节

绠€璁拌处鎴浘2.png

首页的核心交互是底部弹起的记账面板

弹窗实现

我没用 wx:if 控制显隐,而是用 CSS class 切换:

/* 默认隐藏 */
.modal {
  display: none;
  position: fixed;
  top: 0; left: 0;
  width: 100%; height: 100%;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
  justify-content: center;
  align-items: flex-end; /* 关键:内容贴底部 */
}

/* 激活时显示 */
.modal.active {
  display: flex;
}

/* 弹窗面板:只有上方是圆角 */
.modal-content {
  background: white;
  width: 100%;
  border-radius: 48rpx 48rpx 0 0;
  padding: 48rpx;
  max-height: 80vh;
  overflow-y: auto;
}

WXML 里通过三元表达式动态切换 class:

<view class="modal {{showModal ? 'active' : ''}}" bindtap="closeAddModal">
  <view class="modal-content" catchtap="stopPropagation">
    <!-- 内容 -->
  </view>
</view>

注意 catchtap="stopPropagation" 这里——点击面板内容时,阻止事件冒泡到背景层,否则一碰面板就会关闭弹窗。

为什么不用 wx:if

wx:if 是条件渲染,每次显示/隐藏都会销毁/重建 DOM。用 CSS 切换只是修改 display 属性,性能更好,也不会丢失输入框里已填的内容。

动态分类过滤

记收入和记支出要显示不同的分类选项,我把所有分类存在一个数组里,根据类型实时过滤:

openAddModal(e) {
  const type = e.currentTarget.dataset.type // 'income' 或 'expense'

  const incomeCategories = ['salary', 'bonus', 'investment', 'other_income']

  const filtered = allCategories.filter(c =>
    type === 'income'
      ? incomeCategories.includes(c.value)
      : !incomeCategories.includes(c.value)
  )

  this.setData({
    showModal: true,
    modalType: type,
    categories: filtered,
    selectedCategory: filtered[0].value
  })
}

一套数据,两种视图,不用维护两个独立数组。

智能时间显示

交易列表里的时间,我做了语义化处理,比"2026-03-23 10:30"更有温度:

formatDate(isoString) {
  const date = new Date(isoString)
  const now = new Date()
  const diff = now - date
  const days = Math.floor(diff / (1000 * 60 * 60 * 24))

  if (days === 0) {
    const minutes = Math.floor(diff / (1000 * 60))
    if (minutes === 0) return '刚刚'
    const hours = Math.floor(diff / (1000 * 60 * 60))
    if (hours === 0) return `${minutes}分钟前`
    return `今天 ${this.formatTime(date)}`
  }
  if (days === 1) return '昨天'
  if (days < 7) return `${days}天前`
  return `${date.getMonth() + 1}${date.getDate()}日`
}

输出效果:刚刚 / 5分钟前 / 今天 09:30 / 昨天 / 3天前 / 3月15日


五、统计页:分类排行的实现

绠€璁拌处鎴浘3.png

统计页的核心是把原始数据转成可展示的排行列表。

formatCategories(rawStats, type) {
  const categoryMap = {
    food:       { name: '餐饮', icon: '🍜' },
    transport:  { name: '交通', icon: '🚇' },
    shopping:   { name: '购物', icon: '🛒' },
    // ...其他分类
  }

  return Object.entries(rawStats)
    .map(([key, value]) => ({
      key,
      name: categoryMap[key]?.name || key,
      icon: categoryMap[key]?.icon || '📦',
      amount: value.toFixed(2)
    }))
    .sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount)) // 按金额降序
}

Object.entries(){ food: 120, transport: 30 } 这样的对象转成数组,再 map + sort,链式操作很清晰。

结余颜色动态判断:

<view class="stat-value {{balance >= 0 ? 'income' : 'expense'}}">
  ¥{{balance}}
</view>

收支相抵为正显示绿色,亏损显示红色,简单直观。


六、设置页:用剪贴板实现数据导出

微信小程序的文件系统权限比较复杂,直接生成并保存 Excel 文件需要申请额外权限。

我的解法是:生成 CSV 文本,复制到剪贴板,让用户自己粘贴到 Excel

exportData() {
  const transactions = app.getTransactions()
  if (transactions.length === 0) {
    wx.showToast({ title: '暂无数据可导出', icon: 'none' })
    return
  }

  let csv = '类型,金额,备注,分类,日期\n'
  transactions.forEach(t => {
    const date = new Date(t.date)
    const dateStr = `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`
    const type = t.type === 'income' ? '收入' : '支出'
    csv += `${type},${t.amount},${t.note},${t.category},${dateStr}\n`
  })

  wx.setClipboardData({
    data: csv,
    success: () => {
      wx.showModal({
        title: '导出成功',
        content: '数据已复制到剪贴板,请粘贴到Excel中保存',
        showCancel: false
      })
    }
  })
}

这个方案绕开了文件权限的麻烦,对普通用户来说操作也不复杂:复制 → 打开 Excel → 粘贴。


七、UI 设计:用 emoji 代替图标库

整个项目没有引入任何图标字体或 SVG 图标库,全部用 Unicode emoji。

好处:

  • 零包体积增加
  • 天然跨平台兼容
  • 色彩丰富,视觉效果好

每个分类有独立的背景色标:

.transaction-icon.food        { background: #fef3c7; }  /* 暖黄 */
.transaction-icon.transport   { background: #dbeafe; }  /* 浅蓝 */
.transaction-icon.shopping    { background: #fce7f3; }  /* 粉色 */
.transaction-icon.salary      { background: #dcfce7; }  /* 浅绿 */
.transaction-icon.entertainment { background: #e0e7ff; } /* 淡紫 */
.transaction-icon.medical     { background: #fee2e2; }  /* 浅红 */

emoji + 分类色块,不需要设计稿,纯代码实现就有不错的视觉层次。

主色用紫蓝渐变:

background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);

顶部卡片加了毛玻璃效果:

.balance-info {
  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(20rpx);
}

八、数据刷新策略

所有页面都实现了 onLoad + onShow 双钩子刷新:

onLoad() { this.loadData() }
onShow() { this.loadData() }

onLoad 是页面第一次加载时触发,onShow 是每次切换到该页面时触发。

如果只有 onLoad,从统计页切回首页时,余额不会更新。加上 onShow 就解决了多页面数据同步的问题。这是微信小程序开发的标准实践,值得记住。


九、云开发迁移路径

虽然目前用的是本地存储,但项目已经为云开发预留了迁移空间:

app.json 已设置 "cloud": trueapp.js 中有注释掉的初始化代码:

// wx.cloud.init({ env: 'your-env-id', traceUser: true })

迁移时只需修改 app.js 里的五个方法:

当前实现 云开发替换
wx.setStorageSync('transactions', data) db.collection('transactions').add({ data })
wx.getStorageSync('transactions') db.collection('transactions').get()
transactions.filter(t => t.id !== id) + setStorageSync db.collection('transactions').doc(id).remove()

页面代码一行不用改。这就是把数据层抽象到 app.js 的价值所在。


十、总结

这个项目有几个值得借鉴的点:

  1. 全局服务模式app.js 统一管理数据读写,页面解耦
  2. 同步 Storage API:避免异步回调,代码清晰
  3. CSS class 控制弹窗:比 wx:if 性能好,不丢失表单状态
  4. emoji 代替图标库:零依赖,包体积最小
  5. 双钩子刷新onLoad + onShow 保证跨页面数据同步
  6. 剪贴板导出:绕过文件权限限制的轻量方案
  7. 动态分类过滤:一套数据,两种视图
  8. 语义化时间:提升用户体验的小细节
  9. 云开发预留:接口层隔离,未来升级零成本

完整源码已在掘金平台开源,可通过文章开头的链接访问

如果觉得有帮助,点个赞再走~


作者:守(SO) | 2026年3月

私有化 AI Agent 平台进阶指南:智能知识库、Skill 生态与自定义 Agent 实战

作者 王小酱
2026年3月24日 12:10

本文档是《本地部署全能 AI Agent 完整方案》的进阶篇,聚焦于知识库自动化运营、Skill 动态发现与注册、生产级稳定性保障、用户自定义 Agent 体系,以及完整的工程化落地方案。适合已完成基础部署、希望将 Agent 平台打造为可持续运营的生产力工具的读者。


第一部分:知识库内容自动获取与持续更新

1.1 核心问题

手动上传文档到知识库效率低、容易过时。需要一套自动化采集 → 清洗 → 入库 → 更新的流水线。

1.2 自动采集架构

┌─────────────────────────────────────────────────────┐
│                知识库自动化流水线                       │
│                                                     │
│  ┌─────────┐   ┌─────────┐   ┌─────────┐           │
│  │ 数据源   │──▶│ 采集器   │──▶│ 清洗器   │           │
│  │ Sources  │   │ Scrapers│   │ Cleaners│           │
│  └─────────┘   └─────────┘   └─────────┘           │
│       │                            │                │
│       │                            ▼                │
│  ┌─────────┐   ┌─────────┐   ┌─────────┐           │
│  │ 变更检测  │──▶│ 增量更新  │──▶│ Dify    │           │
│  │ Monitor  │   │ Updater │   │ 知识库API│           │
│  └─────────┘   └─────────┘   └─────────┘           │
└─────────────────────────────────────────────────────┘

1.3 各领域知识源自动采集方案

1.3.1 编程开发知识

# knowledge_scrapers/coding_scraper.py
import requests
import os
import json
from pathlib import Path

class CodingKnowledgeScraper:
    """自动采集编程领域知识"""

    def __init__(self, output_dir: str = "./knowledge/coding"):
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(parents=True, exist_ok=True)

    def scrape_github_awesome_lists(self):
        """从 GitHub Awesome 列表采集最佳实践"""
        awesome_repos = [
            "sindresorhus/awesome",
            "enaqx/awesome-react",
            "vinta/awesome-python",
            "avelino/awesome-go",
            "typescript-cheatsheets/react",
        ]
        for repo in awesome_repos:
            url = f"https://api.github.com/repos/{repo}/readme"
            resp = requests.get(url, headers={"Accept": "application/vnd.github.v3.raw"})
            if resp.status_code == 200:
                filename = repo.replace("/", "_") + ".md"
                (self.output_dir / filename).write_text(resp.text, encoding="utf-8")

    def scrape_official_docs(self):
        """采集官方文档(通过 Firecrawl / Crawl4AI)"""
        # Crawl4AI: 开源爬虫,专为 AI 知识采集设计
        # pip install crawl4ai
        from crawl4ai import WebCrawler

        crawler = WebCrawler()
        doc_sites = [
            {"url": "https://react.dev/learn", "name": "react_docs"},
            {"url": "https://www.typescriptlang.org/docs/", "name": "typescript_docs"},
            {"url": "https://docs.python.org/3/tutorial/", "name": "python_docs"},
        ]
        for site in doc_sites:
            result = crawler.run(url=site["url"])
            output_file = self.output_dir / f"{site['name']}.md"
            output_file.write_text(result.markdown, encoding="utf-8")

    def scrape_internal_api_docs(self, swagger_urls: list):
        """从 Swagger/OpenAPI 自动生成 API 知识文档"""
        for url in swagger_urls:
            resp = requests.get(url)
            spec = resp.json()
            markdown = self._openapi_to_markdown(spec)
            name = spec.get("info", {}).get("title", "api").replace(" ", "_")
            (self.output_dir / f"api_{name}.md").write_text(markdown, encoding="utf-8")

    def _openapi_to_markdown(self, spec: dict) -> str:
        """将 OpenAPI spec 转为可读的 Markdown 文档"""
        lines = [f"# {spec.get('info', {}).get('title', 'API Documentation')}\n"]
        for path, methods in spec.get("paths", {}).items():
            for method, detail in methods.items():
                if isinstance(detail, dict):
                    summary = detail.get("summary", "")
                    lines.append(f"## {method.upper()} {path}")
                    lines.append(f"{summary}\n")
                    # 参数
                    params = detail.get("parameters", [])
                    if params:
                        lines.append("**Parameters:**")
                        for p in params:
                            lines.append(f"- `{p.get('name')}` ({p.get('in')}): {p.get('description', '')}")
                    lines.append("")
        return "\n".join(lines)

    def scrape_stackoverflow_tags(self, tags: list, count: int = 50):
        """采集 StackOverflow 高票问答作为 FAQ 知识"""
        for tag in tags:
            url = f"https://api.stackexchange.com/2.3/questions"
            params = {
                "order": "desc", "sort": "votes",
                "tagged": tag, "site": "stackoverflow",
                "pagesize": count, "filter": "withbody"
            }
            resp = requests.get(url, params=params)
            if resp.status_code == 200:
                items = resp.json().get("items", [])
                content = f"# {tag} FAQ (Top {count})\n\n"
                for item in items:
                    content += f"## Q: {item['title']}\n"
                    content += f"Score: {item['score']} | Answers: {item['answer_count']}\n"
                    content += f"Link: {item['link']}\n\n"
                (self.output_dir / f"faq_{tag}.md").write_text(content, encoding="utf-8")

1.3.2 文案写作知识

# knowledge_scrapers/writing_scraper.py
class WritingKnowledgeScraper:
    """自动采集文案写作知识"""

    def scrape_copywriting_templates(self):
        """采集营销文案模板库"""
        # 从开源文案库采集
        sources = [
            # 中文文案排版指南
            "https://raw.githubusercontent.com/sparanoid/chinese-copywriting-guidelines/master/README.zh-Hans.md",
        ]
        for url in sources:
            resp = requests.get(url)
            if resp.status_code == 200:
                filename = url.split("/")[-1]
                (self.output_dir / filename).write_text(resp.text, encoding="utf-8")

    def scrape_writing_guides_from_rss(self, rss_feeds: list):
        """从 RSS 订阅持续采集写作技巧文章"""
        import feedparser

        for feed_url in rss_feeds:
            feed = feedparser.parse(feed_url)
            for entry in feed.entries[:20]:  # 每个源取最新20篇
                content = entry.get("summary", entry.get("description", ""))
                title = entry.get("title", "untitled")
                filename = f"{title[:50].replace('/', '_')}.md"
                doc = f"# {title}\n\n{content}\n\nSource: {entry.get('link', '')}"
                (self.output_dir / filename).write_text(doc, encoding="utf-8")

    def generate_style_guide(self, brand_name: str, sample_texts: list):
        """用 LLM 从样本文本中提取品牌风格指南"""
        prompt = f"""分析以下 {brand_name} 的文案样本,提取品牌调性和写作风格指南:

样本:
{chr(10).join(sample_texts)}

请输出:
1. 品牌调性关键词(3-5个)
2. 语言风格特征
3. 常用句式模板
4. 禁用词/表达
5. 典型文案结构"""

        # 调用本地 Qwen 模型
        resp = requests.post("http://localhost:8000/v1/chat/completions", json={
            "model": "qwen2.5-72b",
            "messages": [{"role": "user", "content": prompt}]
        })
        guide = resp.json()["choices"][0]["message"]["content"]
        return guide

1.3.3 图像/视频知识

# knowledge_scrapers/creative_scraper.py
class CreativeKnowledgeScraper:
    """采集图像和视频创作知识"""

    def scrape_prompt_databases(self):
        """采集高质量 Prompt 数据库"""
        # 从 CivitAI / PromptHero 等平台采集优质 prompt
        # 分类存储:人像、风景、产品、抽象等
        categories = {
            "portrait": "人像摄影 prompt 集合",
            "landscape": "风景 prompt 集合",
            "product": "产品图 prompt 集合",
            "illustration": "插画 prompt 集合",
            "ui_design": "UI 设计 prompt 集合",
        }
        for cat, desc in categories.items():
            # 生成分类 prompt 指南
            prompt = f"请为 FLUX/Stable Diffusion 模型生成 30 个高质量的{desc},每个包含正向和负向 prompt,标注推荐参数(步数、CFG、尺寸)"
            resp = requests.post("http://localhost:8000/v1/chat/completions", json={
                "model": "qwen2.5-72b",
                "messages": [{"role": "user", "content": prompt}]
            })
            content = resp.json()["choices"][0]["message"]["content"]
            (self.output_dir / f"prompts_{cat}.md").write_text(
                f"# {desc}\n\n{content}", encoding="utf-8"
            )

    def scrape_comfyui_workflows(self):
        """从社区采集 ComfyUI 工作流模板"""
        # OpenArt / Comfy.icu 等平台有大量共享工作流
        workflow_categories = [
            "text_to_image_basic",
            "image_to_image",
            "inpainting",
            "upscale",
            "text_to_video",
            "image_to_video",
            "style_transfer",
        ]
        # 将工作流 JSON + 说明文档存入知识库
        for cat in workflow_categories:
            doc = f"# ComfyUI Workflow: {cat}\n\n"
            doc += f"工作流文件: workflows/{cat}.json\n"
            doc += f"使用说明: ...\n"
            (self.output_dir / f"workflow_{cat}.md").write_text(doc, encoding="utf-8")

1.4 自动入库流水线

# knowledge_pipeline.py — 知识库自动更新流水线
import hashlib
import json
import requests
from pathlib import Path
from datetime import datetime

class DifyKnowledgeSync:
    """与 Dify 知识库 API 同步"""

    def __init__(self, dify_url: str, api_key: str):
        self.dify_url = dify_url.rstrip("/")
        self.api_key = api_key
        self.headers = {"Authorization": f"Bearer {api_key}"}
        self.state_file = Path("./knowledge_state.json")
        self.state = self._load_state()

    def _load_state(self) -> dict:
        if self.state_file.exists():
            return json.loads(self.state_file.read_text())
        return {"files": {}}

    def _save_state(self):
        self.state_file.write_text(json.dumps(self.state, indent=2, ensure_ascii=False))

    def _file_hash(self, filepath: Path) -> str:
        return hashlib.sha256(filepath.read_bytes()).hexdigest()

    def sync_directory(self, local_dir: str, dataset_id: str):
        """
        将本地目录与 Dify 知识库同步
        - 新文件 → 上传
        - 已修改文件 → 删除旧版本 + 重新上传
        - 已删除文件 → 从知识库删除
        """
        local_dir = Path(local_dir)
        current_files = {}

        # 扫描本地文件
        for f in local_dir.rglob("*"):
            if f.is_file() and f.suffix in (".md", ".txt", ".pdf", ".docx", ".html"):
                rel_path = str(f.relative_to(local_dir))
                file_hash = self._file_hash(f)
                current_files[rel_path] = {"hash": file_hash, "path": str(f)}

        # 对比变更
        old_files = self.state.get("files", {}).get(dataset_id, {})

        # 新增或修改的文件
        for rel_path, info in current_files.items():
            old_hash = old_files.get(rel_path, {}).get("hash")
            if old_hash != info["hash"]:
                print(f"[SYNC] Uploading: {rel_path}")
                # 如果是更新,先删除旧文档
                old_doc_id = old_files.get(rel_path, {}).get("doc_id")
                if old_doc_id:
                    self._delete_document(dataset_id, old_doc_id)
                # 上传新文档
                doc_id = self._upload_document(dataset_id, info["path"])
                current_files[rel_path]["doc_id"] = doc_id

        # 已删除的文件
        for rel_path in set(old_files.keys()) - set(current_files.keys()):
            old_doc_id = old_files[rel_path].get("doc_id")
            if old_doc_id:
                print(f"[SYNC] Deleting: {rel_path}")
                self._delete_document(dataset_id, old_doc_id)

        # 保存状态
        if "files" not in self.state:
            self.state["files"] = {}
        self.state["files"][dataset_id] = current_files
        self._save_state()

    def _upload_document(self, dataset_id: str, filepath: str) -> str:
        """上传文档到 Dify 知识库"""
        url = f"{self.dify_url}/v1/datasets/{dataset_id}/document/create_by_file"
        with open(filepath, "rb") as f:
            files = {"file": f}
            data = {
                "data": json.dumps({
                    "indexing_technique": "high_quality",
                    "process_rule": {
                        "mode": "automatic"
                    }
                })
            }
            resp = requests.post(url, headers=self.headers, files=files, data=data)
        if resp.status_code == 200:
            return resp.json().get("document", {}).get("id", "")
        return ""

    def _delete_document(self, dataset_id: str, document_id: str):
        """从知识库删除文档"""
        url = f"{self.dify_url}/v1/datasets/{dataset_id}/documents/{document_id}"
        requests.delete(url, headers=self.headers)


# ========== 定时任务:每日自动更新 ==========
# crontab: 0 3 * * * python knowledge_pipeline.py

if __name__ == "__main__":
    from knowledge_scrapers.coding_scraper import CodingKnowledgeScraper
    from knowledge_scrapers.writing_scraper import WritingKnowledgeScraper
    from knowledge_scrapers.creative_scraper import CreativeKnowledgeScraper

    # Step 1: 采集
    print(f"[{datetime.now()}] Starting knowledge scrape...")
    CodingKnowledgeScraper("./knowledge/coding").scrape_github_awesome_lists()
    CodingKnowledgeScraper("./knowledge/coding").scrape_official_docs()
    WritingKnowledgeScraper("./knowledge/writing").scrape_copywriting_templates()
    CreativeKnowledgeScraper("./knowledge/creative").scrape_prompt_databases()
    CreativeKnowledgeScraper("./knowledge/creative").scrape_comfyui_workflows()

    # Step 2: 同步到 Dify
    print(f"[{datetime.now()}] Syncing to Dify...")
    syncer = DifyKnowledgeSync(
        dify_url="http://localhost/api",
        api_key="your-dify-api-key"
    )

    # 每个领域对应一个 Dify dataset
    dataset_mapping = {
        "./knowledge/coding": "dataset-id-coding",
        "./knowledge/writing": "dataset-id-writing",
        "./knowledge/creative": "dataset-id-creative",
        "./knowledge/life": "dataset-id-life",
    }
    for local_dir, dataset_id in dataset_mapping.items():
        syncer.sync_directory(local_dir, dataset_id)

    print(f"[{datetime.now()}] Done!")

1.5 知识库质量保障

# knowledge_quality.py — 知识库质量检测
class KnowledgeQualityChecker:
    """定期检查知识库质量"""

    def check_freshness(self, knowledge_dir: str, max_age_days: int = 90):
        """检查文档时效性,标记过期内容"""
        stale_files = []
        for f in Path(knowledge_dir).rglob("*"):
            if f.is_file():
                age_days = (datetime.now() - datetime.fromtimestamp(f.stat().st_mtime)).days
                if age_days > max_age_days:
                    stale_files.append({"file": str(f), "age_days": age_days})
        return stale_files

    def check_retrieval_quality(self, dataset_id: str, test_queries: list):
        """用测试查询验证检索质量"""
        results = []
        for query in test_queries:
            # 调用 Dify 检索 API
            resp = requests.post(
                f"http://localhost/api/v1/datasets/{dataset_id}/retrieve",
                headers={"Authorization": "Bearer your-key"},
                json={"query": query, "top_k": 5}
            )
            records = resp.json().get("records", [])
            avg_score = sum(r.get("score", 0) for r in records) / max(len(records), 1)
            results.append({
                "query": query,
                "hit_count": len(records),
                "avg_score": round(avg_score, 3),
                "quality": "GOOD" if avg_score > 0.7 else "NEEDS_IMPROVEMENT"
            })
        return results

    def check_duplicates(self, knowledge_dir: str):
        """检测重复或高度相似的文档"""
        from difflib import SequenceMatcher

        files = list(Path(knowledge_dir).rglob("*.md"))
        duplicates = []
        for i, f1 in enumerate(files):
            for f2 in files[i + 1:]:
                text1 = f1.read_text(encoding="utf-8")[:2000]
                text2 = f2.read_text(encoding="utf-8")[:2000]
                similarity = SequenceMatcher(None, text1, text2).ratio()
                if similarity > 0.8:
                    duplicates.append({
                        "file1": str(f1), "file2": str(f2),
                        "similarity": round(similarity, 2)
                    })
        return duplicates

第二部分:Skill 发现与动态注册

2.1 Skill 体系设计

┌────────────────────────────────────────────────┐
│               Skill Registry                    │
│                                                │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐     │
│  │ 内置Skills│  │ 社区Skills│  │ 自定义    │     │
│  │ Built-in │  │ Community│  │ Skills   │     │
│  └─────┬────┘  └─────┬────┘  └─────┬────┘     │
│        └──────────┬──┘─────────────┘           │
│                   ▼                            │
│          ┌──────────────┐                      │
│          │ Skill Router │ ← 意图匹配 + 能力发现│
│          └──────┬───────┘                      │
│                 ▼                              │
│          ┌──────────────┐                      │
│          │ Skill Runner │ ← 执行 + 结果聚合    │
│          └──────────────┘                      │
└────────────────────────────────────────────────┘

2.2 Skill 定义标准

// skills/text2ppt.skill.json
{
  "name": "text2ppt",
  "version": "1.0.0",
  "display_name": "文本转PPT",
  "description": "将文本内容或大纲自动生成专业 PPT 文件",
  "category": "document",
  "tags": ["ppt", "presentation", "office", "演示文稿"],

  "trigger_patterns": [
    "做(一个|份)?PPT",
    "制作演示文稿",
    "生成(PPT|幻灯片)",
    "text.*(to|转|变).*(ppt|slide)"
  ],

  "input_schema": {
    "type": "object",
    "properties": {
      "title": {"type": "string", "description": "PPT标题"},
      "outline": {"type": "string", "description": "内容大纲或主题"},
      "style": {
        "type": "string",
        "enum": ["business", "academic", "creative", "minimal"],
        "default": "business"
      },
      "slide_count": {"type": "integer", "default": 10}
    },
    "required": ["title"]
  },

  "output_schema": {
    "type": "object",
    "properties": {
      "file_path": {"type": "string"},
      "download_url": {"type": "string"},
      "slide_count": {"type": "integer"}
    }
  },

  "dependencies": {
    "tools": ["ppt_generator", "text2img"],
    "knowledge_bases": ["ppt_design"],
    "models": ["qwen2.5-72b"]
  },

  "execution": {
    "type": "workflow",
    "steps": [
      {"action": "llm_generate", "prompt_template": "ppt_outline"},
      {"action": "tool_call", "tool": "text2img", "for_each": "slides"},
      {"action": "tool_call", "tool": "ppt_generator"}
    ]
  }
}

2.3 Skill 自动发现引擎

# skill_discovery.py — Skill 自动发现与注册
import json
import re
from pathlib import Path
from typing import Optional

class SkillRegistry:
    """Skill 注册中心"""

    def __init__(self, skills_dir: str = "./skills"):
        self.skills_dir = Path(skills_dir)
        self.skills: dict = {}
        self._load_all_skills()

    def _load_all_skills(self):
        """启动时加载所有 Skill 定义"""
        for skill_file in self.skills_dir.rglob("*.skill.json"):
            with open(skill_file, "r", encoding="utf-8") as f:
                skill = json.load(f)
                self.skills[skill["name"]] = skill
                print(f"[SKILL] Loaded: {skill['name']} ({skill['display_name']})")

    def match_skill(self, user_input: str) -> Optional[dict]:
        """根据用户输入匹配最合适的 Skill"""
        matches = []
        for name, skill in self.skills.items():
            score = 0
            # 1. 正则 trigger 匹配
            for pattern in skill.get("trigger_patterns", []):
                if re.search(pattern, user_input, re.IGNORECASE):
                    score += 10
            # 2. tag 关键词匹配
            for tag in skill.get("tags", []):
                if tag.lower() in user_input.lower():
                    score += 3
            # 3. description 语义匹配(可用 Embedding 增强)
            if score > 0:
                matches.append({"skill": skill, "score": score})

        matches.sort(key=lambda x: x["score"], reverse=True)
        return matches[0]["skill"] if matches else None

    def register_skill(self, skill_definition: dict):
        """动态注册新 Skill"""
        name = skill_definition["name"]
        skill_file = self.skills_dir / f"{name}.skill.json"
        with open(skill_file, "w", encoding="utf-8") as f:
            json.dump(skill_definition, f, ensure_ascii=False, indent=2)
        self.skills[name] = skill_definition
        print(f"[SKILL] Registered: {name}")

    def list_skills(self, category: str = None) -> list:
        """列出所有可用 Skill"""
        skills = list(self.skills.values())
        if category:
            skills = [s for s in skills if s.get("category") == category]
        return [{"name": s["name"], "display_name": s["display_name"],
                 "description": s["description"]} for s in skills]


class SkillDiscoveryAgent:
    """
    Skill 发现 Agent —— 当用户请求无法匹配现有 Skill 时,
    自动搜索社区/市场,下载并注册新 Skill
    """

    def __init__(self, registry: SkillRegistry):
        self.registry = registry
        # 社区 Skill 市场(可配置多个源)
        self.marketplaces = [
            "https://marketplace.dify.ai/api/skills",   # Dify 官方市场
            "https://registry.mcp.run/api/tools",        # MCP 社区
        ]

    def discover_and_install(self, user_need: str) -> Optional[dict]:
        """
        1. 先在本地匹配
        2. 匹配不到则搜索社区市场
        3. 下载、验证、注册
        """
        # 本地匹配
        skill = self.registry.match_skill(user_need)
        if skill:
            return skill

        # 搜索社区
        print(f"[DISCOVERY] No local skill found for: {user_need}")
        print(f"[DISCOVERY] Searching community marketplaces...")

        for marketplace_url in self.marketplaces:
            try:
                resp = requests.get(marketplace_url, params={"q": user_need}, timeout=10)
                if resp.status_code == 200:
                    results = resp.json().get("results", [])
                    if results:
                        best_match = results[0]
                        print(f"[DISCOVERY] Found: {best_match['name']} from {marketplace_url}")
                        # 下载 Skill 定义
                        skill_def = self._download_skill(best_match["download_url"])
                        if skill_def and self._validate_skill(skill_def):
                            self.registry.register_skill(skill_def)
                            return skill_def
            except Exception as e:
                print(f"[DISCOVERY] Marketplace error: {e}")

        return None

    def _download_skill(self, url: str) -> Optional[dict]:
        """下载 Skill 定义"""
        try:
            resp = requests.get(url, timeout=10)
            return resp.json()
        except Exception:
            return None

    def _validate_skill(self, skill_def: dict) -> bool:
        """验证 Skill 定义的完整性和安全性"""
        required_fields = ["name", "version", "description", "input_schema"]
        for field in required_fields:
            if field not in skill_def:
                print(f"[VALIDATION] Missing required field: {field}")
                return False

        # 安全检查:不允许执行任意代码的 Skill
        execution = skill_def.get("execution", {})
        if execution.get("type") == "code" and "eval" in json.dumps(execution):
            print("[VALIDATION] Rejected: contains unsafe code execution")
            return False

        return True

2.4 内置 Skill 清单

┌──────────────────────────────────────────────────────────────────┐
│                        内置 Skills 清单                          │
├──────────┬───────────────┬───────────────────────────────────────┤
│ 类别      │ Skill 名称     │ 功能描述                             │
├──────────┼───────────────┼───────────────────────────────────────┤
│ 文案      │ text_rewrite  │ 文案润色/改写/风格转换                 │
│          │ seo_optimize  │ SEO 关键词优化                         │
│          │ copywriting   │ 营销文案生成(多模板)                   │
│          │ summary       │ 长文摘要/提取要点                      │
│          │ translation   │ 中英文翻译(保持风格)                   │
├──────────┼───────────────┼───────────────────────────────────────┤
│ 文档      │ text2ppt      │ 文本/大纲 → PPT 生成                  │
│          │ text2doc      │ 结构化文档生成(Word)                  │
│          │ pdf_extract   │ PDF 解析 + 内容提取                    │
│          │ markdown2doc  │ Markdown → Word/PDF 转换              │
├──────────┼───────────────┼───────────────────────────────────────┤
│ 编码      │ code_generate │ 代码生成(多语言)                     │
│          │ code_review   │ 代码审查 + 改进建议                    │
│          │ code_explain  │ 代码逐行解释                          │
│          │ debug_assist  │ 错误诊断 + 修复建议                    │
│          │ test_generate │ 单元测试自动生成                       │
│          │ sql_generate  │ 自然语言 → SQL                        │
├──────────┼───────────────┼───────────────────────────────────────┤
│ 图像      │ text2img      │ 文本描述 → 图片生成                    │
│          │ img2img       │ 图片风格转换                           │
│          │ img_upscale   │ 图片超分辨率放大                       │
│          │ img_inpaint   │ 图片局部修改/擦除                      │
│          │ img_describe  │ 图片内容描述/分析                      │
│          │ remove_bg     │ 智能抠图/去背景                        │
├──────────┼───────────────┼───────────────────────────────────────┤
│ 视频      │ text2video    │ 文本描述 → 视频生成                    │
│          │ img2video     │ 静态图 → 动态视频                      │
│          │ video_trim    │ 视频裁剪/拼接                          │
│          │ video_caption │ 视频字幕生成                           │
│          │ video_bgm     │ 自动配乐/音频处理                      │
├──────────┼───────────────┼───────────────────────────────────────┤
│ 生活      │ weather       │ 天气查询 + 出行建议                    │
│          │ recipe        │ 菜谱推荐(按食材/口味)                  │
│          │ schedule      │ 日程管理/提醒                          │
│          │ health_tip    │ 健康建议/运动推荐                      │
│          │ web_search    │ 联网搜索 + 结果摘要                    │
└──────────┴───────────────┴───────────────────────────────────────┘

2.5 在 Dify 中配置 Skill → Tool 映射

# skill_to_dify_tool.py — 将 Skill 注册为 Dify 自定义工具
import requests
import json

class DifyToolRegistrar:
    """将 Skill 注册为 Dify 工具"""

    def __init__(self, dify_url: str, api_key: str):
        self.dify_url = dify_url
        self.headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}

    def register_skill_as_tool(self, skill: dict):
        """将 Skill 定义转换为 Dify OpenAPI Tool"""
        openapi_spec = {
            "openapi": "3.0.0",
            "info": {
                "title": skill["display_name"],
                "version": skill["version"]
            },
            "paths": {
                f"/skill/{skill['name']}": {
                    "post": {
                        "summary": skill["description"],
                        "operationId": skill["name"],
                        "requestBody": {
                            "content": {
                                "application/json": {
                                    "schema": skill["input_schema"]
                                }
                            }
                        },
                        "responses": {
                            "200": {
                                "content": {
                                    "application/json": {
                                        "schema": skill.get("output_schema", {})
                                    }
                                }
                            }
                        }
                    }
                }
            },
            "servers": [{"url": "http://localhost:8600"}]
        }
        return openapi_spec

第三部分:可实施性与稳定性保障

3.1 分级部署策略(降低风险)

Level 0: 最小可行版(1天搞定)
──────────────────────────────
✅ Ollama + Qwen2.5-14B(单机 8GB 显存即可)
✅ Dify 社区版(docker compose up -d)
✅ 手动上传 5-10 个文档到知识库
✅ 创建 1 个通用 Agent(纯对话 + 知识库检索)
❌ 暂无图像/视频生成
❌ 暂无自定义 Skill

→ 验证:能对话、能检索知识库 = 成功

Level 1: 基础多模态版(1周)
──────────────────────────────
✅ Level 0 全部
✅ 升级模型到 Qwen2.5-32B(24GB 显存)
✅ 部署 ComfyUI + SDXL/FLUX(文生图)
✅ 创建 3-4 个 SubAgent(文案、编码、图像、生活)
✅ 知识库扩展到 50+ 文档
❌ 暂无视频生成
❌ 暂无自动化采集

→ 验证:能分领域对话、能生成图片 = 成功

Level 2: 完整版(2-3周)
──────────────────────────────
✅ Level 1 全部
✅ 升级到 Qwen2.5-72B(双卡/A100)
✅ 视频生成(Wan2.1 / CogVideoX)
✅ PPT/视频剪辑微服务
✅ MCP Server 集成
✅ 知识库自动采集流水线
✅ 全部 6 个 SubAgent + 主 Orchestrator

→ 验证:端到端完成一个"从文案到PPT到配图"的完整任务 = 成功

Level 3: 生产级(4周+)
──────────────────────────────
✅ Level 2 全部
✅ Skill 注册中心 + 自动发现
✅ 用户自定义 Agent 界面
✅ 监控告警(Prometheus + Grafana)
✅ 自动扩缩容
✅ 安全加固(鉴权、审计日志)

→ 验证:多用户并发使用、7×24 稳定运行 = 成功

3.2 稳定性设计

3.2.1 模型服务高可用

# docker-compose.ha.yml — 高可用模型服务
version: '3.8'

services:
  # Nginx 负载均衡
  model-lb:
    image: nginx:alpine
    ports:
      - "8000:80"
    volumes:
      - ./nginx-model.conf:/etc/nginx/nginx.conf
    depends_on:
      - vllm-1
      - vllm-2

  # 模型服务实例 1
  vllm-1:
    image: vllm/vllm-openai:latest
    runtime: nvidia
    environment:
      - CUDA_VISIBLE_DEVICES=0
    command: >
      --model /models/qwen2.5-72b
      --served-model-name qwen2.5-72b
      --max-model-len 16384
      --port 8000

  # 模型服务实例 2(冗余)
  vllm-2:
    image: vllm/vllm-openai:latest
    runtime: nvidia
    environment:
      - CUDA_VISIBLE_DEVICES=1
    command: >
      --model /models/qwen2.5-72b
      --served-model-name qwen2.5-72b
      --max-model-len 16384
      --port 8000
# nginx-model.conf
events { worker_connections 1024; }

http {
    upstream model_backend {
        least_conn;
        server vllm-1:8000 max_fails=3 fail_timeout=30s;
        server vllm-2:8000 max_fails=3 fail_timeout=30s;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://model_backend;
            proxy_connect_timeout 60s;
            proxy_read_timeout 300s;  # LLM 生成可能较慢
            proxy_next_upstream error timeout http_502 http_503;
        }
    }
}

3.2.2 健康检查与自动恢复

# health_monitor.py — 服务健康监控
import requests
import subprocess
import time
import logging
from datetime import datetime

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

class HealthMonitor:
    """监控各服务健康状态,自动重启故障服务"""

    def __init__(self):
        self.services = {
            "vllm": {
                "url": "http://localhost:8000/v1/models",
                "container": "vllm",
                "critical": True,
                "fail_count": 0,
                "max_fails": 3,
            },
            "dify": {
                "url": "http://localhost/api/health",
                "container": "dify-api-1",
                "critical": True,
                "fail_count": 0,
                "max_fails": 3,
            },
            "comfyui": {
                "url": "http://localhost:8188/system_stats",
                "container": "comfyui",
                "critical": False,
                "fail_count": 0,
                "max_fails": 5,
            },
            "ollama": {
                "url": "http://localhost:11434/api/tags",
                "container": "ollama",
                "critical": False,
                "fail_count": 0,
                "max_fails": 3,
            },
        }

    def check_all(self):
        """检查所有服务"""
        for name, svc in self.services.items():
            try:
                resp = requests.get(svc["url"], timeout=10)
                if resp.status_code == 200:
                    svc["fail_count"] = 0
                    logging.debug(f"{name}: OK")
                else:
                    self._handle_failure(name, svc, f"HTTP {resp.status_code}")
            except requests.exceptions.RequestException as e:
                self._handle_failure(name, svc, str(e))

    def _handle_failure(self, name: str, svc: dict, error: str):
        svc["fail_count"] += 1
        logging.warning(f"{name}: FAIL ({svc['fail_count']}/{svc['max_fails']}) - {error}")

        if svc["fail_count"] >= svc["max_fails"]:
            logging.error(f"{name}: Max failures reached, restarting container...")
            self._restart_container(svc["container"])
            svc["fail_count"] = 0

    def _restart_container(self, container: str):
        try:
            subprocess.run(["docker", "restart", container], check=True, timeout=120)
            logging.info(f"Container {container} restarted successfully")
        except subprocess.CalledProcessError as e:
            logging.error(f"Failed to restart {container}: {e}")

    def run_loop(self, interval_seconds: int = 30):
        """持续监控循环"""
        logging.info("Health monitor started")
        while True:
            self.check_all()
            time.sleep(interval_seconds)

if __name__ == "__main__":
    monitor = HealthMonitor()
    monitor.run_loop(interval_seconds=30)

3.2.3 请求队列与限流

# request_queue.py — 防止模型过载
import asyncio
from collections import deque
from dataclasses import dataclass, field
from typing import Any
import time

@dataclass
class QueuedRequest:
    priority: int  # 0=highest
    timestamp: float = field(default_factory=time.time)
    payload: dict = field(default_factory=dict)
    future: asyncio.Future = field(default_factory=lambda: asyncio.get_event_loop().create_future())

class RequestQueue:
    """
    带优先级的请求队列,防止模型服务过载
    - 限制并发请求数
    - 超时自动取消
    - 优先级调度
    """

    def __init__(self, max_concurrent: int = 4, timeout: float = 120.0):
        self.max_concurrent = max_concurrent
        self.timeout = timeout
        self.active_count = 0
        self.queue: list = []  # 优先级队列

    async def submit(self, payload: dict, priority: int = 5) -> Any:
        req = QueuedRequest(priority=priority, payload=payload)
        self.queue.append(req)
        self.queue.sort(key=lambda r: (r.priority, r.timestamp))

        # 等待处理
        asyncio.create_task(self._process_queue())
        return await asyncio.wait_for(req.future, timeout=self.timeout)

    async def _process_queue(self):
        while self.queue and self.active_count < self.max_concurrent:
            req = self.queue.pop(0)
            self.active_count += 1
            try:
                result = await self._call_model(req.payload)
                req.future.set_result(result)
            except Exception as e:
                req.future.set_exception(e)
            finally:
                self.active_count -= 1

    async def _call_model(self, payload: dict) -> dict:
        """实际调用模型 API"""
        import aiohttp
        async with aiohttp.ClientSession() as session:
            async with session.post(
                "http://localhost:8000/v1/chat/completions",
                json=payload, timeout=aiohttp.ClientTimeout(total=120)
            ) as resp:
                return await resp.json()

3.2.4 监控看板

# docker-compose.monitoring.yml
services:
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin123
    volumes:
      - ./grafana_data:/var/lib/grafana
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'vllm'
    static_configs:
      - targets: ['vllm:8000']
  - job_name: 'node'
    static_configs:
      - targets: ['node-exporter:9100']
  - job_name: 'nvidia-gpu'
    static_configs:
      - targets: ['nvidia-gpu-exporter:9400']

3.3 GPU 显存管理策略

# gpu_manager.py — 智能 GPU 显存调度
class GPUMemoryManager:
    """
    多模型共享 GPU 时的显存管理策略

    场景:单卡/双卡 4090 要同时跑 LLM + 图像 + 视频
    策略:分时复用 —— 不同时加载所有模型
    """

    # 策略 1: 常驻 LLM + 按需加载多模态
    STRATEGY_LLM_RESIDENT = {
        "resident": ["qwen2.5-72b"],          # 常驻显存
        "on_demand": ["flux", "wan2.1"],       # 按需加载/卸载
        "preemption": True,                     # 允许抢占
    }

    # 策略 2: 分时段调度
    STRATEGY_TIME_BASED = {
        "schedules": [
            {"hours": "08:00-22:00", "models": ["qwen2.5-72b", "flux"]},    # 白天:对话 + 图像
            {"hours": "22:00-08:00", "models": ["wan2.1"]},                   # 夜间:批量视频生成
        ]
    }

    # 策略 3: 队列优先级
    STRATEGY_PRIORITY = {
        "priorities": {
            "chat": 1,          # 最高:实时对话
            "text2img": 2,      # 中等:图像生成
            "text2video": 3,    # 最低:视频生成(耗时长)
        }
    }

第四部分:用户自定义 Agent

4.1 Agent 创建工作流

用户自定义 Agent 创建流程:

Step 1: 基本信息
┌─────────────────────────────────────────┐
│ Agent 名称:  [我的法律助手            ]  │
│ 头像:        [📚]                       │
│ 描述:        [专注劳动法咨询的AI助手   ]  │
│ 可见性:      ○ 仅自己  ● 团队  ○ 公开    │
└─────────────────────────────────────────┘

Step 2: 选择基础能力
┌─────────────────────────────────────────┐
│ ☑ 对话能力(必选)                       │
│ ☑ 知识库检索                            │
│ ☐ 文生图                               │
│ ☐ 代码执行                             │
│ ☑ 网页搜索                             │
│ ☐ 文件生成(PPT/Word/PDF)              │
└─────────────────────────────────────────┘

Step 3: 配置 Persona(系统提示词)
┌─────────────────────────────────────────┐
│ 你是一名资深劳动法律师,具有10年执业经验。│
│ 你需要:                                │
│ - 用通俗易懂的语言解释法律条文           │
│ - 给出具体可操作的建议                   │
│ - 引用相关法律条款和判例                 │
│ - 在不确定时明确告知需要线下咨询          │
│                                         │
│ [使用AI帮我生成提示词]                   │
└─────────────────────────────────────────┘

Step 4: 挂载知识库
┌─────────────────────────────────────────┐
│ ☑ 劳动法全文.pdf                        │
│ ☑ 劳动合同法.pdf                        │
│ ☑ 最新劳动争议司法解释.pdf               │
│ ☑ 典型劳动纠纷案例100例.pdf             │
│ [+ 上传新文档]  [+ 连接在线知识库]       │
└─────────────────────────────────────────┘

Step 5: 选择 Skills
┌─────────────────────────────────────────┐
│ ☑ web_search(联网搜索最新判例)         │
│ ☑ text2doc(生成法律文书模板)           │
│ ☑ summary(长文摘要)                   │
│ ☐ text2img                             │
│ ☐ code_generate                        │
│ [+ 浏览 Skill 市场]                     │
└─────────────────────────────────────────┘

Step 6: 测试 & 发布
┌─────────────────────────────────────────┐
│ [预览对话]  [运行测试集]  [发布]         │
└─────────────────────────────────────────┘

4.2 Agent 配置格式(YAML DSL)

# agents/legal_assistant.agent.yaml
apiVersion: v1
kind: Agent
metadata:
  name: legal-assistant
  display_name: 法律助手
  description: 专注劳动法咨询的AI助手
  icon: "📚"
  author: user123
  visibility: team    # private | team | public
  tags: ["法律", "劳动法", "咨询"]

spec:
  # 使用的模型
  model:
    provider: local-vllm
    name: qwen2.5-72b
    temperature: 0.3          # 法律领域需要低温度,减少幻觉
    max_tokens: 4096

  # 系统提示词
  persona: |
    你是一名资深劳动法律师,具有10年执业经验。
    回答问题时:
    1. 先判断问题属于哪个法律领域
    2. 引用具体法律条款(如《劳动合同法》第XX条)
    3. 给出通俗易懂的解释
    4. 提供具体可操作的建议
    5. 在涉及复杂案件时,建议线下咨询专业律师

    禁止:
    - 给出明确的案件胜败判断
    - 替代律师做出法律决策
    - 编造不存在的法律条文

  # 知识库
  knowledge_bases:
    - dataset_id: kb-labor-law
      description: 劳动法全文及司法解释
      retrieval_mode: hybrid       # vector + keyword
      top_k: 5
      score_threshold: 0.6

    - dataset_id: kb-cases
      description: 典型劳动纠纷案例库
      retrieval_mode: vector
      top_k: 3
      score_threshold: 0.7

  # 挂载的 Skills / Tools
  skills:
    - name: web_search
      config:
        search_engine: bing
        max_results: 5
        domains: ["court.gov.cn", "law.cn"]    # 限制搜索域名

    - name: text2doc
      config:
        templates: ["labor_contract", "resignation_letter", "arbitration_application"]

    - name: summary
      config:
        max_length: 500

  # 对话设置
  conversation:
    opening_message: "你好!我是法律助手,专注于劳动法领域。请描述你的问题,我会尽力帮你分析。"
    suggested_questions:
      - "公司不签劳动合同怎么办?"
      - "被无故辞退可以要求哪些赔偿?"
      - "加班费怎么计算?"

    # 对话记忆
    memory:
      type: window          # window | summary | full
      window_size: 20       # 保留最近20轮对话

  # 工作流(可选,复杂 Agent 使用)
  workflow:
    - step: classify_intent
      type: llm
      prompt: "判断用户问题属于哪个法律类别:劳动合同、薪酬福利、工伤、离职、劳动仲裁、其他"

    - step: retrieve_knowledge
      type: knowledge_retrieval
      dataset: auto          # 根据分类自动选择知识库
      top_k: 5

    - step: generate_answer
      type: llm
      prompt: "根据检索到的法律知识,回答用户问题。必须引用具体法条。"

    - step: safety_check
      type: llm
      prompt: "检查回答是否包含不当法律建议。如有,添加免责声明。"

  # 安全防护
  guardrails:
    input_filters:
      - type: sensitive_words
        action: reject
        words: ["怎么钻法律空子", "如何逃避"]
    output_filters:
      - type: disclaimer
        trigger: "specific_legal_advice"
        message: "⚠️ 以上分析仅供参考,具体案件建议咨询专业律师。"

4.3 Agent 管理后台 API

# agent_manager.py — Agent 生命周期管理
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
import yaml
import uuid
from pathlib import Path

app = FastAPI(title="Agent Manager")

AGENTS_DIR = Path("./agents")
AGENTS_DIR.mkdir(exist_ok=True)

class AgentCreateRequest(BaseModel):
    name: str
    display_name: str
    description: str
    model: str = "qwen2.5-72b"
    temperature: float = 0.7
    persona: str
    knowledge_base_ids: list[str] = []
    skill_names: list[str] = []
    visibility: str = "private"
    opening_message: Optional[str] = None
    suggested_questions: list[str] = []

class AgentResponse(BaseModel):
    id: str
    name: str
    display_name: str
    status: str

@app.post("/api/agents", response_model=AgentResponse)
async def create_agent(req: AgentCreateRequest):
    """创建新 Agent"""
    agent_id = f"agent-{uuid.uuid4().hex[:8]}"

    # 构建 Agent 配置
    config = {
        "apiVersion": "v1",
        "kind": "Agent",
        "metadata": {
            "id": agent_id,
            "name": req.name,
            "display_name": req.display_name,
            "description": req.description,
            "visibility": req.visibility,
        },
        "spec": {
            "model": {"name": req.model, "temperature": req.temperature},
            "persona": req.persona,
            "knowledge_bases": [{"dataset_id": kb_id} for kb_id in req.knowledge_base_ids],
            "skills": [{"name": s} for s in req.skill_names],
            "conversation": {
                "opening_message": req.opening_message or f"你好!我是{req.display_name},有什么可以帮你的?",
                "suggested_questions": req.suggested_questions,
            },
        },
    }

    # 保存配置
    config_file = AGENTS_DIR / f"{agent_id}.yaml"
    config_file.write_text(yaml.dump(config, allow_unicode=True, default_flow_style=False))

    # 同步注册到 Dify(通过 Dify API 创建应用)
    await _register_in_dify(config)

    return AgentResponse(id=agent_id, name=req.name,
                         display_name=req.display_name, status="active")

@app.get("/api/agents")
async def list_agents(visibility: Optional[str] = None):
    """列出所有 Agent"""
    agents = []
    for f in AGENTS_DIR.glob("*.yaml"):
        config = yaml.safe_load(f.read_text())
        meta = config.get("metadata", {})
        if visibility and meta.get("visibility") != visibility:
            continue
        agents.append({
            "id": meta.get("id"),
            "name": meta.get("name"),
            "display_name": meta.get("display_name"),
            "description": meta.get("description"),
            "visibility": meta.get("visibility"),
        })
    return {"agents": agents}

@app.put("/api/agents/{agent_id}")
async def update_agent(agent_id: str, req: AgentCreateRequest):
    """更新 Agent 配置"""
    config_file = AGENTS_DIR / f"{agent_id}.yaml"
    if not config_file.exists():
        raise HTTPException(status_code=404, detail="Agent not found")
    # ... 更新逻辑(类似 create)
    return {"status": "updated"}

@app.delete("/api/agents/{agent_id}")
async def delete_agent(agent_id: str):
    """删除 Agent"""
    config_file = AGENTS_DIR / f"{agent_id}.yaml"
    if config_file.exists():
        config_file.unlink()
    return {"status": "deleted"}

@app.post("/api/agents/{agent_id}/clone")
async def clone_agent(agent_id: str, new_name: str):
    """克隆现有 Agent 作为模板"""
    config_file = AGENTS_DIR / f"{agent_id}.yaml"
    if not config_file.exists():
        raise HTTPException(status_code=404, detail="Agent not found")

    config = yaml.safe_load(config_file.read_text())
    new_id = f"agent-{uuid.uuid4().hex[:8]}"
    config["metadata"]["id"] = new_id
    config["metadata"]["name"] = new_name

    new_file = AGENTS_DIR / f"{new_id}.yaml"
    new_file.write_text(yaml.dump(config, allow_unicode=True))
    return {"id": new_id, "status": "cloned"}

async def _register_in_dify(config: dict):
    """将 Agent 配置注册到 Dify 平台"""
    # 通过 Dify API 创建对应的应用
    # POST /api/v1/apps
    pass

# 启动: uvicorn agent_manager:app --host 0.0.0.0 --port 8700

4.4 Agent 模板市场

┌─────────────────────────────────────────────────────────────┐
│                    Agent 模板市场                            │
├──────────────┬──────────────────────────────────────────────┤
│ 🔥 热门模板  │                                              │
│              │  📝 新媒体运营助手                            │
│              │     自动写推文、配图、排版,支持多平台风格     │
│              │     Skills: copywriting, text2img, seo        │
│              │     ⭐ 4.8 | 1.2k 使用                       │
│              │     [使用此模板]                              │
│              │                                              │
│              │  💻 全栈开发助手                              │
│              │     代码生成、Review、测试、Debug 全流程       │
│              │     Skills: code_generate, test_gen, debug    │
│              │     ⭐ 4.9 | 2.5k 使用                       │
│              │     [使用此模板]                              │
│              │                                              │
│              │  🎨 设计师助手                                │
│              │     UI设计、Logo生成、配色方案、原型图         │
│              │     Skills: text2img, img2img, remove_bg      │
│              │     ⭐ 4.6 | 800 使用                        │
│              │     [使用此模板]                              │
│              │                                              │
│              │  📊 数据分析师                                │
│              │     SQL生成、图表绘制、报告撰写               │
│              │     Skills: sql_gen, code_execute, text2doc   │
│              │     ⭐ 4.7 | 950 使用                        │
│              │     [使用此模板]                              │
│              │                                              │
│              │  📚 学术研究助手                              │
│              │     论文检索、文献综述、格式排版               │
│              │     Skills: web_search, summary, text2doc     │
│              │     ⭐ 4.5 | 600 使用                        │
│              │     [使用此模板]                              │
├──────────────┼──────────────────────────────────────────────┤
│ 📂 分类      │  🏢 办公效率  |  💻 软件开发  |  🎨 设计创意  │
│              │  📊 数据分析  |  📚 教育学习  |  🏥 健康医疗  │
│              │  ⚖️ 法律顾问  |  💰 财务税务  |  🎮 娱乐休闲  │
└──────────────┴──────────────────────────────────────────────┘

4.5 Agent 间协作(Team 模式)

# teams/content_team.team.yaml
apiVersion: v1
kind: Team
metadata:
  name: content-production-team
  display_name: 内容生产团队
  description: 从选题到发布的全流程内容生产

spec:
  # 团队成员(都是 Agent)
  members:
    - agent: copywriter
      role: 文案撰写
      description: 负责撰写文章正文

    - agent: image-designer
      role: 配图设计
      description: 为文章生成配图

    - agent: video-creator
      role: 视频制作
      description: 将文章内容转为短视频

    - agent: editor
      role: 主编审核
      description: 审核内容质量,给出修改意见

  # 协作流程
  workflow:
    - phase: "1. 选题策划"
      agent: editor
      action: "根据用户需求,确定文章选题、角度和大纲"
      output: topic_outline

    - phase: "2. 文案撰写"
      agent: copywriter
      action: "根据大纲撰写完整文章"
      input: topic_outline
      output: draft_article

    - phase: "3. 内容审核"
      agent: editor
      action: "审核文章质量,给出修改意见"
      input: draft_article
      output: review_feedback
      loop_until: "approved"    # 循环直到审核通过

    - phase: "4. 配图生成"
      agent: image-designer
      action: "根据文章内容生成 3-5 张配图"
      input: draft_article
      output: images
      parallel: true            # 与下一步并行

    - phase: "5. 视频制作"
      agent: video-creator
      action: "将文章核心内容制作为 60 秒短视频"
      input: draft_article
      output: video
      parallel: true            # 与上一步并行

    - phase: "6. 最终整合"
      agent: editor
      action: "整合文章、配图、视频,输出最终成品"
      input: [draft_article, images, video]
      output: final_package

  # 协作规则
  rules:
    max_revision_rounds: 3      # 最多修改3轮
    timeout_per_phase: 300      # 每阶段最长5分钟
    fallback_on_failure: skip   # 某环节失败时跳过(而非中断)

4.6 用户自定义 Agent 的 Prompt 工程辅助

# prompt_engineer.py — 帮助用户生成高质量系统提示词
class PromptEngineer:
    """辅助用户创建 Agent 的系统提示词"""

    TEMPLATE = """请根据以下信息,生成一个专业的 AI Agent 系统提示词:

角色名称: {role_name}
领域: {domain}
目标用户: {target_users}
核心功能: {core_functions}
语气风格: {tone}
限制条件: {restrictions}

要求:
1. 明确角色定位和专业背景
2. 列出具体的行为准则(DO 和 DON'T)
3. 定义输出格式偏好
4. 包含错误处理策略(不确定时怎么做)
5. 加入安全防护指令(防止 prompt 注入、越权回答)
6. 总长度控制在 300-500 字"""

    def generate_persona(self, role_name: str, domain: str,
                         target_users: str, core_functions: str,
                         tone: str = "专业友好",
                         restrictions: str = "不编造信息") -> str:
        prompt = self.TEMPLATE.format(
            role_name=role_name, domain=domain,
            target_users=target_users, core_functions=core_functions,
            tone=tone, restrictions=restrictions
        )
        resp = requests.post("http://localhost:8000/v1/chat/completions", json={
            "model": "qwen2.5-72b",
            "messages": [{"role": "user", "content": prompt}],
            "temperature": 0.7
        })
        return resp.json()["choices"][0]["message"]["content"]

    def optimize_persona(self, current_persona: str,
                         user_feedback: str) -> str:
        """根据用户反馈优化提示词"""
        prompt = f"""当前系统提示词:
{current_persona}

用户反馈:
{user_feedback}

请优化系统提示词,解决用户反馈中提到的问题。保持原有优点,改进不足之处。"""

        resp = requests.post("http://localhost:8000/v1/chat/completions", json={
            "model": "qwen2.5-72b",
            "messages": [{"role": "user", "content": prompt}],
            "temperature": 0.5
        })
        return resp.json()["choices"][0]["message"]["content"]

第五部分:完整目录结构

ai-agent-platform/
├── docker-compose.yml              # 主部署文件
├── docker-compose.ha.yml           # 高可用配置
├── docker-compose.monitoring.yml   # 监控配置
├── .env                            # 环境变量
│
├── models/                         # 模型文件
│   ├── qwen2.5-72b/
│   ├── qwen2.5-coder-32b/
│   └── bge-m3/
│
├── services/                       # 工具微服务
│   ├── ppt/
│   │   ├── Dockerfile
│   │   ├── requirements.txt
│   │   └── ppt_service.py
│   ├── video/
│   │   ├── Dockerfile
│   │   ├── requirements.txt
│   │   └── video_service.py
│   └── skill_runner/
│       ├── Dockerfile
│       └── skill_runner.py
│
├── mcp_servers/                    # MCP Server
│   ├── mcp_filesystem.py
│   ├── mcp_comfyui.py
│   └── mcp_tools.py
│
├── skills/                         # Skill 定义
│   ├── text2ppt.skill.json
│   ├── text2img.skill.json
│   ├── code_generate.skill.json
│   ├── web_search.skill.json
│   └── ...
│
├── agents/                         # Agent 配置
│   ├── copywriter.agent.yaml
│   ├── coder.agent.yaml
│   ├── designer.agent.yaml
│   └── ...
│
├── teams/                          # Team 配置
│   └── content_team.team.yaml
│
├── knowledge/                      # 知识库原始文件
│   ├── coding/
│   ├── writing/
│   ├── creative/
│   ├── life/
│   └── custom/                     # 用户自定义知识库
│
├── knowledge_scrapers/             # 知识采集器
│   ├── coding_scraper.py
│   ├── writing_scraper.py
│   ├── creative_scraper.py
│   └── __init__.py
│
├── core/                           # 核心模块
│   ├── knowledge_pipeline.py       # 知识库同步流水线
│   ├── knowledge_quality.py        # 质量检测
│   ├── skill_discovery.py          # Skill 发现引擎
│   ├── skill_to_dify_tool.py       # Skill → Dify 工具转换
│   ├── agent_manager.py            # Agent 管理 API
│   ├── prompt_engineer.py          # Prompt 工程辅助
│   ├── request_queue.py            # 请求队列
│   ├── gpu_manager.py              # GPU 调度
│   └── health_monitor.py           # 健康监控
│
├── nginx/
│   └── nginx-model.conf
│
├── prometheus/
│   └── prometheus.yml
│
├── comfyui_models/                 # ComfyUI 模型文件
│   ├── checkpoints/
│   ├── unet/
│   └── loras/
│
├── comfyui_workflows/              # ComfyUI 工作流模板
│   ├── text_to_image_flux.json
│   ├── image_to_video_wan21.json
│   └── ...
│
└── scripts/                        # 运维脚本
    ├── setup.sh                    # 一键安装
    ├── download_models.sh          # 模型下载
    ├── backup.sh                   # 备份
    └── update.sh                   # 更新

第六部分:一键启动脚本

#!/bin/bash
# scripts/setup.sh — 一键安装部署脚本

set -e

echo "========================================="
echo "  AI Agent Platform - One-Click Setup"
echo "========================================="

# 检查前置条件
check_prerequisites() {
    echo "[1/8] Checking prerequisites..."

    # Docker
    if ! command -v docker &> /dev/null; then
        echo "Installing Docker..."
        curl -fsSL https://get.docker.com | sh
        sudo usermod -aG docker $USER
    fi
    echo "  ✅ Docker: $(docker --version)"

    # Docker Compose
    if ! command -v docker compose &> /dev/null; then
        echo "ERROR: Docker Compose not found"
        exit 1
    fi
    echo "  ✅ Docker Compose: available"

    # NVIDIA Driver
    if command -v nvidia-smi &> /dev/null; then
        echo "  ✅ NVIDIA Driver: $(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1)"
        echo "  ✅ GPU: $(nvidia-smi --query-gpu=name --format=csv,noheader)"
        echo "  ✅ VRAM: $(nvidia-smi --query-gpu=memory.total --format=csv,noheader)"
    else
        echo "  ⚠️  No NVIDIA GPU detected, will use CPU mode (slow)"
    fi

    # NVIDIA Container Toolkit
    if docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi &> /dev/null; then
        echo "  ✅ NVIDIA Container Toolkit: working"
    else
        echo "  Installing NVIDIA Container Toolkit..."
        distribution=$(. /etc/os-release; echo $ID$VERSION_ID)
        curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
        curl -s -L "https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list" | \
            sudo tee /etc/apt/sources.list.d/nvidia-docker.list
        sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
        sudo systemctl restart docker
    fi
}

# 创建目录结构
create_directories() {
    echo "[2/8] Creating directory structure..."
    mkdir -p models services/ppt services/video mcp_servers skills agents teams
    mkdir -p knowledge/{coding,writing,creative,life,custom}
    mkdir -p knowledge_scrapers core scripts
    mkdir -p comfyui_models/{checkpoints,unet,loras}
    mkdir -p comfyui_workflows nginx prometheus
    mkdir -p ollama_data weaviate_data minio_data grafana_data outputs
    echo "  ✅ Directories created"
}

# 下载模型
download_models() {
    echo "[3/8] Downloading models (this may take a while)..."

    # 检测可用显存,选择合适的模型
    if command -v nvidia-smi &> /dev/null; then
        VRAM=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits | head -1)
        GPU_COUNT=$(nvidia-smi --query-gpu=count --format=csv,noheader | head -1)
        TOTAL_VRAM=$((VRAM * GPU_COUNT))

        if [ "$TOTAL_VRAM" -ge 48000 ]; then
            MODEL="Qwen/Qwen2.5-72B-Instruct-GPTQ-Int4"
            echo "  Using Qwen2.5-72B (${TOTAL_VRAM}MB VRAM detected)"
        elif [ "$TOTAL_VRAM" -ge 24000 ]; then
            MODEL="Qwen/Qwen2.5-32B-Instruct-AWQ"
            echo "  Using Qwen2.5-32B (${TOTAL_VRAM}MB VRAM detected)"
        else
            MODEL="Qwen/Qwen2.5-14B-Instruct-AWQ"
            echo "  Using Qwen2.5-14B (${TOTAL_VRAM}MB VRAM detected)"
        fi
    else
        MODEL="Qwen/Qwen2.5-7B-Instruct"
        echo "  Using Qwen2.5-7B (CPU mode)"
    fi

    pip install modelscope -q
    modelscope download --model "$MODEL" --local_dir ./models/qwen2.5
    echo "  ✅ Language model downloaded"
}

# 启动核心服务
start_services() {
    echo "[4/8] Starting core services..."
    docker compose up -d
    echo "  ✅ Services starting..."

    # 等待服务就绪
    echo "  Waiting for services to be ready..."
    for i in {1..60}; do
        if curl -s http://localhost:8000/v1/models > /dev/null 2>&1; then
            echo "  ✅ Model service ready"
            break
        fi
        sleep 5
    done
}

# 初始化 Dify
init_dify() {
    echo "[5/8] Initializing Dify..."
    cd dify/docker && docker compose up -d && cd ../..
    echo "  ✅ Dify started at http://localhost"
    echo "  📌 Visit http://localhost/install to complete setup"
}

# 初始化知识库
init_knowledge() {
    echo "[6/8] Initializing knowledge base..."
    python core/knowledge_pipeline.py --init
    echo "  ✅ Knowledge base initialized"
}

# 注册 Skills
register_skills() {
    echo "[7/8] Registering skills..."
    for skill_file in skills/*.skill.json; do
        echo "  Registering: $skill_file"
    done
    echo "  ✅ Skills registered"
}

# 完成
finish() {
    echo "[8/8] Setup complete!"
    echo ""
    echo "========================================="
    echo "  🎉 AI Agent Platform is ready!"
    echo "========================================="
    echo ""
    echo "  Services:"
    echo "  - Dify Web UI:     http://localhost"
    echo "  - Model API:       http://localhost:8000"
    echo "  - ComfyUI:         http://localhost:8188"
    echo "  - Agent Manager:   http://localhost:8700"
    echo "  - MinIO Console:   http://localhost:9001"
    echo "  - Grafana:         http://localhost:3000"
    echo ""
    echo "  Next steps:"
    echo "  1. Visit http://localhost/install to set up Dify"
    echo "  2. Add your local model in Dify Settings"
    echo "  3. Create your first Agent!"
    echo ""
}

# 执行
check_prerequisites
create_directories
download_models
start_services
init_dify
init_knowledge
register_skills
finish

本地部署全能 AI Agent 完整方案

作者 王小酱
2026年3月24日 11:47

架构总览

┌─────────────────────────────────────────────────────────┐
│                    用户入口 (Web UI)                      │
│              Dify / Open WebUI / 自研前端                 │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│               Agent 编排层 (Dify + MCP)                  │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐  │
│  │ 文案编辑  │ │ PPT制作  │ │ 编码助手  │ │ 生活助手   │  │
│  │ SubAgent │ │ SubAgent │ │ SubAgent │ │ SubAgent  │  │
│  └──────────┘ └──────────┘ └──────────┘ └───────────┘  │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐                │
│  │ 文生图   │ │ 文生视频  │ │ 视频剪辑  │                │
│  │ MCP Tool │ │ MCP Tool │ │ MCP Tool │                │
│  └──────────┘ └──────────┘ └──────────┘                │
│  ┌─────────────────────────────────────┐                │
│  │          RAG 知识库引擎              │                │
│  └─────────────────────────────────────┘                │
└──────────────────────┬──────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────────┐
│                  模型服务层                               │
│  ┌────────────┐ ┌────────────┐ ┌─────────────────────┐  │
│  │ Qwen2.5    │ │ SD/FLUX    │ │ CogVideoX / Wan2.1  │  │
│  │ 72B (vLLM) │ │ (ComfyUI)  │ │ (ComfyUI)           │  │
│  └────────────┘ └────────────┘ └─────────────────────┘  │
└─────────────────────────────────────────────────────────┘

第一部分:硬件要求

最低配置(能跑)

组件 规格 预算参考
GPU 1× RTX 4090 24GB ~¥16,000
CPU i7-13700K / Ryzen 9 7900X ~¥3,000
内存 64GB DDR5 ~¥1,500
存储 2TB NVMe SSD ~¥1,000
总计 ~¥25,000

此配置可跑 Qwen2.5-32B(量化)+ SDXL/FLUX + CogVideoX-2B

推荐配置(体验好)

组件 规格 预算参考
GPU 2× RTX 4090 24GB 或 1× A100 80GB ¥32,000 ~ ¥80,000
CPU i9-14900K / Ryzen 9 7950X ~¥4,500
内存 128GB DDR5 ~¥3,000
存储 4TB NVMe SSD ~¥2,000

此配置可跑 Qwen2.5-72B(4bit量化)+ FLUX.1 + CogVideoX-5B + Wan2.1

云服务器替代方案(按需租用)

# AutoDL(国内最流行的 GPU 云)
# A100 80GB: ~¥5/小时
# RTX 4090: ~¥2/小时
# 注册: https://www.autodl.com

# 适合不想一次性投入硬件的场景

第二部分:模型选型与部署

2.1 核心语言模型 — Qwen2.5-72B-Instruct

为什么选它

  • 开源模型中综合能力最强之一(代码、数学、中文理解)
  • 原生支持 Function Calling / Tool Use
  • 128K 上下文窗口
  • 阿里持续迭代,社区生态好

部署方式 — vLLM(生产级推理引擎)

# 1. 安装 vLLM
pip install vllm

# 2. 下载模型(国内用 ModelScope 镜像)
pip install modelscope
modelscope download --model Qwen/Qwen2.5-72B-Instruct-GPTQ-Int4 --local_dir ./models/qwen2.5-72b

# 3. 启动 OpenAI 兼容 API 服务
python -m vllm.entrypoints.openai.api_server \
  --model ./models/qwen2.5-72b \
  --served-model-name qwen2.5-72b \
  --tensor-parallel-size 2 \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.9 \
  --host 0.0.0.0 \
  --port 8000

# 4. 验证
curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen2.5-72b",
    "messages": [{"role": "user", "content": "你好"}]
  }'

单卡 4090 方案(用 Qwen2.5-32B 量化)

# 如果只有单卡 4090 24GB
modelscope download --model Qwen/Qwen2.5-32B-Instruct-AWQ --local_dir ./models/qwen2.5-32b

python -m vllm.entrypoints.openai.api_server \
  --model ./models/qwen2.5-32b \
  --served-model-name qwen2.5-32b \
  --quantization awq \
  --max-model-len 16384 \
  --gpu-memory-utilization 0.95 \
  --host 0.0.0.0 \
  --port 8000

Ollama 方案(更简单,适合个人用)

# 安装 Ollama
curl -fsSL https://ollama.com/install.sh | sh

# 拉取模型
ollama pull qwen2.5:72b    # 需要 48GB+ 显存
ollama pull qwen2.5:32b    # 需要 24GB 显存
ollama pull qwen2.5:14b    # 需要 12GB 显存

# 启动(默认暴露 OpenAI 兼容 API 在 11434 端口)
ollama serve

# 验证
curl http://localhost:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model": "qwen2.5:32b", "messages": [{"role": "user", "content": "你好"}]}'

2.2 文生图模型 — FLUX.1 / Stable Diffusion

部署方式 — ComfyUI(最灵活的图像生成工作流)

# 1. 安装 ComfyUI
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI
pip install -r requirements.txt

# 2. 下载模型(放到 models/ 对应目录)
# FLUX.1-dev(推荐,生成质量高)
# 从 https://modelscope.cn 搜索 FLUX.1-dev 下载
# 放到 ComfyUI/models/unet/

# SD3.5 / SDXL 也可以同时部署
# 放到 ComfyUI/models/checkpoints/

# 3. 启动
python main.py --listen 0.0.0.0 --port 8188

# ComfyUI 提供 REST API,可被 Agent 调用
# POST http://localhost:8188/prompt

2.3 文生视频 / 图生视频 — Wan2.1 / CogVideoX

# 方案 A: ComfyUI + Wan2.1 插件(推荐,与图像生成统一管理)
cd ComfyUI/custom_nodes
git clone https://github.com/kijai/ComfyUI-WanVideoWrapper.git
pip install -r ComfyUI-WanVideoWrapper/requirements.txt

# 下载 Wan2.1 模型
# modelscope download --model Wan-AI/Wan2.1-T2V-14B --local_dir ./models/wan2.1

# 方案 B: CogVideoX(智谱开源,适合低显存)
pip install cogvideo-factory
# CogVideoX-2B 只需 8GB 显存,CogVideoX-5B 需要 24GB

2.4 代码专用模型(可选,增强编码能力)

# DeepSeek-Coder-V2 或 Qwen2.5-Coder-32B
ollama pull qwen2.5-coder:32b

# 作为编码 SubAgent 的专用模型

第三部分:Agent 平台部署 — Dify

3.1 为什么选 Dify

  • 开源、可私有化部署、中文社区活跃
  • 原生支持 Agent、RAG 知识库、工作流编排
  • 兼容 OpenAI API 格式(直接对接 vLLM/Ollama)
  • 支持自定义 Tool(可接入 ComfyUI、视频生成等)
  • 社区已有大量 Skills/Tools 可复用

3.2 部署 Dify

# 1. 克隆 Dify
git clone https://github.com/langgenius/dify.git
cd dify/docker

# 2. 配置环境变量
cp .env.example .env

# 编辑 .env,关键配置:
# EDITION=SELF_HOSTED
# SECRET_KEY=<生成一个随机字符串>
# CONSOLE_WEB_URL=http://你的IP
# APP_WEB_URL=http://你的IP

# 3. 启动
docker compose up -d

# 4. 访问 http://localhost/install 完成初始化
# 设置管理员账号密码

3.3 接入本地模型

在 Dify 管理后台:

设置  模型供应商  OpenAI-API-compatible

名称: Local-Qwen2.5-72B
API Base: http://宿主机IP:8000/v1     # vLLM 地址
API Key: not-needed                    # 本地不需要
模型名: qwen2.5-72b                   # 与 vLLM --served-model-name 一致

第四部分:构建多能力 Agent 系统

4.1 Agent 架构设计

                    ┌──────────────┐
                    │  主 Agent    │ ← 路由 + 意图识别
                    │ (Orchestrator)│
                    └──────┬───────┘
           ┌───────┬───────┼───────┬───────┬───────┐
           ▼       ▼       ▼       ▼       ▼       ▼
        ┌──────┐┌──────┐┌──────┐┌──────┐┌──────┐┌──────┐
        │文案   ││PPT   ││编码   ││图像   ││视频   ││生活   │
        │Agent ││Agent ││Agent ││Agent ││Agent ││Agent │
        └──┬───┘└──┬───┘└──┬───┘└──┬───┘└──┬───┘└──┬───┘
           │       │       │       │       │       │
        知识库   python  代码执行 ComfyUI ComfyUI  搜索
        RAG    -pptx   沙箱    API    API    天气/日历

4.2 在 Dify 中创建各个 SubAgent

SubAgent 1: 文案编辑助手

# Dify → 创建应用 → Agent
名称: 文案编辑助手
系统提示词: |
  你是一名专业的文案编辑。擅长:
  - 各类文体写作(公文、营销文案、技术文档、自媒体文案)
  - 文章润色、改写、缩写、扩写
  - SEO 优化、标题党生成
  - 多风格切换(正式、幽默、学术、口语化)

工具:
  - 知识库检索(上传写作范文、品牌调性文档)
  - 网页搜索(Dify 内置)

SubAgent 2: PPT 制作助手

名称: PPT 制作助手
系统提示词: |
  你是一名 PPT 制作专家。根据用户需求生成专业 PPT。
  使用 python-pptx 库生成 .pptx 文件。

工具:
  - 代码执行(Dify Sandbox,预装 python-pptx)
  - 文生图工具(调用 ComfyUI 生成配图)

自定义 Tool — PPT 生成 API

# ppt_service.py — 独立部署的 PPT 生成微服务
from fastapi import FastAPI
from pptx import Presentation
from pptx.util import Inches, Pt
import json, uuid, os

app = FastAPI()

@app.post("/generate_ppt")
async def generate_ppt(request: dict):
    """
    接收结构化 PPT 内容,生成 .pptx 文件
    request: {
        "title": "演示标题",
        "slides": [
            {"title": "页面标题", "content": "内容", "image_url": "可选图片"}
        ]
    }
    """
    prs = Presentation()

    # 封面
    slide = prs.slides.add_slide(prs.slide_layouts[0])
    slide.shapes.title.text = request["title"]

    # 内容页
    for s in request.get("slides", []):
        slide = prs.slides.add_slide(prs.slide_layouts[1])
        slide.shapes.title.text = s["title"]
        slide.placeholders[1].text = s["content"]

    filename = f"/outputs/ppt_{uuid.uuid4().hex[:8]}.pptx"
    prs.save(filename)
    return {"file_path": filename, "download_url": f"/download/{os.path.basename(filename)}"}

# 启动: uvicorn ppt_service:app --host 0.0.0.0 --port 8501

SubAgent 3: 编码助手

名称: 编码助手
模型: qwen2.5-coder:32b  # 用代码专用模型
系统提示词: |
  你是一名资深全栈工程师,擅长 TypeScript/Python/Go/Rust。
  遵循 TDD、SOLID 原则。提供可运行的完整代码。

工具:
  - 代码执行沙箱(Dify Sandbox)
  - 知识库(上传框架文档、内部 API 文档)

SubAgent 4: 文生图 / 图像助手

创建 ComfyUI MCP Tool

# comfyui_tool.py — 封装 ComfyUI API 为 Dify 自定义工具
from fastapi import FastAPI
import requests, json, uuid, time

app = FastAPI()
COMFYUI_URL = "http://localhost:8188"

# 预定义的 ComfyUI 工作流模板
FLUX_WORKFLOW = {
    # ... ComfyUI 导出的 API 格式 workflow JSON
    # 关键节点:正向提示词、负向提示词、尺寸、步数
}

@app.post("/text2img")
async def text2img(prompt: str, width: int = 1024, height: int = 1024):
    workflow = FLUX_WORKFLOW.copy()
    # 注入用户 prompt
    workflow["6"]["inputs"]["text"] = prompt
    workflow["5"]["inputs"]["width"] = width
    workflow["5"]["inputs"]["height"] = height

    # 提交到 ComfyUI
    resp = requests.post(f"{COMFYUI_URL}/prompt", json={"prompt": workflow})
    prompt_id = resp.json()["prompt_id"]

    # 轮询等待完成
    while True:
        history = requests.get(f"{COMFYUI_URL}/history/{prompt_id}").json()
        if prompt_id in history:
            outputs = history[prompt_id]["outputs"]
            # 提取生成的图片路径
            for node_id in outputs:
                if "images" in outputs[node_id]:
                    image = outputs[node_id]["images"][0]
                    return {
                        "image_url": f"{COMFYUI_URL}/view?filename={image['filename']}"
                    }
        time.sleep(1)

# 启动: uvicorn comfyui_tool:app --host 0.0.0.0 --port 8502

SubAgent 5: 视频生成 / 剪辑助手

# video_service.py
from fastapi import FastAPI
import subprocess, uuid

app = FastAPI()

@app.post("/text2video")
async def text2video(prompt: str, duration: int = 4):
    """调用 Wan2.1 / CogVideoX 生成视频"""
    # 通过 ComfyUI API 调用视频生成工作流
    # 类似 text2img,但使用视频生成节点
    pass

@app.post("/edit_video")
async def edit_video(input_path: str, operations: list):
    """
    使用 FFmpeg 进行视频剪辑
    operations: [
        {"type": "trim", "start": "00:00:05", "end": "00:00:15"},
        {"type": "concat", "files": ["a.mp4", "b.mp4"]},
        {"type": "add_text", "text": "标题", "position": "center"},
        {"type": "add_bgm", "audio": "bgm.mp3", "volume": 0.3},
        {"type": "speed", "factor": 2.0}
    ]
    """
    output_path = f"/outputs/video_{uuid.uuid4().hex[:8]}.mp4"

    for op in operations:
        if op["type"] == "trim":
            subprocess.run([
                "ffmpeg", "-i", input_path,
                "-ss", op["start"], "-to", op["end"],
                "-c", "copy", output_path
            ])
        elif op["type"] == "add_text":
            subprocess.run([
                "ffmpeg", "-i", input_path,
                "-vf", f"drawtext=text='{op['text']}':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2",
                output_path
            ])
        # ... 更多操作

    return {"file_path": output_path}

# 启动: uvicorn video_service:app --host 0.0.0.0 --port 8503

SubAgent 6: 生活助手

名称: 生活助手
系统提示词: |
  你是一名全能生活助手,可以帮助用户:
  - 日程管理、待办事项
  - 天气查询、出行建议
  - 菜谱推荐、健康建议
  - 知识问答

工具:
  - 天气 API(和风天气免费 API)
  - 日历/提醒(CalDAV 集成)
  - 网页搜索
  - 知识库(上传菜谱、健康指南等)

4.3 主 Agent(Orchestrator)编排

在 Dify 中创建**工作流(Workflow)**类型应用:

名称: 全能助手
类型: Workflow(Agent 模式)

系统提示词: |
  你是一个智能路由助手。分析用户意图,将任务分发给合适的专业助手:

  - 写文章、改文案、润色  调用「文案编辑助手」
  - 做PPT、制作演示文稿  调用「PPT制作助手」
  - 写代码、调试、技术问题  调用「编码助手」
  - 画图、生成图片、设计  调用「图像助手」
  - 做视频、剪辑、视频生成  调用「视频助手」
  - 日常问题、天气、日程  调用「生活助手」

  如果任务涉及多个领域,按顺序调用多个助手并整合结果。

工具:
  - 文案编辑助手(作为 SubAgent 工具)
  - PPT制作助手(作为 SubAgent 工具)
  - 编码助手(作为 SubAgent 工具)
  - 图像助手(作为 SubAgent 工具)
  - 视频助手(作为 SubAgent 工具)
  - 生活助手(作为 SubAgent 工具)

第五部分:MCP 集成

5.1 什么是 MCP

MCP (Model Context Protocol) 是 Anthropic 提出的开放协议,让 AI Agent 能以标准化方式连接外部工具和数据源。Dify 已原生支持 MCP。

5.2 部署 MCP Server

# 安装 MCP Server SDK
pip install mcp

# 也可使用社区 MCP Server
# https://github.com/modelcontextprotocol/servers

示例:文件系统 MCP Server

# mcp_filesystem.py
from mcp.server import Server
from mcp.types import Tool, TextContent
import os, json

server = Server("filesystem")

@server.tool()
async def read_file(path: str) -> str:
    """读取文件内容"""
    with open(path, "r") as f:
        return f.read()

@server.tool()
async def write_file(path: str, content: str) -> str:
    """写入文件"""
    with open(path, "w") as f:
        f.write(content)
    return f"已写入 {path}"

@server.tool()
async def list_directory(path: str = ".") -> str:
    """列出目录内容"""
    return json.dumps(os.listdir(path), ensure_ascii=False)

# 启动
if __name__ == "__main__":
    import asyncio
    from mcp.server.stdio import stdio_server
    asyncio.run(stdio_server(server))

示例:ComfyUI MCP Server(图像/视频生成)

# mcp_comfyui.py
from mcp.server import Server
import requests

server = Server("comfyui")

@server.tool()
async def generate_image(prompt: str, width: int = 1024, height: int = 1024) -> str:
    """使用 FLUX 模型生成图片"""
    resp = requests.post("http://localhost:8502/text2img",
                         json={"prompt": prompt, "width": width, "height": height})
    return resp.json()["image_url"]

@server.tool()
async def generate_video(prompt: str, duration: int = 4) -> str:
    """使用 Wan2.1 生成视频"""
    resp = requests.post("http://localhost:8503/text2video",
                         json={"prompt": prompt, "duration": duration})
    return resp.json()["file_path"]

5.3 在 Dify 中接入 MCP

Dify 管理后台 → 工具 → 自定义工具 → 添加 MCP 工具

传输方式: SSE
MCP Server URL: http://localhost:MCP端口/sse

第六部分:知识库建设

6.1 知识库架构

知识库/
├── 文案写作/
│   ├── 营销文案范文.pdf
│   ├── 公文写作规范.pdf
│   └── SEO写作指南.md
├── PPT制作/
│   ├── PPT设计原则.pdf
│   └── 配色方案手册.pdf
├── 编程开发/
│   ├── TypeScript最佳实践.md
│   ├── React设计模式.pdf
│   └── 内部API文档/(Swagger导出)
├── 图像设计/
│   ├── Prompt工程指南.md
│   ├── 构图法则.pdf
│   └── 风格参考库.md
├── 视频制作/
│   ├── 剪辑技巧手册.pdf
│   └── 转场效果参考.md
└── 生活百科/
    ├── 菜谱大全.pdf
    ├── 健康指南.pdf
    └── 旅行攻略.md

6.2 在 Dify 中创建知识库

1. Dify → 知识库 → 创建知识库
2. 上传文档(支持 PDF、Markdown、TXT、HTML、DOCX)
3. 分段设置:
   - 分段方式:自动
   - 最大分段长度:1000 tokens
   - 分段重叠:100 tokens
4. 索引方式:高质量(向量 + 关键词混合检索)
5. Embedding 模型:使用本地 bge-m3

部署本地 Embedding 模型

# 使用 Ollama 部署 Embedding 模型
ollama pull bge-m3

# 或使用 Xinference 部署(更多选择)
pip install xinference
xinference launch --model-name bge-m3 --model-type embedding
# API: http://localhost:9997/v1/embeddings

第七部分:Docker Compose 一键部署

# docker-compose.yml — 完整的全能 Agent 栈
version: '3.8'

services:
  # ========== 模型服务 ==========
  vllm:
    image: vllm/vllm-openai:latest
    runtime: nvidia
    ports:
      - "8000:8000"
    volumes:
      - ./models:/models
    command: >
      --model /models/qwen2.5-72b
      --served-model-name qwen2.5-72b
      --tensor-parallel-size 2
      --max-model-len 32768
      --gpu-memory-utilization 0.85
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 2
              capabilities: [gpu]

  ollama:
    image: ollama/ollama:latest
    runtime: nvidia
    ports:
      - "11434:11434"
    volumes:
      - ./ollama_data:/root/.ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

  # ========== 图像/视频生成 ==========
  comfyui:
    image: yanwk/comfyui-boot:latest
    runtime: nvidia
    ports:
      - "8188:8188"
    volumes:
      - ./comfyui_models:/root/ComfyUI/models
      - ./comfyui_output:/root/ComfyUI/output
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

  # ========== 工具微服务 ==========
  ppt-service:
    build: ./services/ppt
    ports:
      - "8501:8501"
    volumes:
      - ./outputs:/outputs

  video-service:
    build: ./services/video
    ports:
      - "8503:8503"
    volumes:
      - ./outputs:/outputs

  # ========== Agent 平台 (Dify) ==========
  # Dify 自带 docker-compose,这里只展示关键配置
  # 详见 dify/docker/docker-compose.yml

  # ========== 向量数据库 ==========
  weaviate:
    image: semitechnologies/weaviate:latest
    ports:
      - "8080:8080"
    environment:
      QUERY_DEFAULTS_LIMIT: 25
      AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true"
      PERSISTENCE_DATA_PATH: "/var/lib/weaviate"
    volumes:
      - ./weaviate_data:/var/lib/weaviate

  # ========== 对象存储(文件上传下载)==========
  minio:
    image: minio/minio:latest
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - ./minio_data:/data
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin123

第八部分:实施步骤时间线

基础设施
├── Day 1: 硬件采购/云服务器开通,安装 NVIDIA 驱动 + CUDA + Docker
├── Day 2: 部署 vLLM/Ollama + Qwen2.5 模型,验证推理正常
└── Day 2: 部署 Dify,接入本地模型,验证基础对话

核心能力
├── Day 3: 部署 ComfyUI + FLUX 模型,实现文生图
├── Day 3: 部署 Wan2.1/CogVideoX,实现文生视频
├── Day 4: 创建各个 SubAgent + 系统提示词
└── Day 4: 搭建知识库,上传初始文档

工具集成
├── Day 5: 开发 PPT/视频剪辑微服务
├── Day 5: 开发 MCP Server,接入 Dify
├── Day 6:  Agent 编排 + 路由测试
└── Day 6: 端到端联调

优化上线
├── Day 7: 性能调优(量化、缓存、并发)
├── Day 7: 安全加固(API 鉴权、网络隔离)
├── Day 8: 编写使用文档
└── Day 8: 正式使用 + 持续迭代知识库

总结:核心技术栈选择

层级 技术选型 理由
语言模型 Qwen2.5-72B (vLLM) 开源最强中文模型,原生 Tool Use
代码模型 Qwen2.5-Coder-32B 编码场景专用
图像生成 FLUX.1-dev (ComfyUI) 当前最优开源图像模型
视频生成 Wan2.1 / CogVideoX 阿里/智谱开源,效果好
Agent 平台 Dify 开源、成熟、支持 MCP/RAG/工作流
推理引擎 vLLM (生产) / Ollama (轻量) 高吞吐 / 易用
向量数据库 Weaviate (Dify内置支持) 知识库检索
工具协议 MCP 标准化工具集成
容器化 Docker Compose 一键部署全栈

QML 入门对象、属性、绑定与信号(六)

作者 HelloReader
2026年3月24日 11:16

适合人群: 跑通过第一个 Qt Quick 应用,想系统理解 QML 语法的新手

前言

前面几篇我们已经能写出简单的 QML 界面,但对语法的理解还停留在"照着抄"的阶段。本文的目标是让你真正理解 QML 的核心机制——对象树、属性绑定、信号与处理器——这些是整个 Qt Quick 开发的地基,后续所有课程都建立在这里。

一、QML 是什么:声明式 vs 命令式

传统的命令式编程描述"怎么做":

// C++ 命令式:一步步告诉程序怎么做
QLabel *label = new QLabel(this);
label->setText("Hello");
label->setGeometry(100, 100, 200, 40);
label->setAlignment(Qt::AlignCenter);

QML 的声明式描述"是什么":

// QML 声明式:描述这个元素的状态
Text {
    x: 100; y: 100
    width: 200; height: 40
    text: "Hello"
    horizontalAlignment: Text.AlignHCenter
}

两段代码效果相同,但 QML 版本更直接地表达了"这是一个文本,位置在这里,内容是这个",而不是一系列操作步骤。这就是声明式的本质。


二、QML 文档结构

每个 .qml 文件的基本结构如下:

// 1. 导入语句
import QtQuick
import QtQuick.Controls

// 2. 根对象(每个文件只有一个根对象)
Rectangle {

    // 3. 属性赋值
    width: 400
    height: 300
    color: "white"

    // 4. 子对象
    Text {
        anchors.centerIn: parent
        text: "Hello, QML!"
    }
}

三条基本规则:

  1. 每个 .qml 文件有且只有一个根对象
  2. 对象可以嵌套,形成父子关系的对象树
  3. import 语句必须写在文件最顶部

三、对象与对象树

QML 界面是一棵对象树,父对象包含子对象,子对象的坐标相对于父对象计算。

Rectangle {              // 根对象(父)
    width: 400
    height: 300
    color: "#f0f0f0"

    Rectangle {          // 子对象 1
        x: 20; y: 20
        width: 160; height: 120
        color: "#4A90E2"

        Text {           // 孙对象
            anchors.centerIn: parent
            text: "左上角"
            color: "white"
        }
    }

    Rectangle {          // 子对象 2
        x: 220; y: 20
        width: 160; height: 120
        color: "#E24A4A"

        Text {
            anchors.centerIn: parent
            text: "右上角"
            color: "white"
        }
    }
}

子对象的 x: 20 是相对于父对象左上角的偏移,而不是相对于屏幕。这让布局计算变得直观:移动父对象,所有子对象跟着一起移动。


四、属性

4.1 基本属性赋值

Rectangle {
    width: 200          // 整数
    height: 100
    color: "steelblue"  // 颜色字符串
    opacity: 0.8        // 浮点数
    visible: true       // 布尔值
    radius: 8           // 圆角半径
}

4.2 属性的类型

QML 中常见的属性类型:

类型 示例
int width: 200
real opacity: 0.5
bool visible: true
string text: "Hello"
color color: "#FF5733"color: "red"
url source: "images/logo.png"
var 任意类型

4.3 自定义属性

property 关键字在对象上定义自己的属性:

Rectangle {
    width: 300
    height: 200

    // 自定义属性
    property int clickCount: 0
    property string userName: "访客"
    property color themeColor: "#4A90E2"

    color: themeColor   // 使用自定义属性

    Text {
        anchors.centerIn: parent
        text: userName + " 点击了 " + clickCount + " 次"
    }
}

自定义属性的好处:把重要的数据集中管理,而不是散落在各个子元素里。

4.4 强类型属性(推荐写法)

Qt 6 推荐使用强类型属性声明,能在编译时发现类型错误:

// 推荐:强类型声明
property int score: 0
property string playerName: ""
property bool isGameOver: false

// 不推荐:var 类型失去类型检查
property var score: 0

五、属性绑定:QML 最核心的概念

属性绑定是 QML 中最重要、也是最容易被忽视的机制。

5.1 什么是属性绑定

Rectangle {
    width: 400
    height: width / 2    // height 绑定到 width
}

这里 height: width / 2 不是一次性赋值,而是建立了一个持续有效的依赖关系:每当 width 变化时,height 自动重新计算。

Rectangle {
    id: container
    width: 400
    height: width / 2    // 绑定

    // 拖动窗口改变 width 时,height 自动跟随
}

5.2 绑定 vs 赋值

这是新手最容易犯的错误:

Rectangle {
    id: box
    width: 200

    Text {
        text: "宽度:" + box.width    // 绑定:自动跟随 box.width 变化
    }

    MouseArea {
        anchors.fill: parent
        onClicked: {
            box.width = 300           // 普通赋值:只改变一次
            // 注意:如果之前有绑定,赋值会破坏绑定!
        }
    }
}

关键规则: 在 JavaScript 代码块(如 onClicked)中用 = 赋值,会打断原有的属性绑定。如果需要在事件处理中保持绑定,使用 Qt.binding()

onClicked: {
    box.width = Qt.binding(function() { return parent.width / 2 })
}

5.3 绑定的实际应用

ApplicationWindow {
    id: window
    width: 640
    height: 480
    visible: true

    Rectangle {
        // 始终填满窗口的一半宽度
        width: window.width / 2
        height: window.height
        color: "#E6F1FB"

        Text {
            anchors.centerIn: parent
            // 实时显示父容器尺寸
            text: parent.width + " × " + parent.height
            font.pixelSize: 16
        }
    }
}

拖动窗口调整大小,矩形自动跟随,文字自动更新。这一切不需要写任何事件监听代码。


六、id:给对象命名

id 是对象在当前 QML 文件中的唯一标识符,用于在其他地方引用这个对象:

Rectangle {
    width: 400
    height: 300

    Rectangle {
        id: redBox          // 定义 id
        width: 100
        height: 100
        color: "red"
    }

    Rectangle {
        // 通过 id 引用另一个对象
        x: redBox.x + redBox.width + 20    // 紧跟在 redBox 右边
        width: redBox.width                 // 与 redBox 同宽
        height: redBox.height
        color: "blue"
    }
}

id 的命名规范:

  • 以小写字母开头:myButtonnameInput
  • 使用驼峰命名:userNameLabel
  • 不能和 QML 关键字冲突:不要用 itemparentroot

七、信号与信号处理器

7.1 什么是信号

信号(Signal)是 Qt 对象系统的核心通信机制。当某件事情发生时,对象发出信号;其他对象可以响应这个信号。

在 QML 中,每个信号对应一个信号处理器,命名规则是:on + 信号名首字母大写。

Button {
    text: "点我"
    onClicked: console.log("按钮被点击")    // clicked 信号的处理器
    onPressed: console.log("按下")          // pressed 信号的处理器
    onReleased: console.log("松开")         // released 信号的处理器
}

7.2 常见的内置信号

// 组件加载完成
Rectangle {
    Component.onCompleted: {
        console.log("组件已加载,宽度:" + width)
    }
}

// 属性变化信号:on + 属性名 + Changed
Rectangle {
    width: 200
    onWidthChanged: console.log("宽度变为:" + width)
}

// 鼠标区域信号
MouseArea {
    anchors.fill: parent
    onClicked: console.log("点击位置:" + mouse.x + ", " + mouse.y)
    onDoubleClicked: console.log("双击")
    onEntered: console.log("鼠标进入")
    onExited: console.log("鼠标离开")
}

7.3 自定义信号

Rectangle {
    id: card
    width: 200
    height: 120
    color: "#f5f5f5"
    radius: 8

    // 声明自定义信号
    signal cardSelected(string cardName)

    property string name: "卡片 A"

    MouseArea {
        anchors.fill: parent
        onClicked: card.cardSelected(card.name)    // 发出信号
    }
}

在父对象中响应这个信号:

Rectangle {
    Card {
        id: myCard
        onCardSelected: function(name) {          // 响应自定义信号
            console.log("选中了:" + name)
        }
    }
}

八、组件:创建可复用的元素

当某段 QML 代码需要在多处使用时,把它封装成组件

方式一:独立的 .qml 文件

新建文件 RoundButton.qml

// RoundButton.qml
import QtQuick
import QtQuick.Controls

Button {
    id: root

    // 暴露可配置的属性
    property color buttonColor: "#4A90E2"

    contentItem: Text {
        text: root.text
        color: "white"
        font.pixelSize: 14
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
    }

    background: Rectangle {
        color: root.buttonColor
        radius: height / 2    // 完全圆角
        opacity: root.pressed ? 0.8 : 1.0
    }
}

在其他文件中使用(文件名即类型名):

import QtQuick

Rectangle {
    width: 400
    height: 200

    RoundButton {
        anchors.centerIn: parent
        text: "确认"
        buttonColor: "#1D9E75"
        width: 120
        height: 44
        onClicked: console.log("确认按钮点击")
    }
}

方式二:内联组件

在同一个文件内定义局部组件:

import QtQuick

Rectangle {
    width: 400
    height: 300

    // 定义内联组件
    component TagLabel: Rectangle {
        property string labelText: ""
        width: tagText.width + 16
        height: 24
        radius: 12
        color: "#E6F1FB"

        Text {
            id: tagText
            anchors.centerIn: parent
            text: parent.labelText
            color: "#185FA5"
            font.pixelSize: 12
        }
    }

    // 使用内联组件
    Row {
        anchors.centerIn: parent
        spacing: 8

        TagLabel { labelText: "Qt Quick" }
        TagLabel { labelText: "QML" }
        TagLabel { labelText: "跨平台" }
    }
}

九、parent 关键字

在 QML 中,parent 指当前对象的父对象:

Rectangle {
    width: 400
    height: 300

    Rectangle {
        // parent 指外层 Rectangle
        width: parent.width * 0.5      // 父宽度的 50%
        height: parent.height * 0.5    // 父高度的 50%
        anchors.centerIn: parent       // 居中于父对象
        color: "#4A90E2"
    }
}

注意: 在信号处理器的 JavaScript 代码块中,parent 的含义可能改变。建议在需要引用特定对象时使用 id,而不是依赖 parent

Rectangle {
    id: outerRect    // 用 id 明确标识

    Rectangle {
        MouseArea {
            onClicked: {
                // 用 id 比用 parent.parent 更清晰可靠
                outerRect.color = "red"
            }
        }
    }
}

十、一个完整的综合示例

把本文的知识点整合成一个计数器应用:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 360
    height: 480
    visible: true
    title: "计数器"

    // 自定义属性:集中管理状态
    property int count: 0
    property int step: 1
    property color activeColor: count >= 0 ? "#1D9E75" : "#E24A4A"

    Column {
        anchors.centerIn: parent
        spacing: 24
        width: 240

        // 计数显示
        Rectangle {
            width: parent.width
            height: 100
            radius: 12
            color: activeColor          // 绑定到 activeColor

            Text {
                anchors.centerIn: parent
                text: count             // 绑定到 count
                font.pixelSize: 48
                font.bold: true
                color: "white"
            }
        }

        // 步长选择
        Row {
            anchors.horizontalCenter: parent.horizontalCenter
            spacing: 8

            Text {
                anchors.verticalCenter: parent.verticalCenter
                text: "步长:"
                font.pixelSize: 14
                color: "#666"
            }

            Repeater {
                model: [1, 5, 10]
                delegate: Button {
                    required property int modelData
                    text: modelData
                    highlighted: step === modelData    // 绑定高亮状态
                    onClicked: step = modelData
                }
            }
        }

        // 操作按钮
        Row {
            anchors.horizontalCenter: parent.horizontalCenter
            spacing: 12

            Button {
                text: "−"
                font.pixelSize: 20
                width: 72; height: 48
                onClicked: count -= step
            }

            Button {
                text: "重置"
                width: 72; height: 48
                onClicked: count = 0
            }

            Button {
                text: "+"
                font.pixelSize: 20
                width: 72; height: 48
                onClicked: count += step
            }
        }

        // 状态文字:纯绑定,无需任何事件代码
        Text {
            anchors.horizontalCenter: parent.horizontalCenter
            text: count === 0 ? "归零"
                : count > 0  ? "正数:" + count
                :               "负数:" + count
            font.pixelSize: 14
            color: "#888"
        }
    }

    // 监听 count 变化
    onCountChanged: {
        if (Math.abs(count) > 100)
            console.log("警告:计数超过 100!")
    }
}

这个示例展示了:自定义属性、属性绑定(颜色跟随正负值变化)、信号处理器、Repeater 动态生成元素,以及属性变化信号 onCountChanged


十一、常见错误与注意事项

错误一:id 重复定义

// 错误:同一文件中 id 必须唯一
Rectangle { id: box; color: "red" }
Rectangle { id: box; color: "blue" }   // 报错!

错误二:在 JS 代码块中误用绑定语法

// 错误:冒号绑定语法不能用在 JS 代码块中
onClicked: {
    myText.color: "red"    // 语法错误!
    myText.color = "red"   // 正确:JS 代码块中用 =
}

错误三:循环绑定

// 错误:a 绑定 b,b 又绑定 a,产生无限循环
Rectangle {
    id: a
    width: b.width    // a.width 依赖 b.width
}
Rectangle {
    id: b
    width: a.width    // b.width 又依赖 a.width → 循环!
}

总结

概念 要点
声明式 描述"是什么",而不是"怎么做"
对象树 父子嵌套,子对象坐标相对于父对象
属性 内置属性 + 自定义 property,推荐强类型声明
属性绑定 a: b + c 建立持续依赖,= 赋值会打断绑定
id 唯一标识符,用于跨对象引用
信号处理器 on + 信号名,响应事件和状态变化
自定义信号 signal 关键字声明,实现组件间通信
组件 独立 .qml 文件或内联 component,封装可复用逻辑

JavaScript 模块化详解:CommonJS、ES Module 到底有什么区别?

2026年3月24日 11:10

前言

当项目代码越来越多时,如果所有变量、函数、对象都写在一个文件中,就会带来很多问题:

  • 容易命名冲突
  • 不方便协作
  • 代码难维护
  • 功能难复用

所以 JavaScript 需要 模块化

模块化并不只是“把代码拆分成多个文件”,更重要的是:

让每个文件都有自己的作用域,并且可以通过导入和导出组织代码。

本文就来系统讲清楚 JavaScript 模块化的发展和常见方案。


一、为什么需要模块化?

早期 JavaScript 没有模块系统,大家通常把变量和函数直接写到全局作用域中:

var name = 'Tom';

function getUser() {
  return name;
}

如果多个文件都写同名变量,就容易冲突。

模块化的出现,就是为了解决这些问题:

  • 避免全局污染
  • 提高代码复用
  • 方便拆分文件
  • 便于团队协作
  • 提高可维护性

二、早期模块化:IIFE

在 ES Module 出现之前,经常用立即执行函数来模拟模块作用域。

const userModule = (function () {
  let name = 'Tom';

  function getName() {
    return name;
  }

  function setName(newName) {
    name = newName;
  }

  return {
    getName,
    setName
  };
})();

console.log(userModule.getName()); // Tom

这种方式本质上利用了 闭包

特点:

  • 可以创建私有作用域
  • 能避免部分全局污染
  • 但不够标准,维护成本高

三、CommonJS

CommonJS 是 Node.js 中常见的模块化规范。

导出

// math.js
function add(a, b) {
  return a + b;
}

module.exports = {
  add
};

导入

// app.js
const math = require('./math');

console.log(math.add(1, 2)); // 3

特点:

  • 主要用于 Node.js

  • 使用

    require

    导入

  • 使用

    module.exports

    导出

  • 同步加载


四、ES Module(ESM)

这是现在最主流、最推荐的模块化方案。

命名导出

// math.js
export function add(a, b) {
  return a + b;
}

export function sub(a, b) {
  return a - b;
}

导入:

import { add, sub } from './math.js';

console.log(add(1, 2));
console.log(sub(5, 3));

默认导出

// user.js
export default function getUser() {
  return { name: 'Tom' };
}

导入:

import getUser from './user.js';

console.log(getUser());

五、命名导出和默认导出的区别

命名导出

export const name = 'Tom';

导入时必须使用对应名字:

import { name } from './file.js';

默认导出

export default function () {}

导入时名字可以自定义:

import myFn from './file.js';

六、CommonJS 和 ES Module 的区别

CommonJS

const math = require('./math');
module.exports = { add };

ES Module

import { add } from './math.js';
export { add };

常见区别:

  • CommonJS 多用于 Node.js 传统环境
  • ES Module 是 JavaScript 官方标准模块系统
  • CommonJS 是同步加载
  • ES Module 更适合现代前端工程化

七、模块化的实际意义

模块化最大的价值是让代码更清晰。

比如一个项目可以拆成:

src
├── api
├── utils
├── components
├── views
└── store

每个模块负责不同功能,代码更容易维护。


八、总结

JavaScript 模块化的核心目标是:

把代码拆分成独立、可维护、可复用的模块。

学习模块化时,重点记住:

  • 早期有 IIFE

  • Node.js 常见 CommonJS

  • 现代前端主流是 ES Module

  • export / import是最常见写法

原型与原型链

2026年3月24日 11:06

前言

在 JavaScript 中,原型是一个非常重要的概念。
如果不理解原型,很多内容都会变得很模糊,比如:

  • 构造函数
  • 实例方法共享
  • 原型链
  • 类的本质

本文重点讲清楚:什么是原型、为什么需要原型、prototype 和 proto 有什么区别、原型链


一、什么是原型?

在 JavaScript 中,每个函数都有一个特殊属性:

prototype

这个属性指向一个对象,这个对象就叫做 原型对象

看一个例子:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function () {
  console.log(`你好,我是 ${this.name}`);
};

const p1 = new Person('Tom');
const p2 = new Person('Alice');

p1.sayHello(); // 你好,我是 Tom
p2.sayHello(); // 你好,我是 Alice

这里:

Person.prototype

就是构造函数

Person

的原型对象。


二、为什么需要原型?

如果我们把方法写在构造函数内部:

function Person(name) {
  this.name = name;
  this.sayHello = function () {
    console.log(`你好,我是 ${this.name}`);
  };
}

那么每创建一个实例,就会重新创建一次

sayHello

方法。

这样会导致:

  • 方法重复创建
  • 浪费内存

如果写到原型上:

Person.prototype.sayHello = function () {
  console.log(`你好,我是 ${this.name}`);
};

那么所有实例就可以共享同一个方法。

所以原型最核心的作用就是:

让实例共享属性和方法。


三、prototype 和 proto 的区别

这是非常高频、也非常容易混淆的知识点。

1)prototype

  • 只有函数才有
  • 指向构造函数的原型对象

2)__proto__

  • 对象才有
  • 指向该对象的原型

看例子:

function Person() {}

const p = new Person();

console.log(Person.prototype);
console.log(p.__proto__);
console.log(p.__proto__ === Person.prototype); // true

这个关系非常重要:

实例对象.__proto__ === 构造函数.prototype


四、原型上的属性和实例上的属性

function Person(name) {
  this.name = name;
}

Person.prototype.age = 18;

const p = new Person('Tom');

console.log(p.name); // Tom
console.log(p.age);  // 18

这里:

  • name

    是实例自己的属性

  • age

    是原型上的属性

当访问

p.age

时,JS 发现实例本身没有这个属性,就会去原型上找。


五、原型的实际意义

原型的最大意义就是“共享”。

例如数组为什么都有

push

pop

map

这些方法?

因为这些方法都定义在:

Array.prototype

上。

所以数组实例本身不需要重复拥有这些方法。


六、总结

原型可以用一句话总结:

原型是 JavaScript 中实现属性和方法共享的机制。

重点记住:

  • 函数有

    prototype

  • 对象有

    __proto__

  • 实例.__proto__ === 构造函数.prototype


学完原型之后,接下来最重要的就是 原型链
原型链本质上解决的是一个问题:

当我们访问对象属性时,JavaScript 到底是怎么查找的?

理解了原型链,你就能更清楚地看懂:

  • 为什么对象可以调用某些方法

  • 为什么数组能用

    push

  • 为什么实例可以访问原型方法


原型链

一、什么是原型链?

当访问一个对象的属性或方法时,JavaScript 会先在对象本身查找。
如果找不到,就会去对象的原型上查找。
如果原型上还找不到,就继续去原型的原型上查找。
这一层一层向上查找的结构,就叫做 原型链


二、属性查找过程

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function () {
  console.log(`你好,我是 ${this.name}`);
};

const p = new Person('Tom');

console.log(p.name); // Tom
p.sayHello();        // 你好,我是 Tom

查找p.name

  1. 先看

    p

    自身有没有

    name

  2. 有,直接返回

查找p.sayHello

  1. 先看

    p

    自身有没有

    sayHello

  2. 没有

  3. p.__proto__

    ,也就是

    Person.prototype

    上找

  4. 找到了,执行它


三、原型链的尽头是什么?

function Person() {}

const p = new Person();

console.log(p.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null

所以原型链大致是:

p
→ Person.prototypeObject.prototypenull

null

就是原型链的终点。


四、数组的原型链

数组也是对象,所以也有原型链。

const arr = [1, 2, 3];

console.log(arr.__proto__ === Array.prototype); // true
console.log(Array.prototype.__proto__ === Object.prototype); // true

原型链结构大致是:

arr
→ Array.prototypeObject.prototypenull

所以数组既能用数组方法,也能用对象原型上的某些方法。


五、为什么原型链重要?

因为 JavaScript 中很多方法并不是对象自身直接拥有的,而是通过原型链继承来的。

例如:

const arr = [1, 2, 3];

arr.push(4);
arr.toString();

这里:

  • push

    来自

    Array.prototype

  • toString

    可能继续来自更上层原型


六、总结

原型链可以简单理解为:

对象查找属性时,沿着原型一层层向上查找的链式结构。

查找规则:

  1. 先找对象自身

  2. 再找原型

  3. 再找原型的原型

  4. 直到

    null

Qt Quick vs Qt Widgets如何选择适合你的 UI 技术路线(五)

作者 HelloReader
2026年3月24日 10:33

适合人群: 了解 Qt 基础,想搞清楚两种 UI 框架区别的开发者

前言

Qt 提供了两套完全不同的 UI 技术路线:Qt Quick(QML)Qt Widgets(C++)。很多新手在项目开始时就卡在这个选择上,不知道该学哪个、用哪个。

本文从渲染原理、开发体验、适用场景三个维度做完整对比,帮你做出清晰判断——而不是给你一个模糊的"看情况"。

一、两种技术的本质区别

Qt Widgets

Qt Widgets 诞生于 1990 年代,是 Qt 最早的 UI 系统。它基于操作系统的原生控件体系:

  • 按钮、输入框、菜单等控件由操作系统负责渲染
  • 外观与系统原生应用一致(Windows 上看起来像 Windows 应用,macOS 上看起来像 Mac 应用)
  • C++ 编写,代码风格偏命令式
// Qt Widgets 示例:创建一个按钮
QPushButton *button = new QPushButton("点我", this);
button->setGeometry(100, 100, 120, 40);
connect(button, &QPushButton::clicked, this, &MainWindow::onButtonClicked);

Qt Quick

Qt Quick 诞生于 2010 年,是 Qt 为现代 UI 设计的全新系统:

  • 所有元素由 Qt 自己的渲染引擎(基于 OpenGL / Vulkan / Metal)绘制,不依赖操作系统控件
  • 外观完全由开发者控制,默认不带任何平台风格
  • QML 编写,声明式语法,更接近现代前端开发
// Qt Quick 示例:创建一个按钮
Button {
    x: 100; y: 100
    width: 120; height: 40
    text: "点我"
    onClicked: console.log("被点击了")
}

二、核心对比

2.1 渲染方式

Qt Widgets Qt Quick
渲染引擎 操作系统原生控件 Qt 自有 GPU 渲染引擎
外观 跟随系统主题 完全自定义
动画性能 有限,复杂动画吃力 流畅,GPU 加速
高 DPI 支持 需要手动适配 原生支持

2.2 开发语言与风格

Qt Widgets Qt Quick
主要语言 C++ QML(+ 少量 JavaScript)
编程范式 命令式 声明式
UI 与逻辑 混合在 C++ 中 UI(QML)与逻辑(C++)分离
学习曲线 需要扎实 C++ 基础 QML 上手较快

2.3 功能特性

Qt Widgets Qt Quick
动画系统 基础 丰富,内置多种动画类型
触控支持 有限 原生支持多点触控
自定义样式 繁琐 灵活,QSS / 属性直接控制
复杂表格/树形控件 成熟完善 相对较弱(但持续改进)
无障碍访问 完善 仍在完善中
Qt Designer 支持 完整拖拽设计 Qt Design Studio

三、适用场景

应该选 Qt Widgets 的情况

1. 需要与操作系统原生外观高度一致

企业内部工具、行政软件、开发者工具等,用户期望看到"原生"的 Windows 或 macOS 风格界面。Widgets 无需任何配置就能做到这一点。

2. 大量复杂的数据展示

需要大型表格、树形结构、可编辑数据网格的应用(如 IDE、数据库管理工具、ERP 系统),Widgets 的 Model/View 框架非常成熟。

3. 团队有深厚的 C++ 背景

已有大量 C++ 代码库,需要与现有代码深度集成的项目。

4. 典型应用举例

  • 桌面 IDE / 代码编辑器
  • 企业 ERP / CRM 系统
  • 科学计算工具
  • 数据库管理软件

应该选 Qt Quick 的情况

1. 需要自定义视觉风格

品牌化的产品 UI、游戏界面、仪表盘等,不希望受系统主题限制,需要完全掌控每一个像素的外观。

2. 流畅动画和过渡效果

Qt Quick 的动画系统基于 GPU 渲染,天然流畅。做炫酷的 UI 过渡、数据可视化动效,Qt Quick 远胜 Widgets。

3. 触控设备 / 移动端

Android、iOS 应用,或带触摸屏的嵌入式设备,Qt Quick 对多点触控的支持比 Widgets 完善得多。

4. UI 与逻辑需要分工协作

设计师用 Qt Design Studio 做 QML 界面,开发者用 C++ 写后端逻辑,两者通过 QML/C++ 集成机制连接。这种分工在 Qt Quick 下非常自然。

5. 典型应用举例

  • 汽车中控 / 仪表盘
  • 智能家居控制面板
  • 嵌入式设备 HMI
  • 移动端应用
  • 多媒体播放器
  • 数据可视化大屏

四、动手对比:同一个 UI,两种写法

用一个包含标签、输入框和按钮的简单表单,直观感受两者的差异。

Qt Widgets 写法(C++)

// mainwindow.h
#include <QMainWindow>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>

class MainWindow : public QMainWindow {
    Q_OBJECT
public:
    MainWindow(QWidget *parent = nullptr);

private slots:
    void onSubmit();

private:
    QLabel    *m_label;
    QLineEdit *m_input;
    QPushButton *m_button;
};
// mainwindow.cpp
#include "mainwindow.h"

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent)
{
    QWidget *central = new QWidget(this);
    QVBoxLayout *layout = new QVBoxLayout(central);

    m_label  = new QLabel("请输入名字:", this);
    m_input  = new QLineEdit(this);
    m_button = new QPushButton("提交", this);

    layout->addWidget(m_label);
    layout->addWidget(m_input);
    layout->addWidget(m_button);

    setCentralWidget(central);
    resize(320, 160);

    connect(m_button, &QPushButton::clicked, this, &MainWindow::onSubmit);
}

void MainWindow::onSubmit()
{
    m_label->setText("你好," + m_input->text() + "!");
}

Qt Quick 写法(QML)

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    width: 320
    height: 160
    visible: true

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 16
        spacing: 8

        Label {
            id: greetLabel
            text: "请输入名字:"
        }

        TextField {
            id: nameInput
            Layout.fillWidth: true
            placeholderText: "名字"
        }

        Button {
            text: "提交"
            onClicked: greetLabel.text = "你好," + nameInput.text + "!"
        }
    }
}

直观感受:

  • Widgets 版本:约 40 行 C++ 代码,需要手动 new 控件、设置布局、连接信号
  • Qt Quick 版本:约 25 行 QML,声明式描述结构,逻辑内联在 onClicked

功能完全相同,但 QML 版本更简洁、更直观地表达了"界面是什么样的"。


五、可以混用吗?

可以,Qt 提供了两种方式将 Qt Quick 内容嵌入 Widgets 应用:

方式一:QQuickWidget

在 Widgets 窗口中嵌入一个 QML 场景:

#include <QQuickWidget>

QQuickWidget *qmlView = new QQuickWidget(this);
qmlView->setSource(QUrl("qrc:/MyComponent.qml"));
qmlView->setResizeMode(QQuickWidget::SizeRootObjectToView);
layout->addWidget(qmlView);

方式二:QQuickView

将整个 QML 场景显示为独立窗口,适合渐进式迁移:

#include <QQuickView>

QQuickView *view = new QQuickView();
view->setSource(QUrl("qrc:/Main.qml"));
view->show();

混用的典型场景: 现有 Widgets 应用想引入一个炫酷的数据可视化面板或动画组件,不需要重写整个应用,只在需要的地方嵌入 Qt Quick。


六、决策流程图

用以下问题快速判断你的项目应该选哪个:

需要触控 / 移动端支持?
├── 是 → Qt Quick
└── 否 ↓

需要完全自定义视觉风格 / 复杂动画?
├── 是 → Qt Quick
└── 否 ↓

主要是数据密集型桌面工具(大表格、树形结构)?
├── 是 → Qt Widgets
└── 否 ↓

团队只有 C++ 经验,没有时间学 QML?
├── 是 → Qt Widgets
└── 否 → Qt Quick(现代项目的默认选择)

七、对于本系列的学习者

你的学习目标包含桌面、嵌入式、移动端和 UI 动画,Qt Quick 是主线

  • 移动端(Android / iOS):Qt Quick
  • 嵌入式 HMI / MCU:Qt Quick / Qt for MCUs
  • UI 动画:Qt Quick 的动画系统
  • 桌面应用:Qt Quick 也完全胜任,复杂数据工具再考虑 Widgets

Qt Widgets 作为补充知识了解即可,在本系列的后期阶段(第四阶段)会专项涉及。


总结

维度 Qt Widgets Qt Quick
渲染 操作系统原生 Qt GPU 渲染引擎
语言 C++ QML + JavaScript
动画 有限 流畅,GPU 加速
触控 有限 原生支持
自定义外观 繁琐 灵活
数据表格 成熟完善 持续改进
适合场景 桌面工具、企业软件 移动端、嵌入式、现代 UI
学习曲线 需要 C++ 基础 QML 上手较快

一句话总结: 做现代 UI、触控设备、嵌入式 HMI 选 Qt Quick;做传统桌面工具、数据密集型企业软件选 Qt Widgets;两者也可以混用。

开源一年,我的 AI 全栈项目 AI 协同编辑器终于有 1.1 k star了 😍😍😍

作者 Moment
2026年3月24日 10:20

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

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

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

image.png

📖 简介

DocFlow 是一款面向团队协作的块级文档编辑器。它融合了 Notion 的灵活性与飞书的协作能力,通过块级内容架构、实时协同编辑和 AI 辅助功能,帮助团队高效完成文档创作与知识管理。

我们希望通过技术手段减少协作摩擦,让文档编辑更接近团队的真实工作流。无论是产品规划文档、技术方案设计,还是会议记录整理,DocFlow 都能提供流畅的创作体验。

✨ 核心特性

DocFlow 参考了 Notion 与飞书的设计理念,将内容以块为单位进行组织。每个块都是独立的编辑单元,可以灵活组合与调整,同时支持实时协作与 AI 辅助。

  • 🧱 块级编辑器:支持文本、标题、列表、代码块、表格、图片、视频等 20+ 种内容类型,通过拖拽即可调整块级元素的顺序与层级关系。

  • ⚡ 实时协作:基于 Yjs CRDT 算法实现多人同步编辑,自动处理编辑冲突。支持实时光标跟踪、成员在线状态与历史版本回溯。

  • 🤖 AI 功能:内置 AI 助手,支持头脑风暴、内容润色、文档续写与智能问答。可根据上下文生成结构化内容建议。

技术选型

DocFlow 采用全栈 TypeScript 架构,前端基于 Next.js 构建,后端使用 NestJS 框架。通过统一的类型系统和现代化的工程实践,保证了代码质量与开发效率。

🎨 前端架构 (Client-side)

Next.js

项目基于 Next.js App Router 架构,利用 React Server Components 优化首屏渲染性能。通过 Server Actions 实现前后端通信,确保类型安全的同时简化了数据流转。

Tiptap

编辑器核心采用 Tiptap 框架,基于 ProseMirror 构建。通过扩展机制实现了丰富的块级编辑能力,支持自定义节点与快捷命令,为用户提供接近 Notion 的编辑体验。

Yjs

协作功能基于 Yjs CRDT 算法实现,能够自动处理多人编辑时的冲突,保证数据最终一致性。配合 Awareness 模块,实现了实时光标追踪与在线状态同步。

⚙️ 后端架构 (Server-side)

NestJS & Prisma

后端使用 NestJS 模块化框架,通过依赖注入实现业务逻辑解耦。Prisma ORM 提供类型安全的数据访问层,支持高效的数据库查询与迁移管理。

Hocuspocus

Hocuspocus 作为 Yjs 的 WebSocket 服务端,负责协调文档协作会话,处理客户端连接与数据同步。通过拦截器机制实现权限控制与数据持久化。

Prometheus & Grafana

集成 Prometheus 进行指标采集,通过 Grafana 可视化展示系统运行状态。监控包括 API 响应时间、数据库查询性能、WebSocket 连接数等核心指标。

20260203091658

Grafana 监控面板实时展示系统各项性能指标,包括请求量、响应时间、错误率等关键数据,帮助快速定位性能瓶颈。

ELK Stack (Elasticsearch & Kibana)

使用 Elasticsearch 存储和检索日志数据,Kibana 提供日志分析与可视化能力。支持全文搜索、日志聚合与异常检测,便于问题排查与系统审计。

日志分析系统

Kibana 日志分析界面,支持按时间、日志级别、服务模块等维度查询和过滤日志,提供结构化的问题排查路径。

MinIO & RabbitMQ

MinIO 提供对象存储服务,用于存储用户上传的图片、视频等文件。RabbitMQ 作为消息队列,处理异步任务如图片压缩、邮件发送等,避免阻塞主业务流程。

功能介绍

DocFlow 将 AI 能力集成到编辑器中,通过理解文档上下文来辅助内容创作。AI 不是简单的文本生成工具,而是能够理解语义、提供决策建议的智能助手。

AI 头脑风暴

当你有一个初步想法但不知如何展开时,AI 头脑风暴可以帮助拓展思路。输入核心概念后,AI 会从不同角度生成 3-6 个结构化方案,每个方案都包含具体的实施思路。

AI 头脑风暴输入界面

在编辑器中输入头脑风暴主题,AI 会基于输入内容理解你的需求场景。

AI 头脑风暴结果展示

AI 生成的多个方案以卡片形式展示,每个方案都有清晰的标题和详细说明。你可以选择任意方案插入到文档中,或者继续优化调整。

这不只是简单的内容生成,AI 会根据上下文理解你的意图。无论是产品功能设计、内容分类规划,还是业务流程优化,AI 都能提供可行的思路参考,帮助快速决策。

AI 文本润色

AI 文本润色功能

选中需要优化的文本段落,AI 会分析文本结构与表达方式,提供更清晰、更专业的改写建议。支持调整语气风格,如正式、简洁、友好等。

AI 续写

AI 续写功能会根据前文内容自然延续写作。当前文内容较长时,系统通过 RAG (检索增强生成) 技术,从文档中检索相关段落,确保续写内容与上下文保持逻辑一致,避免偏离主题。

AI 续写功能演示

AI 续写时会参考前文的写作风格、用词习惯和逻辑结构,生成连贯自然的后续内容。你可以继续编辑生成的文本,或者重新生成。

AI 聊天

目前 AI 聊天功能作为独立页面存在,后续会集成到编辑器侧边栏,与文档内容深度关联。未来计划实现 Agent 模式,类似 Cursor 那样能够自动编辑文档内容。

7a8ba58a4ab3b592bb7fae1b45634648

协同编辑

多人协同编辑

多人同时编辑时,每个用户都有独立的光标颜色标识。文档修改实时同步,冲突自动合并。右侧显示当前在线成员列表与他们的编辑位置。

未来计划

DocFlow 将持续优化协作体验与 AI 能力,同时加强工程化建设,提升系统可扩展性。

🏗️ 工程化体系深度重构

  • 迈向 Monorepo 架构:计划基于 pnpm workspaces 和 Turborepo 将项目重构为 Monorepo。前后端代码分离,共享类型定义与工具函数,提升代码复用率与构建效率。

  • 组件库与插件生态开放:将 Tiptap 自定义扩展(如代码沙箱、交互式图表等)提取为独立 npm 包,开放给社区使用。同时建立插件开发规范,支持第三方开发者扩展编辑器能力。

🎙️ 多维协同体验升级

  • 集成 LiveKit 实时音视频:在文档协作场景中引入实时音视频通话。团队成员可以边看文档边讨论,提升复杂决策场景下的沟通效率。

LiveKit 集成方案

  • 实时群聊系统:在文档侧边栏集成实时聊天功能,支持针对文档内容发起讨论。消息可以关联到具体的文档块,形成完整的协作反馈闭环。

🤖 智能内核的跨越式进化

  • 基于 RAG 的私有知识库:引入 RAG (Retrieval-Augmented Generation) 技术,让 AI 能够检索用户的历史文档。AI 回答问题时会参考团队沉淀的知识资产,提供更精准的决策支持。

  • 从 Copilot 迈向 Agent:探索 AI Agent 在文档场景的应用。未来 AI 将能够自主执行任务,例如从会议纪要中提取待办事项,自动同步到第三方工具,实现从辅助创作到自动化办公的升级。

🚀 快速开始

环境要求

  • Node.js >= 24
  • pnpm >= 10.28.2

本地开发

  1. 克隆仓库
git clone https://github.com/xun082/DocFlow.git
cd DocFlow
  1. 安装依赖
pnpm install
  1. 启动开发服务器
pnpm dev
  1. 打开浏览器访问
http://localhost:3000

🐳 Docker 部署

方式一:使用 Docker Compose(推荐)

# 使用预构建镜像
docker-compose up -d

# 访问应用
http://localhost:3000

方式二:手动构建

  1. 构建镜像
docker build -t docflow:latest .
  1. 运行容器
docker run -d \
  --name docflow \
  -p 3000:3000 \
  -e NODE_ENV=production \
  docflow:latest
  1. 访问应用
http://localhost:3000

健康检查

容器内置健康检查端点:

curl http://localhost:3000/api/health

🤝 贡献指南

欢迎提交 Issue 和 Pull Request!

在提交代码前,请确保:

  • 运行 pnpm type-check 通过类型检查

  • 运行 pnpm lint 通过代码检查

  • 运行 pnpm format 格式化代码

  • 遵循项目的代码规范和提交规范

详见 CONTRIBUTING.md

📬 联系方式

❌
❌