【每日一面】任意 DOM 元素吸頂

簡潔版
CSS
只需要使用 css 屬性 position: sticky 即可,但是這個屬性的使用有很多約束條件,有時可能并不能生效。
JavaScript
這里簡化一些代碼,使用 React 寫了一個 hooks,使用了 ahooks 庫去維護 event 和 React 生命周期。
import { useEventListener, useMounted } from 'ahooks';
import React, { RefObject, useRef, useState } from 'react';
const headerHeight = 48;
interface IRetVal<T> {
ref: RefObject<T>;
isSticky: boolean;
stickyStyle?: React.CSSProperties;
}
/**
* js 模擬吸頂 dom
* @param startOffsetTop 開始吸頂的位置
* @returns
*/
export function useStickyDom<T extends HTMLElement>(startOffsetTop = headerHeight): IRetVal<T> {
const ref = useRef<T>(null);
const [sticky, setSticky] = useState(false);
const [stickyStyle, setStickyStyle] = useState<React.CSSProperties>();
function initStickyStyle(): void {
const rect = ref.current?.getBoundingClientRect();
const { width, left, right, height } = rect || {};
// 模擬的 sticky dom 基礎樣式屬性不變
setStickyStyle({ width, left, right, height });
}
useMount(initStickyStyle);
// 監聽 window 下的滾動
useEventListener(
'scroll',
() => {
if (!ref.current) {
return;
}
if (!sticky && !stickyStyle?.width) {
// 如果在組件掛載的時候沒有獲取到相關的樣式信息,這里需要重新初始化一下
initStickyStyle();
}
const offsetTop = ref.current.getBoundingClientRect().top;
setSticky(offsetTop < startOffsetTop);
},
{ capture: true }
);
// 自適應監聽
useEventListener('resize', () => {
const rect = ref.current?.getBoundingClientRect();
// 這里如果想獲取高度,需要注意 fixed 狀態時,如果元素沒有設置高度,則獲取的值總是 0
setStickyStyle(pre => ({ ...pre, width: rect?.width || pre?.width }));
});
return {
ref,
isSticky: sticky,
stickyStyle
};
}
話多版
為什么需要 “吸頂效果”?
在前端開發中,吸頂效果是一個比較高頻的交互需求 —— 常見于導航欄、篩選條件欄、表格表頭(如電商商品列表篩選區、后臺管理系統數據表格)等場景。
當頁面滾動時,目標元素從 “隨頁面流動” 轉為 “固定在視口頂部”,這樣能夠極大的減少用戶頻繁返回頂部的操作,提升瀏覽效率。
CSS 方案
核心原理
利用 position: sticky 特性。
position: sticky 是 CSS3 新增的定位屬性,兼具 relative 和 fixed 的特性:
- 當元素在視口中時,表現為 relative(隨頁面正常滾動);
- 當元素滾動到 “預設閾值”(通過 top/bottom 等屬性設置)時,自動切換為fixed(固定在視口對應位置),且不會脫離文檔流導致后續元素 “塌陷”。
這里給出一個示例:
DOM 結構
<!-- 父容器:限制sticky的生效范圍 -->
<div class="sticky-container">
<!-- 具有“吸頂效果”的元素 -->
<div class="sticky-header">我是吸頂導航欄</div>
<!-- 其他內容:用于模擬頁面滾動 -->
<div class="content">
這里是大量頁面內容...
</div>
</div>
CSS 樣式
/* 父容器:需有足夠高度讓子元素滾動*/
.sticky-container {
width: 100%;
}
/* 吸頂元素核心樣式 */
.sticky-header {
position: sticky;
top: 0; /* 觸發吸頂的閾值:距離視口頂部 0px 時固定 */
height: 60px;
line-height: 60px;
background: #fff; /* 必須設置背景,避免與下方內容透視重疊 */
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 0 20px;
}
/* 模擬長內容 */
.content {
height: 2000px;
padding: 20px;
background: #f5f5f5;
}
避坑指南
- 生效位置:在 生效,即這個粘性元素是相對于自己所處的容器內吸頂,而不是任意情況下都是全局吸頂。這是一個容易踩坑的地方,但是同樣也是一個特性,可以利用這個性質去實現類似于下一個標題將前一個標題頂出屏幕的效果。
- 必須設置觸發域值:需要明確 top 值,否則就是 relative 效果。
- 父容器限制:父容器不能設置 overflow,否則也沒有吸頂效果,這是因為 sticky 是相對于最近一個具有滾動機制的元素計算的偏移量和 top 值進行比較,如果父容器設置了 overflow,那么這個粘性塊實際上相對父容器的偏移量沒有變,MDN 上則是這么寫的——
粘性定位元素__(stickily positioned element)是
_<font style="color:rgb(0, 0, 0);background-color:rgb(237, 238, 240);">position</font>_屬性為_<font style="color:rgb(0, 0, 0);background-color:rgb(237, 238, 240);">sticky</font>_的元素。在其在其流根(或其滾動的容器)內越過指定臨界值(例如將 設置為 auto 以外的值)之前,它被視為相對定位,此時它被視為“卡住”,直到遇到其的對邊。
JavaScript 方案
核心原理
監聽滾動事件 + 狀態控制。
CSS 方案雖輕量,但無法滿足如“條件性吸頂”(如滾動超過某元素后才吸頂)、“吸頂后修改樣式” 等復雜需求場景,所以這種情況下,我們就不得不用 JavaScript 來輔助我們實現相關的效果。
示例見上文。
避坑指南
- 吸頂瞬時抖動:這是兩個問題,需要根據我們實現的代碼來進行判斷,其一是 scroll 時間監聽比較頻繁,這一點可以通過防抖來優化,其二是我們切換 position 的時候(有些實現可能是切換 fixed,我在上面的實現是切換的 sticky,則不會有這種問題),出現了高度坍塌,這個可以配合給下一個元素同步的增加
padding-top屬性來避免瞬時高度坍塌帶來的問題。
面試追問
- 如何快速定位 “吸頂瞬間抖動” 的原因?
答:通過瀏覽器 “性能” 面板錄制滾動過程,查看是否存在 “長任務阻塞” 或 “頻繁重排重繪”:① 若存在長任務(如滾動事件中執行復雜計算),需用防抖 + requestAnimationFrame 優化,最重要的是優化長任務問題;② 若存在頻繁重排,需檢查是否頻繁調用 getBoundingClientRect()、是否未處理 fixed 元素脫離文檔流的空缺問題;③ 若為 CSSsticky 抖動,可添加 will-change: position 優化渲染。
額外的 Demo
- 吸頂元素頂出效果
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>吸頂效果1</title>
<style>
.block-wrapper {
height: 200px;
background-color: #fff;
border: 1px solid #ccc;
}
.header {
position: sticky;
top: 0px; // 沒有這個,吸頂效果不生效
}
</style>
</head>
<body>
<div>
<div class="block-wrapper">
<div class="header">
<h1>目錄1</h1>
</div>
</div>
<div class="block-wrapper">
<div class="header">
<h1>目錄2</h1>
</div>
</div>
<div class="block-wrapper">
<div class="header">
<h1>目錄3</h1>
</div>
</div>
<div class="block-wrapper">
<div class="header">
<h1>目錄4</h1>
</div>
</div>
<div class="block-wrapper">
<div class="header">
<h1>目錄5</h1>
</div>
</div>
<div class="block-wrapper">
<div class="header">
<h1>目錄6</h1>
</div>
</div>
<div class="block-wrapper">
<div class="header">
<h1>目錄7</h1>
</div>
</div>
</div>
</body>
</html>
本文首發于,公眾號訂閱請關注:

