普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月31日掘金 前端

祝大家 2026 年新年快乐,代码无 bug,需求一次过

作者 前端Hardy
2025年12月31日 18:05
新年将至,你是否想为亲友制作一个特别的新年祝福页面?今天我们就来一起拆解一个精美的 2026 新年祝福页面的完整实现过程。这个页面包含了加载动画、动态倒计时、雪花特效、悬浮祝福卡片等炫酷效果,完全使用

Vue3 防重复点击指令 - clickOnce

2025年12月31日 17:34

Vue3 防重复点击指令 - clickOnce

一、问题背景

在实际的 Web 应用开发中,我们经常会遇到以下问题:

  1. 用户快速多次点击提交按钮:导致重复提交表单,产生多条相同数据
  2. 异步请求未完成时再次点击:可能导致数据不一致或服务器压力增大
  3. 用户体验不佳:没有明确的加载状态反馈,用户不知道操作是否正在进行

这些问题在以下场景中尤为常见:

  • 表单提交(注册、登录、创建订单等)
  • 数据保存操作
  • 文件上传
  • 支付操作
  • API 调用

二、解决方案

clickOnce 指令通过以下机制解决上述问题:

1. 节流机制

使用 @vueuse/coreuseThrottleFn,在 1.5 秒内只允许执行一次点击操作。

2. 按钮禁用

点击后立即禁用按钮,防止用户再次点击。

3. 视觉反馈

自动添加 Element Plus 的 Loading 图标,让用户明确知道操作正在进行中。

4. 智能恢复

  • 如果绑定的函数返回 Promise(异步操作),则在 Promise 完成后自动恢复按钮状态
  • 如果是同步操作,则立即恢复

三、核心特性

自动防重复点击:1.5秒节流时间
自动 Loading 状态:无需手动管理 loading 变量
支持异步操作:自动检测 Promise 并在完成后恢复
优雅的清理机制:组件卸载时自动清理事件监听
类型安全:完整的 TypeScript 支持

四、技术实现

关键技术点

  1. Vue 3 自定义指令:使用 Directive 类型定义
  2. VueUse 节流useThrottleFn 提供稳定的节流功能
  3. 动态组件渲染:使用 createVNoderender 动态创建 Loading 图标
  4. Promise 检测:自动识别异步操作并在完成后恢复状态

工作流程

用户点击按钮
    ↓
节流检查(1.5秒内只执行一次)
    ↓
禁用按钮 + 添加 Loading 图标
    ↓
执行绑定的函数
    ↓
检测返回值是否为 PromisePromise 完成后(或同步函数执行完)
    ↓
移除 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 和禁用
  • 统一的用户体验

七、注意事项

  1. 仅用于异步操作:该指令主要为异步操作设计,同步操作会立即恢复
  2. 绑定函数必须返回 Promise:对于异步操作,确保函数返回 Promise
  3. 节流时间固定:当前节流时间为 1.5 秒,可根据需求调整 THROTTLE_TIME 常量
  4. 依赖 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年12月31日 17:32
告别服务器购买、域名备案和 Nginx 配置。本文教你利用 腾讯云函数 (SCF) + 函数 URL + rpc-server-scf,后端只写纯业务函数,前端像调用本地方法一样远程交互。

lecen:一个更好的开源可视化系统搭建项目--页面设计器(表单设计器)--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人

作者 晴虹
2025年12月31日 17:12
设计器采用拖拽式可视化编辑方式,分为三大功能区:视图绘制区域(放置页面组件)、组件面板(提供UI组件库)和属性面板(配置组件属性和事件)。整个设计过程无需本地开发环境,直接在网页中完成页面构建和实时

Tauri (24)——窗口在隐藏期间自动收起导致了位置漂移

2025年12月31日 17:01

背景

在桌面端应用中,我们为 SearchChat 设计了一种「紧凑模式」:

  • 正常状态下窗口高度较大
  • 当一段时间无操作后,窗口会自动收起到紧凑高度(例如 84px)
  • 收起动作由 compactModeAutoCollapseDelay 控制,比如 5 秒后触发

整体体验在大多数情况下是正常的,但在一次使用中发现了一个非常隐蔽却影响体验的问题


问题现象

问题只会出现在一个特定时序下:

  1. 窗口处于紧凑模式的 “延迟收起倒计时” 中(例如还剩 2~3 秒)
  2. 用户通过快捷键 主动隐藏窗口
  3. 延迟计时器仍然在后台触发
  4. 计时结束后,执行了 setWindowSize 收起逻辑
  5. 用户再次用快捷键唤起窗口

结果是:

窗口位置发生了漂移,不再出现在隐藏前的位置。

这个问题在某些平台或窗口管理器上尤为明显。


问题根因分析

拆开来看,核心原因其实并不复杂:

  • 延迟收起逻辑是一个 纯前端的定时器
  • 窗口被隐藏后,计时器并不会自动停止
  • 计时器触发时,仍然会调用 setWindowSize
  • 某些平台在「窗口不可见」状态下修改窗口尺寸时,会重新计算窗口位置
  • 这个重算过程不是我们可控的

因此,真正的问题不是“收起”本身,而是:

在窗口不可见时发生了尺寸变化,导致系统偷偷帮我们改了位置。


核心设计目标

我们希望做到一件事:

即使窗口在隐藏状态下被触发了尺寸变更,也要保证它在再次显示时,仍然回到隐藏前的位置。

并且要满足几个约束:

  • 不侵入现有窗口尺寸策略
  • 不依赖平台特性 hack
  • 能正确处理高 DPI 场景
  • 修改范围尽量小

解决思路(前端侧)

整体方案分为两步。

一、在窗口失焦 / 隐藏时,记录当前位置

当窗口即将被隐藏时,我们可以认为此刻的位置是“用户认可的位置”。

SearchChat 中:

  • 通过 useTauriFocusonBlur 回调
  • 调用 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

二、延迟收起触发时,如果窗口不可见,强制恢复位置

在自动收起的定时器中:

  1. 正常执行 setWindowSize

  2. 紧接着判断窗口当前是否可见

  3. 如果窗口是隐藏状态,并且我们之前记录过位置:

    • 主动把窗口位置设回去

伪代码逻辑如下:

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 的定时收起路径兜底
  • ✅ 不影响其他窗口尺寸或动画策略

手动验证步骤

建议按以下流程验证:

  1. 设置 compactModeAutoCollapseDelay = 5
  2. 打开窗口,确保满足进入紧凑模式的条件
  3. 在 5 秒倒计时期间,使用快捷键隐藏窗口
  4. 等待超过 5 秒
  5. 再次用快捷键唤起窗口

预期结果:

窗口应出现在隐藏前的位置,不应发生任何跳动或漂移。


小结

这个问题本质上不是 “窗口 API 用错了”,而是 多个合理行为在特定时序下叠加,暴露出的系统边界问题

解决它的关键,不是阻止自动收起,而是:

尊重用户最后一次看到的窗口状态,并在必要时为系统行为兜底。

这类问题在桌面端应用中非常常见,也非常容易被忽略,希望这次的整理能对你有所帮助。

Axios 常用配置及使用

作者 28256_
2025年12月31日 16:27

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)

使用起来更简单,方便书写,减少字段的重复书写。
通过别名使用时urlmethoddata 这些字段名可忽略不写。
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注意事项

  1. 后端返回的长整形数据过长,会导致精度丢失,出现变为0的情况
    原因: axios在处理HTTP响应时,默认使用JSON.parse()解析数据,但JavaScript的number类型安全整数范围有限(最大安全值为2^53 - 1,约16位十进制数),超出时会导致精度丢失,常见于后端返回的长整型ID(如雪花算法生成的19位ID)。‌
❌
❌