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 中进行应用了。