Skip to content

v-model ディレクティブ

以下のようなコンポーネントを考えます.

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

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

コンパイル結果と概要

コンパイル結果は以下のようになります.

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;
}

何といっても注目するべきは

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

の部分です.

delegate がまた登場しているので,これがイベントハンドラの登録であることは何となくわかりますが,withDirectives_vModelText という謎のものが登場しています.
詳細は後で読むとして,とりあえずコンパイラを読んでみましょう.

コンパイラを読む

transformElement -> buildProps -> transformProps -> directiveTransform -> transformVModel と辿っていきます.

packages/compiler-vapor/src/transforms/vModel.ts まず,context から bindingMetadata というものを取り出しています.

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

これは compiler-sfc が収集したもので,SFC に定義されている変数のメタデータです.
setup で定義された let の変数なのか, props なのか, data なのかなどです.

具体的には以下のように列挙されています.

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}

どのように収集されているかはまたどこかで追いましょう.

expbindingType が props だった場合にはエラーを出力しています.親切ですね.

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  }

そして,以下の分岐以降が本題です.

まずは tag が input textarea select のいずれかである場合です.
今回は input なのでここに該当します.

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

input の場合には type 属性を読みつつ,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        }

先ほど出力に出ていた vModelText はこの変数の初期値のようです.

83  let runtimeDirective: VaporHelper | undefined = 'vModelText'

ここまできたら SET_MODEL_VALUE な operation を登録して,

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

先ほど算出した runtimeDirective を使って withDirectives を登録します.

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

意外とシンプルですね.
あとは Codegen ですがここまで来れば楽勝でしょう.

お決まりの流れです.特に説明はありません.

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}

withDirectives の方は少し codegen の動線が違います.
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[] {

になります.

ランタイムを読む

_delegate(n0, "update:modelValue", () => ($event) => (_ctx.text = $event)); の部分はまあ良いとして,問題は withDirectives_vModelText です.

withDirectives

withDirectives を読んでみましょう. 実装は packages/runtime-vapor/src/directives.ts にあります.

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

nodecomponent と,directives を受け取ります.

DirectiveArguments

DirectiveArguments の定義は以下です.

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'

ややこしいですが,簡単にいうと各ライフサイクルでの挙動を定義しています.
(ディレクティブの動作の実態と言えそうです.)

withDirectives の中身

まず,DirectiveBinding という概念があります.

これは新旧の値や修飾子,ディレクティブ本体 (ObjectDirective), コンポーネントの場合はインスタンスなど,必要な情報をまとめたオブジェクトです.

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}

そして,この withDirectives という関数ですが,名前が複数形になっていることからもわかる通り,複数のディレクティブを適用することができます.
引数で受け取ったディレクティブの配列を 1 つづつ回して処理を行います.

124  for (const directive of directives) {

この for 文で行われていることを見ていきましょう.

まずは定義から各種情報を取り出します.

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

normalize もしておきます

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

ベースとなる binding を定義して

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

sourceReactiveEffect でラップし,その effectscheduler には update trigger を仕込みます.

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    }

update trigger は単純にライフサイクルの beforeUpdate, 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}

最後に created hook を実行しておしまいです.

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

vModelText

さてここまで読めたら次は具体的なディレクティブの実装を読んでいきましょう.\

v-model に関する runtimeDirective は packages/runtime-vapor/src/directives/vModel.ts に実装されています.

今回の vModelText は以下です.

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

ここには,beforeMount, mounted, beforeUpdate, などのこのディレクティブに関するライフサイクルごとの動作が定義されています. 順に見ていきましょう.

beforeMount

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

イベントハンドラの登録を行っています.

値のトリムを行ったり,数値へのキャストを行ったりしながら,値を更新しています.

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

値の更新は delegate されたイベントハンドラから assigner を取得し,それを使っています.

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

v-model は IME などの composing のハンドリングが行われていることが公式ドキュメントでも触れらていますが,これはまさにこの処理です.

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

マウント時は初期値の設定をしておしまいです.

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

beforeUpdate

更新までは composing のハンドリングを行ったり,必要のない更新をスキップするなどの処理を行っています.

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

以上で v-model の動作は理解できたはずです.
vModelText 以外にもさまざまなディレクティブ定義がありますが,同じ要領で読み進めることができるはずです.

そして,今回は runtimeDirective や withDirectives などの新しい概念が登場したので少し長くなりましがた,他のディレクティブについてもこれをベースに読み進めていけるはずです.(スピードも上がるはずです.)
この調子でどんどん読んでいきましょう.