前端知识体系(4) —— Javascript 基础(1)

257 阅读20分钟

前言:本前端知识体系供个人复习与巩固用,不一定适合其他人。会逐步的完善该系列文章。

JSDoc 注释规范

什么是 JSDoc

JSDoc 是一个根据 JavaScript 文件中注释信息,生成 JavaScript 应用程序或模块的API文档的工具。你可以使用 JSDoc 标记如:命名空间方法方法参数等。从而使开发者能够轻易地阅读代码,掌握代码定义的类和其属性和方法,从而降低维护成本,和提高开发效率。

JSDoc 注释规则

JSDoc注释一般应该放置在方法或函数声明之前,它必须以/ **开始,以便由JSDoc解析器识别。其他任何以/*/***或者超过3个星号的注释,都将被JSDoc解析器忽略。如下所示:

/**
* 一段简单的 JSDoc 注释。
*/

JSDoc 的注释效果

假如有一段这样的代码,没有任何注释,看起来是不是有一定的成本。

function Book(title, author) {
    this.title=title;
    this.author=author;
}
Book.prototype={
    getTitle:function(){
        return this.title;
    },
    setPageNum:function(pageNum){
        this.pageNum=pageNum;
    }
};

如果使用了 JSDoc 注释该代码后,代码的可阅读性就大大的提高了。

/**
 * Book类,代表一个书本.
 * @constructor
 * @param {string} title - 书本的标题.
 * @param {string} author - 书本的作者.
 */
function Book(title, author) {
    this.title=title;
    this.author=author;
}
Book.prototype={
    /**
     * 获取书本的标题
     * @returns {string|*} 返回当前的书本名称
     */
    getTitle:function(){
        return this.title;
    },
    /**
     * 设置书本的页数
     * @param pageNum {number} 页数
     */
    setPageNum:function(pageNum){
        this.pageNum=pageNum;
    }
};

@constructor 构造函数声明注释

@constructor 明确一个函数是某个类的构造函数。

@param 参数注释

通常会使用 @param 来表示函数、类的方法的参数,@param 是JSDoc中最常用的注释标签。参数标签可表示一个参数的参数名参数类型参数描述的注释。如下所示:

/**
 * @param {String} wording 需要说的句子
 */
function say(wording) {
  console.log(wording);
}

@return 返回值注释

@return 表示一个函数的返回值,如果函数没有显示指定返回值可不写。如下所示:

/*
 * @return {Number} 返回值描述
 */

@example 示例注释

@example 通常用于表示示例代码,通常示例的代码会另起一行编写,如下所示:

/*
 * @example
 * multiply(3, 2); 
 */

其他常用注释

  • @overview 对当前代码文件的描述。
  • @copyright 代码的版权信息。
  • @author <name> [<emailAddress>] 代码的作者信息。
  • @version 当前代码的版本。

变量名的命名规则

  • 变量名的第一个字符必须是字母、下划线(_)或美元符号($),其他字符可以是字母、下划线、美元符号或者数字。
  • 变量名不能使用编程语言的保留字和关键字。

关键字和保留字

在 javaScript 中,保留字和关键字不能够用作标识符,也就是不能用作变量或者函数的名称。

关键字

ECMA-262 描述了一组具有特定用途的关键字,这些关键字可用于表示控制语句的开始或结束,或者用于执行特定操作等。
根据规定,关键字是语言保留的,不能用作标识符即用作变量名或函数名。

下面是 ECMAScript 关键字的完整列表:

  • break
  • case
  • catch
  • continue
  • default
  • delete
  • do
  • else
  • finally
  • for
  • function
  • if
  • in
  • instanceof
  • new
  • return
  • switch
  • this
  • throw
  • try
  • typeof
  • var
  • void
  • while
  • with

注意:如果把关键字用作变量名或函数名,可能得到诸如 "Identifier Expected"(应该有标识符、期望标识符)这样的错误消息。
在 javaScript 中,一些标识符是保留关键字,不能用作变量名或函数名。

保留字

ECMA-262 还描述了另外一组保留字,有些保留字可能还没有任何特定的用途,但它们有可能在将来用作关键字。

以下是ECMAScript规定的保留字:

  • boolean
  • abstract
  • byte
  • char
  • class
  • const
  • debugger
  • double
  • export
  • enum
  • extends
  • final
  • float
  • goto
  • implements
  • import
  • let
  • int
  • interface
  • long
  • native
  • package
  • private
  • protected
  • public
  • short
  • static
  • super
  • synchronized
  • throws
  • transient
  • volatile

标红的关键字是 ECMAScript5 中新添加的。

更多阅读

深入了解 Number 类型

Number 类型作为 JS 的基本数据类型之一,被应用在程序中的各种场景,其重要性就如数字对于我们日常生活。

下面一起深入了解下。

定义方式

一般来说可以直接使用数值字面量格式来定义一个数字,如下:

var num1 = 15;
var num2 = 7;

console.log(typeof num1); // number
console.log(typeof num2); // number

数值类型

定义的数值可分为两种类型,分别为整数和浮点数。

整数

整数,可以通过十进制,八进制,十六进制的字面值来表示。(默认为十进值)

// 十进制
var intNum1 = 55; // 正数
var intNum2 = 0; // 0
var intNum3 = -3; // 负数

// 八进制
// 第一位必须是0,其余位的取值范围为0-7
// 无效的八进制会直接忽略前面的0,解析为十进制
var octalNum1 = 070; // 八进制的56(7*8 + 0)
var octalNum2 = 079; // 无效的八进制数,9超过了8进制数的范围,解析为79
var octalNum3 = 08; // 无效的八进制数,直接解析伪8

// 十六进制
// 前两位必须是0x,其余位的取值范围为 0~9 或 A~F
var hexNum1 = 0xA; // 十六进制的10
var hexNum2 = 0x1f; // 十六进制的31(1*16 + 15)

在进行算数计算时,所有以八进制和十六进制表示的数值最终都将被转换成十进制的数值。

// 对前面定义的八进制和十六进制数值进行运算
console.log(octalNum1 + hexNum1); // 66

浮点数

浮点数其实就是通常所说的小数,所以一定有个小数点。简单示例如下:

var floatNum1 = 5.2;
var floatNum2 = 3.14;

浮点数所占据的内存空间是整数的两倍。如果小数点后只有零或没有数字,为了节省内存空间,则该小数会被转化为整数,如下:

var floatNum3 = 5.0; // 5
var floatNum4 = 2.; // 2

进行算术运算时,浮点数不如整数精准,所以一般不要使用浮点数进行计算,如下:

var floatNum4 = 0.1; 
var floatNum5 = 0.2; 

// 0.1 + 0.2 不等于 0.3
console.log(floatNum4 + floatNum5); // 0.30000000000000004

对极大极小的浮点数一般会采用e表示法,如下:

 var floatNum6 = 3.2e7// 3.2×10(7次幂)
 var floatNum7 = 3.2e-7// 3.2×10(-7次幂)

NaN

NaN 是 not a number 的简写,即非数字。它是一个特殊的值,这个数值用于表示一个本来要返回数值的操作数,结果未返回数值的情况。

NaN 有两个不同寻常的特点:

  • 任何涉及 NaN 的操作都会返回 NaN
  • NaN 值与任何值都不相等,包括本身。
console.log(NaN / 10); // NaN
console.log(NaN == NaN); // false

针对这两个特点,JS 提供了一个 isNaN()函数。该函数接受一个参数(可以是任何类型),而函数会帮助确定这个参数是否“不是数值”。

注:传递的参数会涉及数值转换的问题,例如“10”这个字符串就可以转换为 10,但是“blue”这个字符串则无法转换为数字,所以 isNaN('blue') == true

console.log(isNaN(NaN)); // true
console.log(isNaN(10)); // false
console.log(isNaN("10")); // false,可以被转成数值 10
console.log(isNaN("blue")); // true
console.log(isNaN(true)); // false,可以被转成数值 1

数值转换

有三个函数可以把非数值转换为数值:Number()parseInt()parseFloat()。第一个可以用于任何数据类型,后两个则专门用于把字符串转化为数值。

简单示例如下:

// Number()
// 转换规则比较复杂,可详细参考下面的资料
var numN1 = Number("Hello world!");  // NaN
var numN2 = Number(" ");  // 0 空字符串转为0
var numN3 = Numberl("000011");  // 11
var numN4 = Number(true);  // 1

// parseInt
// 忽略小数点
// 字符串会被转成数值
var numI1 = parseInt(22.5);   // 22
var numI2 = parseInt ("1234blue") ;  // 1234
var numI3 = parseInt (" ") ;   // NaN
var numI4 = parseInt("70");  //70(十进制数)
var numI5 = parseInt ("070") ;  // 56(八进制数)
var numI6 = parseInt ("0xA") ;  // 10(十六进制数)

// parseFloat
// 字符串会被转成数值
// 如果有多个小数点,则只去第一个,其余全部舍弃
var numF1 = parseFloat ("1234blue") ;  // 1234(整数)
var numF2 = parseFloat("0xA");   // 0
var numF3 = parseFloat("22.5");  // 22.5
var numF4 = parseFloat("22.34.5");  // 22.34
var numF5 = parseFloat("0908.5");   // 908.5
var numF6 = parseFloat("3.125e7");   // 31250000

详细介绍可参考:

数值范围

由于内存的限制,JS 并不能保存所有的数值。那么其能表示的最大最小值到底是多少呢?我们可以使用 Number 对象的 MIN_VALUE 和 MAX_VALUE 属性表示(很少很少用到,大概知道就可以,真要用的时候可以再查阅):

  • Number.MIN_VALUE 为能表示的最小正数即最接近 0 的正数 (实际上不会变成 0),它的近似值为 5 x 10-324。
  • Number.MAX_VALUE 为能表示的最大正数,它的近似值为 1.7976931348623157 x 10308

如果一个数值超过了最大能表示数值,则自动变成 Infinity 值(无穷数),可以使用 Number 对象的 isFinite() 来判断一个数是否是有限数,如果非有限数则为无穷数。

console.log(Number.isFinite(56)); // true
console.log(Number.isFinite(Number.MAX_VALUE + Number.MAX_VALUE)); // false

更多 Number 对象的属性和方法可参考:Number 对象 | MDN

数学函数

JS 中内置了一个 Math 对象,它具有数学常数和函数的属性和方法。

先来几个简单的例子:

// 函数返回一个数字四舍五入后最接近的整数值。
Math.round(3.4); // 3

// 函数返回一个随机浮点数, 范围在[0,1)
Math.random(); // 随机浮点数,每次都不一样

// 函数返回一个数的平方根
Math.sqrt(9); // 3

// 函数返回给定的一组数字中的最大值
Math.max(10, 20, 13, 18);   //  20

//sin 方法返回一个 -1 到 1 之间的数值,表示给定角度(单位:弧度)的正弦值。
// Math.PI 表示圆周率,一个圆的周长和直径之比,约等于 3.14159.
Math.sin(Math.PI / 2); // 1

更多 Math 对象可参考:Math 对象 | MDN

数组的常用方法

数组作为一种重要的数据类型,除了前面已经说到的 pop、push、shift、unshift 几个方法外,还有很多实用的方法也是必备技能。

假设有一队人,如下图:

paidui.jpg

要对其进行一些排序或筛选的操作(比喻按高矮排序,筛选女性等),都可以通过数组来进行操作。

注:这里更侧重讲解如何使用,至于详细方法请参考:数组 | MDN

抽出一些人

首先我们用数组定义该数据(为了简单起见,我们数据就不搞那么多):

var aPerson = ['person1', 'person2', 'person3', 'person4', 'person5', 'person6']

slice

现在假设要抽取三个人,可以使用slice()方法来选取三个人,如下:

var aP3 = aPerson.slice(1, 4);
console.log(aPerson); // ['person1', 'person2', 'person3', 'person4', 'person5', 'person6']
console.log(aP3); // ["person2", "person3", "person4"]

该方法返回一个从开始到结束(不包括结束)选择的数组的一部分浅拷贝到一个新数组对象。原数组不会改变。

详细语法请参考:slice

splice

同样还可以使用splice()方法来选取,如下:

var aPerson = ['person1', 'person2', 'person3', 'person4', 'person5', 'person6']
var aP3 = aPerson.splice(1, 3);
console.log(aPerson); // ["person1", "person5", "person6"]
console.log(aP3); // ["person2", "person3", "person4"]

该方法通过删除现有元素或添加新元素来更改数组的内容。原数组会改变。

对于 slice 来说,splice 的功能会更强大点,其区别主要在于:

  • slice 不改变原数组,而 splice 则会改变
  • slice 的第二个参数为截至的索引值,而 splice 则表示要截取的个数
  • splice 还能用于增加元素,slice 则不可以

详细语法请参考:splice

concat

除了从队伍里抽出一些人出来,还可以把另外一个队伍和这个队伍合并成一个新队伍,如下:

var aPerson1 = ['person1', 'person2', 'person3', 'person4', 'person5', 'person6']
var aPerson2 = ['person7', 'person8', 'person9'];

var aPerson3 = aPerson1.concat(aPerson2);
console.log(aPerson3); // ["person1", "person2", "person3", "person4", "person5", "person6", "person7", "person8", "person9"]

concat() 方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。

详细语法请参考:concat

高矮排序

现在以高矮的形式定义一组数据,如下:

var aHeight = ['170', '165', '178', '183', '168', '175', '173'];

reverse

可以直接使用reverse()方法来实现倒序,如下:

aHeight.reverse();
console.log(aHeight); // ["173", "175", "168", "183", "178", "165", "170"]

该方法非常简单,没有任何参数,就是把数组的出现顺序调换下,第一个元素会成为最后一个,最后一个会成为第一个。一般也很少用到。

sort

比起 reverse() 来说,sort() 方法使用的地方就多了。先来个从矮到高的排序,如下:

aHeight.sort();
console.log(aHeight); // ["165", "168", "170", "173", "175", "178", "183"]

sort() 方法默认的排序是升序,如上代码可见。但是也可以传入一个函数,指定其排序方式,如现在让其以降序方式排列:

aHeight.sort(function(a, b){
    return b - a;
});
console.log(aHeight); // ["183", "178", "175", "173", "170", "168", "165"]

详细语法请参考:sort

随机排序

除了正常的升序降序之外,其实还经常使用到随机排序,如抢红包,棋牌游戏中的洗牌都是随机排序的应用。

在使用随机排序的时候,得使用到一个随机函数 Math.random()
该函数返回一个浮点数, 其数字在范围[0,1)。

这样就可以使用该随机生成浮点数与0.5大小进行比较,那样结果可能大于或小于0,最后就得到了的随机排序。

// 第一次运行
aHeight.sort(function(){
    return 0.5 - Math.random();
});
console.log(aHeight); // ["183", "168", "175", "173", "170", "165", "178"]

// 第二次运行
aHeight.sort(function(){
    return 0.5 - Math.random();
});
console.log(aHeight); // ["170", "183", "175", "168", "173", "165", "178"]

因为是随机的,所以每次运行都会不一样,可以多运行几次试试。

条件筛选测试

现在以肤色和年龄的的形式定义两组数据,如下(yellow 表示黄种人,white 表示白人,black 表示黑人):

var aColor = ['yellow', 'black', 'white', 'white', 'yellow', 'yellow'];
var aAge = [19, 30, 25, 37, 18, 35];

测试是否符合条件

every

every() 方法用于测试数组的所有数据是否都通过了指定函数的测试,如果通过返回 true,否则 false

比喻判断是否所有人的年龄都大于20岁,如下:

var ageTest = aAge.every(function(item, index){
    return item > 20;
})

console.log(ageTest); // false

every 需要数组中的每个数据都满足该条件则返回 true,否则就是 false

详细语法请参考:every

some

对应 every() 方法,还有一个 some() 方法,表示数组中只要有任何一个数据满足条件则返回 ture,如果一个数据都不满足则返回 false

比喻判断是否有人的年龄都大于32岁,如下:

var ageTest2 = aAge.some(function(item, index){
    return item > 32;
})

console.log(ageTest2); // true

详细语法请参考:some

includes

includes() 方法用来判断当前数组是否包含某指定的值,如果是,则返回 true,否则返回 false。

比喻判断是否有35岁的人,如下:

var ageTest3 = aAge.includes(35);
var ageTest4 = aAge.includes(28);

console.log(ageTest3); // true
console.log(ageTest4); // false

条件筛选

filter

比喻要选取所有黄皮肤的人,如下:

var aYellow = aColor.filter(function(item, index) {
    return item === 'yellow';
})

console.log(aYellow); // ["yellow", "yellow", "yellow"]

该方法返回所有满足条件数据组成的数组。

详细语法请参考:filter

让每个人都干点啥

forEach

forEach() 方法对数组的每个元素执行一次提供的函数,该方法没有返回值。

比喻过节的时候给每个人去老板那边领个红包,如下:

var aPerson = ['person1', 'person2', 'person3', 'person4', 'person5', 'person6']

aPerson.forEach(function(item, index) {
    console.log(item + '领取了 200 元红包')
})

详细语法请参考:forEach

map

map() 方法创建一个新数组,其结果是该数组中的每个元素调用一个提供的函数。

比喻每个人的工资都增加 5000元,如下:

// 先构造一份工资数据
var aSalary = [8000, 7000, 1500, 9000, 22000];

var aNewSalary = aSalary.map(function(item, index) {
    return item + 5000;
})

console.log(aNewSalary); // [13000, 12000, 6500, 14000, 27000]

详细语法请参考:map

其他

除了上面说的那些方法之外,还有一些常用方法,如 indexOfjoin 等等,这里就不再一一说明了,具体可参考:数组 | MDN

函数声明与函数表达式的区别

前面已经说了两种定义函数的方式:函数声明与函数表达式。那么这两种方式有区别吗,还是一样的呢?下面来进一步探讨探讨。

下面定义了两个函数分别为 hello 和 hi,前者采用函数声明,后者采用函数表达式,然后再调用,如下:

function hello () {
    console.log('Hello the world');
}

var hi = function () {
    console.log('Hi, IMWeb');
}

hello(); // 'Hello the world'
hi(); // 'Hi, IMWeb'

上面的调用,都能得到正确的运行,并没有什么区别。但是如果把顺序掉下,先调用函数后定义函数,那么情况就会有点不一样了。如下:

hello(); // 'Hello the world'
hi(); // Uncaught TypeError: hi is not a function

function hello () {
    console.log('Hello the world');
}

var hi = function () {
    console.log('Hi, IMWeb');
}

从上可以看到,hello 函数可以照常运行,但是 hi 函数就会报错了。根据报错“Uncaught TypeError: hi is not a function”,知道 hi 不是 function 了,那又是什么呢?继续使用 typeof 查看下:

console.log(typeof hello); // function
console.log(typeof hi); // undefined

function hello () {
    console.log('Hello the world');
}

var hi = function () {
    console.log('Hi, IMWeb');
}
function hello () {
    console.log('Hello the world');
}
var hi;

console.log(typeof hello); // function
console.log(typeof hi); // undefined

hi = function () {
    console.log('Hi, IMWeb');
}

通过 typeof 可以看到 hi 现在是个 undefined 了,这是为什么呢?

这是因为 JavaScript 解释器中存在一种变量声明被提升(hoisting)的机制,也就是说变量(函数)的声明会被提升到当前作用域的最前面,即使写代码的时候是写在最后面,也还是会被提升至最前面。

这样上面的例子在执行的时候就成了这样的:

function.png

这样是不是一下就恍然大悟了。所以在实际开发的时候,一定要注意变量(函数)的声明会被提升到当前作用域的最前面

运算符全方位了解

有两个基础的运算符,分别为:三元运算符和位运算符。

这里来说说位运算符。

位运算符

位操作符(Bitwise operators) 将其操作数(operands)当作32位的比特序列(由0和1组成),而不是十进制、十六进制或八进制数值。例如,十进制数9,用二进制表示则为1001。按位操作符操作数字的二进制形式,但是返回值依然是标准的 JavaScript 数值。

注:位运算符在一般的项目中很少应用,可以先大概了解了。

按位与

语法为:a & b

规则为:对于每一个比特位,只有两个操作数相应的比特位都是1时,结果才为1,否则为0。

var num = 9 & 10;
console.log(num); // 8

可以看到上面的操作得到的答案为 8,这是为什么呢?首先我们把对应的数字转成32位比特序列如下(png.pngpng (1).png):

  • 9 的 32 位比特序列为:00000000000000000000000000001001
  • 10的 32 位比特序列为:00000000000000000000000000001010

然后对9和10的比特位按规则进行比对(这里忽略前面相同的0):

num1000
91001
101010

按位或

语法为:a | b

规则为:对于每一个比特位,当两个操作数相应的比特位至少有一个1时,结果为1,否则为0。

var num = 9 | 10;
console.log(num); // 11

按该规则进行比对(这里忽略前面相同的0):

num1011
91001
101010

按位异或

语法为:a ^ b

规则为:对于每一个比特位,当两个操作数相应的比特位有且只有一个1时,结果为1,否则为0。

var num = 9 ^ 10;
console.log(num); // 3

按该规则进行比对(这里忽略前面相同的0):

num0011
91001
101010

按位非

语法为:~a

规则为:对每一个比特位执行非(NOT)操作,即 0 转成 1,1 转成 0

var num = ~9;
console.log(num); // -10

对任一数值 x 进行按位非操作的结果为 -(x + 1)。例如,~5 结果为 -6。

除了这四个之外,还有按位移动操作符,更多详细请参考:位运算符 | MDN

运算符的优先级

就如算术的加减乘除有优先级一样,运算符也有优先级。

实际编程中,不可能每次只用一个运算符,那如果多个运算符一起使用的时候,就有了优先级了。

5 + 3 * 2 // 11
(5 + 3) * 2 // 16

var a = b = 5; // 从右到左依次赋值,先 b = 5,然后 a = b 也就等于5了


// 假如有两道门,每个门一把锁,而你忘了带钥匙
var lock1 = 0; // 未开
var pomen1 = 1; // 强行破门成功
var lock2 = 0; // 未开
var pomen2 = 0; // 强行破门失败


// 第一道门
// 门开了
if( lock1 || pomen1) {
    console.log('门开了。');
} else {
    console.log('请打电话叫开锁的。');
}

// 第二道门
// 请打电话叫开锁的。
if(lock2 || pomen2) {
    console.log('门开了。');
} else {
    console.log('请打电话叫开锁的。');
}

// 组合
// 先执行 lock1 || pomen1,再执行 lock2 || pomen2,然后把两者执行的结果使用 && 
// 请打电话叫开锁的
if((lock1 || pomen1) && (lock2 || pomen2)) {
    console.log('门开了。');
} else {
    console.log('请打电话叫开锁的。');
}

一般来说,如果要明确指定哪个先执行,最好使用()将其包裹,提高其优先级。

除此之外,还有更多各种规则的优先级,详细介绍可参考:运算符优先级

运算符的优先级

运算符的优先级决定了表达式中运算执行的先后顺序,优先级高的运算符最先被执行。例如,乘法的执行先于加法。下面的表将所有 javaScript 运算符按照优先级的不同从高到低排列。

优先级运算类型关联性运算符
20圆括号n/a( … )
19成员访问从左到右… . …
19需计算的成员访问从左到右… [ … ]
19new (带参数列表)n/a… . …
18new (无参数列表)从右到左new …
17后置递增(运算符在后)n/a… ++
17后置递减(运算符在后)n/a… --
16逻辑非从右到左! …
16按位非从右到左~ …
16一元加法从右到左+ …
16一元减法从右到左- …
16前置递增从右到左++ …
16前置递减从右到左-- …
16typeof从右到左typeof …
16void从右到左void …
16delete从右到左delete …
15从右到左… ** …
14乘法从左到由… * …
14除法从左到右… / …
14取模从左到右… % …
13加法从左到右… + …
13减法从左到右… - …
12按位左移从左到右… << …
12按位右移从左到右… >> …
12无符号右移从左到右… >>> …
11小于从左到右… < …
11小于等于从左到右… <= …
11大于从左到右… > …
11大于等于从左到右… >= …
11in从左到右… in …
11instanceof从左到右… instanceof …
10等号从左到右… == …
10非等号从左到右… != …
10全等号从左到右… === …
10非全等号从左到右… !== …
9按位与从左到右… & …
8按位异或从左到右… ^ …
7按位或从左到右
6逻辑与从左到右… && …
5逻辑或从左到右
4条件运算符从右到左… ? … : …
3赋值从右到左… = …
3复合赋值运算符+= …
3复合赋值运算符-= …
3复合赋值运算符*= …
3复合赋值运算符/= …
3复合赋值运算符%= …
3复合赋值运算符<<= …
3复合赋值运算符>>= …
3复合赋值运算符>>>= …
3复合赋值运算符&= …
3复合赋值运算符^= …
3复合赋值运算符= …
2yield从右到左yield …
2yield*从右到左yield* …
1展开运算符n/a... …
0逗号从左到右… , …

代码规范

规范的意义

先来看几个 if语句的写法:

// 写法一
if (true && false) {
  console.log(true);
} else {
  console.log(false);
}

// 写法二
if ( true && false ) {
  console.log( true );
} else {
  console.log( false );
}

// 写法三
if (true && false) console.log(true);
else console.log(false);

发现区别没?

从语法上来说,这三种写法都没有问题,最大的区别是空格,然后是 {},不管是空格还是 {},都只是格式的一种。

没错,代码规范,大多数时候指的就是格式规范,语法上没有对错,它们的目的是让整个团队的或者某个项目的代码统一格式,方便阅读,容易维护,因为不同成员的代码都像是出于同一人之手。

规范是大家共同的约定,务必遵守!

规范的嘴仗

  • 缩进到底是用四个空格还是两个空格?
  • 分号到底是加还是不加?

在程序员嘴仗的历史当中,这两个问题只是个例子,类似的问题数不胜数。有时候想想,很不明白争论这些意义在哪,可能是程序员 coding 之余的放松方式吧。

个人的意见是如果项目已有规范,那遵守即可;如果项目没有规范,那就一起制定规范,再共同遵守就好。

规范就是规范,没有绝对意义上的好和不好,真正能被称为好的,就是一丝不苟的遵守规范,而不是掀起嘴仗。

最好的编程语言,是 PHP ?

知名的业界规范

回到最开始的例子。

“写法一”是来自于一个很流行的规范,airbnb 的语法规范,链接 github.com/airbnb/java… 。

“写法二”是大名鼎鼎的 jQuery 的规范,链接 contribute.jquery.org/style-guide… ,给 jQuery 贡献代码的话,必须遵守这个规范。

“写法三”,反面教材。在规范的争论当中,if 语句是不是要加上 {} 从未被讨论过,猿们总是会加上 {} 。

好吧,业内当然还有许多的其他的规范,比如 google 也有自己的规范,但是规范的意义不在于多,而在于共同遵守,不是吗?

创建文本节点

创建文本节点的方法 createTextNode(string) ,例:

var textNode = document.createTextNode('你的第一个文本节点!');

HTMLCollection

HTMLCollection 的概念一定要注意,它是一个动态集合,当删除其中的某些元素的时候,再去访问,索引就会有变化!所以一般都是把它转成一个普通数组再操作。

事件兼容性初探

兼容性当然是指浏览器的兼容性,而浏览器的兼容性基本上是指 IE的兼容性 。是的,如果要找出最痛恨 IE浏览器的人,非前端工程师莫属。IE,不标准却用户量巨大,想不管它都不行。

当然,也不用为此沮丧,IE 的市场份额逐年下滑,胜利终将属于前端开发!

几个重要的区别

事件模型

IE9 之前只支持事件冒泡,不支持事件捕获,也因此事件捕获在实际开发的过程当中使用的非常少。

addEventListener

IE9 之前不支持 addEventListener 和 removeEventListener ,但是有对应的方法 attachEvent 和 detachEvent ,影响也不是很大。

事件对象

IE9(不包括IE9) 之前的事件对象也不规范。

第一个是获取对象的方式不同。

elem.attachEvent('click', function (event) {
  // 这种绑定方式,`event` 对象可以正确拿到
});

elem.onclick = function() {
  var event = window.event; // 这种绑定方式,只能从 `window` 上拿
}

第二个,事件对象几个常用的属性和方法也不标准,但有对应的属性和实现相同的效果。

标准IE说明
stopPropagation()cancelBubblecancelBubble默认值为 false ,设为 true 可以取消事件冒泡。
preventDefault()returnValue默认值为 true ,设置为 false 可以取消事件的默认行为。
targetsrcElement这个好说,事件的目标元素。

有必要说下事件的默认行为,举个例子, <a> 元素,点击就会跳转到一个新的页面,这个就是浏览器自带的事件的默认行为

有时候不想要这个行为,那么调用事件对象上的 preventDefault() 方法就能达成效果。

跨浏览器的事件

当然不能只局限于理论,而是要用于实战。

思路就是封装一个函数,让它可以在所有的浏览器中正确的绑定事件,让使用者不用关注各个浏览器之间的差异,比如下面的 on 函数。

/**
 * 修复事件对象不兼容的地方
 */
function fixEventObj(e) {
  e.target = e.target || e.srcElement;
  e.preventDefault = e.preventDefault || function() {
    e.returnValue = false;
  };
  e.stopPropagation = e.stopPropagation || function() {
    e.cancelBubble = true;
  };

  return e;
}

/**
 * 跨浏览器的绑定事件
 */
function on(elem, type, handle) {
  if (elem.addEventListener) { // 检测是否有标准方法
    elem.addEventListener(type, handle, false);
  } else if (elem.attachEvent) { // 试图使用 `attachEvent`
    elem.attachEvent('on' + type, function(event) {
      event = fixEventObj(event);
      handle.call(elem, event); // 使用 call 来改变 handle 的作用域,使其指向 elem
    });
  } else { // 兜底
    elem['on' + type] = function() {
      var event = fixEventObj(window.event);
      handle.call(elem, event);
    }
  }
}

// 调用
on(document.body, 'click', function(e) {
  console.log('哈哈哈,好用!', e);
});

这里有个实例页面,点击查看

代码是次要的,思路是重点。大家可以模仿这个 on 写个 off 函数,来跨浏览器的解绑事件。

绑定事件的历史演进

上面在写兼容代码的时候,涉及到 DOM0 级和 DOM2 级事件处理。

elem.onclick = function() {}; // dom 属性的绑定方式就是 DOM0 级
elem.addEventListener(); // 这种就是 DOM2 级

这里不含糊,推荐后一种。

DOM 也有版本演进,DOM0 和 DOM2 就是不同的版本,当然,版本啥的不是重点,想探讨的是这两者的区别。

// 这种方式只能绑定一个 handle
// 当你试图绑定第二个时,就会覆盖上一个
elem.onclick = function() {}; 

// 这种则可绑定任意个 handle
// 在多人开发的项目中,这个特点非常重要,不同的伙伴给同一个元素绑定事件的几率很大的
elem.addEventListener(); // 这种就是 DOM2 级

兼容性问题是肯定存在的,遇到这类问题更要耐心,试着找到不同之处,写出兼容性代码。

键盘事件 keypress 和 keydown

有个示例页面,试试,链接

  • keypress 按字符集触发
  • keydown 按所有键都会触发

两者设计的初衷就不同。

keypress 就是用来检测用户输了啥字符的,而 keydown 则是单纯的检测用户是否按了键盘上的按键,所以 keypress 常用。

两者事件对象上的 keyCode 值也不同。

keyCode是一个代码,与键盘上的一个键对应。在 keypress 事件中,这个 keyCode 还与 ASCII码对应,比如keyCode 等于 105 ,就是按了 i

最后说下,判断一个前端专业不专业,就问下他开发界面的时候有没有考虑过键盘事件。

参考文章

事件委托

一般来说要给某个元素绑定事件,都会直接绑定在该元素上,如下:

// 给 li 元素绑定点击事件
$('li').click(function() {
    console.log('你点击我了');
})

但是这种直接的处理会有以下问题:

  • 通过 JS 新添加的 li 元素并没有该事件绑定,所以点击无效
  • 元素如果比较多的话,比喻有200个 li ,那每个 li 都绑定一个事件,性能是非常低的

那么如何解决这些问题呢?这就是要说的事件委托(或叫事件代理)。

事件委托简单来说就是利用事件冒泡,只指定一个事件处理程序,用来管理某一类型的所有事件。

以一个 todo list 为例:

// 要点击的元素是 todo-item
// 但是我们把事件绑定在父元素 todo-list 上,实现事件委托
// html 结构为:ul#todo-list>li.todo-item*5

$('#todo-list').on('click', '.todo-item', function() {
    $(this).toggleClass('done');
})

以 jQuery 为例,所以看不到背后的本质,下面以一个原生的实现来说明下:

var todoList = document.getElementById("todo-list");

todoList.addEventListener("click", function (e) {
    var target = e.target;
    // 检查事件源 target 是否为 todo-item
    if (target && target.nodeName.toUpperCase() == 'LI' && target.classList.contains('todo-item')) {
        target.classList.toggle('done')
    } else {
        console.log('我不是 todo-item ');
    }
});

注:因为事件委托是依赖于事件冒泡的,所以没有事件冒泡的事件是不能使用事件委托的。

参考资料

jQuery 隐式迭代和链式调用

学过 jQuery 之后,一般是不太有人再愿意写原生 JS 的,甚至有段时间 jQuery 成了 JS 的代名词。原因无他,足够简单方便。可在这简单方便的背后,可少不了两大功臣:隐式迭代和链式调用。

隐式迭代

对于原生 JS 来说,一般设置某类元素的样式,都得使用循环设置,而 jQuery 在使用的时候则无需考虑这点,简单示例如下:

// 设置 .primary 元素的文字颜色为 #188eee

// 原生 JS
var primary = document.getElementsByClassName('primary');
for(var i = 0, len = primary.length; i < len; i++) {
    primary[i].style.color = '#188eee';
}

// jQuery
$('.primary').css('color', '#188eee');

这是因为 jQuery 的方法内部存在隐式迭代,它会对匹配到的所有元素进行循环遍历,执行相应的方法;无需再手动地进行循,方便我们使用。

除了隐式迭代外,jQuery 还提供了 each 方法,方便有需要的时候调用。比喻要对每个元素做不同的处理:

$("li").each(function(i){
   $(this).addClass('item-'+i);
 });

注:jQuery 还有一个全局的 each 方法,用于遍历对象或数组,具体可参考:$.each

链式编程

从前要对某个元素进行一系列操作,只能一个一个来,而 jQuery 提供了链式操作,操作起来简直是不能再爽,如下:

// 原始版
$('div').hide(); //隐藏页面上所有的div元素
$('div').text('new content'); //更新所有div元素内的文本
$('div').addClass("updatedContent"); //在所有的div元素上添加值为updatedContent的class属性
$('div').show(); //显示页面上所有的div元素

// 重写版,链式
$('div').hide().text('new content').addClass("updatedContent").show();

// 缩进版
$('div')
  .hide()
  .text('new content')
  .addClass("updatedContent")
  .show();

其原理就是每个方法的最后都返回了 this 对象,可以使用一份简单的代码演示下:

// 定义类
function Person(opt) {
    this.name = opt.name;
    this.age = opt.age
}

// 定义 getName 方法
Person.prototype.getName = function() {
    console.log(this.name);
    return this; // 返回 this 对象
}

// 定义 sayHello 方法
Person.prototype.sayHello = function() {
    console.log('hello the world');
    return this; // 返回 this 对象
}

// 新建一个叫 next 的 Person 类
var next = new Person({
    name: 'next'
});

// 链式调用 getName 和 sayHello 方法
next.getName().sayHello(); 

更多关于 jQuery 的源码分析可参考:jQuery源码分析系列

charAt

获取字符串指定位置的字符。如下所示:

var str = 'abcd';
// 使用 charAt 方法
str.charAt(1); // 返回 b
// 另外也可以通过使用方括号来获取字符串的字符
str[2]; // c

分割字符串 split

split() 方法可以把字符串分割为字符串数组。如下所示:

"2:3:4:5".split(":")    // 将返回 ["2", "3", "4", "5"]
"|a|b|c".split("|")    // 将返回 ["", "a", "b", "c"]

截取字符串 substring

substring() 方法用于提取字符串中介于两个指定下标之间的字符。如下所示:

var str = 'Hello World!';
console.log(str.substring(3)); // 将返回 lo world!

字符串转换大写 toLocaleUpperCase()

toLocaleUpperCase() 方法用于把字符串转换为大写。如下所示:

var str = 'Hello World!';
console.log(str.toLocaleUpperCase()); // 将返回 HELLO WORLD!

更多阅读

私有变量

什么是私有变量?

对于私有变量,需要理解下面的两句话:

  • 任何在函数中定义的变量,都可以认为是私有变量。因为不能在函数外部访问这些变量。
  • 私有变量包括函数参数,局部变量以及在函数内部定义的其他函数。

如下面的代码,在这个函数内部,有三个私有变量 paramprivateVariable、 privateFunction。在函数内部可以访问到这几个变量,但在函数外部则不能访问他们。

function MyObject(param) {
  var privateVariable = 20;
  function privateFunction(){
      return true;
  }
}
// 在函数外部无法直接访问到私有变量和方法

创建特权方法来访问私有变量

那么,如果需要访问私有变量时,可以怎么做呢?

可以在函数的内部创建一个闭包,那么闭包通过自己的作用域链也可以访问这些变量。而利用这一点,就可以创建用于访问私有变量的公有方法。

也把有权访问私有变量和私有函数的公有方法称为特权方法

如下面的代码,publicMethod 就是特权方法。在创建 MyObject 的实例后,除了使用特权方法 publicMethod 这一途径外,没有其他的办法可以直接访问来访问私有变量 privateVariable 以及私有函数 privateFunction

function MyObject(param) {
  var privateVariable = 20;
  function privateFunction(){
      return 10;
  }
  // 特权方法
  this.publicMethod = function(){
      privateVariable ++;
      return privateFunction();
  }
}

经典的面向对象

这里所说的“经典的面向对象”,是有“类”这个概念的面向对象,比如 Java ,它就有“类”的概念。

面向对象这个概念,实在是太像这个世界。

说道“人”,Person ,肯定是想到一个鼻子,两个眼睛,是一个宽泛的概念,可以想到任何人。

所以“人”,是一个抽象,它描述一个特定别的东西,一动物。

“动物”也是一抽象,从字面意思理解,“动物”是能动的生物,和“植物”区分开来。

人是动物,你我是人,也是动物。

但是,“你我”又有区别,虽然都是人,但我叫 shuaige ,你叫刘德华,我很帅,你却更帅。在“人”这个大的别下,你我是两个不同的实体,两个不同的对象,你我都是具象化的人,是一个具体的概念。

class Animal {
    constructor(legs) {
        this.legs = 2;
    }
}

console.log(new Animal(2)); // { legs: 2 }

上面就是 Animal class 了,非常的简单,也很抽象,但是, new 了一下之后,就具体化了, new 了一个两条腿的动物。

class Person {
    constructor(name) {
        this.legs = 2;
       this.name = name;
    }
}

console.log(new Person('刘德华')); // { legs: 2 }

上面就是 Person class 咯,Person 只是抽象,对吧,但是给他一个名字“刘德华”,他就是活生生的人咯,这就是实例化。

“类”是抽象,其实这个“类”就是分门别“类”里的“类”。“对象”和“实例”,现在如果区分不了,可以混用,就是“类”实例化的产物。

好咯,再看代码:

function Person(name) { // 这个 Person 就是类
    this.name = name;
    this.legs = 2;
}

console.log(new Person('刘德华')); // 活生生的人,就是实例

虽然 JS 里面没有类的概念,但还是习惯叫“类”,而且不同的“类”有时候是有关系的,比如“人”是“动物”,这个时候,“人”就是子类,“动物”就是父类,也挺容易理解。

最后说一句,上面的 class Person 代码,也是 JS 代码,ES6 新增了类的语法,创建对象更容易了。


ES6 的继承

到目前为止,已经知道了 JS 中继承方式的最佳实践了,相信会有这样的疑惑:怎么感觉 JS 继承方式这么绕,又要借用构造函数,又要自己封装继承原型的函数。没错,感觉是对的,大家也是这么觉得的,以至于后来有了很多 JS 继承的封装或者语法糖,比如现在比较火的 TypeScript 就是参照经典的面向对象对 JS 的类的声明和继承进行了封装。

幸运的是,ES6 标准已经将 经典的 class 声明类和继承的方式纳入标准了,前面经典的面向对象扩展资料中就有提到 ES6 中怎么声明类了,下面是使用 ES6 实现 Person 类。

//定义类
class Person {
    // 构造函数
    constructor(name) {
        this.name = name;
    }

    // 方法
    sayName() {
        console.log(this.name);
    }
}

在 ES6 中继承是通过 extends 关键字声明的,比如下面 Student 类继承了 Person 类

//定义类
class Student extends Person {
    // 构造函数
    constructor(name, grade) {
        super(name);
        this.grade = grade;
    }

    // 方法
    sayGrade() {
        console.log(`I am Grade ${this.grade}`);
    }
}

上面继承方式关键点其实就2个,一个是通过 extends 关键字声明继承关系,第二是在子类构造函数 constructor 中调用 super 函数,这其实就相当于借用构造函数。要注意的点是 constructor 中 this 对象要 super 调用之后使用,不然会报错。具体原因可以看下面参考资料。

目前部分现代浏览器新版本已经实现对 ES6 中的 class 和继承的支持,但是注意在旧版本或者 IE 浏览器中是不支持的,所以使用的时候要注意,或者配合使用 Babel 等编译工具。


绘制圆弧

前面讲解了怎么绘制矩形和线条,接下来将讲解下如何在 canvas 画布上 绘制圆弧和圆形

相对矩形来说,绘制圆弧则更为复杂。绘制圆弧需要确定圆心的坐标,圆弧的角度以及绘制圆弧的绘制方法等等。

context.arc()

在 Canvas 中可以使用 context.arc() 的方法来创建圆弧路径。简单来说,在 Canvas 中,创建一条圆弧路径是从与圆心(x, y)距离为一个半径且角度为开始角度的位置开始,最后停在离圆心(x, y)距离为一个半径且角度为结束角度的位置上。

context.arc(x, y, radius, startAngle, endAngle, anticlockwise);

参数说明:

canvas-arc.png

  • x: 圆的中心的 x 坐标
  • y: 圆的中心的 y 坐标
  • radius: 圆的半径
  • startAngle: 圆弧的开始角度
  • endAngle: 圆弧的结束角度
  • anticlockwise: 可选的参数,规定应该逆时针还是顺时针绘图,默认值为 false

弧度表示角度

这里需要注意的是,在 Canvas 中表示圆弧的开始角度和结束角度都是以弧度来表示的,而不是使用角度来表示。

举个例子: 360度使用弧度来表示则是 2π (pi 的两倍) 弧度

canvas-degree.png

可以通过下面的公式进行换算。

var degree = 1; // 表示 1°
var radians = degree * (Math.PI / 180); // 0.0175弧度

绘制圆弧路径

接下来尝试绘制一个圆弧,如下图:

canvas-arc1.png

具体代码如下:

// 开始创建新路径
context.beginPath();
// 创建一个半圆圆弧
context.arc(250, 250, 200, 0, Math.PI, false);
// 调用 stroke 绘制该路径
context.stroke();

绘制部分圆

接下来尝试绘制一个部分圆形填充图形,如下图:

canvas-arc2.png

// 开始创建新路径
context.beginPath();
// 创建一个圆弧
context.arc(250, 250, 200, 0, 0.75 * Math.PI, false);
// 填充该圆弧
context.fill();

绘制圆形

圆形实际上是由圆弧组成(首尾相连的圆弧便是圆形),如果需要绘制下面的圆形:

canvas-arc3.png

具体代码如下:

// 开始创建新路径
context.beginPath();
// 设置开始角度为0,结束角度为 2π 弧度
context.arc(250, 250, 200, 0, 2 * Math.PI, false);
// 使用 fill 自动闭合圆弧路径,然后填充圆弧区域
context.fill();

绘制圆角矩形

不仅可以使用 context.arc() 来绘制圆弧和圆形,还可以来绘制圆角矩形上的圆角。如下图需要绘制一个圆角矩形:

canvas-arc4.png

var x = 120; // 圆角矩形左上角横坐标
var y = 120; // 圆角矩形左上角纵坐标
var width = 250; // 圆角矩形的宽度
var height = 250; // 圆角矩形的高度
var radius = 50; // 圆角的半径

// 开始创建新路径
context.beginPath();
// 绘制左上角圆角
context.arc(x + radius, y + radius, radius, Math.PI, Math.PI * 3 / 2);
// 绘制顶边路径
context.lineTo(width - radius + x, y);
// 绘制右上角圆角
context.arc(width - radius + x, radius + y, radius, Math.PI * 3 / 2, Math.PI * 2);
// 绘制右边路径
context.lineTo(width + x, height + y - radius);
// 绘制右下角圆角
context.arc(width - radius + x, height - radius + y, radius, 0, Math.PI * 1 / 2);
// 绘制底边路径
context.lineTo(radius + x, height +y);
// 绘制左下角圆角
context.arc(radius + x, height - radius + y, radius, Math.PI * 1 / 2, Math.PI);
// 闭合路径 也可使用 context.lineTo(x, y + radius);
context.closePath();
// 设置绘制的颜色
context.strokeStyle = '#188eee';
context.stroke();

图像裁剪

前面讲到如何在 Canvas 中加载各种帅气酷炫的图像。但是有时候并不需要使用完整的图像,而只是图像的一部分内容,这个时候就需要使用图像裁剪图像裁剪是图片 PS 中经常使用到的一种技术,目的是为了突出图片的某个特定的区域。接下来,学习如何使用 Canvas 来裁剪图像。

还是 context.drawImage()

没错,你没看错,还是使用 drawImage 的方法。裁剪是 drawImage 方法的最后一种用法。

context.drawImage(image, source_x, source_y, source_width, source_height, x, y, width, heigh);

它总共涉及9个参数,具体如下:

  • image:源图像对象
  • source_x:源图像的裁剪区原点横坐标
  • source_y:源图像的裁剪区原点纵坐标
  • source_width:源图像的裁剪区宽度
  • source_height:源图像的裁剪区高度
  • x:在画布上绘制图像的原点横坐标
  • y:在画布上绘制图像的原点纵坐标
  • width:在画布上绘制图像的宽度
  • heigh:在画布上绘制图像的高度

上面所有参数的看起来可能比较抽象,可以通过结合下面说明图进行理解:

canvas-drawimage.png

实例

接下来尝试截取图片的中间部分,如下图:

canvas-drawimage-demo.png

相关代码如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>物体移动</title>
</head>
<body>
    <canvas id="canvas" width="500" height="500"></canvas>
    <script>
      var canvas = document.getElementById('canvas');
      var context = canvas.getContext('2d');

      var image = new Image();
      image.src = 'http://coding.imweb.io/img/p3/retina-pixel.jpg';
      image.onload = function () {
        // 加载图片后,边截取图片且缩放展示在画布左上角
        context.drawImage(image, 260, 260, 480, 480, 0, 0, 240, 240);
      }
    </script>
</body>
</html>

requestAnimationFrame

动画原理简介

动画的基本原理是依靠人类具有视觉暂留的特性人的眼睛看到一幅画或一个物体后,在 1/24 秒内不会消失(即每秒钟至少更换24张画面)。利用这一原理,在一幅画(一帧)还没消失前播放下一幅画(下一帧),就会给人造成流畅的视觉变化效果。

如下面的翻书动画,就是利用人的视觉暂留的特性的。

animation.gif

因此可以得出:如果需要实现动画,只需要设置定时不断地绘制下一帧的画面便可以了

早期动画循环

在 JavaScript 中可以使用 setTimeout 和 setInterval 来设置延时任务。 因此在很长时间以来,计时器一直都是 JavaScript 动画的最核心技术。如下面的代码就是使用 setTimeout 方法来实现基本的动画循环:

function animate() {
    // 动画内容
    animation1();
    animation2();
    // 间隔100ms执行动画循环
    setTimeout(function () {
        animate();
    }, 100);
}
// 执行动画
animate();

循环间隔 60Hz

早期的动画循环时候,最关键的问题是确定循环间隔的时长。一方面,循环间隔必须足够短,这样才能动画效果显得更平滑流畅;另一方面,循环时隔还要足够长,这样才能确保浏览器有能力渲染产生的变化。大多数的显示器的刷新频率是 60Hz ,即相当于在每秒钟中屏幕会重绘 60 次。因此最平滑动画的最佳循环间隔是 1000ms/60,约等于 16.7ms。

setTimeout 和 setInterval 问题

然后无论是 setTimeout 和 setInterval 都并不是十分精准。

JavaScript 是一个单线程的解释器,在一定时间能只能执行一段代码。为了要控制代码的执行顺序,就需要通过一个 JavaScript任务队列 来进行管理控制(任务会按照添加到队列的顺序执行)。通过 setTimeout 和 setInterval 能够设置延时多长时间把代码任务添加到 JavaScript任务队列 中。如果当前任务队列是空的,那么添加的代码可以立即执行;如果队列不是空的,则新添加的任务需要等到其前面所有的任务都执行完成才能执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeout 和 setInterval 指定的任务,一定会按照预定时间执行。

如下面的代码:

setTimeout(animateTask, 1000 / 60);
// 耗时长的任务
longTimeTask();

上面代码的 setTimeout,制定 16.7ms 后运行 animateTask 任务。但是,如果由于后面的 longTimeTask 执行(当前脚本的同步任务))非常耗时,即使过了 16.7ms 仍无法结束,那么延迟执行的 animateTask 就只有等着,只有等到前面的任务都运行完,才能轮到它执行。

具体可阅读下 John Resig(jQuery 作者)的这篇文章 How JavaScript Timers Work

requestAnimationFrame

由于 setTimeout 和 setInterval 的不精准问题,促使了 requestAnimationFrame 的诞生。 requestAnimationFrame 是专门为实现高性能的帧动画而设计的一个API,目前已在多个浏览器得到了支持,可以把它用在 DOM 上的效果切换或者 Canvas 画布动画中。 requestAnimationFrame 并不是定时器,但和 setTimeout 很相似,在没有 requestAnimationFrame 的浏览器一般都是用setTimeout模拟。 requestAnimationFrame 跟屏幕刷新同步(大多数是 60Hz )。如果浏览器支持 requestAnimationFrame , 则不建议使用 setTimeout 来做动画。

requestAnimationFrame 的兼容使用

下面是常规使用 requestAnimationFrame 的兼容写法,当浏览器不兼容的 requestAnimationFrame 时则通过使用 setTimeout 来模拟实现,且设定渲染间隔为 1000ms/60

// 判断是否有 requestAnimationFrame 方法,如果有则模拟实现
window.requestAnimFrame =
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function(callback) {
    window.setTimeout(callback, 1000 / 30);
};

使用 requestAnimationFrame 实现动画

下面将使用 requestAnimationFrame 来实现一个物体来回移动的动画。效果如下:

requestAnimationFrame.gif

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>认识Canvas</title>
</head>
<body>
    <canvas id="canvas" width="500" height="500" style="border: 1px solid #33"></canvas>
    <script>
        var canvas = document.getElementById('canvas');
        var context = canvas.getContext('2d');
        // 兼容定义 requestAnimFrame
        window.requestAnimFrame =
        window.requestAnimationFrame ||
        window.webkitRequestAnimationFrame ||
        window.mozRequestAnimationFrame ||
        window.oRequestAnimationFrame ||
        window.msRequestAnimationFrame ||
        function(callback) {
            window.setTimeout(callback, 1000 / 30);
        };

        // 绘制的圆的对象
        var circle = {
            x: 250,
            y: 250,
            radius: 50,
            direction: 'right',
            // 移动圆形
            move: function() {
                if (this.direction === 'right') {
                    if (this.x <= 430) {
                         this.x += 5;
                    } else {
                        this.direction = 'left';
                    }
                } else {
                    if (this.x >= 60) {
                         this.x -= 5;
                    } else {
                        this.direction = 'right';
                    }
                }
            },
            draw: function() {
                // 绘制圆形
                context.beginPath();
                // 设置开始角度为0,结束角度为 2π 弧度
                context.arc(this.x, this.y, this.radius, 0, 2 * Math.PI, false);
                context.fillStyle = '#00c09b';
                context.fill();
            }
        }
        // 动画执行函数
        function animate() {
            // 随机更新圆形位置
            circle.move();
            // 清除画布
            context.clearRect(0, 0, canvas.width, canvas.height);
            // 绘画圆
            circle.draw();
            // 使用requestAnimationFrame实现动画循环
            requestAnimationFrame(animate);
        }

        // 先画第一帧的圆,即初始化的圆
        circle.draw();
        // 执行animate
        animate();        
    </script>
</body>
</html>

键盘事件处理

在制作 PC 端的游戏的时候,经常需要监听键盘的事件,以便响应用户的键盘操作。目前,对键盘事件的支持主要遵循的是 DOM0级

按键相关事件

键盘操作涉及下面三种事件:

  • keydown:当用户按下键盘上的任意键时触发,而且如果按住按住不放的话,会重复触发此事件。
  • keypress:当用户按下键盘上的字符键时触发,而且如果按住不放的,会重复触发此事件(按下Esc键也会触发这个事件)。
  • keyup:当用户释放键盘上的键时触发。

按键过程

用户按下键盘上的字符键时

  • 首先会触发 keydown 事件
  • 然后紧接着触发 keypress 事件
  • 最后触发 keyup事件
    如果用户按下了一个字符键不放,就会重复触发 keydown 和 keypress 事件,直到用户松开该键为止。

键码(keyCode)对照表

在发送 keydown 和 keyup 事件时,event 对象的 keyCode 属性中会包含一个代码,与键盘上一个特定的键对应。如下图,为键盘键位的 keyCode 对照表:

keycode.png

例子:简单实现键盘控制物体移动

实现的基本原理如下:监听全局键盘操作事件,当用户按下某一按键时,返回对应的键值,然后再根据键值判断用户按下了哪一按键,来控制物体上下移动的操作,效果如下:

keyboard.gif

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>物体移动</title>
</head>
<body>
    <canvas id="canvas" width="500" height="500"></canvas>
    <script>
      var canvas = document.getElementById('canvas');
      var context = canvas.getContext('2d');
      var rect = {
        x: 100, // 矩形的 x 坐标
        y: 400, // 矩形的 y 坐标
        width: 100, // 矩形的宽度
        height: 100, // 矩形的高度
        step: 30 // 矩形移动的步伐
      }
      // 全局监听键盘操作的 keydown 事件 
      document.onkeydown = function(e) {  
        // 获取被按下的键值 (兼容写法)
        var key = e.keyCode || e.which || e.charCode;
        switch(key) {
          // 点击左方向键
          case 37: 
            rect.x -= 20;
            drawRect();
            break;
          // 点击上方向键
          case 38: 
            rect.y -= 20;
            drawRect();
            break;
          // 点击右方向键
          case 39: 
            rect.x += 20;
            drawRect();
            break;
          // 点击下方向键
          case 40: 
            rect.y += 20;
            drawRect();
            break;
        } 
      };
      function drawRect() {
        // 清除画布
        context.clearRect(0, 0, canvas.width, canvas.height);
        // 绘制矩形
        context.fillRect(rect.x, rect.y, rect.width, rect.height);
      }
      // 第一次绘制
      drawRect();
    </script>
</body>
</html>

碰撞检测

在实现 Canvas 游戏和动画中,往往需要解决物体相互碰撞的情况如。对于物体碰撞相关的问题,会在动画中采用 碰撞检测 来解决,以此实现更为逼真的动画。

碰撞检测关键步骤

碰撞检测需要处理经历下面两个关键的步骤:

  • 计算判断两个物体是否发生碰撞
  • 发生碰撞后,两个物体的状态和动画效果的处理

计算碰撞

只要两个物体相互接触,它们就会发生碰撞。

矩形物体碰撞检测

假设检测发生碰撞的物体是 矩形1 和 矩形2 时,只需检测 矩形1 的上下左右四侧的和 矩形2 是否存在着距离。可以看看下面的图:

crash-rect.png

可以看到 矩形2 和 矩形1 之间没有发生碰撞共有四种可能的情况:

  • 矩形2的右侧 离 矩形1的左侧有一段距离
  • 矩形2的左侧 离 矩形1的右侧有一段距离
  • 矩形2的底部 离 矩形1的顶部有一段距离
  • 矩形2的顶部 离 矩形1的底部有一段距离

当符合上面其中一种情况,则两个矩形没有发生碰撞。

因此通过逆向推导可以得出:当上面四种情况都不满足的时候,则代表两个矩形碰撞了。在代码中,可以这样写:

// 判断四边是否都没有空隙
if (!(rect2.x + rect2.width < rect1.x) &&
    !(rect1.x + rect1.width < rect2.x) &&
    !(rect2.y + rect2.height < rect1.y) &&
    !(rect1.y + rect1.height < rect2.y)) {
    // 物体碰撞了
}

圆形物体碰撞检测

假设发生碰撞的物体是 圆形 时,检测碰撞则变得比较复杂了,前面矩形所使用的碰撞检测,并不能判断圆形物体的情况。如下图的情况:

crash-circle.png

那么如何检测两圆是否碰撞了呢?这个时候又到了考验数理化的知识了。

检测两圆是否相交:当两个圆心之间的距离是否小于两个圆的半径之和。这是已经被证实的数学运算。如下图所示:

crash-distance.png

其中 dx 和 dy 分别表示两个圆之间的横坐标和纵坐标的差值。 即 dx = x2 - x1; dy = y2 - y1;

然后需要通过 勾股定理 计算两个圆心之间的距离。如下图:

crash-gougu.png

因此碰撞检测的代码可以这样写:

var dx = circle2.x - circle1.x;
var dy = circle2.y - circle1.y;
var distance = Math.sqrt((dx * dx) + (dy * dy));
if (distance < circle1.radius + circle2.radius) {
  // 两个圆形碰撞了
}

前面讲解了怎么检测矩形和圆形是否碰撞,基本已经可以适用大部分场景。对于特殊的场景,则需要大家自己去思考如何检测了。

碰撞后的处理

当检测到碰撞后,则可以对碰撞的物体进行状态设置了,可以是相互毁灭,或者是反弹等。这里大家可以根据场景来决定。

Canvas 更多知识点

这里列出 Canvas 其他的知识点,感兴趣可以深入了解下。

相关资料

下面是一些推荐的 Canvas 学习书籍。

《HTML5 CANVAS基础教程》
十分适合新手看的一本 canvas 书籍。

canvas-end-book1.png

《HTML5 canvas开发详解》

是一本大而全的 canvas 书籍,覆盖的知识点比较多。如果希望全面了解 canvas 的可以看一下。

canvas-end-book2.png

下面是一些推荐的学习 Canvas 的教程文章

在基础构造函数上增加方法

比如通过修改基础构造函数原型的方式去增强 js 的对象和数组,这种操作会影响所有的无论是通过构造函数还是字面量生成的数据,属于污染全局的操作,一般情况下不建议使用。但是这种操作在一些浏览器兼容框架(如:es5-shim)中会比较常用,比如 IE8 下没有实现数组的 forEach 方法,框架就可以通过这种方式去补全。

常见的状态码

HTTP 有 request 和 response,他们都有自己的报文结构:

http-message-structure.jpg

其中 response 有很多不同的状态码,这里呢就聊聊常见的状态码。

404

404 的含义就是客户端所访问的资源不存在,可以试试这个链接

值得说说的就是,服务器当然可以侦测到 404 的请求,从而返回一些好玩的页面,比如腾讯新闻网的找寻失联儿童,点这里瞧瞧

304

这个是面试官常问的状态码,因为涉及到浏览器的缓存。

301 和 302

同样是面试官常问的问题。

这两个都表示跳转了,区别是啥呢?

有一个博客,运营了一段时间,首页链接假设是 http://blog.com/index.html 。

后来呢,换了个域名,链接就变成了 http://superblog.com/index.html 。

现在问题来了,很多迷弟迷妹都只知道老首页的链接呀,肯定希望他们访问老首页就跳转到新首页对不对,所以这个时候跳转的需求登场了!

有两个选择:

  • 301 ,这个表示永久移动,就是再也不会用老域名了,所有能识别 301 语义的客户端啊,请收藏新链接!
  • 302 ,表示临时移动,就是还会用回老域名,现在临时切下,所有能识别 302 语义的客户端,请继续收藏老链接!

以上,就是 301 和 302 ,搞清楚哦。

500

前端工程师的大敌,500 !

调式接口的时候碰到 500 的话,就可光明正大地操起折叠凳怼过去了!

哈哈哈,让 500 来的更多点吧,衰仔!

当然,上面都是常见的、好玩的状态码,更多的没意思的状态码可以参考下其他资料哈。

参考文档

DNS 预解析

developer.mozilla.org/zh-CN/docs/…

performance 详解

为了更加精确地测量和提高网页和Web应用程序的性能够,W3C 和各浏览器厂商共同制定了 Web Performance API

可以通过该接口查看用户访问网站各项性能数据,如连接建立时间、dns时间等信息,为更好地增强网页性能提供了前所未有的支持。

浏览器内存相关 performance.memory

performance.memory 可获取浏览器的内存情况,这个属性并不是标准的performance 属性,只在 chrome浏览器中。具体有以下值:

  • usedJSHeapSize 表示所有被使用的 js 堆栈内存
  • totalJSHeapSize 表示当前 js 堆栈内存总大小
  • jsHeapSizeLimit 表示内存大小限制

其中 usedJSHeapSize 不能大于 totalJSHeapSize,如果大于则可能出现了内存泄漏的情况。

网页导航相关 performance.navigation

performance.navigation 对象存储了两个属性,它们表示触发页面加载的原因。这些原因可能是页面重定向、前进后退按钮或者普通的 URL 加载。两个属性如下:

performance.navigation.type

该属性的值为一个整数值,表示网页的加载来源,共以下4种情况。

数值含义相应的常量
0通过点击链接、地址栏输入、表单提交、脚本操作等方式加载。TYPE_NAVIGATENEXT
1通过刷新操作或者 location.reload() 方法TYPE_RELOAD
2通过历史遍历操作加载TYPE_BACK_FORWARD
255其他来源的加载TYPE_UNDEFINED

performance.navigation.redirectCount

该属性表示到达当前页面,经过重定向的次数。

时间相关 performance.timing

performance.timing 对象包含了各种浏览器性能相关的信息如网站建立连接、DNS查询、DOM 解析等各项数据,使能够更为全面精确地了解网页性能的详细情况。

performance.png

以下是相关的属性:

  • navigationStart:当前浏览器窗口的前一个网页关闭,发生unload事件时的时间。如果没有上一个页面,这个值会和 fetchStart 相同。通常也理解为准备加载新页面的起始时间。
  • redirectStart:到当前页面的重定向开始的时间。当重定向的页面来自同一个域时这个属性才会有值,否则值为0。
  • redirectEnd:到当前页面的重定向结束的时间。当重定向的页面来自同一个域时这个属性才会有值,否则值为0。
  • fetchStart:准备使用HTTP请求(fetch)页面的时间。
  • domainLookupStart:域名查询开始的时间。
  • domainLookupEnd:域名查询结束的时间。
  • connectStart:返回HTTP请求开始向服务器发送的时间,如果使用持久连接(persistent connection),则返回值等同于 fetchStart 的值。
  • (secureConnectionStart) :可选特性。如果页面是HTTPS协议,则返回开始SSL握手的那个时间。如果当前网页不要求安全连接,则返回0。
  • connectEnd:返回浏览器与服务器之间的连接建立的时间。如果建立的是持久连接,则返回值等同于 fetchStart 属性的值。连接建立指的是所有握手和认证过程全部结束。
  • requestStart:返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的时间。
  • responseStart:返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间。
  • responseEnd:返回浏览器从服务器收到(或从本地缓存读取)最后一个字节时的时间。
  • unloadEventStart:返回同一个域名前一个网页的 unload 事件触发时的时间。否则返回值为0。
  • unloadEventEnd:返回同一个域名前一个网页的 unload 事件结束时的时间。否则返回值为0。
  • domLoading:返回当前网页 DOM 结构开始解析时(即Document.readyState属性变为 loading、相应的readystatechange事件触发时)的时间
  • domInteractive:返回当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为 interactive 、相应的readystatechange事件触发时)的时间。
  • domContentLoadedEventStart:返回当解析器发送 DOMContentLoaded 事件的开始时间
  • domContentLoadedEventEnd:返回当文档的 DOMContentLoaded 事件的结束时间。
  • domComplete:返回当前文档解析完成,即Document.readyState 变为 complete 且相对应的readystatechange 被触发时的时间。
  • loadEventStart:返回该文档下,load 事件被发送时的时间。如果这个事件还未被发送,它的值将会是0。
  • loadEventEnd:返回当 load 事件结束,即加载事件完成时的时间。如果这个事件还未被发送,或者尚未完成,它的值将会是0。

计算相关节点

可以通过上面的属性计算出许多网页的信息。

页面经历了多长时间

如下面代码表示距离浏览器开始加载网页到现在的时间间隔。

var timing = performance.timing; 
var duration = Date.now() - timing.navigationStart; 

网页加载整个过程的(onload)耗时

如下面代码表示网页加载整个过程的耗时。

var timing = performance.timing; 
var pageLoadTime = timing.loadEventEnd - timing.navigationStart;

DNS 域名解析的耗时

如下面代码表示 DNS 域名解析的耗时。

var timing = performance.timing; 
var dns = timing.domainLookupEnd - timing.domainLookupStart;

tcp 连接的耗时

如下面代码表示 tcp 连接的耗时。

var timing = performance.timing; 
var tcp = timing.connectEnd - timing.connectStart;

TTFB 获取首字节的耗时

如下面代码表示 TTFB(time to frist byte ) 获取首字节的耗时。

var timing = performance.timing; 
var ttfb = timing.responseStart - timing.navigationStart;

返回时间 performance.now()

performance.now 方法将会返回当前网页自从performance.timing.navigationStart 到当前时间之间的微秒数(毫秒的千分之一)。

performance.now() 返回的时间近似于 Date.now()。但前者的时间精度是后者的 1000 倍。

资源加载相关 performance.getEntries()

浏览器获取网页时,会对网页中每一个资源文件(js、css、图片等)发出一个HTTP请求。performance.getEntries会统计这些请求并且返回相应的时间信息。

如下所说,返回的是第二个HTTP请求的加载情况:

performance-entries.png

更多阅读

Date 类型

在实际应用中,常常需要用到日期或者时间等,而在 JS 中,通常使用 Date 类型来表示日期。

由于 JS 中的 Date 类型是在早期 Java 中的 java.util.Date 类基础上构建的,所以 JS 的 Date 类型使用的是自 UTC 1970年1月1日0时0分0秒(世界标准时间)起的毫秒数。比如2017年8月7号16时37分26秒, 用毫秒数表示就是 1502095046000。当然,实际使用中不需要手动去计算这些毫秒数,但是要理解这个数字的含义。

日期对象

通常调用 new Date() 获取当前时间,函数返回的是日期对象,这个对象有很多方法可以获取日期的信息。

// 当前时间
var now = new Date(); // Tue Aug 08 2017 15:41:26 GMT+0800 (CST)

除此之外,还可以传入参数使用,如:

var today = new Date(1453094034000); // by timestamp(accurate to the millimeter)
var birthday = new Date('December 17, 1995 03:24:00');
var birthday = new Date('1995-12-17T03:24:00');
var birthday = new Date(1995, 11, 17);
var birthday = new Date(1995, 11, 17, 3, 24, 0);

其参数规则如下:

// value 代表自19701100:00:00 (世界标准时间) 起经过的毫秒数
new Date(value);

// 表示日期的字符串值
new Date(dateString);

// year 代表年份的整数值,为了避免2000年问题最好指定4位数的年份; 如使用 1998, 而不要用 98
// month 代表月份的整数值从01月)到1112月)
// day 代表一个月中的第几天的整数值,从1开始
// hour 代表一天中的小时数的整数值 (24小时制),minute 分钟数,second 秒数,millisecond 毫秒数
new Date(year, month[, day[, hour[, minutes[, seconds[, milliseconds]]]]]);

注:由于日期对象的方法实在有点多,这里就抽取几个常用的说明下,其余的可参考下面的参考资料文章。

静态方法

now 方法

es5 中添加了 Date.now() 方法,返回表示调用这个方法时的日期和时间的毫秒数。经常用此方法来测试代码的运行时间,这样就可以根据运行时间判断其性能了,如:

// 记录开始时间
var start = Date.now();

// 这里运行一段代码
doSomething();

// 记录结束时间
var stop = Date.now();

// 得到代码运行时间
var timeUse = start - stop;

但是对于不支持 Date.now() 方法的浏览器(如 IE8-),还可以使用+操作符把 Date 对象转换成字符串,也可以达到同样的目的,如下:

// 记录开始时间
var start = +new Date();

// 这里运行一段代码
doSomething();

// 记录结束时间
var stop = +new Date();

// 得到代码运行时间
var timeUse = start - stop;

实例方法

Getter

  • getFullYear():根据本地时间返回指定日期对象的年份(四位数年份时返回四位数字)
  • getMonth():根据本地时间返回指定日期对象的月份(0-11)
  • getDate():根据本地时间返回指定日期对象的月份中的第几天(1-31)
  • getDay():根据本地时间返回指定日期对象的星期中的第几天(0-6)
  • getHours():根据本地时间返回指定日期对象的小时(0-23)
  • getMinutes():根据本地时间返回指定日期对象的分钟(0-59)
  • getSeconds():根据本地时间返回指定日期对象的秒数(0-59)
  • getMilliseconds():根据本地时间返回指定日期对象的微秒(0-999)
  • getTime():返回从1970-1-1 00:00:00 UTC(协调世界时)到该日期经过的毫秒数,对于1970-1-1 00:00:00 UTC之前的时间返回负值。

下面以一个简单的格式化时间来说明下上面方法的一些使用:

// 将时间格式化为:YYYY-MM-DD HH:mm:ss

// 小于或等于9的数字前面添加0
function addZero(num) {
    return num > 9 ? num : '0' + num;
}

// 格式化时间,可传入一个时间或使用当前时间
function dateFormat(date) {
    var date = date ? new Date(date) : new Date();
    var str = date.getFullYear() + '-' + addZero(date.getMonth() + 1) + '-' + addZero(date.getDate()) + ' ' + addZero(date.getHours()) + ':' + addZero(date.getMinutes()) + ':' + addZero(date.getSeconds());

    return str;
}

dateFormat(); // 2017-08-07 17:44:51
dateFormat(1502095046000); // 2017-08-07 16:37:26
dateFormat('December 3, 1995 03:19:05'); // 1995-12-03 03:19:05

当然对于格式化时间这么常用的功能,已经有Moment.js库来帮处理了。

Setter

  • setFullYear():根据本地时间为指定日期对象设置完整年份(四位数年份是四个数字)
  • setMonth():根据本地时间为指定日期对象设置月份
  • setDate():根据本地时间为指定的日期对象设置月份中的第几天
  • setHours():根据本地时间为指定日期对象设置小时数
  • setMinutes():根据本地时间为指定日期对象设置分钟数
  • setSeconds():根据本地时间为指定日期对象设置秒数
  • setMilliseconds():根据本地时间为指定日期对象设置毫秒数
  • setTime():通过指定从 1970-1-1 00:00:00 UTC 开始经过的毫秒数来设置日期对象的时间,对于早于 1970-1-1 00:00:00 UTC的时间可使用负值。

对于该系列的方法,经常用于倒计时,如下:

// 2017 年 IMWebconf 大会开始时间为: 2017-09-16 09:30:00
// 计算大会时间的倒计时

var nowTime = Date.now(); // 1502189810994,这个时间会变动

var confDate = new Date(2017, 8, 16, 9, 30, 0),
    confTime = confDate.getTime(); // 1505525400000

var diffTime = parseInt((confTime - nowTime) / 1000);

if(diffTime > 0) {
    var days = parseInt(diffTime / (60 * 60 * 24)); // 得到剩余天数
    var hours = parseInt((diffTime % (60 * 60 * 24)) / (60 * 60)); // 得到剩余小时
    var minutes = parseInt(((diffTime % (60 * 60 * 24)) % (60 * 60)) / 60); // 得到剩余分钟
    var seconds = ((diffTime % (60 * 60 * 24)) % (60 * 60)) % 60; // 得到剩余秒
    // 距离大会还剩38天14:33:9,依赖于 nowTime 为 1502189810994 的计算
    console.log('距离大会还剩' + days + '天' + hours + ':' + minutes + ':' + seconds);
} else {
    console.log('大会已经圆满结束,敬请期待下一次大会!')
}

同样对于倒计时,也有很多库可用:分享12个效果精美的 JavaScript 倒计时脚本

参考资料