petite-vue原始碼剖析-雙向繫結`v-model`的工作原理

肥仔John發表於2022-03-14

前言

雙向繫結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...

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/fsjoh... 肥仔John

相關文章