vue学习记录十五:数据响应式原理
一、数据响应式原理
在Javascript里实现数据响应式一般有俩种方案,分别对应着vue2.x 和 vue3.x使用的方式,他们分别是:
-
对象属性拦截 (vue2.x)
Object.defineProperty
-
对象整体代理 (vue3.x)
Proxy
其中对象属性拦截,不管使用其中的哪种方式,道理都是相通的。
二、Object.defineProperty代码解释
1.简单原理
// 1.字面量定义
let data = {
name:'小飞'
}
Object.defineProperty(data,'name',{
get(){
//当我们访问data的属性时,自动调用的方法.
//属性可以不存在,会自动创建
// 并且get函数的返回值就是你返回的值
return '小飞'
//不能再写data['age'],这样式写其实又在访问了。会又进入get方法,变成了无限死循环了。
//data['age']
},
set(newValue){
//当我们设置或修改name属性的时候,自动调用的函数
//并且属性最新的值会被当作实参传入进来
//这里数据发生变化后,可以发送ajax,操作一块dom区域都可以。这样就实现了数据响应。
console.log('设置了name属性=' +newValue )
},
})
2.存在的问题
我们的get方法中返回的值始终是小飞
,是固定的,set中拿到新值之后,我们如何让get中可以得到newVal使我们需要解决的问题。
我们可以 通过一个中间变量 middle
来中转get函数和set函数之间的联动
// 1.字面量定义
let data = {
name:'小飞',
}
let middle = 20
Object.defineProperty(data,'age',{
get(){
//返回20
return middle
},
set(newValue){
//
middle = newValue
console.log('设置了age属性=' +newValue )
},
})
这样每次访问,其实都是返回的那个中间变量middle 的值。设置也是设置的那个middle 的值。
3.更优化的方法
vue中data的数据有很多,是怎么实现的,如何通过劫持的方法把每一个属性都变成setter和getter的形式,通过遍历。
let data = {
name: '小飞',
age: 18,
height:180
}
// 遍历每一个属性
Object.keys(data).forEach((key)=>{
// key 属性名
// data[key] 属性值
// data 原对象
defineReactive(data,key,data[key])
})
// 响应式转化方法
function defineReactive(data,key,value){
Object.defineProperty(data,key,{
get(){
return value
},
set(newVal){
value = newVal
}
})
}
这个地方实际上使用了闭包的特性,在每一次的defineReactive
函数执行的时候,都会形成一块独立的函数作用域,传入的value
因为闭包的关系会常驻内存,这样一来,每个defineReactive
函数中的value
会作为各自set
和get
函数操作的局部变量。
三、数据响应总结
- 所谓的响应式其实就是拦截对象属性的访问和设置,插入一些我们自己想要做的事情。
- 在Javascript中能实现响应式拦截的方法有俩种,
Object.defineProperty
方法和Proxy对象代理
。 - 回归到vue2.x中的data配置项,只要放到了data里的数据,不管层级多深不管你最终会不会用到这个数据都会进行递归响应式处理,所以要求我们如非必要,尽量不要添加太多的冗余数据在data中。
- 需要了解vue3.x中,解决了2中对于数据响应式处理的无端性能消耗,使用的手段是Proxy劫持对象整体 + 惰性处理(用到了才进行响应式转换)。
四、数据的变化反应到视图
1.命令式操作视图
<div id="app">
<p></p>
</div>
<script>
let data = {
name: '小飞',
age: 18,
height:180
}
// 遍历每一个属性
Object.keys(data).forEach((key)=>{
// key 属性名
// data[key] 属性值
// data 原对象
defineReactive(data,key,data[key])
})
function defineReactive(data,key,value){
Object.defineProperty(data,key,{
get(){
return value
},
set(newVal){
value = newVal
// 数据发生变化,操作dom进行更新
document.querySelector('#app p').innerHTML = data.name
}
})
}
// 页面首次加载时候的渲染
document.querySelector('#app p').innerHTML = data.name
</script>
这样就能在每次修改数据的时候,响应到视图了。
2.声明式操作视图
我们将data中name属性的值作为文本渲染到标记了v-text的p标签内部,在vue中,我们把这种标记式的声明式渲染叫做指令
。
<div id="app">
<p v-text="name"></p>
</div>
<script>
let data = {
name: '小飞',
age: 18,
height: 180
}
// 遍历每一个属性
Object.keys(data).forEach((key) => {
// key 属性名
// data[key] 属性值
// data 原对象
defineReactive(data, key, data[key])
})
function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
get() {
return value
},
set(newVal) {
value = newVal
// 数据发生变化,操作dom进行更新
compile()
}
})
}
//
function compile() {
let app = document.getElementById('app')
// 1.拿到app下所有的子元素
const nodes = app.childNodes // [text, input, text]
//2.遍历所有的子元素
nodes.forEach(node => {
// nodeType为1为元素节点
if (node.nodeType === 1) {
const attrs = node.attributes
// 遍历所有的attrubites找到 v-model
// Array.from先将伪数组,转为真数组
Array.from(attrs).forEach(attr => {
const dirName = attr.nodeName
const dataProp = attr.nodeValue
//nodeName是属性名
//nodeValue是属性值
//假设 v-text="name"
//要是属性名等于v-text,
if (dirName === 'v-text') {
// 就把这个元素的文本替换为对象的值data[name]
node.innerText = data[dataProp]
}
})
}
})
}
// 首次渲染
compile()
</script>
找标记,把数据绑定到dom的过程,我们称之为binding
。
五、视图的变化反应到数据
将data中的name属性对应的值渲染到input上面,同时input值发生修改之后,可以反向修改name的值,在vue系统中,v-model指令就是干这个事情的。
<div id="app">
<input v-model="name" />
</div>
<script>
let data = {
name: "小飞",
age: 18,
height: 180
}
// 遍历每一个属性
Object.keys(data).forEach((key) => {
// key 属性名
// data[key] 属性值
// data 原对象
defineReactive(data, key, data[key])
})
function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
get() {
return value
},
set(newVal) {
// 数据发生变化,操作dom进行更新
//要是值相同,就不变
if (newVal === value) {
return
}
value = newVal
compile()
}
})
}
// 编译函数
function compile() {
let app = document.getElementById('app')
// 1.拿到app下所有的子元素
const nodes = app.childNodes // [text, input, text]
//2.遍历所有的子元素
nodes.forEach(node => {
// nodeType为1为元素节点
if (node.nodeType === 1) {
const attrs = node.attributes
// 遍历所有的attrubites找到 v-model
Array.from(attrs).forEach(attr => {
const dirName = attr.nodeName
const dataProp = attr.nodeValue
if (dirName === 'v-model') {
//这里是把数据的值绑定要视图上
node.value = data[dataProp]
// 视图变化反应到数据 无非是事件监听反向修改
//这里给元素绑定事件,input事件,将输入框的值绑定到对象的属性上。这样,在输入的时候,就会触发input事件将值绑定到对象属性上面了。
node.addEventListener('input', (e) => {
data[dataProp] = e.target.value
})
}
})
}
})
}
// 首次渲染
compile()
</script>
六、现存架构的问题
<div id="app">
<p v-text="name"></p>
<p v-text="age"></p>
<input v-model="age"></input>
<input v-model="name"></input>
</div>
<script>
let data = {
name: '小飞',
age: 18,
height: 180
}
// 遍历每一个属性
Object.keys(data).forEach((key) => {
// key 属性名
// data[key] 属性值
// data 原对象
defineReactive(data, key, data[key])
})
function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
get() {
return value
},
set(newVal) {
// 数据发生变化,操作dom进行更新
if (newVal === value) {
return
}
value = newVal
compile()
}
})
}
// 编译函数
function compile() {
let app = document.getElementById('app')
// 1.拿到app下所有的子元素
const nodes = app.childNodes // [text, input, text]
//2.遍历所有的子元素
nodes.forEach(node => {
// nodeType为1为元素节点
if (node.nodeType === 1) {
const attrs = node.attributes
Array.from(attrs).forEach(attr => {
const dirName = attr.nodeName
const dataProp = attr.nodeValue
console.log( dirName,dataProp)
if (dirName === 'v-text') {
console.log(`更新了${dirName}指令,需要更新的属性为${dataProp}`)
node.innerText = data[dataProp]
}
if (dirName === 'v-model') {
//这里是把数据的值绑定要视图上
node.value = data[dataProp]
node.addEventListener('input', (e) => {
data[dataProp] = e.target.value
})
}
})
}
})
}
// 首次渲染
compile()
</script>
通过如上代码可知,每次访问对象的一个属性的时候,都会触发compile函数,打印其他的值,即使他们没发生变化,但其实我们只修改了一个属性。显得不够精准,需要借助设计模式来优化我们的架构,就是发布订阅模式。只变化我们修改的那个属性。
七、发布订阅模式思路
数据更新之后实际上需要执行的代码是
node.innerText = data[dataProp]
为了保存当前的node和dataProp,我们再次设计一个函数执行利用闭包函数将每一次编译函数执行时候的node和dataProp都缓存下来,所以每一次数据变化之后执行的是这样的一个更新函数
。
() => {
node.innerText = data[dataProp]
}
一个响应式数据可能会有多个视图部分都需要依赖,也就是响应式数据变化之后,需要执行的更新函数可能不止一个,如下面的代码所示,name属性有俩个div元素都使用了它,所以当name变化之后,俩个div节点都需要得到更新,那属性和更新函数之间应该是一个一对多的关系。
<div id="app">
<div v-text="name"></div>
<div v-text="name"></div>
<p v-text="age"></p>
<p v-text="age"></p>
</div>
<script>
let data = {
name: 'cp',
age: 18
}
</script>
每一个响应式属性都绑定了相对应的更新函数,是一个一对多的关系,数据发生变化之后,只会再次执行和自己绑定的更新函数。
八、理解发布订阅模式(又叫自定义事件)
dom绑定事件的方式,我们学过俩种
- dom.onclick = function(){}
- dom.addEventListener(‘click’,()=>{})
这俩种绑定方式的区别是,第二种方案可以实现同一个事件绑定多个回调函数,很明显这是一个一对多的场景,既然浏览器也叫作事件,我们试着分析下浏览器事件绑定实现的思路
- 首先addEventListenr是一个函数方法,接受俩个参数,分别是
事件类型
和回调函数
- 因为是一个事件绑定多个回调函数,那在内存里大概会有这样的一个数据结构。
{
click: ['cb1','cb2',....],
input: ['cb1','cb2',...]
}
- 触发事件执行,浏览器因为有鼠标键盘输入可以触发事件,大概的思路是通过事件名称找到与之关联的回调函数列表,然后遍历执行一遍即可。回调函数里面就是
() => {
node.innerText = data[dataProp]
}
更新视图。
九、简单的发布订阅
1.收集依赖和触发依赖
// 增加dep对象 用来收集依赖和触发依赖
const dep = {
map: Object.create(null),
// 收集
//dataProp是那个变化的属性名
//updateFn是变化的函数
collect(dataProp, updateFn) {
//要是变化的属性名不存在
//就以那个属性名创建一个空数组,用来存放更新视图的不同函数
if (!this.map[dataProp]) {
this.map[dataProp] = []
}
//将每个函数放到对应的属性名
this.map[dataProp].push(updateFn)
},
// 触发
//dataProp是那个变化的属性名
//this.map[dataProp]是对应的那个函数数组
//map属性就是用来存放所有的属性名,属性名又包含着所有更新的函数。
trigger(dataProp) {
this.map[dataProp] && this.map[dataProp].forEach(updateFn => {
//触发每个this.map[dataProp]函数数组里的每个函数
updateFn()
})
}
}
2.收集更新函数
在编译函数执行的时候,我们把用于更新dom的更新函数收集起来
// 编译函数
function compile() {
let app = document.getElementById('app')
// 1.拿到app下所有的子元素
const nodes = app.childNodes // [text, input, text]
//2.遍历所有的子元素
nodes.forEach(node => {
// nodeType为1为元素节点
if (node.nodeType === 1) {
const attrs = node.attributes
// 遍历所有的attrubites找到 v-model
Array.from(attrs).forEach(attr => {
const dirName = attr.nodeName
const dataProp = attr.nodeValue
console.log(dirName, dataProp)
if (dirName === 'v-text') {
console.log(`更新了${dirName}指令,需要更新的属性为${dataProp}`)
node.innerText = data[dataProp]
// 收集更新函数
dep.collect(dataProp, () => {
//将每个更新的函数放到 dep.collect里对应的属性名数组里。
node.innerText = data[dataProp]
})
}
})
}
})
}
3.触发更新函数
当属性发生变化的时候,我们通过属性找到对应的更新函数列表,然后依次执行即可。
function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
get() {
return value
},
set(newValue) {
// 更新视图
if (newValue === value) return
value = newValue
// 再次编译要放到新值已经变化之后只更新当前的key
dep.trigger(key)
}
})
}
十、整体总结
- 数据响应式的实现无非是对象属性拦截,我们使用
Object.defineProperty
来实现,在vue3中使用Proxy
对象代理方案进行了优化 - 指令实现的核心无非是通过模板编译找到标识然后把数据绑上去,等到数据变化之后再重新放一次
- 发布订阅模式的本质是解决一对多的问题,在vue中实现数据变化之后的精准更新