谁说你只是"会用"jQuery?

前言

套用上篇文章向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中的事件处理部分说明白说详细,欢迎大家提意见。

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

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容

  • (续jQuery基础(1)) 第5章 DOM节点的复制与替换 (1)DOM拷贝clone() 克隆节点是DOM的常...
    凛0_0阅读 1,268评论 0 8
  • 1.几种基本数据类型?复杂数据类型?值类型和引用数据类型?堆栈数据结构? 基本数据类型:Undefined、Nul...
    极乐君阅读 5,398评论 0 106
  • 上周周日,按照计划冒雨去了无锡鼋头渚。就像许多的计划,并不能完美的践行一样,我们本是去看据说盛开的樱花,脑海里想象...
    梅子的日志阅读 688评论 7 2
  • 好久没有写写最近的心情了,因为不知从何说起,而最深的感触是不知道自己未来的归宿在哪里,我不敢想,也不想去想~看到朋...
    天秤董婷阅读 184评论 0 0