普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月1日技术
昨天 — 2025年12月31日技术

老王请假、客户开喷、我救火:一场递归树的性能突围战

2025年12月30日 08:04

前言:

上周,负责核心业务组件的同事老王突然请假(据说去相亲了),留下一堆代码和风中凌乱的我。

结果前脚刚走,后脚核心客户就炸锅了:“你们这个系统怎么回事?我每次要给员工赋个权,浏览器就直接卡死!打开弹窗挺快,一点开部门就未响应,关掉弹窗还要卡半天!”

看着客户发来的 十几秒卡顿录屏,和老板投来的“和善”目光,我只能硬着头皮接下了这个“锅”。

本文记录了我是如何从吐槽同事代码,到深度排查,最终通过深度优化解决这个 递归组件性能灾难的全过程。

省流版

  • 现象:展开树卡 10s+,关闭弹窗再卡 5s
  • 根因:递归组件隐式全量挂载 + 多实例并发导致 rAF 任务在单帧扎堆 + 高成本 height变化动画
  • 方案:懒加载可见节点 + 以“藏”代“删”(opacity/v-show)+ 合成层优化 (transform: scaleY)

看完本文,你将掌握

  1. 性能侦探能力:如何用 Performance 面板精准定位 Long Task 的“底层元凶”(不只是看红条)。

  2. 分帧渲染陷阱:为什么你写的 requestAnimationFrame 可能会让页面更卡?(含深度图解)。

  3. 高性能交互与动画:利用合成线程进行优化,绕过主线程的样式计算/布局/绘制阶段,实现 0 重排的丝滑展开/收起与销毁

一、 客户现场还原(案发经过)

授权弹窗虽然实现了秒开,但一旦点击展开根节点,瞬间卡死,足足卡顿数秒后才渲染出子节点。

组织架构树响应慢问题排查.gif

业务背景

客户正在使用我们的 “企业级权限管理系统”

出问题的组件是一个核心的 组织架构选择器(TreeSelect) ,它被嵌入在一个高频使用的 “授权弹窗” 中。

  • 高频场景:管理员需要频繁地给不同的员工或部门分配权限。

  • 操作路径:点击“可见范围” → 弹出授权弹窗 → 在树形组件中找到对应部门/人员 → 勾选 → 确定/关闭。

数据规模

该客户是中大型企业,全量组织架构节点约为 2700 个(包含多级部门和人员)。这个数量级在 ToB 业务中其实不算特别大,但足以压垮未经优化的代码。

故障现象

  1. 打开弹窗(快) :点击授权按钮,弹窗秒出(老王用了分帧渲染进行首屏优化,这点挺好)。

  2. 寻找部门(卡死) :管理员试图展开根部门去寻找下级单位,点击小三角的瞬间,界面失去响应,Long Task 持续 10s+ 。管理员以为死机了,疯狂点击,结果还是没反应。

  3. 关闭/取消(卡死) :好不容易选完了,点击“确定”或“关闭”弹窗,界面再次假死 4-5s,才能关掉弹窗。

二、 深度侦查:谁在谋杀主线程?

工程化思考:线上监控能发现吗?

Sentry 或其它性能监控能抓到这个问题吗?

线上监控通常只能告诉你 “页面卡了”(监控到 Long TaskINP 指标飙升),但很多时候难告诉你具体的 “为什么卡”。 要确诊是 JS 逻辑阻塞,还是 CSS 动画引发的 Reflow (重排),必须依赖本地 Chrome DevTools 的 Performance 面板 进行“CT 扫描”。

💡 小贴士:如何使用 Performance? 打开 F12 开发者工具 -> 切换到 Performance 面板 -> 点击左上角“圆点”开始录制 -> 在页面操作复现卡顿 -> 点击 Stop 停止,即可生成分析报告。

我在本地 Mock 了 2700 条左右的数据,模拟了“打开 -> 展开 -> 关闭”的全流程,打开 Chrome Performance 一看,好家伙,红得跟过年一样。

第一步:看“心电图” (Performance 面板)

image.png

现象

很多人看到 Performance 密密麻麻的图表就头晕,其实诀窍就一句话:从上往下看

  1. 看顶部(Main 线程):红色的色块代表 Long Task(长任务),是导致页面卡顿的直接元凶。
  2. 看中间(调用栈):像倒置的火焰一样,越宽代表耗时越久,越往下代表函数调用越深。
  3. 找凶手:顺着红条往下找,最底下的那个“宽条条”,通常就是罪魁祸首。

颜色图例(快速看懂图)

  • 红色斜纹:Long Task,主线程连续超 50ms
  • 黄色:JavaScript 执行(事件、定时器、requestAnimationFrame)
  • 紫色:样式与布局(Recalculate Style、Layout)
  • 绿色:绘制与合成(Paint、Composite)
  • 看到“Animation Frame Fired + Run Microtasks”连续出现,通常是每帧都在做很多 JS,并在帧内清空 Promise/nextTick,导致超预算

回到本案分析

  1. 顶部报警:最显眼的是一条红色斜纹带,说明主线程一直处于阻塞状态。

  2. 中间密集:顺着红色区域往下看,全是密密麻麻的黄色小条,提示 Animation Frame Fired(动画帧requestAnimationFrame触发)。

  3. 底部实锤:继续顺着调用栈 从上往下 找,能看到频繁的 cloneNode(克隆节点)调用。这说明核心耗时在 DOM 操作,浏览器在不断创建新元素。

结论:核心耗时在 DOM 操作,浏览器在疯狂加班造 DOM。cloneNode 就像是在复印文件,这说明代码在没完没了地制造新的页面元素,而且是一刻不停地在造,直接把主线程干趴下了。

第二步:顺藤摸瓜 (找代码)

image.png

线索: Performance 面板明确指出了凶手是 ReSubMenu.vue 里的 renderVisibleData 函数。

代码长这样

// 这是一个分帧渲染函数
function renderVisibleData(data, index = 0) {
  // 1. 先渲染一小部分(比如 20 个)
  visibleChildrenData.value.push(...data.slice(index, index + 20));

  // 2. 如果还有数据没渲染完,申请下一帧接着干
  if (还剩有数据) {
    requestAnimationFrame(() => {
      renderVisibleData(data, index + 20) // <--- 案发地点!
    })
  }
}

原本的算盘: 写这段代码的初衷是好的——试图利用 “分帧渲染” 技术来优化 首屏渲染体验不想让用户盯着白屏干等整棵树渲染完才看到,而是让第一帧渲染出的节点立刻显示给用户看

也就是代码里表达的意思:“浏览器大哥,您别急,先给用户看 20 个解解馋,剩下的咱们每帧画 20 个,慢慢来。”

💡 知识点:什么是分帧渲染?

浏览器的渲染就像一条流水线,比如我们的屏幕刷新率是 60Hz,那么屏幕每秒钟能刷新 60 次(即 60FPS),这样画面才流畅。这意味着每一帧的时间只有 16.6ms (1000ms / 60 ≈ 16.66ms)。

如果你一口气要在页面上画几千个节点,需要耗时就不止 16 毫秒。在渲染这几千个节点的过程中,浏览器的主线程被完全霸占,这时候浏览器就没空理会用户的点击、滚动,这就叫 “掉帧”“卡死”

requestAnimationFrame (rAF) 就是为了解决这个问题。它的作用是:“把大任务切碎”

这里的逻辑是:

  1. 这一帧,我先画 20 个节点,让用户 立刻看到东西(减少首屏等待)。
  2. 剩下的数据,我预约在 “下一帧” (requestAnimationFrame) 再慢慢画。
  3. 这样,既能快速展示内容,又能让浏览器每一帧都有空闲时间去响应用户的操作。

但奇怪的是,明明已经用了分帧渲染,为什么 Performance 面板里还是满屏红色的 Long Task?为什么点击展开时浏览器依然卡死了?

这就得说到那个被忽略的致命细节了……“分帧失效”的真正原因,请看下面的第三步

第三步:真相大白 (逻辑漏洞)

问题出在哪? 看似优雅的“慢慢来”,忽略了一个致命的前提:递归组件的叠加效应

我们的树形组件结构大概是这样的(典型的无限套娃):

  • 入口 (AuthorizedMenu.vue)
    • TreeSelect (外层容器)
      • ReSubMenu (递归的开始)
        • SubMenu (动画/折叠)
          • MenuItem (节点项)
          • ReSubMenu (子节点 -> 套娃开始)

这意味着,如果你的树数据有 5 层深,组件就会自己把自己嵌套 5 层。

我又看了一眼负责展开收起菜单动画的父组件 SubMenu.vue

<!-- 这里只用了 CSS 控制高度来折叠,没有用 v-if -->
<div class="subItem" :style="{ height: ... }">
  <slot></slot>
</div>

案情还原

  1. 看不见的“大军”: 界面上菜单虽然是收起的,但因为没加 v-if 控制按需加载,整棵树 2700 多个节点其实都在后台悄悄挂载了。这就好比你只点了一盘花生米,后厨却把满汉全席都备好了。

  2. 分帧策略失效: 老王的“分帧渲染”本意是好的:“大家别急,排好队,一帧画 20 个。” 但在递归组件中,几百个非叶子组件实例是同时启动的

  3. 菜市场效应

    • 第一层组件喊:“浏览器老师,麻烦帮我画 20 个!”
    • 第二层组件也喊:“我也要画 20 个!”
    • 第 N 层组件齐声喊:“还有我!还有我!”

结果:浏览器瞬间懵了。虽然每个人只请求画 20 个,但这几百个组件同时请求,瞬间就堆积了几千个 cloneNode 任务。这就好比几百只鸭子同时在叫,主线程直接被高并发的 DOM 操作给冲垮了。

你可能会问:“JS 不是单线程的吗?不是按顺序执行吗?怎么会‘并发’呢?”

  • 没错,JS 是单线程。但问题在于,Vue 的 v-for 循环是同步执行的

  • Vue 的机制是:必须等 v-for 这一轮循环彻底跑完,所有的子组件都初始化完毕(setup 执行结束),才会把控制权交还给浏览器去进行样式计算和绘制。

  • 这就导致了一个严重的后果:

    • 树组件中的所有节点(尤其是有子级的非叶子节点)在这一轮同步循环中,都启动了自己的分帧渲染逻辑,纷纷向浏览器申请:“下一帧请运行我的渲染回调”。

过程拆解如下:

  1. 同步初始化: Vue 遇到递归结构时,会一口气同步创建所有子组件实例。假设有 100 个子菜单,Vue 就会同步执行 100 次 setup 函数,中间不会停顿。

  2. 集体预约分帧渲染: 这 100 个组件在初始化时,都立刻启动了自己的分帧渲染逻辑,每个人都向浏览器申请了:“下一帧请运行我的渲染函数”。

  3. 扎堆执行: 到了下一帧,浏览器一看 requestAnimationFrame 的任务队列——好家伙,里面整整齐齐排了 100 个回调任务! 虽然 JS 是挨个执行这些任务的,但它必须把这 100 个回调任务全部跑完才能去刷新屏幕。

最终结果就是

  • 理想情况:1 个组件分帧 → 每帧耗时 2ms左右 → ✅ 丝滑流畅
  • 实际情况:100 个组件在同一帧里扎堆干活 → 总耗时 > 200ms → ❌ 严重掉帧 (Long Task)

这才是“分帧失效”的真正原因:同一帧的 rAF 回调队列被大量组件“同时”塞满,导致单帧工作量远远超出 16.66ms 的预算。

这也完美解释了为什么 Performance 火焰图 顶部会出现那条显眼的 红色长条 —— 它不仅仅是一个长任务,而是 n 多个组件中的分帧回调在这一帧内“排队”连续执行所耗费的总时长

你可能会有疑问:“rAF 不是宏任务吗?不应该是执行一个歇一下吗?”

  • 关键误区就在这! 准确地说,requestAnimationFrame 的回调不属于我们常说的“宏任务队列 (Macrotask Queue)”,它有自己独立的 “动画帧回调队列”。 根据 HTML 事件循环标准,这个队列的执行时机非常特殊:它夹在 “JS 执行”“样式计算/布局” 之间。

  • 致命的“批次执行”机制: 浏览器在这一帧的渲染更新阶段会连续执行本帧的所有 rAF 回调,执行完才进入样式/布局。也就是说,如果同一帧登记了 100 个 rAF,它们会在本帧内连续跑完,而不是留到后续帧。

    • :浏览器在刷新屏幕前会清空本帧的 rAF 队列,整体表现为一段连续的脚本执行;若本帧总耗时超过 50ms,就会被标记为 Long Task(否则只是普通task片段,不显示红条)。

在我们的场景里,这段连续执行远超 50ms,所以 DevTools 顶部标记为 Long Task

为了更直观地理解,我画了一张图来解释下:

image.png

三、 紧急救援:学会“偷懒”

吐槽归吐槽,Bug 还是得修。解决办法就是三个字:懒加载

核心逻辑

看不见的菜单,坚决不渲染! 只有当用户点击“展开”按钮那一刻,我才开始去渲染子菜单。

代码改动

把原来的“一上来就渲染”改成“盯着开关看”:

// ReSubMenu.vue

// 只有当 isExpand (展开状态) 变成 true 时,才开始干活
watch(
  () => props.data.isExpand,
  val => {
    if (val) {
      // 只有展开时,且之前没渲染过,才启动分帧渲染
      if (visibleChildrenData.value.length === 0) {
        renderVisibleData(props.data?.children)
      }
    } else {
       visibleChildrenData.value = []
    }
  },
  { immediate: true }
)

遗留问题:关闭弹窗卡死

关闭弹窗慢问题排查.gif

客户之前反馈“关闭弹窗也卡”,是因为同事老王的代码让组件一开始加载了几千个节点,内存中就堆积了几千个复杂的组件实例。

点击关闭弹窗的那一刻,主线程被 GC(垃圾回收)和 Vue组件的卸载任务(beforeUnmount / unmounted等)瞬间挤爆。

截图证据

image.png

大家看这张截图,关闭弹窗的一瞬间,removeChild 操作竟然耗时 5.41s!这意味着浏览器主线程被这个巨大的“拆迁工程”彻底堵死,用户只能对着屏幕发呆。

四、 深度优化方案

单纯的懒加载解决了“初始化”问题,但为了达到极致体验,我们还引入了其他维度的优化。

1. 渲染性能优化:用 ScaleY 替代 Height 动画

排查

  • 我看了下 CSS,菜单的展开/收起是用 transition: height 做的“窗帘”效果。

  • 每一级菜单都是改高度 height(收起为 0,展开为节点内容高度)。

  • height 是布局属性,动它就会触发 Reflow(重排),浏览器需要把受影响的布局链路重新算一遍。

  • 递归树会层层传导:你点开一层会牵动下面整段子树一起算,链路很长,低端机分分钟卡住

展开节点卡顿现象图:

展开菜单卡顿.gif

优化: 改为使用 CSS3 的 transform: scaleY,同样可以达到丝滑展开的效果。

简单理解 使用ScaleY实现展开动画效果的原理

  • 变的是什么? scaleY 改变的是元素在 Y 轴(竖向) 的缩放比例。
  • 怎么变?0(压扁成一条线,完全看不见)过渡到 1(拉伸回原本的高度)。
  • 为什么像展开? 配合 transform-origin: top(固定顶部),就像把卷帘门从上往下拉下来一样,视觉上就是完美的“展开”效果。

深度原理transform 属性不会触发 Reflow,只会触发 Composite (合成),这个过程完全由 GPU 处理,不占用主线程 CPU 资源。

浏览器渲染三兄弟

  • Reflow (重排):牵一发而动全身。修改 heightwidth 时,浏览器要重新计算所有元素位置,开销最大
  • Repaint (重绘):换汤不换药。修改 colorbackground 时,不影响位置,只重画样子,开销中等
  • Composite (合成)VIP 绿色通道。修改 transformopacity 时,浏览器直接把图层交给 GPU 处理,跳过布局和绘制,开销最小
/* 优化前:卡顿源头 */
.subItem {
  transition: height 0.3s ease-in-out;
}

/* 优化后:丝滑无比 */
.subItem {
  transform-origin: top; /* 确保从顶部开始展开 */
  transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}

.subItem.expanded {
  transform: scaleY(1);
}

.subItem.collapsed {
  transform: scaleY(0);
}

效果: 动画流畅、交互响应即时。(GIF图压缩严重,请自动脑补德芙广告的丝滑质感)

节点展开动画优化.gif

⚠️ 避坑指南:关于 will-change:

有同学可能会问:“为什么不加 will-change: transform,开启gpu加速?”

  • 少量元素时可以考虑;大规模递归树千万别全局加。
  • 参考 MDN:不到不得已不要使用;仅在确有必要时短时添加,过度使用会导致内存占用增加与性能下降。
  • 文档:developer.mozilla.org/zh-CN/docs/…
  • 建议:如需使用,按需、短时、少量元素,并在动画结束后移除。

2. 销毁层:以“藏”代“删”

痛点: 即使用户只是临时关闭弹窗,下次还要再开,原来的逻辑也是直接 v-if="false" 销毁整个组件树。这就导致了那 5 秒的 removeChild 卡顿。

优化: 对于这种巨型组件,关闭弹窗时不要销毁它,而是把它“藏起来”。

代码改动: 将弹窗的显隐控制从 v-if 改为 v-show,或者使用透明度方案:

v-if 、 Opacity、 v-show的区别

  • v-if:真正的条件渲染。切换时,组件及其内部所有的事件监听器和子组件都会被销毁和重建。对于 2500 个节点的树,这意味着成千上万次的 DOM 插入/删除操作。

  • v-show:简单的 CSS 切换(display: none)。组件实例始终保留,开销极小。

  • Opacity (透明)opacity: 0。DOM 保留,配合 GPU 加速仅触发合成 (Composite),切换成本最低(本案最终方案)。

/* 只是看不见,但 DOM 还在,避免了昂贵的卸载过程 */
.dialog-hidden {
  opacity: 0;
  pointer-events: none; /* 确保点不到 */
  z-index: -1;
}

效果: 关闭弹窗时,耗时从 5s 瞬间变为 0ms(只是改了个 CSS 属性)。下次再打开时,因为 DOM 都在,直接恢复透明度即可,实现了真正的“秒开”。

3. 交互层:手风琴模式(Accordion)

🤔 什么是手风琴效果?

简单来说,就是 同一级只有一个节点展开。 就像手风琴的风箱,拉开这一折,那一折自然合上。比如财务部和研发部是兄弟节点,当你点击展开“财务部”的子节点,之前点开的“研发部”会自动收起。

我们先来看优化后的效果图:

手风琴效果.gif

痛点: ToB 系统的用户有时操作很“野”,他们可能会把所有部门一层层全部点开。 如果不加限制,随着用户不断展开,页面上的 DOM 节点数量依然会无限制增长,最终再次拖慢浏览器。

优化: 为组件增加 accordion(手风琴)模式配置。 原理:开启后,同级节点同时只能展开一个。当你展开“财务部”时,之前展开的“研发部”会自动收起。

代码示例

// 伪代码:手风琴逻辑
function onNodeExpand(node) {
  if (accordionMode) {
    // 兄弟节点,统统收起!
    siblings.forEach(sib => {
      if (sib !== node) sib.isExpand = false
    })
  }
  node.isExpand = true
}

这从交互设计层面对 DOM 峰值设定了上限:不管怎么点,同级只保留一个展开项,页面同时存在的 DOM 数始终处于低位且可控。

番外篇:如果数据量再大 10 倍怎么办?

虽然这次 2000多条数据搞定了,但肯定有小伙伴会问:“如果有 2万多条 甚至更多怎么办?”

这时候,单纯的懒加载也不够用了,因为 DOM 节点的总数依然可能突破浏览器极限。我们需要引入核武器——虚拟树 (Virtual Tree)

简单来说,就是只渲染你屏幕里看得到的那些节点。 不管树有几万层,屏幕就那么高(比如 800px),我只渲染这几十个节点,其他的用个空 div 撑开高度把滚动条骗过去就行。

思路也很直白

  1. 拍平:把树拆成一个大的一维数组。
  2. 计算:算算当前滚动条在什么位置,对应数组里的哪几条数据。
  3. 渲染:只把这几条画出来,绝对定位到正确的地方。

那为什么这次没用? 还是那句老话:ROI(投入产出比)

手写虚拟树要处理动态高度、复选框联动等难点,头发都要掉一把;引入现成的库又会增加包体积。 对于 几千条数据,现在的方案已经够用了,再上虚拟树就是“大炮打蚊子”,没必要增加维护成本。

技术选型没有银弹,只有最适合当下的方案。

五、 优化结果与客户反馈

效果图:

优化后结果.gif

我们将优化后的补丁发给客户验证:

  1. 打开弹窗:保持秒开。
  2. 寻找部门(展开):从 十几秒卡死 变为 即时响应
  3. 完成授权(关闭):从 5s 卡死 变为 0 延迟

Performance 面板再次查看,那条心电图终于平稳了,只有零星的几个小波峰,代表正常的渲染任务。

客户反馈:“终于顺畅了,这才是专业系统该有的样子。”(老板终于露出了满意的微笑)

六、 总结:一点心里话

这次“救火”经历其实挺典型的。

刚接手时,看着那密密麻麻的代码和满屏的红色 Long Task,心里确实有点发怵。但静下心来,用 Performance 面板这把“手术刀”切下去,病灶其实很清晰:无节制的 DOM 操作昂贵的重排开销

回顾一下咱们这趟“排雷”之旅:

  1. 排查靠证据:Performance 面板诚不欺我,一眼就看到了 cloneNodeRecalculate Style 在疯狂作案。
  2. 手段要精准
    • 懒加载:别贪多,用多少拿多少,把几千个节点的并发压力拆解到每一次点击中。
    • 合成层优化:能用 GPU 解决的动画,坚决不麻烦 CPU,scaleYopacity 真是好东西。
    • 策略先行:有时候技术手段到了瓶颈,换个交互思路(比如手风琴模式),问题就迎刃而解了。

其实做性能优化,最忌讳的就是“凭感觉瞎猜”。这次虽然没用上高大上的虚拟列表(Virtual List),但对于当前的数据量级,这套组合拳不仅成本最低,效果也最好。

最后,老王回来后,我必须得请他吃顿好的——毕竟没有他这代码,我也没机会写这篇几千字的复盘文章,更没机会在掘金骗大家的赞(手动狗头)。

如果你觉得这篇文章对你有启发,欢迎 点赞、收藏、转发,这对我很重要!

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

作者 前端Hardy
2025年12月31日 18:05

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


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

代码讲解

雪花生成算法

function createSnowflake(container, index) {
  const snowflake = document.createElement('div');

  // 随机属性:大小、位置、时长
  const size = Math.random() * 5 + 2;
  const x = Math.random() * 100;
  const duration = Math.random() * 10 + 5;
  const delay = Math.random() * 5;

  // 设置基础样式
  snowflake.classList.add('snowflake');
  snowflake.style.left = `${x}%`;
  snowflake.style.width = `${size}px`;
  snowflake.style.height = `${size}px`;
}

雪花飘落动画

snowflake.animate(
  [
    { transform: 'translate(0, -20px) rotate(0deg)' },
    { transform: `translate(${Math.sin(duration)*50}px, 100vh) rotate(90deg)` },
    { transform: `translate(${-Math.sin(duration)*25}px, 100vh) rotate(180deg)` },
    { transform: 'translate(0, 100vh) rotate(360deg)' },
  ],
  {
    duration: duration * 1000,
    delay: delay * 1000,
    iterations: Infinity,
    easing: 'linear'
  }
);
  • 使用 Math.sin()制造左右摆动的飘落路径
  • 为每个雪花设置不同的延迟和时长,增加真实感
  • 无限循环(Infinity)实现持续飘落
  • 响应式设计:根据屏幕宽度调整雪花数量

时间计算逻辑

function updateCountdown() {
  const now = new Date();
  const newYear = new Date(2026, 0, 1, 0, 0, 0, 0);
  const difference = newYear.getTime() - now.getTime();

  // 计算天、时、分、秒
  const days = Math.floor(difference / (1000 * 60 * 60 * 24));
  const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
  const minutes = Math.floor((difference / 1000 / 60) % 60);
  const seconds = Math.floor((difference / 1000) % 60);
}

数字格式化

// 两位数格式化
document.getElementById('seconds').textContent =
  String(seconds).padStart(2, '0');
  • 使用 setInterval(updateCountdown, 1000)实现秒级更新
  • padStart()确保始终显示两位数字
  • 防抖处理避免性能问题

自定义动画定义

@keyframes float {
  0% { transform: translateY(0px); }
  50% { transform: translateY(-15px); }
  100% { transform: translateY(0px); }
}

@keyframes glow {
  0%, 100% {
    text-shadow: 0 0 5px rgba(255, 215, 0, 0.5);
  }
  50% {
    text-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
  }
}

一键复制源码

<!doctype html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>2026新年祝福 - 元旦快乐</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
        crossorigin="anonymous" />
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
        // Tailwind CSS 配置
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        primary: '#ef4444',
                        secondary: '#f59e0b',
                    },
                    fontFamily: {
                        sans: ['Inter', 'system-ui', 'sans-serif'],
                    },
                },
            }
        }
    </script>
    <style type="text/tailwindcss">
        @layer utilities {
        .content-auto {
          content-visibility: auto;
        }
        .text-shadow-glow {
          text-shadow: 0 0 10px rgba(255, 215, 0, 0.7), 0 0 20px rgba(255, 215, 0, 0.5);
        }
        .animate-float {
          animation: float 4s ease-in-out infinite;
        }
        .animate-float-delay-1 {
          animation: float 4s ease-in-out 1s infinite;
        }
        .animate-float-delay-2 {
          animation: float 4s ease-in-out 2s infinite;
        }
        .animate-float-delay-3 {
          animation: float 4s ease-in-out 3s infinite;
        }
        .animate-pulse-soft {
          animation: pulseSoft 2s ease-in-out infinite;
        }
        .animate-fade-in {
          animation: fadeIn 1s ease-out forwards;
        }
        .animate-slide-up {
          animation: slideUp 1s ease-out forwards;
        }
        .animate-slide-up-delay-1 {
          animation: slideUp 1s ease-out 0.3s forwards;
        }
        .animate-slide-up-delay-2 {
          animation: slideUp 1s ease-out 0.6s forwards;
        }
        .animate-slide-up-delay-3 {
          animation: slideUp 1s ease-out 0.9s forwards;
        }
        .animate-scale-in {
          animation: scaleIn 0.5s ease-out forwards;
        }
        .animate-glow {
          animation: glow 2s ease-in-out infinite;
        }
      }

      @keyframes float {
        0% { transform: translateY(0px); }
        50% { transform: translateY(-15px); }
        100% { transform: translateY(0px); }
      }

      @keyframes pulseSoft {
        0% { opacity: 0.7; }
        50% { opacity: 1; }
        100% { opacity: 0.7; }
      }

      @keyframes fadeIn {
        from { opacity: 0; }
        to { opacity: 1; }
      }

      @keyframes slideUp {
        from { opacity: 0; transform: translateY(50px); }
        to { opacity: 1; transform: translateY(0); }
      }

      @keyframes scaleIn {
        from { opacity: 0; transform: scale(0.9); }
        to { opacity: 1; transform: scale(1); }
      }

      @keyframes glow {
        0%, 100% { text-shadow: 0 0 5px rgba(255, 215, 0, 0.5); }
        50% { text-shadow: 0 0 20px rgba(255, 215, 0, 0.8); }
      }

      /* 全局样式 */
      html, body {
        height: 100%;
        overflow-x: hidden;
        scroll-behavior: smooth;
      }

      body {
        margin: 0;
        padding: 0;
      }

      /* 雪花样式 */
      .snowflake {
        position: absolute;
        background-color: white;
        border-radius: 50%;
        pointer-events: none;
        z-index: 0;
      }

      /* 加载动画 */
      .loading-screen {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: #dc2626;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 9999;
        transition: opacity 0.8s ease-out;
      }

      .loading-hidden {
        opacity: 0;
        pointer-events: none;
      }
    </style>
</head>

<body class="bg-gradient-to-b from-red-800 via-red-700 to-red-900 text-white">
    <!-- 加载动画 -->
    <div id="loading-screen" class="loading-screen">
        <h1 class="text-4xl font-bold text-white" id="loading-text">2026</h1>
    </div>

    <!-- 主要内容容器 -->
    <div class="relative min-h-screen overflow-hidden">
        <!-- 雪花容器 -->
        <div id="snow-container" class="fixed inset-0 pointer-events-none z-0"></div>

        <!-- 装饰星星 -->
        <div class="absolute top-10 right-10 w-20 h-20">
            <div class="animate-float">
                <i class="fa-solid fa-star text-yellow-300 text-4xl animate-pulse-soft"></i>
            </div>
        </div>

        <div class="absolute bottom-20 left-10 w-16 h-16">
            <div class="animate-float-delay-1">
                <i class="fa-solid fa-star text-yellow-300 text-3xl animate-pulse-soft"></i>
            </div>
        </div>

        <!-- 主要内容 -->
        <div class="container mx-auto px-4 py-16 relative z-10">
            <!-- 标题部分 -->
            <div class="text-center mb-12 opacity-0 animate-slide-up">
                <h1
                    class="text-4xl sm:text-6xl md:text-7xl font-bold mb-4 bg-gradient-to-r from-yellow-300 to-yellow-500 bg-clip-text text-transparent animate-glow">
                    2026新年快乐!
                </h1>
                <p class="text-xl sm:text-2xl text-yellow-100 opacity-0" id="subtitle">
                    愿您新的一年里心想事成,万事如意
                </p>
            </div>

            <!-- 倒计时部分 -->
            <div class="text-center mb-16 opacity-0 animate-slide-up-delay-1">
                <h2 class="text-2xl sm:text-3xl font-semibold mb-6">距离2026年还有</h2>
                <div class="grid grid-cols-4 gap-4 sm:gap-8" id="countdown-container">
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="days">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="hours">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="minutes">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="seconds">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                </div>
            </div>

            <!-- 祝福卡片部分 -->
            <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16 opacity-0 animate-slide-up-delay-2">
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:rotate-1 cursor-pointer opacity-0"
                    id="wish-card-1">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">身体健康,万事如意</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:-rotate-1 cursor-pointer opacity-0"
                    id="wish-card-2">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">事业有成,财源广进</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:-rotate-1 cursor-pointer opacity-0"
                    id="wish-card-3">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">家庭幸福,平安顺遂</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:rotate-1 cursor-pointer opacity-0"
                    id="wish-card-4">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">心想事成,吉祥如意</p>
                    </div>
                </div>
            </div>

            <!-- 新年图片 -->
            <div
                class="relative mx-auto max-w-2xl rounded-2xl overflow-hidden shadow-2xl mb-16 opacity-0 animate-slide-up-delay-3">
                <img src="https://space.coze.cn/api/coze_space/gen_image?image_size=landscape_16_9&prompt=New%20Year%202026%2C%20celebration%2C%20fireworks%2C%20happy%20people%2C%20chinese%20new%20year%20style&sign=7480eb84f78aa7d9bd5edaf5dc5aad19"
                    alt="2026新年庆祝"
                    class="w-full h-auto rounded-2xl transform transition-transform hover:scale-105 duration-700" />
                <div class="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent flex items-end">
                    <p class="text-white p-6 text-lg">祝大家2026年元旦快乐!</p>
                </div>
            </div>

            <!-- 按钮部分 -->
            <div class="flex flex-wrap justify-center gap-4 opacity-0 animate-slide-up-delay-3">
                <button
                    class="bg-gradient-to-r from-yellow-500 to-amber-500 text-red-900 font-bold py-3 px-8 rounded-full text-lg shadow-lg transform transition-transform duration-300 hover:scale-105 hover:shadow-lg hover:shadow-yellow-500/20 active:scale-95"
                    id="share-button">
                    <i class="fa-solid fa-share-alt mr-2"></i>分享祝福
                </button>
                <button
                    class="bg-transparent border-2 border-yellow-500 text-yellow-500 font-bold py-3 px-8 rounded-full text-lg transform transition-all duration-300 hover:bg-yellow-500/10 hover:scale-105 active:scale-95"
                    id="music-button">
                    <i class="fa-solid fa-music mr-2"></i>播放音乐
                </button>
            </div>

            <!-- 底部装饰 -->
            <div class="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-red-900 to-transparent opacity-0 animate-fade-in"
                id="bottom-decoration"></div>

            <!-- 底部文字 -->
            <footer class="text-center mt-20 text-white/60 text-sm opacity-0 animate-fade-in" id="footer">
                <p>© 2025 新年祝福页面 | 祝您新年快乐,阖家幸福</p>
            </footer>
        </div>
    </div>

    <script>
        // 页面加载动画
        document.addEventListener('DOMContentLoaded', function () {
            const loadingScreen = document.getElementById('loading-screen');
            const loadingText = document.getElementById('loading-text');
            const subtitle = document.getElementById('subtitle');
            const countdownItems = document.querySelectorAll('#countdown-container > div');
            const wishCards = document.querySelectorAll('[id^="wish-card-"]');
            const bottomDecoration = document.getElementById('bottom-decoration');
            const footer = document.getElementById('footer');

            // 加载文字动画
            loadingText.animate([
                { transform: 'scale(1)' },
                { transform: 'scale(1.2)' },
                { transform: 'scale(1)' }
            ], {
                duration: 1000,
                iterations: Infinity
            });

            // 500ms后隐藏加载屏幕,显示主内容
            setTimeout(() => {
                loadingScreen.classList.add('loading-hidden');

                // 显示副标题
                setTimeout(() => {
                    subtitle.classList.add('animate-fade-in');
                }, 500);

                // 显示倒计时项
                countdownItems.forEach((item, index) => {
                    setTimeout(() => {
                        item.classList.add('animate-scale-in');
                    }, 800 + index * 200);
                });

                // 显示祝福卡片
                wishCards.forEach((card, index) => {
                    setTimeout(() => {
                        card.classList.add('animate-scale-in');
                    }, 1600 + index * 200);
                });

                // 显示底部装饰和页脚
                setTimeout(() => {
                    bottomDecoration.classList.add('animate-fade-in');
                }, 2400);

                setTimeout(() => {
                    footer.classList.add('animate-fade-in');
                }, 2800);
            }, 1000);

            // 初始化倒计时
            updateCountdown();
            setInterval(updateCountdown, 1000);

            // 初始化雪花效果
            createSnowflakes();

            // 按钮事件处理
            document.getElementById('share-button').addEventListener('click', function () {
                alert('祝福已分享!');
            });

            document.getElementById('music-button').addEventListener('click', function () {
                alert('音乐播放功能即将上线!');
            });
        });

        // 倒计时功能
        function updateCountdown() {
            const now = new Date();
            const newYear = new Date(2026, 0, 1, 0, 0, 0, 0);
            const difference = newYear.getTime() - now.getTime();

            if (difference > 0) {
                const days = Math.floor(difference / (1000 * 60 * 60 * 24));
                const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
                const minutes = Math.floor((difference / 1000 / 60) % 60);
                const seconds = Math.floor((difference / 1000) % 60);

                document.getElementById('days').textContent = String(days).padStart(2, '0');
                document.getElementById('hours').textContent = String(hours).padStart(2, '0');
                document.getElementById('minutes').textContent = String(minutes).padStart(2, '0');
                document.getElementById('seconds').textContent = String(seconds).padStart(2, '0');
            } else {
                document.getElementById('days').textContent = '00';
                document.getElementById('hours').textContent = '00';
                document.getElementById('minutes').textContent = '00';
                document.getElementById('seconds').textContent = '00';
            }
        }

        // 雪花效果
        function createSnowflakes() {
            const snowContainer = document.getElementById('snow-container');
            const particleCount = window.innerWidth < 768 ? 20 : 50;

            for (let i = 0; i < particleCount; i++) {
                createSnowflake(snowContainer, i);
            }

            // 窗口大小改变时重新创建雪花
            window.addEventListener('resize', function () {
                while (snowContainer.firstChild) {
                    snowContainer.removeChild(snowContainer.firstChild);
                }
                const newParticleCount = window.innerWidth < 768 ? 20 : 50;
                for (let i = 0; i < newParticleCount; i++) {
                    createSnowflake(snowContainer, i);
                }
            });
        }

        function createSnowflake(container, index) {
            const snowflake = document.createElement('div');
            snowflake.classList.add('snowflake');

            // 随机属性
            const size = Math.random() * 5 + 2;
            const x = Math.random() * 100;
            const duration = Math.random() * 10 + 5;
            const delay = Math.random() * 5;
            const opacity = Math.random() * 0.5 + 0.3;

            // 设置样式
            snowflake.style.left = `${x}%`;
            snowflake.style.top = '-20px';
            snowflake.style.width = `${size}px`;
            snowflake.style.height = `${size}px`;
            snowflake.style.opacity = `${opacity}`;

            // 添加到容器
            container.appendChild(snowflake);

            // 创建动画
            snowflake.animate(
                [
                    { transform: 'translate(0, -20px) rotate(0deg)' },
                    { transform: `translate(${Math.sin(duration) * 50}px, 100vh) rotate(90deg)` },
                    { transform: `translate(${-Math.sin(duration) * 25}px, 100vh) rotate(180deg)` },
                    { transform: 'translate(0, 100vh) rotate(360deg)' },
                ],
                {
                    duration: duration * 1000,
                    delay: delay * 1000,
                    iterations: Infinity,
                    easing: 'linear'
                }
            );
        }
    </script>
</body>

</html>

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

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

🎣 拒绝面条代码!手把手带你用自定义 Hooks 重构 React 世界

2025年12月31日 17:41

💡 写在前面:你是否还在为 React 组件里那一堆乱糟糟的 useStateuseEffect 感到头秃?是否觉得业务逻辑和 UI 代码像缠在一起的耳机线一样难解难分?

别慌,今天咱们不聊虚的。我们要化身“代码外科医生”,拿起 Custom Hooks(自定义 Hooks) 这把手术刀,把业务逻辑从组件里漂亮地剥离出来。

本文将通过两个实战案例——“鼠标追踪器”“硬核 TodoList”,带你从零开始领悟 Hooks 的设计哲学。准备好了吗?发车!🚗💨


🧐 第一章:Hooks 到底是个啥?

在 React 16.8 之前,函数组件就是个“花瓶”,只负责渲染 UI,没有状态(State),也没有生命周期。如果你想搞点复杂的逻辑,就得写那个笨重的 Class 组件,this 指针指来指去,指到你怀疑人生。

Hooks 的出现,就是为了给函数组件注入灵魂。

它是一种函数式编程思想的体现。简单来说,Hooks 就是一堆以 use 开头的魔法函数,它们让函数组件也能拥有状态管理和生命周期处理的能力。

常用“双子星”

在我们开始自定义 Hooks 之前,必须先复习一下两个最基础的 Hooks,因为自定义 Hooks 本质上就是对它们的封装复用

  1. useState状态的容器

    • 它让函数组件有了“记忆”。
    • const [state, setState] = useState(initialValue);
    • 记住:React 的状态更新是**不可变(Immutable)**的,不要直接修改 state,要用 setState 传入新值。
  2. useEffect副作用的管家

    • 什么是副作用?数据获取、订阅事件、修改 DOM... 凡是跟渲染 UI 没直接关系的事儿,都叫副作用。
    • 它相当于 Class 组件里的 componentDidMountcomponentDidUpdatecomponentWillUnmount 的合体。
    • useEffect(() => { ... return () => cleanup }, [dependencies])

好,基础复习完毕。现在我们要搞点高级的——自定义 Hooks

🌟 核心概念:自定义 Hooks 就是一个普通的 JavaScript 函数,但它遵循两个规则:

  1. 名字必须以 use 开头(这是给 React 插件看的,也是给队友看的)。
  2. 它可以调用其他的 Hooks(这是它强大的根本原因)。

🐭 第二章:初试牛刀——打造“如影随形”的鼠标追踪器

想象一下,你接到了一个需求:在页面的任何地方,都要实时显示当前鼠标的坐标。

如果你把逻辑直接写在组件里,你的组件很快就会变得臃肿。如果好几个组件都需要这个功能呢?难道要复制粘贴代码?No! DRY (Don't Repeat Yourself)!

我们要封装一个 Hook。

2.1 顺藤摸瓜:从 App.jsx 看起

先来看看我们在 App.jsx 里是想怎么使用它的:


// 引入我们即将编写的神器
import { useMouse } from './hooks/useMouse.js';

// ... 其他引入

function MouseMove() {
 
  const { X, Y } = useMouse();
  
  return (
    <>
      <div>
        鼠标位置:{X}, {Y}
      </div>
    </>
  )
}

😲 看!这里多么清爽!

我们不需要关心鼠标怎么监听,不需要关心事件怎么销毁

我们只管“拿”数据,这就是“声明式”编程的美妙

组件变得极其纯粹,它只负责渲染。所有的脏活累活,都扔给了 useMouse

2.2 核心解密:useMouse.js

接下来,我们潜入 useMouse.js,看看这个 Hook 内部到底长什么样。


🎯 封装响应式的 mouse 业务

  • 为什么要封装?
  • 因为 UI 组件应该更简单,只负责 HTML + CSS。 逻辑复用,是前端团队的核心资产!
import {
    useState,
    useEffect
} from 'react';


export const useMouse = () => {

1️⃣ 定义状态:我们需要记录 X 和 Y 坐标

    const [X, setX] = useState(0);
    const [Y, setY] = useState(0);

2️⃣ 定义事件处理函数

  • 这个函数会在每次鼠标移动时被调用
   useEffect(() => {
     
     const update = (event) => {
       // 更新状态,这将触发使用了该 Hook 的组件重新渲染
       setX(event.clientX);
       setY(event.clientY);
     }

3️⃣ 绑定事件监听

     
      // 相当于 componentDidMount
      window.addEventListener('mousemove', update);

4️⃣ ⚠️ 极其重要:清理副作用!

  • 如果不写这个 return 函数,当组件卸载时,事件监听器依然存在。
  • 这会导致严重的【内存泄漏】,控制台会疯狂报错,浏览器会变卡。
      // 相当于 componentWillUnmount
      return () => {
        window.removeEventListener('mousemove', update);
      }
    }, []); // 👈 注意这个空数组
    // 依赖项为空数组 [],意味着这个 effect 只在组件挂载时执行一次,
    // 并且在组件卸载时执行清理函数。

5️⃣ 返回数据

    // 把组件需要的状态暴露出去
    return {
        X,
        Y
    }
}

🔍 深度解析:

  1. 状态驱动:我们要追踪鼠标,本质上就是追踪 xy 两个数字的变化。所以用了两个 useState
  2. 副作用管理:监听 windowmousemove 事件是一个典型的副作用。
  3. 依赖项陷阱
    • 如果 useEffect 的第二个参数不传,它会在每次渲染后都执行。如果你在这里绑定事件,那完了,你会绑定几千个监听器。
    • 传入 [],告诉 React:“嘿,这事儿只在组件出生和死亡时做一次,中间别烦我。”
  4. 内存泄漏(Memory Leak)
    • 这是 React 面试必考题。
    • readme.md 中也提到了这一点:“组件卸载时需要清除事件监听/定时器,否则会导致内存泄漏”
    • React 的 useEffect 允许返回一个函数,这个函数就是专门用来擦屁股的。一定要记得 removeEventListener

📝 第三章:进阶实战——企业级 TodoList 逻辑分离

鼠标追踪只是热身,现在我们要搞点真家伙。我们要写一个 TodoList(待办事项清单)。

你可能会说:“切,TodoList 我闭着眼都能写。”

别急,这次我们不写面条代码。我们要把所有的业务逻辑(增、删、改、查、持久化)全部抽离到一个 useTodos Hook 中。这就叫 Headless UI(无头组件) 设计思想——逻辑与视图分离。

3.1 顶层设计:App.jsx 的视角

先看 App.jsx,它是怎么组织这个应用的。

import { useTodos } from './hooks/useTodos.js';
// ... 引入组件

export default function App() {
  // ...
  const {
    todos,       // 数据列表
    addTodo,     // 添加方法
    toggleTodo,  // 切换状态方法
    deleteTodo   // 删除方法
  } = useTodos();

✨ 魔法时刻 ✨

  • 一行代码,获取了整个 Todo 应用所需的所有数据和方法!
  • 就像去超市买了一个“Todo大礼包”,回家拆开就能用。
  return (
    <>
      {/* 把方法传给输入组件 */}
      <TodoInput addTodo={addTodo} />
      
      {/* 条件渲染:有数据才显示列表 */}
      {
        todos.length > 0 ? 
        (<TodoList 
          todos={todos} 
          toggleTodo={toggleTodo} 
          deleteTodo={deleteTodo}
        />) : 
        (<div>暂无待办事项</div>)
      }
    </>
  )
}

妙啊! App.jsx 变得极其干净。它根本不知道 Todo 是怎么存的,也不知道删除逻辑是 filter 还是 splice,它只负责传递

3.2 核心引擎:useTodos.js

这是本篇文章的重头戏。我们深入 useTodos.js 看看它是怎么运作的。

import {
    useState,
    useEffect
} from 'react';

⭐常量提取,好维护

const STORAGE_KEY = 'todos'; 

🛠️ 辅助函数:从 LocalStorage 读取数据

  • 放在组件外面,因为它不依赖组件内的任何状态,纯函数
function loadFromStorage() {
    const storedTodos = localStorage.getItem(STORAGE_KEY);
    return storedTodos ? JSON.parse(storedTodos) : [];
}

🛠️ 辅助函数:保存数据到 LocalStorage


function saveToStorage(todos) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

1️⃣. 惰性初始化 (Lazy Initialization)

这里的 useState 接收了一个函数 loadFromStorage,而不是直接通过 loadFromStorage() 调用。

为什么?

  • 因为 localStorage 读取是昂贵的 IO 操作。
  • 如果直接写 loadFromStorage(),每次组件渲染都会读一次 Storage。
  • 传函数引用,React 只会在组件【首次渲染】时调用它。 这是一个非常高级且实用的优化技巧!🚀
export const useTodos = () => {
    
    const [todos, setTodos] = useState(loadFromStorage);

2️⃣ 数据持久化

每当 todos 状态变化时,自动同步到 localStorage。 这样用户刷新页面,数据也不会丢。

    
    useEffect(() => {
        saveToStorage(todos);
    }, [todos]); // 依赖项是 todos

3️⃣ 业务逻辑:

多使用es6新特性如结构,map,filter

    // 添加 Todo~~~~~~~~~
    const addTodo = (text) => {
        // ⚠️ 永远不要直接修改 state,比如 todos.push(...) 是绝对禁止的!
        // 必须创建一个新数组。
        setTodos([
            ...todos, // 展开旧数据
            {
                id: Date.now(), // 用时间戳做 ID
                text,
                completed: false
            }
        ])
    }

    //  切换完成状态~~~~~~~~~~
    const toggleTodo = (id) => {
        setTodos(
            todos.map(todo => {
                if(todo.id === id) {
                    // 同样,不要直接修改 todo.completed = !todo.completed
                    // 要返回一个新的对象
                    return {
                        ...todo,
                        completed: !todo.completed
                    }
                }
                return todo;
            })
        )
    }

    //  删除 Todo~~~~~~~~~
    const deleteTodo = (id) => {
        // filter 返回的是新数组,完美符合 React 的不可变性要求
        setTodos(
            todos.filter(todo => todo.id !== id)
        );
    }

4️⃣ 暴露接口

    // 返回一个对象,方便使用者解构
    return {
        todos,
        addTodo,
        toggleTodo,
        deleteTodo
    }
}

💡 知识点总结:

  • Lazy Initialization(惰性初始化)useState(() => heavyComputation())。这招在处理大数据初始化时非常有用,能显著提升性能。
  • Immutability(不可变性):你看 addTodo 用了 [...todos]toggleTodo 用了 mapdeleteTodo 用了 filter。这些都是生成新数组的方法,而不是修改原数组。这是 React 状态更新的金科玉律。
  • 关注点分离useTodos 只管数据怎么变,不管数据怎么展示

3.3 组件落地:三剑客的配合

逻辑有了,现在看看 UI 组件怎么消费这些逻辑。

1. 也是“大脑”的延伸:TodoInput.jsx

TodoInput.jsx 负责收集用户输入。

import { useState } from 'react';

export default function TodoInput ({addTodo}) {
    // 这个 state 是 UI 状态(输入框里的字),属于组件私有
    const [text, setText] = useState('');

    const handleSubmit = (e) => {
        e.preventDefault(); // 阻止表单默认提交刷新页面
        if(!text.trim()) return; // 空校验
        
        // 调用父组件(其实是 Hook)传来的方法
        addTodo(text.trim());
        
        // 清空输入框
        setText('');
    }

    return (
        <form className="todo-input" onSubmit={handleSubmit}>
            <input 
                type="text"
                value={text} // 受控组件value 绑定 state
                onChange={e => setText(e.target.value)} // onChange 更新 state
            />
            <button type="submit">添加</button>
        </form>
    )
}

这里体现了 React 的**受控组件(Controlled Components)**思想:Input 的值由 React 的 State 掌控,而不是 DOM 自身。

2. 中转站:TodoList.jsx

TodoList.jsx 其实是个“傻瓜组件”(Dumb Component),它只负责遍历。

import TodoItem from './TodoItem';

export default function TodoList({
    todos,
    toggleTodo,
    deleteTodo
}) {
    return (
        <ul className="todo-list">
            {
                todos.map(todo => (
                    // 🔑 key 属性至关重要!
                    // 它帮助 React 识别哪些元素改变了、添加了或删除了。
                    // 这里的 key={todo.id} 是最佳实践,千万别用 key={index}!
                    <TodoItem
                        key={todo.id}
                        todo={todo}
                        toggleTodo={toggleTodo}
                        deleteTodo={deleteTodo}
                    />
                ))
            }
        </ul>
    )
}

3. 最终呈现:TodoItem.jsx

TodoItem.jsx 负责渲染单条数据。

export default function TodoItem({
    todo,
    toggleTodo,
    deleteTodo
}) {
    return (
        <li className="todo-item">
            {/* 复选框:控制完成状态 */}
            <input 
                type="checkbox" 
                checked={todo.completed} 
                onChange={() => toggleTodo(todo.id)} 
            />
            
            {/* 动态类名:控制样式 */}
            <span className={todo.completed ? 'completed' : ''}>
                {todo.text}
            </span>
            
            {/* 删除按钮 */}
            <button onClick={() => deleteTodo(todo.id)}>删除</button>
        </li>
    )
}

🎩 总结:自定义 Hooks 的“心法”

通过这两个例子,我们其实只做了一件事:把逻辑从 View 层剥离

为什么要这么做?

  1. 复用性(Reusability):如果明天老板让你在侧边栏也做一个 Todo 列表,你只需要在侧边栏组件里 const { ... } = useTodos(),一秒搞定。
  2. 可测试性(Testability):测试 useTodos 里的纯逻辑,比测试一个混合了 DOM 操作的组件要简单得多。
  3. 清晰度(Readability):你的组件代码量减少了,逻辑更清晰了,不管是自己看还是同事看,都更舒服。

Hooks 是一种心智模型。当你看到一段复杂的逻辑时,下意识地想:“能不能把它抽成一个 Hook?” 恭喜你,你已经从 React 萌新进阶了!


希望这篇文章能帮你打通 Hooks 的任督二脉!我们下期见!👋

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

前言:后端开发真的太累了

作为一个想做独立开发的前端或全栈工程师,每当你想写个小项目(比如工具箱、记账本、个人博客)时,热情往往在配置后端的瞬间熄灭:

  1. 买服务器:几百块一年,性能还一般。
  2. 配环境:SSH 连上去,装 Node、PM2、Nginx,防火墙配置。
  3. 搞域名:买域名、备案(最劝退的一步)、配置 HTTPS 证书。
  4. 写接口:纠结 RESTful 规范,/api/v1/add 还是 /add?参数放 Body 还是 Query?

我就想写个 简单 的接口,至于这么折腾吗?

今天,我要给你安利一套 “零成本”、“免运维”、“免域名” 的全栈接口方案: 腾讯云 SCF (云函数) + 函数 URL + rpc-server-scf

你只需要写纯粹的 JS 函数,它就能自动变成 HTTP 接口,不用管服务器,甚至不用管 Nginx!


🚀 什么是 rpc-server-scf?

它是 js-rpc 生态中专为 腾讯云云函数 (SCF) 设计的服务端框架。

它的核心理念是:把云函数变成一个 RPC 服务器。 你不需要关心 HTTP 请求的报文结构,不需要关心 API 网关的参数透传。你只需要在 api/ 目录下写函数,前端就能直接调用。


🛠️ 后端实战:3步搭建云端 RPC

我们将使用腾讯云 SCF 的 “函数 URL” 功能,它会直接分配给你一个公网 HTTPS 地址,让你彻底告别 API 网关配置和域名备案。

第一步:准备代码

在本地创建一个文件夹,初始化 npm:

mkdir my-scf-rpc
cd my-scf-rpc
npm init -y
npm install rpc-server-scf

第二步:编写业务逻辑 (api/math.js)

我们不需要写路由,只需要在 api 目录下创建文件。文件名就是模块名。

api/math.js

module.exports = {
  // 加法
  async add(a, b) {
    return a + b;
  },
  
  // 乘法
  async multiply(a, b) {
    return a * b;
  },

  // 稍微复杂点的逻辑
  async calculate(params) {
    // 这里可以连数据库、Redis,或者做任何后端逻辑
    const { x, y, op } = params;
    if (op === 'minus') return x - y;
    return 0;
  }
}

index.js (入口文件)

一行代码,启动 RPC 服务:

const { createRpcServer } = require('rpc-server-scf');

// 导出 main_handler 给云函数调用
exports.main_handler = createRpcServer();

这就写完了! 没有 Express,没有 app.listen,代码极其干净。

第三步:部署到腾讯云 SCF(开启“白嫖”模式)

  1. 登录 腾讯云云函数控制台

  2. 点击 “新建” -> 选择 “从头开始”

    • 函数名称:随便填,比如 rpc-demo
    • 运行环境:Nodejs 16.13 或以上。
  3. 上传代码

    • 将你的项目文件夹(包含 node_modules)压缩成 zip 包上传,或者直接在在线编辑器里创建文件并安装依赖。
  4. 🔥 关键步骤:启用访问服务 (函数 URL)

    • 在“访问服务”或“触发器”配置中,找到 【函数 URL】
    • 点击 “启用”
    • 鉴权方式:选择 “不校验 (PUBLIC)”(为了演示方便,生产环境可在代码里用中间件鉴权)。
    • 你将获得一个长这样的地址:https://你的函数ID.scf.tencentcs.com

    (如图所示,不用买域名,直接送你一个 HTTPS 的公网地址!)


⚡️ 前端如何调用?

现在你的后端已经跑在云端了。前端(小程序、UniApp、Web)怎么调?

我们使用配套的客户端 rpc-client-request(专为小程序/UniApp/HTTP场景设计)。

安装

npm install rpc-client-request

调用代码

在小程序或 UniApp 中:

import { create } from 'rpc-client-request';

// 填入刚才腾讯云分配给你的“函数 URL”
const rpc = create({
  url: 'https://service-xxxx.scf.tencentcs.com/release/rpc-demo' 
});

// 业务调用
async function test() {
  try {
    // 像调用本地函数一样!
    // 自动发请求给 api/math.js 的 add 方法
    const sum = await rpc.math.add(10, 20);
    
    console.log('计算结果:', sum); // 30
    
  } catch (err) {
    console.error('调用出错', err);
  }
}

完事! 你没有写 wx.request,没有拼接 URL,没有处理 POST 参数,一切就像在写本地代码。


🌟 为什么这套方案是“独立开发者”的神器?

1. 真正的“零运维” (Serverless)

你不需要维护服务器进程,不需要配置 Nginx 反向代理,不需要担心服务器挂掉。腾讯云帮你托管,有请求自动唤醒,没请求自动休眠。

2. 免域名,自带 HTTPS

利用 SCF 的 “函数 URL” 功能,你省去了购买域名、备案(通常需要半个月)、申请 SSL 证书的所有繁琐流程。起步阶段直接用官方链接,省时省力。

3. “白嫖”级成本

腾讯云 SCF 有免费额度(或者非常低廉的按量付费)。对于个人项目、测试项目或者低频工具类应用,成本几乎为 0。只有当你的业务真的做大了,才需要支付少量费用。

4. 极致的开发体验

  • 后端:只写业务函数,文件即路由。
  • 前端:RPC 调用,像调本地方法一样顺滑。
  • 全栈:你可以把精力 100% 放在业务逻辑上,而不是浪费在 HTTP 协议的翻译工作中。

🔗 总结与资源

如果你厌倦了传统的后端开发流程,想快速上线一个全栈应用,SCF + 函数 URL + js-rpc 绝对是你现在的最佳选择。

  • 项目 GitHub: github.com/myliweihao/…
  • 服务端 SDK: npm install rpc-server-scf
  • 小程序客户端: npm install rpc-client-request

别再让“服务器运维”阻挡你改变世界的创意了,现在就开始吧!

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

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

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)。‌
❌
❌