阅读 1376

我是如何写 Vue 源码的:思路篇

看了那么多篇文章,我发现很多文章只会告诉你他是怎么写的而不会告诉你他是怎么想的。而我认为,能否写出代码最主要的是如何构思的?为什么有的人能把代码写的很优雅而有的人写的却很臃肿?为什么有的人能一直写下去而有的人却容易“中道崩殂”?我希望你在本篇文章有所收获,谢谢你的阅读!

逆向思维

我不知道你有没有试图寻找过 Vue 源码的入口,当然,这对熟悉代码审计的老手来说很容易。但是如果你并没有代码审计的任何经验,我想也你会头疼。当然,我这里并不讲如何进行代码审计。我要告诉你的是如何在不阅读源码的情况下去实现类似的功能,我称之为逆向思维

当然在你要模仿一个东西的时候你首先要熟悉它,而且还要有十分清晰的思路。下面我就谈谈我是如何用最简单的思路去实现 Vue 数据双向绑定的:

<!-- html -->
<div id="app">{{name}}</div>
复制代码
const app = new Vue({
    el: '#app',
    
    data: {
        name: 'Fish Chan'
    }
});
复制代码

这是最简单的 Vue 代码,我相信只要是学过 Vue 都能看懂。上面的代码 new 了一个 Vue 实例,且传了一个参数(对象类型)。

所以我新建了一个文件 core.js,内容如下:

// 目标:我需要一个 Vue 类,构造函数可以接收一个参
class Vue {
    constructor(options) {
        // TODO 编译模板并实现数据双向绑定
    }
}
复制代码

就这样,我们就有了一个基础的 Vue 类,它没有做任何事情。接下来,我们继续。替换模板里面的内容属于_编译_,所以我又创建了一个文件叫 compile.js(这里模拟了 Java 的思维,一个类一个文件,这样每个文件都很小巧,也很清楚每个文件是干嘛的):

// 目标:编译模板,替换掉模板内容: {{name}}
class Compile {
    constructor() {
        // TODO 编译模板
    }
}
复制代码

还是和上面一样,我没有写任何实质性的内容,因为我始终坚持一个原则 不写无用的代码,用则写,所以我写代码的习惯是需要用到某个数据了才会把需要的数据传过来。

现在我的 Compile 需要知道从哪里开始编译,于是我们传入了第一个参数 el; 我还需要把模板内容替换成真实的数据,所以又传了第二个参数,携带数据的 vue 实例:

class Compile {
    constructor(el, vue) {
        this.$el = document.querySelector(el);
        this.$vue = vue;
    }
    
    compileText() {
        // TODO 编译模板,找到 {{name}} 并替换成真实数据
    }
}
复制代码

为了一步步的牵引思路,你会发现我在代码中习惯用 TODO 去写好下一步,当然这在你思路十分清晰的时候是没必要这样做的,除非你临时有事需要离开你的电脑桌。

编译模板

我们顺着思路继续完成 compile.js

class Compile {
    constructor(el, vue) {
        this.$el = document.querySelector(el);
        this.$vue = vue;
    }
    
    compileText() {
        const reg = /\{\{(.*)\}\}/; // 用于匹配 {{name}} 的正则
        
        const fragment = this.node2Fragment(this.$el); // 把操作 DOM 改成操作文档碎片
        const node = fragment.childNodes[0]; // 取节点_对象_
        
        if (reg.test(node.textContent)) {
            let matchedName = RegExp.$1;
            node.textContent = this.$vue._data[matchedName]; // 替换数据
            this.$el.appendChild(node); // 编译好的文档碎片放进根节点
        }
    }
    
    node2Fragment(node) {
        const fragment = document.createDocumentFragment();
        fragment.appendChild(node.firstChild);
        return fragment;
    }
}
复制代码

其实,写到这里我们就已经完成了模板编译的部分。下面我们只需要在 core.js 里面调用它就好了:

class Vue {
    constructor(options) {
    	let data = this._data = options.data;
    	
    	const _complie = new Compile(options.el, this);
    	_complie.compileText();
    }
}
复制代码

先运行一下看看:

成功编译模板

数据双向绑定

嗯,编译模板已经实现了,现在开始实现数据双向绑定,在这之前我希望你先去了解下设计模式之观察者模式Object.defineProperty

新建一个 Observer 类,用于数据双向绑定:

class Observer {
    constructor(data) {
        this.defineReactive(data);
    }
    
    defineReactive(data) {
        Object.keys(data).forEach(key => {
            let val = data[key];
            Object.defineProperty(data, key, {
                get() {
                    // TODO 监听数据
                    return val;
                },
                set(newVal) {
                    val = newVal;
                    // TODO 更新视图
                }
            })
        });
    }
}
复制代码

接下来就是观察者模式的实现了,基本上是一个固定的模板(我认为设计模式是很好学的东西,就好比数学公式一样):

class Dep {
    constructor(vue) {
        this.subs = []; // 存放订阅者
    }
    
    addSubscribe(subscribe) {
        this.subs.push(subscribe);
    }
    
    notify() {
        let length = this.subs.length;
        while(length--)
        {
            this.subs[length].update();
        }
    }
}
复制代码

接下来是订阅者Watcher,订阅者要做的事情就是执行某个事件:

class Watcher {
    constructor(vue, exp, callback) {
        this.vue = vue;
        this.exp = exp;
        this.callback = callback;
        this.value = this.get();
    }
    
    get() {
        Dep.target = this;
        let value = this.vue._data[this.exp];
        Dep.target = null;
        return value;
    }
    
    update() {
        this.value = this.get();
        this.callback.call(this.vue, this.value); // 将新的数据传回,用于更新视图;这里保证了 this 指向 vue
    }
}
复制代码

就这样,照搬了观察者模式和利用Object.defineProperty就简单实现了一个数据双向绑定。

完整代码

下面把所有的 TODO 部分进行代码替换,我们就实现了所有的功能:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="author" content="Fish Chan">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>vue-demo</title>
    <script src="./Dep.js"></script>
    <script src="./Watch.js"></script>
    <script src="./Compile.js"></script>
    <script src="./Observer.js"></script>
    <script src="./core.js"></script>
</head>
<body>
    <div id="app">{{name}}</div>

    <script>
    const app = new Vue({
        el: '#app',

        data: {
            name: 'Fish Chan'
        }
    });
    </script>
</body>
</html>
复制代码

core.js

class Vue {
    constructor(options) {
        let data = this._data = options.data;

        new Observer(data);

        const _complie = new Compile(options.el, this);

        _complie.compileText();
    }
}
复制代码

Observer.js

class Observer {
    constructor(data) {
        this.defineReactive(data);
    }
    
    defineReactive(data) {
        let dep = new Dep();
        Object.keys(data).forEach(key => {
            let val = data[key];
            Object.defineProperty(data, key, {
                get() {
                    Dep.target && dep.addSubscribe(Dep.target);
                    return val;
                },
                set(newVal) {
                    val = newVal;
                    dep.notify();
                }
            })
        });
    }
}
复制代码

Compile.js

class Compile {
    constructor(el, vue) {
        this.$el = document.querySelector(el);
        this.$vue = vue;
    }
    
    compileText() {
        const reg = /\{\{(.*)\}\}/; // 用于匹配 {{name}} 的正则
        
        const fragment = this.node2Fragment(this.$el); // 把操作 DOM 改成操作文档碎片
        const node = fragment.childNodes[0];
        
        if (reg.test(node.textContent)) {
            let matchedName = RegExp.$1;
            node.textContent = this.$vue._data[matchedName]; // 替换数据
            this.$el.appendChild(node); // 编译好的文档碎片放进根节点

            new Watcher(this.$vue, matchedName, function(value) {
                node.textContent = value;
                console.log(node.textContent);
            });
        }
    }
    
    node2Fragment(node) {
        const fragment = document.createDocumentFragment();
        fragment.appendChild(node.firstChild);
        return fragment;
    }
}
复制代码

Watch.js

class Watcher {
    constructor(vue, exp, callback) {
        this.vue = vue;
        this.exp = exp;
        this.callback = callback;
        this.value = this.get();
    }
    
    get() {
        Dep.target = this;
        let value = this.vue._data[this.exp];
        Dep.target = null;
        return value;
    }
    
    update() {
        this.value = this.get();
        this.callback.call(this.vue, this.value); // 将新的数据传回,用于更新视图
    }
}
复制代码

Dep.js

class Dep {
    constructor(vue) {
        this.subs = []; // 存放订阅者
    }
    
    addSubscribe(subscribe) {
        this.subs.push(subscribe);
    }
    
    notify() {
        let length = this.subs.length;
        while(length--)
        {
            this.subs[length].update();
        }
    }
}
复制代码

看下最终的运行图吧:

总结

除了基本功扎实外,写代码一定要理清思路。思路是否清晰可能决定了你能否写出一份优雅的代码,也可能决定你是否能从始至终的完成一个项目。

关注下面的标签,发现更多相似文章
评论