vue不止双向绑定,来看看EventEmitter是怎么实现的

5,902 阅读12分钟
原文链接: github.com

半个月前看到一篇文章将eventEmitter,看完之后心血来潮自己写了一个。晚上睡觉前忽然想到还可以尝试实现vue中emitter。于是,故事就这么开始了。

1、实现一个eventEmiiter

1.1、整体架构

我们先看一张图,看下EventEmitter需要实现哪些功能
image

可以看到一个EventEmitter类的内容其实并不杂乱。根据这张图来看,我们大致可以分为以下几个模块。

  1. EventEmitter初始属性

    • _events //存储所有的监听器
    • _maxListeners setMaxListeners getMaxListeners
  2. addEventListener模块

    • addListener
    • prependListener
    • once
    • prependOnceListener
    • on
  3. emit模块

    • emitNone
    • emitOne emitTwo emitThree
    • emitMany
    • error 事件
  4. removeEventListener模块

    • removeListener
    • removeAllListeners
  5. listeners,eventNames

    • listeners //获取一个监听器下的所有事件
    • eventNames //获取有哪些监听器
  6. 工具函数和兼容性函数

    • spliceOne
    • arrayClone
    • objectCreatePolyfill
    • objectKeysPolyfill
    • functionBindPolyfill

基本上按照上面这个顺序,就可以写出来一个基本的的eventEmitter的类了。

推荐大家可以先自己尝试写一写,这样子等下看成熟库的源码可以得到的收获更多。

然后去网上找了一个成熟库的源码进行对比,果然发现了一些问题需要改善 😳。

点这里。写完之后,可以看下看EventEmitter类源码怎么实现的

  • 自己为了节省代码行数,单个事件和多个事件都用了Array去存储。其实作为库,节约的十几行代码和性能比起来,还是后者更重要
  • 没有考虑emit几个参数的情况,不同情况的处理有助于提高性能
  • 没有考虑限制一个类可以绑定的最大事件数。因为如果数目一多话,容易造成内存泄露.
  • 函数缺少对参数的判断。缺少防御性代码

1.2、简单分析一下部分代码

具体代码就不分析了 😂 😂,稍微对主线讲解一下吧。因为源码并不复杂,沉下心花个半小时肯定能全部看懂

作者一开始新建一个_events对象,这个对象会在后期存取我们监听器。然后设定了一个监听器允许的最大事件,避免内存泄露的可能性。

function EventEmitter() {
  if (!this._events || !Object.prototype.hasOwnProperty.call(this, '_events')) {
    this._events = objectCreate(null);
    this._eventsCount = 0;
  }

  this._maxListeners = this._maxListeners || undefined;
}

然后添加事件,在真实场景中,我们会在html中获取需要添加哪些监听器type,和对应的方法listener

function _addListener(target, type, listener, prepend) {
  var m;
  var events;
  var existing;

  if (typeof listener !== 'function')
    throw new TypeError('"listener" argument must be a function');

  events = target._events;
  if (!events) {
    events = target._events = objectCreate(null);
    target._eventsCount = 0;
  } else {
    if (events.newListener) {
      target.emit('newListener', type,
          listener.listener ? listener.listener : listener);
      events = target._events;
    }
    existing = events[type];
  }

  if (!existing) {
    existing = events[type] = listener;
    ++target._eventsCount;
  } else {
    if (typeof existing === 'function') {
      existing = events[type] =
          prepend ? [listener, existing] : [existing, listener];
    } else {
      if (prepend) {
        existing.unshift(listener);
      } else {
        existing.push(listener);
      }
    }

  }

  return target;
}

当我们事件添加完毕之后,则是通过emit进行调用

EventEmitter.prototype.emit = function emit(type) {
  var er, handler, len, args, i, events;

  events = this._events;
  if (events)
    doError = (doError && events.error == null);
  else if (!doError)
    return false;

  handler = events[type];

  if (!handler)
    return false;

    if (isFn) handler.call(self);
    else {
        var len = handler.length;
        var listeners = arrayClone(handler, len);
        for (var i = 0; i < len; ++i)
          listeners[i].call(self);
        }
    }
  return true;
};

主线代码就这样,更多细节我是真的推荐大家全看源码的。因为这400多行的代码真的不复杂。反倒是源码中间还是有很多细节可以值得细细品味的。

1.2.1、为什么使用Object.create(null)

我们可以看到网上许多库(比如vue)都是使用Object.create(null)来创建对象,而不是使用{}来新建对象。这是为什么呢 🤔?

Object.create()这个API我就不介绍了,不清楚的推荐上MDN先了解一下

我们可以先在chrome的控制台上打印Object.create({})创建的对象是什么样子的:
image

可以看到新创建出来的对象继承了Object原型链上所有的方法。

我们可以再看一下使用Object.create(null)创建出来的对象:
image

没有任何属性,显示No properties。

区别很明显,我们获得了一个非常纯净的对象。那么问题来了,这样对象有什么好处呢 🤔?

首先我们需要知道无论是var a = {}还是Object.create({}),他们返回的对象都是继承自Object的原型,而原型是可以修改的。但是假如有别的库或者开发者在页面上修改了Object的原型,那么你也会继承下来修改后的原型方法,这个可能就不是我们想要的了。

随手在一个csdn的网页控制台写个例子,没想到就出现这个问题
image

而如果我们自己在每个库开头新建一个干净的对象,我们可以自己改写这个对象的原型方法进行复用和继承,既不会影响他人,也不会被他人影响。

1.2.2、比原生splice效率还高的函数

在源码中看到了这么一段代码,作者亲自打了注释说1.5倍速度快于原生。

// About 1.5x faster than the two-arg version of Array#splice().
function spliceOne(list, index) {
  for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1)
    list[i] = list[k];
  list.pop();
}

splice的效率慢我是知道,但是作者说1.5倍快我就要亲自试验下了。

看他方法,缺陷应该是数组长度越长,所需时间越长;下标越靠近开始,所需时间越长。于是我用了不同长度的数组,不同下标去进行反复测试100次。

// 测试的代码是这么写的,如果不合理请指教
var arr = [];for(let i = 0;i < 50000;i++){arr.push(i)}
console.time();
spliceOne(arr,1)
// arr.splice(1,1)
// arr.splice(49995,1)
// spliceOne(arr,49995)
console.timeEnd()
	
//在数据长度是5的情况下,下标为1,splice效率快33%
//在数据长度是500的情况下,下标为1,splice效率快75%
//在数据长度是50000的情况下,下标为1,splice效率快95%
//在数据长度是5的情况下,下标为4,spliceOne效率快20%
//在数据长度是500的情况下,下标为45,spliceOne效率快50%
//在数据长度是50000的情况下,下标为49995,spliceOne效率快50%

因为源码是针对node.js的,不知道是不是浏览器内部对splice做过优化。作者的方法在特定情况下的确是做到了更快,还是很厉害的。 👍 👍 👍 🤕

1.2.3、多个emit方法

源码作者专门为emit不同数量参数写了不同的方法,有emitNone,emitOne,emitTwp,emitThree,emitMany

如果按照我来写,最多也就分成emitNoneemitMany两个方法。但是作者应该是为了更高的效率,尽可能减少for循环这种代码。这也是我这种不怎么写库的人迟钝的地方。节约的十几行代码在压缩之后,重要性是低于性能上的损耗的。

2、简单实现vue中的EventEmitter

在写完EventEmitter之后,仍然感觉特别单调。然后睡觉的时候忽然在想,是不是可以正好将自己写好这个类套进到vue里面呢?有了实际场景,就知道自己写的东西到底能干什么,有什么问题。不然空有理论也是没有任何进步的。

之前网上也有很多文章解析了vue如何实现双向绑定。事实上在编译html的过程中实现了的不仅仅是数据双向绑定,添加事件监听器也是这一过程做的。只是网上关于事件监听的文章却几乎没有。

2.1、自己尝试实现一个vue中的EventEmitter

按照我一开始的想法,应该是先编译HTML获取所有的属性,判断出哪些属性是绑定事件,哪些是数据绑定。

<template>
	<div id="app" :data="data" @click="test" @change="test2">test内容</div>
</>

<script>
var vue = {
    methods: {
        test(){alert(123)},
        test2(){console.log(456)}
    }
}

var onRE = /^@|^v-on:/;
function compile(node) {
    var reg = /\{\{(.*)\}\}/;
    //节点类型为元素
    if(node.nodeType === 1){
        var attr = node.attributes;
        for(var i = 0;i < attr.length; i ++){
            console.log(attr[i])
        }
    }
}
compile(window.app)
</script>

于是自己先写出了第一段代码,希望依靠原生node的方法attributes去获取DOM元素上所有的属性

但是等到获取之后,才发现获取到的每个属性attr[i]竟然是一个神奇的对象类型[object Attr],表现形式是@click=test。虽然表现很像是字符串,但是个NamedNodeMap。靠根本不知道怎么用嘛 😂 😂 😂

去网上找了资料之后,才知道他是怎么获取key和value的。

var onRE = /^@/;
function compile(node) {
    var reg = /\{\{(.*)\}\}/;
    //节点类型为元素
    if(node.nodeType === 1){
        var attr = node.attributes;
        for(var i = 0;i < attr.length; i ++){
            if(onRE.test(attr[i].nodeName)){
                var value = attr[i].nodeValue;
            }
        }
    }
}
compile(window.app)

只是文章里面说DOM4规定中已经不推荐使用这个属性了 😢ㄟ( ▔, ▔ )ㄏ。想了想放弃了,还是乖乖去看了一下vue的源码是怎么实现的吧。

2.2、vue源码实现一个EventEmitter

因为想着vue肯定也是先编译HTML,所以直接找到了源码中的html-parse模块。

vue先定义了一个parseHTML的方法,传进来需要编译的html模板,也就是我们的template。然后通过一个属性正则表达式一步步去match出模板字符串内的所有属性,最后返回了一个包含所有属性的数组attrs

然后vue会对得到的数组attrs进行遍历判断,这个属性是v-for?还是change?还是src等等。当获取到的属性为@click或者v-on:click这种事件之后,然后通过方法addHandler去添加事件监听器。我们也就可以在开发中使用emit了。

当然vue中间还会有很多操作。比如会接着将这个属性数组以及tag传入到一个createASTElement函数里面进行生成一棵AST树渲染成真实的dom等等。只不过这并不是我们本篇文章需要讨论的内容了

我们接下去就按照vue的流程来实现绑定事件。首先我们定义好我们的html内容。

<template>
	<div id="app" :data="data" @click="test" @change="test2">test内容</div>
</>

<script>
var vue = {
    data(){
        return {data:1}
    }
    methods: {
        test(){alert(123)},
        test2(){console.log(456)}
    }
}
</script>

在我们就要开始进行编译之前,我们准备好所有需要用到的正则,新建好一个eventEmitter

var attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
var ncname = '[a-zA-Z_][\\w\\-\\.]*';
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
var startTagClose = /^\s*(\/?)>/;
var onRE = /^@|^v-on:/;

var eventEmitter = new EventEmitter()
const app = document.getElementById("app")

然后开始写我们的编译函数。前面已经说了,我们传进模板,然后依据正则一步步match出所有的属性.

function compiler(html){
    html = trim(html) //因为模板换行有空格,所以需要先去除开头的空格
    let index = html.match(startTagOpen)[0].length
    html = html.substring(index)
    const match = {
        attrs: [],
        attrList: []
    }
    let end, attr;
    //编译完整个html标签
    //如果多层dom,vue有循环,但是测试就不搞那么复杂了
    while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
    	match.attrs.push(attr)
    	index = attr.length
    	html = html.substring(attr[0].length)
    }
    return match
}

解释一下编译过程吧。先根据开头标签的正则,找到需要编译的html。然后截取出除开始标签<div的剩余字符串。

接下来继续对字符串判断。依靠属性正则表达式,判断这段html标签内有没有属性,如果有的话,从字符串中截取出来。

继续不断循环字符串,直到遇到闭合标签/div>为止。然后结束编译,返回数组。

编译完成后我们已经获取到了模板里面所有的属性,但是现在存储起来的属性表现形式是一个match出来的数组,并不是一个方便开发者使用的map形式。所以接下来我们要处理一下我们获得的数组。

function processAttrs(match){
	let l = match.attrs.length
	for (var i = 0; i < l; i++) {
		var args = match.attrs[i];
		// hackish work around FF bug https://bugzilla.mozilla.org/show_bug.cgi?id=369778
		if (args[0].indexOf('""') === -1) {
			if (args[3] === '') { delete args[3]; }
			if (args[4] === '') { delete args[4]; }
			if (args[5] === '') { delete args[5]; }
		}
		var value = args[3] || args[4] || args[5] || '';
		match.attrList[i] = {
			name: args[1],
			value: value
		};
	}
	return match
}

通过这一步,我们已经获取到了一个attrList,并且存储起来了一个个表现为map形式的属性。然后我们要遍历这些属性,判断哪些是需要绑定的方法,哪些使我们不需要的属性。如果是需要绑定的方法,我们通过addHandler函数来添加事件监听器。

function processHandler(match){
	let attrList = match.attrList, l = attrList.length
	let name, value
	for(let i = 0; i < l; i ++){
		name = attrList[i].name
		value = attrList[i].value
		if(onRE.test(name)){
			name = name.replace(onRE, '');
			addHandle(vue, name, value, false);
		}
	}
}

function addHandle(target, name, value, prepend){
	let handler = target.methods[value]
	eventEmitter.addListener(name, handler, prepend)

	eventEmitter.emit("click")
}

走到这里整个流程就已经结束了。接下去每次进入页面去进行初始化编译就好了。

function parseHTML(html){
	const match = compiler(html)
	processAttrs(match)
	processHandler(match)
}

如果想尝试触发我们之前绑定的事件,在vue中是子组件向父组件触发。这里就不搞父子组件这么麻烦了。我们可以直接在JS里面调用emit来进行验证

eventEmitter.emit("click")

game over 😊

文章结束了,日常总结一下吧。实现整个eventEmitter的代码其实并不复杂,尤其在源码非常简洁的情况下,基本上认真看个十几分钟就能明白整个轮廓。然后我没有仔细看vue中的实现是怎么样的,不过我猜测应该相似度很高。

后面看vue提取属性还是花了更多的时间,原来还以为可以自己通过attribute属性来实现的。没想到最后还是参考了vue,再看的途中,也明白了vue编译html的整个过程,以及每个过程实现了哪些内容。

其实看源码可以学到的东西都很多,最直接的就是知道怎么实现一个功能。此外呢?其实此外是是更多的。比如编码习惯,比如防御性代码怎么写,比如结尾处理代码怎么写,比这不就看到有方法比原生API的效率还快。这些都是看源码的乐趣所在。

看完之后,以后妈妈再也不用担心面试靠eventEmitter了