如何实现一个前端框架4-组件化之事件回调

161 阅读5分钟

我们接着上次的话题继续

上一节中我们把父子组件之间的关系建立起来了,不过并没有涉及到父子组件之间数据的传递、父组件对子组件回调的处理等内容

从组件的观点来看,我们的my-alert组件右边其实应该有一个关闭按钮,点它可以将其隐藏

我们首先需要明确一点,目前代码中控制my-alert展示或者隐藏的变量,是父组件App中的alertVisible,而触发其隐藏的button按钮使是子组件里面的
所以我们可以有下面的一个思路:

  1. 在子组件点击事件触发时,改变alertVisible这个值(即在子组件中希望更改父组件的值)
  2. 这个值的改变会触发alertVisible这个变量的set钩子函数的执行
  3. set钩子里面执行的就是App组件的update,App组件在update执行时,发现alertVisible是false,my-alert就不会被渲染出来

我们还是先从组件定义的地方开始,看如何使我们的代码能够支持这个功能: App:

let App = new ViewComponent({
  el: '#app',
  components: {
    MyAlert: MyAlert
  },
  data: function () {
    return {
      alertVisible: false
    }
  },
  methods: {
    showAlert: function () {
      this.alertVisible = true
    },
    hideAlert: function () {
      this.alertVisible = false
    }
  },
  render: function () {
    let children = [
      this.createElement('button', {
        class: 'click-show',
        on: {
          click: this.showAlert
        }
      }, '点击展示')
    ]
    if (this.alertVisible) {
      let o = this.createElement('my-alert', { 
        props: {
          content: '左侧文字'
        },
        on: {
          close: this.hideAlert
        }
      })
      children.push(o)
    }
    return this.createElement('div', { class: 'alert-wrap' }, children)
  }
})

在上面的代码中,我们增加了hideAlert方法,用于将变量alertVisible的值置为false,从而在调用render时告诉它不必将my-alert渲染出来

同时,我们还可以看到,在render函数的if判断中,调用createElement创建my-alert的属性参数中,我们新添加了一个on的属性对象,将上面我们写的hideAlert传了进去,用于在my-alert点击关闭按钮时触发

再来看my-alert子组件:

let MyAlert = {
  props: {
    content: {
      default: ''
    },
  },
  data: function () {
    return {
    }
  },
  methods: {
    close: function () {
      this.notifyParent('close')
    }
  },
  render: function () {
    let children = [
      this.createElement('div', { class: 'text' }, this.content),
      this.createElement('button', {
        class: 'close',
        on: {
          click: this.close
        }
      }, '关闭')
    ]
    return this.createElement('div', { class: 'alert' }, children)
  }
}

my-alert看起来就简单多了,就是在render中加了一个button,这个button绑了一个事件,即methods中的close

不过可以注意到,methods中的close用了一个新的方法notifyParent,这个意思再清楚不过了,就是通知父组件,通知父组件干嘛呢?我们通过参数'close',告诉它执行close方法

还记得我们在App父组件中对MyAlert子组件的修改么?其中有一处修改就是传了回调进去:

        on: {
          close: this.hideAlert
        }

我们可以将close和hideAlert理解为一个映射,当子组件想要通知父组件做一些事情时,一定是通知父组件去执行传给子组件的某个回调,所有的回调都放到了这个on对象里面,key、value分别代表回调的名字,回调实体函数

子组件通知父组件时,就以这个on对象里的某个key作为标识通知就可以了

因为这个标识可能不只对应一个方法,这个key对应的value还可以是数组,数组里面放多个方法,子组件通过notifyParent通知的时候可以遍历执行,当然我们在这里就不实现这个逻辑了,它并不复杂,我们只要知道意思即可

到此大家应该也能猜得出来,我们这里的notifyParent,其实就是Vue中的$emit,在React里,似乎是直接通过属性把回调传进去的,并没有和Vue一样分成props和回调

接下来,我们考虑一下,如何修改我们的代码,使其满足回调的功能,根据我们上面介绍的render中新增的东西,大概有以下两点:

  1. 创建组件时,新增了事件回调,所以在createComponent里面需要把事件回调接到,然后存起来,以供后面子组件notifyParent的时候用
  2. 很显然,我们要实现notifyParent

我们先来看createComponent的改动:

function createComponent (vm, tagName, attrs, childNodes) {
  let normalizedComponentName = kebabToCamelCase(tagName)
  let componentOptions = vm.components[normalizedComponentName] || {}
  let propsData = attrs.props
  // 将传入子组件的回调接管过来,暂存到events中
  let events = attrs.on

  componentOptions.propsData = propsData
  // 将events挂到componentOptions上
  componentOptions.parentListeners = events
  // 在子组件的ViewComponent实例上添加与父组件之间的关系
  componentOptions.$parent = vm
  let componentInstance = new ViewComponent(componentOptions)
  return componentInstance.renderedElement
}

上述改动中,将events挂到componentOptions上这个还比较容易理解,这样一来子组件内部触发父组件的事件时就可以通过这个componentOptions拿到

但是为什么需要在子组件的ViewComponent实例上添加与父组件之间的关系呢?

答案会在notifyParent中有所体现,notifyParent这个方法对this.$parent是有依赖的

不过有个细节我们在此需要先声明一下,这个$parent被挂到componentOptions之后,在构造函数中最终还是会被放到ViewComponent实例对象上的:

function ViewComponent (options) {
  this.el = options.el ? document.querySelector(options.el) : undefined
  this.propOptions = options.props
  this.data = options.data
  this.methods = options.methods
  this.render = options.render
  this.components = options.components
  this.$options = options
  this.renderedElement = null
  // $parent被放到实例对象上
  this.$parent = options.$parent || null
  this.init()
}

再啰嗦一下,其实这里一开始我不是这么实现的,而是在createComponent中new完了以后再将$parent赋值:

function createComponent (vm, tagName, attrs, childNodes) {
  let normalizedComponentName = kebabToCamelCase(tagName)
  let componentOptions = vm.components[normalizedComponentName] || {}
  let propsData = attrs.props
  let events = attrs.on

  componentOptions.propsData = propsData
  componentOptions.parentListeners = events
  let componentInstance = new ViewComponent(componentOptions)
  // 之前的做法:在子组件的ViewComponent实例上添加与父组件之间的关系
  componentInstance.$parent = vm
  return componentInstance.renderedElement
}

我本来是想:这样做就省得在componentOptions上赋值,再带到ViewComponent构造函数里面,再在init中将其挂到this上,似乎代码量更少了些

不过,我后来一想,这样一来,在init的过程中就没法调用notifyParent了,我们刚刚有提到过,notifyParent是依赖this.$parent的

如果在init中没法调用notifyParent,那就意味着在render函数第一次执行时,拿不到notifyParent这个方法的引用,因为第一次调用render就是沿着

init -> renderAndCollectDependencies -> update -> render

这样一个路径来执行的

根据我们实际开发的经验,相信大家一定有感觉,在render中通知父级的回调执行,是一个很常见的逻辑

说了这么多,希望大家不要感觉啰嗦,其实我是想通过这些思考过程的分析和大家分享一个实现框架过程中的一个感受: 可能我们平时写业务代码时,不会遇到这样的复杂度,所以可能一行代码写到这个地方、写到那个地方都没有什么区别,一个需求可能会有多种实现方式,用哪种方式都行,但在设计框架这种比较复杂的情况下,实现方式貌似看起来很多,很多时候都是一种方式看起来没问题,但做到中间发现这种方式有弊端、或者无法满足某个边界条件、或者和其他逻辑有冲突,所以需要采取另外一种方式,造成很多反复,或许减少这种反复也是经验积累的重要一环,而要做到这一点往往比较困难

赶紧回到正题,接下来我们该实现notifyParent了,不过在此之前还要做一件事,就是先要把父组件传给子组件的回调存起来,也放到组件实例对象上,供notifyParent的时候去这里找到对应的方法来调用:

ViewComponent.prototype.init = function () {
  this.initProps()
  this.initData()
  this.initMethods()
  // 我们将回调的处理放到initEvents方法中
  this.initEvents()

  this.renderAndCollectDependencies()
}
ViewComponent.prototype.initEvents = function () {
  let events = this.$options.parentListeners
  if (!events) return

  let keys = Object.keys(events)
  this.events = {}
  for (let i = 0; i < keys.length; i++) {
    this.events[keys[i]] = events[keys[i]]
  }
}

需要要注意一点,initMethods和initEvents都是处理函数,但是要搞清楚它们处理的函数的性质是不一样的 initMethods是把当前实例化的组件中的methods挂在ViewComponent实例对象上 而initEvents是将父组件传给子组件的回调挂在ViewComponent实例上

简单来说 initMethods处理的是当前组件的方法 initEvents处理的是父组件的方法

又有点啰嗦了

保存了这些events之后,我们就可以在notifyParent中进行对应的触发了:

ViewComponent.prototype.notifyParent = function (eventName, args) {
  if (this.events && this.events[eventName]) {
    this.events[eventName].apply(this.$parent, args)
  }
}

前面一直在说notifyParent是依赖this.$parent的,到底怎么依赖呢?看到上面的代码,大家想必很清楚了

我们一再强调events存的是父组件的回调,所以这里的this.events[eventName]也是父组件的回调,但notifyParent的执行是在子组件的上下文环境中的,所以如果直接这样写:

this.events[eventName](...args)

回调就相当于一个普通函数的执行了(将原来隶属于某个对象的方法重新赋值到其他地方,这个方法里面的this指向就会错误),回调里面的this指向就会出问题,所以我们这里要显式地通过apply使子组件里面的this指向为父组件this.$parent