Shadow DOM 的一次挖掘 —— 揭秘 range input 的内部结构

5,402 阅读6分钟

最近在使用 rc-slider 组件实现滑块功能时,遇到了一个 iOS 的 Bug,即滑动时经常会回弹到滑动前的位置,相关 issue 见链接。于是就想着用 range input 这一滑块效果。

属性:具有 input 元素共享属性,外加 max、min、step、list 这四个属性。

range input 的构成:

一、range input 的在各个浏览器上的构成差异:

为了实现不同浏览器下的一致外观,那么我们首先需要了解各浏览器下的表现差异。

先来看看 range input 在不同浏览器下的内部结构:

Chrome:

首先在 Settings 中勾选Show user agent shadow DOM。

打开 Element,不难发现:
::-webkit-slider-runnable-track 匹配 track
::-webkit-slider-thumb 匹配 thumb

Firefox:

在搜索栏输入 about:config 开启 showAllAnonymousContent

打开 Element 面板,我们看到 range input 元素包含三个 <div> ,我们可以分别选中每个 div,根据页面中高亮的区块分辨出他们分别代表什么。

::-moz-range-track 匹配第一个div
::-moz-range-progress 匹配第二个div
::-moz-range-thumb 匹配第三个div

Edge:

Edge 提供了以下伪元素来控制滑块的样式:

::-ms-fill-lower: 已填充的区域
::-ms-fill-upper: 还没有填充的区域
::-ms-ticks-before: 前面、上面的刻度线
::-ms-ticks-after: 后面、下面的刻度线
::-ms-thumb: 滑动改变slider数值的小圆圈
::-ms-track: 滑块凹槽
::ms-tooltip: 拖动时候显示的文字。注意,这个元素只能用 display:none等隐藏样式。

Edge 中的结构比较复杂,我们只能访问带有 -ms-* id 的元素,还有很多元素无法访问到。

二、range input 的构成部分的在各个浏览器的表现差异

接着我们看下 range input 的构成部分的在各个浏览器的表现差异:

input range

box-sizing 在 chrome firefox edge 中都是 content-box,而input range 内部元素(track、thumb...) chrome: border-box, 其他是content-box。

在 chrome 、 safari、edge 下我们需要声明下面的css样式取消系统默认样式后才能设置我们想要的自定义样式。(由于某种原因,音轨已经默认设置了)

input[type='range'] {
  -webkit-appearance: none;
  
  &::-webkit-slider-thumb {
    -webkit-appearance: none;
  }
}

track

在 Chrome 中,我们设置的轨道宽度会被忽略,这么看来,track的宽度必须是依赖于range input宽度。 而使用tranform: scaleX 似乎是唯一的方法来使 track 比它的父滑块更宽或更窄。但是这么做在 chrome 和 edge中 thumb 也是水平缩放的,因为 thumb 是 track 的子节点。不过,在 Firefox 中不是这样,因为它的大小不会受到 track 的影响,因为 track 和 thumb是兄弟节点。

thumb

edge 和 firefox 的thumb滑动区域是 range input 的内容区域。

chrome的滑动区域是track的内容区域:

已填充的区域元素(progress):

firefox中使用 :: -moz-range-progress 伪元素 和 edge中使用::-ms-fill-lower 伪元素匹配这个元素。

从上文的input range 结构中我们已经知道,这个元素在firefox中是 track 元素的兄弟元素,其大小相对于range input,在edge中是 track 元素的子元素,其大小相对于 track 元素。但是在chrome中没有提供伪元素来匹配此元素
edge中填充区域的宽度为 thumb 的中间点到 track 内容左边界的距离:

在firefox中填充区域的宽度为 thumb 左右边界距离 input 内容框左右边界的比例点到 track 内容左边界的距离,这和其他浏览器的表现不一致。不过,如果 thumb 的宽度为 0 的话,那么填充区域的表现就会与其他浏览器一样了。如果一定有 thumb 的尺寸,那么就能需要自己根据当前的值来绘制填充区域。

为了实现在不同浏览器下样式都一样的滑块,需要在各浏览器的伪类下设置统一的样式。由于以下样式设置无效,

input::-webkit-slider-runnable-track, 
input::-moz-range-track, 
input::-ms-track { /* common styles */ }

所以建议使用 mixin 编写通用样式。

@mixin track() { /* common styles */ }

input {
  &::-webkit-slider-runnable-track { @include track }
  &::-moz-range-track { @include track }
  &::-ms-track { @include track }
}

应用

常见 slider 实现

分析了 range input 元素后,来看看如何使用它实现常见的slider:

由于在chrome没有提供填充区域的伪元素,那么怎么自定义填充区域的颜色呢?

也就是在一个 track div 元素中如何展示多个颜色,那么这时就可以想到用线性渐变、或者多背景这种方法。

至于填充区域位置的控制自然就是用background-size,而这个位置值可以根据 input 的当前值通过css变量控制,或者直接在style里设置 background-size。

在计算填充区域范围时,需要考虑上文提到的chrome已填充区域范围的表现,具体实现如下

@mixin track {
	background: linear-gradient(100deg, #5dd8fb 2%, #5dc1fb 100%)
        0/ var(--sx) 100% no-repeat,  $track-color;
}

// --val: 当前input的值  $thumb-w: thumb的宽度
[type='range'] {
  --range: calc(var(--max) - var(--min));
  --ratio: calc((var(--val) - var(--min))/var(--range));
  --sx: calc(.5*#{$thumb-w} + var(--ratio)*(100% - #{$thumb-w}));
}

这里需要注意一点,由于chrome和edge填充区域的特点,track高度应小于thumb高度,不然效果可能会不如你预期。

在线demo

设有step属性的slider实现

要实现这个效果需要自行维护step dot。

html结构:

const dots = [-20, 0, 20, 40, 60, 80];

 <div className="input-box">
    {dots.map((dot, index) => {
      return <div className={`dot dot-${index + 1}`} />;
    })}

    <input
      type="range"
      onChange={(event) => {
        setValue(+event.target.value);
      }}
      min="-20"
      max="80"
      step="20"
      style={{
        "--min": -20,
        "--max": 80,
        "--step": 20,
        "--val": value
      }}
      value={value}
    />
</div>

step dot 样式的控制:

  1. 确定位置。step dot的水平中心点始终和已填充区域的右边界对齐,上一个案例中已经说明了如何计算这个边界值。
.input-box {
  position: relative;
  width: 300px; // 宽度和input一样
  font-size: 0; // 消除input行框的strut对高度的影响
}

.dot {
  position: absolute;
  top: 50%;
  left: 0;
  transform: translate(-50%, -50%);

  // 这里使用了sass的for循环和当前input valuez值计算位置,当然你也可以在style中计算。
  @for $i from 1 through 6 {
    &.dot-#{$i} {
      --val1: calc((#{$i} - 1)*var(--step) - var(--step));
      --ratio1: calc((var(--val1) - var(--min))/ var(--range));
      left: calc(.5*#{$thumb-w} + var(--ratio1)*(100% - #{$thumb-w}));
    }
  }

  &.dot-1 {
    transform: translate(0, -50%);
    left: 0;
  }
}
  1. 提高 thumb 元素的层叠水平,使其在dot 之上。
@mixin thumb() {
	transform: translateZ(0);
}
  1. 高亮dot
.dot.active {
   border: 2px solid #00b9fa;
}

{dots.map((dot, index) => {
  const active = value >= dot ? "active" : "";
  return <div className={`dot dot-${index + 1} ${active}`} />;
})}

在线demo

带散列标记的范围控件

type=range 的 input 元素提供了 list 属性用于实现带散列标记的范围控件,其值是 details 元素的 id 值。但不幸的是,这个使用属性实现的效果很不理想,也无法自定义其样式。所以要实现跨浏览器的带散列标记的范围控件,需要自行使用 repeating-linear-gradient 实现散列标记,使用 label 元素实现标记的值。

demo 地址

tooltip展示:

edge 是唯一一个通过: :-ms-tooltip 提供工具提示的浏览器,但是它不显示

在 DOM 中,不能真正进行样式设置。所以在实现该功能时需要把它隐藏掉,然后使用 output 元素展示。

站点或应用程序可以将计算结果或用户操作的结果注入其中的一个容器元素

在线demo

更多实践:

  • 巧用两个type=range input实现区域范围选择:

思路是:两个type=range输入框叠在一起,然后叠在上面的选择框的只有中间的拖拽按钮,背后的拖拽背景条直接隐藏,这样,视觉上就是一个背景条,2个拖拽按钮了。

  • 使用 type=ragne input + mask 实现评星功能

具体请查看文章

实现图解:

兼容性:

参考:

developer.mozilla.org/zh-CN/docs/…

css-tricks.com/sliding-nig…

juejin.cn/post/691945…

www.zhangxinxu.com/wordpress/2…