女王控的博客

javascript深入笔记

从原型到原型链

构造函数创建对象

我们先使用构造函数创建一个对象:

js 复制代码
function Person() {}
var person = new Person();
person.name = 'Kevin';
console.log(person.name); // Kevin

在这个例子中,Person 就是一个构造函数,我们使用 new 创建了一个实例对象 person。

很简单吧,接下来进入正题:

prototype

每个函数都有一个 prototype 属性,就是我们经常在各种例子中看到的那个 prototype ,比如:

js 复制代码
function Person() {}
// 虽然写在注释里,但是你要注意:
// prototype是函数才会有的属性
Person.prototype.name = 'Kevin';
var person1 = new Person();
var person2 = new Person();
console.log(person1.name); // Kevin
console.log(person2.name); // Kevin

那这个函数的 prototype 属性到底指向的是什么呢?是这个函数的原型吗?

其实,函数的 prototype 属性指向了一个对象,这个对象正是调用该构造函数而创建的实例的原型,也就是这个例子中的 person1 和 person2 的原型。

那什么是原型呢?你可以这样理解:每一个 JavaScript 对象(null 除外)在创建的时候就会与之关联另一个对象,这个对象就是我们所说的原型,每一个对象都会从原型”继承”属性。

让我们用一张图表示构造函数和实例原型之间的关系:

2020 06 07 18 22 00

在这张图中我们用 Object.prototype 表示实例原型。

那么我们该怎么表示实例与实例原型,也就是 person 和 Person.prototype 之间的关系呢,这时候我们就要讲到第二个属性:

__proto__

这是每一个 JavaScript 对象(除了 null)都具有的一个属性,叫__proto__,这个属性会指向该对象的原型。

为了证明这一点,我们可以在火狐或者谷歌中输入:

js 复制代码
function Person() {}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true

于是我们更新下关系图:

2020 06 07 18 28 36

既然实例对象和构造函数都可以指向原型,那么原型是否有属性指向构造函数或者实例呢?

constructor

指向实例倒是没有,因为一个构造函数可以生成多个实例,但是原型指向构造函数倒是有的,这就要讲到第三个属性:constructor,每个原型都有一个 constructor 属性指向关联的构造函数。

为了验证这一点,我们可以尝试:

js 复制代码
function Person() {}
console.log(Person === Person.prototype.constructor); // true

所以再更新下关系图:

2020 06 07 18 36 57

综上我们已经得出:

js 复制代码
function Person() {}

var person = new Person();

console.log(person.__proto__ == Person.prototype); // true
console.log(Person.prototype.constructor == Person); // true
// 顺便学习一个ES5的方法,可以获得对象的原型
console.log(Object.getPrototypeOf(person) === Person.prototype); // true

了解了构造函数、实例原型、和实例之间的关系,接下来我们讲讲实例和原型的关系:

实例与原型

当读取实例的属性时,如果找不到,就会查找与对象关联的原型中的属性,如果还查不到,就去找原型的原型,一直找到最顶层为止。

js 复制代码
function Person() {}

Person.prototype.name = 'Kevin';

var person = new Person();

person.name = 'Daisy';
console.log(person.name); // Daisy

delete person.name;
console.log(person.name); // Kevin

在这个例子中,我们给实例对象 person 添加了 name 属性,当我们打印 person.name 的时候,结果自然为 Daisy。

但是当我们删除了 person 的 name 属性时,读取 person.name,从 person 对象中找不到 name 属性就会从 person 的原型也就是 person.__proto__ ,也就是 Person.prototype 中查找,幸运的是我们找到了 name 属性,结果为 Kevin。

但是万一还没有找到呢?原型的原型又是什么呢?

原型的原型

在前面,我们已经讲了原型也是一个对象,既然是对象,我们就可以用最原始的方式创建它,那就是:

js 复制代码
var obj = new Object();
obj.name = 'Kevin';
console.log(obj.name); // Kevin

其实原型对象就是通过 Object 构造函数生成的,结合之前所讲,实例的 __proto__ 指向构造函数的 prototype ,所以我们再更新下关系图:

2020 06 07 18 44 09

原型链

那 Object.prototype 的原型呢?

js 复制代码
console.log(Object.prototype.__proto__ === null); // true

所以查找属性的时候查到 Object.prototype 就可以停止查找了。

最后一张关系图也可以更新为:

2020 06 07 18 45 45

顺便还要说一下,图中由相互关联的原型组成的链状结构就是原型链,也就是蓝色的这条线。

补充

最后,补充三点大家可能不会注意的地方:

constructor

首先是 constructor 属性,我们看个例子:

js 复制代码
function Person() {}
var person = new Person();
console.log(person.constructor === Person); // true

当获取 person.constructor 时,其实 person 中并没有 constructor 属性,当不能读取到 constructor 属性时,会从 person 的原型也就是 Person.prototype 中读取,正好原型中有该属性,所以:

js 复制代码
person.constructor === Person.prototype.constructor;

__proto__

其次是 __proto__,绝大部分浏览器都支持这个非标准的方法访问原型,然而它并不存在于 Person.prototype 中,实际上,它是来自于 Object.prototype ,与其说是一个属性,不如说是一个 getter/setter,当使用 obj.__proto__ 时,可以理解成返回了 Object.getPrototypeOf(obj)。

真的是继承吗?

最后是关于继承,前面我们讲到“每一个对象都会从原型‘继承’属性”,实际上,继承是一个十分具有迷惑性的说法,引用《你不知道的 JavaScript》中的话,就是:

继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继承,委托的说法反而更准确些。

词法作用域和动态作用域

作用域

作用域是指程序源代码中定义变量的区域。

作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

静态作用域与动态作用域

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。

而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

让我们认真看个例子就能明白之间的区别:

js 复制代码
var value = 1;

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

function bar() {
  var value = 2;
  foo();
}

bar();

// 结果是 ???

假设 JavaScript 采用静态作用域,让我们分析下执行过程:

执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

假设 JavaScript 采用动态作用域,让我们分析下执行过程:

执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。

前面我们已经说了,JavaScript 采用的是静态作用域,所以这个例子的结果是 1。

动态作用域

也许你会好奇什么语言是动态作用域?

bash 就是动态作用域,不信的话,把下面的脚本存成例如 scope.bash,然后进入相应的目录,用命令行执行 bash ./scope.bash,看看打印的值是多少。

js 复制代码
value=1
function foo () {
    echo $value;
}
function bar () {
    local value=2;
    foo;
}
bar

思考题

最后,让我们看一个《JavaScript 权威指南》中的例子:

js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f();
}
checkscope();
js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f;
}
checkscope()();

猜猜两段代码各自的执行结果是多少?

这里直接告诉大家结果,两段代码都会打印:local scope。

原因也很简单,因为 JavaScript 采用的是词法作用域,函数的作用域基于函数创建的位置。

而引用《JavaScript 权威指南》的回答就是:

JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执行 f() 时依然有效。

但是在这里真正想让大家思考的是:

虽然两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

执行上下文栈

顺序执行?

如果要问到 JavaScript 代码执行顺序的话,想必写过 JavaScript 的开发者都会有个直观的印象,那就是顺序执行,毕竟:

js 复制代码
var foo = function() {
  console.log('foo1');
};

foo(); // foo1

var foo = function() {
  console.log('foo2');
};

foo(); // foo2

然而去看这段代码:

js 复制代码
function foo() {
  console.log('foo1');
}

foo(); // foo2

function foo() {
  console.log('foo2');
}

foo(); // foo2

打印的结果却是两个 foo2。

刷过面试题的都知道这是因为 JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个“准备工作”,比如第一个例子中的变量提升,和第二个例子中的函数提升。

但是本文真正想让大家思考的是:这个“一段一段”中的“段”究竟是怎么划分的呢?

到底 JavaScript 引擎遇到一段怎样的代码时才会做“准备工作”呢?

可执行代码

这就要说到 JavaScript 的可执行代码(executable code)的类型有哪些了?

其实很简单,就三种,全局代码、函数代码、eval 代码。

举个例子,当执行到一个函数的时候,就会进行准备工作,这里的“准备工作”,让我们用个更专业一点的说法,就叫做”执行上下文(execution context)“。

执行上下文栈

接下来问题来了,我们写的函数多了去了,如何管理创建的那么多执行上下文呢?

所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文

为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组:

js 复制代码
ECStack = [];

试想当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前,ECStack 最底部永远有个 globalContext:

js 复制代码
ECStack = [globalContext];

现在 JavaScript 遇到下面的这段代码了:

js 复制代码
function fun3() {
  console.log('fun3');
}

function fun2() {
  fun3();
}

function fun1() {
  fun2();
}

fun1();

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。知道了这样的工作原理,让我们来看看如何处理上面这段代码:

js 复制代码
// 伪代码

// fun1()
ECStack.push(<fun1> functionContext);

// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext);

// 擦,fun2还调用了fun3!
ECStack.push(<fun3> functionContext);

// fun3执行完毕
ECStack.pop();

// fun2执行完毕
ECStack.pop();

// fun1执行完毕
ECStack.pop();

// javascript接着执行下面的代码,但是ECStack底层永远有个globalContext

解答思考题

好啦,现在我们已经了解了执行上下文栈是如何处理执行上下文的

js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f();
}
checkscope();
js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f;
}
checkscope()();

两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

答案就是执行上下文栈的变化不一样。

让我们模拟第一段代码:

js 复制代码
ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
// f 执行完毕
ECStack.pop();
// checkscope 执行完毕
ECStack.pop();

让我们模拟第二段代码:

js 复制代码
ECStack.push(<checkscope> functionContext);
// checkscope 执行完毕
ECStack.pop();
ECStack.push(<f> functionContext);
// f 执行完毕
ECStack.pop();

是不是有些不同呢?

当然了,这样概括的回答执行上下文栈的变化不同,是不是依然有一种意犹未尽的感觉呢,为了更详细讲解两个函数执行上的区别,我们需要探究一下执行上下文到底包含了哪些内容。

变量对象

前言

当 JavaScript 代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

今天重点讲讲创建变量对象的过程。

变量对象

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。

因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象。

全局上下文

我们先了解一个概念,叫全局对象。在 W3School 中也有介绍:

全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。

在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。

例如,当 JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。

如果看的不是很懂的话,容我再来介绍下全局对象:

  1. 可以通过 this 引用,在客户端 JavaScript 中,全局对象就是 Window 对象。

    js 复制代码
    console.log(this);
  2. 全局对象是由 Object 构造函数实例化的一个对象。

    js 复制代码
    console.log(this instanceof Object);
  3. 预定义了一堆,嗯,一大堆函数和属性。

    js 复制代码
    // 都能生效
    console.log(Math.random());
    console.log(this.Math.random());
  4. 作为全局变量的宿主。

    js 复制代码
    var a = 1;
    console.log(this.a);
  5. 客户端 JavaScript 中,全局对象有 window 属性指向自身。

    js 复制代码
    var a = 1;
    console.log(window.a);
    
    this.window.b = 2;
    console.log(this.b);

花了一个大篇幅介绍全局对象,其实就想说:

全局上下文中的变量对象就是全局对象呐!

函数上下文

在函数上下文中,我们用活动对象(activation object, AO)来表示变量对象。

活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object 呐,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。

活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。

执行过程

执行上下文的代码会分成两个阶段进行处理:分析和执行,我们也可以叫做:

  1. 进入执行上下文
  2. 代码执行

进入执行上下文

当进入执行上下文时,这时候还没有执行代码,

变量对象会包括:

  1. 函数的所有形参 (如果是函数上下文)

  2. 由名称和对应值组成的一个变量对象的属性被创建

  3. 没有实参,属性值设为 undefined

  4. 函数声明

  5. 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建

  6. 如果变量对象已经存在相同名称的属性,则完全替换这个属性

  7. 变量声明

  8. 由名称和对应值(undefined)组成一个变量对象的属性被创建;

  9. 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

js 复制代码
function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};
  b = 3;
}

foo(1);

在进入执行上下文后,这时候的 AO 是:

js 复制代码
AO = {
  arguments: {
      0: 1,
      length: 1
  },
  a: 1,
  b: undefined,
  c: reference to function c(){},
  d: undefined
}

代码执行

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值

还是上面的例子,当代码执行完后,这时候的 AO 是:

js 复制代码
AO = {
  arguments: {
      0: 1,
      length: 1
  },
  a: 1,
  b: 3,
  c: reference to function c(){},
  d: reference to FunctionExpression "d"
}

到这里变量对象的创建过程就介绍完了,让我们简洁的总结我们上述所说:

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

思考题

最后让我们看几个例子:

第一题

js 复制代码
function foo() {
  console.log(a);
  a = 1;
}

foo(); // ???

function bar() {
  a = 1;
  console.log(a);
}
bar(); // ???

第一段会报错:Uncaught ReferenceError: a is not defined。

第二段会打印:1。

这是因为函数中的 “a” 并没有通过 var 关键字声明,所有不会被存放在 AO 中。

第一段执行 console 的时候, AO 的值是:

js 复制代码
AO = {
  arguments: {
    length: 0
  }
};

没有 a 的值,然后就会到全局去找,全局也没有,所以会报错。

当第二段执行 console 的时候,全局对象已经被赋予了 a 属性,这时候就可以从全局找到 a 的值,所以会打印 1。

第二题

js 复制代码
console.log(foo);

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

var foo = 1;

会打印函数,而不是 undefined 。

这是因为在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。

其中优先级的大小关系为:函数声明 > 形式参数 > 变量声明,验证如下

js 复制代码
// 函数声明
function a(b, c) {
  function b() {}
  var b;
  console.log(a, b, c);
}
a(1, 2);
// 运行结果:ƒ a(b, c){function b(){};var b;console.log(a, b, c);} ƒ b(){} 2

// 形式参数
function a(b, c) {
  var b;
  console.log(a, b, c);
}
a(1, 2);
// 运行结果:ƒ a(b, c){var b;console.log(a, b, c);} 1 2

// 变量声明
function a(b, c) {
  var d;
  console.log(a, b, d);
}
a(1, 2);
// 运行结果:ƒ a(b, c){var d;console.log(a, b, d);} 1 undefined

作用域链

前言

当 JavaScript 代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

今天重点讲讲作用域链。

作用域链

在变量对象中讲到,当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

下面,让我们以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的。

函数创建

在词法作用域和动态作用域中讲到,函数的作用域在函数定义的时候就决定了。

这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链!

举个例子:

js 复制代码
function foo() {
  function bar() {
    ...
  }
}

函数创建时,各自的[[scope]]为:

js 复制代码
foo.[[scope]] = [
  globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.VO
];

函数激活

当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端。

这时候执行上下文的作用域链,我们命名为 Scope:

js 复制代码
Scope = [AO].concat([[Scope]]);

至此,作用域链创建完毕。

总结

以下面的例子为例,结合着之前讲的变量对象和执行上下文栈,我们来总结一下函数执行上下文中作用域链和变量对象的创建过程:

js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope2 = 'local scope';
  return scope2;
}
checkscope();

执行过程如下:

  1. checkscope 函数被创建,保存作用域链到内部属性[[scope]]

    js 复制代码
    checkscope.[[scope]] = [
       globalContext.VO
    ];
  2. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

    js 复制代码
    ECStack = [checkscopeContext, globalContext];
  3. checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链

    js 复制代码
    checkscopeContext = {
       Scope: checkscope.[[scope]],
    }
  4. 第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明

    js 复制代码
    checkscopeContext = {
       AO: {
           arguments: {
               length: 0
           },
           scope2: undefined
       },
       Scope: checkscope.[[scope]],
    }
  5. 第三步:将活动对象压入 checkscope 作用域链顶端

    js 复制代码
    checkscopeContext = {
     AO: {
       arguments: {
         length: 0
       },
       scope2: undefined
     },
     Scope: [AO, [[Scope]]]
    };
  6. 准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值

    js 复制代码
    checkscopeContext = {
     AO: {
       arguments: {
         length: 0
       },
       scope2: 'local scope'
     },
     Scope: [AO, [[Scope]]]
    };
  7. 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

    js 复制代码
    ECStack = [globalContext];

从 ECMAScript 规范解读 this

在《JavaScript 深入之执行上下文栈》中讲到,当 JavaScript 代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

今天重点讲讲 this。

从 ECMASciript5 规范开始讲起。

先奉上 ECMAScript 5.1 规范地址:

英文版:http://es5.github.io/#x15.1

中文版:http://yanhaijing.com/es5/#115

让我们开始了解规范吧!

Types

首先是第 8 章 Types:

Types are further subclassified into ECMAScript language types and specification types.

An ECMAScript language type corresponds to values that are directly manipulated by an ECMAScript programmer using the ECMAScript language. The ECMAScript language types are Undefined, Null, Boolean, String, Number and Object.

A specification type corresponds to meta-values that are used within algorithms to describe the semantics of ECMAScript language constructs and ECMAScript language types. The specification types are Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment, and Environment Record.

我们简单的翻译一下:

ECMAScript 的类型分为语言类型和规范类型。

ECMAScript 语言类型是开发者直接使用 ECMAScript 可以操作的,其实就是我们常说的 Undefined, Null, Boolean, String, Number 和 Object。

而规范类型相当于 meta-values,是用算法描述 ECMAScript 语言结构和 ECMAScript 语言类型的。规范类型包括:Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment 和 Environment Record。

没懂?没关系,我们只要知道在 ECMAScript 规范中还有一种只存在于规范中的类型,它们的作用是用来描述语言底层行为逻辑。

今天我们要讲的重点是便是其中的 Reference 类型。它与 this 的指向有着密切的关联。

Reference

那什么又是 Reference ?

让我们看 8.7 章 The Reference Specification Type:

The Reference type is used to explain the behaviour of such operators as delete, typeof, and the assignment operators.

所以 Reference 类型就是用来解释诸如 delete、typeof 以及赋值等操作行为的。

抄袭尤雨溪大大的话,就是:

这里的 Reference 是一个 Specification Type,也就是 “只存在于规范里的抽象类型”。它们是为了更好地描述语言的底层行为逻辑才存在的,但并不存在于实际的 js 代码中。

再看接下来的这段具体介绍 Reference 的内容:

A Reference is a resolved name binding.

A Reference consists of three components, the base value, the referenced name and the Boolean valued strict reference flag.

The base value is either undefined, an Object, a Boolean, a String, a Number, or an environment record (10.2.1).

A base value of undefined indicates that the reference could not be resolved to a binding. The referenced name is a String.

这段讲述了 Reference 的构成,由三个组成部分,分别是:

  • base value
  • referenced name
  • strict reference

我们简单的理解的话:

base value 就是属性所在的对象或者就是 EnvironmentRecord,它的值只可能是 undefined, an Object, a Boolean, a String, a Number, or an environment record 其中的一种。

referenced name 就是属性的名称。

举个例子:

js 复制代码
var foo = 1;

// 对应的Reference是:
var fooReference = {
  base: EnvironmentRecord,
  name: 'foo',
  strict: false
};

再举个例子:

js 复制代码
var foo = {
  bar: function() {
    return this;
  }
};

foo.bar(); // foo

// bar对应的Reference是:
var BarReference = {
  base: foo,
  propertyName: 'bar',
  strict: false
};

而且规范中还提供了获取 Reference 组成部分的方法,比如 GetBase 和 IsPropertyReference。

这两个方法很简单,简单看一看:

  1. GetBase

    GetBase(V). Returns the base value component of the reference V.

    返回 reference 的 base value。

  2. IsPropertyReference

    IsPropertyReference(V). Returns true if either the base value is an object or HasPrimitiveBase(V) is true; otherwise returns false.

    简单的理解:如果 base value 是一个对象,就返回 true。

GetValue

除此之外,紧接着在 8.7.1 章规范中就讲了一个用于从 Reference 类型获取对应值的方法: GetValue。

简单模拟 GetValue 的使用:

js 复制代码
var foo = 1;

var fooReference = {
  base: EnvironmentRecord,
  name: 'foo',
  strict: false
};

GetValue(fooReference); // 1;

GetValue 返回对象属性真正的值,但是要注意:

调用 GetValue,返回的将是具体的值,而不再是一个 Reference

这个很重要,这个很重要,这个很重要。

如何确定 this 的值

关于 Reference 讲了那么多,为什么要讲 Reference 呢?到底 Reference 跟本文的主题 this 有哪些关联呢?如果你能耐心看完之前的内容,以下开始进入高能阶段:

看规范 11.2.3 Function Calls:

这里讲了当函数调用的时候,如何确定 this 的取值。

只看第一步、第六步、第七步:

1. Let ref be the result of evaluating MemberExpression.
6. If Type(ref) is Reference, then

a. If IsPropertyReference(ref) is true, then i.Let thisValue be GetBase(ref). b. Else, the base of ref is an Environment Record i.Let thisValue be the result of calling the ImplicitThisValue concrete method of GetBase(ref).

7. Else, Type(ref) is not Reference.

a. Let thisValue be undefined.

让我们描述一下:

  1. 计算 MemberExpression 的结果赋值给 ref

  2. 判断 ref 是不是一个 Reference 类型

  3. 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

  4. 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么 this 的值为 ImplicitThisValue(ref)

  5. 如果 ref 不是 Reference,那么 this 的值为 undefined

具体分析

让我们一步一步看:

  1. 计算 MemberExpression 的结果赋值给 ref

什么是 MemberExpression?看规范 11.2 Left-Hand-Side Expressions:

MemberExpression:

  • PrimaryExpression // 原始表达式 可以参见《JavaScript 权威指南第四章》
  • FunctionExpression // 函数定义表达式
  • MemberExpression [ Expression ] // 属性访问表达式
  • MemberExpression . IdentifierName // 属性访问表达式
  • new MemberExpression Arguments // 对象创建表达式

举个例子:

js 复制代码
function foo() {
  console.log(this);
}

foo(); // MemberExpression 是 foo

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

foo()(); // MemberExpression 是 foo()

var foo = {
  bar: function() {
    return this;
  }
};

foo.bar(); // MemberExpression 是 foo.bar

所以简单理解 MemberExpression 其实就是()左边的部分。

  1. 判断 ref 是不是一个 Reference 类型。

关键就在于看规范是如何处理各种 MemberExpression,返回的结果是不是一个 Reference 类型。

举最后一个例子:

js 复制代码
var value = 1;

var foo = {
  value: 2,
  bar: function() {
    return this.value;
  }
};

//示例1
console.log(foo.bar());
//示例2
console.log(foo.bar());
//示例3
console.log((foo.bar = foo.bar)());
//示例4
console.log((false || foo.bar)());
//示例5
console.log((foo.bar, foo.bar)());

foo.bar()

在示例 1 中,MemberExpression 计算的结果是 foo.bar,那么 foo.bar 是不是一个 Reference 呢?

查看规范 11.2.1 Property Accessors,这里展示了一个计算的过程,什么都不管了,就看最后一步:

Return a value of type Reference whose base value is baseValue and whose referenced name is propertyNameString, and whose strict mode flag is strict.

我们得知该表达式返回了一个 Reference 类型!

根据之前的内容,我们知道该值为:

js 复制代码
var Reference = {
  base: foo,
  name: 'bar',
  strict: false
};

接下来按照 2.1 的判断流程走:

2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

该值是 Reference 类型,那么 IsPropertyReference(ref) 的结果是多少呢?

前面我们已经铺垫了 IsPropertyReference 方法,如果 base value 是一个对象,结果返回 true。

base value 为 foo,是一个对象,所以 IsPropertyReference(ref) 结果为 true。

这个时候我们就可以确定 this 的值了:

js 复制代码
this = GetBase(ref)

GetBase 也已经铺垫了,获得 base value 值,这个例子中就是 foo,所以 this 的值就是 foo,示例 1 的结果就是 2!

(foo.bar)()

看示例 2:

js 复制代码
console.log(foo.bar());

foo.bar 被 () 包住,查看规范 11.1.6 The Grouping Operator

Return the result of evaluating Expression. This may be of type Reference.

NOTE This algorithm does not apply GetValue to the result of evaluating Expression.

实际上 () 并没有对 MemberExpression 进行计算,所以其实跟示例 1 的结果是一样的。

(foo.bar = foo.bar)()

看示例 3,有赋值操作符,查看规范 11.13.1 Simple Assignment ( = ):

计算的第三步:

3.Let rval be GetValue(rref).

因为使用了 GetValue,所以返回的值不是 Reference 类型,

按照之前讲的判断逻辑:

2.3 如果 ref 不是 Reference,那么 this 的值为 undefined

this 为 undefined,非严格模式下,this 的值为 undefined 的时候,其值会被隐式转换为全局对象。

(false || foo.bar)()

看示例 4,逻辑与算法,查看规范 11.11 Binary Logical Operators:

计算第二步:

2.Let lval be GetValue(lref).

因为使用了 GetValue,所以返回的不是 Reference 类型,this 为 undefined

(foo.bar, foo.bar)()

看示例 5,逗号操作符,查看规范 11.14 Comma Operator ( , )

计算第二步:

2.Call GetValue(lref).

因为使用了 GetValue,所以返回的不是 Reference 类型,this 为 undefined

结果

js 复制代码
var value = 1;

var foo = {
  value: 2,
  bar: function() {
    return this.value;
  }
};

//示例1
console.log(foo.bar()); // 2
//示例2
console.log(foo.bar()); // 2
//示例3
console.log((foo.bar = foo.bar)()); // 1
//示例4
console.log((false || foo.bar)()); // 1
//示例5
console.log((foo.bar, foo.bar)()); // 1

注意:以上是在非严格模式下的结果,严格模式下因为 this 返回 undefined,所以示例 3、4、5 会报错。

补充

最最后,忘记了一个最最普通的情况:

js 复制代码
function foo() {
  console.log(this);
}

foo();

MemberExpression 是 foo,解析标识符,查看规范 10.3.1 Identifier Resolution,会返回一个 Reference 类型的值:

js 复制代码
var fooReference = {
  base: EnvironmentRecord,
  name: 'foo',
  strict: false
};

接下来进行判断:

2.1 如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么 this 的值为 GetBase(ref)

因为 base value 是 EnvironmentRecord,并不是一个 Object 类型,还记得前面讲过的 base value 的取值可能吗? 只可能是 undefined, an Object, a Boolean, a String, a Number, 和 an environment record 中的一种。

IsPropertyReference(ref) 的结果为 false,进入下个判断:

2.2 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么 this 的值为 ImplicitThisValue(ref)

base value 正是 Environment Record,所以会调用 ImplicitThisValue(ref)

查看规范 10.2.1.1.6,ImplicitThisValue 方法的介绍:该函数始终返回 undefined。

所以最后 this 的值就是 undefined。

多说一句

尽管我们可以简单的理解 this 为调用函数的对象,如果是这样的话,如何解释下面这个例子呢?

js 复制代码
var value = 1;

var foo = {
  value: 2,
  bar: function() {
    return this.value;
  }
};
console.log((false || foo.bar)()); // 1

此外,又如何确定调用函数的对象是谁呢?在写文章之初,我就面临着这些问题,最后还是放弃从多个情形下给大家讲解 this 指向的思路,而是追根溯源的从 ECMASciript 规范讲解 this 的指向,尽管从这个角度写起来和读起来都比较吃力,但是一旦多读几遍,明白原理,绝对会给你一个全新的视角看待 this 。而你也就能明白,尽管 foo() 和 (foo.bar = foo.bar)() 最后结果都指向了 undefined,但是两者从规范的角度上却有着本质的区别。

执行上下文

前言

当 JavaScript 代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。

对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

然后分别在变量对象、作用域链、从 ECMAScript 规范解读 this 中讲解了这三个属性。

阅读本文前,如果对以上的概念不是很清楚,希望先阅读这些文章。

因为这一篇,我们会结合着所有内容,讲讲执行上下文的具体处理过程。

思考题

在词法作用域和动态作用域中,提出这样一道思考题:

js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f();
}
checkscope();
js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f;
}
checkscope()();

两段代码都会打印’local scope’。虽然两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?

紧接着就在执行上下文栈中,讲到了两者的区别在于执行上下文栈的变化不一样,然而如果是这样笼统的回答,依然显得不够详细,本篇就会详细的解析执行上下文栈和执行上下文的具体变化过程。

具体执行分析

我们分析第一段代码:

js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f();
}
checkscope();

执行过程如下:

  1. 执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈

    js 复制代码
    ECStack = [globalContext];
  2. 全局上下文初始化

    js 复制代码
    globalContext = {
     VO: [global],
     Scope: [globalContext.VO],
     this: globalContext.VO
    };
  3. 初始化的同时,checkscope 函数被创建,保存作用域链到函数的内部属性[[scope]]

    js 复制代码
    checkscope.[[scope]] = [
     globalContext.VO
    ];
  4. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

    js 复制代码
    ECStack = [checkscopeContext, globalContext];
  5. checkscope 函数执行上下文初始化:

  6. 复制函数 [[scope]] 属性创建作用域链

  7. 用 arguments 创建活动对象

  8. 初始化活动对象,即加入形参、函数声明、变量声明

  9. 将活动对象压入 checkscope 作用域链顶端

    同时 f 函数被创建,保存作用域链到 f 函数的内部属性[[scope]]

    js 复制代码
    checkscopeContext = {
     AO: {
       arguments: {
         length: 0
       },
       scope: undefined,
       f: reference to function f(){}
     },
     Scope: [AO, globalContext.VO],
     this: undefined
    }
  10. 执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈

    js 复制代码
    ECStack = [fContext, checkscopeContext, globalContext];
  11. f 函数执行上下文初始化, 以下跟第 4 步相同:

  12. 复制函数 [[scope]] 属性创建作用域链

  13. 用 arguments 创建活动对象

  14. 初始化活动对象,即加入形参、函数声明、变量声明

  15. 将活动对象压入 f 作用域链顶端

    js 复制代码
    fContext = {
     AO: {
       arguments: {
         length: 0
       }
     },
     Scope: [AO, checkscopeContext.AO, globalContext.VO],
     this: undefined
    };
  16. f 函数执行,沿着作用域链查找 scope 值,返回 scope 值

  17. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出

    js 复制代码
    ECStack = [checkscopeContext, globalContext];
  18. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出

js 复制代码
ECStack = [globalContext];

第二段代码就留给大家去尝试模拟它的执行过程

js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f;
}
checkscope()();

不过,在闭包中也会提及这段代码的执行过程。

闭包

定义

MDN 对闭包的定义为:

闭包是指那些能够访问自由变量的函数。

那什么是自由变量呢?

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

由此,我们可以看出闭包共有两部分组成:

闭包 = 函数 + 函数能够访问的自由变量

举个例子:

js 复制代码
var a = 1;

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

foo();

foo 函数可以访问变量 a,但是 a 既不是 foo 函数的局部变量,也不是 foo 函数的参数,所以 a 就是自由变量。

那么,函数 foo + foo 函数访问的自由变量 a 不就是构成了一个闭包嘛……

还真是这样的!

所以在《JavaScript 权威指南》中就讲到:从技术的角度讲,所有的 JavaScript 函数都是闭包。

咦,这怎么跟我们平时看到的讲到的闭包不一样呢!?

别着急,这是理论上的闭包,其实还有一个实践角度上的闭包,让我们看看汤姆大叔翻译的关于闭包的文章中的定义:

ECMAScript 中,闭包指的是:

  1. 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  2. 从实践角度:以下函数才算是闭包:
  3. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  4. 在代码中引用了自由变量

接下来就来讲讲实践上的闭包。

分析

让我们先写个例子,例子依然是来自《JavaScript 权威指南》,稍微做点改动:

js 复制代码
var scope = 'global scope';
function checkscope() {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f;
}

var foo = checkscope();
foo();

首先我们要分析一下这段代码中执行上下文栈和执行上下文的变化情况。

另一个与这段代码相似的例子,在执行上下文中有着非常详细的分析。如果看不懂以下的执行过程,建议先阅读这篇文章。

这里直接给出简要的执行过程:

  1. 进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈
  2. 全局执行上下文初始化
  3. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 执行上下文被压入执行上下文栈
  4. checkscope 执行上下文初始化,创建变量对象、作用域链、this 等
  5. checkscope 函数执行完毕,checkscope 执行上下文从执行上下文栈中弹出
  6. 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈
  7. f 执行上下文初始化,创建变量对象、作用域链、this 等
  8. f 函数执行完毕,f 函数上下文从执行上下文栈中弹出

了解到这个过程,我们应该思考一个问题,那就是:

当 f 函数执行的时候,checkscope 函数上下文已经被销毁了啊(即从执行上下文栈中被弹出),怎么还会读取到 checkscope 作用域下的 scope 值呢?

以上的代码,要是转换成 PHP,就会报错,因为在 PHP 中,f 函数只能读取到自己作用域和全局作用域里的值,所以读不到 checkscope 下的 scope 值。(这段我问的 PHP 同事……)

然而 JavaScript 却是可以的!

当我们了解了具体的执行过程后,我们知道 f 执行上下文维护了一个作用域链:

js 复制代码
fContext = {
  Scope: [AO, checkscopeContext.AO, globalContext.VO]
};

对的,就是因为这个作用域链,f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了 checkscopeContext.AO 中的值的时候,即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。

所以,让我们再看一遍实践角度上闭包的定义:

  1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  2. 在代码中引用了自由变量

在这里再补充一个《JavaScript 权威指南》英文原版对闭包的定义:

This combination of a function object and a scope (a set of variable bindings) in which the function’s variables are resolved is called a closure in the computer science literature.

闭包在计算机科学中也只是一个普通的概念,大家不要去想得太复杂。

必刷题

js 复制代码
var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function() {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]();

答案是都是 3,让我们分析一下原因:

当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

js 复制代码
globalContext = {
  VO: {
    data: [...],
    i: 3,
  }
}

当执行 data[0] 函数的时候,data[0] 函数的作用域链为:

js 复制代码
data[0]Context = {
  Scope: [AO, globalContext.VO]
}

data[0]Context 的 AO 并没有 i 值,所以会从 globalContext.VO 中查找,i 为 3,所以打印的结果就是 3。

data[1] 和 data[2] 是一样的道理。

所以让我们改成闭包看看:

js 复制代码
var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = (function(i) {
    return function() {
      console.log(i);
    };
  })(i);
}

data[0]();
data[1]();
data[2]();

当执行到 data[0] 函数之前,此时全局上下文的 VO 为:

js 复制代码
globalContext = {
  VO: {
    data: [...],
    i: 3,
  }
}

跟没改之前一模一样。

当执行 data[0] 函数的时候,data[0] 函数的作用域链发生了改变:

js 复制代码
data[0]Context = {
    Scope: [AO, 匿名函数Context.AO, globalContext.VO]
}

匿名函数执行上下文的 AO 为:

js 复制代码
匿名函数Context = {
  AO: {
    arguments: {
      0: 0,
      length: 1
    },
    i: 0
  }
};

data[0]Context 的 AO 并没有 i 值,所以会沿着作用域链从匿名函数 Context.AO 中查找,这时候就会找 i 为 0,找到了就不会往 globalContext.VO 中查找了,即使 globalContext.VO 也有 i 的值(值为 3),所以打印的结果就是 0。

data[1] 和 data[2] 是一样的道理。

参数按值传递

定义

在《JavaScript 高级程序设计》第三版 4.1.3,讲到传递参数:

ECMAScript 中所有函数的参数都是按值传递的。

什么是按值传递呢?

也就是说,把函数外部的值复制给函数内部的参数,就和把值从一个变量复制到另一个变量一样。

按值传递

举个简单的例子:

js 复制代码
var value = 1;
function foo(v) {
  v = 2;
  console.log(v); //2
}
foo(value);
console.log(value); // 1

很好理解,当传递 value 到函数 foo 中,相当于拷贝了一份 value,假设拷贝的这份叫 _value,函数中修改的都是 _value 的值,而不会影响原来的 value 值。

引用传递?

拷贝虽然很好理解,但是当值是一个复杂的数据结构的时候,拷贝就会产生性能上的问题。

所以还有另一种传递方式叫做按引用传递。

所谓按引用传递,就是传递对象的引用,函数内部对参数的任何改变都会影响该对象的值,因为两者引用的是同一个对象。

举个例子:

js 复制代码
var obj = {
  value: 1
};
function foo(o) {
  o.value = 2;
  console.log(o.value); //2
}
foo(obj);
console.log(obj.value); // 2

哎,不对啊,连我们的红宝书都说了 ECMAScript 中所有函数的参数都是按值传递的,这怎么能按”引用传递”成功呢?

而这究竟是不是引用传递呢?

第三种传递方式

不急,让我们再看个例子:

js 复制代码
var obj = {
  value: 1
};
function foo(o) {
  o = 2;
  console.log(o); //2
}
foo(obj);
console.log(obj.value); // 1

如果 JavaScript 采用的是引用传递,外层的值也会被修改,这怎么又没被改呢?所以真的不是引用传递吗?

这就要讲到其实还有第三种传递方式,叫按共享传递。

而共享传递是指,在传递对象的时候,传递对象的引用的副本。

注意:按引用传递是传递对象的引用,而按共享传递是传递对象的引用的副本!

所以修改 o.value,可以通过引用找到原值,但是直接修改 o,并不会修改原值。所以第二个和第三个例子其实都是按共享传递。

最后,你可以这样理解:

参数如果是基本类型是按值传递,如果是引用类型按共享传递。

但是因为拷贝副本也是一种值的拷贝,所以在高程中也直接认为是按值传递了。

call 和 apply 的模拟实现

call

一句话介绍 call:

call() 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或方法。

举个例子:

js 复制代码
var foo = {
  value: 1
};

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

bar.call(foo); // 1

注意两点:

  1. call 改变了 this 的指向,指向到 foo
  2. bar 函数执行了

模拟实现第一步(函数设为对象属性)

那么我们该怎么模拟实现这两个效果呢?

试想当调用 call 的时候,把 foo 对象改造成如下:

js 复制代码
var foo = {
  value: 1,
  bar: function() {
    console.log(this.value);
  }
};

foo.bar(); // 1

这个时候 this 就指向了 foo,是不是很简单呢?

但是这样却给 foo 对象本身添加了一个属性,这可不行呐!

不过也不用担心,我们用 delete 再删除它不就好了~

所以我们模拟的步骤可以分为:

  1. 将函数设为对象的属性
  2. 执行该函数
  3. 删除该函数

以上个例子为例,就是:

js 复制代码
// 第一步
foo.fn = bar;
// 第二步
foo.fn();
// 第三步
delete foo.fn;

fn 是对象的属性名,反正最后也要删除它,所以起成什么都无所谓。

根据这个思路,我们可以尝试着去写第一版的 call2 函数:

js 复制代码
// 第一版
Function.prototype.call2 = function(context) {
  // 首先要获取调用 call 的函数,用 this 可以获取
  context.fn = this;
  context.fn();
  delete context.fn;
};

// 测试一下
var foo = {
  value: 1
};

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

bar.call2(foo); // 1

正好可以打印 1

模拟实现第二步(传参、调用)

一开始也讲了,call 函数还能给定参数执行函数,举个例子:

js 复制代码
var foo = {
  value: 1
};

function bar(name, age) {
  console.log(name);
  console.log(age);
  console.log(this.value);
}

bar.call(foo, 'kevin', 18);
// kevin
// 18
// 1

注意:传入的参数并不确定,这可咋办?

不急,我们可以从 Arguments 对象中取值,取出第二个到最后一个参数,然后放到一个数组里。

比如这样:

js 复制代码
// 以上个例子为例,此时的arguments为:
// arguments = {
//      0: foo,
//      1: 'kevin',
//      2: 18,
//      length: 3
// }
// 因为arguments是类数组对象,所以可以用for循环
var args = [];
for (var i = 1, len = arguments.length; i < len; i++) {
  args.push('arguments[' + i + ']');
}

// 执行后 args为 ["arguments[1]", "arguments[2]", "arguments[3]"]

不定长的参数问题解决了,我们接着要把这个参数数组放到要执行的函数的参数里面去。

js 复制代码
// 将数组里的元素作为多个参数放进函数的形参里
context.fn(args.join(','));
// (O_o)??
// 这个方法肯定是不行的啦!!!

也许有人想到用 ES6 的方法,不过 call 是 ES3 的方法,我们为了模拟实现一个 ES3 的方法,要用到 ES6 的方法,好像……,嗯,也可以啦。但是我们这次用 eval 方法拼成一个函数,类似于这样:

js 复制代码
eval('context.fn(' + args + ')');

这里 args 会自动调用 Array.toString() 这个方法。

所以我们的第二版克服了两个大问题,代码如下:

js 复制代码
// 第二版
Function.prototype.call2 = function(context) {
  context.fn = this;
  var args = [];
  for (var i = 1, len = arguments.length; i < len; i++) {
    args.push('arguments[' + i + ']');
  }
  eval('context.fn(' + args + ')');
  delete context.fn;
};

// 测试一下
var foo = {
  value: 1
};

function bar(name, age) {
  console.log(name);
  console.log(age);
  console.log(this.value);
}

bar.call2(foo, 'kevin', 18);
// kevin
// 18
// 1

模拟实现第三步(context、返回值)

模拟代码已经完成 80%,还有两个小点要注意:

  1. this 参数可以传 null,当为 null 的时候,视为指向 window

举个例子:

js 复制代码
var value = 1;

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

bar.call(null); // 1

虽然这个例子本身不使用 call,结果依然一样。

  1. 函数是可以有返回值的!

举个例子:

js 复制代码
var obj = {
  value: 1
};

function bar(name, age) {
  return {
    value: this.value,
    name: name,
    age: age
  };
}

console.log(bar.call(obj, 'kevin', 18));
// Object {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }

不过都很好解决,让我们直接看第三版也就是最后一版的代码:

js 复制代码
// 第三版
Function.prototype.call2 = function(context) {
  var context = context || window;
  context.fn = this;

  var args = [];
  for (var i = 1, len = arguments.length; i < len; i++) {
    args.push('arguments[' + i + ']');
  }

  var result = eval('context.fn(' + args + ')');

  delete context.fn;
  return result;
};

// 测试一下
var value = 2;

var obj = {
  value: 1
};

function bar(name, age) {
  console.log(this.value);
  return {
    value: this.value,
    name: name,
    age: age
  };
}

bar.call2(null); // 2

console.log(bar.call2(obj, 'kevin', 18));
// 1
// Object {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }

到此,我们完成了 call 的模拟实现

apply 的模拟实现

apply 的实现跟 call 类似,在这里直接给代码,代码来自于知乎 @郑航的实现:

js 复制代码
Function.prototype.apply = function(context, arr) {
  var context = Object(context) || window;
  context.fn = this;

  var result;
  if (!arr) {
    result = context.fn();
  } else {
    var args = [];
    for (var i = 0, len = arr.length; i < len; i++) {
      args.push('arr[' + i + ']');
    }
    result = eval('context.fn(' + args + ')');
  }

  delete context.fn;
  return result;
};

bind 的模拟实现

一句话介绍 bind:

bind() 方法会创建一个新函数。当这个新函数被调用时,bind() 的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数。(来自于 MDN )

由此我们可以首先得出 bind 函数的两个特点:

  1. 返回一个函数
  2. 可以传入参数

返回函数的模拟实现

从第一个特点开始,我们举个例子:

js 复制代码
var foo = {
  value: 1
};

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

// 返回了一个函数
var bindFoo = bar.bind(foo);

bindFoo(); // 1

关于指定 this 的指向,我们可以使用 call 或者 apply 实现,关于 call 和 apply 的模拟实现,我们来写第一版的代码:

js 复制代码
// 第一版
Function.prototype.bind2 = function(context) {
  var self = this;
  return function() {
    return self.apply(context);
  };
};

此外,之所以 return self.apply(context),是考虑到绑定函数可能是有返回值的,依然是这个例子

js 复制代码
var foo = {
  value: 1
};

function bar() {
  return this.value;
}

var bindFoo = bar.bind(foo);

console.log(bindFoo()); // 1

传参的模拟实现

接下来看第二点,可以传入参数。这个就有点让人费解了,我在 bind 的时候,是否可以传参呢?我在执行 bind 返回的函数的时候,可不可以传参呢?让我们看个例子:

js 复制代码
var foo = {
  value: 1
};

function bar(name, age, gender) {
  console.log(this.value);
  console.log(name);
  console.log(age);
  console.log(gender);
}

var bindFoo = bar.bind(foo, 'daisy');
bindFoo('18', 'woman');
// 1
// daisy
// 18

函数需要传 name 和 age 两个参数,竟然还可以在 bind 的时候,只传一个 name,在执行返回的函数的时候,再传另一个参数 age!

这可咋办?不急,我们用 arguments 进行处理:

js 复制代码
// 第二版
Function.prototype.bind2 = function(context) {
  // this 代表 bar
  var self = this;
  // 获取 bind2 函数从第二个参数到最后一个参数
  var args = Array.prototype.slice.call(arguments, 1);

  return function() {
    // 这个时候的 arguments 是指 bind 返回的函数传入的参数
    var bindArgs = Array.prototype.slice.call(arguments);
    return self.apply(context, args.concat(bindArgs));
  };
};

同理于以下的 add 函数

js 复制代码
function add() {
  var args = Array.prototype.slice.call(arguments);

  function fn() {
    var args2 = Array.prototype.slice.call(arguments);
    return add.apply(null, args.concat(args2));
  }

  fn.toString = function() {
    return args.reduce(function(total, item) {
      return total + item;
    });
  };

  return fn;
}
console.log(add(1)(2)(3));
console.log(add(1, 2)(3));
console.log(add(1, 2, 3));

构造函数效果的模拟实现

完成了这两点,最难的部分到啦!因为 bind 还有一个特点,就是

一个绑定函数也能使用 new 操作符创建对象:这种行为就像把原函数当成构造器,提供的 this 值被忽略,同时调用时的参数被提供给模拟函数。

也就是说当 bind 返回的函数作为构造函数的时候,bind 时指定的 this 值会失效,但传入的参数依然生效。举个例子:

js 复制代码
var value = 2;

var foo = {
  value: 1
};

function bar(name, age) {
  this.habit = 'shopping';
  console.log(this.value);
  console.log(name);
  console.log(age);
}

bar.prototype.friend = 'kevin';

var bindFoo = bar.bind(foo, 'daisy');

var obj = new bindFoo('18');
// undefined
// daisy
// 18
console.log(obj.habit);
console.log(obj.friend);
// shopping
// kevin

注意:尽管在全局和 foo 中都声明了 value 值,最后依然返回了 undefind,说明绑定的 this 失效了,如果大家了解 new 的模拟实现,就会知道这个时候的 this 已经指向了 obj。

所以我们可以通过修改返回的函数的原型来实现,让我们写一下

js 复制代码
// 第二版
Function.prototype.bind2 = function(context) {
  // this 代表 bar
  var self = this;
  // 获取 bind2 函数从第二个参数到最后一个参数
  var args = Array.prototype.slice.call(arguments, 1);

  var fn = function() {
    // 这个时候的 arguments 是指 bind 返回的函数传入的参数
    var bindArgs = Array.prototype.slice.call(arguments);
    // 当作为构造函数时,this 指向实例,此时结果为 true,将绑定函数的 this 指向该实例,可以让实例获得来自绑定函数的值
    // 以上面的 demo 为例,如果改成 `this instanceof fn ? null : context`,实例只是一个空对象,将 null 改成 this ,实例会具有 habit 属性
    // 当作为普通函数时,this 指向 window,此时结果为 false,将绑定函数的 this 指向 context
    return self.apply(this instanceof fn ? this : context, args.concat(bindArgs));
  };

  // 修改返回函数的 prototype 为绑定函数的 prototype,实例就可以继承绑定函数的原型中的值
  fn.prototype = this.prototype;
  return fn;
};

构造函数效果的优化实现

但是在这个写法中,我们直接将 fn.prototype = this.prototype,我们直接修改 fn.prototype 的时候,也会直接修改绑定函数的 prototype。这个时候,我们可以通过一个空函数来进行中转:

js 复制代码
// 第四版
Function.prototype.bind2 = function(context) {
  var self = this;
  var args = Array.prototype.slice.call(arguments, 1);

  var fn = function() {
    var bindArgs = Array.prototype.slice.call(arguments);
    // new 时忽略传入的 this
    return self.apply(this instanceof fn ? this : context, args.concat(bindArgs));
  };

  // 类似于 fn.prototype = Object.create(this.prototype);
  var fNOP = function() {};
  fNOP.prototype = this.prototype;
  fn.prototype = new fNOP();
  return fn;
};

三个小问题

接下来处理些小问题:

  1. apply 这段代码跟 MDN 上的稍有不同

在 MDN 中文版讲 bind 的模拟实现时,apply 这里的代码是:

js 复制代码
self.apply(this instanceof self ? this : context || this, args.concat(bindArgs));

多了一个关于 context 是否存在的判断,然而这个是错误的!

举个例子:

js 复制代码
var value = 2;
var foo = {
  value: 1,
  bar: bar.bind(null)
};

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

foo.bar(); // 2

以上代码正常情况下会打印 2,如果换成了 context || this,这段代码就会打印 1!

所以这里不应该进行 context 的判断,大家查看 MDN 同样内容的英文版,就不存在这个判断!

(2018 年 3 月 27 日更新,中文版已经改了 😀)

  1. 调用 bind 的不是函数咋办?

不行,我们要报错!

js 复制代码
if (typeof this !== 'function') {
  throw new Error('Function.prototype.bind - what is trying to be bound is not callable');
}
  1. 我要在线上用

那别忘了做个兼容:

js 复制代码
Function.prototype.bind = Function.prototype.bind || function () {
    ……
};

当然最好是用 es5-shim 啦。

最终代码

js 复制代码
Function.prototype.bind2 = function(context) {
  if (typeof this !== 'function') {
    throw new Error('Function.prototype.bind - what is trying to be bound is not callable');
  }

  var self = this;
  var args = Array.prototype.slice.call(arguments, 1);

  var fNOP = function() {};

  var fn = function() {
    var bindArgs = Array.prototype.slice.call(arguments);
    // this instanceof fNOP 这里的 fNOP 也可换成 fn,因为 fn.prototype = new fNOP();
    return self.apply(this instanceof fNOP ? this : context, args.concat(bindArgs));
  };

  fNOP.prototype = this.prototype;
  fn.prototype = new fNOP();
  return fn;
};

new 的模拟实现

一句话介绍 new:

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

也许有点难懂,我们在模拟 new 之前,先看看 new 实现了哪些功能。

举个例子:

js 复制代码
// Otaku 御宅族,简称宅
function Otaku(name, age) {
  this.name = name;
  this.age = age;

  this.habit = 'Games';
}

// 因为缺乏锻炼的缘故,身体强度让人担忧
Otaku.prototype.strength = 60;

Otaku.prototype.sayYourName = function() {
  console.log('I am ' + this.name);
};

var person = new Otaku('Kevin', '18');

console.log(person.name); // Kevin
console.log(person.habit); // Games
console.log(person.strength); // 60

person.sayYourName(); // I am Kevin

从这个例子中,我们可以看到,实例 person 可以:

  • 访问到 Otaku 构造函数里的属性
  • 访问到 Otaku.prototype 中的属性

接下来,我们可以尝试着模拟一下了。

因为 new 是关键字,所以无法像 bind 函数一样直接覆盖,所以我们写一个函数,命名为 objectFactory,来模拟 new 的效果。用的时候是这样的:

js 复制代码
function Otaku () {
    ……
}

// 使用 new
var person = new Otaku(……);
// 使用 objectFactory
var person = objectFactory(Otaku, ……)

初步实现

因为 new 的结果是一个新对象,所以在模拟实现的时候,我们也要建立一个新对象,假设这个对象叫 obj,因为 obj 会具有 Otaku 构造函数里的属性,想想经典继承的例子,我们可以使用 Otaku.apply(obj, arguments)来给 obj 添加新的属性。

在 JavaScript 深入系列第一篇中,我们便讲了原型与原型链,我们知道实例的 __proto__ 属性会指向构造函数的 prototype,也正是因为建立起这样的关系,实例可以访问原型上的属性。

现在,我们可以尝试着写第一版了:

js 复制代码
// 第一版代码
function objectFactory() {
  var obj = new Object();
  var Constructor = [].shift.call(arguments);
  obj.__proto__ = Constructor.prototype;
  Constructor.apply(obj, arguments);
  return obj;
}

在这一版中,我们:

  1. 用 new Object() 的方式新建了一个对象 obj
  2. 取出第一个参数,就是我们要传入的构造函数。此外因为 shift 会修改原数组,所以 arguments 会被去除第一个参数
  3. 将 obj 的原型指向构造函数,这样 obj 就可以访问到构造函数原型中的属性
  4. 使用 apply,改变构造函数 this 的指向到新建的对象,这样 obj 就可以访问到构造函数中的属性
  5. 返回 obj

复制以下的代码,到浏览器中,我们可以做一下测试:

js 复制代码
function Otaku(name, age) {
  this.name = name;
  this.age = age;

  this.habit = 'Games';
}

Otaku.prototype.strength = 60;

Otaku.prototype.sayYourName = function() {
  console.log('I am ' + this.name);
};

function objectFactory() {
  var obj = new Object(),
    Constructor = [].shift.call(arguments);
  obj.__proto__ = Constructor.prototype;
  Constructor.apply(obj, arguments);
  return obj;
}

var person = objectFactory(Otaku, 'Kevin', '18');

console.log(person.name); // Kevin
console.log(person.habit); // Games
console.log(person.strength); // 60

person.sayYourName(); // I am Kevin

返回值效果实现

接下来我们再来看一种情况,假如构造函数有返回值,举个例子:

js 复制代码
function Otaku(name, age) {
  this.strength = 60;
  this.age = age;

  return {
    name: name,
    habit: 'Games'
  };
}

var person = new Otaku('Kevin', '18');

console.log(person.name); // Kevin
console.log(person.habit); // Games
console.log(person.strength); // undefined
console.log(person.age); // undefined

在这个例子中,构造函数返回了一个对象,在实例 person 中只能访问返回的对象中的属性。

而且还要注意一点,在这里我们是返回了一个对象,假如我们只是返回一个基本类型的值呢?

再举个例子:

js 复制代码
function Otaku(name, age) {
  this.strength = 60;
  this.age = age;

  return 'handsome boy';
}

var person = new Otaku('Kevin', '18');

console.log(person.name); // undefined
console.log(person.habit); // undefined
console.log(person.strength); // 60
console.log(person.age); // 18

结果完全颠倒过来,这次尽管有返回值,但是相当于没有返回值进行处理。

所以我们还需要判断返回的值是不是一个对象,如果是一个对象,我们就返回这个对象,如果没有,我们该返回什么就返回什么。

再来看第二版的代码,也是最后一版的代码:

js 复制代码
// 第二版的代码
function objectFactory() {
  var obj = new Object();
  var Constructor = [].shift.call(arguments);
  // 相当于 var obj = Object.create(Constructor.prototype)
  obj.__proto__ = Constructor.prototype;
  var ret = Constructor.apply(obj, arguments);
  return typeof ret === 'object' ? ret : obj;
}

类数组对象与 arguments

所谓的类数组对象:

拥有一个 length 属性和若干索引属性的对象

举个例子:

js 复制代码
var array = ['name', 'age', 'sex'];

var arrayLike = {
  0: 'name',
  1: 'age',
  2: 'sex',
  length: 3
};

即便如此,为什么叫做类数组对象呢?

那让我们从读写、获取长度、遍历三个方面看看这两个对象。

读写

js 复制代码
console.log(array[0]); // name
console.log(arrayLike[0]); // name

array[0] = 'new name';
arrayLike[0] = 'new name';

长度

js 复制代码
console.log(array.length); // 3
console.log(arrayLike.length); // 3

遍历

js 复制代码
for(var i = 0, len = array.length; i < len; i++) {
   ……
}
for(var i = 0, len = arrayLike.length; i < len; i++) {
    ……
}

是不是很像?

那类数组对象可以使用数组的方法吗?比如:

js 复制代码
arrayLike.push('4');

然而上述代码会报错: arrayLike.push is not a function

调用数组方法

如果类数组就是任性的想用数组的方法怎么办呢?

既然无法直接调用,我们可以用 Function.call 间接调用:

js 复制代码
var arrayLike = { 0: 'name', 1: 'age', 2: 'sex', length: 3 };

Array.prototype.join.call(arrayLike, '&'); // name&age&sex

Array.prototype.slice.call(arrayLike, 0); // ["name", "age", "sex"]
// slice 可以做到类数组转数组

Array.prototype.map.call(arrayLike, function(item) {
  return item.toUpperCase();
});
// ["NAME", "AGE", "SEX"]

类数组转数组

在上面的例子中已经提到了一种类数组转数组的方法,再补充三个:

js 复制代码
var arrayLike = { 0: 'name', 1: 'age', 2: 'sex', length: 3 };
// 1. slice
Array.prototype.slice.call(arrayLike); // ["name", "age", "sex"]
// 2. splice
Array.prototype.splice.call(arrayLike, 0); // ["name", "age", "sex"]
// 3. ES6 Array.from
Array.from(arrayLike); // ["name", "age", "sex"]
// 4. apply
Array.prototype.concat.apply([], arrayLike);

那么为什么会讲到类数组对象呢?以及类数组有什么应用吗?

要说到类数组对象,Arguments 对象就是一个类数组对象。在客户端 JavaScript 中,一些 DOM 方法(document.getElementsByTagName()等)也返回类数组对象。

Arguments 对象

接下来重点讲讲 Arguments 对象。

Arguments 对象只定义在函数体中,包括了函数的参数和其他属性。在函数体中,arguments 指代该函数的 Arguments 对象。

举个例子:

js 复制代码
function foo(name, age, sex) {
  console.log(arguments);
}

foo('name', 'age', 'sex');

打印结果如下:

2020 06 11 10 04 42

我们可以看到除了类数组的索引属性和 length 属性之外,还有一个 callee 属性,接下来我们一个一个介绍。

length 属性

Arguments 对象的 length 属性,表示实参的长度,举个例子:

js 复制代码
function foo(b, c, d) {
  console.log('实参的长度为:' + arguments.length);
}

console.log('形参的长度为:' + foo.length);

foo(1);

// 形参的长度为:3
// 实参的长度为:1

callee 属性

Arguments 对象的 callee 属性,通过它可以调用函数自身。

讲个闭包经典面试题使用 callee 的解决方法:

js 复制代码
var data = [];

for (var i = 0; i < 3; i++) {
  (data[i] = function() {
    console.log(arguments.callee.i);
  }).i = i;
}

data[0]();
data[1]();
data[2]();

// 0
// 1
// 2

接下来讲讲 arguments 对象的几个注意要点:

arguments 和对应参数的绑定

js 复制代码
function foo(name, age, sex, hobbit) {
  console.log(name, arguments[0]); // name name
  // 改变形参
  name = 'new name';
  console.log(name, arguments[0]); // new name new name
  // 改变arguments
  arguments[1] = 'new age';
  console.log(age, arguments[1]); // new age new age
  // 测试未传入的是否会绑定
  console.log(sex); // undefined
  sex = 'new sex';
  console.log(sex, arguments[2]); // new sex undefined
  arguments[3] = 'new hobbit';
  console.log(hobbit, arguments[3]); // undefined new hobbit
}

foo('name', 'age');

传入的参数,实参和 arguments 的值会共享,当没有传入时,实参与 arguments 值不会共享

除此之外,以上是在非严格模式下,如果是在严格模式下,实参和 arguments 是不会共享的。

传递参数

将参数从一个函数传递到另一个函数

js 复制代码
// 使用 apply 将 foo 的参数传递给 bar
function foo() {
  bar.apply(this, arguments);
}

function bar(a, b, c) {
  console.log(a, b, c);
}

foo(1, 2, 3);

强大的 ES6

使用 ES6 的 … 运算符,我们可以轻松转成数组。

js 复制代码
function func(...arguments) {
  console.log(arguments); // [1, 2, 3]
}

func(1, 2, 3);

应用

arguments 的应用其实很多,在下个系列,也就是 JavaScript 专题系列中,我们会在 jQuery 的 extend 实现、函数柯里化、递归等场景看见 arguments 的身影。这篇文章就不具体展开了。

如果要总结这些场景的话,暂时能想到的包括:

  • 参数不定长
  • 函数柯里化
  • 递归调用
  • 函数重载

创建对象的多种方式以及优缺点

这篇文章讲解创建对象的各种方式,以及优缺点。

工厂模式

js 复制代码
function createPerson(name) {
  var o = new Object();
  o.name = name;
  o.getName = function() {
    console.log(this.name);
  };

  return o;
}

var person1 = createPerson('kevin');

缺点:对象无法识别,因为所有的实例都指向一个原型

构造函数模式

js 复制代码
function Person(name) {
  this.name = name;
  this.getName = function() {
    console.log(this.name);
  };
}

var person1 = new Person('kevin');

优点:实例可以识别为一个特定的类型(person1 instanceof Person)

缺点:每次创建实例时,每个方法都要被创建一次

构造函数模式优化

js 复制代码
function Person(name) {
  this.name = name;
  this.getName = getName;
}

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

var person1 = new Person('kevin');

优点:解决了每个方法都要被重新创建的问题

缺点:这叫啥封装……

原型模式

js 复制代码
function Person(name) {}

Person.prototype.name = 'keivn';
Person.prototype.getName = function() {
  console.log(this.name);
};

var person1 = new Person();

优点:方法不会重新创建

缺点:1. 所有的属性和方法都共享 2. 不能初始化参数

原型模式优化一

js 复制代码
function Person(name) {}

Person.prototype = {
  name: 'kevin',
  getName: function() {
    console.log(this.name);
  }
};

var person1 = new Person();

优点:封装性好了一点

缺点:重写了原型,丢失了 constructor 属性

原型模式优化

js 复制代码
function Person(name) {}

Person.prototype = {
  constructor: Person,
  name: 'kevin',
  getName: function() {
    console.log(this.name);
  }
};

var person1 = new Person();

优点:实例可以通过 constructor 属性找到所属构造函数

缺点:原型模式该有的缺点还是有

组合模式

构造函数模式与原型模式双剑合璧。

js 复制代码
function Person(name) {
  this.name = name;
}

Person.prototype = {
  constructor: Person,
  getName: function() {
    console.log(this.name);
  }
};

var person1 = new Person();

优点:该共享的共享,该私有的私有,使用最广泛的方式

缺点:有的人就是希望全部都写在一起,即更好的封装性

动态原型模式

js 复制代码
function Person(name) {
  this.name = name;
  if (typeof this.getName != 'function') {
    Person.prototype.getName = function() {
      console.log(this.name);
    };
  }
}

var person1 = new Person();

注意:使用动态原型模式时,不能用对象字面量重写原型

解释下为什么:

js 复制代码
function Person(name) {
  this.name = name;
  if (typeof this.getName != 'function') {
    Person.prototype = {
      constructor: Person,
      getName: function() {
        console.log(this.name);
      }
    };
  }
}

var person1 = new Person('kevin');
var person2 = new Person('daisy');

// 报错并没有该方法,这里的 person1 的原型并不是指向 Person.prototype,而是指向 Person.prototype 指向的原型对象,所以直接赋值 Person.prototype 并不会影响,有点共享传值的味道
// 当 new Person() 的时候,是先建立的原型关系,即 person .__proto__ = Person.prototype,而后修改了 Person.prototype 的值,但是 person.__proto__ 还是指向以前的 Person.prototype
person1.getName();

// 注释掉上面的代码,这句是可以执行的。
person2.getName();

为了解释这个问题,假设开始执行 var person1 = new Person(‘kevin’)。

如果对 new 和 apply 的底层执行过程不是很熟悉,可以阅读底部相关链接中的文章。

我们回顾下 new 的实现步骤:

  1. 首先新建一个对象
  2. 然后将对象的原型指向 Person.prototype
  3. 然后 Person.apply(obj)
  4. 返回这个对象

注意这个时候,回顾下 apply 的实现步骤,会执行 obj.Person 方法,这个时候就会执行 if 语句里的内容,注意构造函数的 prototype 属性指向了实例的原型,使用字面量方式直接覆盖 Person.prototype,并不会更改实例的原型的值,person1 依然是指向了以前的原型,而不是 Person.prototype。而之前的原型是没有 getName 方法的,所以就报错了!

如果你就是想用字面量方式写代码,可以尝试下这种:

js 复制代码
function Person(name) {
  this.name = name;
  if (typeof this.getName != 'function') {
    Person.prototype = {
      constructor: Person,
      getName: function() {
        console.log(this.name);
      }
    };

    return new Person(name);
  }
}

var person1 = new Person('kevin');
var person2 = new Person('daisy');

person1.getName(); // kevin
person2.getName(); // daisy

寄生构造函数模式

js 复制代码
function Person(name) {
  var o = new Object();
  o.name = name;
  o.getName = function() {
    console.log(this.name);
  };
  return o;
}

var person1 = new Person('kevin');
console.log(person1 instanceof Person); // false
console.log(person1 instanceof Object); // true

寄生构造函数模式,我个人认为应该这样读:

寄生-构造函数-模式,也就是说寄生在构造函数的一种方法。

也就是说打着构造函数的幌子挂羊头卖狗肉,你看创建的实例使用 instanceof 都无法指向构造函数!

这样方法可以在特殊情况下使用。比如我们想创建一个具有额外方法的特殊数组,但是又不想直接修改 Array 构造函数,我们可以这样写:

js 复制代码
function SpecialArray() {
  var values = new Array();

  for (var i = 0, len = arguments.length; i < len; i++) {
    values.push(arguments[i]);
  }

  values.toPipedString = function() {
    return this.join('|');
  };
  return values;
}

var colors = new SpecialArray('red', 'blue', 'green');
var colors2 = SpecialArray('red2', 'blue2', 'green2');

console.log(colors);
console.log(colors.toPipedString()); // red|blue|green

console.log(colors2);
console.log(colors2.toPipedString()); // red2|blue2|green2

你会发现,其实所谓的寄生构造函数模式就是比工厂模式在创建对象的时候,多使用了一个 new,实际上两者的结果是一样的。

但是作者可能是希望能像使用普通 Array 一样使用 SpecialArray,虽然把 SpecialArray 当成函数也一样能用,但是这并不是作者的本意,也变得不优雅。

在可以使用其他模式的情况下,不要使用这种模式。

但是值得一提的是,上面例子中的循环:

js 复制代码
for (var i = 0, len = arguments.length; i < len; i++) {
  values.push(arguments[i]);
}

可以替换成:

js 复制代码
values.push.apply(values, arguments);

稳妥构造函数模式

js 复制代码
function person(name) {
  var o = new Object();
  o.sayName = function() {
    console.log(name);
  };
  return o;
}

var person1 = person('kevin');

person1.sayName(); // kevin

person1.name = 'daisy';

person1.sayName(); // kevin

console.log(person1.name); // daisy

所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 的对象。

与寄生构造函数模式有两点不同:

  1. 新创建的实例方法不引用 this
  2. 不使用 new 操作符调用构造函数

稳妥对象最适合在一些安全的环境中。

稳妥构造函数模式也跟工厂模式一样,无法识别对象所属类型。

优先推荐使用组合模式

继承的多种方式和优缺点

本文讲解 JavaScript 各种继承方式和优缺点。

原型链继承

js 复制代码
function Parent() {
  this.name = 'kevin';
}

Parent.prototype.getName = function() {
  console.log(this.name);
};

function Child() {}

Child.prototype = new Parent();

var child1 = new Child();

console.log(child1.getName()); // kevin

问题:

  1. 引用类型的属性被所有实例共享,举个例子:
js 复制代码
function Parent() {
  this.names = ['kevin', 'daisy'];
}

function Child() {}

Child.prototype = new Parent();

var child1 = new Child();

child1.names.push('yayu');

console.log(child1.names); // ["kevin", "daisy", "yayu"]

var child2 = new Child();

console.log(child2.names); // ["kevin", "daisy", "yayu"]
  1. 在创建 Child 的实例时,不能向 Parent 传参

借用构造函数(经典继承)

js 复制代码
function Parent() {
  this.names = ['kevin', 'daisy'];
}

function Child() {
  Parent.call(this);
}

var child1 = new Child();

child1.names.push('yayu');

console.log(child1.names); // ["kevin", "daisy", "yayu"]

var child2 = new Child();

console.log(child2.names); // ["kevin", "daisy"]

优点:

  1. 避免了引用类型的属性被所有实例共享

  2. 可以在 Child 中向 Parent 传参

举个例子:

js 复制代码
function Parent(name) {
  this.name = name;
}
function Child(name) {
  Parent.call(this, name);
}
var child1 = new Child('kevin');
console.log(child1.name); // kevin
var child2 = new Child('daisy');
console.log(child2.name); // daisy

缺点:方法都在构造函数中定义,每次创建实例都会创建一遍方法。

组合继承

原型链继承和经典继承双剑合璧。

js 复制代码
function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function() {
  console.log(this.name);
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

var child1 = new Child('kevin', '18');
child1.colors.push('black');

console.log(child1.name); // kevin
console.log(child1.age); // 18
console.log(child1.colors); // ["red", "blue", "green", "black"]

var child2 = new Child('daisy', '20');
console.log(child2.name); // daisy
console.log(child2.age); // 20
console.log(child2.colors); // ["red", "blue", "green"]

优点:融合原型链继承和构造函数的优点,是 JavaScript 中最常用的继承模式。

原型式继承

js 复制代码
function createObj(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

就是 ES5 Object.create 的模拟实现,将传入的对象作为创建的对象的原型。

缺点:包含引用类型的属性值始终都会共享相应的值,这点跟原型链继承一样。

js 复制代码
var person = {
  name: 'kevin',
  friends: ['daisy', 'kelly']
};

var person1 = createObj(person);
var person2 = createObj(person);

person1.name = 'person1';
console.log(person2.name); // kevin

person1.__proto__.name = 'person1';
console.log(person2.name); // person1

person1.friends.push('taylor');
console.log(person2.friends); // ["daisy", "kelly", "taylor"]

注意:修改 person1.name 的值,person2.name 的值并未发生改变,并不是因为 person1 和 person2 有独立的 name 值,而是因为 person1.name = ‘person1’,给 person1 添加了 name 值,并非修改了 person1 原型即 F.prototype 上的 name 值。

寄生式继承

创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象。

js 复制代码
function createObj(o) {
  var clone = Object.create(o);
  clone.sayName = function() {
    console.log('hi');
  };
  return clone;
}

缺点:跟借用构造函数模式一样,每次创建对象都会创建一遍方法。

寄生组合式继承

为了方便大家阅读,在这里重复一下组合继承的代码:

js 复制代码
function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function() {
  console.log(this.name);
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

Child.prototype = new Parent();

var child1 = new Child('kevin', '18');

console.log(child1);

组合继承最大的缺点是会调用两次父构造函数。

一次是设置子类型实例的原型的时候:

js 复制代码
Child.prototype = new Parent();

一次在创建子类型实例的时候:

js 复制代码
var child1 = new Child('kevin', '18');

回想下 new 的模拟实现,其实在这句中,我们会执行:

js 复制代码
Parent.call(this, name);

在这里,我们又会调用了一次 Parent 构造函数。

所以,在这个例子中,如果我们打印 child1 对象,我们会发现 Child.prototype 和 child1 都有一个属性为 colors,属性值为[‘red’, ‘blue’, ‘green’]。

那么我们该如何精益求精,避免这一次重复调用呢?

如果我们不使用 Child.prototype = new Parent() ,而是间接的让 Child.prototype 访问到 Parent.prototype 呢?

看看如何实现:

js 复制代码
function Parent(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.getName = function() {
  console.log(this.name);
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

// 关键的三步
var F = function() {};

F.prototype = Parent.prototype;

Child.prototype = new F();

var child1 = new Child('kevin', '18');

console.log(child1);

最后我们封装一下这个继承方法:

js 复制代码
function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

function prototype(child, parent) {
  var prototype = object(parent.prototype);
  prototype.constructor = child;
  child.prototype = prototype;
}

// 当我们使用的时候:
prototype(Child, Parent);

引用《JavaScript 高级程序设计》中对寄生组合式继承的夸赞就是:

这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

浮点数精度

0.1 + 0.2 是否等于 0.3 作为一道经典的面试题,已经广外熟知,说起原因,大家能回答出这是浮点数精度问题导致,也能辩证的看待这并非是 ECMAScript 这门语言的问题,今天就是具体看一下背后的原因。

数字类型

ECMAScript 中的 Number 类型使用 IEEE754 标准来表示整数和浮点数值。所谓 IEEE754 标准,全称 IEEE 二进制浮点数算术标准,这个标准定义了表示浮点数的格式等内容。

在 IEEE754 中,规定了四种表示浮点数值的方式:单精确度(32 位)、双精确度(64 位)、延伸单精确度、与延伸双精确度。像 ECMAScript 采用的就是双精确度,也就是说,会用 64 位字节来储存一个浮点数。

浮点数转二进制

我们来看下 1020 用十进制的表示:

1020 = 1 _ 10^3 + 0 _ 10^2 + 2 _ 10^1 + 0 _ 10^0

所以 1020 用十进制表示就是 1020……(哈哈)

如果 1020 用二进制来表示呢?

1020 = 1 _ 2^9 + 1 _ 2^8 + 1 _ 2^7 + 1 _ 2^6 + 1 _ 2^5 + 1 _ 2^4 + 1 _ 2^3 + 1 _ 2^2 + 0 _ 2^1 + 0 _ 2^0

所以 1020 的二进制为 1111111100

那如果是 0.75 用二进制表示呢?同理应该是:

0.75 = a _ 2^-1 + b _ 2^-2 + c _ 2^-3 + d _ 2^-4 + …

因为使用的是二进制,这里的 abcd……的值的要么是 0 要么是 1。

那怎么算出 abcd…… 的值呢,我们可以两边不停的乘以 2 算出来,解法如下:

0.75 = a _ 2^-1 + b _ 2^-2 + c _ 2^-3 + d _ 2^-4…

两边同时乘以 2

1 + 0.5 = a _ 2^0 + b _ 2^-1 + c _ 2^-2 + d _ 2^-3… (所以 a = 1)

剩下的:

0.5 = b _ 2^-1 + c _ 2^-2 + d * 2^-3…

再同时乘以 2

1 + 0 = b _ 2^0 + c _ 2^-1 + d * 2^-2… (所以 b = 1)

所以 0.75 用二进制表示就是 0.ab,也就是 0.11

然而不是所有的数都像 0.75 这么好算,我们来算下 0.1:

复制代码
0.1 = a * 2^-1 + b * 2^-2 + c * 2^-3 + d * 2^-4 + ...

0 + 0.2 = a * 2^0 + b * 2^-1 + c * 2^-2 + ...   (a = 0)
0 + 0.4 = b * 2^0 + c * 2^-1 + d * 2^-2 + ...   (b = 0)
0 + 0.8 = c * 2^0 + d * 2^-1 + e * 2^-2 + ...   (c = 0)
1 + 0.6 = d * 2^0 + e * 2^-1 + f * 2^-2 + ...   (d = 1)
1 + 0.2 = e * 2^0 + f * 2^-1 + g * 2^-2 + ...   (e = 1)
0 + 0.4 = f * 2^0 + g * 2^-1 + h * 2^-2 + ...   (f = 0)
0 + 0.8 = g * 2^0 + h * 2^-1 + i * 2^-2 + ...   (g = 0)
1 + 0.6 = h * 2^0 + i * 2^-1 + j * 2^-2 + ...   (h = 1)
....

然后你就会发现,这个计算在不停的循环,所以 0.1 用二进制表示就是 0.00011001100110011……

浮点数的存储

虽然 0.1 转成二进制时是一个无限循环的数,但计算机总要储存吧,我们知道 ECMAScript 使用 64 位字节来储存一个浮点数,那具体是怎么储存的呢?这就要说回 IEEE754 这个标准了,毕竟是这个标准规定了存储的方式。

这个标准认为,一个浮点数 (Value) 可以这样表示:

Value = sign _ exponent _ fraction

看起来很抽象的样子,简单理解就是科学计数法……

比如 -1020,用科学计数法表示就是:

-1 _ 10^3 _ 1.02

sign 就是 -1,exponent 就是 10^3,fraction 就是 1.02

对于二进制也是一样,以 0.1 的二进制 0.00011001100110011…… 这个数来说:

可以表示为:

1 _ 2^-4 _ 1.1001100110011……

其中 sign 就是 1,exponent 就是 2^-4,fraction 就是 1.1001100110011……

而当只做二进制科学计数法的表示时,这个 Value 的表示可以再具体一点变成:

V = (-1)^S _ (1 + Fraction) _ 2^E

(如果所有的浮点数都可以这样表示,那么我们存储的时候就把这其中会变化的一些值存储起来就好了)

我们来一点点看:

(-1)^S 表示符号位,当 S = 0,V 为正数;当 S = 1,V 为负数。

再看 (1 + Fraction),这是因为所有的浮点数都可以表示为 1.xxxx * 2^xxx 的形式,前面的一定是 1.xxx,那干脆我们就不存储这个 1 了,直接存后面的 xxxxx 好了,这也就是 Fraction 的部分。

最后再看 2^E

如果是 1020.75,对应二进制数就是 1111111100.11,对应二进制科学计数法就是 1 _ 1.11111110011 _ 2^9,E 的值就是 9,而如果是 0.1 ,对应二进制是 1 _ 1.1001100110011…… _ 2^-4, E 的值就是 -4,也就是说,E 既可能是负数,又可能是正数,那问题就来了,那我们该怎么储存这个 E 呢?

我们这样解决,假如我们用 8 位字节来存储 E 这个数,如果只有正数的话,储存的值的范围是 0 ~ 254,而如果要储存正负数的话,值的范围就是 -127~127,我们在存储的时候,把要存储的数字加上 127,这样当我们存 -127 的时候,我们存 0,当存 127 的时候,存 254,这样就解决了存负数的问题。对应的,当取值的时候,我们再减去 127。

所以呢,真到实际存储的时候,我们并不会直接存储 E,而是会存储 E + bias,当用 8 个字节的时候,这个 bias 就是 127。

所以,如果要存储一个浮点数,我们存 S 和 Fraction 和 E + bias 这三个值就好了,那具体要分配多少个字节位来存储这些数呢?IEEE754 给出了标准:

2020 06 12 14 14 01

在这个标准下:

我们会用 1 位存储 S,0 表示正数,1 表示负数。

用 11 位存储 E + bias,对于 11 位来说,bias 的值是 2^(11-1) - 1,也就是 1023。

用 52 位存储 Fraction。

举个例子,就拿 0.1 来看,对应二进制是 1 _ 1.1001100110011…… _ 2^-4, Sign 是 0,E + bias 是 -4 + 1023 = 1019,1019 用二进制表示是 1111111011,Fraction 是 1001100110011……

对应 64 个字节位的完整表示就是:

0 01111111011 1001100110011001100110011001100110011001100110011010

同理, 0.2 表示的完整表示是:

0 01111111100 1001100110011001100110011001100110011001100110011010

所以当 0.1 存下来的时候,就已经发生了精度丢失,当我们用浮点数进行运算的时候,使用的其实是精度丢失后的数。

浮点数的运算

关于浮点数的运算,一般由以下五个步骤完成:对阶、尾数运算、规格化、舍入处理、溢出判断。我们来简单看一下 0.1 和 0.2 的计算。

首先是对阶,所谓对阶,就是把阶码调整为相同,比如 0.1 是 1.1001100110011…… _ 2^-4,阶码是 -4,而 0.2 就是 1.10011001100110…_ 2^-3,阶码是 -3,两个阶码不同,所以先调整为相同的阶码再进行计算,调整原则是小阶对大阶,也就是 0.1 的 -4 调整为 -3,对应变成 0.11001100110011…… * 2^-3

接下来是尾数计算:

复制代码
  0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
————————————————————————————————————————————————————————
 10.0110011001100110011001100110011001100110011001100111

我们得到结果为 10.0110011001100110011001100110011001100110011001100111 * 2^-3

将这个结果处理一下,即结果规格化,变成 1.0011001100110011001100110011001100110011001100110011(1) * 2^-2

括号里的 1 意思是说计算后这个 1 超出了范围,所以要被舍弃了。

再然后是舍入,四舍五入对应到二进制中,就是 0 舍 1 入,因为我们要把括号里的 1 丢了,所以这里会进一,结果变成

1.0011001100110011001100110011001100110011001100110100 * 2^-2

本来还有一个溢出判断,因为这里不涉及,就不讲了。

所以最终的结果存成 64 位就是

0 01111111101 0011001100110011001100110011001100110011001100110100

将它转换为 10 进制数就得到 0.30000000000000004440892098500626

因为两次存储时的精度丢失加上一次运算时的精度丢失,最终导致了 0.1 + 0.2 !== 0.3

其他

js 复制代码
// 十进制转二进制
parseFloat(0.1).toString(2);
=> "0.0001100110011001100110011001100110011001100110011001101"

// 二进制转十进制
parseInt(1100100, 2)
=> 100

// 以指定的精度返回该数值对象的字符串表示
(0.1 + 0.2).toPrecision(21)
=> "0.300000000000000044409"
(0.3).toPrecision(21)
=> "0.299999999999999988898"

评论

阅读上一篇

javascript专题笔记
2020-06-12 16:46:23

阅读下一篇

谨慎处理 Service Worker 的更新
2020-06-04 01:37:24
目录
0%