本篇响应式原理介绍比较长,全文大概1w+字。虽然内容繁杂,但阅读过后,绝对会让你对vue的响应式有更加深刻的理解。

分块阅读,效果更佳。(建议读者有一定vue使用经验和基础再食用)

首先上图,下面这张图,即为MVVM响应式原理的整个过程图,我们本篇都是围绕着这张图进行分析,所以这张图是重中之重。

响应式原理图

一脸懵逼?没关系,接下来我们将通过创建一个简单的MVVM响应系统来一步步了解这个上图中的全过程。全文分为两大块,首先介绍实例模板的编译过程,然后详细介绍响应式,这里先介绍编译是为了给介绍响应式奠定基础。

编译

我们把我们创建的这个微型响应系统命名为miniVue,我们按照平常使用Vue的模式,首先创建一个miniVue的实例。

<scirpt>const vm = new miniVue({el: '#app',data: {obj: {name: "miniVue",auth: 'xxx'},msg: "this is miniVue",htmlStr: "<h3>this is htmlStr</h3>"},methods: {handleClick() {console.log(this);}}});
</scirpt>

我们根据这个实例,我们可以创建出miniVue的类,这个类中我们肯定要保存该实例所绑定的DOM以及数据对象data。然后我们要开始解析模板,即解析我们所绑定的DOM

class miniVue {constructor(options) {this.$el = options.elthis.$data = options.datathis.$options = options}if(this.$el) {// 解析模板 to Compile}
}

这里我们来创建一个compile类来进行解析模板的操作

创建compile类

Compile类是用来解析模板的,所以肯定要传入要解析的DOM。拿到DOM后直接操作这个DOM会导致页面频繁的回流和重绘,所以我们把这个DOM放到一个文档碎片中,然后操作这个文档碎片。操作这个文档碎片的过程中我们需要获取到数据对象data中的属性来填充一些节点的内容,所以我们还需要传入实例对象。最后将操作好的文档碎片追加到原本的DOM上。

class Compile {constructor(el, vm) {// 判断的原因是因为传入的el有可能是DOM,也有可能是选择器例如‘#app’this.el = this.isElementNode(el) ? el : document.querySelector(el)this.vm = vm// 新建文档碎片存储DOMconst fragment = this.toFragment(this.el)// 操作文档碎片 to handle fragment// 将操作好的文档碎片追加到原本的DOM上面this.el.appendChild(fragment)}// 判断是否为元素节点isElementNode(node) {return node.nodeType === 1}// dom碎片化toFragment(el) {const f = document.createDocumentFragment()// 递归存入recursion(el, f)function recursion(el, father) {el.children.forEach((child) => {if(child.children.length > 0) {recursion(child.children, child)}father.appendChild(child)})}}
}
​
// 上面的miniVue实例相应的改为
class miniVue {constructor(options) {this.$el = options.elthis.$data = options.datathis.$options = options}if(this.$el) {// 解析模板 to Compilenew Compile(this.$el, this) // 这里的this就是miniVue实例}
}

操作fragment

操作保存好的文档碎片,我们可以专门定义一个函数,然后把文档碎片通过参数传入进来。

操作文档碎片我们又可以分为两步。因为针对文本节点元素节点,我们需要进行不同的操作,所以我们在遍历所有节点后的第一步应该先判断它是元素节点还是文本节点

handleFragment(fragment) {// 获取文档碎片的子节点const childNodes =  fragment.childNodes// 遍历所有子节点[...childNodes].forEach((child) => {if(this.isElementNode(child)) {// 元素节点this.compileElement(child)} else {// 文本节点this.compileText(child)}// 递归遍历if(child.childNodes && child.childNodes.length) {handleFragment(child)}})
}
​
// 同样的我们需要完善一下compile的构造函数
constructor(el, vm) {this.el = this.isElementNode(el) ? el : document.querySelector(el)this.vm = vm// 新建文档碎片存储DOMconst fragment = this.toFragment(this.el)// 操作文档碎片 to handle fragmentthis.handleFragment(fragment)// 将操作好的文档碎片追加到原本的DOM上面this.el.appendChild(fragment)
}

获取元素节点上的信息

元素节点上的信息主要就是这个元素节点上面的属性,然后拿到绑定在节点上面的vue指令,分离出来vue指令的名称和值(注意:@开的头的指令需要额外处理)。然后还有很重要的一步,那就是去掉这些指令(这些指令updater是不认的)

compileElement(node) {const attrs = node.attributes// 遍历节点上的全部属性[...attrs].forEach(({name, value}) => {// 分类看指令以什么开头if(this.headWithV(name)) {// 以v开头const [,directive] = name.split("-") //分离出具体指令const [dir,event] = directive.split(":") // 考虑v-on的情况 例如v-on:click// 将指令的名称、值、node节点、整个vm实例、事件名(如果有的话)一起传给最后真正操作的node的函数handleNode[dir](node, value, this.vm, event)}else if(this.headWithoutV(name)) {// 以@开投const [, event] = name.split("@")// 和上面一样,但是指令名字是确定的,为“on” 因为@是v-on的语法糖handleNode["on"](node, value, this.vm, event)}})
}
​
headWithV(name) {return name.startsWith("v-");
}
headWithoutV(name){return name.startsWith("@");
}

获取文本节点信息

文本节点和元素节点类似,只不过文本节点的信息存储在节点的textContent里面,主要用来替换mustache语法(双大括号插值)需要通过正则识别额外处理。如果是正常的文本节点,则不进行处理(原模原样展示即可)。

compileText(node) {const content = node.textContentif(!/{{(.+?)}}/.test(content)) return// 识别到是mustache语法 处理方法其实和v-text一样handleNode["text"](node, content,this.vm)
}

操作fragment

前面铺垫了这么多,终于到了操作文档碎片这一步了。按照上面的思路,handleNode应该是一个对象,里面有多个属性对应不同的指令的处理方法。

// node--操作的node节点  exp--指令的值(或者是mustache语法内部插入的内容)  vm--vm实例  event--事件名称
const handleNode = {// v-htmlhtml(node, exp, vm) {// 去vm实例中找到这个表达式所对应的值const value = this._get(vm, exp)// 更新nodeupdater.htmlUpdater(node, value)},// v-modelmodel(node, exp, vm) {// 同htmlconst value = this._get(vm, exp)updater.modelUpdater(node, value)},// v-onon(node, exp, vm, event) {// v-on特殊一点,我们需要为该node绑定事件监听器const listener = vm.$options.methods && vm.$options.methods[exp] // 获取监听器的回调// 绑定监听器,注意回调绑定使用bind把this指向vm实例,false代表事件冒泡时触发监听器node.addEventListener(event, listener.bind(this), false) },// v-texttext(node, exp, vm) {// v-text是最复杂的,需要考虑两种情况,一种是通过v-text指令操作node,另一种则是通过mustache语法操作node,需分类let valueif(exp.indexOf("{{") !== -1) {// mustache语法操作node// 捕捉到所有的mustache语法,将其整个替换为vm实例中属性对应的值// 拿我们最初初始化实例的一个数据举例:{{obj.auth}} -- 'xxx'value = exp.replace(/{{(.+?)}}/g, this._get(vm, exp)) }else {// v-text操作nodevalue = this._get(vm, exp)}// 更新nodeupdater.textUpdater(node, value);},
}
​
// 根据表达式去数据对象里面获取值
_get(vm, exp) {const segments = exp.split('.')// 这里使用reduce是为了获取嵌套对象内部属性的值,不熟悉的话去补一补reduce// 比如data.a.b.c,那么每次遍历的值为data[a],data[a][b],最终结果是data[a][b][c]segments.reduce((pre, key) => {return pre[key]}, vm.$data)
}
​
// 终于可以更新node了
const updater = {textUpdater(node, value) {node.textContent = value;},htmlUpdater(node, value) {node.innerHTML = value;},modelUpdater(node, value){node.value = value;}
}

至此我们已经实现了vue实例模板编译,并更新了node,其实到现在我们还没有涉及到响应式这三个字。下面我们开始介绍本篇的核心,即vue是如何实现响应式的。

响应式

数据劫持

关键点:Object.defineProperty(具体用法参考MDN)

主要目的:为data中每个属性添加gettersetter,然后在gettersetter中进行数据劫持

思路很简单,其实就是从最外层的data层开始遍历属性,通过Object.defineProperty给这些属性都添加上gettersetter,需要注意对象的嵌套,所以需要使用递归来为嵌套的属性添加gettersetter

function observe(data) {if(typeof data !== 'object') returnObject.keys(data).forEach((key) => {defineReactive(data, key, data[key])})
}
​
function defineReactive(data, key, value) {// 递归子属性observe(value)Object.defineProperty(data, key, {get() {// 数据劫持 在这个地方进行相关操作return value}set(newVal) {if(newVal == value) returnvalue = newVal// 为新数据添加getter和setterobserve(newVal)// 数据劫持 在这个地方进行相关操作}})
}

收集依赖

依赖其实说白了,就是数据的依赖,data中的某个属性,可能在DOM中好几个地方进行了使用,那DOM中使用到该属性的地方就都会产生一个对于该属性的依赖,也就是watcher。当该属性的值发生了变化,那么就可以通知watcher来使得页面中使用到这个属性的地方进行视图更新。为每个属性绑定watcher的过程其实就是订阅,反过来,当属性的值发生了变化,通知所有watcher的过程就是发布

下面我们来将依赖抽象化,即实现watcher

class Watcher {// data--最外层数据对象  exp--表达式  cb--数据更新后需要执行的回调// 通过data和exp可以获取watcher所依赖属性的具体值constructor(data, exp, cb) {this.data = datathis.exp = expthis.cb = cb// 每次初始化watcher实例时,对依赖属性进行订阅this.value = this.subscribe()}// 订阅subscribe() {// 获取依赖属性的值const value = _get(this.data, this.exp)return value}// 更新update() {// 获取新值this.value = _get(this.data, this.exp)cb()}
}// 根据表达式去数据对象里面获取值 其实上面已经定义过一个了,功能是一样的,这里重复定义加深一下影响,也方便阅读
function _get(obj, exp) {const segments = exp.split('.')// 这里使用reduce是为了获取嵌套对象内部属性的值,不熟悉的话去补一补reduce// 比如data.a.b.c,那么每次遍历的值为data[a],data[a][b],最终结果是data[a][b][c]segments.reduce((pre, key) => {return pre[key]}, obj)
}

依赖我们大概清楚了,但是我们上面讲,需要把一个属性全部的依赖(watcher)收集起来,所以我们该如何收集依赖呢?

首先我们先想第一个问题,一个属性会有一个或者好多个watcher,我们应该如何保存这些watcher呢,这个我们很容易想到,我们可以专门拿一个数组保存一个属性的全部watcher,我们把这个数组命名为dep(dependency)。

第二个问题,我们应该什么时候进行收集watcher的操作呢。还记得我们上面提到的订阅吗,我们每次初始化watcher时,会为该watcher订阅属性,订阅的过程中我们会首先获取这个属性的值,这时就可以发挥数据劫持的作用了,获取这个属性值的时候,我们就会进到这个属性的getter方法中,所以我们可以在这个时候完成收集watcher的操作。

第三个问题,我们说watcher的作用其实就是监听到订阅属性的变化(即监听发布),监听到变化后执行其update方法,即执行更新回调,来更新视图。那么我们怎样才能让watcher监听到“发布”呢,这时我们又需要用到数据劫持,即在setter中通知这个属性所有的watcher

function defineReactive(data, key, value) {// 新建用于存储watcher的数据const dep = []// 递归子属性observe(value)Object.defineProperty(data, key, {get() {// 数据劫持 在这个地方进行相关操作dep.push(watcher) // 收集依赖return value}set(newVal) {if(newVal == value) returnvalue = newVal// 为新数据添加getter和setterobserve(newVal)// 数据劫持 在这个地方进行相关操作dep.notify() // 通知依赖}})
}

现在我觉得我有必要理一下这个依赖收集的全过程。首先页面初次渲染的时候,会遇到我们在data中定义的属性(注意:此时属性上面已经定义好getter和setter了),遇到属性后会初始化一个watcher实例,在此过程中watcher实例会获取这个属性的值,于是会进入到这个属性的getter中,于是我们通过数据劫持来收集这个watcher。那么又出现了一个问题,我们此时在getter中,如何获取到初始化的watcher实例呢,也就是dep.push的时候,其实我们是没有办法直接拿到这个watcher的。因此,我们需要在初始化watcher的时候,把这个watcher放到全局,比如window.target

subscribe() {// 获取依赖属性的值window.target = this // 这里的this即为此时初始化的watcher实例const value = _get(this.data, this.exp)return value
}function defineReactive(data, key, value) {// 新建用于存储watcher的数据const dep = []observe(value)Object.defineProperty(data, key, {get() {dep.push(window.target) // 改为window.targetreturn value}set(newVal) {if(newVal == value) returnvalue = newValobserve(newVal)dep.notify()}})
}

响应式代码完善

Dep类

我们可以讲dep数组抽象为一个类

class Dep {constructor() {this.subs = []}// 收集依赖addSub(watcher) {this.subs.push(watcher)}// 通知依赖notify() {[...this.subs].forEach((watcher) => {watcher.update()})}
}

defineReactive也要做出相应的调整

function defineReactive(data, key, value) {// 新建用于存储watcher的数据const dep = new Dep()observe(value)Object.defineProperty(data, key, {get() {// 收集依赖dep.addSub(window.target) return value}set(newVal) {if(newVal == value) returnvalue = newValobserve(newVal)// 通知依赖dep.notify()}})
}

全局watcher用完清空

下面有一个场景,我们在访问到data中的一个属性a后,实例化了一个watcher1,此时在实例化这个watcher1的过程中,会把window.target设置为watcher1,之后我们在没有实例化其他watcher的情况下直接去访问其他的属性,例如属性b,那么属性b中的getter会直接把watcher1推入到它的依赖数组中。这样是不合理的,所以我们每次将watcher推入到依赖数组中后,要将这个watcher从全局中收回。(window.target这里改成Dep.target了,其实都是一样的)

subscribe() {Dep.target = this // 这里的this即为此时初始化的watcher实例const value = _get(this.data, this.exp)Dep.target = null // 清空暴露在全局中的watcherreturn value
}// 同时在收集依赖时添加一层过滤
addSub(watcher) {if(watcher) {this.subs.push(watcher)}
}

依赖的update方法

上面我们在watcher的update方法中更新了值并且执行了数据更新后的回调,为了让丰富回调中的操作,我们可以将回调的this指向我们的最外层数据对象,这样在回调中就可以通过this任意获取数据对象中的其他属性,并且将更新之前的旧值和新值一起传入到update里面

update() {const oldValue = this.value // 获取旧值this.value = parsePath(this.data, this.expression) // 获取新值this.cb.call(this.data, this.value, oldValue)
}

需要注意的一个地方

下面是watcher中获取所依赖属性值的方法,这里需要说明一下,对于存在对象嵌套的情况,每一层属性的依赖数组中都会添加这个watcher,想不明白的话可以看一下下面的注解。

// 根据表达式去数据对象里面获取值
function _get(obj, exp) {const segments = exp.split('.')// 这里使用reduce是为了获取嵌套对象内部属性的值,不熟悉的话去补一补reduce/*比如data.a.b.c,那么每次遍历的值为data[a],data[a][b],最终结果是data[a][b][c]遍历到data[a]、data[a][b]时,肯定会去访问这两个属性的值,于是会进入到这两个属性的getter里面所以这个watcher不仅仅会被添加到最内层属性的getter中,中间每一层属性的getter中都会有这个watcher即如果data[a]的值发生了变化,也会通知这个watcher去更新视图*/ segments.reduce((pre, key) => {return pre[key]}, obj)
}

双剑合璧

怎样将上面的编译响应式整合到一起形成一个完整的具有响应式的miniVue类呢。其实很简单,从我们最上面那张图就可以看出来。总结一下就两点,在我们通过各种指令操作node节点的时候,同时初始化watcher,另一点即为初始化watcher时指定的回调内部需要执行updater里面对应的方法来更新视图

两点分别对应下图的这两根线

这样是不是就清晰多了。至此”双剑合璧“完成,下面贴一下合璧后的代码(只放需要合成的部分,这样更清晰一点)

// node--操作的node节点  exp--指令的值(或者是mustache语法内部插入的内容)  vm--vm实例  event--事件名称
const handleNode = {// v-htmlhtml(node, exp, vm) {const value = this._get(vm, exp)// 新建watcher实例,并绑定更新回调new Watcher(vm, exp, (newVal, oldVal) => {// 这里是所依赖数据更新以后更新视图this.updater.htmlUpdater(node, newVal);})// 这里是编译的时候更新视图updater.htmlUpdater(node, value)},// v-modelmodel(node, exp, vm) {const value = this._get(vm, exp)// 新建watcher实例,并绑定更新回调new Watcher(vm, exp, (newVal, oldVal) => {this.updater.modelUpdater(node, newVal);});updater.modelUpdater(node, value)},// v-onon(node, exp, vm, event) {// watcher只针对属性 v-on这里不会生成watcher(方法名也没什么好监听的,一般也不会操作方法名让方法名发生变化)const listener = vm.$options.methods && vm.$options.methods[exp] node.addEventListener(event, listener.bind(this), false) },// v-texttext(node, exp, vm) {let valueif(exp.indexOf("{{") !== -1) {// mustache语法操作nodevalue = exp.replace(/{{(.+?)}}/g, this._get(vm, exp)) }else {// v-text操作nodevalue = this._get(vm, exp)}// 新建watcher实例,并绑定更新回调new Watcher(vm, exp, (newVal, oldVal) => {this.updater.textUpdater(node, newVal);});updater.textUpdater(node, value);},
}
_get(vm, exp) {const segments = exp.split('.')segments.reduce((pre, key) => {return pre[key]}, vm.$data)
}
​
const updater = {textUpdater(node, value) {node.textContent = value;},htmlUpdater(node, value) {node.innerHTML = value;},modelUpdater(node, value){node.value = value;}
}
​

最后的最后,修改一下我们最开始定义miniVue类的构造函数

class miniVue {constructor(options) {this.$el = options.elthis.$data = options.datathis.$options = options}if(this.$el) {// 添加数据劫持this.observe()// 编译new Compile(this.$el, this);}
}

大功告成。

总结

如果你是第一次阅读本文,看到最后应该还是会感觉到些许混乱。下面允许我为大家概括一下整体的流程。建议结合我们最上方的中心图。

1.初始化minivue实例 执行其构造函数,首先对实例的数据对象data中全部属性添加数据劫持功能(getterand setter

2.开始编译实例绑定的模板。

3.首先编译做准备,创建compile类,拿到模板的整个DOM对象,遍历其子节点,获取到每个子节点上的信息,这些信息中凡是引用过vm实例data中的属性的,一律都新增一个watcher实例

4.初始化watcher实例的时候,会访问这个属性,然后进入这个属性的getter中,在getter中,将这个watcher添加到这个属性的Dep类中

5.最后更新node,至此初始化编译完成

6.当data中某一个属性的值发生变化,会进入这个属性的setter中,setter会通知该属性的Dep

7.Dep类会通知存储的所有相关watcher进行更新,于是这些watcher分别执行自己update中的回调。回调即会更新node

总结至此,不禁感叹vue官方实现响应式的巧妙之处。码文不易,希望各位看官点个赞。如有问题,请在评论区指出,感激不尽

参考

# 彻底搞懂Vue的MVVM响应式原理

# 前端的Vue响应式原理学习总结

一篇文章带你吃透VUE响应式原理相关推荐

  1. Vue响应式原理探究之“发布-订阅”模式

    前言 在面试题中经常会出现与"发布订阅"模式相关的题目,比如考察我们对Vue响应式的理解,也会有题目直接考验我们对"发布订阅"模式或者观察者模式的理解,甚至还会 ...

  2. vue 数组删除 dome没更新_详解Vue响应式原理

    摘要: 搞懂Vue响应式原理! 作者:浪里行舟 原文:深入浅出Vue响应式原理 Fundebug经授权转载,版权归原作者所有. 前言 Vue 最独特的特性之一,是其非侵入性的响应式系统.数据模型仅仅是 ...

  3. 深入理解Vue响应式原理

    前言 Vue响应式原理是Vue最独特的特性之一,当数据模型进行修改时,视图就会进行更新,这使得状态管理简单直接,但是其底层的细节还是需要我们深入学习理解,这样遇到一些问题我们才能快速进行定位,并解决: ...

  4. 详解Vue响应式原理

    摘要: 搞懂Vue响应式原理! 作者:浪里行舟 原文:深入浅出Vue响应式原理 Fundebug经授权转载,版权归原作者所有. 前言 Vue 最独特的特性之一,是其非侵入性的响应式系统.数据模型仅仅是 ...

  5. Vue 响应式原理(双向数据绑定) 怎样实现 响应式原理?

    Vue 响应式原理(双向数据绑定) 怎样实现 响应式原理? 我们在Vue里面,定义在Data里的属性,叫做响应式属性. 每一个vue组件被创建的时候,同时还有一个对象被创建出来了,这个对象我们是看不到 ...

  6. 手把手教你剖析vue响应式原理,监听数据不再迷茫

    Object.defineProperty实现vue响应式原理 一.组件化基础 1."很久以前"的组件化 (1)asp jsp php 时代 (2)nodejs 2.数据驱动视图( ...

  7. Vue响应式原理的简单模型

    1.前言 最近在梳理vue响应式的原理,有一些心得,值得写一篇博客出来看看. 其实之前也尝试过了解vue响应式的原理,毕竟现在面试看你用的是vue的话,基本上都会问你几句vue响应式的原理.以往学习这 ...

  8. Vue响应式原理(看这一篇就够了)

    你肯定听说过Object.denfineProperty或是Proxy\reflect,这的确是在VUE响应式原理中起重要作用的一部分代码,但这远远不能代表整个流程的精妙.上图: 不懂没关系,请往下看 ...

  9. Vue源码--解读vue响应式原理

    原文链接:https://geniuspeng.github.io/2018/01/05/vue-reactivity/ Vue的官方说明里有深入响应式原理这一节.在此官方也提到过: 当你把一个普通的 ...

最新文章

  1. 若使用numba.cuda.jit加速pytorch训练代码会怎样
  2. 前端控制器html,DispatcherServlet(前端控制器)访问顺序和url匹配规则
  3. excel 复制数据 sql server 粘贴_几百个Excel表格的内容要复制粘贴,如何一键自动化搞定?| 州的先生...
  4. Yet Another Broken Keyboard
  5. C/C++ OpenCV图像的阈值操作
  6. np.linalg.norm()用法
  7. pandownload网页服务器维护,PanDownload
  8. 用JS获取地址栏参数的方法(超级简单)
  9. 已经不需要司机的Waymo无人车,何时才能摆脱后座待命的工程师?
  10. sklearn preprocessing 数据预处理(OneHotEncoder)
  11. 执行cmd并获得结果_用JAVA执行CMD命令备份PG数据库,解决需要口令的问题
  12. 管理新语:年龄经验并不能让一个医生律师厉害,而是他本来就厉害
  13. nagios监控3306端口
  14. 微信小程序最基本代码入门
  15. 数据库原理(上)--收集得空看
  16. 数据库原理与应用实验九 视图的使用
  17. 通过ESP8266手机或电脑浏览器网页能控制远程任意组任意路继电器开关并收发单片机指令 测试OK
  18. 香港等海外公司如何开通认证微信公众号?
  19. logd 删除log
  20. 让Windows无缝地跑在Mac上,VMware发布VMware Fusion 7

热门文章

  1. Ar详细制作发布流程
  2. “二舅”火了,自媒体短视频“爆火”的基本要素,你知道吗?
  3. 改进杂草算法求解WSN节点分布优化问题
  4. 数据结构 -- 栈的基本操作(入栈、出栈、取栈顶元素)
  5. 什么是客户端容器化?
  6. mysql定时任务简单例子
  7. DailyFi - 9.15|PrimeDAO 完成 200万美元种子轮融资,Paradigm 研究员发布新 NFT 碎片化产品...
  8. 理解浏览器的多线程,JavaScript的单线程
  9. Ubuntu下载binutils遇到的问题
  10. 使用Log日志 计算带宽流量峰值