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

重温vue双向绑定原理解析 #3

Open
linzx1993 opened this issue Aug 24, 2017 · 5 comments
Open

重温vue双向绑定原理解析 #3

linzx1993 opened this issue Aug 24, 2017 · 5 comments

Comments

@linzx1993
Copy link
Owner

linzx1993 commented Aug 24, 2017

摘要:因为项目刚开始用的vue框架,所以早期也研究了一下他的代码看过相关文章的解析,说说也能说个七七八八。不过今天再去看以前的demo的时候,发现忽然一知半解了,说明当时可能也没有理解透,所以写篇文章让自己理解的更深一些。

好吧,事实上在我条理清晰的写完文章之后,时隔4个月,还是忘记的七七八八了

本篇文章大多数知识点实在学习了这篇Vue.js双向绑定的实现原理之后避免遗忘,所以写这个温故知新,加强理解。


一、访问器属性

如果稍微看过相关文章的人都知道vue的实现是依靠Object.defineproperty()来实现的。每个对象都有自己内置的set和get方法,当每次使用set时,去改变引用该属性的地方,从而实现数据的双向绑定。简单举例

const obj = {};
Object.defineProperty(obj,'hello',{
    get(value){
        console.log("啦啦啦,方法被调用了");
    },
    set(newVal,oldVal){
        console.log("set方法被调用了,新的值为" + newVal)
    }
})
obj.hello; //get方法被调用了
obj.hello = "1234"; //set方法被调用了

二、极简双向绑定的实现

基于这个原理,如果想实现显示文字根据输入input变化,实现一个简单版的。

<input type="text" id="a"/>
<span id="b"></span>

<script>
    const obj = {};
    Object.defineProperty(obj,'hello',{
        get(){
            console.log("啦啦啦,方法被调用了");
        },
        set(newVal){
            document.getElementById('a').value = newVal;
            document.getElementById('b').innerHTML = newVal;
        }
    })
    document.addEventListener('keyup',function(e){
        obj.hello = e.target.value;
    })
</script>

上面这个实例实现的效果是:随着文本框输入文字的变化,span会同步显示相同的文字内容。同时在控制台用js改变obj.hello,视图也会更新。这样就实现了view->model,model->view的双向绑定。

三、拆解任务,实现vue的双向数据绑定

我们最终实现下面vue的效果

<div id="app">
    <input type="text" v-model="text"/>
</div>
<script>
const vm = new Vue({
    id : "app",
    data : {
        text : "hello world"
    }
})
</script>

1.输入框的文本与文本节点的data数据绑定
2.输入框的内容发生变化时,data中的数据也发生变化,实现view->model的变化
3.data中的数据发生变化时,文本节点的内容同步发生变化,实现model->view的变化

要实现1的要求,则又涉及到了dom的编译,其中有一个DocumentFragment的知识点。

四、DocumentFragment

众所周知,vue吸收了react虚拟DOM的优点,使用DocumentFragment处理节点,其性能和速度远胜于直接操作dom。vue进行编译时,就是将所有挂载在dom上的子节点进行劫持到使用DocumentFragment处理节点,等到所有操作都执行完毕,将DocumentFragment再一模一样返回到挂载的目标上。

先实现一段劫持函数,将要操作的dom全部劫持到DocumentFragment中,然后再append会原位置。

<div id="app">
    <input type="text" v-model="text"/>
</div>
<script>
const app = document.getElementById("app");
const nodetoFragment = (node) => {
    const flag = document.createDocumentFragment();
    let child;
    while(child = node.firstChild){
        flag.appendChild(child);//不断劫持挂载元素下的所有dom节点到创建的DocumentFragment
    }
    return flag
}
const dom = nodetoFragment(app);
</script>

五、数据初始化绑定

当已经获取到所有的dom元素之后,则需要对数据进行初始化绑定,这里简单涉及到了模板的编译。

//  编译HTML模板
	const compile = (node,vm) => {
		const regex = /\{\{(.*)\}\}/;//为临时正则表达式,为demo而生
		//如果节点类型为元素的话
		if(node.nodeType === 1){
			const attrs = node.attributes;
			for(let i = 0;i < attrs.length; i++){
				let attr = attrs[i];
				if(attr.nodeName === "v-model"){
					let name = attr.nodeValue;
					node.addEventListener("input",function (e) {
					    vm.data[name] = e.target.value;
					})
					node.value = vm.data[name];
					node.removeAttribute("v-model");
				}
			}
		}
		//如果节点类型为文本的话
		if(node.nodeType === 3){
			if(regex.test(node.nodeValue)){
				let name = RegExp.$1;//获取匹配的字符串,又学到了。。。
				name = name.trim();
				node.nodeValue = vm.data[name];
			}
		}
	};

	//劫持挂载元素到虚拟dom
	let nodeToFragment = (node,vm) => {
		const flag = document.createDocumentFragment();
		let child;
		while(child = node.firstChild){
			compile(child,vm);//绑定数据,插入到虚拟DOM中
			flag.appendChild(child);
		}
		return flag;
	};

	//初始化
	class Vue {
		constructor(option){
			this.data = option.data;
			let id = option.el;
			let dom = nodeToFragment(document.getElementById(id),this);
			document.getElementById(id).appendChild(dom);
		}
	}

	const vm = new Vue({
		el : "app",
		data : {
			text : "hello world"
		}
	})

通过以上代码先实现了第一个要求,文本框和文本节点已经出现了hello woeld了

六、响应式的数据绑定

接下来我们要实现数据双向绑定的第一步,即view->model的绑定。根据之前那个简单的例子看到,我们可以通过input事件实时获取input中的值,接着将通过Object.defineProperty这个方法将data中的text设置为vm的访问器属性。当我们将获取到的input值设置vm.data.text时,通过set方法,实现了数据层的绑定。在这一步,set中要做的操作是更新属性的值。

let defineReactive = (obj,key,val) => {
    Object.defineProperty(obj,key,{
        get(val){
            return val;
        }
    	set(newVal,oldVal){
    	    if(newVal === oldVal) return;
    	    val = newVal;
    	    console.log(`我获取到了新${val},并成功设置`);
    	}
    })
};

//监听所有data传递进来的数据,将他们绑定到原型vm上面
let observe = (obj,vm) => {
	Object.keys(obj).forEach((key)=>{
		defineReactive(vm.data,key,obj[key]);
	})
};

七、订阅/发布模式(subscribe&publish)

text 属性变化了,set方法触发了,可以通过view层的改变实时改变数据,可是并没有改变文本节点的数据。一个新的知识点:订阅发布模式。

订阅发布模式定义了一种一对多的关系,让多个观察者同时监听一个主题对象,这个主体对象的改变会通知所有观察者对象。

发布者发出通知=>主题对象收到通知并推送给订阅者=>订阅者执行操作

//	三个订阅者
   let sub1 = {updata(){console.log(1);}};
   let sub2 = {updata(){console.log(2);}};
   let sub3 = {updata(){console.log(3);}};

   //  一个主题发布器
   class Dep{
   	constructor(){
   		this.subs = [sub1,sub2,sub3];
   	}
   	notify(){
   		subs.forEach((sub) => {
   			sub.updata();
   		})
   	}
   }
   const dep = new Dep();
//	一个发布者
   const pub = {
   	publish(){
   	    dep.notipy();
   	}
   };
   pub.publish();

上图为一个简单实例,发布者执行发布命令,所有这个主题的订阅者执行更新操作。接下去我们要做的就是,当set方法触发后,input作为发布者,改变了text属性;而文本节点作为订阅者,在收到消息后执行更新操作。

八、双向绑定的实现

每次new一个新的vue对象时,主要是做了两件事,一件是监听数据:observer(监听数据),第二个是编译HTML,nodeToFragement(id)。

在监听数据的过程中,会为data中的每一个属性生成一个主题对象。

而在编译HTML的过程中,会为每个与数据绑定的相关节点生成一个订阅者watcher,订阅者watcher会将自己订阅到相应属性的dep中。

在前面的方法中已经实现了:修改输入框内容=>再时间回调中修改属性值=>触发属性的set方法。

接下来要做的是发出通知dep.notify=>发出订阅者的uodate方法=>更新视图。

那么如何将watcher添加到关联属性的dep中呢。

编译HTML过程中,为每一个与data关联的节点生成一个watcher,那么watcher中又发生了什么?

//  每一个属性节点的watcher
class Watcher{
	constructor(vm,node,name){
		Dep.target = this;
		this.name = name;
		this.node = node;
		this.vm = vm;
		this.update();
		Dep.target = null;
	}
	update(){
		//获得最新值,然后更新视图
		this.get();
		this.node.nodeValue = this.value;
	}
	get(){
		this.value = this.vm.data[this.name];
	}
}

在编译HTML的过程中,生成watcher

let complie = (node,vm){
    ......
    //如果节点类型为文本的话
    if(node.nodeType === 3){
	    if(regex.test(node.nodeValue)){
	        let name = RegExp.$1;
	        name = name.trim();
            node.nodeValue = vm.data[name];
            //在编译过程中,每发现一个属性,则新建一个watcher
            new Watcher(vm,node,name);//在此处添加订阅者
        }
    }
}

首先将自己赋给了一个全局变量Dep.target;然后执行了update方法,进而执行了get方法,读取了vm的访问器属性,从而触发了访问器属性的get方法,get方法将相应的watcher添加到对应访问器属性的dep中。再次,获取属性的值,然后更新视图。最后将dep.target设置为空,是因为这是个全局变量也是watcher与dep之间唯一的桥梁,任何时间都只能保证只有一个值。
(其实就是说全局一个主题,每个订阅者和发布者都是通过这个主题进行沟通。当执行代码时,这个主题接受到一个发布通知,通知完所有订阅者,然后注销掉,用于下一个通知发布。啰嗦了一段就是想讲为什么要设置Dep.target = null)。

//  一个主题发布器
class Dep(){
    constructor(){
        this.subs = [];
    }
    notify(){
        this.subs.forEach((sub) => {
            sub.update();
        }
    }
    addSub(sub){
        this.subs.push(sub);
    }
}
let defineReactive = (obj,key,val) => {
    let dep = new Dep();
    Object.defineProperty(obk,key,{
        get(){
            //在此处将所有的监测器watcher添加进发布器,每一个属性都有自己的发布器
            if(dep.target) dep.addSub(dep.target);
        }        
        set(newVal,oldVal){
            if(newVal === oldVal) return;
            val = newVal;
            dep.notify();
        }
    })
}

至此,hello world 双向绑定就基本实现了。文本内容会随输入框内容同步变化,在控制器中修改 vm.text 的值,会同步反映到文本内容中。

@likaixuan
Copy link

四 : 有错别字 while

@BigKongfuPanda
Copy link

订阅发布模式 与 观察者模式 不是一回事儿吧?

@BigKongfuPanda
Copy link

另外,你最后面是不是写错了

//在此处将所有的监测器watcher添加进发布器,每一个属性都有自己的发布器
            if(dep.target) dep.addSub(dep.target);

dep.target 应该改为 Dep.target 吧?

@linzx1993
Copy link
Owner Author

@BigKongfuPanda 是的,这两个的确不是一个模式。的这是我早期的文章犯的错误,谢谢指正。

@BigKongfuPanda
Copy link

@BigKongfuPanda 是的,这两个的确不是一个模式。的这是我早期的文章犯的错误,谢谢指正。

噢。没事儿,很多人都把这两个模式弄混了。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

3 participants