qiangdada

个人技术站

欢迎来到 qiangdada 的个人技术站 ~

前端交流群:731175396

公众号:『合格前端』

img

Vue 组件开发

一、父子组件之间的通信

总所周知,如果进行组件开发的话,必定存在组件通信的问题,具体通信如何进行的呢,我借用一张 vue 官网的图

图中很明显可以看到,Parent 组件通过 props 向下传递数据(props down),Child 组件通过 events 向上传递消息(events up)。具体通信机制,请转链接 https://vuejs.org/v2/guide/components.html。那么如果不是父子组件关系,而是 slot 节点之间的关系,又该如何进行通信呢。下面的内容会带着大家一步一步解惑。

二、组件的开发

接下里的例子我将模拟 element-ui 中的 dropdown 下拉菜单组件,对组件开发进行详细的解剖。进行开发前我们先看一下 element-ui 中的 dropdown 组件实现了哪些功能(具体功能转链接http://element.eleme.io/#/zh-CN/component/dropdown),这里我们挑选一些不会涉及到调用其他 element-ui 组件的功能,接下来,希望小伙伴们跟着我一起慢慢实现一个属于自己的 element-ui 组件吧。

1、组件设计

如上图所示,整个 dropdown 组件分成了三个组件模块,最外层的 dropdown,下拉菜单 dropdown-menu,以及下拉列表 dropdown-list。至于为何这样设计,主要是为了该组件的 cover 范围可以大,可以适用各种场景。

我们先看下实现功能后每个组件对应的 template 内容

i. dropdown 组件

<style>
.v-dropdown {
  display: inline-block;
  position: relative;
  color: #48576a;
  font-size: 14px;
}
</style>

<template>
<div
  class="v-dropdown"
  :trigger="trigger"
  :visible="visible"
  :hideOnClick="hideOnClick"
  v-clickoutside="hide">
  <slot></slot>
</div>
</template>

ii. dropdown-menu 组件

<style media="screen">
.v-dropdown-menu {
  margin: 5px 0;
  background-color: #fff;
  border: 1px solid #d1dbe5;
  box-shadow: 0 2px 4px rgba(0,0,0,.12), 0 0 6px rgba(0,0,0,.12);
  padding: 6px 0;
  z-index: 10;
  position: absolute;
  top: 20px;
  left: 0;
  min-width: 100px;
}
</style>

<template>
<ul class="v-dropdown-menu" v-show="visible">
  <slot></slot>
</ul>
</template>

iii. dropdown-list 组件

<style media="screen">
ul, li {
  list-style: none;
}
.v-dropdown-menu_list {
  cursor: pointer;
}
</style>
<template>
<li
  class="v-dropdown-menu_list"
  @click="handleClick"
  :command="command">
  <slot></slot>
</li>
</template>

如上,大家也可以看出来,dropdown 组件负责一个全局的控制,他通过向 dropdown-menu 组件传递 visible 属性控制着其消失与显示,对于 dropdown-list 组件点击事件的回调与否的控制则是通过 $emit 监听dropdown组件中是否存在自定义事件 command。

2、组件功能的实现

i. dropdown-menu消失与显示

首先我们实现一个基本功能,通过 hover 或者 click 事件控制 dropdown-menu 组件的显示与否,这里我们需要给 dropdown 组件绑定两个属性,一个是 visible ,一个是 trigger

<template>
<div 
  class="v-dropdown"
  :trigger="trigger"
  :visible="visible">
  <slot></slot>
</div>
</template>

这里我们需要先重写两个方法,一个是 broadcast(向下传递),一个是 dispatch(向上传递),后面的传递也基本基于这两种方法。具体的 mixins 方法 emitter.js 如下

/**
 * [broadcast 上下传递]
 * @param  {[type]} componentName [组件别名]
 * @param  {[type]} eventName     [事件别名]
 * @param  {[type]} params        [事件回调参数]
 */
function broadcast(componentName, eventName, params) {
  // 遍历当前实例的children节点
  this.$children.forEach(child => {
    var name = child.$options.componentName;
    // 如果子节点名称和组件别名相同,则当前子节点为目标节点
    if (name === componentName) {
      // 找到目标节点后,触发事件别名对应的回调,并将参数传入
      child.$emit.apply(child, [eventName].concat(params));
    }
    // 如果子节点名称和组件别名不相同,继续遍历子节点的子节点,以此类推,直到找到目标节点
    else {
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  })
}
/**
* [dispatch 向上传递]
* @param  {[type]} componentName [组件别名]
* @param  {[type]} eventName     [事件别名]
* @param  {[type]} params        [事件回调参数]
*/
function dispatch(componentName, eventName, params) {
  var parent = this.$parent || this.$root;
  var name = parent.$options.name;
  // 向上找目标父节点,如果上一级父节点不符合,则继续往上查询
  while (parent && (!name || name !== componentName)) {
    parent = parent.$parent;

    if (parent) {
      name = parent.$options.name;
    }
  }
  // 找到目标父节点后,触发事件别名对应的回调,并将参数传入
  if (parent) {
    parent.$emit.apply(parent, [eventName].concat(params));
  }
}
export default {
  methods: {
    broadcast(componentName, eventName, params) {
      broadcast.apply(this, [componentName, eventName, params]);
    },
    dispatch(componentName, eventName, params) {
      dispatch.apply(this, [componentName, eventName, params]);
    }
  }
};

好了对于事件传递的 mixins 方法我们已经写好了,接下来我们需要做的就是通过在 dropdown-menu 组件中注册好 visible 事件,代码如下

<template>
<ul class="v-dropdown-menu" v-show="visible">
  <slot></slot>
</ul>
</template>

<script>
export default {
  name: 'VDropdownMenu',
  componentName: 'VDropdownMenu',
  // 组件create的时候进行事件注册
  created () {
    this.$on('visible', val => {
      this.visible = val;
    });
  }
};
</script>

对于 dropdown 组件,则是通过 watch visible 属性,如果 visible 属性发生改变则将 visible 属性的最新值传递给 dropdown-menu 组件并触发其回调。而对于 visible 属性的控制,具体如下

<template>
<div 
 class="v-dropdown"
  :trigger="trigger"
  :visible="visible"
  :hideOnClick="hideOnClick"
  v-clickoutside="hide">
  <slot></slot>
</div>
</template>

<script>
// vue自带指令,点击节点以外地方,并触发回调
import Clickoutside from 'element-ui/src/utils/clickoutside';
import Emitter from 'element-ui/src/mixins/emitter';
export default {
  name: 'VDropdown',
  componentName: 'VDropdown',
  mixins: [ Emitter ],
  // 注册指令
  directives: { Clickoutside },
  props: {
    trigger: {
      type: String,
      default: 'hover'
    },
    hideOnClick: {
      type: Boolean,
      default: true
    }
  },
  data () {
    return {
      timeout: null,
      visible: false
    }
  },
  methods: {
    // 显示
    show () {
      let that = this;
      clearTimeout(this.timeout);
      this.timeout = setTimeout(function () {
        that.visible = true;
      }, 150);
    },
    // 隐藏
    hide () {
      let that = this;
      clearTimeout(this.timeout);
      this.timeout = setTimeout(function () {
        that.visible = false;
      }, 150);
    },
    // click事件的处理
    handleClick () {
      this.visible = !this.visible;
    },
    initEvent () {
      let {trigger, show, hide, handleClick} = this;
      // 触发事件的elm节点
      let triggerElm = this.$slots.default[0].elm;
      // hover事件处理
      if (trigger === 'hover') {
        triggerElm.addEventListener('mouseenter', show);
        triggerElm.addEventListener('mouseleave', hide);
      }
      // click事件处理
      else if (trigger === 'click') {
        triggerElm.addEventListener('click', handleClick);
      }
    }
  },
  watch: {
    // 向下传递,即VDropdownMenu组件传递visible属性并触发其回调
    visible (val) {
      this.broadcast('VDropdownMenu', 'visible', val);
    }
  },
  mounted () {
    this.initEvent();
  }
};
</script>

写到这里 dropdown-menu 的消失与显示的功能则已实现

ii. dropdown-list 点击事件 command 指令的实现

在这里,我们需要实现的则是对于 dropdown-list 组件的拓展功能的实现,试想,如果我需要在点击 dropdown-list 的时候做一些自定义的事件,该如何实现呢?那么接下来我们要做的就是给人提供一个对外的指令接口 command,$emit 监测到 command 指令的时候触发其自定义的事件回调。

首先我们看看 dropdown-list 进行的操作,具体如下

<template>
<li
  class="v-dropdown-menu_list"
  @click="handleClick"
  :command="command">
  <slot></slot>
</li>
</template>

<script>
import Emitter from 'element-ui/src/mixins/emitter';
export default {
  name: 'VDropdownList',
  mixins: [Emitter],
  props: {
    command: String
  },
  methods: {
    // 点击dropdown-list时,向上传递,即监听VDropdown的 menu-list-click自定义事件并触发其回调
    handleClick (e) {
      this.dispatch('VDropdown', 'menu-list-click', [ this.command, this ]);
    }
  }
};
</script>

对于 dropdown 组件中,需要做的事情便是在组件渲染完成后通过 $on 注册 ‘menu-list-click’ 事件,如下

this.$on('menu-list-click', this.handleMenuListClick)
// ...
handleMenuListClick (command, instance) {
  // 点击list后是否隐藏menu,属性通过hideOnClick控制
  if (this.hideOnClick) {
    this.visible = false;
  }
  // 监听command指令,并触发其回调
  this.$emit('command', command, instance);
}

调用如下

<template>
  <v-dropdown trigger="click" @command="commandHandle" :hide-on-click="false">
      <span class="drop-down_link">下拉菜单</span>
      <v-dropdown-menu>
        <v-dropdown-list command="a">下拉列表1</v-dropdown-list>
        <v-dropdown-list command="b">下拉列表2</v-dropdown-list>
        <v-dropdown-list command="c"><h4>下拉列表3</h4></v-dropdown-list>
      </v-dropdown-menu>
  </v-dropdown>
</template>
<script>
export default {
  methods: {
    commandHandle(command) {
      console.log(command);
    }
  }
}
</script>

执行结果如下(点击每个列表)

到这里,点击 dropdown-list 触发的事件回调也就完成了。我们需要完成的属于自己的 dropdown 组件也算是完成了

三、vue部分源码解析

我们看到上面的代码可以看出,对于组件之间的消息与事件的传递我们是通过 $on , $emit 完成的。当然我们看文档还知道, vue 还提供了 $once$off 的实例方法(API链接:https://vuejs.org/v2/api/#vm-on)。那么对于 $on$once$off$emitvue 的作者又是如何实现的呢。

其实从上面 $on ,$emit 实现的功能来看,我们就能看出,对于 $on ,他就像一个发布者,只负责发布消息。而$emit则相当于订阅者,监听发布者发布的消息。而 $once 则只发布一次消息,$off 则取消发布的消息。想要了解观察者模式(发布-订阅者模式)的小伙伴请先转链接 http://www.sxrczx.com/docs/js/2355128.html

下面我将直接将源码及我写好的注释放给大家,具体如下

var hookRE = /^hook:/;
/**
 * [$on 事件注册]
 * @param  {[type]}   event [注册事件别名]
 * @param  {Function} fn    [注册事件对应回调]
 */
Vue.prototype.$on = function (event, fn) {
  var this$1 = this;

  var vm = this;
  // 遍历需要发布的消息是否是数组,如果是,则循环注册
  if (Array.isArray(event)) {
    for (var i = 0, l = event.length; i < l; i++) {
      this$1.$on(event[i], fn);
    }
  // 如果不是则单次注册
  } else {
    // 默认值 vm._events = Object.create(null); 通过数组的push()将注册事件回调保存在vm._events[event]中
    (vm._events[event] || (vm._events[event] = [])).push(fn);
    if (hookRE.test(event)) {
      // 默认值vm._hasHookEvent = false
      vm._hasHookEvent = true;
    }
  }
  return vm
};
/**
 * [$once 仅注册一次事件]
 * @param  {[type]}   event [注册事件别名]
 * @param  {Function} fn    [注册事件对应回调]
 */
Vue.prototype.$once = function (event, fn) {
  var vm = this;
  // 定义 on()函数进行事件监听并移除,同时作为$on() 函数的回调执行
  function on () {
    // 移除事件
    vm.$off(event, on);
    // 执行回调,进行事件监听
    fn.apply(vm, arguments);
  }
  on.fn = fn;
  vm.$on(event, on);
  return vm
};
/**
 * [$off 事件移除]
 * @param  {[type]}   event [注册事件别名]
 * @param  {Function} fn    [注册事件对应回调]
 */
Vue.prototype.$off = function (event, fn) {
  var this$1 = this;

  var vm = this;
  // 移除所有的事件监听器
  if (!arguments.length) {
    vm._events = Object.create(null);
    return vm
  }
  // 如果事件别名是数组,则循环将数组中对应的所有事件别名对应的监听器移除
  if (Array.isArray(event)) {
    for (var i$1 = 0, l = event.length; i$1 < l; i$1++) {
      this$1.$off(event[i$1], fn);
    }
    return vm
  }
  // specific event
  var cbs = vm._events[event];
  if (!cbs) {
    return vm
  }
  // 如果只传了事件别名一个参数,则移除该事件对应的所有监听器
  if (arguments.length === 1) {
    vm._events[event] = null;
    return vm
  }
  // 参数中既传了事件别名,还传了回调
  var cb;
  var i = cbs.length;
  // 遍历事件对应的所有监听器,即 cbs = vm._events[event]
  while (i--) {
    cb = cbs[i];
    // 如果找到目标监听器,则通过splice移除数组中的监听器,并通过break终止循环
    if (cb === fn || cb.fn === fn) {
      cbs.splice(i, 1);
      break
    }
  }
  return vm
};
/**
 * [$emit 触发事件]
 * @param  {[type]} event [事件别名]
 */
Vue.prototype.$emit = function (event) {
  var vm = this;
  {
    var lowerCaseEvent = event.toLowerCase();
    if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
      tip(
        "Event \"" + lowerCaseEvent + "\" is emitted in component " +
        (formatComponentName(vm)) + " but the handler is registered for \"" + event + "\". " +
        "Note that HTML attributes are case-insensitive and you cannot use " +
        "v-on to listen to camelCase events when using in-DOM templates. " +
        "You should probably use \"" + (hyphenate(event)) + "\" instead of \"" + event + "\"."
      );
    }
  }
  // 定义cbs接收 vm._events[event]
  var cbs = vm._events[event];
  if (cbs) {
    // 通过判断cbs缓存的监听器个数,确保cbs为数组,以便下面的循环执行
    cbs = cbs.length > 1 ? toArray(cbs) : cbs;
    var args = toArray(arguments, 1);
    // 遍历数组cbs,循环执行数组cbs中的方法
    for (var i = 0, l = cbs.length; i < l; i++) {
      cbs[i].apply(vm, args);
    }
  }
  return vm
}

OK,到这里,这篇博客也该谢幕了,相信看到这里,小伙伴们应该也能写出属于自己的 vue 组件,并且理解了 vue 是如何进行事件的注册,及事件的回调触发的。对于博客中我实现的 dropdown 组件,已经整理并上传到 github 了。

github 地址

回到顶部

打赏一个呗

取消

感谢您的支持,我会继续努力的!

扫码支持
扫码支持
扫码打赏,你说多少就多少

打开 支付宝 或者 微信 扫一扫,即可进行扫码打赏哦