我正在参与掘金会员专属活动-源码共读第一期,点击参与。
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 操作符,讲解得非常详细。
前置知识
在实现该特点之前,我们需要了解以下这三个知识点:
- 构造函数的
this
指向 call
、bind
、apply
函数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 函数
call
、apply
、bind
函数是用来改变函数的 this
指向。
call
函数的第一个参数是要改变函数内部 this
指向的对象,后面的每一个参数是和函数正常调用时传递的参数一样。
apply
函数的第一个参数与 call
函数一样,第二个参数是一个数组,等同于把 call
函数第二个及以后的参数放到了这个数组里。
bind
函数的参数形式和 call
函数一样,但它的返回值是一个函数,这个函数的 this
指向是 bind
函数的第一个参数。
注意,
bind
函数内部并没有立马执行原函数,而是在返回的函数里执行了原函数,所以需要再调用一次函数,而call
和apply
函数在内部则是立马执行了原函数。
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}
复制代码
如果有朋友想深入了解
call
、apply
、bind
函数的实现原理,可以看这篇文章—手撕 call、apply、bind 函数,讲解得非常详细。
Object.defineProperty() 方法
它是干嘛用的呢?其实看它的方法名就知道它是用来干什么的了。没错,它就是用来给对象定义属性用的。
有的朋友可能会感到奇怪,给对象定义属性还需要调用 Object.defineProperty()
方法吗?不是直接在对象里面写属性和属性值就可以了吗?
是的,如果只是单纯给对象定义一个属性,确实不需要调用这个方法了。但如果我想把这个属性加上不可修改,或不可被遍历,或不能被删除的特性,那么 Object.defineProperty()
方法就派上用场了。
Object.defineProperty(obj, prop, descriptor)
方法有三个参数,其中:
obj
表示定义属性的对象prop
表示要定义或修改的属性名descriptor
属性描述符,是一个对象,用来描述参数prop
的特性。
重点来看第三个参数—descriptor
对象,它是 Object.defineProperty()
方法的灵魂。也就是通过它,我们可以设置属性是否可以被修改或是被删除。
属性描述符对象的键值有:
value
:获取属性时返回的值。writable
:该属性是否可写。enumerable
:该属性是否可以被枚举,也就是能不能被for in
循环遍历。set
:是一个函数,当属性值被修改时,会调用该函数,它接收一个参数,作为被修改的属性值。get
:是一个函数,获取属性值时,会调用该函数。configurable
:表示该属性的属性描述符能不能被再次修改以及该属性能不能被删除。当取值为false
时,上述的键值都不能被二次设置,该属性也不能被删除。
value
,set
,get
默认值为 undefined
;writable
,enumerable
,configurable
默认值为 false
。
上述的键不是都能同时被设置的,因此属性描述符分为两种:
- 数据描述符:设置的键为
value
,writable
,enumerable
,configurable
。 - 存取描述符:设置的键为
set
,get
,enumerable
,configurable
。
这两种描述符是互斥的关系,主要是 value
和 writable
不能与 set
和 get
同时存在,否则就会报错。
再来说说,set
和 get
函数的 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
的构造函数,如何做到类似 Vue2
中 this
能够直接获取 data
和 methods
的特点呢?
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 岁
复制代码
是不是很简单?只通过四行代码就实现了这个特性。客观来说,如果只是为了单纯实现这个特性,确实可以使用这种方法,但是它有一个致命的缺点:实例对象(外部的 vm
或 this
)在获取或修改属性值时,Vue
内部是无法感知的,结果导致 Vue
内部数据无法更新,数据无法更新意味着视图无法得到更新。
那应该怎么办?这时候 Object.defineProperty()
方法就派上用场了,它也是 Vue
实现双向数据绑定的基础。
在前置知识部分讲过,Object.defineProperty()
方法中的 set
和 get
函数分别是在修改属性值和获取属性值会被调用。所以主要是通过这两个函数去通知 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
的特性,需要做两件事:
- 改变
methods
中所有函数的this
指向为实例对象,这样在函数内部就能通过this
直接获取data
的数据了。 - 将
methods
中所有函数赋值到实例对象上,这样就能通过this
(或者说是实例对象) 直接获取methods
了。
第一件事该怎么做呢?在前置知识部分讲过,改变函数的 this
指向有三种方法:call
、apply
、bind
函数,都能改变 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 岁
复制代码
总结
- 本文涉及到的前置知识有:构造函数的
this
指向,call
、apply
、bind
函数,Object.defineProperty()
方法,它们是实现Vue2
中this
能够直接获取data
和methods
特性的基础。 this
能够直接获取data
是因为data
里的属性会赋值到Vue
实例对象上的_data
属性里,在通过this.xxx
访问数据时,是访问Object.defineProperty()
方法代理后的this._data.xxx
;同理,修改数据时,也是修改this._data.xxx
。this
能够直接获取methods
是因为methods
里的函数通过bind
改变了this
指向为实例对象,并将这些函数赋值给了实例对象。
最后附上 Vue2
源码内的实现逻辑:
其中 initMethods
和 initData
函数是我们上文同样的基本实现逻辑,不过 Vue2
增加了更多的细节。