一篇文章带你彻底搞懂VUE响应式原理
作者:Dddusty
首先上图,下面这张图,即为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.el this.$data = options.data this.$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 // 新建文档碎片存储DOM const 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() f.appendChild(el.clone(true)) } } // 上面的miniVue实例相应的改为 class miniVue { constructor(options) { this.$el = options.el this.$data = options.data this.$options = options } if(this.$el) { // 解析模板 to Compile new 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 // 新建文档碎片存储DOM const fragment = this.toFragment(this.el) // 操作文档碎片 to handle fragment this.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.textContent if(!/{{(.+?)}}/.test(content)) return // 识别到是mustache语法 处理方法其实和v-text一样 handleNode["text"](node, content,this.vm) }
操作fragment
前面铺垫了这么多,终于到了操作文档碎片这一步了。按照上面的思路,handleNode
应该是一个对象,里面有多个属性对应不同的指令的处理方法。
// node--操作的node节点 exp--指令的值(或者是mustache语法内部插入的内容) vm--vm实例 event--事件名称 const handleNode = { // v-html html(node, exp, vm) { // 去vm实例中找到这个表达式所对应的值 const value = this._get(vm, exp) // 更新node updater.htmlUpdater(node, value) }, // v-model model(node, exp, vm) { // 同html const value = this._get(vm, exp) updater.modelUpdater(node, value) }, // v-on on(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-text text(node, exp, vm) { // v-text是最复杂的,需要考虑两种情况,一种是通过v-text指令操作node,另一种则是通过mustache语法操作node,需分类 let value if(exp.indexOf("{{") !== -1) { // mustache语法操作node // 捕捉到所有的mustache语法,将其整个替换为vm实例中属性对应的值 // 拿我们最初初始化实例的一个数据举例:{{obj.auth}} -- 'xxx' value = exp.replace(/{{(.+?)}}/g, this._get(vm, exp)) }else { // v-text操作node value = this._get(vm, exp) } // 更新node updater.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 (终于到了更新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中每个属性添加getter
和setter
,然后在getter
和setter
中进行数据劫持
思路很简单,其实就是从最外层的data层开始遍历属性,通过Object.defineProperty
给这些属性都添加上getter
和setter
,需要注意对象的嵌套,所以需要使用递归来为嵌套的属性添加getter
和setter
function observe(data) { if(typeof data !== 'object') return Object.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) return value = newVal // 为新数据添加getter和setter observe(newVal) // 数据劫持 在这个地方进行相关操作 } }) }
收集依赖
依赖其实说白了,就是数据的依赖,data中的某个属性,可能在DOM
中好几个地方进行了使用,那DOM
中使用到该属性的地方就都会产生一个对于该属性的依赖,也就是watcher
。当该属性的值发生了变化,那么就可以通知watcher
来使得页面中使用到这个属性的地方进行视图更新。为每个属性绑定watcher
的过程其实就是订阅,反过来,当属性的值发生了变化,通知所有watcher
的过程就是发布。
下面我们来将依赖抽象化,即实现watcher
class Watcher { // data--最外层数据对象 exp--表达式 cb--数据更新后需要执行的回调 // 通过data和exp可以获取watcher所依赖属性的具体值 constructor(data, exp, cb) { this.data = data this.exp = exp this.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) return value = newVal // 为新数据添加getter和setter observe(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.target return value } set(newVal) { if(newVal == value) return value = newVal observe(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) return value = newVal observe(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 // 清空暴露在全局中的watcher return 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('.') /* 比如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-html html(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-model model(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-on on(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-text text(node, exp, vm) { let value if(exp.indexOf("{{") !== -1) { // mustache语法操作node value = exp.replace(/{{(.+?)}}/g, this._get(vm, exp)) }else { // v-text操作node value = 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.el this.$data = options.data this.$options = options } if(this.$el) { // 添加数据劫持 this.observe() // 编译 new Compile(this.$el, this); } }
大功告成。
总结
如果你是第一次阅读本文,看到最后应该还是会感觉到些许混乱。下面允许我为大家概括一下整体的流程。建议结合我们最上方的中心图。
- 1.初始化
minivue
实例 执行其构造函数,首先对实例的数据对象data中全部属性添加数据劫持功能(getter
andsetter
) - 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响应式原理内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!