这个真的是面一百次,问一百零一次的东西。
首先,记得回答:' vue 双向绑定是 数据劫持+发布订阅者模式实现的'
vue2使用了 Object.defineproperty() 来实现数据劫持,监听数据变动
实现一个Observer方法实现数据劫持,最重要的方法就是Object.defineproperty() ;
实现一个Compile方法,解析模板,对用到双向绑定的模板进行替换以及实例化watcher
实现一个Dep类,作用是对订阅者进行收集,通知 watcher 数据更新;
实现一个Watcher类,订阅者,作用是更新视图
挺简单的 完了
2022/1/15
好吧 ! 上次太懒了, 这次手写实现一遍吧。会写的非常详细,希望所有人都能看得懂!所以耐心点吧,加油!
1.先像 vue 模板一样 写上插值表达式,input输入框 使用 v-model 进行绑定。
(像vue一样,new一个Vue,传进去一个根节点‘#app’,再传进去一个data对象,真实的vue,data是一个函数,return出来一个对象,为了防止各个组件变量命名冲突,我们这里就暂且写一个对象就好,明白这一点就行)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<h3>名字:{{name}}</h3>
<h3>年龄:{{age}}</h3>
<h3>爱好:{{habbay.bskb.chinese}}</h3>
<div>早上好</div>
<div>name:<input type="text" v-model="name"></div>
<div>age:<input type="text" v-model="age"></div>
<div>habbay.bskb.chinese:<input type="text" v-model="habbay.bskb.chinese"></div>
</div>
</body>
<script>
// 像vue一样,new一个Vue,传进去一个根节点‘#app’,再传进去一个data对象
new Vue({
el: '#app',
data: {
name: 'lei',
age: 18,
habbay: {
bskb: {
play: true,
chinese: '打篮球'
}
}
}
})
</script>
</html>
↓↓↓ 页面效果,简陋是简陋了点,不过不影响奥!!!!
此时控制台是这样的↓↓↓
因为我们在代码中 new Vue 但是我们的代码中并没有Vue这个class类,所以控制台告诉我们,Vue 没定义!接下来我们就要努力的去搞个简单的vue双向绑定了!
最终我们要像vue实现数据双向绑定。
2.接下来我们需要创建一个js文件,叫它vue,并且声明一个叫Vue 的 class 类,解决控制台的报错,也是我们正式的一个开始。
(为了大家看的清楚,我将代码和浏览器放在一张图中,方便大家阅读)
在new Vue 之前,引入了我们创建的 vue.js 文件
然后接下来,我们对照着本文第一张图来看,一开始分了两步走,上边一步是实现Observer,进行对数据拦截。 下边一步是Compile,对模板进行编译,绑定更新函数,初始化视图。(免得你要翻上去看,我把它再贴一次)
所以我们首先来进行Observer的实现,(看图中js代码按照1.2.3的思路来看)
此时引申一个小知识,在new 一个类的时候,类中的 constructor 会立即执行。
我们声明的Observer要在 new 这个类的时候执行,因为我们要第一时间对data中的数据进行劫持,所以在Vue的constructor中对 Observer 进行调用。
vue2数据劫持,用到最重要的核心方法就是 Object.defineProperty() ,对此方法不懂的可爱们,快去看看它是怎么使用的,这里我简单介绍一下。
Object.defineProperty() 接收三个参数,第一个是数据对象,第二个是数据对象中的key,第三个是配置,里边包含了set 和 get 方法,当有地方读取被监听的属性时会触发set方法,此时直接returnc出去值,当有地方更改被监听的实行时会触发set方法,set方法接收一个被更新后的新值,将新值赋值给旧值完成数据更改。
想了解Object.defineProperty() 更多可以点击这里 点我
此时你发现控制台打印的数据中,data的第一层数据都有了get和set的方法,证明它被监听到(红色圈起来的地方)。
但是你发现蓝色被圈起来的深层数据,并没有set和get 方法,所以我们接下来要对这种情况进行深层监听,确保每个数据被监听到。
你发现现在,深层的数据也有了各自的get和set方法,到此时,我们就完成了Observer,实现了对数据的拦截监听,在我一次次点击控制台数据展开的过程中,触发了get,并且打印了get中的log(蓝色框),当我更改数据的值时,也会触发set中的log(红色框)。
如果我们更新了值,那么我们也需要对新的值进行监听,所以在这里我们要加上一个递归的Observer
接下来我们实现Compile函数,实现对模板的解析,将插值表达式替换成data中真实的数据。
想一想,如果要想将模板中的数据进行替换,那么我们首先要拿到模板,并且需要data中真是的数据,我们才能对它进行替换。在一开始的时候就需要进行模板编译,所以我们还是在Vue类中的constructor里调用Compile,并且传递模板和数据。
我们现在拿到了跟节点的ID名 ‘#app’,所以通过JS方法可以获取到真是的节点,获取到节点之后,需要遍历每一个子节点,去发现有哪些地方用到了差值表达式,然后把插值表达式替换成真实的数据,那么这就属于通过JS操作DOM,每一次操作都会伴随着页面的重绘,或者回流,必然会浪费性能。所以在此处我们使用文档碎片来对DOM进行替换操作,替换完成之后,再一次性的去操作真实的页面DOM来节省性能。
在接下来的操作中会用到一些js的方法,如果有看不懂的可爱记得去查一下。
我简单说一下文档碎片,当我们获取到页面上的DOM节点,并且把节点插入到文档碎片中去的时候,页面上不再有存在的DOM,它的操作不是一个复制,可以理解为一个剪切。
接下来我们声明一个 replace 函数 对fargment中的插值表达式进行替换,替换完成之后我们将fargment再插入到页面上的根节点中去。 此时可以看到,页面上又出现了所有的节点,并且控制台中的文档碎片变为空。
接下来我们就实现整个替换的操作,分析一下现在的页面上,有哪些地方用到了数据的双向绑定,首先第一个是插值表达式 两个{{}}包裹的地方,还有就是input输入框上绑定的v-model。好了那我们分别对它进行替换,首先我们先来替换第一种情况,插值表达式的情况。
需要替换插值表达式,那么要拿到节点中的文本,首先我们获取到节点中的文本来看看,
这里用到了一些操作节点,获取节点内容的方法,不懂得自行查阅哦!
可以看到我们拿到了每个文本包括插值表达式的内容,但是还看到了控制台一些空白的地方,那是因为代码中用红色框起来的地方,节点与节点之间的换行,也是一个文本节点,所以会出现控制台空白的行。不用在意它,接下来我们需要使用正则来对每个文本进行匹配,首先获取到含有插值表达式的文本,然后获取到插值表达式里的内容。
可以看到红色框起来的地方,就是插值表达式的内容,我们通过这些内容,接下来就可以匹配到data中的它们所代表的真实数据。
我们发现,name age 都可以获取到值,但是使用红色框起来的链式调用并没有拿到真实数据,打印了undefined。是的这里我们需要对链式调用进行匹配,接下来会用到数组的 reduce方法来兼容解决链式调用的情况;
我简单说一下,reduce方法接收两个参数,第一个参数是一个callback,第二个参数会传入callback的第一个形参,也就是说callback的第一个形参是reduce的第二个参数,callback的第二个形参是所循环数组的每一项,
然后开始执行,return 的值会作为callback的第一个形参继续执行,reduce的返回值是循环执行到最后一次return的值。
这里你必须明白这个方法是怎么执行的,不懂的话最好是去查查。
现在我们才算是拿到所有的真实数据,然后我们再将节点内容里的差值表达式替换为真实数据。
至此,我们实现了插值表达式的替换,完成了第一种情况。
第二种情况,input具有v-model的情况,接下来就来完成它。
首先获取到input输入框节点
使用nodeType === 1 判断,首先是个Element结点并且 tagName === 'INPUT' , 然后我们在控制台就看到了三个input输入框的节点,你问我怎么知道 tagName 这种奇奇怪怪不常用的属性,其实我也不知道,我是通过
console.dir(node) 打印,观察它都有哪些属性,选择了使用tagName来判断的,不信你看看,学会了吗?
然后我们需要拿到拥有v-model的input节点,所以通过node.attributes来获取节点属性,可以看到它是一个伪数组
因为我需要使用数组find方法来进行匹配包含v-model属性,所以我需要使用 Array.from()方法,来把这个伪数组转为真数组,然后通过 .value 拿到v-model属性的值,再利用这些值,使用reduce方法来获取真实数据
现在我们既拿得到input节点,也知道了需要替换的真实数据,现在来将真实数据赋给input.value
现在,我们完成了Compile,实现了对模板使用双向绑定数据位置,数据的一个替换,然后初始化了视图,你发现页面上双向绑定处的节点内容都变成了真实数据。
接下来完成一件简单的事情,因为我们最终希望输入框内容更改的时候,改变data中的数据,并且更新视图,那么现在既然我们写到了input节点这里,那不如我们在此刻,将它监听起来,当input的值发生改变,则去更新data数据的值,完成视图更改,数据随之更新的单项绑定。view > model
在这里会有一个小问题,当我在更改name,age输入框的时候都会触发set方法,但是在更改hobby.bskb.chinese输入框的时候,却没有触发到set方法,所以也就是说没有更改掉data中hobby.bskb.chinese的值,实现的这个单项绑定失败了,原因很明显,还是因为链式调用的问题导致的失败。
所以在第72行代码中对 data[vmodelResult.value] = e.target.value; 进行修改,使其兼容链式调用。
OK,现在我们也兼容了链式调用的情况,更改输入框的同时更新data中的数据。完成了数据的单项绑定。view > model
再来看看最开始的图,然后我们再继续往下进行。
ok,我们现在还剩下Dep类和Watcher没有实现。也就是发布订阅者模式。
Dep类和Watcher是Observer和Compile互通的桥梁,能够订阅并收到每个属性变动的通知,执行更新视图的相应回调函数,从而更新视图
继续跟随我们上图的思路,我们Compile实现了解析数据、初始化视图的能力,Compile还有一个能力我们没有实现,就是这条线,实现 订阅数据变化,绑定更新函数 的能力。
也就是说,当Compile解析模板时,遇到双向绑定的数据时,就需要向Watcher中储存一个更新视图的方法,因为页面中有很多地方都用到了数据的双向绑定,所以每个用到数据双向绑定的地方都需要有一个Watcher,所以Watcher是一个类,在Compile替换模板的地方实例化一个Watcher,并且传递一个更新视图的方法,存在Watcher中。
我们在两处更新DOM的地方实例化了Watcher,并且传入了各自更新DOM的方法。因为我没还没有声明Watcher类,所以页面报错,接下来我们声明Watcher类,并且把传入的 更新DOM 方法储存起来,向外抛出一个可以更新视图的方法,在需要的地方对其调用(先抛出一个方法,后边会说哪里需要)。
我们已经声明了一个Watcher类,并且有了更新视图的方法,接下来我们跟着思路来实现Dep类,因为当数据更新时,会通知到Dep,Dep会通知到每个订阅者Watcher,所以Dep应该有一个订阅者Watcher的集合,在接收到通知的时候,通知到每个Watcher,我们来实现Dep类。
现在我们实现了Dep,Watcher,那如何把他们连接起来,结合Observer、Compile 实现数据的双向绑定呢?
继续看图
来想想我们应该在什么地方实例化Dep类,并向Dep类中添加订阅者?,什么时候通知Dep数据发生了改变呢?
首先我们需要在什么地方实例化Dep类? 什么时候通知Dep数据发生了改变呢?
那当然是在Observer中实例化了,因为我们要在数据更改时,第一时间通知Dep
然后什么时候要向Dep类中添加订阅者?
让我们来回忆一下,当有模板需要获取数据的时候会触发set,那么我们就在获取数据的时候将实例化的Watcher添加到Dep的订阅者合集中去。
思路有了,我们要添加实例化的Watcher,那么实例化的Watcher应该在哪能得到呢?
这里我细细的讲,首先在实例化Watcher的时候,我们把Watcher的实例添加到Dep类上去,那么在我们需要添加实例化Watcher的时候,就可以在Dep上直接获取到,当添加完之后,我们再把Dep上保存的这个实例化Watcher置空
接下来如何获取一次当前属性呢?那么我们需要获得data对象,和当前属性的key值,我们就可以进行获取数数据的操作。所以我们在实例化Watcher的地方需要把data和key传入Watcher中。
写到这里,我们去试试我们的双向绑定是否能够运行
当我在输入框输入了 1 2 3 之后,页面上的内容并没有随之改变,但是控制台输出了set方法的log,那么我们再一起来将这个BUG解决掉,可以看到set方法中出去打印之后,还执行了数据的赋值,并且执行了dep.notify
当我注释掉通知方法之后,可以看到输入框输入是正常的,并且触发了set的log,那么定位到问题是在通知方法中,所以我们继续往下分析问题所在
一连串的调用方法,最后指向传入的更新DOM方法中,我们发现当我更改输入框时,页面上的数据并没有进行有效的改变,所以问题应该出在赋值上,
我在name输入框中输入了1,触发了set中的log,触发了DOM更新的 callback 函数,可以看到每次赋值都是旧的值,并不是最新的值,所以我们需要改良一下,把最新的值获取到,然后传入到callback 中去,赋上新的值。
对这几处进行了改良 让我们看看能否实现更改输入框的同时更改页面上的视图呢
经过测试发现在更改输入框的同时已经可以实现数据的双向绑定了。
我写的有错误的地方,欢迎指正出来!
哪里看不懂的地方就留言告诉我吧,我会非常迅速的回复你的疑问!
到这里这个简版的双向绑定就实现了。