Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

看Zepto如何实现增删改查DOM #8

Open
qianlongo opened this issue Oct 2, 2017 · 0 comments

Comments

@qianlongo
Copy link
Owner

commented Oct 2, 2017

前言

dom也就是文档对象模型,是针对HTML和XML的一个api,描绘了一个层次化的节点树。虽然浏览器原生给我们提供了许多操作dom的方法,使我们可以对dom进行查找,复制,替换和删除等操作。但是zepto在其基础上再次封装,给以我们更加便捷的操作方式。先看下图,我们以删除元素插入元素,复制元素包裹元素替换元素几个模块分别探究zepto如何一一将其实现。

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
1 participant
You can’t perform that action at this time.