Js 面向对象-构造函数与原型

上篇学了 js 中对象的基本概念和实现原理, 然后重点是掌握对象 属性描述符, 包含存取属性, 数据属性, 即通过 Object.defineProperty(obj, prop, desc) 来实现对对象属性的增删改查控制, 具体表现为 configurable, enumerable 配合 value + writable 或者 配合 get() + set() 的组合来实现各种高级操作.

本篇则开始讲 js 的对象如何批量生成, 即 new 一个对象, 但 js 中是没有 class 的, 只有函数, 它正是通过对函数, 原型, 等进行拓展, 逐步来实现类似 java / c++ 中的面向对象效果的, 这也是 js 这门语言最为精妙和独特的地方.

批量创建对方式

之前都是用字面量, 或者 new 一个 object, 但当若我们要创建多个对象, 显然通过去一个个写 字面量 的方式是不行的, 这里引入一个设计模式叫 工厂模式.

工厂模式

// 工厂模式: 工厂函数
function createPerson(name, age, height, city) {
  
}

// 创建很多个对象, 同时要保证每个对象数据是互隔离的
var p1 = createPerson('youge', '20', 1.8, '长安镇')
var p2 = createPerson('cj', '18', 1.7, '虎门镇')

要实现这个工厂函数, 则在里面先创建一个空对象, 然后给它逐个添加上属性, 最后返回该对象就好了.

// 工厂模式: 工厂函数
function createPerson(name, age, height, city) {
  var p = {}
  p.name = name 
  p.age = age 
  p.height = height
  p.city = city 

  // 还能添加方法
  p.eating = function() {
    console.log(this.name + ' is eating')
  }

  p.running = function() {
    console.log(this.name + ' is running')
  }

  return p
}

// 创建很多个对象, 同时要保证每个对象数据是互隔离的
var p1 = createPerson('youge', '20', 1.8, '长安镇')
var p2 = createPerson('cj', '18', 1.7, '虎门镇')
var p3 = createPerson('yaya', '18', 1.6, '长安镇')

看上去好像是可以了, 但这样的方式还是有很大缺点的. 首先是对象类型的缺失.

console.log(typeof p1) // 是 object, 而非 Person 

cosole.log(p1);

{
  name: 'youge',
  age: '20',
  height: 1.8,
  city: '长安镇',
  eating: [Function (anonymous)],
  running: [Function (anonymous)]
}

没有类型就很麻烦, 这样我们通过不同的工厂函数, 创建出来的对象的类型, 都是 object 就没法区分了.

构造函数方式

构造函数也称为 构造器 (constructor), 通常是我们创建对象时会自动调用的函数. 在其他编程语言里, 构造函数是存在于类中的一个方法, 称为 构造方法.

先来看看几个其他编程语言的实现, 先看 Java:

// 1. 方法名与类名必须一致
// 2. 没有返回类型 (void)也不能写
// 3. 使用 new 关键字创建时自动调用 

public class Person {
  private String name;
  private age int;
  
  // 无参构造器
  public Person() {
    this.name = 'youge';
    this.age = 18;
  }
  
  // 有参构造器
  public Person(String name, int age) {
    this.name = name;
    thi.age = age
  }
  
  // Getter, Setter ... 啰嗦一大堆
}

// 使用 
Person p1 = new Person() // 无参
Person p2 = new Person('youge', 18) // 有参

然后来看下 Python:

class Person:
  def __init__(self, name="youge", age=18):
    self.name = name
    self.age = age 
 

# 使用, new 都不用写, 优雅呀
p1 = Person()
p2 = Person('cj', 30)

再来看一下 Go 语言, 注意它也是没有 class 的, 清新脱俗.

// go 没有类, 有结构体, 也很优雅 

type Person struct {
  Name string,
  Age int
}

// 构造函数
func NewPerson(name string, age int) *Person {
  return &Person{
    Name: name,
    Age: age
  }
}

// 普通方法 
func (p *Person) Eating() {
  fmt.Println(p.name, ' is eating')
}

// 使用 
func main() {
  p := newPerson("youge", 30)
  p.Eating()
}

回到 js 这里来, 它的构造函数 就是一个普通函数, 从表现形式上看. 区别在于, js 内置了一个操作符 new,

如果一个普通的 js 函数被 new 操作符来调用, 则这个函数被称为一个构造函数.

// js 函数调用

function foo() {
  console.log('foo 函数被调用啦')
}

// 1. 普通调用
foo()

// 2. 显示调用 call, apply, bind
foo.call('nb')

// 3. new 调用, 会返回一个对象
var p = new foo() // foo ...
console.log(p)  // foo {}

调用方式 语法 this 指向 典型用途
普通函数调用 func() 全局对象 / undefined 工具函数
方法调用 obj.method() 调用者对象 (obj) 对象行为
构造函数调用 new Func() 新创建的实例 创建对象
显式绑定调用 func.call/apply/bind() 指定的对象 控制 this

new 操作符的作用

如果一个函数被 new 操作符调用, 则内部会进行 4步操作:

  • 内存中创建一个新对象 (空对象)
  • 对象内部的 [[prototype]] 属性会被赋值为该 构造函数的 prototype 属性
  • 执行构造函数的代码
  • 返回创建的对象, 若构造函数显示返回别的的对象, 则优先

这里比较难理解的是第二步, 先看结论吧, 当做逻辑学的东西看, 慢慢就懂了:

  • js 中, 每个对象都有一个内置的 [[prototype]] 属性, 是个指针, 指向该对象的 原型对象, 也是一个对象
  • js 中, 函数是一个特殊的对象, 前面可以通过 foo.call() 显示调用, 这就是因为 foo 既是函数, 也是对象的缘故
  • js 中, 每个函数都有一个 显示的 prototype 属性, 是个指针, 也是一个对象

因此, js 函数具有二义性, 既是函数, 也是对象, 但注意:

function foo() {}

// foo 此时变为构造函数
var obj = new foo() // () 不加也行的

new 最为核心的一步内置操作是:

// [[prototype]] 可以简单用 对象.__proto__ 访问
obj.__proto__ === foo.prototype 

为了区分一个函数是作为构造函数来用的, 我们有个 约定大于配置 的规范, 函数首字母大写啦~

// 约定规范:  构造函数的首字母大写, 小写也行其实

function Person(name, age, height, city) {
  // 属性
  this.name = name 
  this.age = age 
  this.height = height
  this.city = city 

  // 方法
  this.eating = function() {
    console.log(this.name + ' is eating')
  }
}

var p1 = new Person('cj', 30, 1.8, '长安镇')
var p2 = new Person('youge', 18, 1.7, '虎门镇')

console.log(p1.name) // cj
p2.eating() // youge is eating

通过这样的构造函数方式来创建对象, 最大的缺点是, 对于 方法 来说, 每创建一个对象, 都会重新给这个对象创建一次方法, 这样对象创建越多, 则越是消耗性能的, 而且有点重复浪费.

// 这个 eating() 方法会被每个对象重新创建, 但它的功能是一样的

console.log(p1.eating === p2.eating) // false

Js 原型

在 js 中每个对象都有一个内置属性 [[prototype] 是一个 指针 /引用, 它可以指向另外一个对象, 被指向的对象则被称为该对象的 原型 prototype.

原型有点类似于 Java / Python 等语言中的 "父类", 用来说明当前对象, 是 "继承" 谁

[[Prototype]] 指针, 早期不能访问, 但浏览器实现了 __ proto__ 属性, 可以指向原型对象

对象原型 (隐式) [[prototype]]

// 对象 的 原型对象
var obj = { name: 'youge'}
var obj2 = {}

// obj2 = { __proto__: {}}

// 查看原型对象
console.log(obj.__proto__)  // [Object: null prototype] {}
console.log(obj2.__proto__) // [Object: null prototype] {}

// 这两个原型对象, 是同一个原型对象
console.log(obj.__proto__ === obj2.__proto__)

// __ proto__ 有兼容性, Object.getPrototypeOf() 通用
console.log(Object.getPrototypeOf(obj) === obj.__proto__) // true

// 原型的作用
// 获取属性时候, 会触发 [[get]] 操作
// 1. 先从当前对象中去找属性, 找到则直接使用
// 2. 若没有找到, 则会沿着它的原型去找 [[prototype]]

// console.log(obj.age) // 找不到 undifined

// 添加到原型里面就能可以了 
obj.__proto__.age = 18
console.log(obj.age)  // 18

// ob2 也能访问
console.log(obj2.age) // 18

函数原型 (显式) prototype

函数也是一个对象, 且有一个显示的 prototype 属性( 除箭头函数), 也是一个 指针 指向另外一个对象.

function foo() {}  // 作为构造函数

var f1 = new foo()
var f2 = new foo()

// f1, f2 对象的 [[prototype]] -> 构造函数 foo.prototype
console.log(foo.prototype) // {}

// new 关键字会自动将创建出来的对象的隐式原型, 指向 构造函数的显示原型
console.log(f1.__proto__ === foo.prototype) // true
console.log(f2.__proto__ === foo.prototype) // true

就函数作为构造函数时, 其显示的 prototype 会作为其所有 被 new 出来的对象的 [[prototype]] 对象

对象的 [[prototype]] 用来指向其 "父类" 是谁, 构造函数的 prototype 用来给所有子对象 "共享空间"

foo.prototype !== foo.__ proto __ 的, 前者作为构造函数, "向下共享", 后者作为对象, "指向父类"

因此, js 中的函数具有二义性, 它是一个函数, 也是一个对象, 既有作为对象的隐式 [[prototype]] 对象 (父类), 也有作为构造函数的显式 prototype (给子类共享)

我也是搞了好久才明白这个东西的, 但是请相信, 一旦理解了函数这个特性, 那后面的原型链, 继承等这些东西都轻松拿捏.

// 仅将函数作为对象看
obj.__proto__ === foo.prototype // true

// Funtion() 是 js 内置的函数, 是所有函数的 构造函数
foo.__proto__ === Function.prototype // true

// 那从逻辑推断, Functon() 的构造函数,就是它自身
console.log(Function.__proto__ === Function.prototype) // true

这个设计简直完美, 逻辑完整闭环呀!.

Constructor 属性

在 js 的原型对象上会默认添加一个属性 constructor, 它是一个 函数的引用, 指向当前的构造函数

引用 vs: 指针

从行为上看, 就是 指针, 它指向了一个函数对象, 而已被传值, 调用, 赋值

从实现上看, 不是 指针, 是一个高级语言的引用 (reference)

引用更像一个"快捷方式", 知道去哪找到真正的对象, 但是不能看到 "硬盘地址"

// constructor 是确实存在的

function foo() {}

console.log(foo.prototype) // {}, 有隐藏一些属性

// 看全部属性
console.log(Object.getOwnPropertyDescriptors(foo.prototype))

// new 构造函数出来对象 的 原型对象
{
  constructor: {
    value: [Function: foo],
    writable: true,
    enumerable: false,
    configurable: true
  }
}

可以看到它里面的 value, 存的就是其构造函数的引用.

// constructor 引用

function Person() {}

var p1 = new Person()
var p2 = new Person

// p1, p2 的原型对象是 Person.prototype 对象
// Person.prototype 对象有一个 constructor 属性, 是个引用
// 这个引用, 指向 Person 这个构造函数对象
console.log(Person.prototype.constructor === Person)

// 再来分步理解一下
console.log(p1.__proto__ == Person.prototype) // true 

console.log(Person.prototype.constructor === Person) // true

console.log(p1.__proto__.constructor == Person) // true

我们是可以直接修改整个 prototype 对象的, 但不建议, 这样会破坏默认的原型链结构啦.

// prototype 实现属性, 方法添加和共享

function Person() {}


// 可以添加自己的属性到 Person.prototype 上, 这样能实现共享
Person.prototype.name = 'youge'
Person.prototype.age = 18
Person.prototype.eating = function() {console.log(this.name + ' is eating')}

var p1 = new Person()
var p2 = new Person()

// 原型上的属性都是共享的
console.log(p1.name) // youge
console.log(p2.name) // youge 
p1.eating()

// 修个对象会优先查找, 添加到的是自己对象域
p1.name = 'cj'
console.log(p1.name) // cj
console.log(p2.name) // youge

// 也可以修改整个 prototype 对象, 即指向了新的对象
Person.prototype = { }

console.log(p1.name) // cj 

// p2.name 依旧指向 原来的 prototype, 不会改变 !!!
console.log(p2.name) // name

对象 [[Prototype]] 指向
p1 旧原型对象 { name: 'youge', age: 18 }
p2 旧原型对象 { name: 'youge', age: 18 }
Person.prototype 指向新对象 { }(新原型)

👉 p1p2 仍然指向同一个旧原型对象,所以它们的原型是一致的,没有分裂, 也是被引用不会被垃圾回收.

只有在修改了 Person.prototype 之后再 new 对象 p3 才会指向新对象哈.

// Person.prototype = { }, 前面的 p1, p2 依旧引用之前原型对象

var p3 = new Person  // 后创建的引用新的原型对象
console.log(p3.name) // undefined

但是这样粗暴修改了 prototype 对象之后, 它的 constructor 也就没了, 相当于改掉了了 js 内置设置.

console.log(Person.prototype.construtor === Person) // false 

要还原一下的话, 通过之前学的 Object.defineProperty() 加上去就行啦.

// 改了 prototype 对象后 
console.log(Person.prototype.construtor === Person) // false 

// 再将 constructor 属性加回来, 并指向 构造函数自身
Object.defineProperty(Person.prototype, "construtor", {
  value: Person,
  configurable: true,
  writable: false,  // 不让修改
  enumerable: false // 不让枚举
})

console.log(Person.prototype.construtor === Person) // true

创建对象-构造函数+原型

此前用构造函数创建对象的方式, 有个弊端是, 会创建重复的方法,.

现在学了原型之后, 则可以 将这些重复的方法, 添加到原型对象上, 实现共享.

对象属性通常是不需要的, 因为都是要 "隔离", 直接加自己对象即可, 而共享的更多是方法, 添加的原型中即可

// 创建对象: 构造函数 + 原型

function Person(name, age, height, city) {
  // 对象各自的属性
  this.name = name 
  this.age = age 
  this.height = height
  this.city = city 

  // 对象的各自方法, 会被重复创建的哦
  this.eating = function() {
    console.log(this.name + ' is eating')
  }
}

// 每个实例对象共享的方法, 放在原型上 
Person.prototype.runing = function() {
  console.log(this.name + ' is runing')
}



var p1 = new Person('youge', 18, 1.88, '长安镇')
var p2 = new Person('cjj', 28, 1.78, '虎门镇')

// 调用自己对象上的方法
p1.eating()

// p1, p2 都有各自的 eating 方法
console.log(p1.eating === p2.eating) // false

// 调用原型上的方法
p1.runing() // youge is runing
p2.runing() // cjj is runing

// p1, p2 共享原型上的 runing 方法, 不用重复被创建, 节省内存
console.log(p1.runing === p2.runing) // true

至此, 关于 js 对象的构造函数, js 函数也是对象, 对象的隐式原型对象, 构造函数对象的显式原型对象等核心概念就理解得差不多了, 对原型概念的理解非常关键, 一旦真正理解, 则后续受益无穷呀.

posted @ 2025-08-09 15:43  致于数据科学家的小陈  阅读(22)  评论(0)    收藏  举报