本文讨论 javascript
一个老生常谈的问题------原型。我们从基础的原型出发梳理一下js的类型系统、继承等常见问题。
javascript
是一门 "怪异" 的语言,它是具有:
- C系语言的语法
- 类java:自动内存管理GC ,如果这个也算得话 :)
- 类Schema:函数为一等公民(支持函数式编程的先决条件)
- 类Self:基于原型继承
等特点的多范式编程语言。 在过去的时间里,java是web开发的龙头老大,大部分js程序员首先是java程序员,他们在js中实践 面向对象编程思想时,不免会对js的原型继承感到相当程度的怪异, js的面相对象好像和熟悉的面相对象不太一样。
当前端成为了一个独立的工种后,更标准的规范也相应推出------ECMAScript。在ES6中,出现了类编程的关键字 class
, extends
, 然而大部分前端从业人员的第一语言可能还是java或者根本没有编程基础。当我们在js中践行面向对象编程的思想时,能借鉴的依然是使用java式基于类继承的描述:
- 将基于类的面相对象实践 迁移到js中,你是否有过橘生淮南淮北的感觉?
- 我在工作中只使用
class
,有必要搞明白原型/原型链吗? - js中有没有类?
class
究竟是什么? - 或者:前端生态都在向函数式编程靠拢,我从来不用
class
或继承!
但 原型 是js语言的基石,要搞清楚js的"怪异"行为直面本质是最好的方法。欢迎大家阅读本文,质疑js -> 理解js -> 成为js
术语
value
:表示任意一个合法的javascript值value: <type>
: 表示任意一个合法的javascript值,且其类型是type
value: Object
: 表示任意一个合法的javascript对象(狭义)value: Array
: 表示任意一个合法的javascript数组value: Nil
:表示一个undefiend
或null
value: Ref
: 表示任意一个合法的javascript值,引用类型
value:<!type>
: 表示任意一个合法的javascript值,但除了type
类型的值,与value: <type>
互补[[<property>]]
: 表示一个javascript的 内部属性,仅供javascript引擎读取,无法通过对象访问语法直接读取
原型
在javascript中,任意 value: !Nil
都拥有一个 内部属性 [[Prototype]]
, 引用任意一个 value
。 若有 A
为 value: !Nil
, B
为 value
, 且 A.[[Prototype]] === B
。则称 B
为 A
的原型。
访问原型
内部属性仅供js引擎使用,语言层面无法直接访问,对于 [[Prototype]]
,通常我们有如下三种访问方式:
Object.getPrototypeOf(<value: !Nil>)
: 推荐, es5Reflect.getPrototypeOf(<value: Ref>)
:推荐,es6新增,注意类型<value: !Nil>.__proto__
:不推荐,非标准属性,定义在Object.prototype
上
javascript
const chicken = Object.create(null) // 创建一个空对象,并将null作为其原型
chicken.type = 'chicken'
const ikun = Object.create(chicken) // 创建一个空对象,并将 chicken 作为其原型
ikun.name = 'ikun'
console.log('ikun', ikun)
console.log('原型', Object.getPrototypeOf(ikun))
console.log('原型', Reflect.getPrototypeOf(ikun))
修改原型
由于 value: !Nil
与其原型 是一种通过引用([[Prototype]]
)关联的松散关系,因此可以任意修改 value: !Nil
的 [[Prototype]]
。
Object.setPrototypeOf(<value: !Nil>, <value>)
Reflect.setPrototypeOf(<value: Object>, <value>)
<value: !Nil>.__proto__ = value
: 不推荐
javascript
// [[Prototype]] -> null
const obj = Object.create(null)
// [[Prototype]] -> {age: 18}
Object.setPrototypeOf(obj, {age: 18})
// [[Prototype]] -> {friend: '胡彦斌'}
Reflect.setPrototypeOf(obj, {friend: '胡彦斌'})
原型链
原型 本身是一个 value
, 且 任意一个 value: !Nil
都具有 内部属性 [[Prototype]]
, 所以原型可能还有原型,通过[[Prototype]]
引用就形成了类似 单向链表 的数据结构,称之为 原型链 ,直到 value: Nil
为止,因为 value: Nil
不在具有内部属性[[Prototype]]
了,如果你没有显示手动设置的话,通常为null
。
遮蔽性
当访问 value: !Nil
的属性时,会先查询其自身是否具有该属性,若存在即返回,否则逐级往 [[Prototype]]
上查询,直到原型链的尽头 null
依然不存在 则返回 undefined
。
继承
由于 原型链 具有遮蔽性的特点,因此,javascript可以基于原型链实现 "继承"。之所以打上双引号,是因为此 "继承" 非彼 继承 (基于类的继承,代表语言:Java)。
继承是一种 代码复用的解决方案,避免重复书写相同的代码。在生物学中,子代拷贝一份双亲的基因副本实现了继承,在基于类继承的语言中也是如此, 对一个类多次实力化,每一次都会重复执行拷贝。但javascript基于原型的继承并非如此,value: !Nil
并没有拷贝一份原型的副本,仅仅是通过[[Prototype]]
引用了另一个value
。
基于原型继承的类型系统
函数
value: Function
是javascript中特殊的对象,与其他value
不同的是, 函数具有 prototype
属性,注意与 [[Prototype]]
区分。
[[Prototype]]
是内部属性,他引用了上文中我们提到的 原型 (回顾一下,原型是任意合法的javascript值value
)。prototype
是函数的自有属性,主要为new
调用时消费的,实际上使用new
调用的是prototype.constructor
, 这个属性通常引用其自身。- 注意:箭头函数 没有
prototype
属性,这也是对尖头函数使用new
调用会报not a constructor
异常的原因,
javascript
function add () {}
const multi = () => {}
console.dir(add)
console.dir(multi)
构造函数调用与实例对象
当对函数使用 关键字 new
调用时,就是通常所说的构造函数调用方式。此时, 表达式会返回一个新的对象,并将该对象的[[Prototype]]
指向函数的 prototype
。
demo
javascript
function Person (name, age) {
this.name = name
this.age = age
}
const wells = new Person('harrison wells', 18)
console.dir(wells)
console.dir(Person)
console.log(Object.getPrototypeOf(wells) === Person.prototype) // -> true
new
模拟
严格的说,javascript中并没有构造函数,只是借用基于类继承语言的概念,任何函数,只要以new
调用,我们称之为 构造函数调用 。==即:技术上讲,构造函数和普通函数并无区别。== 为了搞清楚这 new
调用方式与普通函数调用方式的区别,我们来简单的模拟一下new
的行为。
- 若原函数 return 了对象,则返回该对象, 否则:
- 生成一个新的对象
- 将函数的
prototype
属性引用的值设置为新对象的 原型 - 把
this
指向这个新对象 - 返回这个新对象
javascript
function Person (name, age) {
this.name = name
this.age = age
}
const wells = new Person('wells', 18)
/**
* wells: Person
* {
* name: 'wells',
* age: 18
* [[Prototype]]: Person.prototype
* }
*/
模拟: 由于new
是关键字,我们无法自定义一个关键字,只能使用函数来模拟。
javascript
const getType = (val) => Object.prototype.toString.call(val)?.slice(8, -1)
const New = (fn, ...rest) => {
if(typeof fn !== 'function') {
throw Error(`The first argument needs to accept the function type, but it get ${getType(fn)}`)
}
//生成一个新对象,并将fn.prototype作为其原型
const obj = Object.create(fn.prototype)
const res = fn.apply(obj, rest)
// 原函数如过return了对象就返回这个对象,否则返回新建的对象
return typeof res === 'object' && res !== null ? res : obj
}
const harrison = New(Person, 'harrison', 18)
/**
* harrison: Person
* {
* name: 'harrison',
* age: 18
* [[Prototype]]: Person.prototype
* }
*/
我们可以使用构造函数的方式定义数据类型,javascript中内置了一些构造函数, 以下是部分构造函数列表:
构造函数 | 描述 |
---|---|
Number | 生成数字对象 |
String | 生成字符串对象 |
Function | 生成函数对象 |
Object | 生成对象(value: Object ) |
Boolean | 生成布尔对象 |
Array | 生成数组对象 |
js
const num = new Number(1)
const str = new String('hello world')
const func = new Function('x', 'y', 'return x + y')
const obj = new Object({name: 1})
const bool = new Boolean(true)
const arr = new Array(1,2,3)
字面量语法糖
引用类型的字面量表示法实际是对应构造函数的语法糖。
js
// 以下创建对象的方式是等价的, 对象实例的原型均为 Object.prototype
const obj = {}
const obj1 = Object.create(Object.prototype)
// 以下创建数组的方式是等价的, 数组实例的原型均为 Array.prototype
const arr = []
const arr1 = new Array()
const arr2 = Array.of()
原始类型的自动装箱/拆箱
在对原始类型做属性访问操作时,会使用对应的构造函数生成一个新的临时对象,用完即销毁,称之为自动装箱。
拆箱的过程正好相反,调用临时对象的 valueOf | toString
方法。
js
const str = 'Hello World'
str.toUpperCase() // -> 'HELLO WORLD'
// 等价与
new String(str).toUpperCase().valueOf() // -> 'HELLO WORLD'
// 自动装箱的对象是临时的,用完即销毁
str.name = 'wells'
str.name // -> undefined
// 等价与
new String(str).name = 'wells'
new String(str).name // -> undefined, 注意,这两次装箱是不同的临时对象
可见,如果你没有使用一些骚操作, 默认行为下,js中所有value: !Nil
的 [[Prototype]]
都指向对应构造函数的prototype
,基于原型之间的相互关联,就形成了 类型系统。
类型系统
上图是 javascript 中一些值和构造函数之间的关系,其中重要的有两个构造函数Function
和 Object
。
- 大部分函数都是构造函数
Function
的实例,包括构造函数Function
本身 - 所有的构造函数的原型,都指向
Object.prototype
, 因此,默认行为下,Object.prototype
存在于所有value: !Nil
的原型链上。
模拟类
原型继承的一大特点就是原型改变后,会影响所有引用该原型的值,有时候我们希望实例对象创建后,其行为不再因原型对象的改变而改变,是完全独立的,也就是模拟传统的基于类的继承。
寄生组合式继承
javascript
// 封装继承函数
function inherit(superType, subType) {
subType.prototype = Object.create(superType.prototype)
subType.prototype.constructor = subType
}
// 父类
function Parent(name) {
this.name = name
this.type = 'human'
}
// 父类公共方法
Parent.prototype.sayHello = function () {
return `Hello, I'm ${this.name}`
}
// 子类
function Child(name, age) {
// 调用父构造函数给实例对象的name属性赋值,同时获取父类的行为
// name、type 都是每一个实例对象的自有属性,不在通过原型引用了
Parent.call(this, name)
this.age = age
}
// 子类继承父类
inherit(Parent, Child)
// 注意,由于 iherit 中,将子类的原型重写为父类的原型,所以要先inherit,再定义子类原型上的方法或属性,否则会丢失
// 子类公共方法
Child.prototype.getAge = function () {
return this.age
}
const wells = new Child('wells', 18)
Class extends
es6中出现了类语法 Class extends
,一句话,他就是 寄生组合式继承 的语法糖。
js
class Parent {
constructor(name) {
this.name = name
this.type = 'human'
}
sayHello () {
return `Hello, I'm ${this.name}.`
}
}
class Chid extends Parent {
constructor(name, age) {
super(name)
this.age = age
}
getAge() {
return this.age
}
}
babeljs 编译后代码,我去掉了兼容的代码,保留了核心部分。 extends -> inherits
:
js
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function");
}
subClass.prototype = Object.create( superClass.prototype, {
constructor: { value: subClass, writable: true, configurable: true }
})
if (superClass) Object.setPrototypeOf(subClass, superClass);
}