]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-dom): v-model directive runtime
authorEvan You <yyx990803@gmail.com>
Fri, 11 Oct 2019 21:55:20 +0000 (17:55 -0400)
committerEvan You <yyx990803@gmail.com>
Fri, 11 Oct 2019 21:55:34 +0000 (17:55 -0400)
packages/runtime-core/src/directives.ts
packages/runtime-dom/src/directives/vModel.ts
packages/runtime-dom/src/modules/events.ts
packages/runtime-dom/src/modules/props.ts
packages/shared/src/index.ts

index e3f84dd7083a18fc1f1c80c0324a79472b3ac4b8..2e46b6dbad203507d8a84a92a37c9163b86adead 100644 (file)
@@ -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<T = any> = (
+  el: T,
   binding: DirectiveBinding,
-  vnode: VNode,
+  vnode: VNode<any, T>,
   prevVNode: VNode | null
 ) => void
 
-export interface Directive {
-  beforeMount?: DirectiveHook
-  mounted?: DirectiveHook
-  beforeUpdate?: DirectiveHook
-  updated?: DirectiveHook
-  beforeUnmount?: DirectiveHook
-  unmounted?: DirectiveHook
+export interface Directive<T = any> {
+  beforeMount?: DirectiveHook<T>
+  mounted?: DirectiveHook<T>
+  beforeUpdate?: DirectiveHook<T>
+  updated?: DirectiveHook<T>
+  beforeUnmount?: DirectiveHook<T>
+  unmounted?: DirectiveHook<T>
 }
 
 type DirectiveModifiers = Record<string, boolean>
@@ -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) {
index f44528f8af1a16a66860bfe0315228d3ac7c78cd..87c67c785de8e2a18a6ab279da4bad9d348a0f17 100644 (file)
-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)
+  }
+}
+
+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')
+  }
 }
 
-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)
 }
index d24ec3886b426938210d044086847d46bbc84bd8..de0a3714f3b534f404d47e795b7f4e607473ea28 100644 (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)
   }
 }
 
index f7519ac93ca99ac13004dbdbb9f3d58247fc46bc..0e87ffcd1ef6920acca857e22e6c6b5e8fbeca15 100644 (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
 }
index 2b8a1023f4f12faae54c3c490d299c351775fab1..f4ce62a49ad02e2614db053cae03f5d74cb0505d 100644 (file)
@@ -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
+  }
+}