feat: v-model for input & textarea

This commit is contained in:
三咲智子 Kevin Deng 2024-01-21 02:16:30 +08:00
parent 782d60475d
commit 31e8fa35c0
No known key found for this signature in database
GPG Key ID: 69992F2250DFD93E
14 changed files with 348 additions and 20 deletions

View 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,
},
]
}

View 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

View 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

View 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'

View File

@ -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,
),
)
}
}
}

View 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'

View 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,

View 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

View File

@ -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
},
}

View 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) {

View 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))

View 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 */ &&

View 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'

View 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>