美文网首页前端开发那些事儿
Vue 中如何自定义 v-model 及双向绑定的实现原理

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

作者: 橙色流年 | 来源:发表于2020-10-13 19:57 被阅读0次

    我们都知道,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 也是迫在眉睫。

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

    相关文章

      网友评论

        本文标题:Vue 中如何自定义 v-model 及双向绑定的实现原理

        本文链接:https://www.haomeiwen.com/subject/swfvpktx.html