Vueのようなif文
VueはHTMLにif文を仕込むことができます。以下のようにです。
<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を構築してくれます。
let div = document.createElement('div')
div.innertHTML = '<p>Hello</p>'
パースしたい文字列をこれでパースしてDOMを構築し、それを再度パースするという形で実装していきます。
字句解析
まずinnerHTML
にパースしたいテキストを流し込み、その要素を以下のtokenize()
に渡します。
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
ラベルを貼っておきます。
構文解析
字句解析の次は構文解析です。
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を構築する構文実行です。
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
などのノードの属性はこの段階で削除しておきます。
テスト
テストコードを書きます。
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()
を使ってますが、これも手抜きです。本当はちゃんと式を計算する実装をしたほうがいいと思います。
上記の実装で以下のコードを実行します。
node = parseTemplate('<div v-if="1">1</div><div v-else-if="0">2</div><div v-else>3</div>')
そうすると構築されるノードは以下のようになります。
<root><div>1</div></root>
おわりに
言語処理の実装は難易度が高いのでブログ記事としては人気が出ません。そのためQiitaに書いてみることにしました。
お楽しみいただけたら幸いです。