03-面试之前端 Vue 数据绑定

双向数据绑定原理

参考答案:

目前几种主流的 mvc(vm) 框架都实现了单向数据绑定,而我所理解的双向数据绑定无非就是在单向绑定的基础上给可输入元素(input、textare等)添加 change(input) 事件,来动态修改 model 和 view,并没有多高深。所以无需太过介怀是实现的单向或双向绑定。

实现数据绑定的做法有大致如下几种:

  • 发布者-订阅者模式: 一般通过 sub, pub 的方式实现数据和视图的绑定监听,更新数据方式通常做法是 vm.set('property', value)

    这种方式现在毕竟太 low 了,我们更希望通过 vm.property = value 这种方式更新数据,同时自动更新视图,于是有了下面两种方式

  • 脏值检查: angular 是通过脏值检测的方式比对数据是否有变更,来决定是否更新视图,最简单的方式就是通过 setInterval() 定时轮询检测数据变动,当然 Google 不会这么 low,angular 只有在指定的事件触发时进入脏值检测,大致如下:

    • DOM 事件,譬如用户输入文本,点击按钮等。(ng-click)
    • XHR 响应事件。($http)
    • 浏览器 Location 变更事件。($location)
    • Timer 事件。(interval)
    • 执行 digest() 或 apply()
  • 数据劫持: vue 则是采用数据劫持结合 发布者-订阅者 模式的方式,通过 Object.defineProperty() 来劫持各个属性的 setter/getter,在数据变动时发布消息给订阅者,触发相应的监听回调。

Vue 是如何实现数据双向绑定的

Vue 数据双向绑定主要是指:数据变化更新视图,视图变化更新数据,如下图所示:

  • 输入框内容变化时,Data 中的数据同步变化。即 View => Data 的变化。
  • Data 中的数据变化时,文本节点的内容同步变化。即 Data => View 的变化。

其中,View 变化更新 Data,可以通过事件监听的方式来实现,所以 Vue 的数据双向绑定的工作主要是如何根据 Data 变化更新 View。

Vue 主要通过以下 4 个步骤来实现数据双向绑定的:

实现一个监听器 Observer:对数据对象进行遍历,包括子属性对象的属性,利用 Object.defineProperty() 对属性都加上 setter 和 getter。这样的话,给这个对象的某个值赋值,就会触发 setter,那么就能监听到了数据变化。

实现一个解析器 Compile:解析 Vue 模板指令,将模板中的变量都替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,调用更新函数进行数据更新。

实现一个订阅者 Watcher:Watcher 订阅者是 Observer 和 Compile 之间通信的桥梁 ,主要的任务是订阅 Observer 中的属性值变化的消息,当收到属性值变化的消息时,触发解析器 Compile 中对应的更新函数。

实现一个订阅器 Dep:订阅器采用 发布-订阅 设计模式,用来收集订阅者 Watcher,对监听器 Observer 和 订阅者 Watcher 进行统一管理。

以上四个步骤的流程图表示如下,如果有同学理解不大清晰的,可以查看专门介绍数据双向绑定的文章《0 到 1 掌握:Vue 核心之数据双向绑定》,有进行详细的讲解、以及代码 demo 示例。

Vue 怎么实现对象和数组的监听

如果被问到 Vue 怎么实现数据双向绑定,大家肯定都会回答 通过 Object.defineProperty() 对数据进行劫持,但是 Object.defineProperty() 只能对属性进行数据劫持,不能对整个对象进行劫持,同理无法对数组进行劫持,但是我们在使用 Vue 框架中都知道,Vue 能检测到对象和数组(部分方法的操作)的变化,那它是怎么实现的呢?我们查看相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Observe a list of Array items.
*/
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]) // observe 功能为监测数据的变化
}
}
/**
* 对属性进行递归遍历
*/
let childOb = !shallow && observe(val) // observe 功能为监测数据的变化

通过以上 Vue 源码部分查看,我们就能知道 Vue 框架是通过遍历数组和递归遍历对象,从而达到利用 Object.defineProperty() 也能对对象和数组(部分方法的操作)进行监听。

v-model 作用

v-model 本质上不过是语法糖,可以用 v-model 指令在表单及元素上创建双向数据绑定。

  1. 它会根据控件类型自动选取正确的方法来更新元素。
  2. 它负责监听用户的输入事件以更新数据,并对一些极端场景进行一些特殊处理。
  3. v-model 会忽略所有表单元素的 value、checked、selected 特性的初始值,而总是将 Vue 实例的数据作为数据来源,因此我们应该通过 JavaScript 在组件的 data 选项中声明初始值。

扩展:

v-model 在内部为不同的输入元素使用不同的属性并抛出不同的事件:

  1. text 和 textarea 元素使用 value 属性和 input 事件;
  2. checkbox 和 radio 使用 checked 属性和 change 事件;
  3. select 字段将 value 作为 prop 并将 change 作为事件。

实现原理:

  1. v-bind 绑定响应式数据
  2. 触发 oninput 事件并传递数据
1
2
3
4
5
6
7
8
9
<input v-model="sth" />
<!-- 等同于-->
<input :value="sth" @input="sth = $event.target.value" />
<!--自html5开始,input每次输入都会触发oninput事件,所以输入时input的内容会绑定到sth中,于是sth的值就被改变-->
<!--$event 指代当前触发的事件对象;-->
<!--$event.target 指代当前触发的事件对象的dom;-->
<!--$event.target.value 就是当前dom的value值;-->
<!--在@input方法中,value => sth;-->
<!--在:value中,sth => value;-->

Vue2.0 双向绑定的缺陷

Vue2.0 的数据响应是采用数据劫持结合 发布者-订阅者 模式的方式,通过 Object.defineProperty() 来劫持各个属性的 setter、getter,但是它并不算是实现数据的响应式的完美方案,某些情况下需要对其进行修补或者 hack 这也是它的缺陷,主要表现在两个方面:

  1. vue 实例创建后,无法检测到对象属性的新增或删除,只能追踪到数据是否被修改。
  2. 不能监听数组的变化。

解析:

  1. vue 实例创建后,无法检测到对象属性的新增或删除,只能追踪到数据是否被修改(Object.defineProperty 只能劫持对象的属性)。

    当创建一个 Vue 实例时,将遍历所有 DOM 对象,并为每个数据属性添加了 get 和 set。get 和 set 允许 Vue 观察数据的更改并触发更新。但是,如果你在 Vue 实例化后添加(或删除)一个属性,这个属性不会被 vue 处理,改变 get 和 set。

    解决方案:

    1
    2
    3
    4
    5
    6
    7
    Vue.set(obj, propertName/index, value);
    // 响应式对象的子对象新增属性,可以给子响应式对象重新赋值
    data.location = {
    x: 100,
    y: 100
    };
    data.location = {...data, z: 100}
  2. 不能监听数组的变化。

    vue 在实现数组的响应式时,它使用了一些 hack,把无法监听数组的情况通过重写数组的部分方法来实现响应式,这也只限制在数组的 push/pop/shift/unshift/splice/sort/reverse 七个方法,其他数组方法及数组的使用则无法检测到,例如如下两种使用方式

    1
    2
    vm.items[index] = newValue;
    vm.items.length

    通过重写数组的 Array.prototype 对应的方法,具体来说就是重新指定要操作数组的 prototype,并重新该 prototype 中对应上面的 7 个数组方法,通过下面代码简单了解下实现原理:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const methods = ['pop','shift','unshift','sort','reverse','splice', 'push'];
    // 复制Array.prototype,并将其prototype指向Array.prototype
    let proto = Object.create(Array.prototype);
    methods.forEach(method => {
    proto[method] = function () { // 重写proto中的数组方法
    Array.prototype[method].call(this, ...arguments);
    viewRender(); // 视图更新
    function observe(obj) {
    if (Array.isArray(obj)) { // 数组实现响应式
    obj.__proto__ = proto; // 改变传入数组的prototype
    return;
    }
    if (typeof obj === 'object') {
    //... 对象的响应式实现
    }
    }
    }
    })

直接给一个数组项赋值,Vue 能检测到变化吗?

由于 JavaScript 的限制,Vue 不能检测到以下数组的变动:

  • 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  • 当你修改数组的长度时,例如:vm.items.length = newLength

为了解决第一个问题,Vue 提供了以下操作方法:

1
2
3
4
5
6
// Vue.set
Vue.set(vm.items, indexOfItem, newValue)
// vm.$set,Vue.set的一个别名
vm.$set(vm.items, indexOfItem, newValue)
// Array.prototype.splice
vm.items.splice(indexOfItem, 1, newValue)

为了解决第二个问题,Vue 提供了以下操作方法:

1
2
// Array.prototype.splice
vm.items.splice(newLength)

Vue 怎么用 vm.$set() 解决对象新增属性不能响应的问题

受现代 JavaScript 的限制 ,Vue 无法检测到对象属性的添加或删除。由于 Vue 会在初始化实例时对属性执行 getter/setter 转化,所以属性必须在 data 对象上存在才能让 Vue 将它转换为响应式的。

但是 Vue 提供了 Vue.set(object, propertyName, value)/vm.$set (object, propertyName, value) 来实现为对象添加响应式属性,那框架本身是如何实现的呢?

我们查看对应的 Vue 源码:vue/src/core/instance/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export function set (target: Array<any> | Object, key: any, val: any): any {
// target 为数组
if (Array.isArray(target) && isValidArrayIndex(key)) {
// 修改数组的长度, 避免索引>数组长度导致splcie()执行有误
target.length = Math.max(target.length, key)
// 利用数组的splice变异方法触发响应式
target.splice(key, 1, val)
return val
}
// key 已经存在,直接修改属性值
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__
// target 本身就不是响应式数据, 直接赋值
if (!ob) {
target[key] = val
return val
}
// 对属性进行响应式处理
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}

我们阅读以上源码可知,vm.$set 的实现原理是:

  • 如果目标是数组,直接使用数组的 splice 方法触发相应式;
  • 如果目标是对象,会先判读属性是否存在、对象是否是响应式,最终如果要对属性进行响应式处理,则是通过调用 defineReactive 方法进行响应式处理(defineReactive 方法就是 Vue 在初始化对象时,给对象属性采用 Object.defineProperty 动态添加 getter 和 setter 的功能所调用的方法)

Vue3.0 实现数据双向绑定的方法

Vue3.0 实现数据双向绑定是通过 Proxy

Proxy是 ES6 中新增的一个特性,翻译过来意思是”代理”,用在这里表示由它来“代理”某些操作。Proxy 让我们能够以简洁易懂的方式控制外部对对象的访问。其功能非常类似于设计模式中的代理模式。

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

使用 Proxy 的核心优点是可以交由它来处理一些非核心逻辑(如:读取或设置对象的某些属性前记录日志;设置对象的某些属性值前,需要验证;某些属性的访问控制等)。从而可以让对象只需关注于核心逻辑,达到关注点分离,降低对象复杂度等目的。

扩展:

使用 proxy 实现,双向数据绑定,相比 2.0 的 Object.defineProperty() 优势:

  1. 可以劫持整个对象,并返回一个新对象。
  2. 有 13 种劫持操作。

ProxyObject.defineProperty() 优劣对比

Vue3.x 改用 Proxy 替代 Object.defineProperty()

Proxy 的优势如下:

  • Proxy 可以直接监听对象而非属性;
  • Proxy 可以直接监听数组的变化;
  • Proxy 有多达 13 种拦截方法,不限于 apply、ownKeys、deleteProperty、has 等等是 Object.defineProperty 不具备的;
  • Proxy 返回的是一个新对象,我们可以只操作新的对象达到目的,而 Object.defineProperty 只能遍历对象属性直接修改;
  • Proxy 作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利;

Object.defineProperty 的优势如下:

  • 兼容性好,支持 IE9,而 Proxy 存在浏览器兼容性问题,而且无法用 polyfill 磨平,因此 Vue 的作者才声明需要等到下个大版本(3.0)才能用 Proxy 重写。

Vue3.0 里为什么要用 Proxy API 替代 defineProperty API?

  1. defineProperty API 的局限性最大原因是它只能针对单例属性做监听。
    Vue2.x 中的响应式实现正是基于 defineProperty 中的 descriptor,对 data 中的属性做了 遍历 + 递归,为每个属性设置了 getter、setter。这也就是为什么 Vue 只能对 data 中预定义过的属性做出响应的原因。

  2. Proxy API 的监听是针对一个对象的,那么对这个对象的所有操作会进入监听操作,这就完全可以代理所有属性,将会带来很大的性能提升和更优的代码。
    Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

  3. 响应式是惰性的。

    • 在 Vue2.x 中,对于一个深层属性嵌套的对象,要劫持它内部深层次的变化,就需要递归遍历这个对象,执行 Object.defineProperty 把每一层对象数据都变成响应式的,这无疑会有很大的性能消耗。
    • 在 Vue3.0 中,使用 Proxy API 并不能监听到对象内部深层次的属性变化,因此它的处理方式是在 getter 中去递归响应式,这样的好处是真正访问到的内部属性才会变成响应式,简单的可以说是按需实现响应式,减少性能消耗。

03-面试之前端 Vue 数据绑定
https://flepeng.github.io/interview-20-开发语言类-21-frontend-03-Vue-03-面试之前端-Vue-数据绑定/
作者
Lepeng
发布于
2023年8月8日
许可协议