JavaScript学习指南

本文最后更新于:2024年8月7日 中午

参照廖雪峰老师的教程,整理和记录JavaScript的学习过程~

速通语法

  1. JavaScript的语法和Java语言类似,每个语句以;结束,语句块用{...}

  2. {…}内的语句具有缩进,通常是4个空格。缩进不是JavaScript语法要求必须的,但缩进有助于我们理解代码的层次,所以编写代码时要遵守缩进规则。

  3. 注释:

  • 行注释://
  • 块注释:/**/

数据类型和变量

Number

JavaScript不区分整数和浮点数,统一用Number表示。以下都是合法的Number类型:

1
2
3
4
5
6
123; // 整数123
0.456; // 浮点数0.456
1.2345e3; // 科学计数法表示1.2345x1000,等同于1234.5
-99; // 负数
NaN; // NaN表示Not a Number,当无法计算结果时用NaN表示
Infinity; // Infinity表示无限大,当数值超过了JavaScript的Number所能表示的最大值时,就表示为Infinity

Number可以直接做四则运算,规则和数学一致:

1
2
3
4
5
6
1 + 2; // 3
(1 + 2) * 5 / 2; // 7.5
2 / 0; // Infinity
0 / 0; // NaN
10 % 3; // 1
10.5 % 3; // 1.5

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标准新增了一种多行字符串的表示方法,用反引号``表示:
    1
    2
    3
    `这是一个
    多行
    字符串`;
    注意:反引号在键盘的ESC下方,数字键1的左边。
  • 模板字符串:
    要把多个字符串连接起来,可以用+号连接:
    1
    2
    3
    4
    let name = '小明';
    let age = 20;
    let message = '你好, ' + name + ', 你今年' + age + '岁了!';
    alert(message);
    ES6新增了一种模板字符串,表示方法和上面的多行字符串一样,但是它会自动替换字符串中的变量:
    1
    2
    3
    4
    let name = '小明';
    let age = 20;
    let message = `你好, ${name}, 你今年${age}岁了!`;
    alert(message);

常用的字符串操作

  • 获取字符串长度:
    1
    2
    let s = 'Hello, world!';
    s.length; // 13
  • 获取字符串某个指定位置的字符:
    1
    2
    3
    4
    5
    6
    7
    let s = 'Hello, world!';

    s[0]; // 'H'
    s[6]; // ' '
    s[7]; // 'w'
    s[12]; // '!'
    s[13]; // undefined 超出范围的索引不会报错,但一律返回undefined
  • 需要特别注意的是,字符串是不可变的,如果对字符串的某个索引赋值,不会有任何错误,但是,也没有任何效果。

JavaScript为字符串提供了一些常用方法,注意,调用这些方法本身不会改变原有字符串的内容,而是返回一个新字符串:

  • toUpperCase():把一个字符串全部变为大写
  • toLowerCase():把一个字符串全部变为小写
  • indexOf():搜索指定字符串出现的位置
    1
    2
    3
    let s = 'hello, world';
    s.indexOf('world'); // 返回7
    s.indexOf('World'); // 没有找到指定的子串,返回-1
  • substring():返回指定索引区间的子串
    1
    2
    3
    let s = 'hello, world'
    s.substring(0, 5); // 从索引0开始到5(不包括5),返回'hello'
    s.substring(7); // 从索引7开始到结束,返回'world'

布尔值

布尔值和布尔代数的表示完全一致,一个布尔值只有truefalse两种值,要么是true,要么是false,可以直接用true、false表示布尔值,也可以通过布尔运算计算出来:

1
2
3
4
true; // 这是一个true
false; // 这是一个false
2 > 1; // 这是一个true
2 >= 3; // 这是一个false

比较运算符

  1. JavaScript允许对任意数据类型做比较:
    1
    2
    false == 0; // true
    false === 0; // false
  2. JavaScript在设计时,有两种比较运算符:
  • ==:它会自动转换数据类型再比较,很多时候,会得到非常诡异的结果;
  • ===:它不会自动转换数据类型,如果数据类型不一致,返回false,如果一致,再比较。
    • 坚持使用===比较!
  1. NaN和所有的其他值都不相等,包括它自己,需通过isNaN()函数判断是否为NaN。

  2. 浮点数的相等比较:

    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
2
3
4
5
6
7
// 使用BigInt:
var bi1 = 9223372036854775807n;
var bi2 = BigInt(12345);
var bi3 = BigInt("0x7fffffffffffffff");
console.log(bi1 === bi2); // false
console.log(bi1 === bi3); // true
console.log(bi1 + bi2);

使用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
2
3
4
5
6
7
8
var person = {
name: 'Bob',
age: 20,
tags: ['js', 'web', 'mobile'],
city: 'Beijing',
hasCar: true,
zipcode: null
};

JavaScript对象的键都是字符串类型,值可以是任意数据类型。上述person对象一共定义了6个键值对,其中每个键又称为对象的属性,例如,person的name属性为’Bob’,zipcode属性为null。

要获取一个对象的属性,我们用对象变量.属性名的方式:

1
2
person.name; // 'Bob'
person.zipcode; // null

变量

变量在JavaScript中就是用一个变量名表示,变量名是大小写英文、数字、$和_的组合,且不能用数字开头。变量名也不能是JavaScript的关键字,如if、while等。

申明一个变量用var语句,比如:

1
2
3
4
5
var a; // 申明了变量a,此时a的值为undefined
var $b = 1; // 申明了变量$b,同时给$b赋值,此时$b的值为1
var s_007 = '007'; // s_007是一个字符串
var Answer = true; // Answer是一个布尔值true
var t = null; // t的值是null

在JavaScript中,使用等号=对变量进行赋值。可以把任意数据类型赋值给变量,同一个变量可以反复赋值,而且可以是不同类型的变量,但是要注意只能用var申明一次。

1
2
var a = 123; // a的值是整数123
a = 'ABC'; // a变为字符串

这种变量本身类型不固定的语言称之为动态语言,与之对应的是静态语言。静态语言在定义变量时必须指定变量类型,如果赋值的时候类型不匹配,就会报错。例如Java是静态语言,赋值语句如下:

1
2
int a = 123; // a是整数类型变量,类型用int申明
a = "ABC"; // 错误:不能把字符串赋给整型变量

要显示变量的内容,可以用console.log(x),打开Chrome的控制台就可以看到结果。

  • 使用console.log()代替alert()的好处是可以避免弹出烦人的对话框。

strict模式

  • 如果一个变量没有通过var申明就被使用,那么该变量就自动被申明为全局变量;
  • 使用var申明的变量不是全局变量,它的范围被限制在该变量被申明的函数体内,同名变量在不同的函数体内互不冲突。
  • 在strict模式下运行的JavaScript代码,强制通过var申明变量,未使用var申明变量就使用的,将导致运行错误。
    • 启用strict模式的方法是在JavaScript代码的第一行写上:
      1
      'use strict';
  • 另一种申明变量的方式是let,这也是现代JavaScript推荐的方式:
    1
    2
    3
    // 用let申明变量:
    let s = 'hello';
    console.log(s);
    后续会补充介绍var和let的区别。

正则表达式

正则表达式是一种用来匹配字符串的强有力的武器。它的设计思想是用一种描述性的语言来给字符串定义一个规则,凡是符合规则的字符串,我们就认为它“匹配”了,否则,该字符串就是不合法的。

例如:判断一个字符串是否是合法的Email

  1. 创建一个匹配Email的正则表达式;
  2. 用该正则表达式去匹配用户的输入来判断是否合法。

如何用字符来描述字符

在正则表达式中,如果直接给出字符,就是精确匹配。

  • \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
    5
    let re1 = /ABC\-001/;
    let re2 = new RegExp('ABC\\-001');

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

RegExp对象的test()方法用于测试给定的字符串是否符合条件:

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

切分字符串

用正则表达式切分字符串比用固定的字符更灵活。

  1. 正常的切分代码
    1
    'a b   c'.split(' '); // ['a', 'b', '', '', 'c']无法识别连续的空格
  2. 正则表达式
    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']
    split 方法会根据正则表达式显示的匹配模式来分割字符串。

分组

正则表达式还可以用于提取子串。用()表示的就是要提取的分组(Group)。

eg.^(\d{3})-(\d{3,8})$分别定义了两个组,可以直接从匹配的字符串中提取出区号和本地号码:

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

如果正则表达式中定义了组,就可以在RegExp对象上用exec()方法提取出子串来。

  • exec()方法在匹配成功后,会返回一个Array,第一个元素是正则表达式匹配到的整个字符串,后面的字符串表示匹配成功的子串。
    • 在匹配失败时返回null
1
2
let 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']

这个正则表达式可以直接识别合法的时间。但是有些时候,用正则表达式也无法做到完全验证,对于’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
2
3
4
5
6
7
8
9
10
11
12
let xiaoming = {
name: '小明',
age: 14,
gender: true,
height: 1.65,
grade: null,
'middle-school': '\"W3C\" Middle School',
skills: ['JavaScript', 'Java', 'Python', 'Lisp']
};

let s = JSON.stringify(xiaoming, null, ' '); //要输出得好看一些,可以加上参数,按缩进输出
console.log(s);
  • 第二个参数用于控制如何筛选对象的键值,如果我们只想输出指定的属性,可以传入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
    17
    let 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
2
3
4
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()还可以接收一个函数,用来转换解析出的属性:

1
2
3
4
5
6
7
8
let obj = JSON.parse('{"name":"小明","age":14}', function (key, value) {
if (key === 'name') {
return value + '同学';
}
return value;
});
console.log(JSON.stringify(obj)); // {name: '小明同学', age: 14}

面向对象编程

面向对象的两个基本概念:

  • 类:类是对象的类型模板,例如,定义Student类来表示学生,类本身是一种类型,Student表示学生类型,但不表示任何具体的某个学生;
  • 实例:实例是根据类创建的对象,例如,根据Student类可以创建出xiaoming、xiaohong、xiaojun等多个实例,每个实例表示一个具体的学生,他们全都属于Student类型。

在JavaScript中,这个概念需要改一改。JavaScript不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。JavaScript的原型链和Java的Class区别就在,它没有“Class”的概念,所有对象都是实例,所谓继承关系不过是把一个对象的原型指向另一个对象而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 原型对象:
let Student = {
name: 'Robot',
height: 1.2,
run: function () {
console.log(this.name + ' is running...');
}
};

function createStudent(name) {
// 基于Student原型创建一个新对象:
let s = Object.create(Student);
// 初始化新对象:
s.name = name;
return s;
}

let xiaoming = createStudent('小明');
xiaoming.run(); // 小明 is running...
xiaoming.__proto__ === Student; // true
  • 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
    3
    function foo() {
    return 0;
    }

构造函数

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

1
2
3
4
5
6
function Student(name) {
this.name = name;
this.hello = function () {
alert('Hello, ' + this.name + '!');
}
}

用关键字new来调用这个函数,并返回一个对象:

1
2
3
let xiaoming = new Student('小明');
xiaoming.name; // '小明'
xiaoming.hello(); // Hello, 小明!

注意,如果不写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
    7
    function Student(name) {
    this.name = name;
    }

    Student.prototype.hello = function () {
    alert('Hello, ' + this.name + '!');
    };
  • 我们还可以编写一个createStudent()函数,在内部封装所有的new操作。一个常用的编程模式像这样:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    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来调用,二是参数非常灵活,可以不传,也可以这么传:
    1
    2
    3
    4
    5
    let xiaoming = createStudent({
    name: '小明'
    });

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

原型继承

JavaScript的原型继承实现方式就是:

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

例如:
基于Student扩展出PrimaryStudent,可以先定义出PrimaryStudent:

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

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

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

满足要求的原型链是:

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

新的基于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
    6
    function 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
    20
    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;
    };

class继承

新的关键字class从ES6开始正式被引入到JavaScript中。class的目的就是让定义类更简单。

1
2
3
4
5
6
7
8
9
class Student {
constructor(name) {
this.name = name;
}

hello() {
alert('Hello, ' + this.name + '!');
}
}
  • class的定义包含了构造函数constructor和定义在原型对象上的函数hello()(注意没有function关键字),这样就避免了Student.prototype.hello = function () {...}这样分散的代码。

  • 通过extend实现类的继承:

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

    • PrimaryStudent已经自动获得了父类Student的hello方法,我们又在子类中定义了新的myGrade方法。

1
2
3
4
5
6
7
8
9
10
class PrimaryStudent extends Student {
constructor(name, grade) {
super(name); // 记得用super调用父类的构造方法!
this.grade = grade;
}

myGrade() {
alert('I am at grade ' + this.grade);
}
}

浏览器

不同的浏览器对JavaScript支持的差异主要是,有些API的接口不一样,比如AJAX,File接口。对于ES6标准,不同的浏览器对各个特性支持也不一样。

在编写JavaScript的时候,就要充分考虑到浏览器的差异,尽量让同一份JavaScript代码能运行在不同的浏览器中。

浏览器对象

Window

window对象不但充当全局作用域,而且表示浏览器窗口

  • window对象有innerWidthinnerHeight属性,可以获取浏览器窗口的内部宽度和高度。内部宽高是指除去菜单栏、工具栏、边框等占位元素后,用于显示网页的净宽高。
    • 对应的,还有一个outerWidthouterHeight属性,可以获取浏览器窗口的整个宽高。
      1
      console.log('window inner size: ' + window.innerWidth + ' x ' + window.innerHeight);

navigator对象表示浏览器的信息,最常用的属性包括:

  • navigator.appName:浏览器名称;
  • navigator.appVersion:浏览器版本;
  • navigator.language:浏览器设置的语言;
  • navigator.platform:操作系统类型;
  • navigator.userAgent:浏览器设定的User-Agent字符串。
    1
    2
    3
    4
    5
    console.log('appName = ' + navigator.appName);
    console.log('appVersion = ' + navigator.appVersion);
    console.log('language = ' + navigator.language);
    console.log('platform = ' + navigator.platform);
    console.log('userAgent = ' + navigator.userAgent);
    navigator的信息可以很容易地被用户修改,所以JavaScript读取的值不一定是正确的。
  • 充分利用JavaScript对不存在属性返回undefined的特性,直接用短路运算符||计算。
    1
    let width = window.innerWidth || document.body.clientWidth;

判断浏览器版本

1
getIEVersion(navigator.userAgent)

screen

screen对象表示屏幕的信息,常用的属性有:

  • screen.width:屏幕宽度,以像素为单位;
  • screen.height:屏幕高度,以像素为单位;
  • screen.colorDepth:返回颜色位数,如8、16、24。

location

location对象表示当前页面的URL信息。

  • 可以用location.href获取当前页面的URL。
  • 要获得URL各个部分的值,可以这么写:
    1
    2
    3
    4
    5
    6
    location.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
    5
    if (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
    8
    let 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'
  • 由于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
2
3
4
// when AJAX is done:
let state = 'any-data';
let newUrl = '/ajax.html#signin';
history.pushState(state, '', newUrl);

当用户点击“后退”时,浏览器并不会刷新页面,而是触发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
3
4
5
6
7
8
9
10
11
12
13
14
15
// 返回ID为'test'的节点:
let test = document.getElementById('test');

// 先定位ID为'test-table'的节点,再返回其内部所有tr节点:
let trs = document.getElementById('test-table').getElementsByTagName('tr');

// 先定位ID为'test-div'的节点,再返回其内部所有class包含red的节点:
let reds = document.getElementById('test-div').getElementsByClassName('red');

// 获取节点test下的所有直属子节点:
let cs = test.children;

// 获取节点test下第一个、最后一个子节点:
let first = test.firstElementChild;
let last = test.lastElementChild;

(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- HTML结构 -->
<div id="test-div">
<div class="c-red">
<p id="test-p">JavaScript</p>
<p>Java</p>
</div>
<div class="c-red c-green">
<p>Python</p>
<p>Ruby</p>
<p>Swift</p>
</div>
<div class="c-green">
<p>Scheme</p>
<p>Haskell</p>
</div>
</div>

请选择出指定条件的节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 选择<p>JavaScript</p>:
let js = document.getElementById('test-p');

// 选择<p>Python</p>,<p>Ruby</p>,<p>Swift</p>:
let arr = document.getElementById('test-div').getElementsByClassName('c-red c-green')[0].children;

// 选择<p>Haskell</p>:
let haskell = document.getElementById('test-div').getElementsByClassName('c-green')[1].lastElementChild;

// 测试:
if (!js || js.innerText !== 'JavaScript') {
alert('选择JavaScript失败!');
} else if (!arr || arr.length !== 3 || !arr[0] || !arr[1] || !arr[2] || arr[0].innerText !== 'Python' || arr[1].innerText !== 'Ruby' || arr[2].innerText !== 'Swift') {
console.log('选择Python,Ruby,Swift失败!');
} else if (!haskell || haskell.innerText !== 'Haskell') {
console.log('选择Haskell失败!');
} else {
console.log('测试通过!');
}

更新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攻击。

  • 第二种是修改innerTexttextContent属性,这样可以自动对字符串进行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>节点:
    // <p id="p-id">&lt;script&gt;alert("Hi")&lt;/script&gt;</p>

    两者的区别在于读取属性时,innerText不返回隐藏元素的文本,而textContent返回所有文本。

修改CSS

修改CSS也是经常需要的操作。DOM节点的style属性对应所有的CSS,可以直接获取或设置。因为CSS允许font-size这样的名称,但它并非JavaScript有效的属性名,所以需要在JavaScript中改写为驼峰式命名fontSize

1
2
3
4
5
6
// 获取<p id="p-id">...</p>
let p = document.getElementById('p-id');
// 设置CSS:
p.style.color = '#ff0000';
p.style.fontSize = '20px';
p.style.paddingTop = '2em';

本小节练习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 获取<p>javascript</p>节点:
let js = document.getElementById('test-js');

// 修改文本为JavaScript:
// TODO:
js.innerHTML= 'JavaScript';
// 修改CSS为: color: #ff0000, font-weight: bold
// TODO:
js.style.color = '#ff0000';
js.style.fontWeight= 'bold';
// 测试:
if (js && js.parentNode && js.parentNode.id === 'test-div' && js.id === 'test-js') {
if (js.innerText === 'JavaScript') {
if (js.style && js.style.fontWeight === 'bold' && (js.style.color === 'red' || js.style.color === '#ff0000' || js.style.color === '#f00' || js.style.color === 'rgb(255, 0, 0)')) {
console.log('测试通过!');
} else {
console.log('CSS样式测试失败!');
}
} else {
console.log('文本测试失败!');
}
} else {
console.log('节点测试失败!');
}

插入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">的最后一项:
      1
      2
      3
      4
      let
      js = document.getElementById('js'),
      list = document.getElementById('list');
      list.appendChild(js);
      因为我们插入的js节点已经存在于当前的文档树,因此这个节点首先会从原先的位置删除,再插入到新的位置。
    • 从零创建一个新的节点,然后插入到指定位置:
      1
      2
      3
      4
      5
      6
      let
      list = document.getElementById('list'),
      haskell = document.createElement('p');
      haskell.id = 'haskell';
      haskell.innerText = 'Haskell';
      list.appendChild(haskell);
      这样我们就动态添加了一个新的节点:
      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>
      动态创建一个节点然后添加到DOM树中,可以实现很多功能。举个例子,下面的代码动态创建了一个<style>节点,然后把它添加到<head>节点的末尾,这样就动态地给文档添加了新的CSS定义:
      1
      2
      3
      4
      let d = document.createElement('style');
      d.setAttribute('type', 'text/css');
      d.innerHTML = 'p { color: red }';
      document.getElementsByTagName('head')[0].appendChild(d);
    • 使用insertBefore将子节点插入到指定的位置:
      1
      parentElement.insertBefore(newElement, referenceElement);
      子节点会插入到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>
      可以这么写:
      1
      2
      3
      4
      5
      6
      7
      let
      list = document.getElementById('list'),
      ref = document.getElementById('python'),
      haskell = document.createElement('p');
      haskell.id = 'haskell';
      haskell.innerText = 'Haskell';
      list.insertBefore(haskell, ref);
      可见,使用insertBefore重点是要拿到一个参考子节点的引用。很多时候,需要循环一个父节点的所有子节点,可以通过迭代children属性实现:
      1
      2
      3
      4
      5
      6
      let
      i, c,
      list = document.getElementById('list');
      for (i = 0; i < list.children.length; i++) {
      c = list.children[i]; // 拿到第i个子节点
      }

练习:

对于一个已有的HTML结构:

1
2
3
4
5
6
7
8
<!-- HTML结构 -->
<ol id="test-list">
<li class="lang">Scheme</li>
<li class="lang">JavaScript</li>
<li class="lang">Python</li>
<li class="lang">Ruby</li>
<li class="lang">Haskell</li>
</ol>

按字符串顺序重新排序DOM节点:

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
// sort list:
list = document.getElementById('test-list');
sort = Array.from(list.children).sort((a, b) => a.innerText > b.innerText ? 1:-1);
for (let e of sort) list.appendChild(e);

// 测试:
(function () {
let
arr, i,
t = document.getElementById('test-list');
if (t && t.children && t.children.length === 5) {
arr = [];
for (i=0; i<t.children.length; i++) {
arr.push(t.children[i].innerText);
}
if (arr.toString() === ['Haskell', 'JavaScript', 'Python', 'Ruby', 'Scheme'].toString()) {
console.log('测试通过!');
}
else {
console.log('测试失败: ' + arr.toString());
}
}
else {
console.log('测试失败!');
}
})();

删除DOM

要删除一个节点,首先要获得该节点本身以及它的父节点,然后,调用父节点的removeChild把自己删掉:

1
2
3
4
5
6
7
// 拿到待删除节点:
let self = document.getElementById('to-be-removed');
// 拿到父节点:
let parent = self.parentElement;
// 删除:
let removed = parent.removeChild(self);
removed === self; // true
  • 注意:删除后的节点虽然不在文档树中了,但其实它还在内存中,可以随时再次被添加到别的位置。

  • 遍历一个父节点的子节点并进行删除操作时,要注意,children属性是一个只读属性,并且它在子节点变化时会实时更新。

    • 例如,对于如下HTML结构:
      1
      2
      3
      4
      <div id="parent">
      <p>First</p>
      <p>Second</p>
      </div>
      当我们用如下代码删除子节点时:
      1
      2
      3
      let 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属性时刻都在变化。

操作表单

用JavaScript操作表单和操作DOM是类似的,因为表单本身也是DOM树。

不过表单的输入框、下拉框等可以接收用户输入,所以用JavaScript来操作表单,可以获得用户输入的内容,或者对一个输入框设置新的内容。

HTML表单的输入控件主要有以下几种:

  • 文本框,对应的<input type="text">,用于输入文本;
  • 口令框,对应的<input type="password">,用于输入口令;
  • 单选框,对应的<input type="radio">,用于选择一项;
  • 复选框,对应的<input type="checkbox">,用于选择多项;
  • 下拉框,对应的<select>,用于选择一项;
  • 隐藏文本,对应的<input type="hidden">,用户不可见,但表单提交时会把隐藏文本发送到服务器

获取值

如果我们获得了一个<input>节点的引用,就可以直接调用value获得对应的用户输入值:

1
2
3
// <input type="text" id="email">
let input = document.getElementById('email');
input.value; // '用户输入的值'

这种方式可以应用于textpasswordhidden以及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

设置值

设置值和获取值类似,对于textpasswordhidden以及select,直接设置value就可以:

1
2
3
// <input type="text" id="email">
let input = document.getElementById('email');
input.value = 'test@example.com'; // 文本框的内容已更新
  • 对于单选框和复选框,设置checked为true或false即可。

HTML5控件

HTML5新增了大量标准控件,常用的包括date、datetime、datetime-local、color等,它们都使用<input>标签:

1
<input type="date" value="2021-12-02">
1
<input type="datetime-local" value="2021-12-02T20:21:12">
1
<input type="color" value="#ff0000">
  • 不支持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>
      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>
      这个做法看上去没啥问题,但用户输入了口令提交时,口令框的显示会突然从几个变成32个(因为MD5有32个字符)。
    • 要想不改变用户的输入,可以利用<input type="hidden">实现:
      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>
      注意到id为md5-password的<input>标记了name=”password”,而用户输入的id为input-password的<input>没有name属性。没有name属性的<input>的数据不会被提交。

操作文件

在HTML表单中,可以上传文件的唯一控件就是<input type="file">

  • 注意:当一个表单包含<input type="file">时,表单的enctype必须指定为multipart/form-datamethod必须指定为post,浏览器才能正确编码并以multipart/form-data格式发送表单的数据。
  • 通常,上传的文件都由后台服务器处理,JavaScript可以在提交表单时对文件扩展名做检查,以便防止用户上传无效格式的文件:
    1
    2
    3
    4
    5
    6
    let 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提供了FileFileReader两个主要对象,可以获得文件信息并读取文件。

下面的例子演示了如何读取用户选取的图片文件,并在一个<div>中预览图像:

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
let
fileInput = document.getElementById('test-image-file'),
info = document.getElementById('test-file-info'),
preview = document.getElementById('test-image-preview');
// 监听change事件:
fileInput.addEventListener('change', function () {
// 清除背景图片:
preview.style.backgroundImage = '';
// 检查文件是否选择:
if (!fileInput.value) {
info.innerHTML = '没有选择文件';
return;
}
// 获取File引用:
let file = fileInput.files[0];
// 获取File信息:
info.innerHTML = '文件: ' + file.name + '<br>' +
'大小: ' + file.size + '<br>' +
'修改: ' + file.lastModified;
if (file.type !== 'image/jpeg' && file.type !== 'image/png' && file.type !== 'image/gif') {
alert('不是有效的图片文件!');
return;
}
// 读取文件:
let reader = new FileReader();
reader.onload = function(e) {
let
data = e.target.result; // 'data:image/jpeg;base64,/9j/4AAQSk...(base64编码)...'
preview.style.backgroundImage = 'url(' + data + ')';
};
// 以DataURL的形式读取文件:
reader.readAsDataURL(file);
});
  • 上面的代码演示了如何通过HTML5的File API读取文件内容。以DataURL的形式读取到的文件是一个字符串,类似于data:image/jpeg;base64,/9j/4AAQSk...(base64编码)...,常用于设置图像。
    • 如果需要服务器端处理,把字符串base64,后面的字符发送给服务器并用Base64解码就可以得到原始文件的二进制内容。

回调

上面的代码还演示了JavaScript的一个重要的特性就是单线程执行模式。在JavaScript中,浏览器的JavaScript执行引擎在执行JavaScript代码时,总是以单线程模式执行,也就是说,任何时候,JavaScript代码都不可能同时有多于1个线程在执行。

单线程模式执行的JavaScript,如何处理多任务?

在JavaScript中,执行多任务实际上都是异步调用,比如上面的代码:

1
reader.readAsDataURL(file);

就会发起一个异步操作来读取文件内容。因为是异步操作,所以我们在JavaScript代码中就不知道什么时候操作结束,因此需要先设置一个回调函数

1
2
3
reader.onload = function(e) {
// 当文件读取完成后,自动调用此函数:
};

当文件读取完成后,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
2
3
4
5
6
7
8
9
10
11
12
async function get(url) {
let resp = await fetch(url);
let result = await resp.text();
return result;
}

// 发送异步请求:
get('./content.html').then(data => {
let textarea = document.getElementById('fetch-response-text');
textarea.value = data;
});

使用Fetch API配合async写法,代码更加简单。

Fetch API的详细用法可以参考MDN文档。

安全限制

上面代码的URL使用的是相对路径。如果你把它改为'https://www.sina.com.cn/',再运行,肯定报错。在Chrome的控制台里,还可以看到错误信息。

这是因为浏览器的同源策略导致的。默认情况下,JavaScript在发送AJAX请求时,URL的域名必须和当前页面完全一致

完全一致的意思是,域名要相同(www.example.comexample.com不同),协议要相同(httphttps不同),端口号要相同(http默认是:80端口,它和:8080就不同)。有的浏览器口子松一点,允许端口不同,大多数浏览器都会严格遵守这个限制。

那是不是用JavaScript无法请求外域(就是其他网站)的URL了呢?方法还是有的,大概有这么几种:

  • 通过Flash插件发送HTTP请求,这种方式可以绕过浏览器的安全限制,但必须安装Flash,并且跟Flash交互。不过Flash用起来麻烦,而且现在用得也越来越少了。

  • 通过在同源域名下架设一个代理服务器来转发,JavaScript负责把请求发送到代理服务器:

    1
    '/proxy?url=https://www.sina.com.cn'

代理服务器再把结果返回,这样就遵守了浏览器的同源策略。这种方式麻烦之处在于需要服务器端额外做开发。

  • 第三种方式称为JSONP,它有个限制,只能用GET请求,并且要求返回JavaScript。这种方式跨域实际上是利用了浏览器允许跨域引用JavaScript资源
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <html>
    <head>
    <script src="http://example.com/abc.js"></script>
    ...
    </head>
    <body>
    ...
    </body>
    </html>
    JSONP通常以函数调用的形式返回,例如,返回JavaScript内容如下:
    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将无法获取到响应的任何数据。

CORS

假设本域是my.com,外域是sina.com,只要响应头Access-Control-Allow-Originhttp://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上的字体文件时:
    1
    2
    3
    4
    5
    /* CSS */
    @font-face {
    font-family: 'FontAwesome';
    src: url('http://cdn.com/fonts/fontawesome.ttf') format('truetype');
    }
    如果该CDN服务商未正确设置Access-Control-Allow-Origin,那么浏览器无法加载字体资源。

对于PUTDELETE以及其他类型如application/json的POST请求,在发送AJAX请求之前,浏览器会先发送一个OPTIONS请求(称为preflighted请求)到这个URL上,询问目标服务器是否接受:

1
2
3
4
OPTIONS /path/to/resource HTTP/1.1
Host: bar.com
Origin: http://my.com
Access-Control-Request-Method: POST

服务器必须响应并明确指出允许的Method:

1
2
3
4
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://my.com
Access-Control-Allow-Methods: POST, GET, PUT, OPTIONS
Access-Control-Max-Age: 86400

浏览器确认服务器响应的Access-Control-Allow-Methods头确实包含将要发送的AJAX请求的Method,才会继续发送AJAX,否则,抛出一个错误。

由于以POSTPUT方式传送JSON格式的数据在REST中很常见,所以要跨域正确处理POST和PUT请求,服务器端必须正确响应OPTIONS请求。

深入了解CORS请移步MDN文档W3C文档

Promise

在JavaScript的世界中,所有代码都是单线程执行的。

由于这个“缺陷”,导致JavaScript的所有网络操作,浏览器事件,都必须是异步执行。异步执行可以用回调函数实现:

1
2
3
4
5
6
function callback() {
console.log('Done');
}
console.log('before setTimeout()');
setTimeout(callback, 1000); // 1秒钟后调用callback函数
console.log('after setTimeout()');

观察上述代码执行,在Chrome的控制台输出可以看到:

1
2
3
4
before setTimeout()
after setTimeout()
(等待1秒后)
Done

可见,异步操作会在将来的某个时间点触发一个函数调用

AJAX就是典型的异步操作:

1
2
3
4
5
6
7
8
9
request.onreadystatechange = function () {
if (request.readyState === 4) {
if (request.status === 200) {
return success(request.responseText);
} else {
return fail(request.status);
}
}
}

把回调函数success(request.responseText)fail(request.status)写到一个AJAX操作里很正常,但是不好看,而且不利于代码复用。

有没有更好的写法?比如写成这样:

1
2
3
let ajax = ajaxGet('http://...');
ajax.ifSuccess(success)
.ifFail(fail);

这种链式写法的好处在于,先统一执行AJAX逻辑,不关心如何处理结果,然后,根据结果是成功还是失败,在将来的某个时候调用success函数或fail函数。

古人云:“君子一诺千金”,这种“承诺将来会执行”的对象在JavaScript中称为Promise对象。

栗子:

  • 生成一个0-2之间的随机数,如果小于1,则等待一段时间后返回成功,否则返回失败:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    function 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);
    }
    这个test()函数有两个参数,这两个参数都是函数,如果执行成功,我们将调用resolve('200 OK'),如果执行失败,我们将调用reject('timeout in ' + timeOut + ' seconds.')
  • 可以看出,test()函数只关心自身的逻辑,并不关心具体的resolve和reject将如何处理结果。

有了执行函数,我们就可以用一个Promise对象来执行它,并在将来某个时刻获得成功或失败的结果:

1
2
3
4
5
6
7
let p1 = new Promise(test);
let p2 = p1.then(function (result) {
console.log('成功:' + result);
});
let p3 = p2.catch(function (reason) {
console.log('失败:' + reason);
});

变量p1是一个Promise对象,它负责执行test函数。

  • 由于test函数在内部是异步执行的,当test函数执行成功时,我们告诉Promise对象:
    1
    2
    3
    4
    // 如果成功,执行这个函数:
    p1.then(function (result) {
    console.log('成功:' + result);
    });
  • 当test函数执行失败时,我们告诉Promise对象:
    1
    2
    3
    p2.catch(function (reason) {
    console.log('失败:' + reason);
    });
    Promise对象可以串联起来,所以上述代码可以简化为:
    1
    2
    3
    4
    5
    new 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
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建一个新的Promise对象
let p = new Promise(function (resolve, reject) {
// 异步操作
setTimeout(function() {
// 操作成功,调用resolve函数
resolve("操作成功");
}, 1000);
});

// 使用then方法处理已解决状态的Promise
p.then(function(result) {
console.log(result); // 输出: 操作成功
});

reject函数

reject函数是JavaScript中的Promise对象的一部分,用于将Promise从“待定(pending)”状态变为“已拒绝(rejected)”状态,并传递一个错误原因。

  • 这个原因通常是一个Error对象或其他描述错误的值。reject函数通常在异步操作失败时调用,以表示操作的失败和错误信息。

then方法和catch方法

在JavaScript中,处理异步操作结果通常使用Promise对象的then方法和catch方法。

  • then方法用于处理Promise成功完成(fulfilled)后的结果
  • catch方法用于处理Promise失败(rejected)后的错误
1
2
3
4
5
6
7
asyncOperation('Test Input')
.then(function(result) {
console.log(result);
})
.catch(function(error) {
console.error(error);
});
  • function(result):是一个回调函数,result参数是resolve传递的值;error是reject函数传递的值。
    • 在下面示例的asyncOperation函数中,resolve可能传递字符串 ‘Operation successful: Test Input’。这个值将被作为参数传递给then方法的回调函数。

Promise使用示例

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
// 模拟一个异步操作的函数
function asyncOperation(input) {
return new Promise(function (resolve, reject) {
console.log('Starting async operation...');

// 模拟异步操作,延迟1秒
setTimeout(function() {
// 使用随机数模拟成功或失败
let success = Math.random() > 0.5;

if (success) {
resolve('Operation successful: ' + input);
} else {
reject(new Error('Operation failed: ' + input));
}
}, 1000);
});
}

// 使用then和catch方法处理Promise
asyncOperation('Test Input')
.then(function(result) {
console.log(result);
})
.catch(function(error) {
console.error(error);
});

Promise串行实现异步任务

Promise还可以做更多的事情,比如,有若干个异步任务,需要先做任务1,如果成功后再做任务2,任何任务失败则不再继续并执行错误处理函数。

要串行执行这样的异步任务,不用Promise需要写一层一层的嵌套代码。有了Promise,我们只需要简单地写:

1
job1.then(job2).then(job3).catch(handleError);

其中,job1job2job3都是Promise对象。

栗子:

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
40
41
42
//清空日志区域
let logging = document.getElementById('test-promise2-log');
while (logging.children.length > 1) {
logging.removeChild(logging.children[logging.children.length - 1]);
}
//定义日志记录函数
function log(s) {
let p = document.createElement('p');
p.innerHTML = s;
logging.appendChild(p);
}

//定义异步计算函数
// 0.5秒后返回input*input的计算结果:
function multiply(input) {
return new Promise(function (resolve, reject) {
//调用 log 函数,记录正在进行的计算操作。这是一个同步操作,立即执行
log('calculating ' + input + ' x ' + input + '...');
setTimeout(resolve, 500, input * input);
});
}

// 0.5秒后返回input+input的计算结果:
function add(input) {
return new Promise(function (resolve, reject) {
log('calculating ' + input + ' + ' + input + '...');
setTimeout(resolve, 500, input + input);
});
}

let p = new Promise(function (resolve, reject) {
log('start new Promise...');
resolve(123);
});

p.then(multiply)
.then(add)
.then(multiply)
.then(add)
.then(function (result) {
log('Got value: ' + result);
});

setTimeout可以看成一个模拟网络等异步执行的函数。

  • setTimeout 第一个参数是回调函数,这里是 resolve。
  • setTimeout 第二个参数是延迟时间,这里是500毫秒。
  • setTimeout 第三个参数是传递给回调函数的值,这里是 input * input。

Promise并行实现异步任务

除了串行执行若干异步任务外,Promise还可以并行执行异步任务。

试想一个页面聊天系统,我们需要从两个不同的URL分别获得用户的个人信息和好友列表,这两个任务是可以并行执行的,用Promise.all()实现如下:

1
2
3
4
5
6
7
8
9
10
let p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
let p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
// 同时执行p1和p2,并在它们都完成后执行then:
Promise.all([p1, p2]).then(function (results) {
console.log(results); // 获得一个Array: ['P1', 'P2']
});

有些时候,多个异步任务是为了容错。比如,同时向两个URL读取用户的个人信息,只需要获得先返回的结果即可。这种情况下,用Promise.race()实现:

1
2
3
4
5
6
7
8
9
let p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
let p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
Promise.race([p1, p2]).then(function (result) {
console.log(result); // 'P1'
});

由于p1执行较快,Promise的then()将获得结果’P1’。p2仍在继续执行,但执行结果将被丢弃。

如果我们组合使用Promise,就可以把很多异步任务以并行和串行的方式组合起来执行。

async函数

avaScript异步操作需要通过Promise实现,一个Promise对象在操作网络时是异步的,等到返回后再调用回调函数,执行正确就调用then(),执行错误就调用catch(),虽然异步实现了,不会让用户感觉到页面“卡住”了,但是一堆then()、catch()写起来麻烦看起来也乱。

有没有更简单的写法?

可以用关键字async配合await调用Promise,实现异步操作,但代码却和同步写法类似:

1
2
3
4
5
async function get(url) {
let resp = await fetch(url);
let result = await resp.json();
return result;
}
  • 使用async function可以定义一个异步函数,异步函数和Promise可以看作是等价的,在async function内部,用await调用另一个异步函数,写起来和同步代码没啥区别,但执行起来是异步的。
1
let resp = await fetch(url);

自动实现了异步调用,它和下面的Promise代码等价:

1
2
3
4
let promise = fetch(url);
promise.then((resp) => {
// 拿到resp
});
  • 如果我们要实现catch()怎么办?用Promise的写法如下:
    1
    2
    3
    4
    5
    6
    let promise = fetch(url);
    promise.then((resp) => {
    // 拿到resp
    }).catch(e => {
    // 出错了
    });
    用await调用时,直接用传统的try { ... } catch
    1
    2
    3
    4
    5
    6
    7
    8
    9
    async function get(url) {
    try {
    let resp = await fetch(url);
    let result = await resp.json();
    return result;
    } catch (e) {
    // 出错了
    }
    }
    用async定义异步函数,用await调用异步函数,写起来和同步代码差不多,但可读性大大提高。

同步function调用async function

await调用必须在async function中,不能在传统的同步代码中调用。那么问题来了,一个同步function怎么调用async function呢?

首先,普通function直接用await调用异步函数将报错:

1
2
3
4
5
6
7
8
9
10
11
async function get(url) {
let resp = await fetch(url);
return resp.json();
}

function doGet() {
let data = await get('/api/categories');
console.log(data);
}

doGet();
  • 如果把await去掉,调用实际上发生了,但我们拿不到结果,因为我们拿到的并不是异步结果,而是一个Promise对象:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    async 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()就可以拿到结果或错误了:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    async 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();
    因此,定义异步任务时,使用async function比Promise简单,调用异步任务时,使用await比Promise简单,捕获错误时,按传统的try…catch写法,也比Promise简单。只要浏览器支持,完全可以用async简洁地实现异步操作。

Canvas

Canvas是HTML5新增的组件,它就像一块幕布,可以用JavaScript在上面绘制各种图表、动画等。

一个Canvas定义了一个指定尺寸的矩形框,在这个范围内我们可以随意绘制:

1
<canvas id="test-canvas" width="300" height="200"></canvas>

测试浏览器是否支持Canvas

在使用Canvas前,用canvas.getContext来测试浏览器是否支持Canvas:

1
2
3
4
5
6
let canvas = document.getElementById('test-canvas');
if (canvas.getContext) {
console.log('你的浏览器支持Canvas!');
} else {
console.log('你的浏览器不支持Canvas!');
}

使用Canvas绘制2D图像

getContext('2d')方法让我们拿到一个CanvasRenderingContext2D对象,所有的绘图操作都需要通过这个对象完成。

1
let ctx = canvas.getContext('2d');
  • 如果需要绘制3D怎么办?HTML5还有一个WebGL规范,允许在Canvas中绘制3D图形:
    1
    gl = canvas.getContext("webgl");
    本节我们只专注于绘制2D图形。

绘制形状

我们可以在Canvas上绘制各种形状。在绘制前,我们需要先了解一下Canvas的坐标系统:

Canvas的坐标系统

Canvas的坐标以左上角为原点,水平向右为X轴,垂直向下为Y轴,以像素为单位,所以每个点都是非负整数。

CanvasRenderingContext2D对象有若干方法来绘制图形:
举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let
canvas = document.getElementById('test-shape-canvas'),
ctx = canvas.getContext('2d');

ctx.clearRect(0, 0, 200, 200); // 擦除(0,0)位置大小为200x200的矩形,擦除的意思是把该区域变为透明
ctx.fillStyle = '#dddddd'; // 设置颜色
ctx.fillRect(10, 10, 130, 130); // 把(10,10)位置大小为130x130的矩形涂色
// 利用Path绘制复杂路径:
let path=new Path2D();
path.arc(75, 75, 50, 0, Math.PI*2, true);
path.moveTo(110,75);
path.arc(75, 75, 35, 0, Math.PI, false);
path.moveTo(65, 65);
path.arc(60, 65, 5, 0, Math.PI*2, true);
path.moveTo(95, 65);
path.arc(90, 65, 5, 0, Math.PI*2, true);
ctx.strokeStyle = '#0000ff';
ctx.stroke(path);

绘制文本

绘制文本就是在指定的位置输出文本,可以设置文本的字体、样式、阴影等,与CSS完全一致:

1
2
3
4
5
6
7
8
9
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, 300, 100);
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
ctx.shadowBlur = 2;
ctx.shadowColor = '#ccc';
ctx.font = '28px Arial';
ctx.fillStyle = '#999';
ctx.fillText('带阴影的文字', 20, 40);

Canvas除了能绘制基本的形状和文本,还可以实现动画、缩放、各种滤镜和像素转换等高级操作。如果要实现非常复杂的操作,考虑以下优化方案:

通过创建一个不可见的Canvas来绘图,然后将最终绘制结果复制到页面的可见Canvas中;

  • 尽量使用整数坐标而不是浮点数;
  • 可以创建多个重叠的Canvas绘制不同的层,而不是在一个Canvas中绘制非常复杂的图;
  • 背景图片如果不变可以直接用<img>标签并放到最底层。

练习

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
let data = [
{ high: 35, low: 22 },
{ high: 37, low: 24 },
{ high: 37, low: 25 },
{ high: 34, low: 24 },
{ high: 33, low: 23 }
];

let canvas = document.getElementById('weather-canvas');
let ctx = canvas.getContext('2d');

// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);

// 定义绘制参数
let margin = 40;
let width = canvas.width - margin * 2;
let height = canvas.height - margin * 2;
let maxTemp = Math.max(...data.map(d => d.high));
let minTemp = Math.min(...data.map(d => d.low));

// 绘制高温折线图
ctx.beginPath();
ctx.moveTo(margin, height - (data[0].high - minTemp) / (maxTemp - minTemp) * height + margin);
for (let i = 1; i < data.length; i++) {
ctx.lineTo(margin + i * (width / (data.length - 1)), height - (data[i].high - minTemp) / (maxTemp - minTemp) * height + margin);
}
ctx.strokeStyle = 'red';
ctx.lineWidth = 2;
ctx.stroke();

// 绘制低温折线图
ctx.beginPath();
ctx.moveTo(margin, height - (data[0].low - minTemp) / (maxTemp - minTemp) * height + margin);
for (let i = 1; i < data.length; i++) {
ctx.lineTo(margin + i * (width / (data.length - 1)), height - (data[i].low - minTemp) / (maxTemp - minTemp) * height + margin);
}
ctx.strokeStyle = 'blue';
ctx.lineWidth = 2;
ctx.stroke();

// 添加高温点
ctx.fillStyle = 'red';
for (let i = 0; i < data.length; i++) {
ctx.beginPath();
ctx.arc(margin + i * (width / (data.length - 1)), height - (data[i].high - minTemp) / (maxTemp - minTemp) * height + margin, 5, 0, Math.PI * 2);
ctx.fill();
}

// 添加低温点
ctx.fillStyle = 'blue';
for (let i = 0; i < data.length; i++) {
ctx.beginPath();
ctx.arc(margin + i * (width / (data.length - 1)), height - (data[i].low - minTemp) / (maxTemp - minTemp) * height + margin, 5, 0, Math.PI * 2);
ctx.fill();
}

// 添加下载链接
let download = document.getElementById('weather-download');
download.href = canvas.toDataURL();
  • 函数用法积累:
    1
    2
    let 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

未完待续……


JavaScript学习指南
http://zoechen04616.github.io/2024/08/02/JavaScript学习指南/
作者
Yunru Chen
发布于
2024年8月2日
许可协议