Skip to content

ディレクティブを実装しよう (v-bind)

方針

ここからは Vue.js の醍醐味であるディレクティブを実装していきます.
例の如く,ディレクティブも transformer に噛ませるのですが,そこで登場するのが DirectiveTransform というインタフェースです. DirectiveTransform は DirectiveNode,ElementNode を受け取り,Transform 後の Property を返すようなものになっています.

ts
export type DirectiveTransform = (
  dir: DirectiveNode,
  node: ElementNode,
  context: TransformContext,
) => DirectiveTransformResult

export interface DirectiveTransformResult {
  props: Property[]
}

まずは今回目指す開発者インタフェースから確認してみましょう.

ts
import { createApp, defineComponent } from 'chibivue'

const App = defineComponent({
  setup() {
    const bind = { id: 'some-id', class: 'some-class', style: 'color: red' }
    return { count: 1, bind }
  },

  template: `<div>
  <p v-bind:id="count"> v-bind:id="count" </p>
  <p :id="count * 2"> :id="count * 2" </p>

  <p v-bind:["style"]="bind.style"> v-bind:["style"]="bind.style" </p>
  <p :["style"]="bind.style"> :["style"]="bind.style" </p>

  <p v-bind="bind"> v-bind="bind" </p>

  <p :style="{ 'font-weight': 'bold' }"> :style="{ font-weight: 'bold' }" </p>
  <p :style="'font-weight: bold;'"> :style="'font-weight: bold;'" </p>

  <p :class="'my-class my-class2'"> :class="'my-class my-class2'" </p>
  <p :class="['my-class']"> :class="['my-class']" </p>
  <p :class="{ 'my-class': true }"> :class="{ 'my-class': true }" </p>
  <p :class="{ 'my-class': false }"> :class="{ 'my-class': false }" </p>
</div>`,
})

const app = createApp(App)

app.mount('#app')

v-bind には 概ね上記のような記法があります.詳しくは下記の公式ドキュメントを参照してください.
class や style についても今回取り扱います.

https://vuejs.org/api/built-in-directives.html#v-bind

AST の変更

まず,AST についてですが,今は exp, arg 共に string という簡易的なものになってしまっているので,ExpressionNode を受け取れるように変更します.

ts
export interface DirectiveNode extends Node {
  type: NodeTypes.DIRECTIVE
  name: string
  exp: ExpressionNode | undefined // ここ
  arg: ExpressionNode | undefined // ここ
}

改めて nameargexp について説明しておくと, name は v-bind や v-on などのディレクティブ名です.on や bind が入ります. 今回は v-bind を実装していくので,bind が入ります.

arg は : で指定する引数です.v-bind でいうと, id や style などが入ります.
(v-on の場合は click や input などがここに入ってきます.)

exp は右辺です.v-bind:id="count" でいうと count が入ります.
exp も arg も,動的に変数を埋め込むことができるので,型は ExpressionNode になります.
( v-bind:[key]="count" のように arg も動的にできるので)

dir_ast

Parser の変更

parser の実装をこの AST の変更に追従します.exp, arg を SimpleExpressionNode としてパースします.

ついでに v-on などで使う @ やスロットで使う # などもパースします.
(正規表現を考えるのが面倒くさい(説明しながら徐々に追加するのが面倒臭い)のでとりあえず本家のものをそのまま拝借します)
参考: https://github.com/vuejs/core/blob/623ba514ec0f5adc897db90c0f986b1b6905e014/packages/compiler-core/src/parse.ts#L802

少し長いので,コード中にコメントを書きながら説明していきます.

ts
function parseAttribute(
  context: ParserContext,
  nameSet: Set<string>,
): AttributeNode | DirectiveNode {
  // .
  // .
  // .
  // .
  // directive
  const loc = getSelection(context, start)
  // ここの正規表現は本家から拝借
  if (/^(v-[A-Za-z0-9-]|:|\.|@|#)/.test(name)) {
    const match =
      // ここの正規表現は本家から拝借
      /(?:^v-([a-z0-9-]+))?(?:(?::|^\.|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i.exec(
        name,
      )!

    // name 部分のマッチを見て、`:` で始まっていた場合には bind として扱う
    let dirName =
      match[1] ||
      (startsWith(name, ':') ? 'bind' : startsWith(name, '@') ? 'on' : '')

    let arg: ExpressionNode | undefined

    if (match[2]) {
      const startOffset = name.lastIndexOf(match[2])
      const loc = getSelection(
        context,
        getNewPosition(context, start, startOffset),
        getNewPosition(context, start, startOffset + match[2].length),
      )

      let content = match[2]
      let isStatic = true

      // `[arg]` のような動的な引数の場合、`isStatic` を false として、中身を content として取り出す
      if (content.startsWith('[')) {
        isStatic = false
        if (!content.endsWith(']')) {
          console.error(`Invalid dynamic argument expression: ${content}`)
          content = content.slice(1)
        } else {
          content = content.slice(1, content.length - 1)
        }
      }

      arg = {
        type: NodeTypes.SIMPLE_EXPRESSION,
        content,
        isStatic,
        loc,
      }
    }

    return {
      type: NodeTypes.DIRECTIVE,
      name: dirName,
      exp: value && {
        type: NodeTypes.SIMPLE_EXPRESSION,
        content: value.content,
        isStatic: false,
        loc: value.loc,
      },
      loc,
      arg,
    }
  }
}

これで今回扱いたい AST Node にパースすることができました.

Transformer の実装

続いて,この AST を Codegen 用の AST に transform する実装を書いていきます.
少々複雑なので,以下の図に軽く流れをまとめました.まずはそちらをご覧ください.
大まかに,必要な項目を挙げると,v-bind に引数が存在するかどうか,class かどうか,style かどうかです.
※ 今回関係してくる処理以外の部分は省略しています.(あまり厳格な図ではありませんがご了承ください.)

dir_ast

まず,前提として,ディレクティブというものは基本的に要素 (element) に対して宣言されているものなので,

ディレクティブに関する transformer は transformElement に呼ばれます.

今回は v-bind を実装したいので,transformVBind と言う関数を実装していくのですが,
注意点として,この関数では args が存在している宣言のみの変換を行う点が挙げられます.

transformVBind は,

v-bind:id="count"

のようなものを,

ts
{
  id: count
}

というオブジェクト(実際にはこのオブジェクトを表す Codegen Node)に変換する役割のみを持ちます.

本家の実装でも,以下のような説明がなされています.

codegen for the entire props object. This transform here is only for v-bind with args.

引用元: https://github.com/vuejs/core/blob/623ba514ec0f5adc897db90c0f986b1b6905e014/packages/compiler-core/src/transforms/vBind.ts#L13C1-L14C16

流れを見てもわかる通り,transformElement では directive の arg をチェックして,存在していなければ transformVBind を実行せず mergeProps という関数呼び出しに変換しています.

v-bind="hoge"の形式で渡された引数と,そのほかの props をマージする関数です.

vue
<p v-bind="bindingObject" class="my-class">hello</p>

ts
h('p', mergeProps(bindingObject, { class: 'my-class' }), 'hello')

また,class と style に関してはさまざまな開発者インタフェースを持っているため,normalize する必要があります.
https://vuejs.org/api/built-in-directives.html#v-bind

normalizeClass と normalizeStyle という関数を実装し,それぞれに適用します.

arg が動的な場合は,特定が不可能なため,normalizeProps という関数を実装し,それを呼び出すようにします. (内部で normalizeClass と normalizeStyle を呼び出します)

さてここまで実装できたら動作を見てみましょう!

vbind_test

とっても良さそうです!

次回は v-on を実装していきます.

ここまでのソースコード:
GitHub

Released under the MIT License.