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
.
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
.
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
.
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.
// Example)
propsExpression = createCallExpression('mergeProps', mergeArgs, elementLoc)
// ↓
propsExpression = createCallExpression(
context.helper(MERGE_PROPS),
mergeArgs,
elementLoc,
)
Pass context
to createVNodeCall
and register CREATE_VNODE
inside it.
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,
}
}
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(`)`)
}
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
}
// 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()
}
// 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 thefunction
- 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.
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.
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.tsconst 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.
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.
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.
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.
// 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:
- 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.
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
.
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.
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.
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.
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]!--
}
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
.
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
.
// 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
.
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