Skip to content

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:

vue
<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.

js
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.

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

is compiled into

js
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

vue
<template>
  <p>{{ count }}</p>
</template>

is compiled into

js
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:

js
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:

js
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.

json
{
  "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,

ts
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

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:

js
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.

This is the job part.

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.

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!