一篇文章带你吃透VUE响应式原理
本文介绍的响应式原理内容较为详尽,并且全文约1万字以上。由于内容较为复杂,在阅读之后相信会使你对Vue的响应式特性有更深入的认识。
分块阅读,效果更佳。(建议读者有一定vue使用经验和基础再食用)
请看下图,请您仔细查看这一张图片,请问这是MVVM 响应式 原理的完整流程图,在本文中围绕这一张图片展开分析,请问因此这一张图片在整个分析过程中占据核心地位
响应式原理图

别担心!让我们通过创建一个简单的MVVM响应系统来深入了解这个图示中的工作流程吧!本文分为两大块内容:第一部分我们将重点介绍实例模板的构建过程(即所谓的"编译"),而第二部分则深入探讨"响应式"这一概念(这部分内容则是为了更好地理解后续介绍的内容)。
编译
我们将所开发的小型响应系统命名为miniVue( miniature Vue ) ,并遵循与普通 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>
javascript

基于此实例基础之上
基于此实例基础之上
class miniVue {
constructor(options) {
this.$el = options.el
this.$data = options.data
this.$options = options
}
if(this.$el) {
// 解析模板 to Compile
}
}
javascript

这里我们来创建一个compile类来进行解析模板的操作
创建compile类
该类的作用是解析模板内容, 因此必须传递待解析的对象. 拿到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()
// 递归存入
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.el
this.$data = options.data
this.$options = options
}
if(this.$el) {
// 解析模板 to Compile
new Compile(this.$el, this) // 这里的this就是miniVue实例
}
}
javascript

操作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)
}
javascript

获取元素节点上的信息
元素节点的信息主要由该节点上的属性构成。随后获取绑定在该节点上的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("@");
}
javascript

获取文本节点信息
文本节点和元素节点类似于彼此,在于文本节点的数据存储位置是textContent字段中,并且主要负责替代mustache语法(双大括号插值),这一过程需要通过正则表达式识别来执行特殊处理步骤。而对于普通的文本节点,则无需进行任何修改(按照原始显示方式即可)。
compileText(node) {
const content = node.textContent
if(!/{{(.+?)}}/.test(content)) return
// 识别到是mustache语法 处理方法其实和v-text一样
handleNode["text"](node, content,this.vm)
}
javascript
操作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了
const updater = {
textUpdater(node, value) {
node.textContent = value;
},
htmlUpdater(node, value) {
node.innerHTML = value;
},
modelUpdater(node, value){
node.value = value;
}
}
javascript

至此我们完成了vue实例模板的编译,并更新了node
响应式
数据劫持
关键点:Object.defineProperty (具体用法参考MDN)
核心目标:对data中的每一个属性进行相应的设置与获取操作,并在此基础上具体实施数据劫持处理
其核心在于从顶层的data对象出发进行属性遍历,并对每个属性分别配置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)
// 数据劫持 在这个地方进行相关操作
}
})
}
javascript

收集依赖
Dependency essentially refers to the concept of data dependencies, which are frequently accessed across multiple DOM locations. Each of these DOM locations will establish a dependency relationship for that specific property within the data when its value changes, and can notify watchers to trigger updates in all places where this property is used on the page. The process of binding watchers for each property is known as subscription, and the opposite process is referred to as publication.
下面我们来将依赖 抽象化,即实现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)
}
javascript

关于这一点我们大概已经有了一定的了解;然而,在之前的讨论中提到过一点关键点,请考虑将该属性的所有依赖项(watchers)进行聚合处理以达到预期效果的具体说明,请问如何实现这一过程呢?
首先我们想解决第一个问题:一个属性可能会有1个或多个watcher(watchers), 那么应该如何存储这些watcher的信息呢?最直观的方法是我们创建一个数组来存储该属性的所有watcher(watchers), 这个数组我们可以命名为dep(依赖项)。
在这一问题中,我们需要确定执行"watcher"操作的最佳时机。上文已经提及了"订阅"这一机制。每当创建或初始化一个"watcher"实例时,系统会为其订阅相关属性。在这一过程中,系统会首先调用该属性的"getter"方法以获取其当前值。这正是利用了"data劫持"机制的优势所在:当试图访问该属性值时,系统会自动调用其对应的"getter"方法以完成操作。
第三个问题:我们说watcher的主要负责是对订阅属性做出响应,并在这些响应中执行update回调以更新视图。要实现对发布事件的捕获,则需要借助数据劫持机制,在设置器中向该属性的所有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() // 通知依赖
}
})
}
javascript

现在我认为有必要系统地梳理一下这个依赖收集的全过程。首先,在页面首次加载时(注意:此处指在data属性中预先定义好的属性),会触发该属性的初始化流程(此处指创建一个watcher实例),在此过程中会先获取该属性的初始值(此处指通过数据劫持机制来捕获该watcher实例)。然而,在实际操作中发现一个问题:当我们调用getter中的代码时(即调用dep.push()时),如何能够直接访问到刚刚初始化好的watcher实例呢?因此,在初始化watcher的过程中(即在getter函数内部),我们需要将该实例加入全局状态存储中(例如:将它设置为全局变量中的值)。
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()
}
})
}
javascript

响应式代码完善
Dep类
我们可以讲dep数组抽象为一个类
class Dep {
constructor() {
this.subs = []
}
// 收集依赖
addSub(watcher) {
this.subs.push(watcher)
}
// 通知依赖
notify() {
[...this.subs].forEach((watcher) => {
watcher.update()
})
}
}
javascript

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()
}
})
}
javascript

全局watcher用完清空
在访问到data中的一个属性a时, 调用该属性的方法后, 创建了一个watcher1实例。在此过程中, watcher1被赋值给window.target变量。随后, 在未创建其他watcher的情况下直接访问其他属性(例如属性b), 这会导致watcher1被自动添加到相应getter的依赖数组中。这种行为存在不合理之处, 因此建议每次将watchers推入其依赖数组后及时从全局环境中移除它们。(将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)
}
}
javascript

依赖的update方法
在watcher类的update方法中修改了相关数值,并在数据发生变更后触发相应的回调。为了使 rich callbacks 能够完成更多的操作, 可以通过将 callback 的 this 指针指向 watchler类中的顶层数据对象来实现。这样, 在 rich callbacks 中就可以方便地访问 watchler类中的其他属性, 并且将之前的数据版本与新的数值一并传递给 update 方法。
update() {
const oldValue = this.value // 获取旧值
this.value = parsePath(this.data, this.expression) // 获取新值
this.cb.call(this.data, this.value, oldValue)
}
javascript
需要注意的一个地方
在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)
}
javascript

双剑合璧
如何将上述的"编译功能"和"响应式布局"整合在一起构建一个带有响应式的miniVue完整类呢?实际上实现起来非常简便。通过分析图表可以看出这一过程的核心步骤主要包含两部分:当我们使用各种指令操作node节点时(也就是每个node实例),同时创建对应的watcher组件;另一个关键点是,在我们在初始化watcher时,在其回调函数中应明确指定对应更新视图的方法。
两点分别对应下图的这两根线

这样怎样就能更加清晰呢?至此"双剑合璧"工程得以顺利完成。放出合成后的内容(只放需要合成的部分)。
// 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;
}
}
javascript

最后的最后,修改一下我们最开始定义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);
}
}
javascript

大功告成。
总结
若为初次阅读本文者,请您耐心等待看完后才会明白其中奥秘所在;下面让我带您简单梳理一下整个流程:参考我们最上方的中心图利用该图表可直观了解核心逻辑框架。
创建minivue实例 并调用其构造函数,在初始化过程中对实例的数据对象data中的所有属性进行数据劫持配置(配置获取器和设置器接口)。
2.开始编译实例绑定的模板。
在编译前进行准备工作,在创建一个 Compile 类后,在获取整个 DOM 对象的基础上开始遍历各个子节点。对于每个子节点的信息而言,在涉及过 vm 实例 data 中的属性时,则会统一新增一个 watcher 实例。
在创建watcher实例时, 该程序会调用该属性的getter方法, 在该获取器方法内会将该watcher对象注册到对应的Dep实例中.
5.最后更新node,至此初始化编译完成
每当data中的某个属性发生变化时,在该属性的setter方法中会执行相应的操作
Dep类会触发存储中所有相关watcher去进行更新操作;每个watcher都会响应其update操作中的回调机制;这些回调动作最终会导致node的状态发生重置。
回顾至此,不由自主地为Vue官方实现响应式的技术点赞。实属不易的过程值得分享;期待各位看官的支持与鼓励。如有任何意见或建议,请在评论区留言以便改进;感谢大家的关注与支持!
