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

谁说你只是"会用"jQuery? #3

Open
qianlongo opened this issue Jun 8, 2017 · 6 comments
Open

谁说你只是"会用"jQuery? #3

qianlongo opened this issue Jun 8, 2017 · 6 comments

Comments

@qianlongo
Copy link
Owner

qianlongo commented Jun 8, 2017

前言

套用上篇文章向zepto.js学习如何手动触发DOM事件 的开头😀😀😀

前端在最近几年实在火爆异常,vue、react、angular各路框架层出不穷,咱们要是不知道个双向数据绑定,不晓得啥是虚拟DOM,也许就被鄙视了。火热的背后往往也是无尽的浮躁,学习这些先进流行的类库或者框架可以让我们走的更快,但是静下心来回归基础,把基石打牢固,却可以让我们走的更稳,更远。

最近一直在看zepto的源码,希望通过学习它掌握一些框架设计的技巧,也将很久不再拾起的js基础重新温习巩固一遍。如果你对这个系列感兴趣,欢迎点击watch,随时关注动态。这篇文章主要想说一下zepto中事件模块(event.js)的添加事件on以及移除事件off实现原理,中间会详细地讲解涉及到的细节方面。

如果你想看event.js全文翻译版本,请点击这里查看

原文地址

仓库地址

说在前面

在没有vue和react,甚至angular都没怎么接触的刀耕火种的时代,jQuery或者zepto是我们手中的利器,是刀刃,他让我们游刃有余地开发出兼容性好的漂亮的网页,我们膜拜并感叹作者带来的便利,沉浸其中,无法自拔。

但是用了这么久的zepto你知道这样写代码

$('.list').on('click', 'li', function (e) {
  console.log($(this).html())
})

是怎么实现事件委托的吗?为啥此时的this就是你点中的li呢?

平常我们可能还会这样写。

$('.list li').bind('click', function () {})

$('.list').delegate('li', 'click', function () {})

$('.list li').live('click', function () {})

$('.list li').click(function () {})

写法有点多,也许你还有其他的写法,那么

on

bind

delegate

live

click()

这些添加事件的形式,有什么区别,内部之间又有什么联系呢?

相信你在面试过程中也遇到过类似的问题(看完这边文章,你可以知道答案的噢😯)?

接下来我们从源码的角度一步步去探究其内部实现的原理。

一切从on开始

为什么选择从on添加事件的方式开始说起,原因在于其他写法几乎都是on衍生出来的,明白了on的实现原理,其他的也就差不多那么回事了。

祭出一张画了好久的图

上面大概是zepto中on形式注册事件的大致流程,好啦开始看源码啦,首先是on函数,它主要做的事情是注册事件前的参数处理,真正添加事件是内部函数add。

$.fn.on = function (event, selector, data, callback, one) {
  // 第一段
  var autoRemove, delegator, $this = this
  if (event && !isString(event)) {
    $.each(event, function (type, fn) {
      $this.on(type, selector, data, fn, one)
    })
    return $this
  }

  // 第二段
  if (!isString(selector) && !isFunction(callback) && callback !== false)
    callback = data, data = selector, selector = undefined
  if (callback === undefined || data === false)
    callback = data, data = undefined

  if (callback === false) callback = returnFalse

  // 以上为针对不同的调用形式,做好参数处理
  
  // 第三段
  return $this.each(function (_, element) {
    // 处理事件只有一次生效的情况
    if (one) autoRemove = function (e) {
      remove(element, e.type, callback)
      return callback.apply(this, arguments)
    }

    // 添加事件委托处理函数

    if (selector) delegator = function (e) {
      var evt, match = $(e.target).closest(selector, element).get(0)
      if (match && match !== element) {
        evt = $.extend(createProxy(e), { currentTarget: match, liveFired: element })
        return (autoRemove || callback).apply(match, [evt].concat(slice.call(arguments, 1)))
      }
    }

    // 使用add内部函数真正去给选中的元素注册事件

    add(element, event, callback, data, selector, delegator || autoRemove)
  })
}

直接看到这么一大坨的代码不易于理解,我们分段进行阅读。

第一段

var autoRemove, delegator, $this = this
  if (event && !isString(event)) {
    $.each(event, function (type, fn) {
      $this.on(type, selector, data, fn, one)
    })
    return $this
  }

这段代码主要是为了处理下面这种调用形式。

$('.list li').on({
  click: function () {
    console.log($(this).html())
  },
  mouseover: function () {
    $(this).css('backgroundColor', 'red')
  },
  mouseout: function () {
    $(this).css('backgroundColor', 'green')
  }
})

这种写法我们平时写的比较少一点,但是确实是支持的。而zepto的处理方式则是循环调用on方法,以key为事件名,val为事件处理函数。

在开始第二段代码阅读前,我们先回顾一下,平时经常使用on来注册事件的写法一般有哪些

// 这种我们使用的也许最多了
on(type, function(e){ ... })

// 可以预先添加数据data,然后在回调函数中使用e.data来使用添加的数据
on(type, data, function(e){ ... })

// 事件代理形式
on(type, [selector], function(e){ ... })

// 当然事件代理的形式也可以预先添加data
on(type, [selector], data, function(e){ ... })

// 当然也可以只让事件只有一次起效

on(type, [selector], data, function (e) { ... }, true)

还会有其他的写法,但是常见的可能就是这些,第二段代码就是处理这些参数以让后续的事件正确添加。

第二段

// selector不是字符串形式,callback也不是函数
if (!isString(selector) && !isFunction(callback) && callback !== false)
    callback = data, data = selector, selector = undefined
    // 处理data没有传或者传了函数
  if (callback === undefined || data === false)
    callback = data, data = undefined
    // callback可以传false值,将其转换为returnFalse函数
  if (callback === false) callback = returnFalse

三个if语句很好的处理了多种使用情况的参数处理。也许直接看不能知晓到底是如何做到的,可以试试每种使用情况都代入其中,找寻其是如何兼容的。

接下来我们第三段

这段函数做了非常重要的两件事

  1. 处理one传入为true,事件只触发一次的场景
  2. 处理传入了selector,进行事件代理处理函数开发

我们一件件看它如何实现。

if (one) autoRemove = function (e) {
  remove(element, e.type, callback)
  return callback.apply(this, arguments)
}

内部用了一个remove函数,这里先不做解析,只要知道他就是移除事件的函数就可以,当移除事件的时候,再执行了传进来的回调函数。进而实现只调用一次的效果。

那么事件代理又是怎么实现咧?

回想一下平常自己是怎么写事件代理的,一般是利用事件冒泡(当然也可以使用事件捕获)的性质,将子元素的事件委托到祖先元素身上,不仅可以实现事件的动态性,还可以减少事件总数,提高性能。

举个例子

我们把原本要添加到li上的事件委托到父元素ul上。

<ul class="list">
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>
let $list = document.querySelector('.list')

$list.addEventListener('click', function (e) {
  e = e || window.event
  let target = e.target || e.srcElement
  if (target.tagName.toLowerCase() === 'li') {
    target.style.background = 'red'
  }
}, false)

点击查看效果

回到第三段

 if (selector) delegator = function (e) {
    // 这里用了closest函数,查找到最先符合selector条件的元素
    var evt, match = $(e.target).closest(selector, element).get(0)
    // 查找到的最近的符合selector条件的节点不能是element元素
    if (match && match !== element) {
      // 然后将match节点和element节点,扩展到事件对象上去
      evt = $.extend(createProxy(e), { currentTarget: match, liveFired: element })
      // 最后便是执行回调函数
      return (autoRemove || callback).apply(match, [evt].concat(slice.call(arguments, 1)))
    }
  }

zepto中实现事件代理的基本原理是:以当前目标元素e.target为起点向上查找到最先符合selector选择器规则的元素,然后扩展了事件对象,添加了一些属性,最后以找到的match元素作为回调函数的内部this作用域,并将扩展的事件对象作为回调函数的第一个参数传进去执行。

这里需要知道.closest(...)api的具体使用,如果你不太熟悉,请点击这里查看

说道这里,事件还没有添加啊!到底在哪里添加的呢,on函数的最后一句,便是要进入事件添加了。

add(element, event, callback, data, selector, delegator || autoRemove)

参数处理完,开始真正的给元素添加事件了

zepto的内部真正给元素添加事件的地方在add函数。

function add(element, events, fn, data, selector, delegator, capture) {
  var id = zid(element), 
      set = (handlers[id] || (handlers[id] = []))

  events.split(/\s/).forEach(function (event) {
    if (event == 'ready') return $(document).ready(fn)
    var handler = parse(event)
    handler.fn = fn
    handler.sel = selector
    // emulate mouseenter, mouseleave
    if (handler.e in hover) fn = function (e) {
      var related = e.relatedTarget
      if (!related || (related !== this && !$.contains(this, related)))
        return handler.fn.apply(this, arguments)
    }

    handler.del = delegator
    var callback = delegator || fn
    handler.proxy = function (e) {
      e = compatible(e)
      if (e.isImmediatePropagationStopped()) return
      e.data = data
      var result = callback.apply(element, e._args == undefined ? [e] : [e].concat(e._args))
      if (result === false) e.preventDefault(), e.stopPropagation()
      return result
    }

    handler.i = set.length
    set.push(handler)
    
    if ('addEventListener' in element)
      element.addEventListener(realEvent(handler.e), handler.proxy, eventCapture(handler, capture))
  })
}

我的神,又是这么长长长长的一大坨,人艰不拆,看着心累啊啊啊啊!!!
不过不用急,只要一步步去看,最终肯定可以看懂的。

开头有一句话

var id = zid(element)
 function zid(element) {
    return element._zid || (element._zid = _zid++)
  }

zepto中会给添加事件的元素身上加一个唯一的标志,_zid从1开始不断往上递增。后面的事件移除函数都是基于这个id来和元素建立关联的。

// 代码初始地方定义
var handlers = {}, 


set = (handlers[id] || (handlers[id] = []))

handlers便是事件缓冲池,以数字0, 1, 2, 3...保存着一个个元素的事件处理程序。来看看handlers长啥样。

html

<ul class="list">
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>

javascript

$('.list').on('click', 'li', '', function (e) {
  console.log(e)
}, true)

以上截图便是这段代码执行后得到的handlers,其本身是个对象,每个key(1, 2, 3 ...)(这个key也是和元素身上的_zid属性一一对应的)都保存着一个数组,而数组中的每一项目都保存着一个与事件类型相关的对象。我们来看看,每个key的数组都长啥样

[
  {
    e: 'click', // 事件名称
    fn: function () {}, // 用户传入的回调函数
    i: 0, // 该对象在该数组中的索引
    ns: 'qianlongo', // 命名空间
    proxy: function () {}, // 真正给dom绑定事件时执行的事件处理程序, 为del或者fn
    sel: '.qianlongo', // 进行事件代理时传入的选择器
    del: function () {} // 事件代理函数
  },
  {
    e: 'mouseover', // 事件名称
    fn: function () {}, // 用户传入的回调函数
    i: 1, // 该对象在该数组中的索引
    ns: 'qianlongo', // 命名空间
    proxy: function () {}, // 真正给dom绑定事件时执行的事件处理程序, 为del或者fn
    sel: '.qianlongo', // 进行事件代理时传入的选择器
    del: function () {} // 事件代理函数
  }
]

这样的设置给后面事件的移除带了很大的便利。画个简单的图,看看元素添加的事件和handlers中的映射关系。

明白了他们之间的映射关系,我们再回到源码处,继续看。

events.split(/\s/).forEach(function (event) {
  // xxx
})

暂时去除了一些内部代码逻辑,我们看到其对event做了切分,并循环添加事件,这也是我们像下面这样添加事件的原因

$('li').on('click mouseover mouseout', function () {})

那么接下来我们要关注的就是循环的内部细节了。添加了部分注释

// 如果是ready事件,就直接调用ready方法(这里的return貌似无法结束forEach循环吧)
if (event == 'ready') return $(document).ready(fn)
// 得到事件和命名空间分离的对象 'click.qianlongo' => {e: 'click', ns: 'qianlongo'}
var handler = parse(event)
// 将用户输入的回调函数挂载到handler上
handler.fn = fn
// 将用户传入的选择器挂载到handler上(事件代理有用)
handler.sel = selector
// 用mouseover和mouseout分别模拟mouseenter和mouseleave事件
// https://qianlongo.github.io/zepto-analysis/example/event/mouseEnter-mouseOver.html(mouseenter与mouseover为何这般纠缠不清?)
// emulate mouseenter, mouseleave
if (handler.e in hover) fn = function (e) {
  var related = e.relatedTarget
  if (!related || (related !== this && !$.contains(this, related)))
    return handler.fn.apply(this, arguments)
}
handler.del = delegator
// 注意需要事件代理函数(经过一层处理过后的)和用户输入的回调函数优先使用事件代理函数
var callback = delegator || fn
// proxy是真正绑定的事件处理程序
// 并且改写了事件对象event
// 添加了一些方法和属性,最后调用用户传入的回调函数,如果该函数返回false,则认为需要阻止默认行为和阻止冒泡
handler.proxy = function (e) {
  e = compatible(e)
  if (e.isImmediatePropagationStopped()) return
  e.data = data
  var result = callback.apply(element, e._args == undefined ? [e] : [e].concat(e._args))
  // 如果回调函数返回false,那么将阻止冒泡和阻止浏览器默认行为
  if (result === false) e.preventDefault(), e.stopPropagation()
  return result
}
// 将该次添加的handler在set中的索引赋值给i
handler.i = set.length
// 把handler保存起来,注意因为一个元素的同一个事件是可以添加多个事件处理程序的
set.push(handler)
// 最后当然是绑定事件
if ('addEventListener' in element)
  element.addEventListener(realEvent(handler.e), handler.proxy, eventCapture(handler, capture))

至此,添加事件到这里告一段落了。让我们再回到文章初始的问题,

on

bind

delegate

live

click()

这些添加事件的形式,有什么区别,内部之间又有什么联系呢?其实看他们的源码大概就知道区别

// 绑定事件
$.fn.bind = function (event, data, callback) {
  return this.on(event, data, callback)
}

// 小范围冒泡绑定事件
$.fn.delegate = function (selector, event, callback) {
  return this.on(event, selector, callback)
}

// 将事件冒泡代理到body上  
$.fn.live = function (event, callback) {
  $(document.body).delegate(this.selector, event, callback)
  return this
}

// 绑定以及触发事件的快件方式
// 比如 $('li').click(() => {})

; ('focusin focusout focus blur load resize scroll unload click dblclick ' +
  'mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave ' +
  'change select keydown keypress keyup error').split(' ').forEach(function (event) {
    $.fn[event] = function (callback) {
      return (0 in arguments) ?
        // click() 形式的调用内部还是用了bind
        this.bind(event, callback) :
        this.trigger(event)
    }
  })

bind和click()函数都是直接将事件绑定到元素身上,live则代理到body元素身上,delegate是小范围是事件代理,性能在由于live,on就最厉害了,以上函数都可以用on实现调用。

事件移除的具体实现

事件移除的实现有赖于事件绑定的实现,绑定的时候,把真正注册的事件信息都和dom关联起来放在了handlers中,那么移除具体是如何实现的呢?我们一步步来看。

同样先放一张事件移除的大致流程图

off函数

 $.fn.off = function (event, selector, callback) {
  var $this = this
  // {click: clickFn, mouseover: mouseoverFn}
  // 传入的是对象,循环遍历调用本身解除事件
  if (event && !isString(event)) {
    $.each(event, function (type, fn) {
      $this.off(type, selector, fn)
    })
    return $this
  }
  // ('click', fn)
  if (!isString(selector) && !isFunction(callback) && callback !== false)
    callback = selector, selector = undefined

  if (callback === false) callback = returnFalse
  // 循环遍历删除绑定在元素身上的事件,如何解除,可以看remove
  return $this.each(function () {
    remove(this, event, callback, selector)
  })
}

off函数基本上和on函数是一个套路,先做一些基本的参数解析,然后把移除事件的具体工作交给remove函数实现,所以我们主要看remove函数。

remove函数

 // 删除事件,off等方法底层用的该方法

function remove(element, events, fn, selector, capture) {
  // 得到添加事件的时候给元素添加的标志id
  var id = zid(element)
  // 循环遍历要移除的事件(所以我们用的时候,可以一次性移除多个事件)
    ; (events || '').split(/\s/).forEach(function (event) {
      // findHandlers返回的是符合条件的事件响应集合
      findHandlers(element, event, fn, selector).forEach(function (handler) {
        // [{}, {}, {}]每个元素添加的事件形如该结构
        // 删除存在handlers上的响应函数
        delete handlers[id][handler.i]
        // 真正删除绑定在element上的事件及其事件处理函数
        if ('removeEventListener' in element)
          element.removeEventListener(realEvent(handler.e), handler.proxy, eventCapture(handler, capture))
      })
    })
}

继续往下走,一个重要的函数findHandlers

// 根据给定的element、event等参数从handlers中查找handler,
// 主要用于事件移除(remove)和主动触发事件(triggerHandler)

function findHandlers(element, event, fn, selector) {
  // 解析event,从而得到事件名称和命名空间
  event = parse(event)
  if (event.ns) var matcher = matcherFor(event.ns)
  // 读取添加在element身上的handler(数组),并根据event等参数帅选
  return (handlers[zid(element)] || []).filter(function (handler) {
    return handler
      && (!event.e || handler.e == event.e) // 事件名需要相同
      && (!event.ns || matcher.test(handler.ns)) // 命名空间需要相同
      && (!fn || zid(handler.fn) === zid(fn)) // 回调函数需要相同(话说为什么通过zid()这个函数来判断呢?)
      && (!selector || handler.sel == selector) // 事件代理时选择器需要相同
  })
}

因为注册事件的时候回调函数不是用户传入的fn,而是自定义之后的proxy函数,所以需要将用户此时传入的fn和handler中保存的fn相比较是否相等。

结尾

罗里吧嗦说了好多,不知道有没有把zepto中的事件处理部分说明白说详细,欢迎大家提意见。

如果对你有一点点帮助,点击这里,加一个小星星好不好呀

如果对你有一点点帮助,点击这里,加一个小星星好不好呀

如果对你有一点点帮助,点击这里,加一个小星星好不好呀

@lidazhao
Copy link

哈喽,谦龙,.on可以为没有不存在的dom元素绑定事件。是如何做到的呢。多谢您

@qianlongo
Copy link
Owner Author

qianlongo commented Jun 11, 2017

@lidazhao 嗨你好,回复晚了,不好意思。

方便说一下你说的没有不存在的dom元素是指的什么意思吗?

比如是现在没有,但是后面会动态添加的元素?(这种情况可以用事件代理可以实现)

或者动态创建了元素,但是没有塞入网页中,但实际上却在内存中已经存在了,我们是可以手动触发添加在该元素身上的事件的。

这是一个简单例子,不知道是不是你说的意思,欢迎一起交流哈

@lidazhao
Copy link

@qianlongo 正是您说的第二种例子呢。这种机制是如何实现的呢?
我尝试着理解下,应该是handler找到了对应的i和sel,并执行对应的fn是吗?
多谢~

@qianlongo
Copy link
Owner Author

qianlongo commented Jun 11, 2017

@lidazhao 为你点赞。

更新了一下例子

对于刚才那种情况,我们创建了存在于内存中但是没有塞入网页的元素,用.on给他添加事件,其实和有没有塞入网页没有太大的关系,可以认为就是给网页中的元素添加事件。而手动触发就是trigger的原理了。

但是还有另外一种情况比如

<button class="btn3">手动触发存在自定义事件,没有元素存在</button>

let $noElement = $({}) // 这里是一个空的zepto对象,没有创建元素,和上面不一样

let addNoElementEvent = () => {
    $noElement.on('myEvent click', (e) => {
      console.log(`hello ${e.type}`)
    })
  }

let triggerNoElement = () => {
    $('.btn3').on('click', () => {
      let eventArr = ['myEvent', 'click']
      let len = eventArr.length
      let randomEventName = eventArr[Math.floor(Math.random() * len)]
      $noElement.trigger(randomEventName)
    })
  }

addNoElementEvent()
triggerNoElement()

这种就有点类似于你刚才的解释了,不是触发事件,而是直接触发回调函数。

@lidazhao
Copy link

@qianlongo 哦,我懂了。我之前看你的评论我还是有些迷糊,最近很忙没看,今天睡觉有个东西触发了我想了下,再看您写的。我豁然开朗。感谢您。

@qianlongo
Copy link
Owner Author

@lidazhao 👍 一起学习

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants