普通视图

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

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

作者 Electrolux
2026年4月2日 15:02

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


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

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

image.png

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

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

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

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

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

vben的项目维护者直接回复 "nice catch!",让我不禁思考:防抖和节流看似简单,但连这些知名库的资深开发者都会踩坑,说明这背后一定有什么容易忽视的细节。

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


vue-vben-admin 的远程搜索

问题案例

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

问题分析

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

这意味着:

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

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


vue-office 的 Excel 滚动优化

问题案例

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

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

问题分析

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

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

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


框架中的正确使用方式

Vue 组合式 API

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

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

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

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

React Hooks

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

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

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

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

原生 JavaScript

import { throttle } from 'lodash-es'

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

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

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

结语

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

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

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


参考资源


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

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

作者 1024小神
2026年4月2日 14:59

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

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

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

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

常用方向

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

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

作者 coder_Eight
2026年4月2日 14:53

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

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


1. 概述

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

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

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

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

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

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

2. 核心数据结构

2.1 双向链表节点

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

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

2.2 LRU 缓存类成员

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

为什么使用虚拟头尾?

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

2.3 链表状态示意图

初始状态:

head <-> tail

插入一个有效节点后:

head <-> node1 <-> tail

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


4. 主要操作实现

4.1 get(key)

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

流程

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

4.2 put(key, value)

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

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

情况2详细步骤

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

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


5. 完整代码

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

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

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

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

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

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

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

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

6. 执行示例

假设 capacity = 2

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

7. 边界情况处理

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

8. 复杂度分析

  • 时间复杂度

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

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

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

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

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


10. 常见问题

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

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

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

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

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


11. 总结

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

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

作者 angerdream
2026年4月2日 14:46

插槽概述

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

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

默认插槽

概述

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

  • 具名插槽
  • 作用于插槽

默认插槽实例

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

App组件代码

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

Fish组件代码

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

</template>

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

具名插槽

概述

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

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

具名插槽实例

Fish组件代码

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

App组件代码

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

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

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

作用域插槽

概述

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

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

作用域插槽实例

Fish组件代码

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

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

App组件代码

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

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

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

总结

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

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

2026年4月2日 14:43

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

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

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

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

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

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

TypeScript 周榜同样明显:

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

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

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

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

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

背后有三个推力。

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

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

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

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

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

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

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

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

Before:前端的主战场

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

After:前端的新增战场

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

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

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

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

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

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

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

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

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

05 写在最后

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

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

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

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

作者 kyriewen
2026年4月2日 13:50

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

前言

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

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

一、MutationObserver是啥?

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

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

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

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

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

二、能观察到哪些变化?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

六、性能注意事项

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

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

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

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

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

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

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

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

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

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

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

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

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

kotlin安卓项目配置webview开启定位功能

作者 1024小神
2026年4月2日 13:42

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

在 Kotlin 开发的 Android 应用中,让 WebView 正常使用 H5 页面的定位(navigator.geolocation),核心是权限配置 + WebView 设置 + WebChromeClient 回调三部分。下面是完整可直接使用的实现方案。

添加权限(AndroidManifest.xml)

<!-- 网页 Geolocation API(navigator.geolocation) -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

WebView 基础配置(Kotlin)

package com.app.pakeplus

import android.Manifest
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.Gravity
import android.webkit.PermissionRequest
import android.webkit.JavascriptInterface
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.GeolocationPermissions
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.MimeTypeMap
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
// import android.view.Menu
// import android.view.WindowInsets
// import com.google.android.material.snackbar.Snackbar
// import com.google.android.material.navigation.NavigationView
// import androidx.navigation.findNavController
// import androidx.navigation.ui.AppBarConfiguration
// import androidx.navigation.ui.navigateUp
// import androidx.navigation.ui.setupActionBarWithNavController
// import androidx.navigation.ui.setupWithNavController
// import androidx.drawerlayout.widget.DrawerLayout
// import com.app.pakeplus.databinding.ActivityMainBinding
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
import java.net.URISyntaxException
import android.util.Base64
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs

class MainActivity : AppCompatActivity() {

//    private lateinit var appBarConfiguration: AppBarConfiguration
//    private lateinit var binding: ActivityMainBinding

    private lateinit var webView: WebView
    private lateinit var gestureDetector: GestureDetectorCompat
    private var fileUploadCallback: ValueCallback<Array<Uri>>? = null
    private lateinit var fileChooserLauncher: ActivityResultLauncher<Intent>
    private lateinit var permissionLauncher: ActivityResultLauncher<Array<String>>
    private var pendingPermissionRequest: PermissionRequest? = null

    private lateinit var locationPermissionLauncher: ActivityResultLauncher<Array<String>>
    private var pendingGeolocationOrigin: String? = null
    private var pendingGeolocationCallback: GeolocationPermissions.Callback? = null

    // 全屏视频相关
    private var customView: View? = null
    private var customViewCallback: WebChromeClient.CustomViewCallback? = null
    private var originalOrientation: Int = 0

    /** 是否从配置启用了全屏(隐藏状态栏+导航栏) */
    private var isFullScreenMode: Boolean = false

    /** 当前主文档是否已出现加载错误;仅成功时隐藏启动遮罩 */
    private var mainFrameLoadError: Boolean = false

    /** app.json 中 launch 非空时才显示启动图遮罩 */
    private var showLaunchSplash: Boolean = false

    @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 初始化文件选择器
        fileChooserLauncher = registerForActivityResult(
            ActivityResultContracts.StartActivityForResult()
        ) { result ->
            val resultCode = result.resultCode
            val data = result.data

            if (fileUploadCallback == null) return@registerForActivityResult

            var results: Array<Uri>? = null

            if (resultCode == RESULT_OK && data != null) {
                val dataString = data.dataString
                val clipData = data.clipData

                if (clipData != null) {
                    // 多文件选择
                    results = Array(clipData.itemCount) { i ->
                        clipData.getItemAt(i).uri
                    }
                } else if (dataString != null) {
                    // 单文件选择
                    results = arrayOf(Uri.parse(dataString))
                }
            }

            fileUploadCallback?.onReceiveValue(results)
            fileUploadCallback = null
        }

        // 初始化运行时权限请求(摄像头 / 麦克风)
        permissionLauncher = registerForActivityResult(
            ActivityResultContracts.RequestMultiplePermissions()
        ) { permissions ->
            val request = pendingPermissionRequest
            if (request == null) {
                return@registerForActivityResult
            }

            // 所有相关权限都通过才允许 WebView 使用
            val allGranted = permissions.values.all { it }
            if (allGranted) {
                request.grant(request.resources)
            } else {
                request.deny()
            }
            pendingPermissionRequest = null
        }

        // 网页 HTML5 定位(navigator.geolocation)
        locationPermissionLauncher = registerForActivityResult(
            ActivityResultContracts.RequestMultiplePermissions()
        ) { results ->
            val origin = pendingGeolocationOrigin
            val geoCallback = pendingGeolocationCallback
            pendingGeolocationOrigin = null
            pendingGeolocationCallback = null
            if (origin != null && geoCallback != null) {
                val fine = results[Manifest.permission.ACCESS_FINE_LOCATION] == true
                val coarse = results[Manifest.permission.ACCESS_COARSE_LOCATION] == true
                geoCallback.invoke(origin, fine || coarse, false)
            }
        }

        // parseJsonWithNative
        val config = parseJsonWithNative(this, "app.json")
        val fullScreen = config?.get("fullScreen") as? Boolean ?: false
        val gesture = config?.get("gesture") as? Boolean ?: false
        val debug = config?.get("debug") as? Boolean ?: false
        val userAgent = config?.get("userAgent") as? String ?: ""
        val webUrl = config?.get("webUrl") as? String ?: "https://pakeplus.com/"
        val launchCfg = config?.get("launch") as? String
        showLaunchSplash = !launchCfg.isNullOrBlank()
        // enable debug by chrome://inspect
        WebView.setWebContentsDebuggingEnabled(debug)
        // config fullscreen
        isFullScreenMode = fullScreen
        if (fullScreen) {
            window.setFlags(
                WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN,
            )
            window.setFlags(
                WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION,
                WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION
            )
            window.navigationBarColor = android.graphics.Color.TRANSPARENT
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                val lp = window.attributes
                lp.layoutInDisplayCutoutMode =
                    WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
                window.attributes = lp
            }
            // 低于 P 时在这里用旧 API 隐藏导航栏;P 及以上在 setContentView 后由 hideSystemUI() 统一处理
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
                @Suppress("DEPRECATION")
                window.decorView.systemUiVisibility = (
                        View.SYSTEM_UI_FLAG_FULLSCREEN or
                                View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
                                View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
                                View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
                                View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
                                View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                        )
            }
        }
        // 可以让内容视图的颜色延伸到屏幕边缘
        enableEdgeToEdge()
        setContentView(R.layout.single_main)
        if (!showLaunchSplash) {
            findViewById<View>(R.id.splash_overlay).visibility = View.GONE
        }
        // set system safe area
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.ConstraintLayout))
        { view, insets ->
            val systemBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            view.setPadding(systemBar.left, systemBar.top, systemBar.right, systemBar.bottom)
            insets
        }
        // 全屏模式下隐藏状态栏和底部导航栏(Android 9+ 必须在这里调用,window 已就绪)
        if (isFullScreenMode) {
            window.decorView.post { hideSystemUI() }
        }
        webView = findViewById<WebView>(R.id.webview)
        webView.settings.apply {
            javaScriptEnabled = true
            domStorageEnabled = true
            setGeolocationEnabled(true)
            allowFileAccess = true
            useWideViewPort = true
            allowFileAccessFromFileURLs = true
            allowContentAccess = true
            allowUniversalAccessFromFileURLs = true
            loadWithOverviewMode = true
            mediaPlaybackRequiresUserGesture = false
            // setSupportMultipleWindows(true)
        }
        webView
        // set user agent
        if (userAgent.isNotEmpty()) {
            webView.settings.userAgentString = userAgent
        }

        webView.settings.loadWithOverviewMode = true
        webView.settings.setSupportZoom(false)

        // clear cache
        webView.clearCache(true)

        // 为 blob: 链接下载注入 JS 接口
        webView.addJavascriptInterface(BlobDownloadInterface(this), "BlobDownloader")

        // inject js
        webView.webViewClient = MyWebViewClient(debug)

        // get web load progress
        webView.webChromeClient = MyChromeClient(this)

        // 网页内下载:点击下载链接时由 DownloadManager 保存到系统下载目录
        webView.setDownloadListener { url, userAgent, contentDisposition, mimetype, contentLength ->
            startDownload(url, userAgent, contentDisposition, mimetype)
        }

        // Setup gesture detector
        gestureDetector =
            GestureDetectorCompat(this, object : GestureDetector.SimpleOnGestureListener() {
                override fun onFling(
                    e1: MotionEvent?,
                    e2: MotionEvent,
                    velocityX: Float,
                    velocityY: Float
                ): Boolean {
                    if (e1 == null) return false

                    val diffX = e2.x - e1.x
                    val diffY = e2.y - e1.y

                    // Only handle horizontal swipes
                    if (abs(diffX) > abs(diffY)) {
                        if (abs(diffX) > 100 && abs(velocityX) > 100) {
                            if (diffX > 0) {
                                // Swipe right - go back
                                if (webView.canGoBack()) {
                                    webView.goBack()
                                    return true
                                }
                            } else {
                                // Swipe left - go forward
                                if (webView.canGoForward()) {
                                    webView.goForward()
                                    return true
                                }
                            }
                        }
                    }
                    return false
                }
            })

        // Set touch listener for WebView
        webView.setOnTouchListener { _, event ->
            if (gesture) {
                gestureDetector.onTouchEvent(event)
            }
            false
        }

        // load webUrl or file:///android_asset/index.html
        webView.loadUrl(webUrl)

//        binding = ActivityMainBinding.inflate(layoutInflater)
//        setContentView(R.layout.single_main)

//        setSupportActionBar(binding.appBarMain.toolbar)

//        binding.appBarMain.fab.setOnClickListener { view ->
//            Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
//                .setAction("Action", null)
//                .setAnchorView(R.id.fab).show()
//        }

//        val drawerLayout: DrawerLayout = binding.drawerLayout
//        val navView: NavigationView = binding.navView
//        val navController = findNavController(R.id.nav_host_fragment_content_main)

        // Passing each menu ID as a set of Ids because each
        // menu should be considered as top level destinations.
//        appBarConfiguration = AppBarConfiguration(
//            setOf(
//                R.id.nav_home, R.id.nav_gallery, R.id.nav_slideshow
//            ), drawerLayout
//        )
//        setupActionBarWithNavController(navController, appBarConfiguration)
//        navView.setupWithNavController(navController)
    }


    override fun onPause() {
        super.onPause()
        webView.onPause()
        // 如果正在全屏播放视频,暂停播放
        if (customView != null) {
            webView.pauseTimers()
        }
    }

    override fun onResume() {
        super.onResume()
        webView.onResume()
        // 恢复 WebView 的定时器
        webView.resumeTimers()
    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        // 全屏模式下窗口重新获得焦点时再次隐藏导航栏(用户从边缘滑出后会自动再隐藏)
        if (hasFocus && isFullScreenMode && customView == null) {
            hideSystemUI()
        }
    }

    override fun onDestroy() {
        // 清理全屏视图
        if (customView != null) {
            hideCustomView()
        }
        webView.destroy()
        super.onDestroy()
    }

    @Deprecated("Deprecated in Java")
    override fun onBackPressed() {
        // 如果正在全屏播放视频,先退出全屏
        if (customView != null) {
            hideCustomView()
            return
        }

        if (webView.canGoBack()) {
            webView.goBack()
        } else {
            super.onBackPressed()
        }
    }

    // 显示全屏视频
    private fun showCustomView(view: View, callback: WebChromeClient.CustomViewCallback) {
        // 如果已经有全屏视图,先隐藏它
        if (customView != null) {
            hideCustomView()
            return
        }

        customView = view
        customViewCallback = callback

        // 保存当前屏幕方向
        originalOrientation = requestedOrientation

        // 获取根布局
        val decorView = window.decorView as ViewGroup
        val rootView = decorView.findViewById<ViewGroup>(android.R.id.content)

        // 创建全屏容器
        val fullscreenContainer = FrameLayout(this).apply {
            layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
            setBackgroundColor(android.graphics.Color.BLACK)
        }

        // 将全屏视图添加到容器
        fullscreenContainer.addView(view)

        // 将容器添加到根布局
        rootView.addView(fullscreenContainer)

        // 隐藏系统UI
        hideSystemUI()

        // 隐藏WebView
        webView.visibility = View.GONE
    }

    // 隐藏全屏视频
    private fun hideCustomView() {
        if (customView == null) return

        // 恢复系统UI
        showSystemUI()

        // 显示WebView
        webView.visibility = View.VISIBLE

        // 获取根布局
        val decorView = window.decorView as ViewGroup
        val rootView = decorView.findViewById<ViewGroup>(android.R.id.content)

        // 移除全屏容器
        val fullscreenContainer = customView?.parent as? ViewGroup
        fullscreenContainer?.let {
            rootView.removeView(it)
        }

        // 调用回调
        customViewCallback?.onCustomViewHidden()

        // 清理
        customView = null
        customViewCallback = null

        // 恢复屏幕方向
        requestedOrientation = originalOrientation
    }

    // 隐藏系统UI(全屏模式)
    private fun hideSystemUI() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            window.insetsController?.let {
                it.hide(android.view.WindowInsets.Type.systemBars())
                // 设置系统栏行为:通过滑动显示临时栏
                try {
                    @Suppress("NewApi")
                    it.systemBarsBehavior =
                        android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
                } catch (e: Exception) {
                    // 如果常量不可用,忽略此设置
                    Log.w("MainActivity", "BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE not available", e)
                }
            }
        } else {
            @Suppress("DEPRECATION")
            window.decorView.systemUiVisibility = (
                    View.SYSTEM_UI_FLAG_FULLSCREEN
                            or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                            or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
                            or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                            or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                            or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                    )
        }
    }

    // 显示系统UI
    private fun showSystemUI() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            window.insetsController?.show(android.view.WindowInsets.Type.systemBars())
        } else {
            @Suppress("DEPRECATION")
            window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
        }
    }

    fun parseJsonWithNative(context: Context, jsonFilePath: String): Map<String, Any>? {
        val jsonString = assets.open(jsonFilePath).bufferedReader().use { it.readText() }
        return try {
            val jsonObject = JSONObject(jsonString)
            // 提取字段
            val name = jsonObject.getString("name")
            val webUrl = jsonObject.getString("webUrl")
            val debug = jsonObject.getBoolean("debug")
            val userAgent = jsonObject.getString("userAgent")
            val fullScreen = jsonObject.getBoolean("fullScreen")
            val launch = jsonObject.getString("launch")
            // 返回键值对
            mapOf(
                "name" to name,
                "webUrl" to webUrl,
                "debug" to debug,
                "userAgent" to userAgent,
                "fullScreen" to fullScreen,
                "launch" to launch
            )
        } catch (e: Exception) {
            e.printStackTrace()
            null
        }
    }

    /**
     * JS 调用的接口:接收 base64 数据并保存为文件
     */
    inner class BlobDownloadInterface(private val context: Context) {

        @JavascriptInterface
        fun downloadBase64File(base64Data: String, mimeType: String?, fileName: String?) {
            try {
                val bytes = Base64.decode(base64Data, Base64.DEFAULT)

                // 统一保存到系统 Download 目录,和普通下载保持一致
                val downloadsDir =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
                if (!downloadsDir.exists()) {
                    downloadsDir.mkdirs()
                }

                val safeName = when {
                    !fileName.isNullOrBlank() -> fileName
                    !mimeType.isNullOrBlank() -> {
                        val ext = MimeTypeMap.getSingleton()
                            .getExtensionFromMimeType(mimeType) ?: "bin"
                        "download_${System.currentTimeMillis()}.$ext"
                    }

                    else -> "download_${System.currentTimeMillis()}.bin"
                }

                val outFile = File(downloadsDir, safeName)
                FileOutputStream(outFile).use { it.write(bytes) }

                showTopToast(context, "已保存到下载目录: ${outFile.name}", Toast.LENGTH_LONG)
                Log.d("BlobDownload", "File saved: ${outFile.absolutePath}")
            } catch (e: Exception) {
                Log.e("BlobDownload", "save error", e)
                showTopToast(context, "保存失败: ${e.message}", Toast.LENGTH_LONG)
            }
        }
    }

    /**
     * 根据 URL / Content-Disposition / MIME 开始一个系统下载任务
     * - 对常见的 mp4 纠正被识别成 .bin 的问题
     * - 供 WebView DownloadListener 和 shouldOverrideUrlLoading 共用
     */
    private fun startDownload(
        url: String,
        userAgent: String?,
        contentDisposition: String?,
        mimetype: String?
    ) {
        // 1. 先根据 URL / Content-Disposition / MIME 推测文件名
        var fileName = URLUtil.guessFileName(url, contentDisposition, mimetype)

        // 2. 处理 mp4 被识别成 .bin 的场景
        val lowerMime = mimetype?.lowercase() ?: ""
        val lowerName = fileName.lowercase()

        val isVideoMp4 = lowerMime.contains("video/mp4") ||
                (lowerMime.contains("application/octet-stream") && url.contains(
                    ".mp4",
                    ignoreCase = true
                ))

        if (isVideoMp4) {
            fileName = when {
                lowerName.endsWith(".mp4") -> fileName
                lowerName.endsWith(".bin") -> fileName.replace(
                    Regex(
                        "\\.bin$",
                        RegexOption.IGNORE_CASE
                    ), ".mp4"
                )

                !fileName.contains('.') -> "$fileName.mp4"
                else -> fileName
            }
        }

        val request = DownloadManager.Request(Uri.parse(url)).apply {
            // 对于 mp4 强制使用正确的 MIME,避免部分 ROM 再次误判
            if (isVideoMp4) {
                setMimeType("video/mp4")
            } else if (!mimetype.isNullOrEmpty()) {
                setMimeType(mimetype)
            }

            if (!userAgent.isNullOrEmpty()) {
                addRequestHeader("User-Agent", userAgent)
            }
            setDescription(getString(R.string.downloading))
            setTitle(fileName)
            setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
            setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
        }

        val dm = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
        dm.enqueue(request)
        showTopToast(this, getString(R.string.download_started), Toast.LENGTH_SHORT)
    }

    /**
     * 将 Toast 显示在屏幕顶部
     */
    private fun showTopToast(context: Context, message: String, duration: Int) {
        val toast = Toast.makeText(context, message, duration)
        toast.setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL, 0, 120)
        toast.show()
    }

    /**
     * 判断一个 URL 是否是“常见文件类型”,用于自动触发下载
     */
    private fun isDownloadableFileUrl(url: String): Boolean {
        val checkUrl = url.substringBefore("?").substringBefore("#").lowercase()
        // 可按需要继续扩展
        val exts = listOf(
            "mp4", "mov", "mkv", "avi",
            "mp3", "aac", "wav", "flac",
            "jpg", "jpeg", "png", "gif", "webp", "bmp",
            "txt", "pdf",
            "doc", "docx", "xls", "xlsx", "ppt", "pptx",
            "zip", "rar", "7z"
        )
        return exts.any { checkUrl.endsWith(".$it") }
    }

//    override fun onCreateOptionsMenu(menu: Menu): Boolean {
//        // Inflate the menu; this adds items to the action bar if it is present.
//        menuInflater.inflate(R.menu.main, menu)
//        return true
//    }

//    override fun onSupportNavigateUp(): Boolean {
//        val navController = findNavController(R.id.nav_host_fragment_content_main)
//        return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
//    }

    private fun hideSplashOverlay() {
        if (!showLaunchSplash) return
        val overlay = findViewById<View>(R.id.splash_overlay)
        if (overlay.visibility != View.VISIBLE) return
        overlay.animate()
            .alpha(0f)
            .setDuration(200L)
            .withEndAction {
                overlay.visibility = View.GONE
                overlay.alpha = 1f
            }
            .start()
    }

    inner class MyWebViewClient(val debug: Boolean) : WebViewClient() {

        @Deprecated("Deprecated in Java", ReplaceWith("false"))
        override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
            if (url == null) return false
            val fixedUrl = url.toString()

            // 对常见文件类型的 HTTP/HTTPS 链接,直接拦截为下载,不在 WebView 内打开
            if (fixedUrl.startsWith("http://") || fixedUrl.startsWith("https://")) {
                if (isDownloadableFileUrl(fixedUrl)) {
                    val ua = view?.settings?.userAgentString ?: ""
                    // 根据扩展名推断 MIME
                    val ext = MimeTypeMap.getFileExtensionFromUrl(fixedUrl)
                    val mime = ext?.let {
                        MimeTypeMap.getSingleton().getMimeTypeFromExtension(it.lowercase())
                    }
                        ?: "application/octet-stream"
                    this@MainActivity.startDownload(fixedUrl, ua, null, mime)
                    return true
                }
                // 普通网页,交给 WebView 处理
                return false
            }

            // file:// 链接仍交给 WebView 处理
            if (fixedUrl.startsWith("file://")) {
                return false
            }

            // --- 处理外部应用链接 ---
            // 1. 检查是否是 Intent URI (e.g., intent://...)
            if (fixedUrl.startsWith("intent://")) {
                try {
                    // 解析 Intent URI
                    val intent = Intent.parseUri(fixedUrl, Intent.URI_INTENT_SCHEME)

                    // 检查设备上是否有应用可以处理此 Intent
                    if (intent.resolveActivity(view?.context?.packageManager!!) != null) {
                        view.context?.startActivity(intent)
                        return true // 已经处理,阻止 WebView 加载
                    }

                    // 如果找不到能处理的应用,可以尝试打开备用 URL (如果 Intent 中有定义 fallback URL)
                    val fallbackUrl = intent.getStringExtra("browser_fallback_url")
                    if (!fallbackUrl.isNullOrEmpty()) {
                        view.loadUrl(fallbackUrl)
                        return true // 加载备用 URL
                    }

                } catch (e: URISyntaxException) {
                    // 解析 Intent URI 失败
                    Log.e("WebViewClient", "Bad Intent URI: $fixedUrl", e)
                } catch (e: ActivityNotFoundException) {
                    // 找不到匹配的 Activity (外部应用未安装),此情况通常在 `resolveActivity` 后捕获
                    Log.e("WebViewClient", "No activity found to handle Intent: $fixedUrl", e)
                    // 您也可以在这里加载一个 "未安装应用" 的提示页面
                }
                // 如果是 Intent 但无法处理(例如未安装应用),您可以选择返回 false 让 WebView 尝试加载(通常会失败)
                // 或者继续执行下面的 Scheme 检查
            }


            // 3. 检查是否是其他自定义 Scheme (e.g., weixin://, zhihu://)
            // 注意:Intent URI 是更通用和推荐的方式,但有些应用可能直接使用 Scheme。
            try {
                val intent = Intent(Intent.ACTION_VIEW, fixedUrl.toUri())
                // 必须检查是否有应用可以处理此 Intent,否则会导致崩溃
                if (intent.resolveActivity(view?.context?.packageManager!!) != null) {
                    view.context?.startActivity(intent)
                    return true // 已经处理,阻止 WebView 加载
                } else {
                    // 没有安装相应的应用
                    Log.w("WebViewClient", "External app not installed for: $fixedUrl")
                    // 可以添加逻辑提示用户下载应用或打开相应的应用商店链接
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "Error starting external app: $fixedUrl", e)
            }
            // 如果不是外部应用 Scheme,也不是 HTTP/HTTPS,则返回 false,让 WebView 处理
            return false
        }

        override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
            super.doUpdateVisitedHistory(view, url, isReload)
        }

        override fun onReceivedError(
            view: WebView?,
            request: WebResourceRequest?,
            error: WebResourceError?
        ) {
            super.onReceivedError(view, request, error)
            println("webView onReceivedError: ${error?.description}")
            if (showLaunchSplash && request?.isForMainFrame == true) {
                mainFrameLoadError = true
            }
        }

        override fun onReceivedHttpError(
            view: WebView?,
            request: WebResourceRequest?,
            errorResponse: WebResourceResponse?
        ) {
            super.onReceivedHttpError(view, request, errorResponse)
            if (showLaunchSplash && request?.isForMainFrame == true) {
                val code = errorResponse?.statusCode ?: 0
                if (code >= 400) mainFrameLoadError = true
            }
        }

        override fun onPageFinished(view: WebView?, url: String?) {
            super.onPageFinished(view, url)
            // post 一次,尽量避免与 onReceivedError / onReceivedHttpError 的时序竞态
            view?.post {
                if (!mainFrameLoadError) hideSplashOverlay()
            }
            // 注入脚本,拦截 blob: 链接并通过 BlobDownloader 保存到本地
            val blobInterceptor = """
                (function () {
                  if (window.__blobDownloadInjected) return;
                  window.__blobDownloadInjected = true;
                  
                  document.addEventListener('click', function (e) {
                    try {
                      var target = e.target;
                      // 寻找最近的 <a> 标签
                      while (target && target.tagName && target.tagName.toLowerCase() !== 'a') {
                        target = target.parentElement;
                      }
                      if (!target) return;
                      
                      var href = target.getAttribute('href');
                      if (!href || href.indexOf('blob:') !== 0) return;
                      
                      // 拦截浏览器默认行为
                      e.preventDefault();
                      e.stopPropagation();
                      
                      var fileName = target.getAttribute('download') || 'download-' + Date.now();
                      
                      // 通过 fetch 拿到 blob,再转 base64 交给原生
                      fetch(href)
                        .then(function (res) { return res.blob(); })
                        .then(function (blob) {
                          var reader = new FileReader();
                          reader.onloadend = function () {
                            try {
                              var dataUrl = reader.result || '';
                              var commaIndex = dataUrl.indexOf(',');
                              var base64 = commaIndex >= 0 ? dataUrl.substring(commaIndex + 1) : dataUrl;
                              var mime = blob.type || 'application/octet-stream';
                              if (window.BlobDownloader && window.BlobDownloader.downloadBase64File) {
                                window.BlobDownloader.downloadBase64File(base64, mime, fileName);
                              } else {
                                console.error('BlobDownloader not found on window');
                              }
                            } catch (err) {
                              console.error('Blob download convert error', err);
                            }
                          };
                          reader.readAsDataURL(blob);
                        })
                        .catch(function (err) {
                          console.error('Blob download fetch error', err);
                        });
                    } catch (e2) {
                      console.error('Blob download interceptor error', e2);
                    }
                  }, true);
                })();
            """.trimIndent()

            view?.evaluateJavascript(blobInterceptor, null)
        }

        override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
            super.onPageStarted(view, url, favicon)
            if (showLaunchSplash) mainFrameLoadError = false
            if (debug) {
                // vConsole
                val vConsole = assets.open("vConsole.js").bufferedReader().use { it.readText() }
                val openDebug = """var vConsole = new window.VConsole()"""
                view?.evaluateJavascript(vConsole + openDebug, null)
            }
            // inject js
            val injectJs = assets.open("custom.js").bufferedReader().use { it.readText() }
            view?.evaluateJavascript(injectJs, null)
        }
    }

    inner class MyChromeClient(private val activity: MainActivity) : WebChromeClient() {
        override fun onProgressChanged(view: WebView?, newProgress: Int) {
            super.onProgressChanged(view, newProgress)
            val url = view?.url
            println("wev view url:$url")
        }

        // 处理 getUserMedia 权限请求(摄像头 / 麦克风)
        override fun onPermissionRequest(request: PermissionRequest?) {
            if (request == null) return

            activity.runOnUiThread {
                val resources = request.resources

                // 需要对应的原生权限
                val needPermissions = mutableListOf<String>()
                if (resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
                    needPermissions.add(Manifest.permission.CAMERA)
                }
                if (resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
                    needPermissions.add(Manifest.permission.RECORD_AUDIO)
                }

                if (needPermissions.isEmpty()) {
                    // 不涉及摄像头/麦克风,直接允许
                    request.grant(resources)
                    return@runOnUiThread
                }

                // 检查是否已经有原生权限
                val notGranted = needPermissions.filter {
                    ContextCompat.checkSelfPermission(
                        activity,
                        it
                    ) != PackageManager.PERMISSION_GRANTED
                }

                if (notGranted.isEmpty()) {
                    // 已经有权限,直接授予给 WebView
                    request.grant(resources)
                } else {
                    // 先请求原生权限,保存 WebView 的请求
                    activity.pendingPermissionRequest?.deny()
                    activity.pendingPermissionRequest = request
                    activity.permissionLauncher.launch(notGranted.toTypedArray())
                }
            }
        }

        override fun onPermissionRequestCanceled(request: PermissionRequest?) {
            super.onPermissionRequestCanceled(request)
            if (activity.pendingPermissionRequest == request) {
                activity.pendingPermissionRequest = null
            }
        }

        override fun onGeolocationPermissionsShowPrompt(
            origin: String?,
            callback: GeolocationPermissions.Callback?
        ) {
            if (origin == null || callback == null) {
                super.onGeolocationPermissionsShowPrompt(origin, callback)
                return
            }
            activity.runOnUiThread {
                val fineOk = ContextCompat.checkSelfPermission(
                    activity,
                    Manifest.permission.ACCESS_FINE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
                val coarseOk = ContextCompat.checkSelfPermission(
                    activity,
                    Manifest.permission.ACCESS_COARSE_LOCATION
                ) == PackageManager.PERMISSION_GRANTED
                if (fineOk || coarseOk) {
                    callback.invoke(origin, true, false)
                    return@runOnUiThread
                }
                val need = buildList {
                    if (!fineOk) add(Manifest.permission.ACCESS_FINE_LOCATION)
                    if (!coarseOk) add(Manifest.permission.ACCESS_COARSE_LOCATION)
                }.toTypedArray()
                activity.pendingGeolocationCallback?.let { prevCb ->
                    activity.pendingGeolocationOrigin?.let { prevOrigin ->
                        prevCb.invoke(prevOrigin, false, false)
                    }
                }
                activity.pendingGeolocationOrigin = origin
                activity.pendingGeolocationCallback = callback
                activity.locationPermissionLauncher.launch(need)
            }
        }

        override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
            if (view != null && callback != null) {
                activity.showCustomView(view, callback)
            } else {
                super.onShowCustomView(view, callback)
            }
        }

        override fun onHideCustomView() {
            activity.hideCustomView()
            super.onHideCustomView()
        }

        // 处理文件选择(Android 5.0+)
        override fun onShowFileChooser(
            webView: WebView?,
            filePathCallback: ValueCallback<Array<Uri>>?,
            fileChooserParams: FileChooserParams?
        ): Boolean {
            // 如果之前有未完成的回调,取消它
            if (activity.fileUploadCallback != null) {
                activity.fileUploadCallback?.onReceiveValue(null)
            }
            activity.fileUploadCallback = filePathCallback

            try {
                val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
                    addCategory(Intent.CATEGORY_OPENABLE)

                    // 根据参数设置文件类型
                    val acceptTypes = fileChooserParams?.acceptTypes
                    if (acceptTypes != null && acceptTypes.isNotEmpty()) {
                        // 支持多种 MIME 类型
                        if (acceptTypes.size == 1) {
                            type = acceptTypes[0]
                        } else {
                            // 多个类型时使用通配符,并设置额外类型
                            type = "*/*"
                            putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes)
                        }
                    } else {
                        // 默认支持所有文件类型
                        type = "*/*"
                    }

                    // 支持多选
                    if (fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
                        putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
                    }
                }

                // 创建选择器,允许用户选择不同的应用来打开文件
                val chooserIntent = Intent.createChooser(intent, "选择文件")
                activity.fileChooserLauncher.launch(chooserIntent)
                return true
            } catch (e: ActivityNotFoundException) {
                Log.e("WebChromeClient", "无法打开文件选择器", e)
                activity.fileUploadCallback?.onReceiveValue(null)
                activity.fileUploadCallback = null
                return false
            }
        }
    }
}

前端 H5 测试代码

<!DOCTYPE html>
<html>
<body>
    <h1>WebView 定位测试</h1>
    <p id="location"></p>

    <script>
        function getLocation() {
            if (navigator.geolocation) {
                navigator.geolocation.getCurrentPosition(
                    (position) => {
                        const lat = position.coords.latitude;
                        const lng = position.coords.longitude;
                        document.getElementById("location").innerText =
                            `纬度: ${lat}\n经度: ${lng}`;
                    },
                    (error) => {
                        document.getElementById("location").innerText =
                            `错误: ${error.message}`;
                    }
                );
            } else {
                document.getElementById("location").innerText = "浏览器不支持定位";
            }
        }
        // 页面加载后获取定位
        window.onload = getLocation;
    </script>
</body>
</html>

常见问题与注意事项

  1. Android 7.0+ 安全限制

    <ul>
    <li>定位只在 <strong>HTTPS</strong> 页面有效(<code>onGeolocationPermissionsShowPrompt</code> 仅对 HTTPS 调用)</li>
    <li>测试可用 <code>android:usesCleartextTraffic=&quot;true&quot;</code>(仅开发环境)</li>
    </ul>
    </li>
    <li>
    <p><strong>权限回调不触发</strong></p>
    
    <ul>
    <li>必须同时配置:<code>setGeolocationEnabled(true)</code> + <code>WebChromeClient</code></li>
    <li>必须先拿到<strong>系统定位权限</strong>,Web 层权限才会生效</li>
    </ul>
    </li>
    <li>
    <p><strong>定位不准 / 失败</strong></p>
    
    <ul>
    <li>开启 <code>ACCESS_FINE_LOCATION</code>(GPS)</li>
    <li>确保设备已开启「定位服务」</li>
    <li>室外 / 开阔环境测试</li>
    </ul>
    </li>
    <li>
    <p><strong>Android 12+ 后台定位</strong></p>
    
    <ul>
    <li>如需后台定位,额外申请 <code>ACCESS_BACKGROUND_LOCATION</code></li>
    <li>需在系统设置中手动授权「始终允许」</li>
    </ul>
    </li>
    

Qt 信号与槽对象通信的核心机制(十)

作者 HelloReader
2026年4月2日 13:12

适合人群: 已掌握 QML 基础,想理解 Qt 对象系统通信原理的开发者

前言

按钮点击触发动作、输入框内容变化更新界面、后台数据完成加载通知 UI 刷新——这些"某件事发生时,另一件事跟着响应"的场景,在 Qt 中统一由信号与槽机制处理。

信号与槽不只是 QML 的概念,它是整个 Qt 框架的核心通信机制,C++ 和 QML 都建立在它之上。理解它,才能真正读懂 Qt 的运作方式。


一、为什么需要信号与槽?

传统的 UI 编程用回调函数(callback)处理事件:

// 传统回调方式(伪代码)
button->setOnClickCallback([](void* data) {
    doSomething(data);
});

回调函数的问题:

  • 发送方必须知道接收方的具体函数指针
  • 类型不安全,容易传错参数
  • 一对多通知非常繁琐
  • 对象销毁后回调仍可能被调用,导致崩溃

Qt 的信号与槽解决了这些问题:

// Qt 信号槽方式
connect(button, &QPushButton::clicked, this, &MyClass::onButtonClicked);
  • 发送方只需发出信号,不关心谁在监听
  • 编译时类型检查,参数类型不匹配直接报错
  • 一个信号可以连接多个槽
  • 对象销毁时连接自动断开,不会产生悬空指针

二、Qt 对象系统:MOC 的作用

信号与槽是 C++ 语言本身不支持的特性,Qt 通过 元对象编译器(MOC,Meta-Object Compiler) 来实现它。

2.1 MOC 的工作流程

你写的 .h 文件(含 Q_OBJECT 宏)
        ↓  MOC 处理
moc_xxx.cpp(自动生成的元对象代码)
        ↓  普通 C++ 编译器
最终可执行文件

MOC 扫描带有 Q_OBJECT 宏的类,自动生成支持信号槽、属性系统、运行时类型信息所需的代码。

2.2 Q_OBJECT 宏

每个需要使用信号槽的 C++ 类都必须:

  1. 继承自 QObject(直接或间接)
  2. 在类声明的第一行加上 Q_OBJECT
#include <QObject>

class Counter : public QObject
{
    Q_OBJECT    // 必须放在 private 区域第一行

public:
    explicit Counter(QObject *parent = nullptr);
    int value() const { return m_value; }

public slots:
    void setValue(int value);
    void increment() { setValue(m_value + 1); }

signals:
    void valueChanged(int newValue);    // 只声明,不实现

private:
    int m_value = 0;
};

signals 块中只写声明,不需要实现——MOC 自动生成信号的发射代码。


三、C++ 中的信号与槽

3.1 定义信号和槽

// counter.h
#pragma once
#include <QObject>

class Counter : public QObject
{
    Q_OBJECT

public:
    explicit Counter(QObject *parent = nullptr);
    int value() const { return m_value; }

public slots:
    // 槽函数:普通成员函数,加上 slots 关键字
    void setValue(int value) {
        if (value == m_value) return;    // 防止循环触发
        m_value = value;
        emit valueChanged(m_value);     // 发射信号
    }

signals:
    // 信号:只声明,参数就是传递的数据
    void valueChanged(int newValue);

private:
    int m_value = 0;
};

3.2 连接信号与槽

QObject::connect() 是建立连接的核心函数:

// main.cpp
#include "counter.h"
#include <QDebug>

int main()
{
    Counter a, b;

    // 函数指针语法(Qt 5+ 推荐,编译时类型检查)
    QObject::connect(&a, &Counter::valueChanged,
                     &b, &Counter::setValue);

    // 当 a 的值改变时,b 自动同步
    a.setValue(10);
    qDebug() << b.value();    // 输出:10

    return 0;
}

3.3 连接到 Lambda 函数

槽不一定是成员函数,可以直接连接到 Lambda:

Counter counter;

QObject::connect(&counter, &Counter::valueChanged,
                 [](int value) {
                     qDebug() << "值变为:" << value;
                 });

counter.setValue(42);    // 输出:值变为:42

3.4 一信号多槽

一个信号可以连接到多个槽,发射时所有槽按连接顺序依次调用:

Counter counter;
QLabel *label1 = new QLabel;
QLabel *label2 = new QLabel;

// 同一信号连接两个槽
QObject::connect(&counter, &Counter::valueChanged,
                 label1, [label1](int v) { label1->setText(QString::number(v)); });

QObject::connect(&counter, &Counter::valueChanged,
                 label2, [label2](int v) { label2->setText("值:" + QString::number(v)); });

counter.setValue(99);    // label1 和 label2 都会更新

3.5 断开连接

// 断开特定连接
QObject::disconnect(&a, &Counter::valueChanged,
                    &b, &Counter::setValue);

// 断开某对象的所有连接
QObject::disconnect(&a, nullptr, nullptr, nullptr);

四、Qt 内存管理:对象树

Qt 通过父子对象树管理内存,这与信号槽系统密切相关。

4.1 父子关系

QWidget *window = new QWidget();            // 根对象,没有父级
QPushButton *btn = new QPushButton(window); // 父级是 window
QLabel *label = new QLabel(window);         // 父级是 window

规则:父对象销毁时,所有子对象自动销毁。

{
    QWidget *window = new QWidget();
    QPushButton *btn = new QPushButton(window);  // btn 的父是 window
    // ...
    delete window;    // btn 也被自动删除,不会内存泄漏
}

4.2 对象树与信号槽的协作

当一个 QObject 被销毁时,Qt 自动:

  1. 发射 destroyed() 信号
  2. 断开所有与该对象相关的信号槽连接

这保证了不会出现"槽函数引用了已销毁对象"的崩溃问题:

Counter *counter = new Counter();
QLabel  *label   = new QLabel();

QObject::connect(counter, &Counter::valueChanged,
                 label, [label](int v) {
                     label->setText(QString::number(v));
                 });

delete label;       // label 销毁,连接自动断开
counter->setValue(5); // 安全,不会崩溃

4.3 在 QML 中的对象生命周期

QML 对象的父子关系由可视层级决定:

Rectangle {                // 父对象
    id: container

    Rectangle {            // 子对象,container 销毁时一并销毁
        id: child
    }

    Component.onCompleted: {
        console.log("container 加载完成")
    }

    Component.onDestruction: {
        console.log("container 即将销毁")
    }
}

五、QML 中的信号与槽

5.1 QML 内置信号

Qt Quick 的每个属性变化都自动生成对应的信号,命名规则是 属性名 + Changed

Rectangle {
    id: box
    width: 200

    // 监听 width 变化
    onWidthChanged: console.log("宽度变为:" + width)

    // 监听 visible 变化
    onVisibleChanged: console.log("可见性:" + visible)
}

5.2 自定义信号

Rectangle {
    id: card
    width: 200; height: 100

    // 声明自定义信号(可以带参数)
    signal clicked()
    signal dataChanged(string key, var value)

    MouseArea {
        anchors.fill: parent
        onClicked: {
            card.clicked()                          // 发射无参信号
            card.dataChanged("status", "active")    // 发射带参信号
        }
    }
}

在父对象中响应:

Card {
    onClicked: console.log("卡片被点击")
    onDataChanged: function(key, value) {
        console.log(key + " = " + value)
    }
}

5.3 Connections 元素

当需要在对象外部监听信号,或需要动态管理连接时,使用 Connections

import QtQuick

Rectangle {
    id: sender
    signal messageSent(string text)
}

// 在另一个地方监听 sender 的信号
Connections {
    target: sender    // 监听的目标对象

    function onMessageSent(text) {
        console.log("收到消息:" + text)
    }
}

Connections 的实际应用——监听全局单例的信号:

// AppState.qml(单例)
pragma Singleton
import QtQuick

QtObject {
    signal userLoggedIn(string userName)
    signal userLoggedOut()
}
// 在任意组件中监听
Connections {
    target: AppState

    function onUserLoggedIn(userName) {
        welcomeText.text = "欢迎," + userName
    }

    function onUserLoggedOut() {
        welcomeText.text = "请登录"
    }
}

5.4 connect() 方法

QML 中也可以用 JavaScript 风格的 connect() 动态建立连接:

Rectangle {
    id: buttonA
    signal tapped()
}

Rectangle {
    id: buttonB
    signal tapped()

    function onAnyTapped() {
        console.log("有按钮被点击了")
    }

    Component.onCompleted: {
        // 动态连接两个信号到同一个函数
        buttonA.tapped.connect(onAnyTapped)
        buttonB.tapped.connect(onAnyTapped)
    }
}

断开连接:

buttonA.tapped.disconnect(onAnyTapped)

六、C++ 信号与 QML 槽的跨语言连接

Qt 最强大的能力之一是 C++ 后端与 QML 前端之间的信号槽连接。

6.1 C++ 信号 → QML 响应

定义 C++ 类(后端):

// backend.h
#pragma once
#include <QObject>
#include <QString>

class Backend : public QObject
{
    Q_OBJECT

public:
    explicit Backend(QObject *parent = nullptr);

public slots:
    void fetchData();    // QML 可以调用这个函数

signals:
    void dataReady(const QString &data);      // 数据准备好时发射
    void errorOccurred(const QString &msg);   // 出错时发射
};
// backend.cpp
#include "backend.h"
#include <QTimer>

void Backend::fetchData()
{
    // 模拟异步操作:500ms 后返回数据
    QTimer::singleShot(500, this, [this]() {
        emit dataReady("从服务器获取的数据内容");
    });
}

main.cpp 中暴露给 QML:

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include "backend.h"

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);
    QQmlApplicationEngine engine;

    Backend backend;
    // 将 C++ 对象注册为 QML 上下文属性
    engine.rootContext()->setContextProperty("backend", &backend);

    engine.load(QUrl("qrc:/Main.qml"));
    return app.exec();
}

在 QML 中响应 C++ 信号:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    width: 360; height: 300
    visible: true

    // 监听 C++ backend 的信号
    Connections {
        target: backend

        function onDataReady(data) {
            resultText.text = data
            loadingIndicator.visible = false
        }

        function onErrorOccurred(msg) {
            resultText.text = "错误:" + msg
            resultText.color = "red"
        }
    }

    Column {
        anchors.centerIn: parent
        spacing: 16
        width: 280

        BusyIndicator {
            id: loadingIndicator
            anchors.horizontalCenter: parent.horizontalCenter
            visible: false
        }

        Text {
            id: resultText
            width: parent.width
            text: "点击按钮获取数据"
            wrapMode: Text.Wrap
            horizontalAlignment: Text.AlignHCenter
            font.pixelSize: 15
        }

        Button {
            anchors.horizontalCenter: parent.horizontalCenter
            text: "获取数据"
            onClicked: {
                loadingIndicator.visible = true
                resultText.text = "加载中..."
                backend.fetchData()    // 调用 C++ 函数
            }
        }
    }
}

七、综合示例:计时器应用

用信号槽实现一个完整的秒表,涵盖 QML 自定义信号、Connections、状态管理:

import QtQuick
import QtQuick.Controls

ApplicationWindow {
    id: window
    width: 360; height: 500
    visible: true
    title: "秒表"

    // 自定义计时器组件(内联)
    component Stopwatch: QtObject {
        id: sw

        property int elapsed: 0          // 已过毫秒数
        property bool running: false

        signal started()
        signal stopped(int totalMs)
        signal reset()

        function start() {
            running = true
            timer.start()
            started()
        }

        function stop() {
            running = false
            timer.stop()
            stopped(elapsed)
        }

        function doReset() {
            running = false
            timer.stop()
            elapsed = 0
            reset()
        }

        Timer {
            id: timer
            interval: 10        // 每 10ms 触发一次
            repeat: true
            onTriggered: sw.elapsed += 10
        }
    }

    Stopwatch {
        id: stopwatch

        onStarted: statusText.text = "计时中..."
        onStopped: function(totalMs) {
            statusText.text = "已停止,共 " + (totalMs / 1000).toFixed(2) + " 秒"
        }
        onReset: statusText.text = "已重置"
    }

    // 格式化显示
    function formatTime(ms) {
        var minutes = Math.floor(ms / 60000)
        var seconds = Math.floor((ms % 60000) / 1000)
        var millis  = Math.floor((ms % 1000) / 10)
        return pad(minutes) + ":" + pad(seconds) + "." + pad(millis)
    }

    function pad(n) {
        return n < 10 ? "0" + n : "" + n
    }

    Column {
        anchors.centerIn: parent
        spacing: 24
        width: 280

        // 时间显示
        Rectangle {
            width: parent.width
            height: 120
            radius: 16
            color: stopwatch.running ? "#1A2332" : "#f5f5f5"

            Behavior on color {
                ColorAnimation { duration: 300 }
            }

            Text {
                anchors.centerIn: parent
                // 绑定到 elapsed,自动实时更新
                text: formatTime(stopwatch.elapsed)
                font.pixelSize: 42
                font.family: "monospace"
                color: stopwatch.running ? "white" : "#333"
                font.bold: true

                Behavior on color {
                    ColorAnimation { duration: 300 }
                }
            }
        }

        // 状态文字
        Text {
            id: statusText
            anchors.horizontalCenter: parent.horizontalCenter
            text: "准备就绪"
            font.pixelSize: 14
            color: "#888"
        }

        // 控制按钮
        RowLayout {
            width: parent.width
            spacing: 12

            Button {
                Layout.fillWidth: true
                text: stopwatch.running ? "暂停" : "开始"
                highlighted: !stopwatch.running
                onClicked: stopwatch.running ? stopwatch.stop() : stopwatch.start()
            }

            Button {
                Layout.fillWidth: true
                text: "重置"
                enabled: stopwatch.elapsed > 0
                onClicked: stopwatch.doReset()
            }
        }
    }
}

八、常见问题

Q:信号与 JavaScript 函数调用有什么区别?

直接调用函数是同步的、紧耦合的——调用方必须知道被调用方的存在。信号是松耦合的——发射方不知道也不关心谁在监听,可以没有监听者,也可以有多个监听者。

Q:emit 关键字是必须的吗?

在 C++ 中,emit 只是一个空宏(展开为空),语义上是可选的,直接调用信号函数也能发射。但强烈建议保留 emit,它让代码读者一眼看出这里在发射信号,而不是调用普通函数。

Q:槽函数可以有返回值吗?

槽函数本身可以有返回值,但通过 connect() 触发的槽,返回值会被忽略。如果需要获取返回值,应该直接调用函数而不是通过信号槽。

Q:信号可以连接到另一个信号吗?

可以,信号可以直接连接到另一个信号,形成信号链:

connect(sender, &Sender::signal1, receiver, &Receiver::signal2);
// sender 发射 signal1 时,receiver 自动发射 signal2

总结

概念 要点
Q_OBJECT 启用元对象系统,必须继承 QObject 并添加此宏
signals 声明信号,只写声明不写实现,MOC 自动生成
slots 声明槽函数,本质是普通成员函数
emit 发射信号,触发所有连接的槽
connect() 建立信号槽连接,支持函数指针和 Lambda
disconnect() 断开连接
对象树 父对象销毁时子对象自动销毁,连接自动断开
QML signal 声明自定义信号,on + 信号名 处理
Connections 在对象外部监听信号,支持动态目标
跨语言连接 C++ 信号可在 QML 中用 Connections 响应

参考资料:Qt Academy — Making Connections · Qt 信号槽文档

Qt Quick 布局Positioners、Anchors 与 Layouts(九)

作者 HelloReader
2026年4月2日 13:01

适合人群: 已掌握 Qt Quick Controls 基础,想让 UI 自适应不同屏幕尺寸的开发者


前言

写出来的界面能跑,但一调整窗口大小就乱掉——这是 Qt Quick 新手最常遇到的问题。根本原因是没有掌握布局系统。

Qt Quick 提供了三套定位与布局机制,各有适用场景:

机制 模块 特点
anchors QtQuick 内置 基于边对齐,灵活但不自动分配尺寸
Positioners(RowColumnGridFlow QtQuick 内置 自动排列,固定间距,不管理尺寸
Layouts(RowLayoutColumnLayoutGridLayout QtQuick.Layouts 自动排列 + 管理尺寸,响应式首选

本文按从简到难的顺序,把三套机制都讲透。


一、anchors 深入

上一篇简单介绍过 anchors,这里补全所有细节。

1.1 锚点属性完整列表

Item {
    anchors.left:              // 左边
    anchors.right:             // 右边
    anchors.top:               // 上边
    anchors.bottom:            // 下边
    anchors.horizontalCenter:  // 水平中线
    anchors.verticalCenter:    // 垂直中线
    anchors.baseline:          // 文字基线(Text 元素专用)

    anchors.fill:              // 填满某个元素(等同于同时设置四边)
    anchors.centerIn:          // 居中于某个元素

    // 边距
    anchors.margins:           // 四边统一边距
    anchors.leftMargin:        // 单独左边距
    anchors.rightMargin:
    anchors.topMargin:
    anchors.bottomMargin:
}

1.2 常用布局模式

顶部导航栏 + 剩余内容区:

Rectangle {
    id: navbar
    height: 56
    anchors {
        top: parent.top
        left: parent.left
        right: parent.right
    }
    color: "#4A90E2"
}

Rectangle {
    anchors {
        top: navbar.bottom
        left: parent.left
        right: parent.right
        bottom: parent.bottom
    }
    color: "#f5f5f5"
}

左侧边栏 + 右侧内容区:

Rectangle {
    id: sidebar
    width: 200
    anchors {
        top: parent.top
        left: parent.left
        bottom: parent.bottom
    }
    color: "#2C3E50"
}

Rectangle {
    anchors {
        top: parent.top
        left: sidebar.right
        right: parent.right
        bottom: parent.bottom
    }
    color: "#ECF0F1"
}

1.3 anchors 的限制

  • 不能自动分配剩余空间:无法让两个元素平分父容器宽度
  • 不能跨层级锚定:只能锚定父元素或同级兄弟元素
  • 与 Layouts 冲突:放在 Layout 内的子元素不要使用 anchors

遇到这些情况,改用 Layouts。


二、Positioners:快速排列元素

Positioners 适合子元素尺寸固定、只需要自动排列间距的场景。

2.1 Row — 水平排列

import QtQuick

Row {
    spacing: 12

    Rectangle { width: 80; height: 80; color: "#4A90E2"; radius: 8 }
    Rectangle { width: 80; height: 80; color: "#E2934A"; radius: 8 }
    Rectangle { width: 80; height: 80; color: "#4AE29A"; radius: 8 }
}

RTL(从右到左)语言支持:

Row {
    spacing: 8
    layoutDirection: Qt.RightToLeft
}

2.2 Column — 垂直排列

Column {
    spacing: 8
    width: 280

    TextField { width: parent.width; placeholderText: "用户名" }
    TextField { width: parent.width; placeholderText: "密码"; echoMode: TextInput.Password }
    Button    { width: parent.width; text: "登录" }
}

2.3 Grid — 网格排列

Grid {
    columns: 3
    spacing: 10

    Repeater {
        model: 9
        delegate: Rectangle {
            width: 80; height: 80
            color: Qt.rgba(0.2 + index * 0.08, 0.4, 0.8, 1)
            radius: 8

            Text {
                anchors.centerIn: parent
                text: index + 1
                color: "white"
                font.bold: true
                font.pixelSize: 18
            }
        }
    }
}

2.4 Flow — 流式排列(自动换行)

Flow 是最灵活的 Positioner,子元素从左到右排列,空间不足时自动换行:

Flow {
    width: 360
    spacing: 8

    Repeater {
        model: ["Qt Quick", "QML", "跨平台", "嵌入式", "移动端",
                "桌面开发", "C++", "JavaScript", "动画", "UI设计"]
        delegate: Rectangle {
            width: tagText.width + 20
            height: 28
            radius: 14
            color: "#E6F1FB"
            border.width: 1
            border.color: "#B5D4F4"

            Text {
                id: tagText
                anchors.centerIn: parent
                text: modelData
                color: "#185FA5"
                font.pixelSize: 13
            }
        }
    }
}

2.5 Positioners 的局限

Positioners 不管理子元素的尺寸——子元素需要自己设置 widthheight,Positioners 只负责摆放位置。想要自动分配尺寸,用 Layouts。


三、Layouts:响应式布局的核心

import QtQuick.Layouts

3.1 RowLayout

RowLayout {
    width: 400
    height: 48
    spacing: 8

    Button {
        text: "取消"
        Layout.preferredWidth: 80
    }

    Item {
        Layout.fillWidth: true    // 弹性空间,把后面的按钮推到右边
    }

    Button {
        text: "保存草稿"
        Layout.preferredWidth: 100
    }

    Button {
        text: "发布"
        highlighted: true
        Layout.preferredWidth: 80
    }
}

3.2 ColumnLayout

ColumnLayout {
    anchors.fill: parent
    anchors.margins: 20
    spacing: 12

    Label { text: "文章标题"; font.bold: true }

    TextField {
        Layout.fillWidth: true
        placeholderText: "请输入标题"
    }

    Label { text: "正文内容" }

    ScrollView {
        Layout.fillWidth: true
        Layout.fillHeight: true    // 填满剩余高度
        TextArea {
            placeholderText: "在此输入正文..."
            wrapMode: TextArea.Wrap
        }
    }

    RowLayout {
        Layout.fillWidth: true
        spacing: 8
        Button { text: "取消"; Layout.fillWidth: true }
        Button { text: "发布"; highlighted: true; Layout.fillWidth: true }
    }
}

3.3 GridLayout

GridLayout {
    width: 360
    columns: 2
    columnSpacing: 16
    rowSpacing: 12

    Label { text: "姓名" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入" }

    Label { text: "邮箱" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入" }

    Label { text: "手机" }
    TextField { Layout.fillWidth: true; placeholderText: "请输入" }

    Button {
        Layout.columnSpan: 2
        Layout.fillWidth: true
        text: "提交"
        highlighted: true
    }
}

3.4 Layout 附加属性完整参考

属性 说明
Layout.fillWidth: true 填满布局剩余宽度
Layout.fillHeight: true 填满布局剩余高度
Layout.preferredWidth: 120 期望宽度
Layout.preferredHeight: 44 期望高度
Layout.minimumWidth: 80 最小宽度,不会被压缩到此以下
Layout.maximumWidth: 200 最大宽度,不会被拉伸到此以上
Layout.columnSpan: 2 跨列数(GridLayout 专用)
Layout.rowSpan: 2 跨行数(GridLayout 专用)
Layout.alignment: Qt.AlignRight 格子内的对齐方式
Layout.margins: 8 外边距

四、响应式布局:适配不同屏幕

4.1 用属性绑定实现断点

ApplicationWindow {
    id: window
    width: 800; height: 500
    visible: true

    // 定义断点属性
    readonly property bool isNarrow: width < 480
    readonly property bool isMedium: width >= 480 && width < 800
    readonly property bool isWide:   width >= 800

    GridLayout {
        anchors.fill: parent
        anchors.margins: 16
        columns: isNarrow ? 1 : isMedium ? 2 : 3    // 自动切换列数
        columnSpacing: 12
        rowSpacing: 12

        Repeater {
            model: 6
            delegate: Rectangle {
                Layout.fillWidth: true
                height: 100
                radius: 8
                color: "#4A90E2"
                opacity: 0.6 + index * 0.07

                Text {
                    anchors.centerIn: parent
                    text: "模块 " + (index + 1)
                    color: "white"
                    font.pixelSize: 16
                    font.bold: true
                }
            }
        }
    }
}

拖动窗口调整宽度,网格列数在 1、2、3 之间自动切换。

4.2 侧边栏折叠效果

ApplicationWindow {
    id: window
    width: 900; height: 600
    visible: true

    RowLayout {
        anchors.fill: parent
        spacing: 0

        // 侧边栏:窗口窄时自动折叠
        Rectangle {
            Layout.preferredWidth: window.width < 600 ? 56 : 200
            Layout.fillHeight: true
            color: "#1A2332"

            Behavior on Layout.preferredWidth {
                NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
            }

            Text {
                anchors.centerIn: parent
                text: window.width < 600 ? "≡" : "侧边栏"
                color: "white"
                font.pixelSize: window.width < 600 ? 22 : 16
            }
        }

        // 主内容区
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#F5F7FA"

            Text {
                anchors.centerIn: parent
                text: "主内容区\n当前宽度:" + Math.round(parent.width)
                color: "#666"
                font.pixelSize: 14
                horizontalAlignment: Text.AlignHCenter
            }
        }
    }
}

4.3 Flow 卡片自动换行

ScrollView {
    anchors.fill: parent

    Flow {
        width: parent.width
        padding: 16
        spacing: 12

        Repeater {
            model: 12
            delegate: Rectangle {
                width: 160; height: 120
                radius: 10
                color: "#ffffff"
                border.width: 1
                border.color: "#e8e8e8"

                Column {
                    anchors.centerIn: parent
                    spacing: 8

                    Rectangle {
                        width: 36; height: 36; radius: 18
                        color: "#4A90E2"
                        anchors.horizontalCenter: parent.horizontalCenter
                    }

                    Text {
                        text: "功能 " + (index + 1)
                        font.pixelSize: 13
                        font.bold: true
                        color: "#333"
                        anchors.horizontalCenter: parent.horizontalCenter
                    }
                }
            }
        }
    }
}

五、综合示例:响应式仪表盘框架

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    id: window
    width: 900; height: 600
    minimumWidth: 360
    visible: true
    title: "响应式仪表盘"

    readonly property bool compact: width < 600

    RowLayout {
        anchors.fill: parent
        spacing: 0

        // 侧边栏
        Rectangle {
            Layout.preferredWidth: compact ? 56 : 200
            Layout.fillHeight: true
            color: "#1A2332"

            Behavior on Layout.preferredWidth {
                NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
            }

            Column {
                anchors.top: parent.top
                anchors.topMargin: 16
                width: parent.width
                spacing: 2

                // Logo
                Rectangle {
                    width: parent.width; height: 48
                    color: "transparent"
                    Text {
                        anchors.centerIn: parent
                        text: compact ? "D" : "Dashboard"
                        color: "white"
                        font.pixelSize: compact ? 18 : 16
                        font.bold: true
                    }
                }

                Rectangle {
                    width: parent.width; height: 1
                    color: "#ffffff20"
                }

                // 菜单
                Repeater {
                    model: ["概览", "数据", "报告", "设置"]
                    delegate: Rectangle {
                        width: parent.width; height: 44
                        color: index === 0 ? "#ffffff18" : "transparent"

                        Text {
                            anchors.centerIn: parent
                            text: compact ? modelData[0] : modelData
                            color: index === 0 ? "white" : "#ffffff70"
                            font.pixelSize: 14
                        }
                    }
                }
            }
        }

        // 主内容区
        ColumnLayout {
            Layout.fillWidth: true
            Layout.fillHeight: true
            spacing: 0

            // 顶栏
            Rectangle {
                Layout.fillWidth: true
                height: 56
                color: "white"

                RowLayout {
                    anchors.fill: parent
                    anchors.leftMargin: 20
                    anchors.rightMargin: 20

                    Label {
                        text: "概览"
                        font.pixelSize: 18
                        font.bold: true
                    }

                    Item { Layout.fillWidth: true }

                    Label {
                        text: Qt.formatDate(new Date(), "yyyy-MM-dd")
                        color: "#999"
                        font.pixelSize: 13
                    }
                }
            }

            // 内容滚动区
            ScrollView {
                Layout.fillWidth: true
                Layout.fillHeight: true
                contentWidth: availableWidth

                ColumnLayout {
                    width: parent.width
                    spacing: 16

                    Item { height: 4 }

                    // 统计卡片
                    GridLayout {
                        Layout.fillWidth: true
                        Layout.leftMargin: 16
                        Layout.rightMargin: 16
                        columns: compact ? 2 : 4
                        columnSpacing: 12
                        rowSpacing: 12

                        Repeater {
                            model: [
                                { label: "用户总数", value: "12,480", accent: "#4A90E2" },
                                { label: "今日活跃", value: "3,291",  accent: "#1D9E75" },
                                { label: "新增订单", value: "847",    accent: "#E2934A" },
                                { label: "总收入",   value: "¥52K",   accent: "#9B59B6" }
                            ]
                            delegate: Rectangle {
                                Layout.fillWidth: true
                                height: 88
                                radius: 10
                                color: "white"
                                border.width: 1
                                border.color: "#f0f0f0"

                                Rectangle {
                                    width: 4; height: 36; radius: 2
                                    color: modelData.accent
                                    anchors {
                                        left: parent.left; leftMargin: 14
                                        verticalCenter: parent.verticalCenter
                                    }
                                }

                                Column {
                                    anchors {
                                        left: parent.left; leftMargin: 28
                                        verticalCenter: parent.verticalCenter
                                    }
                                    spacing: 4
                                    Text {
                                        text: modelData.label
                                        font.pixelSize: 12; color: "#999"
                                    }
                                    Text {
                                        text: modelData.value
                                        font.pixelSize: 20; font.bold: true; color: "#222"
                                    }
                                }
                            }
                        }
                    }

                    // 图表占位(窄屏隐藏右侧饼图)
                    RowLayout {
                        Layout.fillWidth: true
                        Layout.leftMargin: 16
                        Layout.rightMargin: 16
                        spacing: 12

                        Rectangle {
                            Layout.fillWidth: true
                            height: 180; radius: 10
                            color: "white"
                            border.width: 1; border.color: "#f0f0f0"
                            Text {
                                anchors.centerIn: parent
                                text: "折线图区域"
                                color: "#ccc"; font.pixelSize: 14
                            }
                        }

                        Rectangle {
                            visible: !compact
                            Layout.preferredWidth: 200
                            height: 180; radius: 10
                            color: "white"
                            border.width: 1; border.color: "#f0f0f0"
                            Text {
                                anchors.centerIn: parent
                                text: "饼图区域"
                                color: "#ccc"; font.pixelSize: 14
                            }
                        }
                    }

                    Item { height: 16 }
                }
            }
        }
    }
}

六、三套机制选型指南

image.png

实际项目常见组合:

  • 页面整体框架用 ColumnLayout(顶栏 + 内容区)
  • 卡片网格用 GridLayout + 断点属性(自动切换列数)
  • 标签、按钮组用 Flow(自动换行)
  • 卡片内部元素用 anchors 精确定位
  • 工具栏按钮用 RowLayout + Item { Layout.fillWidth: true } 推到右边

总结

机制 管理尺寸 自动换行 典型场景
anchors 精确定位、顶底栏、填满父容器
Row / Column 固定尺寸元素的快速排列
Grid 固定尺寸元素的网格排列
Flow 标签云、卡片流式布局
RowLayout 工具栏、按钮组、水平自适应
ColumnLayout 表单、页面主框架、垂直自适应
GridLayout 表单标签对、响应式卡片网格

参考资料:Qt Academy — Positioners and Layouts · Qt Quick Layouts 文档

整合 NativeScript 代码与 Swift/Obj-C 代码

作者 sp42_frank
2026年4月2日 11:53

整合 NativeScript 代码与 Swift 代码

只要 Swift 的结构能够暴露给 Objective-C,NativeScript 就可以访问它们,并且操作起来非常直接明了。在 NativeScript 中,我们可以完全访问 Java、Kotlin、Objective-C 和 Swift 这些平台原生 API。每种平台语言都带来了自己独特的语法结构。使用 Swift 时,只要你打算使用的语法结构将其类型暴露给了 Objective-C,你就可以使用它的全部功能。

那么,如果某个 Swift 代码库中的结构没有暴露给 Objective-C,我们该如何将其集成到我们的 NativeScript 应用中呢?

示例代码库 (Pod)

我们将要使用的示例 Swift 代码库是这个:github.com/SwiftKickMo… ,我们需要集成它。它能以多种方式便捷地展示信息卡片。

同时,这个库也无法从 Objective-C 中调用。

搭建项目并添加代码库 (Pod)

我们先创建一个示例项目:

ns create SwiftSample --template @NativeScript/template-hello-world-ts
cd SwiftSample
ns run ios

这样我们就得到了那个可爱的“点击按钮”界面。

添加代码库 (Pod)

现在,我们在应用中添加对这个 Pod 的引用:

  • App_Resources/iOS/ 目录下创建一个名为 Podfile 的文件。
  • 在文件中添加以下文本:
pod 'SwiftMessages'

类型定义在哪里?

此时,我们可以生成类型定义文件,从而获得对 Pod 内容的强类型访问。

ns typings ios

这将在 typings/ios/<architecture> 目录下生成一个文件。

我们确实会得到一个 objc!SwiftMessages.d.ts 文件,但这里有个问题。

如果我查阅 SwiftMessages 的文档,一个简单的用法是 SwiftMessages.show(view: myView),但在我们的类型定义文件里,SwiftMessages 类在哪里呢?文件里根本找不到。问题就出在这里:SwiftMessages 类并没有暴露给 Objective-C,因此无法从 NativeScript 中访问。您可以在此处了解更多信息。

包装它!

那么解决方案是什么呢?借助 NativeScript 的强大功能,我们可以:

  1. 在项目中放入我们自己的 Swift 文件。
  2. 让这个文件可以被 Objective-C 访问。
  3. 然后,通过它来调用代码库 (Pod) 中类的方法。

首先,我们在项目中添加一个新的 Swift 文件,路径为App_Resources/iOS/src/NSCSwiftMessages.swift。然后添加一些代码(代码复制自 SwiftMessages 的 GitHub 示例),以便我们能从 NativeScript 中调用它。

注意: 您可以在这里使用任何类前缀,但我们建议使用 NSC 前缀。这是为了确保您的类不会与大量存在于 iOS 代码中的 NS 前缀类发生冲突,而这些 NS 前缀源于整个 iOS 平台的底层 NeXTStep 系统。您可以将 NSC 理解为 “NativeScript” 或 “NativeScript Compiler” 的缩写,这样可能更容易记住。同时,这也帮助规范了那些仅为 NativeScript 而暴露的平台代码的命名约定。

// 导入 Foundation 和 SwiftMessages 框架
import Foundation
import SwiftMessages

// 使用 @objcMembers 和 @objc 注解将此类及其成员暴露给 Objective-C。
// 类名在 Objective-C 中将显示为 NSCSwiftMessages。
@objcMembers
@objc(NSCSwiftMessages)
public class NSCSwiftMessages: NSObject {

  // 我们可以从 TypeScript 中为此属性赋值一个回调函数。
  @objc public var onDoneCallBack: ((String)-> Void)? = nil;

  // 定义一个公开方法,用于从 NativeScript 调用,传入标题和正文。
  public func showMessage(title: String, body: String) {

    // 从预设的 nib 文件布局中实例化一个消息视图。SwiftMessages 会优先在主包中查找 nib 文件,
    // 因此您可以轻松地将它们复制到您的项目中并进行修改。
    let view = MessageView.viewFromNib(layout: .cardView)

    // 使用成功 (success) 样式主题来配置消息元素。
    view.configureTheme(.success)
    view.button?.isHidden=true; // 隐藏按钮
    // 为视图添加阴影效果。
    view.configureDropShadow()
    view.button?.isHidden=true; // 再次隐藏按钮 (重复行)
    // 设置消息的标题、正文和图标。这里,我们用一个随机的表情符号覆盖默认的警告图片。
    let iconText = ["🤔", "😳", "🙄", "😶"].randomElement()!
    view.configureContent(title: title, body: body, iconText: iconText)

    // 增加卡片周围的外部边距。通常,此设置的效果取决于给定布局如何约束于布局边距。
    view.layoutMarginAdditions = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)

    // 减小圆角半径(适用于具有圆角特性的布局)。
    (view.backgroundView as? CornerRoundingView)?.cornerRadius = 10

    // 创建一个 SwiftMessages 配置对象。
    var config = SwiftMessages.Config()

    // 指定一个或多个事件监听器,以响应显示和隐藏事件。
    config.eventListeners.append() { event in
        if case .didHide = event { // 当消息被隐藏时
            // 如果回调函数已设置,则执行它。
            self.onDoneCallBack?("Message Alert Hidden");
            
        }
    }

    // 显示消息。
    SwiftMessages.show(config: config, view: view)
  }
}

这里重要的地方在于,我们通过添加 @objcMembers@objc 注解,将我们的 Swift 代码暴露给了 Objective-C。现在,我们可以再次运行类型定义生成命令,这次会出现一个新文件 objc!nsswiftsupport.d.ts。在这个新文件里,我们就能看到我们新建的类的类型定义了:

declare class NSCSwiftMessages extends NSObject {
static alloc(): NSCSwiftMessages; // inherited from NSObject
static new(): NSCSwiftMessages; // inherited from NSObject
onDoneCallBack: (p1: string) => void;
showMessageWithTitleBody(title: string, body: string): void;
}

我们将这个文件复制到项目的根目录(或其他任何合适的位置),并在 ./references.d.ts 文件中添加一行对该文件的引用。

/// <reference path="./node_modules/@nativescript/types/index.d.ts" />
/// <reference path="./objc!nsswiftsupport.d.ts" />

调用我们的新代码

现在我们可以调用我们的代码了。在 app/main-view-model.ts 文件中,将 tap 方法修改为:

onTap() {
  // 创建一个我们编写的 Swift 类的实例。
  const message = NSCSwiftMessages.new();
  // 指定一个回调函数,以便在消息关闭时收到通知。
  message.onDoneCallBack = (msg: string) => { this.message = msg; };
  // 显示实际的消息。
  message.showMessageWithTitleBody("This is the Title", "Hello There!");
}

现在,当我们运行应用并点击按钮时,我们漂亮的新消息就会显示出来!

并且当消息被隐藏时,我们会收到一个回调,这个回调会改变界面上标签的文字。

这个示例项目可以在 GitHub 上找到。

如何在 NativeScript 中使用 Swift 或 Objective C 委托

iOS 委托是非常有用且基础的概念,在创建自定义平台行为时必须掌握。让我们来看看如何在 NativeScript 中创建和使用委托。

什么是委托(Delegate)?

委托是指当某些重要事件发生时应当收到通知的任意对象。这些"重要事件"的具体含义取决于上下文环境:比如,表格视图的委托会在用户点击某一行时收到通知,而导航控制器的委托则会在用户在不同视图控制器间切换时收到通知。

ColorPicker 示例

让我们来看看 UIColorPickerViewController,它可以提供一种方式来展示用户可以选择的颜色选项。当控制器检测到用户选择了颜色时,它需要一种方式来告知您的应用程序所选择的颜色。它是通过委托来实现这一点的。苹果为不同的用途提供了不同的协议。对于 UIColorPickerViewController,它提供了 UIColorPickerViewControllerDelegate 协议。

使用这个委托,让我们在 NativeScript 中更新 StackLayout 的背景色。为了从选择器接收选定的颜色,我们要遵循 UIColorPickerViewControllerDelegate 协议,而且总是在两个阶段来进行控制器 / 委托设置。 注意:请务必了解 NativeScript 中委托使用的重要最佳实践:委托、委托、委托!!

第 1 阶段:创建委托实现

创建一个委托实现类,我们称之为ColorPickerDelegateImpl,它继承自 NSObject 并遵循(即implements)委托协议 UIColorPickerViewControllerDelegate。

@NativeClass()
class ColorPickerDelegateImpl
  extends NSObject
  implements UIColorPickerViewControllerDelegate {

    // 告知 NativeScript 连接协议
    static ObjCProtocols = [UIColorPickerViewControllerDelegate];

    // 最佳实践:拥有者弱引用
    owner: WeakRef<HelloWorldModel>;

    // 使用静态初始化实现的常见模式
    static initWithOwner(owner: WeakRef<HelloWorldModel>) {
      const delegate = <ColorPickerDelegateImpl>ColorPickerDelegateImpl.new();
      delegate.owner = owner;
      return delegate;
    }

    // 在此处实现委托方法 ...
}

当使用 NativeScript 创建平台类实现时,我们总是用@NativeClass()装饰它们。@NativeClass()装饰器确保符合 NativeScript 运行时的要求,您可以在此处了解更多相关信息。

我们通常继承 NSObject,因为它提供了我们的实现所需的所有常见基础 iOS 行为,同时也是因为我们的委托只是一个协议,也就是interface,无法被扩展(只能由实现来遵循)。

静态数组static ObjCProtocols可以包含我们希望实现使用的任意数量的协议,并告知 NativeScript 代表我们连接指定的协议。

一个常见的最佳实践是允许委托实现在拥有者弱引用。拥有者是与此委托进行通信的类。您可以在此处了解更多关于 WeakRef 的信息。

实例化实现类有很多方法,但使用 static initWithOwner(owner: WeakRef<HelloWorldModel>) 模式已经变得相当普遍,因为它允许您在不干扰平台(父级)构造函数链的情况下,根据需要将其他引用作为附加方法参数传递。值得注意的是,ColorPickerDelegateImpl.new()是一个便捷的简单构造函数,NativeScript 会将其添加到所有平台类中,避免处理特定的初始化参数(您可能希望稍后处理),并返回一个 NSObject,这就是为什么我们可以简单地将其转换为我们类型<ColorPickerDelegateImpl>的原因。

当希望在委托和其拥有者之间来回传递事件和数据时,拥有者弱引用就发挥作用了。让我们通过实现 UIColorPickerViewControllerDelegate 提供的几种方法来实现这一点:

import { Color } from '@nativescript/core';

@NativeClass()
class ColorPickerDelegateImpl
  extends NSObject
  implements UIColorPickerViewControllerDelegate
{
  // ...

  // all delegate methods come from Apple documentation:
  // https://developer.apple.com/documentation/uikit/uicolorpickerviewcontrollerdelegate#3635512

  colorPickerViewControllerDidFinish(
    viewController: UIColorPickerViewController
  ) {
    // did close/finish event
    this.owner?.deref()
      .changeColor(Color.fromIosColor(viewController.selectedColor));
  }

  colorPickerViewControllerDidSelectColorContinuously(
    viewController: UIColorPickerViewController,
    color: UIColor,
    continuously: boolean
  ) {
    // selecting colors event
  }
}

第 2 阶段:使用委托

创建将使用您的委托的控制器:

const picker = UIColorPickerViewController.alloc().init();

初始化我们上面创建的委托实现,同时按照最佳实践将其分配给实例属性:

this.colorDelegate = ColorPickerDelegateImpl.initWithOwner(
new WeakRef(this)
);

设置控制器所需的委托属性:

picker.delegate = this.colorDelegate;

更多例子:stackblitz.com/edit/native…

blog.nativescript.org/ios-delegat…

流式输出:让 AI 回复像 ChatGPT 一样打字机效果

作者 DanCheOo
2026年4月2日 11:53

流式输出:让 AI 回复像 ChatGPT 一样打字机效果

本文是【前端转 AI 全栈实战】系列第 05 篇。 上一篇:多模型适配:一套代码接 6 家 AI 厂商 | 下一篇:Prompt 工程:前端最容易忽略的核心技能


这篇文章你会得到什么

你有没有注意到 ChatGPT 的回复是一个字一个字"打"出来的,而不是等几秒钟后"啪"一下全部出现?

这不是为了炫酷——这是用户体验的硬需求

AI 模型生成一段 500 字的回复可能需要 3-8 秒。如果让用户干等 8 秒看一个加载动画,大部分人直接关掉了。但如果第一个字在 200ms 内就出现,用户会觉得"很快",即使全部输出完需要同样的时间。

今天的目标:搞懂流式输出的原理,用 JS 和 Python 分别实现后端流式调用,再用前端代码做出打字机效果


非流式 vs 流式:到底差在哪

先对比一下两种模式的区别:

非流式(普通模式)

用户发送请求 → 等待 5 秒 → 一次性收到完整回复

流式(Streaming)

用户发送请求 → 200ms 收到第一个字 → 陆续收到后续文字 → 5 秒后全部输出完

总耗时差不多,但体验天差地别。流式模式下用户从发出请求的第一时间就能看到 AI 在"思考和回答",心理等待感大幅降低。

技术上的区别就一个参数:stream: true


SSE 是什么:一分钟搞懂

流式输出背后的协议是 SSE(Server-Sent Events)——服务端推送事件。

如果你做过前端,你肯定知道 WebSocket。SSE 比 WebSocket 简单得多:

对比 WebSocket SSE
方向 双向通信 服务端 → 客户端(单向)
协议 ws:// 普通 HTTP
复杂度 需要握手、心跳 直接用,几乎零配置
断线重连 手动实现 浏览器自动重连
适用场景 聊天室、实时协作 AI 回复、通知推送

AI 流式输出用 SSE 完全够了——因为只需要"服务器往客户端推文字"这一个方向。

SSE 的数据格式长这样:

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"好"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"!"}}]}

data: [DONE]

每一行 data: 就是一个事件,包含一小块回复内容。最后一个 data: [DONE] 表示结束。

注意和非流式的区别:非流式返回的是 message.content(完整文本),流式返回的是 delta.content(增量文本片段)。


后端流式调用:加一个 stream: true

Node.js 实现

openai SDK,流式调用只需加 stream: true

import OpenAI from 'openai';

const client = new OpenAI({
  baseURL: 'https://api.deepseek.com',
  apiKey: process.env.DEEPSEEK_API_KEY,
});

async function streamChat(userMessage) {
  const stream = await client.chat.completions.create({
    model: 'deepseek-chat',
    messages: [{ role: 'user', content: userMessage }],
    stream: true,
  });

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content;
    if (content) {
      process.stdout.write(content); // 逐字输出,不换行
    }
  }
  console.log(); // 输出完毕后换行
}

streamChat('用 100 字介绍一下 JavaScript');

运行效果:你会看到终端里的文字一个一个蹦出来,而不是等半天后一下子全出来。

Python 实现

from openai import OpenAI
import os

client = OpenAI(
    base_url="https://api.deepseek.com",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
)

def stream_chat(user_message: str):
    stream = client.chat.completions.create(
        model="deepseek-chat",
        messages=[{"role": "user", "content": user_message}],
        stream=True,
    )

    for chunk in stream:
        content = chunk.choices[0].delta.content
        if content:
            print(content, end="", flush=True)
    print()

stream_chat("用 100 字介绍一下 JavaScript")

JS 和 Python 的 openai SDK 都把 SSE 解析封装好了,你不需要手动去拼 data: 行——直接 for await ... offor ... in 遍历就行。

原始 fetch 实现(不依赖 SDK)

如果你想理解底层到底发生了什么,也可以用原始 fetch 来调:

async function streamWithFetch(userMessage) {
  const response = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      messages: [{ role: 'user', content: userMessage }],
      stream: true,
    }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const text = decoder.decode(value, { stream: true });
    // text 可能包含多行 data: ...
    const lines = text.split('\n').filter(line => line.startsWith('data: '));

    for (const line of lines) {
      const data = line.slice(6); // 去掉 "data: " 前缀
      if (data === '[DONE]') return;

      const parsed = JSON.parse(data);
      const content = parsed.choices[0]?.delta?.content;
      if (content) process.stdout.write(content);
    }
  }
}

这就是 SSE 的真面目:一个持续的 HTTP 响应,body 里一行行推送 data: {...} 格式的 JSON。SDK 帮你做的事就是把这个解析过程封装了。


前端逐字渲染:做出打字机效果

后端搞定了流式调用,前端怎么接?这是前端开发者的主场了。

方案一:浏览器原生 EventSource

如果你的后端直接暴露 SSE 接口,前端可以用浏览器原生的 EventSource

const source = new EventSource('/api/chat?message=你好');

source.onmessage = (event) => {
  if (event.data === '[DONE]') {
    source.close();
    return;
  }
  const data = JSON.parse(event.data);
  const content = data.choices[0]?.delta?.content;
  if (content) {
    appendToChat(content); // 追加到聊天界面
  }
};

EventSource 有个硬伤——只支持 GET 请求,不能发 POST body。对于需要发送复杂消息体的 AI 聊天场景,不太够用。

方案二:fetch + ReadableStream(推荐)

实际项目中更常用的是 fetch + ReadableStream

async function fetchStream(messages) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let result = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const text = decoder.decode(value, { stream: true });
    const lines = text.split('\n');

    for (const line of lines) {
      if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;

      try {
        const data = JSON.parse(line.slice(6));
        const content = data.choices[0]?.delta?.content;
        if (content) {
          result += content;
          updateUI(result); // 每收到一个片段就更新界面
        }
      } catch (e) {
        // SSE 行可能被截断,跳过解析失败的行
      }
    }
  }

  return result;
}

核心就三步:

  1. response.body.getReader() — 拿到可读流的 reader
  2. reader.read() 循环 — 不断读取新到达的数据块
  3. 解析 SSE 行 — 提取 delta.content 并追加到界面

React 中的流式状态管理

前端框架里怎么优雅地管理流式状态?以 React 为例:

import { useState, useCallback } from 'react';

function useStreamChat() {
  const [messages, setMessages] = useState([]);
  const [isStreaming, setIsStreaming] = useState(false);

  const sendMessage = useCallback(async (userInput) => {
    const userMsg = { role: 'user', content: userInput };
    const assistantMsg = { role: 'assistant', content: '' };

    setMessages(prev => [...prev, userMsg, assistantMsg]);
    setIsStreaming(true);

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          messages: [...messages, userMsg],
        }),
      });

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const text = decoder.decode(value, { stream: true });
        const lines = text.split('\n');

        for (const line of lines) {
          if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
          try {
            const data = JSON.parse(line.slice(6));
            const content = data.choices[0]?.delta?.content;
            if (content) {
              // 更新最后一条消息(assistant)的内容
              setMessages(prev => {
                const updated = [...prev];
                const last = updated[updated.length - 1];
                updated[updated.length - 1] = {
                  ...last,
                  content: last.content + content,
                };
                return updated;
              });
            }
          } catch (e) {}
        }
      }
    } finally {
      setIsStreaming(false);
    }
  }, [messages]);

  return { messages, isStreaming, sendMessage };
}

使用这个 Hook:

function ChatApp() {
  const { messages, isStreaming, sendMessage } = useStreamChat();
  const [input, setInput] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;
    sendMessage(input);
    setInput('');
  };

  return (
    <div className="chat-container">
      <div className="messages">
        {messages.map((msg, i) => (
          <div key={i} className={`message ${msg.role}`}>
            {msg.content}
            {msg.role === 'assistant' && isStreaming && i === messages.length - 1 && (
              <span className="cursor"></span>
            )}
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="输入消息..."
          disabled={isStreaming}
        />
        <button type="submit" disabled={isStreaming}>
          {isStreaming ? '回复中...' : '发送'}
        </button>
      </form>
    </div>
  );
}

关键设计点:

  • 先插入空的 assistant 消息,然后逐步更新它的 content——这样 React 每次更新的只是最后一条消息的文本,而不是整个列表。
  • isStreaming 状态控制输入框和按钮的禁用,防止用户在回复中途重复发送。
  • 光标动画 在流式输出时显示,结束后自动消失。

Vue 中的流式状态管理

Vue 用户也别着急,写法一样清晰:

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

const messages = ref([]);
const isStreaming = ref(false);
const input = ref('');

async function sendMessage() {
  if (!input.value.trim() || isStreaming.value) return;

  const userMsg = { role: 'user', content: input.value };
  const assistantMsg = { role: 'assistant', content: '' };
  messages.value.push(userMsg, assistantMsg);

  const currentInput = input.value;
  input.value = '';
  isStreaming.value = true;

  try {
    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        messages: messages.value.slice(0, -1),
      }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    const lastMsg = messages.value[messages.value.length - 1];

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const text = decoder.decode(value, { stream: true });
      const lines = text.split('\n');

      for (const line of lines) {
        if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
        try {
          const data = JSON.parse(line.slice(6));
          const content = data.choices[0]?.delta?.content;
          if (content) {
            lastMsg.content += content; // Vue 响应式自动更新
          }
        } catch (e) {}
      }
    }
  } finally {
    isStreaming.value = false;
  }
}
</script>

<template>
  <div class="chat-container">
    <div class="messages">
      <div
        v-for="(msg, i) in messages"
        :key="i"
        :class="['message', msg.role]"
      >
        {{ msg.content }}
        <span
          v-if="msg.role === 'assistant' && isStreaming && i === messages.length - 1"
          class="cursor"
        >▊</span>
      </div>
    </div>
    <form @submit.prevent="sendMessage">
      <input
        v-model="input"
        placeholder="输入消息..."
        :disabled="isStreaming"
      />
      <button type="submit" :disabled="isStreaming">
        {{ isStreaming ? '回复中...' : '发送' }}
      </button>
    </form>
  </div>
</template>

Vue 这边有个天然优势——响应式系统会自动追踪 lastMsg.content 的变化,直接 += 就能触发视图更新,不需要像 React 那样用函数式 setState。


后端转发:Python FastAPI 实现 SSE 接口

实际项目中,前端不会直接调 AI API(API Key 会暴露)。通常是:前端 → 你的后端 → AI API,你的后端负责转发流式响应。

用 FastAPI 实现一个 SSE 流式接口:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import OpenAI
import os
import json

app = FastAPI()

client = OpenAI(
    base_url="https://api.deepseek.com",
    api_key=os.getenv("DEEPSEEK_API_KEY"),
)

@app.post("/api/chat")
async def chat(request: dict):
    messages = request.get("messages", [])

    def generate():
        stream = client.chat.completions.create(
            model="deepseek-chat",
            messages=messages,
            stream=True,
        )
        for chunk in stream:
            content = chunk.choices[0].delta.content
            if content:
                # 按 SSE 格式输出
                data = json.dumps({"choices": [{"delta": {"content": content}}]})
                yield f"data: {data}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(
        generate(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
        },
    )

这样前端 fetch 你的 /api/chat,就能拿到标准 SSE 格式的流式响应,和直接调 OpenAI API 的体验完全一致。


打字机光标 CSS

最后补一个细节——那个一闪一闪的光标,纯 CSS 实现:

.cursor {
  display: inline-block;
  animation: blink 0.8s step-end infinite;
  color: #10b981;
  margin-left: 2px;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

.message.assistant {
  white-space: pre-wrap;
  line-height: 1.6;
}

加上这段 CSS,你的 AI 聊天界面就有了和 ChatGPT 一样的打字机效果。


进阶:让流式输出更丝滑——Typewriter Buffer 模式

前面的实现有一个体验问题:AI 返回 token 的速度是不均匀的

有时候网络一下子推过来一大坨 token,屏幕上"哗"地蹦出一大段文字;有时候又卡顿半秒才来下一个 token。这种忽快忽慢的节奏感很差,用户感觉不到"AI 在稳定地打字"。

ChatGPT 的打字效果之所以流畅,不是因为 token 到得均匀,而是因为前端做了一层缓冲——把到达的 token 先存起来,然后用固定节奏一个个"喂"给界面。

核心思路:生产者-消费者模式

网络层(生产者)→ [Buffer 缓冲区] → 定时器(消费者)→ UI 渲染
  • 生产者:流式 chunk 到达后,往 buffer 里追加文本
  • 消费者:一个定时器(setIntervalrequestAnimationFrame)以固定频率从 buffer 中取出少量字符,更新到界面

这样不管网络推送多快多慢,用户看到的始终是匀速、流畅的打字效果

React 实现

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

function useTypewriterStream() {
  const [messages, setMessages] = useState([]);
  const [streaming, setStreaming] = useState(false);

  const streamBufferRef = useRef('');
  const streamEndedRef = useRef(false);
  const timerRef = useRef(null);

  const TICK_MS = 24;        // 每 24ms 消费一次(约 42fps)
  const CHARS_PER_TICK = 2;  // 每次取 2 个字符

  const startTypewriter = useCallback(() => {
    timerRef.current = setInterval(() => {
      const buf = streamBufferRef.current;

      if (buf.length === 0) {
        // buffer 空了,检查流是否已结束
        if (streamEndedRef.current) {
          clearInterval(timerRef.current);
          timerRef.current = null;
          setStreaming(false);
        }
        return;
      }

      // 从 buffer 中取出固定数量的字符
      const take = Math.min(CHARS_PER_TICK, buf.length);
      const chars = buf.slice(0, take);
      streamBufferRef.current = buf.slice(take);

      // 更新最后一条 assistant 消息
      setMessages(prev => {
        const next = [...prev];
        const last = next[next.length - 1];
        if (last?.role === 'assistant') {
          next[next.length - 1] = { ...last, content: last.content + chars };
        }
        return next;
      });
    }, TICK_MS);
  }, []);

  const sendMessage = useCallback(async (userInput) => {
    const userMsg = { role: 'user', content: userInput };
    setMessages(prev => [...prev, userMsg, { role: 'assistant', content: '' }]);
    setStreaming(true);

    // 重置 buffer 状态
    streamBufferRef.current = '';
    streamEndedRef.current = false;
    startTypewriter();

    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages: [...messages, userMsg] }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() ?? '';

      for (const line of lines) {
        if (!line.trim()) continue;
        try {
          const data = JSON.parse(line.trim());
          if (data.content) {
            // 生产者:往 buffer 追加,不直接更新 UI
            streamBufferRef.current += data.content;
          }
        } catch {}
      }
    }

    // 标记流结束,typewriter 会在 buffer 消费完后自动停止
    streamEndedRef.current = true;
  }, [messages, startTypewriter]);

  return { messages, streaming, sendMessage };
}

关键参数调优

参数 推荐值 效果
TICK_MS 16-30ms 越小越快,16ms ≈ 60fps,24ms 更均匀
CHARS_PER_TICK 1-3 1 个字最像手打,2-3 个更快但仍流畅

你可以根据场景调整:

  • 正式聊天界面TICK_MS=24, CHARS_PER_TICK=2(稳定流畅)
  • 代码生成场景TICK_MS=16, CHARS_PER_TICK=5(代码输出量大,需要更快)
  • 打字机感最强TICK_MS=40, CHARS_PER_TICK=1(慢速逐字,像真人在打字)

用 requestAnimationFrame 替代 setInterval

如果你追求更流畅的渲染,可以用 requestAnimationFrame(RAF)替代 setInterval

const startTypewriterRAF = () => {
  let lastTime = 0;

  const tick = (currentTime) => {
    if (currentTime - lastTime < TICK_MS) {
      rafIdRef.current = requestAnimationFrame(tick);
      return;
    }
    lastTime = currentTime;

    const buf = streamBufferRef.current;
    if (buf.length === 0) {
      if (streamEndedRef.current) {
        setStreaming(false);
        return; // 不再调度下一帧
      }
      rafIdRef.current = requestAnimationFrame(tick);
      return;
    }

    const take = Math.min(CHARS_PER_TICK, buf.length);
    const chars = buf.slice(0, take);
    streamBufferRef.current = buf.slice(take);

    setMessages(prev => {
      const next = [...prev];
      const last = next[next.length - 1];
      if (last?.role === 'assistant') {
        next[next.length - 1] = { ...last, content: last.content + chars };
      }
      return next;
    });

    rafIdRef.current = requestAnimationFrame(tick);
  };

  rafIdRef.current = requestAnimationFrame(tick);
};

RAF 的优势:

  • 和浏览器刷新频率同步,不会出现 setInterval 的掉帧问题
  • 页面不可见时自动暂停,节省性能
  • 与渲染管线对齐,避免不必要的中间帧

为什么不直接每个 token 更新 UI?

对比一下两种方案的效果:

方案 直接更新 Typewriter Buffer
流畅度 忽快忽慢,像"结巴" 匀速流畅,像打字机
渲染频率 取决于网络,可能每秒 200+ 次 固定 ~42 次/秒
性能 高频 setState 可能卡顿 可控,不会压垮 React
网络突发 一下蹦出一大段 均匀释放,无跳跃
网络卡顿 UI 也跟着卡 buffer 有余量,UI 继续流畅

在我自己的项目中实测,Typewriter Buffer 模式的用户满意度明显更高——大家会觉得"AI 回复很稳"。


常见坑和解决方案

1. SSE 行被截断

网络传输中,一个 data: {...} 行可能被拆成两个 chunk 到达。直接 JSON.parse 会报错。

解决方案——用 buffer 拼接:

let buffer = '';

function processChunk(text) {
  buffer += text;
  const lines = buffer.split('\n');
  buffer = lines.pop(); // 最后一行可能不完整,留到下次

  for (const line of lines) {
    if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
    try {
      const data = JSON.parse(line.slice(6));
      const content = data.choices[0]?.delta?.content;
      if (content) onContent(content);
    } catch (e) {
      // 真的解析失败了,记录日志
      console.warn('SSE parse error:', line);
    }
  }
}

2. 用户中途取消

用户可能在 AI 回复到一半的时候想取消。用 AbortController

const controller = new AbortController();

// 发起请求
fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ messages }),
  signal: controller.signal, // 传入 signal
});

// 用户点击"停止生成"按钮
function handleStop() {
  controller.abort();
  setIsStreaming(false);
}

后端 Python 侧也要处理客户端断开:

def generate():
    stream = client.chat.completions.create(
        model="deepseek-chat", messages=messages, stream=True,
    )
    try:
        for chunk in stream:
            content = chunk.choices[0].delta.content
            if content:
                data = json.dumps({"choices": [{"delta": {"content": content}}]})
                yield f"data: {data}\n\n"
    except GeneratorExit:
        stream.close()  # 客户端断开时关闭上游流
    yield "data: [DONE]\n\n"

3. Markdown 渲染时机

AI 的回复通常包含 Markdown(代码块、列表等)。流式输出时如果实时渲染 Markdown,可能出现半个代码块的情况。

两种策略:

  • 简单方案:流式输出时用纯文本显示,全部输出完后再渲染 Markdown。
  • 进阶方案:用增量 Markdown 渲染库(如 marked 配合 debounce),每隔 100ms 重新渲染一次。
import { marked } from 'marked';

let rawText = '';
let renderTimer = null;

function onContent(content) {
  rawText += content;
  if (!renderTimer) {
    renderTimer = setTimeout(() => {
      chatEl.innerHTML = marked.parse(rawText);
      renderTimer = null;
    }, 100);
  }
}

总结

  1. 流式输出的核心价值是用户体验——首字响应 200ms vs 干等 5 秒,体感差距巨大。
  2. SSE 协议很简单data: {...}\n\n 格式,[DONE] 结束,不需要 WebSocket。
  3. 后端只需加 stream: trueopenai SDK(JS/Python)都封装好了流式迭代。
  4. 前端用 fetch + ReadableStream 读取流数据,逐字追加到界面。
  5. React 用函数式 setState 更新最后一条消息,Vue 直接 += 响应式搞定。
  6. 生产环境注意三个坑:SSE 行截断、用户中途取消、Markdown 渲染时机。
  7. 后端用 FastAPI StreamingResponse 做 SSE 转发,API Key 不暴露给前端。

下一篇,我们进入 AI 开发中最被低估的技能——Prompt 工程。很多人觉得 Prompt 就是"随便写一句话",但实际上一个结构化的 Prompt 能把 AI 输出的稳定性和质量提升一个量级。


下一篇预告06 | Prompt 工程:前端最容易忽略的核心技能


讨论话题:你做过流式输出吗?是用 SSE 还是 WebSocket?在处理流式渲染的时候有踩过什么坑吗?评论区聊聊。

基于 OPFS 的前端缓存实践:图片与点云数据的本地持久化

2026年4月2日 11:45

前言

在现代 Web 应用中,处理大量图片和三维点云数据时,重复的网络请求会严重影响加载速度和用户体验。浏览器提供的 Origin Private File System (OPFS) 为我们带来了新的解决方案——它允许 Web 应用在用户设备上读写专属于自己的文件系统,且完全隔离于其他源,无需用户授权即可使用。

本文将分享如何利用 OPFS 封装一个缓存管理器,用于缓存图片和点云几何数据。代码基于 TypeScript 编写,适用于需要在浏览器中高效复用资源的场景。

什么是 OPFS?

OPFS 是 File System Access API 的一部分,它为 Web 应用提供了一个私有的、与源绑定的文件系统。与传统的 IndexedDB 或 localStorage 相比,OPFS 支持高性能的文件读写操作,尤其适合存储二进制大对象。它的主要特点包括:

  • 完全隔离:每个源拥有独立的文件系统,互不干扰。
  • 无需用户授权:无需弹出权限请求。
  • 同步访问(在 Worker 中):支持同步 API,可大幅提升性能。
  • 持久化存储:数据会一直保留,除非用户手动清除。

设计目标

我们需要一个缓存系统,能够:

  1. 缓存从网络加载的图片(Blob 格式)。
  2. 缓存点云几何数据,包括位置、法线、颜色、强度、标签等属性(以 TypedArray 形式存储)。
  3. 支持基于 URL 的缓存键,确保同一资源只存一份。
  4. 提供简单的读写接口,屏蔽 OPFS 的复杂操作。

整体架构

缓存目录结构如下:

label-flow-cache/
├── images/
│   ├── <key>.bin          # 图片二进制数据
│   └── <key>.meta.json    # 图片元信息(如 MIME 类型)
└── pointClouds/
    ├── <key>/             # 每个点云数据一个子目录
    │   ├── meta.json      # 点云元信息(包含哪些属性、范围等)
    │   ├── position.bin   # 位置数组(Float32Array)
    │   ├── normal.bin     # 法线数组(可选)
    │   ├── color.bin      # 颜色数组(可选)
    │   ├── intensity.bin  # 强度数组(可选)
    │   └── label.bin      # 标签数组(可选)

缓存键的生成策略:从 URL 中提取 pathname + search + hash,然后计算 SHA-256 哈希作为最终键名。这样可以保证键名长度固定且唯一。

核心代码解析

1. 单例模式

export class OPFSCache {
  private static instance: OPFSCache;
  private constructor() {}

  public static getInstance(): OPFSCache {
    if (!OPFSCache.instance) {
      OPFSCache.instance = new OPFSCache();
    }
    return OPFSCache.instance;
  }
}

确保全局只有一个缓存实例,避免重复初始化。

2. 目录初始化

private async ensureCacheRootDir(): Promise<FileSystemDirectoryHandle | null> {
  if (typeof window === 'undefined') return null;
  const root = await getOPFSRoot(); // 外部提供的获取 OPFS 根句柄的函数
  if (!root) return null;
  try {
    return await root.getDirectoryHandle('label-flow-cache', { create: true });
  } catch {
    return null;
  }
}

递归获取或创建 label-flow-cache 目录,并缓存其句柄。imagespointClouds 子目录类似。

3. 缓存键生成

private async getFileKey(url: string): Promise<string> {
  const rawKey = getOPFScacheKey(url); // 提取 pathname + search + hash
  const hash = await this.sha256Hex(rawKey);
  if (hash) return hash;
  return encodeURIComponent(rawKey).replace(/%/g, '_').slice(0, 120);
}

优先使用 SHA-256 哈希作为文件名,如果浏览器不支持,则降级为编码后的原始键(截取前 120 个字符)。

4. 图片缓存

写入

public async setImage(url: string, blob: Blob): Promise<void> {
  const imagesDir = await this.ensureImagesDir();
  if (!imagesDir) return;
  const key = await this.getFileKey(url);
  await Promise.all([
    this.writeBlobFile(imagesDir, `${key}.bin`, blob),
    this.writeTextFile(imagesDir, `${key}.meta.json`, JSON.stringify({ type: blob.type }))
  ]);
}

将图片二进制数据和 MIME 类型分别存储。

读取

public async getImage(url: string): Promise<Blob | null> {
  const imagesDir = await this.ensureImagesDir();
  if (!imagesDir) return null;
  const key = await this.getFileKey(url);
  const metaText = await this.readTextFile(imagesDir, `${key}.meta.json`);
  const meta = metaText ? JSON.parse(metaText) : null;
  return await this.readFileBlob(imagesDir, `${key}.bin`, meta?.type);
}

读取元数据获取类型,然后读取二进制文件返回 Blob。

5. 点云缓存

数据结构定义

export interface PointCloudGeometryData {
  position?: Float32Array;
  normal?: Float32Array;
  color?: Float32Array;
  intensity?: Float32Array;
  label?: Int32Array;
  boundingSphere?: { center: [number, number, number]; radius: number };
  heightRange?: { min: number; max: number };
  intensityRange?: { min: number; max: number };
}

写入

public async setPointCloudGeometry(url: string, geometryData: PointCloudGeometryData): Promise<void> {
  const pointCloudsDir = await this.ensurePointCloudsDir();
  if (!pointCloudsDir) return;
  const key = await this.getFileKey(url);
  // 先删除旧目录(如果有)
  await pointCloudsDir.removeEntry(key, { recursive: true }).catch(() => {});
  const pcDir = await pointCloudsDir.getDirectoryHandle(key, { create: true });

  const meta: PointCloudMeta = {
    has: {
      position: !!geometryData.position,
      normal: !!geometryData.normal,
      color: !!geometryData.color,
      intensity: !!geometryData.intensity,
      label: !!geometryData.label,
    },
    boundingSphere: geometryData.boundingSphere,
    heightRange: geometryData.heightRange,
    intensityRange: geometryData.intensityRange,
  };

  const tasks: Promise<void>[] = [this.writeTextFile(pcDir, 'meta.json', JSON.stringify(meta))];

  if (geometryData.position) {
    tasks.push(this.writeBufferFile(pcDir, 'position.bin', this.copyViewToArrayBuffer(geometryData.position)));
  }
  // ... 其他属性类似

  await Promise.all(tasks);
}

为每个点云数据创建一个子目录,将元信息和各个属性分别存储为独立文件。注意写入前会删除旧目录,保证数据一致性。

读取

public async getPointCloudGeometry(url: string): Promise<PointCloudGeometryData | null> {
  const pointCloudsDir = await this.ensurePointCloudsDir();
  if (!pointCloudsDir) return null;
  const key = await this.getFileKey(url);
  let pcDir: FileSystemDirectoryHandle;
  try {
    pcDir = await pointCloudsDir.getDirectoryHandle(key);
  } catch {
    return null;
  }

  const metaText = await this.readTextFile(pcDir, 'meta.json');
  if (!metaText) return null;
  const meta = JSON.parse(metaText) as PointCloudMeta;

  const geometryData: PointCloudGeometryData = {
    boundingSphere: meta.boundingSphere,
    heightRange: meta.heightRange,
    intensityRange: meta.intensityRange,
  };

  if (meta.has.position) {
    const buf = await this.readFileBuffer(pcDir, 'position.bin');
    if (!buf) return null;
    geometryData.position = new Float32Array(buf);
  }
  // ... 其他属性类似

  return geometryData;
}

根据元信息动态读取对应文件,还原成 TypedArray。

6. 辅助方法

  • copyViewToArrayBuffer:将 TypedArray 的数据复制到一个新的 ArrayBuffer,避免共享底层内存带来的潜在问题。
  • writeBlobFile / writeTextFile / writeBufferFile:封装 OPFS 的写入操作。
  • readFileBlob / readTextFile / readFileBuffer:封装 OPFS 的读取操作。

完整代码

以下是经过适当脱敏(例如将示例中的 URL 处理函数替换为占位符)的完整代码。

export async function getOPFSRoot(): Promise<FileSystemDirectoryHandle | null> {
    const storage: any = navigator.storage
    if (!storage?.getDirectory) return null
    try {
        return (await storage.getDirectory()) as FileSystemDirectoryHandle
    } catch {
        return null
    }
}


export interface PointCloudGeometryData {
  position?: Float32Array;
  normal?: Float32Array;
  color?: Float32Array;
  intensity?: Float32Array;
  label?: Int32Array;
  boundingSphere?: {
    center: [number, number, number];
    radius: number;
  };
  heightRange?: {
    min: number;
    max: number;
  };
  intensityRange?: {
    min: number;
    max: number;
  };
}

export function getOPFScacheKey(src: string) {
  try {
    const url = new URL(src);
    return `${url.pathname}${url.search}${url.hash}`;
  } catch {
    return `${src}`;
  }
}

type ImageMeta = {
  type?: string;
};

type PointCloudMeta = {
  has: {
    position?: boolean;
    normal?: boolean;
    color?: boolean;
    intensity?: boolean;
    label?: boolean;
  };
  boundingSphere?: PointCloudGeometryData['boundingSphere'];
  heightRange?: PointCloudGeometryData['heightRange'];
  intensityRange?: PointCloudGeometryData['intensityRange'];
};

export class OPFSCache {
  private static instance: OPFSCache;

  private constructor() {}

  public static getInstance(): OPFSCache {
    if (!OPFSCache.instance) {
      OPFSCache.instance = new OPFSCache();
    }
    return OPFSCache.instance;
  }

  public async init(): Promise<void> {
    await this.ensureCacheRootDir();
  }

  private async ensureCacheRootDir(): Promise<FileSystemDirectoryHandle | null> {
    if (typeof window === 'undefined') return null;
    const root = await getOPFSRoot();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('label-flow-cache', { create: true });
    } catch {
      return null;
    }
  }

  private async ensureImagesDir(): Promise<FileSystemDirectoryHandle | null> {
    const root = await this.ensureCacheRootDir();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('images', { create: true });
    } catch {
      return null;
    }
  }

  private async ensurePointCloudsDir(): Promise<FileSystemDirectoryHandle | null> {
    const root = await this.ensureCacheRootDir();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('pointClouds', { create: true });
    } catch {
      return null;
    }
  }

  private async sha256Hex(input: string): Promise<string | null> {
    const subtle = globalThis.crypto?.subtle;
    if (!subtle) return null;
    try {
      const data = new TextEncoder().encode(input);
      const digest = await subtle.digest('SHA-256', data);
      return Array.from(new Uint8Array(digest))
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('');
    } catch {
      return null;
    }
  }

  private async getFileKey(url: string): Promise<string> {
    const rawKey = getOPFScacheKey(url);
    const hash = await this.sha256Hex(rawKey);
    if (hash) return hash;
    return encodeURIComponent(rawKey).replace(/%/g, '_').slice(0, 120);
  }

  private async tryGetFileHandle(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<FileSystemFileHandle | null> {
    try {
      return await dir.getFileHandle(name);
    } catch {
      return null;
    }
  }

  private async writeBlobFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    blob: Blob
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  }

  private async writeTextFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    text: string
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(text);
    await writable.close();
  }

  private async writeBufferFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    buffer: ArrayBuffer
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(buffer);
    await writable.close();
  }

  private copyViewToArrayBuffer(view: ArrayBufferView): ArrayBuffer {
    const arrayBuffer = new ArrayBuffer(view.byteLength);
    new Uint8Array(arrayBuffer).set(
      new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
    );
    return arrayBuffer;
  }

  private async readTextFile(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<string | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      return await file.text();
    } catch {
      return null;
    }
  }

  private async readFileBlob(
    dir: FileSystemDirectoryHandle,
    name: string,
    type?: string
  ): Promise<Blob | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      const blobType = type || file.type;
      return file.slice(0, file.size, blobType);
    } catch {
      return null;
    }
  }

  private async readFileBuffer(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<ArrayBuffer | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      return await file.arrayBuffer();
    } catch {
      return null;
    }
  }

  public async getImage(url: string): Promise<Blob | null> {
    try {
      const imagesDir = await this.ensureImagesDir();
      if (!imagesDir) return null;

      const key = await this.getFileKey(url);

      const metaText = await this.readTextFile(imagesDir, `${key}.meta.json`);
      const meta: ImageMeta | null = metaText ? JSON.parse(metaText) : null;

      return await this.readFileBlob(imagesDir, `${key}.bin`, meta?.type);
    } catch (error) {
      console.warn('读取图片缓存失败:', error);
      return null;
    }
  }

  public async setImage(url: string, blob: Blob): Promise<void> {
    try {
      const imagesDir = await this.ensureImagesDir();
      if (!imagesDir) return;

      const key = await this.getFileKey(url);
      await Promise.all([
        this.writeBlobFile(imagesDir, `${key}.bin`, blob),
        this.writeTextFile(
          imagesDir,
          `${key}.meta.json`,
          JSON.stringify({ type: blob.type } satisfies ImageMeta)
        ),
      ]);
    } catch (error) {
      console.warn('写入图片缓存失败:', error);
    }
  }

  public async getPointCloudGeometry(url: string): Promise<PointCloudGeometryData | null> {
    try {
      const pointCloudsDir = await this.ensurePointCloudsDir();
      if (!pointCloudsDir) return null;

      const key = await this.getFileKey(url);
      let pcDir: FileSystemDirectoryHandle;
      try {
        pcDir = await pointCloudsDir.getDirectoryHandle(key);
      } catch {
        return null;
      }

      const metaText = await this.readTextFile(pcDir, 'meta.json');
      if (!metaText) return null;
      const meta = JSON.parse(metaText) as PointCloudMeta;

      const geometryData: PointCloudGeometryData = {
        boundingSphere: meta.boundingSphere,
        heightRange: meta.heightRange,
        intensityRange: meta.intensityRange,
      };

      if (meta.has.position) {
        const buf = await this.readFileBuffer(pcDir, 'position.bin');
        if (!buf) return null;
        geometryData.position = new Float32Array(buf);
      }

      if (meta.has.normal) {
        const buf = await this.readFileBuffer(pcDir, 'normal.bin');
        if (!buf) return null;
        geometryData.normal = new Float32Array(buf);
      }

      if (meta.has.color) {
        const buf = await this.readFileBuffer(pcDir, 'color.bin');
        if (!buf) return null;
        geometryData.color = new Float32Array(buf);
      }

      if (meta.has.intensity) {
        const buf = await this.readFileBuffer(pcDir, 'intensity.bin');
        if (!buf) return null;
        geometryData.intensity = new Float32Array(buf);
      }

      if (meta.has.label) {
        const buf = await this.readFileBuffer(pcDir, 'label.bin');
        if (!buf) return null;
        geometryData.label = new Int32Array(buf);
      }

      return geometryData;
    } catch (error) {
      console.warn('读取点云缓存失败:', error);
      return null;
    }
  }

  public async setPointCloudGeometry(
    url: string,
    geometryData: PointCloudGeometryData
  ): Promise<void> {
    try {
      const pointCloudsDir = await this.ensurePointCloudsDir();
      if (!pointCloudsDir) return;

      const key = await this.getFileKey(url);
      await pointCloudsDir.removeEntry(key, { recursive: true }).catch(() => {});
      const pcDir = await pointCloudsDir.getDirectoryHandle(key, { create: true });

      const meta: PointCloudMeta = {
        has: {
          position: !!geometryData.position,
          normal: !!geometryData.normal,
          color: !!geometryData.color,
          intensity: !!geometryData.intensity,
          label: !!geometryData.label,
        },
        boundingSphere: geometryData.boundingSphere,
        heightRange: geometryData.heightRange,
        intensityRange: geometryData.intensityRange,
      };

      const tasks: Promise<void>[] = [
        this.writeTextFile(pcDir, 'meta.json', JSON.stringify(meta)),
      ];

      if (geometryData.position) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'position.bin',
            this.copyViewToArrayBuffer(geometryData.position)
          )
        );
      }

      if (geometryData.normal) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'normal.bin',
            this.copyViewToArrayBuffer(geometryData.normal)
          )
        );
      }

      if (geometryData.color) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'color.bin',
            this.copyViewToArrayBuffer(geometryData.color)
          )
        );
      }

      if (geometryData.intensity) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'intensity.bin',
            this.copyViewToArrayBuffer(geometryData.intensity)
          )
        );
      }

      if (geometryData.label) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'label.bin',
            this.copyViewToArrayBuffer(geometryData.label)
          )
        );
      }

      await Promise.all(tasks);
    } catch (error) {
      console.warn('写入点云缓存失败:', error);
    }
  }
}

export const opfsCache = OPFSCache.getInstance();

使用示例

// 初始化(建议在应用启动时调用)
await opfsCache.init();

// 缓存图片
const response = await fetch('https://example.com/image.jpg');
const blob = await response.blob();
await opfsCache.setImage('https://example.com/image.jpg', blob);

// 获取图片
const cachedBlob = await opfsCache.getImage('https://example.com/image.jpg');

// 缓存点云数据
const geometry = {
  position: new Float32Array([...]),
  color: new Float32Array([...]),
  // ...
};
await opfsCache.setPointCloudGeometry('https://example.com/cloud.pcd', geometry);

// 获取点云数据
const cachedGeometry = await opfsCache.getPointCloudGeometry('https://example.com/cloud.pcd');

总结与注意事项

  • 性能优势:OPFS 提供了接近本地文件系统的读写速度,远优于 IndexedDB 的随机访问性能。
  • 存储容量:OPFS 的存储限制通常与浏览器分配给网站的总存储空间一致(一般较大),但具体取决于浏览器实现。
  • 兼容性:OPFS 在现代浏览器(Chrome 86+、Edge 86+、Safari 15.2+)中得到广泛支持,但在旧版本浏览器中需要降级方案。
  • 数据清理:由于数据存储在用户的私密空间中,开发者无需担心隐私问题。但需要注意及时清理无用缓存,避免占用过多磁盘空间。
  • 错误处理:代码中已经添加了 try-catch,保证了缓存操作失败时不会影响主业务流程。

通过 OPFS,我们可以轻松实现前端高性能缓存,为图片密集型和点云应用带来质的飞跃。希望本文能为大家提供一些实用的思路和代码参考。

用OpenClaw实现小红书自动发布:从零到一的完整技术方案

作者 迈巧克力
2026年4月2日 11:37

用OpenClaw实现小红书自动发布:从零到一的完整技术方案

前言

作为一名技术博主,我一直在探索如何提高内容创作效率。最近我用OpenClaw实现了小红书的自动发布功能,从内容生成到发布上线,整个过程完全自动化,今天分享完整的技术实现。

技术架构

整个系统分为三个核心模块:

1. 内容生成模块

使用AI大模型(GLM-5/Claude)生成高质量内容:

// 内容生成
async function generateContent(topic) {
  const prompt = `
  主题:${topic}
  要求:
  - 标题15字以内,吸引眼球
  - 正文500-800字
  - 包含3-5个话题标签
  - 小红书风格(轻松、生活化)
  `;
  
  const content = await ai.generate(prompt);
  
  return {
    title: extractTitle(content),
    body: extractBody(content),
    tags: extractTags(content)
  };
}

2. 浏览器自动化模块

使用Playwright进行浏览器控制:

// 浏览器自动化
async function publishToXiaohongshu(content) {
  // Step 1: 启动浏览器
  const browser = await playwright.chromium.launch({
    headless: false,
    userDataDir: './user-data' // 保持登录状态
  });
  
  const page = await browser.newPage();
  
  // Step 2: 打开创作者中心
  await page.goto('https://creator.xiaohongshu.com/');
  
  // Step 3: 检查登录状态
  const isLoggedIn = await checkLogin(page);
  if (!isLoggedIn) {
    throw new Error('需要先登录');
  }
  
  // Step 4: 点击发布按钮
  await page.click('[aria-label="发布笔记"]');
  await page.waitForTimeout(2000);
  
  // Step 5: 选择"文字配图"
  await page.click('text=文字配图');
  
  // Step 6: 填写内容
  await fillContent(page, content);
  
  // Step 7: 添加话题标签
  await addTopics(page, content.tags);
  
  // Step 8: 发布
  await page.click('button:has-text("发布")');
  
  return { success: true };
}

3. 话题标签自动化

这是最关键的技术难点。小红书的话题标签需要特殊处理:

// 话题标签转换(核心技术)
async function convertTopicsToClickable(page, topics) {
  // Step 1: 添加纯文本话题
  const editor = await page.$('[contenteditable=true]');
  const topicsText = topics.map(t => `#${t}`).join(' ');
  await editor.type(` ${topicsText}`);
  
  // Step 2: 逐个转换为可点击格式
  for (const topic of topics) {
    // 2.1 点击话题最后一个字后面
    await clickAfterTopic(page, topic);
    
    // 2.2 等待tooltip弹出
    await page.waitForTimeout(1500);
    
    // 2.3 双击tooltip父元素(关键!)
    const tooltip = await page.$('[role="tooltip"]');
    const parent = await tooltip.$('xpath=..');
    await parent.dblclick();
    
    // 2.4 等待转换完成
    await page.waitForTimeout(1000);
  }
}

核心技术难点

难点1:登录状态持久化

问题:每次启动浏览器都需要重新登录

解决方案:使用userDataDir参数保存浏览器数据

const browser = await playwright.chromium.launch({
  userDataDir: './user-data' // 关键!保存登录状态
});

首次登录后,Cookie会自动保存,后续无需再次登录。

难点2:话题标签转换

问题:纯文本的#话题无法点击,需要转换为可点击格式

解决方案

  1. 先添加纯文本:#小红书运营 #自动化
  2. 逐个点击话题最后一个字后面
  3. 等待tooltip弹出
  4. 双击tooltip父元素(不是子元素!)

关键代码

// 精确定位到话题最后一个字后面
async function clickAfterTopic(page, topic) {
  await page.evaluate((topicName) => {
    const editor = document.querySelector('[contenteditable=true]');
    const textNodes = Array.from(editor.querySelectorAll('p'))
      .flatMap(p => Array.from(p.childNodes))
      .filter(n => n.nodeType === 3);
    
    const textNode = textNodes.find(n => 
      n.textContent.includes(`#${topicName}`)
    );
    
    if (textNode) {
      const text = textNode.textContent;
      const index = text.indexOf(topicName) + topicName.length;
      
      // 创建Range并定位到最后一个字后面
      const range = document.createRange();
      range.setStart(textNode, index);
      range.collapse(true);
      
      // 触发点击
      const rect = range.getBoundingClientRect();
      textNode.dispatchEvent(new MouseEvent('click', {
        bubbles: true,
        clientX: rect.left,
        clientY: rect.top + rect.height / 2
      }));
    }
  }, topic);
}

难点3:文字配图生成

小红书的"文字配图"功能可以自动生成封面:

// 生成文字配图
async function generateCoverImage(page, text) {
  // 点击"文字配图"
  await page.click('text=文字配图');
  await page.waitForTimeout(1000);
  
  // 输入封面文字(3-4行,每行5-10字)
  const coverText = text.split('\n').slice(0, 4).join('\n');
  await page.type('[placeholder*="输入内容"]', coverText);
  
  // 等待生成
  await page.waitForTimeout(2000);
  
  // 选择风格(可选)
  await page.click('text=清新'); // 或其他风格
}

完整工作流程

用户输入主题
    ↓
AI生成内容(标题+正文+标签)
    ↓
启动浏览器(保持登录)
    ↓
打开小红书创作者中心
    ↓
点击"发布笔记"
    ↓
选择"文字配图"
    ↓
填写标题和正文
    ↓
生成封面图
    ↓
添加并转换话题标签
    ↓
点击"发布"
    ↓
验证发布成功
    ↓
返回笔记链接

性能优化

1. 并发控制

避免短时间内大量发布:

// 使用队列控制发布频率
class PublishQueue {
  constructor(interval = 5 * 60 * 1000) { // 5分钟间隔
    this.queue = [];
    this.interval = interval;
    this.processing = false;
  }
  
  async add(task) {
    this.queue.push(task);
    if (!this.processing) {
      await this.process();
    }
  }
  
  async process() {
    this.processing = true;
    while (this.queue.length > 0) {
      const task = this.queue.shift();
      await task();
      await new Promise(r => setTimeout(r, this.interval));
    }
    this.processing = false;
  }
}

2. 错误重试

网络问题导致的失败自动重试:

async function publishWithRetry(content, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await publishToXiaohongshu(content);
    } catch (error) {
      console.error(`尝试 ${i + 1} 失败:`, error);
      if (i === maxRetries - 1) throw error;
      await new Promise(r => setTimeout(r, 5000)); // 等待5秒
    }
  }
}

实战效果

我用这个系统发布了20+篇小红书笔记:

📊 数据统计

  • 平均发布时间:5-8分钟/篇
  • 发布成功率:95%+
  • 人工干预次数:<5%
  • 最高阅读量:8.3万
  • 平均阅读量:1.2万

💰 ROI分析

  • 开发时间:约20小时
  • 节省时间:每篇节省20分钟
  • 20篇节省:6.7小时
  • 如果发布100篇:节省33小时

部署建议

1. 环境配置

# 安装依赖
npm install playwright
npm install openai  # 或其他AI SDK

# 安装浏览器
npx playwright install chromium

2. 配置文件

{
  "ai": {
    "provider": "openai",
    "model": "gpt-4",
    "apiKey": "your-api-key"
  },
  "xiaohongshu": {
    "userDataDir": "./user-data",
    "publishInterval": 300000,
    "maxRetries": 3
  }
}

3. 运行方式

# 命令行
node publish.js --topic "OpenClaw自动化实战"

# 或通过API
curl -X POST http://localhost:3000/publish \
  -H "Content-Type: application/json" \
  -d '{"topic": "OpenClaw自动化实战"}'

注意事项

1. 账号安全

  • ✅ 使用独立的浏览器Profile
  • ✅ 不要短时间内大量发布
  • ✅ 保持人工发布的比例(建议80%自动+20%人工)
  • ✅ 内容必须原创或深度改写

2. 内容合规

  • ✅ 避免敏感词汇
  • ✅ 遵守平台规则
  • ✅ 确保内容质量
  • ✅ 定期检查发布结果

3. 技术维护

  • ✅ 定期更新选择器(小红书页面可能变化)
  • ✅ 监控发布成功率
  • ✅ 备份用户数据目录
  • ✅ 记录发布日志

扩展功能

1. 定时发布

// 使用cron定时发布
const cron = require('node-cron');

// 每天上午10点自动发布
cron.schedule('0 10 * * *', async () => {
  const topic = generateDailyTopic();
  await publishWithRetry({ topic });
});

2. 多账号管理

// 支持多个小红书账号
class MultiAccountPublisher {
  constructor(accounts) {
    this.accounts = accounts; // [{userDataDir, name}]
    this.currentIndex = 0;
  }
  
  async publish(content) {
    const account = this.accounts[this.currentIndex];
    await publishToXiaohongshu(content, account.userDataDir);
    
    // 轮换账号
    this.currentIndex = (this.currentIndex + 1) % this.accounts.length;
  }
}

3. 数据分析

// 收集发布数据
async function collectStats(noteId) {
  // 访问笔记页面
  await page.goto(`https://www.xiaohongshu.com/explore/${noteId}`);
  
  // 提取数据
  const stats = await page.evaluate(() => {
    return {
      views: extractViews(),
      likes: extractLikes(),
      comments: extractComments(),
      collects: extractCollects()
    };
  });
  
  // 保存到数据库
  await saveStats(noteId, stats);
}

总结

通过OpenClaw + Playwright实现小红书自动发布,核心技术点包括:

  1. 浏览器自动化:使用userDataDir保持登录状态
  2. 话题标签转换:精确点击+双击tooltip父元素
  3. 内容生成:AI大模型生成高质量内容
  4. 错误处理:重试机制+日志记录

这套方案已经在生产环境运行2个月,发布了100+篇笔记,效果稳定。

参考资料


💡 想要完整教程?

我把这套方案整理成了保姆级教程,包括:

  • ✅ 完整源码
  • ✅ 常见问题解答

👉 感兴趣的话可以私聊我或者评论区回复

Web3前端开发:使用ethers.js监听智能合约事件

作者 竹林818
2026年4月2日 11:10

Web3前端开发:使用ethers.js监听智能合约事件

前言

在Web3开发中,实时获取区块链上的状态变化是构建交互式DApp的关键。传统的前端轮询方式不仅效率低下,还会消耗大量API调用。本文将分享我在一个NFT铸造项目中,如何从轮询优化为事件监听,实现实时更新的完整踩坑记录。

为什么需要事件监听?

在以太坊生态中,智能合约通过事件(Events)向外广播状态变化。与轮询相比,事件监听具有以下优势:

  1. 实时性:事件触发后立即通知前端
  2. 高效性:减少不必要的RPC调用
  3. 可靠性:不会错过任何状态变化
  4. 节省成本:减少API调用次数

基础实现:轮询方式

// 传统轮询方式 - 不推荐
async function pollNFTBalance(userAddress, contract) {
  setInterval(async () => {
    const balance = await contract.balanceOf(userAddress);
    updateUI(balance);
  }, 5000); // 每5秒查询一次
}

这种方式的问题很明显:延迟高、API调用频繁、用户体验差。

优化方案:ethers.js事件监听

1. 基础事件监听

import { ethers } from 'ethers';

// 连接合约
const provider = new ethers.JsonRpcProvider('https://mainnet.infura.io/v3/YOUR_KEY');
const contract = new ethers.Contract(
  CONTRACT_ADDRESS,
  NFT_ABI,
  provider
);

// 监听Transfer事件
contract.on('Transfer', (from, to, tokenId, event) => {
  console.log(`NFT #${tokenId}${from} 转移到 ${to}`);
  updateNFTList(tokenId, to);
});

2. 过滤特定地址的事件

// 只监听与用户相关的事件
const filter = contract.filters.Transfer(null, userAddress);
contract.on(filter, (from, to, tokenId, event) => {
  console.log(`用户收到 NFT #${tokenId}`);
  addToUserCollection(tokenId);
});

3. 处理历史事件

// 获取过去24小时的事件
async function getPastEvents() {
  const blockNumber = await provider.getBlockNumber();
  const fromBlock = blockNumber - 5760; // 大约24小时前的区块
  
  const events = await contract.queryFilter('Transfer', fromBlock, blockNumber);
  events.forEach(event => {
    console.log(`历史事件: ${event.args.tokenId}`);
  });
}

实战踩坑记录

坑1:事件重复触发

问题:同一个事件被监听到多次 原因:重新连接合约时没有移除旧监听器 解决方案

let eventListeners = [];

function setupEventListeners() {
  // 先移除所有旧监听器
  eventListeners.forEach(listener => contract.off(listener));
  eventListeners = [];
  
  // 添加新监听器
  const transferListener = (from, to, tokenId) => {
    console.log(`Transfer: ${tokenId}`);
  };
  contract.on('Transfer', transferListener);
  eventListeners.push(['Transfer', transferListener]);
}

坑2:内存泄漏

问题:页面切换后监听器未清理,导致内存占用持续增长 解决方案

// 组件卸载时清理
useEffect(() => {
  const transferListener = (from, to, tokenId) => {
    // 处理事件
  };
  
  contract.on('Transfer', transferListener);
  
  return () => {
    contract.off('Transfer', transferListener);
  };
}, []);

坑3:网络切换处理

问题:用户切换网络(如从主网切换到测试网)后事件监听失效 解决方案

// 监听网络变化
provider.on('network', (newNetwork, oldNetwork) => {
  if (oldNetwork) {
    // 网络变化,重新连接合约
    setupEventListeners();
  }
});

高级技巧

1. 批量处理事件

// 使用防抖避免频繁UI更新
let eventQueue = [];
let processing = false;

contract.on('Transfer', async (from, to, tokenId) => {
  eventQueue.push({ from, to, tokenId });
  
  if (!processing) {
    processing = true;
    setTimeout(processEvents, 1000); // 1秒后批量处理
  }
});

async function processEvents() {
  if (eventQueue.length === 0) {
    processing = false;
    return;
  }
  
  const batch = [...eventQueue];
  eventQueue = [];
  
  // 批量更新UI
  await updateUIBatch(batch);
  
  processing = false;
}

2. 错误处理与重连

function setupEventListenersWithRetry() {
  try {
    contract.on('Transfer', handleTransfer);
    
    // 监听错误
    contract.on('error', (error) => {
      console.error('事件监听错误:', error);
      setTimeout(setupEventListenersWithRetry, 5000); // 5秒后重试
    });
  } catch (error) {
    console.error('设置监听器失败:', error);
    setTimeout(setupEventListenersWithRetry, 5000);
  }
}

性能优化建议

  1. 按需监听:只监听用户相关的事件
  2. 使用过滤器:减少不必要的事件处理
  3. 批量更新:避免频繁的UI重绘
  4. 清理机制:及时移除不需要的监听器
  5. 错误边界:添加适当的错误处理和重试机制

完整示例代码

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

function useNFTEventListeners(contract, userAddress) {
  const listenersRef = useRef([]);
  
  useEffect(() => {
    if (!contract || !userAddress) return;
    
    const setupListeners = () => {
      // 清理旧监听器
      listenersRef.current.forEach(([event, listener]) => {
        contract.off(event, listener);
      });
      listenersRef.current = [];
      
      // 监听用户收到的NFT
      const receivedFilter = contract.filters.Transfer(null, userAddress);
      const receivedListener = (from, to, tokenId) => {
        console.log(`收到 NFT #${tokenId}`);
        addToCollection(tokenId);
      };
      contract.on(receivedFilter, receivedListener);
      listenersRef.current.push([receivedFilter, receivedListener]);
      
      // 监听用户发送的NFT
      const sentFilter = contract.filters.Transfer(userAddress, null);
      const sentListener = (from, to, tokenId) => {
        console.log(`发送 NFT #${tokenId}`);
        removeFromCollection(tokenId);
      };
      contract.on(sentFilter, sentListener);
      listenersRef.current.push([sentFilter, sentListener]);
    };
    
    setupListeners();
    
    return () => {
      listenersRef.current.forEach(([event, listener]) => {
        contract.off(event, listener);
      });
    };
  }, [contract, userAddress]);
}

总结

从轮询到事件监听,不仅仅是技术方案的改变,更是对Web3开发理念的深入理解。通过合理使用ethers.js的事件监听功能,我们可以:

  1. 构建更实时的用户体验
  2. 显著降低API调用成本
  3. 提高应用的整体性能
  4. 减少服务器压力

希望本文的踩坑经验能帮助你在Web3开发中少走弯路。记住,好的事件监听策略是构建优秀DApp的基石。

下一步

  1. 尝试使用ethers.js的contract.once()方法处理一次性事件
  2. 探索使用The Graph等索引服务替代复杂的事件监听
  3. 考虑使用WebSocket提供商(如Alchemy)获得更好的实时性

Happy building! 🚀

不用 WebSocket 库,在 React 中构建实时功能

作者 AI划重点
2026年4月2日 10:58

一提到"实时",开发者就会想到 WebSocket 库。Socket.IO、Pusher、Ably -- 生态中有太多选择了。但很多实时功能根本不需要双向通信。股票行情、通知推送、部署日志、实时比分 -- 这些都是服务器到客户端的单向数据流。对于这类场景,浏览器有一个更简单、更轻量、还能自动重连的内置协议:Server-Sent Events(SSE)

将 SSE 与用于连接感知的 Network Information API 和用于跨标签页协调的 BroadcastChannel API 结合起来,你就拥有了一套完整的实时工具包 -- 不需要任何 WebSocket 库。本文将先从零开始手动构建每个部分,看看手动实现在哪里会遇到瓶颈,然后用 ReactUse 的 Hooks 替换,只需几行代码就能处理所有边缘情况。

1. 使用 useEventSource 接入 Server-Sent Events

什么是 Server-Sent Events?

Server-Sent Events(SSE)是一个标准协议,允许服务器通过普通 HTTP 连接向浏览器推送更新。与 WebSocket 不同,SSE 是单向的 -- 服务器发送,客户端接收。浏览器原生的 EventSource API 开箱即用,自动处理连接管理、自动重连和事件解析。

// 一个基本的 SSE 端点(服务端,仅供参考)
// GET /api/notifications
// Content-Type: text/event-stream
//
// data: {"message": "新的部署已启动"}
// id: 1
//
// data: {"message": "部署完成"}
// id: 2

手动实现

让我们在不使用任何库的情况下,在 React 中连接 SSE 端点。

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

function useManualEventSource(url: string) {
  const [data, setData] = useState<string | null>(null);
  const [status, setStatus] = useState<
    "CONNECTING" | "CONNECTED" | "DISCONNECTED"
  >("DISCONNECTED");
  const [error, setError] = useState<Event | null>(null);
  const esRef = useRef<EventSource | null>(null);
  const retriesRef = useRef(0);

  useEffect(() => {
    const connect = () => {
      setStatus("CONNECTING");
      const es = new EventSource(url);
      esRef.current = es;

      es.onopen = () => {
        setStatus("CONNECTED");
        setError(null);
        retriesRef.current = 0;
      };

      es.onmessage = (event) => {
        setData(event.data);
      };

      es.onerror = (err) => {
        setError(err);
        setStatus("DISCONNECTED");
        es.close();
        esRef.current = null;

        // 手动重连逻辑
        retriesRef.current += 1;
        if (retriesRef.current < 5) {
          setTimeout(connect, 1000 * retriesRef.current);
        }
      };
    };

    connect();

    return () => {
      esRef.current?.close();
      esRef.current = null;
    };
  }, [url]);

  return { data, status, error };
}

大约 45 行代码,而且已经存在不少问题:

  • 不支持命名事件。 SSE 支持自定义事件类型(如 event: deploy-status),但 onmessage 只能捕获未命名的消息。要支持命名事件,需要对每种事件类型调用 addEventListener,并在卸载时逐一清理。
  • 重连策略过于简陋。 代码最多重试 5 次,使用线性退避,但无法配置重试次数、延迟时间或失败回调。
  • 无法手动关闭/重新打开。 如果用户导航离开又返回,或者你想在标签页隐藏时暂停数据流,还需要更多的状态跟踪。
  • SSR 会崩溃。 EventSource 在服务端不存在。

使用 useEventSource

ReactUse 的 useEventSource Hook 把这些问题全部解决了。

import { useEventSource } from "@reactuses/core";

function DeploymentLog() {
  const { data, status, error, event, lastEventId, close, open } =
    useEventSource("/api/deployments/stream", ["deploy-start", "deploy-end"], {
      autoReconnect: {
        retries: 5,
        delay: 2000,
        onFailed: () => console.error("SSE 连接彻底失败"),
      },
    });

  return (
    <div>
      <div>
        状态:{status}
        {status === "DISCONNECTED" && (
          <button onClick={open}>重新连接</button>
        )}
        {status === "CONNECTED" && (
          <button onClick={close}>断开连接</button>
        )}
      </div>

      {error && <div className="error">连接发生错误</div>}

      <div className="log-entry">
        <span className="event-type">{event}</span>
        <span className="event-id">#{lastEventId}</span>
        <pre>{data}</pre>
      </div>
    </div>
  );
}

看看你免费获得了什么:

  • 命名事件支持。 第二个参数传入事件名数组,Hook 会监听每一个。event 返回值告诉你触发的是哪种事件类型。
  • 可配置的自动重连。 设置重试次数、重试间隔,以及所有重试耗尽时的回调。
  • 手动关闭和重新打开。 调用 close() 断开连接,open() 重新连接 -- 非常适合在后台标签页中暂停数据流。
  • SSR 安全。 Hook 会防范服务端 EventSource 未定义的情况。
  • Last Event ID 追踪。 lastEventId 让你可以从上次断开的位置继续接收(如果服务器支持的话)。

实际示例:实时通知流

import { useEventSource } from "@reactuses/core";
import { useState, useEffect } from "react";

interface Notification {
  id: string;
  title: string;
  body: string;
  severity: "info" | "warning" | "error";
}

function NotificationFeed() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const { data, status, event } = useEventSource(
    "/api/notifications/stream",
    ["info", "warning", "error"],
    {
      autoReconnect: {
        retries: -1, // 无限重试
        delay: 3000,
      },
    }
  );

  useEffect(() => {
    if (data) {
      try {
        const notification: Notification = {
          ...JSON.parse(data),
          severity: event as Notification["severity"],
        };
        setNotifications((prev) => [notification, ...prev].slice(0, 50));
      } catch {
        // 数据格式错误,忽略
      }
    }
  }, [data, event]);

  return (
    <div>
      <h2>
        实时通知
        <span className={`status-dot status-${status.toLowerCase()}`} />
      </h2>
      {notifications.map((n) => (
        <div key={n.id} className={`notification notification-${n.severity}`}>
          <strong>{n.title}</strong>
          <p>{n.body}</p>
        </div>
      ))}
    </div>
  );
}

Hook 管理 SSE 的整个生命周期,你的组件只需要关心数据解析和 UI 渲染。

2. 使用 useFetchEventSource 接入需要认证的 SSE 流

原生 EventSource 的局限

原生 EventSource API 有一个重大限制:无法设置自定义请求头。这意味着不能发送 Authorization: Bearer <token>,不能添加自定义 X-Request-ID,也不能发起带 body 的 POST 请求。如果你的 SSE 端点需要认证,EventSource 就不够用了。

常见的变通方案是把 token 放到查询参数中(/api/stream?token=abc),但这会将凭证泄露到服务器日志、浏览器历史记录和 referrer 头中。这是一种安全反模式。

手动实现

要在 SSE 风格的连接中发送自定义请求头,你需要使用 fetch 配合可读流 -- 然后自己处理分块解析、重连和 abort 信号。

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

function useManualFetchSSE(url: string, token: string) {
  const [data, setData] = useState<string | null>(null);
  const [status, setStatus] = useState<string>("DISCONNECTED");
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    abortRef.current = controller;
    setStatus("CONNECTING");

    const connect = async () => {
      try {
        const response = await fetch(url, {
          headers: {
            Authorization: `Bearer ${token}`,
            Accept: "text/event-stream",
          },
          signal: controller.signal,
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        if (!response.body) throw new Error("No response body");

        setStatus("CONNECTED");
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = "";

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          buffer += decoder.decode(value, { stream: true });
          const lines = buffer.split("\n\n");
          buffer = lines.pop() || "";

          for (const chunk of lines) {
            const dataLine = chunk
              .split("\n")
              .find((l) => l.startsWith("data: "));
            if (dataLine) {
              setData(dataLine.slice(6));
            }
          }
        }
      } catch (err) {
        if (!controller.signal.aborted) {
          setStatus("DISCONNECTED");
          // 重连逻辑写在这里...
        }
      }
    };

    connect();
    return () => controller.abort();
  }, [url, token]);

  return { data, status };
}

已经超过 55 行了,而且还不完整。它不处理命名事件、事件 ID、带退避的重连,也不支持 POST 请求。手动解析 SSE 文本协议容易出错。

使用 useFetchEventSource

ReactUse 的 useFetchEventSource Hook 封装了 @microsoft/fetch-event-source 库,提供了 React 友好的 API。它支持自定义请求头、POST 请求体,以及你需要的所有重连逻辑。

import { useFetchEventSource } from "@reactuses/core";

function AuthenticatedStream() {
  const { data, status, event, error, close, open } = useFetchEventSource(
    "/api/private/stream",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${getAccessToken()}`,
        "X-Request-ID": crypto.randomUUID(),
      },
      body: JSON.stringify({
        channels: ["deployments", "alerts"],
      }),
      autoReconnect: {
        retries: 10,
        delay: 2000,
        onFailed: () => {
          // Token 可能已过期 -- 重定向到登录页
          window.location.href = "/login";
        },
      },
      onOpen: () => console.log("数据流已连接"),
      onError: (err) => {
        console.error("数据流错误:", err);
        return 5000; // 5 秒后重试
      },
    }
  );

  return (
    <div>
      <div>连接状态:{status}</div>
      {error && <div className="error">{error.message}</div>}
      <pre>{data}</pre>
    </div>
  );
}

两个 Hook 的核心区别:

特性 useEventSource useFetchEventSource
自定义请求头 不支持 支持
POST 请求 不支持 支持
请求体 不支持 支持
底层技术 原生 EventSource fetch API
自动重连 支持 支持
命名事件 支持(通过数组) 支持(通过 event 字段)

当端点是公开的或使用 cookie 认证时,用 useEventSource。当你需要 token 认证、自定义请求头或 POST 请求时,用 useFetchEventSource

实际示例:AI 聊天流式响应

SSE 是流式 AI 响应的标准协议(OpenAI、Anthropic 等都在使用)。以下是如何用认证构建流式聊天 UI。

import { useFetchEventSource } from "@reactuses/core";
import { useState, useEffect, useCallback } from "react";

function AIChatStream() {
  const [messages, setMessages] = useState<
    Array<{ role: string; content: string }>
  >([]);
  const [input, setInput] = useState("");
  const [streamedResponse, setStreamedResponse] = useState("");

  const { data, status, open, close } = useFetchEventSource(
    "/api/chat/completions",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${getApiKey()}`,
      },
      body: JSON.stringify({
        messages,
        stream: true,
      }),
      immediate: false, // 不在挂载时连接
      onOpen: () => setStreamedResponse(""),
    }
  );

  // 累积流式传输的 token
  useEffect(() => {
    if (data) {
      try {
        const parsed = JSON.parse(data);
        const token = parsed.choices?.[0]?.delta?.content;
        if (token) {
          setStreamedResponse((prev) => prev + token);
        }
      } catch {
        // 忽略 [DONE] 或格式错误的数据块
      }
    }
  }, [data]);

  const sendMessage = useCallback(() => {
    if (!input.trim()) return;
    setMessages((prev) => [...prev, { role: "user", content: input }]);
    setInput("");
    open(); // 启动 SSE 数据流
  }, [input, open]);

  return (
    <div className="chat">
      {messages.map((msg, i) => (
        <div key={i} className={`message message-${msg.role}`}>
          {msg.content}
        </div>
      ))}
      {streamedResponse && (
        <div className="message message-assistant">{streamedResponse}</div>
      )}
      <div className="input-row">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && sendMessage()}
          placeholder="输入消息..."
        />
        <button onClick={sendMessage} disabled={status === "CONNECTING"}>
          发送
        </button>
      </div>
    </div>
  );
}

这里 immediate: false 选项至关重要 -- 我们不希望在组件挂载时就打开连接,而是在用户发送消息时显式调用 open()

3. 使用 useNetwork 和 useOnline 检测网络状态

如果用户离线了,实时功能就毫无用处。更糟糕的是,它们会静默失败 -- SSE 连接断开,fetch 请求挂起,UI 显示过时数据,却没有任何提示。好的实时 UI 应该具备网络感知能力。

手动实现

import { useState, useEffect } from "react";

function useManualNetworkStatus() {
  const [isOnline, setIsOnline] = useState(
    typeof navigator !== "undefined" ? navigator.onLine : true
  );
  const [connectionType, setConnectionType] = useState<string | undefined>();

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    // Network Information API(并非所有浏览器都支持)
    const conn = (navigator as any).connection;
    if (conn) {
      const handleChange = () => {
        setConnectionType(conn.effectiveType);
      };
      conn.addEventListener("change", handleChange);
      handleChange();

      return () => {
        window.removeEventListener("online", handleOnline);
        window.removeEventListener("offline", handleOffline);
        conn.removeEventListener("change", handleChange);
      };
    }

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return { isOnline, connectionType };
}

大约 35 行代码只获取了两条信息,而且不追踪下行速度、往返时间、数据节省模式或上次状态变化的时间戳。Network Information API 还使用了带厂商前缀的属性(mozConnectionwebkitConnection),这段代码也没有处理。

使用 useNetwork

useNetwork Hook 返回完整的网络信息。

import { useNetwork } from "@reactuses/core";

function NetworkDebugPanel() {
  const {
    online,
    previous,
    since,
    downlink,
    effectiveType,
    rtt,
    saveData,
    type,
  } = useNetwork();

  return (
    <div className="network-panel">
      <div>
        状态:{online ? "在线" : "离线"}
        {previous !== undefined && previous !== online && (
          <span>
            {" "}
            (之前{previous ? "在线" : "离线"},变化于{" "}
            {since?.toLocaleTimeString()})
          </span>
        )}
      </div>
      <div>连接类型:{type ?? "未知"}</div>
      <div>有效类型:{effectiveType ?? "未知"}</div>
      <div>下行速度:{downlink ? `${downlink} Mbps` : "未知"}</div>
      <div>往返时间:{rtt ? `${rtt}ms` : "未知"}</div>
      <div>数据节省:{saveData ? "已启用" : "已关闭"}</div>
    </div>
  );
}

Hook 处理了所有的厂商前缀、事件监听器和 SSR 安全问题。previoussince 字段特别有用 -- 它们让你可以显示"你在 30 秒前离线了",而不仅仅是"离线"。

使用 useOnline

如果你只需要布尔值,useOnline 更加简洁。它是 useNetwork 的轻量封装,只返回 online 值。

import { useOnline } from "@reactuses/core";

function OfflineBanner() {
  const isOnline = useOnline();

  if (isOnline) return null;

  return (
    <div className="offline-banner">
      你当前处于离线状态,实时更新已暂停。
    </div>
  );
}

实际示例:自适应质量推送

useNetwork 返回的网络信息让你可以根据用户的连接质量调整应用行为。

import { useNetwork } from "@reactuses/core";
import { useMemo } from "react";

function useAdaptivePolling(baseInterval: number) {
  const { online, effectiveType, saveData } = useNetwork();

  const interval = useMemo(() => {
    if (!online) return null; // 离线时停止轮询
    if (saveData) return baseInterval * 4; // 尊重数据节省设置
    switch (effectiveType) {
      case "slow-2g":
      case "2g":
        return baseInterval * 3;
      case "3g":
        return baseInterval * 2;
      case "4g":
      default:
        return baseInterval;
    }
  }, [online, effectiveType, saveData, baseInterval]);

  return interval;
}

function LiveScoreboard() {
  const pollingInterval = useAdaptivePolling(5000);
  const { online, effectiveType } = useNetwork();

  return (
    <div>
      {!online && (
        <div className="banner">离线中 -- 显示缓存的比分</div>
      )}
      {effectiveType === "slow-2g" && (
        <div className="banner">慢速连接 -- 更新频率已降低</div>
      )}
      {/* 使用 pollingInterval 的记分牌内容 */}
    </div>
  );
}

在快速 4G 连接上,记分牌每 5 秒更新一次。在慢速 2G 连接上,每 15 秒更新一次。离线时完全停止,显示缓存数据。用户获得的是其连接条件所能支持的最佳体验。

4. 使用 useBroadcastChannel 实现跨标签页通信

实时数据通常需要在浏览器标签页之间共享。如果用户在三个标签页中打开了你的仪表盘,当一条新通知通过 SSE 到达时,三个标签页都应该显示它 -- 但只有一个标签页应该维护 SSE 连接。BroadcastChannel API 让这成为可能。

手动实现

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

function useManualBroadcastChannel<T>(channelName: string) {
  const [data, setData] = useState<T | undefined>();
  const channelRef = useRef<BroadcastChannel | null>(null);

  useEffect(() => {
    if (typeof BroadcastChannel === "undefined") return;

    const channel = new BroadcastChannel(channelName);
    channelRef.current = channel;

    const handleMessage = (event: MessageEvent<T>) => {
      setData(event.data);
    };

    const handleError = (event: MessageEvent) => {
      console.error("BroadcastChannel 错误:", event);
    };

    channel.addEventListener("message", handleMessage);
    channel.addEventListener("messageerror", handleError);

    return () => {
      channel.removeEventListener("message", handleMessage);
      channel.removeEventListener("messageerror", handleError);
      channel.close();
    };
  }, [channelName]);

  const post = useCallback((message: T) => {
    channelRef.current?.postMessage(message);
  }, []);

  return { data, post };
}

这对简单场景够用了,但它不追踪 BroadcastChannel 是否被支持、频道是否已关闭、错误状态或用于去重的时间戳。

使用 useBroadcastChannel

useBroadcastChannel Hook 提供了完整的、类型安全的封装。

import { useBroadcastChannel } from "@reactuses/core";

interface DashboardMessage {
  type: "NEW_DATA" | "USER_ACTION" | "TAB_CLOSING";
  payload?: unknown;
  sourceTab: string;
}

function DashboardSync() {
  const { data, post, isSupported, isClosed, error } = useBroadcastChannel<
    DashboardMessage,
    DashboardMessage
  >({ name: "dashboard-sync" });

  const broadcast = (type: DashboardMessage["type"], payload?: unknown) => {
    post({
      type,
      payload,
      sourceTab: sessionStorage.getItem("tab-id") || "unknown",
    });
  };

  useEffect(() => {
    if (data?.type === "NEW_DATA") {
      // 用来自另一个标签页的数据更新本地状态
      console.log("收到来自标签页的数据:", data.sourceTab, data.payload);
    }
  }, [data]);

  if (!isSupported) {
    return <div>当前浏览器不支持跨标签页同步。</div>;
  }

  return (
    <div>
      <button onClick={() => broadcast("NEW_DATA", { count: 42 })}>
        与其他标签页共享数据
      </button>
      {error && <div className="error">同步出错</div>}
      {isClosed && <div className="warning">频道已关闭</div>}
    </div>
  );
}

这个 Hook 提供了:

  • isSupported -- 在渲染依赖同步的 UI 前检查 BroadcastChannel 是否可用。
  • isClosed -- 知道频道何时被关闭(由你或浏览器关闭)。
  • error -- 处理消息序列化错误。
  • timeStamp -- 当相同数据被多次接收时进行去重。
  • 类型安全 -- 泛型参数 <D, P> 分别对应接收数据类型和发送数据类型。

5. 综合实战:实时监控仪表盘

让我们将这五个 Hook 组合成一个生产级别的实时仪表盘。这个仪表盘:

  • 通过 SSE 接收实时指标(带认证)
  • 检测网络状态并相应调整行为
  • 在标签页之间共享数据,只让一个标签页维护 SSE 连接
  • 向用户展示连接健康状况
import {
  useFetchEventSource,
  useNetwork,
  useOnline,
  useBroadcastChannel,
  useEventSource,
} from "@reactuses/core";
import { useState, useEffect, useCallback, useRef } from "react";

// --- 类型定义 ---

interface MetricEvent {
  timestamp: number;
  cpu: number;
  memory: number;
  requests: number;
  errors: number;
}

interface TabMessage {
  type: "METRIC_UPDATE" | "CLAIM_LEADER" | "RELEASE_LEADER" | "HEARTBEAT";
  payload?: MetricEvent;
  tabId: string;
}

// --- 领导者选举 Hook ---

function useTabLeader(channelName: string) {
  const tabId = useRef(crypto.randomUUID()).current;
  const [isLeader, setIsLeader] = useState(false);
  const { data, post } = useBroadcastChannel<TabMessage, TabMessage>({
    name: channelName,
  });

  useEffect(() => {
    // 挂载时,短暂延迟后尝试获取领导权
    const timer = setTimeout(() => {
      post({ type: "CLAIM_LEADER", tabId });
      setIsLeader(true);
    }, Math.random() * 200);

    return () => {
      clearTimeout(timer);
      post({ type: "RELEASE_LEADER", tabId });
    };
  }, [post, tabId]);

  useEffect(() => {
    if (data?.type === "CLAIM_LEADER" && data.tabId !== tabId) {
      if (data.tabId > tabId) {
        setIsLeader(false);
      }
    }
    if (data?.type === "RELEASE_LEADER") {
      // 另一个标签页释放了 -- 尝试获取领导权
      setTimeout(() => {
        post({ type: "CLAIM_LEADER", tabId });
        setIsLeader(true);
      }, Math.random() * 100);
    }
  }, [data, tabId, post]);

  return { isLeader, tabId };
}

// --- 网络感知 SSE Hook ---

function useMetricsStream(enabled: boolean) {
  const { online, effectiveType } = useNetwork();

  const { data, status, error, close, open } = useFetchEventSource(
    "/api/metrics/stream",
    {
      headers: {
        Authorization: `Bearer ${getAccessToken()}`,
      },
      immediate: false,
      autoReconnect: {
        retries: -1,
        delay: effectiveType === "4g" ? 2000 : 5000,
        onFailed: () => console.error("指标数据流彻底失败"),
      },
    }
  );

  // 根据 enabled 标志和在线状态连接/断开
  useEffect(() => {
    if (enabled && online) {
      open();
    } else {
      close();
    }
  }, [enabled, online, open, close]);

  return { data, status, error };
}

// --- 主仪表盘组件 ---

function RealtimeDashboard() {
  const [metrics, setMetrics] = useState<MetricEvent[]>([]);
  const isOnline = useOnline();
  const { online, effectiveType, rtt } = useNetwork();

  // 领导者选举 -- 只有领导者标签页打开 SSE 连接
  const { isLeader, tabId } = useTabLeader("metrics-leader");

  // SSE 数据流 -- 只在当前标签页是领导者时激活
  const { data: sseData, status: sseStatus } = useMetricsStream(isLeader);

  // 跨标签页数据共享
  const { data: tabData, post: broadcastToTabs } = useBroadcastChannel<
    TabMessage,
    TabMessage
  >({ name: "metrics-data" });

  // 当领导者收到 SSE 数据时,广播给其他标签页
  useEffect(() => {
    if (isLeader && sseData) {
      try {
        const metric: MetricEvent = JSON.parse(sseData);
        setMetrics((prev) => [...prev, metric].slice(-100));
        broadcastToTabs({
          type: "METRIC_UPDATE",
          payload: metric,
          tabId,
        });
      } catch {
        // 数据格式错误
      }
    }
  }, [isLeader, sseData, broadcastToTabs, tabId]);

  // 当非领导者标签页收到广播数据时,更新本地状态
  useEffect(() => {
    if (!isLeader && tabData?.type === "METRIC_UPDATE" && tabData.payload) {
      setMetrics((prev) => [...prev, tabData.payload!].slice(-100));
    }
  }, [isLeader, tabData]);

  const latestMetric = metrics[metrics.length - 1];

  return (
    <div className="dashboard">
      {/* 连接状态栏 */}
      <header className="status-bar">
        <div className="status-indicators">
          <span className={`dot ${isOnline ? "green" : "red"}`} />
          <span>
            {isOnline ? "在线" : "离线"}
            {effectiveType && ` (${effectiveType})`}
            {rtt && ` -- ${rtt}ms 往返`}
          </span>
        </div>
        <div className="tab-info">
          {isLeader ? "领导者标签页(SSE 活跃)" : "跟随者标签页(通过广播)"}
          <span className={`dot ${sseStatus === "CONNECTED" ? "green" : "yellow"}`} />
        </div>
      </header>

      {/* 离线提示 */}
      {!isOnline && (
        <div className="offline-banner">
          你当前处于离线状态。正在显示最近 {metrics.length} 条缓存指标。
          连接恢复后数据将自动继续更新。
        </div>
      )}

      {/* 指标网格 */}
      {latestMetric && (
        <div className="metrics-grid">
          <MetricCard
            label="CPU 使用率"
            value={`${latestMetric.cpu.toFixed(1)}%`}
            status={latestMetric.cpu > 80 ? "danger" : "normal"}
          />
          <MetricCard
            label="内存"
            value={`${latestMetric.memory.toFixed(1)}%`}
            status={latestMetric.memory > 90 ? "danger" : "normal"}
          />
          <MetricCard
            label="请求数/秒"
            value={latestMetric.requests.toLocaleString()}
            status="normal"
          />
          <MetricCard
            label="错误数/秒"
            value={latestMetric.errors.toLocaleString()}
            status={latestMetric.errors > 10 ? "danger" : "normal"}
          />
        </div>
      )}

      {/* 迷你图表(最近 100 个数据点) */}
      <div className="chart-section">
        <h3>CPU 变化趋势</h3>
        <div className="sparkline">
          {metrics.map((m, i) => (
            <div
              key={i}
              className="bar"
              style={{
                height: `${m.cpu}%`,
                backgroundColor: m.cpu > 80 ? "#ef4444" : "#22c55e",
              }}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

function MetricCard({
  label,
  value,
  status,
}: {
  label: string;
  value: string;
  status: "normal" | "danger";
}) {
  return (
    <div className={`metric-card metric-${status}`}>
      <div className="metric-label">{label}</div>
      <div className="metric-value">{value}</div>
    </div>
  );
}

每个 Hook 在这个仪表盘中的贡献:

  • useFetchEventSource -- 连接带认证的指标 SSE 端点,自动重连。
  • useEventSource -- 如果端点不需要自定义请求头,可以替换使用(对组件零 API 变更)。
  • useNetwork -- 为状态栏提供连接质量数据(effectiveTypertt),并实现自适应重连延迟。
  • useOnline -- 驱动离线提示,在网络断开时暂停 SSE 连接。
  • useBroadcastChannel -- 实现领导者选举和跨标签页数据共享,只让一个标签页维护 SSE 连接,而所有标签页都显示实时数据。

最终效果:

  1. 所有标签页共享一个 SSE 连接(节省服务器资源)
  2. 根据连接质量自适应退避重连
  3. 向用户展示实时网络状态
  4. 离线时优雅降级
  5. 所有打开的标签页之间即时共享数据

选择哪个 Hook

场景 Hook 原因
公开 SSE 端点 useEventSource 简单,原生 EventSource
带认证头的 SSE useFetchEventSource 通过 fetch 支持自定义请求头
带 POST 请求体的 SSE useFetchEventSource 支持请求体
简单的在线/离线检测 useOnline 返回单个布尔值
详细的连接信息 useNetwork 下行速度、往返时间、有效类型
跨标签页消息 useBroadcastChannel 内存通信,无持久化
跨标签页 + 持久化 useBroadcastChannel + useLocalStorage 两全其美

安装

npm install @reactuses/core

或使用你偏好的包管理器:

pnpm add @reactuses/core
yarn add @reactuses/core

相关 Hooks

  • useEventSource -- 响应式 Server-Sent Events,支持命名事件和自动重连
  • useFetchEventSource -- 基于 fetch 的 SSE,支持自定义请求头、POST 请求和认证
  • useNetwork -- 详细的网络状态,包括连接类型、下行速度和往返时间
  • useOnline -- 简单的在线/离线布尔值检测
  • useBroadcastChannel -- 通过 BroadcastChannel API 实现类型安全的跨标签页消息传递
  • useDocumentVisibility -- 跟踪当前标签页是否可见
  • useLocalStorage -- 具有自动跨标签页同步的持久化状态

ReactUse 提供了 100+ 个 React Hooks。探索全部 →

深扒 Claude Code Buddy 模式:一只仙人掌背后的确定性随机算法

作者 ZzT
2026年4月1日 15:27

深扒 Claude Code Buddy 模式:一只仙人掌背后的确定性随机算法

今天是 2026 年 4 月 1 日。不,这不是愚人节玩笑——Claude Code 真的藏了一个宠物系统,而且 salt 值就叫 friend-2026-401

什么是 Buddy 模式?

最近更新的 Claude Code 里悄悄藏了一个 /buddy 命令。执行之后,输入框旁边会冒出一只小动物,偶尔发表对你代码的犀利评论。

比如我这只叫 Prong 的仙人掌(COMMON 稀有度),属性长这样:

物种:cactus(仙人掌)  稀有度:COMMON ★
────────────────────────────────
DEBUGGING  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░  77
PATIENCE   ▓▓▓▓▓░░░░░░░░░░░░  24
CHAOS      ▓░░░░░░░░░░░░░░░░   2
WISDOM     ▓▓▓▓▓░░░░░░░░░░░░  22
SNARK      ▓▓▓▓▓▓░░░░░░░░░░░  29

性格描述:"找 bug 找得又准又快,但你不按它说的方式改就会大声抱怨。"

基本操作:

  • /buddy — 呼出宠物
  • /buddy pet — 撸宠物
  • /buddy off — 关闭宠物
  • 不消耗用量,纯装饰/陪伴功能

分配机制:为什么你的宠物和别人不一样?

这套系统最有趣的地方是它的确定性随机设计——同一个账号永远分配到同一只宠物,但你无法通过修改配置来"作弊"刷稀有度。

核心算法:roll() 函数

源码在 src/buddy/companion.ts,核心入口是这个函数:

const SALT = 'friend-2026-401'

// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
// per-turn observer) with the same userId → cache the deterministic result.
let rollCache: { key: string; value: Roll } | undefined

export function roll(userId: string): Roll {
  const key = userId + SALT
  if (rollCache?.key === key) return rollCache.value
  const value = rollFrom(mulberry32(hashString(key)))
  rollCache = { key, value }
  return value
}

流程很清晰:

  1. userId + "friend-2026-401" 作为种子字符串
  2. hashString() 把字符串变成一个 32 位整数
  3. 用这个整数初始化 Mulberry32 PRNG
  4. 用 PRNG 确定性地决定一切属性

salt 值 friend-2026-401 里的 401 就是 4 月 1 日——愚人节彩蛋。

哈希函数:两种实现

function hashString(s: string): number {
  if (typeof Bun !== 'undefined') {
    return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
  }
  // FNV-1a 32-bit
  let h = 2166136261
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i)
    h = Math.imul(h, 16777619)
  }
  return h >>> 0
}

在 Bun 环境用 Bun 原生哈希,否则用 FNV-1a 32-bit 算法。两者都能把字符串映射到一个稳定的 32 位无符号整数。

PRNG:Mulberry32

function mulberry32(seed: number): () => number {
  let a = seed >>> 0
  return function () {
    a |= 0
    a = (a + 0x6d2b79f5) | 0
    let t = Math.imul(a ^ (a >>> 15), 1 | a)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

Mulberry32 是一种轻量级的 32 位 PRNG,统计质量足够好,代码量极小,非常适合"用于挑鸭子"这种不需要密码学安全性的场景(注释原文:// Mulberry32 — tiny seeded PRNG, good enough for picking ducks)。

稀有度系统

权重表

export const RARITY_WEIGHTS = {
  common:    60,  // 60%
  uncommon:  25,  // 25%
  rare:      10,  // 10%
  epic:       4,  //  4%
  legendary:  1,  //  1%
} as const satisfies Record<Rarity, number>

稀有度抽取

function rollRarity(rng: () => number): Rarity {
  const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
  let roll = rng() * total
  for (const rarity of RARITIES) {
    roll -= RARITY_WEIGHTS[rarity]
    if (roll < 0) return rarity
  }
  return 'common'
}

标准的加权随机选择。注意 RARITIES 的顺序是 ['common', 'uncommon', 'rare', 'epic', 'legendary'],越低稀有度越先被"消耗",这是一种等价但直观的实现方式。

物种与外观

18 种物种

duck / goose / blob / cat / dragon / octopus / owl / penguin /
turtle / snail / ghost / axolotl / capybara / cactus / robot /
rabbit / mushroom / chonk

有意思的是,types.ts 里的物种名全部用 String.fromCharCode() 编码:

const c = String.fromCharCode
export const duck    = c(0x64,0x75,0x63,0x6b) as 'duck'
export const goose   = c(0x67,0x6f,0x6f,0x73,0x65) as 'goose'
// ...

注释解释了原因:某个物种名和内部的一个模型代号冲突,触发了构建检查脚本的字符串扫描。通过运行时拼接字面量字符串,既规避了检查,又没有破坏类型系统(as cast 在编译后会被抹去)。

外观组合规则

属性 选项 备注
稀有度 5 级 common 60%, legendary 1%
物种 18 种 均等概率
眼睛 6 种:· ✦ × ◉ @ ° 均等概率
帽子 7 种:crown / tophat / propeller / halo / wizard / beanie / tinyduck common 没有帽子
闪亮变体 1% 概率 独立于稀有度
function rollFrom(rng: () => number): Roll {
  const rarity = rollRarity(rng)
  const bones: CompanionBones = {
    rarity,
    species: pick(rng, SPECIES),
    eye: pick(rng, EYES),
    hat: rarity === 'common' ? 'none' : pick(rng, HATS),  // common 无帽
    shiny: rng() < 0.01,
    stats: rollStats(rng, rarity),
  }
  return { bones, inspirationSeed: Math.floor(rng() * 1e9) }
}

属性系统

每只宠物有 5 个属性:DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK

const RARITY_FLOOR: Record<Rarity, number> = {
  common:    5,
  uncommon: 15,
  rare:     25,
  epic:     35,
  legendary: 50,
}

function rollStats(rng: () => number, rarity: Rarity): Record<StatName, number> {
  const floor = RARITY_FLOOR[rarity]
  const peak = pick(rng, STAT_NAMES)   // 一个顶峰属性
  let dump = pick(rng, STAT_NAMES)      // 一个垃圾属性
  while (dump === peak) dump = pick(rng, STAT_NAMES)

  const stats = {} as Record<StatName, number>
  for (const name of STAT_NAMES) {
    if (name === peak) {
      stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))  // 高区间
    } else if (name === dump) {
      stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))    // 低区间
    } else {
      stats[name] = floor + Math.floor(rng() * 40)                       // 中等
    }
  }
  return stats
}

设计逻辑:

  • 每只宠物必有一个峰值属性(peak)和一个垃圾属性(dump),形成鲜明个性
  • 稀有度越高,floor 越高,意味着即使是 legendary 的垃圾属性也比 common 的大多数属性强
  • common floor=5,legendary floor=50,差距悬殊

防作弊设计:骨架与灵魂分离

这套系统最精妙的地方在于持久化机制:

// Regenerate bones from userId, merge with stored soul. Bones never persist
// so species renames and SPECIES-array edits can't break stored companions,
// and editing config.companion can't fake a rarity.
export function getCompanion(): Companion | undefined {
  const stored = getGlobalConfig().companion
  if (!stored) return undefined
  const { bones } = roll(companionUserId())
  // bones last so stale bones fields in old-format configs get overridden
  return { ...stored, ...bones }
}

类型定义上:

  • Bones(骨架):物种、稀有度、眼睛、帽子、shiny、属性——每次从 userId 重新计算,从不持久化
  • Soul(灵魂):名字、性格描述——持久化到 config

这意味着无论你怎么编辑 config.companion,稀有度和属性都会被 roll(userId) 的结果覆盖。注释说得很直白:editing config.companion can't fake a rarity

同理,如果未来 Anthropic 修改了物种名称或新增物种,已有用户的宠物属性也不会因为 config 里存了旧的 species 字段而损坏——反正重算一遍。

一点碎碎念

这个功能本身很小,但代码设计很有意思:

  1. 确定性随机做到了公平——你不能通过删号重来(同账号永远相同)
  2. 骨架/灵魂分离在做到防作弊的同时,顺手解决了数据迁移问题
  3. 愚人节彩蛋隐藏在 salt 里,时间戳精确到那一天
  4. 注释写得很有人情味:"good enough for picking ducks"、"Bones never persist"

不管怎样,今天是 2026 年 4 月 1 日,我的仙人掌 Prong 已经开始对我的代码发表意见了。


如果你也用 Claude Code,现在可以试试 /buddy 看看你的宠物是什么。

我是一只仙人掌,DEBUGGING 77,但 PATIENCE 只有 24。你呢?

Mac Claude Code

2026年4月2日 10:39

在 Mac 电脑上安装和配置 Claude Code(Anthropic 推出的命令行 AI 编程助手)主要分为安装基础配置以及自定义模型(第三方 API)配置三个部分。

以下是详细的操作步骤:

一、 安装 Claude Code

在 Mac 上,推荐使用 Homebrew 或官方脚本进行安装。

1. 使用 Homebrew 安装(推荐) 打开终端(Terminal),输入以下命令:

brew install claude-code

2. 使用官方脚本安装(备选) 如果没安装 Homebrew,可以使用 curl 脚本:

curl -fsSL https://claude.ai/install.sh | bash

3. 验证安装 安装完成后,检查版本以确认是否成功:

claude --version

二、 基础配置与登录

默认情况下,Claude Code 需要 Anthropic 的付费账号(Pro、Teams 或 Enterprise)。

  1. 启动: 在你的项目根目录下输入 claude
  2. 登录: 首次运行会提示登录。它会打开浏览器让你授权。
  3. 完成引导: 按照终端提示完成初始设置(如主题选择、权限授权等)。

三、 配置使用自定义模型(第三方 API)

如果你希望使用第三方中转接口或自定义模型(例如 DeepSeek、Poe 或国内镜像),可以通过环境变量配置文件来实现。

方法 1:通过环境变量(最简单)

在启动 claude 之前,设置以下环境变量。你可以将其添加到你的 ~/.zshrc~/.bash_profile 中以便永久生效。

# 设置第三方 API 的基础地址 (必须兼容 Anthropic 格式)
export ANTHROPIC_BASE_URL="https://your-proxy-api.com/v1"

# 设置你的 API Key
export ANTHROPIC_AUTH_TOKEN="your-api-key-here"

# 启动 Claude Code
claude

方法 2:修改本地配置文件(更稳定)

你可以创建一个本地设置文件来覆盖默认行为。

  1. 创建配置目录(如果不存在):

    mkdir -p ~/.claude
    
  2. 创建/编辑 settings.json~/.claude/settings.json 中添加环境配置:

    {
      "env": {
        "ANTHROPIC_BASE_URL": "https://api.your-provider.com",
        "ANTHROPIC_AUTH_TOKEN": "sk-xxxxxx"
      }
    }
    

方法 3:跳过官方登录流程(针对纯第三方用户)

如果你没有 Anthropic 官方账号,只想用第三方模型,需要手动“欺骗”程序通过初始化检查:

  1. 创建伪造的 Key 文件:

    echo '{"primaryApiKey": "any-string"}' > ~/.claude/config.json
    
  2. 标记已完成引导: 修改或创建 ~/.claude.json(注意文件名开头的点):

    {
      "hasCompletedOnboarding": true
    }
    

四、 进阶:配置特定模型名称

Claude Code 默认寻找 claude-3-5-sonnet。如果你的第三方供应商使用不同的模型名称(如 deepseek-chat),你可能需要设置默认模型变量:

export ANTHROPIC_DEFAULT_HAIKU_MODEL="your-model-name"
# 或者在 settings.json 的 env 块中添加

五、 常用操作命令

启动 Claude Code 后,你可以在其内部交互界面使用以下斜杠命令:

  • /config:查看和修改当前配置。
  • /help:获取详细帮助指南。
  • /login / /logout:管理官方账号登录。
  • /compact:压缩对话历史以节省 Token。
  • Ctrl+C:停止当前生成的代码或退出。

注意事项

  • 兼容性: Claude Code 的核心功能(如自动读取文件、运行测试、修复 Bug)是针对 Claude 3.5 Sonnet 模型高度优化的。使用非 Claude 系列模型时,可能会出现指令理解不到位或工具调用(Tool Use)失败的情况。
  • 网络: 如果你在国内使用,请确保终端环境可以正常访问你配置的 ANTHROPIC_BASE_URL

Claude Code 未登录 使用第三方模型

2026年4月2日 10:37

1. 最关键:未登录 (Not logged in)

右下角显示 Not logged in · Run /login。这意味着 Claude Code 还没连接到你的账号,无法开始写代码。

  • 处理方法 A(使用官方账号): 在控制台直接输入 /login 并按回车。它会弹出一个网页,你登录你的 Anthropic (Claude.ai) 账号并授权即可。
  • 处理方法 B: 如果你打算用第三方模型而不登录官方账号,你需要按照下面步骤,“欺骗”程序跳过登录:
    1. 按下 Ctrl + C 退出当前界面。
    2. 在终端执行:
      # 标记已完成引导
      mkdir -p ~/.claude && echo '{"hasCompletedOnboarding": true}' > ~/.claude.json
      # 伪造一个 key
      echo '{"primaryApiKey": "any-string"}' > ~/.claude/config.json
      
    3. 设置你的第三方 API 环境变量(例如 export ANTHROPIC_BASE_URL=...)。
    4. 重新输入 claude 启动。

2. 有新版本可用 (Update available!)

最下方提示 Update available! Run: brew upgrade claude-code

  • 处理方法: 如果你想使用最新功能,请先退出 Claude Code(Ctrl + C),然后在终端执行:
    brew upgrade claude-code
    

3. 引导建议 (Tips)

中间提示 Run /init to create a CLAUDE.md

  • 处理方法: 建议在你的项目根目录下输入 /init。这会生成一个 CLAUDE.md 文件,你可以里面写上你的项目规范(比如:使用什么技术栈、缩进是多少、代码风格等),这样 Claude 以后改代码会更符合你的习惯。

总结:现在该做什么?

如果你想立即开始对话,请直接在那个 符号后面输入你的要求,比如:

  • 如果你已配置好第三方 API:输入 你好,请帮我分析一下这个项目结构
  • 如果你还没登录也没配置:先输入 /login

提示: 看到这个界面说明你的 安装已经完全成功 了,只是需要完成“身份验证”这一步。

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

作者 SmalBox
2026年4月2日 10:21

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

Log 节点

Log 节点是 Unity URP Shader Graph 中用于数学计算的重要节点之一,专门用于计算输入值的对数。在图形着色器编程中,对数运算在多种视觉效果和数学计算中扮演着关键角色,特别是在处理非线性关系、数据范围压缩和特定算法实现时。理解 Log 节点的原理和应用对于创建复杂且高效的着色器效果至关重要。

对数函数是数学中的基本概念,它是指数函数的逆运算。在着色器编程中,对数运算常用于亮度调整、高动态范围(HDR)处理、数据归一化等场景。Log 节点通过提供三种不同的底数选项(自然对数、以 2 为底的对数和以 10 为底的对数),为开发者提供了灵活的对数计算能力。

与 Shader Graph 中的其他数学节点相比,Log 节点具有独特的特性。它能够将输入值从指数增长的范围转换为线性范围,这在处理光照计算、颜色校正和物理模拟时特别有用。例如,在 HDR 渲染中,对数运算可以帮助将高动态范围的颜色值映射到适合显示的低动态范围。

Log 节点的实现基于 GPU 的高效数学函数,能够并行处理多个数据通道,这对于实时图形应用至关重要。无论是处理单个浮点数还是多维向量,Log 节点都能提供一致且准确的结果。

描述

Log 节点是 Shader Graph 数学节点库中的基础组件,其主要功能是计算输入值的对数。该节点接收一个动态矢量输入,根据选择的底数类型,输出对应的对数值。作为 Exponential 节点的逆运算,Log 节点在数学运算链中扮演着反向计算的角色,能够将指数增长的数据转换回原始比例。

在数学定义上,如果 a^x = b,那么 log_a(b) = x。在 Log 节点的上下文中,输入值相当于 b,输出值相当于 x,而底数 a 则通过 Base 下拉选单进行选择。这种关系使得 Log 节点成为解决指数相关问题的有力工具。

例如,当使用 base-2 对数时,如果输入值为 8,由于 2^3 = 8,所以输出结果为 3。这一特性在计算机图形学中尤为重要,因为许多计算机系统中的数据存储和处理都是基于二进制的。

Log 节点的应用范围十分广泛。在颜色处理方面,它可用于实现 gamma 校正,将线性颜色空间转换为感知均匀的颜色空间。在光照计算中,对数运算可以帮助处理高动态范围的光照强度,使其适合在标准显示器上呈现。此外,在特效制作中,Log 节点可以用于创建非线性插值、实现特定的衰减曲线或生成复杂的图案。

节点的输入输出类型为动态矢量,这意味着它可以处理从单个浮点数到四维向量的各种数据类型。这种灵活性使得 Log 节点能够同时处理多个颜色通道或空间坐标,大大提高了着色器编程的效率。

底数选择是 Log 节点的核心特性之一。BaseE(自然对数)使用数学常数 e(约等于 2.718)作为底数,在连续增长模型和微积分相关计算中最为常见。Base2(以 2 为底的对数)在计算机科学领域应用广泛,特别适合处理与二进制系统相关的计算。Base10(以 10 为底的对数)则常用于工程和科学计算,特别是在处理数量级和分贝计算时。

端口

Log 节点的端口系统设计简洁而高效,遵循 Shader Graph 标准的数据流模式。了解每个端口的特性和行为对于正确使用该节点至关重要。

名称 方向 类型 描述
In 输入 动态矢量 输入值
Out 输出 动态矢量 输出值

输入端口 (In)

输入端口标记为 "In",是 Log 节点接收数据的入口。这个端口接受动态矢量类型,意味着它可以连接多种数据类型的输出:

  • 单个浮点数值(Float)
  • 二维向量(Vector2)
  • 三维向量(Vector3)
  • 四维向量(Vector4)

输入值的有效范围取决于所选的底数类型:

  • 对于所有底数类型,输入值必须大于零。对数函数在实数范围内仅对正数有定义。
  • 如果输入值为零或负数,结果将是未定义的,通常会导致 NaN(非数字)或平台特定的异常值。

输入端口支持连接其他节点的输出,包括:

  • 常量值节点
  • 属性节点
  • 纹理采样节点
  • 其他数学运算节点的输出
  • 时间节点等动态值源

输出端口 (Out)

输出端口标记为 "Out",提供对数计算的结果。输出数据的维度和类型始终与输入保持一致:

  • 如果输入是标量,输出也是标量
  • 如果输入是矢量,输出的每个分量都会独立计算对数值

输出值的特性:

  • 输出值的范围取决于输入值和选择的底数
  • 对于 base-e 对数,输出范围从负无穷大到正无穷大
  • 对于 base-2 和 base-10 对数,输出同样覆盖整个实数范围
  • 输出值的数据精度遵循 Shader Graph 的精度设置

输出端口可以连接到多种类型的输入:

  • 其他数学节点的输入
  • 颜色节点的输入
  • 材质属性的输入
  • 着色器阶段的输入(如片段着色器颜色输出)

数据类型转换与兼容性

Log 节点在处理不同类型的数据时遵循 Shader Graph 的隐式转换规则:

  • 当连接不同维度的数据时,会自动进行广播操作
  • 例如,将标量连接到矢量输入时,标量值会被复制到所有分量
  • 输出数据的精度与输入数据的精度保持一致

控件

Log 节点的控件系统设计直观,提供了对节点行为的精确控制。主要控件是 Base 下拉选单,它决定了对数计算的数学基础。

名称 类型 选项 描述
Base 下拉选单 BaseE、Base2、Base10 选择对数的底数

Base 下拉选单

Base 下拉选单是 Log 节点的核心控制元素,提供了三种不同的底数选项。每种底数对应不同的数学特性和应用场景。

BaseE(自然对数)

自然对数以数学常数 e(约等于 2.71828)作为底数,在数学和物理学中具有基础性地位:

  • 标记为 "ln" 或在编程中常表示为 "log"
  • 是微积分中的标准对数,与自然指数函数互为逆运算
  • 在连续增长或衰减模型中应用广泛
  • 在概率论、统计学和复杂系统建模中尤为重要

自然对数的特性:

  • 导数简单:d(ln(x))/dx = 1/x
  • 积分关系:∫(1/x)dx = ln|x| + C
  • 在复变函数理论中具有重要地位

Base2(以 2 为底的对数)

以 2 为底的对数在计算机科学和信息技术领域应用广泛:

  • 通常标记为 "log₂"
  • 与二进制系统直接相关,适合处理计算机中的数据
  • 在信息论中用于计算信息熵
  • 在算法分析中用于评估时间复杂度

Base2 对数的特殊应用:

  • 计算数据存储所需的位数
  • 分析分治算法的递归深度
  • 处理纹理 mipmap 级别
  • 实现基于二进制的插值和衰减

Base10(以 10 为底的对数)

以 10 为底的对数在工程和科学计算中最为常见:

  • 通常标记为 "log" 或 "log₁₀"
  • 与十进制计数系统直接对应
  • 在测量科学中用于表示数量级
  • 在声学中用于分贝计算

Base10 对数的实际应用:

  • 计算 pH 值(酸碱性测量)
  • 表示地震的里氏震级
  • 在信号处理中计算信噪比
  • 数据可视化中的对数坐标轴

控件交互与动态行为

Base 下拉选单的交互特性:

  • 选择不同的底数会立即影响节点的计算行为
  • 节点外观可能会轻微变化以反映当前选择
  • 生成的着色器代码会根据选择而改变
  • 不影响输入输出端口的连接状态

性能考虑

不同底数选择的性能特征:

  • 在大多数现代 GPU 上,三种对数计算的性能差异可以忽略不计
  • Base2 对数在某些硬件架构上可能有轻微的性能优势
  • 实际性能取决于目标平台和驱动程序优化
  • 对于移动平台,建议进行性能测试以确认影响

生成的代码示例

Log 节点在编译时会根据底数选择生成相应的 HLSL 代码。理解生成的代码有助于深入掌握节点的内部工作机制,并为高级着色器编程提供基础。

Base E 代码生成

当选择 BaseE(自然对数)时,Log 节点生成类似于以下示例的 HLSL 代码:

void Unity_Log_float4(float4 In, out float4 Out)
{
    Out = log(In);
}

代码分析:

  • 函数名 Unity_Log_float4 表明这是处理 float4 类型数据的自然对数函数
  • In 参数接收输入的四维向量
  • Out 参数通过引用返回计算结果
  • log() 是 HLSL 内置函数,计算自然对数

扩展应用示例:

// 计算 RGB 颜色的自然对数,用于 HDR 色调映射
void ApplyNaturalLogToneMapping(float3 hdrColor, out float3 mappedColor)
{
    mappedColor = log(hdrColor + 1.0); // 加1避免对零取对数
}

// 基于自然对数的自定义光照衰减
float NaturalLogFalloff(float distance, float scale)
{
    return 1.0 / log(distance * scale + 1.0);
}

Base 2 代码生成

当选择 Base2(以 2 为底的对数)时,生成的代码示例如下:

void Unity_Log2_float4(float4 In, out float4 Out)
{
    Out = log2(In);
}

代码分析:

  • 函数名 Unity_Log2_float4 明确表示 base-2 对数计算
  • log2() 是 HLSL 标准函数,专门计算以 2 为底的对数
  • 输入输出结构与其他模式一致

实际应用场景:

// 计算 mipmap 级别选择
float CalculateMipLevel(float2 uvDerivative)
{
    float maxDerivative = max(length(uvDerivative.x), length(uvDerivative.y));
    return log2(maxDerivative * textureSize);
}

// 基于二进制对数的颜色量化
float3 BinaryLogQuantization(float3 color, int levels)
{
    float logColor = log2(color);
    float quantizedLog = floor(logColor * levels) / levels;
    return exp2(quantizedLog);
}

Base 10 代码生成

当选择 Base10(以 10 为底的对数)时,生成的代码形式为:

void Unity_Log10_float4(float4 In, out float4 Out)
{
    Out = log10(In);
}

代码特性:

  • 函数名 Unity_Log10_float4 标识 base-10 对数操作
  • log10() 是 HLSL 内置函数,计算以 10 为底的对数
  • 保持与其他模式一致的接口设计

工程应用示例:

// 计算分贝值
float CalculateDecibels(float signalPower, float referencePower)
{
    float powerRatio = signalPower / referencePower;
    return 10.0 * log10(powerRatio);
}

// 基于数量级的动态范围调整
float LogarithmicRangeAdjustment(float value, float threshold)
{
    if (value > threshold) {
        return log10(value / threshold) + 1.0;
    } else {
        return value / threshold;
    }
}

代码生成的高级特性

Log 节点的代码生成机制还包含一些高级特性:

动态精度支持

// 根据图形API和平台设置自动选择精度
#ifdef UNITY_USE_HIGH_PRECISION_MATH
    precise float4 logResult = log(In);
#else
    float4 logResult = log(In);
#endif

错误处理机制

// 防止对非正数取对数的安全版本
void Unity_SafeLog_float4(float4 In, out float4 Out)
{
    Out = log(max(In, 1e-8)); // 确保输入始终为正数
}

多平台兼容性

  • 生成的代码会自动适应不同的图形 API(DirectX、OpenGL、Vulkan 等)
  • 针对移动平台可能使用优化后的数学函数
  • 保持与各种着色器模型的兼容性

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

❌
❌