Skip to content

Support for v-on

Refactoring

Before proceeding with the implementation, let's do some refactoring.
Currently, in the code generated by codegen, we are importing (or destructuring) many helper functions exported from shared and runtime-core.
And in the implementation of codegen (and transform), we hardcode the function names. This is not very smart.

This time, let's refactor them as runtime-helper and manage them centrally with symbols, and further, change the implementation to only import what is necessary.

First, let's implement symbols representing each helper in compiler-core/runtimeHelpers.ts.
Until now, we have been using the h function for generating VNodes, but this time, let's change it to use createVNode following the original implementation.
Export createVNode from runtime-core/vnode, and in genVNodeCall, change the code to call createVNode instead of genVNodeCall.

ts
export const CREATE_VNODE = Symbol()
export const MERGE_PROPS = Symbol()
export const NORMALIZE_CLASS = Symbol()
export const NORMALIZE_STYLE = Symbol()
export const NORMALIZE_PROPS = Symbol()

export const helperNameMap: Record<symbol, string> = {
  [CREATE_VNODE]: 'createVNode',
  [MERGE_PROPS]: 'mergeProps',
  [NORMALIZE_CLASS]: 'normalizeClass',
  [NORMALIZE_STYLE]: 'normalizeStyle',
  [NORMALIZE_PROPS]: 'normalizeProps',
}

Make symbols available as callee in CallExpression.

ts
export interface CallExpression extends Node {
  type: NodeTypes.JS_CALL_EXPRESSION
  callee: string | symbol
}

Implement an area to register helpers and a function to register them in TransformContext.

ts
export interface TransformContext extends Required<TransformOptions> {
  currentNode: RootNode | TemplateChildNode | null
  parent: ParentNode | null
  childIndex: number
  helpers: Map<symbol, number> // This
  helper<T extends symbol>(name: T): T // This
}

export function createTransformContext(
  root: RootNode,
  { nodeTransforms = [], directiveTransforms = {} }: TransformOptions,
): TransformContext {
  const context: TransformContext = {
    // .
    // .
    // .
    helpers: new Map(),
    helper(name) {
      const count = context.helpers.get(name) || 0
      context.helpers.set(name, count + 1)
      return name
    },
  }

  return context
}

Replace the hardcoded parts with this helper function and modify the Preamble to use the registered helpers.

ts
// Example)
propsExpression = createCallExpression('mergeProps', mergeArgs, elementLoc)
// ↓
propsExpression = createCallExpression(
  context.helper(MERGE_PROPS),
  mergeArgs,
  elementLoc,
)

Pass context to createVNodeCall and register CREATE_VNODE inside it.

ts
export function createVNodeCall(
  context: TransformContext | null, // This
  tag: VNodeCall['tag'],
  props?: VNodeCall['props'],
  children?: VNodeCall['children'],
  loc: SourceLocation = locStub,
): VNodeCall {
  // Here ------------------------
  if (context) {
    context.helper(CREATE_VNODE)
  }
  // ------------------------

  return {
    type: NodeTypes.VNODE_CALL,
    tag,
    props,
    children,
    loc,
  }
}
ts
function genVNodeCall(
  node: VNodeCall,
  context: CodegenContext,
  option: Required<CompilerOptions>,
) {
  const { push, helper } = context
  const { tag, props, children } = node

  push(helper(CREATE_VNODE) + `(`, node) // Call createVNode
  genNodeList(genNullableArgs([tag, props, children]), context, option)
  push(`)`)
}
ts
export function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)
  traverseNode(root, context)
  root.helpers = new Set([...context.helpers.keys()]) // Add helpers to root
}
ts
// Add `_` as a prefix to alias it according to the original implementation
const aliasHelper = (s: symbol) => `${helperNameMap[s]}: _${helperNameMap[s]}`

function genFunctionPreamble(ast: RootNode, context: CodegenContext) {
  const { push, newline, runtimeGlobalName } = context

  // Generate helper declarations based on the helpers registered in ast
  const helpers = Array.from(ast.helpers)
  push(
    `const { ${helpers.map(aliasHelper).join(', ')} } = ${runtimeGlobalName}\n`,
  )
  newline()
}
ts
// Handle symbols in genCallExpression and convert them to helper calls.

export interface CodegenContext {
  // .
  // .
  // .
  helper(key: symbol): string
}

function createCodegenContext(ast: RootNode): CodegenContext {
  const context: CodegenContext = {
    // .
    // .
    // .
    helper(key) {
      return `_${helperNameMap[key]}`
    },
  }
  // .
  // .
  // .
  return context
}

// .
// .
// .

function genCallExpression(
  node: CallExpression,
  context: CodegenContext,
  option: Required<CompilerOptions>,
) {
  const { push, helper } = context

  // If it is a symbol, get it from the helper
  const callee = isString(node.callee) ? node.callee : helper(node.callee)

  push(callee + `(`, node)
  genNodeList(node.arguments, context, option)
  push(`)`)
}

With this, the refactoring we are doing this time is complete. We were able to clean up the hardcoded parts!

Compilation Result

※ Note

  • The input is using the one from the previous playground
  • There is actually a return before the function
  • The generated code is formatted with prettier

When you look at it like this, there are too many unnecessary line breaks and spaces...

Well, let's improve this somewhere else.

ts
function render(_ctx) {
  with (_ctx) {
    const {
      normalizeProps: _normalizeProps,
      createVNode: _createVNode,
      normalizeClass: _normalizeClass,
    } = ChibiVue

    return _createVNode('div', null, [
      '\n  ',
      _createVNode('p', _normalizeProps({ id: count }), ' v-bind:id="count" '),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({ id: count * 2 }),
        ' :id="count * 2" ',
      ),
      '\n\n  ',
      _createVNode(
        'p',
        _normalizeProps({ ['style' || '']: bind.style }),
        ' v-bind:["style"]="bind.style" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({ ['style' || '']: bind.style }),
        ' :["style"]="bind.style" ',
      ),
      '\n\n  ',
      _createVNode('p', _normalizeProps(bind), ' v-bind="bind" '),
      '\n\n  ',
      _createVNode(
        'p',
        _normalizeProps({ style: { 'font-weight': 'bold' } }),
        ' :style="{ font-weight: \'bold\' }" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({ style: 'font-weight: bold;' }),
        ' :style="\'font-weight: bold;\'" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({
          class: _normalizeClass('my-class my-class2'),
        }),
        ' :class="\'my-class my-class2\'" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({ class: _normalizeClass(['my-class']) }),
        ' :class="[\'my-class\']" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({
          class: _normalizeClass({ 'my-class': true }),
        }),
        ' :class="{ \'my-class\': true }" ',
      ),
      '\n  ',
      _createVNode(
        'p',
        _normalizeProps({
          class: _normalizeClass({ 'my-class': false }),
        }),
        ' :class="{ \'my-class\': false }" ',
      ),
      '\n',
    ])
  }
}

v-on

Developer Interface to Aim for This Time

Now let's move on to the implementation of v-on.

v-on also has various developer interfaces. https://vuejs.org/guide/essentials/event-handling.html

This is what we aim for this time.

ts
import { createApp, defineComponent, ref } from 'chibivue'

const App = defineComponent({
  setup() {
    const count = ref(0)
    const increment = (e: Event) => {
      console.log(e)
      count.value++
    }
    return { count, increment, state: { increment }, eventName: 'click' }
  },

  template: `<div>
    <p>count: {{ count }}</p>

    <button v-on:click="increment">v-on:click="increment"</button>
    <button v-on:[eventName]="increment">v-on:click="increment"</button>
    <button @click="increment">@click="increment"</button>
    <button v-on="{ click: increment }">v-on="{ click: increment }"</button>

    <button @click="state.increment">v-on:click="increment"</button>
    <button @click="count++">@click="count++"</button>
    <button @click="() => count++">@click="() => count++"</button>
    <button @click="increment($event)">@click="increment($event)"</button>
    <button @click="e => increment(e)">@click="e => increment(e)"</button>
</div>`,
})

const app = createApp(App)

app.mount('#app')

What I want to do

Actually, as for the implementation of the Parser, the one from the previous chapter is sufficient, and the problem lies in the implementation of the Transformer.
The content of the transformation changes mainly depending on the presence or absence of arg and the type of exp.
And when there is no arg, what needs to be done is almost the same as v-bind.

In other words, what needs to be considered is the types of exp that can be taken as arg and the transformation of the necessary AST Node for them.

  • Task 1
    Assign a function.
    This is the simplest case.

    html
    <button v-on:click="increment">increment</button>
  • Task 2
    Write a function expression on the spot.
    In this case, you can receive the event as the first argument.

    html
    <button v-on:click="(e) => increment(e)">increment</button>
  • Task 3
    Write a statement other than a function.

    html
    <button @click="count = 0">reset</button>

    It seems that this expression needs to be converted to the following function.

    ts
    ;() => {
      count = 0
    }
  • Task 4
    In cases like Task 3, you can use the identifier $event.
    This is a case where you handle the event object.

    ts
    const App = defineComponent({
      setup() {
        const count = ref(0)
        const increment = (e: Event) => {
          console.log(e)
          count.value++
        }
        return { count, increment, object }
      },
    
      template: `
        <div class="container">
          <button @click="increment($event)">increment($event)</button>
          <p> {{ count }} </p>
        </div>
        `,
    })
    // Cannot be used like @click="() => increment($event)".

    It seems that it needs to be converted to the following function.

    ts
    $event => {
      increment($event)
    }

Implementation

When there is no arg

For the time being, let's implement the case where there is no arg, as it is the same as v-bind.
This is the part where I left a TODO comment in the previous chapter. It's around transformElement.

ts
const isVBind = name === 'bind'
const isVOn = name === 'on' // --------------- Here

// special case for v-bind and v-on with no argument
if (!arg && (isVBind || isVOn)) {
  if (exp) {
    if (isVBind) {
      pushMergeArg()
      mergeArgs.push(exp)
    } else {
      // -------------------------------------- Here
      // v-on="obj" -> toHandlers(obj)
      pushMergeArg({
        type: NodeTypes.JS_CALL_EXPRESSION,
        loc,
        callee: context.helper(TO_HANDLERS),
        arguments: [exp],
      })
    }
  }
  continue
}

const directiveTransform = context.directiveTransforms[name]
if (directiveTransform) {
  const { props } = directiveTransform(prop, node, context)
  if (isVOn && arg && !isStaticExp(arg)) {
    pushMergeArg(createObjectExpression(props, elementLoc))
  } else {
    properties.push(...props)
  }
} else {
  // TODO: custom directive.
}

I will implement the helper function called TO_HANDLERS this time.

This function converts an object passed in the form of v-on="{ click: increment }" to the form of { onClick: increment }.
There is nothing particularly difficult about it.

ts
import { toHandlerKey } from '../../shared'

/**
 * For prefixing keys in v-on="obj" with "on"
 */
export function toHandlers(obj: Record<string, any>): Record<string, any> {
  const ret: Record<string, any> = {}
  for (const key in obj) {
    ret[toHandlerKey(key)] = obj[key]
  }
  return ret
}

This completes the implementation when there is no arg.
Let's move on to the implementation when there is arg.

transformVOn

Now, let's move on to the main theme of this time, which is v-on. There are various formats for the exp of v-on.

ts
increment

state.increment

count++

;() => count++

increment($event)

e => increment(e)

First, these formats can be broadly classified into two categories: "function" and "statement". In Vue, if it is a single Identifier, a single MemberExpression, or a function expression, it is treated as a function. Otherwise, it is a statement. In the source code, it seems to be referred to as inlineStatement.

ts
// function (※ Please consider these as function expressions for convenience.)
increment
state.increment
;() => count++
e => increment(e)

// inlineStatement
count++
increment($event)

In other words, the implementation flow for this time is as follows:

  1. First, determine whether it is a function or not (a single Identifier or a single MemberExpression or a function expression).

2-1. If it is a function, generate an ObjectProperty in the form of eventName: exp without any transformation.

2-2. If it is not a function (if it is an inlineStatement), convert it to the form of $event => { ${exp} } and generate an ObjectProperty.

That's the basic idea.

Determining whether it is a function expression or a statement

Let's start by implementing the determination. Whether it is a function expression or not is done using regular expressions.

ts
const fnExpRE =
  /^\s*([\w$_]+|(async\s*)?\([^)]*?\))\s*(:[^=]+)?=>|^\s*(async\s+)?function(?:\s+[\w$]+)?\s*\(/

const isFn = fnExpRE.test(exp.content)

And whether it is a single Identifier or a single MemberExpression is implemented with a function called isMemberExpression.

ts
const isMemberExp = isMemberExpression(exp.content)

This isMemberExpression function is quite complicated and has a long implementation. It's a bit long, so I'll omit it here. (Please take a look at the code if you're interested.)

Once we have determined this far, the condition for it to be an inlineStatement is anything other than these.

ts
const isMemberExp = isMemberExpression(exp.content)
const isFnExp = fnExpRE.test(exp.content)
const isInlineStatement = !(isMemberExp || isFnExp)

Now that we have determined this, let's implement the transformation process based on this result.

ts
const isMemberExp = isMemberExpression(exp.content)
const isInlineStatement = !(isMemberExp || fnExpRE.test(exp.content))
const hasMultipleStatements = exp.content.includes(`;`)

if (isInlineStatement) {
  // wrap inline statement in a function expression
  exp = createCompoundExpression([
    `$event => ${hasMultipleStatements ? `{` : `(`}`,
    exp,
    hasMultipleStatements ? `}` : `)`,
  ])
}

Issues

Actually, there is a slight issue with the above implementation.

The problem is with $event because in dir.exp, we need to process the values bound from setup using processExpression, but the issue is with $event.
On the AST, $event is also treated as an Identifier, so if we leave it as it is, it will be prefixed with _ctx..

So let's make a little improvement. Let's register a local variable in transformContext. And in walkIdentifiers, we won't execute onIdentifier if there is a local variable.

ts
const context: TransformContext = {
  // .
  // .
  // .
  identifiers: Object.create(null),
  // .
  // .
  addIdentifiers(exp) {
    if (!isBrowser) {
      addId(exp)
    }
  },
  removeIdentifiers(exp) {
    if (!isBrowser) {
      removeId(exp)
    }
  },
}

function addId(id: string) {
  const { identifiers } = context
  if (identifiers[id] === undefined) {
    identifiers[id] = 0
  }
  identifiers[id]!++
}

function removeId(id: string) {
  context.identifiers[id]!--
}
ts
export function walkIdentifiers(
  root: Node,
  onIdentifier: (node: Identifier) => void,
  knownIds: Record<string, number> = Object.create(null), 
) {
  ;(walk as any)(root, {
    enter(node: Node) {
      if (node.type === 'Identifier') {
        const isLocal = !!knownIds[node.name] 
        // prettier-ignore
        if (!isLocal) { 
          onIdentifier(node);
        } 
      }
    },
  })
}

Then, when using walkIdentifiers in processExpression, we will pull identifiers from context.

ts
const ids: QualifiedId[] = []
const knownIds: Record<string, number> = Object.create(ctx.identifiers) 

walkIdentifiers(
  ast,
  node => {
    node.name = rewriteIdentifier(node.name)
    ids.push(node as QualifiedId)
  },
  knownIds, 
)

Finally, when transforming in transformOn, let's register $event.

ts
// prettier-ignore
if (!context.isBrowser) { 
  isInlineStatement && context.addIdentifiers(`$event`); 
  exp = dir.exp = processExpression(exp, context); 
  isInlineStatement && context.removeIdentifiers(`$event`); 
} 

if (isInlineStatement) {
  // wrap inline statement in a function expression
  exp = createCompoundExpression([
    `$event => ${hasMultipleStatements ? `{` : `(`}`,
    exp,
    hasMultipleStatements ? `}` : `)`,
  ])
}

Since v-on requires some special handling, and since it is handled individually in transformOn, we will skip it in transformExpression.

ts
export const transformExpression: NodeTransform = (node, ctx) => {
  // .
  // .
  // .
  if (
    exp &&
    exp.type === NodeTypes.SIMPLE_EXPRESSION &&
    !(dir.name === 'on' && arg) 
  ) {
    dir.exp = processExpression(exp, ctx)
  }
}

Now, we have finished the key part of this time. Let's implement the remaining necessary parts and complete v-on!!

Source code up to this point: GitHub

Released under the MIT License.