Skip to content

The v-model Directive

Consider a component like the following.

vue
<script setup>
import { ref } from "vue";
const text = ref("");
</script>

<template>
  <input v-model="text" />
</template>

Compilation Result and Overview

The compilation result is as follows.

js
const _sfc_main = {
  vapor: true,
  __name: "App",
  setup(__props, { expose: __expose }) {
    __expose();

    const text = ref("");

    const __returned__ = { text, ref };
    Object.defineProperty(__returned__, "__isScriptSetup", {
      enumerable: false,
      value: true,
    });
    return __returned__;
  },
};

import {
  vModelText as _vModelText,
  withDirectives as _withDirectives,
  delegate as _delegate,
  template as _template,
} from "vue/vapor";

const t0 = _template("<input>");

function _sfc_render(_ctx) {
  const n0 = t0();
  _withDirectives(n0, [[_vModelText, () => _ctx.text]]);
  _delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event));
  return n0;
}

What stands out the most is

js
_withDirectives(n0, [[_vModelText, () => _ctx.text]]);
_delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event));

Since delegate appears again, it somewhat indicates that this is for registering event handlers, but mysterious elements like withDirectives and _vModelText appear.
While the details will be read later, let's first read the compiler.

Reading the Compiler

Follow the path: transformElement -> buildProps -> transformProps -> directiveTransform -> transformVModel.

packages/compiler-vapor/src/transforms/vModel.ts First, bindingMetadata is extracted from context.

34  const bindingType = context.options.bindingMetadata[rawExp]

This is collected by compiler-sfc and contains metadata about variables defined in the SFC, such as whether a let variable is defined in setup, or if it's a prop, data, etc.
Specifically, it is enumerated as follows.

109export enum BindingTypes {
110  /**
111   * returned from data()
112   */
113  DATA = 'data',
114  /**
115   * declared as a prop
116   */
117  PROPS = 'props',
118  /**
119   * a local alias of a `<script setup>` destructured prop.
120   * the original is stored in __propsAliases of the bindingMetadata object.
121   */
122  PROPS_ALIASED = 'props-aliased',
123  /**
124   * a let binding (may or may not be a ref)
125   */
126  SETUP_LET = 'setup-let',
127  /**
128   * a const binding that can never be a ref.
129   * these bindings don't need `unref()` calls when processed in inlined
130   * template expressions.
131   */
132  SETUP_CONST = 'setup-const',
133  /**
134   * a const binding that does not need `unref()`, but may be mutated.
135   */
136  SETUP_REACTIVE_CONST = 'setup-reactive-const',
137  /**
138   * a const binding that may be a ref.
139   */
140  SETUP_MAYBE_REF = 'setup-maybe-ref',
141  /**
142   * bindings that are guaranteed to be refs
143   */
144  SETUP_REF = 'setup-ref',
145  /**
146   * declared by other options, e.g. computed, inject
147   */
148  OPTIONS = 'options',
149  /**
150   * a literal constant, e.g. 'foo', 1, true
151   */
152  LITERAL_CONST = 'literal-const',
153}

How it is collected will be traced elsewhere.

If the bindingType of exp is props, an error is thrown. Very considerate.

37  if (
38    bindingType === BindingTypes.PROPS ||
39    bindingType === BindingTypes.PROPS_ALIASED
40  ) {
41    context.options.onError(
42      createCompilerError(ErrorCodes.X_V_MODEL_ON_PROPS, exp.loc),
43    )
44    return
45  }

Then, the following branching is the main topic.

First, if the tag is one of input, textarea, or select.
In this case, it's input, so it matches here.

84  if (
85    tag === 'input' ||
86    tag === 'textarea' ||
87    tag === 'select' ||

For input, it reads the type attribute and determines the runtimeDirective.

90    if (tag === 'input' || isCustomElement) {
91      const type = findProp(node, 'type')
92      if (type) {
93        if (type.type === NodeTypes.DIRECTIVE) {
94          // :type="foo"
95          runtimeDirective = 'vModelDynamic'
96        } else if (type.value) {
97          switch (type.value.content) {
98            case 'radio':
99              runtimeDirective = 'vModelRadio'
100              break
101            case 'checkbox':
102              runtimeDirective = 'vModelCheckbox'
103              break
104            case 'file':
105              runtimeDirective = undefined
106              context.options.onError(
107                createDOMCompilerError(
108                  DOMErrorCodes.X_V_MODEL_ON_FILE_INPUT_ELEMENT,
109                  dir.loc,
110                ),
111              )
112              break
113            default:
114              // text type
115              __DEV__ && checkDuplicatedValue()
116              break
117          }
118        }

The previously outputted vModelText seems to be the initial value of this variable.

83  let runtimeDirective: VaporHelper | undefined = 'vModelText'

At this point, it registers an operation called SET_MODEL_VALUE, and

142  context.registerOperation({
143    type: IRNodeTypes.SET_MODEL_VALUE,
144    element: context.reference(),
145    key: arg || createSimpleExpression('modelValue', true),
146    value: exp,
147    isComponent,
148  })

uses the previously calculated runtimeDirective to register withDirectives.

151    context.registerOperation({
152      type: IRNodeTypes.WITH_DIRECTIVE,
153      element: context.reference(),
154      dir,
155      name: runtimeDirective,
156      builtin: true,
157    })

It's surprisingly simple.
As for Codegen, it's a breeze once you reach this point.

It's the usual flow. No particular explanation needed.

33export function genOperation(
34  oper: OperationNode,
35  context: CodegenContext,
36): CodeFragment[] {
52    case IRNodeTypes.SET_MODEL_VALUE:
53      return genSetModelValue(oper, context)
8export function genSetModelValue(
9  oper: SetModelValueIRNode,
10  context: CodegenContext,
11): CodeFragment[] {
12  const { vaporHelper } = context
13  const name = oper.key.isStatic
14    ? [JSON.stringify(`update:${camelize(oper.key.content)}`)]
15    : ['`update:${', ...genExpression(oper.key, context), '}`']
16
17  const handler = genModelHandler(oper.value, context)
18
19  return [
20    NEWLINE,
21    ...genCall(vaporHelper('delegate'), `n${oper.element}`, name, handler),
22  ]
23}
24
25export function genModelHandler(
26  value: SimpleExpressionNode,
27  context: CodegenContext,
28): CodeFragment[] {
29  const {
30    options: { isTS },
31  } = context
32
33  return [
34    `() => ${isTS ? `($event: any)` : `$event`} => (`,
35    ...genExpression(value, context, '$event'),
36    ')',
37  ]
38}

The withDirectives part has a slightly different Codegen flow.
It follows genBlockContent -> genChildren -> genDirectivesForElement -> genWithDirective.

36export function genBlockContent(
37  block: BlockIRNode,
38  context: CodegenContext,
39  root?: boolean,
40  customReturns?: (returns: CodeFragment[]) => CodeFragment[],
41): CodeFragment[] {

51  for (const child of dynamic.children) {
52    push(...genChildren(child, context, child.id!))
53  }

18export function genChildren(
19  dynamic: IRDynamicInfo,
20  context: CodegenContext,
21  from: number,
22  paths: number[] = [],
23): CodeFragment[] {

31    push(...genDirectivesForElement(id, context))

or

31    push(...genDirectivesForElement(id, context))

23export function genDirectivesForElement(
24  id: number,
25  context: CodegenContext,
26): CodeFragment[] {
27  const dirs = filterDirectives(id, context.block.operation)
28  return dirs.length ? genWithDirective(dirs, context) : []
29}

31export function genWithDirective(
32  opers: WithDirectiveIRNode[],
33  context: CodegenContext,
34): CodeFragment[] {

is the path.

Reading the Runtime

While the part _delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event)); is fine, the issue lies with withDirectives and _vModelText.

withDirectives

Let's read withDirectives. The implementation is in packages/runtime-vapor/src/directives.ts.

93export function withDirectives<T extends ComponentInternalInstance | Node>(
94  nodeOrComponent: T,
95  directives: DirectiveArguments,
96): T {

withDirectives receives either node or component, and directives.

DirectiveArguments

The definition of DirectiveArguments is as follows.

81export type DirectiveArguments = Array<
82  | [Directive | undefined]
83  | [Directive | undefined, () => any]
84  | [Directive | undefined, () => any, argument: string]
85  | [
86      Directive | undefined,
87      value: () => any,
88      argument: string,
89      modifiers: DirectiveModifiers,
90    ]
91>
71export type Directive<T = any, V = any, M extends string = string> =
72  | ObjectDirective<T, V, M>
73  | FunctionDirective<T, V, M>
65export type FunctionDirective<
66  T = any,
67  V = any,
68  M extends string = string,
69> = DirectiveHook<T, V, M>
58export type ObjectDirective<T = any, V = any, M extends string = string> = {
59  [K in DirectiveHookName]?: DirectiveHook<T, V, M> | undefined
60} & {
61  /** Watch value deeply */
62  deep?: boolean | number
63}
41export type DirectiveHook<
42  T = any | null,
43  V = any,
44  M extends string = string,
45> = (node: T, binding: DirectiveBinding<T, V, M>) => void
46
47// create node -> `created` -> node operation -> `beforeMount` -> node mounted -> `mounted`
48// effect update -> `beforeUpdate` -> node updated -> `updated`
49// `beforeUnmount`-> node unmount -> `unmounted`
50export type DirectiveHookName =
51  | 'created'
52  | 'beforeMount'
53  | 'mounted'
54  | 'beforeUpdate'
55  | 'updated'
56  | 'beforeUnmount'
57  | 'unmounted'

It's somewhat complicated, but simply put, it defines the behavior for each lifecycle.
(This seems to be the actual behavior of the directive.)

Inside withDirectives

First, there is a concept called DirectiveBinding.

This is an object that bundles necessary information, such as old and new values, modifiers, the directive itself (ObjectDirective), and in the case of a component, the instance.

29export interface DirectiveBinding<T = any, V = any, M extends string = string> {
30  instance: ComponentInternalInstance
31  source?: () => V
32  value: V
33  oldValue: V | null
34  arg?: string
35  modifiers?: DirectiveModifiers<M>
36  dir: ObjectDirective<T, V, M>
37}

Then, this function withDirectives, as the name suggests, can apply multiple directives.
It processes each directive in the array of directives received as arguments.

124  for (const directive of directives) {

Let's look at what is done in this for loop.

First, extract various information from the definition.

125    let [dir, source, arg, modifiers] = directive

Also, normalize.

127    if (isFunction(dir)) {
128      dir = {
129        mounted: dir,
130        updated: dir,
131      } satisfies ObjectDirective
132    }

Define the base binding.

134    const binding: DirectiveBinding = {
135      dir,
136      instance,
137      value: null, // set later
138      oldValue: undefined,
139      arg,
140      modifiers,
141    }

Wrap source with ReactiveEffect, and set up the scheduler of that effect to include update triggers.

143    if (source) {
144      if (dir.deep) {
145        const deep = dir.deep === true ? undefined : dir.deep
146        const baseSource = source
147        source = () => traverse(baseSource(), deep)
148      }
149
150      const effect = new ReactiveEffect(() =>
151        callWithErrorHandling(
152          source!,
153          instance,
154          VaporErrorCodes.RENDER_FUNCTION,
155        ),
156      )
157      const triggerRenderingUpdate = createRenderingUpdateTrigger(
158        instance,
159        effect,
160      )
161      effect.scheduler = () => queueJob(triggerRenderingUpdate)
162
163      binding.source = effect.run.bind(effect)
164    }

The update trigger is simply a trigger that executes the lifecycle's beforeUpdate and updated.

228export function createRenderingUpdateTrigger(
229  instance: ComponentInternalInstance,
230  effect: ReactiveEffect,
231): SchedulerJob {
232  job.id = instance.uid
233  return job
234  function job() {
235    if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) {
236      return
237    }
238
239    if (instance.isMounted && !instance.isUpdating) {
240      instance.isUpdating = true
241      const reset = setCurrentInstance(instance)
242
243      const { bu, u, scope } = instance
244      const { dirs } = scope
245      // beforeUpdate hook
246      if (bu) {
247        invokeArrayFns(bu)
248      }
249      invokeDirectiveHook(instance, 'beforeUpdate', scope)
250
251      queuePostFlushCb(() => {
252        instance.isUpdating = false
253        const reset = setCurrentInstance(instance)
254        if (dirs) {
255          invokeDirectiveHook(instance, 'updated', scope)
256        }
257        // updated hook
258        if (u) {
259          queuePostFlushCb(u)
260        }
261        reset()
262      })
263      reset()
264    }
265  }
266}

Finally, execute the created hook.

168    callDirectiveHook(node, binding, instance, 'created')

vModelText

Now that we've read this far, next let's read the implementation of the specific directive.
The runtimeDirective related to v-model is implemented in packages/runtime-vapor/src/directives/vModel.ts.

This time, vModelText is as follows.

44export const vModelText: ObjectDirective<
45  HTMLInputElement | HTMLTextAreaElement,
46  any,
47  'lazy' | 'trim' | 'number'
48> = {

Here, the behavior for each lifecycle related to this directive, such as beforeMount, mounted, beforeUpdate, etc., are defined. Let's look at them one by one.

beforeMount

49  beforeMount(el, { modifiers: { lazy, trim, number } = {} }) {

Registers the event handler.

Performs trimming of values and casts to numbers while updating the values.

56    addEventListener(el, lazy ? 'change' : 'input', e => {
57      if ((e.target as any).composing) return
58      let domValue: string | number = el.value
59      if (trim) {
60        domValue = domValue.trim()
61      }
62      if (castToNumber) {
63        domValue = looseToNumber(domValue)
64      }
65      assigner(domValue)
66    })

Value updates use the assigner obtained from delegate event handlers.

56    addEventListener(el, lazy ? 'change' : 'input', e => {
57      if ((e.target as any).composing) return
58      let domValue: string | number = el.value
59      if (trim) {
60        domValue = domValue.trim()
61      }
62      if (castToNumber) {
63        domValue = looseToNumber(domValue)
64      }
65      assigner(domValue)
66    })
21function getModelAssigner(el: Element): AssignerFn {
22  const metadata = getMetadata(el)
23  const fn = metadata[MetadataKind.event]['update:modelValue'] || []
24  return value => invokeArrayFns(fn, value)
25}

Tips

Although the official documentation mentions that v-model handles composing such as IME, this is exactly this process.

56    addEventListener(el, lazy ? 'change' : 'input', e => {
57      if ((e.target as any).composing) return
73      addEventListener(el, 'compositionstart', onCompositionStart)
74      addEventListener(el, 'compositionend', onCompositionEnd)
27function onCompositionStart(e: Event) {
28  ;(e.target as any).composing = true
29}
30
31function onCompositionEnd(e: Event) {
32  const target = e.target as any
33  if (target.composing) {
34    target.composing = false
35    target.dispatchEvent(new Event('input'))
36  }
37}

mounted

At mount time, it simply sets the initial value.

83  mounted(el, { value }) {
84    el.value = value == null ? '' : value
85  },

beforeUpdate

Handles composing, such as IME, and skips unnecessary updates up to update.

86  beforeUpdate(el, { value, modifiers: { lazy, trim, number } = {} }) {
87    assignFnMap.set(el, getModelAssigner(el))
88
89    // avoid clearing unresolved text. #2302
90    if ((el as any).composing) return
91
92    const elValue =
93      number || el.type === 'number' ? looseToNumber(el.value) : el.value
94    const newValue = value == null ? '' : value
95
96    if (elValue === newValue) {
97      return
98    }
99
100    // eslint-disable-next-line no-restricted-globals
101    if (document.activeElement === el && el.type !== 'range') {
102      if (lazy) {
103        return
104      }
105      if (trim && el.value.trim() === newValue) {
106        return
107      }
108    }
109
110    el.value = newValue
111  },

With that, the operation of v-model should be understood.
There are various other directive definitions besides vModelText, but you should be able to proceed in the same way based on this.

And, since new concepts such as runtimeDirective and withDirectives have appeared this time, it became a bit lengthy, but you should be able to continue reading other directives based on this. (Your speed should also increase.)
Let's keep reading in this manner.