Vue2源码笔记(4)运行时-创建一个vue实例之initState数据劫持

前言

在上一篇中,我们大致了解了创建一个Vue实例的核心在于运行其中的_init()函数,而在_init()函数中又执行了许多初始化操作。

数据初始化

在这篇中我们来整理initState(),这是其中负责数据初始化的函数。

php 复制代码
/**
 * Vue实例的数据初始化
 * @param {Vue} vm Vue实例
 */
function initState(vm) {
    const opts = vm.$options;
    if (opts.data) {
        initData(vm)
     }
}
kotlin 复制代码
function initData(vm) {
    let data = vm.$options.data; // 获取创建实例时的options中的data数据
    // options中的data有时候会携程函数形式data() {return {}}
    data = vm._data = isFunction(data) ? data.call(vm) : data || {}; // 使用data.call(vm)而不是data(),因为书写时data()中的会用到this参与赋值,本意肯定是指向当前vm的,需要保证this指向正确
    // observe data
    const ob = observe(data)
}

在数据初始化中,最重要的就是vue的核心:响应式。这通过observe(data)实现

数据劫持

通过设置getter\setter,观测数据的读写。

单层劫持

javascript 复制代码
// observe/index.js
function observe(value) {
    // observe只处理object, 其他的不需要响应式,直接 return
    if(!isObject(value)){
        return;
    }
    // 返回一个响应式对象
    return new Observer(value);
}
​
class Observer {
    constructor(value) {
        this.walk(value);
    }
    
    walk(data) {
        // 为data中的值逐个创建响应式
        Object.keys(data).forEach(key => { // Object.keys(data)返回data中所有可枚举的键的数组,用于遍历
            defineReactive(data, key, data[key]);
        });
    }
}
/**
 * 使用Object.defineProperty重新定义data对象中的属性
 * 通过设置getter和setter,我们在数据被读、写时都放入了钩子
 * @param {*} obj 需要定义属性的对象
 * @param {*} key 键
 * @param {*} value 值
 */
function defineReactive(obj, key, value) {
    Object.defineProperty(obj, key, {
        get() {
            return value; // 整理通过闭包取值,如果通过Obj[key]获取的话会触发死循环
        },
        set(newValue) {
            if (newValue === value) return;
            value = newValue;
        }
    })
}

至此,我们已经可以observe对象中每个值的读、写操作了。

深层劫持

但是细心的我们已经发现,这样做,只能对类似data: {message: 'Hello Vue'}这样的单层对象完成数据劫持,但是对于data: {obj: {key: 'v'}}这样的对象是无法对深层数据进行劫持的。

对于这样的场景,我们很容易联想到,通过递归来实现:

javascript 复制代码
function defineReactive(obj, key, value) {
    observe(value); // 对值进行递归,以实现深层劫持
    Object.defineProperty(obj, key, {
        get() {
            return value;
        },
        set(newValue) {
            if (newValue === value) return;
            value = newValue;
        }
    })
}

数组劫持

数组劫持是一个经典的vue2问题,vue2并非完全不能处理数组数据,类似arr.push()这样的操作是可以被响应式处理的,但类似arr[0] = 1这样的操作却不行。这并非代码无法实现,而是如果采用上述的方式来处理数组数据,那么当数组数据量变大后,对每一项创建响应式对象,将带来性能问题(比如对10000条数据添加get&set,再想想我们再get中使用了闭包进行取值...)。

所以考虑到需求和性能的平衡,vue2并不通过defineProperty 处理数组,而是另辟蹊径对数组原型中的拓展方法采取行动。

vue2认为这 7 个方法能够改变原数组:push、pop、splice、shift、unshift、reverse、sort,于是重写了这7个方法。

javascript 复制代码
class Observer {
    constructor(value) {
        // 将Observer实例记录到data属性中
        Object.defineProperty(value, '__ob__', {
          value:this,
          enumerable:false  // 不可被枚举, 这就是不适用value.__ob__ = this; 的原因,一旦可枚举就会在
        });
        if (isArray(value)) {
            value.__proto__ = arrayMethods; // 重新设置原型链,使得钩子能被放入数组原型方法中this.walk(value);中走入死循环
            this.observeArray(value);
        } else {
            this.walk(value);
        }
        
    }
    
    walk(data) {
        // 为data中的值逐个创建响应式
        Object.keys(data).forEach(key => { // Object.keys(data)返回data中所有可枚举的键的数组,用于遍历
            defineReactive(data, key, data[key]);
        });
    }
    // 对改变原数组的值的处理方法,由于创建的响应式是一个Observer对象,所以对于其中数组的值也需要在这个对象中处理
    observeArray(value) {
        for (let i = 0, l = value.length; i < l; i++) {
            observe(value[i]);
        }
    }
}

TODO1:目前看来Observer类中的逻辑似乎是可以单独执行的,那么为什么vue2使用了通过创建Observer类的实例的方式来实现响应式呢?这个问题在后续会揭晓答案。

TODO2:我们可以看出,到目前位置,只有initData()中返回的Observer对象被使用,其他的都仅仅用于数据劫持,就被"抛弃"。这是为什么呢?

ini 复制代码
// Observer/array.js
let oldArrayPrototype = Array.prototype;
let arrayMethods = Object.create(oldArrayPrototype); // 原型继承,以oldArrayPrototype为原型创建新对象,将原型链往后移动
let methods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'reverse',
  'sort',
  'splice'
]
​
methods.forEach(method => {
    arrayMethods[method] = function (...args) {
        // 调用数组原生方法逻辑(绑定到当前调用上下文)
        oldArrayPrototype[method].call(this, ...args);
        // 数组新增的属性如果是属性,要继续观测
        let inserted = null;
        let ob = this.__ob__; // 此时this指向的是调用函数的数组,这个数组在observe()阶段已经被添加了__ob__属性,这个属性指向数据对应的Observer实例
        switch(method) {
            case 'splice':
                inserted = args.slice(2);
                break;
            case 'push':
            case 'unshift':
                inserted = args;
                break;
        }
        if (inserted) ob.observeArray(inserted); // 这里inserted有值就一定是数组
    }
});

TODO3: 明明只有3个原型方法会导致数组中有新增数据,需要进行数据劫持,为什么要重写7个方法而不是3个呢?

边界情况

在完成了对数据新增\初始化时的劫持后,我们并没有大功告成。众所周知,js是弱类型的语言,变量是可以修改的甚至可以修改成其他的数据类型。所以在实际使用中难免会出现一下情况:

数据修改为对象\数组

scss 复制代码
function defineReactive(obj, key, value) {
    observe(value); // 对值进行递归,以实现深层劫持
    Object.defineProperty(obj, key, {
        get() {
            return value;
        },
        set(newValue) {
            if (newValue === value) return;
            observe(newValue); // 一旦newValue不是对象类型,observe的代码逻辑会停下,不会造成副作用
            value = newValue;
        }
    })
}

[Warning] 对象数据新增属性无法添加数据劫持,Vue2提供了.$set()API来解决这个问题。

总结

这篇中我们对数据劫持做了一个整理,包括:

  1. 使用Observer类进行数据劫持
  2. 对对象、数组两大类型的数据劫持通过不同的方式进行
  3. 采用递归思想解决嵌套属性的数据劫持问题
  4. 对边界情况中的数据类型修改为对象\数组进行了处理
相关推荐
小小小小宇2 分钟前
Vue `import` 为什么可以异步加载
前端
WMYeah7 分钟前
【无标题】
前端·rust·抽奖程序·跨平台抽奖程序
Unbelievabletobe8 分钟前
免费外汇api的响应时间在不同时段下的波动分析
大数据·开发语言·前端·python
大哥,带带弟弟17 分钟前
Grafana 前端嵌入与 JWT 鉴权实战
前端·grafana
小小小小宇18 分钟前
前端 V8 引擎垃圾回收机制与内存问题排查
前端
前端老石人29 分钟前
CSS 值定义语法
前端·css
sheeta199840 分钟前
Vue 前端基础笔记
前端·vue.js·笔记
小小小小宇40 分钟前
GitLab + GitLab Runner + Qiankun 微前端 + Nginx + Node 中间件 前端开发机从零搭建 CI/CD 全流程
前端
前端那点事44 分钟前
别再写垃圾组件!Vue3 如何设计「真正可复用」的高质量通用组件
前端·vue.js
卷帘依旧1 小时前
JavaScript 中的 Symbol
前端·javascript