我们知道,vue框架的一个特点之一就是它的响应式,在视图层/控制台对对象进行操作时,会影响对应的视图。
它的核心是数据劫持Object.defineProperty
来实现的,通过监听数据的变化(getter和setter函数)来实时编译新的模板,在vue底层中,尤大大是在这个方法中实现的Vue.util.defineReactive
,里面正是基于数据劫持来实现响应式原理的。
下面来一步步地模仿这个方法,简单实现一下vue的响应式!
ps:下面是又长又臭的代码,尽量写多注释,请耐心观看
- 首先,我们和平时用vue框架一样的步骤:先创建一个vue实例vm,顺便写几行简单的html代码,方便后续的观察。
<div id="app">
<input type="text" v-model="name">
<p>{{name}}</p>
<p>{{age}} </p>
</div>
<script>
//注意:Vue构造函数的参数是一个对象,里面是vue实例的配置项(option)
let vm = new Vue({
el:'#app',
data:{
name:'Koi',
age:11
}
})
</script>
- 往常这时候呢,我们是需要引入vuejs的文件的,才能在页面上看到我们要的效果,因为浏览器是无法编译类似
{{ }}
、v-model
这样的语法的。下面开始来手写啦!
//首先创建一个Vue构造函数
function Vue(option){
this.$el = document.querySelector(option.el);
this.$data = option.data;
//先进行数据劫持==》对应第三点
observe(this.$data,this);
//再进行模板编译===》对应第四点
nodeToFragment(this.$el,this.$data,this)
}
- 数据劫持
function observe(data){
if(({}).toString.call(data) !== '[object Object]') return;//确保data是一个对象!
//我们要对data对象中的各个属性进行数据劫持,所以先获取属性,然后遍历实现数据劫持
Object.keys(data).forEach(key=>{
//key对应data中的各个属性,例如name、age
//进行数据劫持(要操作的对象,要操作的对象的属性,要操作的对象属性的属性值)
defineReactive$$1(data,key,data[key]);
})
}
function defineReactive$$1(data,key,val){
Object.defineProperty(data,key,{
emunerable:true;//使属性key可枚举
get(){//当访问data.key的时候,触发get函数
return val;
}
set(newV){//当data.key=xxx的时候,触发set函数
val = newV;
}
}
}
- 模板编译
function nodeToFragment(el,data){
let fragment = document.createDocumentFragment();//把文档上的节点转移到文档碎片上
let child;
while(child = el.firstChild){//当el有子节点时,把它的第一个子节点赋值给child(这里包括空白文本节点)
complie(child);//编译模板
flagment.appendChild(child);
}
//最后把文档碎片插入到页面上
el.appendChild(flagment);
}
function compile(node,vm){
//先判断节点类型nodeType 1(元素节点) 3(文本节点) 8(注释节点)
// 先处理元素节点
if(node.nodeType === 1){
// console.dir(node)
// 处理行内属性
let arrs = node.attributes;
[...arrs].forEach(item=>{
// console.dir(item)
if(/^v-/.test(item.nodeName)){//获取v-开头的行内属性(v-model)
let vName = item.nodeValue;//获取name这个属性
let val = vm.$data[vName];//获取name属性的属性值 Koi
node.value = val;//把Koi 放到input框中
}
});
//除了行内属性,还要考虑子节点
[...node.childNodes].forEach(item=>{
compile(item,vm);//递归编译子节点
})
}else{
//处理文本节点
// console.dir(node);
let str = node.textContent;//获取文本字符串 {{name}}/{{age}}
str = str.replace(/\{\{(\w+)\}\}/,(a,b)=>{
//a是匹配的值({{name}}/{{age}}),b是匹配到的第一个分组(即括号里面的,name/age)
return vm.$data[b];//把str中的{{name}}替换成Koi,{{age}}替换成11
})
node.textContent = str;//把具体值Koi,11放到文本节点的textContent属性中
}
}
到这里我们已经完成一大半啦,完成了对Vue实例中,配置项data的数据劫持,以及将配置项el对应的css选择器对应的页面进行模板编译。
但是这时候又有问题了——此时它们俩是没有连接关系的,我在视图层input框任意输入文字(即试图改变v-mode=‘name’中name的属性值),却发现,下面的{{name}}并没有任何反应。
别急,因为我们还没实现呢!因为模板是页面一更新就编译好了的,数据劫持虽然知道数据发生改变了,可是却无法通知到模板去重新编译,所以我们需要给它们架起一座桥梁!这里采用订阅者/观察者模式。
简单讲一下订阅者/观察者模式。例如公众号,它可以看成一个订阅器,一旦公众号更新推文,就会通知它的订阅者们:你们可以去看啦!这里的订阅器就是我们劫持的每个数据,一旦发现劫持的数据发生改变,就会通知它的订阅者(观察者/模板编译):我劫持的数据更新啦,你们可以去更新模板啦!
- 订阅者/观察者模式
//订阅者/观察者模式
class Dep{
constructor(){
this.subs = [];
}
// 添加订阅者(事件池)
addSub(sub){
this.subs.push(sub);
}
// 通知订阅者做对应的事件
notify(){
this.subs.forEach(sub=>{
//通知订阅者发布事件了(如果数据发生变化,通知观察者及时更新数据)
sub.update();// 让对应的事件执行 sub 就是哪些 watcher
})
}
}
// 观察者
class Watcher{//node key vm
constructor(node,key,vm){
Dep.target = this;//
this.node = node;
this.key = key;
this.vm = vm;
this.getValue();//到这里可以把当前的Watcher实例放在Dep事件池中
Dep.target = null;
}
getValue(){
this.val = this.vm.$data[this.key];//会触发get函数
}
update(){//页面更新数据
this.getValue();//获取最新的dom值
if(this.node.nodeType === 1){
this.node.value = this.val;
}else{
this.node.textContent = this.val;
}
}
}
然后呢,我们需要在前面代码的基础上进行简单的调整,为了方便观看,我把之前的代码截图,并对一些需要改变的地方标红
这样子我们就简单的实现了vue的响应式啦!