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. 对边界情况中的数据类型修改为对象\数组进行了处理
相关推荐
碎像18 分钟前
uni-app实战教程 从0到1开发 画图软件 (学会画图)
前端·javascript·css·程序人生·uni-app
Hilaku34 分钟前
从“高级”到“资深”,我卡了两年和我的思考
前端·javascript·面试
WebInfra1 小时前
Rsdoctor 1.2 发布:打包产物体积一目了然
前端·javascript·github
用户52709648744901 小时前
SCSS模块系统详解:@import、@use、@forward 深度解析
前端
兮漫天1 小时前
bun + vite7 的结合,孕育的 Robot Admin 【靓仔出道】(十一)
前端·vue.js
xianxin_1 小时前
CSS Text(文本)
前端
秋天的一阵风1 小时前
😈 藏在对象里的 “无限套娃”?教你一眼识破循环引用诡计!
前端·javascript·面试
电商API大数据接口开发Cris1 小时前
API 接口接入与开发演示:教你搭建淘宝商品实时数据监控
前端·数据挖掘·api
用户1409508112801 小时前
原型链、闭包、事件循环等概念,通过手写代码题验证理解深度
前端·javascript
汪子熙1 小时前
错误消息 Could not find Nx modules in this workspace 的解决办法
前端·javascript