源码学习—实现 Vue2 中 this 能够直接获取 data 和 methods

Vue

我正在参与掘金会员专属活动-源码共读第一期,点击参与

this 直接获取 data 和 methods 的特点

在日常使用 Vue2 的过程中,我们通过在 data 属性中定义 Vue 的数据,在 methods 属性中定义 Vue 的方法,并且在方法里可以通过 this 直接获取数据,也可以通过 this 调用其他方法。如下所示:

const vm = new Vue({
    data: {
        name: '我是焦糖不丁',
        age: '今年 18 岁'
    },
    methods: {
        sayName(){
            console.log(this.name);
        },
        sayAge() {
            console.log(this.age);
        },
        introduce() {
            this.sayName();
            this.sayAge();
        }
    },
});
console.log(vm.name); // 我是焦糖不丁
console.log(vm.age); // 今年 18 岁
/*
    我是焦糖不丁
    今年 18 岁
*/
console.log(vm.introduce()); 
复制代码

这段代码是比较简单的 Vue 使用方式,它通过 new 操作符来使用 Vue,并生成一个实例对象,该实例对象赋值给了常量 vm

我们也可以通过实例对象 vm 获取 data 中的数据,或调用 methods 中的方法。

其实 Vue 里面的 this 指向就是 vm 这个实例对象。

如果有朋友想深入了解 new 操作符的实现原理,可以看这篇文章—手撕 new 操作符,讲解得非常详细。

前置知识

在实现该特点之前,我们需要了解以下这三个知识点:

  1. 构造函数的 this 指向
  2. callbindapply 函数
  3. Object.defineProperty() 方法

构造函数的 this 指向

构造函数的 this 指向是一个普通的对象,而不是 window 对象,这个普通对象又叫实例对象,比如:

function Robot(name, age) {
    this.name = name;
    this.age = age;
    console.log(this); // { name: '焦糖不丁', age: 18 }
    console.log(this === window); // false
}
const r = new Robot('焦糖不丁', 18);
console.log(r); // { name: '焦糖不丁', age: 18 }
复制代码

当然了,如果 Robot 函数只是当作普通函数去调用,而不是用 new 操作符当作构造函数去调用的话,在非严格模式下,this 指向是 window

function Robot(name, age) {
    this.name = name;
    this.age = age;
    console.log(this === window); // true
}
Robot('焦糖不丁', 18)
复制代码

call、apply、bind 函数

callapplybind 函数是用来改变函数的 this 指向。

call 函数的第一个参数是要改变函数内部 this 指向的对象,后面的每一个参数是和函数正常调用时传递的参数一样。

apply 函数的第一个参数与 call 函数一样,第二个参数是一个数组,等同于把 call 函数第二个及以后的参数放到了这个数组里。

bind 函数的参数形式和 call 函数一样,但它的返回值是一个函数,这个函数的 this 指向是 bind 函数的第一个参数。

注意,bind 函数内部并没有立马执行原函数,而是在返回的函数里执行了原函数,所以需要再调用一次函数,而 callapply 函数在内部则是立马执行了原函数。

const r1 = {};
const r2 = {};
const r3 = {};
Robot.call(r1, '焦糖不丁', 18); // this === window --> false
Robot.apply(r2, ['焦糖不丁', 18]); // this === window --> false
Robot.bind(r3, '焦糖不丁', 18)(); // this === window --> false
console.log(r1); // {name: '焦糖不丁', age: 18}
console.log(r2); // {name: '焦糖不丁', age: 18}
console.log(r3); // {name: '焦糖不丁', age: 18}
复制代码

如果有朋友想深入了解 callapplybind 函数的实现原理,可以看这篇文章—手撕 call、apply、bind 函数,讲解得非常详细。

Object.defineProperty() 方法

它是干嘛用的呢?其实看它的方法名就知道它是用来干什么的了。没错,它就是用来给对象定义属性用的。

有的朋友可能会感到奇怪,给对象定义属性还需要调用 Object.defineProperty() 方法吗?不是直接在对象里面写属性和属性值就可以了吗?

是的,如果只是单纯给对象定义一个属性,确实不需要调用这个方法了。但如果我想把这个属性加上不可修改,或不可被遍历,或不能被删除的特性,那么 Object.defineProperty() 方法就派上用场了。

Object.defineProperty(obj, prop, descriptor) 方法有三个参数,其中:

  1. obj 表示定义属性的对象
  2. prop 表示要定义或修改的属性名
  3. descriptor 属性描述符,是一个对象,用来描述参数 prop 的特性。

重点来看第三个参数—descriptor 对象,它是 Object.defineProperty() 方法的灵魂。也就是通过它,我们可以设置属性是否可以被修改或是被删除。

属性描述符对象的键值有:

  1. value:获取属性时返回的值。
  2. writable:该属性是否可写。
  3. enumerable:该属性是否可以被枚举,也就是能不能被 for in 循环遍历。
  4. set:是一个函数,当属性值被修改时,会调用该函数,它接收一个参数,作为被修改的属性值。
  5. get:是一个函数,获取属性值时,会调用该函数。
  6. configurable:表示该属性的属性描述符能不能被再次修改以及该属性能不能被删除。当取值为 false 时,上述的键值都不能被二次设置,该属性也不能被删除。

valuesetget 默认值为 undefinedwritableenumerableconfigurable 默认值为 false

上述的键不是都能同时被设置的,因此属性描述符分为两种:

  1. 数据描述符:设置的键为 valuewritableenumerableconfigurable
  2. 存取描述符:设置的键为 setgetenumerableconfigurable

这两种描述符是互斥的关系,主要是 valuewritable 不能与 setget 同时存在,否则就会报错。

再来说说,setget 函数的 this 指向问题。排除箭头函数的情况,它们的 this 指向并不总是参数 obj,更准确地来说,指向的是访问或修改属性的对象。

一般情况下,this 指向是参数 obj

const obj = {};
Object.defineProperty(obj, "name", {
    configurable: true,
    enumerable: true
    set: function (newVal) {
        console.log(this === obj); // true
        this.value = newVal;
    },
    get: function () {
        console.log(this === obj); // true
        return this.value;
    },
});
obj.name = 'jack'
obj.name
复制代码

但如果访问或被修改的属性是从父对象继承过来的,那么 this 指向是子对象:

function Person() {}
Object.defineProperty(Person.prototype, "name", {
    set: function (newVal) {
        console.log(this === Person.prototype); // false
        console.log(this === p1); // true
        this.value = newVal;
    },
    get: function () {
        return this.value;
    },
});
const p1 = new Person();
p1.name = "jack";
复制代码

因此,set 和 get 函数的 this 指向是访问或修改属性的对象。

源码实现

有前置知识的灌输,假如我们让自己实现 miniVue 的构造函数,如何做到类似 Vue2this 能够直接获取 datamethods 的特点呢?

function miniVue(options) {

}
const vm = new miniVue({
    data: {
        name: '我是焦糖不丁',
        age: '今年 18 岁'
    },
    methods: {
        sayName(){
            console.log(this.name);
        },
        sayAge() {
            console.log(this.age);
        },
        introduce() {
            this.sayName();
            this.sayAge();
        }
    },
});
console.log(vm.name); // 我是焦糖不丁
console.log(vm.age); // 今年 18 岁
/*
    我是焦糖不丁
    今年 18 岁
*/
console.log(vm.introduce()); 
复制代码

this 直接获取 data

我们先来实现 this 直接获取 data 的特性。

这个 this 指向的是 miniVue 的实例对象,我们把 data 里面的各个属性值复制到这个实例对象上,不就能通过 this 直接获取 data 了吗?

function miniVue(options) {
    const $data = options.data;
    for (const key in $data) {
        this[key] = data[key];
    }
}
 const vm = new miniVue({
    data: {
        name: "我是焦糖不丁",
        age: "今年 18 岁",
    }
  });
console.log(vm.name); // 我是焦糖不丁
console.log(vm.age); // 今年 18 岁
复制代码

是不是很简单?只通过四行代码就实现了这个特性。客观来说,如果只是为了单纯实现这个特性,确实可以使用这种方法,但是它有一个致命的缺点:实例对象(外部的 vmthis)在获取或修改属性值时,Vue 内部是无法感知的,结果导致 Vue 内部数据无法更新,数据无法更新意味着视图无法得到更新。

那应该怎么办?这时候 Object.defineProperty() 方法就派上用场了,它也是 Vue 实现双向数据绑定的基础。

在前置知识部分讲过,Object.defineProperty() 方法中的 setget 函数分别是在修改属性值和获取属性值会被调用。所以主要是通过这两个函数去通知 Vue 内部数据被修改或者被获取了。

function miniVue(options) {
    const mnVm = this;
    const data = mnVm._data = options.data;
    const sharedPropertyDefinition = {
        enumerable: true,
        configurable: true,
        get: function() {},
        set: function() {},
    };
    for (const key in data) {
        sharedPropertyDefinition.get = function proxyGetter () {
            // 在该函数内部通知 Vue 内部数据被获取了
            return this['_data'][key]
        };
        sharedPropertyDefinition.set = function proxySetter (val) {
            // 在该函数内部通知 Vue 内部数据被修改了
            this['_data'][key] = val;
        };
        Object.defineProperty(mnVm, key, sharedPropertyDefinition);
    }
}
 const vm = new miniVue({
    data: {
        name: "我是焦糖不丁",
        age: "今年 18 岁",
    }
  });
console.log(vm.name); // 我是焦糖不丁
console.log(vm.age); // 今年 18 岁
复制代码

代码 mnVm._data = options.data 的作用是将 data 中的数据作为实例对象的内部数据。

至此,就已经实现 Vue2 内部 this 能够直接获取 data 的特性了,我们再将代码整理一下,补充一些细节,并相对应的功能逻辑抽成函数。

const sharedPropertyDefinition = {
    enumerable: true,
    configurable: true,
    get: function () {},
    set: function () {},
};

function proxy(target, sourceTarget, key) {
    sharedPropertyDefinition.get = function proxyGetter() {
        return this[sourceTarget][key];
    };
    sharedPropertyDefinition.set = function proxySetter(val) {
         this[sourceTarget][key] = val;
    };
    Object.defineProperty(target, key, sharedPropertyDefinition);
}

function initData(mnVm) {
    const data = (mnVm._data = mnVm.$options.data);
    for (const key in data) {
        // 确保获取的属性是 data 自身的属性而不是原型链上的属性
        if (data.hasOwnProperty(key)) {
            proxy(mnVm, "_data", key);
        }
    }
}

function miniVue(options) {
    const mnVm = this;
    // 设置实例对象内部的配置
    mnVm.$options = options;
    if (options.data) {
        initData(mnVm);
    }
}

const vm = new miniVue({
    data: {
        name: "我是焦糖不丁",
        age: "今年 18 岁",
    },
});
console.log(vm.name); // 我是焦糖不丁
console.log(vm.age); // 今年 18 岁
复制代码

将整体代码拆分成各个函数,一个函数只实现一个功能(单一原则),凡是以 _$ 开头的属性名,都表示内部数据的意思。

this 直接获取 methods

实现 methods 的特性,需要做两件事:

  1. 改变 methods 中所有函数的 this 指向为实例对象,这样在函数内部就能通过 this 直接获取 data 的数据了。
  2. methods 中所有函数赋值到实例对象上,这样就能通过 this(或者说是实例对象) 直接获取 methods 了。

第一件事该怎么做呢?在前置知识部分讲过,改变函数的 this 指向有三种方法:callapplybind 函数,都能改变 this 指向,应该用哪种方法更好呢?

我们在改变 this 指向的时候,如果使用 call 或者 apply,那么 methods 里的函数就会执行一次,而我们需要的是在不执行函数的情况下,改变 this 的指向,答案也就呼之欲出了,只能使用 bind 函数改变 this 指向。

第一件事做完之后,第二件事就简单了,直接将改变 this 指向后的函数赋值到实例对象就可以了。代码实现如下:

// ....省略 data 部分的逻辑代码
function miniVue(options) {
    const mnVm = this;
    mnVm.$options = options;
 
    // ....省略 data 部分的逻辑代码

    const methods = mnVm.$options.methods;
    for (const key in methods) {
        if (methods.hasOwnProperty(key)) {
            // 这一行代码做了 `methods` 的两件事
            mnVm[key] = methods[key].bind(mnVm);
        }
    }
}
const vm = new miniVue({
    data: {
        name: "我是焦糖不丁",
        age: "今年 18 岁",
    },
    methods: {
        sayName() {
            console.log(this.name);
        },
        sayAge() {
            console.log(this.age);
        },
        introduce() {
            this.sayName();
            this.sayAge();
        },
    },
});
vm.sayName(); // 我是焦糖不丁
vm.sayAge(); // 今年 18 岁
vm.introduce(); // 我是焦糖不丁 今年 18 岁
复制代码

再将 methods 相关逻辑整理成一个函数:

// ....省略 data 部分的逻辑代码

function initMethods(mnVm) {
    const methods = mnVm.$options.methods;
    for (const key in methods) {
        if (methods.hasOwnProperty(key)) {
            // 这一行代码做了 `methods` 的两件事
            mnVm[key] = methods[key].bind(mnVm);
        }
    }
}

function miniVue(options) {
    const mnVm = this;
    mnVm.$options = options;
 
    // ....省略 data 部分的逻辑代码

    if (options.methods) {
        initMethods(mnVm);
    }
}
复制代码

用65行代码实现mini版

const sharedPropertyDefinition = {
    enumerable: true,
    configurable: true,
    get: function () {},
    set: function () {},
};
function proxy(target, sourceTarget, key) {
    sharedPropertyDefinition.get = function proxyGetter() {
        return this[sourceTarget][key];
    };
    sharedPropertyDefinition.set = function proxySetter(val) {
        this[sourceTarget][key] = val;
    };
    Object.defineProperty(target, key, sharedPropertyDefinition);
}
function initData(mnVm) {
    const data = (mnVm._data = mnVm.$options.data);
    for (const key in data) {
        if (data.hasOwnProperty(key)) {
            proxy(mnVm, "_data", key);
        }
    }
}
function initMethods(mnVm) {
    const methods = mnVm.$options.methods;
    for (const key in methods) {
        if (methods.hasOwnProperty(key)) {
            mnVm[key] = methods[key].bind(mnVm);
        }
    }
}
function miniVue(options) {
    const mnVm = this;
    mnVm.$options = options;
    if (options.data) {
        initData(mnVm);
    }
    if (options.methods) {
        initMethods(mnVm);
    }
}

const vm = new miniVue({
    data: {
        name: "我是焦糖不丁",
        age: "今年 18 岁",
    },
    methods: {
        sayName() {
            console.log(this.name);
        },
        sayAge() {
            console.log(this.age);
        },
        introduce() {
            this.sayName();
            this.sayAge();
        },
    },
});
console.log(vm.name); // 我是焦糖不丁
console.log(vm.age); // 今年 18 岁
vm.sayName(); // 我是焦糖不丁
vm.sayAge(); // 今年 18 岁
vm.introduce(); // 我是焦糖不丁 今年 18 岁
复制代码

总结

  1. 本文涉及到的前置知识有:构造函数的 this 指向,callapplybind 函数, Object.defineProperty() 方法,它们是实现 Vue2this 能够直接获取 datamethods 特性的基础。
  2. this 能够直接获取 data 是因为 data 里的属性会赋值到 Vue 实例对象上的 _data 属性里,在通过 this.xxx 访问数据时,是访问 Object.defineProperty() 方法代理后的 this._data.xxx;同理,修改数据时,也是修改 this._data.xxx
  3. this 能够直接获取 methods 是因为 methods 里的函数通过 bind 改变了 this 指向为实例对象,并将这些函数赋值给了实例对象。

最后附上 Vue2 源码内的实现逻辑:

Vue

其中 initMethodsinitData 函数是我们上文同样的基本实现逻辑,不过 Vue2 增加了更多的细节。

猜你喜欢

转载自juejin.im/post/7219862257644126265