【Vue.js 3.0源码】v-model双向绑定的实现过程

165 阅读3分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

自我介绍:大家好,我是吉帅振的网络日志(其他平台账号名字相同),互联网前端开发工程师,工作5年,去过上海和北京,经历创业公司,加入过阿里本地生活团队,现在郑州北游教育从事编程培训。

一、前言

很多人会把 Vue.js 的响应式原理误解为双向绑定,其实响应式原理是一种单向行为,它是数据到 DOM 的映射。而真正的双向绑定,除了数据变化,会引起 DOM 的变化之外,还应该在操作 DOM 改变后,反过来影响数据的变化。v-model 也不是可以作用到任意标签,它只能在一些特定的表单标签如 input、select、textarea 和自定义组件中使用。举一个基本的示例:

import { vModelText as _vModelText, createVNode as _createVNode, withDirectives as _withDirectives, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return _withDirectives((_openBlock(), _createBlock("input", {
    "onUpdate:modelValue": $event => (_ctx.searchText = $event)
  }, null, 8 /* PROPS */, ["onUpdate:modelValue"])), [
    [_vModelText, _ctx.searchText]
  ])
}

可以看到,作用在 input 标签的 v-model 指令在编译后,除了使用 withDirectives 给这个 vnode 添加了 vModelText 指令对象外,还额外传递了一个名为 onUpdate:modelValue 的 prop,它的值是一个函数,这个函数就是用来更新变量 searchText。

const vModelText = {
  created(el, { value, modifiers: { lazy, trim, number } }, vnode) {
    el.value = value == null ? '' : value
    el._assign = getModelAssigner(vnode)
    const castToNumber = number || el.type === 'number'
    addEventListener(el, lazy ? 'change' : 'input', e => {
      if (e.target.composing)
        return
      let domValue = el.value
      if (trim) {
        domValue = domValue.trim()
      }
      else if (castToNumber) {
        domValue = toNumber(domValue)
      }
      el._assign(domValue)
    })
    if (trim) {
      addEventListener(el, 'change', () => {
        el.value = el.value.trim()
      })
    }
    if (!lazy) {
      addEventListener(el, 'compositionstart', onCompositionStart)
      addEventListener(el, 'compositionend', onCompositionEnd)
    }
  },
  beforeUpdate(el, { value, modifiers: { trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)
    if (document.activeElement === el) {
      if (trim && el.value.trim() === value) {
        return
      }
      if ((number || el.type === 'number') && toNumber(el.value) === value) {
        return
      }
    }
    const newValue = value == null ? '' : value
    if (el.value !== newValue) {
      el.value = newValue
    }
  }
}
const getModelAssigner = (vnode) => {
  const fn = vnode.props['onUpdate:modelValue']
  return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}
function onCompositionStart(e) {
  e.target.composing = true
}
function onCompositionEnd(e) {
  const target = e.target
  if (target.composing) {
    target.composing = false
    trigger(target, 'input')
  }
}

二、两个钩子函数,created 和 beforeUpdate。

1.created

第一个参数 el 是节点的 DOM 对象,第二个参数是 binding 对象,第三个参数 vnode 是节点的 vnode 对象。created 函数首先把 v-model 绑定的值 value 赋值给 el.value,这个就是数据到 DOM 的单向流动;接着通过 getModelAssigner 方法获取 props 中的 onUpdate:modelValue 属性对应的函数,赋值给 el._assign 属性;最后通过 addEventListener 来监听 input 标签的事件,它会根据是否配置 lazy 这个修饰符来决定监听 input 还是 change 事件。当用户手动输入一些数据触发事件的时候,会执行函数,并通过 el.value 获取 input 标签新的值,然后调用 el._assign 方法更新数据,这就是 DOM 到数据的流动。至此,我们就实现了数据的双向绑定,就是这么简单。

2.beforeUpdate

app.component('custom-input', {
  props: ['modelValue'],
  template: `
    <input v-model="value">
  `,
  computed: {
    value: {
      get() {
        return this.modelValue
      },
      set(value) {
        this.$emit('update:modelValue', value)
      }
    }
  }
})

主要就是在组件更新前判断如果数据的值和 DOM 的值不同,则把数据更新到 DOM 上。app.component 全局注册了一个 custom-input 自定义组件,内部我们使用了原生的 input 并使用了 v-model 指令实现数据的绑定。计算属性 value 对应的 getter 函数是直接取 modelValue 这个 prop 的值,而 setter 函数是派发一个自定义事件 update:modelValue。

import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_custom_input = _resolveComponent("custom-input")
  return (_openBlock(), _createBlock(_component_custom_input, {
    modelValue: _ctx.searchText,
    "onUpdate:modelValue": $event => (_ctx.searchText = $event)
  }, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"]))
}

import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_custom_input = _resolveComponent("custom-input")
  return (_openBlock(), _createBlock(_component_custom_input, {
    modelValue: _ctx.searchText,
    "onUpdate:modelValue": $event=>{_ctx.searchText = $event}
  }, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"]))
}

v-model 作用于组件上本质就是一个语法糖,就是往组件传入了一个名为 modelValue 的 prop,它的值是往组件传入的数据 data,另外它还在组件上监听了一个名为 update:modelValue 的自定义事件,事件的回调函数接受一个参数,执行的时候会把参数 $event 赋值给数据 data。正因为这个原理,所以我们想要实现自定义组件的 v-model,首先需要定义一个名为 modelValue 的 prop,然后在数据改变的时候,派发一个名为 update:modelValue 的事件。

Vue.js 3.0 关于组件 v-model 的实现和 Vue.js 2.x 实现是很类似的,在 Vue.js 2.x 中,想要实现自定义组件的 v-model,首先需要定义一个名为 value 的 prop,然后在数据改变的时候,派发一个名为 input 的事件。

总结下来,作用在组件上的 v-model 实际上就是一种打通数据双向通讯的语法糖,即外部可以往组件上传递数据,组件内部经过某些操作行为修改了数据,然后把更改后的数据再回传到外部。

三、总结

了解 v-model 在普通表单元素实现双向绑定的基本实现原理。