Vue.js 一个核心思想是数据驱动。所谓数据驱动是指视图是由数据驱动生成的,对视图的修改,不会直接操作 DOM,而是通过修改数据。vue.js里面只需要改变数据,Vue.js通过Directives指令去对DOM做封装,当数据发生变化,会通知指令去修改对应的DOM,数据驱动DOM的变化,DOM是数据的一种自然的映射。vue.js还会对View操作做一些监听(DOM Listener),当我们修改视图的时候,vue.js监听到这些变化,从而改变数据。这样就形成了数据的双向绑定。
Vue 2官方文档中这样解释其响应式原理:
当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装 vue-devtools 来获取对检查数据更加友好的用户界面。
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
这里需要说明的是,在Vue 2中Vue基于Object.defineProperty
来实现Vue的响应时更新,在Vue 3中,Vue是通过Proxy
代理一个对象来实现Vue的响应式更新的。
由于 JavaScript 的限制,Vue 不能检测数组和对象的变化。尽管如此我们还是有一些办法来回避这些限制并保证它们的响应性。
var vm = new Vue({
data:{
a:1
}
})
// `vm.a` 是响应式的
vm.b = 2
// `vm.b` 是非响应式的
我们在初始化Vue实例的时候,会遍历Vue的data,并递归式的为每一个data数据执行Object.defineProperty
使其拥有响应性,但是,如果我们为vm添加一个属性(如上文中的vm.b),这是就需要主动调用Object.defineProperty
来使该属性具有响应性。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>手写实现Vue的响应式更新</title>
</head>
<body>
<div id="app">
<input id="input" type="text">
<span id="content"></span>
</div>
</body>
<script type="text/javascript">
let data = {
}
Object.defineProperty(data, 'text', {
set: function (v) {
this.value = v //当我们为data.text赋值时,实际上更改的是this.value,
//这里的this.value是我们添加的一个变量,用于保存data.text的内容,
//也可以是任意的其他变量,比如this.a = v,效果也是一样的。
console.log('set', this.value)
document.getElementById('content').innerText = this.value
},
get: function () {
console.log('get', this.value)
return this.value //当我们获取data.text时,其实也是获取到this.value的值
}
})
//所以,如果我们通过相同的方法为data再增加一个name属性,执行data.name=3,修改的也是this.value的值
//所以这时候我们获取data.text的值时,得到的也是3。如果希望data的属性有独立的值,我们可以使用闭包,如下:
// !function (obj, property, value) {
// Object.defineProperty(obj, property, {
// set: function (v) {
// value = v //当我们为data.text赋值时,实际上更改的是this.value,
// //这里的this.value是我们添加的一个变量,用于保存data.text的内容,
// //也可以是任意的其他变量,比如this.a = v,效果也是一样的。
// console.log('set', value)
// document.getElementById('content').innerText = value
// },
// get: function () {
// console.log('get', value)
// return value //当我们获取data.text时,其实也是获取到this.value的值
// }
// })
// } (data, 'text')
document.getElementById('input').addEventListener('input', function (e) {
data.text = e.target.value
})
</script>
</html>
控制台输出:
上面操作直接使用了DOM操作改变了文本节点的值,而且是在知道id的情况下,使用document.getELementById()获取到响应的文本节点,然后修改文本节点的值。
封装成一个框架,肯定不能使用这种做法。所以需要一个可以解析DOM并且能修改DOM中相应变量的模块。
在实际使用中,多次操作DOM节点,为了提高性能和效率,将进行下面操作:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>手写实现Vue的响应式更新</title>
</head>
<body>
<div id="app">
<input id="input" type="text" v-model="input">
{{input}}
</div>
</body>
<script type="text/javascript">
function Compiler (node, vm) {
let fragment = null
if (node) {
fragment = this.nodeToFragment(node, vm) //将node节点转化成文档片段,降低直接操作DOM的频率
return fragment
}
}
Compiler.prototype.nodeToFragment = function (node, vm) {
let fragment = document.createDocumentFragment()
//假设我们的child节点不存在嵌套的情况
while (node.firstChild) {
let child = node.firstChild
this.compileElement(child, vm) //根据vm的数据,得到节点的实际结构
fragment.appendChild(child) //DocumentFragment.appendChild会将child从源文档中移出,所以不会造成无线循环
}
return fragment
}
Compiler.prototype.compileElement = function (node, vm) {
//在编译节点时,我们需要根据节点的类型来分情况处理
//{{text}}: nodeType为Text(3),我们需要将文本替换为text的属性值
//: nodeType为Element(1),我们需要对其添加事件监听,当其value改变时,同时更改vm中的数据
let regExp = /{{(.*)}}/ //用于查找出形如{{param}}的语句
//当node是元素类节点时
if (node.nodeType === 1) {
let attr = node.attributes
//v-model实际上就是节点的一个属性,我们需要拿到v-model的属性值,该值就对应了vm中一个属性
//然后当节点的value改变时,同时改变vm中对应属性的属性值
for (let i = 0; i < attr.length; i++) {
if (attr[i].nodeName === 'v-model') {
//假设该节点存在v-model=text
let v_model_attr_in_vm = attr[i].nodeValue //此时的v_model_attr_in_vm就等于text
node.addEventListener('input', function (e) {
vm.data[v_model_attr_in_vm] = e.target.value
})
// 初始化node节点,将vm中的数据赋值给node
node.value = vm.data[v_model_attr_in_vm]
}
}
}
//当node是Text节点时
else if (node.nodeType === 3) {
//假设该文本节点为{{text}},而不是多个插值表达式的组合以及插值表达式与一半文本的混合
regExp.test(node.nodeValue)
let v_model_attr_in_vm = RegExp.$1 //获取到{{text}}中的text,并将text赋值给v_model_attr_in_vm
node.nodeValue = vm.data[v_model_attr_in_vm]
}
}
function Vue (options) {
this.data = {}
for (let property of Object.keys(options.data)) {
//将options.data中的属性响应式添加到vm.data中
//注意,此时我们假设options.data不存在字典嵌套的情况
!function (vm, property, value){
Object.defineProperty(vm.data, property, {
get: function () {
console.log('get,', value)
return value
},
set: function (val) {
console.log('set,', val)
value = val
}
})
}(this, property,options.data[property])
}
let el = new Compiler(document.getElementById(options.el), this) //根据this中的数据来编译节点,比如, {{text}} =》 text的属性值
document.getElementById(options.el).appendChild(el)
}
let vue = new Vue({
el: 'app',
data: {
input: '输入'
}
})
</script>
</html>
网页:
控制台:
可以发现,当我们改变输入框的值时,能够改变
vm.data
中对应属性的属性值,但是当vm.data
中属性值发生改变时,并不能使视图随之发生变化。但是不用着急,我们现在已经能够使视图层View的变化传递到ViewModel上了。
响应式更新,也就是当vm.data
发生改变时,页面也需要随之改变。
Vue通过观察者模式来实现实现响应式更新,记录了每一个节点所依赖的数据,当数据发生改变时,发布者(Dep)会使订阅者(Watcher)触发相应的更新行为。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>手写实现Vue的响应式更新</title>
</head>
<body>
<div id="app">
<input id="input" type="text" v-model="input">
{{input}}
</div>
</body>
<script type="text/javascript">
function Compiler (node, vm) {
let fragment = null
if (node) {
fragment = this.nodeToFragment(node, vm) //将node节点转化成文档片段,降低直接操作DOM的频率
return fragment
}
}
Compiler.prototype.nodeToFragment = function (node, vm) {
let fragment = document.createDocumentFragment()
//假设我们的child节点不存在嵌套的情况
while (node.firstChild) {
let child = node.firstChild
this.compileElement(child, vm) //根据vm的数据,得到节点的实际结构
fragment.appendChild(child) //DocumentFragment.appendChild会将child从源文档中移出,所以不会造成无线循环
}
return fragment
}
Compiler.prototype.compileElement = function (node, vm) {
//在编译节点时,我们需要根据节点的类型来分情况处理
//{{text}}: nodeType为Text(3),我们需要将文本替换为text的属性值
//: nodeType为Element(1),我们需要对其添加事件监听,当其value改变时,同时更改vm中的数据
let regExp = /{{(.*)}}/ //用于查找出形如{{param}}的语句
//当node是元素类节点时
if (node.nodeType === 1) {
let attr = node.attributes
//v-model实际上就是节点的一个属性,我们需要拿到v-model的属性值,该值就对应了vm中一个属性
//然后当节点的value改变时,同时改变vm中对应属性的属性值
for (let i = 0; i < attr.length; i++) {
if (attr[i].nodeName === 'v-model') {
//假设该节点存在v-model=text
let v_model_attr_in_vm = attr[i].nodeValue //此时的v_model_attr_in_vm就等于text
node.addEventListener('input', function (e) {
vm.data[v_model_attr_in_vm] = e.target.value
})
// 初始化node节点,将vm中的数据赋值给node
// node.value = vm.data[v_model_attr_in_vm]
new Watcher(node, vm, v_model_attr_in_vm)
}
}
}
//当node是Text节点时
else if (node.nodeType === 3) {
//假设该文本节点为{{text}},而不是多个插值表达式的组合以及插值表达式与一半文本的混合
regExp.test(node.nodeValue)
let v_model_attr_in_vm = RegExp.$1 //获取到{{text}}中的text,并将text赋值给v_model_attr_in_vm
// node.nodeValue = vm.data[v_model_attr_in_vm]
new Watcher(node, vm, v_model_attr_in_vm)
}
}
function Watcher (node, vm, property) {
this.node = node
this.vm = vm
this.property = property
Dep.target = this
// 在this.update()之前将this赋值给Dep.target,在执行this.update时,需要获取this.vm[this.property]
// 这就会触发节点依赖数据的get方法,这就能将订阅者this添加到该数据的dep订阅表中了
this.update()
Dep.target = null
}
Watcher.prototype.update = function () {
this.value = this.vm.data[this.property]
if (this.node.nodeType === 1) {
this.node.value = this.value
} else if (this.node.nodeType === 3) {
this.node.nodeValue = this.value
}
}
Watcher.prototype.get = function () {
return this.value
}
function Dep () {
// 每一个数据都拥有自己的Dep,subs代表依赖该数据的节点
this.subs = []
}
Dep.prototype.addSub = function (sub) {
this.subs.push(sub)
}
Dep.prototype.notify = function () {
this.subs.forEach(sub => {
sub.update()
})
}
function observe (vm, data) {
Object.keys(data).forEach(property => {
defineReactive(vm, property, data[property])
})
}
function defineReactive (vm, property, value) {
let dep = new Dep()
Object.defineProperty(vm.data, property, {
set: function (val) {
// 当一个数据更新时,我们需要通知该数据的每一个订阅者
if (val === value) return
value = val
dep.notify()
},
get: function () {
// 当一个节点依赖于某数据时,在获取数据时会触发该数据的get方法,所以我们在get处向dep添加订阅者
if (Dep.target) {
dep.addSub(Dep.target)
}
return value
}
})
}
function Vue (options) {
this.data = {}
observe(this, options.data)
let el = new Compiler(document.getElementById(options.el), this) //根据this中的数据来编译节点,比如, {{text}} =》 text的属性值
document.getElementById(options.el).appendChild(el)
}
let vue = new Vue({
el: 'app',
data: {
input: '输入'
}
})
</script>
</html>
和3.2相比,3.3增加了两个类,Dep(发布者) 和 Watcher(订阅者),增加了两个方法observe
和defineReactive
。
observer
方法中调用了defineReactive
方法,这两个方法相互配合,使vm.data
中的每一条数据添加了get和set方法,并且给每一个数据都赋予了一个dep,dep将用于保存依赖该数据的Watcher。new Compiler(document.getElementById(options.el), this)
,会解析出节点依赖的数据,在3.1中,我们使用node.value = vm.data[v_model_attr_in_vm]
虽然可以获取到实际的节点内容,但是不能实现响应式更新,在3.2中,我们使用new Watcher(node, vm, v_model_attr_in_vm)
,这个Watcher保存了该节点和数据依赖信息,即node
依赖于vm.data[v_model_attr_in_vm]
。new Watcher()
会执行this.update
,以将节点内容更新为实际内容(例如,{{text}} => text的属性值),同时,由于需要获取vm.data[v_model_attr_in_vm],这会触发该数据的get
函数,在该数据的get
函数中,我们将此watcher添加到该数据的dep
中。value != val
时,都会触发dep.notify()
,这会使依赖于该数据的watcher调用this.update()
,这也就会使该节点的View更新。这仅仅是一个简单的Vue实现,要实现一个完整的框架,还有很多功能需要实现,此篇文章仅为浅析Vue的响应式原理,可以按下图总结: