前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

JavaScript原型链回顾与new的实现原理

首页2018-12-22 12:13:53Front-End
JavaScript原型链new

面试问原型链,很多人能背出「每个对象都有 __proto__,指向构造函数的 prototype」,但再追问一句「那 Function.__proto__ 指向谁」就卡住了。我一开始也是这样,图看过好几遍,一到具体的某条线上就理不清方向。

后来发现问题出在把 prototype 和 __proto__ 当成一回事了。这两个属性长得像,挂的对象不一样,指的方向也不一样。这篇从内置对象开始把这两条线分开走一遍,再顺着 new 的过程看这些连接是什么时候建立起来的。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • JS 内置对象里,哪些是函数哪些只是普通对象
  • prototype 是谁的属性,什么时候被创建
  • constructor 到底有什么用,它为什么算历史遗留
  • __proto__ 和 [[Prototype]] 的关系
  • new 操作符做了哪四件事,手写一个 new
  • 原型链的终点在哪里
  • JSON 和 Math 为什么不能 new

# 一、JS内置对象

所谓的内置对象指的是,JavaScript 本身就自己有的对象,可以直接拿来就用,例如 Array、String 等等。按老的教材分法,JavaScript 一共有 12 个内置对象。

函数类型有 10 个:

  • String
  • Number
  • Boolean
  • Array
  • Function
  • Date
  • RegExp
  • Error
  • Object
  • Event

函数类型有 __proto__ 和 prototype 属性。

对象类型有 2 个:

  • Math
  • JSON

对象类型只有 __proto__ 属性。

JS 内置对象分类示意图,函数类型与对象类型的属性差别

这个「12 个」的说法是当年教材里的分法,拿来入门够用,但别当成规范。ECMAScript 现在的内置对象远不止这些,Map、Set、Promise、Symbol、Proxy、Reflect、WeakMap 都是。而且列表里的 Event 严格说不属于 ECMAScript,它是浏览器提供的宿主对象,在 Node 里就没有这个全局变量。

真正要记住的不是数字,而是那条分界线,能不能被 new 调用。能的是构造函数,身上有 prototype;不能的就是个普通对象,只有 __proto__。

# 二、JS原型链

# 2.1 概述

prototype 与 proto 组成原型链的整体关系图

三条基本规则先摆在这儿:

  • 每个函数都有 prototype 属性,该属性指向原型
  • 每个对象都有 __proto__ 属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[Prototype]],但是 [[Prototype]] 是内部属性,我们并不能访问到,所以使用 __proto__ 来访问
  • 对象可以通过 __proto__ 来寻找不属于该对象的属性,__proto__ 将对象连接起来组成了原型链

打开浏览器的控制面板,随便输入一个 JS 内置的构造器函数,比如 Array,控制台输出的是一个名为 Array 的函数体,这好像并没有什么稀奇的。但是当你接着输入 Array.prototype,控制面板输出了一堆我们经常用到的 Array 构造器的方法。把目光转移到最下方,有一个叫 __proto__ 的属性,好奇的点开,列表列出的不是 Object 构造器的方法么,里边有我们非常熟悉的 hasOwnProperty 还有 toString 等方法。

如果 Array 是构造器,那么控制面板输出的 Array.prototype 的所有属性中 constructor 又是什么构造器?点开看看,之后就像身处德罗斯特效应中一样,__proto__ 和 constructor,还有 Array 构造器中常用的方法名不断地出现,一层套一层,一层层展开,没有尽头。

控制台展开 Array.prototype 时层层嵌套的 proto 与 constructor

拿 Array 举例,Array.prototype 中有一个 constructor 属性,这个属性的值就是 Array 构造器自己。

Array.prototype.constructor === Array //true
@前端进阶之旅: 代码已经复制到剪贴板

看起来没有尽头,其实是有的。展开的过程之所以像无限循环,是因为原型对象上的 constructor 指回构造函数,构造函数又能点出 prototype,两者互相引用形成了一个环。DevTools 只是老实地把这个环一层层渲染出来而已,真正的原型链本身是有终点的,后面会讲到。

# 2.2 prototype

这是一个显式原型属性,只有函数才拥有该属性。基本上所有函数都有这个属性,但是也有例外。

let fun = Function.prototype.bind()
@前端进阶之旅: 代码已经复制到剪贴板

如果你以上述方法创建一个函数,那么可以发现这个函数是不具有 prototype 属性的。

原文只提了 bind 这一种例外,其实没有 prototype 的函数还有几类,顺手补全:

  • bind 返回的绑定函数
  • 箭头函数,所以箭头函数不能用 new 调用
  • 对象字面量里的方法简写 { foo() {} }
  • Math.max 这类内置的非构造函数

它们的共同点是「设计上就不打算被 new」。没有 prototype,new 出来的实例就没有原型可以挂,语言干脆直接禁止这么用。

# 2.2.1 prototype 如何产生的

当我们声明一个函数时,这个属性就被自动创建了。

function Foo() {}
@前端进阶之旅: 代码已经复制到剪贴板

并且这个属性的值是一个对象(也就是原型),只有一个属性 constructor,constructor 对应着构造函数,也就是 Foo。

# 2.2.2 constructor

constructor 是一个公有且不可枚举的属性。一旦我们改变了函数的 prototype,那么新对象就没有这个属性了(当然可以通过原型链取到 constructor)。

那么你肯定也有一个疑问,这个属性到底有什么用呢?它可以说是一个历史遗留问题,在大部分情况下是没用的。按我的理解它有两个作用:

  • 让实例对象知道是什么函数构造了它
  • 如果想给某些类库中的构造函数增加一些自定义的方法,就可以通过 xx.constructor.method 来扩展

这里有个坑要注意。老式的继承写法会直接覆盖 prototype:

function Child() {}
Child.prototype = new Parent(); // constructor 丢了
Child.prototype.constructor = Child; // 手动补回来
@前端进阶之旅: 代码已经复制到剪贴板

不补这一行的话,new Child().constructor 会顺着原型链找到 Parent,判断类型的代码就会拿到错的结果。所以老代码里经常能看到这么一行看起来莫名其妙的赋值。

顺带说一句,constructor 之所以是不可枚举的,靠的就是属性描述符里的 enumerable: false。这也是为什么 for...in 遍历实例时不会把它列出来。

# 2.3 __proto__

这是每个对象都有的隐式原型属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[Prototype]],但是 [[Prototype]] 是内部属性,我们并不能访问到,所以使用 __proto__ 来访问。

因为在 JS 中是没有类的概念的(class 是语法糖),为了实现类似继承的方式,通过 __proto__ 将对象和原型联系起来组成原型链,得以让对象可以访问到不属于自己的属性。

有一点要说明,__proto__ 本身是浏览器厂商各自实现之后才被写进规范附录的遗留特性,规范推荐的标准写法是 Object.getPrototypeOf(obj) 和 Object.setPrototypeOf(obj, proto)。日常调试用 __proto__ 方便,写进代码库建议用标准 API。另外 Object.setPrototypeOf 性能很差,会让引擎放弃已有的内联缓存优化,能在创建时用 Object.create 定好就别后面改。

# 2.3.1 实例对象的 __proto__ 如何产生的

当我们使用 new 操作符时,生成的实例对象拥有了 __proto__ 属性。

function Foo() {}
// 这个函数是 Function 的实例对象
// function 就是一个语法糖
// 内部调用了 new Function(...)
@前端进阶之旅: 代码已经复制到剪贴板

所以可以说,在 new 的过程中,新对象被添加了 __proto__ 并且链接到构造函数的原型上。

有一个例外要记住,Object.create(null) 创建出来的对象没有原型,它的 __proto__ 是 undefined。用它当纯粹的字典结构很合适,因为不用担心 hasOwnProperty、toString 这些名字跟你的键撞车。

# 2.3.2 new 的过程

调用 new 的过程中会发生四件事情:

  • 新生成了一个对象
  • 链接到原型
  • 绑定 this
  • 返回新对象

我们也可以试着来自己实现一个 new:

function create() {
    // 创建一个空的对象
    let obj = new Object()
    // 获得构造函数
    let Con = [].shift.call(arguments)
    // 链接到原型
	obj.__proto__ = Con.prototype
    // 绑定 this,执行构造函数
    let result = Con.apply(obj, arguments)
    // 确保 new 出来的是个对象
    return typeof result === 'object' ? result : obj
}
@前端进阶之旅: 代码已经复制到剪贴板

这版能说明思路,但最后那行判断有个漏洞。typeof null 的结果也是 'object',所以构造函数里显式 return null 的话,这个实现会把 null 返回出去,而真正的 new 在这种情况下返回的是新建的那个对象。另外构造函数返回一个函数时,new 也是以返回值为准的。修正后的判断应该是这样:

function create(Con, ...args) {
  // 用标准 API 建对象并链接原型,比赋值 __proto__ 更规范
  const obj = Object.create(Con.prototype)
  const result = Con.apply(obj, args)
  // 只有返回值是对象或函数时才采用它,null 要排除掉
  const isObj = result !== null && (typeof result === 'object' || typeof result === 'function')
  return isObj ? result : obj
}
@前端进阶之旅: 代码已经复制到剪贴板

对于实例对象来说,都是通过 new 产生的,无论是 function Foo() 还是 let a = { b : 1 }。

对于创建一个对象来说,更推荐使用字面量的方式创建对象。因为你使用 new Object() 的方式创建对象需要通过作用域链一层层找到 Object,但是你使用字面量的方式就没这个问题。

// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 }
// 这个字面量内部也是使用了 new Object()
@前端进阶之旅: 代码已经复制到剪贴板

Array 的 proto 指向 Function.prototype,再往上是 Object.prototype,终点是 null

这里 Array 是内置对象且是函数类型,所以 Array 有 __proto__ 属性,指向的是函数类型的原型。当我们输出 Array.__proto__.__proto__ 时,就会返回对象类型的原型,但是再向上就是 null 了,因为 Object.prototype 是这条链的顶端,所有对象最终都继承自它。

原文这里写成了 Array.__proto__.proto__,少了两个下划线,是个笔误,正确写法是 Array.__proto__.__proto__。

fe
  • 一、JS内置对象
  • 二、JS原型链
    • 2.1 概述
    • 2.2 prototype
      • 2.2.1 prototype 如何产生的
      • 2.2.2 constructor
    • 2.3 __proto__
      • 2.3.1 实例对象的 __proto__ 如何产生的
      • 2.3.2 new 的过程
    • 2.4 总结
  • 三、JSON和Math
  • 总结
  • 参考

← JavaScript复制粘贴与剪贴板操作详解浏览器渲染原理 从DOM树到合成层的五个阶段 →