📒 JavaScript高级程序设计第三版 💫

《JavaScript 高级程序设计》 不愧是关于前端的最好的书籍。常读常新,在前端生涯的每个阶段去阅读,都会有不一样的感觉,也会有不一样的收获。
本文是我整理了我最近一次阅读的笔记,我想我下次再读,也能再出一篇文章。

第3章 基本概念

3.7 函数

函数可以封装任意多条语句,在任何地方、任何时候调用执行。
函数或者执行语句的返回值不是必须的,如果没有,执行后默认返回undefined

3.7.1 理解参数

ECMAScript函数的参数可以以任意类型,任意个数传递,也可以不传递,因为在函数体内,是通过arguments这个类数组来访问具体的每一个参数的。

1
2
3
function sum(a, b) {
return a + arguments[1]
}

所以,函数参数的命名不是必须的,只是方便而已。
而因为这个特点,可以根据参数实现在其他语言中的函数重载的效果。

类数组
arguments 并不是Array的实例。只是具有类似于Arrry的使用方法。

1
2
3
4
// es5
Array.prototype.slice.call(arguments)
// es6
Array.from(arguments)

可以将类数组转为Array,使其拥有Array的属性和方法。

通过参数传递的都是值,不是引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use strict'
var a = {x:1}
var b = 3
function foo (x, y, z) {
console.log(arguments.length)
x = null;
console.log('x', x);
console.log('arguments[0]', arguments[0]);
arguments[1] = 4;
console.log('arguments[1]', arguments[1]);
console.log('y', y)
}
foo(a, b); // 2; x null; arguments[0] {x: 1}; arguments[1] 4; y 3
a; // {x: 1}
b; // 3

示例中:

  1. a, b作为参数传入,而最终,`a, b的值没有改变,说明参数传递的是值,而不是引用。
  2. 命名参数x被赋值了,a没有改变,说明了他们的值并不在一个内存空间之内。
  3. 命名参数x被赋值,arguments[0]没有改变;arguments[1]被赋值,y却改变了。说明命名参数和arguments的值会同步,但只是单向的同步。
  4. 值是不能被赋值的。如2 = 3,会直接报错。在函数体内,arguments是作为值存在的,所以直接给其赋值(arguments = 2)也会报错。
  5. arguments.length的值是2。说明arguments只跟当前实际传入参数有关。z的值是undefined,也就是声明了却没有定义。

第4章 变量、作用域和内存问题

ECMAScript中定义的变量可以在其生命周期内变换存储各种数据类型。

4.1基本类型和引用类型的值

按照值的类型,存储在变量中的数据一共有两种:基本类型值和引用类型值。

基本类型值包括五种: undefinednullBooleanNumberString

引用类型值实际上是指向内存地址的地址信息,在ECMAScript中,不能直接操作对象的内存空间。

4.1.1 动态的属性

只能给引用类型值添加属性。

1
2
3
4
5
6
var a = {}
a.b = 2
a.b // 2
var c = 'abc'
c.b =2
c.b // undefined

4.1.2 复制变量值

基本类型值的复制,是对值的拷贝,会给拷贝值分配新的内存。

引用类型值的复制,是对存储在变量里的地址信息的拷贝,复制的地址信息依然指向的是原对象。

4.1.3 传递参数

参数的传递就是变量的复制。将外部的变量复制成为局部的变量。

关于值传递的更多知识可以去赋值策略 🌕 查看更多。

4.1.4 检测类型

对基本数据类型的检测,typeof操作符 是最佳的工具。
而对于对象类型数据,我们更想知道他是什么类型的对象,即它的构造函数是谁,这个时候instanceof操作符就排上用场。

4.2 执行环境及作用域

执行环境(Execution Context)是一个很重要的概念。每个执行环境都有一个与之关联的变量对象(Variable Object),执行环境中定义的所有变量和函数都保存在这个对象中。

全局执行环境是最外围的一个执行环境。在Web浏览器中,全局执行环境被认为是window对象,因此所有的全局变量和函数都是作为window的属性和方法创建的。

某个执行环境中的代码全部执行完毕后,该执行环境会被销毁,保存在其变量对象中的所有变量和函数也随之销毁。全局执行环境直到应用程序退出–如关闭浏览器或者网页关闭–才会被销毁。

每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。

当代码在一个环境中执行时,会创建变量对象的一个作用域链(Scope Chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所 在环境的变量对象。如果这个环境是函数,则将其活动对象(Activation Object)作为变量对象。活动对象在最开始时只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变最对象始终都是作用域链中的最后一个对象。

标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始, 然后逐级地向后回溯,直至找到标识符为止(如果找不到标识符,通常会导致错误发生)。

4.2.1 延长作用域链

延长作用域链是指在当前作用域链的前端再添加一个作用域。

首先声明不建议使用with语句。在严格模式有,该语句已被禁止使用,推荐声明一个临时变量代替。

with

with语句可以扩展一个语句的作用域链。

1
2
3
4
5
6
7
8
var a, x, y;
var r = 10;

with (Math) {
a = PI * r * r;
x = r * cos(PI);
y = r * sin(PI / 2);
}

利:with语句可以在不造成性能损失的情況下,减少变量的长度。其造成的附加计算量很少。使用with可以减少不必要的指针路径解析运算。需要注意的是,很多情況下,也可以不使用with语句,而是使用一个临时变量来保存指针,来达到同样的效果。

弊:with语句使得程序在查找变量值时,都是先在指定的对象中查找。所以那些本来不是这个对象的属性的变量,查找起来将会很慢。如果是在对性能要求较高的场合,with下面的statement语句中的变量,只应该包含这个指定对象的属性。

4.2.2 没有块级作用域

ES5及之前版本,没有块级作用域。在其他的类C语言中,由花括号封闭的代码块都有自己的作用域(也就是上文提到的的执行环境)。

catch

其实,try/catch中的catch区域是一个不完全的块级作用域。

1
2
3
4
5
6
7
try  {
throw new Error()
} catch (err) {
var b = 3
}
b // 3
err // Uncaught ReferenceError: err is not defined

传入的err参数只在catch的区域内有效,但是在区域内定义的其他变量却依然可以被外部访问。
这个不完全的块级作用域甚至被用来作为let的polyfill。

1
2
3
4
5
6
7
8
9
10
11
12
13
// es 6
{
let x= 2
console.log(x)
}
console.log(x)
// es5
try {
throw 2
} catch (x) {
console.log(x)
}
console.log(x)

变量声明

使用var声明的变量会自动被添加到最接近的环境中。在函数内部,最接近的环境就是函数的局部环境;在with语句中,最接近的环境是函数环境。如果初始化变量时没有使用var声明,该变量会自动被添加到全局环境。

在严格模式下,初始化未经声明的变量会导致错误。

查询标识符

当在某个环境中为了读取或写入而引用一个标识符时,必须通过搜索来确定该标识符实际代表什么。搜索过程从作用域链的前端开始,向上逐级查询与给定名字匹配的标识符。如果在局部环境中找到了该标识符,搜索过程停止,变量就绪。如果在局部坏境中没存找到该变量名,则继续沿作用域链向上搜索。搜索过程将一直追溯到全局环境的变量对象。如果在全局环境中也没有找到这个标识符,则意味着该变量尚未声明。

变量查询是有代价的。访问局部变量会比访问全局变量更快。

4.3 垃圾回收

ECMAScript具有自动垃圾回收机制。原理是垃圾收集器按照固定时间间隔(或代码执行中预定的收集时间)周期性的找出那些不在继续使用的变量,然后释放其占用的内存。

垃圾收集器必须跟踪哪个变量有用哪个变量没用,对于不再有用的变量打上标记,以备将来回收其占用的内存。用于标识无用变量的策略可能会因实现而异,但具体到浏览器中的实现,则通常有两个策略。

4.3.1 标记清除

标记清除(mark-and-sweep)其实就是垃圾收集器将进行标记的变量进行内存回收。重要的不是标记的方式,而是标记的策略。

4.3.2 引用计数

引用计数(Referencce counting)是记录每个值被引用的次数,当值的引用次数为0时,会被回收。

但这种策略有个严重的问题– 循环引用:

1
2
3
4
5
6
function problem () {
var objA = {}
var objB = {}
objA.re = objB
objB.re = objA
}

即使在函数执行结束后,对于objAobjB的引用次数仍然不为0。所以,内存也不会释放。

4.3.3 性能问题

涉及IE的一些问题,无可赘述。

4.3.4 管理内存

优化内存占用的最佳方式,就是为执行中的代码只保存必要的数据。一旦数据不再有用,最好通过将其值设置为null来释放其引用——这个做法叫做解除引用(dereferencing)。这适用于大多数全局变贵和全局对象的属性。局部变量会在它们离开执行环境时自动被解除引用。

不过,解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。

第5章 引用类型

引用类型的值(对象)是引用类型的一个实例。听起来很像类,却不是类。更确切的表述是:对象定义————描述一对象所具有的属性和方法。

1
var person = new Object()

Object()的实例person是一个对象,拥有Object()的默认属性和方法。
本章涉及的ObjectArrayDateRegExpFunctionBooleanNumberStringMath等都是引用类型(对象定义)。

5.1 Object类型

创建Object实例有两种方式:构造函数(var a = new Object()) 和 对象字面量(var a = {}

对象字面量
对象字面量的花括号({})可以类比于if(){}的花括号,都是一个作用域的范围,也就是上下文。比如

1
2
3
4
5
6
7
8
var a  = {
x: 1,
y: function() {
console.log(this.x)
}
}

a.y(); // 1

函数y其实就是在对象字面量的花括号中的上下文执行的,而x刚好是定义在这个作用域里的,所以打印出了1.
使用对象字面量定义对象时,并不会调用Object的构造函数。

字面量(Literals) 值的表现形式。如 {} 就是 Object实例的一种表现形式。

5.5 Function类型

ECMAScript中的函数是对象,是Function类型的实例。

1
2
3
var foo = function (x) { console.log('hello', x) }
function foo (x) { console.log('hello', x) }
var foo = new Function('x', 'console.log("hello", x)')

函数名可以看做是一个指针,指向存储函数对象的内存位置。

5.5.1 没有重载

函数重载是指在同一作用域内,可以有一组具有相同函数名,不同参数列表的函数,这组函数被称为重载函数。
因为ECMAScript中,函数名是指针,所以并不会有函数重载。
不过,你可以使用同一个函数,根据参数传入和不同,实现函数重载的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = [1,2,3];

a.splice(0);

a // []

a.splice(0 ,1)

a // [2, 3]

a.splice(0, 1, 2)

a // [2, 2, 3]

5.5.2 函数声明与函数表达式

1
2
3
4
5
6
7
8
9
10
// 函数表达式
alert(sum(1, 2))
// 函数声明 1
var sum = function (a, b) {
return a + b
}
// 函数声明 2
function sum (a, b) {
return a + b
}

函数表达式就是可以立即执行的ECMAScript语句。函数声明跟变量什么没有什么区别,都是定义后赋值给变量。但是示例中的声明1跟2还是有区别的。
以上示例中,两个不同的函数声明配合着函数表达式分别一起执行,效果是不同的。声明1执行会报错,声明2会正常执行。
因为声明2中,会有一个函数声明提升的过程,解析器进入当前上下文时,会将所有类似于声明1的函数预先添加到当前执行环境中,并置于代码树的顶部。
而在声明1中,因为声明了变量,所有有了变量提升,变量sum被预先放在了代码树的顶部,但是它的值其实还是undefined。所以函数,执行到函数表达式时,直接报错,sum is not a function。换言之,代码压根执行不到函数声明的时候就因为报错停止了。

5.5.3 作为值的函数

5.5.4 函数内部属性

arguments 和 this

arguments.callee

MDN

arguments.callee.caller

5.5.5 函数属性和方法

属性

每个函数都包含两个属性: lengthprototype

length
表示函数希望接收的命名参数的个数

1
2
3
4
5
6
var x = function() {}
var y = function(a) {}
var z = function(a, b) {}
x.length; // 0
y.length; // 1
z.length; // 2

prototype
对于所有的引用类型来说,prototype属性保存了它们的所有实例方法。此属性不可枚举。

1
2
3
4
5
6
7
8
// 原生类型 Array
var a = []
a.slice === Array.prototype.slice // true
// 自定义
var B = fucntion () {}
B.prototype.x = function () { return 'hello' }
var b = new B()
b.x === B.prototype.x // true

方法

每个函数都有非继承的方法:apply()call()bind()。它们都可以用来改变函数的内部属性this,这也是它们的主要用途。
apply()call()用法类似,不同的是参数的传递形式不一样。

1
2
3
4
5
// apply的第二个参数可以是数组,也可以是类数组
fn.apply(that, [])
fn.apply(that, arguments)
// call
fn.call(that, a, b, c)

bind()使用后会生成一个改变了函数内部this指向的当前函数的实例。

1
2
fnn = fn.bind(that)
fnn(arguments);

bind()是ES5之后加入的方法,再次之前,会使用apply+ 闭包的方法模拟。

1
2
3
4
5
6
function bind (fn, context) {
return function () {
return fn.apply(context, arguments)
}
}
bind(foo, that)

每个函数都有来自继承的方法: toLocaleString()toString()valueOf()。 它们都返回当前函数的代码内容。

第6章 面向对象的程序设计

对象的定义:无需属性的集合,其属性可以包含基本值、对象、或者函数。可以看做一个散列。
对象的创建:每个对象都是基于一个引用类型创建的,这个引用类型可以是原生类型,也可以是自定义的类型。

6.1 理解对象

6.1.1 属性类型

对象有属性(property),属性有特性(attribute)。特性一般表示形式为双方括号。([[attribute]]
对象有两种属性(property):数据属性和访问器属性。

数据属性
数据属性包含一个数据值的位置。在这个位置可以读/写值。数据属性有四个特性:

  • [[Configurable]] 表示能否通过delete删除属性,能否修改属性的特性,能否把属性修改为访问器属性。直接在对象上定义的属性,默认值为true
  • [[Enumerable]] 表示能否通过for-in循环返回属性。直接在对象上定义的属性,默认值为true
  • [[Writable]] 表示能否修改属性的值。直接在对象上定义的属性,默认值为true
  • [[Value]] 属性的数据值。读写属性值都在这个地方。默认值为undefined

defineProperty方法可以配置属性的特性。(IE9+)

1
2
3
4
5
6
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
writable: true,
value: 123
})

访问器属性
访问器属性没有数据值([[Value]]),所以也没有([[Writable]]),但是多了[[Get]][[Set]]。也叫gettersetter。用于读、写对应属性的值。

  • [[Configurable]] 表示能否通过delete删除属性,能否修改属性的特性,能否把属性修改为数据属性。直接在对象上定义的属性,默认值为true
  • [[Enumerable]] 表示能否通过for-in循环返回属性。直接在对象上定义的属性,默认值为true
  • [[Get]] 读取属性时调用的函数。默认值是undefined
  • [[Set]] 写入属性时调用的函数,参数为写入值,默认值是undefined

访问器属性不能直接定义,必须使用defineProperty来定义。

1
2
3
4
5
6
7
8
9
10
11
var book = {
_page: 2
}
Object.defineProperty(book, 'page', {
get: function () {
return this._page
},
set: function (val) {
this._page = val
}
})

defineProperty是ES5新加的方法,再次之前,对于gettersetter,浏览器内部有自己的实现。

1
2
3
4
5
6
7
8
9
var book = {
_page: 2
}
book.__defineGetter__('page', function () {
return this._page
})
book.__defineSetter__('page', function (val) {
this._page = val
})

6.1.2 定义多个属性

defineProperties()(ES5,IE9+)可以同时定义多个属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var book = {}
Object.defineProperties(book, {
_page: {
value: 2
},
author: {
value: 'JiaHeSheng'
},
page: {
get: function () {
return this._page
},
set: function (val) {
this._page = val
}
}
})

6.1.3 读取属性的特性

如何查看对象某个属性的特性呢?ES5提供了getOwnPropertyDescriptor方法, 它返回一个属性所拥有的特性组成的对象。

1
Object.getOwnPropertyDescriptor(obj, key)

6.2 创建对象

6.2.1 工厂模式

通过参数传入工厂函数,每次都可已生成一个包含必要信息的、新对象。但是我们并不能通过实例找到它的函数类型标识。

1
2
3
4
5
6
7
8
9
10
11
function factoryFoo (name, age) {
var o = {}
o.name = name;
o.age = age;
o.say = function () {
console.log(this.name)
}
return o
}
factoryFoo('Tom', 23)
factoryFoo('Jack', 24)

6.2.2 构造函数模式

1
2
3
4
5
6
7
8
9
10
function Person (name, age) {
this.name = name;
this.age = age;
this.say = function () {
console.log(this.name)
}
}

var p1 = new Person('Tom', 23)
var p2 = new Person('Jack', 24)

与工厂函数相比,少了显式地创建对象,少了return语句。这是因为使用new操作符,隐式地做了这些事情。
另外,使用构造函数模式构造出来的对象实例,可以通过其constructor属性找到它的构造函数。(解决了工厂函数的问题)

1
2
3
4
5
p1.constructor === Person // true
p2.constructor === Person // true

p1 instanceof Person // true
p1 instanceof Object // true

构造函数与普通函数的区别就是,它被new命令用来创建了实例。换言之,没有被new操作的构造函数就是普通函数。

构造函数也有它的缺点:每个方法都会在实例化的时候被重新创造一遍,即使它们一模一样。
上例中的say方法就被创造了两次。

1
2
3
4
5
// 创建实例时
this.say = new Function('console.log(this.name)')

// 创建后
p1.say === p2.say // false

为了解决这个问题,我们可以这样:

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

但是这又引出了一个新问题:总不能每个方法都这样全局定义吧?

new 运算符

new运算符用来创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

当代码 new Foo(…) 执行时,会发生以下事情:

  1. 一个继承自 Foo.prototype 的新对象被创建。
  2. 使用指定的参数调用构造函数 Foo ,并将 this 绑定到新创建的对象。new Foo 等同于 new Foo(),也就是没有指定参数列表,Foo 不带任何参数调用的情况。
  3. 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是用户可以选择主动返回对象,来覆盖正常的对象创建步骤)

6.2.3 原型模式

每一个函数上都有一个prototype属性,这个属性是一个指针,指向一个对象,这个对象包含了一些属性和方法,这些属性和方法可以被所有由这个函数创建的实例所共享。
举例来说,任意一个函数Personprototype属性指向对象prototypeObject对象,所有由new Person()创建的实例(p1p2pn),都会共享prototypeObject的属性和方法。

1
2
3
4
5
6
7
8
9
10
11
12
function Person (){
}
Person.prototype.age = 34;
Person.prototype.getAge = function () {
return this.age
}
var p1 = new Person()
var p2 = new Person()
p1.age === p2.age // true
p1.age // 34
p1.getAge === p2.getAge // true
p2.getAge() // 34

理解原型对象

构造函数、实例、原型对象

无论什么时候,创建一个新函数,新函数就会有prototype属性,它指向该函数的原型对象。
默认情况下,每个原型对象都有一个属性constructor,它指向原型所在的函数。
当调用这个函数生成出一个实例之后,生成的实例有个隐藏的属性(不可见,也无法访问)[[prototype]],它指向原型对象。
幸好,浏览器实现了这个属性:__proto__,通过这个属性可以访问原型对象。不过这不是标准实现,不建议在生产环境中使用。

通过上面的示例和描述,我制作了一张图片,说明:构造函数、实例、原型对象的关系:

知道他们的关系之后,我们看下通过哪些方法可以查看他们关系。

1
2
3
4
5
6
7
8
// 证明 p1是 Person 的实例
p1.constructor === Person // true
p1 instanceof Person // true

// 证明 Person.prototype 是 p1 的原型对象
Person.prototype === p1.__proto__ // true
Person.prototype.isPrototypeOf(p1) // true
Object.getPrototypeOf(p1) === Person.prototype // true

查看Object.prototype.isPrototypeOf()Object.getPrototypeOf的详细用法。

实例、原型对象上的属性和方法

如果要读取实例上的属性或者方法,就会现在实例对象上搜索,如果有就返回搜到的值;如果没有,继续在实例的原型对象上搜索,如果搜到,就返回搜到的值,如果没搜到,就返回undefined
下面是示例:

1
2
3
4
5
6
7
8
9
10
function Person () {}
var p1 = new Person();
p1.x // undefined

Person.prototype.x = 'hello'
p1.x // hello 来自原型对象

p1.x = 'world'
p1.x // world 来自实例
Person.prototype.x // hello

这是抽象出来的搜索流程图:

我们在代码示例中看到,给示例的属性赋值,并没有覆盖原型上对应的属性值,只是在搜索时,屏蔽掉了而已。
而这就是使用原型对象的好处:生成的实例,可以共享原型对象的属性和方法,也可以在自身自定义属性和方法,即使同名也互不影响,并且优先使用示例上的定义。

继续看下面的代码:

1
2
3
4
p1.x = null
p1.x // null 来自实例
delete p1.x
p1.x // hello 来自原型对象

设置属性值为null,获取属性的时候,并不会跳过实例,如果要重新建立与原型对象的链接,可以使用delete删除实例上的属性。

那么如何知道当前获取的属性值是在实例还是在原型对象上面定义的呢?ECMAScript提供了hasOwnProperty方法,该方法会忽略掉那些从原型链上继承到的属性。

1
2
3
4
5
6
7
p1.x = 'world'
p1.hasOwnProperty('x') // true
delete p1.x
p1.hasOwnProperty('x') // false
Person.prototype.x = 'hello'
p1.hasOwnProperty('x') // false
Person.prototype.hasOwnProperty('x') // true
原型与in操作符

in操作符会在通过对象能够访问给定属性时返回true,无论该属性存在于实例还是原型中。

1
2
3
4
5
Person.prototype.x = 'hello'
'x' in Person.prototype // true
'x' in p1 // true
p1.x = 'world'
'x' in p1 // true

组合使用in操作符和hasOwnProperty即可判断取到的属性值,是不是存于原型中的。

1
2
3
4
5
6
7
function hasPrototypeProperty (obj, name) {
return !obj.hasOwnProperty(name) && (name in obj)
}
Person.prototype.x = 'hello'
hasPrototypeProperty(p1, 'x') // true
p1.x = 'world'
hasPrototypeProperty(p1, 'x') // false

那么,如何获取对象上所有自身的属性和方法呢?

  • Object.keys。可以获取对象上所有自身的可枚举属性和方法名,返回一个名称列表。

  • Object.getOwnPropertyNames。可以获取对象上自身的所有属性和方法名,包括不可枚举的,也返回一个名称列表。

更简单的原型语法

上面示例中,我们添加原型属性,是一个一个在Person.prototype上添加。为了减少不必要的输入,视觉上也更易读,我们可以把要添加的属性和方法,直接封装成对象,然后改变Person.prototype指向的位置。

1
2
3
4
5
6
7
function Person () {}
Person.prototype = {
age: 34,
getAge: function () {
return this.age
}
}

但是,如果这样做,Person.prototype.constructor也被重写,指向了封装对象的构造函数,也就是Object

1
Person.prototype.constructor === Object // true

这时,我们已经无法通过constructor知道原型对象的构造类型了。如果你还记得,工厂模式也存在这个问题。
所以,我们可以这样做:

1
2
3
4
5
6
7
8
function Person () {}
Person.prototype = {
constructor: Person,
age: 34,
getAge: function () {
return this.age
}
}

但是,这样也会有问题。默认的constructor是不可枚举的,这样显式的赋值之后,就会变成可枚举的了。

1
Person.hasOwnProperty('constructor') // true

如果你很在意这个,可以使用defineProperty,修改constructor属性为不可枚举。

1
2
3
4
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person
})

原型的动态性

因为联系原型对象和实例的只是一个指针,而不是一个原型对象的副本,所以原型对象上属性的任何修改都会在实例上反应出来,无论实例创建是在改动之前或者之后。

1
2
3
4
5
6
7
8
function Person () {}
Person.prototype.age = 12
var p1 = new Person()
p1.age // 12
Person.prototype.age = 24
p1.age // 24
var p2 = new Person()
p2.age // 24

但如果修改了整个原型对象,那情况不一样了。因为重写原型对象会切断构造函数与原先原型对象的联系,而实例的指针指向的确实原型的原型对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Person () {}
Person.prototype = {
age: 12
}
var p1 = new Person()
p1.age // 12
p1.__proto__ === Person.prototype // true

Person.prototype = {
age: 24
}
p1.age // 12
Person.prototype.age // 24
p1.__proto__ === Person.prototype // false

所以,修改原型对象是把双刃剑,用得好可以解决问题,用不好就会带来问题。

原生对象的原型

原生对象(ObjectArrayString等)其实也是构造函数

1
2
3
typeof Object // function
typeof Array // function
typeof String // function

它们自身拥有一些属性和方法,它们的实例对象也拥有一些,而实例对象上面的属性和方法,都会被它们构造的实例所共享。

1
2
3
4
5
Object.getOwnPropertyNames(Object).join(',') // "length,name,prototype,assign,getOwnPropertyDescriptor,getOwnPropertyDescriptors,getOwnPropertyNames,getOwnPropertySymbols,is,preventExtensions,seal,create,defineProperties,defineProperty,freeze,getPrototypeOf,setPrototypeOf,isExtensible,isFrozen,isSealed,keys,entries,values"

Object.getOwnPropertyNames(Object.prototype).join(',') // "constructor,__defineGetter__,__defineSetter__,hasOwnProperty,__lookupGetter__,__lookupSetter__,isPrototypeOf,propertyIsEnumerable,toString,valueOf,__proto__,toLocaleString"

Object.getOwnPropertyNames(Object.getPrototypeOf({})).join(',') // "constructor,__defineGetter__,__defineSetter__,hasOwnProperty,__lookupGetter__,__lookupSetter__,isPrototypeOf,propertyIsEnumerable,toString,valueOf,__proto__,toLocaleString"

既然可以共享,当然也可以修改和添加。

1
2
3
4
5
Object.prototype.toString = function () {
return 'hello world'
}
var a = {}
a.toString() // hello world

虽然这样很方便,但是,我们并不推荐这么做。因为每个元素对象的属性和方法,都是有规范可寻的,并且这个规范是所有开发人员都认可的。那么,如果「自定义」了这些属性和方法,可能在多人协作的项目中,引起不必要冲突。并且,如果规范更新,也会带来问题。

原型对象的问题

上面说了使用原型对象的诸多优点,但是原型模式也是有问题的。原型模特的优点是因为它的共享特性,缺点也是。比如,我们在原型对象上定义了一个引用类型的属性。

1
2
3
4
5
6
function Person () {}
Person.prototype.family = ['father','mother']
var p1 = new Person()
var p2 = new Person()
p1.family.push('girlFriend')
p2.family // ["father", "mother", "girlFriend"]

我们在p1family属性中添加了girlFriend,但是p2.family也添加了,因为他们指向的是同一个数组。而这,是我们不希望看到的。实例之间需要共享的属性和方法,自然,也需要自有的属性和方法。

自有属性(OwnProperty) 该属性在实例上,而不是原型上。可以在构造函数内部或者原型方法内部创建。
建议只在构造函数中创建所有的自有属性,保证变量声明在一个地方完成。

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

如果你还有印象,之前的构造函数模式不就是创建的自有属性和方法吗?所以,结合使用这两种方式,是目前ECMAScript中使用最广泛、认同度最高的创建自定义类型的方法。

1
2
3
4
5
6
7
8
9
10
11
function Person () {
this.family = ['father','mother']
}
Person.prototype.age = 24
var p1 = new Person()
var p2 = new Person()
p1.family.push('girlFriend')
p1.family // ["father", "mother", "girlFriend"]
p2.family // ["father", "mother"]
p1.age = 25
p2.age // 25

以上示例,既有原型对象的共享属性,也有实例自身的属性,各得其所。

6.2.5 动态原型模式

但是,上面示例在混合使用两种模式时,依然是割裂开的,两种模式并没有在一个方法中完成。而动态原型模式,正是来解决这个问题。

1
2
3
4
5
6
7
8
function Person () {
this.age = 24
if(typeof this.getAge !== 'function'){
Person.prototype.getAge = function () {
return this.age
}
}
}

可以看到,我们把原型对象模式的定义语句移动到了构建函数中,显式的将两种模式统一在了一起。

6.2.6 寄生构造函数模式

之前我们说过,尽量不要改动原生对象,但是如果想在原生对象上增加方法怎么办?我们可以在原生对象的基础上,增加方法,然后生成一个新的对象。这就是寄生构造函数模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function ArrayPlus () {
var plus = []
plus.push.apply(plus, arguments)
plus.pipeStr = function () {
return this.join('|')
}
return plus
}

var plus1 = new ArrayPlus('red','black')
plus1.pipeStr() // red|black

plus1.constructor // Array
Object.getPrototypeOf(Object.getPrototypeOf(plus1)) === Array.prototype // true
plus1 instanceof ArrayPlus // false
plus1 instanceof Aarray // true

但是,生成的实例跟构造函数和原型对象是完全没有联系的,并且也无法通过instanceof确定其类型。所以,在其他模式可用的情况下,不推荐使用这个模式。

6.2.7 稳妥构造函数模式

稳妥对象是指没有公共属性,并且其方法也不引用this的对象。适合一些安全的环境。下面的示例中,除了对象提供的方法,是没有其他途径获得对象内部的原始数据的。
当前与寄生构造函数模式一样,生成的实例跟构造函数和原型对象是完全没有联系的,并且也无法通过instanceof确定其类型。

1
2
3
4
5
6
7
8
9
function Person (age) {
return {
getAge: function () {
return age
}
}
}
var p1 = Person(12)
p1.getAge() // 12

6.3 继承

ECMAScript只支持实现继承,不支持接口继承。其实现继承主要是依赖原型链来实现。

TODDS: 什么是接口继承?
TODOS: Java中的实现继承是怎样的?

6.3.1 原型链

原型链的基本思想史利用原型让一个引用类型继承另一个引用类型的属性和方法。
实际做法就是:

  1. 让一个构造函数(A)的原型对象(A.prototype)等于另一个构造函数(B)的实例(b1),此时(b1 === A.prototype),那么构造函数(A)的实例(a1)会拥有构造函数(B)的原型对象(B.prototype)的所有属性和方法。
  2. 如果构造函数(B)的原型对象(B.prototype)恰好又等于另一个构造函数(C)的实例(c1),即(c1 === B.prototype)。那么构造函数(B)的实例(b1)会拥有构造函数(C)的原型对象(C.prototype)的所有属性和方法。
  3. 如此层层递进,构造函数(A)的实例(a1)会同时拥有构造函数(B)的原型对象(B.prototype)和构造函数(C)的原型对象(C.prototype)的所有属性和方法。

这就是原型链的基本概念。代码实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Grandpa () {}
Grandpa.prototype.sayHello = function () {
return 'hello'
}
function Father () {}
Father.prototype = new Grandpa()
Father.prototype.sayWorld = function () {
return 'world'
}
function Son () {}
Son.prototype = new Father()
var son1 = new Son()
son1.sayHello() // hello
son1.sayWorld() // world

如果你还记得之前的原型搜索机制(还是下面这张图),那么原型链其实就是对这种机制的向下拓展。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 调用送son1实例上的sayWorld方法
son1.sayWorld()
// 先在实例上寻找,没有
Object.getOwnPropertyNames(son1) // []
// 继续在实例的原型上寻找,也没有
Object.getOwnPropertyNames(Son.prototype) // []
// 继续在实例的原型的原型上寻找,找到了
Object.getOwnPropertyNames(Father.prototype) // ["sayWorld"]

// 同样的,调用送son1实例上的sayWorld方法
son1.sayHello()
// 先在实例上寻找,没有
Object.getOwnPropertyNames(son1) // []
// 继续在实例的原型上寻找,也没有
Object.getOwnPropertyNames(Son.prototype) // []
// 继续在实例的原型的原型上寻找,没有
Object.getOwnPropertyNames(Father.prototype) // ["sayWorld"]
// 继续在实例的原型的原型的原型上寻找,找到了
Object.keys(Grandpa.prototype) // ["constructor", "sayHello"]
别忘记默认的类型

那原型链的尽头————实例的原型的原型的原型…的原型是谁呢?
所以函数的默认原型都是Object的实例,所以所有自定义类型都继承了Object.prototype上的属性和方法。

1
2
Object.getPrototypeOf(Grandpa.prototype) === Object.prototype // true
Object.getPrototypeOf(Object.prototype) // null

确定原型与实例的关系

instanceof 操作符。测试实例与原型链中的构造函数。

1
2
3
4
son1 instanceof Son // true
son1 instanceof Father // true
son1 instanceof Grandpa // true
son1 instanceof Object // true

isPrototypeOf()方法。只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型。

1
2
3
4
Son.prototype.isPrototypeOf(son1) // true
Father.prototype.isPrototypeOf(son1) // true
Grandpa.prototype.isPrototypeOf(son1) // true
Object.prototype.isPrototypeOf(son1) // true

谨慎地定义方法

这一块在讲原型的时候也有提及,主要有两点:在原型链末端定义的重名属性或方法,会屏蔽掉在原型链顶端的定义;使用原型覆盖默认原型对象,要在添加原型的方法之前进行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Grandpa() {}
Grandpa.prototype.say = function () {
return 'grandpa'
}
function Father() {}
Father.prototype = new Grandpa()
Father.prototype.say = function () {
return 'father'
}
function Son () {}
Son.prototype.age = 12
Son.prototype = new Father()

var son1 = new Son()
son1.say() // father
son1.age // undefined

另外,使用对象字面量的方式为原型添加方法,也会覆盖之前的原型对象。

原型链的问题

第一个问题之前在讲原型的时候也说过,就是如果在原型对象上定义一个引用类型的属性,可能出现问题。

第二个问题是在创建子类型的实例(son1)时,不能向超类型的构造函数(Grandpa)传递参数。

有鉴于此,一般不单独使用原型链。

6.3.2 借用构造函数

使用构造函数,可以解决上面提到的问题一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Grandpa () {
this.family = ['house', 'car']
}
function Father () {
Grandpa.call(this)
this.age = 26
}
// 工厂模式写法
// function Father () {
// var that = new Grandpa()
// that.age = 26
// return that
// }
var f1 = new Father()
var f2 = new Father()
f1.family.push('money')
f2.family // ['house', 'car']

可以传递参数,解决了问题2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Grandpa (name) {
this.name = name
}
function Father (name) {
Grandpa.call(this, name)
this.age = 26
}
// 工厂模式写法
// function Father (name) {
// var that = new Grandpa(name)
// that.age = 26
// return that
// }
var f1 = new Father('jiahesheng')
f1.name // jiahesheng
f1.age // 26

但是,借用构造函数也有自己的问题。也就是不能复用共享属性和方法了。

6.3.3 组合继承

其实就是结合了原型链和借用构造函数两种技术。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Father (name) {
this.name = name
}
Father.prototype.sayWorld = function () {
return 'world'
}
function Son (name) {
Father.call(this, name)
}
Son.prototype = new Father()
var s1 = new Son('zhu')
var s2 = new Son('sang')
s1.name // 'zhu'
s2.name // sang
s1.sayWorld() // 'world'
s2.sayWorld() // 'world'

所谓的共享和自有,可以这么理解:
使用了原型链共享了属性和方法的实例,其实,就是包含了一堆指针,这些指针指向原型对象;
使用了借用构造函数技术拥有了自有的属性和方法的实例,其实,就是拥有了构造函数属性和方法的副本。

6.3.4 原型式继承

DC 最早提出,ECMAScript添加了Object.create方法规范化了这种模式。原型式继承的主要应用场景,就是便捷地克隆出一个与已知对象相似的对象,与原型模式一样,包含引用类型值的属性,始终都会共享相应的值。

1
2
3
4
5
6
7
8
9
10
11
12
var person  = {
age: 24
}
// from DC
function object (o) {
function F() {}
F.prototype = o
return new F()
}
var p1 = object(person)
// from ECMAScript
var p1 = Object.create(person)

Object.create还支持第二个参数,格式与Object.defineProperties相同

1
2
3
4
5
6
7
8
9
var person = {
age: 24
}
var p1 = Object.create(person, {
age: {
value: 12
}
})
p1.age // 12

Object.create最常用的方法还是创建一个纯净的数据字典(没有原型对象的对象实例): Object.create(null)

6.3.5 寄生式继承

寄生式继承就是创建一个仅用于封装继承过程的函数,该函数在内部增强对象之后,会返回新的对象。寄生式继承的实际用途在下一节能更好的表示。

6.3.6 寄生组合式继承

我们先来回顾一下,组合式继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Father (name) {
this.name = name
}
Father.prototype.sayWorld = function () {
return 'world'
}
function Son (name) {
Father.call(this, name)
}
Son.prototype = new Father()
var s1 = new Son('zhu')
var s2 = new Son('sang')
s1.name // 'zhu'
s2.name // sang
s1.sayWorld() // 'world'
s2.sayWorld() // 'world'

它实现了属性和方法的自有和共享。但是,也带来了一些问题。

  1. 构造函数Father被调用了两次。一次在new Father(),一次在Father.call(this, name)
  2. 因为调用了两次,所以产生了多余的属性。Son.prototype = new Father()这个语句后,其实Son.prototype也拥有了name属性。只是我们在使用name属性的时候,被实例上的name属性屏蔽了。

怎么解决这个问题呢?我们将原型链继承这一步(Son.prototype = new Father())重写即可!避免调用new Father(),避免继承Father的实例属性和方法。
我们可以组合使用寄生式继承和原型式继承,定义这样一个函数:

1
2
3
4
5
function inheritPrototype (prototypeObj, inheritor) {
var prototype = Object.create(prototypeObj)
prototype.constructor = inheritor
inheritor.prototype = prototype
}

inheritPrototype方法做了两件事:恢复了原型对象对构造函数的指针属性,「浅复制」了原型对象。之前我们也说过,其实原型链的共享只是一堆指针的公用,指向的其实还是一个原型对象。所以,「浅复制」刚好用上。

现在我们把这个方法用起来!

1
2
3
4
5
6
7
8
9
10
11
function Father (name) {
this.name = name
}
Father.prototype.sayWorld = function () {
return 'world'
}
function Son (name) {
Father.call(this, name)
}
inheritPrototype(Father.prototype, Son)
var s1 = new Son('zhu')

到此我们实现了最完美的继承!

第7章 函数表达式

定义函数有两种方式:函数声明和函数表达式。
最常见的使用函数表达式创建函数的方式是创建一个匿名函数(Anonymous Function)并将它复制给变量,匿名函数的name属性是空字符串。

函数声明提升
函数声明的特点是函数声明提升(Function Declaration Hoisting),意思是执行代码之前会先读取函数声明。这意味着可以把函数声明放在调用它的语句之后。

1
2
3
4
sayHi()
function sayHi () {
return 'hi'
}

name属性
每个函数都有name属性,这个值是可以重写的。也可以删除,但是删除后并不是变成undefined,而是空字符串。

7.1递归

函数在函数内部通过名字调用自身,就是递归。我门阶乘函数为例:

1
2
3
function factorial (num) {
return (num <= 1) ? 1 : num * factorial(num - 1)
}

但是函数名其实只是一个指向函数内存的指针,如果指针指向别处,就会造成错误。

1
2
3
var other = factorial
factorial = null
other() // Uncaught TypeError: factorial is not a function

所以实现递归不能依赖于函数名,我们可以使用函数内部的arguments.callee(它指向函数内存)解决这个问题

1
2
3
function factorial (num) {
return (num <= 1) ? 1 : num * arguments.callee(num - 1)
}

但是在严格模式下,不能访问arguments.callee这个属性。我们可以使用命名函数表达式:

1
2
3
4
5
6
var factorial = function f(num) {
return (num <= 1) ? 1 : num * f(num - 1)
}
var other = factorial
factorial = null
other(4) // 24

7.2闭包

闭包是指有权访问另一个函数作用域中的变量的函数。

1
2
3
4
5
6
7
8
9
10
11
12
function createFoo (name) {
return function (family) {
var sex = family[name]
if(sex === 'man') {
return 26
} else if(sex === 'woman') {
return 25
} else {
return 0
}
}
}

createFoo内部有个匿名函数,这个匿名函数无论在何时被调用执行都可以访问name变量,因为匿名函数的作用域包含了createFoo的作用域。

当某个函数第一次被调用时,会创建一个执行环境(Execution Context)及相关的作用域链,并把作用域链赋值给一个特殊的内部属性([[scope]]),然后使用thisarguments和其他命名参数的值来初始化函数的活动对象(Activation Object)。但在作用域链中,外部函数的活动对象始终处于第二位,外部函数的外部函数的活动对象处于第三位…直至作为作用域链终点的全局执行环境。

作用域链本质上是一个指向变量对象的指针列表,它只引用但并不实际包含变量对象。无论什么时候在函数中访问一个变量时,就会从作用域链中搜索具有相应名字的变量。一般来讲,当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)。

但是,闭包却并不如此。

1
2
3
4
5
6
var family = {
zhu: 'man',
sang: 'woman'
}
var sayAge = createFoo('zhu')
var age = sayAge(family)

createFoo执行完毕后,其活动对象并没有被销毁,因为匿名函数的作用域链仍然在引用这个活动对象。换句话说,当createFoo执行完毕时,其执行环境的作用域链会被销毁,但它的活动对象仍然会留在内存中;直到匿名函数被销毁后,createFoo的活动对象才会被销毁。

1
2
3
4
5
6
// 创建函数
var sayAge = createFoo('zhu')
// 调用函数
var age = sayAge(family)
// 解除对匿名函数的引用(以便释放内存)
sayAge = null

7.2.1 闭包和变量

关于闭包,有一点需要注意,闭包包含的是上一个环境的整个变量对象,而不是具体的某个值。也就是说,保存的只是对变量对象的引用,如果对应的值发生变动,闭包所引用的值也会发生变动。

1
2
3
4
5
6
7
8
9
10
11
function createFoo () {
var arr = []
for(var i=0;i< 5; i++) {
arr[i] = function() {
return i
}
}
return arr
}
var arr = createFoo()
arr.map(i => i()) // [5, 5, 5, 5, 5]

循环中匿名函数形成了一个闭包,闭包中引用了createFoo的变量对象的i的值,循环结束,i的值,变为5。所以最后,闭包函数执行,输出的i的值都是5。

这可能并不符合预期:预期是期望每个闭包函数范围它在数组中对应的index值。为了实现,预期我们可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
function createFoo () {
var arr = []
for(var i=0;i< 5; i++) {
arr[i] = function(num) {
return function () {
return num
}
}(i)
}
return arr
}
var arr = createFoo()
arr.map(i => i()) // [0, 1, 2, 3, 4]

是的,就是再加一层闭包。i的值在第一个匿名函数值作为参数传入,我们知道参数是对值的拷贝,所以在第一层匿名函数中,当前的i值被拷贝存储下来,附加在第二次匿名函数的作用域链上。

7.2.2 关于this对象

1
2
3
4
5
6
7
8
9
10
var name = 'the Window'
var obj = {
name: 'the Obj',
getName: function () {
return function () {
return this.name
}
}
}
obj.getName()() // 'the Window'

我们之前有提到过,函数被调用时,特殊变量thisarguments并不是在外层函数的执行环境中获得的,而是在函数的执行环境。所以,如果想要访问外部函数的this值,可以将外部函数的this值作为普通变量存储在变量对象中,然后在匿名函数中添加引用。

1
2
3
4
5
6
7
8
9
10
11
var name = 'the Window'
var obj = {
name: 'the Obj',
getName: function () {
var that = this
return function () {
return that.name
}
}
}
obj.getName()() // 'the Obj'

当然,对于arguments,也是一样的。

7.2.3 内存泄露

涉及IE的一些问题,无可赘述。

7.3 模拟块级作用域

因为ES5没有块级作用域的概念,但是每个函数体内就是一个作用域。所以模拟块级函数作用域,其实就是使用了立即使用函数创建了只在函数内有效的变量,并且在函数执行完毕后自动销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function foo (length) {
for(var i = 0; i< length; i++) {

}
console.log(i)
}
foo(12) // 12

// 模拟块状作用域
function foo (length) {
(function() {
for(var i = 0; i< length; i++) {

}
})()
console.log(i)
}
foo(12) // Uncaught ReferenceError: i is not defined

7.4 私有变量

这一章节主要讲了闭包的进一步使用。不过不怎么推荐使用闭包做这些事情。

函数的内部变量是无法被外界访问的。但是利用闭包可以把这些「私有变量」暴露给外界,而暴露的接口就是使用「特权方法」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Foo () {
// 私有变量
var name = 'zpc'
var age = 16
var getAge = function () {
return age
}
// 特权方法
this.public = function () {
return {
name: name,
getAge: getAge
}
}
}
var foo = new Foo()
var obj = foo.public()
obj.name // 'zpc'
obj.getAge() // 16

第8章 BOM

因为BOM起初并没有统一的规范,各个浏览器也是随意拓展,造成兼容性十分不好,所以W3C将BOM纳入了HTML5规范。

8.1 window对象

window对象是浏览器的一个实例。也是ECMAScriptGlobal对象。