JavaScript学习指南
本文最后更新于:2024年8月7日 中午
参照廖雪峰老师的教程,整理和记录JavaScript的学习过程~
速通语法
JavaScript的语法和Java语言类似,每个语句以
;
结束,语句块用{...}
。{…}内的语句具有
缩进
,通常是4个空格。缩进不是JavaScript语法要求必须的,但缩进有助于我们理解代码的层次,所以编写代码时要遵守缩进规则。注释:
- 行注释://
- 块注释:/**/
数据类型和变量
Number
JavaScript不区分整数和浮点数,统一用Number表示。以下都是合法的Number类型:
1 |
|
Number可以直接做四则运算,规则和数学一致:
1 |
|
PS.
%
是求余运算。- JavaScript的Number不区分整数和浮点数,也就是说,12.00 === 12。(在大多数其他语言中,整数和浮点数不能直接比较)并且,JavaScript的整数最大范围不是±2的63次方,而是±2的53次方,因此,超过这个范围的整数就可能无法精确表示。
字符串
字符串是以单引号’或双引号”括起来的任意文本,比如’abc’,”xyz”等等。
- 字符串内部既包含
'
又包含"
,需要用转义字符\
来标识。1
'I\'m \"OK\"!'; // I'm "OK"!
- 一些常见的转义字符:
\n
:换行\t
表示制表符\x##
表示ASCII字符1
'\x41'; // 完全等同于 'A'
\u####
表示一个Unicode字符1
'\u4e2d\u6587'; // 完全等同于 '中文'
多行字符串
:由于多行字符串用\n写起来比较费事,所以最新的ES6标准新增了一种多行字符串的表示方法,用反引号``表示:注意:反引号在键盘的ESC下方,数字键1的左边。1
2
3`这是一个
多行
字符串`;模板字符串
:
要把多个字符串连接起来,可以用+号连接:ES6新增了一种模板字符串,表示方法和上面的多行字符串一样,但是它会自动替换字符串中的变量:1
2
3
4let name = '小明';
let age = 20;
let message = '你好, ' + name + ', 你今年' + age + '岁了!';
alert(message);1
2
3
4let name = '小明';
let age = 20;
let message = `你好, ${name}, 你今年${age}岁了!`;
alert(message);
常用的字符串操作
- 获取字符串长度:
1
2let s = 'Hello, world!';
s.length; // 13 - 获取字符串某个指定位置的字符:
1
2
3
4
5
6
7let s = 'Hello, world!';
s[0]; // 'H'
s[6]; // ' '
s[7]; // 'w'
s[12]; // '!'
s[13]; // undefined 超出范围的索引不会报错,但一律返回undefined - 需要特别注意的是,字符串是不可变的,如果对字符串的某个索引赋值,不会有任何错误,但是,也没有任何效果。
JavaScript为字符串提供了一些常用方法,注意,调用这些方法本身不会改变原有字符串的内容,而是返回一个新字符串:
toUpperCase()
:把一个字符串全部变为大写toLowerCase()
:把一个字符串全部变为小写indexOf()
:搜索指定字符串出现的位置1
2
3let s = 'hello, world';
s.indexOf('world'); // 返回7
s.indexOf('World'); // 没有找到指定的子串,返回-1substring()
:返回指定索引区间的子串1
2
3let s = 'hello, world'
s.substring(0, 5); // 从索引0开始到5(不包括5),返回'hello'
s.substring(7); // 从索引7开始到结束,返回'world'
布尔值
布尔值和布尔代数的表示完全一致,一个布尔值只有true
、false
两种值,要么是true,要么是false,可以直接用true、false表示布尔值,也可以通过布尔运算计算出来:
1 |
|
比较运算符
- JavaScript允许对任意数据类型做比较:
1
2false == 0; // true
false === 0; // false - JavaScript在设计时,有两种比较运算符:
==
:它会自动转换数据类型再比较,很多时候,会得到非常诡异的结果;===
:它不会自动转换数据类型,如果数据类型不一致,返回false,如果一致,再比较。- 坚持使用===比较!
NaN和所有的其他值都不相等,包括它自己,需通过
isNaN()
函数判断是否为NaN。浮点数的相等比较:
1
1 / 3 === (1 - 2 / 3); // false
浮点数在运算过程中会产生误差,因为计算机无法精确表示无限循环小数。要比较两个浮点数是否相等,只能计算它们之差的绝对值,看是否小于某个阈值:
1
Math.abs(1 / 3 - (1 - 2 / 3)) < 0.0000001; // true
BigInt
要精确表示比2的53次方还大的整数,可以使用内置的BigInt类型,它的表示方法是在整数后加一个n,例如9223372036854775808n,也可以使用BigInt()把Number和字符串转换成BigInt:
1 |
|
使用BigInt可以正常进行加减乘除等运算,结果仍然是一个BigInt,但不能把一个BigInt和一个Number放在一起运算。
null和undefined
null
表示一个“空”的值,它和0
以及空字符串''
不同,0
是一个数值,' '
表示长度为0的字符串,而null
表示“空”。
在其他语言中,也有类似JavaScript的null的表示,例如Java也用null,Swift用nil,Python用None表示。但是,在JavaScript中,还有一个和null类似的undefined,它表示“未定义”。
JavaScript的设计者希望用null表示一个空的值,而undefined表示值未定义。事实证明,这并没有什么卵用,区分两者的意义不大。大多数情况下,我们都应该用null。undefined仅仅在判断函数参数是否传递的情况下有用
。
数组
JavaScript的数组可以包括任意数据类型。
- 数组用[]表示,元素之间用,分隔。
1
[1, 2, 3.14, 'Hello', null, true];
- 另一种创建数组的方法是通过
Array()
函数实现:1
new Array(1, 2, 3); // 创建了数组[1, 2, 3]
数组的元素可以通过索引来访问。索引的起始值为0。
对象
JavaScript的对象是一组由键-值组成的无序集合。
1 |
|
JavaScript对象的键都是字符串类型,值可以是任意数据类型。上述person对象一共定义了6个键值对,其中每个键又称为对象的属性,例如,person的name属性为’Bob’,zipcode属性为null。
要获取一个对象的属性,我们用对象变量.属性名的方式:
1 |
|
变量
变量在JavaScript中就是用一个变量名表示,变量名是大小写英文、数字、$和_的组合,且不能用数字开头。变量名也不能是JavaScript的关键字,如if、while等。
申明一个变量用var语句,比如:
1 |
|
在JavaScript中,使用等号=对变量进行赋值。可以把任意数据类型赋值给变量,同一个变量可以反复赋值,而且可以是不同类型的变量,但是要注意只能用var申明一次。
1 |
|
这种变量本身类型不固定的语言称之为动态语言,与之对应的是静态语言。静态语言在定义变量时必须指定变量类型,如果赋值的时候类型不匹配,就会报错。例如Java是静态语言,赋值语句如下:
1 |
|
要显示变量的内容,可以用console.log(x)
,打开Chrome的控制台就可以看到结果。
- 使用
console.log()
代替alert()
的好处是可以避免弹出烦人的对话框。
strict模式
- 如果一个变量没有通过var申明就被使用,那么该变量就自动被申明为全局变量;
- 使用var申明的变量不是全局变量,它的范围被限制在该变量被申明的函数体内,同名变量在不同的函数体内互不冲突。
- 在strict模式下运行的JavaScript代码,强制通过var申明变量,未使用var申明变量就使用的,将导致运行错误。
- 启用strict模式的方法是在JavaScript代码的第一行写上:
1
'use strict';
- 启用strict模式的方法是在JavaScript代码的第一行写上:
- 另一种申明变量的方式是
let
,这也是现代JavaScript推荐的方式:后续会补充介绍var和let的区别。1
2
3// 用let申明变量:
let s = 'hello';
console.log(s);
正则表达式
正则表达式是一种用来匹配字符串的强有力的武器。它的设计思想是用一种描述性的语言来给字符串定义一个规则,凡是符合规则的字符串,我们就认为它“匹配”了,否则,该字符串就是不合法的。
例如:判断一个字符串是否是合法的Email
- 创建一个匹配Email的正则表达式;
- 用该正则表达式去匹配用户的输入来判断是否合法。
如何用字符来描述字符
在正则表达式中,如果直接给出字符,就是精确匹配。
\d
:匹配一个数字\w
:匹配一个字母or数字1
2
3'00\d'可匹配'007',但无法匹配'00A';
'\d\d\d'可以匹配'010';
'\w\w'可以匹配'js';.
可以匹配任意字符- ‘js.’可以匹配’jsp’、’jss’、’js!’等等
\s
匹配任何空白字符,包括空格、制表符(tab)、换行符等\,
匹配逗号字符。逗号前面的反斜杠是用来转义逗号的,因为在字符集合中逗号没有特殊意义,但通常为了避免混淆,还是建议加上反斜杠- 表示不定长字符:
- 用
*
表示任意个字符(包括0个) - 用
+
表示至少一个字符 - 用
?
表示0个or1个字符 - 用
{n}
表示n个字符 - 用
{n,m}
表示n-m个字符1
2
3
4
5\d{3}\s+\d{3,8}
(1) \d{3}表示匹配3个数字,例如'010';
(2) \s可以匹配一个空格(也包括Tab等空白符),所以\s+表示至少有一个空格,例如匹配' ','\t\t'等;
(3) \d{3,8}表示3-8个数字,例如'1234567'
综合起来,上面的正则表达式可以匹配以任意个空格隔开的带区号的电话号码
- 用
- 用[]表示范围
[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$
表示必须以数字结束
RegExp
JavaScript有两种方式创建一个正则表达式:
- 第一种方式:直接通过/正则表达式/写出;
- 第二种方式:
new RegExp('正则表达式')
创建一个RegExp对象。1
2
3
4
5let re1 = /ABC\-001/;
let re2 = new RegExp('ABC\\-001');
re1; // /ABC\-001/
re2; // /ABC\-001/
RegExp对象的test()
方法用于测试给定的字符串是否符合条件:
1 |
|
切分字符串
用正则表达式切分字符串比用固定的字符更灵活。
- 正常的切分代码
1
'a b c'.split(' '); // ['a', 'b', '', '', 'c']无法识别连续的空格
- 正则表达式split 方法会根据正则表达式显示的匹配模式来分割字符串。
1
2
3'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)。
eg.^(\d{3})-(\d{3,8})$
分别定义了两个组,可以直接从匹配的字符串中提取出区号和本地号码:
1 |
|
如果正则表达式中定义了组,就可以在RegExp对象上用exec()方法提取出子串来。
exec()
方法在匹配成功后,会返回一个Array
,第一个元素是正则表达式匹配到的整个字符串,后面的字符串表示匹配成功的子串。- 在匹配失败时返回null
1 |
|
这个正则表达式可以直接识别合法的时间。但是有些时候,用正则表达式也无法做到完全验证,对于’2-30’,’4-31’这样的非法日期,用正则还是识别不了,或者说写出来非常困难,这时就需要程序配合识别了。
JSON
JSON是JavaScript Object Notation的缩写,它是一种数据交换格式。
- 为了统一解析,JSON的字符集必须是UTF-8,字符串规定必须用双引号
""
,Object的键也必须用双引号""
。 - 几乎所有编程语言都有解析JSON的库,而在JavaScript中,我们可以直接使用JSON,因为JavaScript内置了JSON的解析。
- 把任何JavaScript对象变成JSON,就是把这个对象序列化成一个JSON格式的字符串,这样才能够通过网络传递给其他计算机。
- 如果我们收到一个JSON格式的字符串,只需要把它反序列化成一个JavaScript对象,就可以在JavaScript中直接使用这个对象了。
序列化
把小明这个对象序列化成JSON格式的字符串:
1 |
|
- 第二个参数用于控制如何筛选对象的键值,如果我们只想输出指定的属性,可以传入Array
1
JSON.stringify(xiaoming, ['name', 'skills'], ' ');
- 还可以传入一个函数,这样对象的每个键值对都会被函数先处理:
1
2
3
4
5
6
7
8
9//下面的代码把所有属性值都变成大写
function convert(key, value) {
if (typeof value === 'string') {
return value.toUpperCase();
}
return value;
}
JSON.stringify(xiaoming, convert, ' '); - 还可以给xiaoming定义一个toJSON()的方法,直接返回JSON应该序列化的数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17let 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对象:
1 |
|
JSON.parse()还可以接收一个函数,用来转换解析出的属性:
1 |
|
面向对象编程
面向对象的两个基本概念:
- 类:类是对象的类型模板,例如,定义Student类来表示学生,类本身是一种类型,Student表示学生类型,但不表示任何具体的某个学生;
- 实例:实例是根据类创建的对象,例如,根据Student类可以创建出xiaoming、xiaohong、xiaojun等多个实例,每个实例表示一个具体的学生,他们全都属于Student类型。
在JavaScript中,这个概念需要改一改。JavaScript不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。JavaScript的原型链和Java的Class区别就在,它没有“Class”的概念,所有对象都是实例,所谓继承关系不过是把一个对象的原型指向另一个对象而已。
1 |
|
- Object.create()方法可以传入一个原型对象,并创建一个基于该原型的新对象。例如:Object.prototype 生成Student.prototype,它同时生成了一个Student对象 Student.protoytype,Student.prototype 可以生成xiaoming之类的对象(类似继承)。
创建对象
JavaScript对每个创建的对象都会设置一个原型,指向它的原型对象。
当我们用obj.xxx访问一个对象的属性时,JavaScript引擎先在当前对象上查找该属性,如果没有找到,就到其原型对象上找,如果还没有找到,就一直上溯到Object.prototype对象,最后,如果还没有找到,就只能返回undefined。
- Array是一个对象,它的原型是Array.prototype,是由Object.prototype构建的,Array.prototype定义了indexOf()、shift()等方法,因此你可以在所有的Array对象上直接调用这些方法。
- 函数也是一个对象,它的原型是Function.prototype,是由Object.prototype构建的,由于Function.prototype定义了apply()等方法,因此,所有函数都可以调用apply()方法。
1
2
3function foo() {
return 0;
}
构造函数
除了直接用{ … }创建一个对象外,JavaScript还可以用一种构造函数的方法来创建对象。它的用法是,先定义一个构造函数:
1 |
|
用关键字new来调用这个函数,并返回一个对象:
1 |
|
注意,如果不写new
,这就是一个普通函数,它返回undefined
。但是,如果写了new
,它就变成了一个构造函数,它绑定的this指向新创建的对象,并默认返回this
,也就是说,不需要在最后写return this;。
- 用new Student()创建的对象还从原型上获得了一个
constructor
属性,它指向函数Student本身 - 如果我们通过new Student()创建了很多对象,这些对象的hello函数实际上只需要
共享同一个函数
就可以了,这样可以节省很多内存。
-要让创建的对象共享一个hello函数,根据对象的属性查找原则,我们只要把hello函数移动到xiaoming、xiaohong这些对象共同的原型上就可以了,也就是Student.prototype:1
2
3
4
5
6
7function Student(name) {
this.name = name;
}
Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}; - 我们还可以编写一个
createStudent()
函数,在内部封装所有的new操作。一个常用的编程模式像这样:这个createStudent()函数有几个巨大的优点:一是不需要new来调用,二是参数非常灵活,可以不传,也可以这么传:1
2
3
4
5
6
7
8
9
10
11
12function 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 || {})
}1
2
3
4
5let xiaoming = createStudent({
name: '小明'
});
xiaoming.grade; // 1 - 如果创建的对象有很多属性,我们只需要传递需要的某些属性,剩下的属性可以用默认值。由于参数是一个Object,我们无需记忆参数的顺序。如果恰好从JSON拿到了一个对象,就可以直接创建出xiaoming。
原型继承
JavaScript的原型继承实现方式就是:
- 定义新的构造函数,并在内部用call()调用希望“继承”的构造函数,并绑定this;
- 借助中间函数F实现原型链继承,最好通过封装的inherits函数完成;
- 继续在新的构造函数的原型上定义新方法。
例如:
基于Student扩展出PrimaryStudent,可以先定义出PrimaryStudent:
1 |
|
但是,调用了Student构造函数不等于继承了Student,PrimaryStudent创建的对象的原型是:
1 |
|
满足要求的原型链是:
1 |
|
新的基于PrimaryStudent创建的对象不但能调用PrimaryStudent.prototype定义的方法,也可以调用Student.prototype定义的方法。
我们必须借助一个中间对象
来实现正确的原型链,这个中间对象的原型要指向Student.prototype。为了实现这一点,参考道爷(就是发明JSON的那个道格拉斯)的代码,中间对象可以用一个空函数F
来实现:
注意,函数F仅用于桥接,我们仅创建了一个new F()实例,而且,没有改变原有的Student定义的原型链。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39// 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:
let 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把继承这个动作用一个inherits()函数封装起来,还可以隐藏F的定义,并简化代码:
1
2
3
4
5
6function inherits(Child, Parent) {
let F = function () {};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
}这个inherits()函数可以复用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20function 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;
};
class继承
新的关键字class从ES6开始正式被引入到JavaScript中。class的目的就是让定义类更简单。
1 |
|
class的定义包含了构造函数
constructor
和定义在原型对象上的函数hello()
(注意没有function关键字),这样就避免了Student.prototype.hello = function () {...}
这样分散的代码。通过
extend
实现类的继承:PrimaryStudent的定义也是class关键字实现的,而extends则表示原型链对象来自Student。子类的构造函数可能会与父类不太相同,例如,PrimaryStudent需要name和grade两个参数,并且需要通过super(name)来调用父类的构造函数,否则父类的name属性无法正常初始化。
PrimaryStudent已经自动获得了父类Student的hello方法,我们又在子类中定义了新的myGrade方法。
1 |
|
浏览器
不同的浏览器对JavaScript支持的差异主要是,有些API的接口不一样,比如AJAX,File接口。对于ES6标准,不同的浏览器对各个特性支持也不一样。
在编写JavaScript的时候,就要充分考虑到浏览器的差异,尽量让同一份JavaScript代码能运行在不同的浏览器中。
浏览器对象
Window
window对象不但充当全局作用域
,而且表示浏览器窗口
。
- window对象有
innerWidth
和innerHeight
属性,可以获取浏览器窗口的内部宽度和高度。内部宽高是指除去菜单栏、工具栏、边框等占位元素后,用于显示网页的净宽高。- 对应的,还有一个
outerWidth
和outerHeight
属性,可以获取浏览器窗口的整个宽高。1
console.log('window inner size: ' + window.innerWidth + ' x ' + window.innerHeight);
- 对应的,还有一个
navigator
navigator对象表示浏览器的信息,最常用的属性包括:
- navigator.appName:浏览器名称;
- navigator.appVersion:浏览器版本;
- navigator.language:浏览器设置的语言;
- navigator.platform:操作系统类型;
- navigator.userAgent:浏览器设定的User-Agent字符串。navigator的信息可以很容易地被用户修改,所以JavaScript读取的值不一定是正确的。
1
2
3
4
5console.log('appName = ' + navigator.appName);
console.log('appVersion = ' + navigator.appVersion);
console.log('language = ' + navigator.language);
console.log('platform = ' + navigator.platform);
console.log('userAgent = ' + navigator.userAgent); - 充分利用JavaScript对不存在属性返回undefined的特性,直接用短路运算符||计算。
1
let width = window.innerWidth || document.body.clientWidth;
判断浏览器版本
1 |
|
screen
screen对象表示屏幕的信息,常用的属性有:
- screen.width:屏幕宽度,以像素为单位;
- screen.height:屏幕高度,以像素为单位;
- screen.colorDepth:返回颜色位数,如8、16、24。
location
location对象表示当前页面的URL信息。
- 可以用
location.href
获取当前页面的URL。 - 要获得URL各个部分的值,可以这么写:
1
2
3
4
5
6location.protocol; // 'http'
location.host; // 'www.example.com'
location.port; // '8080'
location.pathname; // '/path/index.html'
location.search; // '?a=1&b=2'
location.hash; // 'TOP' - 要加载一个新页面,可以调用
location.assign()
。如果要重新加载当前页面,调用location.reload()
方法非常方便。1
2
3
4
5if (confirm('重新加载当前页' + location.href + '?')) {
location.reload();
} else {
location.assign('/'); // 设置一个新的URL地址
}
document
document对象表示当前页面。由于HTML在浏览器中以DOM形式表示为树形结构,document对象就是整个DOM树的根节点。
document的title属性是从HTML文档中的
xxx 读取的,但是可以动态改变:1
document.title = '努力学习JavaScript!';
用document对象提供的
getElementById()
和getElementsByTagName()
可以按ID获得一个DOM节点和按Tag名称获得一组DOM节点:1
2
3
4
5
6
7
8let menu = document.getElementById('drink-menu');
let drinks = document.getElementsByTagName('dt');
let s = '提供的饮料有:';
for (let i=0; i<drinks.length; i++) {
s = s + drinks[i].innerHTML + ',';
}
console.log(s);document对象还有一个
cookie
属性,可以获取当前页面的Cookie。- Cookie是由服务器发送的key-value标示符。因为HTTP协议是无状态的,但是服务器要区分到底是哪个用户发过来的请求,就可以用Cookie来区分。当一个用户成功登录后,服务器发送一个Cookie给浏览器,例如
user=ABC123XYZ(加密的字符串)...
,此后,浏览器访问该网站时,会在请求头附上这个Cookie,服务器根据Cookie即可区分出用户。 - Cookie还可以存储网站的一些设置,例如,页面显示的语言等等。
- JavaScript可以通过document.cookie读取到当前页面的Cookie:
1
document.cookie; // 'v=123; remember=true; prefer=zh'
- Cookie是由服务器发送的key-value标示符。因为HTTP协议是无状态的,但是服务器要区分到底是哪个用户发过来的请求,就可以用Cookie来区分。当一个用户成功登录后,服务器发送一个Cookie给浏览器,例如
由于JavaScript能读取到页面的Cookie,而用户的登录信息通常也存在Cookie中,这就造成了巨大的安全隐患,这是因为在HTML页面中引入第三方的JavaScript代码是允许的。
1
2
3
4
5
6
7<!-- 当前页面在wwwexample.com -->
<html>
<head>
<script src="http://www.foo.com/jquery.js"></script>
</head>
...
</html>如果引入的第三方的JavaScript中存在恶意代码,则
www.foo.com
网站将直接获取到www.example.com网站的用户登录信息。服务器在设置Cookie时可以使用`httpOnly`,设定了httpOnly的Cookie将不能被JavaScript读取。这个行为由浏览器实现,主流浏览器均支持httpOnly选项,IE从IE6 SP1开始支持。
history
history对象保存了浏览器的历史记录,JavaScript可以调用history.back()
或history.forward ()
,相当于用户点击了浏览器的“后退”或“前进”按钮。
对使用AJAX动态加载的页面,如果希望页面更新时同时更新history对象,应当使用history.pushState()方法:
1 |
|
当用户点击“后退”时,浏览器并不会刷新页面,而是触发popstate事件,可由JavaScript捕获并更新相应的部分页面内容。
操作DOM
由于HTML文档被浏览器解析后就是一棵DOM树,要改变HTML的结构,就需要通过JavaScript来操作DOM。
始终记住DOM是一个树形结构。操作一个DOM节点实际上就是这么几个操作:
- 更新:更新该DOM节点的内容,相当于更新了该DOM节点表示的HTML的内容;
- 遍历:遍历该DOM节点下的子节点,以便进行进一步操作;
- 添加:在该DOM节点下新增一个子节点,相当于动态增加了一个HTML节点;
- 删除:将该节点从HTML中删除,相当于删掉了该DOM节点的内容以及它包含的所有子节点。
拿到一个DOM节点
(1)第一种方法:
- document.getElementById()
- document.getElementsByTagName()
- document.getElementsByClassName() (CSS选择器)
由于ID在HTML文档中是唯一的,所以document.getElementById()
可以直接定位唯一的一个DOM节点。document.getElementsByTagName()
和document.getElementsByClassName()
总是返回一组DOM节点。要精确地选择DOM,可以先定位父节点,再从父节点开始选择,以缩小范围。
1 |
|
(2)第二种方法:需要了解selector语法,然后使用条件来获取节点,更加方便
- querySelector()
- querySelectorAll()
1
2
3
4
5// 通过querySelector获取ID为q1的节点:
let q1 = document.querySelector('#q1');
// 通过querySelectorAll获取q1节点内的符合条件的所有节点:
let ps = q1.querySelectorAll('div.highlighted > p');
严格地讲,我们这里的DOM节点是指Element
,但是DOM节点实际上是Node
,在HTML中,Node包括Element、Comment、CDATA_SECTION等
很多种,以及根节点Document类型,但是,绝大多数时候我们只关心Element,也就是实际控制页面结构的Node,其他类型的Node忽略即可。根节点Document已经自动绑定为全局变量document
。
练习(通过!):
1 |
|
请选择出指定条件的节点:
1 |
|
更新DOM
拿到一个DOM节点后,我们可以对它进行更新。
可以直接修改节点的文本,方法有两种:
一种是修改
innerHTML
属性,这个方式非常强大,不但可以修改一个DOM节点的文本内容,还可以直接通过HTML片段修改DOM节点内部的子树:1
2
3
4
5
6
7// 获取<p id="p-id">...</p>
let p = document.getElementById('p-id');
// 设置文本为abc:
p.innerHTML = 'ABC'; // <p id="p-id">ABC</p>
// 设置HTML:
p.innerHTML = 'ABC <span style="color:red">RED</span> XYZ';
// <p>...</p>的内部结构已修改用innerHTML时要注意,是否需要写入HTML。如果写入的字符串是通过网络拿到的,要注意对字符编码来避免XSS攻击。
第二种是修改
innerText
或textContent
属性,这样可以自动对字符串进行HTML编码,保证无法设置任何HTML标签:1
2
3
4
5
6// 获取<p id="p-id">...</p>
let p = document.getElementById('p-id');
// 设置文本:
p.innerText = '<script>alert("Hi")</script>';
// HTML被自动编码,无法设置一个<script>节点:
//两者的区别在于读取属性时,innerText不返回隐藏元素的文本,而textContent返回所有文本。
修改CSS
修改CSS也是经常需要的操作。DOM节点的style
属性对应所有的CSS,可以直接获取或设置。因为CSS允许font-size这样的名称,但它并非JavaScript有效的属性名,所以需要在JavaScript中改写为驼峰式命名fontSize
:
1 |
|
本小节练习:
1 |
|
插入DOM
当我们获得了某个DOM节点,想在这个DOM节点内插入新的DOM,应该如何做?
如果这个DOM节点是空的,例如,
,那么,直接使用innerHTML = '<span>child</span>'
就可以修改DOM节点的内容,相当于“插入”了新的DOM节点。如果这个DOM节点不是空的,那就不能这么做,因为innerHTML会直接替换掉原来的所有子节点。
- 使用
appendChild
,把一个子节点添加到父节点的最后一个子节点。把1
2
3
4
5
6
7<!-- HTML结构 -->
<p id="js">JavaScript</p>
<div id="list">
<p id="java">Java</p>
<p id="python">Python</p>
<p id="scheme">Scheme</p>
</div><p id="js">JavaScript</p>
添加到<div id="list">
的最后一项:因为我们插入的js节点已经存在于当前的文档树,因此这个节点首先会从原先的位置删除,再插入到新的位置。1
2
3
4let
js = document.getElementById('js'),
list = document.getElementById('list');
list.appendChild(js); - 从零创建一个新的节点,然后插入到指定位置: 这样我们就动态添加了一个新的节点:
1
2
3
4
5
6let
list = document.getElementById('list'),
haskell = document.createElement('p');
haskell.id = 'haskell';
haskell.innerText = 'Haskell';
list.appendChild(haskell);动态创建一个节点然后添加到DOM树中,可以实现很多功能。举个例子,下面的代码动态创建了一个1
2
3
4
5
6
7<!-- HTML结构 -->
<div id="list">
<p id="java">Java</p>
<p id="python">Python</p>
<p id="scheme">Scheme</p>
<p id="haskell">Haskell</p>
</div><style>
节点,然后把它添加到<head>
节点的末尾,这样就动态地给文档添加了新的CSS定义:1
2
3
4let d = document.createElement('style');
d.setAttribute('type', 'text/css');
d.innerHTML = 'p { color: red }';
document.getElementsByTagName('head')[0].appendChild(d); - 使用
insertBefore
将子节点插入到指定的位置:子节点会插入到referenceElement之前。1
parentElement.insertBefore(newElement, referenceElement);
还是以上面的HTML为例,假定我们要把Haskell插入到Python之前:可以这么写:1
2
3
4
5
6<!-- HTML结构 -->
<div id="list">
<p id="java">Java</p>
<p id="python">Python</p>
<p id="scheme">Scheme</p>
</div>可见,使用insertBefore重点是要拿到一个1
2
3
4
5
6
7let
list = document.getElementById('list'),
ref = document.getElementById('python'),
haskell = document.createElement('p');
haskell.id = 'haskell';
haskell.innerText = 'Haskell';
list.insertBefore(haskell, ref);参考子节点
的引用。很多时候,需要循环一个父节点的所有子节点,可以通过迭代children属性实现:1
2
3
4
5
6let
i, c,
list = document.getElementById('list');
for (i = 0; i < list.children.length; i++) {
c = list.children[i]; // 拿到第i个子节点
}
- 使用
练习:
对于一个已有的HTML结构:
1 |
|
按字符串顺序重新排序DOM节点:
1 |
|
删除DOM
要删除一个节点,首先要获得该节点本身以及它的父节点,然后,调用父节点的removeChild把自己删掉:
1 |
|
注意:删除后的节点虽然不在文档树中了,但其实它还在内存中,可以随时再次被添加到别的位置。
遍历一个父节点的子节点并进行删除操作时,要注意,children属性是一个只读属性,并且它在子节点变化时会实时更新。
- 例如,对于如下HTML结构: 当我们用如下代码删除子节点时:
1
2
3
4<div id="parent">
<p>First</p>
<p>Second</p>
</div>浏览器报错:1
2
3let parent = document.getElementById('parent');
parent.removeChild(parent.children[0]);
parent.removeChild(parent.children[1]); // <-- 浏览器报错parent.children[1]
不是一个有效的节点。原因就在于,当<p>First</p>
节点被删除后,parent.children
的节点数量已经从2变为了1,索引[1]已经不存在了。
因此,删除多个节点时,要注意children属性时刻都在变化。
- 例如,对于如下HTML结构:
操作表单
用JavaScript操作表单和操作DOM是类似的,因为表单本身也是DOM树。
不过表单的输入框、下拉框等可以接收用户输入,所以用JavaScript来操作表单,可以获得用户输入的内容,或者对一个输入框设置新的内容。
HTML表单的输入控件主要有以下几种:
- 文本框,对应的
<input type="text">
,用于输入文本; - 口令框,对应的
<input type="password">
,用于输入口令; - 单选框,对应的
<input type="radio">
,用于选择一项; - 复选框,对应的
<input type="checkbox">
,用于选择多项; - 下拉框,对应的
<select>
,用于选择一项; - 隐藏文本,对应的
<input type="hidden">
,用户不可见,但表单提交时会把隐藏文本发送到服务器
获取值
如果我们获得了一个<input>
节点的引用,就可以直接调用value
获得对应的用户输入值:
1 |
|
这种方式可以应用于text
、password
、hidden
以及select
。
- 但是,对于单选框和复选框,value属性返回的永远是HTML预设的值,而我们需要获得的实际是用户是否“勾上了”选项,所以应该用checked判断:
1
2
3
4
5
6
7
8// <label><input type="radio" name="weekday" id="monday" value="1"> Monday</label>
// <label><input type="radio" name="weekday" id="tuesday" value="2"> Tuesday</label>
let mon = document.getElementById('monday');
let tue = document.getElementById('tuesday');
mon.value; // '1'
tue.value; // '2'
mon.checked; // true或者false
tue.checked; // true或者false
设置值
设置值和获取值类似,对于text
、password
、hidden
以及select
,直接设置value
就可以:
1 |
|
- 对于单选框和复选框,设置
checked
为true或false即可。
HTML5控件
HTML5新增了大量标准控件,常用的包括date、datetime、datetime-local、color等,它们都使用<input>
标签:
1 |
|
1 |
|
1 |
|
- 不支持HTML5的浏览器无法识别新的控件,会把它们当做
type="text"
来显示。 - 支持HTML5的浏览器将获得格式化的字符串。
- 例如,type=”date”类型的input的value将保证是一个有效的YYYY-MM-DD格式的日期,或者空字符串。
提交表单
JavaScript可以以两种方式来处理表单的提交(AJAX方式在后面章节介绍)。
方式一是通过
<form>
元素的submit()方法提交一个表单,例如,响应一个<button>
的click事件,在JavaScript代码中提交表单:1
2
3
4
5
6
7
8
9
10
11
12
13
14<!-- HTML -->
<form id="test-form">
<input type="text" name="test">
<button type="button" onclick="doSubmitForm()">Submit</button>
</form>
<script>
function doSubmitForm() {
let form = document.getElementById('test-form');
// 可以在此修改form的input...
// 提交form:
form.submit();
}
</script>这种方式的缺点是扰乱了浏览器对form的正常提交。浏览器默认点击
<button type="submit">
时提交表单,或者用户在最后一个输入框按回车键。第二种方式是响应
<form>
本身的onsubmit事件,在提交form时作修改:1
2
3
4
5
6
7
8
9
10
11
12
13
14<!-- HTML -->
<form id="test-form" onsubmit="return checkForm()">
<input type="text" name="test">
<button type="submit">Submit</button>
</form>
<script>
function checkForm() {
let form = document.getElementById('test-form');
// 可以在此修改form的input...
// 继续下一步:
return true;
}
</script>注意要
return true
来告诉浏览器继续提交,如果return false,浏览器将不会继续提交form,这种情况通常对应用户输入有误,提示用户错误信息后终止提交form。在检查和修改
<input>
时,要充分利用<input type="hidden">
来传递数据。- 例如,很多登录表单希望用户输入用户名和口令,但是,安全考虑,提交表单时不传输明文口令,而是口令的MD5。普通JavaScript开发人员会直接修改
<input>
:这个做法看上去没啥问题,但用户输入了口令提交时,口令框的显示会突然从几个变成32个(因为MD5有32个字符)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16<!-- HTML -->
<form id="login-form" method="post" onsubmit="return checkForm()">
<input type="text" id="username" name="username">
<input type="password" id="password" name="password">
<button type="submit">Submit</button>
</form>
<script>
function checkForm() {
let pwd = document.getElementById('password');
// 把用户输入的明文变为MD5:
pwd.value = toMD5(pwd.value);
// 继续下一步:
return true;
}
</script> - 要想不改变用户的输入,可以利用
<input type="hidden">
实现:注意到id为md5-password的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18<!-- HTML -->
<form id="login-form" method="post" onsubmit="return checkForm()">
<input type="text" id="username" name="username">
<input type="password" id="input-password">
<input type="hidden" id="md5-password" name="password">
<button type="submit">Submit</button>
</form>
<script>
function checkForm() {
let input_pwd = document.getElementById('input-password');
let md5_pwd = document.getElementById('md5-password');
// 把用户输入的明文变为MD5:
md5_pwd.value = toMD5(input_pwd.value);
// 继续下一步:
return true;
}
</script><input>
标记了name=”password”,而用户输入的id为input-password的<input>
没有name属性。没有name属性的<input>
的数据不会被提交。
- 例如,很多登录表单希望用户输入用户名和口令,但是,安全考虑,提交表单时不传输明文口令,而是口令的MD5。普通JavaScript开发人员会直接修改
操作文件
在HTML表单中,可以上传文件的唯一控件就是<input type="file">
。
- 注意:当一个表单包含
<input type="file">
时,表单的enctype
必须指定为multipart/form-data
,method
必须指定为post
,浏览器才能正确编码并以multipart/form-data
格式发送表单的数据。 - 通常,上传的文件都由后台服务器处理,JavaScript可以在提交表单时对文件扩展名做检查,以便防止用户上传无效格式的文件:
1
2
3
4
5
6let f = document.getElementById('test-file-upload');
let filename = f.value; // 'C:\fakepath\test.png'
if (!filename || !(filename.endsWith('.jpg') || filename.endsWith('.png') || filename.endsWith('.gif'))) {
alert('Can only upload image file.');
return false;
}
File API
由于JavaScript对用户上传的文件操作非常有限,尤其是无法读取文件内容,使得很多需要操作文件的网页不得不用Flash这样的第三方插件来实现。
随着HTML5的普及,新增的File API允许JavaScript读取文件内容,获得更多的文件信息。
HTML5的File API提供了File
和FileReader
两个主要对象,可以获得文件信息并读取文件。
下面的例子演示了如何读取用户选取的图片文件,并在一个<div>
中预览图像:
1 |
|
- 上面的代码演示了如何通过HTML5的File API读取文件内容。以
DataURL
的形式读取到的文件是一个字符串,类似于data:image/jpeg;base64,/9j/4AAQSk...(base64编码)...
,常用于设置图像。- 如果需要服务器端处理,把字符串base64,后面的字符发送给服务器并用Base64解码就可以得到原始文件的二进制内容。
回调
上面的代码还演示了JavaScript的一个重要的特性就是单线程执行模式
。在JavaScript中,浏览器的JavaScript执行引擎在执行JavaScript代码时,总是以单线程模式执行,也就是说,任何时候,JavaScript代码都不可能同时有多于1个线程在执行。
单线程模式执行的JavaScript,如何处理多任务?
在JavaScript中,执行多任务实际上都是异步调用
,比如上面的代码:
1 |
|
就会发起一个异步操作来读取文件内容。因为是异步操作,所以我们在JavaScript代码中就不知道什么时候操作结束,因此需要先设置一个回调函数
:
1 |
|
当文件读取完成后,JavaScript引擎将自动调用我们设置的回调函数。执行回调函数时,文件已经读取完毕,所以我们可以在回调函数内部安全地获得文件内容。
AJAX
AJAX:Asynchronous JavaScript and XML
,意思就是用JavaScript执行异步网络请求。
如果仔细观察一个Form的提交,你就会发现,一旦用户点击“Submit”按钮,表单开始提交,浏览器就会刷新页面,然后在新页面里告诉你操作是成功了还是失败了。如果不幸由于网络太慢或者其他原因,就会得到一个404页面。
这就是Web的运作原理:一次HTTP请求对应一个页面
。
如果要让用户留在当前页面中,同时发出新的HTTP请求,就必须用JavaScript发送这个新请求,接收到数据后,再用JavaScript更新页面,这样一来,用户就感觉自己仍然停留在当前页面,但是数据却可以不断地更新。
最早大规模使用AJAX的就是Gmail,Gmail的页面在首次加载后,剩下的所有数据都依赖于AJAX来更新。
用JavaScript写一个完整的AJAX代码并不复杂,但是需要注意:
- AJAX请求是异步执行的,也就是说,要通过回调函数获得响应。
在现代浏览器上写AJAX主要依靠XMLHttpRequest对象,如果不考虑早期浏览器的兼容性问题,现代浏览器还提供了原生支持的Fetch API,以Promise方式提供。使用Fetch API发送HTTP请求代码如下:
1 |
|
使用Fetch API
配合async
写法,代码更加简单。
Fetch API的详细用法可以参考MDN文档。
安全限制
上面代码的URL使用的是相对路径。如果你把它改为'https://www.sina.com.cn/'
,再运行,肯定报错。在Chrome的控制台里,还可以看到错误信息。
这是因为浏览器的同源策略
导致的。默认情况下,JavaScript在发送AJAX请求时,URL的域名必须和当前页面完全一致
。
完全一致的意思是,域名要相同(www.example.com
和example.com
不同),协议要相同(http
和https
不同),端口号要相同(http默认是:80
端口,它和:8080
就不同)。有的浏览器口子松一点,允许端口不同,大多数浏览器都会严格遵守这个限制。
那是不是用JavaScript无法请求外域(就是其他网站)的URL了呢?方法还是有的,大概有这么几种:
通过Flash插件发送HTTP请求,这种方式可以绕过浏览器的安全限制,但必须安装Flash,并且跟Flash交互。不过Flash用起来麻烦,而且现在用得也越来越少了。
通过在同源域名下架设一个代理服务器来转发,JavaScript负责把请求发送到代理服务器:
1
'/proxy?url=https://www.sina.com.cn'
代理服务器再把结果返回,这样就遵守了浏览器的同源策略。这种方式麻烦之处在于需要服务器端额外做开发。
- 第三种方式称为JSONP,它有个限制,只能用
GET
请求,并且要求返回JavaScript。这种方式跨域实际上是利用了浏览器允许跨域引用JavaScript资源
:JSONP通常以函数调用的形式返回,例如,返回JavaScript内容如下:1
2
3
4
5
6
7
8
9<html>
<head>
<script src="http://example.com/abc.js"></script>
...
</head>
<body>
...
</body>
</html>这样一来,我们如果在页面中先准备好1
foo('data');
foo()
函数,然后给页面动态加一个<script>
节点,相当于动态读取外域的JavaScript资源,最后就等着接收回调了。
CORS
如果浏览器支持HTML5,那么就可以一劳永逸地使用新的跨域策略:CORS
了。
CORS全称Cross-Origin Resource Sharing,是HTML5规范定义的如何跨域访问资源。
了解CORS前,我们先搞明白概念:
Origin表示本域,也就是浏览器当前页面的域。当JavaScript向外域(如sina.com)发起请求后,浏览器收到响应后,首先检查Access-Control-Allow-Origin
是否包含本域,如果是,则此次跨域请求成功,如果不是,则请求失败,JavaScript将无法获取到响应的任何数据。
假设本域是my.com
,外域是sina.com
,只要响应头Access-Control-Allow-Origin
为http://my.com
,或者是*
,本次请求就可以成功。
- 可见,跨域能否成功,取决于对方服务器是否愿意给你设置一个正确的
Access-Control-Allow-Origin
,决定权始终在对方手中。
上面这种跨域请求,称之为“简单请求
”。简单请求包括GET、HEAD和POST(POST的Content-Type类型 仅限application/x-www-form-urlencoded、multipart/form-data和text/plain),并且不能出现任何自定义头(例如,X-Custom: 12345),通常能满足90%的需求。
无论你是否需要用JavaScript通过CORS跨域请求资源,你都要了解CORS的原理。最新的浏览器全面支持HTML5。
- 在引用外域资源时,除了JavaScript和CSS外,都要验证CORS。例如,当你引用了某个第三方CDN上的字体文件时:如果该CDN服务商未正确设置Access-Control-Allow-Origin,那么浏览器无法加载字体资源。
1
2
3
4
5/* CSS */
@font-face {
font-family: 'FontAwesome';
src: url('http://cdn.com/fonts/fontawesome.ttf') format('truetype');
}
对于PUT
、DELETE
以及其他类型如application/json
的POST请求,在发送AJAX请求之前,浏览器会先发送一个OPTIONS
请求(称为preflighted请求)到这个URL上,询问目标服务器是否接受:
1 |
|
服务器必须响应并明确指出允许的Method:
1 |
|
浏览器确认服务器响应的Access-Control-Allow-Methods
头确实包含将要发送的AJAX请求的Method,才会继续发送AJAX,否则,抛出一个错误。
由于以POST
、PUT
方式传送JSON格式的数据在REST中很常见,所以要跨域正确处理POST和PUT请求,服务器端必须正确响应OPTIONS
请求。
Promise
在JavaScript的世界中,所有代码都是单线程执行的。
由于这个“缺陷”,导致JavaScript的所有网络操作,浏览器事件,都必须是异步执行。异步执行可以用回调函数实现:
1 |
|
观察上述代码执行,在Chrome的控制台输出可以看到:
1 |
|
可见,异步操作会在将来的某个时间点触发一个函数调用
。
AJAX就是典型的异步操作:
1 |
|
把回调函数success(request.responseText)
和fail(request.status)
写到一个AJAX操作里很正常,但是不好看,而且不利于代码复用。
有没有更好的写法?比如写成这样:
1 |
|
这种链式写法的好处在于,先统一执行AJAX逻辑,不关心如何处理结果,然后,根据结果是成功还是失败,在将来的某个时候调用success函数或fail函数。
古人云:“君子一诺千金”,这种“承诺将来会执行”的对象在JavaScript中称为Promise
对象。
栗子:
- 生成一个0-2之间的随机数,如果小于1,则等待一段时间后返回成功,否则返回失败:这个test()函数有两个参数,这两个参数都是函数,如果执行成功,我们将调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14function test(resolve, reject) {
let timeOut = Math.random() * 2;
log('set timeout to: ' + timeOut + ' seconds.');
setTimeout(function () {
if (timeOut < 1) {
log('call resolve()...');
resolve('200 OK');
}
else {
log('call reject()...');
reject('timeout in ' + timeOut + ' seconds.');
}
}, timeOut * 1000);
}resolve('200 OK')
,如果执行失败,我们将调用reject('timeout in ' + timeOut + ' seconds.')
。 - 可以看出,test()函数只关心自身的逻辑,并不关心具体的resolve和reject将如何处理结果。
有了执行函数,我们就可以用一个Promise对象来执行它,并在将来某个时刻获得成功或失败的结果:
1 |
|
变量p1是一个Promise对象,它负责执行test函数。
- 由于test函数在内部是异步执行的,当test函数执行成功时,我们告诉Promise对象:
1
2
3
4// 如果成功,执行这个函数:
p1.then(function (result) {
console.log('成功:' + result);
}); - 当test函数执行失败时,我们告诉Promise对象:Promise对象可以串联起来,所以上述代码可以简化为:
1
2
3p2.catch(function (reason) {
console.log('失败:' + reason);
});1
2
3
4
5new Promise(test).then(function (result) {
console.log('成功:' + result);
}).catch(function (reason) {
console.log('失败:' + reason);
}); - 可见Promise最大的好处是在异步执行的流程中,把执行代码和处理结果的代码清晰地分离了。
resolve函数
resolve函数是JavaScript中的Promise对象的一部分,用于将Promise从“待定(pending)”状态变为“已解决(fulfilled)”状态,并传递一个值。
- 这个值可以是任何类型,包括基本类型(如数字、字符串等)和对象、数组、另一个Promise等。
resolve函数通常在异步操作成功完成时调用,以表示操作的成功和结果。
Promise有三种状态:
- 待定(pending):初始状态,既不是成功也不是失败。
- 已解决(fulfilled):表示操作成功完成。
- 已拒绝(rejected):表示操作失败。
使用示例:
1 |
|
reject函数
reject函数是JavaScript中的Promise对象的一部分,用于将Promise从“待定(pending)”状态变为“已拒绝(rejected)”状态,并传递一个错误原因。
- 这个原因通常是一个Error对象或其他描述错误的值。reject函数通常在异步操作失败时调用,以表示操作的失败和错误信息。
then方法和catch方法
在JavaScript中,处理异步操作结果通常使用Promise对象的then方法和catch方法。
- then方法用于处理Promise成功完成(fulfilled)后的结果
- catch方法用于处理Promise失败(rejected)后的错误
1 |
|
- function(result):是一个回调函数,result参数是resolve传递的值;error是reject函数传递的值。
- 在下面示例的asyncOperation函数中,resolve可能传递字符串 ‘Operation successful: Test Input’。这个值将被作为参数传递给then方法的回调函数。
Promise使用示例
1 |
|
Promise串行实现异步任务
Promise还可以做更多的事情,比如,有若干个异步任务,需要先做任务1,如果成功后再做任务2,任何任务失败则不再继续并执行错误处理函数。
要串行执行这样的异步任务,不用Promise需要写一层一层的嵌套代码。有了Promise,我们只需要简单地写:
1 |
|
其中,job1
、job2
和job3
都是Promise对象。
栗子:
1 |
|
setTimeout可以看成一个模拟网络等异步执行
的函数。
- setTimeout 第一个参数是回调函数,这里是 resolve。
- setTimeout 第二个参数是延迟时间,这里是500毫秒。
- setTimeout 第三个参数是传递给回调函数的值,这里是 input * input。
Promise并行实现异步任务
除了串行执行若干异步任务外,Promise还可以并行执行异步任务。
试想一个页面聊天系统,我们需要从两个不同的URL分别获得用户的个人信息和好友列表,这两个任务是可以并行执行的,用Promise.all()
实现如下:
1 |
|
有些时候,多个异步任务是为了容错。比如,同时向两个URL读取用户的个人信息,只需要获得先返回的结果即可。这种情况下,用Promise.race()
实现:
1 |
|
由于p1执行较快,Promise的then()将获得结果’P1’。p2仍在继续执行,但执行结果将被丢弃。
如果我们组合使用Promise,就可以把很多异步任务以并行和串行的方式组合起来执行。
async函数
avaScript异步操作需要通过Promise实现,一个Promise对象在操作网络时是异步的,等到返回后再调用回调函数,执行正确就调用then(),执行错误就调用catch(),虽然异步实现了,不会让用户感觉到页面“卡住”了,但是一堆then()、catch()写起来麻烦看起来也乱。
有没有更简单的写法?
可以用关键字async
配合await
调用Promise,实现异步操作,但代码却和同步写法类似:
1 |
|
- 使用
async function
可以定义一个异步函数,异步函数和Promise可以看作是等价的,在async function内部,用await
调用另一个异步函数,写起来和同步代码没啥区别,但执行起来是异步的。
1 |
|
自动实现了异步调用,它和下面的Promise代码等价:
1 |
|
- 如果我们要实现catch()怎么办?用Promise的写法如下:用await调用时,直接用传统的
1
2
3
4
5
6let promise = fetch(url);
promise.then((resp) => {
// 拿到resp
}).catch(e => {
// 出错了
});try { ... } catch
:用async定义异步函数,用await调用异步函数,写起来和同步代码差不多,但可读性大大提高。1
2
3
4
5
6
7
8
9async function get(url) {
try {
let resp = await fetch(url);
let result = await resp.json();
return result;
} catch (e) {
// 出错了
}
}
同步function调用async function
await调用必须在async function中,不能在传统的同步代码中调用。那么问题来了,一个同步function怎么调用async function呢?
首先,普通function直接用await调用异步函数将报错:
1 |
|
- 如果把await去掉,调用实际上发生了,但我们拿不到结果,因为我们拿到的并不是异步结果,而是一个Promise对象:
1
2
3
4
5
6
7
8
9
10async function get(url) {
let resp = await fetch(url);
let result = await resp.text();
return result;
}
function doGet() {
let promise = get('./content.html');
console.log(promise);
} - 因此,在普通function中调用async function,不能使用await,但可以直接调用async function拿到Promise对象,后面加上then()和catch()就可以拿到结果或错误了:因此,定义异步任务时,使用async function比Promise简单,调用异步任务时,使用await比Promise简单,捕获错误时,按传统的try…catch写法,也比Promise简单。只要浏览器支持,完全可以用async简洁地实现异步操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15async function get(url) {
let resp = await fetch(url);
let result = await resp.text();
return result;
}
function doGet() {
let promise = get('./content.html');
promise.then(data => {
// 拿到data
document.getElementById('test-response-text').value = JSON.stringify(data);
});
}
doGet();
Canvas
Canvas是HTML5新增的组件,它就像一块幕布,可以用JavaScript在上面绘制各种图表、动画等。
一个Canvas定义了一个指定尺寸的矩形框,在这个范围内我们可以随意绘制:
1 |
|
测试浏览器是否支持Canvas
在使用Canvas前,用canvas.getContext
来测试浏览器是否支持Canvas:
1 |
|
使用Canvas绘制2D图像
getContext('2d')
方法让我们拿到一个CanvasRenderingContext2D
对象,所有的绘图操作都需要通过这个对象完成。
1 |
|
- 如果需要绘制3D怎么办?HTML5还有一个WebGL规范,允许在Canvas中绘制3D图形:本节我们只专注于绘制2D图形。
1
gl = canvas.getContext("webgl");
绘制形状
我们可以在Canvas上绘制各种形状。在绘制前,我们需要先了解一下Canvas的坐标系统:
Canvas的坐标以左上角为原点,水平向右为X轴,垂直向下为Y轴,以像素为单位,所以每个点都是非负整数。
CanvasRenderingContext2D
对象有若干方法来绘制图形:
举个栗子:
1 |
|
绘制文本
绘制文本就是在指定的位置输出文本,可以设置文本的字体、样式、阴影等,与CSS完全一致:
1 |
|
Canvas除了能绘制基本的形状和文本,还可以实现动画、缩放、各种滤镜和像素转换等高级操作。如果要实现非常复杂的操作,考虑以下优化方案:
通过创建一个不可见的Canvas来绘图,然后将最终绘制结果复制到页面的可见Canvas中;
- 尽量使用整数坐标而不是浮点数;
- 可以创建多个重叠的Canvas绘制不同的层,而不是在一个Canvas中绘制非常复杂的图;
- 背景图片如果不变可以直接用
<img>
标签并放到最底层。
练习
1 |
|
- 函数用法积累:
1
2let maxTemp = Math.max(...data.map(d => d.high));
let minTemp = Math.min(...data.map(d => d.low));data
是一个包含天气数据的数组,每个元素都是一个对象,包含高温 (high) 和低温 (low) 信息。data.map(d => d.high)
:- map 方法会创建一个新数组,其元素是调用一次提供的函数对原数组中的每个元素执行结果。
- 这里的 d => d.high 是一个箭头函数,它从 data 数组中的每个对象提取
high
属性,生成一个新的数组,包含所有高温值。 - 结果是一个高温值数组 [35, 37, 37, 34, 33]。
Math.max(...[35, 37, 37, 34, 33])
:- Math.max 函数返回给定数字中的最大值。
- 使用展开运算符
...
将数组展开成一系列参数传递给 Math.max。 - 最终结果是最高温度 37。
- 这两行代码的作用是从天气数据中提取出最高温度和最低温度。这对于后续在Canvas上绘制温度折线图非常重要,因为我们需要这些最大和最小值来正确地缩放和定位温度点。
1
ctx.beginPath()
- 功能:开始一条新的路径,或重置当前路径。
- 作用:在Canvas中绘制一条新的折线图前,调用这个方法来开始一条新的路径。
1
ctx.moveTo(margin, height - (data[0].high - minTemp) / (maxTemp - minTemp) * height + margin);
- 功能:将绘图的起始点移动到指定的坐标。
- 参数解释:
- margin:x坐标,绘制区域的左边距。
- height - (data[0].high - minTemp) / (maxTemp - minTemp) * height + margin:
y坐标,计算方式如下:- data[0].high:第一天的高温值。
- minTemp 和 maxTemp:整个数据集中的最低温度和最高温度。
- (data[0].high - minTemp) / (maxTemp - minTemp):将当前温度值归一化到0到1的范围内。
- height:将归一化的值映射到绘图区域的高度。
- height - …:
反转y轴
,因为Canvas的y坐标是从上到下增加的。
- margin:加上顶部边距,保证图形不紧贴画布边缘。
1
ctx.lineTo(margin + i * (width / (data.length - 1)), height - (data[i].high - minTemp) / (maxTemp - minTemp) * height + margin);
- 功能:将当前绘图位置连接到指定坐标处的一个新点,绘制一条直线。
- 参数解释:
margin + i * (width / (data.length - 1))
:
x坐标,计算方式如下:- i:当前循环的索引,表示第几天的数据。
- width / (data.length - 1):
计算每一天之间的水平距离,width 是绘图区域的宽度,data.length - 1 是总天数减去1,因为折线需要绘制 data.length - 1 段。 - margin + i * (width / (data.length - 1)):加上左边距,计算出当前数据点的x坐标。
- height - (data[i].high - minTemp) / (maxTemp - minTemp) * height + margin:
y坐标,与之前解释的moveTo方法中的计算相同,只是使用当前索引 i 对应的高温值。
1
ctx.stroke();
- 功能:绘制当前路径。
- 作用:在Canvas上绘制之前定义的折线。调用stroke()方法后,前面用 moveTo 和 lineTo 定义的路径将被实际绘制到画布上。
错误处理
错误传播
异步错误处理
jQuery
未完待续……