关于vue.js:petitevue源码剖析双向绑定vmodel的工作原理

36次阅读

共计 8070 个字符,预计需要花费 21 分钟才能阅读完成。

前言

双向绑定 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 的工作原理》咱们一起来摸索吧!

正文完
 0