看Zepto如何實現增刪改查DOM

謙龍發表於2017-10-02

前言

dom也就是文件物件模型,是針對HTML和XML的一個api,描繪了一個層次化的節點樹。雖然瀏覽器原生給我們提供了許多操作dom的方法,使我們可以對dom進行查詢,複製,替換和刪除等操作。但是zepto在其基礎上再次封裝,給以我們更加便捷的操作方式。先看下圖,我們以刪除元素插入元素,複製元素包裹元素替換元素幾個模組分別探究zepto如何一一將其實現。

DOM操作
DOM操作

原文連結

github專案地址

刪除元素

remove

當父節點存在時,從其父節點中刪除當前集合中的元素。

remove: function () {
  return this.each(function () {
    if (this.parentNode != null)
      this.parentNode.removeChild(this)
  })
}複製程式碼

遍歷當前集合中的元素,當該元素的父節點存在的時候,使用removeChild刪除該元素。

detach

功能和remove一樣,都是刪除元素。

$.fn.detach = $.fn.remove複製程式碼

可以看到就是在$的原型上新增了一個指向remove函式的方法detach

empty

清空物件集合中每個元素的DOM內容

empty: function () {
  return this.each(function () { this.innerHTML = '' })
},複製程式碼

遍歷當前集合中的元素,然後將元素的innerHTML屬性設定為空。也就達到了清除DOM內容的目的。

插入元素

插入元素的相關api比較多,我們先來重溫部分api的使用用法和比較一下他們之間的區別。

append, prepend, after, before


<ul class="box">
  <li>1</li>
</ul>複製程式碼
let $box = $('.box')
let insertDom = '<li>i am child</li>'

// append appendTo
// $box.append(insertDom)
// $(insertDom).appendTo($box)

/*
  <ul class="box">
    <li>1</li>
    <li>i am child</li>
  </ul>
*/


// prepend prependTo
// $box.prepend(insertDom)
// $(insertDom).prependTo($box)

/*
  <ul class="box">
    <li>i am child</li>
    <li>1</li>
  </ul>
*/

// before insertBefore
// $box.before(insertDom)
// $(insertDom).insertBefore($box)

/*
  <li>i am child</li>
  <ul class="box">
    <li>1</li>
  </ul>
*/

 // after insertAfter
// $box.after(insertDom)
// $(insertDom).insertAfter($box)

/*
  <ul class="box">
    <li>1</li>
  </ul>
  <li>i am child</li>
*/複製程式碼

以上是append,appendTo,prepend,prependTo,after,insertAfter,before,insertBefore八個方法的基本用法,以及用過之後的dom結構。我們總結一下他們的區別。

首先每個方法的入參都可以為html字串,dom節點,或者節點組成的陣列。參考自zeptojs_api

append,appendTo,prepend,prependTo都是在元素內部插入內容,而after,insertAfter,before,insertBefore則是在元素外部插入內容。

append,appendTo是在元素的末尾插入內容,prepend,prependTo是在元素的初始位置插入,after,insertAfter是在元素的後面插入內容,before,insertBefore則是在元素的前面插入內容

接下來我們開始學習和閱讀實現這8大方法的核心原始碼部分

adjacencyOperators = ['after', 'prepend', 'before', 'append']

adjacencyOperators.forEach(function(operator, operatorIndex) {
  var inside = operatorIndex % 2

  $.fn[operator] = function() {
    // arguments can be nodes, arrays of nodes, Zepto objects and HTML strings
    var argType, nodes = $.map(arguments, function(arg) {
      var arr = []
      argType = type(arg)
      if (argType == "array") {
        arg.forEach(function(el) {
          if (el.nodeType !== undefined) return arr.push(el)
          else if ($.zepto.isZ(el)) return arr = arr.concat(el.get())
          arr = arr.concat(zepto.fragment(el))
        })
        return arr
      }
      return argType == "object" || arg == null ?
        arg : zepto.fragment(arg)
    }),
        parent, copyByClone = this.length > 1
    if (nodes.length < 1) return this

    return this.each(function(_, target) {
      parent = inside ? target : target.parentNode

      // convert all methods to a "before" operation
      target = operatorIndex == 0 ? target.nextSibling :
      operatorIndex == 1 ? target.firstChild :
      operatorIndex == 2 ? target :
      null

      var parentInDocument = $.contains(document.documentElement, parent)

      nodes.forEach(function(node) {
        if (copyByClone) node = node.cloneNode(true)
        else if (!parent) return $(node).remove()

        parent.insertBefore(node, target)
        if (parentInDocument) traverseNode(node, function(el) {
          if (el.nodeName != null && el.nodeName.toUpperCase() === 'SCRIPT' &&
              (!el.type || el.type === 'text/javascript') && !el.src) {
            var target = el.ownerDocument ? el.ownerDocument.defaultView : window
            target['eval'].call(target, el.innerHTML)
          }
        })
          })
    })
  }複製程式碼

遍歷adjacencyOperators陣列給$原型新增對應的方法

adjacencyOperators = ['after', 'prepend', 'before', 'append']

adjacencyOperators.forEach(function(operator, operatorIndex) {
  // xxx
  $.fn[operator] = function() {
    // xxx
  }
  // xxx
})複製程式碼

可以看到通過迴圈遍歷adjacencyOperators從而給$的原型新增對應的方法。

轉換node節點


var argType, nodes = $.map(arguments, function(arg) {
  var arr = []
  argType = type(arg)
  if (argType == "array") {
    arg.forEach(function(el) {
      if (el.nodeType !== undefined) return arr.push(el)
      else if ($.zepto.isZ(el)) return arr = arr.concat(el.get())
      arr = arr.concat(zepto.fragment(el))
    })
    return arr
  }
  return argType == "object" || arg == null ?
    arg : zepto.fragment(arg)
})複製程式碼

例子

// 1 html字串
$box.append('<span>hello world</span>')
// 2 dom節點
$box.append(document.createElement('span'))
// 3 多個引數
$box.append('<span>1</span>', '<span>2</span>')
// 4 陣列
$box.append(['<span>hello world</span>', document.createElement('span')])複製程式碼

因為傳入的內容可以為html字串,dom節點,或者節點組成的陣列。這裡對可能的情況分型別做了處理。通過內部的type函式判斷每個引數的資料型別並儲存在argType中。

當引數型別為陣列(類似上面例子中的4)的時候,再對該引數進行遍歷,如果該引數中的元素存在nodeType屬性則將該元素推進陣列arr,
如果該引數中的元素是一個Zepto物件,則呼叫get方法,將arr與返回的原生元素陣列進行合併。

當引數型別為object或者null的時候直接返回,否則就是處理字串形式了,通過呼叫zepto.fragment(這個函式在後面的文章中會詳細講解,現在就其理解為將html字串處理成dom節點陣列就可以了)處理並將結果返回。

到現在為止,我們已經明白了怎麼將傳入的content轉化為對應的dom節點

接下來我們來看如何將nodes中建立好的dom節點插入到目標位置。


parent, copyByClone = this.length > 1

if (nodes.length < 1) return this複製程式碼

先留意一下parent,以及copyByClone這兩個變數,挺重要的,具體作用下面會詳細說明。並且如果需要插入的元素陣列的長度小於1,那麼也就沒有必要繼續往下走了,直接return this進行鏈式操作。


return this.each(function(_, target) {
  // xxx
  nodes.forEach(function(node) {
    // xxx 
    // 注意這行,所有的插入操作都通過insertBefore函式完成
    parent.insertBefore(node, target)
    // xxx
  })

})複製程式碼

整個後續程式碼就是兩層巢狀迴圈,第一層遍歷當前選中的元素集合,第二層就是需要插入的nodes節點集合。通過兩個迴圈來最終完成元素的插入操作,並且很重要的一點是,不管是append還是after等方法都是通過insertBefore來模擬完成的。

確定parent節點以及target目標節點

通過上面的分析我們知道通過insertBefore(在當前節點的某個子節點之前再插入一個子節點)來完成節點的插入,很重要的幾個因素就是

parentNode.insertBefore(newNode, referenceNode)

  1. 父節點(parentNode)
  2. 需要插入的新節點(newNode)
  3. 參考節點referenceNode

所以確定以上1和3就顯得極其重要了。怎麼確定呢?


return this.each(function(_, target) {
  parent = inside ? target : target.parentNode

  // convert all methods to a "before" operation
  target = operatorIndex == 0 ? target.nextSibling :
  operatorIndex == 1 ? target.firstChild :
  operatorIndex == 2 ? target :
  null
  // xxx
})複製程式碼

inside是個啥啊!!!,讓我們回到頂部看這段

adjacencyOperators = ['after', 'prepend', 'before', 'append']
adjacencyOperators.forEach(function (operator, operatorIndex) {
  var inside = operatorIndex % 2
  // xxx
})複製程式碼

所以說當要往$原型上新增的方法是prependappend的時候inside為1也就是真,當為afterbefore的時候為0也就是假。

因為prependappend都是往當前選中的元素內部新增新節點,所以parent當然就是target本身了,但是afterbefore確是要往選中的元素外部新增新節點,自然parent就變成了當前選中元素的父節點。到這裡上面的三要素1,已經明確了,還有3(target)如何確定呢?

target = operatorIndex == 0 ? target.nextSibling :
  operatorIndex == 1 ? target.firstChild :
  operatorIndex == 2 ? target :
  null複製程式碼
  1. 如果operatorIndex為0,即after方法,node節點應該是插入到目標元素target的後面,也就是target的下一個兄弟節點的前面
  2. 如果operatorIndex為1,即prepend方法,node應該插入到目標元素target的第一個子元素的前面
  3. 如果operatorIndex為2,即before方法,node節點應該插入到target節點的前面
  4. 否則operatorIndex為4了,即append方法,node節點應該插入到target最後一個子節點的末尾,insertBefore傳入null,正好與其功能相對應

好啦三要素3頁已經明確了,接下來我們把重要放在第二個迴圈。

將新節點插入到指定位置


nodes.forEach(function(node) {
  if (copyByClone) node = node.cloneNode(true)
  else if (!parent) return $(node).remove()

  parent.insertBefore(node, target)
  // 處理插入script情況
})複製程式碼

在將節點插入到指定位置的前有一個判斷,如果copyByClone為真,就將要插入的新節點複製一份。為什麼要這麼做呢?我們來看個例子。


<ul class="list">
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>複製程式碼

let $list = document.querySelector('.list')
  let $listLi = document.querySelectorAll('.list li')
  let createEle = (tagName, text) => {
    let ele = document.createElement(tagName)
    ele.innerHTML = text
    return ele
  }
  let $span1 = createEle('span', 'span1')
  let $span2 = createEle('span', 'span2')

  Array.from($listLi).forEach((target) => {
    [$span1, $span2].forEach((node) => {
      // node = node.cloneNode(true)
      $list.insertBefore(node, target)
    })
  })複製程式碼

先將cloneNode那部分給登出了,我們期望往三個li的前面都插入兩個span,但是結果會怎麼樣呢?只有最後一個節點前面可以成功地插入兩個span節點。這樣就不是我們先要的結果了,根據insertBefore mdn解釋,如果newElement已經在DOM樹中,newElement首先會從DOM樹中移除。,所以當我們需要往多個li中插入同樣類似的兩個節點的時候,才需要將新節點克隆一份再插入。

我們接著回到原始碼。


nodes.forEach(function(node) {
  if (copyByClone) node = node.cloneNode(true)
  else if (!parent) return $(node).remove()

  parent.insertBefore(node, target)
  // 處理插入script情況
})複製程式碼

如果需要(當前選中元素的個數大於1)克隆節點的時候,先將新節點克隆一份,如果沒有找到對應的parent節點,就講要插入的新節點刪除,最後通過insertBefore方法插入新節點。

到了這裡我們似乎已經完成了從

建立新節點 => 將新節點插入到指定位置的操作了。任務好像已經完成了,但是革命尚未成功,同志仍需努力啊。接下來看最後一點程式碼,主要是處理,當插入的節點是script
標籤的時候,需要手動去執行其包含的js程式碼。



var parentInDocument = $.contains(document.documentElement, parent)

if (parentInDocument) traverseNode(node, function(el) {
  if (el.nodeName != null && el.nodeName.toUpperCase() === 'SCRIPT' &&
      (!el.type || el.type === 'text/javascript') && !el.src) {
    var target = el.ownerDocument ? el.ownerDocument.defaultView : window
    target['eval'].call(target, el.innerHTML)
  }
})複製程式碼

先提前看一下traverseNode這個函式的程式碼

function traverseNode(node, fun) {
  fun(node)
  for (var i = 0, len = node.childNodes.length; i < len; i++)
    traverseNode(node.childNodes[i], fun)
}複製程式碼

這個函式的主要作用就是將傳入的node節點作為引數去呼叫傳入的fun函式。並且遞迴的將node節點的子節點,交給fun去處理。

接下來繼續看。

首先通過$.contains方法判斷parent是否在document文件中,接著需要滿足一下幾個條件才去執行後續操作。

  1. 存在nodeName屬性
  2. nodeName是script標籤
  3. type屬性為空或者type屬性為text/javascript
  4. src屬性為空(即不指定外部指令碼)

確定window物件

var target = el.ownerDocument ? el.ownerDocument.defaultView : window複製程式碼

新節點存在ownerDocument mdn則window物件為defaultView mdn,否則使用window物件本身。

這裡主要會考慮node節點是iframe種的元素情況,才需要做三目處理。

最後便是呼叫target['eval'].call(target, el.innerHTML)去執行script中的程式碼了。

到這裡我們終於知道了'after', 'prepend', 'before', 'append'實現全過程(偷樂一下?,不容易啊)。

appendTo, prependTo, insertBefore, insertAfter

緊接著我們繼續往前走,前面說了插入操作有很多個方法,其中
insertAfter,insertBefore,prependTo,appendTo的實現基於上述幾個方法。

// append   => appendTo
// prepend  => prependTo
// before   => insertBefore
// after    => insertAfter

$.fn[inside ? operator + 'To' : 'insert' + (operatorIndex ? 'Before' : 'After')] = function (html) {
  $(html)[operator](this)
  return this
}複製程式碼

如果是append或者prepend則往$原型上新增appendToprependTo方法,如果是before或者after的時候,便往$的原型上新增insertBeforeinsertAfter方法。因為其兩兩對應的方法本質上是同樣的功能,只是在使用上有點相反的意思,所以簡單的反向呼叫一下就可以了。

html

獲取或設定物件集合中元素的HTML內容。當沒有給定content引數時,返回物件集合中第一個元素的innerHtml。當給定content引數時,用其替換物件集合中每個元素的內容。content可以是append中描述的所有型別 zeptojs_api

例子

1. html()   ⇒ string

2. html(content)   ⇒ self

3. html(function(index, oldHtml){ ... })   ⇒ self複製程式碼

原始碼實現

html: function (html) {
  return 0 in arguments ?
    this.each(function (idx) {
      var originHtml = this.innerHTML
      $(this).empty().append(funcArg(this, html, idx, originHtml))
    }) :
    (0 in this ? this[0].innerHTML : null)
}複製程式碼

當沒有傳html引數的時候,先判斷當前選中的元素是否存在,存在則讀取第一個元素的innerHTML並返回,否則直接返回null

(0 in this ? this[0].innerHTML : null)複製程式碼

當傳了html引數的時候。對當前選中的元素集合進行遍歷設定,先儲存當前元素的innerHTML到originHtml變數中,再將當前元素的innerHTML置空,並將funcArg函式執行之後返回的html插入到當前元素中。

function funcArg(context, arg, idx, payload) {
  return isFunction(arg) ? arg.call(context, idx, payload) : arg
}複製程式碼

可以看到funcArg會對傳入arg進行型別判斷,如果是函式,就把對應的引數傳入函式再將函式的執行結果返回,不是函式就直接返回arg。

text

獲取或者設定所有物件集合中元素的文字內容。當沒有給定content引數時,返回當前物件集合中第一個元素的文字內容(包含子節點中的文字內容)。當給定content引數時,使用它替換物件集合中所有元素的文字內容。它有待點似 html,與它不同的是它不能用來獲取或設定 HTML。zeptojs_api

text: function (text) {
  return 0 in arguments ?
    this.each(function (idx) {
      var newText = funcArg(this, text, idx, this.textContent)
      this.textContent = newText == null ? '' : '' + newText
    }) :
    (0 in this ? this.pluck('textContent').join("") : null)
}複製程式碼

text實現方法與html比較類似有些不同的是沒有傳引數的時候,html是獲取第一個元素的innerHTMLtext則是將當前所有元素的textContent拼接起來並返回.

複製元素

clone

通過深度克隆來複制集合中的所有元素。zeptojs_api


clone: function () {
  return this.map(function () { return this.cloneNode(true) })
}複製程式碼

對當前選中的元素集合進行遍歷操作,底層還是用的瀏覽器cloneNode,並傳參為true表示需要進行深度克隆(其實感覺這裡是不是將true設定為可選引數比較好呢,讓使用者決定是深度克隆與否不是更合理?)

需要注意的地方是cloneNode方法不會複製新增到DOM節點中的Javascript屬性,例如事件處理程式等,這個方法只複製特性,子節點,其他一切都不會複製,IE在此存在一個bug,即他會賦值事件處理程式,所以我們建議在賦值之間最好先移除事件處理程式(摘自《JavaScript高階程式設計第三版》10.1.1 Node型別小字部分)

替換元素

replaceWidth

用給定的內容替換所有匹配的元素。(包含元素本身) zeptojs_api

replaceWith: function(newContent) {
  return this.before(newContent).remove()
}複製程式碼

原始碼實現其實很簡單分兩步,第一步呼叫前面我們講的before方法將制定newContent插入到元素的前面,第二部步將當前選中的元素刪除。自然也就達到了替換的目的。

包裹元素

wrapAll

在所有匹配元素外面包一個單獨的結構。結構可以是單個元素或 幾個巢狀的元素zeptojs_api/#wrapAll

wrapAll: function (structure) {
  // 如果選中的元素存在
  if (this[0]) {
    // 則將制定structure結構通過before方法,插入到選中的第一個元素的前面
    $(this[0]).before(structure = $(structure))
    var children
    // drill down to the inmost element
    // 獲取structure的最深層次的第一個子元素
    while ((children = structure.children()).length) structure = children.first()
    // 將當前的元素集合通過append方法新增到structure末尾
    $(structure).append(this)
  }
  // 反則直接返回this進行後續的鏈式操作
  return this
}複製程式碼

原始碼實現直接看註釋就可以了,這裡需要注意一下children函式是獲取物件集合中所有的直接子節點。而first函式則是獲取當前集合的第一個元素。

另外我們看一下下面兩個例子。


<ul class="box">
  <li>1</li>
  <li>2</li>
</ul>
<div class="wrap">
</div>
<div class="wrap">
</div>複製程式碼
$('.box').wrapAll('.wrap')複製程式碼

執行上述程式碼之後dom結構會變成


<div class="wrap">
  <ul class="box">
    <li>1</li>
    <li>2</li>
  </ul>
</div>

<div class="wrap">
  <ul class="box">
    <li>1</li>
    <li>2</li>
  </ul>
</div>

<ul class="box">
  <li>1</li>
  <li>2</li>
</ul>複製程式碼

可以看到原來ul結構還是存在,彷彿是複製了一份ul及其子節點到wrap中被包裹起來。

接下來再看一個例子,唯一的區別就在wrap結構中巢狀了基層。


<ul class="box">
    <li>1</li>
    <li>2</li>
</ul>
<div class="wrap">
  <div class="here"></div>
  <div></div>
</div>
<div class="wrap">
  <div class="here"></div>
  <div></div>
</div>複製程式碼

但是最後執行$('.box').wrapAll('.wrap')得到的dom結果是。

<div class="wrap">
  <div class="here">
    <ul class="box">
      <li>1</li>
      <li>2</li>
    </ul>
  </div>
  <div></div>
</div>

<div class="wrap">
  <div class="here"></div>
  <div></div>
</div>複製程式碼

嘿嘿可以看到,ul原來的結構不見了,被移動到了第一個wrap的第一個子節點here中。具體原因是什麼呢?大家可以重新回去看一下append的核心實現。

wrap

在每個匹配的元素外層包上一個html元素。structure引數可以是一個單獨的元素或者一些巢狀的元素。也可以是一個html字串片段或者dom節點。還可以是一個生成用來包元素的回撥函式,這個函式返回前兩種型別的包裹片段。zeptojs_api/#wrapAll

wrap: function (structure) {
  var func = isFunction(structure)
  // 當前選中的元素不為空,並且structure不是一個函式
  if (this[0] && !func)
    // 就將structure轉化後的第一個元素賦值給dom元素
    var dom = $(structure).get(0),
      // 如果dom元素的parentNode存在或者當前選中的元素個數大於1那麼clone為true
      clone = dom.parentNode || this.length > 1
  // 對當前選中元素進行遍歷並且呼叫wrapAll方法
  return this.each(function (index) {
    $(this).wrapAll(
      // 如果structure為函式,則將當前的元素和對應的索引傳入函式
      func ? structure.call(this, index) :
        // 如果clone為true,則使用拷貝的副本
        clone ? dom.cloneNode(true) : dom
    )
  })
}複製程式碼

wrapInner

將每個元素中的內容包裹在一個單獨的結構中 zeptojs_api/#wrapInner

wrapInner: function (structure) {
  // 判斷structure是否為函式
  var func = isFunction(structure)
  // 對當前元素集合進行遍歷處理
  return this.each(function (index) {
    // contents => 獲取當前元素的所有子節點(包括元素節點和文字節點)
    var self = $(this), contents = self.contents(),
      // structure為函式則將其執行結果賦值為dom,否則直接將其賦值
      dom = func ? structure.call(this, index) : structure
      // 當前元素的子節點不為空,則呼叫wrapAll,否則直接將dom插入self當前元素即可
    contents.length ? contents.wrapAll(dom) : self.append(dom)
  })
}複製程式碼

需要注意的是這個函式和前面的wrapAll和wrap有點不一樣,這裡強調的是將當前元素中的內容(包括元素節點和文字節點)進行包裹。

unwrap

移除集合中每個元素的直接父節點,並把他們的子元素保留在原來的位置

unwrap: function () {
  // 通過parent()獲取當前元素集合的所有直接父節點
  // 將獲取到的父節點集合進行遍歷
  this.parent().each(function () {
    // 將該父節點替換為該父節點的所有子節點
    $(this).replaceWith($(this).children())
  })
  return this
},複製程式碼

結尾

呼呼呼,終於寫完了,快累死了。歡迎大家指正文中的問題。

參考

讀Zepto原始碼之操作DOM

Zepto原始碼分析-zepto模組

ownerDocument

insertBefore

innerHTML

《JavaScript高階程式設計第三版》

文章記錄

form模組

  1. zepto原始碼分析之form模組(2017-10-01)

zepto模組

  1. 這些Zepto中實用的方法集(2017-08-26)
  2. Zepto核心模組之工具方法拾遺 (2017-08-30)
  3. 看zepto如何實現增刪改查DOM (2017-10-2)

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)

相關文章