[vue源码笔记07]vue2.x props的传递和更新

561 阅读4分钟

提出问题

  1. 父组件的值是怎么通过props传递给子组件的
  2. 子组件是如何读取props的
  3. 父组件的数据变更,子组件的props如何更新
  4. 为什么不建议直接在子组件中更新props

实际场景

场景代码如下:

new Vue({
  el: '#app1',
  components: {
    child: {
      props: {
        num: {
          type: Number
        }
      },
      template: `<strong>child: {{ num }}</strong>`
    }
  },
  template: `
    <div>
      <p>father: {{ count }}</p>
      <button @click="handleClick">count+1</button>
      <child :num="count" />
    </div>
    `,
  data() {
    return {
      count: 1
    }
  },
  methods: {
    handleClick() {
      this.count += 1
    }
  }
})

父组件怎么传值给子组件props

根据场景设置,child是一个子组件,接收一个propsnum,父组件把自身datacount传递给child

父组件template将被编译为如下render函数:

function anonymous() {
  with (this) {
    return _c(
      'div',
      [
        _c('p', [_v('father: ' + _s(count))]),
        _v(' '),
        _c('button', { on: { click: handleClick } }, [_v('count+1')]),
        _v(' '),
        _c('child', { attrs: { num: count } }) // 生成子组件child vnode
      ],
      1
    )
  }
}

render函数通过with运算符将作用域绑定为thisthis指向父Vue实例;

对于_c('child', { attrs: { num: count } })中变量count查找实际上是在父Vue实例上面查找,等价于:_c('child', { attrs: { num: 父vm.count } })

执行_c('child')传入一个包含attrs属性的对象求值为:_c('child', { attrs: { num: 1} }),这样就完成了父组件向子组件的传值

子组件是怎么接收props的

执行_c('child')创建子vnode,创建过程主要是执行createComponent(Ctor, data, context, children, tag)

参数解释:

  1. Ctor:组件构造器,这里就是传入的componentOptions对象
  2. data:_c('child')传入的参数:{ attrs: { num: 1} }
  3. context:上下文,这里指向父Vue实例
  4. children:子组件,这里为undefined
  5. tag:组件名,这里是'child'

createComponent源代码:

function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData, // 属性值
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | void {
  // 如果传入参数Ctor为一个对象,则以此对象为基础转为一个构造函数,具体参考全局方法Vue.extend
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

  data = data || {}
  // 重新解析Ctor的options,由于在Ctor构造函数创建的时候对global-options做了一次合并
  // 本次解析避免在Ctor创建后global-options又经历了mixins发生变更
  resolveConstructorOptions(Ctor)

  // 提取props,将父组件实际传入的属性和子组件声明的属性做一个对应
  // 只保留子组件声明过的属性
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)

  // 事件的处理
  const listeners = data.on
  data.on = data.nativeOn

  // 为data.hook添加四个方法
  // init、prepatch、insert、destory
  // 如果data本身有,则合并
  // 如果data本身没有,则添加
  // 这些方法在后续的patch中有用
  mergeHooks(data)
  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
  return vnode
}

其中和子组件接收props相关的代码是20行,方法extractPropsFromVNodeData,来具体看一下:

function extractPropsFromVNodeData (
  data: VNodeData,
  Ctor: Class<Component>,
  tag?: string
): ?Object {
  const propOptions = Ctor.options.props // 这里是定义子组件时声明的props
  const res = {}
  const { attrs, props } = data // 这里是父组件实际传过来的属性,放在attrs中
  if (isDef(attrs) || isDef(props)) {
    for (const key in propOptions) {
      const altKey = hyphenate(key) // 对属性key做一次标准化'xxx-yyy' --> 'xxxYyy'
      // 从父组件实际传入的props中筛选出在子组件中定义过的props
      checkProp(res, props, key, altKey, true) ||
      checkProp(res, attrs, key, altKey, false)
    }
  }
  return res
}
// 筛选过程实际是一个值的复制过程,是一个浅复制
function checkProp (
  res: Object,
  hash: ?Object,
  key: string,
  altKey: string,
  preserve: boolean
): boolean {
  if (isDef(hash)) {
    if (hasOwn(hash, key)) {
      res[key] = hash[key]
      if (!preserve) {
        delete hash[key]
      }
      return true
    } else if (hasOwn(hash, altKey)) {
      res[key] = hash[altKey]
      if (!preserve) {
        delete hash[altKey]
      }
      return true
    }
  }
  return false
}

所以子组件是以其声明的props为模板对父组件实际传入的props进行一次筛选,只接收被预先定义的props,筛选过程是一个浅复制的过程,所以对于基础数据类型的props值来说子组件中如果对某一props进行了修改是不会影响父组件的(当然不推荐这样做),但是引用数据类型的props值就不同了,子组件直接修改该属性值,则会影响父组件

父组件数据变更,子组件props如何更新

要想了解父组件数据变更,子组件props如何更新,就应该首先了解子组件是如何初始化的,前面已经创建了子组件vnode,但是该vnode其实是个空壳,因为子组件的构造器并没有被实例化,但是前面的步骤已经为构造器的实例化准备好了,在实例化前子组件的vnode应该是这样的:

{
	componentInstance: undefined, // 保存的是实例化后的sub-vue实例
	componentOptions: {propsData: {…}, listeners: undefined, tag: "child", children: undefined, Ctor: ƒn},
	context: 父Vue实例,
	data: {attrs: {…}, on: undefined, hook: {…}},
	isComment: false,
	isStatic: false,
	key: undefined,
	parent: undefined,
	tag: "vue-component-1-child"
}

实例化子组件后保存在componentInstance属性,实例化过程和父组件的实例化过程大同小异,这里只关注对于props的处理initProps

/*
vm 指向子Vue实例
propsOptions为声明的props
*/
function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {} // 实际接收到的props值
  const props = vm._props = {}
  const keys = vm.$options._propKeys = [] // 将所有props的属性名保存到数组,便于更新的时候diff运算
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm) // 校验接收到的props值和声明的props类型是否一致
    defineReactive(props, key, value) // 响应化vm._props
    // 对于子组件中显式声明的props,在Vue.extend创建构造器的时候已经进行了代理:将vm[key] --> Sub.prototype._props[key]
    if (!(key in vm)) { // 如果vm不包含key属性,则代理 vm[key] --> vm._props[key]
      proxy(vm, `_props`, key)
    }
  }
}

在实例化子组件构造器中初始化props做了以下事情:

  1. 校验接收到的props值和声明的props类型是否一致
  2. 响应化子vm._props对象
  3. 对于在Vue.extend阶段未显示声明并进行代理的props进行代理vm[key] --> vm._props[key]

子组件的render

function anonymous() {
  with (this) {
    return _c('strong', [_v('child: ' + _s(num))])
  }
}

props经过以上初始化,在子组件的render执行时引用到props num将触发getter进行render的依赖收集,如果num变更将通知render更新(响应化请参考前面文章)

父组件数据更新,结合响应化的知识:父组件的data数据count的订阅者列表中包含父Watcher,当它发生变更则通知父Watcher更新,重新执行一次render(render函数会被缓存不会再次创建):

function anonymous() {
  with (this) {
    return _c(
      'div',
      [
        _c('p', [_v('father: ' + _s(count))]),
        _v(' '),
        _c('button', { on: { click: handleClick } }, [_v('count+1')]),
        _v(' '),
        _c('child', { attrs: { num: count } }) // 生成子组件child vnode
      ],
      1
    )
  }
}

创建vnode的过程一致,在patch阶段将diff比较新旧vnode进行相应更新,其中重点关注新旧子组件child的更新:

prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  const options = vnode.componentOptions // new-child-componentOptions,其中包含新的props值
  const child = vnode.componentInstance = oldVnode.componentInstance // 直接将old-child-componentInstance赋给new-child-componentInstance
  updateChildComponent(
    child,
    options.propsData, // updated props
    options.listeners, // updated listeners
    vnode, // new parent vnode
    options.children // new children
  )
}

updateChildComponent源代码:

export function updateChildComponent(
  vm: Component, // old-vnode
  propsData: ?Object, // updated props
  listeners: ?Object, // updated listeners
  parentVnode: VNode, // updated-vnode
  renderChildren: ?Array<VNode> // new children
) {

  const hasChildren = !!(
    renderChildren ||               // has new static slots
    vm.$options._renderChildren ||  // has old static slots
    parentVnode.data.scopedSlots || // has new scoped slots
    vm.$scopedSlots !== emptyObject // has old scoped slots
  )
  // 所有引用vnode的地方进行更新,更新成新的vnode
  vm.$options._parentVnode = parentVnode
  vm.$vnode = parentVnode
  if (vm._vnode) {
    vm._vnode.parent = parentVnode
  }
  vm.$options._renderChildren = renderChildren
  // 更新传入props以及listeners,使其指向更新后的值
  vm.$attrs = (parentVnode.data && parentVnode.data.attrs) || emptyObject
  vm.$listeners = listeners || emptyObject

  // 更新props,vm.$options.props即为声明的props及其类型等信息
  if (propsData && vm.$options.props) {
    const props = vm._props
    const propKeys = vm.$options._propKeys || [] // 在第一次初始化组件props时将所有props key都保存在了这里
    // 遍历所有props key进行一一更新
    for (let i = 0; i < propKeys.length; i++) {
      const key = propKeys[i]
      props[key] = validateProp(key, vm.$options.props, propsData, vm) // 校验更新的props是否和声明的props类型一致,并进行更新
    }
    vm.$options.propsData = propsData
  }

  // 更新listeners
  if (listeners) {
    const oldListeners = vm.$options._parentListeners
    vm.$options._parentListeners = listeners
    updateComponentListeners(vm, listeners, oldListeners)
  }
  // 解析slots,如果有children,则强制更新
  if (hasChildren) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context)
    vm.$forceUpdate()
  }

  if (process.env.NODE_ENV !== 'production') {
    isUpdatingChildComponent = false
  }
}

更新props的源代码从27行开始,在首次initProps时对子vm._props进行了响应化,此时更新props值将触发对应setter进行变更通知,通知child-render重新执行

经过以上分析可以整理出父组件数据更新,子组件props是如何更新的:

  1. 父组件data变更,通知父render-watcher进行update,即重新执行render
  2. 重新执行render得到更新后的vnode
  3. 在父组件的patch阶段会将新旧vnode进行比较更新
  4. 对于component-vnode diff将调用prepatchupdateChildComponent方法
  5. prepatch方法中直接将旧的componentInstance赋给新vnode.componentInstance属性,所以子组件并不会进行重新实例化
  6. updateChildComponent方法中会遍历声明的props的所有key对每一个prop进行一一更新
  7. 子组件对于props的更新将触发首次初始化props时定义的setter,从而通知子render-watcher更新,触发子render重新执行以达到更新子组件视图的目的

为什么不建议直接在子组件中更新props

因为子组件的props是对父组件数据的一个浅拷贝,如果props值为基础数据类型,直接进行修改并不会影响父组件,但是如果props的值为引用类型直接修改则会影响父组件,通常父组件的数据可能被多个子组件所共享,这样以来在一个子组件中修改可能会影响其他兄弟组件造成预期之外的影响,同时这也违背了props单向数据流的设计初衷;更重要的一点原因是这样会引发结果不一致,即当props值为基础数据类型时直接更新不会引发父组件的更新,但是当为引用类型时却会引发父组件的更新,这将造成混乱。

应该怎么在子组件中修改props呢?

正确的做法应该是通过事件通知父组件更新自己的状态,从而驱动子组件props更新,这样将更新props交给父组件来处理,由父组件全权控制props,因为props来源于父组件,同时更容易控制。