我们仍未知道浏览器的事件处理函数存储在哪里

78 阅读11分钟

1. 开门见山

先说结论:

  1. 如果是通过HTML元素标签属性(attribute)或者HTML元素对象属性(property)添加的事件处理函数,那么其存储在该元素对象相应的事件属性上。
  2. 如果是通过 addEventListener 添加的事件处理函数,那么其存储在 ???? 我不知道,我唯一知道的是事件处理函数是存放在一个 list 里的,至于这个 list 是数组还是链表,我也不知道。这个 list 是浏览器的内部实现,是不对开发者暴露的。
  3. 如果有懂浏览器内部实现的大佬偶然看到这里,请不吝赐教。

2. 缘起

这个问题起源于我的一个小demo,在那个demo里,我创建了一个类,这个类有两个方法,类似如下:

class Hello {
    getWorld() {
        return "world"
    }
    say() {
        console.log(`Hello ${this.getWorld()}`)
    }
}

有一个按钮,我将这个类实例的 say 方法添加为这个按钮的点击事件处理函数:

const hello = new Hello()
const btn = document.querySelector("button")
btn.addEventListener("click", hello.say)

我的预期是,当我点击这个按钮时,控制台应该会输出 “Hello World”,可是当我点击按钮后,浏览器报出一个错误提醒我 this.getWorld 不是一个函数。

shot1.jpg

我立马反应过来是 this 的指向出错了,我习惯性的以为将 say 函数当做事件处理函数传递后,它还会正常工作,但实际上 say 函数执行时 this 是指向了按钮的元素对象。因此,在传递之前对 say 进行绑定就能修复这个问题:

btn.addEventListener("click", hello.say.bind(hello))

3. 假设和推理

但这次出错促使我开始思考,通过 addEventListener 添加的事件处理函数的引用是存储在哪里的?为什么将一个未绑定函数当做事件处理函数进行传递时,这个未绑定函数在执行时 this 会自动指向元素对象本身。

下面写出来的是我的思考过程,我不想一开始就直接去Google查找答案,我想通过一步步的假设和推理来得出答案:

3.1 事件处理函数是通过哪种方式使其在运行时使 this 指向元素对象本身的?

首先,想想 this 的指向原理,JavaScript中的函数有4种调用模式:

  1. 函数调用,此时 this 指向全局对象
  2. 作为某个对象的方法调用,此时 this 指向该对象
  3. new 关键字调用,此时 this 指向创建的空对象
  4. call applybind 指定 this 的指向对象

排除掉第1种和第3种模式,剩下的就是两种:要么事件处理函数被当做该元素对象的一个方法,要么就是通过 call apply 或者 bind 强行使 this 指向该元素对象。

如果是第一种的话,那么应该可以直接通过查看元素对象的属性查找到这个方法。

浏览器绑定事件处理函数的方式有3种:

第一种:通过元素标签的属性(attribute)绑定:

<button id="btn" onclick="console.log(`hello`)">Click me</div>

第二种:通过元素对象的属性(property)绑定:

const btn = document.querySelector("#btn")
btn.onClick = function () { console.log(`world`) }

第三种:通过 addEventListener 绑定:

btn.addEventListener("click", function () { console.log(`我是神里凌华小姐的狗`) }

我们先来看第一种绑定方式,获取这个button元素对象,然后打印它的属性,可以看到它的 onclick 属性是被绑定了一个函数的:

shot2.jpg

为了确保这个函数就是我们绑定的函数,手动调用这个属性执行它:

const btn = document.querySelector("#btn")
btn.onclick()

shot3.jpg

OK,证明了我的推论,第一种绑定方式和第二种绑定方式里,事件处理函数是直接被当做该元素对象的一个属性方法的。

并且第一种绑定方式和第二种绑定方式理论上是等价的。不同之处在于,第一种绑定方式是在 HTML的parse过程中由浏览器解析自动绑定的。第二种绑定方式需要开发者手动操作元素对象手动绑定。

这也解释了为什么通过这两种方式只能绑定一个,因为位子就一个啊,没有多的位置了啊。

接下来再看第三种绑定方式,通过 addEventListener 绑定:

btn.addEventListener("click", function () { console.log(`我是神里凌华小姐的狗`) }

很遗憾,我按钮对象的属性上并没有找到这个函数,说明通过 addEventListener 添加的事件处理函数只有可能是通过 call apply 或者 bind 来执行的。

其实从 addEventListener 可以添加多个事件处理函数这一特点出发进行思考的话,也可以得出相同的结论。

通过 addEventListener 添加的多个事件处理函数应该是以类似数组或者队列的形式进行存储的:

const handlers = []

每次调用 addEventListener 添加新的事件处理函数时,就会往这个数组/队列里进行插入。然后当事件触发时,会依次调用这个数组或队列里的函数:

// 这里只是一段伪代码,用来解释我的想法

// 获取元素的DOM
const dom = getDom() 

// 获取传递给被调用函数的参数
let arguments = getArguments() 

// 使用 apply 调用函数
handlers.forEach( handler => handler.apply(dom, arguments) )

// 如果是使用 bind 触发函数的话,那么在使用 addEventListener 添加函数时
// 应该会有进行函数绑定的步骤,我瞎猜的
handlers.push(handler.bind(dom))

// 函数的调用
handler.forEach( handler => handler(arguments) )

callapply 除了传入的参数不同外 (一个是单独传参,一个是打包传参) 是可以看做同一个函数的。因此问题进一步升级为:当一个事件触发时,事件处理函数是通过 apply 调用的还是 bind 调用的?

理论上应该无论是使用 apply 还是使用 bind 都能完成这项工作,那么两者之间有何区别呢?使用 bind 的话会多出一次创建新函数的开销,而 apply 没有。那么事件处理函数到底是使用哪种方式调用的呢?到了这一步,我失去了继续推理下去的线索。

但是关于《事件处理函数是通过哪种方式使其在运行时使 this 指向元素对象本身的?》至此已经有了个基本的答案,虽然这个答案差强人意。

3.2 通过 addEventListener 绑定的事件处理函数是存储在哪里的?

从绑定事件处理函数的元素本身出发,有两种可能性:

  1. 这个存储事件处理函数的数据结构是保存在该元素对象的某个属性里,在上文我已经证明了前两种绑定事件回调函数的方法是采用的这个路径。
  2. 这个存储事件处理函数的数据结构存储在另外一个与该元素对象存在某种关系的对象里

对于第一种可能性,为了保险起见,我仔细翻看了元素对象的所有属性和它原型,原型的原型,一直到原型的尽头 Object,都并没有找到这个数据结构,所以暂时排除这种可能性。

那么只剩下第二种,一个与元素对象存在某种关系的对象,并且从封装的原则性出发,这个对象应该是不会对开发者暴露的(那我还找个G2!淦!)。

行,逼着我去读Spec!

10分钟过去了。。。。。。

shot4.jpg

我鼻青脸肿地回来了,Spec上很明确的说了每个 EventTarget 也就是元素对象本身(因为所有的HTML元素对象的原型链的尽头除了 Object 外都是 EventTarget )都有一个与之关联的事件处理函数list。这尼玛就说的很微妙了呀,与之关联, associated ,但是没有明确说明这个 list 是 EventTarget 的一个属性还是其他什么东西。不过根据我上面的推理,这个list是不存在 EventTarget 内部的。

可以做个小实验,手动创建一个 EventTarget ,给其注册一个事件和事件处理函数,然后手动触发这个事件,再观察这个 EventTarget 实例:

// 创建一个 EventTarget 实例
var et = new EventTarget()

// 给这个实例注册一个事件和相应的事件回调函数
et.addEventListener("click", () => {console.log(`hello`)})

// 手动分发这个事件
et.dispatchEvent(new MouseEvent('click', {
    view: window,
    bubbles: true,
    cancelable: true
}))

// 查看这个 EventTarget 实例
console.dir(et)

下面是运行的结果:

shot5.jpg

看到没有,看到没有,这是个空对象!里面空的不能再空了。

但是这个回调函数被触发了耶!我碰到了浏览器给开发者设置的禁区边界诶!

它跟我说,好了好了,停下,年轻人,再往里面走水很深,你把握不住的!

4. 浏览器事件系统简单回顾(猜想)

感觉我前面洋洋洒洒地说了一堆好像没啥用的废话,不过都写到这里了,还是多写一点吧,不然Spec不白看了?就当做写个读书笔记,咱把浏览器的事件模型简单总结一下加我的一些猜想,请别相信我。

事件系统可以简单分为3个部分:

  • Event
  • EventTarget
  • EventListener

这里我简单回顾加猜想一下这个事件系统的运行原理,假设一个用户做了一次点击事件。

  1. 首先浏览器记录下这次事件的信息,并生成一个 Event 对象。
  2. 然后浏览器找到产生事件的那个元素对象,对它说:嘿小子,把这个 Event 发出去。
  3. 元素对象:好勒老板。然后元素对象找到自己的小弟 dispatchEvent 方法:你,去!把它发送出去!
  4. dispatchEvent 方法:接过 Event 对象,然后 dispatchEvent 方法呼叫浏览器老板,说:老板!请麻烦给我一份从 window 到我老大的元素数组。(这里必然有一个路径查找过程:查找从发出事件的节点到document节点中间的所有节点,然后加上window对象,我猜的!双手叉腰理直气壮.jpg)
  5. 浏览器:行吧行吧,不情愿地呼叫这些元素,你们,给老子排队站好! window 你站第一个! dispatchEvent ,他们站好了,你开始吧。
  6. 然后 dispatchEvent 就把 Event 先交给 windowwindow 检查了下这个Event的类型,然后翻看自己的神秘小仓库,看看有没有相应的处理函数,有就执行它们,没有就轮到了它的儿子(捕获阶段)。
  7. 然后老父亲传儿子,儿子传孙子,最后终于到了那个可怜巴巴的产生事件的元素对象手里,元素对象也翻看自己的神秘小仓库,看看有没有相应的处理函数,有就执行它们。(捕获过程)
  8. 接着把自己的小弟 onclick 叫出来,问他:你有事情吗?有屁就快放。等 onclickEvent 蹂躏一番后,心满意足地把它交还给自己的老大(目标阶段)。
  9. 元素对象又一次翻看了自己的神秘小仓库,看看有没有相应的处理函数,有就执行它们。然后把Event交给自己的爸爸,爸爸再传爸爸,依次进行下去(冒泡阶段

5. 插曲:如何手写一个 bind

我印象中好像一个面试题是让你手写一个 bind ,尼玛这年头总喜欢搞这种无聊的八股文,卷死啦!不过学学 bind 背后的实现原理也不错嘛,参考MDN对 bind 的说明,我也来做做这个面试题,下面是MDN对 bind 函数的描述:

shot6.jpg

从描述中可以看到, bind 创建了一个新的绑定函数,这个函数的本质是将原函数进行了封装,并增加了几个内置属性:

  • BoundTargetFunction: 原函数
  • BoundThis:被绑定的this
  • BoundArguments: 额外使用的预制参数

下面是我参考这个说明写的,至于可用性,我不管,我就写着玩,诶,就是玩儿~

function bind(fn, thisArg, optArg) {
    if (arguments.length === 0) {
        throw new Error(`arguments need: BoundTargetFunction and BoundThis`)
    }
    if (arguments.length === 1) {
        throw new Error(`argument need: BoundThis`)
    }
    if (typeof arguments[0] !== "function") {
        throw new Error(`BoundTargetFunciton must be a function`)
    }
    // 理论上thisArg也可以接受数组和函数,null, undefined,我这里就限制一下它只能是object,我乐意
    if (typeof arguments[1] !== "object") {
        throw new Error(`BoundThis should be a object`)
    }
    const args = Array.prototype.slice.call(arguments, 2)
    const BoundTargetFunciton = fn
    const BoundThis = thisArg
    return function () {
        if (args.length > 0) {
            arguments.unshift(args)
        }
        return BoundTargetFunciton.apply(BoundThis, arguments)
    }
}

实验一下:

var x = 2
const obj = {
  x: 1,
  say: function() {
		console.log(this.x)
  }
}

const unboundFn = obj.say
unboundFn()
const boundFn = bind(obj.say, obj)
boundFn()

结果如下,基本算是实现了吧。什么?你说单测?风太大,我听! 不! 清!

shot7.jpg

参考文献

  1. EventTarget
  2. Event
  3. EventTarget.addEventListener()
  4. EventTarget.dispatchEvent()
  5. DOM Living Standard
  6. Function.prototype.bind() - JavaScript | MDN