mirror of https://github.com/vuejs/core.git
feat: v-model for input & textarea
This commit is contained in:
parent
782d60475d
commit
31e8fa35c0
|
@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
|
@ -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 */ &&
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue