向Zepto學習關於"偏移"的那些事

謙龍發表於2017-12-16

前言

這篇文章主要想說一下Zepto中與"偏移"相關的一些事,很久很久以前,我們經常會使用offsetpositionscrollTopscrollLeft等方式去改變元素的位置,他們之間有什麼區別,是怎麼實現的呢?接下來我們一點點去扒開他們的面紗。

原文連結

原始碼倉庫

向Zepto學習關於"偏移"的那些事

offsetParent

offsetposition兩個api內部的實現都依賴offsetParent方法,我們先看一下它是怎麼一回事。

找到第一個定位過的祖先元素,意味著它的css中的position 屬性值為“relative”, “absolute” or “fixed” #offsetParent

我們都知道css屬性position用於指定一個元素在文件中的定位方式,其初始值是static, css3中甚至還增加了sticky等屬性,不過目前貌似瀏覽器幾乎還未支援。

看一下這個例子

html

<div class="wrap">
  <div class="child1">
    <div class="child2">
      <div class="child3"></div>
    </div>
  </div>
</div>

複製程式碼

css

<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

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

複製程式碼

既然原生已經有了一個offsetParentmdn offsetParent屬性供我們使用,為什麼Zepto還要自己實現一個呢?其實他們之間還是有些不同的,比如同樣是上面的例子,如果child3的display屬性設定為了none,原生的offsetParent返回的是null,但是Zepto返回的是包含body元素的Zepto物件。

原始碼分析


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了

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

原始碼

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原則,我們先來看看獲取操作是如何實現的。


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

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的位置。


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


<div class="parent">
  <div class="child"></div>
</div>

複製程式碼

css


.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;
}


複製程式碼
console.log($('.child').position()) // {top: 10, left: 10}

複製程式碼

下面分別是父子元素的盒模型以及標註了需要獲取的top的值

向Zepto學習關於"偏移"的那些事

向Zepto學習關於"偏移"的那些事

接下來我們來看它怎麼實現的吧,come on!!!

  1. 第一步

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. 第二步

// 相對於第一個定位祖先元素的位置關係不應該包括margin的舉例,所以減去
offset.top -= parseFloat($(elem).css('margin-top')) || 0
offset.left -= parseFloat($(elem).css('margin-left')) || 0

複製程式碼
  1. 第三步

// 祖先定位元素加上border的寬度
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
}


複製程式碼

整體思路還是用當前元素相對於文件的位置減去第一個定位祖先元素相對於文件的位置,但有兩點需要注意的是position這個api要計算出來的值,不應該包括父元素的border長度以及子元素的margin空間長度。所以才會有第二和第三步。

scrollLeft

獲取或設定頁面上的滾動元素或者整個視窗向右滾動的滾動距離。


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

獲取或設定頁面上的滾動元素或者整個視窗向下滾動的距離。

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)

相關文章