vue响应式原理 VUE 响应式原理源码:带你一步精通 VUE | 原力计划

栏目:军事 2021-12-02 04:51:42
分享到:
作者 | 爱编程的小和尚责编 | 王晓曼出品 | CSDN博客学过 VUE 如果不了解响应式的原理,怎么能说自己熟练使用 VUE,要是没有写过一个简易版的 VUE 怎么能说自己精通 VUE,这篇文章通过300多行代码,带你写一个简易版的 VUE,主要实现 VUE 数据响应式 、数组的变异方法、编译指令,数据的双向绑定的功能。本文需要有一定 VUE 基础,并不适合新手学习。文章较长,且有些难度,建议大家,找一个安静的环境,并在看之前沐浴更衣,保持编程的神圣感。下面是实现的简易版VUE 的源码地址,一定要先下载下来!因为文章中的并非全部的代码。Github源码地址:https://github.com/young-monk/myVUE.git前言在开始学习之前,我们先来了解一下什么是 MVVM ,什么是数据响应式。我们都知道 VUE 是一个典型的 MVVM 思想,由数据驱动视图。那么什么是 MVVM 思想呢?MVVM是Model-View-ViewModel,是把一个系统分为了模型、视图和 view-model 三个部分。VUE在 MVVM 思想下,view 和model 之间没有直接的联系,但是 view 和 view-model 、model和 view-model之间时交互的,当 view 视图进行 dom 操作等使数据发生变化时,可以通过 view-model 同步到 model 中,同样的 model 数据变化也会同步到 view 中。那么实现数据响应式都有什么方法呢?1、发布者-订阅者模式:当一个对象状态发生改变时,所有依赖它的对象都会得到通知。通俗点来讲,发布者就相当于报纸,而订阅者相当于读报纸的人。2、脏值检查:通过存储旧的数据,和当前新的数据进行对比,观察是否有变更,来决定是否更新视图。angular.js 就是通过脏值检查的方式。最简单的实现方式就是通过 setInterval 定时轮询检测数据变动,但这样无疑会增加性能,所以, angular 只有在指定的事件触发时进入脏值检测。3、数据劫持:通过 Object.defineProperty 来劫持各个属性的 setter,getter,在数据变动时触发相应的方法。VUE是如何实现数据响应式的呢?VUE.js 则是通过数据劫持结合发布者-订阅者模式的方式。当执行 new VUE 时,VUE 就进入了初始化阶段,VUE会对指令进行解析,同时通过 Obserber会遍历数据并通过 Object.defineProperty 的 getter 和 setter 实现对的监听, 当数据发生变化的时候,Observer 中的 setter 方法被触发,setter 会立即调用Dep.notify, Dep 开始遍历所有的订阅者,并调用订阅者的 update 方法,订阅者收到通知后对视图进行相应的更新。我来依次介绍一下图中的重要的名词:1、Observer:数据监听器,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者,内部采用 Object.defineProperty 的 getter 和 setter 来实现2、Compile:指令解析器,它的作用对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数3、Dep:订阅者收集器或者叫消息订阅器都可以,它在内部维护了一个数组,用来收集订阅者,当数据改变触发 notify 函数,再调用订阅者的 update 方法4、Watcher:订阅者,它是连接 Observer 和 Compile 的桥梁,收到消息订阅器的通知,更新视图5、Updater:视图更新所以我们想要实现一个 VUE 响应式,需要完成数据劫持、依赖收集、 发布者订阅者模式。下面我来介绍我模仿源码实现的功能:1、数据的响应式、双向绑定,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者2、解析 VUE 常用的指令 v-html,v-text,v-bind,v-on,v-model,包括3、数组变异方法的处理4、在 VUE 中使用 this 访问或改变 data 中的数据我们想要完成以上的功能,需要实现如下类和方法:1、实现 Observe r类:对所有的数据进行监听2、实现 array 工具方法:对变异方法的处理3、实现 Dep 类:维护订阅者4、实现 Watcher 类:接收 Dep 的更新通知,用于更新视图5、实现 Compile 类:用于对指令进行解析6、实现一个 CompileUtils 工具方法,实现通过指令更新视图、绑定更新函数Watcher7、实现 this.data 代理:实现对 this. data 代理:实现对 this.data 代理:实现对 this.data 代理,可以直接在 VUE 中使用 this 获取当前数据我是使用了webpack作为构建工具来协同开发的,所以在我实现的VUE响应式中会用到ES6模块化,webpack的相关知识。实现 Observer 类我们都知道要用Obeject.defineProperty来监听属性的数据变化,我们需要对 Observer 的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter 和 getter ,这样的话,当给这个对象的某个值赋值,就会触发 setter,那么就能监听到了数据变化。当然我们在新增加数据的时候,也要对新的数据对象进行递归遍历,加上 setter 和 getter 。但我们要注意数组,在处理数组时并不是把数组中的每一个元素都加上 setter 和 getter ,我们试想一下,一个从后端返回的数组数据是非常庞大的,如果为每个属性都加上 setter 和 getter ,性能消耗是十分巨大的。我们想要得到的效果和所消耗的性能不成正比,所以在数组方面,我们通过对数组的7 个变异方法来实现数据的响应式。只有通过数组变异方法来修改和删除数组时才会重新渲染页面。那么监听到变化之后是如何通知订阅者来更新视图的呢?我们需要实现一个Dep,其中有一个 notify 方法,是通知订阅者数据发生了变化,再让订阅者来更新视图。我们怎么添加订阅者呢?我们可以通过 new Dep,通过 Dep 中的addSaubs 方法来添加订阅者。我们来看一下具体代码。我们首先需要声明一个 Observer 类,在创建类的时候,我们需要创建一个消息订阅器,判断一下是否是数组,如果是数组,我们便改造数组,如果是对象,我们便需要为对象的每一个属性都加入 setter 和 getter 。import { arrayMethods } from './array' //数组变异方法处理 class Observer { constructor { //用于对数组进行处理,存放数组的观察者watcher this.dep = new Dep if ) { //如果是数组,使用数组的变异方法 data.__proto__ = arrayMethods //把数组数据添加 __ob__ 一个Observer,当使用数组变异方法时,可以更新视图 data.__ob__ = this //给数组的每一项添加数据劫持 this.observerArray } else { //非数组数据添加数据劫持 this.walk } }}在上面,我们给 data 的__proto__原型链重新赋值,我们来看一下 arrayMethods 是什么,arrayMethods 是 array.js 文件中,抛出的一个新的 Array 原型:// 获取Array的原型链const arrayProto = Array.prototype;// 重新创建一个含有对应原型的对象,在下面称为新Arrayconst arrayMethods = Object.create;// 处理7个数组变异方法.forEach // 获取Observer对象 const ob = this.__ob__ // 更新视图 ob.dep.notify }})export { arrayMethods}此时呢,我们就拥有了数组的变异方法,我们还需要通过 observerArray 方法为数组的每一项添加 getter 和setter ,注意,此时的每一项只是最外面的一层,并非递归遍历。//循环遍历数组,为数组每一项设置setter/getterobserverArray { for { this.observer }}如果是一个对象的话,我们就要对对象 的每一个属性递归遍历,通过 walk 方法:walk { //数据劫持 if { for { //绑定setter和getter this.defineReactive } }}在上面的调用了 defineReactive ,我们来看看这个方法是干什么的?这个方法就是设置数据劫持的,每一行都有注释。//数据劫持,设置 setter/getteer defineReactive { //如果是数组的话,需要接受返回的Observer对象 let arrayOb = this.observer //创建订阅者/收集依赖 const dep = new Dep //setter和getter处理 Object.defineProperty //如果是数组,则添加上数组的观察者 Dep.target && arrayOb && arrayOb.dep.addSubs return value }, set: => { //新旧数据不相等时更改 if { //为新设置的数据添加setter/getter arrayOb = this.observer; value = newVal //通知 dep 数据发送了变化 dep.notify } } }) }}我们需要注意的是,在上面的图解中,在 Observer 中,如果数据发生变化,会通知消息订阅器,那么在何时绑定消息订阅器呢?就是在设置 setter 和 getter 的时候,创建一个 Dep,并为 Dep添加订阅者,Dep.target&& dep.addSubs,通过调用 dep 的 addSubs 方法添加订阅者。实现 DepDep 是消息订阅器,它的作用就是维护一个订阅者数组,当数据发送变化是,通知对应的订阅者,Dep中有一个 notify 方法,作用就是通知订阅者,数据发送了变化:// 订阅者收集器export default class Dep { constructor { //管理的watcher的数组 this.subs = } addSubs { //添加watcher this.subs.push } notify { //通知watcher更新dom this.subs.forEach }}实现 watcherWatcher 就是订阅者, watcher 是 Observer 和 Compile 之间通信的桥梁,当数据改变时,接收到 Dep 的通知,来调用自己的update方法,触发 Compile 中绑定的回调,达到更新视图的目的。import Dep from './dep'import { complieUtils } from './utils'export default class Watcher { constructor { //当前的vue实例 this.vm = vm; //表达式 this.expr = expr; //回调函数,更新dom this.cb = cb //获取旧的数据,此时获取旧值的时候,Dep.target会绑定上当前的this this.oldVal = this.getOldVal } getOldVal { //将当前的watcher绑定起来 Dep.target = this //获取旧数据 const oldVal = complieUtils.getValue //绑定完成后,将绑定的置空,防止多次绑定null return oldVal } update { //更新函数 const newVal = complieUtils.getValue if ) { //条用更新在compile中创建watcher时传入的回调函数 this.cb } }}上面中用到了 ComplieUtils 中的 getValue 方法,会在下面讲,主要作用是获取到指定表达式的值。我们把整个流程分成两条路线的话:newVUE ==> Observer数据劫持 ==> 绑定Dep ==> 通知watcher ==> 更新视图newVUE ==> Compile解析模板指令 ==> 初始化视图 和 绑定watcher此时,我们第一条线的内容已经实现了,我们再来实现一下第二条线。实现 CompileCompile 主要做的事情是解析模板指令,将模板中的变量替换成数据,初始化渲染页面视图。同时也要绑定更新函数,添加订阅者。因为在解析的过程中,会多次的操作 dom,为提高性能和效率,会先将 VUE 实例根节点的 el 转换成文档碎片 fragment 进行解析编译操作,解析完成,再将 fragment 添加回原来的真实 dom 节点中。class Complie { constructor { this.el = this.isNodeElement ? el : document.querySelector; this.vm = vm; // 1、将所有的dom对象放到fragement文档碎片中,防止重复操作dom,消耗性能 const fragments = this.nodeTofragments // 2、编译模板 this.complie // 3、追加子元素到根元素 this.el.appendChild } }我们可以看到,Complie 中主要进行了三步,第一步 nodeTofragments 是讲所有的 dom 节点放到文档碎片中操作,最后一步,是把解析好的 dom 元素,从文档碎片重新加入到页面中,这两步的具体方法,大家去下载我的源码,看一下就明白了,有注释。我就不再解释了。我们来看一下第二步,编译模板:complie { //获取所有节点 const nodes = fragments.childNodes; .forEach) { //1. 编译元素节点 this.complieElement } else { //编译文本节点 this.complieText } //如果有子节点,循环遍历,编译指令 if { this.complie } })}我们要知道,模板可能有两种情况,一种是文本节点和元素节点。我们获取所有节点后对每个节点进行判断,如果是元素节点,则用解析元素节点的方法,如果是文本节点,则调用解析文本的方法。complieElement { //1.获取所有的属性 const attrs = node.attributes; //2.筛选出是属性的 .forEach) { //将指令分离 text, html, on:click const = name.split //处理on:click或bind:name的情况 on,click const = directive.split //编译模板 complieUtils //删除属性,在页面中的dom中不会再显示v-html这种指令的属性 node.removeAttribute } else if ) { // 如果是事件处理 @click='handleClick' let = name.split; complieUtils; node.removeAttribute; } else if ) { // 如果是事件处理 :href='...' let':');'bind'];} })}我们在编译模板中调用了 complieUtils方法,这是工具类中的一个方法,用于处理指令。我们再来看看文本节点,文本节点就相对比较简单,只需要匹配{{}}形式的插值表达式就可以了,同样的调用工具方法,来解析。complieText { //1.获取所有的文本内容 const text = node.textContent //匹配{{}} if }}/.test) { //编译模板 complieUtils }}上面用来这么多工具方法,我们来看看到底是什么。实现 ComplieUtils 工具方法这个方法主要是对指令进行处理,获取指令中的值,并在页面中更新相应的值,同时我们在这里要绑定 watcher 的回调函数。我来以 v-text 指令来解释,其他指令都有注释,大家自己看。import Watcher from './watcher'export const complieUtils = { //处理text指令 text { let value; if ) { //处理 {{}} value = expr.replace}}/g, => { //绑定观察者/更新函数 new Watcher) }) return this.getValue }) } else { //v-text new Watcher => { this.updater.updaterText }) //获取到value值 value = this.getValue } //调用更新函数 this.updater.updaterText },}Text 处理函数是对 dom 元素的 TextContent 进行操作的,所以有两种情况,一种是使用 v-text 指令,会更新元素的 textContent,另一种情况是{{}} 的插值表达式,也是更新元素的 textContent。在此方法中我们先判断是哪一种情况,如果是 v-text 指令,那么就绑定一个 watcher 的回调,获取到 textContent 的值,调用 updater.updaterText 在下面讲,是更新元素的方法。如果是双大括号的话,我们就要对其进行特殊处理,首先是将双大括号替换成指定的变量的值,同时为其绑定 watcher 的回调。//通过表达式, vm获取data中的值, person.namegetValue { return expr.split.reduce => { return data }, vm.$data)},获取 textContent 的值是用一个 reduce 函数,用法在最后面的链接中,因为数据可能是 person.name 我们需要获取到最深的对象的值。//更新dom元素的方法updater: { //更新文本 updaterText { node.textContent = value }}updater.updaterText更新dom的方法,其实就是对 textContent 重新赋值。我们再来将一下v-model指令,实现双向的数据绑定,我们都知道,v-model其实实现的是 input 事件和 value 之间的语法糖。所以我们这里同样的监听一下当前 dom 元素的 input 事件,当数据改变时,调用设置新值的方法://处理model指令model { const value = this.getValue //绑定watcher new Watcher => { this.updater.updaterModel }) //双向数据绑定 node.addEventListener => { //设值方法 this.setVal }) this.updater.updaterModel},这个方法同样是通过 reduce 方法,为对应的变量设置成新的值,此时数据改变了,会自动调用更新视图的方法,我们在之前已经实现了。//通过表达式,vm,输入框的值,实现设置值,input中v-model双向数据绑定setVal { expr.split.reduce => { data = inputVal }, vm.$data)},实现VUE最后呢,我们就要来整合这些类和工具方法,在创建一个 VUE 实例的时候,我们先获取 options 中的参数,然后对起进行数据劫持和编译模板:class Vue { constructor { //获取模板 this.$el = options.el; //获取data中的数据 this.$data = options.data; //将对象中的属性存起来,以便后续使用 this.$options = options //1.数据劫持,设置setter/getter new Observer //2.编译模板,解析指令 new Complie }}此时我们想要使用 VUE 中的数据,比如我们想要在 vm 对象中使用person.name, 必须用 this.$data.person.name 才能获取到,如果我们想在 vm 对象中使用 this.person.name 直接修改数据,就需要代理一下 this.$data 。其实就是将当前的 this.$data 中的数据放到全局中进行监听。export default class Vue { constructor { //... //1.数据劫持,设置setter/getter //2.编译模板,解析指令 if { //如果有模板 //代理this this.proxyData } } proxyData { for { //将当前的数据放到全局指向中 Object.defineProperty { data = newVal } }) } }}文章到了这里,就实现了一个简易版的 VUE,建议大家反复学习,仔细体验,细细品味。在文章的最后,我通过问、答的形式,来解答一些常见的面试题:问:什么时候页面会重新渲染?答:数据发生改变,页面就会重新渲染,但数据驱动视图,数据必须先存在,然后才能实现数据绑定,改变数据,页面才会重新渲染。问:什么时候页面不会重新渲染?答:有3种情况不会重新渲染:1、未经声明和未使用的变量,修改他们,都不会重新渲染页面2、通过索引的方式和更改长度的方式更改数组,都不会重新渲染页面3、增加和删除对象的属性,不会重新渲染页面问:如何使 未声明/未使用的变量、增加/删除对象属性可以使页面重新渲染?答:添加利用 vm.$set/VUE.set,删除利用vm.$delete/VUE.delete方法问:如何更改数组可以使页面重新渲染?答:可以使用数组的变异方法:push、pop、unshift、shift、splice、sort、reverse问:数据更新后,页面会立刻重新渲染么?答:更改数据后,页面不会立刻重新渲染,页面渲染的操作是异步执行的,执行完同步任务后,才会执行异步的同步队列,异步队列问:如果更改了数据,想要在页面重新渲染后再做操作,怎么办?答:可以使用 vm.$nextTick 或 VUE.nextTick问:来介绍一下vm.$nextTick 和 VUE.nextTick 吧。答:我们来看个小例子就明白啦:
{{ name }}
问:vm.$nextTick 和 VUE.nextTick 有什么区别呢 ?答:VUE.nextTick 内部函数的 this 指向 Window,vm.$nextTick 内部函数的 this 指向 VUE 实例对象。Vue.nextTick; // window})vm.$nextTick; // vm实例})问:vm.$nextTick 和 VUE.nextTick 是通过什么实现的呢?答:二者都是等页面渲染后执行的任务,都是使用微任务。if { // 微任务 // 首先看一下浏览器中有没有promise // 因为IE浏览器中不能执行Promise const p = Promise.resolve;} else if { // 微任务 // 突变观察 // 监听文档中文字的变化,如果文字有变化,就会执行回调 // vue的具体做法是:创建一个假节点,然后让这个假节点稍微改动一下,就会执行对应的函数 } else if { // 宏任务 // 只在IE下有 } else { // 宏任务 // 如果上面都不能执行,那么则会调用setTimeout }同样的这也是 VUE 的一个小缺点:VUE 一直是等主线程执行完以后再执行渲染任务,如果主线程卡死,则永远渲染不出来。问:利用 Object.defineProperty 实现响应式有什么缺点?答:1、天生就需要进行递归2、监听不到数组不存在的索引的改变3、监听不到数组长度的改变4、监听不到对象的增删版权声明:本文为CSDN博主「爱编程的小和尚」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
  • 喀什吧 疫情过后 和我们一起去新疆喀什吧

    疫情过后 和我们一起去新疆喀什 你有没有认真想过呆在家里: 当疫情结束后,你可以重新开始你的旅行计划。第一站:你会选择哪里? 我们决定去新疆喀什。 我爱喀什很久了。前几天,我又看了电影《追风筝的人》。我不知道你是

    ·21-12-02

  • wow上面的熊 魔兽世界怀旧服:熊T和防战谁更强 民间大神从两大维度告诉你差异在哪里

    享受魔兽世界的热点新闻。大家好,这里是兔子分享的魔兽世界热点新闻。虽然对于大多数玩家来说,魔兽世界怀旧服是他们休闲怀旧的地方,但总有一些玩家喜欢动脑研究一些专业数据,提升自己职业的特色和强度。最近NGA大神的一

    ·21-12-02

  • 唯心不易 年轻人不上进太佛系 佛系不是错 而是一种自我救赎

    首先,疯狂的过度工作会破坏身体 之前有一个轰动,那就是《今晚80后脱口秀》的幕后玩家赖宝,因为心脏问题去世了。赖宝是一个80后,很有才华,年纪轻轻就离开让人觉得很难过。 我加入的一个社区也讨论过这件事。有人说,他们的

    ·21-12-02

  • 总拉肚子 为什么我总拉肚子

    一些研究表明,十分之一的人每年至少腹泻四周。那么,腹泻在医生嘴里到底是什么意思呢?正常情况下,肠道蠕动每天发生3次以上,粪便疏松或液体即为腹泻。但是,肠道蠕动引起的一些病菌等情况也很常见。 什么引起急性腹泻? 肠道感

    ·21-12-02

  • 无锡新楼盘 目前无锡这4大新楼盘抗跌甚至有升值空间

    金九是房地产行业最热的一个月。九月的竞争很激烈。转眼间,“银十”就要结束了。自10月份以来,无锡楼市降温,拍卖价格已经关闭。很多买家现在都犹豫要不要买。 对于购房者来说,他们最想要的就是买房后就升值价格,这样他们

    ·21-12-02

  • 知心姐姐卢勤的微信号 是什么在影响和塑造我们的孩子 知心姐姐卢勤如是说

    “腿不直也能跳舞”? 在一次演讲中,我亲密的姐姐陆琴谈到了她小时候的舞蹈经历。 小学毕业前夕,北京舞蹈学校招收青年舞蹈生,知心姐姐入选。 采访中,当他们选中的四个小女孩把手放在老师的密码后面,用脚趾一起站直时,一位

    ·21-12-02

  • 夏天去哪旅游好 夏季旅游去哪儿好 10个清爽的国内旅行地推荐给大家

    夏天的热浪让人烦躁不安 这个时候出行就更重要了 让我们伸展一下! 今天,边肖为大家推荐几款 国内夏季旅游的目的地 看看吧。 杭州西湖 来源:图片网 “上有天堂,下有苏杭”。作为八大古都之一,杭州因其美丽的风景而被称

    ·21-12-02

  • 猪头鱼 吃遍大连-鱼头美味的小盘点 花样不少 各有千秋~

    猪头、鱼头、鸭头、牛头、兔头... 中国人有自己的暴力美学, 相比之下, 鱼头最有名, 美味的鱼头在于“滑”字。 富含胶质,有骨头和肉, 只有当你能吮吸和咀嚼时,你才能理解它的味道。 懂得吃鱼头的人, 通常目光敏锐, 颊肉,鱼脑

    ·21-12-02