马冬梅知识系列之 this、Prototype、Proxy、Reflect

323 阅读11分钟

有些面试问题一直记了忘,忘了记。我称其为马冬梅知识。果不其然我最近面试又问了这些问题,又陷入了“啊,马什么梅来着?”的状态,然后挂了。

我觉得针对这些,找出为何如此如何利用,一般会是比较好的学习和记忆方法。所以这次尝试把他们全部串起来理解一下。

this

this 永远指向最后调用它的那个对象!(箭头函数除外)

无论我们在哪里声明一个函数(箭头函数除外),用的时候谁调用它,this 就是谁。举例函数如下:

function logThis () { console.log(this) }

我们会有以下几种情况

  1. 直接调用

当没有调用者的时候,调用者是当前作用域。

logThis() // window
  1. 作为函数属性时
const obj = {
    logThis() { console.log(this) }
}
obj.logThis() // obj ,this 就是 obj
[obj.logThis][0]() // [f] ,在这里this就是这个数组了
const logThis = obj.logThis; logThis() // window ,获取了函数的引用并拿出来之后
  1. 注意:在 'use strict'; 下的脚本全局作用域中this不再指向全局而是undefined。

这会带来一个好处,我们用函数去声明class的时候,

'use strict';
function MyClass() { this.clazz = "MyClass" }
MyClass() // 报错, this 是 undefined
  1. 箭头函数在定义时已经绑定了 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]

函数、newprototype

基于原型编程实现继承

如果我们单纯以 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 相同能力的东西,我们可以从下面这些点开始:

  1. 规定了 new 关键字,一个函数可以是一个从原型生成新实例的函数(也就是构造函数)。当使用 new 去触发这个函数的时候会走特定的逻辑,最终返回一个对应此函数代表原型的新实例
  2. 可以构造出一个单向引用链,逐级向上找到并执行所有原型的初始化函数,去解决没有 super 的问题。

还有一些额外的期望,以更贴近 class 的行为,如:

  1. 同一原型链上,所有实例中相同的函数要指向同一个函数,避免重复创建函数;
  2. 而每个实例的数据都是自有的;
  3. 原型上有默认数据;

关于 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 等之外,还会遇到一些问题:

  1. 原型本身是 Proxy,再由此生成的新实例会有些问题;
  2. 原型的构造器是 Proxy (也就是如 const ProxyUser = new Proxy(User, {...}); new ProxyUser()),会有什么后果;

要解决上面两个问题,首先要搞懂 Proxy 和 Reflect 的作用。

基本原理

  1. Proxy 是用于创建一个对象的代理,从而实现基本操作的拦截和自定义。
  2. Reflect 是一个包含用于调用可拦截的(1) JavaScript 对象内部方法(2)(invoking interceptable(1) JavaScript object internal methods(2))

MDN 上英文文档的介绍用此更明确。中文文档看了让人一头雾水 -_-。

  1. 可拦截的:Reflect 里面函数都是跟 new Proxy(target, handler) 中 handler 的方法是一一对应的。这也就是可被中断的函数的含义——指被 Proxy 中断。
  2. 对象内部方法:哪些是对象内部方法呢?比如 new 的调用;delete 的使用;获取/设置 [[Prototype]] ;执行 getter setter

其中最典型的就是 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 可以整出一些让人觉得眼前一黑相当奇妙的效果,也算是学以致用来。(不过说实话,还是没啥用 🐶)