我们都知道,Vue
中使用 v-model
可以实现双向绑定,先看一个小栗子:
<template>
<div id="app">
<p>{{ city }}</p>
<input type="text" v-model="city" />
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
city: '北京'
}
},
};
</script>
city
的值通过 v-model
绑定到 input
上,因此它会根据 input
输入框的值进行动态变化。根据官方文档的解释,v-model
其实是一个语法糖,它会自动的在元素或者组件上面解析为 :value = " "
和 @input = " "
。所以我们可以将上述代码进行拆解:
<template>
<div id="app">
<p>{{ city }}</p>
<input type="text" :value="city" @input="city = $event.target.value" />
</div>
</template>
不使用 v-model
,我们也可以得到相同的效果。上述代码中,当在 input
输入框输入内容时,会自动的触发 input
事件,更新绑定的 city
值为当前 input
框输入的值。所以,了解这些后,我们是否可以在自己写的组件上面实现 v-model
的效果呢?
官方文档:自定义组件的 v-model。这里我们将官网的栗子改造一下拿过来瞅瞅:
// 父组件
<template>
<div id="app">
<p>{{ name }}</p>
<!--自定义组件上使用 v-model,name 的值会传入到子组件 model-prop-text 中-->
<CustomVModel v-model="name" />
</div>
</template>
<script>
import CustomVModel from './components/CustomVModel'
export default {
name: "App",
components: { CustomVModel },
data() {
return {
name: '张三'
}
},
};
</script>
// 子组件 CustomVModel.vue
<template>
<input
type="text"
:value="text"
@input="$emit('change', $event.target.value)"
/>
</template>
<script>
export default {
model: {
prop: 'text', // 可任意定义的变量,用来接收父组件 v-model 中传递过来的值
event: 'change'
},
props: {
text: String, // 对自己定义的变量的类型和默认值进行声明,必须和自己定义的变量名相同
default() {
return ''
}
}
}
</script>
根据官方文档给出的说法:父组件中的 name
的值将会传入子组件中名为 text(该名称可自己定义)
的 prop
中,同时子组件的 input
如果触发 @input
事件并附带一个新的值的时候,父组件中 name
的值也会被重新更新。当然,我们还是需要在子组件的 props 选项里声明 text
这个 prop
。
双向绑定的实现原理
上述我们了解了 v-model
可以实现双向绑定,但是 Vue
实现双向绑定的原理是啥呢?我们先来看一下通过控制台输出一个定义在 Vue
初始化数据上的对象是个什么东西。
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12"></script>
<script>
const vm = new Vue({
data() {
return {
obj: {
a: 1
}
}
},
created() {
console.log(this.obj)
}
})
</script>
打印结果:
我们可以看到属性
a
有两个相对应的 get
和 set
方法,为什么会多出这两个方法呢?因为 Vue
是通过 Object.defineProperty()
来实现数据劫持的。
好吧,Object.defineProperty( )
是用来做什么的?它可以来控制一个对象属性的一些特有操作,比如读写权、是否可以枚举,这里我们主要先来研究下它对应的两个描述属性 get()
和 set()
,该方法的详细用法参考 MDN。根据官方文档给出的案例,我们先来了解它的基础用法:
const Book = {}
let name = ''
Object.defineProperty(Book, 'name', {
set: function (value) {
name = value
console.log('你取了一本书名叫做' + value)
},
get: function () {
return `《${name}》`
}
})
Book.name = '老人与海' // 你取了一本书名叫做老人与海
console.log(Book.name) //《老人与海》
我们通过 Object.defineProperty( )
设置了对象 Book
的 name
属性,对其 get()
和 set()
进行重写操作,顾名思义,get()
就是在读取 name
属性这个值触发的函数,set()
就是在设置 name
属性这个值触发的函数,所以当执行 Book.name = '老人与海'
这个语句时,控制台会打印出 "你取了一个书名叫做老人与海",紧接着,当读取这个属性时,就会输出 "《老人与海》",因为我们在 get()
里面对该值做了加工了。
大概了解了 Object.defineProperty( )
的基本用法和 get()、set()
之后,有没有觉得抓住了一些什么,趁热打铁,我们来写一个监听器,用于监听所有属性。
// 触发更新视图
function updateView() {
console.log('视图更新')
}
// 重新定义属性,监听起来
function defineReactive(target, key, value) {
// 使用递归进行深度监听
observer(value)
// 核心 API
Object.defineProperty(target, key, {
get() {
return value
},
set(newVal) {
if (newVal !== value) {
// 设置新值
value = newVal
// 触发更新视图
updateView()
}
}
})
}
// 监听对象属性
function observer(target) {
if (typeof target !== 'object' || target === null) {
// 不是对象或数组
return target
}
// 重新定义各个属性(for in 也可以遍历数组)
for (let key in target) {
defineReactive(target, key, target[key])
}
}
// 准备数据
const data = {
name: 'zhangsan',
age: 20,
info: {
address: '北京' // 需要深度监听
}
}
// 监听数据
observer(data)
// 测试
data.name = 'lisi' // 视图更新
data.info.address = '上海' // 如果不在监听执行函数中进行递归调用,无法监听到数据的变化
上述代码中我们定义了一个监听器的入口函数 observer
和一个监听执行函数 defineReactive
,我们在每次监听到属性变化的时候触发 updateView
函数。如果只是进行 "一维对象" 进行值改变的监听,我们很容易就能通过 updateView
进行响应,但是原数组中 info
属性的值又是一个对象,所以我们需要在监听执行函数里面使用递归,这样就可以正确响应。
但是我们如果对 data
进行新增或者删除操作,updateView
函数仍然无法正确响应,如下栗子:
data.sex = '男' // 新增属性,监听不到 ----- 所以有 Vue.set
delete data.name // 删除属性,监听不到 ----- 所以有 Vue.delete
相信小伙伴们在项目开发中都用过 Vue.set
方法,其实根本原因就是 Object.defineProperty( )
无法监听到对象属性的新增和删除。所以 Vue
才专门针对这两种情况设置了两个对应的 API
。其实这里还有一个问题,就是 Object.defineProperty( )
也无法对数组的改变进行监听....当然,小伙伴们肯定不信,因为 Vue
实际开发中是可以对数组的变化进行响应的。我们先将上述代码改造整理一下:
const data = {
name: 'zhangsan',
age: 20,
info: {
address: '北京' // 需要深度监听
},
nums: [10, 20, 30] // 新增属性 nums,它的值时数组
}
data.nums.push(40) // 新增一个值 ----- updateView 函数未触发,监听不到
通过代码可以看到我们确实监听不到数组的变化,那么 Vue
中是如何实现的呢?我们一起来研究研究
// 重新定义数组原型
const oldArrayProperty = Array.prototype
// 创建新对象,原型指向 oldArrayProperty,再扩展新的方法不会影响原型
const arrProto = Object.create(oldArrayProperty)
我们首先定义了 oldArrayProperty
来让它等于 Array
原型上的方法,然后通过 Object.create()
创建了一个新的对象,让其原型指向 oldArrayProperty
。为什么我们要做这一步呢?我们可以先打印一下 arrProto
console.log(arrProto)
结果如下图:
arrProto
为一个空的对象,但是它的 __proto__
上面有所有 Array
的 prototype
上的方法。这样我们就可以在 arrProto
上面扩展自己的方法,并且并不会污染 __proto__
上的方法,如下栗子:
arrProto.push = function () {
console.log(100)
}
console.log(arrProto.push) // ƒ () { console.log(100) }
console.log(arrProto.__proto__.push) // ƒ push() { [native code] }
这样我们是不是可以声明一个数组方法名的集合,如果我们通过 arrProto
去调用这些方法名,那么就可以直接在我们自己定义的数组原型上让这些方法真正触发。如下栗子:
// 为什么是这7个数组名,而没有其它是有讲究的哦~~~
const useArr = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
// 将这些数组中的方法名追加到 arrProto 上,然后它执行时触发数组原型上的对应方法
useArr.forEach(methodName => {
arrProto[methodName] = function () {
updateView() // 触发视图更新
oldArrayProperty[methodName].call(this, ...arguments)
}
})
这样我们就完成了对数组变化的响应监听,有没有感觉很巧妙~~~最后汇总所有代码:
// 触发更新视图
function updateView() {
console.log('视图更新')
}
// 重新定义数组原型
const oldArrayProperty = Array.prototype
// 创建新对象,原型指向 oldArrayProperty,再扩展新的方法不会影响原型
const arrProto = Object.create(oldArrayProperty)
const useArr = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
useArr.forEach(methodName => {
arrProto[methodName] = function () {
updateView() // 触发视图更新
oldArrayProperty[methodName].call(this, ...arguments)
}
})
// 重新定义属性,监听起来
function defineReactive(target, key, value) {
// 使用递归进行深度监听
observer(value)
// 核心 API
Object.defineProperty(target, key, {
get() {
return value
},
set(newVal) {
if (newVal !== value) {
// 设置新值
value = newVal
// 触发更新视图
updateView()
}
}
})
}
// 监听对象属性
function observer(target) {
if (typeof target !== 'object' || target === null) {
// 不是对象或数组
return target
}
// 如果这个对象是数组,那么就将其原型指向我们生成的新对象上
if (Array.isArray(target)) {
target.__proto__ = arrProto
}
// 重新定义各个属性(for in 也可以遍历数组)
for (let key in target) {
defineReactive(target, key, target[key])
}
}
// 准备数据
const data = {
name: 'zhangsan',
age: 20,
info: {
address: '北京' // 需要深度监听
},
nums: [10, 20, 30]
}
// 监听数据
observer(data)
// 测试'
data.nums.push(40) // 视图更新
当然上述代码其实还有坑留下,在 Vue
实现响应式时,把无法监听数组的情况通过重写数组的部分方法来实现响应式,但是只局限在以下 7 种方法:
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
这也是为什么我代码中只罗列了那七种方法,到底是不是这样呢?我们可以通过代码来瞅瞅:
// 请引入 vue.js 哦
const vm = new Vue({
data() {
return {
obj: {
a: 1,
nums: [10, 20, 30]
}
}
},
created() {
// this.obj.nums.push(40)
// console.log(this.obj.nums) [10, 20, 30, 40, __ob__: we]
const arr = [1, 2, 3]
this.obj.nums.concat(arr)
console.log(this.obj) // [10, 20, 30, __ob__: we]
}
})
我只用了 push
和 concat
来进行演示,可以看到使用 push
确实能正确监听响应,但是使用 concat
无法正确监听。我们观察发现 vue
支持直接响应的数组 API
基本上都是有返回值且都是直接改变原数组的 API
,所以可以直接监听响应。而像 concat
、slice
、map
、filter
等这些方法都并不会改变原数组而是返回一个新数组,所以无法正确监听响应。(forEach
方法返回值是 undefined
)。此理解仅供个人参考~~~
总结 Object.defineProperty
的缺点:
1 - 复杂对象的深度监听需要递归到底,一次性计算量大
2 - 无法监听新增属性/删除属性(Vue.set Vue.delete)
3 - 无法原生监听数组,需要特殊处理
当然瑕不掩瑜,毕竟 Vue
作为如今最优秀的前端框架之一,肯定是考虑到了这些问题在日常开发中所用到的场景极其少,并且也给出了自己的一些解决方法(如:Vue.set
、Vue.delete
)。作为前端框架的代表之一,Vue
也在 3.0
的版本中放弃了使用 Object.defineProperty
来进行双向绑定从而启用 Proxy
。这里我们先不深究 Proxy
,毕竟它的兼容性还没有那么好,Vue 3.0
尽管正式版本已经出来,但是很长一段时间我们仍然还是会用 Vue 2.0
,毕竟也要等周边生态的完善以及更好的兼容性和稳定性,当然学习 Vue 3.0
和 TypeScript
也是迫在眉睫。
如果文中有不对的地方或者理解有误的地方欢迎大家提出并指正。每一天都要相对前一天进步一点,加油!!!