向Zepto学习关于偏移的那些事

前言

这篇文章主要想说一下Zepto中与”偏移”相关的一些事,很久很久以前,我们经常会使用offsetpositionscrollTopscrollLeft等方式去改变元素的位置,他们之间有什么区别,是怎么实现的呢?接下来我们一点点去扒开他们的面纱。

原文链接

源码仓库

offsetParent

offsetposition两个api内部的实现都依赖offsetParent方法,我们先看一下它是怎么一回事。

找到第一个定位过的祖先元素,意味着它的css中的position 属性值为“relative”, “absolute” or “fixed” #offsetParent

我们都知道css属性position用于指定一个元素在文档中的定位方式,其初始值是static, css3中甚至还增加了sticky等属性,不过目前貌似浏览器几乎还未支持。

看一下这个例子

html

1
2
3
4
5
6
7
<div class="wrap">
<div class="child1">
<div class="child2">
<div class="child3"></div>
</div>
</div>
</div>

css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<style>
.wrap{
width: 400px;
height: 400px;
border: solid 1px red;
}
.child1{
width: 300px;
height: 300px;
border: solid 1px green;
position: relative;
padding: 10px;
}
.child2{
width: 200px;
height: 200px;
border: solid 1px bisque;
}
.child3{
width: 100px;
height: 100px;
border: solid 1px goldenrod;
position: absolute;
left: 0;
top: 0;
}
</style>

javascript

1
2
console.log($('.child3').offsetParent()) // child1
console.log(document.querySelector('.child3').offsetParent) // child1

既然原生已经有了一个offsetParentmdn offsetParent属性供我们使用,为什么Zepto还要自己实现一个呢?其实他们之间还是有些不同的,比如同样是上面的例子,如果child3的display属性设置为了none,原生的offsetParent返回的是null,但是Zepto返回的是包含body元素的Zepto对象。

源码分析

1
2
3
4
5
6
7
8
9
offsetParent: function () {
return this.map(function () {
var parent = this.offsetParent || document.body
while (parent && !rootNodeRE.test(parent.nodeName) && $(parent).css("position") == "static")
parent = parent.offsetParent
return parent
})
}

实现逻辑还是比较简单,通过map方法遍历当前选中的元素集合,结果是一个数组,每个项即是元素的最近的定位祖先元素。

首先通过offsetParent原生DOM属性去获取定位元素,如果没有默认是body节点,这里其实就能解释前面的child3设置为display:none,原生返回null,但是Zepto得到的是body了

1
var parent = this.offsetParent || document.body

再通过一个while循环如果

  1. parent元素存在
  2. parent元素不是html或者body元素
  3. parent元素的display属性是static,则再次获取parent属性的offsetParent再次循环。

offset

获得当前元素相对于document的位置。返回一个对象含有: top, left, width和height

当给定一个含有left和top属性对象时,使用这些值来对集合中每一个元素进行相对于document的定位。

  1. offset() ⇒ object
  2. offset(coordinates) ⇒ self v1.0+
  3. offset(function(index, oldOffset){ … }) ⇒

#offset

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
offset: function (coordinates) {
if (coordinates) return this.each(function (index) {
var $this = $(this),
coords = funcArg(this, coordinates, index, $this.offset()),
parentOffset = $this.offsetParent().offset(),
props = {
top: coords.top - parentOffset.top,
left: coords.left - parentOffset.left
}
if ($this.css('position') == 'static') props['position'] = 'relative'
$this.css(props)
})
if (!this.length) return null
if (document.documentElement !== this[0] && !$.contains(document.documentElement, this[0]))
return { top: 0, left: 0 }
var obj = this[0].getBoundingClientRect()
return {
left: obj.left + window.pageXOffset,
top: obj.top + window.pageYOffset,
width: Math.round(obj.width),
height: Math.round(obj.height)
}
}

和Zepto中的其他api类似遵循get one, set all原则,我们先来看看获取操作是如何实现的。

1
2
3
4
5
6
7
8
9
10
11
if (!this.length) return null
if (document.documentElement !== this[0] && !$.contains(document.documentElement, this[0]))
return { top: 0, left: 0 }
var obj = this[0].getBoundingClientRect()
return {
left: obj.left + window.pageXOffset,
top: obj.top + window.pageYOffset,
width: Math.round(obj.width),
height: Math.round(obj.height)
}
  1. !this.length如果当前没有选中元素,自然就没有往下走的必要了,直接return掉

  2. 当前选中的集合中不是html元素,并且也不是html节点子元素。直接返回{ top: 0, left: 0 }

  3. 接下来的逻辑才是重点。首先通过getBoundingClientRect获取元素的大小及其相对于视口的位置,再通过pageXOffsetpageYOffset获取文档在水平和垂直方向已滚动的像素值,相加既得到我们最后想要的值。

再看设置操作如何实现之前,先看下面这张图,或许会有助于理解

offset

1
2
3
4
5
6
7
8
9
10
11
12
if (coordinates) return this.each(function(index) {
var $this = $(this),
coords = funcArg(this, coordinates, index, $this.offset()),
parentOffset = $this.offsetParent().offset(),
props = {
top: coords.top - parentOffset.top,
left: coords.left - parentOffset.left
}
if ($this.css('position') == 'static') props['position'] = 'relative'
$this.css(props)
})

还是那个熟悉的模式,熟悉的套路,循环遍历当前元素集合,方便挨个设置,通过funcArg函数包装一下,使得入参既可以是函数,也可以是其他形式。

通过上面那张图,我们应该可以很清晰的看出,如果要将子元素设置到传入的coords.left的位置,那其实

  1. 父元素(假设父元素是定位元素)相对文档的左边距(parentOffset.left)
  2. 子元素相对父元素的左边距(left)
  3. 相加得到的就是入参coords.left

那再做个减法,就得到我们最终通过css方法需要设置的left和top值啦。

需要注意的是如果元素的定位属性是static,则会将其改为relative定位,相对于其正常文档流来计算。

position

获取对象集合中第一个元素相对于其offsetParent的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
position: function() {
if (!this.length) return
var elem = this[0],
offsetParent = this.offsetParent(),
offset = this.offset(),
parentOffset = rootNodeRE.test(offsetParent[0].nodeName) ? { top: 0, left: 0 } : offsetParent.offset()
offset.top -= parseFloat($(elem).css('margin-top')) || 0
offset.left -= parseFloat($(elem).css('margin-left')) || 0
parentOffset.top += parseFloat($(offsetParent[0]).css('border-top-width')) || 0
parentOffset.left += parseFloat($(offsetParent[0]).css('border-left-width')) || 0
return {
top: offset.top - parentOffset.top,
left: offset.left - parentOffset.left
}
}

先看一个例子

html

1
2
3
4
<div class="parent">
<div class="child"></div>
</div>

css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.parent{
width: 400px;
height: 400px;
border: solid 1px red;
padding: 10px;
margin: 10px;
position: relative;
}
.child{
width: 200px;
height: 200px;
border: solid 1px green;
padding: 20px;
margin: 20px;
}
1
console.log($('.child').position()) // {top: 10, left: 10}

下面分别是父子元素的盒模型以及标注了需要获取的top的值

接下来我们来看它怎么实现的吧,come on!!!

  1. 第一步
1
2
3
4
5
6
7
var offsetParent = this.offsetParent(),
// Get correct offsets
// 获取当前元素相对于document的位置
offset = this.offset(),
// 获取第一个定位祖先元素相对于document的位置,如果是根元素(html或者body)则为0, 0
parentOffset = rootNodeRE.test(offsetParent[0].nodeName) ? { top: 0, left: 0 } : offsetParent.offset()
  1. 第二步
1
2
3
4
// 相对于第一个定位祖先元素的位置关系不应该包括margin的举例,所以减去
offset.top -= parseFloat($(elem).css('margin-top')) || 0
offset.left -= parseFloat($(elem).css('margin-left')) || 0
  1. 第三步
1
2
3
4
// 祖先定位元素加上border的宽度
parentOffset.top += parseFloat($(offsetParent[0]).css('border-top-width')) || 0
parentOffset.left += parseFloat($(offsetParent[0]).css('border-left-width')) || 0

第四步

1
2
3
4
5
6
// 相减即结果
return {
top: offset.top - parentOffset.top,
left: offset.left - parentOffset.left
}

整体思路还是用当前元素相对于文档的位置减去第一个定位祖先元素相对于文档的位置,但有两点需要注意的是position这个api要计算出来的值,不应该包括父元素的border长度以及子元素的margin空间长度。所以才会有第二和第三步。

scrollLeft

获取或设置页面上的滚动元素或者整个窗口向右滚动的滚动距离。

1
2
3
4
5
6
7
8
9
scrollLeft: function (value) {
if (!this.length) return
var hasScrollLeft = 'scrollLeft' in this[0]
if (value === undefined) return hasScrollLeft ? this[0].scrollLeft : this[0].pageXOffset
return this.each(hasScrollLeft ?
function () { this.scrollLeft = value } :
function () { this.scrollTo(value, this.scrollY) })
}

首先判断当前选中的元素是否支持scrollLeft特性。

如果value没有传进来,又支持hasScrollLeft特性,就返回第一个元素的hasScrollLeft值,不支持的话返回第一个元素的pageXOffset值。

pageXOffset是scrollX的别名,而其代表的含义是返回文档/页面水平方向滚动的像素值

传进来了value就是设置操作了,支持scrollLeft属性,就直接设置其值即可,反之需要用到scrollTo,当然设置水平方向的时候,垂直方向还是要和之前的保持一致,所以传入了scrollY作为

scrollTop

获取或设置页面上的滚动元素或者整个窗口向下滚动的距离。

1
2
3
4
5
6
7
8
scrollTop: function(value) {
if (!this.length) return
var hasScrollTop = 'scrollTop' in this[0]
if (value === undefined) return hasScrollTop ? this[0].scrollTop : this[0].pageYOffset
return this.each(hasScrollTop ?
function() { this.scrollTop = value } :
function() { this.scrollTo(this.scrollX, value) })
},

可以看出基本原理和模式与scrollLeft一致,就不再一一解析。

结尾

以上就是Zepto中与”偏移”相关的几个api的解析,欢迎指出其中的问题和有错误的地方。

参考

读Zepto源码之属性操作

scrollTo

scrollLeft

pageXOffset

文章记录

ie模块

  1. Zepto源码分析之ie模块(2017-11-03)

data模块

  1. Zepto中数据缓存原理与实现(2017-10-03)

form模块

  1. zepto源码分析之form模块(2017-10-01)

zepto模块

  1. 这些Zepto中实用的方法集(2017-08-26)
  2. Zepto核心模块之工具方法拾遗 (2017-08-30)
  3. 看zepto如何实现增删改查DOM (2017-10-2)
  4. Zepto这样操作元素属性(2017-11-13)
  5. 向Zepto学习关于”偏移”的那些事(2017-12-10)

event模块

  1. mouseenter与mouseover为何这般纠缠不清?(2017-06-05)
  2. 向zepto.js学习如何手动触发DOM事件(2017-06-07)
  3. 谁说你只是”会用”jQuery?(2017-06-08)

ajax模块

  1. 原来你是这样的jsonp(原理与具体实现细节)(2017-06-11)