Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【源码】手写mvvm 之 Compiler类 #37

Open
YIngChenIt opened this issue Jul 3, 2020 · 0 comments
Open

【源码】手写mvvm 之 Compiler类 #37

YIngChenIt opened this issue Jul 3, 2020 · 0 comments

Comments

@YIngChenIt
Copy link
Owner

【源码】手写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内部会把{{ xx }}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是如何实现数据劫持,双向绑定的

源码

点击查看源码

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant