关于Vue2的双向绑定

本文是个人学习Vue2双向绑定源码的手撕实现

本文是整理小破站up@技术蛋老师的讲解而得:www.bilibili.com/video/BV193...

演示的DOM如下:

html 复制代码
<body>
    <div id="app">
        <span>所以你叫什么:{{ name }}</span>
        <input type="text" v-model="name" />
        <span>个性:{{ personality.like }}</span>
        <input type="text" v-model="personality.like" />
    </div>
    <!-- 该js文件用于实现构造方法Vue -->
    <script src="./Vue_Model.js"></script>
    <script>
        const vm = new Vue({
            el: '#app',
            data: {
                name: '超级三文鱼',
                personality: {
                    like: '14',
                },
            },
        });
        console.log(vm);
    </script>
</body>

书写Vue的构造方法 用于实现数据的双向绑定

双向绑定可以分为两部分:数据劫持发布订阅

首先获取组件的data属性

js 复制代码
// Vue_Model.js
class Vue {
    constructor(obj_instance) {
    this.$data = obj_instance.data;
	}
}

数据劫持

为什么要进行数据劫持?

解释:JS中,如果对象的属性发生了变化,需要通知我们,然后把更改后的属性更新到对应的DOM节点身上 。换句话说,我们需要做到能够监听对象里属性的变化,因此这初始化Vue实例的时候调用数据监听函数Observer

Observer
js 复制代码
// Vue_Model.js
class Vue {
    constructor(obj_instance) {
		this.$data = obj_instance.data;
        Observer(this.$data);
    }
}

// 数据劫持 - 监听实例里的数据
function Observer(data_instance) {}

打开浏览器看一眼,可以发现我们的vm实例已经创建出来了

说到数据监听,会想到getter和setter,接下来我们补充下Observer函数的内容

js 复制代码
function Observer(data_instance) {
    Object.keys(data_instance).forEach((key) => {
        let value = data_instance[key];
        Object.defineProperty(data_instance, key, {
            enumerable: true, // 表示数据是可枚举的
            configurable: true, // 表示属性描述符可以被改变
            get() {
                console.log(`访问了属性:${key} -> 值:${value}`);
                return value;
            },
            set(newValue) {
                console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
                value = newValue;
            },
        });
    });
}

做到这里,Observer只能浅拷贝data,而还无法获得获得personality.like这个属性,所以接下来我们加入递归

js 复制代码
function Observer(data_instance) {
    // 递归出口
    if (!data_instance || typeof data_instance !== 'object') return; // 新增
    Object.keys(data_instance).forEach((key) => {
        let value = data_instance[key];
        // 递归 - 子属性数据劫持
        Observer(value);  // 新增
        Object.defineProperty(data_instance, key, {
            enumerable: true, 
            configurable: true, 
            get() {
                console.log(`访问了属性:${key} -> 值:${value}`);
                return value;
            },
            set(newValue) {
                console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
                value = newValue;
            },
        });
    });
}

看上去好像没啥毛病了,其实我们忽略一个问题:

假如我们改写data属性里的name:vm.$data.name = { 'Hello' : 'JS是世界上最好的语言' },并没有创建对应Hello的getter和setter,改写personality也是同理。

为什么会这样呢?因为我们根本没有设置,只需要在setter中增加Observer(newValue)即可解决问题

js 复制代码
function Observer(data_instance) {
    if (!data_instance || typeof data_instance !== 'object') return;
    Object.keys(data_instance).forEach((key) => {
        let value = data_instance[key];
        Observer(value);  
        Object.defineProperty(data_instance, key, {
            enumerable: true, 
            configurable: true, 
            get() {
                console.log(`访问了属性:${key} -> 值:${value}`);
                return value;
            },
            set(newValue) {
                console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
                value = newValue;
                Observer(newValue);  // 新增
            },
        });
    });
}

当然,其实还有一种情况我们没有考虑到:当我们添加一个data中没有的属性,同样是没有getter和setter的,这属于是另一种情况,本文着重考虑new一个Vue实例时背后发生的事情。

我们在能够劫持数据以后就要把Vue里的数据应用到页面DOM上,插值表达式里的数据就是我们需要进行更新的。实际步骤可以参考如下:

获取页面元素 -> 放入临时内存区域 -> 应用Vue数据 -> 渲染页面

中间开辟一个内存区域,把所有的数据更新后再渲染页面,而不是获取一个元素就渲染一个页面,可以减少频繁操作DOM。所以接下来我们需要书写解析函数Compile了。

Compile

添加:在创建Vue实例时我们就可以直接调用Compile模板解析器

Compile函数有两个形参,第一个是要编译的DOM元素,第二个是vm实例(会用到data里的数据)

js 复制代码
class Vue {
    constructor(obj_instance) {
        this.$data = obj_instance.data;
        Observer(this.$data);
        Compile(obj_instance.el, this); // 新增
    }
}

// HTML模板解析 - 替换DOM内容
function Compile(element, vm) {}

接下来我们补充Compile函数:

首先获取页面元素DOM,然后创建临时内存,把该DOM的子节点逐个逐个加到fragment变量里面

js 复制代码
function Compile(element, vm) {
    vm.$el = document.querySelector(element);
    const fragment = document.createDocumentFragment();
    let child = null;
    while ((child = vm.$el.firstChild)) {
        fragment.append(child); // 同时会移除页面上的DOM
    }
}

而我们的目的是:修改DOM里的文本,也就是插值语法 {{}} 里的内容(文本节点),所以接下来我们写一个fragment_compile函数并调用它去实现这个一个修改

js 复制代码
function Compile(element, vm) {
    vm.$el = document.querySelector(element);
    const fragment = document.createDocumentFragment();
    let child = null;
    while ((child = vm.$el.firstChild)) {
        fragment.append(child); 
    }
    // 以下全部内容为新增
    fragment_compile(fragment);
    // 替换文档碎片内容(文本节点)
    function fragment_compile(node) {
        // 通过正则表达式来匹配文本节点里的文本
        const pattern = /\{\{\s*(\S+)\s*\}\}/;
        // 文本节点的nodeType是3
        if (node.nodeType === 3) {
            console.log(node);
            console.log(node.nodeValue);
            return;
        }
        // 文本节点里可能还有子节点,进行递归
        node.childNodes.forEach((child) => fragment_compile(child));
    }
}

在上面的代码中,我们打印了文档碎片内容:如下

发现:有一些内容是我们不想要的,比如换行符,大括号等等 ,我们只想取到插值语法里面的内容,就是这里的name和personality.like。

一个很常见的匹配方法就是正则:const pattern = /\{\{\s*(\S+)\s*\}\}/;

解释下这里的正则:花括号我们用了一个反斜杠来转义,在连续两个花括号的内部考虑到程序员会有空格,所以用了一个\s*来匹配前后的空格,中间的小括号部分是我们要匹配出来的。

修改一些代码 -> 运用正则的exec()方法获取到我们想要的内容:

js 复制代码
function fragment_compile(node) {
    const pattern = /\{\{\s*(\S+)\s*\}\}/;
    if (node.nodeType === 3) {
        // 修改部分-始
        const result_regex = pattern.exec(node.nodeValue);
            if (result_regex) {
                console.log(node.nodeValue);
                console.log(result_regex);
            }
        // 修改部分-终
        return;
    }
    // 文本节点里可能还有子节点,进行递归
    node.childNodes.forEach((child) => fragment_compile(child));
}

我们打印了文档碎片fragment的值和正则匹配后得到的值,如下图所示:我们把换行符给干掉了

而我们想要提取的元素正是正则匹配出来的索引为1的元素,也就是属性的名字 name 和 personality.like

接下来我们就可以把这些需要的元素取出来了。需要注意的是:对于personality这个属性,因为他是一个对象,所以我们需要采用链式获取属性里子属性like的值,而关于这个链式获取,我们可以采用数组的reduce方法,又因为正则匹配出来的数据中,索引为1的这个数据是一个字符串,所以我们需要先将其用split方法转换为一个数组。

js 复制代码
function fragment_compile(node) {
    const pattern = /\{\{\s*(\S+)\s*\}\}/;
    if (node.nodeType === 3) {
        const result_regex = pattern.exec(node.nodeValue);
            if (result_regex) {
                // 修改部分-始
                const arr = result_regex[1].split('.');
                    const value = arr.reduce(
                        (total, current) => total[current],
                        vm.$data
                    );
                    console.log(value);
                // 修改部分-终
            }
        return;
    }
    // 文本节点里可能还有子节点,进行递归
    node.childNodes.forEach((child) => fragment_compile(child));
}

同样的,我们打印下获取结果,控制台输出如下:可以看出我们成功获取了对应的值

然后我们就可以把当前节点值的内容用replace方法进行替换,然后再赋值把原先的节点内容给覆盖掉

js 复制代码
function fragment_compile(node) {
    const pattern = /\{\{\s*(\S+)\s*\}\}/;
    if (node.nodeType === 3) {
        const result_regex = pattern.exec(node.nodeValue);
            if (result_regex) {
                const arr = result_regex[1].split('.');
                    const value = arr.reduce(
                        (total, current) => total[current],
                        vm.$data
                    );
                    // 修改部分-始
                    node.nodeValue = node.nodeValue.replace(pattern, value);
                    console.log(node.nodeValue);
                    // 修改部分-终
            }
        return;
    }
    // 文本节点里可能还有子节点,进行递归
    node.childNodes.forEach((child) => fragment_compile(child));
}

我们同样打印下修改后fragment碎片中的值,可以看到原本有插值表达式的文本已经被替换过来了

接下来我们就可以把文本碎片应用到对应的DOM里面了,因为文本碎片我们已经修改好了,完成这一步之后我们页面的DOM就恢复了,并且是没有插值表达式的大括号的

js 复制代码
function Compile(element, vm) {
    // ...
    fragment_compile(fragment);
    function fragment_compile(node) {
        // ...
    }
    
    // 新增部分
    vm.$el.appendChild(fragment);  // 把文本碎片应用到vm里的$el属性里面
}

自此,我们就实现了数据劫持,并把数据应用到页面了。但是数据变动还不能实时更新,接下来我们来实现发布者-订阅者模式。

发布者-订阅者模式

首先我们来创建两个个类,类(Dependency)用于收集和通知订阅者,类(Watcher)是订阅者

js 复制代码
// 依赖 - 收集和通知订阅者
class Dependency {
    constructor() {
        this.subsribers = []; // 收集所有的订阅者
    }
    addsubsriber(sub) {
        this.subsribers.push(sub); // 由于一开始并非知道有多少个订阅者
    }
    notify() {
        this.subsribers.forEach((sub) => sub.update()); // 通知订阅者去更新数据
    }
}

// 订阅者
class Watcher {
    constructor(vm, key, callback) {
        this.vm = vm;	           // vm实例
        this.key = key;	     // 对应的属性名
        this.callback = callback;  // 回调函数:记录如何更新文本内容
    }
    update() {
        this.callback();
    }
}

那么接下来的问题就是什么时候来创建订阅者实例呢?

解答:在模板解析的时候,我们就会修改文本内容,也就是替换节点值内容的时候,所以创建订阅者实例的时机就是:替换fragment文档碎片内容的时候

js 复制代码
function Compile(element, vm) {
	// ...
    fragment_compile(fragment);
    function fragment_compile(node) {
        const pattern = /\{\{\s*(\S+)\s*\}\}/;
        if (node.nodeType === 3) {
            // 注意:这里必须存储旧的fragment碎片值,因为创建Watcher实例时并未调用回调函数
            const node_oldValue = node.nodeValue;  // 新增
            const result_regex = pattern.exec(node.nodeValue);
            if (result_regex) {
                const arr = result_regex[1].split('.');
                const value = arr.reduce(
                    (total, current) => total[current],
                    vm.$data
                );
                // 这里也修改为了node_oldValue
                node.nodeValue = node_oldValue.replace(pattern, value);
                // 新增:创建订阅者
                new Watcher(vm, result_regex[1], (newValue) => {
                    node.nodeValue = node_oldValue.replace(pattern, newValue);
                })
            }
            return;
        }
        node.childNodes.forEach((child) => fragment_compile(child));
    }
    vm.$el.appendChild(fragment);
}

创建完了Watcher实例,我们该如何把该订阅者实例存储到Dependency实例的数组中呢?

我们进行如下修改:在Dependency类中增加temp属性存储订阅者实例上下文

js 复制代码
// 订阅者
class Watcher {
    constructor(vm, key, callback) {
        this.vm = vm;	           // vm实例
        this.key = key;		// 对应的属性名
        this.callback = callback;  // 回调函数:记录如何更新文本内容

        // 新增:在Dependency类中增加temp属性存储订阅者实例上下文
        Dependency.temp = this;
    }

    update() {
        this.callback();
    }
}

接下来我们要把这个新的订阅者添加到订阅者数组里,而且我们要为所有的订阅者都进行该操作

我们可以利用getter的特性来执行这个操作:初次渲染也就是将插值语法转换为值的时候,肯定会用到getter,我们在getter中便可以把这个订阅者实例给push到Dependency实例的订阅者数组中。

当然了,Dependency实例也是需要new的;还有另一个关键点,订阅者实例加入订阅者数组结束后,需要把Dependency.temp置空,防止同一watcher被反复加入订阅者数组。

js 复制代码
function Observer(data_instance) {
    if (!data_instance || typeof data_instance !== 'object') return;
    // 创建Dependency实例
    const dependency = new Dependency(); // 新增
    Object.keys(data_instance).forEach((key) => {
        let value = data_instance[key];
        Observer(value);  
        Object.defineProperty(data_instance, key, {
            enumerable: true, 
            configurable: true, 
            get() {
                console.log(`访问了属性:${key} -> 值:${value}`);
                // 订阅者加入dependency实例的数组
                Dependency.temp && dependency.subsribers.push(Dependency.temp); // 新增
                return value;
            },
            set(newValue) {
                console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
                value = newValue;
                Observer(newValue); 
            },
        });
    });
}

// 订阅者
class Watcher {
    constructor(vm, key, callback) {
        this.vm = vm;	           // vm实例
        this.key = key;		// 对应的属性名
        this.callback = callback;  // 回调函数:记录如何更新文本内容

        Dependency.temp = this;
        // 新增:只遍历,不做其他操作
            key.split('.').reduce(
                    (total, current) => total[current],
                    vm.$data
            );
            // 新增:getter调用完后,将temp置空
            Dependency.temp = null;
    }
    
    update() {
        this.callback();
    }
}

好的,getter处理好了就轮到setter了。我们在修改数据的时候一定会通知订阅者来进行更新,因此我们在setter里调用dependency实例身上的notify方法

js 复制代码
function Observer(data_instance) {
    if (!data_instance || typeof data_instance !== 'object') return;
    const dependency = new Dependency(); 
    Object.keys(data_instance).forEach((key) => {
        let value = data_instance[key];
        Observer(value);  
        Object.defineProperty(data_instance, key, {
            enumerable: true, 
            configurable: true, 
            get() {
                console.log(`访问了属性:${key} -> 值:${value}`);
                Dependency.temp && dependency.subsribers(Dependency.temp); 
                return value;
            },
            set(newValue) {
                console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
                value = newValue;
                Observer(newValue);  // 新增
            },
        });
    });
}

通知过程如下:

到这里就已经实现了:修改组件的data,页面能同步更新展示了。

input标签的绑定

完成了文本的绑定,接下来我们完成带有v-model的input标签的绑定

那首先我们需要判定哪个节点有v-model:元素节点的节点类型是1,我们可以用nodeName来匹配INPUT元素;因此,我们便可以在判断文本节点下面进行新的if判断

js 复制代码
function Compile(element, vm) {
    // ...
    
    fragment_compile(fragment);
    function fragment_compile(node) {
        // ...
        if (node.nodeType === 3) {...}
        // 新增:元素节点的nodeType是1
        if (node.nodeType === 1 && node.nodeName === 'INPUT') {
            const attr = node.attributes;
            console.log(attr);  // 打印属性
        }
        // ...
    }
    // ...
}

把获取到的attr打印出来如下图:

我们将attr转换成数组:const attr = Array.from(node.attributes)

接下来我们完善代码,实现:修改组件的data数据后,input标签的value能同步更新显示

js 复制代码
function Compile(element, vm) {
    // ...
    
    fragment_compile(fragment);
    function fragment_compile(node) {
        // ...
        if (node.nodeType === 3) {...}
        if (node.nodeType === 1 && node.nodeName === 'INPUT') {
            const attr = Array.from(node.attributes);
                attr.forEach((i) => {
                    if (i.nodeName === 'v-model') {
                        const value = i.nodeValue.split('.').reduce(
                            (total, current) => total[current],
                            vm.$data
                        );
                        node.value = value;
                        // 同样需要new Watcher
                        new Watcher(vm, i.nodeValue, newValue => {
                            node.value = newValue;
                        })
                    }
                })
            
        }
        // ...
    }
    // ...
}

这里我们已经实现修改组件的data数据后,input标签的value能同步更新显示啦

最后,我们还需要实现最后一个功能:页面上input标签发生input事件时,要同步修改组件的data里的属性

我们就在有v-model的input标签上增加input监听事件就可以了

js 复制代码
function Compile(element, vm) {
    // ...
    
    fragment_compile(fragment);
    function fragment_compile(node) {
        // ...
        if (node.nodeType === 3) {...}
        if (node.nodeType === 1 && node.nodeName === 'INPUT') {
            const attr = Array.from(node.attributes);
                attr.forEach((i) => {
                    if (i.nodeName === 'v-model') {
                        const value = i.nodeValue.split('.').reduce(
                            (total, current) => total[current],
                            vm.$data
                        );
                        node.value = value;
                        new Watcher(vm, i.nodeValue, newValue => {
                            node.value = newValue;
                        })
                        
                        // 新增:难点在于赋值data中深层次的属性
                        node.addEventListener('input', (e) => {
                            const arr1 = i.nodeValue.split('.');
                            const arr2 = arr1.slice(0, arr1.length - 1);
                            const final = arr2.reduce(
                                (total, current) => total[current],
                                vm.$data
                            );
                            final[arr1[arr1.length - 1]] = e.target.value;
                        });
                    }
                })
            
        }
        // ...
    }
    // ...
}

关于这里的赋值操作,并不是像之前取值直接的split('.').reduce(...),我们来详细看一下这里的操作:

比如给personality.like赋值时,i.nodeValue这里的值为'personality.like',而我们是不能用这种方式来赋值的:vm.$data[personality.like] = e.target.value,因为取不到。

这里先把i.nodeValue转换为数组并存储在arr1中,arr1即为['personality', 'like'],然后把arr1的最后一个元素去掉并存储在arr2中,arr2即为['personality'],然后再对arr2用reduce方法一直得到arr1最后一个元素的前一层级的对象并存储在final中,最后再把新值赋值给final.like,就可以成功更换组件中data里的深层次属性了。

而又因为我们已经实现了:修改data里的属性后,页面能同步更新显示,所以到这里我们就实现了:修改input标签的内容,页面同步更新显示了,至此我们整个双向绑定就完成了。

附录:Vue_model.js源代码

tip:DOM结构在页面最上方就不重复写在此处了

js 复制代码
class Vue {
    constructor(obj_instance) {
        this.$data = obj_instance.data;
        Observer(this.$data);
        Compile(obj_instance.el, this);
    }
}

// 数据劫持 - 监听实例里的数据
function Observer(data_instance) {
    // 递归出口
    if (!data_instance || typeof data_instance !== 'object') return;
    const dependency = new Dependency();
    Object.keys(data_instance).forEach((key) => {
        let value = data_instance[key];
        // 递归 - 子属性数据劫持
        Observer(value);
        Object.defineProperty(data_instance, key, {
            enumerable: true, // 表示数据是可枚举的
            configurable: true, // 表示属性描述符可以被改变
            get() {
                console.log(`访问了属性:${key} -> 值:${value}`);
                // 订阅者加入dependency实例的数组
                Dependency.temp && dependency.subsribers.push(Dependency.temp);
                return value;
            },
            set(newValue) {
                console.log(`属性${key}的值${value}修改为 -> ${newValue}`);
                value = newValue;
                Observer(newValue);
                dependency.notify();
            },
        });
    });
}

// HTML模板解析 - 替换DOM内容
function Compile(element, vm) {
    vm.$el = document.querySelector(element);
    const fragment = document.createDocumentFragment();
    let child = null;
    while ((child = vm.$el.firstChild)) {
        fragment.append(child); // 同时会移除页面上的DOM
    }

    fragment_compile(fragment);
    // 替换文档碎片内容(文本节点)
    function fragment_compile(node) {
        // 通过正则表达式来匹配文本节点里的文本
        const pattern = /\{\{\s*(\S+)\s*\}\}/;
        // 文本节点的nodeType是3
        if (node.nodeType === 3) {
            const node_oldValue = node.nodeValue;
            const result_regex = pattern.exec(node.nodeValue);
            if (result_regex) {
                    const arr = result_regex[1].split('.');
                    const value = arr.reduce((total, current) => total[current], vm.$data);
                    node.nodeValue = node_oldValue.replace(pattern, value);
                    // 创建订阅者
                    new Watcher(vm, result_regex[1], (newValue) => {
                            node.nodeValue = node_oldValue.replace(pattern, newValue);
                    });
            }
            return;
        }
        // 元素节点的nodeType是1
        if (node.nodeType === 1 && node.nodeName === 'INPUT') {
            const attr = Array.from(node.attributes);
            attr.forEach((i) => {
                if (i.nodeName === 'v-model') {
                    const value = i.nodeValue
                        .split('.')
                        .reduce((total, current) => total[current], vm.$data);
                    node.value = value;
                    // 同样需要new Watcher
                    new Watcher(vm, i.nodeValue, (newValue) => {
                        node.value = newValue;
                    });
                    // input事件时,要同步修改组件的data里的属性
                    node.addEventListener('input', (e) => {
                        const arr1 = i.nodeValue.split('.');
                        const arr2 = arr1.slice(0, arr1.length - 1);
                        const final = arr2.reduce(
                            (total, current) => total[current],
                            vm.$data
                        );
                        final[arr1[arr1.length - 1]] = e.target.value;
                    });
                }
            });
        }
        // 文本节点里可能还有子节点,进行递归
        node.childNodes.forEach((child) => fragment_compile(child));
    }

    // 把文本碎片应用到vm里的$el属性里面
    vm.$el.appendChild(fragment);
}

// 依赖 - 收集和通知订阅者
class Dependency {
	constructor() {
            this.subsribers = []; // 收集所有的订阅者
	}

	addsubsriber(sub) {
            this.subsribers.push(sub); // 由于一开始并非知道有多少个订阅者
	}

	notify() {
            this.subsribers.forEach((sub) => sub.update()); // 通知订阅者去更新数据
	}
}

// 订阅者
class Watcher {
    constructor(vm, key, callback) {
        this.vm = vm; // vm实例
        this.key = key; // 对应的属性名
        this.callback = callback; // 回调函数:记录如何更新文本内容

        // 临时属性 - 触发getter
        Dependency.temp = this;
        // 只遍历,不做其他操作
        key.split('.').reduce((total, current) => total[current], vm.$data);
        // getter调用完后,将temp置空
        Dependency.temp = null;
    }

    update() {
        const newValue = this.key
            .split('.')
            .reduce((total, current) => total[current], this.vm.$data);
        this.callback(newValue);
    }
}
相关推荐
skyWang4166 分钟前
基于 Vue3 和 Tiptap的在线多人协同编辑文本编辑器
vue.js
编程毕设2 小时前
【开题报告+论文+源码】基于SpringBoot+Vue的招聘管理系统的设计与实现
vue.js·spring boot·后端
二川bro3 小时前
Vue 项目中 package.json 文件的深度解析
前端·vue.js·json
WDeLiang12 小时前
Vue学习笔记 - 逻辑复用 - 组合式函数
vue.js·笔记·学习
hepherd17 小时前
Vue学习笔记 - 插件
前端·vue.js
大强的博客19 小时前
《Vue Router实战教程》22.导航故障
前端·javascript·vue.js
2401_8906658620 小时前
免费送源码:Java+SpringBoot+MySQL SpringBoot网上宠物领养管理系统 计算机毕业设计原创定制
java·vue.js·spring boot·python·mysql·pycharm·html5
爷傲奈我何!20 小时前
小程序中实现音频播放(原生 + uniapp)
前端·vue.js
jqq66620 小时前
(二)「造轮子」我也写了个Vue3脚手架!(项目环境搭建)
前端·javascript·vue.js