Vue源码-指令v-model

1,064 阅读8分钟

在Vue中我们可以用v-model指令来使表单的值和状态进行双向绑定,当表单的值改变时绑定的值也会变化。其实,v-model是Vue提供的props和事件的语法糖,现在我们通过源码分析下这其中的原理。

表单元素绑定

我们先来看一下v-model的例子:

import Vue from 'vue';

new Vue({
  el: '#app',
  template: `
    <div>
      <input v-model="message" />
      <p>{{ message }}</p>
    </div>
  `,
  data: {
    message: ''
  }
});

编译解析

对于v-model和其他指令一样,在模版的编译解析阶段会走src/compiler/parser/index.js文件的processAttrs方法,这个方法是对ast节点的attrsList属性进行处理。因为这个指令不是v-bindv-on等特殊指令,所以该方法会走下面逻辑:

name = name.replace(dirRE, '')
// parse arg
const argMatch = name.match(argRE)
let arg = argMatch && argMatch[1]
isDynamic = false
if (arg) {
  name = name.slice(0, -(arg.length + 1))
  if (dynamicArgRE.test(arg)) {
    arg = arg.slice(1, -1)
    isDynamic = true
  }
}
addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i])
if (process.env.NODE_ENV !== 'production' && name === 'model') {
  checkForAliasModel(el, value)
}

这个方法就是处理普通指令并调用addDirective方法在ast节点的directives属性上增加指令对象,对于我们的例子,执行完的结果:

现在对v-model的编译解析阶段就完成了,接下来是进行编译代码生成阶段。

代码生成

在编译代码生成阶段,会在src/compiler/codegen/index.js文件对于data代码生成入口函数genData中处理指令代码的相关逻辑,这部分逻辑都在genDirectives函数处理:

// 生成render代码入口
export function genData (el: ASTElement, state: CodegenState): string {
  let data = '{'

  // directives first.
  // directives may mutate the el's other properties before they are generated.
  const dirs = genDirectives(el, state)
  if (dirs) data += dirs + ','
  
  // ...
}

来看下genDirectives函数的定义:

function genDirectives (el: ASTElement, state: CodegenState): string | void {
  const dirs = el.directives
  if (!dirs) return
  let res = 'directives:['
  let hasRuntime = false
  let i, l, dir, needRuntime
  for (i = 0, l = dirs.length; i < l; i++) {
    dir = dirs[i]
    needRuntime = true
    const gen: DirectiveFunction = state.directives[dir.name]
    if (gen) {
      // compile-time directive that manipulates AST.
      // returns true if it also needs a runtime counterpart.
      needRuntime = !!gen(el, dir, state.warn)
    }
    if (needRuntime) {
      hasRuntime = true
      res += `{name:"${dir.name}",rawName:"${dir.rawName}"${
        dir.value ? `,value:(${dir.value}),expression:${JSON.stringify(dir.value)}` : ''
      }${
        dir.arg ? `,arg:${dir.isDynamicArg ? dir.arg : `"${dir.arg}"`}` : ''
      }${
        dir.modifiers ? `,modifiers:${JSON.stringify(dir.modifiers)}` : ''
      }},`
    }
  }
  if (hasRuntime) {
    return res.slice(0, -1) + ']'
  }
}

这个方法循环遍历ast节点的directives属性的每个指令,对于每个指令会调用state.directives[dir.name]返回的函数。这里的state是指Vue编译相关的一些配置,这些配置和平台有关,它的入口在src/platforms/web/compiler/options.js

import directives from './directives/index'
//...

export const baseOptions: CompilerOptions = {
  expectHTML: true,
  modules,
  directives,
  isPreTag,
  isUnaryTag,
  mustUseProp,
  canBeLeftOpenTag,
  isReservedTag,
  getTagNamespace,
  staticKeys: genStaticKeys(modules)
}

和指令相关配置定义在src/platforms/web/compiler/directives/index.js中:

import model from './model'
import text from './text'
import html from './html'

export default {
  model,
  text,
  html
}

很明显Vue对这3个特殊的指令编译都有特殊处理。所以上面的gen函数就是指src/platforms/web/compiler/directives/model.js文件中定义的model方法:

export default function model (
  el: ASTElement,
  dir: ASTDirective,
  _warn: Function
): ?boolean {
  warn = _warn
  const value = dir.value
  const modifiers = dir.modifiers
  const tag = el.tag
  const type = el.attrsMap.type

  if (process.env.NODE_ENV !== 'production') {
    // inputs with type="file" are read only and setting the input's
    // value will throw an error.
    if (tag === 'input' && type === 'file') {
      warn(
        `<${el.tag} v-model="${value}" type="file">:\n` +
        `File inputs are read only. Use a v-on:change listener instead.`,
        el.rawAttrsMap['v-model']
      )
    }
  }

  if (el.component) {
    genComponentModel(el, value, modifiers)
    // component v-model doesn't need extra runtime
    return false
  } else if (tag === 'select') {
    genSelect(el, value, modifiers)
  } else if (tag === 'input' && type === 'checkbox') {
    genCheckboxModel(el, value, modifiers)
  } else if (tag === 'input' && type === 'radio') {
    genRadioModel(el, value, modifiers)
  } else if (tag === 'input' || tag === 'textarea') {
    genDefaultModel(el, value, modifiers)
  } else if (!config.isReservedTag(tag)) {
    genComponentModel(el, value, modifiers)
    // component v-model doesn't need extra runtime
    return false
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `<${el.tag} v-model="${value}">: ` +
      `v-model is not supported on this element type. ` +
      'If you are working with contenteditable, it\'s recommended to ' +
      'wrap a library dedicated for that purpose inside a custom component.',
      el.rawAttrsMap['v-model']
    )
  }

  // ensure runtime directive metadata
  return true
}

这个方法主要是处理v-model绑定在不同表单或者组件的处理。在我们例子是绑定在input,所以会调用genDefaultModel方法:

function genDefaultModel (
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const type = el.attrsMap.type

  const { lazy, number, trim } = modifiers || {}
  const needCompositionGuard = !lazy && type !== 'range'
  const event = lazy
    ? 'change'
    : type === 'range'
      ? RANGE_TOKEN
      : 'input'

  let valueExpression = '$event.target.value'
  if (trim) {
    valueExpression = `$event.target.value.trim()`
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`
  }

  let code = genAssignmentCode(value, valueExpression)
  if (needCompositionGuard) {
    code = `if($event.target.composing)return;${code}`
  }

  addProp(el, 'value', `(${value})`)
  addHandler(el, event, code, null, true)
  if (trim || number) {
    addHandler(el, 'blur', '$forceUpdate()')
  }
}

这个方法先获取v-model指令的修饰符,接下来是根据不同修饰符对事件类型event和表达式的值valueExpression的处理。然后调用genAssignmentCode方法生成我们回调函数的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})`
  }
}

这个方法主要是要处理指令表达式是类似test[test1[key]], test["a"][key]等情况。我们例子直接返回${value}=${assignment}。因为我们没设置lazy,所以最终我们的code为if($event.target.composing)return;message=$event.target.value。对于composing为真直接返回这段逻辑我们稍后分析。接下来就是v-model指令的关键逻辑:

addProp(el, 'value', `(${value})`)
addHandler(el, event, code, null, true)

它会往ast节点上增加一个props和绑定一个事件event,这就是Vue语法糖实现的核心。执行完这段逻辑看下ast节点结果:

执行完平台的model方法后返回true,再回到genDirectives方法,如果needRuntimetrue,就把指令相关属性就行字符串代码拼接并最终返回。这里我们看下genData函数有一细节,就是函数最开始就处理指令,这是因为处理指令时候可能会在节点上新增其他一些属性,例如我们v-model指令会增加props和事件。

最后,来看下render生成的代码结果:

with (this) {
  return _c('div', [
    _c('input', {
      directives: [{ name: 'model', rawName: 'v-model', value: message, expression: 'message' }],
      domProps: { value: message },
      on: {
        input: function($event) {
          if ($event.target.composing) return;
          message = $event.target.value;
        }
      }
    }),
    _v(' '),
    _c('p', [_v(_s(message))])
  ]);
}

指令钩子

在上面分析后,我们的例子其实等价于:

new Vue({
  el: '#app',
  template: `
    <div>
      <input :value="message" @input="message=$event.target.value"/>
      <p>{{ message }}</p>
    </div>
  `,
  data: {
    message: ''
  }
});

但是这里面有一个细微的差别我们可能没注意,那就是对于中文输入的处理。使用v-model输入中文过程中我们状态message是不会更着变化的,而等价的写法就会,那这中间的处理Vue是怎么实现的呢?

我们知道Vue的自定义指令存在钩子函数,并且在绑定的元素的插入或者更新阶段触发。其实,Vue也内置了v-model的钩子函数来处理我们上面说的中文输入的场景。现在来看下它的定义。

在我们虚拟节点的patch过程中会触发一系列的钩子函数,对于指令会在create,updatedestory钩子都会有处理,它的入口定义在src/core/vdom/modules/directives.js

export default {
  create: updateDirectives,
  update: updateDirectives,
  destroy: function unbindDirectives (vnode: VNodeWithData) {
    updateDirectives(vnode, emptyNode)
  }
}

function updateDirectives (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  if (oldVnode.data.directives || vnode.data.directives) {
    _update(oldVnode, vnode)
  }
}

很明显,在上面的三个时期都会调用_update函数:

function _update (oldVnode, vnode) {
  const isCreate = oldVnode === emptyNode
  const isDestroy = vnode === emptyNode
  const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
  const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)

  const dirsWithInsert = []
  const dirsWithPostpatch = []

  let key, oldDir, dir
  for (key in newDirs) {
    oldDir = oldDirs[key]
    dir = newDirs[key]
    if (!oldDir) {
      // new directive, bind
      callHook(dir, 'bind', vnode, oldVnode)
      if (dir.def && dir.def.inserted) {
        dirsWithInsert.push(dir)
      }
    } else {
      // existing directive, update
      dir.oldValue = oldDir.value
      dir.oldArg = oldDir.arg
      callHook(dir, 'update', vnode, oldVnode)
      if (dir.def && dir.def.componentUpdated) {
        dirsWithPostpatch.push(dir)
      }
    }
  }

  if (dirsWithInsert.length) {
    const callInsert = () => {
      for (let i = 0; i < dirsWithInsert.length; i++) {
        callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
      }
    }
    if (isCreate) {
      mergeVNodeHook(vnode, 'insert', callInsert)
    } else {
      callInsert()
    }
  }

  if (dirsWithPostpatch.length) {
    mergeVNodeHook(vnode, 'postpatch', () => {
      for (let i = 0; i < dirsWithPostpatch.length; i++) {
        callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
      }
    })
  }

  if (!isCreate) {
    for (key in oldDirs) {
      if (!newDirs[key]) {
        // no longer present, unbind
        callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
      }
    }
  }
}

这个方法用isCreate表示当前vnode是否是新建的节点,isDestroy表示当前节点是否销毁。normalizeDirectives方法是获取格式化指令对象,把指令的钩子函数进行整合到def。接着循环新节点的指令数组newDirs,对于每个指令对象dir在老的指令对象oldDirs不存在,这会调用指令的bind钩子,如果有定义insert钩子,则push到dirsWithInsert队列中,这样能保证所有的指令执行完bind钩子才去执行insert钩子。

如果老的指令对象oldDir存在,则调用指令的update钩子,并把componentUpdated钩子存到dirsWithPostpatch中,这样能保证所有的指令执行完update钩子才去执行componentUpdated钩子。最后把执行指令insert钩子数组函数合并到虚拟节点的自身的insert钩子,把执行指令componentUpdated钩子数组函数合并到虚拟节点的自身的postpatch钩子,这样就会更新虚拟节点在patch过程的对应阶段执行。

如果不是新建的节点,并且老的指令数组oldDirs如果有newDirs中不存在的,则证明该指令已经废弃,会调用响应的unbind钩子函数。

回到我们上面的问题,看看v-model内置的insert钩子的实现,它定义在src/platforms/web/runtime/directives/model.js中:

const directive = {
  inserted (el, binding, vnode, oldVnode) {
    if (vnode.tag === 'select') {
      // #6903
      if (oldVnode.elm && !oldVnode.elm._vOptions) {
        mergeVNodeHook(vnode, 'postpatch', () => {
          directive.componentUpdated(el, binding, vnode)
        })
      } else {
        setSelected(el, binding, vnode.context)
      }
      el._vOptions = [].map.call(el.options, getValue)
    } else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
      el._vModifiers = binding.modifiers
      if (!binding.modifiers.lazy) {
        el.addEventListener('compositionstart', onCompositionStart)
        el.addEventListener('compositionend', onCompositionEnd)
        el.addEventListener('change', onCompositionEnd)
        if (isIE9) {
          el.vmodel = true
        }
      }
    }
  }
}

上面代码在处理绑定inputtextarea类型的绑定时,在元素插入DOM后会另外绑定compositionstartcompositionend事件,它们分别会在中文输入过程和输入完成触发。来看下对应的回调函数:

function onCompositionStart (e) {
  e.target.composing = true
}

function onCompositionEnd (e) {
  // prevent triggering an input event for no reason
  if (!e.target.composing) return
  e.target.composing = false
  trigger(e.target, 'input')
}

function trigger (el, type) {
  const e = document.createEvent('HTMLEvents')
  e.initEvent(type, true, true)
  el.dispatchEvent(e)
}

在中文输入过程中,设置e.target.composingtrue,这个时候我们再来看下v-model绑定事件的函数体:

if ($event.target.composing) return;
message = $event.target.value;

当中文输入过程中触发的input事件,$event.target.composingtrue直接返回,这样状态就会不更着改变了。当中文输入完成执行onCompositionEnd函数会把e.target.composing设置为false,这个时候执行函数体就会修改状态message了。

组件绑定

v-model也可以用到组件上,先看一个例子:

const Child = {
  template: `<div>
    <input :value="value" @input="handleInput">
  </div>`,
  props: ['value'],
  methods: {
    handleInput(e) {
      this.$emit('input', e.target.value);
    }
  }
};

new Vue({
  el: '#app',
  template: `
    <div>
      <Child v-model="message"></Child>
      <p>{{ message }}</p>
    </div>
  `,
  data: {
    message: ''
  },
  components: { Child }
});

在组件上使用v-model也会在编译模版时进行处理,不同的是在gen函数中会走下面的逻辑:

else if (!config.isReservedTag(tag)) {
  genComponentModel(el, value, modifiers)
  // component v-model doesn't need extra runtime
  return false
}

因为组件不是平台保留的标签,调用genComponentModel方法进行处理并且返回false

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}}`
  }
}

这个方法主要在ast节点上添加model属性来表示指令相关数据,我们例子中执行完的结果为:

然后返回genData函数,这里返回的dirs为undefined,因为组件使用v-model单纯是个语法糖,不需要在运行时进行相关处理。另外,这个函数要把节点上的model赋值给data属性:

// 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(
    'div',
    [
      _c('Child', {
        model: {
          value: message,
          callback: function(?v) {
            message = ?v;
          },
          expression: 'message'
        }
      }),
      _v(' '),
      _c('p', [_v(_s(message))])
    ],
    1
  );
}

很明显,在Childdata增加了model属性,并且会在创建组件构造器时进行处理。在src/core/vdom/create-component.js文件的createComponent函数有下面一段逻辑:

// v-model的处理
  if (isDef(data.model)) {
    transformModel(Ctor.options, data)
  }

来看下transformModel的定义:

function transformModel (options, data: any) {
  const prop = (options.model && options.model.prop) || 'value'
  const event = (options.model && options.model.event) || 'input'
  ;(data.attrs || (data.attrs = {}))[prop] = data.model.value
  const on = data.on || (data.on = {})
  const existing = on[event]
  const 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
  }
}

这个方法向组件虚拟节点data属性增加一个key为prop的属性,并且在on增加事件event,这样就实现了v-model的功能。

总结

那么至此,v-model的实现就分析完了,我们了解到它是 Vue 双向绑定的真正实现,但本质上就是一种语法糖,它即可以支持原生表单元素,也可以支持自定义组件。在组件的实现中,我们是可以配置子组件接收prop名称,以及派发的事件名称。

>>>原文地址