JavaScript 具备面向对象编程(OOP)的能力。 在面向对象编程中,你会想从“模板”(类)创建对象(实例),让它们包含特定的数据和功能。 在面向对象的语境中,数据属性叫作_字段_,函数属性叫作_方法_。
在 2015 年类被加入语言规范之前,JavaScript 根本没有类,但它允许使用基于原型的继承来实现面向对象编程。
尽管如今已经有了class关键字,JavaScript 仍然是一门_基于原型的_语言。
为了理解“基于原型”的语言意味着什么,以及 JavaScript 究竟如何工作,我们要回到还没有类的那个年代。
在 JavaScript 中,模板(类)由普通函数来实现。
当一个函数要被当作这样的模板使用时,它就叫作_构造函数_,约定是函数名要以大写字母开头。
调用构造函数时,用 new 关键字从模板派生出实例(对象)。
function Car() {
// ...
}
const myCar = new Car();
const yourCar = new Car();
需要注意的是,在 JavaScript 中,即使实例已经创建完成,实例和构造函数之间仍然保持着关系。
每个实例对象都包含一个隐藏的内部属性,在语言规范中称为 [[prototype]]。
它保存着对构造函数 prototype 键的值的引用。
没错,你没看错,JavaScript 函数可以有键值对,因为在幕后它本身也是一个对象。
总结一下:
prototype 属性会成为实例的_原型_。通常,你希望所有派生出的对象(实例)都包含一些字段,并在构造对象时给这些字段传入一些初始值。
这可以通过 this 关键字来实现。
在构造函数内部,this 代表将通过 new 创建的新对象。
当构造函数用 new 调用时,this 会被自动返回。
也就是说,只要在构造函数里把字段加到 this 上,就能给新实例添加字段。
function Car(color, weight) {
this.color = color;
this.weight = weight;
this.engineRunning = false;
}
const myCar = new Car('red', '2mt');
myCar.color;
// => 'red'
myCar.engineRunning;
// => false
方法通过构造函数的 prototype 属性添加。
在方法内部,你可以通过 this 访问实例的字段。
这之所以可行,是因为下面这条通用规则。
当一个函数作为对象的方法被调用时,它的
this会被设为调用该方法时所针对的对象。1
function Car() {
this.engineRunning = false;
// ...
}
Car.prototype.startEngine = function () {
this.engineRunning = true;
};
Car.prototype.addGas = function (litre) {
// ...
};
const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true
上面例子中的 myCar 是一个普通的 JavaScript 对象,如果我们查看它(比如在浏览器控制台里),会发现 myCar 对象内部并没有直接名为 startEngine、值为函数的属性。
那么上面的代码到底是怎么工作的呢?
这里的秘密叫作_原型链_。
当你试图访问对象的任意属性(字段或方法)时,JavaScript 首先检查相应的键能否直接在对象自身中找到。
如果找不到,它会继续在原始对象的 [[prototype]] 属性所引用的对象里查找这个键。
如前所述,在我们的例子里,[[prototype]] 指向构造函数的 prototype 属性。
JavaScript 就是在这里找到 startEngine 函数的,因为我们把它加在了那里。
function Car() {
// ...
}
Car.prototype.startEngine = function () {
// ...
};
而且这条链并不止于此。
Car.prototype 的 [[prototype]] 属性(即 myCar.[[prototype]].[[prototype]])引用了 Object.prototype(Object 构造函数的 prototype 属性)。
它包含所有 JavaScript 对象都可以使用的通用方法,比如 toString()。
总之,你可以调用 myCar.toString(),这个方法会存在,因为 JavaScript 会沿着整条原型链查找它。
注意,只有在读取值时才会沿着原型链查找。 直接设置或删除实例对象的属性,只会作用于那个特定的实例。 如果你习惯了基于类的继承的语言,这可能和你预期的不同。
如今,JavaScript 支持用 class 关键字定义类。
这是在 2015 年被加入语言规范的。
一方面,它提供了语法糖,让类更易于阅读和编写。
新语法更接近 C++ 或 Java 等语言中书写类的方式。
从这些语言转过来的开发者更容易适应。
另一方面,类语法为原型语法中无法使用的全新语言特性铺平了道路。
使用新语法时,类用 class 关键字定义,后面跟着类名以及花括号里的类主体。
主体中包含构造函数的定义,也就是一个名为 constructor 的特殊方法。
这个函数的运作方式和原型语法中的构造函数完全一样。
类主体还包含所有方法的定义。
方法的语法类似于我们见过的简写形式,即把函数作为值添加到对象里,参见概念:对象。
class Car {
constructor(color, weight) {
this.color = color;
this.weight = weight;
this.engineRunning = false;
}
startEngine() {
this.engineRunning = true;
}
addGas(litre) {
// ...
}
}
const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true
请记住,在幕后 JavaScript 仍然是一门基于原型的语言。 上面“原型语法”一节里学到的所有机制依然适用。
默认情况下,JavaScript 中所有实例字段都是公有的。 它们可以被直接访问和赋值。
不过,有一个约定俗成的惯例:以下划线开头的字段和方法应被视为私有的。 不应该从类的外部直接访问它们。
私有字段有时会搭配 getter 和 setter。
借助 get 和 set 关键字,你可以定义在访问或赋值与函数同名的属性时执行的函数。
class Car {
constructor() {
this._mileage = 0;
}
get mileage() {
return this._mileage;
}
set mileage(value) {
throw new Error(`Mileage cannot be manipulated, ${value} is ignored.`);
// Just an example, usually you would not provide a setter in this case.
}
}
const myCar = new Car();
myCar.mileage;
// => 0
myCar.mileage = 100;
// => Error: Mileage cannot be manipulated, 100 is ignored.
this 示例:作为对象方法,MDN。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#as_an_object_method(引用日期:2021 年 12 月 3 日) ↩
在这个练习中,你将模拟一个基于窗口的计算机系统。你将创建一些可以移动和调整尺寸的窗口。下图展示了下面将要使用的各个值。
<--------------------- screenSize.width --------------------->
^ ┌────────────────────────────────────────────────────────────┐
| │ │
| │ position.x, _ │
| │ position.y \ │
| │ \<----- size.width -----> │
| │ ^ *──────────────────────┐ │
| │ | │ title │ │
| │ | ├──────────────────────┤ │
screenSize.height │ | │ │ │
| │ size.height │ │ │
| │ | │ contents │ │
| │ | │ │ │
| │ | │ │ │
| │ v └──────────────────────┘ │
| │ │
| │ │
v └────────────────────────────────────────────────────────────┘
📣 为了练习你广泛的 JavaScript 技能,请尝试用原型语法完成任务 1 和 2,其余任务用类语法完成。
定义一个名为 Size 的类(构造函数)。它应该有两个字段 width 和 height,用来存储窗口当前的尺寸。构造函数应该接收这些字段的初始值。第一个形参提供宽度,第二个形参提供高度。宽度和高度的默认值应分别为 80 和 60。
此外,定义一个方法 resize(newWidth, newHeight),它接收新的宽度和高度作为形参,并修改这些字段以反映新的尺寸。
const size = new Size(1080, 764);
size.width;
// => 1080
size.height;
// => 764
size.resize(1920, 1080);
size.width;
// => 1920
size.height;
// => 1080
定义一个名为 Position 的类(构造函数),它有两个字段 x 和 y,分别存储窗口左上角当前的水平位置和垂直位置。构造函数应该接收这些字段的初始值。第一个形参提供 x 的值,第二个形参提供 y 的值。两个字段的默认值都应为 0。
位置 (0, 0) 是屏幕的左上角,向右移动时 x 值增大,向下移动时 y 值增大。
另外定义一个方法 move(newX, newY),它接收新的 x 和 y 形参,并修改属性以反映新的位置。
const point = new Position();
point.x;
// => 0
point.y;
// => 0
point.move(100, 200);
point.x;
// => 100
point.y;
// => 200
定义一个 ProgramWindow 类,包含以下字段:
screenSize:保存一个 Size 类型的固定值,width 为 800,height 为 600size:保存一个 Size 类型的值,初始值为 Size 实例的默认值position:保存一个 Position 类型的值,初始值为 Position 实例的默认值窗口被打开(创建)时,一开始总是具有默认的尺寸和位置。
const programWindow = new ProgramWindow();
programWindow.screenSize.width;
// => 800
// Similar for the other fields.
补充说明:使用 ProgramWindow 这个名字而不是 Window,是为了将这个类与浏览器环境中内置的 Window 类区分开。
ProgramWindow 类应该包含一个方法 resize。它应该接收一个 Size 类型的形参作为输入,并尝试将窗口调整为指定的尺寸。
不过,新尺寸不能超出特定的边界。
x = 400、y = 300,并且请求调整为 height = 400、width = 300,那么窗口会被调整为 height = 300、width = 300,因为在 y 方向上屏幕不够大,无法完全满足该请求。const programWindow = new ProgramWindow();
const newSize = new Size(600, 400);
programWindow.resize(newSize);
programWindow.size.width;
// => 600
programWindow.size.height;
// => 400
除了调整尺寸的功能外,ProgramWindow 类还应该包含一个方法 move。它应该接收一个 Position 类型的形参作为输入。move 方法与 resize 类似,不过它调整的是窗口的_位置_,将其设为请求的值,而不是尺寸。
和 resize 一样,新位置也不能超出特定的限制。
x 和 y 的最小位置都是 0。x = 250、y = 100,并且请求移动到 x = 600、y = 200,那么窗口会被移动到 x = 550、y = 200,因为在 x 方向上屏幕不够大,无法完全满足该请求。const programWindow = new ProgramWindow();
const newPosition = new Position(50, 100);
programWindow.move(newPosition);
programWindow.position.x;
// => 50
programWindow.position.y;
// => 100
实现一个 changeWindow 函数,它接收一个 ProgramWindow 实例作为输入,并将窗口修改为指定的尺寸和位置。在应用更改后,该函数应返回传入的那个 ProgramWindow 实例。
窗口应该获得宽度 400、高度 300,并位于 x = 100、y = 150。
const programWindow = new ProgramWindow();
changeWindow(programWindow);
programWindow.size.width;
// => 400
// Similar for the other fields.