v-model在组件中的双向绑定原理分析

201 阅读1分钟

高频面试题:v-model?

答案:v-model是语法糖。
组件的渲染都大底会经历通过编译进行render函数的获取、虚拟DOM的获取和视图渲染过程这三个主要流程。

// main.js
const baseCheckbox = {
  template: `<input type="checkbox" v-bind:checked="checked" v-on:change="$emit('change', $event.target.checked)">`,
  model: {
    prop: "checked",
    event: "change"
  },
  props: {
    checked: Boolean
  }
};

new Vue({
  el: "#app",
  template: `<base-checkbox v-model="lovingVue"></base-checkbox>`,
  components: {
    baseCheckbox
  },
  data() {
    return {
      lovingVue: true
    };
  }
});

1、父组件的编译

(1)ast的获取

const ast = parse(template.trim(), options)的过程中,会通过正则的方式去将template转换成ast树,整个过程中如果遇到闭合标签在ast树管理过程中会执行closeElement(element)中的element = processElement(element, options),其中有属性管理的方法processAttrs(element)。过程中会执行到addDirective

export function addDirective (
  el: ASTElement,
  name: string,
  rawName: string,
  value: string,
  arg: ?string,
  isDynamicArg: boolean,
  modifiers: ?ASTModifiers,
  range?: Range
) {
  (el.directives || (el.directives = [])).push(rangeSetItem({
    name,
    rawName,
    value,
    arg,
    isDynamicArg,
    modifiers
  }, range))
  el.plain = false
}

执行结果中包含属性directives,其中有属性为:

{
    // ...
    name: "model",
    value: "lovingVue"
    // ...
}

(2)code的获取

genData的过程中,针对组件会执行genComponentModel(el, value, modifiers)

/**
 * Cross-platform code generation for component v-model
 */
export function genComponentModel (
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const { number, trim } = modifiers || {}

  const baseValueExpression = '$$v'
  let valueExpression = baseValueExpression
  if (trim) {
    valueExpression =
      `(typeof ${baseValueExpression} === 'string'` +
      `? ${baseValueExpression}.trim()` +
      `: ${baseValueExpression})`
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`
  }
  const assignment = genAssignmentCode(value, valueExpression)

  el.model = {
    value: `(${value})`,
    expression: JSON.stringify(value),
    callback: `function (${baseValueExpression}) {${assignment}}`
  }
}
/**
 * Cross-platform codegen helper for generating v-model value assignment code.
 */
export function genAssignmentCode (
  value: string,
  assignment: string
): string {
  const res = parseModel(value)
  if (res.key === null) {
    return `${value}=${assignment}`
  } else {
    return `$set(${res.exp}, ${res.key}, ${assignment})`
  }
}

执行结果会使el中包含属性model,其中有属性为:

{
    callback: "function ($$v) {lovingVue=$$v}",
    expression: "\"lovingVue\"",
    value: "(lovingVue)",
}

字符串拼接时,如果存在model会执行以下逻辑:

// component v-model
  if (el.model) {
    data += "model:{value:" + (el.model.value) + ",callback:" + (el.model.callback) + ",expression:" + (el.model.expression) + "},";
  }

最后生成的_render结果为:

with(this) {
    return _c('base-checkbox', {
        model: {
            value: (lovingVue),
            callback: function ($$v) {
                lovingVue = $$v
            },
            expression: "lovingVue"
        }
    })
}

2、父组件的vNode

父组件执行_cbase-checkbox作为标签,将model对象作为data开始执行,最终会执行到_createElement中:

if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
  vnode = createComponent(Ctor, data, context, children, tag)
}

这里的解析到Ctor组件为:

baseCheckbox {
    template: `<input type="checkbox" v-bind:checked="checked" v-on:change="$emit('change', $event.target.checked)">`,
    model: {
        prop: "checked",
        event: "change"
    },
    props: {
        checked: Boolean
    }
}

再看createComponent

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  if (isUndef(Ctor)) {
    return
  }

  const baseCtor = context.$options._base

  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }
  // ...

  // transform component v-model data into props & events
  if (isDef(data.model)) {
    transformModel(Ctor.options, data)
  }

  // extract props
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)

  // ...

  // extract listeners, since these needs to be treated as
  // child component listeners instead of DOM listeners
  const listeners = data.on
  // replace with listeners with .native modifier
  // so it gets processed during parent component patch.
  data.on = data.nativeOn

  // ...

  // return a placeholder vnode
  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
}

(1)transformModel

// transform component v-model info (value and callback) into
// prop and event handler respectively.
function transformModel (options, data) {
  var prop = (options.model && options.model.prop) || 'value';
  var event = (options.model && options.model.event) || 'input'
  ;(data.attrs || (data.attrs = {}))[prop] = data.model.value;
  var on = data.on || (data.on = {});
  var existing = on[event];
  var callback = data.model.callback;
  if (isDef(existing)) {
    if (
      Array.isArray(existing)
        ? existing.indexOf(callback) === -1
        : existing !== callback
    ) {
      on[event] = [callback].concat(existing);
    }
  } else {
    on[event] = callback;
  }
}

注释中清晰解释,这里是将v-modelvaluecallback转换成propevent handler
如果没有定义model中的prop,默认为value;如果没有定义model中的event,默认为input。当前例子中定义了:

model: {
    prop: "checked",
    event: "change"
},

transform执行后data为:

{
    attrs: {
        checked: true
    },
    model: {
        value: true,
        expression: 'lovingVue',
        callback: function ($$v) {lovingVue=$$v}
    },
    on: {
        change: function ($$v) {lovingVue=$$v}
    },
}

(2)extractPropFromVNodeData

export function extractPropsFromVNodeData (
  data: VNodeData,
  Ctor: Class<Component>,
  tag?: string
): ?Object {
  // we are only extracting raw values here.
  // validation and default values are handled in the child
  // component itself.
  const propOptions = Ctor.options.props
  if (isUndef(propOptions)) {
    return
  }
  const res = {}
  const { attrs, props } = data
  if (isDef(attrs) || isDef(props)) {
    for (const key in propOptions) {
      const altKey = hyphenate(key)
      if (process.env.NODE_ENV !== 'production') {
        const keyInLowerCase = key.toLowerCase()
        if (
          key !== keyInLowerCase &&
          attrs && hasOwn(attrs, keyInLowerCase)
        ) {
          tip(
            `Prop "${keyInLowerCase}" is passed to component ` +
            `${formatComponentName(tag || Ctor)}, but the declared prop name is` +
            ` "${key}". ` +
            `Note that HTML attributes are case-insensitive and camelCased ` +
            `props need to use their kebab-case equivalents when using in-DOM ` +
            `templates. You should probably use "${altKey}" instead of "${key}".`
          )
        }
      }
      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
}

当前例子中extractPropsFromVNodeData(data, Ctor, tag)在执行完checkProp(res, attrs, key, altKey, false)后,将checked: true返回,并删除attrs中的checked: true,最后将返回值赋值给propsData。此时data的值为:

{ 
    attrs: {},
    model: {
        value: true,
        expression: 'lovingVue',
        callback: function ($$v) {lovingVue=$$v}
    },
    on: {
        change: function ($$v) {lovingVue=$$v}
    },
}

(3)listeners = data.on

通过const listeners = data.on的方式将其中事件赋值给listeners,再通过data.on = data.nativeOn将原生事件赋值给data.on。此时data的值为:

{
attrs: { },
model: {
    value: true,
    expression: 'lovingVue',
    callback: function ($$v) {lovingVue=$$v}
},
on: undefined,
}

最后将{ Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children }作为参数componentOptionsnew VNode实例化vNode的时候传入。

3、子组件的createComponentInstanceForVnode

子组件会执行到init钩子函数中的:

init: function init (vnode, hydrating) {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      var mountedNode = vnode; // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode);
    } else {
      var child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      );
      child.$mount(hydrating ? vnode.elm : undefined, hydrating);
    }
},

createComponentInstanceForVnode的过程中会执行new vnode.componentOptions.Ctor(options),进而执行继承于Vue中的this._init

(1)initEvents

initEvents最终会执行到:

Vue.prototype.$on = function (event, fn) {
    var vm = this;
    if (Array.isArray(event)) {
      for (var i = 0, l = event.length; i < l; i++) {
        vm.$on(event[i], fn);
      }
    } else {
      (vm._events[event] || (vm._events[event] = [])).push(fn);
      // optimize hook:event cost by using a boolean flag marked at registration
      // instead of a hash lookup
      if (hookRE.test(event)) {
        vm._hasHookEvent = true;
      }
    }
    return vm
};

如果vm._events[event]不存在,将其赋值为[],并将当前处理后的事件推入其中。

(2)initProps

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      const 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, () => {
        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
          )
        }
      })
    } else {
      defineReactive(props, key, value)
    }
    // 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)
    }
  }
  toggleObserving(true)
}

通过var value = validateProp(key, propsOptions, propsData, vm)获取到value,并通过defineReactive(props, key, value, () => {/* */})的方式将props处理成响应式。

通过initEventsinitProps就为当前vm实例上增加了_props_events属性。

4、子组件的$mount

子组件在执行child.$mount(hydrating ? vnode.elm : undefined, hydrating)时,先通过编译过程获得_render

with(this) {
    return _c('input', {
        attrs: {
            "type": "checkbox"
        },
        domProps: {
            "checked": checked
        },
        on: {
            "change": function ($event) {
                return $emit('change', $event.target.checked)
            }
        }
    })
}

获得的vNode中包含data值为:

{
    "attrs": {
        "type": "checkbox"
    },
    "domProps": {
        "checked": true
    },
    "on": {
        change: ƒunction($event) {
            return $emit('change', $event.target.checked)
        },
    }
}

在执行到invokeCreateHooks:

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); }
    }
}

这里会通过updateAttrs(oldVnode, vnode)elm设置属性type="checkbox";
再通过updateDOMListenerselm绑定change事件;
最后通过updateDOMPropselm设置elm['checked'] = true

至此就完成了首次的渲染。

5、再次渲染

再次渲染会执行到$emit('change', $event.target.checked)$event.target.checked进行触发。

  Vue.prototype.$emit = function (event: string): Component {
    const vm: Component = this
    if (process.env.NODE_ENV !== 'production') {
      const lowerCaseEvent = event.toLowerCase()
      if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
        tip(
          `Event "${lowerCaseEvent}" is emitted in component ` +
          `${formatComponentName(vm)} but the handler is registered for "${event}". ` +
          `Note that HTML attributes are case-insensitive and you cannot use ` +
          `v-on to listen to camelCase events when using in-DOM templates. ` +
          `You should probably use "${hyphenate(event)}" instead of "${event}".`
        )
      }
    }
    let cbs = vm._events[event]
    if (cbs) {
      cbs = cbs.length > 1 ? toArray(cbs) : cbs
      const args = toArray(arguments, 1)
      const info = `event handler for "${event}"`
      for (let i = 0, l = cbs.length; i < l; i++) {
        invokeWithErrorHandling(cbs[i], vm, args, vm, info)
      }
    }
    return vm
  }
}

这里通过let cbs = vm._events[event]获取到在initEvents阶段定义的事件,再通过invokeWithErrorHandling进行执行。相当于执行了ƒ ($$v) {lovingVue=$$v}回调函数,将父级中的数据进行改变,达到子组件修改父组件的目的。

总结:

组件中v-model通过prop和回调函数的方式进行实现。