Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue源码-指令v-model #8

Open
wozien opened this issue Aug 28, 2020 · 0 comments
Open

Vue源码-指令v-model #8

wozien opened this issue Aug 28, 2020 · 0 comments
Labels

Comments

@wozien
Copy link
Owner

wozien commented Aug 28, 2020

在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名称,以及派发的事件名称。

@wozien wozien added the vue label Sep 15, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant