原型与原型链: 如何自己实现 call, bind, new?

1,396 阅读6分钟

原型与原型链可以说是JavaScript中最重要的概念了, JavaScript是基于面向对象的思想设计的语言, 而前两者则是这种思想的集中体现, 理解了原型与原型链, 才能更好的了解JavaScript这门语言, 为此我决定系统地梳理一下.

构造函数设计模式

我们先来看看构造函数与普通函数执行的差异, 普通函数执行特性, 我在JavaScript数据类型细节点: 用简单的方式让你理解概念的文末做了详细的阐述, 不懂的同学可以看看哦!🤭

构造函数在创建与执行时,与普通函数的差异:

  1. 构造函数在初始化this指向的时候, 会使它指向一个空实例对象.

  2. 并且在构造函数内部, 所有关于 this.xx 的成员访问都是直接访问的实例对象内的成员.

  3. 构造函数会默认返回实例对象, 如果我们自行添加了返回值则:

    • 返回的如果是一个对象, 则覆盖实例对象

    • 返回的如果是一个原始值, 则默认返回实例对象

proptotype

绝大多数函数都带有prototype原型属性, 这个属性是一个对象, 浏览器会默认开启一个堆内存, 用来存储当前类所属实例的公有属性与方法.

具备prototype属性的函数:

  1. 普通函数
  2. 构造函数/类(ES6)
  3. 生成器函数generator

不具备prototy属性的函数:

  1. 箭头函数
  2. 基于ES6对对象某个成员赋值函数的操作, 如:
function func(){
    fun1: function(){} // 非简写
    
    func2(){
    	// ... 简写, 不具备prototype
    }
}
class{
    constructor(){}
    fn(){
    	// ... 简写, 不具备prototype
    }
}

原型重定向

值得注意的一点是: 手写的类允许重定向, 但是内置类只允许添加, 不允许重定向, 不过可以针对单一函数进行逐一修改.

那如何在原型中批量添加方法?

  1. Object,assign(obj1, obj2)

    • assign会返回合并后的第一个对象, 所以obj1 要作为我们的目标对象

2, 利用闭包的形式批量添加

// 1.
Object.assign(Number.prototype, {
	plus: function plus (num) {
    	return num++
    }
})
// 2.
(function (prototype) {
    const plus = function plus (num) {
    	return num++
    }
    prototype.plus = plus
})(Number.prototype)

如何检测一个属性是否为对象的公有/私有属性

原生给我们提供了一些办法, 但是还不大够用.

  • obj.hasOwnProperty([attr]): 该属性是否为对象的私有属性?

  • [attr] in obj: 是否为obj的属性

那公有属性看起来是似乎可以这么做:

function hasPubProperty (obj, attr) {
	return (attr in obj) && !obj.hasOwnProperty(attr)
}

我相信你也很快看出了问题, 如果一个属性,它既是私有属性, 又是共有属性, 那返回的也是false, 所以我们来看看更好的写法.

解决办法:

  • Object.keys(obj): 遍历obj

  • Object.getOwnPropertyNames(obj): 获取非Symbol私有属性.

  • Object.getOwnPropertySymbols(obj): 获取私有的Symbol属性

1. 获取私有属性

  function each(obj, callback) {
    let objKeys = Object.keys(obj),
      len = 0,
      i = 0
	// 确定当前版本存在Symbol
    if (typeof Symbol !== 'undefined') {
      // 获取Symbol属性
      objKeys = objKeys.concat(Object.getOwnPropertySymbols(obj))
    }
    len = objKeys.length

    for (; i < len; i++) {
      let key = objKeys[i],
        value = obj[key]
      callback(key, value)
    }
  }

  each(obj, (key, value) => {
    console.log(key, value)
  })

2. 检测是否为公有属性

  Object.prototype.hasPubProperty = function hasPubProperty(attr) {
    let self = this,
      prototype = Object.getPrototypeOf(attr)

    while (prototype) {
      if (prototype.hasOwnProperty(attr)) return true
      prototype = Object.getPrototypeOf(prototype)
    }
    return false
  }

__proto__(原型链)(⭐⭐)

每一个对象数据类型的值都具有一个__proto__(原型链), 属性值指向了自己所属的原型对象(prototype)

这个概念可以说是所有萌新的噩梦, 不过只要把图画出来, 其实也不过如此:

function A(){} // 构造函数
let a = new A()
let o = new Object()

针对上述两个实例, 我们来画一张图:

可能在顶级的函数与对象之间, 原型链会比较复杂, 但对此我们仍可以总结出一些规律:

  1. 所有实例的__proto__都会指向它构造函数原型对象(prototype)
  2. 所有函数对象的__proto__, 都会指向顶级构造函数Function原型对象(prototype)
  3. 对象没有原型, 原型是函数独有的一个属性.

基于上述几点, 我们可以通过图可以看出, 其实总体来说, 顶级函数顶级对象, 他们都具有一个普通对象和函数该有的特质, 只是指向上有一些复杂.

new的工作原理(阿里面试题)

new执行构造函数, 会返回一个实例对象, 实例对象的原型链会指向构造函数的原型对象

new有哪些注意点

  1. 改变构造函数执行的this指向, 并且令函数返回这个实例

  2. 构造函数必须是一个函数

  3. 不能是Symbol或者Bigint类型, 它们不能被new

  4. 创造的实例必须指向构造函数的原型(prototype)

  5. 对于new执行函数而言, 默认情况下要返回一个实例对象, 如果我们改写了返回值, 如果是对象则返回, 如果是原始值则不改写返回值.

new的实现:(⭐)

  function _new(Ctor, ...params) {
    let prototype = Ctor.prototype,
      CtorType = typeof Ctor,
      self,
      result

    if (CtorType === 'symbol' || CtorType === 'bigint' || !prototype || CtorType !== 'function') {
      throw new TypeError(`${Ctor} is not a constructor`)
    }
    // 创建一个空实例对象
    self = create(prototype)
    // Ctor执行但this指向实例
    result = Ctor.apply(self, params)
    // 判断返回值
    if (result !== null && /^(object|function)$/.test(typeof result)) return result

    return self
  }
  // 创造一个空对象实例指向参数
  function create(prototype) {
    if (prototype !== null && typeof prototype !== 'object') {
      throw new TypeError('balabala')
    }
    function Proxy() {}
    Proxy.prototype = prototype
    return new Proxy()
  }

ES6 类class

跟构造函数大同小异, 只是无法直接执行

注意点:

  • 构造体内的参数直接挂载在实例对象上
  • 无法在原型上设置非函数的公有属性
  class Fn {
    // 构造函数体, 直接挂在实例对象上
    constructor(name) {
      this.name = name
      this.getName = function getName() {
        //...
      }
    }
    // 无法在原型上设置非函数的公有属性.
    age = 12 // 等同于在构造体内 this.age = 12
    getAge() {}

    // static 关键字可以让方法挂在构造函数上(相当于一个普通函数)
    static sex = 1
    static getSex = function getSex() {}
  }
  Fn.prototype.getAllInfo = function getAllInfo() {
    //..
  }

this

注意点:

  1. 给事件绑定事件时, this会指向Dom对象本身 ( 排除IE8- )

  2. 箭头函数,块级上下文,没有this, 如果上级上下文有this, 他会继承上级上下文的this, 但无法改变指向

  3. bind 改变允许函数异步执行.

实现 call (⭐)

callapply并没有太大的区别, 仅是传入参数不同.

  Function.prototype._call = function _call(context, ...params) {
    // 1. 将context作为函数执行的this
    // 2. 执行原来的函数

    context == null ? (context = window) : context
    // 原始值则装箱
    if (!/^(function|object)$/.test(typeof context)) context = Object(context)

    let self = this,
      key = Symbol('key'),
      result
    // 防止与其他属性冲突
    context[key] = self
    result = context[key](...params)
    delete context[key]
    return result
  }

实现 bind (⭐)

bind 与 call / apply 最大的区别在于, 允许如事件绑定这样的行为异步执行. 本质上就是返回的不是函数的执行结果, 将其结果包裹在一个代理函数下, 待到事件出发才执行.

有一点需要注意的是: 在bind函数中, 由于我们返回的是一个代理函数, 那用户就有可能会给这个代理函数传参, 如在事件绑定中, 给函数传入事件对象event, 所以我们需要取到代理函数的实参集合, 合并到我们的params中.

(代理函数内部的做法, 其他部分就与_call一样了, 请允许我偷个懒, 如要复制代码, 记得也把👆上面的_call方法也复制过去哦)

Function.prototype._bind = function _bind(context, ...params) {
  let self = this
  return function proxy() {
    params = params.concat(...arguments)
    return self._call(context, ...params)
  }
}

感谢😘


如果觉得文章内容对你有帮助:

  • ❤️欢迎关注点赞哦! 我会尽最大努力产出高质量的文章
    个人公众号: 前端Link
    联系作者: linkcyd 😁