JavaScript的对象和面向对象编程学习篇

JavaScript的对象和面向对象编程学习篇

标准对象

在JavaScript的世界里,一切都是对象。但是某些对象还是和其他对象不太一样。为了区分对象的类型,用typeof操作符获取对象的类型,它总是返回一个字符串:

typeof 123; // 'number'
typeof NaN; // 'number'
typeof 'str'; // 'string'
typeof true; // 'boolean'
typeof undefined; // 'undefined'
typeof Math.abs; // 'function'
typeof null; // 'object'
typeof []; // 'object'
typeof {}; // 'object'

可见,number、string、boolean、function和undefined有别于其他类型。特别注意null的类型是object,Array的类型也是object,如果用typeof将无法区分出null、Array和通常意义上的object——{}。

包装对象
除了这些类型外,JavaScript还提供了包装对象,熟悉Java的肯定很清楚int和Integer这种关系。number、boolean和string都有包装对象。没错,在JavaScript中,字符串也区分string类型和它的包装类型。包装对象用new创建:

var n = new Number(123); // 123,生成了新的包装类型
var b = new Boolean(true); // true,生成了新的包装类型
var s = new String('str'); // 'str',生成了新的包装类型

虽然包装对象看上去和原来的值一模一样,显示出来也是一模一样,但他们的类型已经变为object了!所以,包装对象和原始值用===比较会返回false:

typeof new Number(123); // 'object'
new Number(123) === 123; // false

typeof new Boolean(true); // 'object'
new Boolean(true) === true; // false

typeof new String('str'); // 'object'
new String('str') === 'str'; // false

如果在使用Number、Boolean和String时,没有写new会发生什么情况?
此时,Number()、Boolean和String()被当做普通函数,把任何类型的数据转换为number、boolean和string类型(注意不是其包装类型):

var n = Number('123'); // 123,相当于parseInt()或parseFloat()
typeof n; // 'number'
var b = Boolean('true'); // true
typeof b; // 'boolean'
var b2 = Boolean('false'); // true! 'false'字符串转换结果为true!因为它是非空字符串!
var b3 = Boolean(''); // false
var s = String(123.45); // '123.45'
typeof s; // 'string'

总结一下,有这么几条规则需要遵守:
不要使用new Number()、new Boolean()、new String()创建包装对象;
用parseInt()或parseFloat()来转换任意类型到number;
用String()来转换任意类型到string,或者直接调用某个对象的toString()方法;
通常不必把任意类型转换为boolean再判断,因为可以直接写if (myVar) {…};
typeof操作符可以判断出number、boolean、string、function和undefined;
判断Array要使用Array.isArray(arr);
判断null请使用myVar === null;
判断某个全局变量是否存在用typeof window.myVar === ‘undefined’;
函数内部判断某个变量是否存在用typeof myVar === ‘undefined’。
但是,任何对象都有toString()方法吗?null和undefined就没有!确实如此,这两个特殊值要除外,虽然null还伪装成了object类型。还有个问题是,number对象调用toString()报SyntaxError:

123.toString(); // SyntaxError

遇到这种情况,要特殊处理一下:

123..toString(); // '123', 注意是两个点!
(123).toString(); // '123'

Date

在JavaScript中,Date对象用来表示日期和时间。要获取系统当前时间,用:

var now = new Date();
now; // Wed Jun 24 2015 19:49:22 GMT+0800 (CST)
now.getFullYear(); // 2015, 年份
now.getMonth(); // 5, 月份,注意月份范围是0~11,5表示六月
now.getDate(); // 24, 表示24号
now.getDay(); // 3, 表示星期三
now.getHours(); // 19, 24小时制
now.getMinutes(); // 49, 分钟
now.getSeconds(); // 22, 秒
now.getMilliseconds(); // 875, 毫秒数
now.getTime(); // 1435146562875, 以number形式表示的时间戳

注意,当前时间是浏览器从本机操作系统获取的时间,所以不一定准确,因为用户可以把当前时间设定为任何值。如果要创建一个指定日期和时间的Date对象,可以用:

var d = new Date(2015, 5, 19, 20, 15, 30, 123);
d; // Fri Jun 19 2015 20:15:30 GMT+0800 (CST)

你可能观察到了一个非常非常坑爹的地方,就是JavaScript的月份范围用整数表示是0~11,0表示一月,1表示二月……,所以要表示6月,我们传入的是5!这绝对是JavaScript的设计者当时脑抽了一下,但是现在要修复已经不可能了。 JavaScript的Date对象月份值从0开始,牢记0=1月,1=2月,2=3月,……,11=12月。
第二种创建一个指定日期和时间的方法是解析一个符合ISO 8601格式的字符串:

var d = Date.parse('2015-06-24T19:49:22.875+08:00');
d; // 1435146562875

但它返回的不是Date对象,而是一个时间戳。不过有时间戳就可以很容易地把它转换为一个Date:

var d = new Date(1435146562875);
d; // Wed Jun 24 2015 19:49:22 GMT+0800 (CST)
d.getMonth(); // 5

使用Date.parse()时传入的字符串使用实际月份01-12,转换为Date对象后getMonth()获取的月份值为0~11。
时区
Date对象表示的时间总是按浏览器所在时区显示的,不过我们既可以显示本地时间,也可以显示调整后的UTC时间:

var d = new Date(1435146562875);
d.toLocaleString(); // '2015/6/24 下午7:49:22',本地时间(北京时区+8:00),显示的字符串与操作系统设定的格式有关
d.toUTCString(); // 'Wed, 24 Jun 2015 11:49:22 GMT',UTC时间,与本地时间相差8小时

那么在JavaScript中如何进行时区转换呢?实际上,只要我们传递的是一个number类型的时间戳,就不用关心时区转换。任何浏览器都可以把一个时间戳正确转换为本地时间。时间戳是个什么东西?时间戳是一个自增的整数,它表示从1970年1月1日零时整的GMT时区开始的那一刻,到现在的毫秒数。假设浏览器所在电脑的时间是准确的,那么世界上无论哪个时区的电脑,它们此刻产生的时间戳数字都是一样的,所以,时间戳可以精确地表示一个时刻,并且与时区无关。所以,只需要传递时间戳,或者把时间戳从数据库里读出来,再让JavaScript自动转换为当地时间就可以了。
要获取当前时间戳,可以用:

'use strict';
if (Date.now) {
    console.log(Date.now()); // 老版本IE没有now()方法
} else {
    console.log(new Date().getTime());
}
1595255120328

RegExp

字符串是编程时涉及到的最多的一种数据结构,对字符串进行操作的需求几乎无处不在。比如判断一个字符串是否是合法的Email地址,虽然可以编程提取@前后的子串,再分别判断是否是单词和域名,但这样做不但麻烦,而且代码难以复用。
正则表达式是一种用来匹配字符串的强有力的武器。它的设计思想是用一种描述性的语言来给字符串定义一个规则,凡是符合规则的字符串,就认为它“匹配”了,否则,该字符串就是不合法的。所以判断一个字符串是否是合法的Email的方法是:
创建一个匹配Email的正则表达式;用该正则表达式去匹配用户的输入来判断是否合法。因为正则表达式也是用字符串表示的,所以,要首先了解如何用字符来描述字符。
在正则表达式中,如果直接给出字符,就是精确匹配。用\d可以匹配一个数字,\w可以匹配一个字母或数字,所以:

'00\d'可以匹配'007',但无法匹配'00A''\d\d\d'可以匹配'010''\w\w'可以匹配'js'

.可以匹配任意字符,所以:

'js.'可以匹配'jsp''jss''js!'等等

要匹配变长的字符,在正则表达式中,用*表示任意个字符(包括0个),用+表示至少一个字符,用?表示0个或1个字符,用{n}表示n个字符,用{n,m}表示n-m个字符:
来看一个复杂的例子:\d{3}\s+\d{3,8}
从左到右解读一下:
\d{3}表示匹配3个数字,例如’010’;
\s可以匹配一个空格(也包括Tab等空白符),所以\s+表示至少有一个空格,例如匹配' ',’\t\t’等;
\d{3,8}表示3-8个数字,例如’1234567’。
综合起来,上面的正则表达式可以匹配以任意个空格隔开的带区号的电话号码。
如果要匹配’010-12345’这样的号码呢?由于’-‘是特殊字符,在正则表达式中,要用’‘转义,所以,上面的正则是\d{3}\-\d{3,8}
但是,仍然无法匹配’010 - 12345’,因为带有空格。所以需要更复杂的匹配方式。
进阶
要做更精确地匹配,可以用[ ]表示范围,比如:
[0-9a-zA-Z\_]可以匹配一个数字、字母或者下划线;
[0-9a-zA-Z\_]+可以匹配至少由一个数字、字母或者下划线组成的字符串,比如’a100’,‘0_Z’,‘js2015’等等;
[a-zA-Z\_\$][0-9a-zA-Z\_\$]*可以匹配由字母或下划线、$开头,后接任意个由一个数字、字母或者下划线、$组成的字符串,也就是JavaScript允许的变量名;
[a-zA-Z\_\$][0-9a-zA-Z\_\$]{0, 19}更精确地限制了变量的长度是1-20个字符(前面1个字符+后面最多19个字符)。
A|B可以匹配A或B,所以(J|j)ava(S|s)cript可以匹配’JavaScript’、‘Javascript’、‘javaScript’或者’javascript’。
^表示行的开头,^\d表示必须以数字开头。
$表示行的结束,\d$表示必须以数字结束。
可能注意到,js也可以匹配’jsp’,但是加上^js$就变成了整行匹配,就只能匹配’js’了。

RegExp
有了准备知识,现在就可以在JavaScript中使用正则表达式了。JavaScript有两种方式创建一个正则表达式:
第一种方式是直接通过/正则表达式/写出来,第二种方式是通过new RegExp(‘正则表达式’)创建一个RegExp对象。两种写法是一样的:

var re1 = /ABC\-001/;
var re2 = new RegExp('ABC\\-001');

re1; // /ABC\-001/
re2; // /ABC\-001/

注意,如果使用第二种写法,因为字符串的转义问题,字符串的两个\实际上是一个\。
先看看如何判断正则表达式是否匹配:

var re = /^\d{3}\-\d{3,8}$/;
re.test('010-12345'); // true
re.test('010-1234x'); // false
re.test('010 12345'); // false

RegExp对象的test()方法用于测试给定的字符串是否符合条件。
切分字符串
用正则表达式切分字符串比用固定的字符更灵活,请看正常的切分代码:

'a b   c'.split(' '); // ['a', 'b', '', '', 'c']

无法识别连续的空格,用正则表达式试试:

'a b   c'.split(/\s+/); // ['a', 'b', 'c']

无论多少个空格都可以正常分割。加入,试试:

'a,b, c  d'.split(/[\s\,]+/); // ['a', 'b', 'c', 'd']

再加入;试试:

'a,b;; c  d'.split(/[\s\,\;]+/); // ['a', 'b', 'c', 'd']

如果用户输入了一组标签,下次记得用正则表达式来把不规范的输入转化成正确的数组。

分组
除了简单地判断是否匹配之外,正则表达式还有提取子串的强大功能。用()表示的就是要提取的分组(Group)。比如:
^(\d{3})-(\d{3,8})$分别定义了两个组,可以直接从匹配的字符串中提取出区号和本地号码:

var re = /^(\d{3})-(\d{3,8})$/;
re.exec('010-12345'); // ['010-12345', '010', '12345']
re.exec('010 12345'); // null

如果正则表达式中定义了组,就可以在RegExp对象上用exec()方法提取出子串来。exec()方法在匹配成功后,会返回一个Array,第一个元素是正则表达式匹配到的整个字符串,后面的字符串表示匹配成功的子串。exec()方法在匹配失败时返回null。提取子串非常有用。来看一个更凶残的例子:

var re = /^(0[0-9]|1[0-9]|2[0-3]|[0-9])\:(0[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]|[0-9])\:(0[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9]|[0-9])$/;
re.exec('19:05:30'); // ['19:05:30', '19', '05', '30']

这个正则表达式可以直接识别合法的时间。但是有些时候,用正则表达式也无法做到完全验证,比如识别日期:

var re = /^(0[1-9]|1[0-2]|[0-9])-(0[1-9]|1[0-9]|2[0-9]|3[0-1]|[0-9])$/;

对于’2-30’,'4-31’这样的非法日期,用正则还是识别不了,或者说写出来非常困难,这时就需要程序配合识别了。

贪婪匹配
需要特别指出的是,正则匹配默认是贪婪匹配,也就是匹配尽可能多的字符。举例如下,匹配出数字后面的0:

var re = /^(\d+)(0*)$/;
re.exec('102300'); // ['102300', '102300', '']

由于\d+采用贪婪匹配,直接把后面的0全部匹配了,结果0*只能匹配空字符串了。必须让\d+采用非贪婪匹配(也就是尽可能少匹配),才能把后面的0匹配出来,加个?就可以让\d+采用非贪婪匹配:

var re = /^(\d+?)(0*)$/;
re.exec('102300'); // ['102300', '1023', '00']

全局搜索
JavaScript的正则表达式还有几个特殊的标志,最常用的是g,表示全局匹配:

var r1 = /test/g;
// 等价于:
var r2 = new RegExp('test', 'g');

全局匹配可以多次执行exec()方法来搜索一个匹配的字符串。当指定g标志后,每次运行exec(),正则表达式本身会更新lastIndex属性,表示上次匹配到的最后索引:

var s = 'JavaScript, VBScript, JScript and ECMAScript';
var re=/[a-zA-Z]+Script/g;

// 使用全局匹配:
re.exec(s); // ['JavaScript']
re.lastIndex; // 10

re.exec(s); // ['VBScript']
re.lastIndex; // 20

re.exec(s); // ['JScript']
re.lastIndex; // 29

re.exec(s); // ['ECMAScript']
re.lastIndex; // 44

re.exec(s); // null,直到结束仍没有匹配到

全局匹配类似搜索,因此不能使用/^...$/,那样只会最多匹配一次。正则表达式还可以指定i标志,表示忽略大小写,m标志,表示执行多行匹配。

练习
请尝试写一个验证Email地址的正则表达式。版本一应该可以验证出类似的Email:

'use strict';
var re =/^[0-9a-zA-Z\.]+@[0-9a-zA-Z]+\.[a-zA-Z]+$/;
// 测试:
var
    i,
    success = true,
    should_pass = ['someone@gmail.com', 'bill.gates@microsoft.com', 'tom@voyager.org', 'bob2015@163.com'],
    should_fail = ['test#gmail.com', 'bill@microsoft', 'bill%gates@ms.com', '@voyager.org'];
for (i = 0; i < should_pass.length; i++) {
    if (!re.test(should_pass[i])) {
        console.log('测试失败: ' + should_pass[i]);
        success = false;
        break;
    }
}
for (i = 0; i < should_fail.length; i++) {
    if (re.test(should_fail[i])) {
        console.log('测试失败: ' + should_fail[i]);
        success = false;
        break;
    }
}
if (success) {
    console.log('测试通过!');
}
测试通过!

JSON

JSON是JavaScript Object Notation的缩写,它是一种数据交换格式。在JSON出现之前,大家一直用XML来传递数据。因为XML是一种纯文本格式,所以它适合在网络上交换数据。XML本身不算复杂,但是,加上DTD、XSD、XPath、XSLT等一大堆复杂的规范以后,任何正常的软件开发人员碰到XML都会感觉头大了,最后大家发现,即使你努力钻研几个月,也未必搞得清楚XML的规范。于是在2002年的一天,道格拉斯·克罗克福特(Douglas Crockford)同学为了拯救深陷水深火热同时又被某几个巨型软件企业长期愚弄的软件工程师,发明了JSON这种超轻量级的数据交换格式。
他设计的JSON实际上是JavaScript的一个子集。在JSON中,一共就这么几种数据类型:

number:和JavaScript的number完全一致;
boolean:就是JavaScript的truefalse;
string:就是JavaScript的string;
null:就是JavaScript的null;
array:就是JavaScript的Array表示方式——[];
object:就是JavaScript的{ ... }表示方式。
以及上面的任意组合。

JSON还定死了字符集必须是UTF-8,表示多语言就没有问题了。为了统一解析,JSON的字符串规定必须用双引号" ",Object的键也必须用双引号" "。由于JSON非常简单,很快就风靡Web世界,并且成为ECMA标准。几乎所有编程语言都有解析JSON的库,而在JavaScript中,可以直接使用JSON,因为JavaScript内置了JSON的解析。
把任何JavaScript对象变成JSON,就是把这个对象序列化成一个JSON格式的字符串,这样才能够通过网络传递给其他计算机。 如果收到一个JSON格式的字符串,只需要把它反序列化成一个JavaScript对象,就可以在JavaScript中直接使用这个对象了。
序列化
先把小明这个对象序列化成JSON格式的字符串:

'use strict';
var xiaoming = {
    name: '小明',
    age: 14,
    gender: true,
    height: 1.65,
    grade: null,
    'middle-school': '\"W3C\" Middle School',
    skills: ['JavaScript', 'Java', 'Python', 'Lisp']
};
var s = JSON.stringify(xiaoming);
console.log(s);

{"name":"小明","age":14,"gender":true,"height":1.65,"grade":null,"middle-school":"\"W3C\" Middle School","skills":["JavaScript","Java","Python","Lisp"]}

要输出得好看一些,可以加上参数,按缩进输出:

JSON.stringify(xiaoming, null, '  ');

结果:

{
  "name": "小明",
  "age": 14,
  "gender": true,
  "height": 1.65,
  "grade": null,
  "middle-school": "\"W3C\" Middle School",
  "skills": [
    "JavaScript",
    "Java",
    "Python",
    "Lisp"
  ]
}

第二个参数用于控制如何筛选对象的键值,如果只想输出指定的属性,可以传入Array:

JSON.stringify(xiaoming, ['name', 'skills'], '  ');

结果:

{
  "name": "小明",
  "skills": [
    "JavaScript",
    "Java",
    "Python",
    "Lisp"
  ]
}

还可以传入一个函数,这样对象的每个键值对都会被函数先处理:

function convert(key, value) {
    if (typeof value === 'string') {
        return value.toUpperCase();
    }
    return value;
}
JSON.stringify(xiaoming, convert, '  ');

上面的代码把所有属性值都变成大写:

{
  "name": "小明",
  "age": 14,
  "gender": true,
  "height": 1.65,
  "grade": null,
  "middle-school": "\"W3C\" MIDDLE SCHOOL",
  "skills": [
    "JAVASCRIPT",
    "JAVA",
    "PYTHON",
    "LISP"
  ]
}

如果我们还想要精确控制如何序列化小明,可以给xiaoming定义一个toJSON()的方法,直接返回JSON应该序列化的数据:

var xiaoming = {
    name: '小明',
    age: 14,
    gender: true,
    height: 1.65,
    grade: null,
    'middle-school': '\"W3C\" Middle School',
    skills: ['JavaScript', 'Java', 'Python', 'Lisp'],
    toJSON: function () {
        return { // 只输出name和age,并且改变了key:
            'Name': this.name,
            'Age': this.age
        };
    }
};

JSON.stringify(xiaoming); // '{"Name":"小明","Age":14}'

反序列化
拿到一个JSON格式的字符串,直接用JSON.parse()把它变成一个JavaScript对象:

JSON.parse('[1,2,3,true]'); // [1, 2, 3, true]
JSON.parse('{"name":"小明","age":14}'); // Object {name: '小明', age: 14}
JSON.parse('true'); // true
JSON.parse('123.45'); // 123.45

JSON.parse()还可以接收一个函数,用来转换解析出的属性:

'use strict';
var obj = JSON.parse('{"name":"小明","age":14}', function (key, value) {
    if (key === 'name') {
        return value + '同学';
    }
    return value;
});
console.log(JSON.stringify(obj)); // {name: '小明同学', age: 14}

{"name":"小明同学","age":14}

面向对象编程

JavaScript的所有数据都可以看成对象,那是不是已经在使用面向对象编程了呢?
当然不是。如果只使用Number、Array、string以及基本的{…}定义的对象,还无法发挥出面向对象编程的威力。JavaScript的面向对象编程和大多数其他语言如Java、C#的面向对象编程都不太一样。如果你熟悉Java或C#,你一定明白面向对象的两个基本概念:
类: 类是对象的类型模板,例如,定义Student类来表示学生,类本身是一种类型,Student表示学生类型,但不表示任何具体的某个学生;
实例: 实例是根据类创建的对象,例如,根据Student类可以创建出xiaoming、xiaohong、xiaojun等多个实例,每个实例表示一个具体的学生,他们全都属于Student类型。
所以,类和实例是大多数面向对象编程语言的基本概念。不过,在JavaScript中,这个概念需要改一改。JavaScript不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。
原型是指当我们想要创建xiaoming这个具体的学生时,我们并没有一个Student类型可用。那怎么办?恰好有这么一个现成的对象:

var robot = {
    name: 'Robot',
    height: 1.6,
    run: function () {
        console.log(this.name + ' is running...');
    }
};

我们看这个robot对象有名字,有身高,还会跑,有点像小明,干脆就根据它来“创建”小明得了!于是把它改名为Student,然后创建出xiaoming:

var Student = {
    name: 'Robot',
    height: 1.2,
    run: function () {
        console.log(this.name + ' is running...');
    }
};
var xiaoming = {
    name: '小明'
};
xiaoming.__proto__ = Student;

注意最后一行代码把xiaoming的原型指向了对象Student,看上去xiaoming仿佛是从Student继承下来的:

xiaoming.name; // '小明'
xiaoming.run(); // 小明 is running...

xiaoming有自己的name属性,但并没有定义run()方法。不过,由于小明是从Student继承而来,只要Student有run()方法,xiaoming也可以调用。
JavaScript的原型链和Java的Class区别就在,它没有“Class”的概念,所有对象都是实例,所谓继承关系不过是把一个对象的原型指向另一个对象而已。
如果把xiaoming的原型指向其他对象:

var Bird = {
    fly: function () {
        console.log(this.name + ' is flying...');
    }
};
xiaoming.__proto__ = Bird;

现在xiaoming已经无法run()了,他已经变成了一只鸟:

xiaoming.fly(); // 小明 is flying...

在JavaScrip代码运行时期,可以把xiaoming从Student变成Bird,或者变成任何对象。
上述代码仅用于教学目的。在编写JavaScript代码时,不要直接用obj.proto__去改变一个对象的原型,并且,低版本的IE也无法使用__proto。Object.create()方法可以传入一个原型对象,并创建一个基于该原型的新对象,但是新对象什么属性都没有,因此,可以编写一个函数来创建xiaoming:

// 原型对象:
var Student = {
    name: 'Robot',
    height: 1.2,
    run: function () {
        console.log(this.name + ' is running...');
    }
};
function createStudent(name) {
    // 基于Student原型创建一个新对象:
    var s = Object.create(Student);
    // 初始化新对象:
    s.name = name;
    return s;
}
var xiaoming = createStudent('小明');
xiaoming.run(); // 小明 is running...
xiaoming.__proto__ === Student; // true

这是创建原型继承的一种方法,JavaScript还有其他方法来创建对象,后面会一一讲到。

创建对象

JavaScript对每个创建的对象都会设置一个原型,指向它的原型对象。当用obj.xxx访问一个对象的属性时,JavaScript引擎先在当前对象上查找该属性,如果没有找到,就到其原型对象上找,如果还没有找到,就一直上溯到Object.prototype对象,最后,如果还没有找到,就只能返回undefined。
例如,创建一个Array对象:

var arr = [1, 2, 3];

其原型链是:

arr ----> Array.prototype ----> Object.prototype ----> null

Array.prototype定义了indexOf()、shift()等方法,因此你可以在所有的Array对象上直接调用这些方法。
当创建一个函数时:

function foo() {
    return 0;
}

函数也是一个对象,它的原型链是:

foo ----> Function.prototype ----> Object.prototype ----> null

由于Function.prototype定义了apply()等方法,因此,所有函数都可以调用apply()方法。很容易想到,如果原型链很长,那么访问一个对象的属性就会因为花更多的时间查找而变得更慢,因此要注意不要把原型链搞得太长。

构造函数
除了直接用{ … }创建一个对象外,JavaScript还可以用一种构造函数的方法来创建对象。它的用法是,先定义一个构造函数:

function Student(name) {
    this.name = name;
    this.hello = function () {
        alert('Hello, ' + this.name + '!');
    }
}

这不是一个普通函数吗?
这确实是一个普通函数,但是在JavaScript中,可以用关键字new来调用这个函数,并返回一个对象:

var xiaoming = new Student('小明');
xiaoming.name; // '小明'
xiaoming.hello(); // Hello, 小明!

注意,如果不写new,这就是一个普通函数,它返回undefined。但是,如果写了new,它就变成了一个构造函数,它绑定的this指向新创建的对象,并默认返回this, 也就是说,不需要在最后写return this;。
新创建的xiaoming的原型链是:

xiaoming ----> Student.prototype ----> Object.prototype ----> null

也就是说,xiaoming的原型指向函数Student的原型。如果你又创建了xiaohong、xiaojun,那么这些对象的原型与xiaoming是一样的:

xiaoming ↘
xiaohong -→ Student.prototype ----> Object.prototype ----> null
xiaojun  ↗

用new Student()创建的对象还从原型上获得了一个constructor属性,它指向函数Student本身:

xiaoming.constructor === Student.prototype.constructor; // true
Student.prototype.constructor === Student; // true
Object.getPrototypeOf(xiaoming) === Student.prototype; // true
xiaoming instanceof Student; // true

用下图可以表示出这些对象的关系就是:
对象关系图
红色箭头是原型链。注意,Student.prototype指向的对象就是xiaoming、xiaohong的原型对象,这个原型对象自己还有个属性constructor,指向Student函数本身。另外,函数Student恰好有个属性prototype指向xiaoming、xiaohong的原型对象,但是xiaoming、xiaohong这些对象可没有prototype这个属性,不过可以用__proto__这个非标准用法来查看。
现在就认为xiaoming、xiaohong这些对象“继承”自Student。不过还有一个小问题,注意观察:

xiaoming.name; // '小明'
xiaohong.name; // '小红'
xiaoming.hello; // function: Student.hello()
xiaohong.hello; // function: Student.hello()
xiaoming.hello === xiaohong.hello; // false

xiaoming和xiaohong各自的name不同,这是对的,否则我们无法区分谁是谁了。xiaoming和xiaohong各自的hello是一个函数,但它们是两个不同的函数,虽然函数名称和代码都是相同的!
如果通过new Student()创建了很多对象,这些对象的hello函数实际上只需要共享同一个函数就可以了,这样可以节省很多内存。要让创建的对象共享一个hello函数,根据对象的属性查找原则,只要把hello函数移动到xiaoming、xiaohong这些对象共同的原型上就可以了,也就是Student.prototype。
修改代码如下:

function Student(name) {
    this.name = name;
}
Student.prototype.hello = function () {
    alert('Hello, ' + this.name + '!');
};

忘记写new怎么办
如果一个函数被定义为用于创建对象的构造函数,但是调用时忘记了写new怎么办?
在strict模式下,this.name = name将报错,因为this绑定为undefined,在非strict模式下,this.name = name不报错,因为this绑定为window,于是无意间创建了全局变量name,并且返回undefined,这个结果更糟糕。
所以,调用构造函数千万不要忘记写new。为了区分普通函数和构造函数,按照约定,构造函数首字母应当大写,而普通函数首字母应当小写,这样,一些语法检查工具如jslint将可以检测到漏写的new。
最后,还可以编写一个createStudent()函数,在内部封装所有的new操作。一个常用的编程模式像这样:

function Student(props) {
    this.name = props.name || '匿名'; // 默认值为'匿名'
    this.grade = props.grade || 1; // 默认值为1
}
Student.prototype.hello = function () {
    alert('Hello, ' + this.name + '!');
};
function createStudent(props) {
    return new Student(props || {})
}

这个createStudent()函数有几个巨大的优点:一是不需要new来调用,二是参数非常灵活,可以不传,也可以这么传:

var xiaoming = createStudent({
    name: '小明'
});
xiaoming.grade; // 1

如果创建的对象有很多属性,只需要传递需要的某些属性,剩下的属性可以用默认值。由于参数是一个Object,无需记忆参数的顺序。如果恰好从JSON拿到了一个对象,就可以直接创建出xiaoming。

练习
利用构造函数定义Cat,并让所有的Cat对象有一个name属性,并共享一个方法say(),返回字符串’Hello, xxx!’:

'use strict';
function Cat(name) {
    this.name = name;
};
Cat.prototype.say = function(){
  return `Hello, ${this.name}!`;
}
// 测试:
var kitty = new Cat('Kitty');
var doraemon = new Cat('哆啦A梦');
if (kitty && kitty.name === 'Kitty'
    && kitty.say
    && typeof kitty.say === 'function'
    && kitty.say() === 'Hello, Kitty!'
    && kitty.say === doraemon.say
) {
    console.log('测试通过!');
} else {
    console.log('测试失败!');
}

测试通过!

原型继承

在传统的基于Class的语言如Java、C++中,继承的本质是扩展一个已有的Class,并生成新的Subclass。由于这类语言严格区分类和实例,继承实际上是类型的扩展。但是,JavaScript由于采用原型继承,我们无法直接扩展一个Class,因为根本不存在Class这种类型。但是办法还是有的。先回顾Student构造函数:

function Student(props) {
    this.name = props.name || 'Unnamed';
}
Student.prototype.hello = function () {
    alert('Hello, ' + this.name + '!');
}

以及Student的原型链:
原型链

现在,要基于Student扩展出PrimaryStudent,可以先定义出PrimaryStudent:

function PrimaryStudent(props) {
    // 调用Student构造函数,绑定this变量:
    Student.call(this, props);
    this.grade = props.grade || 1;
}

但是,调用了Student构造函数不等于继承了Student,PrimaryStudent创建的对象的原型是:

new PrimaryStudent() ----> PrimaryStudent.prototype ----> Object.prototype ----> null

必须想办法把原型链修改为:

new PrimaryStudent() ----> PrimaryStudent.prototype ----> Student.prototype ----> Object.prototype ----> null

这样,原型链对了,继承关系就对了。新的基于PrimaryStudent创建的对象不但能调用PrimaryStudent.prototype定义的方法,也可以调用Student.prototype定义的方法。
如果你想用最简单粗暴的方法这么干:

PrimaryStudent.prototype = Student.prototype;

是不行的!如果这样的话,PrimaryStudent和Student共享一个原型对象,那还要定义PrimaryStudent干啥?必须借助一个中间对象来实现正确的原型链,这个中间对象的原型要指向Student.prototype。为了实现这一点,参考道格拉斯的代码,中间对象可以用一个空函数F来实现:

// PrimaryStudent构造函数:
function PrimaryStudent(props) {
    Student.call(this, props);
    this.grade = props.grade || 1;
}
// 空函数F:
function F() {
}
// 把F的原型指向Student.prototype:
F.prototype = Student.prototype;
// 把PrimaryStudent的原型指向一个新的F对象,F对象的原型正好指向Student.prototype:
PrimaryStudent.prototype = new F();
// 把PrimaryStudent原型的构造函数修复为PrimaryStudent:
PrimaryStudent.prototype.constructor = PrimaryStudent;
// 继续在PrimaryStudent原型(就是new F()对象)上定义方法:
PrimaryStudent.prototype.getGrade = function () {
    return this.grade;
};
// 创建xiaoming:
var xiaoming = new PrimaryStudent({
    name: '小明',
    grade: 2
});
xiaoming.name; // '小明'
xiaoming.grade; // 2

// 验证原型:
xiaoming.__proto__ === PrimaryStudent.prototype; // true
xiaoming.__proto__.__proto__ === Student.prototype; // true

// 验证继承关系:
xiaoming instanceof PrimaryStudent; // true
xiaoming instanceof Student; // true

用一张图来表示新的原型链:
新的原型链
注意,函数F仅用于桥接,仅创建了一个new F()实例,而且,没有改变原有的Student定义的原型链。如果把继承这个动作用一个inherits()函数封装起来,还可以隐藏F的定义,并简化代码:

function inherits(Child, Parent) {
    var F = function () {};
    F.prototype = Parent.prototype;
    Child.prototype = new F();
    Child.prototype.constructor = Child;
}

这个inherits()函数可以复用:

function Student(props) {
    this.name = props.name || 'Unnamed';
}

Student.prototype.hello = function () {
    alert('Hello, ' + this.name + '!');
}

function PrimaryStudent(props) {
    Student.call(this, props);
    this.grade = props.grade || 1;
}

// 实现原型继承链:
inherits(PrimaryStudent, Student);

// 绑定其他方法到PrimaryStudent原型:
PrimaryStudent.prototype.getGrade = function () {
    return this.grade;
};

小结
JavaScript的原型继承实现方式就是:
定义新的构造函数,并在内部用call()调用希望“继承”的构造函数,并绑定this;
借助中间函数F实现原型链继承,最好通过封装的inherits函数完成;
继续在新的构造函数的原型上定义新方法。

class继承

在前面内容可以看到JavaScript的对象模型是基于原型实现的,特点是简单,缺点是理解起来比传统的类-实例模型要困难,最大的缺点是继承的实现需要编写大量代码,并且需要正确实现原型链。
有没有更简单的写法?有!新的关键字class从ES6开始正式被引入到JavaScript中。class的目的就是让定义类更简单。
先回顾用函数实现Student的方法:

function Student(name) {
    this.name = name;
}
Student.prototype.hello = function () {
    alert('Hello, ' + this.name + '!');
}

如果用新的class关键字来编写Student,可以这样写:

class Student {
    constructor(name) {
        this.name = name;
    }
    hello() {
        alert('Hello, ' + this.name + '!');
    }
}

比较一下就可以发现,class的定义包含了构造函数constructor和定义在原型对象上的函数hello()(注意没有function关键字),这样就避免了Student.prototype.hello = function () {…}这样分散的代码。
最后,创建一个Student对象代码和前面章节完全一样:

var xiaoming = new Student('小明');
xiaoming.hello();

用class定义对象的另一个巨大的好处是继承更方便了。想一想从Student派生一个PrimaryStudent需要编写的代码量。现在,原型继承的中间对象,原型对象的构造函数等等都不需要考虑了,直接通过extends来实现:

class PrimaryStudent extends Student {
    constructor(name, grade) {
        super(name); // 记得用super调用父类的构造方法!
        this.grade = grade;
    }
    myGrade() {
        alert('I am at grade ' + this.grade);
    }
}

注意PrimaryStudent的定义也是class关键字实现的,而extends则表示原型链对象来自Student。子类的构造函数可能会与父类不太相同,例如,PrimaryStudent需要name和grade两个参数,并且需要通过super(name)来调用父类的构造函数,否则父类的name属性无法正常初始化。PrimaryStudent已经自动获得了父类Student的hello方法,现在又在子类中定义了新的myGrade方法。

ES6引入的class和原有的JavaScript原型继承有什么区别呢?实际上它们没有任何区别,class的作用就是让JavaScript引擎去实现原来需要我们自己编写的原型链代码。简而言之,用class的好处就是极大地简化了原型链代码。

class这么好用,能不能现在就用上?
不是所有的主流浏览器都支持ES6的class。如果一定要现在就用上,就需要一个工具把class代码转换为传统的prototype代码。

练习
请利用class重新定义Cat,并让它从已有的Animal继承,然后新增一个方法say(),返回字符串’Hello, xxx!’:

'use strict';
class Animal {
    constructor(name) {
        this.name = name;
    }
}
class Cat extends Animal{
      constructor(name) {
        super(name);
    }
      say(){
       return ('Hello, '+this.name+'!');
      }
}

// 测试:
var kitty = new Cat('Kitty');
var doraemon = new Cat('哆啦A梦');
if ((new Cat('x') instanceof Animal) && kitty && kitty.name === 'Kitty' && kitty.say && typeof kitty.say === 'function' && kitty.say() === 'Hello, Kitty!' && kitty.say === doraemon.say) {
    console.log('测试通过!');
} else {
    console.log('测试失败!');
}

测试通过!

这个练习需要浏览器支持ES6的class,如果遇到SyntaxError,则说明浏览器不支持class语法。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值