Skip to content

Start Reading the Runtime

Now that we have followed through the implementation that compiles

vue
<template>
  <p>Hello, Vapor!</p>
</template>

we can now look at how the resulting code

js
const _sfc_main = {};
import { template as _template } from "vue/vapor";
const t0 = _template("<p>Hello, Vapor!</p>");
function _sfc_render(_ctx) {
  const n0 = t0();
  return n0;
}
export default Object.assign(_sfc_main, {
  render: _sfc_render,
  vapor: true,
  __file: "/path/to/App.vue",
});

actually works!

vue/vapor

We've been overlooking it, but we haven't explained this package yet.
This is the entry point for Vapor Mode.

The source code is located in packages/vue/vapor.

There is another entry package for Vapor Mode called packages/vue-vapor, but vue/vapor simply imports this package.

2  "name": "@vue/vapor",
1export * from '@vue/vapor'

We import the helper functions necessary for the Vapor runtime from this vue/vapor.

template Function

This is one of the helper functions for Vapor.

js
import { template } from "vue/vapor";
const t0 = template("<p>Hello, Vapor!</p>");
const n0 = t0();

This is how you declare a template and obtain a Block.

Let's look at the implementation of the template function.

2export function template(html: string) {
3  let node: ChildNode
4  const create = () => {
5    // eslint-disable-next-line no-restricted-globals
6    const t = document.createElement('template')
7    t.innerHTML = html
8    return t.content.firstChild!
9  }
10  return (): Node => (node || (node = create())).cloneNode(true)
11}

It stores the string passed as an argument into the innerHTML of a temporary template element and obtains the Block by reading the firstChild of the template.
The node created once is kept as a local variable of this function, and subsequent calls result in a cloneNode.

js
import { template } from "vue/vapor";
const t0 = template("<p>Hello, Vapor!</p>");
const n0 = t0();
const n1 = t0(); // clone node

As seen in the following code,

js
function _sfc_render(_ctx) {
  const n0 = t0();
  return n0;
}
export default Object.assign(_sfc_main, {
  render: _sfc_render,
  vapor: true,
  __file: "/path/to/App.vue",
});

in Vapor Mode, the render function of the component just returns a DOM element for this code.

Application Entry Point

Now, let's understand the application's entry point to see how this component works.

When building an app with Vue.js, it is often written like this:

ts
import { createApp } from "vue";
import App from "./App.vue";

createApp(App).mount("#app");

The same applies to Vapor Mode. We use createVaporApp instead of createApp.

ts
import { createVaporApp } from "vue/vapor";
import App from "./App.vue";

createVaporApp(App).mount("#app");

In other words, if we read the implementation of createVaporApp, we can understand how this component works.

createVaporApp

The implementation is located in packages/runtime-vapor/src/apiCreateVaporApp.ts.

22export function createVaporApp(
23  rootComponent: Component,
24  rootProps: RawProps | null = null,
25): App {

It is almost the same as createApp in runtime-core.

First, it creates the application's context and creates an App instance.
This App instance has a method called mount.

38  const context = createAppContext()
43  const app: App = (context.app = {
44    _uid: uid++,
45    _component: rootComponent,
46    _props: rootProps,
47    _container: null,
48    _context: context,
49    _instance: null,
50
51    version,
112    mount(rootContainer): any {

There are also component functions for registering components and use functions for using plugins.
It is mostly the same as traditional Vue.js.

App.mount

Let's look at the process of the mount function.

112    mount(rootContainer): any {
113      if (!instance) {
114        rootContainer = normalizeContainer(rootContainer)
115        // #5571
116        if (__DEV__ && (rootContainer as any).__vue_app__) {
117          warn(
118            `There is already an app instance mounted on the host container.\n` +
119              ` If you want to mount another app on the same host container,` +
120              ` you need to unmount the previous app by calling \`app.unmount()\` first.`,
121          )
122        }
123        instance = createComponentInstance(
124          rootComponent,
125          rootProps,
126          null,
127          false,
128          context,
129        )
130        setupComponent(instance)
131        render(instance, rootContainer)
132
133        app._container = rootContainer
134        // for devtools and telemetry
135        ;(rootContainer as any).__vue_app__ = app
136
137        if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
138          app._instance = instance
139          devtoolsInitApp(app, version)
140        }
141
142        return instance
143      } else if (__DEV__) {
144        warn(
145          `App has already been mounted.\n` +
146            `If you want to remount the same app, move your app creation logic ` +
147            `into a factory function and create fresh app instances for each ` +
148            `mount - e.g. \`const createMyApp = () => createApp(App)\``,
149        )
150      }
151    },

It treats the selector or element passed as an argument as a container.

The implementation of the normalizeContainer function is like this:

114export function normalizeContainer(container: string | ParentNode): ParentNode {
115  return typeof container === 'string'
116    ? (querySelector(container) as ParentNode)
117    : container
118}

After that, it performs createComponentInstance, setupComponent, and render (initial) to complete the mount process.

123        instance = createComponentInstance(
124          rootComponent,
125          rootProps,
126          null,
127          false,
128          context,
129        )
130        setupComponent(instance)
131        render(instance, rootContainer)

createComponentInstance

createComponentInstance creates an object called ComponentInternalInstance.

262export function createComponentInstance(
263  component: Component,
264  rawProps: RawProps | null,
265  slots: RawSlots | null,
266  once: boolean = false,
267  // application root node only
268  appContext?: AppContext,
269): ComponentInternalInstance {
151export interface ComponentInternalInstance {

ComponentInternalInstance holds internal component information, such as registered lifecycle, props, emit information, state, etc. It also holds the definition of the provided component.

191  /**
192   * @internal
193   */
194  [VaporLifecycleHooks.BEFORE_MOUNT]: LifecycleHook
195  /**
196   * @internal
197   */
198  [VaporLifecycleHooks.MOUNTED]: LifecycleHook
199  /**
200   * @internal
201   */
202  [VaporLifecycleHooks.BEFORE_UPDATE]: LifecycleHook
203  /**
204   * @internal
205   */
206  [VaporLifecycleHooks.UPDATED]: LifecycleHook
207  /**
208   * @internal
209   */
210  [VaporLifecycleHooks.BEFORE_UNMOUNT]: LifecycleHook
211  /**
212   * @internal
213   */
214  [VaporLifecycleHooks.UNMOUNTED]: LifecycleHook
215  /**
216   * @internal
217   */
218  [VaporLifecycleHooks.RENDER_TRACKED]: LifecycleHook
219  /**
220   * @internal
221   */
222  [VaporLifecycleHooks.RENDER_TRIGGERED]: LifecycleHook
223  /**
224   * @internal
225   */
226  [VaporLifecycleHooks.ACTIVATED]: LifecycleHook
227  /**
228   * @internal
229   */
230  [VaporLifecycleHooks.DEACTIVATED]: LifecycleHook
231  /**
232   * @internal
233   */
234  [VaporLifecycleHooks.ERROR_CAPTURED]: LifecycleHook
167  rawProps: NormalizedRawProps
168  propsOptions: NormalizedPropsOptions
169  emitsOptions: ObjectEmitsOptions | null
170
171  // state
172  setupState: Data
173  setupContext: SetupContext | null
174  props: Data
175  emit: EmitFn
176  emitted: Record<string, boolean> | null
177  attrs: Data
178  slots: StaticSlots
179  refs: Data
180  // exposed properties via expose()
181  exposed?: Record<string, any>

This is also mostly the same as runtime-core.

In createComponentInstance, it not only generates a ComponentInternalInstance object but also creates an EffectScope and initializes props, emit, and slot.

360  instance.root = parent ? parent.root : instance
361  instance.scope = new BlockEffectScope(instance, parent && parent.scope)
362  initProps(instance, rawProps, !isFunction(component), once)
363  initSlots(instance, slots)
364  instance.emit = emit.bind(null, instance)

A unique implementation for Vapor is that it holds a block.
Traditionally, it held a VNode (virtual DOM) as subTree or next, but in Vapor, it holds a Block.

Traditional:

324  /**
325   * The pending new vnode from parent updates
326   * @internal
327   */
328  next: VNode | null
329  /**
330   * Root vnode of this component's own vdom tree
331   */
332  subTree: VNode

From now on, the Block will be stored here when rendered.

We'll revisit props, emit, and slot handling when we run components that use them.
We'll skip them for now.

setupComponent

Now, let's move on to the rendering process.
This is the essence of Vapor Mode.

Previously, in the file renderer.ts, the patch process of the VNode was performed.

In Vapor Mode, there is no VNode or patch, so the initial setup process is everything.
Subsequent updates directly manipulate the DOM (Block) via the reactivity system.

For now, since we don't have any state, let's see how the Block obtained from the render function is handled.

This function is located in a file called packages/runtime-vapor/src/apiRender.ts, which implements the rendering processes.

First, as soon as we enter setupComponent, we set the currentInstance to the target component.

40  const reset = setCurrentInstance(instance)

Next, various setups are executed within the effectScope generated in createComponentInstance.

41  instance.scope.run(() => {

We won't go into detail about effectScope since it's part of the Vue.js API, but for those who don't know, it's essentially "a way to collect effects and make them easier to clean up later."

https://vuejs.org/api/reactivity-advanced.html#effectscope

By forming various effects within this scope, we can clean up when the component is unmounted by stopping the effectScope.

155export function unmountComponent(instance: ComponentInternalInstance): void {
156  const { container, block, scope } = instance
157
158  // hook: beforeUnmount
159  invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_UNMOUNT, 'beforeUnmount')
160
161  scope.stop()

Now, let's see what exactly is done within the effectScope.

setupComponent > effectScope

First is handling the setup function.
If the component itself is a function, it is executed as a function component.
If it is an object, the setup function is extracted.

50    const setupFn = isFunction(component) ? component : component.setup

Then, this function is executed.

56      stateOrNode = callWithErrorHandling(
57        setupFn,
58        instance,
59        VaporErrorCodes.SETUP_FUNCTION,
60        [__DEV__ ? shallowReadonly(props) : props, setupContext],
61      )

The result will be either a state or a Node.

If the result is a Node (or a fragment or component), it is stored in a variable called block.

65    let block: Block | undefined
66
67    if (
68      stateOrNode &&
69      (stateOrNode instanceof Node ||
70        isArray(stateOrNode) ||
71        fragmentKey in stateOrNode ||
72        componentKey in stateOrNode)
73    ) {
74      block = stateOrNode

After this, if there is still nothing in the block variable, it attempts to obtain the block from the render function.
In this component, it enters this branch, and the render function is executed, storing the block (n0).

78    if (!block && component.render) {
79      pauseTracking()
80      block = callWithErrorHandling(
81        component.render,
82        instance,
83        VaporErrorCodes.RENDER_FUNCTION,
84        [instance.setupState],
85      )
86      resetTracking()
87    }

At this point, the block is stored in instance.block.

And that's it for setting up the screen update.
As we'll see when looking at the compilation results of more complex components, most update processes are directly described as effects in the component.

Therefore, rendering a component is as simple as executing the setup function (which defines the state) and generating the block with the render function (where the effect is formed).

All that's left is to mount the block obtained from the render function to the DOM.

render

At the end of https://github.com/vuejs/core-vapor/blob/30583b9ee1c696d3cb836f0bfd969793e57e849d/packages/runtime-vapor/src/apiCreateVaporApp.ts#L123-L131, we have the render part.
This render function is an internal function and is different from the render function of the component.

12  render,
13  setupComponent,
14  unmountComponent,
15} from './apiRender'

Like setupComponent, it is implemented in packages/runtime-vapor/src/apiRender.ts.

What it does is very simple: it mounts the component and executes the tasks in the queue (scheduler).
(※ You don't need to worry about the scheduler for now.)

106export function render(
107  instance: ComponentInternalInstance,
108  container: string | ParentNode,
109): void {
110  mountComponent(instance, (container = normalizeContainer(container)))
111  flushPostFlushCbs()
112}

mountComponent is also very simple.

120function mountComponent(
121  instance: ComponentInternalInstance,
122  container: ParentNode,
123) {

It sets the container (the DOM selected from #app in this case) passed as an argument to instance.container.

124  instance.container = container

Then, it executes the beforeMount hook.

130  // hook: beforeMount
131  invokeLifecycle(instance, VaporLifecycleHooks.BEFORE_MOUNT, 'beforeMount')

Finally, it inserts the block into the container.

133  insert(instance.block!, instance.container)

(The insert function is really just an insert.)

23export function insert(
24  block: Block,
25  parent: ParentNode,
26  anchor: Node | null = null,
27): void {
28  normalizeBlock(block).forEach(node => parent.insertBefore(node, anchor))
29}

After executing the mounted hook, the component's mount is complete.

135  // hook: mounted
136  invokeLifecycle(
137    instance,
138    VaporLifecycleHooks.MOUNTED,
139    'mounted',
140    instance => (instance.isMounted = true),
141    true,
142  )

Summary

We've seen how the compiled code like

js
const _sfc_main = {};
import { template as _template } from "vue/vapor";
const t0 = _template("<p>Hello, Vapor!</p>");
function _sfc_render(_ctx) {
  const n0 = t0();
  return n0;
}
export default Object.assign(_sfc_main, {
  render: _sfc_render,
  vapor: true,
  __file: "/path/to/App.vue",
});

works, which is just to create a component instance, execute the setup function (if available), call the render function to get the block, and then app.mount(selector) to insert the block into the selector.

It's very simple.

Now we understand how a SFC like

vue
<template>
  <p>Hello, Vapor!</p>
</template>

is compiled and works in the runtime!

The steps are:

  1. Write a Vue.js SFC.
  2. Run it through the Vapor Mode compiler.
  3. Look at the output (get an overview).
  4. Check the implementation of the compiler.
  5. Read the output code.
  6. Go back to step 1.

We have completed up to step 5.

Let's go back to step 1 and look at more complex components in the same way!