深入理解 Vue3 的 v-model 及自定义指令的实现原理(下)

vModelSelect 指令

在分析 vModelSelect 指令实现的原理之前,我们先对 select 表单选择器做一些了解,select 表单选择器既可以单选又可以多选,要实现多选的话只需要要在 selected 标签上设置 multiple 属性即可。

复制代码
<select v-model="selected" multiple>
  <option>A</option>
  <option>B</option>
  <option>C</option>
</select>

当 select 是多选的情况下,v-model 绑定的状态数据可以是数组也可以是 Set。有了这些前置知识我们开始分析 vModelSelect 指令实现的原理。

vModelSelect 指令的源码如下:

复制代码
export const vModelSelect = {
  created(el, { value, modifiers: { number } }, vnode) {
    // 判断 v-model 绑定的状态数据是否 Set 类型
    const isSetModel = isSet(value)
    addEventListener(el, 'change', () => {
      // 通过 Array.prototype.filter.call 方法筛选选中的选项数据,返回值是数组
      const selectedVal = Array.prototype.filter
        .call(el.options, (o: HTMLOptionElement) => o.selected)
        .map(
          (o: HTMLOptionElement) =>
            // 如果存在 number 修饰器则对返回值进行数字化处理
            number ? toNumber(getValue(o)) : getValue(o)
        )
      // 更新 v-model 绑定的状态数据
      el._assign(
        el.multiple
          ? isSetModel
            ? new Set(selectedVal) // 如果多选且是 Set 类型则返回 Set 类型数据
            : selectedVal // 如果是多选其是数组
          : selectedVal[0] // 因为上面经过处理返回的数据是数组
      )
    })
    // 获取当前节点 props 中的 onUpdate:modelValue 更新函数
    el._assign = getModelAssigner(vnode)
  },
  // 设置 value 值需要在 mounted 方法和 updated 方法中,因为需要等待子元素 option 也渲染完毕
  mounted(el, { value }) {
    setSelected(el, value)
  },
  beforeUpdate(el, _binding, vnode) {
    // 更新当前节点 props 中的 onUpdate:modelValue 更新函数
    el._assign = getModelAssigner(vnode)
  },
  updated(el, { value }) {
    setSelected(el, value)
  }
}
created 函数

首先设置 isSetModel 判断 v-model 绑定的状态数据是否 Set 类型,在 change 事件的回调函数中通过 Array.prototype.filter.call 方法筛选选中的选项数据,因为返回值是数组,所以继续使用 map 通过链式调用处理返回的数组内容,主要是使用 getValue 函数获取 option 选项中 value 值,因为跟单选框一样 select 中 option 标签中设置的 value 值最终会被 Vue3 处理成 _value 属性挂载 option 元素实例对象 el 上。再判断是否存在 number 修饰符,如果存在则还需要把 option 上的值转成 number 类型。

最后通过 props 中的 onUpdate:modelValue 更新函数更新状态值,对更新的值还需要根据不同的情况进行处理。如果是多选且更新的值是 Set 类型则返回 Set 类型数据,如果是多选且更新的值是数组则不用做额外处理,如果是单项,则把数组的第0项返回即可,因为上面经过处理返回的数据是数组。

以上就是用户点击 select 选择器之后,从真实 DOM 的变化到数据变化的流程。

mounted 函数

在 mounted 函数中主要做的工作就是初始化,也就是设置 select 的 option 标签哪个处于被选中状态。由于 option 标签是 select 标签的子元素,所以需要等 option 标签也渲染完毕才能进行设置,所以就需要在 mounted 方法里设置了,同样更新也一样,跟文本框指令、单选框指令、复选框指令的更新是在 beforeUpdate 函数中处理不同,选择器指令的更新需要在 updated 函数中处理。因为 mounted 和 updated 都是通过异步调用执行的,所以根据 Vue3 运行流程 mounted 和 updated 方法执行的时候当前所有的节点挂载或更新完毕了。

因为 mounted 和 updated 的处理逻辑是一样的所以封装了一个 setSelected 方法,统一在一个方法里面处理。

setSelected 源码:

复制代码
function setSelected(el, value) {
  // 是否多选
  const isMultiple = el.multiple
  for (let i = 0, l = el.options.length; i < l; i++) {
    const option = el.options[i]
    // 通过 getValue 函数获取 value 值,因为 select 的 option 选项的 value 也会被设置为 _value
    const optionValue = getValue(option)
    if (isMultiple) { // 多选的情况
      if (isArray(value)) {
        // 数组的情况处理
        option.selected = looseIndexOf(value, optionValue) > -1
      } else {
        // Set 类型的处理
        option.selected = value.has(optionValue)
      }
    } else { // 单选的情况
      if (looseEqual(getValue(option), value)) {
        // selectIndex 为被选中 option 元素的索引值,通过 selectIndex 可设置选中项、获取索引值、删除指定项和修改指定项文本
        el.selectedIndex = i
        return
      }
    }
  }
  // 单选时且没有选中任何 options 则把 select.selectedIndex 置为 -1
  if (!isMultiple) {
    // selectedIndex 为 -1 则没有选项被选中
    el.selectedIndex = -1
  }
}

setSelected 函数的处理逻辑也很简单,通过循环选择器实例 el.options 数据进行进行判断哪一项是选中状态的,然后设置选中状态。具体就是通 getValue 函数获取 select 标签的后代 option 的 value 值,因为 select 的 option 选项的 value 也会被 Vue3 设置为 _value。

如果是多选的情况,不管是 v-model 绑定的数据是数组还是 Set 类型都通过判断当前 option 选项的 value 值是否存在 v-model 绑定的数据中,存在就会设置 option 实例对象的 selected 属性值为 true,否则就设置为 false。如果是单选的情况则通过当前的 option 选项的 value 值是否等于 v-model 绑定的数据,如果等于则通过设置 selectedIndex 属性值为当前 option 的索引,这样达到和设置 option 实例对象的 selected 属性值为 true 一样的效果

最后单选时且没有选中任何 options 则把 select.selectedIndex 置为 -1,selectedIndex 为 -1 则没有选项被选中。

最后我们来看看 selectedIndex 的定义:

HTML DOM中 的 Select selectedIndex 属性用于在下拉列表中设置或返回所选选项的索引。下拉列表的索引通常以 0 开头,如果未选择任何选项,则返回 -1。如果下拉列表允许多个选择,则此属性返回第一个选项的索引。

beforeUpdate 函数

beforeUpdate 函数中只是去更新当前节点 props 中的 onUpdate:modelValue 更新函数

updated 函数

updated 函数所做的事情跟 mounted 一样,且封装成了一个相同的函数 setSelected,关于 setSelected 函数的实现我们已经在上文已经分析了,这里就不再赘述了。

自定义指令的实现原理

我们从上文 v-model 指令的分析中可以总结出,指令的主要作用就是提供让我们去操作 DOM 的能力,当然除此之外我们还可以通过模板引用来操作。

自定义指令的定义

从上文中还可以知道所谓指令本质上就是一个 JavaScript 对象,对象上挂着一些生命周期的钩子函数。

来自 Vue 官网对指令的定义:

复制代码
const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode, prevVnode) {
  // 下面会介绍各个参数的细节
  },
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},
  // 在绑定元素的父组件
  // 及他自己的所有子节点都更新后调用
  updated(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode, prevVnode) {},
  // 绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode, prevVnode) {}
}

这些钩子函数将来在不同的时期被调用执行,自定义指令跟 Vue3 底层内置的指令运行原理是一致的。

自定义指令的应用

我们先来看一下 Vue 官方给出的应用例子:

复制代码
const focus = {
  mounted: (el) => el.focus()
}

export default {
  directives: {
    // 在模板中启用 v-focus
    focus
  }
}

这个自定义指令的主要作用是当一个 input 元素被 Vue 插入到 DOM 中后,它会被自动聚焦。

经过上述定义后我们就可以在 template 中使用了。

复制代码
<input v-focus />

和组件类似,自定义指令在模板中使用前必须先注册。在上面的例子中,我们使用 directives 选项完成了指令的局部注册。

当然我们也可以进行全局注册:

复制代码
const app = createApp({})

// 使 v-focus 在所有组件中都可用
app.directive('focus', {
  /* ... */
})
自定义指令的注册

所谓注册,其实就是把指令的定义保存到一个对象上,在未来使用的时候再从保存的对象上取出来。这个过程跟组件的局部注册和全局注册是一样的原理。

我们知道系统内置的指令是在编译成 render 函数的时候通过 vue 包引入的,那么自定义指令呢?比如我们把上面定义的 v-focus 进行使用。代码如下:

复制代码
<input v-focus="value" />

我们看看编译后的 render 函数:

复制代码
import { resolveDirective as _resolveDirective, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _directive_focus = _resolveDirective("focus")

  return _withDirectives((_openBlock(), _createElementBlock("input", null, null, 512 /* NEED_PATCH */)), [
    [_directive_focus, _ctx.value]
  ])
}

我们可以看到跟组件的引用方式是一致,也是通过一个函数进行引入,指令的所使用的函数是 resolveDirective,而组件则是 resolveComponent。

整个过程可以简单总结为如下代码:

复制代码
export function resolveDirective(name) {
    // 获取当前组件的实例对象
    const instance = currentRenderingInstance || currentInstance
    if (instance) {
      // 通过组件实例获取组件对象,也就是 type 属性值
      const Component = instance.type
      const res = 
            // 获取局部注册的指令
            resolve(Component.directives, name) ||
            // 获取全局注册的指令
            resolve(instance.appContext.directives, name)
      return res
    }
}
// 获取指令对象
function resolve(registry, name) {
    return (
      registry &&
      registry[name]
    )
}

通过上面的代码我们可以看到注册的自定义指令使用的原理,其实很简单,先把要在 template 中要使用到的指令对象注册到组件对象的 directives 属性上,然后在渲染函数 render 中通过当前的组件实例对象获取组件对象的 directives 属性,看看有没有对应的指令对象,有则把对应的指令对象获取到进行返回。如果在当前的组件对象上没有获取到对应的指令对象,则去全局上下文 appContext 上 directives 指令属性上进行获取。

全局上下文 appContext 上 directives 指令是通过 app.directive 函数注册的,它的实现原理跟组件的全局注册过程也是相同的。

复制代码
function createAppAPI(render) {
    return function createApp(rootComponent) {
        const context = createAppContext()
        const app = {
            // 注册全局组件方法
            directive(name, directive) {
                // 把组件注册到 Vue3 应用实例上下文对象的 directives 属性上
                context.directives[name] = directive
                return app
            },
        }
        return app
    }
}

通过上面的代码我们可以知道注册全局指令的时候,是把指令注册到 Vue3 应用实例上下文对象的 directives 属性上,最后应用实例的上下文对象会设置到根组件的 vnode 的 appContext 属性上。

接下来会在每一个组件初始化的时候会进行设置组件实例对象上的 appContext 属性,这是为什么上面可以通过每个组件的实例(instance.appContext.directives)进行获取全局注册的指令的原因。

至此获取到自定义的指令之后,指令的运行原理则跟 Vue3 系统内置的指令一样了。

而值得注意的是自定义指令并不会在 props 中生成类似 v-model 指令一样的 onUpdate:modelValue 函数。所以自定义指令更加纯粹只是操作普通元素的底层 DOM 访问的逻辑。

组件中的 v-model 实现原理

v-model 指令应用在组件上的时候,就等于是给组件传入了一个名为 modelValue 的 prop,它的值是组件传入的状态变量,此外还会在组件上传入一个名为 onUpdate:modelValue 的监听事件,事件的回调函数拥有一个参数 $event,执行的时候会把参数 $event 赋值给状态变量。

基于此,我们在一个自定义组件上封装 v-model 的基本思路就是,定义一个名为 modelValue 的 prop,然后在数据发生改变的时候,派发一个名为 onUpdate:modelValue 的事件。这样我们就可以在组件上使用 v-model 来实现双向数据绑定了。

此外还有一个值得注意的是在组件上可以进行多个 v-model 绑定,而在元素上则不可以。

复制代码
<UserName
  v-model:first-name="first"
  v-model:last-name="last"
/>

总结

v-model 并不能应用在所有的元素标签中,只能应用在特定的元素标签上,比如 input、textarea、select、单选框 radio、复选框 checkbox,而且应用在这些不同的元素标签上,底层都是通过不通过的标签指令来分别进行不通过的处理。所以 v-model 本质上是一个语法糖,通过 v-model 我们可以很方便快捷地实现数据的双向绑定,也就是数据变化会引起 DOM 的变化,反过来亦然,DOM 的变化也会引起数据的变化。

此外当 v-model 应用在组件上的时候,其实是在传递了一个名为 modelValue 的自定义 props 属性,同时传递一个监听事件 onUpdate:modelValue 的事件,在事件的回调函数中进行数绑定的状态数据的修改。所以说 v-model 也是父子组件数据传输的一种方式,本质上还是通过 props 和 监听事件进行传输的。

此外还可以在组件上应用多个 v-model,而在元素中则不可以。

在组件上 v-model 本质也是一个打通父子组件双向数据通讯的语法糖,在我们 Element Plus 中基本所有的组件都是通过 v-model 完成数据交换的。

此外我们还学习了自定义指令的运行原理,自定义指令本质是 Vue 提供给用户去操作 DOM 元素的一种方式。自定义指令跟内部的 v-model 的指令一样,本质上都是一个 JavaScript 对象,在这个对象上定义不同的生命周期的钩子函数,我们只需要在合适的钩子函数中编写一些相关的处理逻辑。

定义完自定义指令后,需要像自定义组件那样进行局部注册或者全局注册,而且注册和读取的过程也跟自定义组件的原理一样,注册后就可以在 template 中进行应用了。

相关推荐
We་ct1 小时前
LeetCode 105. 从前序与中序遍历序列构造二叉树:题解与思路解析
前端·算法·leetcode·链表·typescript
Roc.Chang2 小时前
Vite 启动报错:listen EACCES: permission denied 0.0.0.0:80 解决方案
linux·前端·vue·vite
Desirediscipline2 小时前
cerr << 是C++中用于输出错误信息的标准用法
java·前端·c++·算法
sunny_2 小时前
前端构建产物里的 __esModule 是什么?一次讲清楚它的原理和作用
前端·架构·前端工程化
Soulkey3 小时前
复刻小红书Web端打开详情过渡动画
前端
yuki_uix3 小时前
你点了「保存」之后,数据都经历了什么?
前端
猪头男3 小时前
【从零开始学习Vue|第六篇】生命周期
前端
zheshiyangyang5 小时前
前端面试基础知识整理【Day-7】
前端·面试·职场和发展
猫头虎5 小时前
web开发常见问题解决方案大全:502/503 Bad Gateway/Connection reset/504 timed out/400 Bad Request/401 Unauthorized
运维·前端·nginx·http·https·gateway·openresty