Vue原理解析(十):搞懂事件API原理及在组件库中的妙用

** 上一章** Vue原理解析(九):监听属性watch和计算属性computed实现原理

vue内部初始化时会为每个组件实例挂载一个this._events私有的空对象属性:

vm._events = Object.create(null) // 没有__proto__属性

这个里面存放的就是当前实例上的自定义事件集合,也就是自定义事件中心,它存放着当前组件所有的自定义事件。和自定义事件相关的API分为以下四个:this.$onthis.$emitthis.$offthis.$once,它们会往这个事件中心中添加、触发、移除对应的自定义事件,从而组成了vue的自定义事件系统,接下来看下它们都是怎么实现的。

  • this.$on

描述:监听当前实例上的自定义事件。事件可以由vm.$emit触发,回调函数会接收所有传入事件触发函数的额外参数。

export default {
  created() {
    this.$on('test', res => {
      console.log(res)    
    })
  },
  methods: {
    handleClick() {
      this.$emit('test', 'hello-vue~')
    }
  }
}

以上示例首先在created钩子内往当前组件实例的事件中心_events中添加一个名为test的自定义事件,第二个参数为该自定义事件的回调函数,而触发handleClick这个方法后,就会在事件中心中尝试找到test自定义事件,触发它并传递给回调函数hello-vue~这个字符串,从而打印出来。我们来看下$on的实现:

Vue.prototype.$on = function (event, fn) {
  const hookRE = /^hook:/    //检测自定义事件名是否是hook:开头
  
  const vm = this
  if (Array.isArray(event)) {  // 如果第一个参数是数组
    for (let i = 0; i < event.length; i++) {
      this.$on(event[i], fn)  // 递归
    }
  } else {
    (vm._events[event] || (vm._events[event] = [])).push(fn)
    // 如果有对应事件名就push,没有创建为空数组然后push
    
    if (hookRE.test(event)) {  // 如果是hook:开头
      vm._hasHookEvent = true  // 标志位为true
    }
  }
  return vm
}

以上就是$on的实现了,它接受两个参数,自定义事件名event和对应的回调函数fn。主要就是往事件中心_events下挂载对应的event事件名key,而事件名对应的key又是一个数组形式,这样相同事件名的回调会在一个数组之内。而接下来的_hasHookEvent标志位表示是否监听组件的钩子函数,这个之后示例说明。

  • this.$emit

描述:触发当前实例上的事件,附加参数都会传给监听器回调。

Vue.prototype.$emit = function (event) {
  const vm = this
  let cbs = vm._events[event]  // 找到事件名对应的回调集合
  if (cbs) {
    const args = toArray(arguments, 1)  // 将附加参数转为数组
    
    for (let i = 0; i < cbs.length; i++) {
      cbs[i].apply(vm, args)  // 挨个执行对应的回调集合
    }
  }
  return vm
}

$emit的实现会更好理解些,首先从事件中心中找到event对应的回调集合,然后将$emit其余参数转为args数组,最后挨个执行回调集合内的回调并传入args。通过这么一对朴实的API可以帮我们理解三件小事:

1. 理解自定义事件原理

app.vue
<template>
  <child-component @test='handleTest' />
</template>
export default {
  methods: {
    handleTest(res) {
      console.log(res)
    }
  }
}

----------------------------------------

child.vue
<template>
  <button @click='onClick'>btn</button>
</template>
export default {
  methods: {
    onClick() {
      this.$emit('test', 'hello-vue~')
    }
  }
}

以上是父子组件通过自定义事件通信,想必大家非常熟悉。自定义事件的实现原理和通常解释的会不同,它们的原理是父组件在经过编译模板后,会将定义在子组件上的自定义事件test及其回调handleTest通过$on添加到子组件的事件中心中,当子组件通过$emit触发test自定义事件时,会在它的事件中心中去找test,找到后传递hello-vue~给回调函数并执行,不过因为回调函数handleTest是在父组件作用域内定义的,所以看起来就像是父子组件之间通信般。

image

2. 监听组件的钩子函数

也就是$on内自定义事件名之前是hook:的情况,可以监听组件的钩子函数触发:

app.vue
<template>
  <child-component @hook:created='handleHookEvent' />
</template>

以上示例为当子组件的created钩子触发时,就触发父组件内定义的handleHookEvent回调。接下来让我们再看一个官网的示例,使用这个特性如何帮我们写出更优雅的代码:

监听组件钩子之前:
mounted () {
  this.picker = new Pikaday({  // Pikaday是一个日期选择库
    field: this.$refs.input,
    format: 'YYYY-MM-DD'
  })
},
beforeDestroy () {  // 销毁日期选择器
  this.picker.destroy()
}

监听组件钩子之后:
mounted() {
  this.attachDatepicker('startDateInput')
  this.attachDatepicker('endDateInput')  // 同时为两个input添加日期选择
},
methods: {
  attachDatepicker(refName) {  // 封装为一个方法
    const picker = new Pikaday({  // Pikaday是一个日期选择库
      field: this.$refs[refName],  // 为input添加日期选择
      format: 'YYYY-MM-DD'
    })

    this.$once('hook:beforeDestroy', () => {  // 监听beforeDestroy钩子
      picker.destroy()  // 销毁日期选择器
    })  // $once和$on类似,只是只会触发一次
  }
}

首先不用在当前实例下挂载一个额外的属性,其次可以封装为一个方法,复用更方便。

3. 不借助vuex跨组件通信

再开发组件库时,因为都是独立的组件,从而引入vuex这种强依赖是不现实的,而且很多时候是用插槽来放置子组件,所以子组件的位置、嵌套、数量并不会确定,从而在组件库内完成跨组件的通信就尤为重要。

通过接下来的示例介绍组件库中会运用到的一种,使用$on$emit来实现跨组件通信,子组件通过父组件的name属性找到对应的实例,找到后使用$emit触发父组件的自定义事件,而在这之前父组件已经使用$on完成了自定义事件的添加:

export default {
  methods: {  // 混入mixin使用
    dispatch(componentName, eventName, params) {
      let parent = this.$parent || this.$root  // 找父组件
      let name = parent.$options.name  // 父组件的name属性

      while (parent && (!name || name !== componentName)) {  // 和传入的componentName进行匹配
        parent = parent.$parent  // 一直向上查找

        if (parent) {
          name = parent.$options.name  // 重新赋值name
        }
      }
      if (parent) {  // 找到匹配的组件实例
        parent.$emit.apply(parent, [eventName].concat(params))  // $emit触发自定义事件
      }
    }
  }
}

接下来介绍表单验证组件内的使用案例:

image

不知道大家是否对这种表单验证好奇过,为什么点一下提交,就可以将所有的表单项全部做验证,接下来笔者试着写一个极简的表单验证组件来说明它的原理。这里会有两个组件,一个是iForm为整个表单,一个是iFormItem为其中的某个表单项:

iForm组件:

<template>
  <div> <slot /> </div>  // 只有一个插槽
</template>

<script>
export default {
  name: "iForm",  // 组件名很重要
  data() {
    return {
      fields: []  // 收集所有表单项的集合
    };
  },
  created() {
    this.$on("on-form-item-add", field => {  // $on必须得比$emit先执行,因为要先添加嘛
      this.fields.push(field)  // 添加到集合内
    });
  },
  methods: {
    validataAll() {  // 验证所有的接口方法
      this.fields.forEach(item => {
        item.validateVal()  // 执行每个表单项内的validateVal方法
      });
    }
  }
};
</script>

模板只有一个slot插槽,这个组件主要是做两件事,将所有的表单项的实例收集到fields内,提供一个可以验证所有表单项的方法validataAll,然后看下iFormItem组件:

<template>
  <div>
    <input v-model="curValue" style="border: 1px solid #aaa;" />
    <span style="color: red;" v-show="showTip">输入不能为空</span>
  </div>
</template>

<script>
import emitter from "./emitter"  // 引入之前的dispatch方法

export default {
  name: "iFormItem",
  mixins: [emitter],  // 混入
  data() {
    return {
      curValue: "",  // 表单项的值
      showTip: false  // 是否验证通过
    };
  },
  created() {
    this.dispatch("iForm", "on-form-item-add", this)  // 将当前实例传给iForm组件
  },
  methods: {
    validateVal() {  // 某个表单项的验证方法
      if (this.curValue === "") {  // 不能为空
        this.showTip = true  // 验证不通过
      }
    }
  }
};
</script>

看到这里我们知道了原来这种表单验证原理是将每个表单项的实例传入给iForm,然后在iForm内遍历的执行每个表单项的验证方法,从而可以一次性验证完所有的表单项。表单验证调用方式:

<template>
  <div>
    <i-form ref='form'>  // 引用
      <i-form-item />
      <i-form-item />
      <i-form-item />
      <i-form-item />
      <i-form-item />
    </i-form>
    <button @click="submit">提交</button>
  </div>
</template>

<script>
import iForm from "./form"
import iFormItem from "./form-item"

export default {
  methods: {
    submit() {
      this.$refs['form'].validataAll() // 验证所有
    }
  },
  components: {
    iForm, iFormItem
  }
};
</script>

这里就使用了$on$emit这么一对API,通过组件的名称去查找组件实例,不论嵌套以及数量,然后使用事件API去跨组件传递参数。

注意点:当$on$emit配合使用时,$on要优先与$emit执行。因为首先要往实例的事件中心去添加事件,才能被触发。

  • this.$off

描述:移除自定义事件监听器,不过根据传入的参数分为三种形式:

  • 如果没有提供参数,则移除所有的事件监听器;
  • 如果只提供了事件,则移除该事件所有的监听器;
  • 如果同时提供了事件与回调,则只移除这个回调的监听器。
export default {
  created() {
    this.$on('test1', this.test1)
    this.$on('test2', this.test2)
  },
  mounted() {
    this.$off()  // 没有参数,清空事件中心
  }
}

-------------------------------------------

export default {
  created() {
    this.$on('test1', this.test1)
    this.$on('test2', this.test2)
  },
  mounted() {
    this.$off('test1')  // 在事件中心中移除test1
  }
}

-------------------------------------------

export default {
  created() {
    this.$on('test1', this.test1)
    this.$on('test1', this.test3)
    this.$on('test2', this.test2)
  },
  mounted() {
    this.$off('test1', this.test3)  // 在事件中心中移除事件test1的test3回调
  }
}

知道了这个API的调用方式之后,接下来看下$off的实现方式:

Vue.prototype.$off = function (event, fn) {
  const vm = this
  if (!arguments.length) {  // 如果没有传递参数
    vm._events = Object.create(null)  // 重置事件中心
    return vm
  }
  
  if (Array.isArray(event)) {  // event如果是数组
    for (let i = 0, l = event.length; i < l; i++) {
      vm.$off(event[i], fn)  // 递归清空
    }
    return vm
  }
  
  if (!fn) {  // 只传递了事件名没回调
    vm._events[event] = null  // 清空对应所有的回调
    return vm
  }
  
  const cbs = vm._events[event]  // 获取回调集合
  let cb
  let i = cbs.length
  while (i--) {
    cb = cbs[i]  // 回调集合里的每一项
    if (cb === fn || cb.fn === fn) {  // cb.fn为$once时挂载的
      cbs.splice(i, 1)  // 找到对应的回调,从集合内移除
      break
    }
  }
  return vm
}

也是分为了三种情况,根据参数的不同做分别处理。

  • this.$once

描述:监听一个自定义事件,但是只触发一次,在第一次触发之后移除监听器。

效果和$on是类似的,只是说触发一次之后会从事件中心中移除。所以它的实现思路也很好理解,首先通过$on实现功能,当触发之后从事件中心中移除这个事件。来看下它的实现原理:

Vue.prototype.$once = function (event, fn) {
  const vm = this
  function on () {
    vm.$off(event, on)
    fn.apply(vm, arguments)
  }
  on.fn = fn  // 回调挂载到on下,移除时好做判断
  vm.$on(event, on)  // 将on添加到事件中心中
  return vm
}

首先将回调fn挂载到on函数下,将on函数注册到事件中心去,触发自定义事件时首先会在$emit内执行on函数,在on函数内执行$offon函数移除,然后执行传入的fn回调。这个时候事件中心没有了on函数,回调函数也执行了一次,完成$once功能~

事件API总结:$on往事件中心添加事件;$emit是触发事件中心里的事件;$off是移除事件中心里的事件;$once是触发一次事件中心里的事件。哪怕是如此不显眼的API,再理解了它们的实现原理后,也能让我们再更多场景更好的使用它们~

最后按照惯例我们还是以一道vue可能会被问到的面试题作为本章的结束(想不到事件相关特别好的题目~)。

面试官微笑而又不失礼貌的问道:

  • 说下自定义事件的机制。

怼回去:

  • 子组件使用this.$emit触发事件时,会在当前实例的事件中心去查找对应的事件,然后执行它。不过这个事件回调是在父组件的作用域里定义的,所以$emit里的参数会传递给父组件的回调函数,从而完成父子组件通信。

** 上一章** Vue原理解析(十一):搞懂extend和$mount原理并实现一个命令式Confirm弹窗组件

顺手点个赞或关注呗,找起来也方便~

分享一个组件库,你可能会用的上哦 ~ ↓

你可能会用的上的一个vue功能组件库,持续完善中...

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

推荐阅读更多精彩内容