轨道
/
JavaScript
JavaScript
/
练习
/
窗口系统
窗口系统

窗口系统

学习练习

简介

JavaScript 具备面向对象编程(OOP)的能力。 在面向对象编程中,你会想从“模板”(类)创建对象(实例),让它们包含特定的数据和功能。 在面向对象的语境中,数据属性叫作_字段_,函数属性叫作_方法_。

在 2015 年类被加入语言规范之前,JavaScript 根本没有类,但它允许使用基于原型的继承来实现面向对象编程。 尽管如今已经有了class关键字,JavaScript 仍然是一门_基于原型的_语言。

为了理解“基于原型”的语言意味着什么,以及 JavaScript 究竟如何工作,我们要回到还没有类的那个年代。

原型语法

构造函数

在 JavaScript 中,模板(类)由普通函数来实现。 当一个函数要被当作这样的模板使用时,它就叫作_构造函数_,约定是函数名要以大写字母开头。 调用构造函数时,用 new 关键字从模板派生出实例(对象)。

function Car() {
  // ...
}

const myCar = new Car();
const yourCar = new Car();

需要注意的是,在 JavaScript 中,即使实例已经创建完成,实例和构造函数之间仍然保持着关系。 每个实例对象都包含一个隐藏的内部属性,在语言规范中称为 [[prototype]]。 它保存着对构造函数 prototype 键的值的引用。 没错,你没看错,JavaScript 函数可以有键值对,因为在幕后它本身也是一个对象。

Note

总结一下:

  • 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 会沿着整条原型链查找它。

Caution

注意,只有在读取值时才会沿着原型链查找。 直接设置或删除实例对象的属性,只会作用于那个特定的实例。 如果你习惯了基于类的继承的语言,这可能和你预期的不同。

类语法

如今,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 仍然是一门基于原型的语言。 上面“原型语法”一节里学到的所有机制依然适用。

私有字段、getter 和 setter

默认情况下,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.

  1. 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,其余任务用类语法完成。

1. 定义 Size 用于存储窗口尺寸

定义一个名为 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

2. 定义 Position 用于存储窗口位置

定义一个名为 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

3. 定义 ProgramWindow 类

定义一个 ProgramWindow 类,包含以下字段:

  • screenSize:保存一个 Size 类型的固定值,width 为 800,height 为 600
  • size:保存一个 Size 类型的值,初始值为 Size 实例的默认值
  • position:保存一个 Position 类型的值,初始值为 Position 实例的默认值

窗口被打开(创建)时,一开始总是具有默认的尺寸和位置。

const programWindow = new ProgramWindow();
programWindow.screenSize.width;
// => 800

// Similar for the other fields.

补充说明:使用 ProgramWindow 这个名字而不是 Window,是为了将这个类与浏览器环境中内置的 Window 类区分开。

4. 添加一个用于调整窗口尺寸的方法

ProgramWindow 类应该包含一个方法 resize。它应该接收一个 Size 类型的形参作为输入,并尝试将窗口调整为指定的尺寸。

不过,新尺寸不能超出特定的边界。

  • 允许的最小高度或宽度是 1。请求的高度或宽度小于 1 时会被截断为 1。
  • 最大高度和宽度取决于窗口的当前位置,窗口的边缘不能超出屏幕的边缘。超过这些边界的值会被截断为它们能达到的最大尺寸。例如,如果窗口的位置在 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

5. 添加一个用于移动窗口的方法

除了调整尺寸的功能外,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

6. 修改程序窗口

实现一个 changeWindow 函数,它接收一个 ProgramWindow 实例作为输入,并将窗口修改为指定的尺寸和位置。在应用更改后,该函数应返回传入的那个 ProgramWindow 实例。

窗口应该获得宽度 400、高度 300,并位于 x = 100、y = 150。

const programWindow = new ProgramWindow();
changeWindow(programWindow);
programWindow.size.width;
// => 400

// Similar for the other fields.
通过 GitHub 编辑 链接将在新窗口或新标签页中打开
JavaScript Exercism

准备好开始 窗口系统 了吗?

注册 Exercism,借助 37 个概念159 个练习 和真人导师指导,学习并掌握 JavaScript,全部免费。