vue双向数据绑定的实现原理
2021-12-25 本文已影响0人
东邪_黄药师
实现数据响应式
在Javascript里实现数据响应式一般有俩种方案,分别对应着vue2.x
和 vue3.x
使用的方式,他们分别是:
- 1.对象属性拦截 (vue2.x)
Object.defineProperty - 2.对象整体代理 (vue3.x)
Proxy
实现对象属性拦截
- Object.defineProperty对象定义
cript>
let data = {}
Object.defineProperty(data,'name',{
// 访问name属性就会执行此方法 返回值就是获取到的值
get(){
console.log('name属性被获取了')
return '林云龙'
},
// 设置新值就会执行此方法 newVal就是设置的新值
set(newVal){
console.log('name属性被设置新值了')
console.log(newVal)
}
})
优化- get和set
上述代码get方法中返回的值始终是
林云龙
,是固定的,set中拿到新值之后,我们如何让get中可以得到newVal使我们需要解决的问题

解决方法:
我们可以 通过一个中间变量例如:_name
来中转get函数和set函数之间的联动
let data = {}
let _name = '李云龙'
Object.defineProperty(data, 'name', {
get() {
console.log('你访问了data1的name属性')
return _name
},
set(newValue) {
console.log('你修改了data1的name属性最新的值为', newValue)
_name = newValue
}
})

优化2-更加通用的劫持方案
// 1.如何把这个提前申明好的对象,把里面的所有的属性都变成我们刚才讲过的 set和get的形式?
// 可以做到不管是访问data中的任何一个属性还是设置data中的任何一个属性我们都能知道
// 1. 由于有多个属性 对象的遍历
Object.keys(data).forEach((key) => {
console.log(key, data[key])
// key代表data对象的每一个属性名
// data[key]代表data对象每一个属性对应的value值
// data 源对象
// 处理每一个对象key转变成响应式
defineReactive(data, key, data[key])
})
1. 函数定义形参相当于在内部 申明了和形参名字对应的变量 并且初始值为undefined
2. 函数调用传入实参的时候 相当于给内部申明好的变量做了赋值操作 (首次遍历举例)
3. defineReactive函数调用完毕 本来应该内部所有的变量都会被回收 但是如果内部有其它函数使用了当前变量则形成了闭包 不会被回收
4. 内部由于有其它方法引用了value属性 所以defineReactive函数的执行并不会导致value变量的销毁 会一直常驻内存
5. 由于闭包的特性 每一个传入下来的value都会常驻内存 相当于我们上一节讲的中间变量_name 目的是为了set和get的联动
function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
get() {
console.log('您访问了属性', key)
return value
},
set(newValue) {
console.log('您修改了属性', key)
value = newValue
}
})
}

结构说明:这个地方实际上使用了闭包的特性,看下图,在每一次的defineReactive函数执行的时候,都会形成一块独立的函数作用域,传入的value 因为闭包的关系会常驻内存,这样一来,每个defineReactive函数中的value 会作为各自set和get函数操作的局部变量

响应式总结
- 所谓的响应式其实就是拦截对象属性的访问和设置,插入一些我们自己想要做的事情
- 在Javascript中能实现响应式拦截的方法有俩种,
Object.defineProperty
方法和Proxy对象代理
- 在Javascript中能实现响应式拦截的方法有俩种,
- 回归到vue2.x中的data配置项,只要放到了data里的数据,不管层级多深不管你最终会不会用到这个数据都会进行递归响应式处理,所以要求我们如非必要,尽量不要添加太多的冗余数据在data中
- vue3.x中,解决了2中对于数据响应式处理的无端性能消耗,使用的手段是Proxy劫持对象整体 + 惰性处理(用到了才进行响应式转换))
数据的变化反应到视图
- 命令式操作视图
<body>
<div id="app">
<!-- p标签就是我们想要把每一次的数据都反映上来的视图 -->
<p></p>
</div>
<script>
// 1. 准备数据
let data = {
name: '林云龙'
}
// 2. 将数据转换成响应式 (数据发生变化之后操作我们的视图)
Object.keys(data).forEach((key) => {
defineReactive(data, key, data[key])
})
function defineReactive(data, key, value) {
// 进行转换操作
Object.defineProperty(data, key, {
get() {
console.log('您访问了属性', key)
return value
},
set(newValue) {
// set函数的执行 不会自动判断俩次修改的值是否相等
// 显然如果相等 不应该执行变化的逻辑
if (newValue === value) {
return
}
console.log('您修改了属性', key)
value = newValue
// 这里我们把最新的值 反映到视图中 这里是关键的位置
// 核心:操作dom 就是通过操作dom api 把最新的值设置上去
document.querySelector('#app p').innerText = newValue
// 只要修改name属性 就会触发set函数的执行 内部我们把最新的值通过dom操作
// 设置到dom内部 实现了数据的变化 反映到了视图中
}
})
}
document.querySelector('#app p').innerText = data.name
</script>
</body>

- 2 .声明式操作视图(v-text的实现原理)
我们将data中name属性的值作为文本渲染到标记了v-text的p标签内部,在vue中,我们把这种标记式的声明式渲染叫做指令
v-text 声明式的指令版本实现
目标:一旦data中的name发生变化之后 标记了v-text的p标签的文本内容会立刻得到更新
实现指令的核心:不管是指令也好还是插值表达式也好 它们都是数据和视图之间建立关联的‘标识’
所以本质就是通过一定的手段找到符合标识的dom元素 然后把数据放上去 每当数据发生变化 就重新
执行一遍放置数据的操作
实现步骤:
1. 先通过标识查找把数据放到对应的dom上显示出来
2. 数据变化之后再次执行将最新的值放到对应的dom上 (数据变化之后再次执行compile函数即可)
代码:
<body>
<div id="app">
<p v-text="name"></p>
<div v-text="name"></div>
<a href="3" v-text="age"></a>
</div>
<script>
let data = {
name: '李云龙',
age: 18
}
// 把data中的属性变成响应式的
Object.keys(data).forEach((key) => {
defineReactive(data, key, data[key])
})
function defineReactive(data, key, value) {
// 进行转换操作
Object.defineProperty(data, key, {
get() {
console.log('您访问了属性', key)
return value
},
set(newValue) {
// set函数的执行 不会自动判断俩次修改的值是否相等
// 显然如果相等 不应该执行变化的逻辑
if (newValue === value) {
return
}
console.log('您修改了属性', key)
value = newValue
// 这里我们把最新的值 反映到视图中 这里是关键的位置
// 核心:操作dom 就是通过操作dom api 把最新的值设置上去
compile()
}
})
}
function compile () {
let app = document.getElementById('app')
let childNodes = app.childNodes
// console.log(childNodes)
childNodes.forEach ((node) => {
// console.log(node.nodeType)
if (node.nodeType === 1) {
// console.log(node)
// 拿到所有的标签属性
const attrs = node.attributes
// console.log(attrs)
Array.from(attrs).forEach(attr =>{
console.log(attr)
const nodeName = attr.nodeName
const nodeValue = attr.nodeValue
// nodeName -> v-text 就是我们需要查找的标识
// nodeValue -> name data中对应数据的key
console.log(nodeName, nodeValue)
// 把data中的数据 放到满足标识的dom上
if (nodeName === 'v-text') {
console.log('设置值', node)
node.innerText = data[nodeValue]
}
})
}
})
}
compile()
</script>
</body>
v-model的实现原理
- M -> V 指令名换一下 然后把操作dom的api换一下即可
- V -> M 做事件监听 在事件触发的回调函数中拿到当前最新的输入框值 赋值到绑定的数据上
<body>
<div id="app">
<input type="text" v-model="name" style="width: 100%;" />
<script>
let data = {
name: '李云龙',
age: 17
}
// 把data中的属性变成响应式的
Object.keys(data).forEach((key) => {
defineReactive(data, key, data[key])
})
function defineReactive(data, key, value) {
// 进行转换操作
Object.defineProperty(data, key, {
get() {
console.log('您访问了属性', key)
return value
},
set(newValue) {
// set函数的执行 不会自动判断俩次修改的值是否相等
// 显然如果相等 不应该执行变化的逻辑
if (newValue === value) {
return
}
console.log('您修改了属性', key)
value = newValue
// 这里我们把最新的值 反映到视图中 这里是关键的位置
// 核心:操作dom 就是通过操作dom api 把最新的值设置上去
compile()
}
})
}
// 1.通过标识查找把数据放到对应的dom上显示出来
function compile() {
let app = document.getElementById('app')
// 拿到所有节点
const childNodes = app.childNodes // 所有类型的节点包括文本节点和标签节点
console.log(childNodes)
// 刷选出来目标节点 -> p
childNodes.forEach(node => {
console.log(node.nodeType)
if (node.nodeType === 1) {
// 这里拿到的是标签节点
console.log(node)
// 刷选v-text属性 p id class (v-text)
// 拿到所有的标签属性
const attrs = node.attributes
console.log(attrs)
Array.from(attrs).forEach(attr => {
console.log(attr)
const nodeName = attr.nodeName
const nodeValue = attr.nodeValue
console.log(nodeName, nodeValue)
// 实现v-model
if (nodeName === 'v-model') {
// 调用dom操作给input标签绑定数据
node.value = data[nodeValue]
// 监听input事件 在事件回调中 拿到最新的输入值 赋值给绑定的属性
node.addEventListener('input', (e) => {
let newValue = e.target.value
// 反向赋值
data[nodeValue] = newValue
})
}
})
}
})
}
compile()
</script>
</body>

上述代码的问题
- 存在的问题:更新太过粗暴
不管你修改了哪个属性,其它属性也会一起跟着进行更新 哪怕你根本就没有动他
只修改了name的时候 正常逻辑 应该只有name相关的更新操作才进行 而不是粗暴的吧所有的都更新一遍 - 期望:
哪个属性进行了实质性的修改 哪个属性对应的‘编译’部分才得到执行,这个更新优化
我们称之为‘精准更新’
发布订阅模式理解实现
- 1.数据更新之后实际上需要执行的代码是什么?
node.innerText = data[dataProp]
为了保存当前的node和dataProp,我们再次设计一个函数执行利用闭包函数将每一次编译函数执行时候的node和dataProp都缓存下来,所以每一次数据变化之后执行的是这样的一个更新函数
() => {
node.innerText = data[dataProp]
}
2.一个响应式数据可能会有多个视图部分都需要依赖,也就是响应式数据变化之后,需要执行的更新函数可能不止一个,如下面的代码所示,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>
经过分析我们可以得到下面的存储架构图,每一个响应式属性都绑定了相对应的更新函数,是一个一对多的关系,数据发生变化之后,只会再次执行和自己绑定的更新函数

理解发布订阅模式(自定义事件)
// 增加dep对象 用来收集依赖和触发依赖
const dep = {
map: Object.create(null),
// 收集
collect(dataProp, updateFn) {
if (!this.map[dataProp]) {
this.map[dataProp] = []
}
this.map[dataProp].push(updateFn)
},
// 触发
trigger(dataProp) {
this.map[dataProp] && this.map[dataProp].forEach(updateFn => {
updateFn()
})
}
}
发布订阅模式优化架构(最终优化版)
<body>
<div id="app">
<p v-text="name"></p>
<p v-text="name"></p>
<span v-text="age"></span>
<input type="text" v-model="age">
</div>
<script>
// 引入发布订阅模式
const Dep = {
map: {},
// 收集事件的方法
collect(eventName, fn) {
// 如果当前map中已经初始化好了 click:[]
// 就直接往里面push 如果没有初始化首次添加 就先进行初始化
if (!this.map[eventName]) {
this.map[eventName] = []
}
this.map[eventName].push(fn)
},
// 触发事件的方法
trigger(eventName) {
this.map[eventName].forEach(fn => fn())
}
}
let data = {
name: '柴达木',
age: 17
}
// 把data中的属性变成响应式的
Object.keys(data).forEach((key) => {
defineReactive(data, key, data[key])
})
function defineReactive(data, key, value) {
// 进行转换操作
Object.defineProperty(data, key, {
get() {
return value
},
set(newValue) {
// set函数的执行 不会自动判断俩次修改的值是否相等
// 显然如果相等 不应该执行变化的逻辑
if (newValue === value) {
return
}
value = newValue
// 这里我们把最新的值 反映到视图中 这里是关键的位置
// 核心:操作dom 就是通过操作dom api 把最新的值设置上去
// 在这里进行精准更新 -> 通过data中的属性名找到对应的更新函数依次执行
Dep.trigger(key)
}
})
}
// 1.通过标识查找把数据放到对应的dom上显示出来
function compile() {
let app = document.getElementById('app')
// 拿到所有节点
const childNodes = app.childNodes // 所有类型的节点包括文本节点和标签节点
childNodes.forEach(node => {
if (node.nodeType === 1) {
const attrs = node.attributes
Array.from(attrs).forEach(attr => {
const nodeName = attr.nodeName
const nodeValue = attr.nodeValue
// 实现v-text
if (nodeName === 'v-text') {
node.innerText = data[nodeValue]
// 收集更新函数
Dep.collect(nodeValue, () => {
console.log(`当前您修改了属性${nodeValue}`)
node.innerText = data[nodeValue]
})
}
// 实现v-model
if (nodeName === 'v-model') {
// 调用dom操作给input标签绑定数据
node.value = data[nodeValue]
// 收集更新函数
Dep.collect(nodeValue,()=>{
node.value = data[nodeValue]
})
// 监听input事件 在事件回调中 拿到最新的输入值 赋值给绑定的属性
node.addEventListener('input', (e) => {
let newValue = e.target.value
// 反向赋值
data[nodeValue] = newValue
})
}
})
}
})
}
compile()
console.log(Dep)
</script>
</body>