阅读 158

详解JavaScript原型

写在前面

之前看到过一个问题,大概意思是“如何创建一个纯净的对象”,看到这道题以后我有点懵,JavaScript 中不是万物皆对象?无论什么类型都可以是继承自 Object,也就是说拥有 Object 构造函数原型上的方法。
怎么还有纯净对象一说?后来了解了一下 Object 构造函数原型上的 API 才知道,原来有一个 Object.create(null) 方法可以初始化一个没有原型的对象。
之后还有看到一个问题,说“原型的终点是什么?”
好家伙,拉夫德鲁?最后了解了以后才终于知道,原来是 null。
但是我们可以看到一个奇怪的现象:

console.log(typeof null === 'object') // true
console.log(Object.prototype.__proto__ === null) // true
复制代码

为什么是 null 的类型是对象,而对象原型链的终点却又是 null 呢?这其实是 JavaScript 这门语言的 bug。
在 JavaScript 最初版本使用的 32 位系统中会使用低位存储变量的类型信息,类型标签为 0 表示对象。
而 null 是一个空指针,以 000 开头,因此 null 的类型标签也被阴差阳错定义成了 0,于是便被识别为对象。
自此,null 走马上任,像极了封神榜里的张友仁。
简而言之,我名义上的儿子其实是我的爷爷。。。
因为 JavaScript 原型链种种有趣的事儿让我有了盘一盘的兴趣,让我们开始吧~

原型链

提到原型链就不得不去思考两个问题:

  • 为什么要有原型链?
  • 无论什么类型都有原型链吗?

原型链哪儿来的?

众所周知,最开始 JavaScript 只是为了验证浏览器表单而被设计出来的,因此在继承这一特性上,创始人并没有设计复杂的 class,只是借用了 C++ 和 Java 中 new 操作符的方式简单实现了继承。
但是问题来了,如果每个实例的成员变量都是私有的,那会造成数据太过独立无法共享,每生成一个实例就需要将构造函数中的成员都新创建一次,对于内存来说是极大的浪费。
于是,prototype 被设计了出来,将需要共享的成员放置于 prototype 中,私有变量放置于构造函数中。
但是,不能光有往下找的索引没有往上找的索引啊,constructor 就这样出现了。
也就是说,实例如果想要找到共享成员就需要去构造函数的 prototype 中查找。那么,怎么获取这个共享成员呢?_proto_ 由此被设计了出来。
原型链自此诞生~

误区

实例是否拥有 constructor 属性?
我们经常可以看到说实例通过 constructor 属性就可以找到自己的构造函数。
这句话没错,但是我们可以通过查看实例对象的具体属性发现,实例对象并没有一个叫做 constructor 的属性。
那么,我们常以为的 instance.constructor === Ctro 是怎么实现的?
其实,这一步是通过 _proto_ 桥接完成的。instance.constructor 本质上其实是 instance.__proto__.constructor,因为实例的 _proto_ 指向了构造函数的原型,而原型对象是拥有 constructor 属性的。

考题分析 · 一

function Person() {}
Person.prototype.say = function() {}
const me = new Person()
const you = new Person()
console.log(me.constructor === Person) // true -> 都指向了 Person
console.log(you.constructor === Person) // true -> 都指向了 Person
console.log(me.constructor.prototype === me.__proto__) // true -> 都指向了 Person 的原型
console.log(me.__proto__.constructor === Person) // true -> 都指向了 Person
console.log(me.__proto__.constructor === you.constructor) // true -> 都指向了 Person
console.log(me.constructor.prototype === you.constructor.prototype) // true -> 都指向了 Person 的原型
console.log(me.__proto__ === you.__proto__) // true -> 都指向了 Person 的原型
console.log(me.__proto__.say === you.__proto__.say) // true -> 都指向了 Person 的原型上的 say 方法
复制代码

无论什么类型都有原型链吗?

这个问题涉及到 JavaScript 的底层原理,JavaScript 最初被设计时就定义了所有的类型都是对象。
既然所有的类型都是对象那就说明它必定是 Object 的实例,间接说明,无论什么类型都有原型链。
不信?那你随便定义一个基本类型的变量,看看它是否拥有 _proto_ 属性~

题外话

_proto_ 是一个规范属性,但是该规范已经从 Web 标准中被删除,虽然如今依然可以在许多浏览器中被调用到,但为了安全起见,建议使用 Object.getPrototypeOf(instance) 或者 Reflect.getPrototypeOf(instance) 来代替~

考题分析 · 二

function Person(hobbies) {
  this.hobbies = hobbies
}
Person.prototype.getHobbies = function() {
  return this.hobbies
}
const somebodyA = new Person(['电影', '足球'])
const somebodyB = new Person(['CSGO'])
console.log(somebodyA.hobbies === somebodyB.hobbies) // false -> 一个是 ['电影', '足球'] 一个是 ['CSGO']
console.log(somebodyA.getHobbies() === somebodyB.getHobbies()) // false -> 一个是 ['电影', '足球'] 一个是 ['CSGO']
console.log(somebodyA.getHobbies === somebodyB.getHobbies) // true -> 都指向了 Person.prototype.getHobbies 方法
console.log(somebodyA.prototype === somebodyB.prototype) // true -> 都指向了 Person.prototype
复制代码

考题分析 · 三 · (1)

由上面的讲解我们可以得知,实例获取共享成员的方式是通过 _proto_ 获取到构造函数原型上的成员,如果当构造函数身上不存在这个成员呢?
会一直往原型的原型(的原型的原型的原型的原型...)上找,直到找到找不到为止,返回 undefined

Object.prototype.name = 'aeorus'
function Person() {}
const me = new Person()
console.log(me.name) // aeorus
复制代码

考题分析 · 三 · (2)

function Person() {}
Person.prototype.name = 'aiolos'
const me = new Person()
console.log(me.name) // aiolos
delete Person.prototype.name
console.log(me.name) // undefined
复制代码

考题分析 · 三 · (3)

Object.prototype.name = 'aeorus'
function Person() {}
Person.prototype.name = 'aiolos'
const me = new Person()
console.log(me.name) // aiolos
delete Person.prototype.name
console.log(me.name) // aeorus
复制代码

new 操作符

上面我提到说实例本身并不具有 constructor 属性,只有构造函数的原型才拥有。
对于实例本身来说,所能做的就是通过 _proto_ 来获取到构造函数的原型而已。
那么,new 操作符在实例化一个构造函数时到底做了哪些操作呢?

设置原型链

简单梳理一下,可以分为以下4种链条:

构造函数 --- prototype ---> 原型
构造函数 --- new ---> 实例
原型 --- constructor ---> 构造函数
实例 --- constructor ( 本质其实是 -> \__proto__.constructor ) ---> 构造函数
实例 --- \__proto__ ---> 原型
复制代码

模拟 new 操作符的内部原理

  • 构造函数中可以有返回值,如果是基本类型则忽略,如果是引用类型则将返回值覆盖构造函数
  • 通过 Object.create(Ctro.prototype) 生成一个继承了 Ctro 原型的新对象
  • 判断构造函数的调用的返回值类型
  • 根据返回值类型返回实例
function Person(name) {
  this.name = name
  // return 1
  // return [1, 2]
  // return { a: 1 }
  // return null
  /* return () => {
    console.log('a')
  } */
}
Person.prototype.say = function() {
  return this.name
}
function _createInstance() {
  const Ctro = Array.from(arguments)[0]
  if (!Ctro || typeof Ctro !== 'function') {
    throw 'createInstance must receive a function for constructor'
  }
  // 获取构造函数的入参
  const args = [].slice.call(arguments, 1)
  // 获取拥有构造函数的原型的新对象
  const newObject = Object.create(Ctro.prototype)
  // 调用构造函数,将其 this 指向新对象,其目的有二
  // 一是为了将构造函数内的成员绑定到新对象上
  // 二是为了判断返回值是否为引用类型
  const ctroReturnResult = Ctro.apply(newObject, args)
  // 判断 ctroReturnResult 是否有返回值及其返回值类型
  const isObject = typeof ctroReturnResult === 'object' && ctroReturnResult !== null
  const isFunction = typeof ctroReturnResult === 'function'
  // 如果有返回值,并且是非 null 的对象或者方法,则返回 ctroReturnResult
  if (isObject || isFunction) return ctroReturnResult
  // 如果没有,则返回 newObject
  return newObject
}
const me = _createInstance(Person, 'aeorus')
复制代码

继承

JavaScript 在设计之处没考虑过继承相关事宜,因此诞生了许多奇巧淫技的继承方法。
但我并不准备赘述太多的继承方法,在这只拿出最经典的三种继承方式以及 ES6 新增的 class 继承方式做介绍~

function Person(name, age) {
  this.name = name
  this.age = age
  this.reference = ['black', 'white', 'gray']
}
Person.prototype.getInfo = function() {
  return `My name is ${this.name}, and I'm ${this.age} years.`
}
复制代码

原型链继承

  • 既是父类的实例,也是子类的实例
  • 父类成员共享程度太高,一个改变所有实例的该成员都会改变
  • 子类在实例化父类作为自己的原型时无法传参
  • 子类无法在实例化父类作为自己的原型前定义自己原型上的属性
  • 子类只能继承一个父类
function Male(name, age) {
  this.name = name
  this.age = age
}
Male.prototype = new Person()
Male.prototype.getAge = function() {
  return this.age
}
// test
const me = new Male('aeorus', 28)
const you = new Male('aiolos', 30)
console.log(me instanceof Person) // true 
console.log(me instanceof Male) // true 
me.getInfo() // My name is aeorus, and I'm 28 years. -> 虽然父类身上的 name 和 age 都是 undefined,但 this 会沿着作用域往上找
me.reference.pop()
console.log(me.reference) // ["black", "white"] -> 缺点
console.log(you.reference) // ["black", "white"] -> 缺点
复制代码

组合继承

  • 啥都好,就是 Person.call 了一次,然后又 new Person 了一次,调用了两次
function Male(name, age) {
  Person.call(this, name, age)
  this.name = name
  this.age = age
}
Male.prototype = new Person()
Male.prototype.constructor = Male
// test
const me = new Male('aeorus', 28)
const you = new Male('aiolos', 30)
console.log(me instanceof Person) // true 
console.log(me instanceof Male) // true 
me.getInfo() // My name is aeorus, and I'm 28 years. -> 虽然父类身上的 name 和 age 都是 undefined,但 this 会沿着作用域往上找
me.reference.pop()
console.log(me.reference) // ["black", "white"]
console.log(you.reference) // ["black", "white", "gray"]
复制代码

寄生组合继承

function Male(name, age) {
  Person.call(this, name, age)
  this.name = name
  this.age = age
}
const prototype = Object.create(Person.prototype)
prototype.constructor = Male
Male.prototype = prototype
// test
const me = new Male('aeorus', 28)
const you = new Male('aiolos', 30)
console.log(me instanceof Person) // true 
console.log(me instanceof Male) // true 
me.getInfo() // My name is aeorus, and I'm 28 years.
me.reference.pop()
console.log(me.reference) // ["black", "white"]
console.log(you.reference) // ["black", "white", "gray"]
复制代码

ES6 class 继承

class Male extends Person {
  constructor(name, age) {
    super(name, age)
  }
  getAge() {
    return this.age
  }
  getInfo() {
    return super.getInfo() + ' And this message is in class Male'
  }
}
// test
const me = new Male('aeorus', 28)
const you = new Male('aiolos', 30)
console.log(me instanceof Person) // true 
console.log(me instanceof Male) // true 
me.getInfo() // My name is aeorus, and I'm 28 years. And this message is in class Male
me.reference.pop()
console.log(me.reference) // ["black", "white"]
console.log(you.reference) // ["black", "white", "gray"]
复制代码

手写 class

既然我们提到了 ES6 class 继承,那我们就想想如何实现这个内置的 class

class Parent {
  constructor(name, age) {
    this.name = name
    this.age = age
  }
  getInfo() {
    return `My name is ${this.name}, and I'm ${this.age} years.`
  }
  getName() {
    return this.name
  }
  static getPrototype() {
    return "Class constructor Parent cannot be invoked without 'new'"
  }
}
复制代码
function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function")
  }
}
function _defineProperties(target, props) {
  for (var i = 0; i < props.length; i++) {
    var descriptor = props[i]
    descriptor.enumerable = descriptor.enumerable || false
    descriptor.configurable = true
    if ("value" in descriptor) descriptor.writable = true
    Object.defineProperty(target, descriptor.key, descriptor)
  }
}
function _createClass(Ctro, props, staticProps) {
  if (props) _defineProperties(Ctro.prototype, props)
  if (staticProps) _defineProperties(Ctro, staticProps)
  return Ctro
}
var Parent = function () {
  function Parent(name, age) {
    _classCallCheck(this, Parent)
    this.name = name
    this.age = age
  }
  _createClass(Parent, [{
    key: "getInfo",
    value: function getInfo() {
      return "My name is ".concat(this.name, ", and I'm ").concat(this.age, " years.")
    }
  }, {
    key: "getName",
    value: function getName() {
      return this.name
    }
  }], [{
    key: "getPrototype",
    value: function getPrototype() {
      return "Class constructor Parent cannot be invoked without 'new'"
    }
  }])
  return Parent
}()
复制代码

Object.create

在开篇我就提到一个问题——如何创建一个纯净的对象?我们现在知道是通过 Object.create(null) 方法达到的。
那么,这个方法的原理是什么呢?和我们这篇讲的原型又有什么关系呢?
就此,让我们对各种操作到原型的原生方法开始探索吧~ MDN 上面对于这个方法的定义为: 创建一个新对象,使用现有的对象来提供新创建的对象的 _proto_
有一说一,这句话有点拗口,但是我们可以看到 _proto_ 这个词,说明这里面藏着一个构造函数。
简单翻译一下就是: 传入一个对象,返回一个以这个对象为原型的实例。
这么一解释是不是就能很快手写一个了?

const _objectCreate = function(prototype) {
  function F() {}
  F.prototype = prototype
  return new F()
}
const obj = _objectCreate({ name: 'aeorus' })
console.log(obj)
复制代码

但是不要忘了,Object.create 拥有第二个可选入参 propertyObject,它是一个属性配置对象 ( 参考 Object.defineProperty 的第三个参数 ),该参数内定义的属性将被直接绑定到新对象身上。
因此,我们还需要去遍历第二个参数,通过 Object.defineProperty 的方式将其挂载到新对象身上。

const _objectCreate = function(prototype, propertyObject) {
  function F() {}
  F.prototype = prototype
  const result = new F()
  for (let key in propertyObject) Object.defineProperty(result, key, propertyObject[key])
  return result
}
const obj = _objectCreate({ name: 'aeorus' }, {
  // foo会成为所创建对象的数据属性
  foo: {
    writable:true,
    configurable:true,
    value: "hello"
  },
  // bar会成为所创建对象的访问器属性
  bar: {
    configurable: false,
    get: function() { return 10 },
    set: function(value) {
      console.log("Setting `o.bar` to", value);
    }
  }
})
console.log(obj)
复制代码
文章分类
前端
文章标签