petite-vue原始碼剖析-雙向繫結`v-model`的工作原理
前言
雙向繫結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,
// ......
}
)
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的工作原理》我們一起來探索吧!