ディレクティブを実装しよう (v-bind)
方針
ここからは Vue.js の醍醐味であるディレクティブを実装していきます.
例の如く,ディレクティブも transformer に噛ませるのですが,そこで登場するのが DirectiveTransform というインタフェースです. DirectiveTransform は DirectiveNode,ElementNode を受け取り,Transform 後の Property を返すようなものになっています.
export type DirectiveTransform = (
dir: DirectiveNode,
node: ElementNode,
context: TransformContext,
) => DirectiveTransformResult
export interface DirectiveTransformResult {
props: Property[]
}
まずは今回目指す開発者インタフェースから確認してみましょう.
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 を受け取れるように変更します.
export interface DirectiveNode extends Node {
type: NodeTypes.DIRECTIVE
name: string
exp: ExpressionNode | undefined // ここ
arg: ExpressionNode | undefined // ここ
}
改めて name
と arg
と exp
について説明しておくと, 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 も動的にできるので)
Parser の変更
parser の実装をこの AST の変更に追従します.exp, arg を SimpleExpressionNode
としてパースします.
ついでに v-on などで使う @
やスロットで使う #
などもパースします.
(正規表現を考えるのが面倒くさい(説明しながら徐々に追加するのが面倒臭い)のでとりあえず本家のものをそのまま拝借します)
参考: https://github.com/vuejs/core/blob/623ba514ec0f5adc897db90c0f986b1b6905e014/packages/compiler-core/src/parse.ts#L802
少し長いので,コード中にコメントを書きながら説明していきます.
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 かどうかです.
※ 今回関係してくる処理以外の部分は省略しています.(あまり厳格な図ではありませんがご了承ください.)
まず,前提として,ディレクティブというものは基本的に要素 (element) に対して宣言されているものなので,
ディレクティブに関する transformer は transformElement に呼ばれます.
今回は v-bind を実装したいので,transformVBind と言う関数を実装していくのですが,
注意点として,この関数では args が存在している宣言のみの変換を行う点が挙げられます.
transformVBind は,
v-bind:id="count"
のようなものを,
{
id: count
}
というオブジェクト(実際にはこのオブジェクトを表す Codegen Node)に変換する役割のみを持ちます.
本家の実装でも,以下のような説明がなされています.
codegen for the entire props object. This transform here is only for v-bind with args.
流れを見てもわかる通り,transformElement では directive の arg をチェックして,存在していなければ transformVBind を実行せず mergeProps という関数呼び出しに変換しています.
v-bind="hoge"
の形式で渡された引数と,そのほかの props をマージする関数です.
<p v-bind="bindingObject" class="my-class">hello</p>
↓
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 を呼び出します)
さてここまで実装できたら動作を見てみましょう!
とっても良さそうです!
次回は v-on を実装していきます.
ここまでのソースコード:
GitHub