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

简洁版

CSS

只需要使用 css 属性 position: sticky 即可,但是这个属性的使用有很多约束条件,有时可能并不能生效。

JavaScript

这里简化一些代码,使用 React 写了一个 hooks,使用了 ahooks 库去维护 event 和 React 生命周期。

typescript 复制代码
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 结构

html 复制代码
<!-- 父容器:限制sticky的生效范围 -->
<div class="sticky-container">
  <!-- 具有"吸顶效果"的元素 -->
  <div class="sticky-header">我是吸顶导航栏</div>
  <!-- 其他内容:用于模拟页面滚动 -->
  <div class="content">
    这里是大量页面内容...
  </div>
</div>

CSS 样式

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;
}

避坑指南

  1. 生效位置 :在 Container Block 生效,即这个粘性元素是相对于自己所处的容器内吸顶,而不是任意情况下都是全局吸顶。这是一个容易踩坑的地方,但是同样也是一个特性,可以利用这个性质去实现类似于下一个标题将前一个标题顶出屏幕的效果。
  2. 必须设置触发域值:需要明确 top 值,否则就是 relative 效果。
  3. 父容器限制:父容器不能设置 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>_的元素。在其 包含块在其流根(或其滚动的容器)内越过指定临界值(例如将 top设置为 auto 以外的值)之前,它被视为相对定位,此时它被视为"卡住",直到遇到其 包含块的对边。

JavaScript 方案

核心原理

监听滚动事件 + 状态控制。

CSS 方案虽轻量,但无法满足如"条件性吸顶"(如滚动超过某元素后才吸顶)、"吸顶后修改样式" 等复杂需求场景,所以这种情况下,我们就不得不用 JavaScript 来辅助我们实现相关的效果。

示例见上文。

避坑指南

  1. 吸顶瞬时抖动 :这是两个问题,需要根据我们实现的代码来进行判断,其一是 scroll 时间监听比较频繁,这一点可以通过防抖来优化,其二是我们切换 position 的时候(有些实现可能是切换 fixed,我在上面的实现是切换的 sticky,则不会有这种问题),出现了高度坍塌,这个可以配合给下一个元素同步的增加 padding-top 属性来避免瞬时高度坍塌带来的问题。

面试追问

  1. 如何快速定位 "吸顶瞬间抖动" 的原因?

答:通过浏览器 "性能" 面板录制滚动过程,查看是否存在 "长任务阻塞" 或 "频繁重排重绘":① 若存在长任务(如滚动事件中执行复杂计算),需用防抖 + requestAnimationFrame 优化,最重要的是优化长任务问题;② 若存在频繁重排,需检查是否频繁调用 getBoundingClientRect()、是否未处理 fixed 元素脱离文档流的空缺问题;③ 若为 CSSsticky 抖动,可添加 will-change: position 优化渲染。

额外的 Demo

  1. 吸顶元素顶出效果
html 复制代码
<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>