前言
在 用 JavaScript 实现 position sticky 文章中,我提到了用 wheel 来模拟 scroll 效果。
这篇来说说具体怎么实现,挺简单的哦。
Preparation
table.html
First Name | Last Name | Age | Address | Phone | City | Country | Occupation | Salary | |
---|---|---|---|---|---|---|---|---|---|
John | Doe | 30 | 123 Main St | john.doe@example.com | 123-456-7890 | New York | USA | Software Engineer | $80,000 |
Jane | Smith | 25 | 456 Elm St | jane.smith@example.com | 987-654-3210 | Los Angeles | USA | Graphic Designer | $60,000 |
Michael | Johnson | 35 | 789 Oak St | michael.johnson@example.com | 456-789-0123 | Chicago | USA | Teacher | $50,000 |
Sarah | Williams | 28 | 321 Pine St | sarah.williams@example.com | 789-012-3456 | Miami | USA | Accountant | $70,000 |
David | Brown | 40 | 654 Cedar St | david.brown@example.com | 210-987-6543 | Houston | USA | Engineer | $90,000 |
Emily | Miller | 33 | 987 Maple St | emily.miller@example.com | 567-890-1234 | Seattle | USA | Manager | $100,000 |
James | Wilson | 27 | 753 Walnut St | james.wilson@example.com | 890-123-4567 | San Francisco | USA | Marketing Specialist | $75,000 |
Emma | Anderson | 29 | 159 Birch St | emma.anderson@example.com | 234-567-8901 | Boston | USA | Consultant | $85,000 |
Christopher | Lee | 32 | 852 Oakwood St | christopher.lee@example.com | 678-901-2345 | Atlanta | USA | Lawyer | $120,000 |
Olivia | Clark | 26 | 357 Elmwood St | olivia.clark@example.com | 123-456-7890 | Denver | USA | Artist | $55,000 |
William | White | 31 | 951 Cedarwood St | william.white@example.com | 456-789-0123 | Phoenix | USA | Architect | $95,000 |
Ava | Hall | 34 | 246 Pinecrest St | ava.hall@example.com | 789-012-3456 | Dallas | USA | Financial Analyst | $80,000 |
Alexander | Young | 29 | 753 Maplewood St | alexander.young@example.com | 210-987-6543 | Philadelphia | USA | Real Estate Agent | $70,000 |
Mia | Scott | 38 | 852 Oak St | mia.scott@example.com | 567-890-1234 | Minneapolis | USA | Doctor | $150,000 |
Ethan | Adams | 27 | 369 Walnut St | ethan.adams@example.com | 890-123-4567 | Portland | USA | Journalist | $65,000 |
Isabella | Carter | 30 | 147 Pine St | isabella.carter@example.com | 234-567-8901 | Detroit | USA | Entrepreneur | $200,000 |
Logan | Green | 31 | 258 Elm St | logan.green@example.com | 678-901-2345 | San Diego | USA | Engineer | $90,000 |
Amelia | Roberts | 29 | 369 Cedar St | amelia.roberts@example.com | 123-456-7890 | Charlotte | USA | Designer | $70,000 |
Benjamin | Hill | 35 | 741 Oakwood St | benjamin.hill@example.com | 456-789-0123 | San Antonio | USA | Manager | $100,000 |
Charlotte | Adams | 33 | 852 Maple St | charlotte.adams@example.com | 789-012-3456 | Orlando | USA | Software Developer | $85,000 |
Gabriel | Cook | 28 | 159 Pinecrest St | gabriel.cook@example.com | 210-987-6543 | Tampa | USA | Writer | $60,000 |
View Code
table.scss
.container {
max-height: 256px;
overflow-y: auto;
max-width: 768px;
margin-inline: auto;
}
table {
border-spacing: 0;
margin-inline: auto;
th,
td {
border: 1px solid black;
}
:is(th, td):nth-child(n + 2) {
border-left: unset;
}
td {
border-top: unset;
}
thead {
tr {
background-color: white;
}
th {
padding: 16px;
}
}
td,
th {
padding: 16px;
min-width: 250px;
max-width: 250px;
}
}
View Code
实现原理
监听 wheel 事件,会得到一个 WheelEvent 对象。
它里面有一个 deltaY 属性,我们 wheel 一下,这个 deltaY 会是 100 或 -100。
positive 表示 scroll down,negative 表示 scroll up。
100 是游览器设定的一下 wheel 要移动多少 scrollTop。
轻轻 wheel 一下就是 scrollTop += 100
如果快速 wheel 几下,这个 deltaY 不一定是 100,有可能是 200 甚至 300。
也就是说 wheel 的越快越多,移动的 scrollTop 越大。这是游览器的交互体验。
我们监听 wheel 然后 update scrollTop 就可以了。如果要体验好,就加入 animation,让它 smooth 一点。
具体实现代码
table.ts
我用了 RxJS,不熟悉的朋友可以参考:RxJS 系列
const container = document.querySelector
// 监听 wheel 事件
const wheel$ = fromEvent
// preventDefault body scroll,因为我们要控制的是 div scroll
wheel$.subscribe(e => e.preventDefault());
// 从 event 取出 deltaY
const deltaY$ = wheel$.pipe(map(e => e.deltaY)).pipe(share());
// 区分出 scroll up 和 scroll down
const [scrollUp$, scrollDown$] = partition(deltaY$, deltaY => deltaY < 0);
// for loop subscribe scroll$
for (const scroll$ of [scrollUp$, scrollDown$]) {
scroll$
.pipe(
// 下面 scroll 指的是 要 scrollTop 多少
// 轻轻 wheel 一下,scrollPerWheel 是 100
// 快快 wheel 的话,scrollPerWheel 可能会去到 200, 300
mergeMap(scrollPerWheel => {
// 如果是 scroll up,scrollPerWheel 会是 negative,我们为了统一算法,把它变成 positive 会比较方便
if (scroll$ === scrollUp$) scrollPerWheel *= -1;
// 150ms 内要完成 scroll
const duration = 150;
// 每一 ms 要 scroll 多少?
const scrollPerMillisecond = scrollPerWheel / duration;
return animationFrames().pipe(
// animationFrames 就是递归调用 requestAnimatonFrame
// elapsed 是一个累加的 ms
map(e => e.elapsed),
startWith(0),
pairwise(),
// 通过 current elapsed 减去 previous elapsed 就可以直到这一次的 requestAnimatonFrame 间隔多少时间
// 游览器 requestAnimatonFrame 通常间隔是在 16ms 左右,但也不太准,所以我们还是得准确算一下
map(([prev, curr]) => curr - prev),
scan(
({ totalScroll }, animationInterval) => {
// 每 16ms 左右我们就会 scroll 一点点
// 一直到 scroll 到 100px 就停
// remainingScroll 就是一个从 100 一直累减到 0 的记入
const remainingScroll = scrollPerWheel - totalScroll;
// 计算这一次要 scroll 多好
const scroll = limitMax(Math.ceil(animationInterval * scrollPerMillisecond), remainingScroll);
// totalScroll 则是已经 scroll 了多少
return { totalScroll: totalScroll + scroll, lastScroll: scroll };
},
{ totalScroll: 0, lastScroll: 0 },
),
// 判断 totalScroll 满了就停
takeWhile(({ totalScroll }) => totalScroll !== scrollPerWheel, true),
// 如果是 scroll up 要把它转换回 negative
map(e => (scroll$ === scrollDown$ ? e.lastScroll : e.lastScroll * -1)),
// 150ms 内如果用户反方向 wheel 就立刻停止以前方向的 scroll
takeUntil(scroll$ === scrollDown$ ? scrollUp$ : scrollDown$),
);
}),
)
// 每一次修改 scrollTop
.subscribe(scroll => (container.scrollTop += scroll));
}
效果
和原生的不会差太远,够用。
如果还想加入 overscroll 概念,可以添加一个 targetScrollElement$,它会决定要 scroll 哪一个 element (child to ancestor)
// 当用户停止 wheel 之后的第一个 wheel 做检查
// 这里使用 debounceTime 300ms 来等待用户停止 wheel
const targetScrollElement$ = wheel$.pipe(debounceTime(300), startWith(null)).pipe(
switchMap(() => {
return wheel$.pipe(
map(e => {
const upOrDown = e.deltaY > 0 ? 'Down' : 'Up';
// scrollElements 是 child to ancestor element
return scrollElements.find((scrollElement, index) => {
// 如果已经是最后一个 element 直接返回就好,总要有人可以 scroll 嘛
if (index === scrollElements.length - 1) return true;
// 如果要 scroll up 同时还没有 scroll 到顶就可以 scroll 这个 element
if (upOrDown === 'Up' && !reachedTop(scrollElement)) return true;
// 如果要 scroll down 同时还没有 scroll 到底就可以 scroll 这个 element
if (upOrDown === 'Down' && !reachedBottom(scrollElement)) return true;
// 不可以就去检查下一个 parent
return false;
})!;
}),
take(1), // 检查一次就行了
);
// 判断是否已经 scroll 到顶部
function reachedTop(element: HTMLElement) {
return element.scrollTop === 0;
}
// 判断是否已经 scroll 到底部
function reachedBottom(element: HTMLElement) {
return element.scrollHeight - element.clientHeight === element.scrollTop;
}
}),
shareReplay(1),
);
完整代码
export function setupWheelToScroll(
wheelElement: HTMLElement,
scrollElement: HTMLElement | HTMLElement[],
): Subscription {
const duration = 150;
const scrollElements = Array.isArray(scrollElement) ? scrollElement : [scrollElement];
const subscription = new Subscription();
const wheel$ = fromEvent
subscription.add(wheel$.subscribe(e => e.preventDefault()));
// 当用户停止 wheel 之后的第一个 wheel 做检查
// 这里使用 debounceTime 300ms 来等待用户停止 wheel
const targetScrollElement$ = wheel$.pipe(debounceTime(300), startWith(null)).pipe(
switchMap(() => {
return wheel$.pipe(
map(e => {
const upOrDown = e.deltaY > 0 ? 'Down' : 'Up';
// scrollElements 是 child to parent element
return scrollElements.find((scrollElement, index) => {
// 如果已经是最后一个 element 直接返回就好,总要有人可以 scroll 嘛
if (index === scrollElements.length - 1) return true;
// 如果要 scroll up 同时还没有 scroll 到顶就可以 scroll 这个 element
if (upOrDown === 'Up' && !reachedTop(scrollElement)) return true;
// 如果要 scroll down 同时还没有 scroll 到底就可以 scroll 这个 element
if (upOrDown === 'Down' && !reachedBottom(scrollElement)) return true;
// 不可以就去检查下一个 parent
return false;
})!;
}),
take(1), // 检查一次就行了
);
// 判断是否已经 scroll 到顶部
function reachedTop(element: HTMLElement) {
return element.scrollTop === 0;
}
// 判断是否已经 scroll 到底部
function reachedBottom(element: HTMLElement) {
return element.scrollHeight - element.clientHeight === element.scrollTop;
}
}),
shareReplay(1),
);
const deltaY$ = wheel$.pipe(map(e => e.deltaY)).pipe(share());
const [scrollUp$, scrollDown$] = partition(deltaY$, deltaY => deltaY < 0);
for (const scroll$ of [scrollUp$, scrollDown$]) {
const scrollSub = scroll$
.pipe(
mergeMap(scrollPerWheel => {
if (scroll$ === scrollUp$) scrollPerWheel *= -1;
const scrollPerMillisecond = scrollPerWheel / duration;
return animationFrames().pipe(
map(e => e.elapsed),
startWith(0),
pairwise(),
map(([prev, curr]) => curr - prev),
scan(
({ totalScroll }, animationInterval) => {
const remainingScroll = scrollPerWheel - totalScroll;
const scroll = limitMax(Math.ceil(animationInterval * scrollPerMillisecond), remainingScroll);
return { totalScroll: totalScroll + scroll, lastScroll: scroll };
},
{ totalScroll: 0, lastScroll: 0 },
),
takeWhile(({ totalScroll }) => totalScroll !== scrollPerWheel, true),
map(e => (scroll$ === scrollDown$ ? e.lastScroll : e.lastScroll * -1)),
takeUntil(scroll$ === scrollDown$ ? scrollUp$ : scrollDown$),
);
}),
withLatestFrom(targetScrollElement$),
)
.subscribe(([scroll, targetScrollElement]) => (targetScrollElement.scrollTop += scroll));
subscription.add(scrollSub);
}
return subscription;
}
View Code