教你如何写 Vue 源码中关于`编译、指令`的实现

135 阅读6分钟

# 前言

上篇文章简单实现了数据驱动,没看的小伙伴可以看下,我们今天主要来实现{{ counter }}的编译,以及简单指令的源码实现,包括指令类型的判断,依赖收集的逻辑等。

# 初步实现编译

上篇文章中我们的MyVue类主要实现了对options.data的响应式处理以及proxy代理两件事,今天我要实现第三件事:编译节点,如下:

class MyVue {
    constructor(options) {
        // 保存选项
        this.$options = options;
        this.$data = options.data;
        // 对 data 实现数据响应式
        observe(options.data);

        // 做代理
        proxy(this)

        // 编译
        new Compile(options.el, this)
    }
}

实现 Compile 类

class Compile {
    constructor(el, vm) {
        this.$vm = vm;
        this.$el = document.querySelector(el);
        // 如果节点存在,则开始编译
        if (this.$el) {
            this.compile(this.$el)
        }
    }

    // 遍历node,判断节点类型,做不同处理
    compile(node) {
        const childNodes = node.childNodes;
        // 遍历所有子节点
        Array.from(childNodes).forEach(n => {
            // 判断节点类型
            if (this.isElement(n)) {
                console.log('编译元素')
                    // 递归遍历是否有子元素
                if (n.childNodes.length > 0) {
                    this.compile(n)
                }
            } else {
                console.log('编译文本')
            }
        })
    };
    // 判断节点是否为 元素
    isElement(n) {
        return n.nodeType === 1
    }
}

如上:创建编译实例的时候接收两个参数:节点el实例vm,接着在拿到节点后开始编译节点。编译节点时就需要递归遍历所有子节点,并判断每个节点的类型,这里我们简单实现了一个元素类型和非元素类型的判断,html代码myVue.js代码如下:

html代码 

<div id="app">
    <p> {{counter}}</p>
    <p k-text="counter"></p>
</div>
<script src="./src/myVue.js"></script>
<script>
    const appVue = new MyVue({
        el: "#app",
        data: {
            counter: 1
        }
    });
</script>

myVue.js 代码:

// 定义响应式属性
function defineReactive(obj, key, val) {
    // 递归监测
    observe(val);

    // 定义响应式
    Object.defineProperty(obj, key, {
        get() {
            console.log('get', key, obj);
            return val;
        },
        set(newVal) {
            if (newVal != val) {
                console.log('set', newVal)
                val = newVal;
            }
        }
    })
}

// 遍历响应式处理
function observe(obj) {
    if (typeof obj !== 'object' || obj == null) {
        return obj;
    };

    // 创建观测实例
    new Observer(obj);
}

// 将传入的对象中的所有key都代理到指定的对象上
function proxy(vm) {
    Object.keys(vm.$data).forEach(key => {
        Object.defineProperty(vm, key, {
            get() {
                return vm.$data[key];
            },
            set(v) {
                vm.$data[key] = v;
            }
        })
    })
}

class Observer {
    constructor(obj) {
        if (Array.isArray(obj)) {
            // 数组类型的响应式观测
        } else {
            // 非数组类型的响应式观测
            this.walk(obj);
        }
    }
    walk(obj) {
        Object.keys(obj).forEach((key) => defineReactive(obj, key, obj[key]));
    }
}

class MyVue {
    constructor(options) {
        // 保存选项
        this.$options = options;
        this.$data = options.data;
        // 对 data 实现数据响应式
        observe(options.data);

        // 做代理
        proxy(this)

        // 编译
        new Compile(options.el, this)
    }
}

class Compile {
    constructor(el, vm) {
        this.$vm = vm;
        this.$el = document.querySelector(el);
        // 如果节点存在,则开始编译
        if (this.$el) {
            this.compile(this.$el)
        }
    }

    // 遍历node,判断节点类型,做不同处理
    compile(node) {
        const childNodes = node.childNodes;
        // 遍历所有子节点
        Array.from(childNodes).forEach(n => {
            // 判断节点类型
            if (this.isElement(n)) {
                console.log('编译元素')
                    // 递归遍历是否有子元素
                if (n.childNodes.length > 0) {
                    this.compile(n)
                }
            } else {
                console.log('编译文本')
            }
        })
    };
    // 判断节点是否为 元素
    isElement(n) {
        return n.nodeType === 1
    }
}

这时我们看下控制台的打印:

1650607385(1).png

接下来我们继续完善Comile类的内容,如下:

class Compile {
    constructor(el, vm) {
        this.$vm = vm;
        this.$el = document.querySelector(el);
        this.$interRegExp = /\{\{(.*)\}\}/;
        // 如果节点存在,则开始编译
        if (this.$el) {
            this.compile(this.$el)
        }
    }

    // 遍历node,判断节点类型,做不同处理
    compile(node) {
        const childNodes = node.childNodes;
        // 遍历所有子节点
        Array.from(childNodes).forEach(n => {
            // 判断节点类型为元素类型
            if (this.isElement(n)) {
                // 递归遍历是否有子元素
                if (n.childNodes.length > 0) {
                    this.compile(n)
                }
            }
            // 判断节点类型为文本节点且是插值表达式
            else if (this.isInter(n)) {
                // 编译插值文本
                this.compileText(n);
            }
        })
    };
    // 判断节点是否为 元素
    isElement(n) {
        return n.nodeType === 1
    };
    // 判断是否是插值表达式形如:{{xxx}}
    isInter(n) {
        return n.nodeType === 3 && this.$interRegExp.test(n.textContent);
    };
    // 编译插值文本
    compileText(n) {
        // 拿到表达式
        const interExp = n.textContent.match(this.$interRegExp)[1];
        // 替换表达式内容
        n.textContent = this.$vm[interExp]
    }
}

这时html代码中的{{counter}}已经被被编译成1了,如下图:

1650609493(1).png

实现了{{counter}}表达式后,我们继续实现k-text指令的编译。指令的编译肯定是在元素上的,所以当我们判断节点为元素的时候,就要遍历它所有的属性并做处理。这里我们增加一个compileElement方法去处理,如下:


class Compile {
    constructor(el, vm) {
        this.$vm = vm;
        this.$el = document.querySelector(el);
        this.$interRegExp = /\{\{(.*)\}\}/;
        // 如果节点存在,则开始编译
        if (this.$el) {
            this.compile(this.$el)
        }
    }

    // 遍历node,判断节点类型,做不同处理
    compile(node) {
        const childNodes = node.childNodes;
        // 遍历所有子节点
        Array.from(childNodes).forEach(n => {
            // 判断节点类型为元素类型
            if (this.isElement(n)) {
                //编译指令
                this.compileElement(n);

                // 递归遍历是否有子元素
                if (n.childNodes.length > 0) {
                    this.compile(n)
                }
            }
            // 判断节点类型为文本节点且是插值表达式
            else if (this.isInter(n)) {
                // 编译插值文本
                this.compileText(n);
            }
        })
    };
    // 判断节点是否为 元素
    isElement(n) {
        return n.nodeType === 1
    };
    // 判断是否是插值表达式形如:{{xxx}}
    isInter(n) {
        return n.nodeType === 3 && this.$interRegExp.test(n.textContent);
    };
    // 编译插值文本
    compileText(n) {
        // 拿到表达式
        const interExp = n.textContent.match(this.$interRegExp)[1];
        // 替换表达式内容
        n.textContent = this.$vm[interExp]
    };
    // 编译元素:遍历所有属性拿到`k-`开头的指令做处理
    compileElement(n) {
        // 拿到所有属性
        const attrs = n.attributes;
        // 遍历属性,并判断是否为指令
        Array.from(attrs).forEach(attr => {
            const attrName = attr.name;
            const attrValue = attr.value;
            // 如当前属性是指令
            if (this.isDir(attrName)) {
                const dir = attrName.substring(2);
                this[dir] && this[dir](n, attrValue)
            }
        })
    };
    // 指令 k-text 的处理函数
    text(node, exp) {
        node.textContent = this.$vm[exp];
    };
    // 判断是否为指令
    isDir(attrName) {
        return attrName.startsWith('k-');
    }
}

这时再看我们的页面,如下图:

1650610959(1).png

可以看到{{counter}k-text="counter"都已经被编译了,nice ~~

问题:如果我们的html代码加上个定时器每次累加counter,页面会实时更新吗?

<div id="app">
    <p> {{counter}}</p>
    <p k-text="counter"></p>
</div>
<script src="./src/myVue.js"></script>
<script>
    const appVue = new MyVue({
        el: "#app",
        data: {
            counter: 1
        }
    });
    setInterval(_ => {
        appVue.counter++
    }, 1000);
</script>

结果是没有实时更新,如下图:

1650611362(1).png

这是因为我们还没有实现依赖收集,接下来我们就看下如何实现依赖收集。

# 实现依赖收集 Watcher

在实现Watcher之前,我们先优化下我们的代码方便以后Watcher的调用。现在我们要把所有的更新操作全都提到update函数里,代码如下:

class Compile {
    constructor(el, vm) {
        this.$vm = vm;
        this.$el = document.querySelector(el);
        this.$interRegExp = /\{\{(.*)\}\}/;
        // 如果节点存在,则开始编译
        if (this.$el) {
            this.compile(this.$el)
        }
    }
    // 遍历node,判断节点类型,做不同处理
    compile(node) {
        const childNodes = node.childNodes;
        // 遍历所有子节点
        Array.from(childNodes).forEach(n => {
            // 判断节点类型为元素类型
            if (this.isElement(n)) {
                //编译指令
                this.compileElement(n);

                // 递归遍历是否有子元素
                if (n.childNodes.length > 0) {
                    this.compile(n)
                }
            }
            // 判断节点类型为文本节点且是插值表达式
            else if (this.isInter(n)) {
                // 编译插值文本
                this.compileText(n);
            }
        })
    };
    // 判断节点是否为 元素
    isElement(n) {
        return n.nodeType === 1
    };
    // 判断是否是插值表达式形如:{{xxx}}
    isInter(n) {
        return n.nodeType === 3 && this.$interRegExp.test(n.textContent);
    };
    // 编译插值文本
    compileText(n) {
        // 拿到表达式
        const interExp = n.textContent.match(this.$interRegExp)[1];
        // 替换表达式内容
        this.update(n, interExp, 'text')
    };
    // 编译元素:遍历所有属性拿到`k-`开头的指令做处理
    compileElement(n) {
        // 拿到所有属性
        const attrs = n.attributes;
        // 遍历属性,并判断是否为指令
        Array.from(attrs).forEach(attr => {
            const attrName = attr.name;
            const attrValue = attr.value;
            // 如当前属性是指令
            if (this.isDir(attrName)) {
                const dir = attrName.substring(2);
                this[dir] && this[dir](n, attrValue)
            }
        })
    };
    // 更新操作
    update(node, exp, dir) {
        // 初始化时
        const fn = this[dir + 'Updater'];
        fn && fn(node, this.$vm[exp]);
    };
    // 指令 k-text 的处理函数
    text(node, exp) {
        this.update(node, exp, 'text')
    };
    textUpdater(node, val) {
        node.textContent = val;
    };
    // 指令 k-html 的处理函数
    html(node, exp) {
        this.update(node, exp, 'html')
    };
    htmlUpdater(node, val) {
        node.innerHTML = val;
    };
    // 判断是否为指令
    isDir(attrName) {
        return attrName.startsWith('k-');
    }
}

如上:所有动态的更新造作都没有直接更新,而是调用了update方法。updata方法主要干两件事:初始化时数据更新时,现在我们只实现了初始化的操作。当初始化的时候,我通过拼接拿到了真正更新操作的函数xxUpdate,然后执行并更新。

准备操作做好了,我们开始实现依赖收集,这里主要是两个类:watcher 和 Dep,如下:

// 负责Dom更新
class Watcher {
    constructor(vm, key, updateFn) {
        this.vm = vm;
        this.key = key;
        this.this.vm = updateFn;
    }
    // 更新Dom
    update() {
        this.updateFn.call(this.vm, this.vm[this.key]);
    }
}

// 保存Watcher实例的依赖类
class Dep {
    constructor() {
        this.deps = []
    };
    // dep: Watcher 的实例
    addDep(dep) {
        this.deps.push(dep)
    };
    // 执行更新
    notify() {
        this.deps.forEach(dep => dep.update())
    }
}

到这里有个问题:依赖收集在哪开始呢?

答案就是在defineReactive函数中创建的,在此之前我们还得保存下Watcher实例,并出发一下get函数,如下:

// 负责Dom更新
class Watcher {
    constructor(vm, key, updateFn) {
        this.vm = vm;
        this.key = key;
        this.this.vm = updateFn;

        //触发get
        Dep.target = this;
        this.vm[this.key];
        Dep.target = null;
    }

    update() {
        this.updateFn.call(this.vm, this.vm[this.key]);
    }
}

// 定义响应式属性
function defineReactive(obj, key, val) {
    // 递归监测
    observe(val);
    // 创建Dep实例
    const dep = new Dep();
    // 定义响应式
    Object.defineProperty(obj, key, {
        get() {
            console.log('get', key, obj);
            Dep.target && dep.addDep(Dep.target)
            return val;
        },
        set(newVal) {
            if (newVal != val) {
                console.log('set', newVal)
                val = newVal;
                //执行更新
                dep.notify();
            }
        }
    })
}

上面我们提到,Compile 类里边的update方法主要干了来两件事:初始化和更新,所以我们要再实现下更新操作,如下:

// 更新操作
update(node, exp, dir) {
    // 初始化时
    const fn = this[dir + 'Updater'];
    fn && fn(node, this.$vm[exp]);
    // 数据更新时
    new Watcher(this.$vm, exp, val => {
        fn && fn(node, val);
    })
};

至此已经创建Watcher、收集依赖以及依赖和Watcher怎么对应起来,页面执行结如下图:

1650616419(1).png

完成代码如下:

// 定义响应式属性
function defineReactive(obj, key, val) {
    // 递归监测
    observe(val);
    // 创建Dep实例
    const dep = new Dep();
    // 定义响应式
    Object.defineProperty(obj, key, {
        get() {
            console.log('get', key, obj);
            // 依赖关系的收集
            Dep.target && dep.addDep(Dep.target)
            return val;
        },
        set(newVal) {
            if (newVal != val) {
                console.log('set', newVal)
                val = newVal;
                //执行更新
                dep.notify();
            }
        }
    })
}

// 遍历响应式处理
function observe(obj) {
    if (typeof obj !== 'object' || obj == null) {
        return obj;
    };

    // 创建观测实例
    new Observer(obj);
}

// 将传入的对象中的所有key都代理到指定的对象上
function proxy(vm) {
    Object.keys(vm.$data).forEach(key => {
        Object.defineProperty(vm, key, {
            get() {
                return vm.$data[key];
            },
            set(v) {
                vm.$data[key] = v;
            }
        })
    })
}

class Observer {
    constructor(obj) {
        if (Array.isArray(obj)) {
            // 数组类型的响应式观测
        } else {
            // 非数组类型的响应式观测
            this.walk(obj);
        }
    }
    walk(obj) {
        Object.keys(obj).forEach((key) => defineReactive(obj, key, obj[key]));
    }
}

class MyVue {
    constructor(options) {
        // 保存选项
        this.$options = options;
        this.$data = options.data;
        // 对 data 实现数据响应式
        observe(options.data);

        // 做代理
        proxy(this)

        // 编译
        new Compile(options.el, this)
    }
}

class Compile {
    constructor(el, vm) {
        this.$vm = vm;
        this.$el = document.querySelector(el);
        this.$interRegExp = /\{\{(.*)\}\}/;
        // 如果节点存在,则开始编译
        if (this.$el) {
            this.compile(this.$el)
        }
    }

    // 遍历node,判断节点类型,做不同处理
    compile(node) {
        const childNodes = node.childNodes;
        // 遍历所有子节点
        Array.from(childNodes).forEach(n => {
            // 判断节点类型为元素类型
            if (this.isElement(n)) {
                //编译指令
                this.compileElement(n);

                // 递归遍历是否有子元素
                if (n.childNodes.length > 0) {
                    this.compile(n)
                }
            }
            // 判断节点类型为文本节点且是插值表达式
            else if (this.isInter(n)) {
                // 编译插值文本
                this.compileText(n);
            }
        })
    };
    // 判断节点是否为 元素
    isElement(n) {
        return n.nodeType === 1
    };
    // 判断是否是插值表达式形如:{{xxx}}
    isInter(n) {
        return n.nodeType === 3 && this.$interRegExp.test(n.textContent);
    };
    // 编译插值文本
    compileText(n) {
        // 拿到表达式
        const interExp = n.textContent.match(this.$interRegExp)[1];
        // 替换表达式内容
        this.update(n, interExp, 'text')
    };
    // 编译元素:遍历所有属性拿到`k-`开头的指令做处理
    compileElement(n) {
        // 拿到所有属性
        const attrs = n.attributes;
        // 遍历属性,并判断是否为指令
        Array.from(attrs).forEach(attr => {
            const attrName = attr.name;
            const attrValue = attr.value;
            // 如当前属性是指令
            if (this.isDir(attrName)) {
                const dir = attrName.substring(2);
                this[dir] && this[dir](n, attrValue)
            }
        })
    };
    // 更新操作
    update(node, exp, dir) {
        // 初始化时
        const fn = this[dir + 'Updater'];
        fn && fn(node, this.$vm[exp]);
        // 数据更新时
        new Watcher(this.$vm, exp, val => {
            fn && fn(node, val);
        })
    };
    // 指令 k-text 的处理函数
    text(node, exp) {
        this.update(node, exp, 'text')
    };
    textUpdater(node, val) {
        node.textContent = val;
    };
    // 指令 k-html 的处理函数
    html(node, exp) {
        this.update(node, exp, 'html')
    };
    htmlUpdater(node, val) {
        node.innerHTML = val;
    };
    // 判断是否为指令
    isDir(attrName) {
        return attrName.startsWith('k-');
    }
}

// 负责Dom更新
class Watcher {
    constructor(vm, key, updateFn) {
        this.vm = vm;
        this.key = key;
        this.updateFn = updateFn;

        //触发get
        Dep.target = this;
        this.vm[this.key];
        Dep.target = null;
    };
    // 通知视图进行更新
    update() {
        this.updateFn.call(this.vm, this.vm[this.key]);
    }
}

// 保存Watcher实例的依赖类
class Dep {
    constructor() {
        this.deps = []
    };
    // dep: Watcher 的实例,创建依赖关系时调用
    addDep(dep) {
        this.deps.push(dep)
    };
    // 执行更新
    notify() {
        this.deps.forEach(dep => dep.update())
    }
}

到这里两篇文章主要模拟了数据驱动和指令的变编译,其中涉及到响应式处理、依赖收集和Watcher的创建等操作,虽是模拟造作,但也有源码的核心思想,希望对你理解Vue有所帮助,欢迎评论留言,nice ~~