JavaScript 面向对象精要 - Nicholas C.Zakas

avatarplhDigital nomad
Nicholas C.Zakas又一新著,好书不常见,认真看下去吧。
一开始以为JavaScript是一门基于继承的原型链语言,后来发现它更大的是,JavaScript是一门基于事件驱动的语言。看一本书的好坏,第一也是最浅显的是看他对于面向对象以及JavaScript底层引擎所做的一些事的描述。第二就比较深,JavaScript是一门基于事件驱动的语言,那么看书中对于事件驱动的描述。究竟是一笔带过,只描述用法,还是能够做到由浅入深,解析浏览器背后所做的一些事情。
但是从另一个角度来说,不正是因为丰富的事件交互,才构成了一个个精彩的页面?
如果你有玩过nodejs的话,你会发现,nodejs的事件循环。这个才是nodejs的真正核心技术。
好像2本书 <ES6入门基础>-阮一峰 <深入了解ES6> - Nicholas C.Zakas,真的得两本都去看看,一本讲用法,一本讲这一新属性的缘由。顺便吐槽,二者水平根本不在同一水平线上。

第一章,原始类型和引用类型

可能你想说js有七种数据类型(boolean,object,Number,Symbol,Function,null,NaN,undefined),但是我想说的不是这个啊,

  • 原始类型,看下面代码,b虽然等于a,但是b是一个新的数据,他不是指针指向a,所以a改变,b还是原来的数值。
var a = '13123';
var b = a;
a = '1231212323';
console.log(b);    // '13123'
  • 引用类型,这个就是指针了俗称**Point**,b如果等于a,a改变,b也会改变,因为它是指针指向。
{} === {}  // false
var a = {}
var b = a;
a.t = 't';
console.log(b)  // {t:"t"};

为什么字符串会拥有方法?

原因依旧很简单,可能你想说:字符串的方法都是继承过来的。但其实它本身就有方法。

'234'.match(/\d/)   // '2'

image

1.2 原始类型

原始类型包括:

类型名字定义
boolean布尔类型,值为ture,false
number数字,值为任何整数或者浮点数
string字符串,值为单引号或者双引号括出的单个字符和连续字符串(JavaScript不区分字符串类型)
null空类型,该原始类型值只有一个:null
undefined未定义,该原始类型只有一个值:undefined

前三种类型boolean,string,number表现行为类似,而后两个null,undefined则有一点区别,后面讨论,所有原始类型都有字面形式,字面形式是不被保存在变量中的值,如硬编码的姓名或价格,下面举例子:

// string
var name = 'NUisdc';
var selection = 'aa';

// number 
var b = 25;
var cost = 1.25;

// boolean
var found = true;

// null
var object = null;

// undefined
var flat = undefined;
var ref;   // 未定义即 undefined

JavaScript的原始类型值直接保存,而对象则是指针。

强制转换 == ,两个等号会强制转换 而 === 则不会

'25' == 25;  // true

原始类型拥有方法,但他们不是对象。

引用类型

创建对象

  • 方法一:new Object();使用new操作符和构造函数,构造函数就是通过new来创建对象的函数---任何函数都可以是构造函数。但是根据命名规范,构造函数首字母必须是大写,用来和普通函数区分。
var object = new Object();

上面代码,由于引用类型只是point,所以object并不包括对象的实例,他只是一个指针,指向内存中,实际对象所在位置,而原始值是直接保存在变量中。

13,2 对象引用的解除

JavaScript有辣鸡回收机制,因此无需担心内存分配,但最好在不使用对象时候将其引用解除,object=null;

1.4类型的实例化

以下类型都可以实例化

代码定义
Array数组类型,以数字为索引的一组值的有序列表
Date日期和时间类型
Error错误类型,还有一些更加特别的错误子类型
Function函数类型
Object通用对象类型
RegExp正则类型

1.8 原始类型的封装

JavaScript共3种原始类型,String,Number,Boolean, 当你读取一个原始类型的时候,例如读取字符串,数字,布尔值的时候,原始封装类型自动创建如下:

var name = 'Peng';
var firstChar = name.charAt(0);
console.log(firstChar);                    // g

但是这仅仅只是表面现象,真实的事件发生过程如下

// what the javascript engine does
var name = 'Peng';
var temp = new String(name);
var firstChar = temp.charAt(0);
temp = null;
console.log(firstChar);

由于第二行把字符串当作对象使用,JavaScript引擎创建了一个字符串的实例,让charAt(0)可以工作,字符串对象的存在,仅仅用于该语句之后就被销毁(一种自动打包过程)。为了测试这一点,试着给字符串添加一个属性试一下/???如果是一个对象,那么他的属性一直被保留,如果是一个字符串,那么他的属性不会被保留,因为在使用过后,就会被销毁。

var name = 'peng';
name.test = 'test';
name.test   //  undefined
var obj = new Object();
obj.test = 'test';
obj.test      //  'test'

下面是JavaScript引擎在背后所做的一些工作

// what the javascript engine does
// 第一次  var name = 'peng',  并且,name.last = 'last'的时候,背后的一些事情
var name = 'Nicholas';
var temp = new String(name);
temp.last = 'last';
temp = null;

// 第二次  打印变量的时候,看到没有,用完之后,实例化String对象会被销毁。
var temp = new String(name);
console.log(temp.last)   // undefined
temp = null

同样的你也可以手动创建一个对象类型,但是副作用就是typeof 无法鉴别

var name = new String('peng')
console.log(typeof name)  // object

第二章 函数

前面说过 函数就是对象,而使函数不同于对象的地方在于[[call]]内部属性,本章讨论函数的行为和其他语言的不同,以function为例子,

2.1声明还是表达

声明function a (){}; 这个会有一个函数提升 表达var a = function(){}; 这个没有提升

因为声明的函数,JavaScript 引擎提前就知道了,

2.2 函数就是值

这个是函数式基础 var str = () => ''str" 考虑一下下面例子:

function hi(){
  console.log('hi');
}
hi();
var sayHi = hi;
sayHi();   // output 'hi'

为了便于理解,用Function构造函数写上面的例子

var hi = new Function('console.log("hi");');
hi()   // output 'hi'
var sayHi = hi;
sayHi();  // output 'hi';

构造函数更能清晰看出,函数就是对象,所以他才能这样的被传来传去。

2.3 参数

你可以给函数传递任意数量的参数,而不报错,那是因为参数实际被保存在一个被称为arguments的类似于数组里面的对象中,

2.4 重载

大多数面向对象语言支持函数重载,他能让一个函数既有多个签名,但是js只能声明一次

function js (val){
  console.log(val)
}
function js (){
  console.log('default value');
}
js('hi')    // out put 'default value'

上面代码其实可以被改写成下面代码

var js = new Function("val","console.log('val');");
js = new Function("console.log('default value');");
js('hi')    // out put 'default value'

js 函数被重新指针了一次嘛。

2.5.1 this对象

你可能注意到了函数的一些奇怪之处。sayName()方法直接引用了person.name,在方法和对象之间耦合严重,如果你要改变变量名字呢?这个时候使用this吧,this代表全局对象。

function sayName(){
    console.log(this.name);
}

var person1 = {
  name: 'peng',
  sayname: sayName
};
var person2 = {
  name: 'gray',
  sayname: sayName
};
var name = 'Micheal';
person1.sayName();   // 'peng'
person2.sayName();   // 'gray'
sayName();           // 'Micheal'

本例子定义函数sayName,然后以字面量形式创建两个对象以sayName作为sayName的方法。当person1调用sayName()时,输出'Peng',person2输出Gray,那是因为this在调用时候才被设置,所以this.name是对的。 最后,本例定义的全局变量。

改变this

在JavaScript中,使用和操作函数中的this的能力是良好地面向对象编程的关键,函数会在各种不同上下文中被使用,他必须到哪都能工作,一般this自动设置,但是你可以改变他的值来完成不同目标。有3种方法

  • 1.call方法 第一个用于操作this的方法,就是call,它以指定this值和参数来决定执行函数,call()的第一个参数指定了函数执行this的值,其后所有的参数都是需要被传入函数的值。假设你更新sayName让他接受一个参数。
const name = 'global name';
function sayName(label) {
  console.log(this);
  console.log(`${label}: ${this}`);
}

const person1 = {
  name: 'name1',
};
const person2 = {
  name: 'name2',
};

sayName.call(this, 'global');
sayName.call(person1, 'person1');
sayName.call(person2, 'person2');

上面的例子,sayName()接受一个label的参数,输出,然后这个函数被调用了3次。注意调用函数的时候没有括号,因为它被调用作为对象访问而不是被执行的代码,第一次调用使用全局this,严格模式下全局this为undefined,之后两次分别是person1,和person2。由于调用了call()方法,你不需要将函数加入每个对象,你显样指定了每个this的值而不是让JavaScript引擎自动指定。

    1. apply()方法 apply()是你可以用来操作this的第二个函数方法。apply()的工作方式和call一样,但是它只接受2个参数:1.this,2.一个数组或者类似数组的对象。,也就是说你可以把arguments作为第二个数组参数。你不需要像使用call()那样一个个指定参数,而是可以轻松传递整个数组给apply()。除此之外,call()和apply()完全一样,下面例子演示了apply()的使用方法。
const name = 'global name';
function sayName(label, label1) {
  console.log(this);
  console.log(label1);
  console.log(`${label}: ${this}`);
}
const person1 = {
  name: 'name1',
};
const person2 = {
  name: 'name2',
};
sayName.apply(this, ['global']);
sayName.apply(person1, ['person1', 'person1111234']);
sayName.apply(person2, ['person2']);

这段代码使用了数组替代之前的字符串。 一般来说如果你的参数是数组,那么用apply,如果你的参数是单个的一个,用call。

  • 3.bind()方法 改变this的三个函数方法是bind()。es5中新增的这个方法和之前略有不同,bind的参数第一个是要传给this的值。其他所有参数都要被永久设置成新函数的命名参数,你可以在之后继续设置任何非永久性参数。

const name = 'global name';
function sayName(label) {
  console.log(this);
  console.log(`${label}: ${this}`);
}

const person1 = {
  name: 'name1',
};
const person2 = {
  name: 'name2',
};

// create a function just for person1
const sayNameBindP1 = sayName.bind(person1);
sayNameBindP1('person1');

// create a function just for person2
const sayNameBindP2 = sayName.bind(person2);
sayNameBindP2('person2');

// attaching a method to an object doesn't change this
person2.sayName = sayNameBindP1;
person2.sayName('person2222');

sayNameForPerson1()没有绑定参数,所以你仍然需要传入label参数用于输出。sayNameForP1()不仅绑定this为person2,同时也绑定了第一个参数为person2。这意味着你可以调用sayNameBindP2()而不传入任何额外参数。例子最后将sayNameBindP1()设置为person2的sayName方法。由于其this的值已经绑定,所以虽然sayNameBindP1现在是person2的方法,他仍然输出perosn1.name的值.

第三章:理解对象

尽管JavaScript本身就有很多对象如:Array,Boolean,Function,等对象,但是你还是会创建一些原生对象。,JavaScript中的对象是动态的,你可以在代码执行的任何时刻改变。 JavaScript编程的一大重点就是管理哪些对象,这就是为什么理解对象如何运作时这个重要,后面章节详细讨论。

3.1 定义属性

前面讲过,两种创建对象的方式。

var person1 = {
  name: 'Nicholas'
};
var person2 = new Object();
person2.name = 'Nicholas';

person1.age = '12';
person2.age = '12';

person1.name = 'Greg';
person2.name = 'Michael';

person1 和person2 都具有name属性。 那么说说底层JavaScript引擎对它的实现机制吧。当一个属性第一次被添加给对象时,JavaScript在对象上调用一个名为[[Put]]的内部方法。[[Put]]方法会在对象上面创建一个新节点来保存属性,,就像第一次在哈希表上面添加一个键值一样,这个操作不仅仅指定了初始的值,也定义了属性的一些特性。所以在方法中,当前属性name和age第一次被定义的时候都会调用[[Put]]. 调用[[Put]]的结果是在对象上创建了一个自由属性,一个自有属性表明仅仅该指定的对象实例拥有该属性。该属性被直接保存在实例内,对该属性的所有操作都必须通过该对象进行。

自有属性有别于原型属性,后面慢慢讨论。

当一个已有属性被赋予新的值的时候,调用的是一个名为[[Set]]的方法,为它赋予新的值。上例为name设置第二个属性的时候调用[[Set]]方法,

3.2 属性是否存在

最常见的方法,包括我

if(a.name) console.log('exist')

问题是如果a.name = 0 的时候呢??雪崩,bug无处不在 a.name = false/0/null/undefined/Nan的时候都会雪崩 正确的方法是使用in 操作符,

if('name' in a){console.log('exist')}

但是如果属性是继承的呢,这个时候需要hasOwnProperty()的方法来鉴别,in 可以获取所有的方法,,而hasOwnProperty不能获取继承的方法。

3.3 删除属性

正如属性可以在任何时候被添加到对象上,他们也可以在任何时候被移除,但设置一个属性的值为null,并不能从对象中移除,只是调用[[Set]]将null值替换了该属性原来的值而已。这点前面说过。你只能使用delete彻底删除一个属性, delete操作符指针对单个对象属性调用名为[[Delete]]的内部方法。你可以认为该值在hash里面移除了一个键值对,当delete成功的时候,返回true image 删除后in操作符返回false image

3.4 属性枚举

所有你添加的属性默认可枚举,也就是说你可以用for...in枚举他们,可枚举的属性的内部特征[[Enumerable]]都被设为true,同样Object.keys()同样可以得到所有可枚举属性,一般自定义属性可枚举,原生方法不可枚举。

const person = {
  name: 'Nicholas',
};
console.log('name' in person); // true
console.log(person.propertyIsEnumerable('name')); // true
let prop = Object.keys(person);
console.log('length' in prop);  // true
console.log(prop.propertyIsEnumerable('length'));  // false

这里,属性name是可枚举的,因为他是你定义的,而length是原生属性,不可枚举。

3.5 属性类型

属性也分为2种,

  • 数据属性,包含一个值,如{name:'peng'}
  • 访问器属性. 例如getter和setter
const person = {
  _name: 'peng',
  get name() {
    console.log('you are  get name');
    return this._name;
  },
  set name(val) {
    console.log('you r setting name to %s', val);
    this._name = val;
  },
};
person.name;
person.name = 'pppp';

getter和setter很像函数但是没有function关键字。特殊关键字set 和 get 被用在访问属性名字的前面,后面跟着小括号和函数体。getter被期望返回一个值,而setter则接受一个需要被赋值的属性,本例子使用_name来保存属性数据,

3.4通用特征

两个属性特征是所有访问器属性都有的,一个是[[Enumerable]],它决定了该属性是否可枚举,另一个[[Configurable]],他决定该属性是否可配置,同时你定义的所有属性都是可枚举可配置。 如果你想定义可改变属性特征,使用Object.defineProperty()方法,他接受3个参数,

const person = {
  name: 'peng',
  age: 15,
};

Object.defineProperty(person, 'name', {
  enumerable: false,
});
Object.defineProperty(person, 'age', {
  configurable: false,
});

console.log(person);
console.log('name' in person); //
delete person.age;
console.log(person);

image

数据属性

多了一个[[Value]]还有一个[[Writable]]。所有属性的值都会被保存在[[Value]]中,哪怕是一个函数。

第四章 构造函数 和 原型对象

构造函数和类可以给javascript对象带来类似类的功能,本章详细解释JavaScript如何使用构造函数和原型对象创建对象。

4.1构造函数就是你用new对象创建对象时候调用的函数。目前为止,你已经见过几次内建JavaScript构造函数了。例如 new Object() new Array() new Function(),好处是他们具有相同属性方法。

构造函数唯一区别就是首字母大写。

var person1 =  new Person();
var person2 =  new Person();

等价于

var person1 =  new Person;
var person2 =  new Person;

即使Person不返回任东西,person1都是一个新的Person类的实例。

person1 instanceof Person;   // true

可以看出 instance就是例的意思,instance of 即实例化 image 同样的每个类都在创建的时候就拥有一个构造函数的属性 image image image

其中包含了一个指针指向其构造函数的引用,哪些通过字面量形式或者Object构造函数创建出来的泛用对象,其构造函数属性指向Object;

person1.constructor === Person;
person2.constructor === Person;

虽然存在这样的指向关系,但是还是建议你使用 instanceof来监察对象类型。这是因为构造函数属性可以被覆盖,不确定。 当然,一个空的构造函数接受一个命名参数name,并将他赋值给this对象name属性,同时,构造函数还给对象添加了一个sayName()方法,,new会自动创建this对象,其类型就是构造函数的类型。

function Person(name){
    this.name = name;
    this.sayName = function(){
        console.log(this.name);
    }
}

上面例子中,构造函数本身不需要返回一个值,new 操作符会帮你返回。

我本身世间的一颗因子,如何冲击我都可以。

之后可以使用Person构造函数来创建具有初始name属性的对象了。

var person1 = new Person('Nicholas');
console.log(person1.name);      // Nicholas

你可以在构造函数中显示的调用return,如果返回的值是一个对象,他就会代替新的对象实例返回。但是如果返回的值是一个原始类型,那么他就会被忽略,新建的对象实例会被返回。

function Person(name){
     Object.defineProperty(this, 'name', {
        get: function(){
            return name;
        },
        set: function(){
            name = newName;
        },
        enumerable: true,
        configurable: true
    });
    
    this.sayName = function (){
        console.log(this.name);
    }
}

这个版本中的Person函数中,name属性是一个访问者属性,利用name参数来存取世纪的值。之所以能这样,是因为命名参数就相当于一个本地变量。 始终保持用new调用构造函数,否则构造函数内的this就会指向window,或者undefined,就会改变全局变量。

var  person1 = Person('Nicholas');
console.log('person1 instanceof');   // false;
console.log(typeof person1);     // undefined;
console.log(name);             //  "Nicholas";

image 然而如今已经不允许不用new实例化一个构造函数了。其实还是可以猜出,当你不用new的时候,this指向全局对象,。由于Person构造函数依靠new提供返回值,person1变量为undefined。没有new,Person只不过是一个没有返回函数的函数。 image 而对于this.name,则会创建一个全局变量来保持。严格模式下,this指向undefined,所以一切给undefined赋值都会报错。

这个的时候你不妨猜测一下,new到底做了什么。目前为止显而易见的是:
  • new 生成一个新的构造函数并返回。
  • 新的构造函数原型全部指向被实例化的那个对象
  • 将this内部变量全部指向构造函数本身

image

image

构造函数本身并没有冗余。每个实例化对象都有自己的sayName方法,这意味着,如果你有100个实例化对象,他们就有一百个对象做同样的事情。只是使用的数据不一样。 如果同一个对象共享一个方法更有效率,该方法使用this访问到正确的数据,这就需要原型对象。

如果你有看前面的原始类型和引用类型,就会知道,原型只不过就是一个指针。它指向原始对象的方法及属性。这样的创建其实不会过多的创建对象。节省内存。

4.2 原型对象

可以把原型看做是一个对象的基类,几乎所有的函数(除了一些内建函数)都有一个名为prototype的属性,该属性是一个原型对象用来创建新的对象实例。所有创建的对象的实例共享该原型对象。例如hasOwnProperty()方法被定义在泛用对象Object原型中,但却可以被任何对象当作自己的属性使用。

var book = {
    title: 'The Principles of Object-Oriented Javascript'
};
console.log('title' in book);   // true
console.log(book.hasOwnProperty('title'));               // true;
console.log('hasOwnProperty' in book);                   // true
console.log(book.hasOwnProperty('hasOwnProperty'));               // false
console.log(book.prototype.hasOwnProperty('hasOwnProperty'));               // true

可以看出,即使book没有hasOwnProperty()方法的定义,仍然可以通过,book.hasOwnProperty()来访问这个方法, ,因为这个方法存在prototype内部,而in可以无论原型还是自有属性都返回true。

鉴别一个原型属性 你可以这样用,一个函数去鉴别一个属性是否属于原型。

function hasPrototype(object, name){
  return name in object && !object.hasOwnPrototype(name);
}
console.log(hasPrototype(book, 'title'))    // false
console.log(hasPrototype(book, 'hasOwnProperty'))    // false

如果一个属性 in 返回 true ,hasOwnProperty() 返回false,那么这个属性就是原型属性。

4.2.1 [[Prototype]]属性

一个对象实例通过内部属性[[Prototype]]跟踪其原型对象。该属性是一个指向该实例使用原型指针的对象。当你用new创建一个新的对象是,构造函数的原型对象,事实上,原型就是proptotype指向实例化的对象,你可以用

Object.getPrototypeOf({})  ;    // 得到{}空对象的原型

大多数对象都有一个__proto__属性。该属性使得你可以直接读写[[Prototype]]属性,Firefox,safari,ndoejs都应该支持, 你也可以用isPrototypeOf方法检查某个对象是否是另一个对象的原型,该方法被包含在所有对象中

var obj = {}
console.log(Object.prototype.isPrototypeOf(obj));; // true

因为obj本身是一个泛对象。他的原型是Object.prototype,意味着本例子中isPrototype应该返回ture. 当我们读取一个对象属性,JavaScript引擎首先应该现在对象自有属性中查找,如果自有属性不包含名字,则JavaScript会搜索 [[Prototype]]中的对象。如果找到就返回,找不到则返回undefined。 同时,你不可能给一个对象的原型赋值,为什么呢,因为你的赋值会被添加到自有方法里面。同时你永远无法删除原型属性,delete仅仅只对自由属性起作用。

在构造函数中使用原型对象

原型的对象的共享机制使得他们成为一次性为所有对象定义方法的理想手段。因为一个方法对所有对象实例做相同的事情。没理由每个实例都要有自己的方法。 image

function Person (name){this.name = name};
Person.prototype.sayName = function(){console.log(this.name)};
var p = new Person('peng')
console.log(p);

在这个版本的Person构造函数中,sayName()被定义在原型对象上而不是构造函数中。创建出的对象和本章之前的例子中创建的无二,只不过sayName()现在在原型属性中,而不是自有属性。而person1和person2调用sayName()时,相对的this值被分别附上person1和person2. 也可以在原型上储存其他类型的数据,比如数组,但是原型上面的数组属于原型指针指向继承,所有实例化对象都共享,

function Person(name){
    this.name = name;
}

Person.prototype.sayName = function(){
    console.log(this.name);
}
Person.prototype.favorites = [];

var person1 = new Person('Nicholas');
var person2 = new Person('Grey');

person1.favorites.push('234');
person2.favorites.push('34');

console.log(person1.favorites)    // ['234','34'];
console.log(person2.favorites)    // ['234','34'];

同样你可以用prototype 同时赋予多个属性

Person.prototype = {
   toString: function(){},
   sayName: function(){},
}

上面代码同样可以定义两个原型,但是要注意,person1的原型是一个对象,而不是Person了。为避免这一点,你需要手动添加constructor属性

Person.prototype = {
   constructor: Person,
   toString: function(){},
   sayName: function(){},
}

非常巧妙,这样你是否发现了new背后所做的不为人知的事情了吧,new 新建了一个构造函数并指向Person。 构造函数,原型对象和对象实例之间,最有趣的关系,就是对象实例和构造寒素没有直接关系。不过实例对象和原型对象以及原型和构造函数之间都是有关系的,

person1.prototype.constructor = Person

4.2.3 改变原型对象,

你可以改变原型对象,因为实例化对象的原型类指向原型的所有方法要继承,这一切只是指针关系。同样的道理实例化对象就是被冻结,他的原型同样可以被改变,。因为这对象和原型仅仅只是指针关系。

4.2.4 内建对象的原型扩展

给基本类型Array添加新属性,虽然方便,但是不可取,严禁这么做。