手把手打造高性能Toggle切换器的实战技巧

静欣 ☘︎ 组件 阅读 2,041
赞 29 收藏
二维码
手机扫码查看
反馈

我为什么又要重新看一遍Toggle?

说实话,写这个组件的时候我心里有点烦。又不是没写过Toggle,但每次项目一换技术栈,或者UI设计扔过来一个“稍微不一样”的动效需求,我就得重新翻一遍方案。React、Vue、原生自定义元素、Tailwind……每种都有人说好,但我踩过的坑告诉我:没有银弹。

手把手打造高性能Toggle切换器的实战技巧

这次是给一个管理后台做暗黑模式切换,需求看着简单:一个圆钮在轨道里滑动,开/关状态分明。结果设计稿上的过渡动画是缓入缓出+颜色渐变,开发时才发现默认的CSS transition根本不够用。折腾了两天才定下来用哪个方案,所以干脆记一笔,下次别再重复造轮子了。

谁更灵活?谁更省事?

我对比了三个主流做法:

  • 纯CSS + label hack(最老派)
  • React + state控制的div模拟(现在最多人用)
  • Web Components封装(最近开始用,真香)

先说结论:日常项目我选React那个;如果组件要复用到多个项目,或者团队有统一组件库计划,我会上Web Components。至于纯CSS?只适合静态页面或者Demo,真上生产环境迟早出事。

CSS Label Hack:轻量但脆弱

这招我大学时候就学会了——用隐藏的checkbox + CSS伪类来模拟Toggle。确实代码少,也不依赖框架。

<label class="toggle">
  <input type="checkbox" class="toggle-input" />
  <span class="toggle-slider"></span>
</label>
.toggle-input {
  opacity: 0;
  position: absolute;
}

.toggle-slider {
  display: inline-block;
  width: 50px;
  height: 24px;
  background: #ccc;
  border-radius: 12px;
  position: relative;
  cursor: pointer;
  transition: background 0.3s;
}

.toggle-slider::before {
  content: '';
  position: absolute;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: white;
  top: 2px;
  left: 2px;
  transition: transform 0.3s;
}

.toggle-input:checked + .toggle-slider {
  background: #4caf50;
}

.toggle-input:checked + .toggle-slider::before {
  transform: translateX(26px);
}

看起来很完美对吧?但问题来了:一旦你想加一个“加载中”状态,或者禁用点击时保留视觉反馈,你就得改DOM结构,甚至引入JS。而且accessibility支持得自己补全aria-*属性,不然残障用户直接懵逼。

我之前在一个政府项目里用这方案,验收时被无障碍检测工具打了回来,补了一堆aria-checkedrole="switch",最后还不如一开始就用JS控制。

React状态驱动:自由度最高

我现在大部分项目都用React,所以更倾向用state来控件状态。虽然代码多几行,但逻辑清晰,扩展性强。

import { useState } from 'react';

function Toggle({ onChange, defaultChecked = false }) {
  const [checked, setChecked] = useState(defaultChecked);

  const handleClick = () => {
    const newValue = !checked;
    setChecked(newValue);
    onChange?.(newValue);
  };

  return (
    <div
      className={toggle ${checked ? &#039;toggle-on&#039; : &#039;toggle-off&#039;}}
      onClick={handleClick}
      role="switch"
      aria-checked={checked}
      tabIndex={0}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          handleClick();
        }
      }}
    >
      <div className="toggle-handle"></div>
    </div>
  );
}
.toggle {
  width: 50px;
  height: 24px;
  background: #ddd;
  border-radius: 12px;
  position: relative;
  cursor: pointer;
  transition: background 0.3s ease;
}

.toggle-on {
  background: #4caf50;
}

.toggle-handle {
  width: 20px;
  height: 20px;
  background: white;
  border-radius: 50%;
  position: absolute;
  top: 2px;
  left: 2px;
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.toggle-on .toggle-handle {
  transform: translateX(26px);
}

这个方案的好处是你可以在onChange里塞任何逻辑:发请求、打埋点、控制全局主题切换。而且动画曲线也能自定义,比如我上面用了cubic-bezier,滑动更有弹性感。

坑也不是没有:如果你不在意性能,可能会忘记防抖。曾经有一次用户连点五次,结果触发了五次API调用,后端报警了。后来我在onChange外面包了一层防抖函数才解决。

Web Components:一次封装,到处跑

最近公司搞微前端,各个团队技术栈不一样,React/Vue/Angular都有,这时候我就开始尝试用Web Components写基础组件。

class CustomToggle extends HTMLElement {
  constructor() {
    super();
    this.checked = this.hasAttribute('checked');
    this.disabled = this.hasAttribute('disabled');
  }

  connectedCallback() {
    this.render();
    this.addEventListener('click', this.toggle.bind(this));
  }

  toggle() {
    if (this.disabled) return;
    this.checked = !this.checked;
    this.setAttribute('checked', this.checked);
    this.render();

    const event = new CustomEvent('change', {
      detail: { checked: this.checked },
      bubbles: true,
    });
    this.dispatchEvent(event);
  }

  render() {
    this.innerHTML = 
      &lt;style&gt;
        :host {
          display: inline-block;
          width: 50px;
          height: 24px;
          background: ${this.checked ? &#039;#4caf50&#039; : &#039;#ccc&#039;};
          border-radius: 12px;
          position: relative;
          cursor: ${this.disabled ? &#039;not-allowed&#039; : &#039;pointer&#039;};
          transition: background 0.3s;
        }
        .handle {
          width: 20px;
          height: 20px;
          background: white;
          border-radius: 50%;
          position: absolute;
          top: 2px;
          left: ${this.checked ? &#039;26px&#039; : &#039;2px&#039;};
          transition: left 0.3s;
        }
      &lt;/style&gt;
      &lt;div class=&quot;handle&quot;&gt;&lt;/div&gt;
    ;
  }
}

customElements.define('custom-toggle', CustomToggle);

注册之后 anywhere 都能用:

<custom-toggle checked></custom-toggle>
<script>
  document.querySelector('custom-toggle').addEventListener('change', (e) => {
    console.log(e.detail.checked);
    // fetch('https://jztheme.com/api/theme', {
    //   method: 'POST',
    //   body: JSON.stringify({ darkMode: e.detail.checked })
    // });
  });
</script>

这个方案最大的优点是解耦。React项目可以直接当HTML标签用,不需要引入额外依赖。而且样式隔离做得好,不怕全局污染。

缺点也很明显:调试麻烦,Chrome DevTools里看不到props,只能靠attributes。还有就是IE11不支持,如果你还得兼容老浏览器,那就别玩这个了。

我的选型逻辑

我现在是怎么选的?很简单:

  • 内部管理系统,React为主 → 用React组件
  • 多团队协作、跨框架 → 上Web Components
  • Demo或静态页 → 直接CSS hack凑合

还有一个细节很多人忽略:**动画完成后再执行回调**。比如你要切主题,最好是动画滑完再改CSS变量,不然会有闪烁。我现在的做法是在React版本里用requestAnimationFrame延迟dispatch,效果顺滑很多。

另外提一嘴Tailwind。如果你用Tailwind,其实可以直接用class切换,但我不推荐把逻辑塞进className里。还是那句话:可读性比少写两行代码重要得多。

以上是我的对比总结,有不同看法欢迎评论区交流

这三个方案我都在线上项目用过,各有各的适用场景。没有绝对的好坏,只有适不适合当前项目节奏和团队能力。

我个人现在更偏向于用React + TypeScript封装一个通用Toggle,加上onBeforeChange钩子用来拦截异步操作(比如保存失败就不允许切换),这样既灵活又可控。

Web Components虽然前景好,但周边生态还是弱,比如测试工具链、文档生成都不如React成熟。等哪天它像Button一样普及了,我可能才会全面切换。

总之,别迷信“最先进”,选你能维护得住的方案才是正道。毕竟我们是写业务的,不是炫技大赛选手。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论