Vue3探秘系列— v-model:双向数据绑定的实现原理(十三)

0 阅读7分钟

前言

Vue3探秘系列文章链接:

不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)

不止响应式:Vue3探秘系列— 组件更新会发生什么(二)

不止响应式:Vue3探秘系列— diff算法的完整过程(三)

不止响应式:Vue3探秘系列— 组件的初始化过程(四)

终于轮到你了:Vue3探秘系列— 响应式设计(五)

Hello~大家好。我是秋天的一阵风

在日常开发或者面试中经常会提到一个问题:什么是双向数据绑定?

很多同学就会开始侃侃而谈,比如说: "Vue对数据进行了响应式处理,当数据改变时,页面也会重新渲染。。。实现的原理在Vue2是通过Object.defineProperty,在 Vue3则是通过Proxy API。。。" 等等。。。。

其实很多同学都把双向数据绑定响应式原理搞混了。上面所说的其实是响应式原理,且响应式原理是一种单向行为,是数据到DOM的映射。

双向数据绑定呢,是一种双向行为,除了数据更改引起DOM的变化以外,在操作DOM以后,反过来也会影响数据的变化。

Vue中的内置指令 v-model 就是一种双向数据绑定的实现。

v-model的使用是有限制的,一般只能在特定的html标签比如inputselecttextarea自定义组件中使用。

接下来我们就从 普通html标签自定义组件 两个方面来探究它的实现原理。

一、普通html元素使用v-model

我们来看在普通表单元素上作用 v-model,还是先举一个基本的示例:

<input v-model="searchText"/>

我们先看这个模板编译后生成的 render 函数:

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

1. _withDirectives 函数

render函数中又出现了我们熟悉的_withDirectives,我们在之前探究自定义指令的实现原理时就分析过这个方法:

function withDirectives(vnode, directives) {
  const internalInstance = currentRenderingInstance
  if (internalInstance === null) {
    (process.env.NODE_ENV !== 'production') && warn(`withDirectives can only be used inside render functions.`)
    return vnode
  }
  const instance = internalInstance.proxy
  const bindings = vnode.dirs || (vnode.dirs = [])
  for (let i = 0; i < directives.length; i++) {
    let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
    if (isFunction(dir)) {
      dir = {
        mounted: dir,
        updated: dir
      }
    }
    bindings.push({
      dir,
      instance,
      value,
      oldValue: void 0,
      arg,
      modifiers
    })
  }
  return vnode
}

我们简单回顾下,withDirectives 接收一个vnode和指令数组directives,核心逻辑就是就给vnode添加一个dirs属性,属性的值就是这个元素节点上的所有指令构成的对象数组

所以在这里,其实就是使用withDirectivesvnode添加 vModelText 指令对象。

除此之外,还额外传递了一个名为 onUpdate:modelValueprop,它的值是一个函数,这个函数就是用来更新变量 searchText

2. vModelText

我们来看 vModelText 的实现:

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

  1. vModelText 指令实现了两个钩子函数:created beforeUpdate

created

  1. 我们先看 created 函数:第一个参数 el 是节点的 DOM 对象,第二个参数是 binding 对象,第三个参数 vnode 是节点的 vnode对象。如果你对参数不熟悉,可以在官网这里查看参数的更详细信息

3. created 函数首先把 v-model 绑定的值 value 赋值给 el.value,这个就是数据到 DOM 的单向流动

  1. 接着通过 getModelAssigner 方法获取 props 中的 onUpdate:modelValue 属性对应的函数,赋值给 el._assign 属性;

  2. 最后通过 addEventListener 来监听 input 标签的事件,它会根据是否配置 lazy 这个修饰符来决定监听 input 还是 change 事件,当前案例lazyfalse,所以监听的是input事件。

  3. 我们接着看这个事件监听函数,当用户手动输入一些数据触发事件的时候,会执行函数,并通过 el.value 获取 input 标签新的值,然后调用el._assign方法更新数据,这就是 DOM 到数据的流动

至此,我们就实现了数据的双向绑定,就是这么简单。

扩展: 指令还可以接收不同的修饰符,也就是modifiers对象。

  1. 如果是 lazy true,则监听的change事件,在input元素数去焦点且值改变的时候才会触发。

  2. 如果 trimtrue,在获取 DOM 的值后,会手动调用 trim 方法去除首尾空格。另外,还会额外监听 change 事件执行 el.value.trim() 把 DOM 的值的首尾空格去除。

  3. 如果 number true,或者 input typenumber,就会把DOM的值转成 number 类型后再赋值给数据。

beforeUpdate

beforeUpdate 非常简单,主要就是在组件更新前判断如果数据的值和 DOM 的值不同,则把数据更新到 DOM 上。

二、自定义组件使用v-model

我们通过一个示例说明:

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

我们先通过 app.component 全局注册了一个 custom-input 自定义组件,内部我们使用了原生的input并使用了 v-model 指令实现数据的绑定。

注意这里我们不能直接把modelValue作为 input 对应的 v-model 数据,因为不能直接对props的值修改,因此这里使用计算属性。

计算属性value对应的 getter 函数是直接取 modelValue 这个 prop 的值,而setter函数是派发一个自定义事件 update:modelValue

接下来你可以用两种方式来使用这个自定义组件:

<custom-input v-model="searchText"/>
<custom-input :modelValue="searchText" @update:modelValue="$event=>{searchText = $event}"/>

你会发现,这两个模板编译后生成的 render 函数都是一样的,而且编译的结果似乎和指令没有什么关系,并没有调用 withDirective 函数。:

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"]))
}
  1. 因为v-model作用于组件上本质就是一个语法糖,就是往组件传入了一个名为modelValue prop,它的值是往组件传入的数据 data,另外它还在组件上监听了一个名为 update:modelValue 的自定义事件,事件的回调函数接受一个参数,执行的时候会把参数 $event 赋值给数据 data

  2. 正因为这个原理,所以我们想要实现自定义组件的 v-model,首先需要定义一个名为 modelValueprop,然后在数据改变的时候,派发一个名为 update:modelValue 的事件。

  3. 当然,modelValue这个变量名你如果不喜欢,也是可以更换的。在Vue3中你甚至还可以定义多个v-model。当然这不是我们本篇的重点,你可以在官网查看更多详细信息

三、自定义事件的派发

现在我们知道了v-model就是一个语法糖,由prop和一个自定义事件来实现。

之前也探究过prop是如何传递到组件里面去,这个属于单向传递。

请你注意,在编译结果中,是传递了两个prop,一个是modelValue,另外一个prop是 onUpdate:modelValue

那么自定义事件是如何进行派发更新呢?

这个需要我们继续探究:子组件执行this.$emit('update:modelValue',value)方法派发自定义事件,$emit 内部执行了 emit 方法,其实核心在于 emit 方法之中

function emit(instance, event, ...args) {
  const props = instance.vnode.props || EMPTY_OBJ
  let handlerName = `on${capitalize(event)}`
  let handler = props[handlerName]
if (!handler && event.startsWith(‘update: ’)) {
    handlerName = on$ {
        capitalize(hyphenate(event))
    }
    handler = props[handlerName]
}
if (handler) {
    callWithAsyncErrorHandling(handler, instance, 6
    /* COMPONENT_EVENT_HANDLER */
    , args)
}
}

1.emit方法支持 3 个参数,第一个参数 instance 是组件的实例,也就是执行$emit方法的组件实例,第二个参数 event 是自定义事件名称,第三个参数 args 是事件传递的参数。

  1. emit 方法首先获取事件名称,把传递的 event 首字母大写,然后前面加上on 字符串,比如我们前面派发的 update:modelValue 事件名称,处理后就变成了 onUpdate:modelValue

  2. 接下来,通过这个事件名称,从 props 中根据事件名找到对应的 prop 值,作为事件的回调函数。

  3. 如果找不到对应的 prop 并且 event 是以 update: 开头的,则尝试把 event 名先转成连字符形式然后再处理。

  4. 找到回调函数 handler 后,再去执行这个回调函数,并且把参数 args 传入。针对v-model场景,这个回调函数就是拿到子组件回传的数据然后修改父元素传入到子组件的 prop 数据,这样就达到了数据双向通讯的目的。

总结

本篇我们一起探究了v-model在普通表单元素和自定义组件上的使用方式和实现原理。除此之外,还明白了自定义事件派发的原理。希望同学们不要再把响应式原理和双向数据绑定原理搞混~