对象

对象:无序属性的集合,其属性可以包含基本值、对象或者函数

ECMA-262

JavaScript中的对象和其他OO(Object-Oriented,面向对象)语言不大相同。它没有类的概念。所以根据ECMAScript的定义,对象无非就是一组键值对,类似于散列(Hash)表的概念,其中的值可以是基本类型也可以是对象或函数。

一个常见的对象像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var Stu = new Object();
Stu.name = 'shenlvmeng';
Stu.age = 23;
Stu.school = 'BUPT';
Stu.sayHi = function () {
alert('Hi');
};

//或者直接使用字面量
var Stu = {
name: 'shenlvmeng',
age: 23,
school: 'BUPT',
sayHi: function () {
alert('Hi');
}
}

对象属性

对象属性(property)是实现JavaScript引擎用的,由两对方括号包裹,表示是内部值,如[[Enumerable]]。ECMAScript中目前分两种属性:数据属性访问器属性

数据属性

  • [[Configurable]]: 表示能否通过delete删除属性,能否修改属性。默认为true。
  • [[Enumerable]]: 能否通过for-in语句循环返回属性。默认为true。
  • [[Writable]]: 如同字面意思,能否修改属性的值。
  • [[Value]]: 属性的数据值。

数据属性可以直接通过字面量来定义。可以通过ECMAScript 5中Object.defineProperty()方法修改对象默认属性。方法接受三个参数:属性所在对象,属性名,和一个描述符对象。其中描述符对象的属性必须是上述4个属性的子集。值得注意的是,修改configurable为false后,将无法将其变为true。此时,只能修改writablevalue

在使用Object.defineProperty()创建新属性时,若不指定,前三项的属性默认均为false

访问器属性

访问器属性不包含数据值(即value),取而代之的是getter和setter两个函数。不过它们也不是必须的。

  • [[Configurable]]: 表示能否通过delete删除属性,能否修改属性。默认为true。
  • [[Enumerable]]: 能否通过for-in语句循环返回属性。默认为true。
  • [[Get]]: 读取属性时调用的函数,默认为undefined
  • [[Set]]: 写入属性时调用的函数,默认为undefined

访问器属性不能直接定义。必须使用Object.defineProperty()方法定义。未指定getter或setter时,意味着属性不可读或不可写。强制读写时,在严格模式下会抛出错误,非严格模式下会返回undefined。

1
2
3
4
5
6
7
8
9
10
11
12
13
var person = {
_name: '', //`_`表示只能通过方法访问
nickname: '酱'
};
Object.defineProperty(person, 'name', function () {
get: function () {
return this._name;
},
set: function (newValue) {
this.nickname = newValue + '酱';
this._name = newValue;
}
});

访问器属性实际上使得数据劫持得以实现,即在存取属性值时执行预定义的操作。Vue的数据绑定就是这么来实现的(具体的实现方式见另外的博文)。不过支持Object.defineProperty()方法的浏览器需要IE9+,Firefox4+,Safari5+,Opera12+,Chrome。在这个方法前,通常使用两个非规范的方法__defineGetter__()__defineSetter__(),它们是对象的prototype中的的方法。

IE8其实也实现了`Object.defineProperty()`方法,不过存在诸多限制,只能对DOM对象使用。

ECMAScript 5还定义了一个Object.defineProperties()方法,用于为对象定义多个属性。用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var person = {};
Object.defineProperties(person, {
_name: {
writable: true,
value: 'shenlvmeng'
},
nickname: {
writable: true,
value: 'shenlvmeng酱'
},
name: {
get: function () { return this._name; },
set: function (newName) { this._name = newName; this.nickname = newName + '酱'; }
}
})

读取属性的特性

Object.getOwnPropertyDescriptor()方法可以读取指定对象属性的描述符。如果属性是数据属性,则返回对象包含configurable, enumerable, writable, value;如果属性是访问器属性,则返回对象包括configurable, enumerable, get, set。支持这个方法的浏览器包括IE9+,Firefox4+,Safari5+,Opera12+,Chrome。

创建对象

最简单的创建方法是通过Object构造函数和字面量的形式,如第一段代码里展示的那样。不过很显然,这么做有点蠢。在创建大量对象的时候,会产生成吨的重复代码。于是就产生了下面这些工程手段。

工厂模式

既然会产生重复代码,那么将这些重复代码封装成函数不就行了么。工厂模式就这么出现了。

1
2
3
4
5
6
7
8
9
10
11
12
function createPerson (name, age, school) {
var o = new Object(); //用字面量的形式亦可
o.name = name;
o.age = age;
o.school = school;
o.sayHi = function () {
alert(this.name + ' say hi.');
}
return o;
}
var p1 = createPerson('shenlvmeng', 23, 'BUPT');
p1.sayHi();

这么做解决了创建相似对象的问题,不过并没解决对象识别的问题。而且可以复用的函数创建了很多次。

构造函数模式

ECMAScript中的构造函数可用来创建特定类型的对象,从而解决了对象识别的问题。上面的例子用构造函数模式重写如下:

1
2
3
4
5
6
7
8
9
10
function Person (name, age, school) {
this.name = name;
this.age = age;
this.school = school;
this.sayHi = function () {
alert(this.name + ' say hi.');
}
}
var p1 = new Person('shenlvmeng', 23, 'BUPT');
p1.sayHi();

通过构造函数创建实例时,需要使用new操作符。创建的步骤如下:

  1. 创建一个空对象
  2. 将构造函数的作用域(this)赋给空对象
  3. 执行函数代码
  4. 返回这个对象
1
2
3
4
p1.constructor == Person //true
p1 instanceof Person //true
p2 = new Person('weii', 23, 'BUPT');
p1.constructor == p2.constructor //true

返回对象会自带constructor属性,指向构造函数本身。可以用来区分对象类型。不过使用instanceof操作符要更可靠些。因为构造函数本身也是函数,只是用来构造对象而已。为了和其他函数区分开,通常命名首字母使用大写字母。这么做是为了避免一个问题:当不使用new操作符调用构造函数时,函数作用域并不会指向新创建的函数,因此this实际上是进入函数时的全局作用域,从而会污染全局作用域。

可以看到一个问题,使用构造函数模式并未解决函数复用的目标,同样的函数创建了许多次,通过p1.sayHi == p2.sayHi //false即可发现。为了复用函数,可以把函数放在构造函数外。

1
2
3
4
5
6
7
8
9
function Person (name, age, school) {
this.name = name;
this.age = age;
this.school = school;
this.sayHi = sayHi;
}
function sayHi () {
alert(this.name + ' say hi.');
}

这么做却又带来了副作用,全局作用域中定义的函数,实际上只能被函数调用。且不同对象的方法作为全局函数混杂在一起,封装性很差劲。

原型模式

原型(prototype)的设计解决了这个大问题。每个函数都有一个prototype属性。这个属性是一个指针,指向一个对象,包含由该函数构造对象共享的属性和方法。从而,不必在构造函数中定义所有实例共有的属性和方法。

1
2
3
4
5
6
7
function Person () {};
Person.prototype.name = 'shenlvmeng';
Person.prototype.age = 23;
Person.prototype.school = 'BUPT';
Person.prototype.sayHi = function () {
alert(this.name + ' say hi.');
}

原型对象

无论何时,只要创建了一个新函数,就会相应的为该函数创建一个prototype属性,指向该函数的原型对象。默认情况下,所有原型对象都会有一个constructor属性指向prototype属性所在函数。

创建自定义的构造函数时,原型对象默认只会有constructor属性,其他的方法都继承自Object。在调用构造函数创建对象实例后,实例的内部都有一个指针[[prototype]]指向构造函数的原型对象。这个指针是内部的,但在FF,Safari,Chrome中,有__proto__属性可以访问。

是不是听起来有点晕,下面的图(来自红宝书)形象地说明了上面这些关系。

虽然无法访问到[[prototype]]属性,但是可以通过prototype的isPrototypeOf()方法确认对象和原型的对应关系,或ES5中的Object.prototype()得到[[prototype]]的值。

代码在尝试读取对象属性的时候,会先从实例本身属性开始,若找到同名属性,则返回值;如果没有找到,则继续搜索指针指向的原型对象。例如,因为原型对象中包含constructor属性,所以实际上对象实例也都可以访问到constructor这个属性。在为对象实例添加属性时,这个属性会屏蔽(不是覆盖)原型对象中的同名属性。通过对象的hasOwnProperty()方法,可以检测属性来自实例还是原型对象。

in 操作符

in操作有两种使用方法,单独使用和配合for-in循环使用。前者在对象可以访问给定属性时返回true

1
2
3
4
5
alert(p1.hasOwnProperty('name')); //false
alert('name' in p1); //true
delete p1.name;
alert(p1.hasOwnProperty('name')) //false
alert('name' in p1); //true name属性来自原型

for-in循环可以访问所有对象可以访问的、可枚举(enumerable)的属性。既包含实例自身属性,也包含原型中的属性。下面是个简单的例子。

1
2
3
4
var body = document.body.attributes;
for (var prop in body) {
console.log(prop + ': ' + body[prop]);
}

使用最新的Object.keys()可以获取所有键名,使用Object.getOwnPropertyNames()方法可以返回所有实例属性,而不论是否可枚举。

重写prototype

上面原型模式里一个一个属性为prototype赋值的方法略显重复,可以直接通过对象字面量的形式重写整个原型对象。但是需要注意的是,重写后的原型对象中的constructor属性继承自Object。此时只能通过instanceof操作符确定对象类型了。

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person () {};
Person.prototype = {
name: 'shenlvmeng',
age: 23,
school: 'BUPT',
sayHi: function () {
alert(this.name + ' says hi.');
}
}
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person
});

因为在原型中查找值是一次搜索的过程,我们对原型对象所做的修改都会立即在对象中体现出来。

问题

原型对象中的所有属性和方法都在实例中共享,这在某些场景下可能并不是我们想要的。比如,不同的Person间应该总有些自己的属性。这些应该在构造函数中体现出来。

组合使用构造函数和原型模式

如上面所说,将实例的属性放在构造函数中,将共有属性和方法放在原型对象中,可以最大程度减少无谓的内存占用。因此,这也是目前使用最多的创建自定义类型的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person (name, age, school) {
this.name = name;
this.age = age;
this.school = school;
}

Person.prototype = {
constructor: Person,
sayHi: function () {
alert(this.name);
}
}
p1 = new Person('shenlvmeng', 23, 'BUPT');
p2 = new Person('weii', 29, 'BUPT');
alert(p1.sayHi == p2.sayHi);

其他

寄生构造函数和稳妥构造函数在有些时候也用来构造对象。前者仅仅将创建对象的代码封装起来,通过new操作符调用,内部不使用this。这种情况下,对象和构造函数实际上没有关系,因此不能使用instanceof操作符确定类型。

稳妥构造函数有Douglas Crockford提出,利用了闭包的特点,保证了内部数据的安全性和封装性。函数内部没有公共属性,也不引用this对象。

1
2
3
4
5
6
7
8
9
function Person (name, age, school) {
var o = new Object();
o.sayHi = function () {
alert(name + ' says hi.');
}
return o;
}
var p1 = Person('shenlvmeng', 23, 'BUPT');
p1.sayHi();

继承

传统面向对象语言支持继承接口和继承实现。前者只继承签名,实现接口;后者继承实现的方法。ECMAScript只支持后者(ECMAScript中没有函数签名)。其实现主要利用原型链。

原型链

ECMAScript中最基本的继承方式,它的思想在于通过原型让一个自定义类型用于另一个类型的属性和方法。具体实现上,只用将一个对象实例作为另一个对象的原型对象即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//父类
function SuperType (name) {
var name = name;
}
SuperType.prototype.sayName = function () {
alert(this.name);
}
//子类
function Subtype (age) {
this.age = age;
}
//实现继承
SubType.prototype = new SuperType('shenlvmeng');
SubType.prototype.sayAge = function () {
alert(this.age);
}

var o = new Subtype(23);
o.sayAge();

上面的关键一步就是将父类的实例赋给子类的prototype,从而子类的所有实例可以共享父类的所有属性和方法。在上面的步骤中,SubType默认的原型被替换为SuperType的实例,所以实际上,SubTypeconstructor属性成为了SuperType。这是因为,再找不到属性或方法时,搜索过程会一步一步向原型链末端前进,直到Object。

下面是SuperType和SubType构造函数以及原型对象间的关系。

可以发现,使用原型链的一个问题是,父类的实例属性变成了子类的原型属性,分享在子类所有实例间。很显然是不合理的。第二,创建子类型时,为超类构造函数传递的参数将作为原型对象影响整个子类对象实例。

借用构造函数

这种方法的思路是,在子类构造函数调用父类的构造函数,并将执行环境绑定在子类环境中。这样可以方便地向父类构造函数中添加自己的参数而不影响其他的子类实例。

1
2
3
4
5
6
7
8
9
10
function SuperType (newFriend) {
this.friends = ['Alice', 'Bob', 'Caley'].push(newFriend);
}
function SubType (newFriend) {
SuperType.call(this, newFriend);
this.newFriend = newFriend;
}
var o = new SubType('Dude');
alert(o.friends); //[Alice, Bob, Caley, Dude]
alert(o.newFriend); //Dude

这么做的缺点也很明显,函数的定义都需要在构造函数中重新写一遍。因此,这种技术很少单独使用。

组合继承

组合继承发挥了上面两者的长处,通过原型链继承了原型属性和方法,通过构造函数实现对父类实例属性的继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function SuperType (newFriend) {
this.friends = ['Alice', 'Bob', 'Caley'].push(newFriend);
}
function SubType (newFriend) {
//继承属性
SuperType.call(this, newFriend);
this.newFriend = newFriend;
}
//继承方法
SubType.prototype = new SuperType();
//修改构造函数,新添新方法
SubType.prototype.constructor = SubType;
SubType.prototype.sayHi = function () {
alert(this.newFriend + ' says hi.');
}

组合继承是JavaScript中最常用的集成模式。不过它实际上调用了两次父类的构造函数,因此后面介绍的寄生组合式继承方法又对此进行了优化。

原型式继承

Douglas CrockFord在2006年提出可以通过原型基于已有对象创建新对象,还不必创建自定义类型。函数大概像下面这样

1
2
3
4
5
function object(o) {
function F() {}
F.prototype = o;
return new F();
}

可以看到,本质上,object函数只是对传入的对象o进行了一层浅复制。从而其中的引用类型将会在返回的对象间共享。ECMAScript 5对这种通过对象创建对象的原型式继承方式进行了规范,新增了Object.create()方法。方法接受两个参数,一个作为新对象原型,一个作为新对象新增的额外属性。

寄生式继承模式和原型式继承很类似。它将创建一个仅用来封装继承过程的函数,在函数内部处理增强对象的过程。功能和Object.create()类似。但是这么做不能做到函数复用,从而效率会降低,用在简单的场景下。

寄生组合式继承

这种模式解决了组合式继承的弊端——调用两次父类构造函数。其中SubType构造函数中的调用作为实例的属性将覆盖原型中的同名属性。寄生组合式继承的关键在于:不必为了指定子类型的原型而调用父类的构造函数,我们不过是要一个父类原型的副本而已。因此,可以得到下面这样的基本模式:

1
2
3
4
5
function inheritPrototype (subtype, supertype) {
var prototype = object(superType.prototype) //或Object.create(superType.prototype)
prototype.constructor = subtype;
subType.prototype = prototype;
}

上面的三步分别是创建对象,添加constructor属性,替换子类原型。从而在继承的过程中只调用了1次SuperType函数。同时原型链保持了不变。因此insanceof和isPrototypeOf()可以正常使用。寄生组合式继承是最理想的继承模式。就像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SuperType (newFriend) {
this.friends = ['Alice', 'Bob', 'Caley'].push(newFriend);
}
function SubType (newFriend) {
//继承属性
SuperType.call(this, newFriend);
this.newFriend = newFriend;
}
//继承方法
inheritPrototype(SubType, SuperType);
//新增新方法
SubType.prototype.sayHi = function () {
alert(this.newFriend + ' says hi.');
}

总结

总结一下,ECMAScript支持面向对象编程,但没有类和接口的概念。对象和原型的定义和关系比较松散。

在创建对象上,有工厂模式原型对象构造函数利用闭包几种方式可选,它们也可以组合使用。

在实现继承上,可以借助原型链、构造函数和寄生组合式的模式实现比较严格的继承,原型式和寄生式模式用于不那么严格的对象间继承。

函数

函数是JavaScript中最有特色同时又容易让人困扰的特性。定义函数的方式有两种:函数声明和函数表达式。

1
2
3
4
5
6
7
8
9
//函数声明
function foo (arg0, arg1) {
//函数体
}

//函数表达式
var bar = function () {
//函数体
}

在非IE浏览器中,function都有非标准的name属性,属性值为function后的标识符或表达式的接收变量名。在函数声明里有一个重要特征——函数声明提升(function declaration hoisting)。这意味着函数声明可以放在调用它的语句后。

1
2
3
4
sayHi();
function sayHi () {
alert("Hi!"); // "Hi!"
}

而函数表达式则不能这样使用,因为变量声明提升会将函数名提升,下面的代码将导致错误。

1
2
3
4
sayHi();
var sayHi = function () {
alert("Hi!"); // Error!
}

正确理解函数声明提升将会避免很多潜在的错误,或者干脆养成好习惯——定义在前,调用在后

递归

递归函数是一个函数通过调用自身得到的。如

1
2
3
4
5
6
7
function factorial (num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num-1);
}
}

这是一个经典的递归阶乘函数。不过当我们不知道函数名或者函数是匿名函数时,可以通过arguments.callee来调用自身。
argument.callee是一个指向正在执行的函数的指针。

在ES5的严格模式下,`arguments.callee`特性是禁止使用的。它将影响解释器的优化和运行效率。

闭包

闭包几乎是前端面试必考的一个知识点。它的存在是JavaScript中作用域链带来的特性。闭包是指有权访问另一个函数
作用域中变量的函数。创建闭包最常用的方式就是在函数内部创建另一个函数。就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
function fatherFunction (propertyName, value) {
var sum = value;
return function (object1, object2) {
var a = object1[propertyName],
b = object2[propertyName];
if (a + b > value) {
return 1
} else {
return 0;
}
}
}

可以看到,在返回的函数中引用了外部函数的变量propertyNamesum。即使这个函数已经返回,只要内部函数还存在,那么这两个变量就仍然可以访问。这就是闭包的直观体现。

解释闭包就要先理解JS中的作用域链。执行环境是JS中的一个关键概念。它定义了变量或函数可以访问的数据。全局执行环境是最外层的执行环境。根据ECMAScript实现宿主的不同,全局执行环境也各不相同。某个执行环境中的代码执行完毕后,环境被销毁,包括其中的所有变量和函数定义。JS中的每个函数都有自己的执行环境。执行流进入一个函数时,函数环境就被推入到环境栈中,待执行完毕后出栈。在执行环境中的代码时,会创建变量对象的作用域链,由当前的活动对象到外部环境变量对象一直到全局执行环境中的变量对象。内部环境可以通过作用域链访问所有外部环境,但是外部环境不能访问内部环境中的变量和函数。

现在回过头看闭包。在函数执行过程中,为了读取和写入变量值,需要保存整个作用域链。因此,在fatherFunction中创建的匿名函数的作用域链实际上包含了fantherFunction()的活动对象(输入参数和变量)以及全局变量对象。在fatherFunction()返回时,匿名函数的作用域链仍然引用着它的活动对象,使其并不会被销毁,直到匿名函数被销毁。

1
2
3
4
5
6
// 创建函数
var compare = fatherFunction("value", 0);
// 调用函数
var res = compare({value: 2}, {value: -1});
// 解除匿名函数的引用,释放内存
compare = null;
由于闭包会携带包含它的函数作用域,过度使用闭包会导致内存占用过多。忘记解除匿名函数引用还会导致内存泄漏。

闭包与变量

闭包可以取得父级函数的变量的最终值,因此配合for循环这样的结构就容易发生意外,就像下面的函数:

1
2
3
4
5
6
7
8
9
10
function bindClickFunctions () {
var buttons = document.getElementsByTagName('button');
// 让我们假设length是20
for (var i = 0, len = buttons.length; i < len; i++) {
buttons[i].onclick = function () {
alert(i);
}
}
return;
}

上面的函数会为所有的按钮绑定点击事件,不过效果却并不像预想中那样,不同的button弹出不同的值。结果是所有的button在点击后弹出的都是20。这是因为所有的匿名函数都使用着同一个外部函数的活动对象。可以通过在创建一层闭包来达到预期的目的。

1
2
3
4
5
6
7
8
9
10
11
function bindClickFunctions () {
var buttons = document.getElementsByTagName('button');
for (var i = 0, len = buttons.length; i < len; i++) {
buttons[i].onclick = function (i) {
return function () {
alert(i);
}
}(i);
}
return;
}

我们在每层循环中创建了一个匿名函数,匿名函数包含一个输入参数i,再分别保存在内部函数的作用域链中,就可以使闭包间引用的i互不干扰了。

块级作用域

JavaScript中是没有块级作用域的。不过可以利用匿名函数的作用域模拟一个块级作用域出来。在其中定义的私有变量也不必担心与其他作用域的变量名相冲突。这种用法很常用于最外层的封装,用于隐藏代码中的变量,在一定程度上保证安全。

1
2
3
4
(function(){
var foo = "You can see me, but you cannot touch me."
alert(foo);
})()

私有变量

同样的,JavaScript中是没有私有成员的概念的。但是,利用闭包可以制造出私有变量。原理是,利用函数作用域隐藏其中的变量甚至输入参数,通过返回的闭包操作这些“私有”变量。如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
function Stu (name) {
this.getName = function () {
return name;
};
this.setName = function (value) {
name = value;
}
}
var stu = new Stu("Shenlvmeng");
alert(stu.getName());
stu.setName("Weii");
alert(stu.getName());

这里只是一个很简单的展示,红宝书中还介绍了模块模式和增强模块模式,利用闭包的特点实现了单例的构造和特权方法。下面对上面的Stu函数进行改造,可以使得所有通过Stu()构造的对象都有相同的公有变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(function(){
var name = '';

Stu = function (value) {
name = value;
};

Stu.prototype.getName = function () {
return name;
};

Stu.prototype.setName = function (value) {
name = value;
};
})();

var stu1 = new Stu('shenlvmeng');
alert(stu1.getName());
var stu2 = new Stu('weii');
alert(stu2.getName()); // "weii"
alert(stu1.getName()); // "weii"

DOM

DOM(文档对象模型)是针对HTML和XML文档的一个API,描绘了层次化的节点树。开发者可以借助DOM对页面的某一部分进行添加、移除、修改。DOM来源于网景和微软提出的DHTML。1998年10月,DOM 1级规范称为W3C的标准,为文档查询和改动提供了接口。各大主流浏览器都完善地实现了DOM。


DOM把HTML和XML文档描述成一个多层节点构成的结构。每个节点有都有自己的特点、数据和方法。

文档节点是每个文档的根节点。HTML中<html>元素是文档节点唯一的子节点,又称为文档元素。每一个标签都通过树中的一个节点表示,HTML元素表示为元素节点,特性通过特性节点表示,文档表示为文档节点,如此这样,总共有12中节点类型。

Node

DOM 1级中定义了Node类型。JavaScript中的所有节点类型都继承于Node类型。每个节点都有一个nodeType属性。常用的取值如下:

  • Node.ELEMENT_NODE(1)
  • Node.ATTRIBUTE_NODE(2)
  • Node.TEXT_NODE(3)
  • Node.COMMENT_NODE(8)
  • Node.DOCUMENT_NODE(9)
  • Node.DOCUMENT_TYPE_NODE(10)
  • Node.DOCUMENT_FRAGMENT_NODE(11)

为了确保浏览器兼容性,建议还是将nodeType属性和数字值进行比较,减少使用常量。因为IE没有公开Node类型的构造函数。

1
2
3
if (someNode.nodeType == 1) { // 元素节点
alert("This is an element node.");
}

除了nodeType,节点类型还有nodeNamenodeValue两个属性。它们的取值取决于节点的类型。后面的介绍中也会提到。

节点关系

在父子关系上,每个节点有一个childNodes属性,保存着NodeList对象。这个对象有length属性,也可以通过方括号访问其中的值,也可以通过item()方法访问,但它并不是Array的实例。且DOM结构的变化会实时地反映到这个NodeList对象中。减少使用NodeList可以避免它带来的时延

使用firstChildlastChild属性可以分别访问到列表中第一个和最后一个节点。同时,每个节点都有一个parentNode属性,指向文档树的父节点。

在兄弟关系上,使用nextSiblingpreviousSibling分别可以访问到下一个和上一个兄弟节点。hasChildNodes()在节点包含多个子节点时返回true。

最后,所有节点都有ownerDocument属性,指向整个文档的文档节点。

节点操作

appendChildinsertBefore分别用于在childNodes末尾和某位前插入节点。并返回插入的DOM节点。需要注意的是,如果插入的节点来自于DOM中,则节点会从原来的位置删除

1
2
3
var returnedNode = someNode.appendChild(newNode);
var anotherNode = someNode.insertBefore(newNode, null); // 插入到最后一位
anotherNode = someNode.insertBefore(newNode, someNode.firstChild); // 插入到第二位

replaceChild用于替换节点,cloneChild用于复制节点,cloneChild方法接受一个布尔值参数,表示是否执行深复制。在参数为true时执行深复制,否则执行浅复制。

需要留意的是,`cloneChild`不会复制DOM节点中的JavaScript属性,如事件处理程序。IE则会复制事件处理程序。为了保证一致性,建议在复制前移除事件处理程序。

最后,节点的normalize方法可以删除节点后代中的空文本节点,合并相邻的文本节点。

Document

JavaScript中用Document类型表示文档,浏览器中的document表示整个页面。它是window对象的一个属性。因此可以作为全局对象来访问。

它的nodeType为9,nodeName为"#document",子节点可以是一个DocumentType或Element类型的节点。在HTML中可以通过documentElement属性便捷地得到子节点<html>。所有浏览器都支持这个属性。document.body属性也都被支持。

除了<html>,另一个可能的子节点是DocumentType,即<!DOCTYPE>标签。浏览器对它的支持差异很大。同样的还有<html>元素外的注释。

document还有下面4个独特的HTML属性:

  • title包含网页的标题,属性的修改直接对应<title>元素
  • URL表示页面的URL,只读
  • domain表示页面的域名,可修改
  • referer保存链接到当前页面的URL,只读

其中修改domain可以用来访问同一父级域名下的iframe中的资源。

为了保证安全,域名修改只能由“紧绷”变“松散”,而不能反过来。

查找元素

DOM 1级标准只有getElementById()getElementsByTagName()getElementsByName()三种方法。具体功能不必再提。它们返回的是一个HTMLCollection对象。这个对象和NodeList很类似。同样可以通过方括号访问其中的子元素。不过HTMLCollection额外提供namedItem()方法,通过name属性值访问其中的子元素。

除此以外,document对象还有一个特殊的集合,它们也都是HTMLCollection对象。如document.anchorsdocument.formsdocument.imgsdocument.links

其他

document.implementation属性用来检测浏览器对DOM实现的程度,它有一个hasFeature()

write()writeln()open()close()方法可以将输出流写入到网页中。

Element

除了document外,Element类型应该是HTML中最常用的类型了。它的nodeType为1,nodeName为元素的标签名,tagName属性也可以用来访问元素标签名。

所有HTML元素都由HTMLElement的子类型表示。所有的HTML元素都具有下面的一些标准特性:

  • id:元素的唯一标识符
  • title:有关元素的附加说明信息
  • lang:元素的语言代码
  • dir:语言的方向
  • className:与元素的class对应

这些都可以通过访问元素的属性得到。如:

1
<div id="myDiv" class="foo" title="text" lang="en" dir="ltr"></div>

元素的信息可以通过下面的方式得到,同样,这些属性可以直接赋予新值来修改。

1
2
3
4
5
6
var div = document.getElementById("myDiv");
console.log(div.id);
console.log(div.class);
console.log(div.title);
console.log(div.lang);
console.log(div.dir);

未完待续

引子

ECMAScript的变量是松散类型的,所谓松散类型就是可以用来保存任何类型的数据。换句话说,每个变量仅仅是一个用于保存值的占位符而已。

Nicolas C.Zakas --JavaScript高级程序设计

由于JavaScript是一种松散类型的语言,即变量在使用时,并不需要事先知道它的类型。因此不同变量间的比较往往要作类型转换,这也是一些常见quiz的由来。
比如下面的一道面试题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//请写出下面语句的输出结果
if ([]) console.log(1); // 1
if ({}) console.log(2); // 2
if ([] == true) console.log(3); // 无
if ({} == true) console.log(4); // 无
if (null == undefined) console.log(5) // 5
if (NaN == NaN) console.log(6) // 无
if ("5" == 5) console.log(7) // 7
//下面的结果你能写出来么
console.log([] + {}); // "[object Object]"
console.log({} + []); // "[object Object]"
console.log({} - []); // -0
console.log([] - {}); // NaN
console.log([] + []); // ""
console.log([] - []); // 0
console.log({} + {}); // "[object Object][object Object]"
console.log({} - {}); // NaN
//下面的呢
typeof null // "object"
typeof function () {} // "function"
[] instanceof Array // "true"
{} instanceof Object // "true"

怎么样?是不是有点晕,下面我们一部分一部分地来解释JavaScript中一些类型和相等相关的“潜规则”。

数据类型

让我们先从JavaScript的数据类型开始。JavaScript中只有5种基本类型和引用类型。其中5种基本类型分别是:

  • Undefined
  • Null
  • Number
  • Boolean
  • String

除此之外只有1种引用类型——Object,Object本质上是由一组无序的键值对组成。5种基本类型是按值访问的,引用类型Object是按引用访问的。

可以使用typeof操作符监测变量的基本类型。*它可以判断变量是否为除null的其他5种基本类型以及function类型。除此之外都会返回”object”*。之所以null的typeof结果也为”object”,是因为null实际上表示引用指向空对象。

使用instanceof可以判断引用类型的具体值。使用方法类似于A instanceof B的形式。当B为“Object”时,表达式永远返回true。因为根据规定,所有引用类型的值都是Object的实例。

下面是几个例子。通过instanceof操作符可以很方便地区分空数组和空对象(当然还有Object.prototype.toString.call()和[].concat()两种方法。)

1
2
console.log([] instanceof Array); // true
console.log(/w+/g instanceof RegExp); // true

类型转换

to Boolean类型

Boolean类型是ECMAScript中使用最多的类型之一。类型只有true和false两个字面量。true不一定等于1,false也不一定等于0.可以通过调用Boolean()函数将其他类型转型为Boolean类型。规则如下:

  • String类型:非空字符串=>true,空字符串=>false
  • Number类型:非零数字(包括Inifity)=>true, 0和NaN=>false
  • Object类型:任何对象=>true, null=>false
  • Undefined:false

在使用if()语句或三元操作符等情况要求Boolean类型时,括号内的表达式将会自动使用Boolean()函数转换为布尔类型。

to String类型

有两种方法可以将值转为字符串,一种是使用几乎所有值都有的toString方法,对于null和undefined使用另一种——String()函数。

前者适用于除null和undefined外的所有值,甚至String本身(返回一个自身的副本)。有些toString()方法接收一个基数作为参数(如Number)对Object使用toString方法时,会根据对象内toString的定义决定。

  • Array返回逗号隔开的不包括外侧中括号的字符串
  • Function返回Function定义的字符串
  • 普通Object返回”[object Object]”
  • null和undefined分别返回”null”和”undefined”

to Number类型

可以使用Number(), parseInt()和parseFloat()三个函数做强制转换。转换到Number类型的规则要更好理解些。

  • 是Boolean类型时,true和false分别转换到1和0
  • 数字类型时,返回本身
  • null时返回0
  • undefined时返回NaN
  • 对字符串使用类似于parseInt和parseFloat类似的方法(可以识别0x这样的进制前缀甚至Infinity这样的字符串
  • 对象使用valueOf()方法,再使用之前的规则;如果结果是NaN,再使用toString()方法作转换

类型转换场景

一元加减

一元加减只需对操作数强制转换到Number类型。向下面这样的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var s1 = "01";
var s2 = "1.1";
var s3 = "z";
var b = false;
var f = 1.1;
var o = {
valueOf: function() {
return -1;
}
}

s1 = +s1; // 1
s2 = +s2; // 1.1
s3 = -s3; // NaN
b = +b; // 0
f = +f; // 1.1
o = -o; // 1

加性操作符

ECMAScript中规定的加减法这两个操作符有一些特殊行为,不仅处理数值的加减,还处理字符串的加减。因此转换规则还有些复杂。

加法

优先做数值加减,无法完成时做字符串拼接。两个操作数都是数值时,执行常规的加法计算。

  • 一个操作数为NaN时,返回NaN
  • Inifity + -Inifity,返回NaN
  • +0 加 -0,返回+0

只要有一个操作数为字符串类型,应用下面规则:

  • 两个都是字符串时,则将它们拼接起来。
  • 一个是字符串时,先将另一个转换为字符串

布尔值和null以及undefined在另一个操作数是数值类型时转换为数值类型,反之转换为字符串类型
一个操作数为对象时,转换为字符串类型

减法

与加法类似,除了数值相减减法也需要做一些类型转换。但是和加法不一样的是,减法返回的一定是Number类型

  • 一个数值为NaN时,结果为NaN
  • 同号的Infinity相减返回NaN(如Infinity - Infinity),异号的Infinity相减等于第一个操作数
  • 除了-0减+0返回-0,其余0间相减均返回+0
  • 操作数出现字符串、布尔值、null、undefined时,做Number转换再进行数值减法
  • 对象先尝试用valueOf方法获得对象数值,若无此方法则调用toString方法,并转换得到的字符串。

关系操作符

关系操作符即大于(>)、小于(<)、大于等于(>=)和小于等于(<=)。在操作数并非纯数值时,ECMAScript也会进行数据转换或一些奇怪的操作。

  • 两个操作数都是数值时,进行数值比较
  • 两个操作数都是字符串时,按照对应字符编码顺序比较
  • 一个操作数是数值时,转换另一个为数值再比较
  • 一个操作数是对象时,优先使用valueOf方法比较数值,没有该方法时再使用toString方法
  • 任何数和NaN比较都会返回false

相等和全等

相等和全等用于确认两个变量是否相等。对此ECMAScript提供两组操作符:-相等-和-全等-。相等先转换类型后比较,全等仅比较不转换类型。由于情况较多较复杂,这里单独列一节。

ECMAScript中相等操作符为==。不相等操作符为!=。它们都会先强制转型变量再相互比较。转换规则如下:

  • 先将布尔值转换为数值,false转换为0,true转换为1
  • 字符串数值比较时,将字符串转换为数值
  • 两个操作数都是对象时,判断它们是否指向同一个对象(只比较引用)
  • 只有一个操作数是对象时,调用valueOf()或toString()方法获得基本类型值
  • nullundefined是相等的
  • nullundefined在比较时不会被转换
  • NaN出现时,相等操作符返回false

全等操作符为===,对象的不全等操作符为!==。它们不会转换变量类型,相比较类型后比较值。因此行为更容易预测。

CSS3中提供了animation的特性,用来通过指定关键帧(@kenframe)来实现动画效果。这么做方便高效。但是浏览器的兼容效果则比较捉急,且不能实现高级的缓动函数,更别说暂停、回放、倒放等功能了。所以大部分炫酷的动画还是采用JS动画来完成。

传统的JS动画无非是通过setInterval或是setTimeout定时器函数实现。这在对动画实时性以及流畅性要求不高时没有什么问题。不过当消息队列较拥挤时,定时效果不能得到保障。同时不同浏览器的UI渲染频率各不相同,很可能与用户设置的时间间隔相冲突。如,相当一部分浏览器的显示频率是16.7ms,此时如果我们设置的时延是10ms就会出现丢帧的情况。为了解决这个问题,requestAnimationFrame千呼万唤始出来。

requestAnimationFrame是window对象在HTML5中的新API。它的使用方法与setTimeout类似,不同的是,requestAnimationFrame()方法将告诉浏览器您希望执行动画,并请求浏览器调用指定的函数在下一次重绘之前调用回调函数更新动画。从而,不同的动画有了一个统一的刷新机制,可以提升系统性能,节省了CPU、GPU和电池等(CSS中的will-change也发挥着类似功能)。

那么requestAnimationFrame的兼容性如何呢?

好像还不错。在老版本的浏览器上,也有shim方法来实现同样的效果,借助了setTimeout函数。

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
(function () {
"use strict";

var lastTime = 0,
vendors = ['ms', 'moz', 'webkit', 'o'],
x;

for (x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'];
window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame']
|| window[vendors[x] + 'CancelRequestAnimationFrame'];
}

if (!window.requestAnimationFrame) {
window.requestAnimationFrame = function (callback) {
var currTime = new Date().getTime(),
timeToCall = Math.max(0, 16 - (currTime - lastTime)),
id = window.setTimeout(function () {
callback(currTime + timeToCall);
}, timeToCall);
lastTime = currTime + timeToCall;
return id;
};
}

if (!window.cancelAnimationFrame) {
window.cancelAnimationFrame = function (id) {
window.clearTimeout(id);
};
}
}());

那么requestAnimationFrame怎么用呢。语法像下面这样。

1
2
3
requestID = window.requestAnimationFrame(callback);               // Firefox 23 / IE10 / Chrome / Safari 7 (incl. iOS)
requestID = window.mozRequestAnimationFrame(callback); // Firefox < 23
requestID = window.webkitRequestAnimationFrame(callback); // Older versions Chrome/Webkit

注意它只接受回调函数作为参数,不需要指定延时哦。同样的,相对应的还有一个cancelAnimationFrame(requestID)方法取消重绘。最常见的用法是在一个动画函数里通过requestAnimationFrame循环调用自身。就像下面这样。

1
2
3
4
5
6
7
8
9
10
11
funFall = function() {
var start = 0, during = 100;
var _run = function() {
start++;
var top = Tween.Bounce.easeOut(start, objBall.top, 500 - objBall.top, during);
ball.css("top", top);
shadowWithBall(top); // 投影跟随小球的动
if (start < during) requestAnimationFrame(_run);
};
_run();
};
0%