Skip to content

マスタッシュとステートのバインド ​

今回の対象コンポーネント ​

さて,同じ手順で次々といろんなコンポーネントを読み進めてみましょう.

続いては以下のようなコンポーネントを見てください.

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

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

上記のコードでは count は変化しないので,あまり実用的なコードではありませんが,ステートの定義とマスタッシュによるバインディングを行ってみました.

コンパイル結果 ​

まずはこの SFC がどのようなコンパイル結果になるかを見てみましょう.

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

前回のコンポーネントよりも少し複雑になっていますが,基本的な構造は変わりません.

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

の部分が,

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

にコンパイルされ,

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

の部分が

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

script 部分に関してはこれは vuejs/core-vapor の実装ではなく,元からある compiler-sfc の実装によるものです.
まぁ,<scirpt setup> の登場以前から Vue.js を触ってる人からすると,これはなんとなく見慣れた感じがしていると思います.
例の如く compileScript という関数で実装されています.ここは今回は読み飛ばします.

今回メインで注目したいのは,template 部分の方です.

出力コードの概要を理解する ​

以下のコードを重点的に理解しましょう.

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

まず,template としては <p>{{ count }}</p> と書いた部分が <p></p> に変換されています.
間の {{ count }} は _renderEffect と _setText によって count の値が更新されるたびに更新されるようになっています.

setText は名前から予想できる通り,指定した要素にテキストをセットする関数です.
renderEffect とはなんでしょうか...?

こちらは一言で言うと,「update hook 実行付きの watchEffect」です.

Vue.js には watchEffect という API があります.

https://vuejs.org/api/reactivity-core.html#watcheffect

この関数は,引数に渡したたコールバック関数を初回に実行しつつ,そのコールバックをトラックする関数です.
つまり,初回実行以降,今回で言うと _ctx.count というリアクティブな変数が更新されるたびに,コールバック関数が再実行されるというわけです.

イメージ的には,

js
watch(
  () => ctx.count,
  () => setText(n0, _ctx.count),
  { immediate: true }
);

に近いです.これにより count が更新されるたびに,setText が実行され,n0 のテキストが更新されるようになります.(画面が更新される)

renderEffect のもう一つ重要なポイントが.

update hook 実行付きの

の部分です.

Vue.js は画面が更新され前後に beforeUpdate と updated というライフサイクルフックを提供しています.
通常の watch はコールバックの実行時にこれらのフックが実行されません.
(画面の更新をハンドリングするものではないので当然です.)

しかし,今回のエフェクトは紛れもなく画面を更新させるためのものです.
renderEffect はこの画面更新の前後に beforeUpdate と updated フックを実行するようになっています.
画面をレンダリングするためのエフェクトを作るための関数だということです.

逆に言えば,コンパイラは画面を更新させるようなエフェクトを全て renderEffect でラップします.

コンパイラの実装を読む ​

まずは template の AST を出力してみましょう.

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
}

Template AST の概要で一通りの Node はみているので,もう意味は分かるかと思います.
そして,Parser の実装もみているのでどのようにこのオブジェクトを得られるかも皆さんはすでに知っているはずです.

transformer を読む ​

次は,これをどのように transform していくのか,という実装を見ていきましょう.
おそらくこれからもこういった流れ (AST, Parse はさらっと流して transformer をしっかり読む) が多くなると思います.

例の如く,transform ->transformNode に入ると,NodeTransformer が実行されます.
transformElement (onExit) -> tarnsformChildren と入っていき,transfoemText に入ってきます.

ここまではいつも通りで,ここからが今回のポイントです.

22export const transformText: NodeTransform = (node, context) => {

今回はこのチェックを通る時,

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}

Interpolation を含んでいるので以下の分岐に入ります.

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    )

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}

どうやらここで registerEffect という関数を呼び出しています.
そして,正しく type: IRNodeTypes.SET_TEXT になっています.

リテラルも取得し,全て null じゃなかった場合にはそのまま連結して context.childrenTemplate に追加し終了します.
(つまり template の引数に落ちる)

逆に,そうじゃない場合は context.childrenTemplate が空っぽなままなので,この部分は template の引数には乗りません.
(今回の場合,最終的な template は "<p></p>" になる)

そうじゃない場合は registerEffect です.
context.reference を実行し,この Node を変数に保持することをマークしつつ id を取得します.

registerEffect ​

registerEffect という関数の中身を少しみてみましょう.

137  registerEffect(
138    expressions: SimpleExpressionNode[],
139    ...operations: OperationNode[]
140  ): void {

引数として,expressions と operations を受け取ります.

expression は AST の SimpleExpression です. (e.g. count, obj.prop など)

operations は新概念です.
これは IR の一種で,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

この定義を見れば想像はつくと思いますが,「操作」を表す Node です.
例えば,SetTextIRNode は「テキストをセットする」という操作です.
他にも,イベントをセットする SetEventIRNode やコンポーネントを生成する CreateComponentIRNode などがあります.

今回は SetTextIRNode が使われているので少しみてみましょう.

108export interface SetTextIRNode extends BaseIRNode {
109  type: IRNodeTypes.SET_TEXT
110  element: number
111  values: SimpleExpressionNode[]
112}

SetTextIRNode は element の id (number) と values (SimpleExpression[]) を持ちます.

例えば,id が 0 で value が count を表す SimpleExpression だとすると,

ts
setText(n0, count);

というようなコードの IR を表現することになります.

registerEffect の続きに戻ると,

151      this.block.effect.push({
152        expressions,
153        operations,
154      })

入ってきた expressions と operations を block.effect に push しています.

block.effect は

です.

これで概ねマスタッシュの IR の生成が完了しました.
あとはこれを元に codegen していくだけです.

Codegen を読む ​

まぁ,予想通り特に難しいところはありません.
block が持っている effect を type によって分岐して処理していくだけです.

おそらく何も説明なしで読めると思います.

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}

なんとあっさり!コンパイラ完全制覇です!

ランタイムを読む ​

さて,コンパイル結果の

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

のランタイム(実際の動作)部分を読んでいきましょう.

application のエントリで component のインスタンスが作られ,コンポーネントの render 関数が呼ばれてその結果の node が container に入っていくのは今までと同じです.
実際に render が実行された時に何が起こるかを見ていきましょう.

まずは setText です.
この辺りのオペレーションは概ね packages/runtime-vapor/src/dom に実装されています.

setText の実装は以下です.

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}

本当に単純なことしかしていません.ただの DOM 操作です. values を join して el の textContent に突っ込みます.

あとは renderEffect の実装を見てこのページは終わりにしましょう.
改めて renderEffect は「update hook 実行付きの watchEffect」です.

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

現在の instance や effectScope を設定しつつ,コールバックをラップして,

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  }

ReactiveEffect を生成します.

37  const effect = new ReactiveEffect(() =>
38    callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION),
39  )

effect.scheduler (effect.run 経由ではなく,trigger 等で呼ばれる動作) には job という関数 (後述) を設定しています.

41  effect.scheduler = () => queueJob(job)

以下が初回実行になります.

job 部分です.

effect の実行の前にライフサイクルフック (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      }

そして effect の実行を行い.

最後にライフサイクルフック (updated) を実行します.
実際にはスケジューラのキューに積んでいるだけです.
(スケジューラがいい感じに重複排除等を行って然るべきところで実行されます)

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

そろそろスケジューラ周りの実装がよく出てくるよになったので,次のページではスケジューラの実装について少し見てみましょう!