feat(runtime-dom): v-model directive runtime
This commit is contained in:
parent
a371b2ec0e
commit
a42ad6cc9d
@ -12,7 +12,7 @@ return applyDirectives(h(comp), [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { VNode, cloneVNode } from './vnode'
|
import { VNode, cloneVNode } from './vnode'
|
||||||
import { extend, isArray, isFunction } from '@vue/shared'
|
import { extend, isArray, isFunction, EMPTY_OBJ } from '@vue/shared'
|
||||||
import { warn } from './warning'
|
import { warn } from './warning'
|
||||||
import { ComponentInternalInstance } from './component'
|
import { ComponentInternalInstance } from './component'
|
||||||
import { currentRenderingInstance } from './componentRenderUtils'
|
import { currentRenderingInstance } from './componentRenderUtils'
|
||||||
@ -21,26 +21,26 @@ import { ComponentPublicInstance } from './componentProxy'
|
|||||||
|
|
||||||
export interface DirectiveBinding {
|
export interface DirectiveBinding {
|
||||||
instance: ComponentPublicInstance | null
|
instance: ComponentPublicInstance | null
|
||||||
value?: any
|
value: any
|
||||||
oldValue?: any
|
oldValue: any
|
||||||
arg?: string
|
arg?: string
|
||||||
modifiers?: DirectiveModifiers
|
modifiers: DirectiveModifiers
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DirectiveHook = (
|
export type DirectiveHook<T = any> = (
|
||||||
el: any,
|
el: T,
|
||||||
binding: DirectiveBinding,
|
binding: DirectiveBinding,
|
||||||
vnode: VNode,
|
vnode: VNode<any, T>,
|
||||||
prevVNode: VNode | null
|
prevVNode: VNode | null
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
export interface Directive {
|
export interface Directive<T = any> {
|
||||||
beforeMount?: DirectiveHook
|
beforeMount?: DirectiveHook<T>
|
||||||
mounted?: DirectiveHook
|
mounted?: DirectiveHook<T>
|
||||||
beforeUpdate?: DirectiveHook
|
beforeUpdate?: DirectiveHook<T>
|
||||||
updated?: DirectiveHook
|
updated?: DirectiveHook<T>
|
||||||
beforeUnmount?: DirectiveHook
|
beforeUnmount?: DirectiveHook<T>
|
||||||
unmounted?: DirectiveHook
|
unmounted?: DirectiveHook<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
type DirectiveModifiers = Record<string, boolean>
|
type DirectiveModifiers = Record<string, boolean>
|
||||||
@ -53,7 +53,7 @@ function applyDirective(
|
|||||||
directive: Directive,
|
directive: Directive,
|
||||||
value?: any,
|
value?: any,
|
||||||
arg?: string,
|
arg?: string,
|
||||||
modifiers?: DirectiveModifiers
|
modifiers: DirectiveModifiers = EMPTY_OBJ
|
||||||
) {
|
) {
|
||||||
let valueCacheForDir = valueCache.get(directive)!
|
let valueCacheForDir = valueCache.get(directive)!
|
||||||
if (!valueCacheForDir) {
|
if (!valueCacheForDir) {
|
||||||
|
@ -1,40 +1,190 @@
|
|||||||
import { Directive } from '@vue/runtime-core'
|
import { Directive, VNode, DirectiveBinding, warn } from '@vue/runtime-core'
|
||||||
|
import { addEventListener } from '../modules/events'
|
||||||
|
import { looseEqual, isArray } from '@vue/shared'
|
||||||
|
|
||||||
|
const getModelAssigner = (vnode: VNode): ((value: any) => void) =>
|
||||||
|
vnode.props!['onUpdate:modelValue']
|
||||||
|
|
||||||
|
function onCompositionStart(e: CompositionEvent) {
|
||||||
|
;(e.target as any).composing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCompositionEnd(e: CompositionEvent) {
|
||||||
|
const target = e.target as any
|
||||||
|
if (target.composing) {
|
||||||
|
target.composing = false
|
||||||
|
trigger(target, 'input')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trigger(el: HTMLElement, type: string) {
|
||||||
|
const e = document.createEvent('HTMLEvents')
|
||||||
|
e.initEvent(type, true, true)
|
||||||
|
el.dispatchEvent(e)
|
||||||
|
}
|
||||||
|
|
||||||
// We are exporting the v-model runtime directly as vnode hooks so that it can
|
// We are exporting the v-model runtime directly as vnode hooks so that it can
|
||||||
// be tree-shaken in case v-model is never used.
|
// be tree-shaken in case v-model is never used.
|
||||||
export const vModelText: Directive = {
|
export const vModelText: Directive<HTMLInputElement | HTMLTextAreaElement> = {
|
||||||
beforeMount(el, binding) {
|
beforeMount(el, { value, modifiers: { lazy } }, vnode) {
|
||||||
el.value = binding.value
|
el.value = value
|
||||||
|
const assign = getModelAssigner(vnode)
|
||||||
|
addEventListener(el, lazy ? 'change' : 'input', () => {
|
||||||
|
// TODO number & trim modifiers
|
||||||
|
assign(el.value)
|
||||||
|
})
|
||||||
|
if (!lazy) {
|
||||||
|
addEventListener(el, 'compositionstart', onCompositionStart)
|
||||||
|
addEventListener(el, 'compositionend', onCompositionEnd)
|
||||||
|
// Safari < 10.2 & UIWebView doesn't fire compositionend when
|
||||||
|
// switching focus before confirming composition choice
|
||||||
|
// this also fixes the issue where some browsers e.g. iOS Chrome
|
||||||
|
// fires "change" instead of "input" on autocomplete.
|
||||||
|
addEventListener(el, 'change', onCompositionEnd)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
mounted(el, binding, vnode) {},
|
beforeUpdate(el, { value }) {
|
||||||
beforeUpdate(el, binding, vnode, prevVNode) {},
|
// TODO number & trim handling
|
||||||
updated(el, binding, vnode) {}
|
el.value = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const vModelRadio: Directive = {
|
export const vModelCheckbox: Directive<HTMLInputElement> = {
|
||||||
beforeMount(el, binding, vnode) {},
|
beforeMount(el, { value }, vnode) {
|
||||||
mounted(el, binding, vnode) {},
|
// TODO handle array checkbox & number modifier
|
||||||
beforeUpdate(el, binding, vnode, prevVNode) {},
|
el.checked = !!value
|
||||||
updated(el, binding, vnode) {}
|
const assign = getModelAssigner(vnode)
|
||||||
|
addEventListener(el, 'change', () => {
|
||||||
|
assign(el.checked)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
beforeUpdate(el, { value }) {
|
||||||
|
el.checked = !!value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const vModelCheckbox: Directive = {
|
export const vModelRadio: Directive<HTMLInputElement> = {
|
||||||
beforeMount(el, binding, vnode) {},
|
beforeMount(el, { value }, vnode) {
|
||||||
mounted(el, binding, vnode) {},
|
// TODO number modifier
|
||||||
beforeUpdate(el, binding, vnode, prevVNode) {},
|
el.checked = looseEqual(value, vnode.props!.value)
|
||||||
updated(el, binding, vnode) {}
|
const assign = getModelAssigner(vnode)
|
||||||
|
addEventListener(el, 'change', () => {
|
||||||
|
assign(getValue(el))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
beforeUpdate(el, { value }, vnode) {
|
||||||
|
// TODO number modifier
|
||||||
|
el.checked = looseEqual(value, vnode.props!.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const vModelSelect: Directive = {
|
export const vModelSelect: Directive<HTMLSelectElement> = {
|
||||||
beforeMount(el, binding, vnode) {},
|
// use mounted & updated because <select> relies on its children <option>s.
|
||||||
mounted(el, binding, vnode) {},
|
mounted(el, { value }, vnode) {
|
||||||
beforeUpdate(el, binding, vnode, prevVNode) {},
|
setSelected(el, value)
|
||||||
updated(el, binding, vnode) {}
|
const assign = getModelAssigner(vnode)
|
||||||
|
addEventListener(el, 'change', () => {
|
||||||
|
const selectedVal = Array.prototype.filter
|
||||||
|
.call(el.options, (o: HTMLOptionElement) => o.selected)
|
||||||
|
.map(getValue)
|
||||||
|
assign(el.multiple ? selectedVal : selectedVal[0])
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updated(el, { value }) {
|
||||||
|
setSelected(el, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const vModelDynamic: Directive = {
|
function setSelected(el: HTMLSelectElement, value: any) {
|
||||||
beforeMount(el, binding, vnode) {},
|
const isMultiple = el.multiple
|
||||||
mounted(el, binding, vnode) {},
|
if (isMultiple && !isArray(value)) {
|
||||||
beforeUpdate(el, binding, vnode, prevVNode) {},
|
__DEV__ &&
|
||||||
updated(el, binding, vnode) {}
|
warn(
|
||||||
|
`<select multiple v-model> ` +
|
||||||
|
`expects an Array value for its binding, but got ${Object.prototype.toString
|
||||||
|
.call(value)
|
||||||
|
.slice(8, -1)}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let selected, option
|
||||||
|
for (let i = 0, l = el.options.length; i < l; i++) {
|
||||||
|
option = el.options[i]
|
||||||
|
if (isMultiple) {
|
||||||
|
selected = looseIndexOf(value, getValue(option)) > -1
|
||||||
|
if (option.selected !== selected) {
|
||||||
|
option.selected = selected
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (looseEqual(getValue(option), value)) {
|
||||||
|
if (el.selectedIndex !== i) {
|
||||||
|
el.selectedIndex = i
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isMultiple) {
|
||||||
|
el.selectedIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looseIndexOf(arr: Array<any>, val: any): number {
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
if (looseEqual(arr[i], val)) return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve raw value set via :value bindings
|
||||||
|
function getValue(el: HTMLOptionElement | HTMLInputElement) {
|
||||||
|
return '_value' in el ? (el as any)._value : el.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vModelDynamic: Directive<
|
||||||
|
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||||
|
> = {
|
||||||
|
beforeMount(el, binding, vnode) {
|
||||||
|
callModelHook(el, binding, vnode, null, 'beforeMount')
|
||||||
|
},
|
||||||
|
mounted(el, binding, vnode) {
|
||||||
|
callModelHook(el, binding, vnode, null, 'mounted')
|
||||||
|
},
|
||||||
|
beforeUpdate(el, binding, vnode, prevVNode) {
|
||||||
|
callModelHook(el, binding, vnode, prevVNode, 'beforeUpdate')
|
||||||
|
},
|
||||||
|
updated(el, binding, vnode, prevVNode) {
|
||||||
|
callModelHook(el, binding, vnode, prevVNode, 'updated')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function callModelHook(
|
||||||
|
el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
|
||||||
|
binding: DirectiveBinding,
|
||||||
|
vnode: VNode,
|
||||||
|
prevVNode: VNode | null,
|
||||||
|
hook: keyof Directive
|
||||||
|
) {
|
||||||
|
let modelToUse: Directive
|
||||||
|
switch (el.tagName) {
|
||||||
|
case 'SELECT':
|
||||||
|
modelToUse = vModelSelect
|
||||||
|
break
|
||||||
|
case 'TEXTAREA':
|
||||||
|
modelToUse = vModelText
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
switch (el.type) {
|
||||||
|
case 'checkbox':
|
||||||
|
modelToUse = vModelCheckbox
|
||||||
|
break
|
||||||
|
case 'radio':
|
||||||
|
modelToUse = vModelRadio
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
modelToUse = vModelText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fn = modelToUse[hook]
|
||||||
|
fn && fn(el, binding, vnode, prevVNode)
|
||||||
}
|
}
|
||||||
|
@ -47,6 +47,24 @@ const reset = () => {
|
|||||||
}
|
}
|
||||||
const getNow = () => cachedNow || (p.then(reset), (cachedNow = _getNow()))
|
const getNow = () => cachedNow || (p.then(reset), (cachedNow = _getNow()))
|
||||||
|
|
||||||
|
export function addEventListener(
|
||||||
|
el: Element,
|
||||||
|
event: string,
|
||||||
|
handler: EventListener,
|
||||||
|
options?: EventListenerOptions
|
||||||
|
) {
|
||||||
|
el.addEventListener(event, handler, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEventListener(
|
||||||
|
el: Element,
|
||||||
|
event: string,
|
||||||
|
handler: EventListener,
|
||||||
|
options?: EventListenerOptions
|
||||||
|
) {
|
||||||
|
el.removeEventListener(event, handler, options)
|
||||||
|
}
|
||||||
|
|
||||||
export function patchEvent(
|
export function patchEvent(
|
||||||
el: Element,
|
el: Element,
|
||||||
name: string,
|
name: string,
|
||||||
@ -71,12 +89,12 @@ export function patchEvent(
|
|||||||
prev.once !== next.once
|
prev.once !== next.once
|
||||||
) {
|
) {
|
||||||
if (invoker) {
|
if (invoker) {
|
||||||
el.removeEventListener(name, invoker as any, prevOptions as any)
|
removeEventListener(el, name, invoker, prev)
|
||||||
}
|
}
|
||||||
if (nextValue && value) {
|
if (nextValue && value) {
|
||||||
const invoker = createInvoker(value, instance)
|
const invoker = createInvoker(value, instance)
|
||||||
nextValue.invoker = invoker
|
nextValue.invoker = invoker
|
||||||
el.addEventListener(name, invoker, nextOptions as any)
|
addEventListener(el, name, invoker, next)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -89,14 +107,15 @@ export function patchEvent(
|
|||||||
nextValue.invoker = invoker
|
nextValue.invoker = invoker
|
||||||
invoker.lastUpdated = getNow()
|
invoker.lastUpdated = getNow()
|
||||||
} else {
|
} else {
|
||||||
el.addEventListener(
|
addEventListener(
|
||||||
|
el,
|
||||||
name,
|
name,
|
||||||
createInvoker(value, instance),
|
createInvoker(value, instance),
|
||||||
nextOptions as any
|
nextOptions || void 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (invoker) {
|
} else if (invoker) {
|
||||||
el.removeEventListener(name, invoker, prevOptions as any)
|
removeEventListener(el, name, invoker, prevOptions || void 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,5 +13,10 @@ export function patchDOMProp(
|
|||||||
if ((key === 'innerHTML' || key === 'textContent') && prevChildren != null) {
|
if ((key === 'innerHTML' || key === 'textContent') && prevChildren != null) {
|
||||||
unmountChildren(prevChildren, parentComponent, parentSuspense)
|
unmountChildren(prevChildren, parentComponent, parentSuspense)
|
||||||
}
|
}
|
||||||
|
if (key === 'value' && el.tagName !== 'PROGRESS') {
|
||||||
|
// store value as _value as well since
|
||||||
|
// non-string values will be stringified.
|
||||||
|
el._value = value
|
||||||
|
}
|
||||||
el[key] = value == null ? '' : value
|
el[key] = value == null ? '' : value
|
||||||
}
|
}
|
||||||
|
@ -64,3 +64,44 @@ export const hyphenate = (str: string): string => {
|
|||||||
export const capitalize = (str: string): string => {
|
export const capitalize = (str: string): string => {
|
||||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two values are loosely equal - that is,
|
||||||
|
* if they are plain objects, do they have the same shape?
|
||||||
|
*/
|
||||||
|
export function looseEqual(a: any, b: any): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
const isObjectA = isObject(a)
|
||||||
|
const isObjectB = isObject(b)
|
||||||
|
if (isObjectA && isObjectB) {
|
||||||
|
try {
|
||||||
|
const isArrayA = isArray(a)
|
||||||
|
const isArrayB = isArray(b)
|
||||||
|
if (isArrayA && isArrayB) {
|
||||||
|
return (
|
||||||
|
a.length === b.length &&
|
||||||
|
a.every((e: any, i: any) => looseEqual(e, b[i]))
|
||||||
|
)
|
||||||
|
} else if (a instanceof Date && b instanceof Date) {
|
||||||
|
return a.getTime() === b.getTime()
|
||||||
|
} else if (!isArrayA && !isArrayB) {
|
||||||
|
const keysA = Object.keys(a)
|
||||||
|
const keysB = Object.keys(b)
|
||||||
|
return (
|
||||||
|
keysA.length === keysB.length &&
|
||||||
|
keysA.every(key => looseEqual(a[key], b[key]))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if (!isObjectA && !isObjectB) {
|
||||||
|
return String(a) === String(b)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user