• petite-vue源码剖析-双向绑定`v-model`的工作原理


    前言

    双向绑定v-model不仅仅是对可编辑HTML元素(select, input, textarea和附带[contenteditable=true])同时附加v-bindv-on,而且还能利用通过petite-vue附加给元素的_value_trueValue_falseValue属性提供存储非字符串值的能力。

    深入v-model工作原理

    export const model: Directive<
      HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
    > = ({ el, exp, get, effect, modifers }) => {
      const type = el.type
      // 通过`with`对作用域的变量/属性赋值
      const assign = get(`val => { ${exp} = val }`)
      // 若type为number则默认将值转换为数字
      const { trim, number = type ==== 'number'} = modifiers || {}
    
      if (el.tagName === 'select') {
        const sel = el as HTMLSelectElement
        // 监听控件值变化,更新状态值
        listen(el, 'change', () => {
          const selectedVal = Array.prototype.filter
            .call(sel.options, (o: HTMLOptionElement) => o.selected)
            .map((o: HTMLOptionElement) => number ? toNumber(getValue(o)) : getValue(o))
          assign(sel.multiple ? selectedVal : selectedVal[0])
        })
    
        // 监听状态值变化,更新控件值
        effect(() => {
          value = get()
          const isMultiple = sel.muliple
          for (let i = 0, l = sel.options.length; i < i; i++) {
            const option = sel.options[i]
            const optionValue = getValue(option)
            if (isMulitple) {
              // 当为多选下拉框时,入参要么是数组,要么是Map
              if (isArray(value)) {
                option.selected = looseIndexOf(value, optionValue) > -1
              }
              else {
                option.selected = value.has(optionValue)
              }
            }
            else {
              if (looseEqual(optionValue, value)) {
                if (sel.selectedIndex !== i) sel.selectedIndex = i
                return
              }
            }
          }
        })
      }
      else if (type === 'checkbox') {
        // 监听控件值变化,更新状态值
        listen(el, 'change', () => {
          const modelValue = get()
          const checked = (el as HTMLInputElement).checked
          if (isArray(modelValue)) {
            const elementValue = getValue(el)
            const index = looseIndexOf(modelValue, elementValue)
            const found = index !== -1
            if (checked && !found) {
              // 勾选且之前没有被勾选过的则加入到数组中
              assign(modelValue.concat(elementValue))
            }
            else if (!checked && found) {
              // 没有勾选且之前已勾选的排除后在重新赋值给数组
              const filered = [...modelValue]
              filteed.splice(index, 1)
              assign(filtered)
            }
            // 其它情况就啥都不干咯
          }
          else {
            assign(getCheckboxValue(el as HTMLInputElement, checked))
          }
        })
    
        // 监听状态值变化,更新控件值
        let oldValue: any
        effect(() => {
          const value = get()
          if (isArray(value)) {
            ;(el as HTMLInputElement).checked = 
              looseIndexOf(value, getValue(el)) > -1
          }
          else if (value !== oldValue) {
            ;(el as HTMLInputElement).checked = looseEqual(
              value,
              getCheckboxValue(el as HTMLInputElement, true)
            )
          }
          oldValue = value
        })
      }
      else if (type === 'radio') {
        // 监听控件值变化,更新状态值
        listen(el, 'change', () => {
          assign(getValue(el))
        })
    
        // 监听状态值变化,更新控件值
        let oldValue: any
        effect(() => {
          const value = get()
          if (value !== oldValue) {
            ;(el as HTMLInputElement).checked = looseEqual(value, getValue(el))
          }
        })
      }
      else {
        // input[type=text], textarea, div[contenteditable=true]
        const resolveValue = (value: string) => {
          if (trim) return val.trim()
          if (number) return toNumber(val)
          return val
        }
    
        // 监听是否在输入法编辑器(input method editor)输入内容
        listen(el, 'compositionstart', onCompositionStart)
        listen(el, 'compositionend', onCompositionEnd)
        // change事件是元素失焦后前后值不同时触发,而input事件是输入过程中每次修改值都会触发
        listen(el, modifiers?.lazy ? 'change' : 'input', () => {
          // 元素的composing属性用于标记是否处于输入法编辑器输入内容的状态,如果是则不执行change或input事件的逻辑
          if ((el as any).composing) return
          assign(resolveValue(el.value))
        })
        if (trim) {
          // 若modifiers.trim,那么当元素失焦时马上移除值前后的空格字符
          listen(el, 'change', () => {
            el.value = el.value.trim()
          })
        }
    
        effect(() => {
          if ((el as any).composing) {
            return
          }
          const curVal = el.value
          const newVal = get()
          // 若当前元素处于活动状态(即得到焦点),并且元素当前值进行类型转换后值与新值相同,则不用赋值;
          // 否则只要元素当前值和新值类型或值不相同,都会重新赋值。那么若新值为数组[1,2,3],赋值后元素的值将变成[object Array]
          if (document.activeElement === el && resolveValue(curVal) === newVal) {
            return
          }
          if (curVal !== newVal) {
            el.value = newVal
          }
        })
      }
    }
    
    // v-bind中使用_value属性保存任意类型的值,在v-modal中读取
    const getValue = (el: any) => ('_value' in el ? el._value : el.value)
    
    const getCheckboxValue = (
      el: HTMLInputElement & {_trueValue?: any, _falseValue?: any}, // 通过v-bind定义的任意类型值
      checked: boolean // checkbox的默认值是true和false
    ) => {
      const key = checked ? '_trueValue' : '_falseValue'
      return key in el ? el[key] : checked
    }
    
    const onCompositionStart = (e: Event) => {
      // 通过自定义元素的composing元素,用于标记是否在输入法编辑器中输入内容
      ;(e.target as any).composing = true
    }  
    
    const onCompositionEnd = (e: Event) => {
      const target = e.target as any
      if (target.composing) {
        // 手动触发input事件
        target.composing = false
        trigger(target, 'input')
      }
    }
    
    const trigger = (el: HTMLElement, type: string) => {
      const e = document.createEvent('HTMLEvents')
      e.initEvent(type, true, true)
      el.dispatchEvent(e)
    }
    

    compositionstartcompositionend是什么?

    compositionstart是开始在输入法编辑器上输入字符触发,而compositionend则是在输入法编辑器上输入字符结束时触发,另外还有一个compositionupdate是在输入法编辑器上输入字符过程中触发。

    当我们在输入法编辑器敲击键盘时会按顺序执行如下事件:
    compositionstart -> (compositionupdate -> input)+ -> compositionend -> 当失焦时触发change
    当在输入法编辑器上输入ri后按空格确认字符,则触发如下事件
    compositionstart(data="") -> compositionupdate(data="r") -> input -> compositionupdate(data="ri") -> input -> compositionupdate(data="日") -> input -> compositionend(data="日")

    由于在输入法编辑器上输入字符时会触发input事件,所以petite-vue中通过在对象上设置composing标识是否执行input逻辑。

    事件对象属性如下:

    readonly target: EventTarget // 指向触发事件的HTML元素
    readolny type: DOMString // 事件名称,即compositionstart或compositionend
    readonly bubbles: boolean // 事件是否冒泡
    readonly cancelable: boolean // 事件是否可取消
    readonly view: WindowProxy // 当前文档对象所属的window对象(`document.defaultView`)
    readonly detail: long
    readonly data: DOMString // 最终填写到元素的内容,compositionstart为空,compositionend事件中能获取如"你好"的内容
    readonly locale: DOMString
    

    编码方式触发事件

    DOM Level2的事件中包含HTMLEvents, MouseEvents、MutationEvents和UIEvents,而DOM Level3则增加如CustomEvent等事件类型。

    enum EventType {
      // DOM Level 2 Events
      UIEvents,
      MouseEvents, // event.initMouseEvent
      MutationEvents, // event.initMutationEvent
      HTMLEvents, // event.initEvent
      // DOM Level 3 Events
      UIEvent,
      MouseEvent, // event.initMouseEvent
      MutationEvent, // event.initMutationEvent
      TextEvent, // TextEvents is also supported, event.initTextEvent
      KeyboardEvent, // KeyEvents is also supported, use `new KeyboardEvent()` to create keyboard event
      CustomEvent, // event.initCustomEvent
      Event, // Basic events module, event.initEvent
    }
    
    • HTMLEvents包含abort, blur, change, error, focus, load, reset, resize, scroll, select, submit, unload, input
    • UIEvents包含DOMActive, DOMFocusIn, DOMFocusOut, keydown, keypress, keyup
    • MouseEvents包含click, mousedown, mousemove, mouseout, mouseover, mouseup
    • MutationEvents包含DOMAttrModified,DOMNodeInserted,DOMNodeRemoved,DOMCharacterDataModified,DOMNodeInsertedIntoDocument,DOMNodeRemovedFromDocument,DOMSubtreeModified

    创建和初始化事件对象

    MouseEvent

    方法1

    const e: Event = document.createEvent('MouseEvent')
    e.initMouseEvent(
      type: string,
      bubbles: boolean,
      cancelable: boolean,
      view: AbstractView, // 指向与事件相关的视图,一般为document.defaultView
      detail: number, // 供事件回调函数使用,一般为0
      screenX: number, // 相对于屏幕的x坐标
      screenY: number, // 相对于屏幕的Y坐标
      clientX: number, // 相对于视口的x坐标
      clientY: number, // 相对于视口的Y坐标
      ctrlKey: boolean, // 是否按下Ctrl键
      altKey: boolean, // 是否按下Ctrl键
      shiftKey: boolean, // 是否按下Ctrl键
      metaKey: boolean, // 是否按下Ctrl键
      button: number, // 按下按个鼠标键,默认为0.0左,1中,2右
      relatedTarget: HTMLElement // 指向于事件相关的元素,一般只有在模拟mouseover和mouseout时使用
    )
    

    方法2

    const e: Event = new MouseEvent('click', {
      bubbles: false,
      // ......
    })
    

    KeyboardEvent

    const e = new KeyboardEvent(
      typeArg: string, // 如keypress
      {
        ctrlKey: true,
        // ......
      }
    )
    

    https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/KeyboardEvent

    Event的初始方法

    /**
     * 选项的属性
     * @param {string} name - 事件名称, 如click,input等
     * @param {boolean} [cancelable=false] - 指定事件是否可冒泡
     * @param {boolean} [cancelable=false] - 指定事件是否可被取消
     * @param {boolean} [composed=false] - 指定事件是否会在Shadow DOM根节点外触发事件回调函数
     */
    const e = new Event('input', {
      name: string, 
      bubbles: boolean = false, 
      cancelable: boolean = false, 
      composed: boolean = false
    })
    

    CustomEvent

    方法1

    const e: Event = document.createEvent('CustomEvent')
    e.initMouseEvent(
      type: string,
      bubbles: boolean,
      cancelable: boolean,
      detail: any
    )
    

    方法2

    /**
     * 选项的属性
     * @param {string} name - 事件名称, 如click,input等,可随意定义
     * @param {boolean} [cancelable=false] - 指定事件是否可冒泡
     * @param {boolean} [cancelable=false] - 指定事件是否可被取消
     * @param {any} [detail=null] - 事件初始化时传递的数据
     */
    const e = new CustomEvent('hi', {
      name: string, 
      bubbles: boolean = false, 
      cancelable: boolean = false, 
      detail: any = null
    })
    

    HTMLEvents

    const e: Event = document.createEvent('HTMLEvents')
    e.initMouseEvent(
      type: string,
      bubbles: boolean,
      cancelable: boolean
    )
    

    添加监听和发布事件

    element.addEventListener(type: string)
    element.dispatchEvent(e: Event)
    

    针对petite-vue进行分析

    const onCompositionEnd = (e: Event) => {
      const target = e.target as any
      if (target.composing) {
        // 手动触发input事件
        target.composing = false
        trigger(target, 'input')
      }
    }
    const trigger = (el: HTMLElement, type: string) => {
      const e = document.createEvent('HTMLEvents')
      e.initEvent(type, true, true)
      el.dispatchEvent(e)
    }
    

    当在输入法编辑器操作完毕后会手动触发input事件,但当事件绑定修饰符设置为lazy后并没有绑定input事件回调函数,此时在输入法编辑器操作完毕后并不会自动更新状态,我们又有机会可以贡献代码了:)

    // change事件是元素失焦后前后值不同时触发,而input事件是输入过程中每次修改值都会触发
        listen(el, modifiers?.lazy ? 'change' : 'input', () => {
          // 元素的composing属性用于标记是否处于输入法编辑器输入内容的状态,如果是则不执行change或input事件的逻辑
          if ((el as any).composing) return
          assign(resolveValue(el.value))
        })
    

    外番:IE的事件模拟

    var e = document.createEventObject()
    e.shiftKey = false
    e.button = 0
    document.getElementById('click').fireEvent('onclick', e)
    

    总结

    整合LayUI等DOM-based框架时免不了使用this.$ref获取元素实例,下一篇《petite-vue源码剖析-ref的工作原理》我们一起来探索吧!
    尊重原创,转载请注明来自:https://www.cnblogs.com/fsjohnhuang/p/16004134.html 肥仔John

  • 相关阅读:
    CAN bus的状态
    堆排序(838,839)(堆中的元素编号与插入堆的插入序号相映射)
    Go实现日志2——支持结构化和hook
    艾美捷内毒素纯化树脂说明书
    Linux- 使用ssh远程连接
    C. Omkar and Baseball
    机器学习笔记之最优化理论与方法(十)无约束优化问题——共轭梯度法背景介绍
    项目管理平台—基于Jira平台—工作流
    【Recurrent Neural Network(RNN)】循环神经网络——李宏毅机器学习阅读笔记
    杭电oj--求奇数的乘积
  • 原文地址:https://www.cnblogs.com/fsjohnhuang/p/16004134.html