feat(runtime-dom): v-model directive runtime

This commit is contained in:
Evan You
2019-10-11 17:55:20 -04:00
parent a371b2ec0e
commit a42ad6cc9d
5 changed files with 262 additions and 47 deletions

View File

@@ -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
// be tree-shaken in case v-model is never used.
export const vModelText: Directive = {
beforeMount(el, binding) {
el.value = binding.value
export const vModelText: Directive<HTMLInputElement | HTMLTextAreaElement> = {
beforeMount(el, { value, modifiers: { lazy } }, vnode) {
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, binding, vnode, prevVNode) {},
updated(el, binding, vnode) {}
beforeUpdate(el, { value }) {
// TODO number & trim handling
el.value = value
}
}
export const vModelRadio: Directive = {
beforeMount(el, binding, vnode) {},
mounted(el, binding, vnode) {},
beforeUpdate(el, binding, vnode, prevVNode) {},
updated(el, binding, vnode) {}
export const vModelCheckbox: Directive<HTMLInputElement> = {
beforeMount(el, { value }, vnode) {
// TODO handle array checkbox & number modifier
el.checked = !!value
const assign = getModelAssigner(vnode)
addEventListener(el, 'change', () => {
assign(el.checked)
})
},
beforeUpdate(el, { value }) {
el.checked = !!value
}
}
export const vModelCheckbox: Directive = {
beforeMount(el, binding, vnode) {},
mounted(el, binding, vnode) {},
beforeUpdate(el, binding, vnode, prevVNode) {},
updated(el, binding, vnode) {}
export const vModelRadio: Directive<HTMLInputElement> = {
beforeMount(el, { value }, vnode) {
// TODO number modifier
el.checked = looseEqual(value, vnode.props!.value)
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 = {
beforeMount(el, binding, vnode) {},
mounted(el, binding, vnode) {},
beforeUpdate(el, binding, vnode, prevVNode) {},
updated(el, binding, vnode) {}
export const vModelSelect: Directive<HTMLSelectElement> = {
// use mounted & updated because <select> relies on its children <option>s.
mounted(el, { value }, vnode) {
setSelected(el, value)
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 = {
beforeMount(el, binding, vnode) {},
mounted(el, binding, vnode) {},
beforeUpdate(el, binding, vnode, prevVNode) {},
updated(el, binding, vnode) {}
function setSelected(el: HTMLSelectElement, value: any) {
const isMultiple = el.multiple
if (isMultiple && !isArray(value)) {
__DEV__ &&
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)
}

View File

@@ -47,6 +47,24 @@ const reset = () => {
}
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(
el: Element,
name: string,
@@ -71,12 +89,12 @@ export function patchEvent(
prev.once !== next.once
) {
if (invoker) {
el.removeEventListener(name, invoker as any, prevOptions as any)
removeEventListener(el, name, invoker, prev)
}
if (nextValue && value) {
const invoker = createInvoker(value, instance)
nextValue.invoker = invoker
el.addEventListener(name, invoker, nextOptions as any)
addEventListener(el, name, invoker, next)
}
return
}
@@ -89,14 +107,15 @@ export function patchEvent(
nextValue.invoker = invoker
invoker.lastUpdated = getNow()
} else {
el.addEventListener(
addEventListener(
el,
name,
createInvoker(value, instance),
nextOptions as any
nextOptions || void 0
)
}
} else if (invoker) {
el.removeEventListener(name, invoker, prevOptions as any)
removeEventListener(el, name, invoker, prevOptions || void 0)
}
}

View File

@@ -13,5 +13,10 @@ export function patchDOMProp(
if ((key === 'innerHTML' || key === 'textContent') && prevChildren != null) {
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
}