如何实现一个前端框架5-组件化之注意事项

205 阅读9分钟

本节分为两部分:
第一部分不会新增代码,主要讨论一下组件的数据流问题
第二部分解决一些遗留下来的bug以及实现mounted的hook

首先来看第一部分

无论是React、还是Vue,作者们都告诫我们,不要直接修改子组件的props,而是要通过触发回调通知父组件,然后让父组件更改对应的数据,进而引发父组件update的执行,更新子组件

但是,为什么要这样做?换句话说,为什么必须得是单向数据流才行呢?我相信有的朋友一定会想:我曾经改过组件的props,看起来似乎也没问题啊,而且很方便很好用,为啥非得费那个劲搞个事件通知父组件呢?

我们继续沿用前两节一直用的my-alert,来讨论下为何不能直接修改props,不过my-alert的props,我们就需要更改一下了(其实是改为另一种实现方式)

我们之前是在父组件App的render函数内,通过判断alertVisible的值,来控制my-alert组件是否渲染的,其实在很多组件库中还有另外一种方式来控制组件是否渲染: App的render:

  render: function () {
    let children = [
      this.createElement('button', {
        class: 'click-show',
        on: {
          click: this.showAlert
        }
      }, '点击展示')
    ]
    let o = this.createElement('my-alert', { 
      props: {
        content: this.alertInfo,
        visible: this.alertVisible
      },
      on: {
        close: this.hideAlert
      }
    })
    if (o) {
      children.push(o)
    }
    return this.createElement('div', { class: 'alert-wrap' }, children)
  }

和上一节中App的render不同,我们这次通过一个visible属性传给my-alert组件,期望根据传进去的这个visible(也就是data上的alertVisible)来控制渲染的结果,当alertVisible为true时,返回my-alert渲染完之后的DOM,当alertVisible为false时,返回null,并将结果存在变量o中,这样根据返回值o判断是否将my-alert push到children中,进而就决定了它是否渲染

my-alert组件中,我们的定义如下:

let MyAlert = {
  props: {
    content: {
      default: ''
    },
    visible: {
      default: false
    },
  },
  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.visible ? this.createElement('div', { class: 'alert' }, children) : null
  }
}

可以看到,我们在props中新增了visible属性,将父组件传过来的值接管过来,render中也做了对应的修改

不过,最重要的是,在methods.close中,可以发现,我们依然是通过 this.notifyParent('close')来通知父级修改alertVisible,然后alertVisible的修改会触发父组件的render,将正确结果渲染出来

而最大的疑问也正是在这里,为什么这里不能直接通过this.visible = false来实现呢?

我们可以分析一下this.visible = false执行的过程
由于props在实例化的时候也通过initProps -> setDataReactive做了响应式处理,因此给它赋值也会触发子组件的update,进而触发render得到null,进而就让my-alert消失了,一切看起来似乎没有问题

不过要注意,通过上面流程的分析,不知道各位是否有感觉到,整个流程完全是子组件内部数据在改动,改动了之后子组件自己内部渲染,和它的父组件没有什么关系

但不要忘了,父组件中有一个值是决定my-alert是否渲染的,这个值就是而这就是传给子组件的this.alertVisible,直接修改子组件的值,父组件是不会有任何的感知的

在我们这个例子中,虽然我们将my-alert中this.visible的值改成了false,但父组件中this.alertVisible依然是true

这就是问题的关键,我们的页面是从最顶层的组件,到组件树下面的各级子组件一层层渲染的,这一次改完子组件的props渲染之后看起来没问题,但如果之后在某个事件回调中,又改了父组件的某个值,触发了一次父组件的渲染,父组件这次渲染就会将这次错误的alertVisible传给子组件,已经消失的子组件就又会重新被渲染出来

上面这段话如果不理解也没有关系,我们接下来用案例来说明问题,我们把这个my-alert的例子再改一下,加一个功能

添加一个按钮,点击按钮可以修改my-alert左边的提示信息,大概就像下面这样:

父组件的render就变成了这样:

  render: function () {
    let children = [
      this.createElement('button', {
        class: 'click-show',
        on: {
          click: this.showAlert
        }
      }, '点击展示'),
      this.createElement('button', {
        class: 'click-show',
        on: {
          click: this.changeAlertInfo
        }
      }, '点击更改alert中提示信息')
    ]
    let o = this.createElement('my-alert', { 
      props: {
        content: this.alertInfo,
        visible: this.alertVisible
      },
      on: {
        close: this.hideAlert
      }
    })
    if (o) {
      children.push(o)
    }
    return this.createElement('div', { class: 'alert-wrap' }, children)
  }

可以看到在新加的按钮上,我们绑了一个事件changeAlertInfo,事件里面做的事情很简单,就是改变alertInfo的值

my-alert子组件中,我们把隐藏的回调事件改成:

  methods: {
    close: function () {
      // this.notifyParent('close')
      this.visible = false
    }
  },

接下来我们做下面的操作:

  1. 页面刚渲染完时,只有两个按钮
  2. 点击“点击展示”按钮,my-alert被渲染出来
  3. 点击“关闭”按钮,my-alert消失
  4. 点击“点击更改alert中提示信息”,应该是什么样的效果呢?

如果没什么意外的话,第4步完成后,应该不会渲染出my-alert的,也就是说,页面上应该没有任何反应,如果接下来再点“点击展示”的时候,才会展示出带有新的内容的my-alert

因为在第4步中,我改的是this.alertInfo,而不是this.alertVisible,只有当this.alertVisible变成true时,my-alert才应该被渲染出来

但实际操作之后,我们发现,结果并不是如我们所想的那样,在第4步中,点了“点击更改alert中提示信息”之后,my-alert被渲染出来了,而且带着我们改过之后的this.alertInfo

为什么会是这样的结果呢?

我们按照步骤来分析一下

  1. 页面第一次渲染完后,App中的alertVisible是false,my-alert中的visible也是false
  2. 点击“点击展示”按钮,将alertVisible值改为true,触发App的update,将my-alert渲染出来,子组件中visible也是true
  3. 点击“关闭”按钮,将my-alert中的visible改为false,但注意,此时App中的alertVisible依然是true
  4. 点击“点击更改alert中提示信息”,App中的this.alertInfo被改为新的内容,触发App组件的update -> render方法,再次强调,此时alertVisible的值为true,所以my-alert被渲染出来了,alertInfo的内容也被改成新的了

说了这么多,其实就是想要表达一个意思:子组件中通过props渲染出来的状态,其实最终还是由父组件来决定的,子组件的props仅仅是做了一次数据传递,而如果擅自修改props,就会造成父子组件对于该状态记录的值的不同步,那么在之后父组件再渲染子组件时,就一定会拿着旧的数据渲染,导致错误

第一部分到此结束

完整代码

接下来我们来看我们现有代码中的一个bug,在初次渲染、收集依赖的方法renderAndCollectDependencies中,我们把当前正在渲染的组件update方法赋给了变量curExecUpdate

等初次渲染结束、依赖收集完后,又将curExecUpdate置为null,下一个组件实例化时会给curExecUpdate这个全局变量赋这个组件的update

不过这么做有个问题,问题在于有多层组件的情况curExecUpdate会被覆盖

什么意思呢?

我们可以先看一个组件执行的流程:

请大家注意红色部分curExecUpdate的变化:

一个组件里面可能会有多个元素,所以从图中我们可以看出,一个组件在render的时候是可能会多次调用createElement来创建DOM对象的,而在每次调用createElement就可能会读取data上的数据,读取的时候就会触发该数据的get钩子,将curExecUpdate放到属于它自己的updateFns数组中

如果在createElement中又创建了新的组件,则会变成:

请注意图中黑色的框,它代表一个子组件的执行过程

从上面的执行流程,我们可以看到,父组件在创建元素过程中,可能遇到自定义的标签,然后创建子组件,子组件建完之后后面可能还有createElement继续创建父组件下的DOM,那么问题就来了

curExecUpdate是一个全局变量,子组件创建完之后会将其置为null,此时再回到父组件中调用createElement创建DOM的时候,如果用到了data上的数据,这时触发get钩子将一个curExecUpdate已经是null的值收集到updateFns数组里面,有什么意义呢?

所以,我在图中标明,那个createElement收集不到依赖

之前之所以没有暴露出这个问题,原因主要是我们并没有在子组件创建完之后再在创建父组件中创建其他一些DOM

那么解决这个bug的方法,就要用到栈,给update方法设置一个栈:

let updateStack = []
function pushCurExecUpdateToStack (fn) {
  updateStack.push(fn)
  curExecUpdate = fn
}

function popCurExecUpdateFromStack () {
  updateStack.pop()
  curExecUpdate = updateStack[updateStack.length - 1]
}

每初始化一个组件,执行到它的renderAndCollectDependencies中时,就把this.update push到栈中,this.update执行完之后,再将其pop出来

如果有子组件嵌套,子组件的this.update pop出来之后,栈顶就是父组件的this.update了

ViewComponent.prototype.renderAndCollectDependencies = function () {
  this.update = this.update.bind(this)
  pushCurExecUpdateToStack(this.update)
  this.update()
  popCurExecUpdateFromStack()
}

除此之外,为了之后方便起见,我们还有一个小功能要加上,这个功能就是mounted钩子,即组件DOM创建完之后的钩子,在Vue中,这个钩子支持数组、函数等若干种形式,我们这里只做简单处理即可

ViewComponent.prototype.renderAndCollectDependencies = function () {
  this.update = this.update.bind(this)
  pushCurExecUpdateToStack(this.update)
  this.update()
  popCurExecUpdateFromStack()
  let fn = this.$options.mounted
  if (fn && isFunction(fn)) {
    fn.call(this)
  }
}

这个mounted钩子,我们通常会这么用: 在mounted初始化一些参数或标识变量,那么这样一来就会涉及到访问methods、访问data中的数据,也就是对data中的数据做赋值操作,从而触发该数据的set钩子,从而将updateFns中的方法(其实就是组件的update)取出来挨个遍历执行

注意,这次是this.update的第2次执行

this.update的第一次执行时什么时候呢?

第一次就是初始化时收集依赖的那一次

Vue中还给了created钩子,在created钩子中给data里的数据赋值,是不会触发组件render的,这个时候DOM还没渲染出来

完整代码