深入 JavaScript,从对象开始

2,263 阅读18分钟
入坑前端开发有一段时间了,面对形形色色的JavaScript优秀框架,到底该选择那一款呢?最近在使用Vue.js写一款markdown编辑器插件(mavonEditor)的时候,遇到了各种各样的问题,而导致这些问题的根本原因不是Vue.js,而是JavaScript基本功,所以我觉得,在一昧追求技术更新的同时,不要忘记夯实基础。

最近又翻了翻“犀牛书”,在此,写写我的心得。(本文主要用于笔者整理思路,有误望及时指正)

不满足于使用 JavaScript,而是“妄想”成为掌握 JavaScript 的程序员

JavaScript的数据类型分为两类:原始类型(primitive type)、对象类型( object type )

原始类型:数字、字符串、布尔型 , 特殊的原始值 null , undefined

对象类型:对象是属性的集合,数组、函数(特殊)...

要点: 
    1.函数与方法的区别: 定义在对象中作为属性值的函数称之为方法
    2.原始类型都有其对应的包装对象,Number() , String() , Boolean()
      可以通过构造函数显式创建(后面会详细解释): var str = new Number('string')
      这时候我们就可以 str.toUpperCase() 调用它的属性方法。
      这里有一个问题,如果我们定义一个字符串直接量 var str = 'string'
      此时str为原始类型,但是我们常常会 str.toUpperCase() 调用属性方法,
      str不是对象,为什么会有属性方法?
      其实这里javascript会隐式创建包装对象,通过包装对象来调用属性以及属性方法,一旦
      操作结束后,临时包装对象就会被销毁。
    3.原始类型不可修改,对象类型的属性是可变的。
      var str = 'string'
      str.toUpperCase() 并不会改变'string'为'STRING',而是重新返回一个新的字符串。
      同样,数字与布尔型的值更是无法改变,var num = 5 , 数值5无法改变,能够改变的只
      是变量num指向的值。
    4.原始类型比较大小时候,会比较其值,
      var num1 = 5, num2 = 5
      num1 == num2 -> true
      对象类型比较其是否引用同一个基对象,
      var obj1 = {
         x: 1      
      }
      var obj2 = {
          x: 1   
      }
      var obj3 = obj1
      obj1 == obj2 -> false
      obj1 == obj3 -> true
      obj2 == obj3 -> false

个人觉得 W3School 对于对象与原始类型的讲解不太合适 JavaScript 对象 ,虽然容易理解,但是存在本质上的区别。

综上,那么掌握了对象,是学好javascript的重要一步(个人见解),而理解这些晦涩繁琐的关系,又是掌握对象的关键。

梳理关系

对象的属性类型:

  1. 自有属性:直接定义在对象中的属性
  2. 继承属性:来自原型对象的属性
var obj = {
   name: 'myobj'
}
obj.name // name为自有属性
obj.toString() // toString为继承属性,因为我们并没有去定义toString这个属性方法,  
               //它是从原型对象中继承来的

------------------------下面是重点------------------------

Q: 原型对象是什么?

A: 原型对象是JavaScript 类的核心

Q: 那么类又是什么?类、对象、原型之间又是什么样的关系?

A: 类是相似特征的对象的集合。这与java中的类与实例化对象的关系基本一致

原型是类的核心。这句话并非空穴来风,类的所有实例对象都从同一个原型对象中继承属性 (也就是属性类型中的继承属性)。

好像还是不太理解...

那么我们从类的源头说起,

如何定义类?

  1. 构造函数(第一节已经叙述了构造函数的特殊性来自于new关键词,下面小节会详细讲述)
  2. Object.create()
  3. ES6语法新增的 class 关键词(实质上为语法糖

阮老师的Javascript定义类(class)的三种方法 - 阮一峰的网络日志博客中讲解了定义类的三种方式,但是严格意义上他所描述的第三种极简主义法并非定义一种新的类型,从实例对象的__proto__属性可以看出,对象的原型依然是Object.prototype(原型链的终点)

取而代之的第三种方法,也是最为容易理解的ES6新增的class关键词,它使得JavaScript更加类似于传统面向对象语言。

看到这更加懵逼了.... 继续看...

Q: 什么是语法糖?

A: 语法糖往往给程序员提供了更实用的编码方式,有益于更好的编码风格,更易读。不过其并没有给语言添加什么新东西。也就是说,class 关键词并没有导致JavaScript的语法结构产生变更,class 只是帮助你完成了一些繁琐的定义类型的步骤,但是我们不能只知其一不知其二,理解前两种方式会让你把class关键词运用的更加得心应手。

Q: 什么是ES6?

A: ECMAScript 6入门阮老师的这本开源书籍真心不错。(依然在亚马逊买了正版表达对阮老师的仰慕)

Q: 什么是原型链?(重点)

A: 原型链是JavaScript的继承机制,理解原型链前,我们需要知道prototype__proto__

prototype:是函数的一个属性(每个函数都有一个prototype属性),这个属性是一个指针,指向一个对象,即原型对象。

__proto__:是一个对象拥有的内置属性(请注意:prototype是函数的内置属性,__proto__ 是对象的内置属性),是JS内部使用寻找原型链的属性。

因为构造函数可以定义类,所以也可以理解类的prototype属性指向类的原型对象。

__proto__指向实例对象继承的某个原型对象。

var date = new Date();
console.log(date.__proto__)
console.log(Date.prototype)

两者打印了同样的原型对象

<img src="https://pic4.zhimg.com/v2-5c01c5e271df368ca2916b808821fdaf_b.png" data-rawwidth="466" data-rawheight="290" class="origin_image zh-lightbox-thumb" width="466" data-original="https://pic4.zhimg.com/v2-5c01c5e271df368ca2916b808821fdaf_r.png">可以看出他们的原型对象都为Date,下面是该原型对象Date的__proto属性

可以看出他们的原型对象都为Date,下面是该原型对象Date的__proto属性

<img src="https://pic2.zhimg.com/v2-f32c7f84b50ed74815207cb4b05a9bad_b.png" data-rawwidth="401" data-rawheight="228" class="content_image" width="401">原型对象Date中也存在__proto__属性(因为对象含有__proto__属性),它又标志这个原型对象的原型来源,可以看出其来自于Object,准确的说来自于Object的原型对象。此时Object的原型对象并没有__proto__属性

原型对象Date中也存在__proto__属性(因为对象含有__proto__属性),它又标志这个原型对象的原型来源,可以看出其来自于Object,准确的说来自于Object的原型对象。此时Object的原型对象并没有__proto__属性

这就是所谓的原型链,同时也说明了原型链的终点是Object.prototype

看到这里,你有没有对原型、类、对象有了一定了解呢?

这个图能够帮助我们更好的理解三者关系,

对象a是类A的实例对象,这里A的原型来自于Object,如下图

<img src="https://pic2.zhimg.com/v2-4c76da56a9ef96d4a84d2b3becd232a9_b.png" data-rawwidth="623" data-rawheight="140" class="origin_image zh-lightbox-thumb" width="623" data-original="https://pic2.zhimg.com/v2-4c76da56a9ef96d4a84d2b3becd232a9_r.png">

关系并不复杂吧!这时候你再回过头看Javascript定义类(class)的三种方法 - 阮一峰的网络日志第三种方法,也就能理解为什么我说它并没有定义一个新的类型了吧!因为这种方法获取的实例对象的原型为Object.prototype所以它并未产生新的类型。

现在我们再回过头来看三种定义类的方式

1. 构造函数:利用函数的prototype属性定义类的原型,从而产生新的类型,借助new 关键词实例化对象

// 类名最好大写(约定)
function Cat() {
   this.age = 3
}
Cat.prototype.name = 'cat'
var cat = new Cat(); // cat.__proto__ == Cat.prototype 
cat.name === 'name' // name 为 cat的继承属性 
cat.age === 3 // age 为 cat的自有属性

2. Object.create(obj): 返回一个实例对象,这个实例对象的原型为obj(详细用法

  1. var Cat = {
    	name: 'cat'
    	}
    var cat = Object.create(Cat); 
    // cat.__proto__ == Cat 方式2的Cat等同于方式1的Cat.prototype
    cat.name === 'name' 
    // name 为 cat的继承属性 ,这种方式无法预置自有属性
    

3. ES6 class 关键词: 语法糖,实质原理基本一致,详细用法参考 ES6标准入门(第二版)

class Cat {
  constructor () {
     this.age = 3;
  }
  getAge () {
     return this.age;
  }
}
var cat = new Cat();
// cat.__proto__ == Cat.prototype
// age为自有属性 , getAge 为属性方法

下面一节将详细讲述对象


对象剖析

创建对象:

  1. 对象直接量
    var obj = {
      name: 'obj',
      getName: function() {
         return this.name;
      }
    }
    // 这里的name getName 都是对象的自有属性
    
  2. 通过类型实例化(new Obj() , Object.create(Obj.prototype))。我们通过一幅图,来理解这种方式的实例化对象的过程

<img src="https://pic3.zhimg.com/v2-4258184972151ea3f9923c5c4f95fcb2_b.png" data-rawwidth="785" data-rawheight="347" class="origin_image zh-lightbox-thumb" width="785" data-original="https://pic3.zhimg.com/v2-4258184972151ea3f9923c5c4f95fcb2_r.png">没找到合适的图,自己手画...

没找到合适的图,自己手画...

稍微解释下,当我们通过new 关键词实例化对象时候,首先回去访问这个类的原型对象,原型对象中有个属性constructor指向构造函数,通过它可以访问到构造函数,带着构造函数中的自有属性,并且通过构造函数中的prototype回到其指向的原型对象,带着其它原型属性,生成一个新的对象并且返回。虽然JavaScript的代码运行逻辑不一定是这样,但是可以这么去理解。

对象的具体内容我们就不深入说了,每本JavaScript基础书籍应该都会详细说明。下面我们会剖析一种特殊的对象——函数(本节的函数主要用于构造)。

var fun = new Function('x' , 'return x');
function fun2 (x) {
    return x;
}

fun 与 fun2 在功能逻辑上完全相同,那么它们之间又有什么区别与联系呢?

---------------------------下面是重点----------------------------------

在这里,我们不仅要搞清function与Function的关系,还要完全搞懂对象从何而来。

上面的例子 fun2为是我们常用的定义函数的方法,如果第一节你完全理解了的话,你能很容易判断出fun2.prototype 指向fun2的原型,那么我们一直说,函数也是对象,那么函数有没有__proto__属性呢?

console.log(fun2.__proto__)
console.log(fun2.prototype)

<img src="https://pic4.zhimg.com/v2-b45646d94377277149ee5e7436c51b3b_b.png" data-rawwidth="271" data-rawheight="72" class="content_image" width="271">很明显,两次结果并不相同,fun2.prototype的结果在我们预料之内,fun2.__proto__输出的结果让我们有点不知所措。其实它是Function的原型对象,我们可以

很明显,两次结果并不相同,fun2.prototype的结果在我们预料之内,fun2.__proto__输出的结果让我们有点不知所措。其实它是Function的原型对象,我们可以console.log(Function.prototype)看看结果

<img src="https://pic2.zhimg.com/v2-8bd8c66dae12563bbc2d3f75444b5255_b.png" data-rawwidth="263" data-rawheight="69" class="content_image" width="263">

那么Function.prototype又是从何而来?

通过原型链也很好追本溯源,我们继续输出 console.log(Function.prototype.__proto__)

<img src="https://pic1.zhimg.com/v2-96ceeebfa31d1d8acb72c186885b4100_b.png" data-rawwidth="366" data-rawheight="331" class="content_image" width="366">

这个是不是很眼熟?这就是Object.prototype(原型链的终点)

完全相同,这也就说明了两点

1. 函数是对象,函数的原型是Function

2. Object.prototype是原型链的终点

好像接近真相了...

函数源于Object.prototype,它是一个具有特殊使命的对象,通过它定义其它类型,简单的说,JavaScript定义了Function类型用来定义其它类型

举个例子,Object类型从何而来?

直接上图...

<img src="https://pic2.zhimg.com/v2-9b8e0fbd5fcb67b6cf8ed5b667915c5d_b.jpg" data-rawwidth="1082" data-rawheight="563" class="origin_image zh-lightbox-thumb" width="1082" data-original="https://pic2.zhimg.com/v2-9b8e0fbd5fcb67b6cf8ed5b667915c5d_r.jpg">

1. 首先: JavaScript中先创建的是Object.prototype这个原型对象。
2. 然后:在这个原型对象的基础之上创建了 Function.prototype这个原型对象。
3. 其次:通过这个原型对象 创建出来Function这个函数。
4. 最后 : 又通过Function这个函数创建出来之后,Object()这个对象。

图中,每个箭头的含义就不详细说明了,搞懂prototype与__proto__应该很容易理解

(摘录自:JavaScript中的Object,Function和自定义function之间的区别和联系 - sizee的博客 - 博客频道 - CSDN.NET

原理搞懂了,下面看看如何运用...

---------------通过构造函数定义类型---------------------

(Object.create() 与 class 两种方式就不详细说明了)

function Obj () {
    this.name = 'obj';
    this.getName = function() {
      return this.name;
    }
}
Obj.prototype.protoName = 'protoObj';
Obj.prototype.getProtoName = function () {
    return this.protoName 
}
// name 、 getName 自有属性
// protoName 、 getProtoName 原型属性

这里有两个注意点

1. 如果原型属性值为非函数对象时,为了避免共享属性值,应当将此属性放入自有属性。

举个例子,依然以上述例子为基础

// 增加一个原型属性 , 值为数组
Obj.prototype.arr = [1];
// 实例化两个对象
var obj1 = new Obj();
var obj2 = new Obj();
obj1.arr.push(2);
console.log(obj2.arr) // [1, 2]
console.log(obj2.arr) // [1, 2]

因为原型对象中的属性值实质上是指向对象的指针。将arr更改为自有属性,就不会出现该问题

2. Obj.prototype 默认携带 constructor 构造属性,属性值为构造函数对象

console.log(Obj.prototype)
<img src="https://pic2.zhimg.com/v2-f085ab9f575860ad484c1caeacdef871_b.png" data-rawwidth="389" data-rawheight="244" class="content_image" width="389">

如果我们在定义原型对象的时候,采用了重新定义,而不是新增属性,如

Obj.prototype = {
    protoName: 'protoObj',
    getProtoName: function () {
        return this.protoName;
    }
}

这时候,原型对象就失去了构造属性,可以主动添加此属性

Obj.prototype = {
    contructor: Obj, // 构造属性
    protoName: 'protoObj',
    getProtoName: function () {
        return this.protoName;
    }
}

这是通过构造函数定义类的基本方式,当然还有很多基于构造函数的拓展方式,但是万变不离其宗。

下一节讲述函数的其他功能

函数剖析
  • 函数当作属性值叫做方法
  • new 可赋予函数构造类的能力

提及函数,往往会想到函数作用域:变量在声明它的函数体内以及这个函数体嵌套的任意函数体都是有定义的。

函数作用域内有一个特点,变量的提前声明

var name = 'js';
function scope() {
  console.log(name);
  var name = 'javascript';
  console.log(name);
}
scope();

我们来看看输出结果:

<img src="https://pic4.zhimg.com/v2-442027f049aba24149da81c22f74aff3_b.png" data-rawwidth="214" data-rawheight="40" class="content_image" width="214">为什么第一次输出undefined?(undefined标识已定义未赋值),这就是变量的提前声明:

为什么第一次输出undefined?(undefined标识已定义未赋值),这就是变量的提前声明:函数体内所有的变量的声明都会提前到函数顶部。其实,这段代码同下:

var name = 'js';
function scope() {
  var name;
  console.log(name);
  name = 'javascript';
  console.log(name);
}
scope();

那么我们也来论证一下:JavaScript不存在块级作用域

if (true) {
    var blockscope = 'blockscope'
}
console.log(blockscope) // 'blockscope'

结果很明显...输出了'blockscope',论证完毕!

-------------------下面是重点--------------------

作用域链是什么?

JavaScript权威指南定义如下:

在 JavaScript 的 最 顶层 代码 中( 也就是 不 包含 在任 何 函数 定义 内 的 代码), 作用域 链由 一个 全局 对象 组成。 在 不 包含 嵌套 的 函数 体内, 作用域 链 上有 两个 对象, 第一个 是 定义 函数 参数 和局 部 变量 的 对象, 第二个 是 全局 对象。 在 一个 嵌套的 函数 体内, 作用域 链 上至 少有 三个 对象。 理解 对象 链 的 创建 规则 是 非常 重要的。 当 定义 一个 函数 时, 它 实际上 保存 一个 作用域 链。 当 调用 这个 函数 时, 它 创建 一个 新的 对象 来 存储 它的 局部 变量, 并将 这个 对象 添加 至 保存 的 那个 作用域 链 上, 同时 创建 一个 新的 更长 的 表示 函数 调用 作用域 的“ 链”。

通俗点解释:一个函数就是一个作用域(顶层函数隶属于全局作用域),函数的嵌套导致作用域之间的层级包含关系(像链条一样层层关联),这就是最直观的作用域链。

我们在创建一个函数的时候,它的属性变量、方法都叫做变量对象(VO),当我们调用函数的时候,并不是直接在当前定义的作用域链上进行逻辑操作,而是根据当前函数的作用域,重新创建一个作用域链,这个作用域链用来执行代码时候使用,执行时候用到的变量对象(VO),我们称作为活动对象(AO),活动对象其实就是指向变量对象的指针。

例子(知乎回答-闭家锁):

var pubvar = 1;
function pub () {
  var pravar = 2;
  return pubvar + pravar;
}

pub();     // 调用pub()函数

此时第一级作用域下存在变量pubvar 与 函数pub

<img src="https://pic1.zhimg.com/v2-fe441c103c7c7104482fce1b9893dfdc_b.jpg" data-rawwidth="600" data-rawheight="424" class="origin_image zh-lightbox-thumb" width="600" data-original="https://pic1.zhimg.com/v2-fe441c103c7c7104482fce1b9893dfdc_r.jpg">当我们调用pub()的时候,会激活当前pub的作用域链,创建一个新的执行作用域B,当函数执行完成后,执行作用域B会被销毁。B环境中的变量对象就称为活动对象

当我们调用pub()的时候,会激活当前pub的作用域链,创建一个新的执行作用域B,当函数执行完成后,执行作用域B会被销毁。B环境中的变量对象就称为活动对象

<img src="https://pic4.zhimg.com/v2-4acf90eb2c64dcbfd079270fe6cb6d93_b.jpg" data-rawwidth="600" data-rawheight="424" class="origin_image zh-lightbox-thumb" width="600" data-original="https://pic4.zhimg.com/v2-4acf90eb2c64dcbfd079270fe6cb6d93_r.jpg">这是最简单的作用域链。在

这是最简单的作用域链。在 js 中的活动对象 与 变量对象 什么区别?这个提问中闭家锁的回答对作用域链,以及闭包解释的都很透彻,我就不搬砖了。不过下面闭包我们还是要讲讲

理解作用域链的创建过程,对闭包也就理解了一半

看一个最简单的闭包例子:

function person () {
    var amount = 5;
    function addPerson () {
        amount ++;
        console.log(amount )   
    }
    return addPerson;
}
var ps = person(); // ps是一个内部函数
ps();
ps();

运行结果:

<img src="https://pic4.zhimg.com/v2-7b27dae2cc10d5f5a5d25aea6a85f283_b.png" data-rawwidth="148" data-rawheight="38" class="content_image" width="148">从中我们可以看出闭包的基本作用:

从中我们可以看出闭包的基本作用:

1. 在外层作用域中可以访问局部变量(我们在外层作用域改变了amount的值)

2. 致使执行环境没有被销毁,两次调用ps() 使用的是同一个活动对象amount,从运行结果可以看出

也可以理解为改变了函数内部局部变量的生命周期的手段叫做闭包。也正是因为这个局部变量一直处于激活状态(活动对象),所以导致执行环境一直存在。

闭包应用场景非常广,可能你一直在用,只是你不知道何为闭包。

举个最简单的例子,当我们创建一个新的对象的时候,对象的属性都是公开的,我们想要模拟私有属性,借助闭包来做:

function obj() {
   var privateVar = 'private'
   return {
       getPrivateVar: function() {
           return privateVar ;
       },
       setPrivateVar: function(str) {
          privateVar = str; 
        } 
   }
}
var o = obj();
// 无法直接访问 privateVar ,但是可以通过返回的对象中的方法 间接干预
console.log(obj.getPrivateVar()) // private
obj.setPrivateVar('js')
console.log(obj.getPrivateVar()) // js

------------------------------------------------------------

在这里,我们再稍微提一下JavaScript中能够改变执行环境的几个方法。

call()apply()

这两个方法作用上完全相同,

var dog = {
  name: 'dog',
  say: function () {
    console.log(this.name)
  }
}
var cat = {
    name: 'cat'
}
dog.say()
dog.say.call(cat)

运行结果:

<img src="https://pic4.zhimg.com/v2-eb6b6ebaad891ac485a6690675aacbaf_b.png" data-rawwidth="291" data-rawheight="42" class="content_image" width="291">A.call(B) 就是将A放入B的执行环境中执行。call 与 apply的唯一区别就是传参方式

A.call(B) 就是将A放入B的执行环境中执行。call 与 apply的唯一区别就是传参方式

待执行.call ( 执行环境 , arg1 , arg2 )

待执行.apply ( 执行环境 ,[ arg1 , arg2] ) // 只有两个参数 , 第二个单数为形参数组

bind()

bind方法与 call 大同小异,A.bind(B)最大的区别就是,它并没有立即执行A , 而是返回了一个新的方法,这个方法的执行环境是B,你也可以A.bind(B , arg1 , arg2 ...) 传递参数

var sum = function (y){
   return this.x + y // x从执行环境中获取
}
var obj = {
   x: 1
}
var succ = sum.bind(obj , 2);
console.log(succ()) // 3
var succ2 = sum.bind(obj)
console.log(succ(2)) // 3


总结

将这些原理上的东西剖析透彻,一些无法理解的逻辑自然而然就会迎刃而解,思考的方式也会潜移默化的发生改变。

参考