重新认识js

我们似乎很容易就会安于现状,怀疑着、踟蹰着不敢向前迈出一步,但等自己真的尝试去做了,霍然回首,原来也不是很难,又成长了一些。 ——龙龙 《随笔》

前言

1995年,netscape公司一位名叫Brendan Eich的工程师创造了JavaScript,当时js名为LiveScript,后来因为sun公司的Java语言的兴起和广泛使用,Netscape出于宣传和推广的考虑,更名为JavaScript。尽管两者之间咩有什么共同点,这便是产生混淆的根源。
与大多数编程语言不同,Js并没有输入或输出的概念。他是一个在宿主环境下运行的脚本语言,任何与外界沟通的机制都是由宿主环境提供的。浏览器是最常见的宿主环境,但在非常多的其他程序中也包含JS解释器,如Adobe Acrobat、Photoshop、SVG图像、Yahoo!的Widget引擎,以及Node.js之类的服务器环境。JS的实际应用远不止这些,还有NoSQL数据库、嵌入式计算机,以及包括GNOME在内的桌面环境等等

概览

JS是一种面向对象的动态语言,它包含类型、运算符、标准内置对象和方法。

  • js的语法来源于Java和C,所以这两种语言的许多语法特性同样适用于JavaScropt
  • js并不支持类,类的概念在js中通过对象原型得到延续。
  • js中的函数也是对象,js允许函数在包含可执行代码的同时,能像其他对象一样被传递

类型

任何编程语言都必不可少的组成部分——类型,js的类型包括(7种)

  • Number(数字)
  • String (字符串)
  • Boolean(布尔值)
  • Symbol(符号)(ES6新增)
  • Object(对象)(万物皆对象)
    • Function(函数)
    • Array(数组)
    • Date(日期)
    • RegExp(正则)
  • Null(空)
  • Undefined(未定义)

数字

js采用“IEEE 754”标准定义的双精度64位格式表示数字,和其他编程语言不同,js不区分整数和浮点数,所有数字在js中均用浮点数值表示。
在具体实现时,整数值通常被视为32位整型变量。

1
0.1+0.2===0.30000000000000004 //true

js支持标准的算术运算符,加减、取模(取余),内置对象Math等

1
2
Math.sin(3.5);
var d = Math.PI*(r+r)

内置对象parseInt(),将字符串转化成整型,该函数的第二个参数表示字符串所表示的数字的基(进制)

1
2
parseInt('123',10);// 123
parseInt('11',2); // 3 二进制数字字符串转化成整数值

js还有个类似的内置函数parseFloat(),用以解析浮点数字符串,并且只应用于解析十进制数字
单元运算符 + 也可以吧数字字符串转换成数值

1
2
+ '42'; // 42
+ '0x10'; // 16 函数把0x解析成16进制

如果给定的字符串不存在数值形式,函数会返回一个特殊的值 NaN

1
parseInt('hello',10); //NaN

要小心NaN:如果把NaN作为参数进行数学运算,结果也会是NaN:

1
NaN + 5; // NaN

可以使用内置函数isNaN()来判断一个变量是否为NaN:

1
isNaN(NaN) //true

js还有两个特殊的值:Infinity(正无穷)和-Infinity(负无穷)

1
2
1/0; //Infinity
-1/0; //-Infinity

可以使用内置函数isFinite()来判断一个变量是否是一个有穷数,如果类型为 Infinity、-Infinity、NaN、则返回false

1
2
3
4
5
6
7
isFinite(1/0); //false
isFinite(Infinity); //false finite(有限的、有穷的)
isFinite(NaN); //false

isFinite(0); //true Number.isFinite(0); //true
isFinite("0");// true 如果是纯数值类型的检测,则返回false Number.isFinite("0"); //false
isFinite(2e74);//true

  • parseInt()和parseFloat()会尝试逐个解析字符串中的字符,直到遇到无法解析的那一个,就返回该字符之前的所有数字;
  • 运算符 + ,只要字符串中含有无法解析的数字,就会返回NaN
    对于"10.2abc"显示结果

字符串

js中的字符串是一串 Unicode字符 序列,准确说是一串 UTF-16 编码单元的序列,每一个编码单元由一个16位二进制数表示。每一个Unicode字符由一或两个编码单元表示。
如果想表示一个单独的字符,只需要使用长度为1的字符串

1
"hello".length; //5

字符串也有methods(方法)

1
2
3
"hello".charAt(1); // e
"hello,world".replace("hello","goddbye"); //"goodbey,world"
"hello".toUpperCase(); //HELLO

其他类型

  • null 表示空值,必须使用null关键字才能访问。
  • undefined 是 未定义 类型的对象,表示一个未初始化的值。一个未被赋值的变量就是undefined类型,undefined实际上是一个不允许修改的值。
  • boolean,根据需要js可以根据如下规则将变量转换成布尔类型
    • 0、NaN、空字符串(“”)、false、null、undefined 转换为false ( 六种 )
    • 其他所有值转换为true

变量

js与其他语言的重要区别是 js中语句块 是没有 作用域的,只有函数有作用域。如果在复合语句中(例如if)使用var声明一个变量,那么他的作用域是整个函数(复合语句 在 函数中)。但是在es6中,let 和 const 关键字允许创建块作用域的变量

运算符

算术运算符+ - * / 和 %(求余)
+ 操作符还可以链接字符串(还有 字符串 转换 数字)

1
"hello" + "Angela" ;// "helloAngela"

如果你用一个字符串加上一个数字,那么操作数都会首先被转换为字符串

1
2
3 + "2"  //"32"
"3" + 4 + 6 //"346"

这里有咩有看出一个实用的技巧——通过空字符串相加,可以将某个变量迅速变成 字符串 类型

js的比较操作使用<、>、<=、>=,这些运算符对于数字和字符串都通用。
== 和 === , != 和 !== ,区别是有咩有在比较前 进行 类型自适应(类型转换)

控制结构

  • if else
  • for
  • while 和 do-while (循环体 至少执行一次)

例如;

1
2
3
4
5
6
7
while(a){
a+=1; a为true,才会执行
}

do{
a+=1; //至少执行一次
}while(a>0)

&& 和 || 运算符使用 短路逻辑,是否会执行第二个语句取决于 第一个操作数的结果。在需要访问某个对象的属性时,可以使用这个特性先判断对象是对为空

1
var a = Q && Q.length

或运算可以用来设置默认值

1
var name = otherName || 'default'

对象

js中的对象表现形式为”名——值”对,类似

  • python中的字典
  • c/c++中的散列表
  • Perl 和 Ruby 中的散列(哈希)
  • Java 中的 HashMap
  • php中的关联数组

正因为js中的一切(除了核心类型 core object)都是对象,所以js必然与大量的散列表查找操作有着千丝万缕的联系,而散列表擅长的就是告诉查找

有两种简单的方法可以创建一个对象

1
var obj = new Object();


1
var obj = {};

第二种更方便的方法叫做“对象字面量”,这也是json格式的核心语法,一般我们优先选择第二种。
下面的方法创建一个对象原型Person,和原型的对象实例you

1
2
3
4
5
6
7
8
function Person(name,age){
this.name = name;
thia.age = age;
}

//定义一个对象

var you = new Person('you','20')

创建完成后,对象的属性可以通过如下两种方式进行赋值和访问

1
2
obj.name = 'angela';
var name = obj.name;

第二种

1
2
obj['name'] = 'angela';
var name = obj['name'];

这两种方法在语义上是相同的,第二种方法的优点是,属性的名称被看做一个字符串,这意味着她可以在运行时被计算,缺点是
有可能在后期无法被解释器优化,他也可以被用来访问某些以 预留关键字作为名称的属性(注:es5开始, 预留关键字可以作为对象的属性名)

数组

数组是js中一种特殊的对象,他的工作原理与普通对象类似(以数字为属性名,但只能通过[]来访问),他还有一个特殊的属性 length,这个属性的值比最大索引数大1.

1
2
3
4
5
var a = {'a','b','c'};  //数组字面量比 传统的声明数组更加方便
a.length; // 3

a[100] = 'x';
a.length = 101;

记住:数组的长度是比最大索引数 大 1 的数。

如果试图访问一个不存在的索引,会得到 undefined;

1
typeof(a[90]); //undefined

可以通过如下的方式遍历一个数组:

1
2
3
for(var i = 0; i < a.length; i++){
xxxxx;
}

遍历数组的另一种方法是使用 for…in循环,注意,如果有人向Array.prototype中添加了新的属性,使用for…in循环,这些属性也同样会被遍历。所以不推荐这种方法。

1
2
3
for(var i in a){
xxx;
}

ES5增加了遍历数组的另一种方法,forEach();

1
2
3
4
5
['a','b''c'].forEach(function(currentValue,index,array){
// do something with currentValue or array[index]
})

['a','b','c'].forEach(currentValue => console.log(currentValue))

如果想在数组后追加元素,只需要

1
a.push(item);

Array类 自带了许多方法。

  • a.toString(); //返回一个包含数组中所有元素的字符串,每个元素通过逗号分隔
  • a.toLocaleString(); //根据宿主环境的区域设置,返回一个包含数组中所有元素的字符串,每个元素通过逗号分隔
  • a.concat(item1,item2,…,itemN); //连接,返回一个数组,包含了原先 a 和 item1,item2,…,itemN中的所有元素
  • a.join(sep); //返回一个包含数组中所有元素的字符串,每个元素通过指定的sep分隔
  • a.join(sep).splice(sep); //返回原数组
  • a.splice(start,delcount,item1,item2,…,itemN);//从start开始,删除delcount个元素。然后插入所有的item
  • a.slice(start,end);//返回子数组,以a[start]开头,以a[end]前一个元素结尾。
  • a.pop(); //删除并返回 数组中的最后一个元素
  • a.shift(); //删除并返回 数组中的第一个元素
  • a.unshift(item);//将item插入数组头部,并返回数组新长度(考虑undefined)
  • a.push(item1,item2); //将item1,item2追加至数组a末尾
  • a.reverse();//数组逆序
  • a.sort(cmpfn);//依据cmpfn返回的结果进行排序,如果为指定比较函数,则按字符顺序比较(即使元素是数字)

函数

apply()

apply()方法调用一个函数,其具有一个指定的this值,以及作为一个数组(或 类似数组的对象),提供的参数。
call()方法与apply()方法类似,只有一个区别,就是call()方法接受的是若干个参数的列表,而apply()方法接受的是一个包含多个参数的数组
语法

1
fun.apply(thisArg,[argsArray])

参数
thisArg
在fun函数运行时指定的this值。需要注意的是,指定的this值并不一定是该函数执行时真正this值,如果这个函数处于非严格模式下,则指定为null或undefined时会自动指向全局对象(浏览器中就是window对象),
同时值为原始值(数字、字符串、布尔值)的this会指向该原始值的自动包装对象
argsArray
一个数组或者类数组对象,其中的数组元素将作为单独的参数传给fun函数。如果该参数的值为null或undefined,则表示不需要传入任何参数

单纯表情包

函数

1
2
3
4
function add(x, y){
var total = x + y;
return total;
}

return 语句返回一个值并结束函数。如果没有使用return函数语句,或者一个没有值的return语句,JavaScript会返回undefined。
函数实际上市访问了函数体中一个名为arguments的内部对象,这个对象就如同一个类似于数组的对象一样,包括了所有被传入的参数。让我们重写一下上面这个函数,使他可以接收任意个数的参数:

1
2
3
4
5
6
7
8
function add() {
var sum = 0;
for(var i = 0, j = arguments.length; i < j; i++){
sum += arguments[i];
}
return sum;
}
add(2, 3, 4, 5);//14

求平均数

1
2
3
4
5
6
7
8
function avg() {
var sum = 0;
for(var i = 0,j = arguments.length.length; i < j; i++){
sum += arguments[i];
}
return sum/arguments.length;
}
avg(2,3,4,5); //3.5

如果处理一个数组的平均值,js允许使用 任意函数对象的 apply() 方法来调用函数,并传递给他一个包含了参数的数组。

1
avg.apply(null,[2,3,4,5]); //3.5

js允许以递归的方式调用函数。递归在处理树形结构时非常有用。

自定义对象

在经典的面向对象的语言中,对象是指数据和在这些数据上进行的操作的集合,js是一种基于原型的编程语言,并没与class语句,而是把函数用作类。
我们来定义一个人名对象,名(first) 姓(last);姓(last)名(first)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function makePerson(first,last){
return{
first: first,
last: last,
fullName: function(){
return this.first + ' ' + this.last;
},
fullNameReversed: function(){
return this.lasr + ' ' + this.first;
}
}

}
s = makePerson('Angela','Ma');
s.fullName(); //Angela ma
s.fullNameReversed(); Ma Angela

上面的代码中用到了关键字this,当在函数中使用时,this指代当前的对象,也就是调用了函数的对象。
如果在一个对象上使用 点或者方括号 来访问属性或者方法,这个对象就成了this。
如果并没有使用”点”运算符调用某个对象,那么this将指向全局对象。这是一个经常出错的地方。

1
2
3
4
s = makePerson('Angela','Ma');
var fullName = s.fullName();
fullName(); //undefined undefined
fullName('Angela','Ma');// undefined undefined

当我们调用 fullName() 时,this实际上是指向全局对象的,并没有名为first或last的全局变量,所以他们两个的返回值都是 unfined.

下面使用关键字this改进已有的函数

1
2
3
4
5
6
7
8
9
10
11
function Person(first,last) {
this.first = first;
this.last = last;
this.fullName = function() {
return this.first + ' ' + this.last;
}
this.fullNameReversed = function() {
return this.last + ' ' + this.first;
}
}
var s = new Person('Angela','Ma');

我们引入了另外一个关键字:new,他和 this 密切相关。他的作用是创建一个崭新的空对象,然后使用指向那个对象的 this 调用特定的函数。
注意,含有 this 的特定函数不会返回任何值,只会修改 this 对象本身。 new 关键字将生成的 this 对象返回给调用方,而被 new 调用的函数成为构造函数。

这个改进有相同的问题,单独调用 fullName() 还是undefined

虽然我们的perSon已经相当完善了,但是还有可以改进的地方么?当然,每次我们创建一个Person 对象的时候,我们都在其中创建了两个新的函数对象,如果这个代码可以共享不是更好么

1
2
3
4
5
6
7
8
9
10
function Person(first, last){
this.first = first;
this.last = last;
}
Person.prototype.fullName = function() {
return this.first + ' ' + this.last;
}
Person.prototype.fullNameReversed = function() {
return this.last + ' ' this.first;
}

Person.protoType 是一个可以被 Person 的所有实例共享的对象。他是一个名叫原型链的查询链的一部分:当你试图访问一个 Person 没有定义的属性时,解释器会首先检查这个 Person.prototype 来判断是都存在这样一个属性。
所以,任何分配给 Person.prototype 的东西 对 通过this对象构造的实例 都是可用的。

这个特性功能十分强大,js允许你在程序中的任何时候修改原型中的一些东西,也就是说你可以在运行时给已存在的对象添加额外的方法:

1
2
3
4
5
6
7
s = new Person("Angela", "Ma");
s.firstNameCaps(); // TypeError on line 1: s.firstNameCaps is not a function

Person.prototype.firstNameCaps = function() {
return this.first.toUpperCase()
}
s.firstNameCaps(); //

有趣的是,还可以给js的内置函数原型添加东西,让我们给 String 添加一个方法用来返回逆序的字符串:

1
2
3
4
5
6
7
8
9
10
11
var s = 'Angela';
s.reversed(); // TypeError on line 1: s.reversed is not a function

String.prototype.reversed = function() {
var r = "";
for(var i = this.length; i >= 0; i--){
r += this[i];
}
return r;
}
s.reversed(); //alegnA

定义新方法也可以在字符串字面量上用

1
"This can now be reversed".reversed(); // desrever eb won nac sihT

正如我前面提到的,原型组成链的一部分。那条链的根节点是 Object.prototype,它包括 toString() 方法——将对象转换成字符串时调用的方法。这对于调试我们的 Person 对象很有用:

1
2
3
4
5
6
7
var s = new Person("Simon", "Willison");
s; // [object Object]

Person.prototype.toString = function() {
return '<Person: ' + this.fullName() + '>';
}
s.toString(); // <Person: Simon Willison>

你是否还记得之前我们说的 avg.apply() 中的第一个参数 null?现在我们可以回头看看这个东西了。apply() 的第一个参数应该是一个被当作 this 来看待的对象。下面是一个 new 方法的简单实现:

1
2
3
4
5
function trivialNew(constroctor,...args) {
var o = {};
constroctor.apply(o,args);
return o;
}

这并不是 new 的完整实现,因为它没有创建原型(prototype)链。想举例说明 new 的实现有些困难,因为你不会经常用到这个,但是适当了解一下还是很有用的。在这一小段代码里,…args(包括省略号)叫作剩余参数(rest arguments)。如名所示,这个东西包含了剩下的参数。因此调用

1
var bill = trivialNew(Person,"Angela","Ma");

可以认为和下面语句是等效的

1
var bill = new Person("Angela","Ma");

apply() 有一个姐妹函数,名叫 call,它也可以允许你设置 this,但它带有一个扩展的参数列表而不是一个数组。

1
2
3
4
5
6
7
8
function lastNameCaps() {
return this.last.toUpperCase();
}
var s = new Person("Simon", "Willison");
lastNameCaps.call(s);
// 和以下方式等价
s.lastNameCaps = lastNameCaps;
s.lastNameCaps();

内部函数

js允许在一个函数内部定义函数,一个很重要的细节是他们可以访问父函数作用域中的变量:

1
2
3
4
5
6
7
function betterExampleNeeded() {
var a = 1;
function oneMoewThanA() {
return a + 1;
}
return onMoreThanA;
}

这也是一个减少使用全局变量的好方法。
内部函数可以共享父函数的变量,所以可以使用这个特性把一些函数捆绑在一起,这可以有效的防止”污染”全局命名空间——可以称为“局部全局”。

闭包

1
2
3
4
5
6
7
8
9
function makeAdder(a) {
return function(b) {
return a + b;
}
}
var x = makeAdder(5);
var y = makeAdder(20);
x(6); //11
y(7); //27

makeAdder 函数 :创建了一个新的Adder函数,这个函数自身带有一个参数,他被调用的时候这个参数会被加在外层函数传进来的参数上。

这与内嵌函数十分相似:

  • 一个函数被定义在另一个函数内部,内部函数可以访问外部函数的变量
  • 唯一不同的是,外部函数被返回了,常识告诉我们局部变量“应该”不存在了,但是仍然存在,否则adder函数将不能工作

到底发生了什么?
每当js执行一个函数时候,都会创建一个作用域对象,用来保存在这个函数中创建的局部变量。他和被传入函数的变量一起被初始化。
这与全局对象和函数的全局对象类似,但仍有一些很重要的区别。

  • 第一、每次函数被执行的时候,都会创建一个 新的、特定的 作用域对象;
  • 第二、与全局对象不同的是,不能直接从js代码中直接访问作用域对象,也没有可以遍历当前作用域对象里面属性的方法

所以当调用makeAdder时,解释器创建了一个作用域对象,它带有一个属性: a,这个属性 被当做参数传入makeAdder函数。然后makeAdder返回一个新创建的函数。
通常js的垃圾回收器会在这时回收 makeAdder 创建的作用域对象,但是 返回的函数 却保留了一个指向那个 作用域对象的引用。 结果是这个作用域对象不会被垃圾回收器回收,
直到指向 makeAdder 返回的那个 函数对象的引用 计数为零。

作用域对象组成了一个名为 作用域链 的链。它类似于原型链一样,被js的对象系统使用。

一个闭包 就是一个函数 和 被创建的函数中的作用域对象 的组合。

内存泄漏

使用闭包的一个坏处是,在IE浏览器中它容易导致 内存泄漏。js是一种具有垃圾回收机制的语言——对象再被创建的时候 分配内存,然后当指向这个对象的引用计数为零时,浏览器会回收内存。宿主环境提供的对象都是按照这种方法被处理的。

浏览器需要处理大量的对象来描绘一个正在被展现的html页面——DOM对象。浏览器负责管理他们的内存分配和回收。

IE浏览器有自己的一套垃圾回收机制,这套机制与js提供的垃圾回收机制进行交互的时候,可能会发生内存泄漏。

在IE中,每当一个js对象 和 一个本地对象 之间形成循环引用时,就会发生内存泄漏。

1
2
3
4
5
6
// bad  
function leakMemory() {
var el = document.getElementById('el');
var o = {'el':el};
el.o = o;
}

这段代码的循环引用会导致内存泄漏:IE 不会释放被 el 和 o 使用的内存,直到浏览器被彻底关闭并重启后。

一般也很少发生如此明显的内存泄漏现象——通常泄漏的数据结构有多层的引用,往往掩盖了循环引用的情况。

闭包很容易发生 无意识的内存泄漏。

1
2
3
4
5
6
function addHandler() {
var el = document.getElementById('el');
el.onclick = function() {
el.style.backgroundColor = 'red';
}
}

这段代码创建了一个元素,当他被点击的时候变红,但同时也发生了内存泄漏。
为什么?
因为对 el 的引用不小心 放在一个匿名内部函数中。这就在js对象(内部函数) 和 本地对象(el)之间创建了一个循环引用。

这个问题有很多种解决办法,最简单的一种就是不要使用 el 变量

1
2
3
4
5
function addHandler() {
document.getElementById('el').onclick = function() {
this.style.backgroundColor = 'red';
}
}

另外一种避免闭包的好方法就是在 windows.onunload 事件发生期间破坏 循环引用。许多事件库都能完成这项工作。注意这样做将使 Firefox 中 的becache 无法工作。
所以除非有其他必要的原因,最好不要在Firefox中注册一个 onunload 的监听器。