CommanderXL / Biu-blog

个人博客
431 stars 39 forks source link

Vue 2.0 父子组件通讯 #17

Open CommanderXL opened 5 years ago

CommanderXL commented 5 years ago

父子组件通讯

Vue中,父子组件基本的通讯方式就是父组件通过props属性将数据传递给子组件,这种数据的流向是单向的,当父props属性发生了改变,子组件所接收到的对应的属性值也会发生改变,但是反过来却不是这样的。子组件通过event自定义事件的触发来通知父组件自身内部所发生的变化。

Vue props 是如何传递以及父 props 更新如何使得子模板视图更新

还是从一个实例出发:

// 模板
<div id="app">
  <child-component :message="val"></child-component>
</div>

// js

Vue.component('child-component', {
  props: ['message']
  template: '<div>this is child component, I have {{message}}</div>',
})

new Vue({
  el: '#app',
  data() {
    return {
      val: 'parent val'
    }
  },
  mounted () {
    setTimeout(() => {
      this.val = 'parent val which has been changed after 2s'
    }, 2000)
  }
})

最终页面渲染出的内容为:

this is child component, I have parent val

2s后文案变更为:
this child component, I hava parent val which has been changed after 2s

接下来我们就来看下父子组件是如何通过props属性来完成数据的传递的。

首先根组件开始实例化,完成一系列的初始化的内容。首先将val转化为响应式的数据,并调用Vue.prototype.$mount方法完成vnode的生成,真实dom元素的挂载等功能:

Vue.prototype._init = function (options) {
  ...
  initState(vm)

  ...
  if (vm.$options.el) {
    vm.$mount(vm.$options.el);
  }
  ...
}

Vue.prototype.$mount方法内部:

Vue.prototype.$mount = function (
  el,
  hydrating
) {
  el = el && inBrowser ? query(el) : undefined;
  return mountComponent(this, el, hydrating)
};

var mount = Vue.prototype.$mount;
Vue.prototype.$mount = function (
  el,
  hydrating
) {
  el = el && query(el);

  var options = this.$options;
  // resolve template/el and convert to render function
  if (!options.render) {
    var template = options.template;
    if (template) {
      ...
    } else if (el) {
      template = getOuterHTML(el);
    }
    if (template) {
      ...
      var ref = compileToFunctions(template, {
        shouldDecodeNewlines: shouldDecodeNewlines,
        shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this);
      // 生成render函数
      var render = ref.render;
      var staticRenderFns = ref.staticRenderFns;
      options.render = render;
      options.staticRenderFns = staticRenderFns;
    }
  }
  return mount.call(this, el, hydrating)
};

function mountComponent (
  vm,
  el,
  hydrating
) {
  vm.$el = el;
  if (!vm.$options.render) {
    ...
  }
  // 挂载前
  callHook(vm, 'beforeMount');

  var updateComponent;
  /* istanbul ignore if */
  if ("development" !== 'production' && config.performance && mark) {
    ...
  } else {
    updateComponent = function () {
      vm._update(vm._render(), hydrating);
    };
  }

  new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */);
  hydrating = false;

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true;
    callHook(vm, 'mounted');
  }
  return vm
}

完成模板的编译,同时生成render函数,这个render函数在实际实行生成vnode时,会将作用域绑定到对应的vm实例的作用域下,即在创建vnode的环节当中,始终访问的是当前这个vm实例,子vnode创建时是没法直接访问到父组件中定义的数据的。除非通过props属性来完成数据由父组件向子组件的传递。

;(function() {
  with (this) {
    return _c(
      'div',
      {
        attrs: {
          id: 'app'
        }
      },
      [
        _c('my-component', {
          attrs: {
            message: val
          }
        })
      ],
      1
    )
  }
})

完成模板的编译生成render函数后,调用_c方法,对应访问vue实例的_c方法,开始创建对应的vnode,注意这里val变量,即vue实例上data属性定义的val,在创建对应的vnode前,实例已经调用initState方法将val转化为响应式的数据。因此在创建vnode过程中,访问val即访问它的getter

在访问过程中Dep.target已经被设定为当前vue实例的watcher(具体见mountComponent方法内部创建watcher对象),因此会将当前的watcher加入到valdep当中。这样便完成了val的依赖收集的工作。

在创建VNode时,又分为:

其中内置标签的VNode的没有需要特别说明的地方,就是调用VNode的构造函数完成创建过程。

但是在创建自定义标签元素的VNode时,完成一些重要的操作(因为本文是讲解 props 传递,所以挑出和 props 相关的部分):

function createComponent () {

  ...
  // 注意这个方法。它完成了从父组件对应的props字段获取值的作用,具体到本例子,就是获取到了message字段的值
  // 这样就完成了props从父组件传递到子组件的功能
  var propsData = extractPropsFromVNodeData(data, Ctor, tag);
  ...

  // 给component初始化挂载钩子函数,在VNode实例化成vue component会调相关的钩子函数
  installComponentHooks(data);
  ...
  // 创建VNode
  var vnode = new VNode(
    ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
    data, undefined, undefined, undefined, context,
    { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
    asyncFactory
  );
  return vnode
}

createComponent创建VNode的过程中需要注意的是:Vue的父子组件传递props属性的时候都是在子组件上直接写自定义的dom attrs

<div id="app">
  <child-component :message="val"></child-component>
</div>

但是在模板编译后,统一将dom节点(不管是built in的节点还是自定义component节点)上的属性转化为attrs对象(见代码片段 111),在创建VNode过程中调用了extractPropsFromVNodeData这个方法完成从attrs对象上获取到这个component所需要的props属性,获取完成后还会将attrs对象上对应的key值删除。因此这个key值对应的是要传入子component的数据,而非原生dom属性,最终由VNode生成真实dom的时候是不需要这些自定义数据的,因此需要删除。

当然如果你在子组件中传入了props数据,但是在子组件中没有定义相关的props属性,那么这个 props 属性最终会渲染到子组件的真实的dom元素上,不过控制台也会出现报错:

> [Vue warn]: Property or method "message" is not defined on the instance but referenced during render. Make sure that this property is reactive, either in the data option, or for class-based components, by initializing the property

当完成了my-componentVNode创建后,开始创建它的父VNode,即根VNode

(function() {
  with (this) {
    return _c('div', {
        attrs: {
          "id": "app"
        }
      }, <my-component-vnode>, 1)
   }
  }
)

vm._render()方法调用完成后,即所有的VNode都创建完成,开始递归将VNode渲染成真实的dom节点,同时挂载到document当中(见上方调用的mountComponent内部vm.update(vm._render()))。

Vue.prototype._update = function (vnode, hydrating) {
    var vm = this;
    if (vm._isMounted) {
      // 触发beforeUpdate钩子函数
      callHook(vm, 'beforeUpdate');
    }
    var prevEl = vm.$el;
    var prevVnode = vm._vnode;
    var prevActiveInstance = activeInstance;
    activeInstance = vm;
    vm._vnode = vnode;
    // 第一次渲染
    if (!prevVnode) {
      // initial render
      // 初始化render
      vm.$el = vm.__patch__(
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      );
      ...
    } else {
      // updates
      // 将prevVnode和vnode进行patch操作并更新
      vm.$el = vm.__patch__(prevVnode, vnode);
    }
    ...
  }

在将VNode递归渲染成真实的dom节点过程当中:

对于自定义标签元素(即组件)的渲染,首先完成组件vue实例的初始化。又重复到上文一开始的Vue.prototype._init方法。在实例化my-component组件的过程中,还是通过调用initState方法,将定义的props属性中的message属性转化为响应式的数据。 在此之前,my-component组件上的message属性已经被初始化为从父组件传递过来的值。因此在页面初次渲染的时候,my-component通过定义的props属性从父组件上获取到的值为parent val(上面的例子中定义的)

这样便完成了父组件通过props属性向子组件传递数据。

父 props 的改变是如何影响到子 component 的视图的更新

在子组件生成 VNode 的过程中会对应创建 render watcher,通过 props 从父组件传递给子组件的数据是在父作用域下获取得到的。因此,props 的 Dep 中会将这个 watcher 作为依赖添加进去。那么当父组件中的数据发生了改变,便会调用这个响应式数据Dep.notify()方法去通知相关的订阅者去完成更新,其中就包括子组件的 render watcher。

Vue 父子组件如何传递/绑定自定义事件的

那么在子组件需要和父组件进行通讯的时候,所使用的events事件又是如何实现的呢?

// 模板
<div id="app">
  <child-component @foo="bar"></child-component>
</div>

// js
Vue.component('child-component', {
  props: ['message']
  template: '<div @click="foo">this is child component, I have {{message}}</div>',
  methods: {
    foo () {
      this.$emit('foo', 'this is child component')
    }
  }
})

new Vue({
  el: '#app',
  data() {
    return {
      val: 'parent val'
    }
  },
  methods: {
    foo (val) {
      console.log(val)
    }
  }
})

当点击<child-component>时,会在控制台输出this is child component。那我们来看下整个过程是如何进行的:

首先在模板编译的过程:

;(function() {
  with (this) {
    return _c(
      'div',
      {
        attrs: {
          id: 'app'
        }
      },
      [
        _c('my-component', {
          staticClass: 'my-component',
          attrs: {
            message: val
          },
          on: {
            test: test
          }
        })
      ],
      1
    )
  }
})

在创建my-componentcomponent VNode过程中,通过传入data数据上定义的on属性。这个时候test访问的还是在父组件上定义的test方法。

//
function createComponent (
  Ctor,
  data,
  context,
  children,
  tag
) {
  ...
  // extract listeners, since these needs to be treated as
  // child component listeners instead of DOM listeners
  // 获取父 component 作用域中定义的 listeners。这个 listeners 会作为 componentOptions 的属性传递进 VNode 当中
  // 注意这个地方和 DOM listeners 的区别。DOM listeners是使用的浏览器原生的事件系统
  var listeners = data.on;
  ...
  // install component management hooks onto the placeholder node
  installComponentHooks(data);

  // return a placeholder vnode
  var name = Ctor.options.name || tag;
  // 将从父 component 作用作用域定义的 listeners 作为 VNode 的 componentOptions 传入 VNode 的构造函数内部
  var vnode = new VNode(
    ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
    data, undefined, undefined, undefined, context,
    { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
    asyncFactory
  );

  /* istanbul ignore if */
  return vnode
}

接下来在将这个VNode实例成vue component的时候:

  Vue.prototype._init = function (options) {
    var vm = this;

    // 实例化子vue component
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      // 从VNode的componentOptions属性上获取关于这个vue component定义的属性
      initInternalComponent(vm, options);
    } else {
      ...
    }
    // expose real self
    vm._self = vm;
    initLifecycle(vm);
    // 初始化vm的上绑定的自定义事件
    initEvents(vm);
    ...
  };

  // 实例化vue component自定义事件
  function initEvents (vm) {
    vm._events = Object.create(null);
    vm._hasHookEvent = false;
    // init parent attached events
    // 获取从父组件上传递过来的自定义事件
    var listeners = vm.$options._parentListeners;
    if (listeners) {
      updateComponentListeners(vm, listeners);
    }
  }

  function updateComponentListeners (
    vm,
    listeners,
    oldListeners
  ) {
    // 设置全局target对象
    target = vm;
    updateListeners(listeners, oldListeners || {}, add, remove$1, vm);
    target = undefined;
  }

  // 给全局target对象挂载自定义事件
  function add (event, fn, once) {
    if (once) {
      target.$once(event, fn);
    } else {
      target.$on(event, fn);
    }
  }

在将VNode实例化过程当中,调用initEvents方法,获取在这个VNode上绑定的从父组件传递下来的方法,并缓存至对应事件的回调函数数组当中。当你在子组件当中去$emit对应的事件的时候,便会执行对应的回调函数。这里父子间的event事件机制实际上是利用了发布订阅的设计模式。

这个是有关父子组件自定义事件的机制。这里也顺带讲下 Vue 是如何绑定原生 DOM 事件的。

在代码片段 xxx 当中,生成 VNode 的环节当中,会将 nativeOn 赋值给data.on(data 上保存了将 VNode 渲染成真实 DOM 节点的数据)。当开始渲染真实 DOM 元素的时候:

function createElm () {
  ...
  if (isDef(data)) {
    invokeCreateHooks(vnode, insertedVnodeQueue)
  }
  ...
}

function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (var i$1 = 0; i$1 < cbs.create.length; ++i$1) {
    cbs.create[i$1](emptyNode, vnode);
  }
  i = vnode.data.hook; // Reuse variable
  if (isDef(i)) {
    if (isDef(i.create)) { i.create(emptyNode, vnode); }
    if (isDef(i.insert)) {
      insertedVnodeQueue.push(vnode);
    }
  }
}

当 data 有值的时候,那么就开始执行 DOM 相关属性更新的工作。即执行在 cbs 上有关 create 阶段所有的回调函数,其中包括:

var platformModules = [
  attrs, // attrs 属性
  klass, // class 
  events, // 原生 dom 事件
  domProps,
  style,
  transition
]

其中我们来看下有关 events,即原生 dom 事件是如何绑定到 DOM 元素上的。

var events = {
  create: updateDOMListeners,
  update: updateDOMListeners
}

function updateDOMListeners (oldVnode, vnode) {
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  var on = vnode.data.on || {};
  var oldOn = oldVnode.data.on || {};
  target$1 = vnode.elm; // 真实的 dom 节点
  normalizeEvents(on);
  updateListeners(on, oldOn, add$1, remove$2, vnode.context);
  target$1 = undefined;
}

function add$1 (
  event,
  handler,
  once$$1,
  capture,
  passive
) {
  handler = withMacroTask(handler); // 强制放到 marcoTask 当中去执行
  if (once$$1) { handler = createOnceHandler(handler, event, capture); }
  target$1.addEventListener(
    event,
    handler,
    supportsPassive
      ? { capture: capture, passive: passive }
      : capture
  );
}

function remove$2 (
  event,
  handler,
  capture,
  _target
) {
  (_target || target$1).removeEventListener(
    event,
    handler._withTask || handler,
    capture
  );
}

在初次渲染DOM节点的时候,传入的 oldVNode 为一个空的 VNode,即拿这个空的 VNode 和即将要渲染的 VNode 进行原生DOM事件的 diff 工作。在updateDOMListeners方法当中还是继续调用updateListeners方法去进行事件的绑定,这个时候绑定事件的函数使用的是add$1,即调用DOM提供的addEventListener方法去完成原生DOM事件的绑定工作。在这里我们也可以看出去 Vue 提供的事件修饰符在这里进行配置生效。这样便完成了原生的DOM事件的绑定。

.sync修饰符-数据双向绑定

在 2.3.0+ 版本,Vue 提供了一种可以对 props 进行数据双向绑定的语法糖。基本的使用方法为:

事实上是 Vue 在将模板编译成渲染函数时,会将带有.sync标识符的 props 自动添加一个自定义的事件update:message事件

{
  ...
  on: {
    'update:message': function ($event) {
      message = $event
    }
  }
  ...
}

那么当你在子组件当中去调用update:message方法的时候,并传入值的时候即会更新 message 的值。这个 message 的值即在父组件当中的数据。这样便完成了数据的双向绑定。

// vm._update(vm._render()) // patch // prepatch 方法 // updateChildComponent 完成 props 等属性的 setter 操作 _props是在实例初始化过程中定义的一个内部属性,同时调用defineReactive方法完成将响应式数据存放到_props属性上。在VNodepatch过程中,如果有属性发生了变化,那么会调用这个属性的setter方法完成值的变更操作,继而完成视图的更新。当然了,如果组件在定义的过程,没有定义props属性,那么在实例初始化的过程中,_props属性也不会被创建。只有组件上定义过props属性,在初始化的过程中才会定义这个内部属性。