]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat: v-model for input & textarea
author三咲智子 Kevin Deng <sxzz@sxzz.moe>
Sat, 20 Jan 2024 18:16:30 +0000 (02:16 +0800)
committer三咲智子 Kevin Deng <sxzz@sxzz.moe>
Sat, 20 Jan 2024 18:16:53 +0000 (02:16 +0800)
14 files changed:
packages/compiler-vapor/src/compile.ts
packages/compiler-vapor/src/generate.ts
packages/compiler-vapor/src/ir.ts
packages/compiler-vapor/src/transforms/vBind.ts
packages/compiler-vapor/src/transforms/vModel.ts [new file with mode: 0644]
packages/compiler-vapor/src/transforms/vOn.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/directive.ts
packages/runtime-vapor/src/directives/vModel.ts [new file with mode: 0644]
packages/runtime-vapor/src/dom.ts
packages/runtime-vapor/src/dom/on.ts [moved from packages/runtime-vapor/src/on.ts with 64% similarity]
packages/runtime-vapor/src/dom/patchProp.ts
packages/runtime-vapor/src/index.ts
playground/src/todo-mvc.vue

index ab74e5ffd2242d3935b445ca61cbb423d3469985..d271cc1bbcc978bcee201009d0720d4d24878a57 100644 (file)
@@ -24,6 +24,7 @@ import { transformVShow } from './transforms/vShow'
 import { transformRef } from './transforms/transformRef'
 import { transformInterpolation } from './transforms/transformInterpolation'
 import type { HackOptions } from './ir'
+import { transformVModel } from './transforms/vModel'
 
 export type CompilerOptions = HackOptions<BaseCompilerOptions>
 
@@ -103,6 +104,7 @@ export function getBaseTransformPreset(
       html: transformVHtml,
       text: transformVText,
       show: transformVShow,
+      model: transformVModel,
     },
   ]
 }
index b66a4629c42503ec695040af74945a2d43d6eeaa..c6e5202dec01e5d697dced5115d1f3e625bb02d2 100644 (file)
@@ -25,6 +25,7 @@ import {
   type RootIRNode,
   type SetEventIRNode,
   type SetHtmlIRNode,
+  type SetModelValueIRNode,
   type SetPropIRNode,
   type SetRefIRNode,
   type SetTextIRNode,
@@ -389,6 +390,8 @@ function genOperation(oper: OperationNode, context: CodegenContext) {
       return genSetHtml(oper, context)
     case IRNodeTypes.SET_REF:
       return genSetRef(oper, context)
+    case IRNodeTypes.SET_MODEL_VALUE:
+      return genSetModelValue(oper, context)
     case IRNodeTypes.CREATE_TEXT_NODE:
       return genCreateTextNode(oper, context)
     case IRNodeTypes.INSERT_NODE:
@@ -453,6 +456,34 @@ function genSetRef(oper: SetRefIRNode, context: CodegenContext) {
   )
 }
 
+function genSetModelValue(oper: SetModelValueIRNode, context: CodegenContext) {
+  const { vaporHelper, push, newline, pushFnCall } = context
+
+  newline()
+  pushFnCall(
+    vaporHelper('on'),
+    // 1st arg: event name
+    () => push(`n${oper.element}`),
+    // 2nd arg: event name
+    () => {
+      if (isString(oper.key)) {
+        push(JSON.stringify(`update:${camelize(oper.key)}`))
+      } else {
+        push('`update:${')
+        genExpression(oper.key, context)
+        push('}`')
+      }
+    },
+    // 3rd arg: event handler
+    () => {
+      push((context.isTS ? `($event: any)` : `$event`) + ' => ((')
+      // TODO handle not a ref
+      genExpression(oper.value, context)
+      push(') = $event)')
+    },
+  )
+}
+
 function genCreateTextNode(
   oper: CreateTextNodeIRNode,
   context: CodegenContext,
@@ -576,7 +607,7 @@ function genSetEvent(oper: SetEventIRNode, context: CodegenContext) {
 function genWithDirective(oper: WithDirectiveIRNode, context: CodegenContext) {
   const { push, newline, pushFnCall, pushMulti, vaporHelper, bindingMetadata } =
     context
-  const { dir } = oper
+  const { dir, builtin } = oper
 
   // TODO merge directive for the same node
   newline()
@@ -591,6 +622,8 @@ function genWithDirective(oper: WithDirectiveIRNode, context: CodegenContext) {
       pushMulti(['[', ']', ', '], () => {
         if (dir.name === 'show') {
           push(vaporHelper('vShow'))
+        } else if (builtin) {
+          push(vaporHelper(builtin))
         } else {
           const directiveReference = camelize(`v-${dir.name}`)
           // TODO resolve directive
index 20e0d14acd39b51b18b7b9f1206b3aa6085f67cb..dbf381dcb69bc2835810d32cfa72bb590a822285 100644 (file)
@@ -18,6 +18,7 @@ export enum IRNodeTypes {
   SET_EVENT,
   SET_HTML,
   SET_REF,
+  SET_MODEL_VALUE,
 
   INSERT_NODE,
   PREPEND_NODE,
@@ -100,6 +101,14 @@ export interface SetRefIRNode extends BaseIRNode {
   value: IRExpression
 }
 
+export interface SetModelValueIRNode extends BaseIRNode {
+  type: IRNodeTypes.SET_MODEL_VALUE
+  element: number
+  key: IRExpression
+  value: IRExpression
+  isComponent: boolean
+}
+
 export interface CreateTextNodeIRNode extends BaseIRNode {
   type: IRNodeTypes.CREATE_TEXT_NODE
   id: number
@@ -129,6 +138,7 @@ export interface WithDirectiveIRNode extends BaseIRNode {
   type: IRNodeTypes.WITH_DIRECTIVE
   element: number
   dir: VaporDirectiveNode
+  builtin?: string
 }
 
 export type IRNode =
@@ -142,6 +152,7 @@ export type OperationNode =
   | SetEventIRNode
   | SetHtmlIRNode
   | SetRefIRNode
+  | SetModelValueIRNode
   | CreateTextNodeIRNode
   | InsertNodeIRNode
   | PrependNodeIRNode
index ff2799a15807243a36967577045724e6d8d63b2f..1379db91c23434432414b4c71b7cbd60fe184b08 100644 (file)
@@ -3,7 +3,7 @@ import {
   type SimpleExpressionNode,
   createCompilerError,
   createSimpleExpression,
-} from '@vue/compiler-core'
+} from '@vue/compiler-dom'
 import { camelize, isReservedProp } from '@vue/shared'
 import { IRNodeTypes } from '../ir'
 import type { DirectiveTransform } from '../transform'
diff --git a/packages/compiler-vapor/src/transforms/vModel.ts b/packages/compiler-vapor/src/transforms/vModel.ts
new file mode 100644 (file)
index 0000000..adc344f
--- /dev/null
@@ -0,0 +1,167 @@
+import {
+  BindingTypes,
+  DOMErrorCodes,
+  ElementTypes,
+  ErrorCodes,
+  NodeTypes,
+  createCompilerError,
+  createDOMCompilerError,
+  findDir,
+  findProp,
+  hasDynamicKeyVBind,
+  isMemberExpression,
+  isStaticArgOf,
+} from '@vue/compiler-dom'
+import type { DirectiveTransform } from '../transform'
+import { IRNodeTypes } from '..'
+
+export const transformVModel: DirectiveTransform = (dir, node, context) => {
+  const { exp, arg, loc } = dir
+  if (!exp) {
+    context.options.onError(
+      createCompilerError(ErrorCodes.X_V_MODEL_NO_EXPRESSION, dir.loc),
+    )
+    return
+  }
+
+  // we assume v-model directives are always parsed
+  // (not artificially created by a transform)
+  const rawExp = exp.loc.source
+  const expString = exp.content
+
+  // in SFC <script setup> inline mode, the exp may have been transformed into
+  // _unref(exp)
+  const bindingType = context.options.bindingMetadata[rawExp]
+
+  // check props
+  if (
+    bindingType === BindingTypes.PROPS ||
+    bindingType === BindingTypes.PROPS_ALIASED
+  ) {
+    context.options.onError(
+      createCompilerError(ErrorCodes.X_V_MODEL_ON_PROPS, exp.loc),
+    )
+    return
+  }
+
+  const maybeRef =
+    !__BROWSER__ &&
+    context.options.inline &&
+    (bindingType === BindingTypes.SETUP_LET ||
+      bindingType === BindingTypes.SETUP_REF ||
+      bindingType === BindingTypes.SETUP_MAYBE_REF)
+
+  if (
+    !expString.trim() ||
+    (!isMemberExpression(expString, context.options) && !maybeRef)
+  ) {
+    context.options.onError(
+      createCompilerError(ErrorCodes.X_V_MODEL_MALFORMED_EXPRESSION, exp.loc),
+    )
+    return
+  }
+
+  const isComponent = node.tagType === ElementTypes.COMPONENT
+  let runtimeDirective: string | undefined
+
+  if (isComponent) {
+    if (dir.arg)
+      context.options.onError(
+        createDOMCompilerError(
+          DOMErrorCodes.X_V_MODEL_ARG_ON_ELEMENT,
+          dir.arg.loc,
+        ),
+      )
+  } else {
+    const { tag } = node
+    const isCustomElement = context.options.isCustomElement(tag)
+    runtimeDirective = 'vModelText'
+    if (
+      tag === 'input' ||
+      tag === 'textarea' ||
+      tag === 'select' ||
+      isCustomElement
+    ) {
+      if (tag === 'input' || isCustomElement) {
+        const type = findProp(node, 'type')
+        if (type) {
+          if (type.type === NodeTypes.DIRECTIVE) {
+            // :type="foo"
+            runtimeDirective = 'vModelDynamic'
+          } else if (type.value) {
+            switch (type.value.content) {
+              case 'radio':
+                runtimeDirective = 'vModelRadio'
+                break
+              case 'checkbox':
+                runtimeDirective = 'vModelCheckbox'
+                break
+              case 'file':
+                runtimeDirective = undefined
+                context.options.onError(
+                  createDOMCompilerError(
+                    DOMErrorCodes.X_V_MODEL_ON_FILE_INPUT_ELEMENT,
+                    dir.loc,
+                  ),
+                )
+                break
+              default:
+                // text type
+                __DEV__ && checkDuplicatedValue()
+                break
+            }
+          }
+        } else if (hasDynamicKeyVBind(node)) {
+          // element has bindings with dynamic keys, which can possibly contain
+          // "type".
+          runtimeDirective = 'vModelDynamic'
+        } else {
+          // text type
+          __DEV__ && checkDuplicatedValue()
+        }
+      } else if (tag === 'select') {
+        runtimeDirective = 'vModelSelect'
+      } else {
+        // textarea
+        __DEV__ && checkDuplicatedValue()
+      }
+    } else {
+      context.options.onError(
+        createDOMCompilerError(
+          DOMErrorCodes.X_V_MODEL_ON_INVALID_ELEMENT,
+          dir.loc,
+        ),
+      )
+    }
+  }
+
+  context.registerOperation({
+    type: IRNodeTypes.SET_MODEL_VALUE,
+    element: context.reference(),
+    key: (arg && arg.isStatic ? arg.content : arg) || 'modelValue',
+    value: exp,
+    isComponent,
+    loc: loc,
+  })
+
+  if (runtimeDirective)
+    context.registerOperation({
+      type: IRNodeTypes.WITH_DIRECTIVE,
+      element: context.reference(),
+      dir,
+      loc,
+      builtin: runtimeDirective,
+    })
+
+  function checkDuplicatedValue() {
+    const value = findDir(node, 'bind')
+    if (value && isStaticArgOf(value.arg, 'value')) {
+      context.options.onError(
+        createDOMCompilerError(
+          DOMErrorCodes.X_V_MODEL_UNNECESSARY_VALUE,
+          value.loc,
+        ),
+      )
+    }
+  }
+}
index f23faced1c84e406872a49ef50183720414d050a..f2545c602dc1ada4716b27baf82643697dc18a1a 100644 (file)
@@ -2,7 +2,7 @@ import {
   ElementTypes,
   ErrorCodes,
   createCompilerError,
-} from '@vue/compiler-core'
+} from '@vue/compiler-dom'
 import type { DirectiveTransform } from '../transform'
 import { IRNodeTypes, type KeyOverride, type SetEventIRNode } from '../ir'
 import { resolveModifiers } from '@vue/compiler-dom'
index 267b3827dcefb1eb6ee4ffc7c1dd695ba12fb618..9b811086194c65ef444641420073f50ea0be8fad 100644 (file)
@@ -27,6 +27,10 @@ export interface ObjectComponent {
 
 type LifecycleHook<TFn = Function> = TFn[] | null
 
+export interface ElementMetadata {
+  props: Data
+}
+
 export interface ComponentInternalInstance {
   uid: number
   container: ParentNode
@@ -43,6 +47,7 @@ export interface ComponentInternalInstance {
 
   /** directives */
   dirs: Map<Node, DirectiveBinding[]>
+  metadata: WeakMap<Node, ElementMetadata>
 
   // lifecycle
   isMounted: boolean
@@ -151,6 +156,7 @@ export const createComponentInstance = (
     setupState: EMPTY_OBJ,
 
     dirs: new Map(),
+    metadata: new WeakMap(),
 
     // lifecycle
     isMounted: false,
index 3c796d910f655349d1c1bd5c70caf0ba7330dd66..228f751c8c7fb9e44b7173b7398bb6765239ce76 100644 (file)
@@ -7,7 +7,7 @@ import { renderWatch } from './renderWatch'
 export type DirectiveModifiers<M extends string = string> = Record<M, boolean>
 
 export interface DirectiveBinding<V = any, M extends string = string> {
-  instance: ComponentInternalInstance | null
+  instance: ComponentInternalInstance
   source?: () => V
   value: V
   oldValue: V | null
diff --git a/packages/runtime-vapor/src/directives/vModel.ts b/packages/runtime-vapor/src/directives/vModel.ts
new file mode 100644 (file)
index 0000000..811ef10
--- /dev/null
@@ -0,0 +1,100 @@
+import type { ComponentInternalInstance } from '../component'
+import type { ObjectDirective } from '../directive'
+import { on } from '../dom/on'
+import { invokeArrayFns, isArray, looseToNumber } from '@vue/shared'
+
+type AssignerFn = (value: any) => void
+
+function getModelAssigner(
+  el: Element,
+  instance: ComponentInternalInstance,
+): AssignerFn {
+  const metadata = instance.metadata.get(el)!
+  const fn: any = metadata.props['onUpdate:modelValue']
+  return isArray(fn) ? (value) => invokeArrayFns(fn, value) : fn
+}
+
+function onCompositionStart(e: Event) {
+  ;(e.target as any).composing = true
+}
+
+function onCompositionEnd(e: Event) {
+  const target = e.target as any
+  if (target.composing) {
+    target.composing = false
+    target.dispatchEvent(new Event('input'))
+  }
+}
+
+const assignKeyMap = new WeakMap<HTMLElement, AssignerFn>()
+
+// 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: ObjectDirective<
+  HTMLInputElement | HTMLTextAreaElement
+> = {
+  beforeMount(el, { instance, modifiers: { lazy, trim, number } = {} }) {
+    const assigner = getModelAssigner(el, instance)
+    assignKeyMap.set(el, assigner)
+
+    const castToNumber = number // || (vnode.props && vnode.props.type === 'number')
+    on(el, lazy ? 'change' : 'input', (e) => {
+      if ((e.target as any).composing) return
+      let domValue: string | number = el.value
+      if (trim) {
+        domValue = domValue.trim()
+      }
+      if (castToNumber) {
+        domValue = looseToNumber(domValue)
+      }
+      assigner(domValue)
+    })
+    if (trim) {
+      on(el, 'change', () => {
+        el.value = el.value.trim()
+      })
+    }
+    if (!lazy) {
+      on(el, 'compositionstart', onCompositionStart)
+      on(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.
+      on(el, 'change', onCompositionEnd)
+    }
+  },
+  // set value on mounted so it's after min/max for type="range"
+  mounted(el, { value }) {
+    el.value = value == null ? '' : value
+  },
+  beforeUpdate(
+    el,
+    { instance, value, modifiers: { lazy, trim, number } = {} },
+  ) {
+    assignKeyMap.set(el, getModelAssigner(el, instance))
+
+    // avoid clearing unresolved text. #2302
+    if ((el as any).composing) return
+
+    const elValue =
+      number || el.type === 'number' ? looseToNumber(el.value) : el.value
+    const newValue = value == null ? '' : value
+
+    if (elValue === newValue) {
+      return
+    }
+
+    // eslint-disable-next-line no-restricted-globals
+    if (document.activeElement === el && el.type !== 'range') {
+      if (lazy) {
+        return
+      }
+      if (trim && el.value.trim() === newValue) {
+        return
+      }
+    }
+
+    el.value = newValue
+  },
+}
index 2641a33d1458ae22a60ce04914544cbd9c280912..44540940eabc74465d938b636ddae76d292c8de1 100644 (file)
@@ -3,6 +3,7 @@ import type { Block, ParentBlock } from './render'
 
 export * from './dom/patchProp'
 export * from './dom/templateRef'
+export * from './dom/on'
 
 export function insert(block: Block, parent: Node, anchor: Node | null = null) {
   // if (!isHydrating) {
similarity index 64%
rename from packages/runtime-vapor/src/on.ts
rename to packages/runtime-vapor/src/dom/on.ts
index a25f47cc8959c86011752e12df2e9dd4197240b4..47d7bfe1a5f785d7e70c078492cf85d4677d0697 100644 (file)
@@ -1,11 +1,14 @@
 import { getCurrentEffect, onEffectCleanup } from '@vue/reactivity'
+import { recordPropMetadata } from './patchProp'
+import { toHandlerKey } from '@vue/shared'
 
 export function on(
   el: HTMLElement,
   event: string,
-  handler: () => any,
+  handler: (...args: any) => any,
   options?: AddEventListenerOptions,
 ) {
+  recordPropMetadata(el, toHandlerKey(event), handler)
   el.addEventListener(event, handler, options)
   if (getCurrentEffect()) {
     onEffectCleanup(() => el.removeEventListener(event, handler, options))
index daeb5b68300cc050205fc205368d66a968582b18..97eb8cd3f13a511ebe15279aa96fd3ad110567ea 100644 (file)
@@ -4,15 +4,18 @@ import {
   normalizeClass,
   normalizeStyle,
 } from '@vue/shared'
+import { currentInstance } from '../component'
 
 export function setClass(el: Element, oldVal: any, newVal: any) {
   if ((newVal = normalizeClass(newVal)) !== oldVal && (newVal || oldVal)) {
+    recordPropMetadata(el, 'class', newVal)
     el.className = newVal
   }
 }
 
 export function setStyle(el: HTMLElement, oldVal: any, newVal: any) {
   if ((newVal = normalizeStyle(newVal)) !== oldVal && (newVal || oldVal)) {
+    recordPropMetadata(el, 'style', newVal)
     if (typeof newVal === 'string') {
       el.style.cssText = newVal
     } else {
@@ -23,6 +26,7 @@ export function setStyle(el: HTMLElement, oldVal: any, newVal: any) {
 
 export function setAttr(el: Element, key: string, oldVal: any, newVal: any) {
   if (newVal !== oldVal) {
+    recordPropMetadata(el, key, newVal)
     if (newVal != null) {
       el.setAttribute(key, newVal)
     } else {
@@ -34,6 +38,7 @@ export function setAttr(el: Element, key: string, oldVal: any, newVal: any) {
 export function setDOMProp(el: any, key: string, oldVal: any, newVal: any) {
   // TODO special checks
   if (newVal !== oldVal) {
+    recordPropMetadata(el, key, newVal)
     el[key] = newVal
   }
 }
@@ -64,6 +69,16 @@ export function setDynamicProp(
   }
 }
 
+export function recordPropMetadata(el: Node, key: string, value: any) {
+  if (currentInstance) {
+    let metadata = currentInstance.metadata.get(el)
+    if (!metadata) {
+      currentInstance.metadata.set(el, (metadata = { props: {} }))
+    }
+    metadata.props[key] = value
+  }
+}
+
 // TODO copied from runtime-dom
 const isNativeOn = (key: string) =>
   key.charCodeAt(0) === 111 /* o */ &&
index 244853c537d2e65bfd2c0947f811bbf615d12a69..cc411068ee4702c34a4d5b40692b5dcacf6dcced 100644 (file)
@@ -41,13 +41,14 @@ export { withModifiers, withKeys } from '@vue/runtime-dom'
 
 export { nextTick } from './scheduler'
 export { getCurrentInstance, type ComponentInternalInstance } from './component'
-export * from './on'
 export * from './render'
 export * from './renderWatch'
 export * from './template'
 export * from './apiWatch'
 export * from './directive'
 export * from './dom'
-export * from './directives/vShow'
 export * from './apiLifecycle'
 export * from './if'
+
+export * from './directives/vShow'
+export * from './directives/vModel'
index e6228f8c494772ccd1954a95c76de648ee47233a..75fcf27b837dbb89b8b10d883c3271e6d76ffa8a 100644 (file)
@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue/vapor'
+import { ref } from 'vue/vapor'
 
 interface Task {
   title: string
@@ -7,7 +7,6 @@ interface Task {
 }
 const tasks = ref<Task[]>([])
 const value = ref('hello')
-const inputRef = ref<HTMLInputElement>()
 
 function handleAdd() {
   tasks.value.push({
@@ -17,11 +16,6 @@ function handleAdd() {
   // TODO: clear input
   value.value = ''
 }
-
-onMounted(() => {
-  console.log('onMounted')
-  console.log(inputRef.value)
-})
 </script>
 
 <template>
@@ -45,12 +39,7 @@ onMounted(() => {
       {{ tasks[3]?.title }}
     </li>
     <li>
-      <input
-        type="text"
-        :ref="el => (inputRef = el)"
-        :value="value"
-        @input="evt => (value = evt.target.value)"
-      />
+      <input type="text" v-model="value" />
       <button @click="handleAdd">Add</button>
     </li>
   </ul>