记录一些Vue底层原理

tech2023-09-27  101

使用Vue开发项目有一段时间了,一直停留在使用的阶段,未成真正的深入了解,最近看来一些关于Vue原理的博客和视频,在此记录一些心得方便以后回忆。

vue.js的两个核心

数据驱动:ViewModel,保证数据和视图的一致性。组件系统:应用类UI可以看作全部是由组件树构成的。

这里先介绍一下Vue的数据双向绑定原理:

vue的数据双向绑定是通过数据劫持&发布者-订阅者模式。所谓的数据双向绑定表现出来的就是通过v-model指令实现视图层变化促使逻辑层变化,当逻辑层变化视图层也发生相应的变化。

view层–>model层:个人认为通过给相应的元素添加chang(input)事件,当触发chang(input)事件的时候就可以通过发布订阅者模式来改变model层。model层—>view层:个人认为通过Object.defineProperty()来劫持对象属性的set和get方法,当对象属性变化或获取时就可以通过该方法来进行监控,触发相应的监听回调。这样我们就可以监听MVVM中的逻辑层中的数据,然后通过发布订阅者模式来触发视图层的改变。

数据数据双向绑定原理图

Object.defineProperty数据劫持:
<!DOCTYPE html> <html lang="en"> <body> <div id="app"> <p id="name"></p> </div> <script> var obj = {}; Object.defineProperty(obj, "name", { get: function() { return document.querySelector("#name").innerHTML; }, set: function(val) { document.querySelector("#name").innerHTML = val; } }); obj.name = "Jerry"; </script> </body> </html>

订阅者和发布者模式

订阅者和发布者模式,通常用于消息队列中.一般有两种形式来实现消息队列,一是使用生产者和消费者来实现,二是使用订阅者-发布者模式来实现,其中订阅者和发布者实现消息队列的方式,就会用订阅者模式.

所谓的订阅者,就像我们在日常生活中,订阅报纸一样。我们订阅报纸的时候,通常都得需要在报社或者一些中介机构进行注册。当有新版的报纸发刊的时候,邮递员就需要向订阅该报纸的人,依次发放报纸。(这上面三句话引用别人的,感觉很形象)

Vue原理开始实现:

这上面是MVVM原理图

通过上述分析得到一下步骤:

需要一个数据劫持Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。需要一个模板解析器Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。需要一个订阅者Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。再开始前我们看看需要实现的最终目的是什么?这时候需要一个index.html,如下面的代码,在这个html文件中,我们会发现包含大量的Vue指令、插值表达式、事件绑定等。还包含KVue这个对象,该对象就像我们经常写的Vue对象一样。接下来的目的是将html和Vue联系起来实现一个简单的Vue原理。

index.html代码如下:

<!DOCTYPE html> <html lang="en"> <body> <div id="app"> <!-- 插值绑定 --> <p>{{name}}</p> <!-- 指令解析 --> <p k-text="name"></p> <p>{{age}}</p> <p>{{doubleAge}}</p> <!-- 双向绑定 --> <input type="text" k-model="name" /> <!-- 事件处理 --> <button @click="changeName">呵呵</button> <!-- html内容解析 --> <div k-html="html"></div> </div> <script src="./compile.js"></script> <script src="./Dep.js"></script> <script src="./Watcher.js"></script> <script src="./KVue.js"></script> <script> const kaikeba = new KVue({ el: "#app", data: { name: "I am test.", age: 12, html: "<button>这是一个按钮</button>" }, created() { setTimeout(() => { this.name = "我是测试"; }, 1500); }, methods: { changeName() { this.name = "哈喽,开课吧"; this.age = 1; } } }); </script> </body> </html>

第一步实现Observer数据劫持:

这时候需要将data中的数据显示到html对应的属性中,首先我们需要创建一个KVue类,并且遍历KVue实例中data里的属性,接着我们需要实现Compile解析器,解析模板指令,并替换模板数据,初始化视图。**数据监听器的核心方法就是Object.defineProperty(),通过遍历循环对所有属性值进行监听,并对其进行Object.defineProperty()处理。

Observe代码:

class KVue{ constructor(options) { this.$options = options; this.$data = options.data; this.observe(this.$data); //实例化解析器 new Compile(options.el,this) } observe(dataObj){ if(!dataObj || typeof(dataObj) !=='object'){ return false; } Object.keys(dataObj).forEach(key=>{ this.defineReactive(dataObj,key,dataObj[key]) //代理data中的属性到vue实例上 this.proxyData(key); }) } // 将data每一个属性添加set 和get 方法 defineReactive(dataObj,key,value){ this.observe(value) Object.defineProperty(dataObj,key,{ get(){ return value }, set(newValue){ if(newValue === value){ return; } value = newValue } }) } } 在上述代码中只是简单的对KVue实例中data里的属性进行数据劫持和实例化解析器Compile,并没有在劫持的方法中进行相应的回调监听,因为接下来会牵扯到Dep订阅者和wather观察者。

第二步实现模板解析器Compile(上述代码中已经 new Compile(options.el,this))

可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。

Compile代码:

//new Compile(el,vm) class Compile { constructor(el, vm) { this.$el = document.querySelector(el); this.$vm = vm; if (this.$el) { // // 因为遍历解析的过程有多次操作dom节点,为提高性能和效率,会先将跟节点el转换成文档碎片fragment进行解析编译操作,解析完成,再将fragment添加回原来的真实dom节点中(这部分就是虚拟Dom) this.fragment = this.node2Fragment(this.$el); // 开始编译 this.compile(this.fragment); // 将编译完成的html添加到app上 this.$el.appendChild(this.fragment); } } node2Fragment(el) { let frag = document.createDocumentFragment(); let child; while (child = el.firstChild) { //如果文档树中已经存在了 el.firstChild,它将从文档树中删除,然后插入它的新位置。也就是说Dom树中的元素在一个个减少,frag中元素在慢慢的添加。 frag.appendChild(child) } return frag; } compile(el) { const childNodes = el.childNodes; //childNodes不是真正的数组,是一个类数组,需要用Array.from()方法来将他转化为真正的数组,这样他才能使用forEach()方法。 Array.from(childNodes).forEach(node => { //判断是不是元素节点(nodeType === 1) if (this.isElement(node)) { //这是元素节点 console.log(`这是元素节点${node}`) //获取每一个node的属性,因为需要识别还有k-、@等Vue特殊的指令 const attributes = node.attributes; //同样将attributes转为真正的数组 Array.from(attributes).forEach(attr => { const attrName = attr.name; //属性名(k-html,k-text,k-model,@...) const exp = attr.value; // 属性值(data中的各个属性name,age,html) //k-指令 if (this.isDirective(attrName)) { // 提取k-model,k-text,k-html 后面的名称(model,text,html) var dir = attrName.substring(2) // 调用指令解析方法,这里会以dir来创建一下方法如 html(node=节点,vm=KVue实例,exp=data的属性name,age..) this[dir] && this[dir](node, this.$vm, exp); } //@事件 if (this.isEvent(attrName)) { console.log(`这是事件${attrName}`) //提取事件类别(click,input,change...) var dir = attrName.substring(1) //调用事件解析方法 this.EventHander(node, this.$vm, exp, dir) } }) } //判断是不是{{...}} if (this.isInterpolation(node)) { //这是插值表达式 console.log(`这是插值表达式${node.textContent}`) //调用插值表达式的解析方法 this.textCompile(node); } //递归遍历含有子节点的Node if (node.childNodes && node.childNodes.length != 0) { this.compile(node) } }) } //model解析方法 model(node, vm, exp) { this.updated(node, vm, exp, 'model') //为需要双向绑定的元素绑定input change事件 node.addEventListener("input", function (e) { vm[exp] = e.target.value }) } //1.html解析方法 html(node, vm, exp) { this.updated(node, vm, exp, 'html') } //2.文本解析方法 text(node, vm, exp) { this.updated(node, vm, exp, 'text') } //3.事件解析方法 EventHander(node, vm, exp, dir) { let fn = vm.$options.methods && vm.$options.methods[exp]; if (dir && fn) { node.addEventListener(dir, fn.bind(vm)); } } // 编译插值表达式 textCompile(node) { let exp = RegExp.$1; this.updated(node, this.$vm, exp, 'text') } //初始化页面,注册wather的集合 updated(node, vm, exp, dir) { let updateFn = this[dir + 'Update']; updateFn && updateFn(node, vm[exp]) new Watcher(vm, exp, function (value) { updateFn(node, value) }) } textUpdate(node, value) { node.textContent = value } htmlUpdate(node, value) { node.innerHTML = value } modelUpdate(node, value) { node.value = value } // 判断是否为指令 isDirective(attrName) { return attrName.indexOf('k-') === 0; } // 判断是否为事件 isEvent(attrName) { return attrName.indexOf('@') === 0; } // 判断是否为元素节点 isElement(node) { return node.nodeType == 1; } // 判断是否为插值表达式 isInterpolation(node) { return node.nodeType == 3 && /\{\{(.*)\}\}/.test(node.textContent); } }

第三步实现Dep

发布者,每一个vue的data属性都对应一个Dep,Dep是一个数组,可能包含多个或零个,取决于html中是否使用了该属性,如果同一个属性多次出现在html中,那么该属性对应的Dep含有多个watcher。初始化,定义Dep实例含有一个deps属性,该属性是一个数组。添加一个addDep方法,用来添加watcher实例到对应的Dep中添加一个notify方法,用来通知每一个watcher进行页面更新。 // 订阅者 用来管理watcher class Dep{ constructor() { this.deps=[] } addDep(dep){ this.deps.push(dep) } notify(){ this.deps.forEach(item=>{ item.update() }) } }

第四步完善Observe

将每一个vue中data的属性都分配一个发布者Dep每当获取该属性值得时候,就可以将该属性对应的订阅者watcher添加到Dep数组中了。每当属性值发生变化时,该属性对应的Dep就会调用自身的notify() 方法来通知所包含的watcher改变视图。 //new KVue(data:{....}) class KVue{ constructor(options) { this.$options = options; this.$data = options.data; this.observe(this.$data); // new Watcher(); // this.name; this.$data.name = "我是" new Compile(options.el,this) //绑定created方法到KVue实例上,使用了call(this) 这也就是为什么created中this指向KVue的原因了 if(options.created){ options.created.call(this) } } observe(dataObj){ if(!dataObj || typeof(dataObj) !=='object'){ return false; } Object.keys(dataObj).forEach(key=>{ this.defineReactive(dataObj,key,dataObj[key]) //代理data中的属性到vue实例上 this.proxyData(key); }) } // 代理data中的属性到vue实例上 ,方便获取和设置KVue实例data中的值 proxyData(key){ Object.defineProperty(this,key,{ get(){ return this.$data[key] }, set(newValue){ console.log(newValue) this.$data[key] = newValue } }) } // 将data每一个属性添加set 和get 方法 defineReactive(dataObj,key,value){ this.observe(value) const dep = new Dep(); Object.defineProperty(dataObj,key,{ get(){ //当获取该属性值得时候,就可以将该属性对应的订阅者添加到Dep数组中了。 Dep.target && dep.addDep(Dep.target) return value }, set(newValue){ if(newValue === value){ return; } value = newValue //当值发生变化时,dep通知它所包含的watcher进行相应的更新。 dep.notify(); } }) } }

第五步实现Watcher

在Compile代码中我们能够发现,在遍历整个dom节点中只要发现data中含有的属性或者methods中的方法都会new Watcher生产一个watcher;

也就是说页面中同一个data属性可能有多个watcher。

watcher用来将node和KVue中的数据联系起来,在Observe代码中,我们已经为每一个data属性添加了一个自定义的get方法,在该方法中我们需要将对应的watcher添加到Dep中,那么如何才能确保每次只有一个watcher实例能,这时候我们需要通过Dep.target来指向watcher实例。

初始化在Compile代码中 new Watcher(Kvue实例,data中对应的属性,初始化页面的方法)

Dep.target指向自己,确保单例

访问一下vm[exp],触发get方法,将watcher添加得到Dep中

清除Dep.target;

创建一个更新方法,通过调用实例化传过来的初始化方法来改变vue页面。

class Watcher { constructor(vm, exp, cb) { this.$vm = vm; this.exp = exp; this.$cb = cb; Dep.target = this; vm[exp] Dep.target = null; } update() { console.log("我要更新处理",this.$vm[this.exp]) this.$cb.call(this.$vm,this.$vm[this.exp]) } }
最新回复(0)