jQuery 源码:元素位置
一、前记
本文围绕“获取元素的位置” 这一主题,以 jQuery api 中的 offset()、 position()、offsetParent() 三个函数作为切入点 ,分析背后的兼容原理。
写本文所测试的浏览器版本分别是: IE6(ietester), IE7,IE8,FF18.0,CHROME 23.0.1271.97 m。下文中的浏览器测试结果均只限于这些版本。
二、offset()
offset 函数返回元素的文档坐标。jQuery 依赖 getBoundingClientRect() 函数获取元素的窗口坐标,之后转换为文档坐标。转换的过程中,需要把窗口坐标的x,y值分别加上文档在x和y轴方向的滚动距离,再分别加上文档在x和y轴方向上的边框的宽度。然而,获取文档的滚动距离和边框宽度也并非一帆风顺,需要使用clientTop 、clientLeft 、scrollTop 、 scrollLeft 对IE8-进行兼容问题。
getBoundingClientRect() 返回的对象包含left/top属性分别代表元素在x/y坐标轴上距离文档左上角的距离。需要注意的是,此距离没有把文档的边框计算在内。由于 IE5 率先引入了这个函数,兼容性良好。但是获取文档的滚动距离和边框宽度就并非一帆风顺了。
获取文档的滚动距离方面,在IE9+浏览器及其它现代浏览器中,使用window.pageXOffset 和 window.pageYOffset 可以达到目的。对于IE8-,如果文档在混杂模式下,则document.body.scrollLeft(Top)为所求,此时,通过document.documentElement.scrollLeft(Top)获取的值为0;否则,document.documentElement.scrollLeft(Top) 为所求,此时通过document.body.scrollLeft(Top)获取的值为0。
由于存在这种特性,所以可以把获取滚动距离的代码简化为:
var html = document.documentElement, body = document.body; //其中 html 与 body 顺序可互换 var scrollLeft = window.pageXOffset || html.scrollLeft || body.scrollLeft, scrollTop = window.pageYOffset || html.scrollTop || body.scrollTop;
jQuery 1.6.3 的实现如下:
scrollTop = win.pageYOffset || jQuery.support.boxModel && docElem.scrollTop || body.scrollTop;
scrollLeft = win.pageXOffset || jQuery.support.boxModel && docElem.scrollLeft || body.scrollLeft;
以scrollTop为例,其中的逻辑是,优先用pageXOffset,否则,对于老版本,则如果当前文档支持标准盒模型(boxModel为true,即不处在怪异模式下),则使用document.documentElement.scrollTop,否则用document.body.scrollTop。(这段代码的合理性,可以参考 《javascript 权威指南》第六版翻译版 15.8.1 节 文档坐标和视口坐标 例 15-8 ,以下称此书为"犀牛书")
不过 jQuery 1.8.3 则似乎放弃了对怪异模式的支持:
scrollTop = win.pageYOffset || docElem.scrollTop;
scrollLeft = win.pageXOffset || docElem.scrollLeft;
获取文档的左、上边框宽度方面,经过测试发现,在不同的文档渲染模式下,假如通过js或css设置了<html>或者<body>的左/上边框宽度,则通过clientLeft(Top)获取的值存在兼容性问题。jQuery在获取这两个值的过程中似乎完全不理会这些问题:
clientTop = docElem.clientTop || body.clientTop || 0,
clientLeft = docElem.clientLeft || body.clientLeft || 0,
从代码中可以看出,jQuery 有很明显的 "未设置过<html> 或者<body>元素的上、左边框的宽度" 这个假设。不过,即便在这种假设下,它的代码依旧存在兼容问题。例如,在这个假设下,当文档模式为"Transitional"或者使用<!DOCTYPE html> 时,IE7 下的 docElem.clientLeft(Top) 均为2,其余均为0。据此,我认为,代码还是改成下面这样最好:
clientTop = docElem.clientTop + body.clientTop || 0,
clientLeft = docElem.clientLeft + body.clientLeft || 0,
接下来讲讲 <html> 和 <body> 元素的 clientLeft(Top) 值在不同文档模式和设置过border-Left(Top) 值的情况下的兼容问题,以上边框宽度为例。
未设置<html>的border-top:
1. 文档模式为 Transitional 或者 <!DOCTYPE html>时,IE7 取值2,其余0
2. 未指定文档类型也不使用<!DOCTYPE html> 时,都是0
设置<html>的border-top为10px :
1. 文档模式为 Transitional 或者 <!DOCTYPE html>时,firefox 取值0,IE7 取值2 , IE68 和 chrome 取值10
2. 未指定文档类型也不使用<!DOCTYPE html> 时,firefox 取值10,IE7 取值0,IE68 取值0,chrome 取值10
此外,本文中把 clientTop 说成是上边框的宽度,这是不严格的,大部分情况下,可以这么理解,但假如文档的垂直滚动条出现在文档上部,那么clientTop还要加上滚动条的高度。这部分论述,见犀牛书15.8.5 节。
附jQuery 1.6.3 offset() 函数的实现代码:
三、offsetParent()
offsetParent 的定义:
让笔者一直很揪心的是,犀牛书、《javascript高级程序设计》甚至 W3C 文档 都没有对 offsetParent 进行过定义,不过 MDN 倒是给出了 offsetParent 的定义,该定义指出,offsetParent 是离调用它的元素最近的已经定位了的元素,如果元素本身未定位,则其offsetParent是离它最近的文档根元素或者表格单元(table cell)。
MSDN 未给出关于 offsetParent 的较有用的信息。
获取 offsetParent 的算法:
在一份 W3C文档 中,offsetParent 以及 offsetLeft 等属性被作为 HTMLElement 接口的扩展属性。该文档虽然没有给出 offsetParent 的定义,但却给出了获取 offsetParent 的算法:
The offsetParent attribute must return the result of running these steps:
1. If any of the following holds true return null and terminate this algorithm:
1) The element does not have an associated CSS layout box.
2) The element is the root element.
3) The element is the HTML body element.
4) The element's computed value of the 'position' property is fixed.
2. Return the nearest ancestor element of the element for which at least one of the following is true and terminate this algorithm if such an ancestor is found:
1) The computed value of the 'position' property is not static.
2) It is the HTML body element.
3) The computed value of the 'position' property of the element is static and the ancestor is one of the following HTML elements: td, th, or table.
3. Return null.
算法的一些理解:
首先,必须指出,offsetParent 是对 HTMLElement 接口的扩展属性,所以,文档片段、文本节点、注释节点等是不具有此属性的,它们的 offsetParent 为 undefined(而不是算法中提到的 null)。
其次,注意到算法中出现了 "CSS layout box" 这个短语,在 W3C 上无法寻到定义。笔者猜测,将 HTMLElement 插入到文档中经过浏览器渲染之后,该元素就会获得这个所谓的 "CSS layout box",未渲染则offsetParent 为 null。给出小例子如下:
var dad = document.createElement('div'), son = document.createElement('div'); dad.appendChild(son); console.log(dad.offsetParent);//dad 没有加入文档中,null console.log(son.offsetParent);//son 没有加入文档中,null
此外,固定定位的元素是相对于视口定位的,所以其 offsetParent 从道理上讲应该是 null 。
最后, 对比 W3C 文档与 MDN 的定义,可以大致了解到 MDN 定义里的所谓 "未定位的" 是指 position 值为 'static' ,而所谓的 “table cell” 指的是 <table> <td> 或 <th> 。
如果元素已经渲染并且既不是根元素也不是<body>元素而且也不是固定定位的,那么算法会在节点层次中上溯直到找到第一个position的计算值不是'static'的元素作为offsetParent。在此过程中,如果遇到<body> 则将其作为返回值;如果发现了<td> <th> 或者<table> 并且该元素本身position属性的计算值是 'static' 则返回该 td/th/table 元素。
各浏览器实现的差异:
1. firefox 中,固定定位元素的 offsetParent 不为 null,webkit 浏览器和 IE 则不为null,与标准一致。
2. 一旦元素或元素的祖先元素被设置了display:none; 则 firefox 和 webkit 下该元素的 offsetParent 为 null ,IE 下不为 null。
jQuery 实现:
var rtable = /^t(?:able|d|h)$/i, rroot = /^(?:body|html)$/i; jQuery.fn.extend({ offsetParent: function() { return this.map(function() { var offsetParent = this.offsetParent || document.body; while ( offsetParent && (!rroot.test(offsetParent.nodeName) && jQuery.css(offsetParent, "position") === "static") ) { offsetParent = offsetParent.offsetParent; } return offsetParent; }); } });
注意到 jQuery 别有用心地把那些 position 的 computed value 值为 'static' 的 offsetParent 给过滤掉了,这直接导致, offsetParent() 函数的返回值里,将不会出现 <table> <td> <th> 这三种元素。细想其用意,也许是要把 offsetParent 统一到已定位的元素(position值不为static)上吧。
四、position()
position() 函数返回元素在它的 offsetParent 中的坐标。它的运作方式是,找到 offsetParent ,分别获取元素本身以及 offsetParent 的文档坐标,然后用两者的坐标做差求得结果,不过还要减去 offsetParent 的上(左)边框以及上(左)边距的宽度。
这个过程依赖于上文提到的 offset() 和 offsetParent() 函数,代码中的注释指出,在safari下,若对元素设置了margin:auto; 则会导致offsetLeft与marginLeft相等,从而导致BUG,jQuery 未对此做处理,其余不再赘述。
2. 代码
jQuery.fn.extend({ position: function() { if ( !this[0] ) { return null; } var elem = this[0], // Get *real* offsetParent offsetParent = this.offsetParent(), // Get correct offsets offset = this.offset(), parentOffset = rroot.test(offsetParent[0].nodeName) ? { top: 0, left: 0 } : offsetParent.offset(); // Subtract element margins // note: when an element has margin: auto the offsetLeft and marginLeft // are the same in Safari causing offset.left to incorrectly be 0 offset.top -= parseFloat( jQuery.css(elem, "marginTop") ) || 0; offset.left -= parseFloat( jQuery.css(elem, "marginLeft") ) || 0; // Add offsetParent borders parentOffset.top += parseFloat( jQuery.css(offsetParent[0], "borderTopWidth") ) || 0; parentOffset.left += parseFloat( jQuery.css(offsetParent[0], "borderLeftWidth") ) || 0; // Subtract the two offsets return { top: offset.top - parentOffset.top, left: offset.left - parentOffset.left }; } });
五、参考书目、网站索引
1] 《javascript权威指南》第六版淘宝翻译版
1) clientTop / clientLeft ( 15.8.5 节)clientLeft 和 clientTop 属性没什么用: 它们返回元素的内边距的外边缘和它的边框的外边缘之间的水平距离和垂直距离,通常这些值就等于左边和上边的边框宽度。但是,如果元素有滚动条,并且浏览器将这些滚动条放置在左侧或顶部(可这不太常见),clientLeft 和 clientTop 也就包含了滚动条的宽度。对于内联元素,clientLeft 和 clientTop 总是为0。
2) offsetParent (客户端javascript参考部分,Element词条的offsetParent 部分) 。指出了<table> <td> <th> 可能是 offset parent。指出了 offset parent 值为 null 的一个可能情况,当然,没说全,更全的应参考 W3C 文档,例如 <html> 和 <body> 的 offsetParent 为 null 等等。
3) 文档与视口坐标(15.8.1 节例15-8)
2] 《javascript高级程序设计》第二版
1) offsetParent (11.2.3 节),信息量不大,"<td>元素的offsetParent是作为其祖先元素的<table>元素,因为<table>是在DOM层次中距<td>最近的一个具有大小的元素"
3] W3C
1) offsetParent等 指出是HTMLElement的扩展属性; 未给出定义,但给出计算方法,非常有用
4] MSDN
1) offsetParent 给出了 firefox 版的定义以及兼容提示