高效获取元素可见区域的坐标

20

StackOverflow上有很多关于如何检查元素是否真正可见于视口的问题,但它们都寻求一个布尔型的答案。我想知道的是元素实际可见的区域。

注:loaded with questions需要根据具体语境翻译,无法确定具体含义。

function getVisibleAreas(e) {
    ...
    return rectangleSet;
}

更正式地说,元素的“可见区域”是CSS坐标系中的一组(最好不重叠的)矩形,其中如果点(x,y)包含在集合的至少一个矩形中,则elementFromPoint(x, y)将返回该元素。
调用此函数对所有DOM元素(包括iframe)的结果应该是一组非重叠区域集合,其并集是整个视口区域。
我的目标是创建某种视口“转储”数据结构,它可以有效地返回给定点在视口中的单个元素,反之亦然-对于转储中的给定元素,它将返回可见区域的集合。 (当我需要查询视口结构时,将向远程客户端应用程序传递数据结构,因此我不一定能访问实际文档)。

实现要求:

  • 显然,实现应该考虑元素的hidden状态、z-index、头和脚等。
  • 我正在寻找在所有常用浏览器中都能正常工作的实现,尤其是移动端 - Android 的 Chrome 和 iOS 的 Safari。
  • 最好不要使用外部库。
  • 当然,我可能有点天真,会对视口中的每个离散点调用 elementFromPoint,但性能非常关键,因为我要迭代所有元素,并且经常这样做。

    请指导我如何实现这个目标。

    免责声明:我对 web 编程概念相当陌生,所以可能使用了错误的技术术语。

    进展:

    我想出了一个实现方法。算法非常简单:

    1. 遍历所有元素,并将它们的垂直/水平线添加到坐标图中(如果坐标在视口内)。
    2. 对于步骤1中生成的每个“矩形”的中心位置调用 `document.elementFromPoint`。矩形是从步骤1中的地图中两个相邻垂直坐标和两个相邻水平坐标之间的区域。

    这将产生一组区域/矩形,每个指向一个单独的元素。

    我的实现存在以下问题:
    1. 对于复杂的页面效率低下(对于大屏幕和Gmail收件箱可能需要2-4分钟)。
    2. 每个元素产生了大量矩形,使得将其转换为字符串并通过网络发送变得低效,并且使用起来不方便(我希望每个元素最终只有尽可能少的矩形集合)。
    据我所知,elementFromPoint 调用占用了大量时间,导致我的算法相对无用...
    有人能提出更好的方法吗?
    以下是我的实现:
    function AreaPortion(l, t, r, b, currentDoc) {
        if (!currentDoc) currentDoc = document;
        this._x = l;
        this._y = t;
        this._r = r;
        this._b = b;
        this._w = r - l;
        this._h = b - t;
    
        center = this.getCenter();
        this._elem = currentDoc.elementFromPoint(center[0], center[1]);
    }
    
    AreaPortion.prototype = {
        getName: function() {
            return "[x:" + this._x + ",y:" + this._y + ",w:" + this._w + ",h:" + this._h + "]";
        },
    
        getCenter: function() {
            return [this._x + (this._w / 2), this._y + (this._h / 2)];
        }
    }
    
    function getViewport() {
        var viewPortWidth;
        var viewPortHeight;
    
        // IE6 in standards compliant mode (i.e. with a valid doctype as the first line in the document)
        if (
                typeof document.documentElement != 'undefined' &&
                typeof document.documentElement.clientWidth != 'undefined' &&
                document.documentElement.clientWidth != 0) {
            viewPortWidth = document.documentElement.clientWidth,
            viewPortHeight = document.documentElement.clientHeight
        }
    
        // the more standards compliant browsers (mozilla/netscape/opera/IE7) use window.innerWidth and window.innerHeight
        else if (typeof window.innerWidth != 'undefined') {
            viewPortWidth = window.innerWidth,
            viewPortHeight = window.innerHeight
        }
    
        // older versions of IE
        else {
            viewPortWidth = document.getElementsByTagName('body')[0].clientWidth,
            viewPortHeight = document.getElementsByTagName('body')[0].clientHeight
        }
    
        return [viewPortWidth, viewPortHeight];
    }
    
    function getLines() {
        var onScreen = [];
        var viewPort = getViewport();
        // TODO: header & footer
        var all = document.getElementsByTagName("*");
    
        var vert = {};
        var horz = {};
    
        vert["0"] = 0;
        vert["" + viewPort[1]] = viewPort[1];
        horz["0"] = 0;
        horz["" + viewPort[0]] = viewPort[0];
        for (i = 0 ; i < all.length ; i++) {
            var e = all[i];
            // TODO: Get all client rectangles
            var rect = e.getBoundingClientRect();
            if (rect.width < 1 && rect.height < 1) continue;
    
            var left = Math.floor(rect.left);
            var top = Math.floor(rect.top);
            var right = Math.floor(rect.right);
            var bottom = Math.floor(rect.bottom);
    
            if (top > 0 && top < viewPort[1]) {
                vert["" + top] = top;
            }
            if (bottom > 0 && bottom < viewPort[1]) {
                vert["" + bottom] = bottom;
            }
            if (right > 0 && right < viewPort[0]) {
                horz["" + right] = right;
            }
            if (left > 0 && left < viewPort[0]) {
                horz["" + left] = left;
            }
        }
    
        hCoords = [];
        vCoords = [];
        //TODO: 
        for (var v in vert) {
            vCoords.push(vert[v]);
        }
    
        for (var h in horz) {
            hCoords.push(horz[h]);
        }
    
        return [hCoords, vCoords];
    }
    
    function getAreaPortions() {
        var portions = {}
        var lines = getLines();
    
        var hCoords = lines[0];
        var vCoords = lines[1];
    
        for (i = 1 ; i < hCoords.length ; i++) {
            for (j = 1 ; j < vCoords.length ; j++) {
                var portion = new AreaPortion(hCoords[i - 1], vCoords[j - 1], hCoords[i], vCoords[j]);
                portions[portion.getName()] = portion;
            }
        }
    
        return portions;
    }
    

    如果你想要知道视口中一个元素的可见高度,可以查看这个问题中的答案。我不会将其标记为重复,因为你的需求可能不同。 - Rory McCrossan
    如果我理解你的问题正确——你问的是计算和构建数据结构应该在服务器端还是客户端上完成——答案是我不在乎,只要通过网络传输的数据大小相似即可。最终结果应该是客户端独立地可用于以后的使用。 - Elist
    @RoryMcCrossan - 我给你的答案点了赞,它让我对 offset 的概念有了一些想法,但实际上,它并不符合我的要求... - Elist
    有没有一种方法可以窥视elementFromPoint的JavaScript实现?这对我来说将是一个很好的起点。 - Elist
    也许你可以在elementFromPoint()扫描中有点“半幼稚”,跳过10px而不是1px。只有当元素与之前的元素不同时,你才会回溯(或转到1px rez)。此外,getBoundingClientRect()很昂贵,你可以在调用它之前通过使用类似if(!e.scrollHeight || !e.scrollWidth) continue;的东西进行检查,从而提前退出循环。 - dandavis
    3个回答

    1

    尝试

    var res = [];
    $("body *").each(function (i, el) {
        if ((el.getBoundingClientRect().bottom <= window.innerHeight 
            || el.getBoundingClientRect().top <= window.innerHeight)
            && el.getBoundingClientRect().right <= window.innerWidth) {
                res.push([el.tagName.toLowerCase(), el.getBoundingClientRect()]);
        };
    });
    

    我是一名有用的助手,可以为您翻译文本。

    jsfiddle http://jsfiddle.net/guest271314/ueum30g5/

    请参见Element.getBoundingClientRect()

    $.each(new Array(180), function () {
        $("body").append(
        $("<img>"))
    });
    
    $.each(new Array(180), function () {
    $("body").append(
    $("<img>"))
    });
    
    var res = [];
    $("body *").each(function (i, el) {
    if ((el.getBoundingClientRect().bottom <= window.innerHeight || el.getBoundingClientRect().top <= window.innerHeight)
        && el.getBoundingClientRect().right <= window.innerWidth) {
        res.push(
        [el.tagName.toLowerCase(),
        el.getBoundingClientRect()]);
        $(el).css(
            "outline", "0.15em solid red");
        $("body").append(JSON.stringify(res, null, 4));
        console.log(res)
    };
    });
    body {
        width : 1000px;
        height : 1000px;
    }
    img {
        width : 50px;
        height : 50px;
        background : navy;
    }
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>


    @ZachSaucier 部分位于视口内的元素可以通过调整 || el.getBoundingClientRect().top <= window.innerHeight(包括垂直方向上部分位于视口内的元素,移除以排除顶部部分位于视口内的元素)和 el.getBoundingClientRect().right <= window.innerWidth(包括水平方向上部分位于视口内的元素 - 调整以排除部分可见元素)将其包含在 res 中或从 res 中排除。谢谢 - guest271314
    1
    谢谢,但是你的脚本没有处理重叠元素的功能,这正是我的问题所在... - Elist
    @Elist 没错。所有的答案都涉及到“窗口”,其中元素可以被其他元素隐藏,有时还带有隐藏的溢出。 - Nathan B

    1
    我不知道性能是否足够(特别是在移动设备上),而且结果并不完全符合您要求的矩形集,但您考虑过使用位图来存储结果吗?
    请注意,某些元素可能具有3D CSS变换(例如倾斜,旋转),某些元素可能具有边框半径,并且某些元素可能具有不可见背景-如果您还想为“像素元素”功能包括这些功能,则矩形集无法帮助您-但是位图可以容纳所有视觉特征。
    生成位图的解决方案相当简单(我想象...未经测试):
    1. 创建一个与可见屏幕大小相同的画布。 2. 递归地遍历所有元素,按z-order排序,忽略隐藏的元素。 3. 对于每个元素,在画布中绘制一个矩形,矩形的颜色是元素的标识符(例如可以是递增计数器)。如果需要,可以根据元素的视觉特征(倾斜、旋转、边框半径等)修改矩形。 4. 将画布保存为无损格式,例如png而不是jpg。 5. 将位图作为屏幕上元素的元数据发送。
    要查询在点(x,y)处的元素,您可以检查像素(x,y)处位图的颜色,并且该颜色将告诉您该元素是什么。

    有趣的方法。除了z-index和css-displayed之外,我还应该考虑哪些可见性方面呢?我的意思是,元素不能移动/浮动/出现在其父级边界之外吗? - Elist
    说实话,我不太确定。元素肯定可以超出其父范围,但这并不是上述算法的问题。我知道会有与iframes和IE7相关的复杂情况,还有其他问题我晚上想不到,但我认为这是可行的。 - Iftah
    另一个可能的解决方案并不完全是你所要求的矩形集合 - 将整个DOM状态与服务器同步,然后使用服务器端浏览器组件(例如http://phantomjs.org/)来查询点(x,y)处的元素。 - Iftah
    你能更具体地说明如何拍摄DOM状态的快照(包括客户端大小、滚动、缩放等)吗? - Elist
    我认为 document.body.innerHTML 应该可以解决问题,但如果网站比较复杂,它可能会很大,因此如果您希望快速与服务器同步状态,则可能不太适合。 - Iftah

    0
    如果你可以放弃IE,这里有一个简单的方法:
    function getElementVisibleRect(el) {
      return new Promise((resolve, reject) => {
        el.style.overflow = "hidden";
        requestAnimationFrame((timeStamp) => {
          var br = el.getBoundingClientRect();
          el.style.overflow = "";
          resolve(br);
        });
      });
    }
    

    即使如此,Promises 很容易进行 polyfill,并且 requestAnimationFrame() 可以在 IE 8 上运行。而到了 2016 年,你唯一需要考虑的是为那些使用旧版 IE 的可怜人提供一个可读的体验。

    你能解释一下改变 style.overflow 和调用 requestAnimationFrame 的作用吗?我不能只调用 getBoundingClientRect 吗?此外,这似乎无法帮助计算重叠/超出视口区域。 - Elist

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接