普通视图
我的2025年终总结
月哥创业3年,还活着!
理清 https 的加密逻辑
用 Flutter、SwiftUI 和 Compose 写同一个界面:一份真实开发者的实测报告
老王请假、客户开喷、我救火:一场递归树的性能突围战
Gemini 3 最新版!Node.js 代理调用教程
祝大家 2026 年新年快乐,代码无 bug,需求一次过
🎣 拒绝面条代码!手把手带你用自定义 Hooks 重构 React 世界
Vue3 防重复点击指令 - clickOnce
Vue3 防重复点击指令 - clickOnce
一、问题背景
在实际的 Web 应用开发中,我们经常会遇到以下问题:
- 用户快速多次点击提交按钮:导致重复提交表单,产生多条相同数据
- 异步请求未完成时再次点击:可能导致数据不一致或服务器压力增大
- 用户体验不佳:没有明确的加载状态反馈,用户不知道操作是否正在进行
这些问题在以下场景中尤为常见:
- 表单提交(注册、登录、创建订单等)
- 数据保存操作
- 文件上传
- 支付操作
- API 调用
二、解决方案
clickOnce 指令通过以下机制解决上述问题:
1. 节流机制
使用 @vueuse/core 的 useThrottleFn,在 1.5 秒内只允许执行一次点击操作。
2. 按钮禁用
点击后立即禁用按钮,防止用户再次点击。
3. 视觉反馈
自动添加 Element Plus 的 Loading 图标,让用户明确知道操作正在进行中。
4. 智能恢复
- 如果绑定的函数返回 Promise(异步操作),则在 Promise 完成后自动恢复按钮状态
- 如果是同步操作,则立即恢复
三、核心特性
✅ 自动防重复点击:1.5秒节流时间
✅ 自动 Loading 状态:无需手动管理 loading 变量
✅ 支持异步操作:自动检测 Promise 并在完成后恢复
✅ 优雅的清理机制:组件卸载时自动清理事件监听
✅ 类型安全:完整的 TypeScript 支持
四、技术实现
关键技术点
-
Vue 3 自定义指令:使用
Directive类型定义 -
VueUse 节流:
useThrottleFn提供稳定的节流功能 -
动态组件渲染:使用
createVNode和render动态创建 Loading 图标 - Promise 检测:自动识别异步操作并在完成后恢复状态
工作流程
用户点击按钮
↓
节流检查(1.5秒内只执行一次)
↓
禁用按钮 + 添加 Loading 图标
↓
执行绑定的函数
↓
检测返回值是否为 Promise
↓
Promise 完成后(或同步函数执行完)
↓
移除 Loading + 恢复按钮状态
五、使用方法
1. 注册指令
// main.ts
import clickOnce from '@/directives/clickOnce'
app.directive('click-once', clickOnce)
2. 在组件中使用
<template>
<!-- 异步操作示例 -->
<el-button
type="primary"
v-click-once="handleSubmit">
提交表单
</el-button>
<!-- 带参数的异步操作 -->
<el-button
type="success"
v-click-once="() => handleSave(formData)">
保存数据
</el-button>
</template>
<script setup lang="ts">
const handleSubmit = async () => {
// 模拟 API 调用
await api.submitForm(formData)
ElMessage.success('提交成功')
}
const handleSave = async (data: any) => {
await api.saveData(data)
ElMessage.success('保存成功')
}
</script>
六、优势对比
传统方式
<template>
<el-button
type="primary"
:loading="loading"
:disabled="loading"
@click="handleSubmit">
提交
</el-button>
</template>
<script setup lang="ts">
const loading = ref(false)
const handleSubmit = async () => {
if (loading.value) return
loading.value = true
try {
await api.submit()
} finally {
loading.value = false
}
}
</script>
问题:
- 需要手动管理 loading 状态
- 每个按钮都要写重复代码
- 容易遗漏 finally 清理逻辑
使用 clickOnce 指令
<template>
<el-button
type="primary"
v-click-once="handleSubmit">
提交
</el-button>
</template>
<script setup lang="ts">
const handleSubmit = async () => {
await api.submit()
}
</script>
优势:
- 代码简洁,无需管理状态
- 自动处理 loading 和禁用
- 统一的用户体验
七、注意事项
- 仅用于异步操作:该指令主要为异步操作设计,同步操作会立即恢复
- 绑定函数必须返回 Promise:对于异步操作,确保函数返回 Promise
-
节流时间固定:当前节流时间为 1.5 秒,可根据需求调整
THROTTLE_TIME常量 - 依赖 Element Plus:使用了 Element Plus 的 Loading 图标和样式
八、适用场景
✅ 适合使用:
- 表单提交按钮
- 数据保存按钮
- 文件上传按钮
- API 调用按钮
- 支付确认按钮
❌ 不适合使用:
- 普通导航按钮
- 切换/开关按钮
- 需要快速连续点击的场景(如计数器)
九、指令源码
import type { Directive } from 'vue'
import { createVNode, render } from 'vue'
import { useThrottleFn } from '@vueuse/core'
import { Loading } from '@element-plus/icons-vue'
const THROTTLE_TIME = 1500
const clickOnce: Directive<HTMLButtonElement, () => Promise<unknown> | void> = {
mounted(el, binding) {
const handleClick = useThrottleFn(
() => {
// 如果元素已禁用,直接返回(双重保险)
if (el.disabled) return
// 禁用按钮
el.disabled = true
// 添加 loading 状态
el.classList.add('is-loading')
// 创建 loading 图标容器
const loadingIconContainer = document.createElement('i')
loadingIconContainer.className = 'el-icon is-loading'
// 使用 Vue 的 createVNode 和 render 来渲染 Loading 组件
const vnode = createVNode(Loading)
render(vnode, loadingIconContainer)
// 将 loading 图标插入到按钮开头
el.insertBefore(loadingIconContainer, el.firstChild)
// 将 loading 图标存储到元素上,以便后续移除
;(el as any)._loadingIcon = loadingIconContainer
;(el as any)._loadingVNode = vnode
// 执行绑定的函数(应返回 Promise 或普通函数)
const result = binding.value?.()
const removeLoading = () => {
el.disabled = false
// 移除 loading 状态
el.classList.remove('is-loading')
const icon = (el as any)._loadingIcon
if (icon && icon.parentNode === el) {
// 卸载 Vue 组件
render(null, icon)
el.removeChild(icon)
delete (el as any)._loadingIcon
delete (el as any)._loadingVNode
}
}
// 如果返回的是 Promise,则在完成时恢复;否则立即恢复
if (result instanceof Promise) {
result.finally(removeLoading)
} else {
// 非异步操作,立即恢复(或根据需求决定是否恢复)
// 通常建议只用于异步操作,所以这里也可以不处理,或给出警告
removeLoading()
}
},
THROTTLE_TIME,
)
// 将 throttled 函数存储到元素上,以便在 unmount 时移除
;(el as any)._throttledClick = handleClick
el.addEventListener('click', handleClick)
},
beforeUnmount(el) {
const handleClick = (el as any)._throttledClick
if (handleClick) {
el.removeEventListener('click', handleClick)
// 取消可能还在等待的 throttle
handleClick.cancel?.()
delete (el as any)._throttledClick
}
},
}
export default clickOnce
十、总结
clickOnce 指令通过封装防重复点击逻辑,提供了一个开箱即用的解决方案,让开发者可以专注于业务逻辑,而不用担心重复点击的问题。它结合了节流、状态管理和视觉反馈,为用户提供了更好的交互体验。
这应该是前端转后端最简单的办法了,不买服务器、不配 Nginx,也能写服务端接口,腾讯云云函数全栈实践
2025 提效别再卷了:当我把 AI 当“团队”,工作真的顺了
lecen:一个更好的开源可视化系统搭建项目--页面设计器(表单设计器)--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人
2026最新React技术栈梳理,全栈必备
表格组件封装详解(含完整代码)
Tauri (24)——窗口在隐藏期间自动收起导致了位置漂移
背景
在桌面端应用中,我们为 SearchChat 设计了一种「紧凑模式」:
- 正常状态下窗口高度较大
- 当一段时间无操作后,窗口会自动收起到紧凑高度(例如 84px)
- 收起动作由
compactModeAutoCollapseDelay控制,比如 5 秒后触发
整体体验在大多数情况下是正常的,但在一次使用中发现了一个非常隐蔽却影响体验的问题。
问题现象
问题只会出现在一个特定时序下:
- 窗口处于紧凑模式的 “延迟收起倒计时” 中(例如还剩 2~3 秒)
- 用户通过快捷键 主动隐藏窗口
- 延迟计时器仍然在后台触发
- 计时结束后,执行了
setWindowSize收起逻辑 - 用户再次用快捷键唤起窗口
结果是:
窗口位置发生了漂移,不再出现在隐藏前的位置。
这个问题在某些平台或窗口管理器上尤为明显。
问题根因分析
拆开来看,核心原因其实并不复杂:
- 延迟收起逻辑是一个 纯前端的定时器
- 窗口被隐藏后,计时器并不会自动停止
- 计时器触发时,仍然会调用
setWindowSize - 某些平台在「窗口不可见」状态下修改窗口尺寸时,会重新计算窗口位置
- 这个重算过程不是我们可控的
因此,真正的问题不是“收起”本身,而是:
在窗口不可见时发生了尺寸变化,导致系统偷偷帮我们改了位置。
核心设计目标
我们希望做到一件事:
即使窗口在隐藏状态下被触发了尺寸变更,也要保证它在再次显示时,仍然回到隐藏前的位置。
并且要满足几个约束:
- 不侵入现有窗口尺寸策略
- 不依赖平台特性 hack
- 能正确处理高 DPI 场景
- 修改范围尽量小
解决思路(前端侧)
整体方案分为两步。
一、在窗口失焦 / 隐藏时,记录当前位置
当窗口即将被隐藏时,我们可以认为此刻的位置是“用户认可的位置”。
在 SearchChat 中:
- 通过
useTauriFocus的onBlur回调 - 调用
outerPosition()获取当前窗口位置 - 将结果保存到
windowPositionRef
关键点在于:
-
outerPosition()返回的是 physical position - 这个坐标不受 DPI / scale factor 影响
const pos = await window.outerPosition()
windowPositionRef.current = { x: pos.x, y: pos.y }
代码位置:
src/components/SearchChat/index.tsx:113-119
二、延迟收起触发时,如果窗口不可见,强制恢复位置
在自动收起的定时器中:
-
正常执行
setWindowSize -
紧接着判断窗口当前是否可见
-
如果窗口是隐藏状态,并且我们之前记录过位置:
- 主动把窗口位置设回去
伪代码逻辑如下:
await platformAdapter.setWindowSize(width, height)
if (!(await window.isVisible()) && windowPositionRef.current) {
const { x, y } = windowPositionRef.current
await platformAdapter.setWindowPhysicalPosition(x, y)
}
代码位置:
src/components/SearchChat/index.tsx:158-179
这样即使系统在隐藏期间偷偷“动了手脚”,也会被我们立刻纠正。
为什么要用 Physical Position
这里有一个非常容易踩坑的点:DPI 缩放。
-
outerPosition()返回的是 physical position - 项目中原有的
setWindowPosition(x, y)使用的是 logical position - 如果存的是 physical,却用 logical 去设,高 DPI 下会产生明显偏移
因此,我们补充了一个明确的 API:
setWindowPhysicalPosition
Tauri 实现
import { PhysicalPosition, getCurrentWebviewWindow } from '@tauri-apps/api/window'
const win = getCurrentWebviewWindow()
await win.setPosition(new PhysicalPosition(x, y))
代码位置:
src/utils/tauriAdapter.ts:85-89
Web 实现(占位)
Web 模式下不需要真实移动窗口,只保留日志即可:
src/utils/webAdapter.ts:88-90
最终效果
这个方案带来的收益非常明确:
- ✅ 修复隐藏期间自动收起导致的窗口位置漂移
- ✅ 正确处理高 DPI 场景,避免 logical / physical 混用
- ✅ 改动范围小,只在
SearchChat的定时收起路径兜底 - ✅ 不影响其他窗口尺寸或动画策略
手动验证步骤
建议按以下流程验证:
- 设置
compactModeAutoCollapseDelay = 5 - 打开窗口,确保满足进入紧凑模式的条件
- 在 5 秒倒计时期间,使用快捷键隐藏窗口
- 等待超过 5 秒
- 再次用快捷键唤起窗口
预期结果:
窗口应出现在隐藏前的位置,不应发生任何跳动或漂移。
小结
这个问题本质上不是 “窗口 API 用错了”,而是 多个合理行为在特定时序下叠加,暴露出的系统边界问题。
解决它的关键,不是阻止自动收起,而是:
尊重用户最后一次看到的窗口状态,并在必要时为系统行为兜底。
这类问题在桌面端应用中非常常见,也非常容易被忽略,希望这次的整理能对你有所帮助。
从一行好奇的代码说起:Vue怎么没有React的props.children
Axios 常用配置及使用
Axios配置详解
{
常用实例配置项
// `baseURL` 将自动加在 `url` 前面,除非 `url` 是一个绝对 URL。
// 它可以通过设置一个 `baseURL` 便于为 axios 实例的方法传递相对 URL
baseURL: 'https://some-domain.com/api/',
// `timeout` 指定请求超时前的毫秒数。
// 如果请求耗时超过 `timeout`,则请求将被中止。
timeout: 1000, // default is `0` (no timeout)
// `withCredentials`用于指示跨域访问控制请求是否携带凭证
// 请求需要携带token时,需要设置为true
withCredentials: false, // default
常用请求配置项
url: '/user',
method: 'get', // default
headers: {'X-Requested-With': 'XMLHttpRequest'},
// `responseType` 表示服务器将返回的数据类型
// 选项包括:'arraybuffer', 'document', 'json', 'text', 'stream'
// browser only: 'blob'
responseType: 'json', // default
// `params` 是即将与请求一起发送的 URL 参数
// 一般用于get请求携带参数
// 也可post请求时,在url上拼接参数
params: { ID: 12345 },
// `data` 是作为请求主体被发送的数据
// 只适用于这些请求方法 'PUT', 'POST', 和 'PATCH'
data: { firstName: 'Fred' },
不常用配置项
// `transformRequest` 允许在向服务器发送前,修改请求数据
// 只能用在 'PUT', 'POST' 和 'PATCH' 这几个请求方法
// 后面数组中的函数必须返回一个字符串,或 ArrayBuffer,或 Stream
transformRequest: [function (data, headers) {
// 对 data 进行任意转换处理
return data;
}],
// `transformResponse` 在传递给 then/catch 前,允许修改响应数据
transformResponse: [function (data) {
// 对 data 进行任意转换处理
return data;
}],
}
Axios用法
基本用法
axios(config)
axios({
method: 'post',
url: '/user/12345',
data: {
firstName: 'Fred',
lastName: 'Flintstone'
},
...
});
通过别名使用
axios.request(config)
使用起来更简单,方便书写,减少字段的重复书写。
通过别名使用时url、method、data 这些字段名可忽略不写。
header之类的需要指明字段名
// 忽略了method url params 等字段
axios.get('/user?ID=12345')
// 也可以是
axios.get('/user', {
params: {
ID: 12345
}
})
// 忽略了method url data 等字段
axios.post('/user', {
firstName: 'Fred',
lastName: 'Flintstone',
})
针对get请求可以简化为下面这种。method默认就是get
axios('/user/12345');
Axios如何取消请求
Axios注意事项
-
后端返回的长整形数据过长,会导致精度丢失,出现变为0的情况
原因: axios在处理HTTP响应时,默认使用JSON.parse()解析数据,但JavaScript的number类型安全整数范围有限(最大安全值为2^53 - 1,约16位十进制数),超出时会导致精度丢失,常见于后端返回的长整型ID(如雪花算法生成的19位ID)。