普通视图

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

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

作者 前端Hardy
2026年4月2日 16:24

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

如果你受够了:

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

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


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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

1. 创建组件:MyInput.vue

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

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

2. 父组件使用

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

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

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

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


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

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

父组件

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

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

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

子组件:UserForm.vue

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

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

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

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


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

坑1:绑定非响应式数据

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

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

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

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

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

坑3:v-model:value 混用

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

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

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

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

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


五、谁在用 Vue3 的 v-model?

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

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

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


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

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

作者 前端Hardy
2026年4月2日 16:23

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

如果你受够了:

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

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


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

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

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

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


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

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

# 推荐 pnpm
pnpm add pinia

# 或 npm / yarn
npm install pinia
yarn add pinia

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

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

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

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


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

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

import { defineStore } from 'pinia'

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

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

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

2. 在组件中使用

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

1. 安装插件

pnpm add pinia-plugin-persistedstate

2. 配置插件

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

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

3. 仓库开启持久化

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

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


五、谁在用 Pinia?

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

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

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


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

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

作者 前端Hardy
2026年4月2日 16:22

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

如果你受够了:

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

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


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

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

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

  • Options API 写法

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

ref 实操示例】

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

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

【reactive实操示例】

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

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

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

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

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

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

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

【常用生命周期对照表】

Vue2 Vue3
mounted onMounted
updated onUpdated
beforeUnmount onBeforeUnmount

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

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

const list = ref([])

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

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


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

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

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

// 正确
count.value = 1

坑2:用 reactive 创建简单数据

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

// 正确
const count = ref(0)

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

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


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

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

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

第一步:创建 hooks/useForm.js

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

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

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

  return { form, errors, validate }
}

第二步:在组件中使用

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

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

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

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

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


五、谁在用 Composition API?

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

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

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


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

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

作者 李剑一
2026年4月2日 16:15

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

image.png

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

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

Wasm 到底是啥?

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

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

image.png

用最直白的话来说:

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

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

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

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

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

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

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

Wasm 在跨平台框架中使用

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

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

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

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

Electron使用

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

image.png

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

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

// 执行调用
loadWasm();

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

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

React Native使用

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

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

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

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

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

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

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

export default WasmDemo;

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

总结

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

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

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

作者 王霸天
2026年4月2日 15:47

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

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


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

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

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

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

const app = createApp(App)

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

app.mount('#app')

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


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

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

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

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

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

官方资源直达:


🔗 推荐阅读与下期预告

📚 推荐深度阅读

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


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

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

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

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

作者 Daiyaosei
2026年4月1日 00:31

🚨 事件概述

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

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

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

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

🔍 攻击手法深度解析

1️⃣ 账号劫持

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

2️⃣ 绕过 CI/CD 审核

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

3️⃣ 虚假依赖注入

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

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

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

4️⃣ 双重伪装策略

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

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

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

5️⃣ 自动执行机制

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

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

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

💀 恶意行为分析

感染流程

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

Windows 系统

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

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

macOS 系统

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

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

Linux 系统

# 直接执行
/tmp/ld.py

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

恶意功能

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

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

🎯 影响范围评估

高危项目

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

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

传播途径

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

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

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

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

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

🛡️ 紧急处置方案

第一步:立即自查

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

# 或使用 pnpm
pnpm list axios

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

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

  • axios@1.14.1
  • axios@0.30.4

第二步:紧急卸载

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

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

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

第三步:检查失陷迹象

Windows 系统

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

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

macOS 系统

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

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

Linux 系统

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

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

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

第四步:重置凭证

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

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

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

🔒 长期防护策略

1. 锁定依赖版本

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

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

2. 禁用自动脚本执行

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

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

3. 启用 npm 审计

# 安装时自动审计
npm audit

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

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

4. 使用安全工具

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

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

5. 实施依赖审查流程

对于企业级项目,建议:

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

6. AI 编程工具使用规范

如果你使用 AI 编程工具:

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

📊 技术细节补充

恶意域名信息

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

恶意包哈希值

供安全工具检测使用:

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

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

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

网络特征

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

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

🎓 事件启示

供应链安全的脆弱性

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

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

开源安全的新挑战

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

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

开发者的责任

作为开发者,我们需要:

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

📝 总结

关键要点

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

行动清单

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

🔗 参考资料

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

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

作者 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+篇笔记,效果稳定。

参考资料


💡 想要完整教程?

我把这套方案整理成了保姆级教程,包括:

  • ✅ 完整源码
  • ✅ 常见问题解答

👉 感兴趣的话可以私聊我或者评论区回复

❌
❌