Vue 2.x 源码阅读记录(四):组件更新与 Props

吐槽君 分类:javascript

组件更新

文章为阅读笔记向,需 clone 下来 Vue 源码并加以调试服用~~

通过上面的分析可以知道,Vue 更新的关键点就在于 _render() 函数中对数据的访问触发数据的 getter,在得到更新后的 vnode 时,会执行 _update() 方法重新 patch DOM,_update() 在更新时会进入 prevNode === true 的逻辑,就是下面的 else 分支,传给 __patch__ 的时更新前的 vnode 与新的 vnode

 Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
     const vm: Component = this
     const prevEl = vm.$el
     const prevVnode = vm._vnode  // 更新前的 vnode
     const restoreActiveInstance = setActiveInstance(vm)
     // ...
     vm._vnode = vnode  // 保存更新前的 vnode
     if (!prevVnode) {
         vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
     } else {
         // updates
         vm.$el = vm.__patch__(prevVnode, vnode)
     }
     // ...
 }
 

patch 方法中,有 新旧节点相同 与 新旧节点不同 两种逻辑,通过 sameVnode 方法判断:

if (!isRealElement && sameVnode(oldVnode, vnode)) {
    // 新旧节点相同
} else {
    // 新旧节点不同
} 
 

sameVnode 方法就是将新旧 vnode 中的一些属性进行对比,以及对异步组件的属性进行对比:

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}
 

新旧节点不同

对于新旧节点不同的逻辑,与初次 patch #app 过程相似,分为创建新节点、更新占位符节点、删除旧节点三个过程。这里先看一个新旧节点不同的例子,例子中,Comp 组件内就是新旧节点不同的情况,当调用 changeMsg 的时候,会走到 Comp 组件内部元素的 patch:

const Comp = Vue.extend({
  template: `
    <div class="comp" v-if="msg === 'Hello'">
      <h1>{{msg}} from Comp</h1>
    </div>
    <ul v-else>
      <li>{{msg}}</li>
    </ul>
  `,
  props: {
    msg: String
  },
});

const App = Vue.component('App', {
  template: `
    <div id="my-app">
      <Comp :msg="msg" />
      <h2>{{msg}} from App</h2>
      <button @click="changeMsg">Change msg</button>
    </div>
  `,
  data() {
    return {
      msg: 'Hello',
    };
  },
  methods: {
    changeMsg() {
      this.msg = 'World';
    }
  },
  components: {
    Comp
  }
});

const vm = new Vue({
  el: '#app',
  render: (h) => h(App)
});
 
  • 创建新节点,根据旧 vnode.elm 获取旧的 DOM 节点并获取它的父元素,调用 createElm 进行 DOM 挂载:
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

createElm(
    vnode,
    insertedVnodeQueue,
    // extremely rare edge case: do not insert if old element is in a
    // leaving transition. Only happens when combining transition +
    // keep-alive + HOCs. (#4590)
    oldElm._leaveCb ? null : parentElm,
    nodeOps.nextSibling(oldElm)
)
 
  • 更新占位符节点(上面例子中的 <Comp :msg="msg" /> ),找到当前 vnode.parent 对应的占位符节点,递归地调用 destorycreate 等钩子函数:
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
    let ancestor = vnode.parent
    const patchable = isPatchable(vnode)
    while (ancestor) {
        for (let i = 0; i < cbs.destroy.length; ++i) {
            cbs.destroy[i](ancestor)
        }
        ancestor.elm = vnode.elm
        if (patchable) {
            for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)
            }
            // #6513
            // invoke insert hooks that may have been merged by create hooks.
            // e.g. for directives that uses the "inserted" hook.
            const insert = ancestor.data.hook.insert
            if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                for (let i = 1; i < insert.fns.length; i++) {
                    insert.fns[i]()
                }
            }
        } else {
            registerRef(ancestor)
        }
        ancestor = ancestor.parent
    }
}
 
  • 删除旧节点,其中会将旧的 vnode 与旧的 DOM 节点删除、执行一些销毁节点的钩子函数等:
// destroy old node
if (isDef(parentElm)) {
    removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
    invokeDestroyHook(oldVnode)
}
 

新旧节点相同

这里先留一个问题,为什么用户调用 changeMsg 的时候,修改的只是 App 组件下的数据,理应只对当前 App 组件的 vnode 进行 patch,但通过单步调试发现,它也会将 <Comp :msg="msg" /> 组件内部进行 patch?

<div id="my-app">
    <Comp :msg="msg" />
   	<h2>{{msg}} from App</h2>
	<button @click="changeMsg">Change msg</button>
</div>
 

新旧节点相同的下,会调用 patchVnode 方法,将新的 vnode patch 到旧的 vnode 上,主要逻辑可以拆成四个步骤:

  • 执行组件 prepatch 钩子函数。当 patch 一个组件 vnode 时,会执行 createComponent 时定义的 prepatch 钩子:

    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
        // debugger
        i(oldVnode, vnode)
    }
     

    prepatch 钩子获取组件的 options 和组件实例,调用 updateChildComponent 方法,updateChildComponent 方法会将组件上的 propsslotslisteners 等属性更新:

    prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
        const options = vnode.componentOptions
        const child = vnode.componentInstance = oldVnode.componentInstance
        updateChildComponent(
            child,
            options.propsData, // updated props
            options.listeners, // updated listeners
            vnode, // new parent vnode
            options.children // new children
        )
    }
     
  • 执行 update 钩子函数。 在执行完新的 vnodeprepatch 钩子函数,会执行所有 moduleupdate 钩子函数以及用户自定义 update 钩子函数,对于 module 的钩子函数,之后会有具体的章节针对一些具体的 case 分析:

    if (isDef(data) && isPatchable(vnode)) {
        for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
        if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
     
  • 完成 patch 过程。如果 vnode 是一个文本节点则直接将替换元素的文本内容,不是文本节点的时候,分为几种情况:

    1. 新旧节点都有 children 子节点并且不相等的情况,调用 updateChildren patch 所有子节点。
    2. 只有新节点有 children, 表示旧节点不需要了 。如果旧节点是文本节点,将其文本置空,调用 addVnodes 将新节点 children 添加到 elm 中。addVnodes 方法中循环调用了 createElm 方法 将 vnode patch 到 DOM。
    3. 只有旧节点有 children,表示更新的是空节点(children 为空或 undefined 的情况,也不会是文本节点,因为第一个判断已经处理了),则需要将旧的节点通过 removeVnodes 全部清除。
    4. 旧节点是文本节点,清除其文本内容。
    if (isUndef(vnode.text)) {
        if (isDef(oldCh) && isDef(ch)) {
            // 1
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
        } else if (isDef(ch)) {
            // 2
            if (process.env.NODE_ENV !== 'production') {
                checkDuplicateKeys(ch)
            }
            if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        } else if (isDef(oldCh)) {
            // 3
            removeVnodes(oldCh, 0, oldCh.length - 1)
        } else if (isDef(oldVnode.text)) {
            nodeOps.setTextContent(elm, '')
        }
    } else if (oldVnode.text !== vnode.text) {
        nodeOps.setTextContent(elm, vnode.text)
    }
     
  • 执行 postpatch 方法。它是组件自定义的钩子函数,有则执行:

    if (isDef(data)) {
        if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
     

解答新旧节点相同疑问

  • 首先需要确定一个情况,当用户调用 changeMsg 时新生成的 vnode 直接是 App 组件的根元素对应的 vnode,而不是 App 组件的占位符 vnode,因为在初始化 patch 时已经将旧的 options.el 模板删除了,App 组件的根元素就是全局的根元素。

  • 通过上面的例子和分析可以知道,App 组件进入了相同 vnode 的 patch 过程,而 App 的根元素此时不是一个组件,则直接走到上面完成 patch 过程,此时 Appchildren 下有 5 个 vnode 节点,然后回进入 updateChildren 分支。

  • updateChildren 函数内会再次调用 patchVnode 方法(后面会分析),由于 App.children 的第一位是 <Comp /> 的占位符 vnode ,这时就进入了它的 patchVnode 过程。

  • 此时的 vnode 是组件占位符 vnode ,就会执行到上面的执行组件 prepatch 钩子函数流程,prepatch 函数调用了 updateChildComponent ,在 updateChildComponent 中有一段更新 props 的逻辑:

    // update props
    if (propsData && vm.$options.props) {
        toggleObserving(false);
        var props = vm._props;
        var propKeys = vm.$options._propKeys || [];
        for (var i = 0; i < propKeys.length; i++) {
            var key = propKeys[i];
            var propOptions = vm.$options.props; // wtf flow?
            // 更新组件的 props
            props[key] = validateProp(key, propOptions, propsData, vm);
        }
        toggleObserving(true);
        // keep a copy of raw propsData
        vm.$options.propsData = propsData;
    }
     

    更新 props 时,实际上会触发 props 属性的 setter。根据之前的分析知道的,组件收集的是组件执行 mountComponent 时订阅的渲染 Watcher ,派发更新过程会将 Watcher.getter 的执行推入异步队列。再回到 updateChildComponent ,执行完后会接着将 App 剩下的子节点都 patch 完,页面渲染完毕:

    set: function reactiveSetter (newVal) {
        // debugger
        const value = getter ? getter.call(obj) : val
        /* eslint-disable no-self-compare */
        if (newVal === value || (newVal !== newVal && value !== value)) {
            return
        }
        /* eslint-enable no-self-compare */
        if (process.env.NODE_ENV !== 'production' && customSetter) {
            customSetter()
        }
        // #7981: for accessor properties without setter
        if (getter && !setter) return
        if (setter) {
            setter.call(obj, newVal)
        } else {
            val = newVal
        }
        childOb = !shallow && observe(newVal)
        // notify 之前,已经将 data[val] 更新了
        dep.notify()
    }
     

    页面渲染完毕就是同步任务执行完毕了,此时会开始执行异步队列中保存的 Watcher.getter ,而此时就会开始进入进入 <Comp /> 组件的 patch 过程了。

updateChildren

​ 前面提到新旧节点都有 children 子节点并且不相等的情况,调用 updateChildren patch 所有子节点。这其实是一个递归的过程,这里通过一个例子来分析:

<div id="app">
    <ul>
    	<li v-for="item in list" :key="item.value">{{item.text}}</li>
    </ul>
    <button @click="reverseList">Reverse</button>
</div>

new Vue({
  el: '#app',
  data: {
    list: [{
      text: 'A',
      value: '1'
    }, {
      text: 'B',
      value: '2'
    }, {
      text: 'C',
      value: '3'
    }, {
      text: 'D',
      value: '4'
    }]
  },
  methods: {
    reverseList() {
      this.list = this.list.reverse();
    }
  }
});
 

当 patch 到 ulvnode 时,会进入 updateChildren 流程。updateChildren 的流程有点复杂,这里就结合上面例子大致理解一下。首先定义了一些遍历来作为当前 child vnode 的索引与新旧的头尾 vnode,然后开启一个循环,对比 vnode 的各种相等条件,然后继续调用 patchVnodeaddVnodescreateElmremoveVnodes 完成对 vnodepatch 过程。递归调用完成 li vnode 以及其内部的 text vnode 的 patch 之后,则回到 ul vnode 的 patch 过程继续往下走:

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    debugger
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        debugger
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }
 

针对例子的 patch 流程图可以参考文档:ustbhuangyi.github.io/vue-analysi…

总结

组件更新就是对其新旧 vnode 的 patch 过程,也可以称之为 diff。又分为新旧节点相同与不同的情况:

  • 新旧节点相同:创建新的节点 (createElm)--> 更新占位符节点(如果有) --> 删除旧节点(removeVnodes)
  • 新旧节点不同:组件执行更新的钩子函数(prepatchupdate) --> 更新所有 children(updateChildren

Props

规范化

在组件初始化 mergeOptions 的过程中,会对用户定义的 props 选项进行规范化。它在 mergeOptions 中调用了 normalizeProps 方法。props 支持数组与对象写法,数组时,会将其每个值作为 reskey 存入,并默认 type: null;对象时,将对象直接存入 res

function normalizeProps (options: Object, vm: ?Component) {
  const props = options.props
  if (!props) return
  const res = {}
  let i, val, name
  if (Array.isArray(props)) {
    i = props.length
    while (i--) {
      val = props[i]
      if (typeof val === 'string') {
        // 转驼峰命名
        name = camelize(val)
        // 添加到 res
        res[name] = { type: null }
      } else if (process.env.NODE_ENV !== 'production') {
        warn('props must be strings when using array syntax.')
      }
    }
  } else if (isPlainObject(props)) {
    for (const key in props) {
      val = props[key]
      name = camelize(key)
      // msg: String --> msg: { type: String }
      res[name] = isPlainObject(val)
        ? val
        : { type: val }
    }
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `Invalid value for option "props": expected an Array or an Object, ` +
      `but got ${toRawType(props)}.`,
      vm
    )
  }
  options.props = res
}
 

下面是一个例子:

// 数组
props: ['msg']
// 转换成
props: {
    msg: {
        type: null
    }
}

// 对象
props: {
    msg: String,
    msg: {
        type: String
    }
}
// 统一转换成
props: {
    msg: {
        type: String
    }
}
 

初始化

在初始化的 initState 中,会调用 initProps 进行初始化。初始化存在三个步骤,遍历地为每个 prop 属性执行:

  1. 调用 validateProp 拿到父组件传入的 prop 对应的值。

  2. 调用 defineReactiveprop 值添加 gettersetter 将其变成响应式。注意在调用时传入了一个自定义 setter,组件内部修改 props 时会报错。

  3. 调用 proxy 将其 this 访问代理到 _props。

    function initProps (vm, propsOptions) {
        debugger
        var propsData = vm.$options.propsData || {};
        var props = vm._props = {};
        // cache prop keys so that future props updates can iterate using Array
        // instead of dynamic object key enumeration.
        var keys = vm.$options._propKeys = [];
        var isRoot = !vm.$parent;
        // root instance props should be converted
        if (!isRoot) {
          toggleObserving(false);
        }
        var loop = function ( key ) {
          keys.push(key);
          var value = validateProp(key, propsOptions, propsData, vm);
          /* istanbul ignore else */
          {
            // 将 key 转成小写
            var hyphenatedKey = hyphenate(key);
            if (isReservedAttribute(hyphenatedKey) ||
                config.isReservedAttr(hyphenatedKey)) {
              // 如果为保留属性则报出警告
              warn(
                ("\"" + hyphenatedKey + "\" is a reserved attribute and cannot be used as component prop."),
                vm
              );
            }
            defineReactive(props, key, value, function () {
              if (!isRoot && !isUpdatingChildComponent) {
                warn(
                  "Avoid mutating a prop directly since the value will be " +
                  "overwritten whenever the parent component re-renders. " +
                  "Instead, use a data or computed property based on the prop's " +
                  "value. Prop being mutated: \"" + key + "\"",
                  vm
                );
              }
            });
          }
          // static props are already proxied on the component's prototype
          // during Vue.extend(). We only need to proxy props defined at
          // instantiation here.
          if (!(key in vm)) {
            proxy(vm, "_props", key);
          }
        };
    
        for (var key in propsOptions) loop( key );
        toggleObserving(true);
      }
     

    对于组件的 props ,会在 extend 生成组件构造函数时执行 props 代理,这样在实例初始化 initProps 就不会再为 props 执行 proxy。这样做的好处是不用在每个组件实例创建时执行代理,因为每次执行代理都会触发响应式数据的 getter

    // For props and computed properties, we define the proxy getters on
    // the Vue instances at extension time, on the extended prototype. This
    // avoids Object.defineProperty calls for each instance created.
    if (Sub.options.props) {
        initProps(Sub)
    }
    
    function initProps (Comp) {
      const props = Comp.options.props
      for (const key in props) {
        proxy(Comp.prototype, `_props`, key)
      }
    }
     

子组件更新

对于 props 的更改会使子组件重新渲染这块逻辑,在之前组件更新的 updateChildren 提到过。props 内部还有一些对响应式的优化,这个以后再回过来看。

回复

我来回复
  • 暂无回复内容