简单实现数据双向绑定(一)

跟着百度前端学院中的任务要求从最基础部分的开始学习Vue的数据双向绑定。

阅读参考: Vue早期源码学习系列

最基础的数据监听绑定

毫无疑问,想要监听数据的读取和更改,就是Object.definePrototypegetset结合使用。

任务一:监听对象属性的变化。

class Observer {
    constructor (value) {
        this.data = value;
        this.walk(value);
    }

    walk (obj) {
        for (let key of Object.keys(obj)) {
            let value = obj[key];
            this.convert(obj, key, value);
        }
    }

    convert (obj, key, value) {
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get () {
                console.log(`你访问了${key}属性`);
                return value;
            },
            set (newValue) {
				if (value === newValue) {
					return;
				}
                console.log(`你将${key}属性从${value}改为了${newValue}`);
                value = newValue;
            }
        })
    }
}

let app1 = new Observer({
    name: 'zzr',
    age: '24'
});

console.log(app1.data.age);	// 你访问了age属性 // 24
app1.data.age = '25';		// 你将age属性从24改为了25
console.log(app1.data.age);	// 你访问了age属性 // 25

从上面程序中Observer.prototype.walk 方法中可以知道,该方法无法针对对象中的多重嵌套,比如:

// 第一种情况:传递给构造函数的Observer的对象的属性也是对象
let app2 = new Observer({
	msg: {
		'a': 1,
		'b': 2
	}
});
console.log(app2.data.msg.a);	// 你访问了msg属性(没有提到a)

// 第二种情况:将实例化对象的属性改成对象
app1.data.name = {
	'firstName': 'Zhang',
	'lastName': 'zirui'
};	// 你将name属性从zzr改为了[object Object]

console.log(app1.data.name.firstName)	// 你访问了name属性 (没有提到firstName)

面对这两种情况就需要对对象进行深层绑定。

深层对象绑定

使用递归的方法对深层嵌套对象的属性进行逐个绑定,题目参考:任务二

主要添加代码:

if (isObject(value)) {	// isObject()的实现太简单,没有给出
    new Observer(value);
}

分别针对前面所提出来的两种情况,将上述代码分别加入到 Observer 原型链中的walkconvert 方法中:

walk (obj) {
    for (let key of Object.keys(obj)) {
        let value = obj[key];
        if (isObject(value)) {
            new Observer(value);
        }
        this.convert(obj, key, value);
    }
}

convert (obj, key, value) {
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get () {
            console.log(`你访问了${key}属性`);
            return value;
        },
        set (newValue) {
			if (value === newValue) {
				return;
			}
            console.log(`你将${key}属性从${value}改为了${newValue}`);
            value = newValue;
            if (isObject(value)) {
                new Observer(value);
            }
        }
    })
}

关于监听数组

其实数组也是对象,所以如果运用上面的代码其实可以对数组里面的修改做监听的。可是Vue明确在注意事项中提出来通过下标索引直接设置更改数组的某个项是不被响应的。这是为什么呢,因为数组的监听方式就不是和一般对象一样针对每一个属性通过getset来实现的。而是通过对数组的常用方法进行监听,当然这里监听的方法肯定不是直接对原生Array.prototype上面的方法进行改变,而是对监听的数组实例改变其__proto__指向,当然Vue源码中对不存在__proto__的情况作了polyfill。

// 举例
function Observer (obj) {
	this.data = obj;
	if (Array.isArray(obj)) {
		obj.__proto__ = Object.create(Array.prototype);
		// do something...
	} else {
		this.walk(obj);
	}
}

因为数组虽然也是对象,但是数组常用的操作方式都是通过一些数组方法,比如push,pop,splice等等,并且数组的变动性往往会很大。如果也使用definedProperty来实现监听的话,会产生很大的开销。

实现事件监听

任务二中还有一个要求,那就是能够嵌入观察者模式,即能够给监听的属性绑定事件,当属性变化的时候可以触发所绑定的事件。

面对这个要求,可以在Observer构造函数上直接添加自定义事件方法和触发事件方法。但是其实自定义事件这一类的方法其实和Observer构造函数是可以互相独立的,写在一起的话就有点高耦合了。所以就可以再定义个简单事件自定义和触发的构造函数,然后用Observer继承父类方法即可。

 class Observer extends Event {
    constructor (value) {
        super();	// 添加
        this.data = value;
        this.walk(value);
    }

	walk () { 
		//不变 
	}		

	convert (obj, key, value) {
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get () {
                console.log(`你访问了${key}属性`);
                return value;
            },
            set: newValue => {
				if (value === newValue) {
					return;
				}
                console.log(`你将${key}属性从${value}改为了${newValue}`);
                value = newValue;
                if (isObject(value)) {
                    new Observer(value);
                }
                this.$trigger(key, value);	// 添加
            }
        })
    }
}

 app1.$watch('age', function (age) {
     console.log(`触发监听事件,age变成${age}`);
});
app1.data.age = '18';	// 你将age属性从25改为了18 // 触发监听事件,age变成18

Event构造函数就是一个简单的观察者模式。

class Event {
    constructor () {
        this.cache = {};
    }

    $watch (type, fn) {
        if (this.cache[type] === undefined) {
            this.cache[type] = [];
        }
        let fns = this.cache[type];
        if (fns.indexOf(fn) === -1) {
            fns.push(fn);
        }
    }

    $trigger (type, ...rest) {
        let fns = this.cache[type];
        if (fns === undefined) {
            return false;
        }
        for (let fn of fns) {
            fn.apply(this, rest);
        }
    }

    $remove (type, fn) {
        let fns = this.cache[type];
        if (fns === undefined) {
            return false;
        }
        if (!fn) {
            fns = undefined;
        } else {
            fns.splice(fns.indexOf(fn), 1);
        }
    }
}

没错,任务二是这么愉快又简单的完成了,但是在任务三中又要求事件机制能够向上传播,类似事件冒泡机制一样。比如:

let app3 = new Observer ({
	user: {
		name: 'zzr',
		age: '24',
		sex: 'man'
	}
});

app3.$watch('user', function () { 
	console.log('监听该属性以及该属性的下级属性所有的改变'); 
})

app3.data.user.name = 'Zhangzirui'; //改变user对象中的name属性,事件会冒泡到对user的监听事件中

为了实现这个方法,则需要在触发事件的同时找到“父级”,也就是当前更改对象的上一级。怎么样找到上一级呢,首先得看程序中是怎么联系下一级的。

Observer构造函数中,是通过walk来遍历每一个可枚举属性,然后检查该属性是不是对象,如果是则递归。关键代码为:

if (isObject(value)) {
    new Observer(value);
}

这也是我们在实现深层对象绑定时所添加的代码,该代码明显就没有和上层属性有关联,所以需要作出改变。

// Observer构造函数内部
walk (obj) {
    for (let key of Object.keys(obj)) {
        let value = obj[key];
        if (isObject(value)) {
            this.observer(key, value);	//改变
        }
        this.convert(obj, key, value);
    }
}	

observe (key, value) {
	let ob = new Observer(value);
	ob.parent = {
		'key': key,
		'ob': this;
	}
}

本来在walk中对初始obj的可枚举属性进行遍历,如果key值对应的value是对象的话,那么就使用observe方法,将该对象重新生成一个Observer实例,然后将新生成实例的parent属性与原本的key值联系起来,与上一级的实例this也联系起来。 这样新生成的Observer实例与上一级的Observer实例有了联系,那么就可以在事件传播上面下功夫了。

// Observer构造函数内部
convert (obj, key, value) {
    Object.defineProperty(obj, key, {
        // ...
        set: newValue => {
            // ...
            if (isObject(value)) {
                this.observer(key, value);	//改变
            }
            this.bubble(key, value);	//改变
        }
    })
}

bubble (key, value) {
	this.$trigger(key, value);
    let parent = this.parent;
    if (!parent) {
        return;
    }
    parent.ob.bubble(parent.key, value);
}

通过添加bubble方法,每次触发属性的set事件则触发bubble方法,在bubble方法中先触发自身的事件,然后再类似冒泡一样传到上层。

测试一下:

app3.$watch('user', function () { 
	console.log('监听该属性以及该属性的下级属性所有的改变'); 
});

app3.data.user.name = 'Zhangzirui';	//你访问了user属性 //你将name属性从zzr改为了Zhangzirui //监听该属性以及该属性的下级属性所有的改变

这样name属性改变的事件就传递到了user上面来。但是这样写的代码会有一个问题,那就是不可以直接对对象的深层属性进行监听。比如:

app3.$watch('age', function () {
	console.log('监听user属性下的age属性')
});

app3.data.user.age = '18';	//你访问了user属性 //你将age属性从24改为了18

出现这个现象是因为this改变了,虽然在程序中user下面的age改变时,同样会触发$trigger事件,但是触发$trigger事件的thisuser属性生成的Observer实例,然而app3.$watch监听事件的this是初始data对象生成的Observer实例。所以app3.$watch监听的事件放入了该实例下的cache对象中,而在user属性生成的Observer实例中cache是空的,所以即使是触发了$trigger事件,但是由于cache是空的,所以什么都没有发生。

渲染HTML模板

接着我们开始实现HTML模板渲染,具体要求在任务四

如果要实现单纯的模板渲染,不要求动态数据绑定的话,其实很简单,单纯的字符串匹配而已。将模板片段取到,然后用正则取出里面的要求渲染的数据,然后用真正的数据来替换即可。

然后任务五中要求模板与数据绑定,想到的第一想法,肯定就是给dom涉及的数据注册$watch事件,然后当改变数据的时候,触发监听事件,重新渲染模板。

// index.js

class Zue {
    constructor (obj) {
        this.el = document.querySelector(obj.el);
        this.data = obj.data;
        this.observer = new Observer(obj.data);
        this.run();
    }

    run () {
        let template = this.el.innerHTML.trim(),
            pattern = /\{\{(.+)\}\}/g;
        let matchArr = this.getMatchArr(pattern, template);
		// 获取要监听的属性
        let keys = matchArr.map(item => item.split('.')[0]);
		// 对监听的属性注册监听事件
        keys.map(item => this.observer.$watch(item, () => {
            this.renderTemplate(matchArr, template)
        }));
        this.renderTemplate(matchArr, template);
    }

    getMatchArr (pattern, str) {
        let result = [],
            item = pattern.exec(str);
        while (item) {
            result.push(item[1]);
            item = pattern.exec(str);
        }
        return result;
    }

    renderTemplate (matchArr, template) {
        matchArr.map(item => {
            template = template.replace(`{{${item}}}`, eval(`this.data.${item}`));
        });
        this.el.innerHTML = template;
    }
}


// index.html

<div id="app">
    <p>name: {{user.name}}</p>
    <p>age: {{user.age}}</p>
</div>

/******************test******************/
let app1 = new Zue({
    el: '#app',
    data: {
        user: {
            name: 'zzr',
            age: 24,
			sex: 'man'
        },
        id: 36
    }
});

因为是对模板中存在的属性进行了单独注册监听事件,就可以实现任务五中所说的困难目标,即只有当user.age和user.name发生改变的时候,DOM会重新渲染,当id发生变化的时候,不触发改变。

如果这篇文章对你很有帮助,你可以犒劳一下WO

打赏