# 【源码】手写mvvm 之 Compiler类

# 前言

本文是我们模仿源码手写一款MVVM的上篇 - 手写 Compiler, 也就是我们说的模板编译, 但是和源码不一样的是源码中使用了ast而我们为了方便使用的是fragment来对节点进行操作。好啦我们直接进入正题手写 Compiler.

# 手撸Vue中的模板编译

我们先来看一个简单的Vue用法, 然后根据这个用法一步一步的写出我们想要的效果




 
















<body>
    <div id="app">
        <input type="text" v-model="man.name">
        <div>{{ man.name }}</div>
        <div>{{ man.age }}</div>
    </div>
    <script src="vue.js"></script>
    <script>
        let vm = new Vue({
            el: '#app',
            data: {
                man: {
                    name: 'chenying',
                    age: '23',
                }
            }
        })
    </script>
</body>

我们可以发现, Vue内部会把v-xx等语法进行编译, 最后使用data中对应的数据填充过去,这个就是我们模板编译的大致思路.

# Vue类

我们在使用Vue的时候往往会这样初始化




 






    let vm = new Vue({
        el: '#app',
        data: {
            man: {
                name: 'chenying',
                age: '23',
            }
        }
    })

不难看出Vue是一个类,接受一个options, 也就是钩子的集合,我们来实现一下这个类




 







class Vue {
    constructor(options) {
        this.$el = options.el
        this.$data = options.data

        if (this.$el) { // 如果元素存在 我们需要编译模板
            new Compiler(this.$el, this)
        }
    }
}

我们会将options中的钩子挂载到Vue的实例上, 这里只挂载了我们需要的$el$data供后序流程使用, 然后通过new Compiler(this.$el, this)来进行编译。我们接下来重点看下Compiler是什么

# Compiler类




 























class Compiler {
    constructor(el, vm) {
        this.el = this.isElementNode(el) ? el : document.querySelector(el)
        this.vm = vm

        // 将当前节点的元素全部丢到内存中去(源码是通过ast)
        let fragment = this.noToFragment(this.el)
        
        // 编译模板 用数据替换
        this.complie(fragment)

        // 将内存的节点放回到页面中
        this.el.appendChild(fragment)
    }
    isElementNode(node) { // 判断是否是node节点
        return node.nodeType === 1
    }
    noToFragment(node) { // 将元素丢到文档碎片中
        let fragment = document.createDocumentFragment()
        let firstChild
        while (firstChild = node.firstChild) {
            fragment.appendChild(firstChild)
        }
        return fragment
    }
}

首先会判断当前$el上面的值是节点还是字符串, 如果是字符串的话使用document.querySelector获取真实的DOM

然后调用noToFragment()方法递归遍历这个DOM节点, 将它存在文档碎片, 也就是内存中。

TIP

这里需要注意的是, 最新源码中并不是使用文档碎片的形式, 而是使用AST的形式

最后调用complie()方法对文档碎片进行编译,然后将编译后的文档随便追加到页面中。

我们可以很容易的猜到complie()方法的作用就是用options中的数据替换掉Vue语法中的{ {xx} }v-xx等值或者逻辑

接下来我们重点看下complie()方法内部做了什么

# complie方法




 














complie(node) { // 将内存中的节点编译
    let childNodes = node.childNodes
    const childs = [...childNodes]
    childs.forEach(child => {
        if (this.isElementNode(child)) { //如果是节点 走节点的编译
            this.complieElement(child)
        } else { // 否则走文本的编译
            this.complieText(child)
        }
    })
}
complieText(node) { // 编译文本
    const content = node.textContent
    if (/\{\{(.+?)\}\}/.test(content)) {//正则匹配{{ xxx }}的文本节点
        CompilerUnit['text'](node, content, this.vm)
    }
}

我们可以发现complie()方法的作用是遍历我们的模板得到一个个的节点, 然后判断当前是节点还是文档,调用对应的编译方法进行编译

# 编译节点

我们首先开看下是如何编译节点的




 






























isDirective(attrName) { // 判断是不是指令 v-xxx
    return attrName.startsWith('v-')
}
complieElement(node) { // 编译节点
    this.complie(node) //如何是节点 节点内部的节点或者元素也要编译
    const attributes = node.attributes
    const attrs = [...attributes]
    attrs.forEach(attr => {
        const {name, value:expr} = attr
        if (this.isDirective(name)) { // v-model v-html
            let [, directive] = name.split('-') // 拿到 model html
            CompilerUnit[directive](node, expr, this.vm) // 不同的指令走不同的处理函数
        }
    })
}

CompilerUnit = {
    getVal(vm, expr) { // 根据 man.name 拿到 $data 里面的name的值
        return expr.split('.').reduce((data, current) => {
            return data[current]
        }, vm.$data)
    },
    model(node, expr, vm) {
        const fn = this.updata['modelUpdata']
        let value = this.getVal(vm, expr)
        fn(node, value)
    },
    updata: { // 更新页面数据的方法集合
        modelUpdata(node, newValue) {
            node.value = newValue
        },
    }
}

我们拿到node节点之后, 会是如下的格式




 
    <input type="text" v-model="xxx">
    <div>{{ xxx }}</div>
    <div>{{ xxx }}</div>

所以我们首先需要调用complie()方法进行递归,保证节点的子节点也可以编译。

然后我们拿到每个节点的属性attrs,通过正则判断是不是Vue中的指令格式v-xxx, 如果是的话调用对应模块的方法, 如v-modal调用的是CompilerUnit.model()方法

我们再将重心放到CompilerUnit.model()方法, 这里出现了一个很神奇的用法, 这也是我在源码当中看到的挺好玩的地方, 假设我们的代码是这样的v-modal="man.name", 也就是我们拿到的表达式是man.name,在源码中是通过reduce的用法将这类表达式解析然后获取到vm.$data.man.name的值

最后就是将这个值通过js渲染到页面的元素上

# 编译文本

在看完编译节点之后,我们最后来看下是如何编译文本的




 






















complieText(node) { // 编译文本
    const content = node.textContent
    if (/\{\{(.+?)\}\}/.test(content)) {//正则匹配{{ xxx }}的文本节点
        CompilerUnit['text'](node, content, this.vm)
    }
}
CompilerUnit = {
    getVal(vm, expr) { // 根据 man.name 拿到 $data 里面的name的值
        return expr.split('.').reduce((data, current) => {
            return data[current]
        }, vm.$data)
    },
    text(node, expr, vm) {
        const fn = this.updata['textUpdata']
        let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
            return this.getVal(vm, args[1].trim()) // 去除首尾空格 兼容多种写法 {{x}} {{ x }}
        })
        fn(node, content)
    },
    updata: { // 更新页面数据的方法集合
        textUpdata(node, newValue) {
            node.textContent = newValue
        }
    }
}

编译文本的原理是找到模板中{ { xxx } }格式的数据, 并且用vm.$data中的数据替换掉格式中的xxx就好了.

# 总结

其实Vue中的Compiler不难,核心就是通过正则等手段将Vue中特定的语法用options的值替换,但是也有很多我们可以学习的地方,如匹配的正则表达式/\{\{(.+?)\}\}/、如何通过reduce来获取深层次的数据、如果判断节点的类型等等。

那么下一篇我们将在上述写好模板编译功能代码的基础上实现我们MVVM中缺少的那部分 -- Vue是如何实现数据劫持,双向绑定的

# 源码

点击查看源码