• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

网页开发中,我们经常要计算各种距离。

比如 OnBoarding 组件,我们要拿到每一步的高亮元素的位置、宽高:

比如 Popover 组件,需要拿到每个元素的位置,然后确定浮层位置:

比如滚动到页面底部,触发列表的加载,这需要拿到滚动的距离和页面的高度。

类似这样,需要计算距离、宽高等的场景有很多。

而浏览器里与距离、宽高有关的属性也有不少。

今天我们来整体过一遍。

首先,页面一般都是超过一屏的,右边会出现滚动条,代表当前可视区域的位置:

这里窗口的部分是可视区域,也叫做视口 viewport。

如果我们点击了可视区域内的一个元素,如何拿到位置信息呢?

我们只看 y 轴方向好了,x 轴也是一样的。

事件对象可以拿到 pageY、clientY、offsetY,分别代表到点击的位置到文档顶部,到可视区域顶部,到触发事件的元素顶部的距离。

还有个 screenY,是拿到到屏幕顶部的距离。

我们试一下:

npx create-vite

去掉 main.tsx 的里 index.css 和 StrictMode:

然后改下 App.tsx

import { MouseEventHandler, useEffect, useRef } from "react";

function App() {
    const ref = useRef<HTMLDivElement>(null);

    const clickHandler: MouseEventHandler<HTMLDivElement> = (e) => {
        console.log("box pageY", e.pageY);
        console.log("box clientY", e.clientY);
        console.log("box offsetY", e.offsetY);
        console.log("box screenY", e.screenY);
    };

    useEffect(() => {
        document.getElementById("box")!.addEventListener("click", (e) => {
            console.log("box2 pageY", e.pageY);
            console.log("box2 clientY", e.clientY);
            console.log("box2 offsetY", e.offsetY);
            console.log("box2 screenY", e.screenY);
        });
    }, []);

    return (
        <div>
            <div
                id="box"
                ref={ref}
                style={{
                    marginTop: "800px",
                    width: "100px",
                    height: "100px",
                    background: "blue",
                }}
                onClick={clickHandler}></div>
        </div>
    );
}

export default App;

为什么要用两种方式添加点击事件呢?

因为这里要介绍一个 react 事件的坑点:

react 事件是合成事件,所以它少了一些原生事件的属性,比如这里的 offsetY,也就是点击的位置距离触发事件的元素顶部的距离。

你写代码的时候 ts 就报错了:

那咋办呢?

react-use 提供的 useMouse 的 hook 就解决了这个问题:

它是用 e.pageY 减去 getBoundingClientRect().top 减去 window.pageYOffset 算出来的。

这里的 getBoundingClientRect 是返回元素距离可以可视区域的距离和宽高的:

而 window.pageYOffset 也叫 window.scrollY,顾名思义就是窗口滚动的距离。

想一下,pageY 去了 window.scrollY,去了 getBoundingClientRect().top,剩下的可不就是 offsetY 么:

试一下:

const clickHandler: MouseEventHandler<HTMLDivElement> = (e) => {
    const top = document.getElementById("box")!.getBoundingClientRect().top;

    console.log("box pageY", e.pageY);
    console.log("box clientY", e.clientY);
    console.log("box offsetY", e.pageY - top - window.pageYOffset);
    console.log("box screenY", e.screenY);
};

因为 getBoundingClientRect 返回的数值是更精确的小数,所以算出来的也是小数。

还有,这里的 window.pageYOffset 过时了,简易换成 window.scrollY,是一样的:

当然,你也可以访问原生事件对象,拿到 offsetY 属性:

此外,窗口的滚动距离用 window.scrollY 获取,那元素也有滚动条呢?

元素内容的滚动距离用 element.scrollTop 获取。

import { MouseEventHandler, useEffect, useRef } from "react";

function App() {
    const ref = useRef<HTMLDivElement>(null);

    const clickHandler: MouseEventHandler<HTMLDivElement> = (e) => {
        console.log(ref.current?.scrollTop);
    };

    return (
        <div>
            <div
                id="box"
                ref={ref}
                style={{
                    marginTop: "800px",
                    width: "100px",
                    height: "100px",
                    background: "ping",
                    overflow: "auto",
                }}
                onClick={clickHandler}>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
            </div>
        </div>
    );
}

export default App;

给 box 加一些内容,设置 overflow:auto。

试一下:

这就是元素的 scrollTop。

此外,元素还有 offsetTop 和 clientTop 属性:

import { useEffect, useRef } from "react";

function App() {
    const ref = useRef < HTMLDivElement > null;

    useEffect(() => {
        console.log("offsetTop", ref.current?.offsetTop);
        console.log("clientTop", ref.current?.clientTop);
    }, []);

    return (
        <div>
            <div
                style={{
                    position: "relative",
                    margin: "100px",
                    padding: "200px",
                    border: "1px solid blue",
                }}>
                <div
                    id="box"
                    ref={ref}
                    style={{
                        border: "20px solid #000",
                        width: "100px",
                        height: "100px",
                        background: "pink",
                    }}></div>
            </div>
        </div>
    );
}

export default App;

box 外层添加一个 div,margin 为 100px,padding 为 200px。

可以看到,clientTop 也就是上边框的高度 20px。

offsetTop 是距离最近的有 position 属性(relative 或 absolute 或 fixed)的元素的距离。

所以是 200px。

注释掉就是 301px 了,这时候就是相对于文档顶部,所以是 200px padding+ 1px border + 100px margin。

offsetTop 相对于哪个元素,那个元素就是 offsetParent。

还可以递归累加到 offsetParent 的 offsetTop,直到 offsetParent 为 null,也就是到了根元素,这时候算出来的就是元素到根元素的 offsetTop:

import { useEffect, useRef } from "react";

function App() {
    const ref = useRef<HTMLDivElement>(null);

    function getTotalOffsetTop(element: HTMLElement) {
        let totalOffsetTop = 0;
        while (element) {
            totalOffsetTop += element.offsetTop;
            element = element.offsetParent as HTMLElement;
        }
        return totalOffsetTop;
    }

    useEffect(() => {
        console.log("offsetTop", ref.current?.offsetTop);
        console.log("clientTop", ref.current?.clientTop);

        console.log("totol offsetTop", getTotalOffsetTop(ref.current!));
    }, []);

    return (
        <div>
            <div
                style={{
                    position: "relative",
                    margin: "100px",
                    padding: "200px",
                    border: "1px solid blue",
                }}>
                <div
                    id="box"
                    ref={ref}
                    style={{
                        border: "20px solid #000",
                        width: "100px",
                        height: "100px",
                        background: "pink",
                    }}></div>
            </div>
        </div>
    );
}

export default App;

但是你会发现它少计算了 border 的宽度:

因为 offsetTop 元素顶部到 offsetParent 内容部分的距离,不包括 border。

这时候加上 clientTop 就可以了,它就是上边框的高度。

function getTotalOffsetTop(element: HTMLElement) {
    let totalOffsetTop = 0;
    while (element) {
        if (totalOffsetTop > 0) {
            totalOffsetTop += element.clientTop;
        }
        totalOffsetTop += element.offsetTop;
        element = element.offsetParent as HTMLElement;
    }
    return totalOffsetTop;
}

这里有两个 clientTop,当前元素的 clientTop 不用加:

综上,当鼠标事件触发时,可以通过 pageY、clientY、screenY、offsetY 来计算位置,也可以通过元素的 getBoundingClientRect 和 scrollTop、offsetTop、clientTop 等来算,结合 window.scrollY。

这里 clientY 和 getBoundingClientRect().top 也要区分下:

一个是元素距离可视区域顶部的距离,一个是鼠标事件触发位置到可视区域顶部的距离。

比如页面是否滚动到底部,就可以通过 document.documentElement.scrollTop + window.innerHeihgt 和 document.documentElement.scrollHeight 对比。

这里有涉及到了几个新的属性。

根元素 documentElement 的 scrollTop 就是 window.scrollY:

然后 window.innerHeight、window.innerWidth 是窗口的宽高,也就是可视区域的宽高。

至于 scrollHeight,这是元素的包含滚动区域的高度。

类似的有 clientHeight、offsetHeight、getBoundingClient().height 这几个高度要区分下:

import { MouseEventHandler, useEffect, useRef } from "react";

function App() {
    const ref = useRef<HTMLDivElement>(null);

    const clickHandler: MouseEventHandler<HTMLDivElement> = (e) => {
        console.log("clentHeight", ref.current?.clientHeight);
        console.log("scrollHeight", ref.current?.scrollHeight);
        console.log("offsetHeight", ref.current?.offsetHeight);
        console.log(
            "clent rect height",
            ref.current?.getBoundingClientRect().height
        );
    };

    return (
        <div>
            <div
                id="box"
                ref={ref}
                style={{
                    border: "10px solid #000",
                    marginTop: "300px",
                    width: "100px",
                    height: "100px",
                    background: "pink",
                    overflow: "auto",
                }}
                onClick={clickHandler}>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
            </div>
        </div>
    );
}

export default App;

试一下:

clientHeight 是内容区域的高度,不包括 border。

offsetHeight 包括 border。

scrollHeight 是滚动区域的总高度,不包括 border。

那看起来 getBoundingClientRect().height 和 offsetHeight 一模一样?

绝大多数情况下是的。

但你旋转一下:

就不一样了:

getBoundingClientRect 拿到的包围盒的高度,而 offsetHeight 是元素本来的高度。

所以,对于滚动到页面底部的判断,就可以用 window.scrollY + window.innerHeight 和 document.documentElement.scrollHeight 对比。

import { useEffect, useRef } from "react";

function App() {
    const ref = useRef < HTMLDivElement > null;

    useEffect(() => {
        window.addEventListener("scroll", () => {
            console.log(
                window.scrollY + window.innerHeight,
                document.documentElement.scrollHeight
            );
        });
    }, []);

    return (
        <div>
            <div
                id="box"
                ref={ref}
                style={{
                    border: "10px solid #000",
                    marginTop: "800px",
                    width: "100px",
                    height: "100px",
                    background: "pink",
                    overflow: "auto",
                    transform: "rotate(45deg)",
                }}>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
                <p>xxxxx</p>
            </div>
        </div>
    );
}

export default App;

这样,浏览器里的各种距离和宽高我们就过了一遍。

总结

浏览器里计算位置、宽高、判断一些交互,都需要用到距离、宽高的属性。

这类属性比较多,我们整体过了一遍:

  • e.pageY:鼠标距离文档顶部的距离
  • e.clientY:鼠标距离可视区域顶部的距离
  • e.offsetY:鼠标距离触发事件元素顶部的距离
  • e.screenY:鼠标距离屏幕顶部的距离
  • winwodw.scrollY:页面滚动的距离,也叫 window.pageYOffset,等同于 document.documentElement.scrollTop
  • element.scrollTop:元素滚动的距离
  • element.clientTop:上边框高度
  • element.offsetTop:相对有 position 的父元素的内容顶部的距离,可以递归累加,加上 clientTop,算出到文档顶部的距离
  • clientHeight:内容高度,不包括边框
  • offsetHeight:包含边框的高度
  • scrollHeight:滚动区域的高度,不包括边框
  • window.innerHeight:窗口的高度
  • element.getBoundingClientRect:拿到 width、height、top、left 属性,其中 top、left 是元素距离可视区域的距离,width、height 绝大多数情况下等同 offsetHeight、offsetWidth,但旋转之后就不一样了,拿到的是包围盒的宽高

其中,还要注意 react 的合成事件没有 offsetY 属性,可以自己算,react-use 的 useMouse 的 hook 就是自己算的,也可以用 e.nativeEvent.offsetY 来拿到。

掌握这些宽高、距离属性,就足够处理各种需要计算位置、宽高的需求了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
19.手写react-lazyload
Next
21.自定义hook练习