Mustache and State Binding
Target Component for This Section
Now, let's continue reading through various components in the same manner.
Next, take a look at the following component:
<script setup>
import { ref } from "vue";
const count = ref(0);
</script>
<template>
<p>{{ count }}</p>
</template>
In the above code, count
doesn't change, so it's not a very practical example, but we have defined a state and bound it using mustache.
Compilation Result
First, let's see what the compilation result of this SFC looks like.
import { ref } from "vue";
const _sfc_main = {
vapor: true,
__name: "App",
setup(__props, { expose: __expose }) {
__expose();
const count = ref(0);
const __returned__ = { count, ref };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
};
import {
renderEffect as _renderEffect,
setText as _setText,
template as _template,
} from "vue/vapor";
const t0 = _template("<p></p>");
function _sfc_render(_ctx) {
const n0 = t0();
_renderEffect(() => _setText(n0, _ctx.count));
return n0;
}
import _export_sfc from "/@id/__x00__plugin-vue:export-helper";
export default /*#__PURE__*/ _export_sfc(_sfc_main, [
["render", _sfc_render],
["vapor", true],
["__file", "/path/to/core-vapor/playground/src/App.vue"],
]);
The structure has become slightly more complex than the previous component, but the basic structure remains the same.
<script setup>
import { ref } from "vue";
const count = ref(0);
</script>
is compiled into
import { ref } from "vue";
const _sfc_main = {
vapor: true,
__name: "App",
setup(__props, { expose: __expose }) {
__expose();
const count = ref(0);
const __returned__ = { count, ref };
Object.defineProperty(__returned__, "__isScriptSetup", {
enumerable: false,
value: true,
});
return __returned__;
},
};
And the template part
<template>
<p>{{ count }}</p>
</template>
is compiled into
import {
renderEffect as _renderEffect,
setText as _setText,
template as _template,
} from "vue/vapor";
const t0 = _template("<p></p>");
function _sfc_render(_ctx) {
const n0 = t0();
_renderEffect(() => _setText(n0, _ctx.count));
return n0;
}
As for the script part, this is not an implementation of vuejs/core-vapor
, but rather the existing implementation of compiler-sfc
. For those who have been using Vue.js since before the introduction of <script setup>
, this might feel somewhat familiar.
It's implemented with a function called compileScript
, but we'll skip that part for now.
What we want to focus on this time is the template part.
Understanding the Overview of the Output Code
Let's focus on understanding the following code:
import {
renderEffect as _renderEffect,
setText as _setText,
template as _template,
} from "vue/vapor";
const t0 = _template("<p></p>");
function _sfc_render(_ctx) {
const n0 = t0();
_renderEffect(() => _setText(n0, _ctx.count));
return n0;
}
First, the part written as <p>{{ count }}</p>
in the template has been converted to <p></p>
. The content {{ count }}
is set to be updated whenever the value of count
changes, through _renderEffect
and _setText
.
setText
is, as the name suggests, a function that sets text to a specified element.
So, what is renderEffect
?
In short, it’s a "watchEffect with an update hook."
Vue.js has an API called watchEffect
.
https://vuejs.org/api/reactivity-core.html#watcheffect
This function executes the callback function passed as an argument the first time and then tracks it.
In other words, after the initial execution, the callback function will be re-executed whenever the reactive variable, in this case, _ctx.count
, is updated.
Conceptually, it is similar to:
watch(
() => ctx.count,
() => setText(n0, _ctx.count),
{ immediate: true }
);
With this, setText
will be executed each time count
is updated, and the text of n0
will be updated (the screen will be refreshed).
Another important point of renderEffect
is:
with update hooks execution
Vue.js provides lifecycle hooks beforeUpdate
and updated
that are executed before and after the screen is updated.
A normal watch does not execute these hooks when the callback is executed.
(This is natural because it is not meant to handle screen updates.)
However, the effect in this case is undoubtedly meant to update the screen.renderEffect
is designed to execute the beforeUpdate
and updated
hooks before and after the screen is updated.
It is a function to create an effect for rendering the screen.
Conversely, the compiler wraps all effects that cause screen updates with renderEffect
.
Reading the Compiler's Implementation
First, let's output the AST of the template.
{
"type": "Root",
"source": "\n <p>{{ count }}</p>\n",
"children": [
{
"type": "Element",
"tag": "p",
"ns": 0,
"tagType": 0,
"props": [],
"children": [
{
"type": "Interpolation",
"content": {
"type": "SimpleExpression",
"content": "count",
"isStatic": false,
"constType": 0,
"ast": null
}
}
]
}
],
"helpers": {},
"components": [],
"directives": [],
"hoists": [],
"imports": [],
"cached": [],
"temps": 0
}
By now, you should be able to understand the general nodes of the Template AST.
And since you've seen the implementation of the parser, you should already know how to obtain this object.
Reading the transformer
Next, let's look at the implementation of how to transform this.
Probably, this kind of flow (skim through AST, Parse and thoroughly read the transformer) will become more common from now on.
As usual, when entering transform
->transformNode
, the NodeTransformer is executed.
It enters transformElement
(onExit) -> transformChildren
, and then comes into transformText
.
Up to here it's as usual, and from here is the main point this time.
22export const transformText: NodeTransform = (node, context) => {
This time, when passing this check,
89function isAllTextLike(children: TemplateChildNode[]): children is TextLike[] {
90 return (
91 !!children.length &&
92 children.every(isTextLike) &&
93 // at least one an interpolation
94 children.some(n => n.type === NodeTypes.INTERPOLATION)
95 )
96}
Because it includes Interpolation
, it enters the following branch.
29 if (
30 node.type === NodeTypes.ELEMENT &&
31 node.tagType === ElementTypes.ELEMENT &&
32 isAllTextLike(node.children)
33 ) {
34 processTextLikeContainer(
35 node.children,
36 context as TransformContext<ElementNode>,
37 )
Let's look at processTextLikeContainer
.
63function processTextLikeContainer(
64 children: TextLike[],
65 context: TransformContext<ElementNode>,
66) {
67 const values = children.map(child => createTextLikeExpression(child, context))
68 const literals = values.map(getLiteralExpressionValue)
69 if (literals.every(l => l != null)) {
70 context.childrenTemplate = literals.map(l => String(l))
71 } else {
72 context.registerEffect(values, {
73 type: IRNodeTypes.SET_TEXT,
74 element: context.reference(),
75 values,
76 })
77 }
78}
Apparently, it calls a function called registerEffect
here.
And it correctly sets type: IRNodeTypes.SET_TEXT
.
It also retrieves the literals, and if none of them are null, it concatenates them as is and adds them to context.childrenTemplate
, then finishes.
(In other words, it falls into the template
argument)
Conversely, if not, context.childrenTemplate
remains empty, so this part does not get passed to the template
argument.
(In this case, the final template becomes "<p></p>"
)
Otherwise, it is registerEffect
.
It executes context.reference
, marks keeping this Node in a variable, and obtains the id.
registerEffect
Let's take a look at the contents of the function called registerEffect
.
137 registerEffect(
138 expressions: SimpleExpressionNode[],
139 ...operations: OperationNode[]
140 ): void {
It takes expressions
and operations
as arguments.
expression
is a SimpleExpression
of the AST. (e.g. count
, obj.prop
, etc.)
operations
is a new concept.
This is a kind of IR
, called OperationNode
.
211export type OperationNode =
212 | SetPropIRNode
213 | SetDynamicPropsIRNode
214 | SetTextIRNode
215 | SetEventIRNode
216 | SetDynamicEventsIRNode
217 | SetHtmlIRNode
218 | SetTemplateRefIRNode
219 | SetModelValueIRNode
220 | CreateTextNodeIRNode
221 | InsertNodeIRNode
222 | PrependNodeIRNode
223 | WithDirectiveIRNode
224 | IfIRNode
225 | ForIRNode
226 | CreateComponentIRNode
227 | DeclareOldRefIRNode
228 | SlotOutletIRNode
If you look at this definition, you can probably imagine, but it's a Node that represents an "operation".
For example, SetTextIRNode
is an operation to "set text".
There are also SetEventIRNode
to set events and CreateComponentIRNode
to create components.
This time, since SetTextIRNode
is used, let's take a look.
108export interface SetTextIRNode extends BaseIRNode {
109 type: IRNodeTypes.SET_TEXT
110 element: number
111 values: SimpleExpressionNode[]
112}
SetTextIRNode
has the element's id (number) and values (SimpleExpression[]).
For example, if the id is 0 and the value is a SimpleExpression representing count
,
setText(n0, count);
it represents the IR
of code like this.
Returning to the continuation of registerEffect
,
151 this.block.effect.push({
152 expressions,
153 operations,
154 })
It pushes the incoming expressions
and operations
to block.effect
.
block.effect
is
51 effect: IREffect[]
With this, the generation of the IR
for the master stack is roughly complete.
All that's left is to perform codegen based on this.
Reading Codegen
Well, as expected, there's nothing particularly difficult.
It just branches and processes the effects
held by the block
based on the type
.
You can probably read it without any explanations.
36export function genBlockContent(
37 block: BlockIRNode,
38 context: CodegenContext,
39 root?: boolean,
40 customReturns?: (returns: CodeFragment[]) => CodeFragment[],
41): CodeFragment[] {
56 push(...genEffects(effect, context))
75export function genEffects(
76 effects: IREffect[],
77 context: CodegenContext,
78): CodeFragment[] {
79 const [frag, push] = buildCodeFragment()
80 for (const effect of effects) {
81 push(...genEffect(effect, context))
86export function genEffect(
87 { operations }: IREffect,
88 context: CodegenContext,
89): CodeFragment[] {
90 const { vaporHelper } = context
91 const [frag, push] = buildCodeFragment(
92 NEWLINE,
93 `${vaporHelper('renderEffect')}(() => `,
94 )
95
96 const [operationsExps, pushOps] = buildCodeFragment()
97 operations.forEach(op => pushOps(...genOperation(op, context)))
98
99 const newlineCount = operationsExps.filter(frag => frag === NEWLINE).length
100 if (newlineCount > 1) {
101 push('{', INDENT_START, ...operationsExps, INDENT_END, NEWLINE, '})')
102 } else {
103 push(...operationsExps.filter(frag => frag !== NEWLINE), ')')
104 }
105
106 return frag
107}
33export function genOperation(
34 oper: OperationNode,
35 context: CodegenContext,
36): CodeFragment[] {
42 case IRNodeTypes.SET_TEXT:
43 return genSetText(oper, context)
12export function genSetText(
13 oper: SetTextIRNode,
14 context: CodegenContext,
15): CodeFragment[] {
16 const { vaporHelper } = context
17 const { element, values } = oper
18 return [
19 NEWLINE,
20 ...genCall(
21 vaporHelper('setText'),
22 `n${element}`,
23 ...values.map(value => genExpression(value, context)),
24 ),
25 ]
26}
And just like that, completely mastering the compiler!
Reading the Runtime
Now, let's read the runtime (actual behavior) part of the compiled result:
import {
renderEffect as _renderEffect,
setText as _setText,
template as _template,
} from "vue/vapor";
const t0 = _template("<p></p>");
function _sfc_render(_ctx) {
const n0 = t0();
_renderEffect(() => _setText(n0, _ctx.count));
return n0;
}
In the application entry, a component instance is created, and the component's render
function is called to place the resulting node into the container, which is the same as before.
Let's see what actually happens when render
is executed.
First, setText
.
These operations are mostly implemented in packages/runtime-vapor/src/dom.
The implementation of setText
is as follows:
188export function setText(el: Node, ...values: any[]): void {
189 const text = values.map(v => toDisplayString(v)).join('')
190 const oldVal = recordPropMetadata(el, 'textContent', text)
191 if (text !== oldVal) {
192 el.textContent = text
193 }
194}
It really does only very simple things. It's just a DOM operation. It joins
the values
and assigns them to the textContent
of el
.
Next, let's look at the implementation of renderEffect
to conclude this page.
In other words, renderEffect
is a "watchEffect with an update hook execution".
The implementation is in packages/runtime-vapor/src/renderEffect.ts.
While setting the current instance and effectScope, it wraps the callback,
19 const instance = getCurrentInstance()
20 const scope = getCurrentScope()
21
22 if (scope) {
23 const baseCb = cb
24 cb = () => scope.run(baseCb)
25 }
26
27 if (instance) {
28 const baseCb = cb
29 cb = () => {
30 const reset = setCurrentInstance(instance)
31 baseCb()
32 reset()
33 }
34 job.id = instance.uid
35 }
and generates a ReactiveEffect
.
37 const effect = new ReactiveEffect(() =>
38 callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION),
39 )
For effect.scheduler
(a behavior called via triggers rather than through effect.run
), it sets a function called job
(discussed later).
41 effect.scheduler = () => queueJob(job)
The following is the initial execution.
50 effect.run()
This is the job
part.
52 function job() {
Before executing the effect
, it runs the lifecycle hook (beforeUpdate).
62 const { bu, u, scope } = instance
63 const { dirs } = scope
64 // beforeUpdate hook
65 if (bu) {
66 invokeArrayFns(bu)
67 }
68 if (dirs) {
69 invokeDirectiveHook(instance, 'beforeUpdate', scope)
70 }
Then, it executes the effect
.
72 effect.run()
Finally, it runs the lifecycle hook (updated).
In reality, it just queues it in the scheduler.
(The scheduler appropriately handles deduplication and executes it at the proper time.)
74 queuePostFlushCb(() => {
75 instance.isUpdating = false
76 const reset = setCurrentInstance(instance)
77 if (dirs) {
78 invokeDirectiveHook(instance, 'updated', scope)
79 }
80 // updated hook
81 if (u) {
82 queuePostFlushCb(u)
83 }
84 reset()
85 })
Since the implementation around the scheduler is starting to come up frequently, in the next page, let's take a look at the implementation of the scheduler!