4

petite-vue源码剖析-双向绑定`v-model`的工作原理 - ^_^肥仔John

 2 years ago
source link: https://www.cnblogs.com/fsjohnhuang/p/16004134.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

双向绑定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

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时使用
)
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

const e: Event = document.createEvent('CustomEvent')
e.initMouseEvent(
  type: string,
  bubbles: boolean,
  cancelable: boolean,
  detail: any
)
/**
 * 选项的属性
 * @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的工作原理》我们一起来探索吧!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK