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

前端进阶之路:点击事件绑定 #48

Open
cssmagic opened this issue Mar 12, 2015 · 31 comments
Open

前端进阶之路:点击事件绑定 #48

cssmagic opened this issue Mar 12, 2015 · 31 comments

Comments

@cssmagic
Copy link
Owner

cssmagic commented Mar 12, 2015



引言

前端之所以被称为前端,是因为它是整个 Web 技术栈中距离用户最近、直接与用户进行交互的一环。而网页界面与用户的交互通常是通过各种事件来达成的;在各种事件之中,点击事件 往往又是最常见、最通用的一种界面事件。

本文将介绍我在 “点击事件绑定” 这一场景下的进阶之路。

背景

我是一个前端小兵,我在一家互联网公司做做一些简单的业务开发。

某一天,我接到了一个需求,做一个抽奖功能。公司里的前辈们已经完成了业务逻辑,而且已经提供了业务功能的接口,只需要我制作页面并完成事件绑定即可。

实践

开动

我写好了页面,页面中有一个 ID 为 lucky-draw 的按钮元素。接下来,我需要为它绑定点击事件。我是这样写的:

var btn = document.getElementById('lucky-draw')
btn.onclick = function () {
	BX.luckyDraw()
}

这其中 BX.luckyDraw() 就是前辈们提供的业务接口,执行它就可以运行后续的抽奖功能。

我测试了一下,代码工作正常,于是很开心地准备上线。

第一关

然而前辈们告诉我,这些重要功能的按钮是需要加统计的。这也难不倒我,因为我很熟悉统计系统的 API。于是我修改了一下事件绑定的代码:

btn.onclick = function () {
	BX.luckyDraw()
	BX.track('lucky-draw')
}

这样做是有效的,但前辈们又告诉我,因为某些原因,统计代码和业务代码是分布在不同位置的,以上代码需要拆开。于是我尝试这样修改:

btn.onclick = function () {
	BX.luckyDraw()
}

// some code...

btn.onclick = function () {
	BX.track('lucky-draw')
}

结果发现点击按钮时的抽奖功能失效了。原来,使用 .onclick 这样的事件属性来绑定事件有一个非常大的缺点,重复赋值会覆盖旧值。也就是说,这种方式只能绑定最后一次赋值的事件处理函数。

我硬着头皮去请教前辈,才知道原来这种方式早已经不推荐使用了,应该使用 DOM 标准的事件绑定 API 来处理(在旧版 IE 下有一些兼容性问题,这里不展开)。因此我的代码改成了这样:

btn.addEventListener('click', function () {
	BX.luckyDraw()
}, false)

// some code...

btn.addEventListener('click', function () {
	BX.track('lucky-draw')
}, false)

所有功能终于又正常了,我很开心地准备上线。

第二关

事实证明我还是太天真了,PM 是不会一次性把所有需求都告诉你的。原来,这个抽奖功能还需要做 A/B 测试,也就是说,只有一半的用户会看到这个抽奖功能。

这意味着用户的页面上可能根本没有 btn 这个元素,那么 btn.addEventListener(...) 这一句直接就抛错了。因此,在为按钮绑定事件处理函数之前,我不得不先判断一下:

if (btn) {
	btn.addEventListener('click', function () {
		BX.luckyDraw()
	}, false)
}

// some code...

if (btn) {
	btn.addEventListener('click', function () {
		BX.track('lucky-draw')
	}, false)
}

虽然这样的代码在所有用户的页面上都可以正常工作,但这些预先判断看起来很蛋疼啊。我再次带着疑惑向前辈请教。前辈慈祥地看着我,说出了一句经典名言:

傻瓜,为什么不用万能的 jQuery 呢?

原来,神奇的 jQuery 允许我们忽略很多细节,比如这种没有取到元素的情况会被它默默地消化掉。而且 jQuery 的事件绑定方法也不存在兼容性问题,API 也比较好看。不错不错,不管网上的大神们怎么喷 jQuery,但它简直是我的救星啊!

于是,我的代码变成了以下这样:

var $btn = $('#lucky-draw')
$btn.on('click', function () {
	BX.luckyDraw()
})

// some code...

$btn.on('click', function () {
	BX.track('lucky-draw')
})

我的代码看起来像那么回事了,我很开心地准备上线。

第三关

当然,我的故事不会这么快结束。要知道,对一个有追求的前端团队来说,不断提升用户体验是永恒的目标。比如,我们网站使用了一些方法来提升页面加载性能,部分页面内容并不是原本存在于页面中的,而是在用户需要时,由 JavaScript 动态生成的。

拿这个抽奖功能来说,抽奖按钮存在于一个名为 “惊喜” 的 tab 中,而这个 tab 在初始状态下是没有内容的,只有当用户切换到这个 tab 时,才会由 JS 填充其内容。示意代码是这样的:

$('.tabs > .surprise').on('click', function () {
	var htmlSurpriseTab = [
		'<div>',
			'<button id="lucky-draw">Lucky Draw</button>',
		'</div>'
	].join('')
	$('.tab-panels > .surprise').html(htmlSurpriseTab)

	// BTN READY
})

这意味着,我写的事件绑定代码需要写在 // BTN READY 处。这种深层的耦合看起来很不理想,我需要想办法解决它。

我想起来,我在阅读 jQuery 文档时看到有一种叫作 “事件委托” 的方法,可以在元素还未添加到页面之前就为它绑定事件。于是,我尝试这样来写:

$(document.body).on('click', '#lucky-draw', function () {
	BX.luckyDraw()
})

果然,我成功了!好事多磨啊,这个需求终于开心地上线了。

经过进一步的研究,我了解到 “事件委托” 的本质是利用了事件冒泡的特性。把事件处理函数绑定到容器元素上,当容器内的元素触发事件时,就会冒泡到容器上。此时可以判断事件的源头是谁,再执行对应的事件处理函数。由于事件处理函数是绑定在容器元素上的,即使容器为空也没有关系;只要容器的内容添加进来,整个功能就是准备就绪的。

虽然事件委托的原理听起来稍有些复杂,但由于 jQuery 对事件委托提供了完善的支持,我的代码并没有因此变得很复杂。

多想一步

经过这一番磨炼,我收获了很多经验值;同时,我也学会了更进一步去发现问题和思考问题。比如,在我们的网页,通常会有多个按钮,那为它们绑定事件的脚本代码可能就是这样的:

$body = $(document.body)
$body.on('click', '#lucky-draw', function () {
	BX.luckyDraw()
})

$body.on('click', '#some-btn', function () {
	// do something...
})
$body.on('click', '#another-btn', function () {
	// do something else...
})

我隐隐觉得这样不对劲啊!虽然这些代码可以正常工作,但每多一个按钮就要为 body 元素多绑定一个事件处理函数;而且根据直觉,这样一段段长得差不多的代码是需要优化的。因此,如果我可以把这些类似的代码整合起来,那不论是在资源消耗方面,还是在代码组织方面,都是有益的。

于是,我尝试把所有这些事件委托的代码合并为一次绑定。首先,为了实现合并,我需要为这些按钮找到共同点。很自然地,我让它们具有相同的 class:

<button class="action" id="lucky-draw">Lucky Draw</button>
<button class="action" id="some-action">Button</button>
<a href="#" class="action" id="another-action">Link</a>
<a href="#" class="action" id="another-action-2">Link</a>

然后,我试图通过一次事件委托来处理所有这些按钮:

$body.on('click', '.action', function () {
	// WHEN CLICK ANY '.action', WE COME HERE.
})

很显然,所有具有 action 类名的元素被点击后都会触发上面这个事件处理函数。那么,接下来,我们在这里区分一下事件源头,并执行对应的任务:

$body.on('click', '.action', function () {
	switch (this.id) {
		case 'lucky-draw':
			BX.luckyDraw()
			break
		case 'some-btn':
			// do something...
			break
		// ...
	}
})

这样一来,所有分散的事件委托代码就被合并为一处了。在这个统一的事件处理函数中,我们使用 ID 来区分各个按钮。

但 ID 有一些问题,由于同一页面上不能存在同名的元素,相信前端工程师们都对 ID 比较敏感,在日常开发中都尽量避免滥用。此外,如果多个按钮需要执行的任务相同,但它的 ID 又必须不同,则这些 ID 和它们对应的任务之间的对应关系就显得不够明确了。

于是,我改用 HTML5 的自定义属性来标记各个按钮:

<button class="action" data-action="lucky-draw">Lucky Draw</button>
<button class="action" data-action="some-action">Button</button>
<a href="#" class="action" data-action="another-action">Link</a>
<a href="#" class="action" data-action="another-action-2">Link</a>

我在这里使用了 data-action 这个属性来标记各个按钮元素被点击时所要执行的动作。回过头看,由于各个按钮都使用了这个属性,它们已经具备了新的共同点,而 class 这个共同点就不必要了,于是我们的 HTML 代码可以简化一些:

<button data-action="lucky-draw">Lucky Draw</button>
<button data-action="some-action">Button</button>
<a href="#" data-action="another-action">Link</a>
<a href="#" data-action="another-action-2">Link</a>

同时 JS 代码也需要做相应调整:

$body.on('click', '[data-action]', function () {
	var actionName = $(this).data('action')
	switch (actionName) {
		case 'lucky-draw':
			BX.luckyDraw()
			break
		case 'some-btn':
			// do something...
			break
		// ...
	}
})

我们的代码看起来已经挺不错了,但我已经停不下来了,还要继续改进。那个长长的 switch 语句看起来有点臃肿。通常优化 switch 的方法就是使用对象的键名和键值来组织这种对应关系。于是我继续改:

var actionList = {
	'lucky-draw': function () {
		BX.luckyDraw()
	},
	'some-btn': function () {
		// do something...
	}
	// ...
}

$body.on('click', '[data-action]', function () {
	var actionName = $(this).data('action')
	var action = actionList[actionName]

	if ($.isFunction(action)) action()
})

经过这样的调整,我发现代码的嵌套变浅了,而且按钮们的标记和它们要做的事情也被组织成了 actionList 这个对象,看起来更清爽了。

在这样的组织方式下,如果页面需要新增一个按钮,也很容易做扩展:

// HTML
$body.append('<a href="#" data-action="more-action">Link</a>')

// JS
$.extend(actionList, {
	'more-action': function () {
		// ...
	}
})

到这里,这一整套实践终于像那么回事了!

开源

我自己用这一套方法参与了很多项目的开发,在处理事件绑定时,它节省了我很多的精力。我忽然意识到,它可能还适合更多的人、更多的项目。那不妨把它开源吧!

于是我发布了 Action 这个项目。这个小巧的类库帮助开发者轻松随意地绑定点击事件,它使用 “动作” 这个概念来标记按钮和它被点击后要做的事情;它提供的 API 可以方便地定义一些动作:

action.add({
	'my-action': function () {
		// ...
	}
})

也可以手动触发已经定义的动作:

action.trigger('my-action')

应用

Action 这个类库已经被移动 Web UI 框架 CMUI 采用,作为全局的基础服务。CMUI 内部的各个 UI 组件都是基于 Action 的事件绑定机制来实现的。我们这里以对话框组件为例,来看看 Action 在 CMUI 中的应用(示意代码):

CMUI.dialog = {
	template: [
		'<div class="dialog">',
			'<a href="#" data-action="close-dialog">×</a>',
			'<h2><%= data.title %></h2>',
			'<div class="content"><%- data.html %></div>',
		'</div>'
	].join(''),

	init: function () {
		action.add({
			'close-dialog': function () {
				$(this).closest('.dialog').hide()
			}
		})
	},
	open: function (config) {
		var html = render(this.template, config)
		$(html).appendTo('body').show()
	}
}

CMUI.dialog.init()

只要当 CMUI.dialog.init() 方法执行后,对话框组件就准备就绪了。我们在业务中直接调用 CMUI.dialog.open() 方法、传入构造对话框所需要的一些配置信息,这个对话框即可创建并打开。

大家可以发现,在构造对话框的过程中,我们没有做任何事件绑定的工作,对话框的关闭按钮就自然具备了点击关闭功能!原因就在于关闭按钮(<a href="#" data-action="close-dialog">×</a>)自身已经通过 data-action 属性声明了它被点击时所要执行的动作('close-dialog'),而这个动作早已在组件初始化时(CMUI.dialog.init())定义好了。

结语

希望本文对你有所启发,也希望 Action 能在实际开发中帮到你。

关于更多细节,欢迎继续阅读:


© Creative Commons BY-NC-ND 4.0   |   我要订阅   |   我要打赏

@monkeytwins
Copy link

好文章,步进式的学习思路。

@paddingme
Copy link

腻害,学习了,感谢博主 ❤️

@baoqingping
Copy link

非常好的文章。感谢楼主分享

@toutouli
Copy link

toutouli commented Apr 7, 2015

思路很好啊,但是如果点击事件带参数的话就不能用了

@cssmagic
Copy link
Owner Author

cssmagic commented Apr 7, 2015

思路很好啊,但是如果点击事件带参数的话就不能用了

把数据挂载到 Event 对象上叫事件参数,把数据挂载到 DOM 元素上叫属性。看你怎么组织/传递/获取数据了。 😃

@toutouli
Copy link

在项目里用了action库,结果今天测试的时候发现在ios设备上的点击事件全部无效,搜了一下,好像是ios的Safari不支持click,不知还有没有其它兼容性问题

@cssmagic
Copy link
Owner Author

@toutouli 请参考这篇文档:《所有元素都可以用 Action 来绑定点击事件吗?》
如果遇到其它问题也请及时反馈给我(发 issue 到 Action 项目),谢谢。

另外请问你们的项目在哪里,可以访问到吗?

@toutouli
Copy link

@cssmagic 谢谢你的解答

@lovecn
Copy link

lovecn commented May 3, 2015

好文,学习

@confidence68
Copy link

我的博客也同步到issue中了,https://github.com/confidence68/blog/issues

@zhouyupeng
Copy link

学习

@chen4342024
Copy link

厉害,也许各种库就是这样从小小的细节里面演化出来的~

@0326
Copy link

0326 commented Nov 10, 2015

整个思路很值得去学习,不断去改善代码中的痛点,才能从菜鸟一步步变大神:)

@liuyidi
Copy link

liuyidi commented Dec 17, 2015

学习了 思路好厉害 膜拜 题主团队还需要人吗?

@cssmagic
Copy link
Owner Author

@liuyidi
永久招人 😄 !把简历通过微博私信发给我吧,我在微博叫 “CSS魔法”。

@liuyidi
Copy link

liuyidi commented Dec 29, 2015

@cssmagic https://github.com/liuyidi/cv 个人简历

@wananys
Copy link

wananys commented Jan 11, 2016

受益匪浅,谢谢分享。

@zyxFront
Copy link

zyxFront commented Apr 8, 2016

群主思路好清晰,理得超顺,最佩服这种思路清晰的前端,赞赞赞

@rccoder
Copy link

rccoder commented Apr 8, 2016

var htmlSurpriseTab = [
        '<div>',
            '<button id="lucky-draw">Lucky Draw</button>',
        '</div>'
    ].join('')

这种方法拼接好赞~

之前一直是用\来做的 😢

@SimonDolph
Copy link

@rccoder join 和 + 的性能在不同的浏览器上是不一样的

@sszsfan
Copy link

sszsfan commented Sep 3, 2016

干货

@falseLuffy
Copy link

魔法师好帅,我要给你生孩子。不过你别想了,我是个男的。

@zhuanqizhirou
Copy link

最近一直被事件绑定折磨,主要是不确定怎么做,为什么
也一直在找事件处理相关的内容
直到看到博主的内容,由浅到深,终于系统理解了!
非常感谢!

@molaifeng
Copy link

学习了

@ivanberry
Copy link

@rccoder 带变量的字符串拼接怎么处理呢?

@hkongm
Copy link

hkongm commented Dec 15, 2016

@toutouli 在CSS中给你需要click的元素添加 cursor:pointer 再试试

@HopeXKelvin
Copy link

还能这样写事件绑定,真的学习了!Mark

@xiaoyudesu
Copy link

感谢,学习了!

@plh97
Copy link

plh97 commented Apr 19, 2018

大神啊,请教一下啊,事件绑定的过程中到底发生了什么,func会不断生成新的函数?
而事实上我了解到,js事件线程属于不同于js主线程的另一个线程,那么我的节流函数就不会发生作用了???事件绑定的机制,JavaScript引擎到底做了什么?

function func(){
  _.debound(()=>{
    // 
    console.log('test')
  },1000)
}
dom.addEventListener('click',func,flase)

@plh97
Copy link

plh97 commented Apr 19, 2018

写的很棒啊,我也觉得我终于可以升级我的全局事件代理函数了,,我也觉得我的全局代理函数好臃肿,虽然只经过一次事件绑定在容器元素上面

@cssmagic
Copy link
Owner Author

cssmagic commented May 7, 2018

@pengliheng
你的意图是下面这样吗?

var func = _.debound(() => {
    console.log('test')
}, 1000)
dom.addEventListener('click', func, flase)

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

No branches or pull requests