有些面试问题一直记了忘,忘了记。我称其为马冬梅知识。果不其然我最近面试又问了这些问题,又陷入了“啊,马什么梅来着?”的状态,然后挂了。
我觉得针对这些,找出为何如此和如何利用,一般会是比较好的学习和记忆方法。所以这次尝试把他们全部串起来理解一下。
this
this 永远指向最后调用它的那个对象!(箭头函数除外)
无论我们在哪里声明一个函数(箭头函数除外),用的时候谁调用它,this 就是谁。举例函数如下:
function logThis () { console.log(this) }
我们会有以下几种情况
- 直接调用
当没有调用者的时候,调用者是当前作用域。
logThis() // window
- 作为函数属性时
const obj = {
logThis() { console.log(this) }
}
obj.logThis() // obj ,this 就是 obj
[obj.logThis][0]() // [f] ,在这里this就是这个数组了
const logThis = obj.logThis; logThis() // window ,获取了函数的引用并拿出来之后
- 注意:在
'use strict';下的脚本全局作用域中this不再指向全局而是undefined。
这会带来一个好处,我们用函数去声明class的时候,
'use strict';
function MyClass() { this.clazz = "MyClass" }
MyClass() // 报错, this 是 undefined
- 箭头函数在定义时已经绑定了 this 为当前作用域。
注意:箭头函数 bind、call、apply 都不能改变this的指向。
Prototype
基于原型编程
为啥要先介绍一下 this 呢?因为 Javascript 是一门基于原型编程(Prototype-based programming)的语言。 基于原型编程是其中一种实现面向对象编程的风格(另一种常见的就是 基于类),在这种风格中,我们不会显式地定义类 ,而会通过向其他类的实例(对象)中添加属性和方法来创建类,甚至偶尔使用空对象创建类。
而 this 在这种基于原型编程的实现方式中有很大的作用。
在 Javascript 中的基于原型是:
const foo = { one: 1, two: 2, logThis() { console.log(this) } }
const bar = Object.create( foo ) // 等价于 bar.[[Prototype]] = foo
基于原型实际上就是从示例集(prototype objects)去创建(一般是克隆)一个新的实例。示例集和实例同样都是对象。
上面创建了 foo 作为原型,而 bar 通过 Object.create 去“复制”。对它们进行一些操作:
bar.three = 3 // 当然可以给 bar 新增一个 foo 没有的属性
bar.three // 3
bar.one // 1 得到了 foo.one 的结果
bar.two // 2 得到了 foo.two 的结果
foo.one = 'one' // 如果修改 foo.one
bar.one // 'one' 因为实际上返回的是 foo.one
bar.one = 'bar.one'
foo.one // 'one' 相反则不会生效
可以看到,实际上在 Javascript 从原型“复制”新实例这个行为并不是复制,也就是为什么要加引号。可以理解成:
foo.one = 'one' // 赋值
bar.one // 'one' 因为 bar 没有 one 属性 所以会尝试在 bar.[[Prototype]] 也就是 foo 里面找有没有 one
bar.one = 'bar.one' // 这时也是赋值 并且赋值给的是 bar 本身
foo.one // 'one' 因为上面并没有赋值给 foo.one
当原型下有函数时, this 的作用就显现出来了:
foo.logThis() // foo obj
bar.logThis() // bar obj
虽然使用的都是声明在 foo 上的函数,但是因为 this 的指向是在运行时根据指向最后调用它的那个对象决定的,所以 foo 和 bar 可以同时使用同一个函数。
所以实际上原型本身的想法很好理解。正如 Douglas Crockford 所说: Objects inherit from objects. What could be more object oriented than that?[1]
函数、new 和 prototype
用基于原型编程实现继承
如果我们单纯以 Object.create 这种方式来处理继承,一点都不会复杂。但是会缺少一个非常重要的能力—— constructor super。
看一个 ES6 (也就是基于类编程)写一个例子。
// 类的声明
class User {
constructor(name) {
this.name = name
}
}
// 类的实例化
const bob = new User('bob')
如果我单纯用 Object.create 也可以用一个函数封装初始化的工作:
// 原型
const UserProto = {
name: '',
}
function newUser(name) {
const u = Object.create(UserProto)
u.name = name
return u
}
// 类的实例化
const bob = newUser('bob')
然而如果我再加一层继承关系呢?
class User {
constructor(name) {
this.name = name
}
}
class Consumer extends User {
constructor(name, vipLevel) {
super(name)
this.vipLevel = vipLevel
}
}
const bob = new Consumer('bob', 0)
可以发现 Object.create 没什么太好写使用“原型的原型的初始化函数”这种代码。
为了解决这种问题,或者说整出一个跟 class 相同能力的东西,我们可以从下面这些点开始:
- 规定了
new关键字,一个函数可以是一个从原型生成新实例的函数(也就是构造函数)。当使用new去触发这个函数的时候会走特定的逻辑,最终返回一个对应此函数代表原型的新实例。 - 可以构造出一个单向引用链,逐级向上找到并执行所有原型的初始化函数,去解决没有
super的问题。
还有一些额外的期望,以更贴近 class 的行为,如:
- 同一原型链上,所有实例中相同的函数要指向同一个函数,避免重复创建函数;
- 而每个实例的数据都是自有的;
- 原型上有默认数据;
关于 xxx.prototype xxx.__proto__ xxx.[[Prototype]] 的区别先放一下。我知道这个时候其他文章一般就开始拉某个经典马冬梅图出来,我们先用原型“复制”的思路来解决上面的问题。
我们一定要记住,我们并不是在用基于类编程,而是基于原型编程。所以 Consumer 原型会是 User 原型创建, Consumer 实例从 Consumer 原型创建。也就是大概会有下面的代码:
// User 原型
const UserPrototype = {
// 把初始化放到这里
constructor(name) {
this.name = name
},
getName() {
return this.name
},
}
// 用一个 iife 包裹一下相关逻辑
const ConsumerPrototype = (function() {
// Consumer extends User 转换成基于原型的风格是 Consumer 原型继承自 User 原型
const proto = Object.create(UserPrototype)
// 获取上一级的初始化函数
const superConstructor = proto.constructor
// 因为 proto 上没有自己的 constructor 此时会等价于
// proto.[[Prototype]].constructor 也就是 UserPrototype.constructor
// 这样我们就顺利拿到了原型的原型的初始化函数
function constructor(name, vipLevel) {
superConstructor.call(this, name)
this.vipLevel = vipLevel
}
function getVipLevel() {
return this.vipLevel
}
proto.constructor = constructor
proto.getVipLevel = getVipLevel
return proto
})()
// ConsumerPrototype
// ConsumerPrototype.[[Prototype]] 是 UserPrototype
// ConsumerPrototype.getName = ConsumerPrototype.[[Prototype]].getName = UserPrototype.getName
const bob = Object.create(ConsumerPrototype)
bob.constructor('bob', 999) // 执行一遍初始化
函数挂在原型上,后续所有从该原型生成的对象都会引用相同的函数。继承理解为,从此原型生成的新对象,同时也是新的原型。(从 UserPrototype 生成了新对象 ConsumerPrototype ,同时 ConsumerPrototype 也是一个原型)
面向原型的编程里,一定是从原型,而不是像类那样从定义直接创建一个新实例的。当然为了不手动执行初始化,我们使用 new,我们看一下 new 的执行规则:
// new 后面跟着的需要是一个函数
function User(name) {
this.name = name
}
const bob = new User('bob')
// 以下伪代码
what_new_do () {
const consturctor = User
const args = ['bob']
// 注意!这里会读 consturctor.prototype!
const instance = Object.create(consturctor.prototype)
// 有可能返回的不是 instance
const returnInstance = User.apply(instance, args)
return typeof returnInstance === 'object' ? returnInstance : instance
}
new 的关键是它会取当前函数的 prototype 属性作为原型来生成要返回的对象的原型。并且关于 prototype 有以下一些设定:
// 0. Object Function Array 等都是函数
// 0.1 Object Function Array 都有 prototype 属性
// 1. 基本上对象都有同一个原型 Object.prototype (不是所有对象都有这个原型,Object.create(null) 会没有这个原型)
Object.prototype
// { constructor: f, toString: f, ... }
// 2. 函数也是对象
function log() {}
log instanceof Object // true
// 也就是 log.[[Prototype]].[[Prototype]]... 一直往上找肯定能找到 Object.prototype
// 3. 函数也有原型
log.[[Prototype]] = Function.prototype
// function 声明过程实际是从 Function.prototype 进行 Object.create 创建的过程
// 4. function 声明后 自动会有 prototype
User.prototype // { constructor: User }
为了利用 new ,需要把上面的原型挂到构造函数上的 prototype 上。我们可以把上面的 ConsumerPrototype UserPrototype 改变一种写法:
const User = (function() {
'use strict'; // 还记得上面提到的 this 在严格模式下会 undefined 吗
// 相当于上面 UserProtype.consturctor
function User(name) {
this.name = name
}
// 创建一个原型
const prototype = Object.create(Object.prototype, {
constructor: {
value: User // 记得把自己挂回去
},
getName: {
value() {
return this.name
},
},
})
User.prototype = prototype // 挂上去,让 new 过程中可以拿到
return User
})()
const Consumer = (function() {
'use strict';
// 获取上一级的初始化函数
const superConstructor = User
function Consumer(name, vipLevel) {
// super 调用
superConstructor.call(this, name)
this.vipLevel = vipLevel
}
// 一个细节 Consumer 构造函数的 [[Prototype]] = User
Object.setPrototypeOf(Consumer, User) // 这里是为了 static 属性能够继承
// 创建一个原型
const prototype = Object.create(User.prototype, {
constructor: {
value: Consumer // 记得把自己挂回去
},
getVipLevel: {
value() {
return this.vipLevel
},
},
})
Consumer.prototype = prototype // 挂上去
return Consumer
})()
const bob = new Consumer('bob', 999)
小细节
// 1.
{} // {} 并且有 [[Prototype]] = Object.prototype 有 toString 等方法
new Object() // 同上
Object.create(Object.prototype) // 同上
// 2.
Object.create(null) // [[Prototype]] 不存在
Object.create(null) instanceof Object // 所以会得到 false
// 这个使用的场景一般是为了得到一个干净的 map
// 比如遍历此 obj 时不会拿到 Object.prototype 上的函数
// 3. 源头再往上就是 null 了
Object.prototype.__proto__ // null
// 但是 Object.prototype.[[Prototype]] 在 chrome 控制台上并没有显示
// 4. [[Prototype]] 和 __proto__ 概念上是相同的
// 尽量不要使用 __proto__
// 使用 Object.getPrototypeOf
// 5. 函数创建后就会有 prototype 属性,并且 prototype.constructor 指向自己
function test() {}
test.prototype // { constructor: f }
test.prototype.constructor // function test {}
// 6. class 继承写法会导致 Object.getPrototypeOf(Child) === Parent
应用一下
当我们用 function 来声明一个构造器,因为构造器同时是函数,所以构造器是可以直接执行的。如何防止构造器直接被使用了呢?
function User(name) {
// 此时 this 已经是从 User.prototype 生成的对象了
if (!(this instanceof User)) {
throw new TypeError(`User cannot be invoked without 'new'`)
}
this.name = name
}
Proxy 与 Reflect 与 原型链
有看过 babel 编译结果的同学们都会发现真正 class 编译后结果比上面写的复杂的多。
除了要使用 Object.defineProperty 去控制 configurable enumerable writable 等之外,还会遇到一些问题:
- 原型本身是 Proxy,再由此生成的新实例会有些问题;
- 原型的构造器是 Proxy (也就是如
const ProxyUser = new Proxy(User, {...}); new ProxyUser()),会有什么后果;
要解决上面两个问题,首先要搞懂 Proxy 和 Reflect 的作用。
基本原理
- Proxy 是用于创建一个对象的代理,从而实现基本操作的拦截和自定义。
- Reflect 是一个包含用于调用可拦截的(1) JavaScript 对象内部方法(2)(invoking interceptable(1) JavaScript object internal methods(2))
MDN 上英文文档的介绍用此更明确。中文文档看了让人一头雾水 -_-。
- 可拦截的:Reflect 里面函数都是跟
new Proxy(target, handler)中 handler 的方法是一一对应的。这也就是可被中断的函数的含义——指被 Proxy 中断。 - 对象内部方法:哪些是对象内部方法呢?比如
new的调用;delete的使用;获取/设置[[Prototype]];执行gettersetter。
其中最典型的就是 getter setter 导致的问题。
getter setter
const user = {
_name: 'bob',
get name() { return this._name; },
set name(val) { this._name = val; },
};
user.name // 'bob'
getter setter 是函数,但是外部无法获取到这个函数本身,通过值的获取/赋值方式会直接触发函数执行。
既然是函数, 就是在实际执行时 this 才会确定。在上面例子中, this 因为 user.name 所以按照惯例是指向 user 。但是如果同时加入原型链和 Proxy 的影响,就未必了:
const UserPrototype = {
_name: 'name not set yet',
get name() { return this._name; },
set name(val) { this._name = val; },
};
const proxyUserPrototype = new Proxy(UserPrototype, {
get(target, prop, receiver) {
// target UserPrototype
// prop 'name'
// receiver bob
return target[prop]
},
})
UserPrototype.name // 'name not set yet'
const bob = Object.create(proxyUserPrototype, {
_name: {
value: 'bob'
}
})
bob.name // 'name not set yet'
按照通常的理解 bob.name 虽然触发了原型链上的 get name() { ... } 但是很明显这里的 this 应该指向 bob 而不是 UserPrototype 才对。为什么会直接指向到了 UserPrototype 呢?
这是因为在我们 Proxy 的 handler.get 中,实际上返回的是 UserPrototype['name']。
而我们无法获取 UserPrototype.[getter name] 这个函数从而使用 UserPrototype.[getter name].call(receiver) 将 this 纠正到正确的指向。(如果直接返回 receiver['name'] 就是 bob.name ,触发死循环了)
所以这里需要 Reflect.get(target, prop, receiver) 去解决这个问题:
const proxyUserPrototype = new Proxy(UserPrototype, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver)
},
})
Reflect.get 在这里就是帮助我们调用内部的方法(也就是 UserPrototype.[getter name] )从而纠正 this 指向。
代理构造函数
对 User 类的实例上所有属性的赋值进行日志打印,如何使用Proxy实现?
可以使用 Proxy handler.construct 对 new 过程进行代理。
class User {
name = '123'
logName() {
console.log(this.name)
}
}
const ProxyUser = (function() {
return new Proxy(User, {
construct(target, args, newTarget) { // 中断了 `new` 的过程
// target User
// newTarget ProxyUser
const instance = Reflect.construct(target, args, newTarget) // 相当于 new User()
return new Proxy(instance, {
set(target, prop, value, receiver) {
console.log(`set ${prop} ${value}`)
Reflect.set(target, prop, value, receiver)
}
})
}
})
})()
const bob = new ProxyUser()
对不存在的属性值获取进行报错
使用 Proxy 使对于某个 class 的实例,在获取未被定义的属性时能报错.
注意如果仅仅是值为 undefined 或者 null 是不需要报错的。
我们可以利用 class 本身也是 function,或者说 function 可以当作 class 的特性:
const KeyNotExistThrowError = (function() {
function _KeyNotExistThrowError() {}
// 重制 prototype
_KeyNotExistThrowError.prototype = new Proxy({
// 防止因为缺少 Symbol.toStringTag 导致 toString 报错
// 当然也可以在下面判断加一个例外 那最终就会输出 '[object Object]'
[Symbol.toStringTag]: 'KeyNotExistThrowError',
}, {
get(target, prop, receiver) {
if (Reflect.has(target, prop)) {
// 支持 instanceof
if (prop === 'constructor') return KeyNotExistThrowError
return Reflect.get(target, prop, receiver)
} else {
throw Error(`prop ${String(prop)} not defined`)
}
}
})
return _KeyNotExistThrowError
})()
class User extends KeyNotExistThrowError {
name = '123'
age = undefined
logName() {
console.log(this.name)
}
}
class Consumer extends User {
vipLevel = 999
logVipLevel() {
console.log(this.vipLevel)
}
}
const bob = new Consumer()
bob.age // undefined
bob instanceof KeyNotExistThrowError // true
bob.toString() // '[object KeyNotExistThrowError]'
bob.haha // Error: prop haha not defined
总结
暂时研究到这里。之前一直没有关注“名称”,也就是原型这个词背后代表的含义和设计理念,一直尝试用基于类的风格去理解原型,没有理解“原型 = 对象 = 实例”,导致自己一直处于半懂不懂的状态。(没有用扮演法 🐶)
最后结合 Proxy 可以整出一些让人觉得眼前一黑相当奇妙的效果,也算是学以致用来。(不过说实话,还是没啥用 🐶)