Vueのようなif文を自分で実装する

# Vueのようなif文

VueはHTMLにif文を仕込むことができます。以下のようにです。

```vue
<div v-if="1">1</div>
<div v-else-if="2">2</div>
<div v-else>3</div>
```

このようなif文を自分で実装する方法を書いていきます。

## 設計

まずこの実装は分野的にはどの分野になるのかというと、言語処理系の実装に似てます。言語処理系の実装は以下のようにフェーズが分かれています。

* 字句解析
* 構文解析
* 構文実行

この3つのフェーズはインタプリタ言語などで使われることがある設計です。今回もこの設計で行います。

## JSのDOMを有効利用する

しかし字句解析をまた一から実装するというのははっきり言ってだるいです。コード書いててむなしくなります。よって、JSのDOMを再利用する形で実装していきます。JSでは要素の`innerHTML`にテキストを流し込むとパースしてDOMを構築してくれます。

```js:
let div = document.createElement('div')
div.innertHTML = '<p>Hello</p>'
```

パースしたい文字列をこれでパースしてDOMを構築し、それを再度パースするという形で実装していきます。

## 字句解析

まず`innerHTML`にパースしたいテキストを流し込み、その要素を以下の`tokenize()`に渡します。

```js:
function tokenize (elem) {
	switch (elem.nodeType) {
	default:
		let vIf = elem.getAttribute('v-if')
		let vElseIf = elem.getAttribute('v-else-if')
		let vElse = elem.hasAttribute('v-else')
		if (vIf) {
			elem.vueNodeName = 'if'	
			elem.vueExpr = vIf
		} else if (vElseIf) {
			elem.vueNodeName = 'else-if'
			elem.vueExpr = vElseIf
		} else if (vElse) {
			elem.vueNodeName = 'else'
		} else {
			elem.vueNodeName = 'none'
		}
		for (let child of elem.childNodes) {
			tokenize(child)
		}
		break
	case 3:  // TextNode
		elem.vueNodeName = 'none'
		break
	}

	return elem
}
```

この処理ではDOMにラベルを貼っていきます。`vueNodeName`というのがラベルです。今回はif文の実装なので、属性に`v-if`などがあればそれに対応したラベルを貼っていきます。if文でないノードには`none`ラベルを貼っておきます。

## 構文解析

字句解析の次は構文解析です。

```js:
class Obj {
	constructor (
		type,
		{
			elem=null,
			children=[],
		}={}
	) {
		this.type = type
		this.elem = elem
		this.children = children
	}
}

/* BNF

prog := node*
node := ELEM | e-node
e-node := e-if
e-if := node* ( e-else | e-if )*
e-else := node*
*/
function parse (elem) {
	return parseNode(elem)
}

function parseNode (elem) {
	if (elem.vueNodeName === 'none') {
		return parseELEM(elem)
	} else {
		return parseVNode(elem)
	}
}

function parseELEM (elem) {
	let children = []

	for (let node of elem.childNodes) {
		let o = parse(node)
		if (o) {
			children.push(o)
		}
	}

	let obj = new Obj('ELEM', {
		elem,
		children,
	})

	return obj
}

function parseVNode (elem) {
	switch (elem.vueNodeName) {
	case 'if': return parseVIf(elem); break
	}
}

function parseVElse (elem) {
	let obj = new Obj('else', {
		elem,
	})
	for (let node of elem.childNodes) {
		let child = parse(node)
		if (child) {
			obj.children.push(child)
		}
	}
	return obj
}

function parseVIf (elem, objName='if') {
	let obj = new Obj(objName, {
		elem,
	})

	let next = elem.nextSibling

	if (next) {
		if (next.vueNodeName === 'else') {
			obj.else = parseVElse(next)
		} else if (next.vueNodeName === 'else-if') {
			obj.elseIf = parseVIf(next, 'else-if')
		}
	}

	let children = []

	for (let node of elem.childNodes) {
		let child = parse(node)
		if (child) {
			children.push(child)
		}
	}

	obj.children = children
	return obj
}

```

構文解析では`Obj`という中間オブジェクトを生成します。BNFがコメントで書いてありますが、これが言語の設計書になります。このBNFに従って`parse()`とそのファミリー関数を実装していきます。
`Obj`は親子関係のある中間オブジェクトです。ノードに子ノードがある場合はそれも`parse()`して`children`にプッシュしておきます。
パースでは字句解析で貼ったラベルに従ってパースしていきます。`if`ラベルが貼ってあればif文用のノードとして処理します。`else`ラベルが貼ってあれば`else`用として処理します。
`ELEM`というオブジェクトはif文以外のノードの総称です。構文に関係のないノードがこれです。

## 構文実行

最後にDOMを構築する構文実行です。

```js:
function exec (obj) {
	let results = []

	switch (obj.type) {
	case 'ELEM': {
		let node = obj.elem.cloneNode(false)

		for (let o of obj.children) {
			let n = exec(o)
			if (n) {
				node.appendChild(n)
			}
		}
		return node
	} break
	case 'if':
	case 'else-if':
		if (eval(obj.elem.vueExpr)) {
			let node = obj.elem.cloneNode(false)
			node.removeAttribute('v-if')
			node.removeAttribute('v-else-if')
			for (let o of obj.children) {
				node.appendChild(exec(o))
			}		
			return node
		} else {
			if (obj.elseIf) {
				return exec(obj.elseIf)
			} else if (obj.else) {
				return exec(obj.else)
			}
		}
		break
	case 'else': {
		let node = obj.elem.cloneNode(false)
		node.removeAttribute('v-else')
		for (let o of obj.children) {
			node.appendChild(exec(o))
		}
		return node
	} break
	}
}
```

`exec()`で`Obj`を実行してDOMを構築していきます。`node.cloneNode()`を使っていますのでノードのコピーを行っています。ですのでパフォーマンスは最初の段階は出ないかもしれません。コピー分のオーバーヘッドがあるでしょう。
構文解析で構築したif文の構文を格納してある`Obj`を再帰的に実行していきます。この再帰実行も実は手抜きです。本当は最近のインタプリタ言語はオペコードという配列にして高速化を行うのが普通なんですが、この実装では構文木をそのまま実行する手抜き実装にしてあります。
`v-if`などのノードの属性はこの段階で削除しておきます。

## テスト

テストコードを書きます。

```js:
function parseTemplate (template) {
	let el = document.createElement('root')
	el.innerHTML = template
	let toks = tokenize(el)
	let obj = parse(toks)
	console.log('parsed', obj)
	let node = exec(obj)
	console.log('result', node)
	return node
}

class Test {
	assert (a) {
		if (!a) {
			throw new Error('assert failed')
		}
	}

	run () {
		let node

		node = parseTemplate('<div v-if="1">1</div><div v-else-if="0">2</div><div v-else>3</div>')
		this.assert(node.textContent === '1')
		node = parseTemplate('<div v-if="0">1</div><div v-else-if="1">2</div><div v-else>3</div>')
		this.assert(node.textContent === '2')
		node = parseTemplate('<div v-if="0">1</div><div v-else-if="0">2</div><div v-else>3</div>')
		this.assert(node.textContent === '3')

		node = parseTemplate('<div><div v-if="1">123</div></div>')
		console.log(node)
	}
}

let test = new Test()
test.run()
```

テストしてあるケースは上記だけなのでバグがあるかもしれません。またif文の式の評価に`eval()`を使ってますが、これも手抜きです。本当はちゃんと式を計算する実装をしたほうがいいと思います。
上記の実装で以下のコードを実行します。

```js:
    node = parseTemplate('<div v-if="1">1</div><div v-else-if="0">2</div><div v-else>3</div>')
```

そうすると構築されるノードは以下のようになります。

```html:
<root><div>1</div></root>
```

## おわりに

言語処理の実装は難易度が高いのでブログ記事としては人気が出ません。そのためQiitaに書いてみることにしました。
お楽しみいただけたら幸いです。