Vue 中如何自定义 v-model 及双向绑定的实现原理

我们都知道,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 有两个相对应的 getset 方法,为什么会多出这两个方法呢?因为 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( ) 设置了对象 Bookname 属性,对其 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__ 上面有所有 Arrayprototype 上的方法。这样我们就可以在 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]
  }
})

我只用了 pushconcat 来进行演示,可以看到使用 push 确实能正确监听响应,但是使用 concat 无法正确监听。我们观察发现 vue 支持直接响应的数组 API 基本上都是有返回值且都是直接改变原数组的 API,所以可以直接监听响应。而像 concatslicemapfilter 等这些方法都并不会改变原数组而是返回一个新数组,所以无法正确监听响应。(forEach 方法返回值是 undefined)。此理解仅供个人参考~~~

总结 Object.defineProperty 的缺点:

1 - 复杂对象的深度监听需要递归到底,一次性计算量大
2 - 无法监听新增属性/删除属性(Vue.set Vue.delete)
3 - 无法原生监听数组,需要特殊处理

当然瑕不掩瑜,毕竟 Vue 作为如今最优秀的前端框架之一,肯定是考虑到了这些问题在日常开发中所用到的场景极其少,并且也给出了自己的一些解决方法(如:Vue.setVue.delete)。作为前端框架的代表之一,Vue 也在 3.0 的版本中放弃了使用 Object.defineProperty 来进行双向绑定从而启用 Proxy。这里我们先不深究 Proxy,毕竟它的兼容性还没有那么好,Vue 3.0 尽管正式版本已经出来,但是很长一段时间我们仍然还是会用 Vue 2.0,毕竟也要等周边生态的完善以及更好的兼容性和稳定性,当然学习 Vue 3.0TypeScript 也是迫在眉睫。

如果文中有不对的地方或者理解有误的地方欢迎大家提出并指正。每一天都要相对前一天进步一点,加油!!!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342