> For the complete documentation index, see [llms.txt](https://onenesss-organization.gitbook.io/jj-xiaoce/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://onenesss-organization.gitbook.io/jj-xiaoce/vue-3-ji-shu-jie-mi/20.-te-shu-yuan-su-zhi-ling-shuang-xiang-bang-ding-shi-ru-he-shi-xian-de.md).

# 20.特殊元素&指令：双向绑定是如何实现的？

### 前言

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

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

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

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

### 表单元素

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

我们使用 `Vue` 提供的官方[模版转换工具](https://vue-next-template-explorer.netlify.app/)来尝试一下在 `<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` 节点类似，这里就不再介绍了，有兴趣的可以在源码详细了解）：

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

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

```
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` 内置了 `created`、`mounted`、`beforeUpdate` 钩子函数。

在 `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](https://vue-next-template-explorer.netlify.app/) 将上述模版转出来的渲染函数的表达形式：

```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` 来实现对数据值的更新操作。

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