文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

Vue 3.0 进阶之双向绑定探秘

2024-12-03 11:32

关注

本文转载自微信公众号「全栈修仙之路」,作者阿宝哥。转载本文请联系全栈修仙之路公众号。  

本文是 Vue 3.0 进阶系列 的第三篇文章,在阅读本文前,建议你先阅读 Vue 3.0 进阶之指令探秘 和 Vue 3.0 进阶之自定义事件探秘 这两篇文章。在看具体示例前,阿宝哥先来简单介绍一下双向绑定,它由两个单向绑定组成:

在 Vue 中 :value 实现了 模型到视图 的数据绑定,@event 实现了 视图到模型 的事件绑定:

  1. "searchText" @input="searchText = $event.target.value" /> 

而在表单中,通过使用内置的 v-model 指令,我们可以轻松地实现双向绑定,比如 。介绍完上面的内容,接下来阿宝哥将以一个简单的示例为切入点,带大家一起一步步揭开双向绑定背后的秘密。

  1. "app"
  2.    "searchText" /> 
  3.    

    搜索的内容:{{searchText}}

     
 
  •  
  • 在以上示例中,我们在 input 搜索输入框中应用了 v-model 指令,当输入框的内容发生变化时,p 标签中内容会同步更新。

    要揭开 v-model 指令背后的秘密,我们可以利用 Vue 3 Template Explorer 在线工具,来看一下模板编译后的结果:

    1. "searchText" /> 
    2.  
    3. const _Vue = Vue 
    4. return function render(_ctx, _cache, $props, $setup, $data, $options) { 
    5.   with (_ctx) { 
    6.     const { vModelText: _vModelText, createVNode: _createVNode,  
    7.       withDirectives: _withDirectives, openBlock: _openBlock, createBlock: _createBlock } = _Vue 
    8.  
    9.     return _withDirectives((_openBlock(), _createBlock("input", { 
    10.       "onUpdate:modelValue": $event => (searchText = $event) 
    11.     }, null, 8 , ["onUpdate:modelValue"])),  
    12.     [  
    13.       [_vModelText, searchText]  
    14.     ]) 
    15.   } 

    在 模板生成的渲染函数中,我们看到了 Vue 3.0 进阶之指令探秘 文章中介绍的 withDirectives 函数,该函数用于把指令信息添加到 VNode 对象上,它被定义在 runtime-core/src/directives.ts 文件中:

    1. // packages/runtime-core/src/directives.ts 
    2. export function withDirectives
    3.   vnode: T, 
    4.   directives: DirectiveArguments 
    5. ): T { 
    6.   const internalInstance = currentRenderingInstance 
    7.   // 省略部分代码 
    8.   const instance = internalInstance.proxy 
    9.   const bindings: DirectiveBinding[] = vnode.dirs || (vnode.dirs = []) 
    10.   for (let i = 0; i < directives.length; i++) { 
    11.     let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i] 
    12.     // 在 mounted 和 updated 时,触发相同行为,而不关系其他的钩子函数 
    13.     if (isFunction(dir)) { // 处理函数类型指令 
    14.       dir = { 
    15.         mounted: dir, 
    16.         updated: dir 
    17.       } as ObjectDirective 
    18.     } 
    19.     bindings.push({ // 把指令信息保存到vnode.dirs数组中 
    20.       dir, instance, value,  
    21.       oldValue: void 0, arg, modifiers 
    22.     }) 
    23.   } 
    24.   return vnode 

    除此之外,在模板生成的渲染函数中,我们看到了 vModelText 指令,通过它的名称,我们猜测该指令与模型相关,所以我们先来分析 vModelText 指令。

    一、vModelText 指令

    vModelText 指令是 ObjectDirective 类型的指令,该指令中定义了 3 个钩子函数:

    1. // packages/runtime-dom/src/directives/vModel.ts 
    2. type ModelDirective = ObjectDirective 
    3.  
    4. export const vModelText: ModelDirective< 
    5.   HTMLInputElement | HTMLTextAreaElement 
    6. > = { 
    7.   created(el, { modifiers: { lazy, trim, number } }, vnode) { 
    8.     // ... 
    9.   }, 
    10.   mounted(el, { value }) { 
    11.     // .. 
    12.   }, 
    13.   beforeUpdate(el, { value, modifiers: { trim, number } }, vnode) { 
    14.     // .. 
    15.   } 

    接下来,阿宝哥将逐一分析每个钩子函数,这里先从 created 钩子函数开始。

    1.1 created 钩子

    1. // packages/runtime-dom/src/directives/vModel.ts 
    2. export const vModelText: ModelDirective< 
    3.   HTMLInputElement | HTMLTextAreaElement 
    4. > = { 
    5.   created(el, { modifiers: { lazy, trim, number } }, vnode) { 
    6.     el._assign = getModelAssigner(vnode) 
    7.     const castToNumber = number || el.type === 'number' // 是否转为数值类型 
    8.     // 若使用 lazy 修饰符,则在 change 事件触发后将输入框的值与数据进行同步 
    9.     addEventListener(el, lazy ? 'change' : 'input', e => {  
    10.       if ((e.target as any).composing) return // 组合输入进行中 
    11.       let domValue: string | number = el.value 
    12.       if (trim) { // 自动过滤用户输入的首尾空白字符 
    13.         domValue = domValue.trim() 
    14.       } else if (castToNumber) { // 自动将用户的输入值转为数值类型 
    15.         domValue = toNumber(domValue) 
    16.       } 
    17.       el._assign(domValue) // 更新模型 
    18.     }) 
    19.     if (trim) { 
    20.       addEventListener(el, 'change', () => { 
    21.         el.value = el.value.trim() 
    22.       }) 
    23.     } 
    24.     if (!lazy) { 
    25.       addEventListener(el, 'compositionstart', onCompositionStart) 
    26.       addEventListener(el, 'compositionend', onCompositionEnd) 
    27.       // Safari < 10.2 & UIWebView doesn't fire compositionend when 
    28.       // switching focus before confirming composition choice 
    29.       // this also fixes the issue where some browsers e.g. iOS Chrome 
    30.       // fires "change" instead of "input" on autocomplete. 
    31.       addEventListener(el, 'change', onCompositionEnd) 
    32.     } 
    33.   }, 

    对于 created 方法来说,它会通过解构的方式获取 v-model 指令上添加的修饰符,在 v-model 上可以添加 .lazy、.number 和 .trim 修饰符。这里我们简单介绍一下 3 种修饰符的作用:

    1. "age" type="number" /> 
    1. "msg" /> 

    而在 created 方法内部,会通过 getModelAssigner 函数获取 ModelAssigner,从而用于更新模型对象。

    1. // packages/runtime-dom/src/directives/vModel.ts 
    2. const getModelAssigner = (vnode: VNode): AssignerFn => { 
    3.   const fn = vnode.props!['onUpdate:modelValue'
    4.   return isArray(fn) ? value => invokeArrayFns(fn, value) : fn 

    对于我们的示例来说,通过 getModelAssigner 函数获取的 ModelAssigner 对象是 $event => (searchText = $event) 函数。在获取 ModelAssigner 对象之后,我们就可以更新模型的值了。created 方法中的其他代码相对比较简单,阿宝哥就不详细介绍了。这里我们来介绍一下 compositionstart 和 compositionend 事件。

    中文、日文、韩文等需要借助输入法组合输入,即使是英文,也可以利用组合输入进行选词等操作。在一些实际场景中,我们希望等用户组合输入完的一段文字才进行对应操作,而不是每输入一个字母,就执行相关操作。

    比如,在关键字搜索场景中,等用户完整输入 阿宝哥 之后再执行搜索操作,而不是输入字母 a 之后就开始搜索。要实现这个功能,我们就需要借助 compositionstart 和 compositionend 事件。另外,需要注意的是,compositionstart 事件发生在 input 事件之前,因此利用它可以优化中文输入的体验。

    了解完 compositionstart(组合输入开始) 和 compositionend (组合输入结束)事件,我们再来看一下 onCompositionStart 和 onCompositionEnd 这两个事件处理器:

    1. function onCompositionStart(e: Event) { 
    2.   ;(e.target as any).composing = true 
    3.  
    4. function onCompositionEnd(e: Event) { 
    5.   const target = e.target as any 
    6.   if (target.composing) {  
    7.     target.composing = false 
    8.     trigger(target, 'input'
    9.   } 
    10.  
    11. // 触发元素上的指定事件 
    12. function trigger(el: HTMLElement, type: string) { 
    13.   const e = document.createEvent('HTMLEvents'
    14.   e.initEvent(type, truetrue
    15.   el.dispatchEvent(e) 

    当组合输入时,在 onCompositionStart 事件处理器中,会 e.target 对象上添加 composing 属性并设置该属性的值为 true。而在 change 事件或 input 事件回调函数中,如果发现 e.target 对象的 composing 属性为 true 则会直接返回。当组合输入完成后,在 onCompositionEnd 事件处理器中,会把 target.composing 的值设置为 false 并手动触发 input 事件:

    1. // packages/runtime-dom/src/directives/vModel.ts 
    2. export const vModelText: ModelDirective< 
    3.   HTMLInputElement | HTMLTextAreaElement 
    4. > = { 
    5.   created(el, { modifiers: { lazy, trim, number } }, vnode) { 
    6.     // 省略部分代码 
    7.     addEventListener(el, lazy ? 'change' : 'input', e => { 
    8.       if ((e.target as any).composing) return 
    9.      // ... 
    10.     }) 
    11.   }, 

    好的,created 钩子函数就分析到这里,接下来我们来分析 mounted 钩子。

    1.2 mounted 钩子

    1. // packages/runtime-dom/src/directives/vModel.ts 
    2. export const vModelText: ModelDirective< 
    3.   HTMLInputElement | HTMLTextAreaElement 
    4. > = { 
    5.   // set value on mounted so it's after min/max for type="range" 
    6.   mounted(el, { value }) { 
    7.     el.value = value == null ? '' : value 
    8.   }, 

    mounted 钩子的逻辑很简单,如果 value 值为 null 时,把元素的值设置为空字符串,否则直接使用 value 的值。

    1.3 beforeUpdate 钩子

    1. // packages/runtime-dom/src/directives/vModel.ts 
    2. export const vModelText: ModelDirective< 
    3.   HTMLInputElement | HTMLTextAreaElement 
    4. > = { 
    5.   beforeUpdate(el, { value, modifiers: { trim, number } }, vnode) { 
    6.     el._assign = getModelAssigner(vnode) 
    7.     // avoid clearing unresolved text. #2302 
    8.     if ((el as any).composing) return 
    9.     if (document.activeElement === el) { 
    10.       if (trim && el.value.trim() === value) { 
    11.         return 
    12.       } 
    13.       if ((number || el.type === 'number') && toNumber(el.value) === value) { 
    14.         return 
    15.       } 
    16.     } 
    17.     const newValue = value == null ? '' : value 
    18.     if (el.value !== newValue) { // 新旧值不相等时,执行更新操作 
    19.       el.value = newValue 
    20.     } 
    21.   } 

    相信使用过 Vue 的小伙伴都知道,v-model 指令不仅可以应用在 input 和 textarea 元素上,在复选框(Checkbox)、单选框(Radio)和选择框(Select)上也可以使用 v-model 指令。不过需要注意的是,虽然这些元素上都是使用 v-model 指令,但实际上对于复选框、单选框和选择框来说,它们是由不同的指令来完成对应的功能。这里我们以单选框为例,来看一下应用 v-model 指令后,模板编译的结果:

    1. "radio" value="One" v-model="picked" /> 
    2.  
    3. const _Vue = Vue 
    4. return function render(_ctx, _cache, $props, $setup, $data, $options) { 
    5.   with (_ctx) { 
    6.     const { vModelRadio: _vModelRadio, createVNode: _createVNode,  
    7.       withDirectives: _withDirectives, openBlock: _openBlock, createBlock: _createBlock } = _Vue 
    8.  
    9.     return _withDirectives((_openBlock(), _createBlock("input", { 
    10.       type: "radio"
    11.       value: "One"
    12.       "onUpdate:modelValue": $event => (picked = $event) 
    13.     }, null, 8 , ["onUpdate:modelValue"])), [ 
    14.       [_vModelRadio, picked] 
    15.     ]) 
    16.   } 

    由以上代码可知,在单选框应用 v-model 指令后,双向绑定的功能会交给 vModelRadio 指令来实现。除了 vModelRadio 之外,还有 vModelSelect 和 vModelCheckbox 指令,它们被定义在 runtime-dom/src/directives/vModel.ts 文件中,感兴趣的小伙伴可以自行研究一下。

    其实 v-model 本质上是语法糖。它负责监听用户的输入事件来更新数据,并在某些场景下进行一些特殊处理。需要注意的是 v-model 会忽略所有表单元素的 value、checked、selected attribute 的初始值而总是将当前活动实例的数据作为数据来源。你应该通过在组件的 data 选项中声明初始值。

    此外,v-model 在内部为不同的输入元素使用不同的 property 并抛出不同的事件:

    这里你已经知道,可以用 v-model 指令在表单