六、面向对象编程
1,实例对象与new命令
面向对象编程(Object Oriented Programming,缩写为 OOP)是目前主流的编程范式。它将真 实世界各种复杂的关系,抽象为一个个对象,然后由对象之间的分工与合作,完成对真实世界的模拟。 每一个对象都是功能中心,具有明确分工,可以完成接受信息、处理数据、发出信息等任务。对象可以 复用,通过继承机制还可以定制。因此,面向对象编程具有灵活、代码可复用、高度模块化等特点,容 易维护和开发,比起由一系列函数或指令组成的传统的过程式编程(procedural programming), 更适合多人合作的大型软件项目。
那么,“对象”(object)到底是什么?我们从两个层次来理解。
(1)对象是单个实物的抽象。 一本书、一辆汽车、一个人都可以是对象,一个数据库、一张网页、一个与远程服务器的连接也可以是 对象。当实物被抽象成对象,实物之间的关系就变成了对象之间的关系,从而就可以模拟现实情况,针 对对象进行编程。
(2)对象是一个容器,封装了属性(property)和方法(method)。 属性是对象的状态,方法是对象的行为(完成某种任务)。比如,我们可以把动物抽象为 animal 对 象,使用“属性”记录具体是那一种动物,使用“方法”表示动物的某种行为(奔跑、捕猎、休息等等)。
构造函数
面向对象编程的第一步,就是要生成对象。前面说过,对象是单个实物的抽象。通常需要一个模板,表 示某一类实物的共同特征,然后对象根据这个模板生成。 典型的面向对象编程语言(比如 C++ 和 Java),都有“类”(class)这个概念。所谓“类”就是对象 的模板,对象就是“类”的实例。但是,JavaScript 语言的对象体系,不是基于“类”的,而是基于构 造函数(constructor)和原型链(prototype)。 JavaScript 语言使用构造函数(constructor)作为对象的模板。所谓”构造函数”,就是专门用来 生成实例对象的函数。它就是对象的模板,描述实例对象的基本结构。一个构造函数,可以生成多个实 例对象,这些实例对象都有相同的结构。构造函数就是一个普通的函数,但是有自己的特征和用法。
1. var Vehicle = function () {
2. this.price = 1000;
3. };
上面代码中, Vehicle 就是构造函数。为了与普通函数区别,构造函数名字的第一个字母通常大写。 构造函数的特点有两个。
函数体内部使用了 this 关键字,代表了所要生成的对象实例。
生成对象的时候,必须使用 new 命令。
new 命令
new 命令的作用,就是执行构造函数,返回一个实例对象。
1. var Vehicle = function () {
2. this.price = 1000;
3. };
4.
5. var v = new Vehicle();
6. v.price // 1000
上面代码通过 new 命令,让构造函数 Vehicle 生成一个实例对象,保存在变量 v 中。这个新生 成的实例对象,从构造函数 Vehicle 得到了 price 属性。 new 命令执行时,构造函数内部 的 this ,就代表了新生成的实例对象, this.price 表示实例对象有一个 price 属性,值是 1000。
使用 new 命令时,根据需要,构造函数也可以接受参数。
1. var Vehicle = function (p) {
2. this.price = p;
3. };
4.
5. var v = new Vehicle(500);
new 命令本身就可以执行构造函数,所以后面的构造函数可以带括号,也可以不带括号。下面两行代码是等价的,但是为了表示这里是函数调用,推荐使用括号。
1. // 推荐的写法
2. var v = new Vehicle();
3. // 不推荐的写法
4. var v = new Vehicle;
一个很自然的问题是,如果忘了使用 new 命令,直接调用构造函数会发生什么事? 这种情况下,构造函数就变成了普通函数,并不会生成实例对象。而且由于后面会说到的原 因, this 这时代表全局对象,将造成一些意想不到的结果。
1. var Vehicle = function (){
2. this.price = 1000;
3. };
4.
5. var v = Vehicle();
6. v // undefined
7. price // 1000
上面代码中,调用 Vehicle 构造函数时,忘了加上 new 命令。结果,变量 v 变成 了 undefined ,而 price 属性变成了全局变量。因此,应该非常小心,避免不使用 new 命令、 直接调用构造函数。 为了保证构造函数必须与 new 命令一起使用,一个解决办法是,构造函数内部使用严格模式,即第一 行加上 use strict 。这样的话,一旦忘了使用 new 命令,直接调用构造函数就会报错。
1. function Fubar(foo, bar){
2. 'use strict';
3. this._foo = foo;
4. this._bar = bar;
5. }
6.
7. Fubar()
8. // TypeError: Cannot set property '_foo' of undefined
上面代码的 Fubar 为构造函数, use strict 命令保证了该函数在严格模式下运行。由于严格模式 中,函数内部的 this 不能指向全局对象,默认等于 undefined ,导致不加 new 调用会报错 (JavaScript 不允许对 undefined 添加属性)。 另一个解决办法,构造函数内部判断是否使用 new 命令,如果发现没有使用,则直接返回一个实例对 象。
1. function Fubar(foo, bar) {
2. if (!(this instanceof Fubar)) {
3. return new Fubar(foo, bar);
4. }
5.
6. this._foo = foo;
7. this._bar = bar;
8. }
9.
10. Fubar(1, 2)._foo // 1
11. (new Fubar(1, 2))._foo // 1
上面代码中的构造函数,不管加不加 new 命令,都会得到同样的结果。
new 命令的原理
使用 new 命令时,它后面的函数依次执行下面的步骤。
1. 创建一个空对象,作为将要返回的对象实例。
2. 将这个空对象的原型,指向构造函数的 prototype 属性。
3. 将这个空对象赋值给函数内部的 this 关键字。
4. 开始执行构造函数内部的代码。
也就是说,构造函数内部, this 指的是一个新生成的空对象,所有针对 this 的操作,都会发生 在这个空对象上。构造函数之所以叫“构造函数”,就是说这个函数的目的,就是操作一个空对象 (即 this 对象),将其“构造”为需要的样子。 如果构造函数内部有 return 语句,而且 return 后面跟着一个对象, new 命令会返 回 return 语句指定的对象;否则,就会不管 return 语句,返回 this 对象。
1. var Vehicle = function () {
2. this.price = 1000;
3. return 1000;
4. };
5.
6. (new Vehicle()) === 1000
7. // false
上面代码中,构造函数 Vehicle 的 return 语句返回一个数值。这时, new 命令就会忽略这 个 return 语句,返回“构造”后的 this 对象。 但是,如果 return 语句返回的是一个跟 this 无关的新对象, new 命令会返回这个新对象,而不是 this 对象。这一点需要特别引起注意。
1. var Vehicle = function (){
2. this.price = 1000;
3. return { price: 2000 };
4. };
5.
6. (new Vehicle()).price
7. // 2000
上面代码中,构造函数 Vehicle 的 return 语句,返回的是一个新对象。 new 命令会返回这个 对象,而不是 this 对象。 另一方面,如果对普通函数(内部没有 this 关键字的函数)使用 new 命令,则会返回一个空对 象。
1. function getMessage() {
2. return 'this is a message';
3. }
4.
5. var msg = new getMessage();
6.
7. msg // {}
8. typeof msg // "object"
上面代码中, getMessage 是一个普通函数,返回一个字符串。对它使用 new 命令,会得到一个空 对象。这是因为 new 命令总是返回一个对象,要么是实例对象,要么是 return 语句指定的对象。 本例中, return 语句返回的是字符串,所以 new 命令就忽略了该语句。
new 命令简化的内部流程,可以用下面的代码表示。
1. function _new(/* 构造函数 */ constructor, /* 构造函数参数 */ params) {
2. // 将 arguments 对象转为数组
3. var args = [].slice.call(arguments);
4. // 取出构造函数
5. var constructor = args.shift();
6. // 创建一个空对象,继承构造函数的 prototype 属性
7. var context = Object.create(constructor.prototype);
8. // 执行构造函数
9. var result = constructor.apply(context, args);
10. // 如果返回结果是对象,就直接返回,否则返回 context 对象
11. return (typeof result === 'object' && result != null) ? result : context;
12. }
13.
14. // 实例
15. var actor = _new(Person, '张三', 28);
new.target
函数内部可以使用 new.target 属性。如果当前函数是 new 命令调用, new.target 指向当前函 数,否则为 undefined 。
1. function f() {
2. console.log(new.target === f);
3. }
4.
5. f() // false
6. new f() // true
使用这个属性,可以判断函数调用的时候,是否使用 new 命令。
1. function f() {
2. if (!new.target) {
3. throw new Error('请使用 new 命令调用!');
4. }
5. // ...
6. }
7.
8. f() // Uncaught Error: 请使用 new 命令调用!
上面代码中,构造函数 f 调用时,没有使用 new 命令,就抛出一个错误。
Object.create() 创建实例对象
构造函数作为模板,可以生成实例对象。但是,有时拿不到构造函数,只能拿到一个现有的对象。我们 希望以这个现有的对象作为模板,生成新的实例对象,这时就可以使用 Object.create() 方法。
1. var person1 = {
2. name: '张三',
3. age: 38,
4. greeting: function() {
5. console.log('Hi! I\'m ' + this.name + '.');
6. }
7. };
8.
9. var person2 = Object.create(person1);
10.
11. person2.name // 张三
12. person2.greeting() // Hi! I'm 张三.
上面代码中,对象 person1 是 person2 的模板,后者继承了前者的属性和方法。
2,this关键字
this 关键字是一个非常重要的语法点。毫不夸张地说,不理解它的含义,大部分开发任务都无法完 成。 前一章已经提到, this 可以用在构造函数之中,表示实例对象。除此之外, this 还可以用在别 的场合。但不管是什么场合, this 都有一个共同点:它总是返回一个对象。 this 就是属性或方法“当前”所在的对象。
1. this.property
上面代码中, this 就代表 property 属性当前所在的对象。 下面是一个实际的例子。
1. var person = {
2. name: '张三',
3. describe: function () {
4. return '姓名:'+ this.name;
5. }
6. };
7.
8. person.describe()
9. // "姓名:张三"
上面代码中, this.name 表示 name 属性所在的那个对象。由于 this.name 是 在 describe 方法中调用,而 describe 方法所在的当前对象是 person ,因此 this 指 向 person , this.name 就是 person.name 。 由于对象的属性可以赋给另一个对象,所以属性所在的当前对象是可变的,即 this 的指向是可变 的。
1. var A = {
2. name: '张三',
3. describe: function () {
4. return '姓名:'+ this.name;
5. }
6. };
7.
8. var B = {
9. name: '李四'
10. };
11.
12. B.describe = A.describe;
13. B.describe()
14. // "姓名:李四"
上面代码中, A.describe 属性被赋给 B ,于是 B.describe 就表示 describe 方法所在的当 前对象是 B ,所以 this.name 就指向 B.name 。 稍稍重构这个例子, this 的动态指向就能看得更清楚。
1. function f() {
2. return '姓名:'+ this.name;
3. }
4.
5. var A = {
6. name: '张三',
7. describe: f
8. };
9.
10. var B = {
11. name: '李四',
12. describe: f
13. };
14.
15. A.describe() // "姓名:张三"
16. B.describe() // "姓名:李四"
上面代码中,函数 f 内部使用了 this 关键字,随着 f 所在的对象不同, this 的指向也不 同。 只要函数被赋给另一个变量, this 的指向就会变。
1. var A = {
2. name: '张三',
3. describe: function () {
4. return '姓名:'+ this.name;
5. }
6. };
7.
8. var name = '李四';
9. var f = A.describe;
10. f() // "姓名:李四"
上面代码中, A.describe 被赋值给变量 f ,内部的 this 就会指向 f 运行时所在的对象(本 例是顶层对象)。 再看一个网页编程的例子。
1. <input type="text" name="age" size=3 onChange="validate(this, 18, 99);">
2.
3. <script>
4. function validate(obj, lowval, hival){
5. if ((obj.value < lowval) || (obj.value > hival))
6. console.log('Invalid Value!');
7. }
8. </script>
上面代码是一个文本输入框,每当用户输入一个值,就会调用 onChange 回调函数,验证这个值是否 在指定范围。浏览器会向回调函数传入当前对象,因此 this 就代表传入当前对象(即文本框),然 后就可以从 this.value 上面读到用户的输入值。 JavaScript 语言之中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中 运行, this 就是函数运行时所在的对象(环境)。这本来并不会让用户糊涂,但是 JavaScript 支持运行环境动态切换, this 的指向是动态的,没有办法事先确定到底指向哪个对象, 这才是最让初学者感到困惑的地方。
实质
JavaScript 语言之所以有 this 的设计,跟内存里面的数据结构有关系。
1. var obj = { foo: 5 };
上面的代码将一个对象赋值给变量 obj 。JavaScript 引擎会先在内存里面,生成一个对象 { foo: 5 } ,然后把这个对象的内存地址赋值给变量 obj 。也就是说,变量 obj 是一个地址 (reference)。后面如果要读取 obj.foo ,引擎先从 obj 拿到内存地址,然后再从该地址读出 原始的对象,返回它的 foo 属性。 原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面例子的 foo 属性,实际上是以下面的形式保存的。
1. {
2. foo: {
3. [[value]]: 5
4. [[writable]]: true
5. [[enumerable]]: true
6. [[configurable]]: true
7. }
8. }
注意, foo 属性的值保存在属性描述对象的 value 属性里面。 这样的结构是很清晰的,问题在于属性的值可能是一个函数。
1. var obj = { foo: function () {} };
这时,引擎会将函数单独保存在内存中,然后再将函数的地址赋值给 foo 属性的 value 属性。
1. {
2. foo: {
3. [[value]]: 函数的地址
4. ...
5. }
6. }
由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。
1. var f = function () {};
2. var obj = { f: f };
3.
4. // 单独执行
5. f()
6.
7. // obj 环境执行
8. obj.f()
JavaScript 允许在函数体内部,引用当前环境的其他变量。
1. var f = function () {
2. console.log(x);
3. };
上面代码中,函数体里面使用了变量 x 。该变量由运行环境提供。 现在问题就来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获得 当前的运行环境(context)。所以, this 就出现了,它的设计目的就是在函数体内部,指代函数 当前的运行环境。
1. var f = function () {
2. console.log(this.x);
3. }
上面代码中,函数体里面的 this.x 就是指当前运行环境的 x 。
1. var f = function () {
2. console.log(this.x);
3. }
4.
5. var x = 1;
6. var obj = {
7. f: f,
8. x: 2,
9. };
10.
11. // 单独执行
12. f() // 1
13.
14. // obj 环境执行
15. obj.f() // 2
上面代码中,函数 f 在全局环境执行, this.x 指向全局环境的 x ;在 obj 环境执 行, this.x 指向 obj.x 。
使用场合
this 主要有以下几个使用场合。
(1)全局环境
全局环境使用 this ,它指的就是顶层对象 window 。
1. this === window // true
2.
3. function f() {
4. console.log(this === window);
5. }
6. f() // true
上面代码说明,不管是不是在函数内部,只要是在全局环境下运行, this就是指顶层对象window 。
(2)构造函数
构造函数中的 this ,指的是实例对象。
1. var Obj = function (p) {
2. this.p = p;
3. };
上面代码定义了一个构造函数 Obj 。由于 this 指向实例对象,所以在构造函数内部定 义 this.p ,就相当于定义实例对象有一个 p 属性。
1. var o = new Obj('Hello World!');
2. o.p // "Hello World!"
(3)对象的方法
如果对象的方法里面包含 this , this 的指向就是方法运行时所在的对象。该方法赋值给另一个 对象,就会改变 this 的指向。 但是,这条规则很不容易把握。请看下面的代码。
1. var obj ={
2. foo: function () {
3. console.log(this);
4. }
5. };
6.
7. obj.foo() // obj
上面代码中, obj.foo 方法执行时,它内部的 this 指向 obj 。 但是,下面这几种用法,都会改变 this 的指向。
1. // 情况一
2. (obj.foo = obj.foo)() // window
3. // 情况二
4. (false || obj.foo)() // window
5. // 情况三
6. (1, obj.foo)() // window
上面代码中, obj.foo 就是一个值。这个值真正调用的时候,运行环境已经不是 obj 了,而是全 局环境,所以 this 不再指向 obj 。 JavaScript 引擎内部, obj 和 obj.foo 储存在两个内存地址,称为地址一和地 址二。 obj.foo() 这样调用时,是从地址一调用地址二,因此地址二的运行环境是地址 一, this 指向 obj 。但是,上面三种情况,都是直接取出地址二进行调用,这样的话,运行环境 就是全局环境,因此 this 指向全局环境。上面三种情况等同于下面的代码。
1. // 情况一
2. (obj.foo = function () {
3. console.log(this);
4. })()
5. // 等同于
6. (function () {
7. console.log(this);
8. })()
9.
10. // 情况二
11. (false || function () {
12. console.log(thi