Skip to content

特殊元素&指令:双向绑定是如何实现的?

前言

有些小伙伴在理解 Vue 的响应式原理的时候,可能会认为 Vue 的响应式是双向绑定的,但实际上这是不准确的,所谓数据的双向绑定可以体现为以下两部分:

  1. 数据流向 DOM 的绑定:数据的更新最终映射到对应的视图更新。
  2. DOM 流向数据的绑定:操作 DOM 的变化引起数据的更新。

我们在前面的章节花了不少篇幅介绍了响应式原理,其实这块就是着重在介绍数据流向 DOM 的过程。

Vuejs 中,我们则会经常通过 v-model 指令来实现数据的 “双向绑定”。 v-model 指令既可以作用在普通表单元素,也可以作用在一些组件上。接下来我们将分别介绍这两种情况的实现原理。

表单元素

在使用 Vuejs 编写表单类的 UI 控件时,经常会使用 v-model 指令来为 <input><select><textarea> 进行数据的双向绑定。

我们使用 Vue 提供的官方模版转换工具来尝试一下在 <input><select><textarea> 输入类型的表单中使用 v-model 指令会被编译成什么样子:

模版:

html
<input v-model='value1' />
<textarea v-model='value2' />
<select v-model='value3' />

编译结果

js
import { vModelText as _vModelText, createElementVNode as _createElementVNode, withDirectives as _withDirectives, vModelSelect as _vModelSelect, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = ["onUpdate:modelValue"]
const _hoisted_2 = ["onUpdate:modelValue"]
const _hoisted_3 = ["onUpdate:modelValue"]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _withDirectives(_createElementVNode("input", {
      "onUpdate:modelValue": $event => ((_ctx.value1) = $event)
    }, null, 8 /* PROPS */, _hoisted_1), [
      [_vModelText, _ctx.value1]
    ]),
    _withDirectives(_createElementVNode("textarea", {
      "onUpdate:modelValue": $event => ((_ctx.value2) = $event)
    }, null, 8 /* PROPS */, _hoisted_2), [
      [_vModelText, _ctx.value2]
    ]),
    _withDirectives(_createElementVNode("select", {
      "onUpdate:modelValue": $event => ((_ctx.value3) = $event)
    }, null, 8 /* PROPS */, _hoisted_3), [
      [_vModelSelect, _ctx.value3]
    ])
  ], 64 /* STABLE_FRAGMENT */))
}

可以看到通过 v-model 绑定的元素,在转成渲染函数的时候,最外层都被套上了一个 withDirectives 函数,这个函数传入了两个变量,一个通过 createElementVNode 创建的 vnode 节点,另一个是一个数组类型的参 directives,这个我们后面再介绍。我们先来简单看一下 withDirectives 这个函数的实现:

js
export function withDirectives(vnode, directives) {
  const internalInstance = currentRenderingInstance
  if (internalInstance === null) {
    return vnode
  }
  const instance = getExposeProxy(internalInstance) || internalInstance.proxy
  // 获取指令集
  const bindings = vnode.dirs || (vnode.dirs = [])
  // 遍历 directives
  for (let i = 0; i < directives.length; i++) {
    let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
    // 如果存在指令
    if (dir) {
      // 指令是个函数,构造 mounted、updated 钩子
      if (isFunction(dir)) {
        dir = {
          mounted: dir,
          updated: dir
        }
      }
      // 存在 deep 属性,遍历访问每个属性
      if (dir.deep) {
        traverse(value)
      }
      // bindings 中添加构造好的指令元素
      bindings.push({
        dir,
        instance,
        value,
        oldValue: void 0,
        arg,
        modifiers
      })
    }
  }
  return vnode
}

可以看到 withDirectives 函数主要就是为 vnode 节点上添加 dirs 属性,对于我们示例中的 <input> 节点而言,生成的 dir 内容大致为( select 节点类似,这里就不再介绍了,有兴趣的可以在源码详细了解):

yaml
{
  dir: vModelText,
  value: _ctx.value1,
  ...
}

其中 vModelText 是一个对象,内置了 v-model 指令相关的生命周期的实现:

typescript
export const vModelText = {
  // created 生命周期
  created(el, { modifiers: { lazy, trim, number } }, vnode) {
    // 获取 props 上 onUpdate:modelValue 函数
    el._assign = getModelAssigner(vnode)
    const castToNumber =
      number || (vnode.props && vnode.props.type === 'number')
    // 注册 input/change 事件  
    addEventListener(el, lazy ? 'change' : 'input', e => {
      // ...
      let domValue = el.value
      // .trim 修饰符
      if (trim) {
        domValue = domValue.trim()
      }
      if (castToNumber) {
        domValue = looseToNumber(domValue)
      }
      // 执行 onUpdate:modelValue 函数
      el._assign(domValue)
    })
    if (trim) {
      addEventListener(el, 'change', () => {
        el.value = el.value.trim()
      })
    }
    // ...
  },
  mounted(el, { value }) {
    // 赋值
    el.value = value == null ? '' : value
  },
  beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
    // 更新 el._assign
    el._assign = getModelAssigner(vnode)
    if (el.composing) return
    if (document.activeElement === el && el.type !== 'range') {
      if (lazy) {
        return
      }
      if (trim && el.value.trim() === value) {
        return
      }
      if (
        (number || el.type === 'number') &&
        looseToNumber(el.value) === value
      ) {
        return
      }
    }
    // 更新值
    const newValue = value == null ? '' : value
    if (el.value !== newValue) {
      el.value = newValue
    }
  }
}

可以看到 vModelText 内置了 createdmountedbeforeUpdate 钩子函数。

created 的时候,会从 pops 上获取 onUpdate:modelValue 函数,这个函数也就是我们在遇到 v-model 指令后,Vue 的编译器自动转换生成的。然后再监听对应 DOM 上的 change 或者 input 事件,事件触发时再回调执行 onUpdate:modelValue 函数。

mounted 的时候,会将当前的值 value 赋值给 el.value

指令生命周期的触发

前面我们提到了 v-model 注册的指令节点,会生成一个带有 dirs 的属性,属性中会包含类似于 vModelText 这样的对象,这个对象内部包含了一些生命周期函数,那这些生命周期函数又是在何时执行的呢?再回到我们之前的 mountElement 函数内,这次我们着重看一下与指令相关的代码实现:

js
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  // ...
  const { type, props, shapeFlag, transition, dirs } = vnode

  if (dirs) {
    // 执行 created 钩子函数
    invokeDirectiveHook(vnode, null, parentComponent, 'created')
  }
  // ...
  if (props) {
    // 处理 props,比如 class、style、event 等属性
  }
  if (dirs) {
    // 执行 beforeMount 钩子函数
    invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
  }
  // 挂载 dom
  hostInsert(el, container, anchor)
  
  if (
    (vnodeHook = props && props.onVnodeMounted) ||
    needCallTransitionHooks ||
    dirs
  ) {
    queuePostRenderEffect(() => {
      vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
      needCallTransitionHooks && transition!.enter(el)
      // 执行 mounted 钩子函数
      dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
    }, parentSuspense)
  }
}

可以看到指令相关的钩子函数在进行 vnode 初始化挂载的时候,会在挂载的各个阶段被分别调用,从而完成生命周期函数的执行过程。

组件

我们首先来看一下,v-model 在组件中一些常规的使用方式:

html
<Component v-model="value1" />
<Component v-model:title="bookTitle" />
<Component v-model:first-name="first" v-model:last-name="last" />

在组件上,v-model 不仅仅可以使用 modelValue 作为 prop,以 update:modelValue 作为对应的事件,还支持了给 v-model 一个自定义参数来更改这些名字。因为有了自定义参数的功能,所以也就支持了一个组件多个 v-model 绑定的功能。

接下来再看看通过 Vue 3 Template Explorer 将上述模版转出来的渲染函数的表达形式:

js
import { resolveComponent as _resolveComponent, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_Component = _resolveComponent("Component")

  return (_openBlock(), _createElementBlock(_Fragment, null, [
    _createVNode(_component_Component, {
      modelValue: _ctx.value1,
      "onUpdate:modelValue": $event => ((_ctx.value1) = $event)
    }, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"]),
    _createVNode(_component_Component, {
      title: _ctx.bookTitle,
      "onUpdate:title": $event => ((_ctx.bookTitle) = $event)
    }, null, 8 /* PROPS */, ["title", "onUpdate:title"]),
    _createVNode(_component_Component, {
      "first-name": _ctx.first,
      "onUpdate:firstName": $event => ((_ctx.first) = $event),
      "last-name": _ctx.last,
      "onUpdate:lastName": $event => ((_ctx.last) = $event)
    }, null, 8 /* PROPS */, ["first-name", "onUpdate:firstName", "last-name", "onUpdate:lastName"])
  ], 64 /* STABLE_FRAGMENT */))
}

可以看到,编译器在处理组件带有 v-model 指令的时候,会将其根据相关参数进行解析,最后组成一个 props 传入组件中。拿一个 v-model:title = 'bookTitle' 举例,生成的 props 大致是这样的:

js
{
  title: value,
  "onUpdate:title": $event => _ctx.bookTitle = $event
}

所以这也解释了为什么组件内部需要定义一个 props 用来承接 title 的值;定义一个 emit,在 title 值变化的时候,用来触发 onUpdate:title,并传入更新后的值。

html
<!-- Component.vue -->
<script setup>
defineProps(['title'])
defineEmits(['update:title'])
</script>

<template>
  <input
    type="text"
    :value="title"
    @input="$emit('update:title', $event.target.value)"
  />
</template>

接下来我们再看看这个 $emit 是如何触发 onUpdate:title 函数的执行的。先来看看 $emit 函数的实现:

js
export function emit(instance, event, ...rawArgs) {
  if (instance.isUnmounted) return
  const props = instance.vnode.props || EMPTY_OBJ
  
  let args = rawArgs
  
  // 定义事件名称
  let handlerName
  // update:xxx => onUpdate:xxx
  let handler =
    props[(handlerName = toHandlerKey(event))] ||
    props[(handlerName = toHandlerKey(camelize(event)))]
  // 找到了 handler 触发调用
  if (handler) {
    callWithAsyncErrorHandling(
      handler,
      instance,
      ErrorCodes.COMPONENT_EVENT_HANDLER,
      args
    )
  }
  // ...
}

其中第一个参数是当前组件实例,$emit 自动为我们绑定了当前组件,event 为事件名称,rawArgs 就是传入的一些参数。整个函数逻辑还是很清晰的,就是将传入的 event 名称转成 onUpdate:xxx 的写法,然后在 props 上找对应的函数,也就是我们传入的那个事件函数。找到了后就通过 callWithAsyncErrorHandling 方法进行调用,完成事件的执行。

总结

v-model 不管是在表单元素还是在组件元素上都会被编译器转成一个 props 对象,在表单元素上是这样的:

js
{
  "onUpdate:modelValue": $event => _ctx.bookTitle = $event
}

而在组件时则会编译成:

js
{
  title: value,
  "onUpdate:title": $event => _ctx.bookTitle = $event
}

那么,所谓的双向数据绑定的 DOM 操作触发数据的更新就可以理解为:

在表单元素上,事件名 modelValue 是默认的,通过 vModelText 函数在内部实现了一个监听 DOM 变更的事件 change/input 来实现对数据值的更新操作。

在组件元素上,则是通过组件内部自定义值接受和事件派发机制完成对数据的更新操作。

前端知识体系 · wcrane