
1. 从零手写一个开关组件为什么我不推荐直接用原生 checkbox做前端这些年我经手过不少后台管理系统和移动端 H5 项目几乎每个项目里都会出现开关这个控件。一开始我也图省事直接用input typecheckbox加一点 CSS 就完事后来发现事情远没有想象中简单——原生 checkbox 在不同浏览器里的默认样式差异大得离谱想做出设计稿里那种圆润的滑动动画得写一堆 hack 去覆盖系统默认外观改到最后代码又臭又长维护起来非常痛苦。后来我干脆自己用纯 JS 写了一个开关组件不依赖任何框架和第三方库核心逻辑不到一百行样式和交互全部可控。这篇文章就把我这几年的实现思路、踩过的坑和优化技巧完整地分享出来。不管你是刚入门前端的新手还是想找一个轻量级方案的老手都能从里面拿到可以直接用的东西。先明确一下这个组件要解决的核心问题把开/关这种二元状态用一个视觉上直观、交互上顺滑、代码上可复用的控件表达出来。它看起来简单但要做好涉及状态管理、事件处理、无障碍访问、动画性能、表单集成这几个维度每一个都有讲究。下面我会一层一层拆开讲。2. 先想清楚一个开关组件到底要管哪些事2.1 状态、视觉、交互三者的关系很多人写开关第一反应是点一下换个颜色这是把问题想简单了。一个合格的开关本质上是状态驱动视觉交互改变状态。状态是唯一的真相来源single source of truth视觉只是状态的一种投影。我习惯把开关拆成三个层次来理解状态层一个布尔值checked表示当前是开还是关。这是唯一需要持久化和对外暴露的东西。视觉层根据checked决定滑块的位置、轨道的背景色、过渡动画。视觉层永远被动地跟随状态层不自己维护状态。交互层监听点击、键盘、触摸事件把用户的意图翻译成切换状态这个动作然后通知状态层更新。这三层分清楚之后代码结构就自然清晰了。我见过太多实现把这三者揉在一起点击事件里直接改 DOM 样式结果外部想通过 JS 设置开关状态时样式和状态就对不上了。这是新手最容易踩的第一个坑。2.2 为什么不用原生 checkbox 直接改样式有人会问原生 checkbox 本身就有checked状态也有change事件为什么不用它答案是能用但不好用。原生 checkbox 的渲染由浏览器和操作系统共同决定在部分平台上你几乎无法完全控制它的外观。虽然appearance: none能去掉默认样式但一旦去掉你就得自己画一个框那还不如直接用一个div或button来得干净。而且原生 checkbox 的点击热区、焦点样式、禁用态表现各浏览器都不一致想统一就得写大量兼容代码。我的做法是用一个语义化的容器元素承载交互内部用纯 CSS 画轨道和滑块状态存在 JS 变量里。这样既拿到了完全的控制权又能通过role和aria-*属性补回无障碍语义两全其美。2.3 组件对外应该暴露什么接口在动手写之前先定好接口这决定了组件好不好用。我一般会暴露这几样东西接口类型作用checked布尔当前状态可读可写disabled布尔是否禁用onChange函数状态变化时的回调参数是新状态setChecked(bool)方法编程式设置状态不触发回调toggle()方法编程式切换状态触发回调这里有个细节值得说setChecked和toggle的区别在于是否触发 onChange 回调。为什么要有这个区分因为外部代码主动设置状态时往往不希望再收到自己的回调否则容易造成循环调用。而用户点击触发的切换则应该通知外部。这个设计是我踩过坑之后才想明白的后面会详细讲。3. 核心实现从 DOM 结构到状态同步3.1 DOM 结构怎么设计才合理我的 DOM 结构通常是这样button classswitch roleswitch aria-checkedfalse typebutton span classswitch__track span classswitch__thumb/span /span /button为什么用button而不是div因为 button 天生可聚焦、可键盘触发、有正确的语义省去了自己处理 tabindex 和键盘事件的麻烦。roleswitch告诉屏幕阅读器这是一个开关aria-checked则把当前状态同步给辅助技术。轨道track和滑块thumb用两个 span 嵌套是为了让动画只作用在 thumb 上track 只负责背景色变化。这样性能更好因为位移用transform触发的是合成层动画不会引起重排。3.2 状态同步的关键单一数据源核心逻辑我一般写成一个工厂函数返回一个实例对象function createSwitch(element, options {}) { let checked options.checked || false; let disabled options.disabled || false; const onChange options.onChange || function () {}; function render() { element.setAttribute(aria-checked, String(checked)); element.classList.toggle(is-checked, checked); element.classList.toggle(is-disabled, disabled); element.disabled disabled; } function setChecked(next, silent) { if (disabled) return; if (next checked) return; checked next; render(); if (!silent) onChange(checked); } function handleClick() { setChecked(!checked, false); } element.addEventListener(click, handleClick); render(); return { get checked() { return checked; }, setChecked: (v) setChecked(v, true), toggle: () setChecked(!checked, false), destroy: () element.removeEventListener(click, handleClick) }; }这段代码有几个关键点。第一render函数是唯一的 DOM 操作入口所有视觉变化都从这里走保证状态和视觉永远一致。第二setChecked里做了幂等判断next checked时直接返回避免无意义的重渲染和回调。第三silent参数控制是否触发回调这就是前面说的setChecked和toggle的区别所在。3.3 点击事件里那些容易忽略的细节点击事件看似简单但有几个坑我踩过不止一次。第一个坑是重复触发。如果你在 button 内部又嵌套了可点击元素点击内部元素时事件会冒泡到 button导致一次点击触发两次切换。解决办法是在内部元素上阻止冒泡或者干脆不在内部放可点击元素。第二个坑是禁用态的处理。很多人只在视觉上把开关变灰但忘了拦截点击。正确做法是在setChecked开头判断disabled直接 return。同时给 button 加disabled属性这样键盘和鼠标都无法触发。第三个坑是快速连点。如果开关带动画用户快速连点可能导致状态和视觉错位。因为状态是同步更新的而动画是异步的所以只要保证状态更新是同步的、视觉跟随状态就不会错位。这也是为什么我坚持状态驱动视觉而不是点击直接改样式。提示如果你的开关需要和表单一起提交记得在组件内部维护一个隐藏的input typehidden把 checked 值同步进去否则表单拿不到这个字段。4. 样式与动画让开关滑起来而不是跳过去4.1 用 transform 而不是 left 做位移动画滑块移动新手最直觉的写法是改left值.switch__thumb { left: 2px; transition: left 0.2s; } .switch.is-checked .switch__thumb { left: 22px; }这样能跑但性能不好。left属于布局属性每次变化都会触发浏览器的重排reflow在低端设备上能明显感觉到卡顿。正确做法是用transform: translateX().switch__thumb { transform: translateX(0); transition: transform 0.2s ease; } .switch.is-checked .switch__thumb { transform: translateX(20px); }transform触发的是合成composite阶段浏览器可以直接在 GPU 上处理不引起重排重绘动画丝滑得多。这个优化在移动端尤其明显我实测过同样一个开关列表用 left 在低端安卓机上滚动时会掉帧换成 transform 就稳了。4.2 过渡时长的选择有讲究过渡时长不是随便定的。太短比如 0.1s会显得生硬太长比如 0.5s会让人觉得迟钝。我一般用0.2s 到 0.25s配合ease或cubic-bezier(0.4, 0, 0.2, 1)这种先快后慢的曲线手感最接近原生系统控件。另外轨道的背景色变化也可以加过渡但时长可以比滑块稍短一点这样视觉上滑块滑过去的同时颜色已经变了整体更协调。这种细节设计稿上不会标但做出来就是比别人的精致。4.3 禁用态和焦点态的视觉处理禁用态不能只是简单地把透明度调低那样看起来像半死不活。我的做法是轨道背景换成中性灰滑块颜色也变灰同时把cursor设为not-allowed。这样用户一眼就知道这个开关点不了。焦点态则要照顾键盘用户。默认的焦点轮廓outline在很多设计里被去掉了这是无障碍的大忌。我的做法是保留 outline但用:focus-visible只在键盘操作时显示鼠标点击时不显示兼顾美观和可用性.switch:focus-visible { outline: 2px solid #4a90d9; outline-offset: 2px; }4.4 尺寸和圆角的适配方案开关的尺寸最好用 CSS 变量控制这样一套代码能适配不同场景.switch { --switch-width: 44px; --switch-height: 24px; --thumb-size: 20px; --thumb-offset: 2px; }滑块的位移距离就是width - height因为滑块直径等于高度减去两倍 offset。这样改尺寸时只需要改变量位移自动算对不用手动改 translateX 的值。这个技巧帮我省了很多重复劳动尤其是做响应式的时候。5. 无障碍与键盘操作别让一部分用户用不了你的开关5.1 role 和 aria 属性怎么用才对无障碍这块很多人知道要加roleswitch但不知道还要配aria-checked。这两个是配套的role 告诉辅助技术这是个开关aria-checked 告诉它现在是开还是关。缺了后者屏幕阅读器只会念开关不会念状态用户根本不知道当前是开是关。另外如果开关旁边有文字标签最好用aria-labelledby关联起来或者直接用aria-label给一个描述。这样屏幕阅读器会念通知开关已开启信息完整。5.2 键盘交互空格和回车都要支持原生 button 天然支持空格和回车触发点击这也是我选 button 而不是 div 的原因之一。但如果你非要用 div就得自己监听 keydownelement.addEventListener(keydown, (e) { if (e.key || e.key Enter) { e.preventDefault(); handleClick(); } });注意e.preventDefault()不能少否则空格会触发页面滚动。这个细节很多人会漏导致键盘用户按空格时页面乱跳。5.3 屏幕阅读器的实测反馈我在几个主流屏幕阅读器上测过只要 role、aria-checked、aria-label 都到位朗读效果是准确的。有一个小坑是某些阅读器在 aria-checked 变化时不会自动重新朗读需要配合aria-live区域或者手动触发。不过对于开关这种控件用户操作后一般会主动再听一次影响不大。如果做的是关键业务可以额外加一个视觉上的状态文字提示。6. 表单集成与框架适配的实战经验6.1 怎么让开关参与表单提交纯 JS 组件默认不参与表单提交因为表单只认name属性的表单元素。解决办法是在组件内部藏一个 hidden inputconst hidden document.createElement(input); hidden.type hidden; hidden.name options.name || switch; hidden.value checked ? 1 : 0; element.appendChild(hidden);然后在render里同步hidden.value。这样表单提交时就能拿到开关的值。值用什么表示可以自定义我一般用1/0或者on/off看后端约定。6.2 在原生 JS 项目里怎么复用原生项目里我一般把createSwitch挂到一个命名空间下然后写一个初始化函数扫描页面上所有带特定 class 的元素批量实例化document.querySelectorAll([data-switch]).forEach((el) { createSwitch(el, { checked: el.dataset.checked true, onChange: (v) console.log(changed, v) }); });这样 HTML 里只要写button>