[TOC]

写作不易,Star是最大鼓励,感觉写的不错的可以给个Star⭐,请多多指教。本博客的Github地址

响应式理解

当js对象中的数据发生改变的时候,与js对象中数据相关联的DOM视图也会进行更新(即所谓的数据驱动视图)。

数据驱动视图

Vue.js 一个核心思想是数据驱动。所谓数据驱动,是指视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据。

响应式实现思路

想要实现响应式,需要做如下事情:

  1. 监听对象数据的变化。

  2. 收集视图依赖了哪些数据(依赖收集)。

  3. 数据变化时,自动通知和数据相关联的视图页面,并对视图进行更新。

  4. 利用Proxy或Object.defineProperty生成的Observer针对对象/对象的属性进行"劫持",在属性发生变化后通知订阅者;

  5. 解析器Compiler解析模板中的Directive(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染;

  6. Watcher是Observer和Compile桥梁,它将接收到的Observer产生的数据变化,并根据Compiler提供的指令进行视图渲染,使得数据变化促使视图变化。

如何监听对象数据的变化?

对象数据监听也叫做数据劫持,Vue采用数据劫持加发布者-订阅者模式,通过Object.defineProperty来劫持各个属性的setter,getter。在数据变化时发送消息给订阅者,触发相应的监听回调。当然也可以使用ES6中的Proxy来对各个属性进行代理(推荐)。

当把一个普通的JavaScript对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因深入响应式原理

响应式代码实现

在ES5中,新增了Object.defineProperty这个API,允许我们为对象的属性设定getter和setter。我们可以使用该API对该对象的属性值获取或设置进行劫持。

// Vue2.0如何实现响应式原理
// 数据变化了,可以更新视图
function observer(target) {
    // 判断target是否为对象
    if (typeof target !== 'object' || target == null) {
        return target;
    }
    for (const key in target) {
        if (target.hasOwnProperty(key)) {
            // 定义响应式
            defineReactive(target, key, target[key]);
        }
    }
}

function defineReactive(target, key, value) {
    Object.defineProperty(target, key, {
        get() {
            return value;
        },
        set(newValue) {
            if (newValue !== value) {
                updateView();
                value = newValue;
            }
        }
    });
}

function updateView() {
    console.log('数据更新了');
}

// 只是一层
const person = {name: 'kobe'};
observer(person);
person.name = 'james';
console.log(person.name);
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
26
27
28
29
30
31
32
33
34
35
36
37
38

注意点1:针对key的value也是对象的情况(需要递归进行绑定)。

// Vue2.0如何实现响应式原理
// 数据变化了,可以更新视图
function observer(target) {
    if (typeof target !== 'object' || target == null) {
        return target;
    }
    for (const key in target) {
        if (target.hasOwnProperty(key)) {
            defineReactive(target, key, target[key]);
        }
    }
}

function defineReactive(target, key, value) {
    // 注意点1:针对key的value是对象的情况,递归遍历子对象
    observer(value);
    Object.defineProperty(target, key, {
        get() {
            return value;
        },
        set(newValue) {
            if (newValue !== value) {
                updateView();
                value = newValue;
            }
        }
    });
}

function updateView() {
    console.log('数据更新了');
}

const person = {name: 'kobe', age: {value: 12}};
observer(person);
// person.name = 'james';
person.age.value = 14;
console.log(person.age.value);
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
26
27
28
29
30
31
32
33
34
35
36
37
38

注意点2:针对给key重新赋值的value是对象的情况。

// Vue2.0如何实现响应式原理
// 数据变化了,可以更新视图
function observer(target) {
    if (typeof target !== 'object' || target == null) {
        return target;
    }
    for (const key in target) {
        if (target.hasOwnProperty(key)) {
            defineReactive(target, key, target[key]);
        }
    }
}

function defineReactive(target, key, value) {
    // 注意点1:针对key的value是对象的情况,递归
    observer(value);
    Object.defineProperty(target, key, {
        get() {
            return value;
        },
        set(newValue) {
            // 注意点2:针对给key重新赋值的value是对象的情况,如果新值是对象的话,递归该对象进行监听
            observer(newValue);
            if (newValue !== value) {
                updateView();
                value = newValue;
            }
        }
    });
}

function updateView() {
    console.log('数据更新了');
}

const person = {name: 'kobe', age: {value: 12}};
observer(person);
person.age = {value: 13};
person.age.value = 16;
// 应该输出两次'数据更新了',因为age和value都是响应式的
console.log(person.age.value);
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

Object.defineProperty的缺陷

问题:

  1. 对于对象新增的属性将不会是响应式的
  2. 不支持属性值是数组的情况

实现数组劫持

// Vue2.0如何实现响应式原理
// 数据变化了,可以更新视图
const oldArrayPrototype = Array.prototype;
const proto = Object.create(oldArrayPrototype); // 继承数组原型的方法
['push', 'shift', 'unshift'].forEach(method => {
    // 函数劫持,把函数进行重写,内部继续调用老的数组方法
    proto[method] = function() {
        updateView(); // 面向切片编程
        oldArrayPrototype[method].call(this, ...arguments);
    }
});
function observer(target) {
    if (typeof target !== 'object' || target == null) {
        return target;
    }
    if (Array.isArray(target)) { // 拦截数组,将数组的方法进行重写
        Object.setPrototypeOf(target, proto);
        // target.__proto__ = proto;
    }
    for (const key in target) {
        if (target.hasOwnProperty(key)) {
            defineReactive(target, key, target[key]);
        }
    }
}

function defineReactive(target, key, value) {
    // 注意点1:针对key的value是对象的情况,递归
    observer(value);
    Object.defineProperty(target, key, {
        get() { // get中进行依赖收集
            return value;
        },
        set(newValue) {
            // 注意点2:针对给key重新赋值的value是对象的情况
            observer(newValue);
            if (newValue !== value) {
                updateView();
                value = newValue;
            }
        }
    });
}

function updateView() {
    console.log('数据更新了');
}

const person = {name: 'kobe', hobbies: ['🏀', '⚽️']};
observer(person);
person.hobbies.push('🏉'); // 需要对数组的方法进行重写
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

Vue中数组的this.$set实现

<body>
    <div id="app-4">
        <ol>
            <li v-for="todo in todos">
            {{ todo.text }}
            </li>
            <li v-for="i in nums">
                {{i}}
            </li>
        </ol>
        <p>{{obj}}</p>
    </div>
    <script>
        var app4 = new Vue({
            el: '#app-4',
            data: {
                todos: [
                    { text: '学习 JavaScript' },
                    { text: '学习 Vue' },
                    { text: '整个牛项目' }
                ],
                nums: [1, 2, 3],
                obj: {
                    name: 'lisi'
                }
            },
            mounted() {
                this.add();
                // this.setObj();
            },
            methods: {
                add() {
                    console.log(this.nums);
                    // this.nums[0] = 6; // 这样改的话,数组中的值是发生变化了,但是页面并不会重新渲染
                    this.$set(this.nums, 0, 6); // 需要调用this.$set方法
                    console.log(this.nums);
                },
                setObj() {
                    // this.obj.age = 12; // 这样改的话,页面不会重新渲染
                    console.log(this.obj);
                    this.$set(this.obj, 'age', 18);
                    console.log(this.obj);
                    this.obj.age = 12;
                }
            }
        });
    </script>
</body>
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

需要注意:在Vue中对数组的下标赋值处理时,是不会触发视图的更新,于是Vue提供了一个静态方法:set

this.$set方法本质上是通过改写后的splice方法来实现的:Vue中只重写了7个数组方法,分别是push,shift,unshift,pop,reverse,sort,splice。

if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val) // 利用扩展的splice方法进行响应式
    return val
}
1
2
3
4
5
// /util/lang.js
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable,
    writable: true,
    configurable: true
  })
}
1
2
3
4
5
6
7
8
9
/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */

// def方法是对Object.defineProperty的封装
import {def} from '../util/index'

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

/**
 * Intercept mutating methods and emit events
 */
// 遍历要重写的方法
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method] // 缓存之前的原型方法
  // arrayMethods是数组原型的副本
  // 将数组方法进行重写,返回结果不变,只不过在返回结果前做了一些事情
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args) // 还是调用原生数组方法进行结果获取
    const ob = this.__ob__ // 是否响应式的标志,保存了依赖收集对象deps
    let inserted // 保存要插入的参数值,是一个数组
    switch (method) {
      case 'push':
      case 'unshift': // 插入操作
        inserted = args // 将要插入的参数赋值给inserted
        break
      case 'splice':
        inserted = args.slice(2) // 获取到要插入的参数
        break
    }
    if (inserted) ob.observeArray(inserted) // 进行数据响应式
    // notify change
    ob.dep.notify() // 手动更新变化
    return result
  })
})
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// observer/index.js
observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
1
2
3
4
5
6
export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

所以,在Vue中调用数组的push、pop等方法时其实不是直接调用的数组原型给我们提供的push、pop等方法,而是调用的重写后的push、pop等方法。

Vue.set数组实现的原理:其实Vue.set()对于数组的处理其实就是调用了splice方法(而splice是被重写过的,会触发视图更新)

实现对象新增属性劫持(this.$set)

Vue 无法检测 property 的添加或移除。由于 Vue 会在初始化实例时对 property 执行 getter/setter 转化,所以 property 必须在 data 对象上存在才能让 Vue 将它转换为响应式的。例如:

var vm = new Vue({
  data: {
    a: 1
  }
});

// `vm.a` 是响应式的

vm.b = 2
// `vm.b` 是非响应式的
1
2
3
4
5
6
7
8
9
10

对于已经创建的实例,Vue 不允许动态添加根级别的响应式 property。但是,可以使用 Vue.set(object, propertyName, value) 方法向嵌套对象添加响应式 property。例如,对于:

Vue.set(vm.someObject, 'b', 2)
1

您还可以使用 vm.$set 实例方法,这也是全局 Vue.set 方法的别名:

this.$set(this.someObject, 'b', 2)
1

从vue源码看Vue.set()和this.$set()

Vue.set和this.$set实现原理

先来看Vue.set的源码:

import {set} from '../observer/index'

...
Vue.set = set // 静态属性
...
1
2
3
4
5

再来看this.$set的源码:

import { set } from '../observer/index'

...
Vue.prototype.$set = set // 原型属性
...
1
2
3
4
5

发现Vue.set和this.$set这两个api的实现原理是一致的,都是使用了set函数。set函数是从../observer/index文件中导出的,区别在于Vue.set()是将set函数绑定在Vue构造函数上,this.$set()是将set函数绑定在Vue原型上。

接下来看下../observer/index中导出的set函数:

export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 先判断如果key本来就是对象中的一个属性,并且key不是Object原型上的属性。说明这个key本来就在对象上面已经定义过了的,直接修改值就可以了,可以自动触发页面刷新
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if (!ob) {
    target[key] = val
    return val
  }
  // 给新的属性定义响应式,以后修改新的属性值也会触发视图更新
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}
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
26
27
28
29
30
31
32
33

使用Proxy来实现数据劫持

Object.defineProperty方法存在如下缺点:

  1. 监听数组的方法不能触发Object.defineProperty方法中set操作(如果我们需要监听的话,我们需要重写数组的方法)。
  2. 必须遍历每个对象的每个属性,如果对象嵌套比较深的话,我们需要递归调用。

参考文档

  1. 深入理解Vue响应式原理
  2. vue系列---响应式原理实现及Observer源码解析(七)
  3. 深入理解 Object.defineProperty 及实现数据双向绑定
  4. Vue 的数据响应式原理