笔记:JavaScript 面向对象
前言
最近在琢磨 TypeScript 和 Nest.js 的项目,发现后端里面的写法直接是 OOP 面向对象编程的样子了,痛苦的想起以前看 ThinkPHP 的时候的懵逼的感觉,决定花点时间搞懂到底是什么玩意,花了一两个小时彻底琢磨了一下面向对象编程,搞懂以后终于知道以前看 Vue 里面那个“创建实例”是啥意思了,现在做个笔记记录一下面向对象,顺便和 JavaScript 的知识一起总结一下。合着学了这么久才发现其实现在在真正入门计算机,这辈子有了。
面向对象
在直接讲语法之前,先来知道下"面向对象编程"到底是个什么寄吧东西,来看一段百度的定义:“把相关的数据和方法组织为一个整体来看待,从更高的层次来进行系统建模,更贴近事物的自然运行模式……”,说的非常抽象。那从博客里面找段代码来看看案例呢?
public class Person {
String name; //名称
String idCard; //身份证
String phone; //手机
String qq; //qq号
}
好像就只是把数据抽象到一起了?当作集合??我前面了解面向对象概念的时候也是这么粗略的认为的,至于其他的“封装”、“继承”、“多态”之类的特征都没记住过,直到最近。和“面向对象”相对的是“面向过程”,一个对象一个过程。我们分别用两种思想写一段代码来更好的看出它们的区别。我们编写一个简单的程序,它包含一个 "购物车" 功能,允许用户添加商品并计算总价。先来看看面向过程的代码是咋样的:
// 定义商品数据
let items = [];
let totalPrice = 0;
// 添加商品到购物车
function addItem(name, price) {
items.push({ name, price });
totalPrice += price;
}
// 打印购物车内容
function printCart() {
console.log("购物车内容:");
items.forEach(item => {
console.log(`商品: ${item.name}, 价格: ${item.price}`);
});
console.log(`总价: ${totalPrice}`);
}
// 使用
addItem("苹果", 5);
addItem("香蕉", 3);
addItem("橙子", 4);
printCart();
清晰易懂,代码从上往下按照程序执行的过程来执行。代码是通过函数组织的,每个函数完成一个特定的任务。数据是独立于函数的,函数通过传递参数来处理这些数据。一开始入门 JavaScript 的时候,基本都是这么按照过程来编写的。
那面向过程呢?来看看具体的代码:
class ShoppingCart {
constructor() {
this.items = [];
this.totalPrice = 0;
}
// 添加商品
addItem(name, price) {
this.items.push({ name, price });
this.totalPrice += price;
}
// 打印购物车内容
printCart() {
console.log("购物车内容:");
this.items.forEach(item => {
console.log(`商品: ${item.name}, 价格: ${item.price}`);
});
console.log(`总价: ${this.totalPrice}`);
}
}
// 创建购物车实例
const cart = new ShoppingCart();
// 使用购物车
cart.addItem("苹果", 5);
cart.addItem("香蕉", 3);
cart.addItem("橙子", 4);
cart.printCart();
可以发现,和购物车相关的代码被组织在一个class
开头的类里面了,我们还看到了一些陌生的东西,比如constructor()
、“实例”、“构造函数”。这几个概念是 JavaScript 中很常见的一个东西!
类、实例、构造函数
类和实例
刚刚看完了面向对象和面向过程的两段代码,其实对面向对象编程有了个初步认识,类这个玩意儿,就像是一个蓝图,里面定义了我们需要的各种数据和行为,然后通过构造函数创建一个实例,也就是实际操作的对象。
欸?!实例是什么?很简单,类本身只是一个定义了行为和属性的抽象存在,无法直接调用其方法或访问其属性,类本身并不提供功能,只有通过实例化(换个说法叫,创建实例)后,才能使用类中的方法和属性。
构造函数
实现
那这个构造函数是什么,构造函数和类有什么区别?一句话,JavaScript 中的类其实就是通过构造函数实现的。在 ES6 还没发布之前,JavaScript 没有 class 语法,所有的对象创建都是通过构造函数实现的。JavaScript 中的类的引入实际上是为了提供一种更清晰、易懂的语法,它在底层仍然依赖构造函数。
举个例子,我们建个叫 Person 的类:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
console.log(`Hi, I am ${this.name} and I am ${this.age} years old.`);
}
}
const person1 = new Person('Alice', 25); // 使用类和构造函数创建实例
person1.greet(); // 输出:Hi, I am Alice and I am 25 years old.
它其实和下面这段代码是一个意思:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.greet = function() {
console.log(`Hi, I am ${this.name} and I am ${this.age} years old.`);
};
const person1 = new Person('Alice', 25); // 通过构造函数创建实例
person1.greet(); // 输出:Hi, I am Alice and I am 25 years old.
所以,在现在的 JavaScript 中,这个语法class
,其实就是方便你程序员写的(语法糖),它的底层仍然是通过构造函数来实现的。
constructor()
我们刚刚的 ShoppingCart 类里用了 constructor,这个是类里面的构造函数,它在创建实例的时候会自动调用,把初始化的逻辑写在里面,比如 items 和 totalPrice。写了这么多其实就是让代码更好组织,不至于到处乱飞。
说点实际的,如果不用类的方式,每次新增一个购物车都得手动去维护那些变量和函数,搞多了代码会相互干扰,特别是数据混乱的时候难以定位问题。用类之后,每个实例都有自己的 items 和 totalPrice,互不干扰,清晰明了。就像工厂流水线上生产的商品,每个商品独立存在,都有自己的数据和行为。当然!如果你的项目经验不多,可能没法马上理解这是啥意思,反正就是让代码更好的组织。
比如我们再创建一个新的购物车实例,这两个购物车实例的数据就互不干扰了:
const cart1 = new ShoppingCart();
const cart2 = new ShoppingCart();
cart1.addItem("苹果", 5);
cart2.addItem("香蕉", 3);
cart1.printCart(); // 输出 "购物车内容: 苹果,价格:5 总价:5"
cart2.printCart(); // 输出 "购物车内容: 香蕉,价格:3 总价:3"
这么做的好处就是代码的可维护性和扩展性大大增强了,以后要加功能,比如打折、税率等逻辑,只需要修改这个类或新增方法就行了,别的实例用的逻辑就都能跟着走。
创建实例时的过程
当我们使用 new 关键字创建一个类的实例时,实际发生了以下几个步骤:
- 创建一个新对象:JavaScript 创建了一个空的对象。
- 将 this 绑定到新对象:构造函数中的 this 被绑定到这个新对象上。
- 执行构造函数:构造函数内部的代码执行,给新对象的属性赋值(如 this.name = name)。
- 返回新对象:默认情况下,构造函数返回这个新创建的对象,除非你手动返回另一个对象。
const person1 = new Person('Alice', 25);
这行代码做的事情就是:创建一个 Person 的新实例,调用构造函数 Person 并将 this 绑定到新创建的 person1 对象上。
原型
原型
原型(Prototype)是 JavaScript 中的一个对象属性。每个 JavaScript 对象都有一个隐藏的内部属性,叫做 [[Prototype]]
,也就是我们常说的 __proto__
。这个属性指向该对象的原型对象,原型对象中定义了该对象可以共享的属性和方法。
概念太抽象,直接看对象实例化的时候原型起到的作用:每个函数都有一个特殊的属性 prototype,它指向的是一个对象,当函数作为构造函数(通过 new 关键字调用)时,这个对象将成为新实例的原型。
function Person(name) {
this.name = name;
}
Person.prototype.greet = function() {
console.log(`Hello, my name is ${this.name}`);
};
const person1 = new Person("Alice");
const person2 = new Person("Bob");
person1.greet(); // 输出:Hello, my name is Alice
person2.greet(); // 输出:Hello, my name is Bob
这个是刚才举例类的本质是构造函数的例子,还是用这个例子继续。来简单说明一下:Person
是一个构造函数。Person.prototype
是它的原型对象,greet 方法被定义在这个原型上。
当我们创建 person1 和 person2 时,这两个实例可以通过它们的原型链访问Person.prototype
上的 greet 方法。这意味着 person1 和 person2 实例共享 greet 方法,而不会为每个实例创建独立的方法。
原型链
原型链,“链”什么?一层一层链接起来。JavaScript 这门语言,它本身就是基于原型继承的。简而言之,原型链是对象继承属性和方法的机制。每个对象都有一个叫做 __proto__
的属性。对象通过原型属性(proto)链接到它的原型对象,原型对象又可以链接到它自己的原型,直到链的末端为 null(即Object.prototype.__proto__
为 null)。这种链接形成了一个链式结构,称为原型链。
class ShoppingCart {
constructor() {
this.items = [];
this.totalPrice = 0;
}
// 添加商品
addItem(name, price) {
this.items.push({ name, price });
this.totalPrice += price;
}
// 打印购物车内容
printCart() {
console.log("购物车内容:");
this.items.forEach(item => {
console.log(`商品: ${item.name}, 价格: ${item.price}`);
});
console.log(`总价: ${this.totalPrice}`);
}
}
// 创建购物车实例
const cart = new ShoppingCart();
// 使用购物车
cart.addItem("苹果", 5);
cart.addItem("香蕉", 3);
cart.addItem("橙子", 4);
cart.printCart();
JavaScript 中,类的方法其实是挂在原型上的,比如我们前面的 ShoppingCart 类,addItem 和 printCart 方法都不会直接存在于实例本身,而是存在于 ShoppingCart.prototype 上,实例通过原型链找到这些方法。
console.log(cart.__proto__ === ShoppingCart.prototype); // true
这段代码的意思是,cart 的 proto 属性指向 ShoppingCart 的原型,也就是说 cart 实例是通过 __proto__
找到 ShoppingCart 类的 addItem 和 printCart 方法的。
为了节省内存,JavaScript 把所有实例共享的内容放在原型上,不会每次创建实例都重新创建一遍方法,这就是原型链的好处。比如你有 100 个购物车实例,但 addItem 方法只在 ShoppingCart.prototype 上存一份,所有实例都用同一份方法。
后记
前面学 Vue 2 的时候有看到“创建实例”,当时就单纯的以为通过new Vue({ ... })
的过程就是调用构造函数,创建一个 Vue 实例,怎么创建的?我想这一定是从导入到 Vue 文件里面的function Vue(...) {...}
导入的,然后从Vue
这个构造函数里“复制”一个和 Vue 功能一模一样的东西的,但是为什么它自己不能直接用?具体怎么样我也没深究。
这几日研究 TS 和 Nest 的时候搜了下面向对象,重新看了一下类相关的内容,才发现这玩意构造函数和类不是一个东西吗,其实就是建了个蓝图但是需要你导入东西来使用,只有导入配置导入参数才能创建实例,实例化,去使用。感觉和模块化的思想很像,写了这么久前端才发现原来前端工程的设计也是和面向对象比较像的。