JS高级程序设计读书笔记(上)

by Teobler on 30/07/2017

undefined views

这篇内容会比较杂比较长,纯属为了自己以后的复习查阅,也算是自己串一遍自己认为比较重要的知识点,复习复习

js基本的数据类型

js有6中基本数据类型

  1. undefined
  2. null
  3. boolean
  4. number
  5. string
  6. symbol

再加上一种复杂数据类型 --- object

typeof操作符可以返回目标操作数的数据类型,分别为

  1. undefined
  2. boolean
  3. number
  4. string
  5. symbol
  6. function (从技术角度来讲函数也是对象,但是函数也有一些特殊的属性,所以有必要将其与一般的对象区别开来)
  7. object (null被看做是一个空对象的引用)

编程语言通性

例如各种操作符,循环判断语句,函数等,列举几个js的特殊点

相等操作符

首先是 '==' 与 '===' 的区别,前者为相等操作符,后者为全等操作符。相等操作符进行判断时,会将等号两边不同类型的数据进行一系列转换后再进行比较,如果相等则返回true;后者则不进行转换直接进行比较,其只有在两个操作数未经转换就相等才会返回true('!='与'!==='亦然)

'=='转换比较规则:

  • 如果两个操作数都是数值,则执行数值比较
  • 如果两个操作数都是字符串,则比较两个字符串对应的字符编码值
  • 如果一个操作数是数值,则将另一个操作数转换为一个数值,然后执行数值比较
  • 如果一个操作数是对象,则调用这个对象的 valueOf()方法,用得到的结果按照前面的规则执 行比较。如果对象没有 valueOf()方法,则调用 toString()方法,并用得到的结果根据前面 的规则执行比较
  • 如果一个操作数是布尔值,则先将其转换为数值,然后再执行比较

也正是由于相等运算符这个特点,会造成一些我们不希望发生的意外比较,同时也让代码阅读和维护产生疑惑,所以大部分情况建议使用严格相等运算符

位操作符

js中的位操作与其他高级语言并没有太大的不同,需要注意的是js的数值存储方式:

  • 在js中,数值以64位进行存储,在进行位操作时。会先将64位的数转换成32位,操作结束后再转换成64位
  • 对于有符号数来说,首位为符号位,0为正1为负,没有用到的位使用符号位的数值进行填充
  • 负数使用补码表示,即该数的绝对值求二进制,然后取反,只有加1
  • 位运算将 NaN 与 Infiniti 看做 0
  • 当直接输出二进制数时,js会向人们隐藏这些底层存储,转换为人们较为习惯的方式表现出来,如 将-18转换为二进制输出,为 -10010

关于函数

  • 在js中,参数在函数内部是使用类数组对象arguments来表示的。函数接收到的始终是这个数组,也不关心数组中包含哪些参数
  • 由于函数不关心传递参数的个数,所以在js中如果一个函数需要3个函数,如果你只传递了2个,js不会像java那样报错,而是会将未传递的参数默认设置为undefined
  • 也正是由于上述的原因,js的函数没有重载,如果一个函数被定义了两次,那么后定义的函数会覆盖掉前面定义的函数

变量、作用域与内存

变量

数据类型

  • 在js中,变量分为基本类型(undefined、null、boolean、number和string)和引用类型

存储类型

  • 在操作基本类型时,直接操作保存在变量中的值,即基本类型的变量值直接存储在变量访问的位置。这是因为这些原始类型占据的空间是固定的,所以可将他们存储在较小的内存区域 --- 栈中。这样存储便于迅速查寻变量的值
  • 在操作引用类型时,操作的是对象的引用而不是实际的对象,即该对象保存的是对象在内存堆中的地址(因为js不允许直接访问内存中的位置),而栈中保存的是该地址的一个指针(地址大小是固定不变的,对于变量性能无影响)

访问机制

在javascript中是不允许直接访问保存在堆内存中的对象的,所以在访问一个对象时,首先得到的是这个对象在堆内存中的地址,然后再按照这个地址去获得这个对象中的值,这就是传说中的按引用访问。而原始类型的值则是可以直接访问到的

复制变量

  • 基本类型:在将一个保存着原始值的变量复制给另一个变量时,会将原始值的副本赋值给新变量,此后这两个变量是完全独立的,拥有两个完全独立的内存空间,他们只是拥有相同的value而已
  • 引用类型:在将一个保存着对象内存地址的变量复制给另一个变量时,会把这个内存地址赋值给新变量,也就是说这两个变量都指向了堆内存中的同一个对象,他们中任何一个作出的改变都会反映在另一个身上(即复制对象时并不会在堆内存中新生成一个一模一样的对象,只是多了一个保存指向这个对象指针的变量)

参数传递

首先js中所有的函数的参数都是按照值传递的。但是为什么又会有引用传递呢?归根结底还是因为存储方式不同

  • 基本类型:与复制变量类似,只是把变量里的值传递给参数,之后参数和这个变量互不影响
  • 引用类型:对象变量中的值是这个对象在堆内存中的内存地址,因此在进行参数传递时,虽然进行的是值传递,但是它传递的值是这个变量的内存地址,这也就是为什么函数内部对这个参数的修改会体现在外部,因为它们都指向同一个对象

作用域链

首先要理解js中的作用域链,先要理解什么是执行环境

执行环境定义了变量或函数有权访问的其他数据,决定了他们的行为。在每一个执行环境中,都有一个变量对象,环境中定义的所有变量和函数都保存在这个对象中。我们没有办法访问到这个对象。在web浏览器中,一般认为window对象是全局执行环境,因此所有的变量和函数都是作为window对象的属性和方法创建的。

作用域链的作用是保证执行环境里有权访问的变量和函数是有序的,作用域链的变量只能向上访问,变量访问到window对象即被终止,作用域链向下访问变量是不被允许的

需要注意的是,js中没有块级作用域,即for循环或if语句中创建的变量,在for循环结束后也依然能够被在同一执行环境中的其他变量或函数访问到

内存管理

垃圾收集

js具有自动的辣鸡手机机制,一般情况下,开发人员不需要手动清理垃圾。而js使用的垃圾清理方式主要有两种:

标记清除(mark and sweep)

这是JavaScript最常见的垃圾回收方式,当变量进入执行环境的时候,比如函数中声明一个变量,垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”。

垃圾回收器会在运行的时候给存储在内存中的所有变量加上标记,然后去掉环境中的变量以及被环境中变量所引用的变量(闭包),在这些完成之后仍存在标记的就是要删除的变量了

引用计数(reference counting)

在低版本IE中经常会出现内存泄露,很多时候就是因为其采用引用计数方式进行垃圾回收。引用计数的策略是跟踪记录每个值被使用的次数,当声明了一个变量并将一个引用类型赋值给该变量的时候这个值的引用次数就加1,如果该变量的值变成了另外一个,则这个值得引用次数减1,当这个值的引用次数变为0的时候,说明没有变量在使用,这个值没法被访问了,因此可以将其占用的空间回收,这样垃圾回收器会在运行的时候清理掉引用次数为0的值占用的空间。

在IE中虽然JavaScript对象通过标记清除的方式进行垃圾回收,但BOM与DOM对象却是通过引用计数回收垃圾的,也就是说只要涉及BOM及DOM就会出现循环引用问题。

面向对象的程序设计

首先,我们来跳出书本,什么是面向对象的程序设计

知乎上有这么个例子:面向对象是相对于面向过程的,比如你要充话费,你会想,可以下个支付宝,然后绑定银行卡,然后在淘宝上买卡,自己冲,这就是面向过程。但是对于你女朋友就不一样了,她是面向“对象”的,她会想,谁会充话费呢?当然是你了,她把电话号码给你,然后你把之前的做了一遍,然后她收到到帐的短信。这就是面向对象!女的思维大部分是面向“对象”的!她不关心处理的细节,只关心谁可以,和结果!

在理解了什么是面向对象之后,我们再理解它的三个特性:封装、继承和多态

封装:封装的意义,在于明确标识出允许外部使用的所有成员函数和数据项,或者叫接口。有了封装,就可以明确区分内外,使得类实现者可以修改封装内的东西而不影响外部调用者;而外部调用者也可以知道自己不可以碰哪里。这就提供一个良好的合作基础——或者说,只要接口这个基础约定不变,则代码改变不足为虑。

继承+多态:继承和多态必须一起说。一旦割裂,就说明理解上已经误入歧途了。

先说继承:继承同时具有两种含义 --- 其一是继承基类的方法,并做出自己的改变和/或扩展——解决了代码重用问题;其二是声明某个子类兼容于某基类(或者说,接口上完全兼容于基类),外部调用者可无需关注其差别(内部机制会自动把请求派发[dispatch]到合适的逻辑)。

再说多态:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同。很显然,多态实际上是依附于继承的两种含义的:“改变”和“扩展”本身就意味着必须有机制去自动选用你改变/扩展过的版本,故无多态,则两种含义就不可能实现。

我们用典型的面向对象语言java来举个例子

abstract class Money  //基类
{
    private final string bankName = "中国人民银行";
    public string getBankName(){
        return bankName; 
    } 
    
    public abstract int value();
} 
    
class NoteFiveYuan extends Money  // 子类1
{
    public int value(){
        return 5;    // 多态的体现
    }
}
    
class NoteTenYuen extends Money  // 子类2
{
    public int value(){
        return 10; // 多态的体现
    } 
}
    
static int main()
{
    ArrayList<Money> wallet = new ArrayList<>();
    wallet.Add(new NoteFiveYuan());
    wallet.Add(new NoteTenYuan());
	
    for(Money money : wallet)
    {
        println(money.getBankName());
        println(money.value());
    }
}

如果Money的设计者把“中国人民银行”改成了“美国人民银行”,你会发现其他地方完全不需要修改,这就是封装的好处;尤其是如果getBankName里面有一些复杂操作的话,好处会更明显。

如果没有继承和多态,你就做不到把两个类的实例全扔到同一个wallet里面,并且在面对每一个Money的时候,你还必须得去手动判断他到底是哪种Money。现在有了继承和多态,只要你采用覆盖虚函数的方法,根本不用操心。更关键的是,如果以后还有30元的货币类,只要也继承Money并重写value,那么主函数里面数钱的那一段根本就不用动。

回到js中来,虽然 Object 构造函数或对象字面量都可以用来创建单个对象,但这些方式有个明显的缺点:使用同 一个接口创建很多对象,会产生大量的重复代码。为解决这个问题,人们开始使用工厂模式的一种变体。

工厂模式

工厂模式是软件工程领域一种广为人知的设计模式,这种模式抽象了创建具体对象的过程。考虑到在 ECMAScript 中无法创建类,开发人员 就发明了一种函数,用函数来封装以特定接口创建对象的细节,如下面的例子所示。

function createPerson(name, age, job){ 
    var o = new Object();
	
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){ 
        alert(this.name); 
    }; 
	
    return o;
}
	
var person1 = createPerson("Nicholas", 29, "Software Engineer"); 
var person2 = createPerson("Greg", 27, "Doctor");

函数 createPerson()能够根据接受的参数来构建一个包含所有必要信息的 Person 对象。可以无 数次地调用这个函数,而每次它都会返回一个包含三个属性一个方法的对象。工厂模式虽然解决了创建 多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)

构造函数模式

可以使用构造函数模式将前面的例子重写如下:

function Person(name, age, job){ 
	this.name = name; 
	this.age = age; 
	this.job = job; 
	this.sayName = function(){ 
		alert(this.name); 
	}; 
}
	
var person1 = new Person("Nicholas", 29, "Software Engineer"); 
var person2 = new Person("Greg", 27, "Doctor");

注意,函数名 Person 使用的是大写字母 P。按照惯例,构造函数始终都应该以一个 大写字母开头,而非构造函数则应该以一个小写字母开头。这个做法借鉴自其他 OO 语言,主要是为了 区别于 ECMAScript 中的其他函数;因为构造函数本身也是函数,只不过可以用来创建对象而已。

同时要创建 Person 的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 4 个步骤:

  1. 创建一个新对象
  2. 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

在前面例子的最后,person1 和 person2 分别保存着 Person 的一个不同的实例。这两个对象都 有一个 constructor(构造函数)属性,该属性指向 Person,如下所示。

alert(person1.constructor === Person); //true 
alert(person2.constructor === Person); //true

我们在这个例子中创建的所有对象既是 Object 的实例,同时也是Person 的实例,这一点通过 instanceof 操作符可以得到验证,这也就解决了工厂模式所遗留的问题:

alert(person1 instanceof Object); //true
alert(person1 instanceof Person); //true
alert(person2 instanceof Object); //true
alert(person2 instanceof Person); //true

缺点: 这个在全局作用域中定义的函数其实只能被某个对象调用,让全局函数有点名不副实。同时如果对象需要定义很多方法的话,就需要定义很多全局函数,同时这个所谓的对象没有任何封装性可言

工厂模式

function Person(){}

Person.prototype.name = ‘nico’
Person.prototype.age = 29
Person.prototype.sex = ‘male’
Person.prototype.sayName = function(){ 
alert(this.name)
}

var person1 = new Person()
var person2 = new Person()
	
person1.sayName === person2.sayName // true

在这里,我们将所有属性和方法都直接定义到了Person的prototype属性中,让Person构造函数变成了一个空函数。在创建实例时,所有实例都会从Person的原型对象‘引用’相应的属性和方法。

那么问题来了,什么是原型对象呢?

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor (构造函数)属性,这个属性包含一个指向 prototype 属性所在函数的指针。就拿前面的例子来说, Person.prototype. constructor 指向 Person。而通过这个构造函数,我们还可继续为原型对象 添加其他属性和方法。

创建了自定义的构造函数之后,其原型对象默认只会取得 constructor 属性;至于其他方法,则 都是从 Object 继承而来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部 属性),指向构造函数的原型对象。ECMA-262 第 5 版中管这个指针叫[[Prototype]]。虽然在脚本中 没有标准的方式访问 [[Prototype]] , 但 Firefox、Safari 和 Chrome 在每个对象上都支持一个属性 proto;而在其他实现中,这个属性对脚本则是完全不可见的。不过,要明确的真正重要的一点就 是,这个连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。

以前面使用 Person 构造函数和 Person.prototype 创建实例的代码为例,图 6-1 展示了各个对象之间的关系。

图 6-1 展示了 Person 构造函数、Person 的原型属性以及 Person 现有的两个实例之间的关系。 在此,Person.prototype 指向了原型对象,而 Person.prototype.constructor 又指回了 Person。 原型对象中除了包含 constructor 属性之外,还包括后来添加的其他属性。Person 的每个实例——person1 和 person2 都包含一个内部属性,该属性仅仅指向了 Person.prototype;换句话说,它们 与构造函数没有直接的关系。此外,要格外注意的是,虽然这两个实例都不包含属性和方法,但我们却可以调用 person1.sayName()。这是通过查找对象属性的过程来实现的。

需要注意的是我们不能通过对象实例重写对象原型中的值,但是如果在实例中新增加了值,那么在访问实例时,实例中的值就会屏蔽掉对象实例中的值,如果只有删除了实例中的值,那么重新访问时就会又得到对象原型中的值。

缺点:首先通过原型模式创造的实例会共享原型对象的所有值,也正是由于共享这一属性,假如某一个属性的值是引用类型的话,就会‘牵一发而动全身’,例如某个属性为数组,当你在某一个实例中push一个新值,那么所有实例都会一起‘push一个新值’,这显然不是我们希望看到的。

为了解决这个问题,又衍生出了下面的几种方法

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

function Person(name, age, sex) {
    this.name = name
    this.age = age
    this.sex = sex
    this.friends = [‘Shelby’, ‘Court’]  // 通过构造函数构造的实例为单独实例,互不影响
}

Person.prototype = {
    constructor: Person 
    // 使用大括号声明时,本质上完全重写了prototype,使得constructor属性不再指向Person,而是指向了Object,为了防止使用该属性发生错误,可将其重新改写
    sayName: function() {
        alert(this.name)
    }
}

由于这样的写法可能会使有其他oo经验的人感到困惑,于是产生了 动态原型模式:

function Person(name, age, sex) {
	this.name = name
	this.age = age
	this.sex = sex

	if (thpeof this.sayName !== ‘function’){
   		Person.prototype.sayName = function(){
   		alert(this.name)
		}
	}
}

注意if语句中的代码,这里只有在sayName()方法不存在的情况下才会将他添加到原型中。也就是说,这部分代码只有在第一次执行创建实例时才会使用,也就避免了构造函数每一次都创建新的实例。

关于继承

原型链与原型继承

说到继承,我们需要说清楚什么是原型链,而关于原型链,高程上有比较书面的解释:

每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型 对象的内部指针。那么,假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的 原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数 的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实 例与原型的链条。这就是所谓原型链的基本概念。

看不懂?没关系,我们来看一段高程上的代码示例:

function SuperType(){ 
    this.property = true; 
}
	
SuperType.prototype.getSuperValue = function(){ 
    return this.property; 
};
	
function SubType(){ 
    this.subproperty = false; 
}
	
//继承了 SuperType 
SubType.prototype = new SuperType();
	
SubType.prototype.getSubValue = function (){ 
    return this.subproperty; 
};
	
var instance = new SubType(); 
alert(instance.getSuperValue());    //true

以上代码先定义了一个构造函数 SuperType ,并定义了一个属性 property 为 true ,一个方法 getSuperValue 返回该属性的值;之后又定义了一个构造函数 SubType,之后把 SuperType 的一个实例赋值给了 SubType 的原型,使得 SubType 的所有实例都能够拥有 SuperType 的属性和方法,这就是原型继承的原理。

需要注意的是,这里的原型链其实是少了一环的,所有的对象都继承自 Object 对象,所以上述的两个函数都算是继承自 Object。

一句话,SubType 继承了 SuperType,而 SuperType 继承了 Object。这就是通过原型的继承,也叫做原型继承。

最后关于原型继承,还有一点需要注意的是给原型添加方法的代码一定要放在替换原型的语句之后。重写父类的方法将会屏蔽原来的那个方法,换句话说,父类的实例调用该方法时使用的是父类的方法,子类的实例调用时使用的是子类中重写的方法。同时在通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做就会重写原型链。

缺点:

  • 最主要的问题来自包含引用类型值的原型。前面介绍过包含引用类型值的原型属性会被所有实例共享;而这也正是为什么要在构造函数中,而不是在原型对象中定义属性的原因。在通过原型来实现继承时,原型实际上会变成另一个类型的实例。于是,原先的实例属性也就顺理成章地变成了现在的原型属性了
  • 在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上, 应该说是没有办法在不影响所有对象实例的情况下,给超类型的构造函数传递参数。

借用构造函数的继承

为了弥补原型继承的缺点,开发人员使用一种叫做借用构造函数的技术。即 在子类型构造函数的内部调用超类型构造函数。别忘了,函数只不过是在特定环境中执行代码的对象, 因此通过使用 apply()和 call()方法也可以在(将来)新创建的对象上执行构造函数,如下所示:

function SuperType(){ 
    this.colors = ["red", "blue", "green"]; 
}
	
function SubType(){ 
    //继承了 SuperType 
    SuperType.call(this); 
}
	
var instance1 = new SubType(); 
instance1.colors.push("black"); 
alert(instance1.colors); //"red,blue,green,black"
	
var instance2 = new SubType(); 
alert(instance2.colors); //"red,blue,green"

代码中继承的那一部分“借调”了超类型的构造函数。通过使用 call()方法(或 apply()方法 也可以),我们实际上是在(未来将要)新创建的 SubType 实例的环境下调用了 SuperType 构造函数。 这样一来,就会在新 SubType 对象上执行 SuperType()函数中定义的所有对象初始化代码。结果, SubType 的每个实例就都会具有自己的 colors 属性的副本了。

**优点:**相对于原型链而言,借用构造函数有一个很大的优势,即可以在子类型构造函数中向超类型构造函 数传递参数。看下面这个例子:

function SuperType(name){ 
    this.name = name; 
}
	
function SubType(){
    //继承了 SuperType,同时还传递了参数 
    SuperType.call(this, "Nicholas");
	
    //实例属性 
    this.age = 29;
}
	
var instance = new SubType(); 
alert(instance.name);   //"Nicholas";
alert(instance.age);    //29

缺点:

  • 无法避免构造函数模式存在的问题——方法都在构造函数中定 义,因此函数复用就无从谈起了
  • 在超类型的原型中定义的方法,对子类型而言也是不可见的,结 果所有类型都只能使用构造函数模式

组合继承

组合继承(combination inheritance),有时候也叫做伪经典继承,指的是将原型链和借用构造函数的 技术组合到一块,从而发挥二者之长的一种继承模式。其背后的思路是使用原型链实现对原型属性和方 法的继承,而通过借用构造函数来实现对实例属性的继承。这样,既通过在原型上定义方法实现了函数 复用,又能够保证每个实例都有它自己的属性。下面来看一个例子。

function SuperType(name){ 
    this.name = name; 
    this.colors = ["red", "blue", "green"]; 
}
	
SuperType.prototype.sayName = function(){ 
    alert(this.name);
};
	
function SubType(name, age){
    //继承属性 
    SuperType.call(this, name);
    this.age = age;
}
	
//继承方法 
SubType.prototype = new SuperType(); 
SubType.prototype.constructor = SubType;
	
SubType.prototype.sayAge = function(){ 
    alert(this.age); 
};
	
var instance1 = new SubType("Nicholas", 29); 
instance1.colors.push("black"); 
alert(instance1.colors); //"red,blue,green,black" 
instance1.sayName(); //"Nicholas"; 
instance1.sayAge(); //29
	
var instance2 = new SubType("Greg", 27); 
alert(instance2.colors); //"red,blue,green" 
instance2.sayName(); //"Greg"; 
instance2.sayAge(); //27

在这个例子中,SuperType 构造函数定义了两个属性:name 和 colors。SuperType 的原型定义 了一个方法 sayName()。SubType 构造函数在调用 SuperType 构造函数时传入了 name 参数,紧接着 又定义了它自己的属性 age。然后,将 SuperType 的实例赋值给 SubType 的原型,然后又在该新原型 上定义了方法 sayAge()。这样一来,就可以让两个不同的 SubType 实例既分别拥有自己属性——包 括 colors 属性,又可以使用相同的方法了

组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JavaScript 中最常用的继 承模式。而且,instanceof 和 isPrototypeOf()也能够用于识别基于组合继承创建的对象。