Skip to content

ランタイムを読み始める

もう,

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

をコンパイルする実装は一通り追うことができたので,ここからはその結果である

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",
});

が実際にどのように動作するかを見ていきましょう!

vue/vapor

ずっと見過ごしてきたのですが,実はこのパッケージの説明をまだしていません. これは Vapor Mode のエントリポイントです.

ソースは packages/vue/vapor にあります.

Vapor Mode のエントリパッケージとして,packages/vue-vapor というものもあるのですが,vue/vapor はこのパッケージを import しているだけです.

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

Vapor のランタイムに必要なヘルパー関数はこの vue/vapor から import します.

template 関数

これは Vapor のヘルパー関数の一つです.

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

のようにテンプレートを宣言し,Block を得ます.

template 関数の実装を読んでみましょう.

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}

引数から渡された文字列を一時的な template という要素の innerHTML に格納し,templatefirstChild を読み取ることにより Block を得ています.
一度作られた Node はこの関数のローカル変数として保持され,2 回目以降の実行では cloneNode が結果となります.

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

以下の,

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",
});

からわかる通り,Vapor Mode ではこのコードに関してはコンポーネントの render 関数がただの DOM 要素を返しています.

application のエントリポイント

さて,このコンポーネントがどのように動作するのかをみていきますが,そのためにアプリケーションのエントリを把握しましょう.

Vue.js でアプリを構築する際はしばしばこのように書かれると思います.

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

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

Vapor Mode でも同様です. createApp の代わりに createVaporApp を使います.

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

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

つまるところ,createVaporApp に実装を読んでいけば,このコンポーネントがどのように動作するのかがわかるということです.

createVaporApp

実装は packages/runtime-vapor/src/apiCreateVaporApp.ts にあります.

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

ここに関してはほとんど runtime-corecreateApp と同じです.

まずは application のコンテキストを作成し,App インスタンスを作ります.
この App インスタンスが 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 {

他にも component を登録するための component 関数や,plugin を使用する use 関数があったりします.
ほとんど従来の Vue.js と同じです.

App.mount

mount 関数の処理を見てみましょう.

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    },

引数で渡されたセレクタもしくは要素を container として扱います.

normalizeContainer 関数の実装は以下のような感じです.

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

そうしたら,createComponentInstance, setupComponent, render (初回) を行ってマウント処理は終わりです.

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

createComponentInstance

createComponentInstanceComponentInternalInstance というオブジェクトを作ります.

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 は内部的なコンポーネント情報で,登録されたライフサイクルや props, emit の情報, state などを持っています.
渡されたコンポーネントの定義も保持します.

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>

これもほとんど runtime-core と同じです.

createComponentInstance では ComponentInternalInstance オブジェクトを生成するとともに,EffectScope の生成や,props, emit, 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)

Vapor 固有の実装として,block を保持するという点が挙げられます.
これは従来は subTreenext として VNode (仮想 DOM) を保持していましたが,Vapor では Block を保持するようになりました.

従来:

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

今後は render した際にここに Block が保持されるようになります.

props や emit, slot についてはそれらを使ったコンポーネントを動かす際にまた見にきましょう.
今回は読み飛ばします.

setupComponent

さて,ここからはレンダリングの処理です.
Vapor Mode の真髄と言ってもいいでしょう.

従来は,renderer.ts というファイルで VNodepatch 処理 を行っていました.

Vapor Mode には VNode や patch といったものはないので,最初の setup 処理が全てです.
それ以降の更新はリアクティビティシステムによって直接 DOM (Block) に対して操作が行われます.

今はまだ state を持っていないので,単純に render 関数から得た Block がどのように扱われているかを見ていきましょう.

この関数は packages/runtime-vapor/src/apiRender.ts という,レンダリング周りの処理が実装されたファイルにあります.

まず,setupComponent に入ったらすぐに currentInstance を対象のコンポーネントにセットします.

40  const reset = setCurrentInstance(instance)

次に,createComponentInstance の時に生成した effectScope 内で各種 setup を実行していきます.

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

effectScope は Vue.js の API なので,詳しい説明は行いませんが,知らない方のために簡単に説明しておくと,「エフェクトを収集して,あとで回収しやすくするためのもの」です.

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

この中でさまざまなエフェクトを形成することで,コンポーネントがアンマウントした際にそこ EffectScop を stop してしまえばクリーンナップを行うことができます.

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()

さて,具体的にどのようなことを effectScope 内で行っているかを見てみましょう.

setupComponent > effectScope

まずは setup 関数のハンドリングです.
コンポーネント自体が関数である場合は関数コンポーネントとしてそれを実行します.
そうでない場合 (オブジェクトだった場合) は setup 関数を取り出します.

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

そして,この関数を実行します.

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

結果は state か,Node になります.

Node (もしくは fragment, component) だった場合は 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

このあと,まだ block という変数に何も入っていない場合は render 関数から block の取得を試みます.
今回のコンポーネントはこの分岐に入って,render 関数が実行され 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    }

ここまでやったら instance.block に block を格納します.

なんと,画面更新のためのセットアップはこれでおしまいです.
後に複雑なコンポーネントのコンパイル結果を見ればわかるのですが,ほとんどの更新処理はエフェクトとして component に直接記載されています.

そのため,コンポーネントのレンダリングは「setup 関数を実行する (ここでそのステートの定義がされる)」「render 関数によって block を生成する (ここで effect が形成される)」の 2 ステップでおしまいなのです.

あとは render 関数によって得た block を DOM にマウントするだけです.

render

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

の最後,render の部分です. この render という関数は内部関数です.コンポーネントが持つ render 関数とは別物です.

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

setupComponent と同じく,packages/runtime-vapor/src/apiRender.ts に実装されています.

やっていることは非常にシンプルで,component のマウントと,キュー(スケジューラ)にあるタスクを実行するだけです.
(※ スケジューラについては今は気にする必要はないです)

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

mountComponent も非常にシンプルで,

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

instance.container に引数で渡ってきた container (今回で言うと #app からセレクトされた DOM) をセットして,

124  instance.container = container

beforeMount hook を実行して,

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

container に block を insert します.

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

(insert 関数は本当にただの 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}

最後に mounted hook を実行したら component の mount はおしまいです.

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

まとめ

実際にコンパイル後の

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",
});

のようなコードがどのように動作するかを見てきましたが,ただ component のインスタンスを用意して,(あれば) setup 関数を実行して,render 関数によって得られた Blockapp.mount(selector) して得られた containerinsert するだけです.

とてもシンプルだということがわかりました.

ここまででなんと,

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

という SFC がどのような流れでどうやってコンパイルされ,どのようにランタイム上で動作するのかが分かるようになりました!

手順の,

  1. Vue.js の SFC を書く
  2. Vapor Mode のコンパイラにかける
  3. 出力を見る (概要を理解する)
  4. コンパイラの実装を見る
  5. 出力コードの中身を読む
  6. 1 に戻る

5 まで終わったわけです.

ここからは 1 に戻って.もっと複雑なコンポーネントを同じ手順でみていきましょう!