el-switch实现

Mar 12 · 8 min

写这篇文章的起因是看到了 Element-Plus 官网 nav 栏的黑夜模式切换功能,开启黑夜模式的时候 switch 中的 icon 也能从白天的图标转换成黑夜的图标,配合上 Dark 模式的过渡动画效果,当时我就觉得这个交互很惊艳,想要去了解一下这个是怎么实现的,并学着写一个 switch

#思考

  • 点击 switch 是怎么做到左右切换的效果?
  • 很好奇 beforeChange 前置钩子的实现,怎么做到切换之前调用 beforeChange 方法?
  • 为什么文档说 v-model 的绑定值必须等于 active-valueinactive-value?
  • 为什么需要 active-valueinactive-value?直接使用 v-model 绑定一个值切换不行吗?

#源码整体架构

先看构图部分:

<template>
  <div class="el-switch is-checked">
    <input class="el-switch__input" />
    <div class="el-switch__core">
      <span class="el-switch__inner"></span>
      <span class="el-switch__action"></span>
    </div>
  </div>
</template>
vue

switch 的大致的 HTML 结构是这样的,我们知道 element-plus 整体样式采用了 BEM 的规范。源码中的样式都是通过 useNameSpace 这个方法来生成 BEM 样式的,这里我将样式名字抽离出来,比较好看清楚一些。

<template>
  <div class="el-switch is-checked" @click.prevent="switchValue"></div>
</template>
vue

其中外层的 wapper 的上有一个 is-checked 样式,这是 BEM 规范的状态标签,表示 switch 处于 on 或者 off 的状态,并带上一个点击事件 switchValue ,这个方法就是切换状态的方法。

#人机交互设计

<template>
  <input
    :id="inputId"
    ref="input"
    class="el-switch__input"
    type="checkbox"
    role="switch"
    :aria-checked="checked"
    :aria-disabled="switchDisabled"
    :name="name"
    :true-value="activeValue"
    :false-value="inactiveValue"
    :disabled="switchDisabled"
    :tabindex="tabindex"
    @change="handleChange"
    @keydown.enter="switchValue"
  />
</template>
vue

这里的 input 标签里面有很多属性,其实我在调试源码的时候,很困惑的点是:把 input 标签注释掉,我点击 switch 照样能切换状态,因为其 class 的作用就是把它隐藏,那么为什么要在写 input 标签并在上写这么多的属性?在此之中我不太熟悉的属性如下:

  • aria-checked
  • aria-disabled
  • tabindex

其中的 ARIA 是可以让我们更好的跟机器交互,而 tabindex 也是为了这个目标而写的,当我们按下键盘上的 tab 键时,就可以选中我们的 input 标签,从而使用键盘的 space 和 enter 去操作我们的 switch 按钮,所以 input 的标签最后的事件 @keydown.enter 的作用就在此。

相关规范 switch 的 ARIA 设计规范可以看这里

ARIA 全称 Accessible Rich Internet Applications,是能过让残障人士更加便利的访问 Web 内容和使用 Web 应用的一套机制。也就是说在标签上写上以 aria 为前缀的属性。

还有没带 aria 前缀的属性 role,也是为了优化网页交互的效果。

在按下 tab 键的时候,可以发现 switch 的边框变亮了,源码中使用了伪类选择器中的 focus-visible 来实现:

.el-switch__input {
  position: absolute;
  width: 0;
  height: 0;
  opacity: 0;
  margin: 0;
  &:focus-visible {
    & ~ .el-switch__core {
      outline: 2px solid var(--el-switch-on-color);
      outline-offset: 1px;
    }
  }
}
scss

其中两个知识点 css 选择器 ~ 和伪类选择器 focus-visible

  • ~: p ~ ul 表示每一个 ul 标签前面都有 p 标签的 ul 标签
  • focus-visible: 表示 tab 选中时的伪类选择器,和 focus 很像,它俩的区别在于用户使用键盘事件的时候,focus 选择器中的样式并不能生效,它只作用于鼠标事件,而 focus-visible 是对于鼠标和键盘事件都是起作用的,对比详情

#切换按钮

<template>
  <span class="el-switch__action"></span>
</template>
vue

这里是 switch 切换动作的动画标签,相关类名:el-switch__core

.el-switch {
  display: inline-flex;
  align-items: center;
  position: relative;
  font-size: 14px;
  line-height: 20px;
  height: 32px;
  vertical-align: middle;
  --el-switch-on-color: var(--el-color-primary);
  --el-switch-off-color: var(--el-border-color);
 
  &.is-checked .el-switch__core .el-switch__action {
    left: 100%;
    margin-left: calc(-1px - 16px);
    color: var(--el-switch-on-color);
  }
 
  .el-switch__action {
    position: absolute;
    top: 1px;
    left: 1px;
    border-radius: 100%;
    transition: all 0.3s;
    width: 16px;
    height: 16px;
    background-color: var(--el-color-white);
    display: flex;
    justify-content: center;
    align-items: center;
    color: var(--el-switch-off-color);
  }
}
scss

在 action 中首先使用绝对定位,将 action 定位到 switch 的最左端,使用 border-radius: 100% 将 action 变成圆块,对全部属性使用 transition 属性进行过渡处理。当按钮被激活的时候,is-checked 状态类被触发,这时将圆块相对 switch 容器向左偏移 100%,代码块中高亮代码 margin-left 是为了处理圆块自身的宽度,这样 switch 就会从左到右带过渡动画的转换了。

#核心逻辑实现

在逻辑方面首先定义了组件的名字

<script setup lang="ts">
defineOptions({
  name: 'ElSwitch'
});
</script>
ts

这个 api 在 vue3 的文档中并没有提到,是一个 vite 插件,用来定义组件名字,当使用 setup 语法的时候,我们需要另起一个 script 标签写一个 Options API 来定义它的名字,有了它之后我们就可以直接在 setup 中直接定义组件的名字了,更多详情

接下来定义 propsemit :

const props = defineProps(switchProps)
const emit = defineEmits(switchEmits)
js

上述 switchProps 和 switchEmits 是存在 switch.ts 中,如下:

  • modelValue
  • inlinePrompt
  • activeIcon
  • inactiveIcon
  • activeValue
  • inactiveValue
  • beforeChange

这里展示的属性不全,但其余属性都是类似的实现方式。先来看如下代码:

const isControlled = ref(props.modelValue !== false)
watch(
  () => props.modelValue,
  () => {
    isControlled.value = true
  }
)
watch(
  () => props.value,
  () => {
    isControlled.value = false
  }
)
const actualValue = computed(() => (isControlled.value ? props.modelValue : props.value))
const checked = computed(() => actualValue.value === props.activeValue)
js

首先是使用 ref 定义了 isControlled 属性,可以看到,它里面并没有直接使用 modelValue 的值,而是进行了一个判断,是因为 modelValue 的类型值有Boolean, String, Number这三种,这一操作让 isControlled 的结果是布尔类型的值,有利于后续的判断。

后面使用了 watch 函数和 actualValue 计算属性,这都是为了区分用户是使用 modelValue 还是 value 属性向 switch 传的值。

但对于 value 属性,将在 2.3.0 版本被弃用,从官网也可以看到现在只使用modelValue来绑定切换的值,所以个人认为最后一句在放弃 value 属性的情况下也可以直接使用 modelValue 和 activeValue 进行全等判断得出 checked 的值。

const checked = computed(() => modelValue.value === props.activeValue)
js

注意是全等判断!所以官方文档里也强调了 activeValueinactiveValue 的类型必须相同!但为什么类型要一定要严格相同呢?

实际上是有它的用途存在的,因为考虑到 switch 切换绑定值 modelValue 类型灵活性,它提供了三种类型的格式,那么用户在实际场景下也不会乖乖的只传 Boolean 类型的值,若用户传递的是 Number 类型的 modelValue 值,如:2,而上面两个属性默认值是 Boolean 值,那么它们就永远不可能全等!所以 checked 永远是 false!按钮的状态将永远处于 off 状态。

#用户点击时

const switchValue = () => {
  if (switchDisabled.value) return
 
  const { beforeChange } = props
  if (!beforeChange) {
    handleChange()
    return
  }
}
js

当用户点击时会触发 switchValue 方法,总得来看 switchValue 方法处理了 switchDisabled 属性,beforeChange 钩子进行处理,如果用户没有传递这个钩子,则会执行 handleChange 方法。

先看 handleChange 方法:

const handleChange = () => {
  const val = checked.value ? props.inactiveValue : props.activeValue
  emit(UPDATE_MODEL_EVENT, val) // 帮助父组件更新modelValue的值
  emit(CHANGE_EVENT, val) // 抛出change事件,用作一些逻辑
  emit(INPUT_EVENT, val) // 抛出inptu事件,用作一些逻辑
 
  nextTick(() => {
    input.value!.checked = checked.value // 设置input属性的check值
  })
}
js

定义了 val 值,从官网可以看到,绑定值 modelValue 等于 inactiveValueactiveValue 中的其中之一,在 checked 被选中的时候,这里却选择了 inactiveValue ,而不是 activeValue ,当初我看到的时候比较困惑。为什么要这样写呢?看个小例子:

<script setup>
import { ref } from 'vue'
const data = ref(true)
const test = val => {
  data.value = val
}
</script>
<template>
  <el-switch v-model="data" @change="test"></el-switch>
</template>
vue

此时 switch 为 on 状态,当我点击 switch 的时候,会触发 handleChange 方法,这时 cheked.value 还是 true,所以会选中 inactiveValue 的值,它默认是 false,所以 val 的值就是 false,通过后面的 UPDATE_MODEL_EVENT 事件传递出去了,test 方法被触发,val 的值就是刚刚 inactiveValue 的值 fasle,赋值给 data,这样这个时候 switch 中的 checked 也变成了 fasle,从而将 switch 的状态从 on 转换成 off。

#beforeChange 钩子函数

const { beforeChange } = props
const shouldChange = beforeChange()
 
const isPromiseOrBool = [
  isPromise(shouldChange),
  isBoolean(shouldChange)
].includes(true)
if (!isPromiseOrBool) {
  throwError(
    COMPONENT_NAME,
    'beforeChange must return type `Promise<boolean>` or `boolean`'
  )
}
 
if (isPromise(shouldChange)) {
  shouldChange
    .then((result) => {
      if (result) {
        handleChange()
      }
    })
    .catch((e) => {
      debugWarn(COMPONENT_NAME, `some error occurred: ${e}`)
    })
} else if (shouldChange) {
  handleChange()
}
js

官网介绍,beforeChange 是 switch 状态改变前的钩子,返回 false 或者返回 Promise 且被 reject 则停止切换。若传递了 beforeChange 钩子函数,它将会被先执行,代码第八行用来校验用户传递的函数的返回值是否是 Promise 或是 Boolean 类型。

第二句高亮代码是对 Promise 的返回值进行处理,等到 beforeChange 钩子执行完毕后再执行 handleChange 切换按钮的逻辑,其方法里面还有 change 事件可以抛出执行,所以 beforeChange 可以说是一个 change 事件的前置钩子

其后的 else if 语句块执行说明用户回调函数返回的是 Boolean 类型的值,就简单执行了一下 hanldeChange 也就达到 beforChange 的前置钩子的目的了。

#剩余 style 相关 Props

const coreStyle =
  computed <
  CSSProperties >
  (() => ({
    width: addUnit(props.width)
  }))
 
const styles = computed(() => {
  return ns.cssVarBlock({
    ...(props.activeColor ? { 'on-color': props.activeColor } : null),
    ...(props.inactiveColor ? { 'off-color': props.inactiveColor } : null),
    ...(props.borderColor ? { 'border-color': props.borderColor } : null)
  })
})
js

官网定义了一些可直接修改的样式属性,如 width,我也挺好奇 switch 的样式设计的,源码中 coreStyle 和 styles 都被应用在了标签的内联样式 style 上,这样做的目的就是为了覆盖之前定义的默认样式。 其中 CSSProperties官网中提到,是 ts 的工具类,需要 style 属性绑定更多样式。addUnit 是一个工具函数用来区分 width props 传递的类型。

#总结

  • 学习了 switch 左右切换的思想
  • beforeChange 前置钩子的实现方式
  • activeValueinactiveValue 有两个功能
    • 用来跟 modelValue 全等比较确定 checked 的值
    • 在 handleChange 中自动取反,在 emit 事件中抛出
  • CSSProperties 的使用场景
  • 学习了如何通过 props 自定义样式覆盖原有样式