likes
comments
collection
share

javascript基础——基于原型的语言

作者站长头像
站长
· 阅读数 23

javascript基础——基于原型的语言

本文讨论 javascript 一个老生常谈的问题——原型。我们从基础的原型出发梳理一下js的类型系统、继承等常见问题。

javascript 是一门 "怪异" 的语言,它是具有:

  1. C系语言的语法
  2. 类java:自动内存管理GC ,如果这个也算得话 :)
  3. 类Schema:函数为一等公民(支持函数式编程的先决条件)
  4. 类Self:基于原型继承

等特点的多范式编程语言。 在过去的时间里,java是web开发的龙头老大,大部分js程序员首先是java程序员,他们在js中实践 面向对象编程思想时,不免会对js的原型继承感到相当程度的怪异, js的面相对象好像和熟悉的面相对象不太一样。

当前端成为了一个独立的工种后,更标准的规范也相应推出——ECMAScript。在ES6中,出现了类编程的关键字 class, extends, 然而大部分前端从业人员的第一语言可能还是java或者根本没有编程基础。当我们在js中践行面向对象编程的思想时,能借鉴的依然是使用java式基于类继承的描述:

  1. 将基于类的面相对象实践 迁移到js中,你是否有过橘生淮南淮北的感觉?
  2. 我在工作中只使用class,有必要搞明白原型/原型链吗?
  3. js中有没有类?class 究竟是什么?
  4. 或者:前端生态都在向函数式编程靠拢,我从来不用 class或继承!

原型 是js语言的基石,要搞清楚js的“怪异”行为直面本质是最好的方法。欢迎大家阅读本文,质疑js -> 理解js -> 成为js

术语

  • value :表示任意一个合法的javascript值
  • value: <type>: 表示任意一个合法的javascript值,且其类型是 type
    • value: Object: 表示任意一个合法的javascript对象(狭义)
    • value: Array: 表示任意一个合法的javascript数组
    • value: Nil:表示一个 undefiendnull
    • value: Ref: 表示任意一个合法的javascript值,引用类型
  • value:<!type>: 表示任意一个合法的javascript值,但除了 type 类型的值,与value: <type> 互补
  • [[<property>]]: 表示一个javascript的 内部属性,仅供javascript引擎读取,无法通过对象访问语法直接读取

原型

在javascript中,任意 value: !Nil 都拥有一个 内部属性 [[Prototype]], 引用任意一个 value。 若有 Avalue: !Nil , Bvalue, 且 A.[[Prototype]] === B。则称 BA 的原型。

javascript基础——基于原型的语言

访问原型

内部属性仅供js引擎使用,语言层面无法直接访问,对于 [[Prototype]] ,通常我们有如下三种访问方式:

  • Object.getPrototypeOf(<value: !Nil>): 推荐, es5
  • Reflect.getPrototypeOf(<value: Ref>):推荐,es6新增,注意类型
  • <value: !Nil>.__proto__:不推荐,非标准属性,定义在Object.prototype
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))

javascript基础——基于原型的语言

修改原型

由于 value: !Nil 与其原型 是一种通过引用([[Prototype]])关联的松散关系,因此可以任意修改 value: !Nil[[Prototype]]

  • Object.setPrototypeOf(<value: !Nil>, <value>)
  • Reflect.setPrototypeOf(<value: Object>, <value>)
  • <value: !Nil>.__proto__ = value: 不推荐
// [[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

javascript基础——基于原型的语言

遮蔽性

当访问 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异常的原因,
function add () {}
const multi = () => {}

console.dir(add)
console.dir(multi)

javascript基础——基于原型的语言

构造函数调用与实例对象

当对函数使用 关键字 new 调用时,就是通常所说的构造函数调用方式。此时, 表达式会返回一个新的对象,并将该对象的[[Prototype]]指向函数的 prototype

javascript基础——基于原型的语言

demo

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

javascript基础——基于原型的语言

new 模拟

严格的说,javascript中并没有构造函数,只是借用基于类继承语言的概念,任何函数,只要以new 调用,我们称之为 构造函数调用。==即:技术上讲,构造函数和普通函数并无区别。== 为了搞清楚这 new 调用方式与普通函数调用方式的区别,我们来简单的模拟一下new 的行为。

  • 若原函数 return 了对象,则返回该对象, 否则:
  • 生成一个新的对象
  • 将函数的 prototype 属性引用的值设置为新对象的 原型
  • this 指向这个新对象
  • 返回这个新对象
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是关键字,我们无法自定义一个关键字,只能使用函数来模拟。

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生成数组对象
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(123)

字面量语法糖

引用类型的字面量表示法实际是对应构造函数的语法糖。


// 以下创建对象的方式是等价的, 对象实例的原型均为 Object.prototype
const obj = {}
const obj1 = Object.create(Object.prototype) 

// 以下创建数组的方式是等价的, 数组实例的原型均为 Array.prototype
const arr = []
const arr1 = new Array()
const arr2 = Array.of()

原始类型的自动装箱/拆箱

在对原始类型做属性访问操作时,会使用对应的构造函数生成一个新的临时对象,用完即销毁,称之为自动装箱。

拆箱的过程正好相反,调用临时对象的 valueOf | toString 方法。

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基础——基于原型的语言

上图是 javascript 中一些值和构造函数之间的关系,其中重要的有两个构造函数FunctionObject

  • 大部分函数都是构造函数Function的实例,包括构造函数Function本身
  • 所有的构造函数的原型,都指向Object.prototype, 因此,默认行为下,Object.prototype 存在于所有value: !Nil的原型链上。

模拟类

原型继承的一大特点就是原型改变后,会影响所有引用该原型的值,有时候我们希望实例对象创建后,其行为不再因原型对象的改变而改变,是完全独立的,也就是模拟传统的基于类的继承。

寄生组合式继承

// 封装继承函数
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,一句话,他就是 寄生组合式继承 的语法糖。

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:

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);
}