ランタイムを読み始める
もう,
<template>
<p>Hello, Vapor!</p>
</template>
をコンパイルする実装は一通り追うことができたので,ここからはその結果である
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 のヘルパー関数の一つです.
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
に格納し,template
の firstChild
を読み取ることにより Block
を得ています.
一度作られた Node はこの関数のローカル変数として保持され,2 回目以降の実行では cloneNode
が結果となります.
import { template } from "vue/vapor";
const t0 = template("<p>Hello, Vapor!</p>");
const n0 = t0();
const n1 = t0(); // clone node
以下の,
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 でアプリを構築する際はしばしばこのように書かれると思います.
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
Vapor Mode でも同様です. createApp
の代わりに createVaporApp
を使います.
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-core
の createApp
と同じです.
まずは 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
createComponentInstance
は 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
は内部的なコンポーネント情報で,登録されたライフサイクルや 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>
288 type: component,
これもほとんど 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
を保持するという点が挙げられます.
これは従来は subTree
や next
として VNode
(仮想 DOM) を保持していましたが,Vapor では Block
を保持するようになりました.
158 block: Block | null
従来:
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
というファイルで VNode
の patch
処理 を行っていました.
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 を格納します.
96 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 )
まとめ
実際にコンパイル後の
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 関数によって得られた Block
を app.mount(selector)
して得られた container
に insert
するだけです.
とてもシンプルだということがわかりました.
ここまででなんと,
<template>
<p>Hello, Vapor!</p>
</template>
という SFC がどのような流れでどうやってコンパイルされ,どのようにランタイム上で動作するのかが分かるようになりました!
手順の,
- Vue.js の SFC を書く
- Vapor Mode のコンパイラにかける
- 出力を見る (概要を理解する)
- コンパイラの実装を見る
- 出力コードの中身を読む
- 1 に戻る
5 まで終わったわけです.
ここからは 1 に戻って.もっと複雑なコンポーネントを同じ手順でみていきましょう!