getBoundingClientRect()を使って要素のページ内座標を取得するよい方法

結論

function getAbsolutePosition(elm){
  const {left, top} = elm.getBoundingClientRect();
  const {left: bleft, top: btop} = document.body.getBoundingClientRect();
  return {
    left: left - bleft,
    top: top - btop,
  };
}

解説

Webページ中で、JavaScriptからある要素のページ内での位置を取得したいという需要がときどき発生します。 そのための方法は主に2通りあり、1つはoffsetTop, offsetLeft及びoffsetParentを用いる方法です。 もう1つがgetBoundingClientRect()を用いる方法です。これらはいずれもCSSOM View Moduleで定義されています。

後者のほうがより簡潔に記述できる上用途に適していると考えられますので、この記事では後者を取り扱います。 getBoundingClientRect()はある要素の位置や大きさの情報を返してくれるのですが、この位置情報は現在のviewportに相対的なものとなっています(CSSOM Viewをざっと読んだだけであまり理解していないのでこれが正しいのかどうか自信がありませんが、広くそのように理解されています)。 つまり、ブラウザの表示領域の左上を(0, 0)としてそこからの相対位置で示されています。 要素のページ内座標を取得するには、この値をページ内の位置に変換する必要があります。 従来広く用いられてきた方法は次のようなものです。

function getAbsolutePosition(elm){
  const {left, top} = elm.getBoundingClientRect();
  return {
    left: left + window.scrollX,
    top: top + window.scrollY,
  };
}

すなわち、ブラウザの表示領域の左上のページ内座標はページのスクロール量に一致すると考えられるため、それを足すことでページ内座標に変換するという方法です。

この方法はうまくいくように見えますが、実は問題がありました。 スマートフォンやタッチパネル付きのタブレットPC等で2本指のジェスチャーによりページを拡大・スクロールした場合、window.scrollXwindow.scrollYは正しくスクロール量を返す一方、getBoundingClientRect()はなぜか表示領域の左上ではなくページの左上を起点とするようになり、この方法だとスクロール量が2重に加味されてしまいずれた値が返ってしまいます。 なぜこの挙動になるのかは調査してもいまいち分かりませんでしたが、iOS10上のSafariLinux上のChrome46が共にこの挙動を示すことを確認しました。

この問題を回避するのが冒頭の手法です。 document.bodyは常にページの左上に位置すると考え、そことの差を取ることでページ内での座標を取得します。 この方法では要素の位置とページの左上の位置をともにgetBoundingClientRect()で取得するため、getBoundingClientRect()が基準とする座標がずれても問題なく動作できるという利点があります。 もしかしたらパフォーマンスが違うかもしれませんが、未検証です。

余談

要素のページ内での絶対座標を取得したい場面でよくあるのが、マウス等のイベントでマウスの要素内での位置を取得したい場合です。自分も今回そのケースで悩んでいました。 定石はイベントのpageX, pageYと要素の絶対座標から求める方法ですが、よく考えたらclientXclientYがあるのだからそれとgetBoundingClientRect()でいいのではないかという気がしてきました。

余談2

仕様書を読むとleftとかtopよりもxyを使ってこうしたほうがいいと思うのですが、ブラウザ(少なくともChrome)が対応していませんでした。

function getAbsolutePosition(elm){
  const {x, y} = elm.getBoundingClientRect();
  const {x: bx, y: by} = document.body.getBoundingClientRect();
  return {
    x: x - bx,
    y: y - by,
  };
}