双向绑定不神秘,神秘的是认知
都快2020年了,我想国内做前端开发的没有没用过vue的吧,但是真知道vue的最具特色的双向绑定原理的可能真的不多,知道的可能也就是能答个订阅者模式,观察者模式云云。。。(我曾经也面试过很多应聘者,当然我以前出去面试也是这样答的。。。),因为没有深入理解,所以只能大概回答,有些人觉得会很神秘,其实神秘的是自己的认知。
深入原理-我自己先来一个
废话不多讲,直接进正题,我一直好奇一个问题,双向绑定为什么不给每一个需要双向绑定的DOM加一个检测事件不就搞定了吗?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<h1>我来接受信息!</h1>
<input type="text" class="text" onkeyup="changeInput(this)">
<div style="font-size: 24px;padding-top: 30px;">标题变我就变!</div>
</body>
</html>
<script>
var _h1 = document.querySelector('h1')
var _div = document.querySelector('div')
var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
//input变化
function changeInput(e){
let text = e.value;
console.log(text)
_h1.innerHTML = text
}
// 配置观察选项:
var config = { attributes: true, childList: true, characterData: true }
// 创建观察者对象
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
//发生变化我就更新
_div.innerHTML = _h1.innerHTML
});
});
// 传入目标节点和观察选项
observer.observe(_h1, config);
</script>
-
首先监控数据输入者,这个简单就是用
onkeyup事件,只要你输入信息我就去改DOM里的信息。 -
然后 new 了一个
MutationObserver,这个不知道的可以去看下API,套路就是设置个目标节点,和对于它的一些配置config,然后调用observe方法,把节点和信息传进去,接收变化触发内部事件。 -
图一
-
图二
看起来很好用啊,好了,双向绑定实现了,再见(我是来搞笑的。。。)
其实进一步推敲我发现事情不是我想的那么简单,当我更深入理解vue原理后,我发现自己实现的是很粗糙,本质原因还是思维的维度问题,vue监控的是变量的变化,而我监控的是DOM的变化! 大家都清楚像vue和react等一些运用虚拟DOM实现的框架,是数据驱动视图,就是说数据的变化才是他最关心的,数据的变化才是核心!
深入原理-仿照一个
那么vue具体怎么实现呢?还是先上代码。
demo.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<h1 id="name"></h1>
<input type="text" class="input_1">
<input type="button" value="改变data内容" onclick="changeInput()">
</body>
</html>
<script src="observer.js"></script>
<script src="watcher.js"></script>
<script>
//定义容器
function myVue (data, el, exp) {
this.data = data;
observable(data); //将数据变的可观测
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
var ele = document.querySelector('#name');
var input_1 = document.querySelector('.input_1');
var myVue = new myVue({
name: 'hello world',
}, ele, 'name');
//改变输入框内容
input_1.oninput = function (e) {
myVue.data.name = e.target.value
}
function changeInput(){
myVue.data.name = '点我干嘛!'
}
</script>
observer.js
/**
* 把一个对象的每一项都转化成可观测对象
* @param { Object } obj 对象
*/
function observable (obj) {
if (!obj || typeof obj !== 'object') {
return;
}
let keys = Object.keys(obj);
keys.forEach((key) =>{
defineReactive(obj,key,obj[key])
})
return obj;
}
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive (obj,key,val) {
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){
dep.depend();
console.log(`${key}属性被读取了`);
return val;
},
set(newVal){
val = newVal;
console.log(`${key}属性被修改了`);
dep.notify() //数据变化通知所有订阅者
}
})
}
class Dep {
constructor(){
this.subs = []
}
//增加订阅者
addSub(sub){
this.subs.push(sub);
}
//判断是否增加订阅者
depend () {
// console.log(Dep.target)
if (Dep.target) {
//console.log(Dep.target)
this.addSub(Dep.target)
// console.log(this.subs)
}
}
//通知订阅者更新
notify(){
this.subs.forEach((sub) =>{
sub.update()
})
}
}
Dep.target = null;
watcher.js
class Watcher {
constructor(vm,exp,cb){
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get(); // 将自己添加到订阅器的操作
}
get(){
console.log(this)
Dep.target = this; // 缓存自己
let value = this.vm.data[this.exp] // 强制执行监听器里的get函数
Dep.target = null; // 释放自己
return value;
}
update(){
let value = this.vm.data[this.exp];
let oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value);
}
}
}
最后实现的效果
深入原理-代码逐行解读
(长文慎入!)
- 起点
input_1.oninput = function (e) {
myVue.data.name = e.target.value
}//不用过多解释,触发了事件。当然我们现在不触发,只是一个引子。
2.触发改变了myVue对象属性值,myvue是啥?
function myVue (data, el, exp) {
this.data = data;
observable(data); //将数据变的可观测
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
return this;
}
var myVue = new myVue({
name: 'hello world',
}, ele, 'name');
在这里,实例化一个叫myVue的函数,给他传进去三个参数,一个对象,一个DOM节点,还有一个对象的Key.
what? 什么是实例化函数? 为什么不直接调用 ? 这样有毛用?(灵魂3问)
先看个例子
function a (){
this.add = function(a,b){
alert(a + b)
};
}
a.add(1,2);// a.add is not a function
var A = new a();
A.add(1,2);//ok
我想我不用多解释什么了吧,所谓实例化函数,就是外部可以调用函数内部定义的属性和方法, 那么对应到咱们代码作用就是,定义了一个data属性 this.data 就相当于上述代码 this.add 让这个data变得可以让外部去调用
3.继续function myVue
==》
this.data = data;
==》
observable(data); //将数据变的可观测
这时候就进入到observable这个方法里面了
==》
function observable (obj) {
if (!obj || typeof obj !== 'object') {
return;
}
let keys = Object.keys(obj);
keys.forEach((key) =>{
defineReactive(obj,key,obj[key])
})
return obj;
}
常规操作,拿出传进来的data,然后拿出对象的key,遍历key,循环进入defineReactive方法, 并把data , data的每一个key, data的每一个值传入。
function defineReactive (obj,key,val) {
let dep = new Dep(); //又来一个实例化,F***K!
Object.defineProperty(obj, key, {
get(){
dep.depend();
console.log(`${key}属性被读取了`);
return val;
},
set(newVal){
val = newVal;
console.log(`${key}属性被修改了`);
dep.notify() //数据变化通知所有订阅者
}
})
}
先略过Dep这个对象,Object.defineProperty这个不了解的可以看下API,说白了就是检测对象的哪个key的value变动了, 读和写分别触发 get 和 set 方法,这也是双向绑定的核心,有了监听事件,其他的就是添砖加瓦。继续
4.到了Dep这个类了,你可以理解这就是个账本,当data里的值读或者写的时候记录一下。 上面出现了let dep = new Dep(),一样,目的就是一个=>>>暴露内部属性,让defineReactive里的方法调用。
class Dep {
constructor(){
this.subs = []
}
//增加订阅者
addSub(sub){
this.subs.push(sub);
}
//判断是否增加订阅者
depend () {
// console.log(Dep.target)
if (Dep.target) {
//console.log(Dep.target)
this.addSub(Dep.target)
// console.log(this.subs)
}
}
//通知订阅者更新
notify(){
this.subs.forEach((sub) =>{
sub.update()
})
}
}
Dep.target = null; //第一次给个空
(1)当data某个值读的时候,判断一下Dep.target(这是什么?这个一会就会碰到),如果有值,就把Dep.target存入到内部的定义好的this.subs里面。 (2)当data某个值改变的时候,遍历存好的subs并调用每一项的update的方法。 走到这一步,会有一些不明确的,target是什么?subs存的是什么?不急我们继续=====》
5.到了这里,我们就走完这一步了observable(data);还记得吗?
function myVue
==》
this.data = data; //把传给直接的data转为自己内部的data
==》
observable(data); //这一步走完了
先梳理一下方便继续走,第一次走到这是初始化
- 实例化myvue ,并传入要监控的对象,改变的目标DOM,要监控的key
- 把传入的data转为自己的data属性,然后传给监控函数observable
- 把传入的data进行监控,并且读的时候记录下属性,写的时候在dep里面进行分发去更新
那么问题来了,怎么样记录和怎么样去更新呢? 咱们继续走
6. function myVue
==》
el.innerHTML = this.data[exp]; // 初始化模板数据的值
new Watcher(this, exp, function (value) {
el.innerHTML = value;
})
-
把data里面key的值赋给目标DOM
-
实例化Watcher实体类,并把this(也就是myvue这个函数,注意现在传进来的data已经是myvue的一个内部属性了,用this可以调用),要监控的key,还有一个可以改变目标DOM的函数。
到了最后一个实体类了,他接受的三个值分别转化为 vm, exp, cb,继续
class Watcher {
constructor(vm,exp,cb){
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get(); // 将自己添加到订阅器的操作
}
get(){
console.log(this)
Dep.target = this; // 缓存自己
let value = this.vm.data[this.exp] // 强制执行监听器里的get函数
Dep.target = null; // 释放自己
return value;
}
update(){
let value = this.vm.data[this.exp];
let oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value);
}
}
}
constructor里面其他几个值就不多说了,就是把传过来的3个值转成自己的,然后注意这里定义了一个新的值value,其他的值都是一个值,这个不同,他调了一个自己的方法 get() , 这里面注意重点!! Dep的target有值了,watcher把自己给了Dep的target,这个时候Dep的target不在是null了,然后吧data里面key的值给了value,并且触发了get方法 ,value又给了自身的变量this.value,Dep的target的值为空结束初始化。
顺序就是:
- 现在Dep的target有了一个值,就是wacther
- 现在watcher的get方法里的value就是 'hello word',并触发读的操作,走了监听器的get方法
- 现在Dep的target又空了
- 现在内部定义的this.value = ‘hello word’
走到这里就很清晰了,还记得读的get方法吗?他触发了Dep的depend方法,此刻 Dep的target有值,所以存入到自身的this.subs里面。此刻subs这个数组有值了,就一个值,就是watcher这个可访问的实体类!然后Dep的target又还原了为空! 初始化完成!!! 。。。。。。。累。。。。。。。。。。。。。。。。
7.相信能仔细看到这,并自己动动手的估计都应该明白了这个流程了,我们还是先停下,到目前为止初始化完成,其实就是完成了三件事。
- 实例化一个myVue函数,并传参。
- 开始监控传入的变量。
- 通过强制调用监控的get方法,把wacther实体类存到Dep里面
8。改变变量会怎么样?剩下的流程水到渠成了,有了上几部的认知,我们继续走就豁然开朗。还是回到第一步改变我们的input
input_1.oninput = function (e) {
myVue.data.name = e.target.value
}
这个时候改变myVue的属性就已经不是单纯的改变了。这时候会触发defineReactive里的set
set(newVal){
val = newVal;
console.log(`${key}属性被修改了`);
dep.notify() //数据变化通知所有订阅者
}
set 又触发了dep里的notify这个方法
//通知订阅者更新
notify(){
this.subs.forEach((sub) =>{
sub.update()
})
}
根据上面的初始化,这时候subs有一个值,记得吗?对就是watcher这个已经被实体化的类。
//watcher的方法
update(){
let value = this.vm.data[this.exp];
let oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value);
}
}
好了到这一步要明确vm是什么,this.value是什么?this.cb是啥?vm就是已经实体化的函数myVue,这时候myVue里定义好的data已经被外部的myVue.data.name = e.target.value所改变,所以let value = '最新的值',那么this.value是什么呢?当然还是上一次初始化存进去的this.value = 'hello word',当然对比下,如果不一样的,那么就把最新的在存进this.value里面,然后this.cb就是传进来的方法:
//再看一下
new Watcher(this, exp, function (value) {
el.innerHTML = value;
});
然后call了一下,不明白call和apply的可以自行去脑补一下,就是改变了指针,脱离现在环境把this指向myVue这个实体类,然后把最新的value传进去,然后在当前环境下去改变DOM的值,当然这里你也可以不用去call也是可以的,因为内部没有用到this,但是如果后续要在里面增加逻辑,里面的this可就不是myVue会变成watcher这个实体类。
至此目标DOM得到了更新,等一下好像还漏掉一个东西,对,这个时候el.innerHTML = value;因为value现在是谁,他可不是单纯的值了,现在他是this.vm.data[this.exp],对没错,他就是被监控的this.data里面的一个值,this.data初始化说过,他已经就是传过来的参数data,他们的同指向一个堆栈 {name: 'hello world'},所以很自然被监控了,并且触发了监控里的get方法。你又被读了一遍,最后就像这样。
深入原理-写在最后的话
至此整个流程结束,还是回归一下我自己做的例子,有一个明显的区别就是,只有当我改变input的值才能去改变数据,从而触发数据绑定,但vue提供的思路是先给dom绑定上,然后改变我所定义的数据,就算dom不去触发或者其他操作都可以实现视图更新,只要我改变数据的值就能更新视图,不难发现,虽然我做的比较简单但是他很适合jq时代对dom操作时的要求,但随着业务量及前端的工作量和复杂性大大提高,操作dom变得很繁琐,所以操作数据变成的了主流,反正你的视图也是要跟着我数据去变. 最后还是希望读完会给你有所帮助,在面试中有底气有思路,而不是在面试中不知其里泛泛的回答一通,当然最最最希望的是能提供一个思路然后用到实际工作中,毛主席不是说过吗,实践是检验真理的唯一标准,共勉。