前言
为何要自定义滚动条?答案是追求 website 的极致体验。
浏览器为 DOM 元素容器所提供的默认滚动条样式,在局部滚动区域内使用会影响页面布局的美观,特别是在 windows 系统
或 Mac 系统设置 显示滚动条 为始终
下较为明显。
尽管 CSS 一些浏览器厂商属性可以改变滚动条样式,但能够自定义出来的体验效果也很有限,也存在一些浏览器兼容问题。
利用 DOM 来模拟滚动条样式,则成为许多网站实现上的首选方案。社区也提供了一些现成的工具来实现,比如:el-scrollbar。
下面,我们参考 el-scrollbar
来探究和实现一个 React
版本的 Scrollbar
组件。
大纲如下:
Scrollbar
的实现思想Scrollbar
的基础结构(元素组成部分)- 隐藏浏览器原生滚动条
- 计算滚动滑块
thumb
尺寸(width/height) - 监听内容变化,自动更新滚动滑块
thumb
尺寸 handleScroll
滚动事件- 点击
bar
滚动条实现滚动 - 拖动
thumb
滚动滑块实现滚动
一、Scrollbar 的实现思想
- 首先是容器的 滚动行为 还是采用浏览器原生能力,即为 滚动容器 设置
CSS overflow: scroll
,这样容器的onScroll
滚动可以正常使用; - 要实现自定义滚动条,势必要先考虑 如何隐藏浏览器原生滚动条,可利用 CSS 布局溢出隐藏的方式将 原生滚动条 超出隐藏;
- 其次是自定义 滚动条 bar 和 滚动滑块 thumb 样式,采用 div 布局即可实现;
- 最后结合 鼠标事件 实现 滚动条 和 滚动滑块 的滚动行为。
二、Scrollbar 的基础结构(元素组成部分)
bash
scrollbar
├── scrollbar-view-wrap
│ └── scrollbar-view
│ └── children
├── scrollbar-bar (horizontal)
│ └── scrollbar-thumb
└── scrollbar-bar (vertical)
└── scrollbar-thumb
scrollbar
是外层容器,用做设置overflow: hidden
;scrollbar-view
是滚动容器,用作设置overflow: scroll
;scrollbar-view
是内容容器,主要用于承载chidlren
;children
是需要展示的内容;scrollbar-bar
自定义实现的滚动条,分 水平(horizontal) 和 垂直(vertical) 两个;scrollbar-thumb
自定义实现的滚动滑块;
对应我们初版的代码布局如下:
tsx
import React, { useState, useRef, useEffect, UIEvent, MouseEvent as ReactMouseEvent } from "react";
import "./scrollbar.scss";
import classNames from "classnames"; // 一个连接 className 的工具库
export interface IScrollbarProps {
children: React.ReactNode;
className?: string;
wrapClass?: string;
viewClass?: string;
wrapStyle?: React.CSSProperties;
onScroll?: React.UIEventHandler<HTMLDivElement>;
}
function Scrollbar(props: IScrollbarProps) {
const { className, wrapClass, viewClass, wrapStyle = {}, onScroll, children } = props;
const containerEle = useRef<HTMLDivElement>(null); // 外层容器
const wrapEle = useRef<HTMLDivElement>(null); // 滚动容器
const viewEle = useRef<HTMLDivElement>(null); // 内容容器
return (
<div className={classNames("scrollbar", className)} ref={containerEle}>
<div className={classNames("scrollbar-view-wrap", wrapClass)} style={wrapStyle} ref={wrapEle}>
<div className={classNames("scrollbar-view", viewClass)} ref={viewEle}>
{children}
</div>
</div>
<Bar />
<Bar vertical />
</div>
);
}
export interface IBarProps {
vertical?: boolean; // 是否为 垂直 Bar
}
function Bar(props: IBarProps) {
const { vertical } = props;
const barEle = useRef<HTMLDivElement>(null);
const thumbEle = useRef<HTMLDivElement>(null);
const bar = BAR_MAP[vertical ? "vertical" : "horizontal"];
return (
<div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle}>
<div className={classNames("scrollbar-thumb")} ref={thumbEle}></div>
</div>
);
}
BAR_MAP
记录了 Bar 在 水平 和 垂直 方向 上所用到的属性定义。
tsx
interface IBarInfo {
key: "vertical" | "horizontal";
size: "height" | "width";
axis: "Y" | "X";
offset: "offsetHeight" | "offsetWidth";
scroll: "scrollTop" | "scrollLeft";
scrollSize: "scrollHeight" | "scrollWidth";
client: "clientY" | "clientX";
direction: "top" | "left";
}
const BAR_MAP: Record<"vertical" | "horizontal", IBarInfo> = {
vertical: {
key: "vertical",
size: "height",
axis: "Y",
offset: "offsetHeight",
scroll: "scrollTop",
scrollSize: "scrollHeight",
client: "clientY",
direction: "top",
},
horizontal: {
key: "horizontal",
size: "width",
axis: "X",
offset: "offsetWidth",
scroll: "scrollLeft",
scrollSize: "scrollWidth",
client: "clientX",
direction: "left",
},
};
CSS 采用 SASS 编写,样式布局代码如下:
scss
.scrollbar {
// 容器设置超出隐藏
overflow: hidden;
position: relative;
&,
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
// 移入容器展示滚动条
&:hover,
&:active,
&:focus {
.scrollbar-bar {
opacity: 1;
transition: opacity 340ms ease-out;
}
}
.scrollbar-view-wrap {
// view-wrap 设置区域滚动
overflow: scroll;
height: 100%;
// 使用 CSS 来隐藏原生滚动条(用于 Mac 将 显示滚动条 设置为 根据鼠标和触摸板自动显示)
&__hidden-default {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
}
.scrollbar-bar {
position: absolute;
// 水平/垂直 方向与容器留 2px 间隔
right: 2px;
bottom: 2px;
z-index: 1;
border-radius: 4px;
opacity: 0;
transition: opacity 120ms ease-out;
&.is-vertical {
width: 6px;
top: 2px;
.scrollbar-thumb {
width: 100%;
}
}
&.is-horizontal {
height: 6px;
left: 2px;
.scrollbar-thumb {
height: 100%;
}
}
}
.scrollbar-thumb {
position: relative;
display: block;
width: 0;
height: 0;
cursor: pointer;
border-radius: inherit;
background-color: rgba(#909399, 0.3);
transition: 0.3s background-color;
&:hover {
background-color: rgba(#909399, 0.5);
}
}
}
三、隐藏浏览器原生滚动条
上面「Scrollbar 的实现思路」已经介绍:要自定义滚动条,就需要先将原生滚动条隐藏。
实现隐藏浏览器原生滚动条常见的一种方式是:计算出 滚动条 的宽度,然后通过溢出隐藏的方式,将滚动条裁剪出可视容器。
但是计算 滚动条 的宽度,要针对两个场景做处理:
- 一是 Windows 系统 或者是 Mac 系统设置为 浏览器滚动条 始终展示,滚动条会占据滚动容器的空间,可以通过计算得到滚动条的宽度;
- 而是 Mac 系统设置滚动条显示为「根据鼠标或触摸板自动显示」 ,滚动条是 浮动在滚动容器内,无法通过计算得到滚动条的宽度;
3.1 JS 计算浏览器原生滚动条宽度,实现溢出隐藏
针对场景一,我们实现一个 getScrollBarWidth()
方法计算得到滚动条宽度 gutter
,然后将滚动条的宽度部分进行溢出隐藏。
溢出隐藏的方式有两种:
- 为滚动容器设置
margin-right: -滚动条宽度
和margin-bottom: -滚动条宽度
,el-scrollbar
采用这种方式实现; - 为滚动容器设置
width = 外层容器.width + 滚动条宽度
和height = 外层容器.height + 滚动条宽度
,这是 本文的实现方式。
tsx
let scrollBarWidth: number | undefined;
function getScrollBarWidth() {
if (scrollBarWidth !== undefined) return scrollBarWidth;
// 创建一个用于测试的元素
const outer = document.createElement("div");
outer.style.visibility = "hidden";
outer.style.width = "100px";
outer.style.position = "absolute";
outer.style.top = "-9999px";
document.body.appendChild(outer);
// 获取元素的内部宽度
const widthNoScroll = outer.offsetWidth;
// 强制显示滚动条
outer.style.overflow = "scroll";
// 创建一个内部元素
const inner = document.createElement("div");
inner.style.width = "100%";
outer.appendChild(inner);
// 获取带有滚动条的宽度
const widthWithScroll = inner.offsetWidth;
// 移除测试元素
outer.parentNode!.removeChild(outer);
// 计算滚动条宽度
scrollBarWidth = widthNoScroll - widthWithScroll;
return scrollBarWidth;
}
function Scrollbar(props: IScrollbarProps) {
...
// 获取浏览器原生滚动条宽度
const gutter = getScrollBarWidth();
// 隐藏滚动条:通过偏移量溢出实现(场景:Windows 系统 或者是 Mac 系统设置为 浏览器滚动条 始终展示)
if (gutter && containerEle.current) {
// el-scrillbar 采用 margin-right 和 marign-bottom 设置负值隐藏滚动条;这里采用给 width 和 height 多加上 滚动条宽度 实现隐藏。
const { clientWidth, clientHeight } = containerEle.current;
const gutterStyle = {
width: clientWidth + gutter + "px",
height: clientHeight + gutter + "px",
};
Object.assign(wrapStyle, gutterStyle);
}
return (
<div className={classNames("scrollbar", className)} ref={containerEle}>
<div className={classNames("scrollbar-view-wrap", wrapClass)} style={wrapStyle} ref={wrapEle}>...</div>
</div>
);
}
3.2 CSS 属性隐藏原生滚动条
针对场景二,由于使用 getScrollBarWidth()
会得到浏览器滚动条宽度为 0,只能采用 CSS 来隐藏滚动条。在 gutter = 0
时,为滚动容器添加 .scrollbar-view-wrap__hidden-default
类样式。
diff
<div
+ className={classNames("scrollbar-view-wrap", wrapClass, { "scrollbar-view-wrap__hidden-default": !gutter })}
style={wrapStyle}
ref={wrapEle}>
...
</div>
diff
.scrollbar-view-wrap {
// view-wrap 设置区域滚动
overflow: scroll;
height: 100%;
+ &__hidden-default {
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ }
+ }
}
四、计算滚动滑块 thumb 尺寸(width/height)
- 如果是
vertical bar
,滚动滑块thumb
的尺寸指代height
; - 如果是
horizontal bar
,滚动滑块thumb
的尺寸指代width
。
以 vertical bar
为例,滚动滑块 thumb
的尺寸(height
),可通过 滚动容器.clientHeight
和 滚动容器.scrollHeight
计算得出。
我们在组件 mount 时计算出滚动滑块 thumb
的尺寸,得到一个百分比值,以 size
属性的方式传递给 Bar
组件。
tsx
function Scrollbar(props: IScrollbarProps) {
...
// 定义 Bar thumb 尺寸(水平和垂直)
const [size, setSize] = useState<Record<"width" | "height", string>>({ width: "", height: "" });
useEffect(() => {
// mount 时根据内容计算一次 Bar size
computedSize();
}, []);
// 根据内容计算出 Bar size
const computedSize = () => {
let heightPercentage, widthPercentage;
const wrap = wrapEle.current;
if (!wrap) return;
heightPercentage = (wrap.clientHeight * 100) / wrap.scrollHeight;
widthPercentage = (wrap.clientWidth * 100) / wrap.scrollWidth;
// 计算 滚动滑块 size 尺寸的公式:以垂直滚动为例,容器的可视高度 / 容器的滚动高度
setSize({
height: heightPercentage < 100 ? heightPercentage + "%" : "",
width: widthPercentage < 100 ? widthPercentage + "%" : "",
});
};
return (
<div className={classNames("scrollbar", className)} ref={containerEle}>
...
<Bar size={size.width} />
<Bar vertical size={size.height} />
</div>
}
export interface IBarProps {
size: string; // scrollbar-thumb 的尺寸(宽/高)
vertical?: boolean; // 是否为 垂直 Bar
}
function renderThumbStyle({ size, bar }: { size: string; bar: IBarInfo }) {
const style: React.CSSProperties = {};
style[bar.size] = size;
return style;
}
function Bar(props: IBarProps) {
...
const { vertical, size } = props;
const thumbStyle = renderThumbStyle({ size, bar });
return (
<div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle}>
<div className={classNames("scrollbar-thumb")} style={thumbStyle} ref={thumbEle}></div>
</div>
);
}
五、监听内容变化,自动更新滚动滑块 thumb 尺寸
由于我们计算 thumb
尺寸只是在组件 mount 时执行了一次,若内容发生 增/减,滚动滑块的尺寸该如何实时同步呢?
这里我们可以借助 ResizeObserver API
监控内容容器的尺寸变化,执行 computedSize()
重新计算 thumb
尺寸。
diff
function Scrollbar(props: IScrollbarProps) {
...
useEffect(() => {
// mount 时根据内容计算一次 Bar size
computedSize();
+ // 启动监听,在 view 内容发生变化后,重新计算 Bar size
+ addResizeListener(viewEle.current!, computedSize);
+ return () => {
+ viewEle.current && removeResizeListener(viewEle.current);
+ };
}, []);
}
// 防抖函数
+ function debounce(time: number, fn: Function) {
let timer: undefined | number;
return function (...args: unknown[]) {
clearTimeout(timer);
timer = window.setTimeout(() => {
fn.apply(this, args);
}, time);
};
}
// 添加 ResizeObserver
+ const addResizeListener = function (element: HTMLElement, fn: Function) {
(element as any).__ro__ = new ResizeObserver(debounce(16, fn));
(element as any).__ro__.observe(element);
};
// 销毁 ResizeObserver
+ const removeResizeListener = function (element: HTMLElement) {
(element as any).__ro__.disconnect();
};
六、handleScroll 滚动事件
由于我们实现上要保留滚动容器的原生滚动能力,所以滚动容器的 onScroll
事件行为可以正常使用。
虽然现在容器可以正常滚动,但是我们自定义实现的 thumb
滚动滑块,并未跟随滚动行为而移动其在滚动条上的位置。
接下来我要实现:在滚动期间,滚动滑块 thumb
能够 跟随滚动实时调整所处滚动条的位置。
以 vertical bar
为例,通过 滚动容器.scrollTop
/ 滚动容器.clientHeight
可以计算出滚动滑块 thumb
在垂直方向上的偏移量 move.y
(百分比值)。
然后将 move
作为属性传递给 Bar
组件,通过 transform: translate()
实现 thump
跟随滚动移动。
diff
function Scrollbar(props: IScrollbarProps) {
...
+ // 定义 Bar thumb 滚动位置(水平和垂直)
+ const [move, setMove] = useState<Record<"x" | "y", number>>({ x: 0, y: 0 });
// 监控 view wrap 的滚动,实时计算和调整 Bar.thumb(滚动滑块)的滚动位置
+ const handleScroll = (event: UIEvent<HTMLDivElement>) => {
+ const wrap = wrapEle.current!;
+ // 计算 滚动滑块 的 move 位置公式:以垂直滚动为例,容器顶部已滚动的距离 / 容器的可视高度
+ setMove({
+ y: (wrap.scrollTop * 100) / wrap.clientHeight,
+ x: (wrap.scrollLeft * 100) / wrap.clientWidth,
+ });
+ onScroll && onScroll(event);
+ };
return (
<div className={classNames("scrollbar", className)} ref={containerEle}>
<div
className={classNames("scrollbar-view-wrap", wrapClass, { "scrollbar-view-wrap__hidden-default": !gutter })}
style={wrapStyle}
+ onScroll={handleScroll}
ref={wrapEle}>
<div className={classNames("scrollbar-view", viewClass)} ref={viewEle}>
{children}
</div>
</div>
+ <Bar size={size.width} move={move.x} />
+ <Bar vertical size={size.height} move={move.y} />
</div>
);
}
export interface IBarProps {
size: string; // scrollbar-thumb 的尺寸(宽/高)
+ move: number; // scrollbar-thumb 所处偏移量位置(滚动位置)
vertical?: boolean; // 是否为 垂直 Bar
}
function renderThumbStyle({ move, size, bar }: { move: number; size: string; bar: IBarInfo }) {
const style: React.CSSProperties = {};
+ const translate = `translate${bar.axis}(${move}%)`;
style[bar.size] = size;
+ style.transform = translate;
+ style.msTransform = translate;
+ style.WebkitTransform = translate;
return style;
}
function Bar(props: IBarProps) {
...
+ const { vertical, size, move } = props;
+ const thumbStyle = renderThumbStyle({ move, size, bar });
return (
<div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle}>
<div className={classNames("scrollbar-thumb")} style={thumbStyle} ref={thumbEle}></div>
</div>
);
}
七、点击 bar 滚动条实现滚动
我们知道原生滚动条 bar 允许点击,且有这样一交互:点击滚动条,让 thumb 滑块的中心,移动到所点击的位置,基于这个位置来计算出 滚动值。
以 垂直滚动 为例,实现滚动条 点击滚动 的思路如下:
- 首先计算出 鼠标所点位置 距离 滚动条 barEle 起始位置 相差多少,记做
offset
。公式:event.clientY - target.getBoundingClientRect().top
; - 其次,拿到 thumbEle 一半的高度:
thumbEle.offsetHeight / 2
,记做thumbClickPosition
; offset
-thumbClickPosition
则是滚动的距离;- 在计算得出滚动距离后,执行
wrapEle.offsetTop
让滚动容器进行滚动;
在对滚动容器执行滚动后,会触发
onScroll
事件并进入handleScroll
,重新计算move
去改变thumb
滚动滑块的位置。
diff
export interface IBarProps {
size: string; // scrollbar-thumb 的尺寸(宽/高)
move: number; // scrollbar-thumb 所处偏移量位置(滚动位置)
+ wrapEle: React.RefObject<HTMLDivElement>; // view wrap ele
vertical?: boolean; // 是否为 垂直 Bar
}
function Bar(props: IBarProps) {
...
+ const { wrapEle, vertical, size, move } = props;
+ // 点击滚动条,让 thumb 滑块的中心,移动到所点击的位置,基于这个位置来计算出 滚动值
+ const handleBarClick = (event: ReactMouseEvent<HTMLDivElement>) => {
+ const target = barEle.current!;
// 1. 以 垂直滚动 为例,首先计算出 鼠标所点位置距离滚动条 barEle 起始位置有多少距离。公式:event.clientY - target.getBoundingClientRect().top
+ const offset = event[bar.client] - target.getBoundingClientRect()[bar.direction];
// 2. 拿到 thumbEle 一半的高度:thumbEle.offsetHeight / 2
+ const thumbClickPosition = thumbEle.current![bar.offset] / 2;
// 3. 计算出将 thumbEle 中心放在点击位置时,barEle 起始位置 到 thumbEle 起始位置这段距离所占 barEle viewHeight 的百分比
+ const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / target[bar.offset];
// 4. 根据百分比计算出要滚动值:(percentage * wrapEle.scrollHeight )
+ wrapEle.current![bar.scroll] = (thumbPositionPercentage * wrapEle.current![bar.scrollSize]) / 100;
+ };
return (
+ <div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle} onMouseDown={handleBarClick}>
<div className={classNames("scrollbar-thumb")} style={thumbStyle} ref={thumbEle}></div>
</div>
);
}
function Scrollbar(props: IScrollbarProps) {
...
return (
<div className={classNames("scrollbar", className)} ref={containerEle}>
...
+ <Bar wrapEle={wrapEle} move={move.x} size={size.width} />
+ <Bar wrapEle={wrapEle} vertical move={move.y} size={size.height} />
</div>
);
}
八、拖动 thumb 滚动滑块实现滚动
滚动滑块 thumb
允许被按住拖动 进行滚动,实现这个交互涉及两部分:拖动实现
和 计算滚动值
。
- 拖动实现 :在按下
thumb
时,给document
注册mousemove
移动事件; - 计算滚动值 :和 点击滚动条滚动 思路相似,不同点是:点击滚动条滚动 的计算参照物是
thumbEle
高度的一半,这里则是thumbEle
的起点到鼠标按下时的这段距离。
diff
function Bar(props: IBarProps) {
...
const startMove = useRef<Record<"X" | "Y", number>>({ X: 0, Y: 0 });
// 按下拖动 thumb 滑块滚动逻辑
+ const handleThumbClick = (event: ReactMouseEvent<HTMLDivElement>) => {
if (event.ctrlKey || event.button === 2) {
return;
}
// 在鼠标按下时,记录点击位置与 thumb 滑块起始位置之间的距离
startMove.current[bar.axis] = event[bar.client] - thumbEle.current!.getBoundingClientRect()[bar.direction];
startDrag(event);
};
+ const startDrag = (event: ReactMouseEvent<HTMLDivElement>) => {
event.stopPropagation();
document.addEventListener("mousemove", handleMousemove);
document.addEventListener("mouseup", handleMouseup);
document.onselectstart = () => false; // 禁止拖动滚动时,将文本滑入选中
};
// 按住 thumb 移动滚动,与点击 bar 滚动的区别在于:始终以鼠标按下时 thumb 的位置,来计算滚动值
+ const handleMousemove = (event: MouseEvent) => {
const target = barEle.current!;
// 1. 计算出最新的 鼠标位置 距离 barEle 起始位置的距离
const offset = event[bar.client] - target.getBoundingClientRect()[bar.direction];
// 2. 拿到在鼠标按下时距离 thumbEle 起始位置的距离
const thumbClickPosition = startMove.current[bar.axis];
// 3. 计算出 barEle 起始位置 到 thumbEle 起始位置这段距离所占 barEle viewHeight 的百分比
const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / target[bar.offset];
// 4. 根据百分比计算出要滚动值:(percentage * wrapEle.scrollHeight )
wrapEle.current![bar.scroll] = (thumbPositionPercentage * wrapEle.current![bar.scrollSize]) / 100;
};
+ const handleMouseup = () => {
startMove.current[bar.axis] = 0;
document.removeEventListener("mousemove", handleMousemove);
document.removeEventListener("mouseup", handleMouseup);
document.onselectstart = null;
};
return (
<div className={classNames("scrollbar-bar", `is-${bar.key}`)} ref={barEle} onMouseDown={handleBarClick}>
+ <div className={classNames("scrollbar-thumb")} style={thumbStyle} ref={thumbEle} onMouseDown={handleThumbClick}></div>
</div>
);
}
至此,一个 Scrollbar
完美实现。
文末
感谢阅读,如有不足之处,欢迎指出 👏。
- github 源码位置:cegz-react-scrollbar
- npm 包:cegz-react-scrollbar