乐趣区

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

前言
双向绑定 v -model 不仅仅是对可编辑 HTML 元素 (select, input, textarea 和附带[contenteditable=true]) 同时附加 v -bind 和 v -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)
}
复制代码

compositionstart 和 compositionend 是什么?
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,
    // ......
  }
)
复制代码

developer.mozilla.org/en-US/docs/…

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 获取元素实例

最初
如果你感觉此文对你有一丁点帮忙,点个赞。或者能够退出我的开发交换群:1025263163 互相学习,咱们会有业余的技术答疑解惑

如果你感觉这篇文章对你有点用的话,麻烦请给咱们的开源我的项目点点 star:http://github.crmeb.net/u/defu 不胜感激!

PHP 学习手册:https://doc.crmeb.com
技术交换论坛:https://q.crmeb.com

退出移动版