【前端读书笔记】你不知道的JavaScript(上卷)

193 阅读47分钟

1. 作用域和闭包

1.1. 作用域是什么

作用域:

一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域

1.1.1. 编译原理

JavaScript是一门编译语言

传统编译语言在执行之前会经历三个步骤,统称为编译:

  1. 分词/词法分析
  2. 解析/语法分析
  3. 代码生成

JavaScript引擎要复杂的多,介绍:

  1. JavaScript引擎不会有大量的(像其他语言编译器那么多大)时间来进行优化,因为与其他语言不同,JavaScript的编译过程不是发生在构建之前的。
  2. 对于JavaScript来说,大部分情况下的编译发生在代码执行前的几微秒(甚至更短)的时间内
  3. 简单的说,任何JavaScript代码片段在执行前都要进行编译(通常就在执行前)。因此,JavaScript编译器首先会对 var a=2这段程序进行编译,然后做好执行他的准备,并且通常马上就会执行他

1.1.2. 理解作用域

  • 引擎 从头到尾负责整个JavaScript程序的编译和执行过程
  • 编译器 引擎的好朋友之一,负责语法分析及代码生成等脏活累活
  • 作用域 引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限

1.1.2.1. 变量赋值操作

案例:

var a = 2;

引擎会认为这里有两个完全不同的声明:一个由编译器在编译时处理,另一个则由引擎在运行时处理

  1. 编译器首先会将这段代码分解成词法单元,然后将词法单元解析成一个树结构。之后生成代码:编译器询问作用域是否已经有一个该名称的变量存在于同一个作用域集合中,如果有,就忽略该声明,如果没有,就要求作用域在当前作用域的集合中声明一个新变量并命名为a
  2. 引擎运行:引擎运行编译器生成的代码,引擎运行时会首先询问作用域,当前作用域集合中是否存在一个叫a的变量,如果是,就使用这个变量;如果否,就继续查找该变量。如果找到了a变量,就将2赋值给它,如果否,就抛出异常

总结: 变量的赋值会执行两个操作,首先编译器会在当前作用域中声明一个变量(如果之前没声明过),然后在运行时引擎会在作用域中查找该变量,如果能找到就会对它赋值

1.1.2.2. 编译器术语

LHS和RHS

当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。讲的更准确一点,RHS查询与简单地查找某个变量的值别无二致,而LHS查询则是视图找到变量的容器本身,从而可以对其赋值。

LHS和RHS的含义是“赋值操作的左侧或者右侧”,并不一定意味着就是“=”赋值操作值的左侧或者右侧。在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”

1.1.3. 作用域嵌套

当一个块或者函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或者抵达最外层的作用域(也就是全局作用域)为止。

1.1.4. 异常

区分LHS和RHS是一件重要的事情。

在变量还没有声明的情况下,这两种查询的行为是不一样的。

function foo(a){
    console.log(a+b);
    b=a;
}


foo(2);

第一次对b进行RHS查询是无法找到该变量的,这是一个未声明的变量,在任何相关的作用域中都无法找到它。

如果RHS在所有嵌套的作用域中都找不到所需的变量,引擎就会抛出``ReferenceError`异常

当引擎执行LHS查询时,如果在全局作用域中也找不到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎。

当程序处于严格模式下运行,LHS查询失败后,不会创建一个全局变量,引擎会抛出同RHS查询失败时类似的ReferenceError异常

当RHS找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如视图对一个非函数类型的值进行函数调用,或者引用Null或Undefined类型的值中的属性,那么引擎会抛出异常TypeError

ReferenceError同作用域判断失败相关,TypeError代表作用域判断成功,但是对结果的操作是非法或者不合理的。

1.1.5. 小结

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,使用LHS查询,如果目的是获取变量的值,就使用RHS查询。

1.2. 词法作用域

作用域共有两种主要的工作模式:

  • 词法作用域,被大多数编程语言所采用
  • 动态作用域

1.2.1. 词法阶段

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和作用域写在哪里来决定的,因此当词法分析其处理代码时会保持作用域不变。

1.2.1.1 查找

作用域查找始终从运行时所处的最内部作用域开始,逐级向外进行,作用域查找会在找到第一个匹配的标识符时停止。在多层嵌套作用域中可以定义同名的标识符,这叫做遮蔽效应(内部的标识符遮蔽了外部的标识符)。

全局变量会自动成为全局对象的属性,因此可以不通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。

window.a

这种技术可以访问哪些被同名变量遮蔽的全局变量。但是非全局的变量如果被遮蔽了,无论如何都无法被访问到

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

词法作用域只会查找以及一级标识符,比如a,b,c。如果代码中引用了foo.bar.baz,词法作用域只会查找foo标识符,找到这个变量后,对象属性访问规则会分别接管对bar和baz属性的访问

1.2.2. 欺骗词法

有两种机制可以实现欺骗词法作用域的目的:

  • eval
  • with

1.2.2.1. eval()

eval()函数接受一个字符串作为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。

在执行eval()之后的代码时,引擎并不知道或者在意前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的。引擎只会如常地进行词法作用域查找

function foo(str,a){
  eval(str)  //欺骗
  console.log(a,b);
}

var b = 2;
foo('var b = 3;',1)  //1,3

默认情况下,如果eval()中所执行的代码包含有一个或多个声明(无论是变量还是函数),就会对eval()所处的语法作用域进行修改。

严格模式下,eval()在运行时有气自己的词法作用域,意味着其中的声明无法修改所在的作用域。

在程序中动态生成代码的场景非常罕见,因为它所带来的好处无法抵消性能上的损失

1.2.2.2. with()

with通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

with可以将一个没有货有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在个这作用域中国的词法标识符

1.2.3. 小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。

JavaScript中有两个机制可以欺骗词法作用域:eval()with()。前者可以对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域。后者本质上是通过将一个对象的引用当做作用域来处理,将对象的属性当做作用域中的标识符来处理,从而创建了一个新的词法作用域。

1.3. 函数作用域和块作用域

1.3.1. 函数中的作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用

1.3.2. 隐藏内部实现

基于作用域“隐藏”变量和函数的原因大多从最小特权原则中引申出来

最小特权原则:这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或者对象的API设计

1.3.2.1. 规避冲突

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突

1.3.2.1.1. 全局命名空间

变量冲突的一个典型例子存在于全局作用域中,当程序中加载了多个第三方库时,如果他们没有妥善的将内部私有的函数或者变量隐藏起来,就容易引发冲突

这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象的属性。

1.3.2.1.2. 模块管理

另一种避免冲突的方法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需即将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显示地导入到另外一个特定的作用域中。

1.3.3. 函数作用域

为代码片段在外部添加包装函数带来了两个问题:

  • 必须声明一个具名函数(例如foo()),这意味着foo这个名称本身“污染”了所在的作用域
  • 必须通过函数名(foo())调用这个函数才能运行

解决这两个问题的方案:

var a=2;
(function foo(){  // <---添加这一行

  var a = 3;
  console.log(a);  // 3
})();  // <---以及这一行

console.log(a); //2

包装函数的声明以(function ...而不仅是以function...开始。函数会被当做函数表达式而不是一个标准的函数声明来处理

区分函数声明和函数表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式

普通的函数声明的foo被绑定在所在的作用域中,可以直接通过foo()来调用它。第二个片段中foo被绑定在函数表达式自身的函数中,而不是躲在作用域中。

1.3.3.1. 匿名和具名

对于函数表达式最熟悉的场景可能就是回调函数了:

setTimeout( function(){
  console.log('I waited 1 second');
},1000);

这叫做匿名函数表达式,因为function()...没有名称标识符。

函数表达式可以匿名,函数声明则不可以省略函数名

补充: 函数声明的三种方式

// Function()构造器
// var 变量名 = new Function('形参1','形参2'....,'函数体');
var f = new Function()

// 函数表达式
var f = function(){
  console.log(1)
}

//直接声明
function f(){
  console.log(2)
}

匿名函数表达式写起来简单,但是也有几个缺点:

  • 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难
  • 没有函数名,当调用自身时比较困难
  • 匿名函数省略了对于代码可读性很重要的函数名

1.3.3.2. 立即执行函数表达式

var a=2;
(function foo(){  // <---添加这一行

  var a = 3;
  console.log(a);  // 3
})();  // <---以及这一行

console.log(a); //2

由于函数被包含在一对括号内部,因此成为了一个表达式,通过在末尾加上另外一个括号可以立即执行这个函数

IIFE,立即执行函数表达式(Immediately Invoked Funciton Expression)

另一种形式:*

(function(){...}())

两种形式在功能上是一致的

立即执行函数表达式的用法:

  1. 把他当做函数调用并传递参数进去
    var a=2;
    (function IIFE(global){
      var a=3;
      console.log(a);  //3
      console.log(global.a);  //2
    })(window);
    
    console.log(a) ;   //2
    
  2. 解决undefined标识符的默认值被错误覆盖导致的异常
    undefined=true; //undefined被错误覆盖
    
    (function IIFE(undefined){
      var a;
      if(a===undefined){
        console.log('undefined is safe here')
      }
    })()
    
  3. 倒置代码的运行顺序
    var a=2;
    (function IIFE(def){
      def(window);
    })(function def(global){
      var a=3;
      console.log(a);  //3
      console.log(global.a);  //2
    });
    
    函数表达式def定义在片段的第二部分,然后当做参数(这个参数也叫做def)被传递进IIFE函数定义的第一部分中。最后参数def被调用,并将window传入当做global参数的值

1.3.4. 块作用域

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息

1.3.4.1. with

with是块作用域的一个例子,用with从对象中创建的出的作用域仅在with声明中而非外部作用域中有效。

1.3.4.2. try/catch

try{
  undefined();  //执行一个非法操作来强制制造一个异常
}
catch(err){
  console.log(err);  //能够正常执行
}

console.log(err) ; //ReferenceError:err not found

err仅存在catch分句内部,当视图从别处引用时会抛出错误

1.3.4.3. let

let 关键字可以将变量绑定到所在的任意作用域中(通常是{...}内部)。换句话说,let为其声明的变量隐式地了所在的块作用域。

只要声明式有效的,在声明中的任意位置都可以使用{...}括号来为let创建一个用于绑定的块。

例如;

var foo=true;
if(foo){
  { // <---显式的块
    let bar =foo*2;
    bar=something(bar);
    console.log(bar);
  }
}

console.log(bar); //ReferenceError

使用let进行声明的不会再块作用域中进行提升。声明的代码在被运行之前,声明并不“存在”。例如:

{
  console.log(bar); //ReferenceError
  let bar =2;
}

1.3.4.4. const

const 同样用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误

1.4. 提升

1.4.1. 先有鸡还是先有蛋

第一个例子:

a=2;
var a;
console.log(a); //2

第二个例子:

console.log(a);  //undefined
var a=2;

问题:到底是声明(蛋)在前,还是赋值(鸡)在前?

1.4.2. 编译器再度来袭

引擎会在解释JavaScript代码之前首先对其进行编译。编译阶段的一部分工作就是找到所有的声明,并用合适的作用域将他们关联起来。因此,正确的思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

例如:var a = 2,JavaScript会将其看做两个声明:var a;a=2;。第一个定义声明式在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。

所以在1.4.1 中第一个例子 会变为:

var a ;
a=2;
console.log(a); //2

第二个例子会变为:

var a;
console.log(a); //undefined
a=2;

这个变化过程就好像变量和函数声明从他们在代码中出现的位置被移动到了最上面。这个过程就叫做变量提升

换句话说先有蛋(声明),后有鸡(赋值

注意:

  • 每个作用域都会进行提升操作

  • 函数声明会被提升,但是函数表达式不会被提升(即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用)

1.4.3. 函数优先

函数声明和变量声明都会被提升,但是,函数会首先被提升,然后才是变量提升

foo(); //1

var foo;

function foo(){
  console.log(1);
}

foo=function(){
  console.log(2);
}

这个代码片段会被引擎理解为如下形式:

function foo(){
  console.log(1);
}

foo(); //1

foo=function(){
  console.log(2);
}

注意:var foo尽管出现在function foo()...的声明之前,但是他是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。foo=function(){...}是函数表达式,不会被提升。

1.5. 作用域闭包

1.5.1. 启示

闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它而有意识的创建闭包。

1.5.2. 实质问题

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

示例代码:

function foo(){
  var a=2;

  function bar(){
    console.log(a);
  }

  return bar;
}

var baz=foo();

baz();  //2 这就是闭包的效果

函数bar()的词法作用域能够访问foo()的内部作用域。然后我们将bar()函数本省当做一个值类型进行传递。在这个例子中,我们将bar所引用的函数本身当做返回值

foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过不同的标识符引用了内部的函数bar()

bar()在自己定义的词法作用域以外的地方执行。

foo()执行后,通常会期待foo()的整个内部作用域都被销毁。因为看上去foo()的内容不会再被使用,所以考虑对其进行回收

但是闭包的神奇之处正是可以阻止回收。事实上内部作用域依然存在,没有被回收,是谁在使用这个内部作用域?是bar()本身在使用

bar()所声明的位置所赐,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用

bar()依然持有对该作用域的引用,而这个引用叫做闭包

无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包

无论通过何种手段将内部函数传递到所在的词法作用域之外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包

如果将函数(访问他们的各自的词法作用域)当做第一级的值类型并到处传递,你就会看到闭包在这个函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信或者其他异步任务中,只要使用的回调函数,实际上就是在使用闭包。

1.5.4. 循环和闭包

代码示例:

for (var i=1;i<=5;i++){
  setTimeout(function timer(){
    console.log(i);
  },i*1000);
}

正常情况下,我们对这段代码的预期是分别输出数字1~5,每秒一次,每次一个。

但是实际上,这段代码在运行时会以每秒一次的频率输出五次6

6是循环结束的条件

延迟回调会在循环结束时才执行

所以会输出5个6

代码中存在“缺陷”导致它的行为与语义所按时的不一样

缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各自迭代中分别定义的,但是他们都被封闭在一个共享的全局作用域中,因此实际上只有一个i.

如果将延迟函数的回调重复定义5次,完全不使用循环,那它同这段代码时完全等价的。

要实现我们的想法,我们需要更多的作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

IIEF(立即执行函数表达式)会通过声明并立即执行一个函数来创建作用域。

所以:

for(var i=1;i<=5;i++){
  (function(){
    setTimeout( function timer(){
      console.log(i);
    },i*1000);
  })();
}

但是这样也是不行的,因为IIFE只是一个什么都没有的空作用域。它需要自己的变量,用来在每个迭代中存储i的值:

for (var i=1;i<=5;i++){
  (function(){
    var j=i;
    setTimeout( function timer(){
      console.log(j);
    },j*1000)
  })()
}

这样就可以完成我们的想法了

改进一下:

for (var i=1;i<=5;i++){
  (function(j){
    setTimeout( function timer(){
      console.log(j);
    },j*1000)
  })()
}

在迭代内使用IIFE会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

1.5.4.1. 使用块作用域

我们使用IIFE在每次迭代是都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。

let声明可以用来劫持块作用域,并且在这个块作用域中声明一个变量

for (var i=1;i<=5;i++){
  let j=i;  //闭包的块作用域
  setTimeout(function timer(){
    console.log(j);
  },j*1000)
}

for循环头部的let声明还会有一个特殊的性岗位。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

for (let i=1;i<=5;i++){
  setTimeout(function timer(){
    console.log(i)
  },i*1000)
}

1.5.5. 模块

当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时,我们已经创造了可以观察和实践闭包的条件。

模块模式需要两个必备条件:

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中国形成闭包,并且可以访问或者修改私有的状态。

1.5.5.1. 现代的模块机制

大多数模块依赖加载器/管理器,本质上都是将这种模块定义封装进一个友好的API

1.5.5.2. 未来的模块机制

ES6中尾模块增加了一级语法支持。但通过模块系统进行加载时,ES6会将文件当做独立的模块进行处理。每个模块都可以导入其他模块或者特定的API成员,同样也可以导出自己的API成员

ES6的模块没有行内格式,必须被定义在单独的文件中。(一个文件一个模块)浏览器或者引擎有一个默认的“模块加载器”,可以在导入模块时异步地加载模块文件

  1. import可以将一个模块中的一个或多个API导入当前作用域中,并分别绑定在一个变量上
  2. module会将整个模块的API导入并绑定到一个变量上
    module foo from 'foo';
    
  3. export会将当前模块的一个标识符导出为公共API。
    function hello(who){
      return 'let me untroduce :' + who;
    }
    
    export hello;
    

1.5.6. 小结

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包

模块的两个主要特征:

  1. 为创建内部作用域而调用了一个包装函数
  2. 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包

2. this和对象原型

2.1. 关于this

2.1.1. 为什么要用this

this提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API设计得更加简介并且易于复用

2.1.2. 误解

2.1.2.1. 指向自身

第一种误解是this指向函数自身。

示例代码:

function foo(num) {
    console.log('foo:' + nu
    // <!-- 记录foo被调用的次数 -->
    this.count++;
}

foo.count =0
var i;
for (i = 0; i < 10; i++) {
    if (i > 5) {
        foo(i);
    }
}
// foo:6
// foo:7
// foo:8
// fo
console.log(foo.count);  //0

foo.count为0,显然从字面意思理解this是错的

执行foo.count=0时,的确向函数对象foo添加了一个属性count,但是函数内部代码this.count中的this并不是指向那个函数对象,所以虽然属性名相同,根对象却并不相同

如果要从函数对象内部引用它自身,那只使用this是不够的。一般来说需要通过一个指向函数对象的词法标识符(变量)来引用它

function foo(){
  foo.count=4; //foo指向它自身
}

setTimeout(function(){
     //匿名函数无法指向自身
},1000)

但是这种方法回避了this的问题,并且完全依赖于变量foo的词法作用域

另一种方法是强制this指向foo函数对象:

function foo(num) {
    console.log('foo:' + nu
    // <!-- 记录foo被调用的次数 -->
    this.count++;
}

foo.count =0
var i;
for (i = 0; i < 10; i++) {
    if (i > 5) {
      //使用call(...)可以确保this指向函数对象foo本身
        foo.call(foo,i);
    }
}
// foo:6
// foo:7
// foo:8
// fo
console.log(foo.count);  //4

2.1.2.2. 它的作用域

第二种常见的误解是,this指向函数的作用域。

需要明确的是,this在任何情况下都不指向函数的词法作用域。

        function foo(){
            var a = 2;
            this.bar();
        }

        function bar(){
            console.log(this.a);
        }


        foo(); //undefined

代码解析: 首先,这段代码试图通过this.bar()来引用bar()函数。这是不可能成功的。调用bar()最自然的方法是省略前面的this

此外,这段代码试图使用this联通foo()bar()的词法作用域,从而让bar()可以访问foo()作用域里面的变量a。这不可能,不能使用this来引用一个词法作用域内部的东西。

每当你想要把this和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。

2.1.3. this到底是什么

this是在运行时绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也成为上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this就是记录的其中一个属性,会在函数执行的过程中用到。

2.1.4. 小结

this 既不指向函数自身也不指向函数的词法作用域

this实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用

2.2. this全面解析

2.2.1. 调用位置

调用位置是函数在代码中被调用的位置(而不是声明的位置)。只有分析调用位置才能知道this到底引用的是什么。

寻找“函数被调用的位置”并不容易,重要的是分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前执行的函数的前一个调用中。

调用栈和调用位置代码示例:

function baz(){
  //因此当前调用位置是全局作用域

  console.log('baz');
  bar(); // bar的调用位置
}

function bar(){
  //当前调用栈是 baz ->bar
  // 因此,当前调用位置是在baz中

  console.log('bar');
  foo(); //foo的调用位置
}

function foo(){
  // 当前调用栈是baz->bar->foo
  // 因此当前的调用位置在bar中

  console.log('foo');
}

baz();  // baz的调用位置

可以把调用栈想象成一个函数调用链,就像注释中写的那样,但是这种方法麻烦且容易出错。 另一个查看调用栈的方法是使用浏览器的调试工具。

2.2.2. 绑定规则

函数的执行过程中调用位置是如何决定this的绑定对象呢?

2.2.2.1. 默认绑定

独立函数调用时最常用的函数调用类型。可以把这规则看做是无法应用其他规则时的默认规则

示例代码:

function foo(){
  console.log(this.a);

  var a=2;
  foo(); //2
}

声明在全局作用域中的变量(比如var a = 2)就是全局对象的一个同名属性。他们本质上是一个东西

当调用foo()时,this.a被解析成了全局变量a,因为在本例中,函数调用时应用了this的默认绑定,因此this指向全局对象。

在本例中,foo()是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则

如果使用严格模式,那么全局对象将无法使用默认绑定,因此this会绑定到undefined:

function foo(){
  'use strict';

  console.log(this.a);
}

var a=2;

foo(); //TypeError:this is undefined

这里有一个微妙但是非常重要的细节,虽然this的绑定规则完全取决于调用位置,但是只有foo()运行在非strict mode下时,默认绑定才能绑定到全局对象,严格模式下与foo()的调用位置无关:

function foo(){
  console.log(this.a);
}

var a=2;

(function(){
  'use strict';
  
  foo(); //2
})()

2.2.2.2. 隐式绑定

另一条需要考虑的规则时调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。

示例代码:

function foo(){
  console.log(this.a);
}

var obj={
  a:2,
  foo:foo
}

obj.foo()  //2

foo()被调用时,它的落脚点确实指向obj对象。

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。

因为调用foo()时this被绑定到obj,因此this.aobj.a是一样的。

引用属性引用链中只有最顶层或者说最后一层会影响调用位置:

funciton foo(){
  console.log(this.a);
}

var obj2={
  a:42,
  foo:foo
};

var obj1={
  a:2,
  obj2:obj2
};

obj1.obj2.foo()  //42

只有最后一层obj2会影响调用位置

隐式丢失

一个最常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定到全局对象或者Undefined上,取决于是否是严格模式

代码示例:

function foo(){
  console.log(this.a);
}

var obj={
  a:2,
  foo:foo
};

var bar=obj.foo;  //函数别名

var a='Hello,World!';

bar();  // Hello,World!

虽然bar是obj.foo的一个引用,但实际上,它引用的是foo函数本身,因此此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定

回调函数丢失this绑定是非常常见的,另外,调用回调函数的函数可能会修改this。

无法控制回调函数的执行方式,因此就没有办法来控制会影响绑定的调用位置

1.2.2.3. 显式绑定

在分析隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接(隐式)绑定到一个对象上

如果我们不想在对象内部包含函数引用,而是向在某个对象上强制调用函数,这时call()apply()方法可以实现。

call()apply()方法第一个参数是一个对象,他们会把这个对象绑定到this,接着在调用函数时指定这个this。因为你可以直接指定this的绑定对象,所以称显式绑定

示例代码:

function foo(){
  console.log(this.a);
}

var obj={
  a:2
};

foo.call(obj); //2

通过call(),我们可以在调用foo时强制把它的this绑定在obj上

假如传入一个原始值(字符串类型、布尔类型或者数字类型)来当做this的绑定对象,这个原始值会被转换成它的对象形式(也就是new String(...) new Boolean(...) 或者new Number(...) 这通常被称为装箱

从this绑定的角度来说,call()apply()是一样的,他们的区别体现在其他参数上

硬绑定

示例代码:

function foo(){
  console.log(this.a);
}

var obj={
  a:2
}

var bar=function(){
  foo.call(obj);
};

bar();  //2

setTimeout(bar,100) //2

// 硬绑定的bar不可能再修改它的this
bar.call(window);  //2

创建一个函数bar(),并在它的内部手动调用了foo.call(obj)因此强制把foo的this绑定到了obj。无论如何调用bar,它总会在obj上调用foo。这是一种强制绑定,称为硬绑定

硬绑定的典型应用场景:

  • 创建一个包裹函数,传入所有的参数并返回接收到的所有值
  • 创建一个i可以重复使用的辅助函数

由于硬绑定是一种非常常用的模式,所以在ES5中提供了内置的方法Function.prototype.bind,它的用法如下:

function foo(something){
  console.log(this.a,something);
  return this.a+something;
}

var obj={
  a:2
};

var bar=foo.bind(obj);

var b=bar(3); //2 3
console.log(b); //5

bind(...)会返回一个硬编码的新函数,它会把参数设置为this的上下文并调用原始函数。

API调用的上下文

第三方库的许多函数,以及JavaScript语言和宿主环境中的许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文(context)”,其作用和bind(...)一样,确保回调函数使用指定的this

例如:

function foo(el){
  console.log(el,this.id);
}

var obj={
  id:'hello'
};

// 调用`foo(..)`时把this绑定到obj
[1,2,3].forEach(foo,obj);  //1 hello 2 hello 3 hello

这些函数(例如这里的forEach)实际上就是通过call()或者apply()实现了显示绑定

2.2.2.4. new绑定

关于对JavaScript中构造函数的误解的解释: 在JavaScript中,构造函数只是一些使用new操作符时被调用的函数。他们并不会属于某一个类,也不会实例化一个类。实际上,他们甚至都不能说是一种特殊的函数类型,他们只是被new操作符调用的普通函数而已。

包括内置对象函数(比如Number(..))在内的所有函数都可以用new来调用,这种函数被称为构造函数调用。实际上,并不存在所谓的构造函数,只有对函数的构造调用

用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

  1. 创建(或者说构造)一个全新的对象
  2. 这个对象会被执行原型连接
  3. 这个新对象会绑定到函数调用的this
  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象

示例代码:

function foo(a){
  this.a=a;
}

var bar=new foo(2);
console.log(bar.a);  //2

使用new来调用foo(..)时,我们会构造一个新对象并把它绑定到foo(..)调用中的this上。称之为new绑定

2.2.3. 优先级

默认绑定时四条规则中最低的

显式绑定优先与隐式绑定

new绑定优先与隐式绑定

new和call/apply无法一起使用,但是可以通过使用硬绑定来测试他俩的优先级

new绑定优先于显示绑定

所以优先级如下:

new绑定>显式绑定>隐式绑定>默认绑定
判断this

我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则,按照下面的顺序进行判断:

  1. 函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象
    var bar=new foo()
    
  2. 函数是否通过call、apply(显示绑定)或者是硬绑定调用?如果是,this绑定的是指定的对象。
    var bar = foo.call(obj2)
    
  3. 憨憨是否在某个上下文对象中调用(隐式绑定)?如果是,this绑定的是那个上下文对象。
    var bar=obj1.foo()
    
  4. 如果都不是的话,就是默认绑定。严格模式this绑定到undefined,否则绑定到全局对象

2.2.4. 绑定例外

某些场景下,你认为应用的是其他绑定规则,但是实际上应用的是默认绑定规则

2.2.4.1. 被忽略的this

如果把null或者undefined作为thiss的绑定对象传入call,apply或者bind,这些值在调用时都会被忽略,实际应用的是默认绑定规则

使用null来忽略this绑定可能产生一些副作用。 更好的方法是传入一个特殊的对象,比如一个空对象。可以用Object.create(null)来创建一个空对象。

Object.create(null){}很像,但是不会创建Object.prototype这个委托,所以它比{}更空。

2.2.4.2. 间接引用

是时候可能(有意或者无意地)创建一个函数的“间接引用”,这种情况下,调用这个函数会应用默认绑定规则。

间接引用最容易在赋值时发生:

function foo(){
  console.log(this.a);
}

var a=2;
var o={a:3,foo:foo};
var p={a:4};

o.foo();  //3
(p.foo=o.foo)();  //2

2.2.4.3. 软绑定

硬绑定会大大降低函数的灵活性,使用硬绑定之后无法使用隐式绑定或者显式绑定来修改this

如果可以给默认绑定指定一个全局对象和undefined以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改this的能力

2.2.5. this词法

箭头函数无法使用之前的this绑定规则

箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this

箭头函数的词法作用域:

function foo(){
  //返回一个箭头函数
  return (a)=>{
    // this继承自foo()
    console.log(this.a);
  }
}


var obj1={
  a:2
}

var obj2={
  a:3
}


var bar=foo.call(obj1);
bar.call(obj2)   //2,不是3

这是因为foo()内部创建的箭头函数会捕获调用时foo()的this。由于foo()的this绑定到obj1,bar的this也会绑定到obj1.箭头函数的绑定无法被修改!

实际上,在ES6之前我们就已经在使用一种几乎和箭头函数完全一样的模式:

function foo(){
  var self=this;

  setTimeout(function(){
    console.log(self.a);
  },1000)
}

var obj={a:2};


foo.call(obj);  //2

虽然self=this和箭头函数看起来都可以取代bind(..),但是从本质上来说,他们想替代的是this机制。

2.3. 对象

2.3.1. 语法

对象可以通过两种形式定义:

  • 声明(文字)形式
  • 构造形式

文字语法:

var myObj={
  key:value;
}

构造形式:

var myObj=new Object();
myObj.key=value;

2.3.2. 类型

JavaScript一共有六种主要类型:

  • string
  • number
  • boolean
  • null
  • undefined
  • object

注意:简单基本类型(六种主要类型的前五种)本身并不是对象

null执行``typeof null时返回字符串object`,但这不意味着null是object类型,这只是JavaScript的一个bug

JavaScript中有许多特殊的对象子类型,我们称为复杂基本类型。例如函数、数组

内置对象

JavaScript中有一些对象子类型,通常被称为内置对象。 这里的有些内置对象的名字看起来和基本简单类型一样,但是他们的关系比较复杂

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

在JavaScript中,这些内置对象实际上只是一些内置的函数。这些内置函数可以当做构造函数来使用,从而可以构造一个对应子类型的新对象。

其中,null和undefined没有对应的构造形式,只有文字形式。相反,Date只有构造,没有文字形式

对于Object,Array,Function,RegExp来说,无论使用构造还是文字形式,他们都是对象。不是字面量。

Error对象很少显式创建,一般是抛出异常时自动创建。也可以使用new Error(...)来创建

2.3.3. 内容

对象的内容由一些存储在特定命名位置的(任意类型的)值组成的,我们称之为属性

当我们说内容时,往往暗示这些值存储在对象内部,实际上存储在对象容器内部的是这些属性的名称,他们就像指针一样,指向这些值的真正存储位置。

代码示例:

var myObj={
  a:2
}

myObj.a; // 2
myObj['a']; //2

.a语法常被称为属性访问,['a']语法通常被称为键访问 两者区别在于: 属性访问要求属性名满足标识符的命名规范,而键访问可以接受任意utf-8/unicode字符串作为属性名;此外键访问使用字符串来访问属性,所以可以在程序总构造这个字符串

在对象中,属性名永远是字符串

2.3.3.1. 可计算属性名

如果需要通过表达式来计算属性名,那么键访问就能派上用场了,如可以使用myObj[profix+name],但是属性访问这样做是行的。

例如:

var prefix='foo';

var myObj={
  [prefix+'bar']:'hello',
  [prefix+'baz']:'world'
}


console.log(myObj['foobar'])  //hello
console.log(myObj['foobaz'])  // world

可计算属性名最常用的场景可能是ES6的符号(Symbol)

2.3.3.2. 属性与方法

如果访问的对象属性是一个函数,有些开发者喜欢使用不一样的叫法以作区分。由于函数很容易被认为是属于某个对象,在其他语言中,属性对象(也被称为类)的函数通常被称为方法

但是在JavaScript中函数永远不会“属于”一个对象

无论返回值是什么类型,每次访问对象的属性就是属性访问。如果属性访问返回的是一个函数,那它也不是一个方法。属性访问返回的函数和其他函数没有任何区别

2.3.3.3. 数组

数组支持[]访问形式,数组期望的是数值下标,也就是说值储存的位置是整数。

数组也是对象,所以虽然每个下标都是整数,但是仍然可以给数组添加属性。

例如:

var myArray=['foo',123,'bar'];

myArray.baz='hahaha'
myAarray.length;  // 3
myArray.baz;  //baz

虽然添加了命名属性,也可以访问到属性值,数组的length值并未发生变化。 虽然可以把数组当成一个普通的键值对对象来使用,但是这并不是一个好主意。

如果试图向数组添加一个属性,但是属性名看起来像一个数字,那么它会变成一个数值下标。

var myArr=['foo',123,'zoo']

myArr['3']='haha'
myArr.length; //4
myArr[3]; //zoo

如果这个看起来像数字的属性名不是大于当前数字的长度,那么此时会修改数组为这个(数字长度+1)的数组,中间的用undefined填充:

var myArr=['foo',123,'zoo']

myArr['1024']='heihei'
myArr.length; //1025
myArr[3]; //undefined

2.3.3.4. 复制对象

示例代码:

function anotherFunction(){/*....*/};

var anotherObject={
  c:true
}

var anotherArray=[];

var myObj={
  a:2;
  b:anotherObject, //这是引用
  c:anotherArray, //这是引用
  d:anotherFunction
}

anotherArray.push(anotherObject,myObj)

如何准确的表示myObj的复制呢?

如果要复制myObj首先要确定是深复制还是浅复制,对于浅复制来说,复制出的新对象中a的值会复制旧对象中a的值,也就是2,但是新对象中b,c,d三个属性其实只是三个引用,他们和旧对象的b,c,d的引用对象时一样的。

对于深复制来说,除了复制myObj以外还会复制anotherObject和anotherArray,这样问题就来了,anotherArray引用了anotherObject和myObj,所以又要复制myObj,这样就陷入了死循环。

对于JSON安全(也就是说可以被序列化为一个JSON字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:

var newObj=JSON.parse(JSON.stringify(someObj));

这个方法需要保证JSON安全,只适用于部分情况

浅复制: ES6定义了Object.assign()来实现浅复制。Object.assign()方法的第一个参数是目标对象,之后还可以跟一个或多个源对象。

例如:

var newObj=Object.assign({},myObj);

2.3.3.5. 属性描述符

从ES5开始,所有属性都具备了属性描述符

代码示例:

var myObj={a:2};

console.log(Object.getOwnPropertyDescriptor(myObject,'a'));
// value:2,
// writable:true,
// eumuerable:true,
// configurable:true

普通对象属性对应的属性描述符:

  • writable(可写)
  • enumerable(可枚举)
  • configurable(可配置)

修改属性描述符:Object.defineProperty() 例如:

var myObj={};

Object.defineProperty(myObj,'a',{
  value:2,
  writable:true,
  configurable:true,
  enumerable:true
})

2.3.3.6. 不变性

有时候会希望属性或者对象时不可改变的。

很重要的一点是,所有的方法创建的都是浅不变性,也就是说,他们只会影响目标对象和它的直接属性,如果目标对象引用了其他对象(数组,对象,函数等),其他对象的内容不受影响,仍然是可变的。

实现深不可变性;

1. 对象常量

结合writable:falseconfigurable:false就可以创建一个真正的常量属性

var myObj={};

Object.defineProperty(myObj,"FAVORITE_NUMBER",{
  value:42,writable:false,configurable:false
})

这里的FAVORITE_NUMBER就不可改变了,但是属性名区分大小写,如果有一个属性名为favortite_number,那么favortite_number的属性值还是可以变的

2. 禁止扩展

如果想禁止一个对象添加新属性并且保留已有属性,可以使用Object.preventExtensions(..)

var myObj={a:2}

Object.preventExtensions(myObj);

myObj.b=3;
myObj.b;//undefined

myObj.a=1024;
myObj.a; // 1024

新添加属性是不行的,但是可以修改原来的属性

3. 密封

Object.seal(..)会创建一个密封对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions(..)并把所有现有属性标记为configurable:false

密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(但是可以修改属性的值

        var myObj = {
            a: 2
        }

        Object.seal(myObj);

        myObj.b = 3; 
        console.log(myObj.b); //undefined
        myObj.a = 1024;
        console.log(myObj.a);  //1024
4. 冻结

Object.freeze(..)会创建一个冻结对象,这个方法实际上会在一个现有属性上调用Object.seal(..)并把所有属性标为writable:false,这样现有的值也无法修改了

冻结是可以应用在对象上的级别最高的不可变性。它会禁止对于对象本身以及任意直接属性的修改。(不过,这个对象引用的其他对象时不受影响的)


        var myObj = {
            a: 2,
            b: {
                c: 233
            }
        }

        Object.freeze(myObj);

        myObj.b = 3;
        console.log(myObj.b);   // {c:233}
        myObj.b.c = 1024;
        console.log(myObj.b.c);  // 1024 

如果要深度冻结一个对象,具体方法为现在这个对象上调用Object.freeze(..),然后遍历它引用的所有对象那个并且在这些对象上调用Object.freeze(..)

2.3.3.7. [[Get]]

var myObj={a:2};

myObj.a;  //2

在语言规范中,myObj.a在myObj上实际是实现了[[get]]操作。对象默认的内置[[get]]操作会首先在对象中找是否有名称相同的属性,如果找到就会返回这个值,如果没找到,就会遍历可能存在的[[prototype]]链,如果还是找不到,就会返回undefined

2.3.3.8. [[Put]]

[[Put]]被触发时,实际的行为取决于很多因素,包括对象中是否已经存在这个属性(这是最重要的因素)

如果已经存在这个属性,[[Put]]算法会大致检查下面这些内容:

  1. 属性是否是访问描述符?如果是并且存在setter就调用setter
  2. 属性的数据描述符writable是否是FALSE?如果是,非严格模式下静默失败,在严格模式下抛出TypeError异常
  3. 如果都不是,将该值设置为属性的值

如果不存在这个属性,[[Put]]操作更加复杂

2.3.3.9. Getter和Setter

在ES5中可以使用getter和setter部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。getter是一个隐藏属性,会在获取属性值时调用。setter也是一个隐藏属性,会在设置属性值是调用

当你给一个属性定义getter、setter或者两者都有时,这个属性会被定义为“访问描述符(或称存取描述符)”(和数据描述符相对)。对于访问描述符来说,JavaScript会忽略他们的value和writable特性,取而代之关心的是set和get(还有configurable和enumerable)特性

对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。

描述符可以拥有的键值:

configurableenumerablevaluewritablegetset
数据描述符YYYYNN
存取描述符YYNNYY

getter和setter通常成对出现,setter会覆盖单个属性默认的[[Put]]

var myObj={
  //给a定义一个getter
  get a(){
    return this._a_;
  },

  // 给a定义一个setter
  set a(val){
    this._a_=val*2;
  }
};

myObj.a=2;
myObj.a // 4

这里的_a_只是一种命名惯例,没有任何特殊的行为,和其他属性一样

2.3.3.10. 存在性

前面说过,myObj.a的属性值访问返回值可能是undefined,但是这个值可能是属性中存储的undefined,也可能是属性不存在所以返回undefined,如何区分这两种情况呢?

我们可以在不访问属性值的情况下判断对象总是否存在这个属性:

        var myObj = {
            a: 2
        };

        console.log('a' in myObj);  //true
        console.log('b' in myObj);  //false

        console.log(myObj.hasOwnProperty('a'));  //true
        console.log(myObj.hasOwnProperty('b'));  //false

所有的对象都可以通过Object.prototype的委托来访问hasOwnProperty()但是有的对象可能没有连接到Object.prototype(比如通过Object.create(null)来创建)。这种情况下,Object.hasOwnProperty(...)就不管用了

枚举

可枚举相当于可以出现在对象属性的遍历中

2.3.4. 遍历

for...in循环可以用来遍历对象的可枚举属性列表(包括[[Prototype]]链)。但是如何遍历属性值?

对于数值索引的数组来说,可以用标准的for循环来遍历值。但是这实际上不是在遍历值,而是在遍历下标来指向值

ES5中增加了一些数字的辅助迭代器,包括:

  • forEach():遍历数组中的所有值并忽略回调函数中的返回值
  • every():一直运行直到回调函数返回FALSE
  • some():会一直运行直到回调函数返回true

如何直接遍历值而不是对象属性呢? ES6中新增for...of循环语法:

var myArr=[1,2,3];

for(var v of myArr){
  console.log(v)
}

// 1
// 2
// 3

普通对象没有内置的@@iterator,所以无法自动完成for...of循环遍历。

2.4. 混合对象“类”

类的设计模式:

  • 实例化(instantiation)
  • 继承(inheritance)
  • 多态(polymorphism)

这些概念实际上无法直接对应到JavaScript的对象机制,因此我们会绝少许多JavaScript开发者所使用的解决方法(比如混入,mixin)

2.4.1. 类理论

类/继承描述了一种代码的组织结构形式————一种在软件中对真是世界问题领域的建模方法

类的另一个概念是多态,这个概念是说父类的通用行为可以被子类用更特殊的行为重写。实际上相对多态性允许我们从重写行为中引用基础行为。

2.4.1.1. “类”设计模式

类是一种可选的代码抽象方式,并不是必须的。

2.4.1.2. JavaScript中的“类”

JavaScript在相当长的时间里只有一些近似类的语法元素(比如new和instanceof),后来在ES6中增加了一些新元素,比如class关键字

但是这并不意味着JavaScript中实际有类

类是一种设计模式,你可以用一些方法近似实现类的功能,JavaScript也提供了一些近似类的语法。但是实际上,JavaScript机制和类完全不同,JavaScript中的类和其他语言的类不一样。

2.4.2. 类的机制

类是一种抽象表示,只有对类实例化之后才能对其操作

2.4.2.1. 建造

2.4.2.2. 构造函数

类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,称为构造函数。这个方法的任务就是初始化实例需要的所有信息

类构造函数属于类,而且通常和类同名。此外,构造函数大多需要new来调,这样语言引擎才知道你想要构造一个新的类实例。

2.4.3. 类的继承

2.4.3.1. 多态

多态是一个非常广泛的话题,我们现在所说的相对只是堕胎的一个方面:任何方法都可以引用进程层次中高层的方法。之所以说“相对”是因为我们并不会定义想要访问的绝对继承层次,而是使用相对引用“查找上一层”

2.4.3.2. 多重继承

JavaScript本身并不提供多重继承的功能,但是许多开发者会尝试使用各种方法来实现多重继承

2.4.4. 混入

在继承或者实例化时,JavaScript的对象并不会自动执行复制行为。简单来说,JavaScript中只有对象,并不存在可以被实例化的类。一个对象并不会被复制到其他对象,他们会被关联起来。但是由于在其他语言中类表现出来的都是复制行为,因此JavaScript开发者也想出了一个方法来模拟类的复制行为,这个方法就是混入

两种类型的混入:

  • 显式混入
  • 隐式混入

2.4.4.1. 显式混入

由于JavaScript不会自动实现继承间的复制行为,所以我们需要手动实现复制功能。这个功能在许多库和框架中称为extend(..),但是为了方便理解我们称之为mixin(..)

非常简单的mixin(..)例子:

function mixin(sourceObj,targetObj){
  for(var key in sourceObj){
    // 只会在不存在的情况下复制
    if(!key in targetObj){
      targetObj[key]=sourceObj[key]
    }
  }

  return targetObj;
}
多态

JavaScript中并没有相对多态的机制,所以由于继承中的父类和子类如果都有一个同名的函数,为了指明调用对象,我们必须使用决定引用。

混合复制

示例代码:

function mixin(sourceObj,targetObj){
  for(var key in sourceObj){
    // 只会在不存在的情况下复制
    if(!key in targetObj){
      targetObj[key]=sourceObj[key]
    }
  }

  return targetObj;
}




var Vehicle={};

// 首先创建一个空对象把Vehicle的内容复制过去:
var Car=mixin(Vehicle,{});

// 然后把新内容复制到Car中
mixin({
  wheels:4,
  drive:function(){
    //
  }
},Car);

复制操作完成后,Car和Vehicle就分离了,向Car中添加属性不会影响Vehicle,反之亦然。

JavaScript中的函数无法真正地复制,所以只能复制对共享函数对象的引用。如果修改了共享的函数对象,比如添加了一个属性,那么Vehicle和Car都会受到影响。

显示混入是JavaScript中一个很棒的机制,虽然它可以把一个对象的属性复制到另一个对象中。如果你向目标对象中显式混入超过一个属性,就可以模仿多重继承行为。

2.4.4.2. 隐式混入

示例代码:

var Something={
  cool:function(){
    this.greeting='Hello,World';
    this.count=this.count?this.count+1:1;
  }
};

Something.cool();
Something.greeting; //Hello,World
Something.count; //1

var Another={
  cool:function(){
    // 隐式把Something混入Another
    Something.cool.call(this)
  }
}


Another.cool();
Another.greeting;  //Hello,World
Another.cool();   //1 (count不是共享状态)

2.5. 原型

2.5.1. [[Prototype]]

JavaScript中的对象有一个特殊的[[Prototype]]内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时[[Prototype]]属性都会被赋予一个非空的值。

注意:对象的[[Prototype]]链接可以为空,但比较少见。

当试图引用对象的属性时会触发[[Get]]操作,对于[[Get]]操作来说,第一步是检查对象本身是否含有这个属性,如果不含有,就会继续访问对象的[[Prototype]]链。这个过程会持续到找到匹配的属性或者查找整条[[Prototype]]链。如果是后者,[[Get]]操作的返回值是undefined

使用for...in遍历对象来检查属性在对象中是否存在时,同样会查找整条原型链

因此,当通过各种语法进行属性查找时都会查找[[Prototype]]链,知道找到属性或者查找完整条原型链。

2.5.1.1. Object.prototype

所有普通的[[Prototype]]链最终都会指向Object.prototype

2.5.1.2. 属性设置和屏蔽

示例代码:

myObj.foo='bar';

如果属性名既出现在myObj中也出现在myObj的[[Prototype]]链上层,那么就会发生屏蔽。myObj中包含的foo属性会屏蔽原型链上层的所有foo属性。

如果foo不直接存在于myObj中国而是存在于原型链上层时myObj.foo='bar'会出现三种情况:

  1. 如果在[[Prototype]]链上层存在名为foo的普通数据访问属性并且没有被标记为只读,那么就会直接在myObj中添加一个名为foo的新属性。这个新属性是一个屏蔽属性。
  2. 如果在[[Prototype]]链存在foo但是被标记为只读,那么我发修改已有属性或者在myObj上创建新的屏蔽属性。不会发生屏蔽
  3. 如果在[[Prototype]]链上层存在foo并且他是一个setter,那么就一定会调用这个setter。foo不会被添加到myObj,也不会重新定义foo这个setter

2.5.2. “类”

JavaScript和面向类的语言不同,它没有类作为对象的抽象模式。JavaScript中只有对象。

JavaScript是少有的可以不通过类,直接创建对象的语言。

JavaScript中只有对象,没有类。

2.5.2.1. “类”函数

在JavaScript中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将他们关联起来:

2022-05-24-10-47-59.png

这个机制通常被称为原型继承,常常被视为动态语言版本的类继承。 但是并不是这样。

继承意味着复制操作,JavaScript并不会复制对象属性。相反,JavaScript会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。

2.5.2.2. “构造函数”

在JavaScript中对于“构造函数”最准确的解释是:所有带new的函数调用。

函数不是构造函数,但是当且仅当使用new时,函数调用会变成“构造函数调用”

2.5.2.3. 技术

a1.constructor是一个非常不可靠且不安全的引用,应尽量避免

2.5.3. (原型)继承

示例代码:

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

Foo.prototype.myName=function(){
  return this.name
}

function Bar(name,label){
  Foo.call(this,name);
  this.label=label;
}

// 我们创建了一个新的`Bar.prototype`对象并关联到`Foo.prototype`
Bar.prototype=Object.create(Foo.prototype);

Bar.prototype.myLabel=function(){
  return this.label;
};

var a=new Bar('a','obj a')

a.myName(); //a
a.myLabel(); //obj a

这段代码的核心部分就是语句Bar.prototype=Object.create(Foo.prototype);调用Object.create()会凭空创建一个新对象并把新对象内部的[[Prototype]]关联到指定的对象

也就是说,“创建一个Bar.prototype对象并把他关联到Foo.prototype

我们要创建一个合适的关联对象,我们必须使用Object.create()。这样做的唯一的缺点就是需要创建一个新对象然后把旧对象抛弃掉,不能直接修改已有的默认对象

ES6之后,辅助函数Object.setPrototypeOf()可以用标准并且可靠的方法来修改关联。

Object.setPrototypeOf(Bar.prototype,Foo.prototype);
检查类关系

假设有对象a,如何寻找对象a委托的对象呢?在传统的面向类环境中,检查一个实例(JavaScript中的对象)的继承祖先(JavaScript中的委托关联)通常被称为内省(或者反射)

示例代码:

function Foo(){
  // ...
}

Foo.prototype.blah=...;

var a=new Foo();

方法一 判断[[Prototype]]反射的方法:

Foo.prototype.isPrototypeOf(a); //true

isPrototypeOf(a)回答的问题是:在a的整条[[Prototype]]链中是否出现过Foo.prototype

方法二 直接获取一个对象的[[Prototype]]链进行对比

Object.getPrototypeOf(a)===Foo.prototype ;  //true

方法三

绝大多数浏览器也支持一种非标准的方法来访问内部[[Prototype]]属性:

a.__proto__==Foo.prototype; //true;

2.5.4. 对象关联

2.5.4.1. 创建关联

Object.create()会创建一个新对象(bar)并把它关联到我们指定的对象(foo),这样我们就可以充分发挥[[Prototype]]的威力(委托)并且避免不必要的麻烦(比如使用new的构造函数调用会生成.prototype.constructor引用

我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。

2.5.4.2. 关联关系是备用

示例代码:

var anotherObject={
  cool:function(){
    console.log('cool');
  }
}

var myObj=Object.create(anotherObject);

myObj.cool(); //cool

如果上述代码的目的是为了让myObj在复发处理水性或方法时可以使用备用的anotherObject,那代码就会变得难以理解。

因为myObj中没有cool这个方法,但是调用后确实可以起效果。这可能有点令人费解。

有不那么令人费解的方法:

var anotherObject={
  cool:function(){
    console.log('cool')
  }
}

myObj.doCool=function(){
  this.cool(); //内部委托
}

myObj.doCool(); //cool

这样API设计更加清晰。我们的实现遵循的是委托设计模式

2.6. 行为委托

回顾前一章知识: [[Prototype]]机制就是值对象中的一个内部链接引用另一个对象

如果在第一个对象上没有找到需要的属性或者方法引用,就会在[[Prototype]]关联的对象上进行查找,以此类推。这一系列对象的链接被称为原型链

换句话说,JavaScript这个机制的本质是对象之间的关联关系

2.6.1. 面向委托的设计

面向委托是一种不同于类的设计模式

2.6.1.1. 类理论

类设计模式鼓励在继承时使用方法重写(和多态)

2.6.1.2. 委托理论

先定义一个名为Task的对象,它会包含所有任务都可以使用(写作使用,读作委托)的具体行为。接着,对于每个任务都定义一个对限售股来存储对应的数据和行为。然后把特定的任务对象都关联到Task功能对象上,让他们在需要的时候进行委托。

委托行为以为着某些对象在找不到属性或者方法引用时会把这个请求委托给另一个对象

互相委托(禁止)

你无法在两个或者两个以上互相(双向)委托的对象之间创建循环委托。例如:把A关联到B然后把B关联到A就会出错

2.6.3. 类与对象

真实场景中方法的应用

创建UI控件

2.6.2.1. 控件“类”

不使用任何“类”辅助库或者语法的情况下,使用纯JavaScript实现类风格的代码:

class Widget{
  constructor(width,height){
    this.width=width || 50;
    this.height=height || 50;
    this.$elem=null;
  }

  render($where){
    if(this.$elem){
      this.$elem.css({
        width:this.width+'px',
        height:this.height+'px'
      }).appendTo($where);
    }
  }
}

class Button extend Widget{
  constructor(width,height,label){
    super(width,height);
    this.label=label || 'Default';
    this.$elem=$('<button>').text(this.lebel);
  }

  render($where){
   super($where);
   this.$elem.click(this.onClick.bing(this))
  }

  onClick(evt){
    console.log("Button' "+ this.label+"'clicked");
  }
}


$(document).ready(function(){
  var $body=$(document.body);
  var btn1=new Button(125,30,'hello');
  var btn2=new Button(150,40,'world')

  btn1.render($body)
  btn2.render($body)
})

这里的class依然是通过[[Prototype]]机制实现的。

2.6.2.2. 委托控件对象

var Widget={
  init:function(width,height){
    this.width=width || 50;
    this.height=height || 50;
    this.$elem=null;
    },
    insert:function($where){
      if(this.$elem){
        this.$elem.css({
          whdth:this.width+'px',
          height:this.height+'px'
        }).appendTo($where)
      }
    } 
}


var Button=Object.create(Widget);

Button.setup=function(width,height,label){
  //委托调用
  this.init(width,height);
  this.label=label||'Default';

  this.$elem=$('<button>').text(this.label)
}

button.build=funciotn($where){
  //委托调用
  this.insert($where);
  this.$elem.click(this.onClick.bind(this))
}

Button.onClick=function(evt){
  console.log("Button' "+ this.label+"'clicked")
};

$(document).ready(funciton(){
  var $body=$(document.body);
  var btn1=Object.create(Button);
  btn1.setup(125,30,'hello');
  var btn2=Object.create(Button);
  btn2.setup(150,40,'world');

  btn1.render($body)
  btn2.render($body)
})