From a42ad6cc9d420decccd98df5115a80e721fca862 Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 11 Oct 2019 17:55:20 -0400 Subject: [PATCH] feat(runtime-dom): v-model directive runtime --- packages/runtime-core/src/directives.ts | 30 +-- packages/runtime-dom/src/directives/vModel.ts | 204 +++++++++++++++--- packages/runtime-dom/src/modules/events.ts | 29 ++- packages/runtime-dom/src/modules/props.ts | 5 + packages/shared/src/index.ts | 41 ++++ 5 files changed, 262 insertions(+), 47 deletions(-) diff --git a/packages/runtime-core/src/directives.ts b/packages/runtime-core/src/directives.ts index e3f84dd708..2e46b6dbad 100644 --- a/packages/runtime-core/src/directives.ts +++ b/packages/runtime-core/src/directives.ts @@ -12,7 +12,7 @@ return applyDirectives(h(comp), [ */ 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 { ComponentInternalInstance } from './component' import { currentRenderingInstance } from './componentRenderUtils' @@ -21,26 +21,26 @@ import { ComponentPublicInstance } from './componentProxy' export interface DirectiveBinding { instance: ComponentPublicInstance | null - value?: any - oldValue?: any + value: any + oldValue: any arg?: string - modifiers?: DirectiveModifiers + modifiers: DirectiveModifiers } -export type DirectiveHook = ( - el: any, +export type DirectiveHook = ( + el: T, binding: DirectiveBinding, - vnode: VNode, + vnode: VNode, prevVNode: VNode | null ) => void -export interface Directive { - beforeMount?: DirectiveHook - mounted?: DirectiveHook - beforeUpdate?: DirectiveHook - updated?: DirectiveHook - beforeUnmount?: DirectiveHook - unmounted?: DirectiveHook +export interface Directive { + beforeMount?: DirectiveHook + mounted?: DirectiveHook + beforeUpdate?: DirectiveHook + updated?: DirectiveHook + beforeUnmount?: DirectiveHook + unmounted?: DirectiveHook } type DirectiveModifiers = Record @@ -53,7 +53,7 @@ function applyDirective( directive: Directive, value?: any, arg?: string, - modifiers?: DirectiveModifiers + modifiers: DirectiveModifiers = EMPTY_OBJ ) { let valueCacheForDir = valueCache.get(directive)! if (!valueCacheForDir) { diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts index f44528f8af..87c67c785d 100644 --- a/packages/runtime-dom/src/directives/vModel.ts +++ b/packages/runtime-dom/src/directives/vModel.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + // use mounted & updated because ` + + `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, 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') + } } -export const vModelDynamic: Directive = { - beforeMount(el, binding, vnode) {}, - mounted(el, binding, vnode) {}, - beforeUpdate(el, binding, vnode, prevVNode) {}, - updated(el, binding, vnode) {} +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) } diff --git a/packages/runtime-dom/src/modules/events.ts b/packages/runtime-dom/src/modules/events.ts index d24ec3886b..de0a3714f3 100644 --- a/packages/runtime-dom/src/modules/events.ts +++ b/packages/runtime-dom/src/modules/events.ts @@ -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) } } diff --git a/packages/runtime-dom/src/modules/props.ts b/packages/runtime-dom/src/modules/props.ts index f7519ac93c..0e87ffcd1e 100644 --- a/packages/runtime-dom/src/modules/props.ts +++ b/packages/runtime-dom/src/modules/props.ts @@ -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 } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2b8a1023f4..f4ce62a49a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -64,3 +64,44 @@ export const hyphenate = (str: string): string => { export const capitalize = (str: string): string => { 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 + } +} -- 2.47.3