JavaScript核心编程 - 原型链 作用域 与 执行上下文

原型

在JavaScript中,每个对象都有一个内部属性,称为__proto__(在ES6中,这个属性被Object.getPrototypeOf()和Object.setPrototypeOf()方法标准化),这个属性指向该对象的原型。原型本身也是一个对象,这意味着它也有自己的原型,以此类推,形成一个链式结构。

当你尝试访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript引擎会沿着这个链向上查找,直到找到该属性或者到达链的末端(即Object.prototype的原型是null)

原型链

原型链是对象之间的一系列原型链接。当你创建一个新对象时,你可以通过构造函数的prototype属性来指定新对象的原型。这样,新对象就可以继承原型对象的属性和方法。

例如,如果你有一个构造函数Person,并且你创建了一个新的Person实例p,那么p的原型就是Person.prototype。如果p没有某个属性或方法,JavaScript会查找Person.prototype是否有这个属性或方法,如果没有,再继续查找Person.prototype的原型,以此类推。

创建对象方式 - 构造函数创建对象

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

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

prototype

每个函数都有一个 prototype 属性,比如:

javascript 复制代码
function Person() {​
​
}​
// prototype是函数才会有的属性​
Person.prototype.name = 'Jerry';​
​
var person1 = new Person();​
var person2 = new Person();​
​
console.log(person1.name) // Jerry​
console.log(person2.name) // Jerry

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

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

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

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

这里用 Object.prototype 表示实例原型。​

那么该怎么表示实例与实例原型,也就是 person 和 Person.prototype 之间的关系呢?

proto属性

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

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

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

constructor

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

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

所以,这里可以得到:

javascript 复制代码
function Person() {​
​
}​
​
var person = new Person();​
​
console.log(person.__proto__ == Person.prototype) // true​
​
console.log(Person.prototype.constructor == Person) // true​
​
console.log(Object.getPrototypeOf(person) === Person.prototype) // true

实例与原型

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

举个例子:

javascript 复制代码
function Person() {​
​
}​
​
Person.prototype.name = 'Jerry';​
​
var person = new Person();​
​
person.name = 'Tom';​
console.log(person.name) // Jerry​
​
delete person.name;​
console.log(person.name) // Tom

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

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

原型的原型

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

原型链

那 Object.prototype 的原型呢?​

null,我们可以打印:

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

然而 null 究竟代表了什么呢?​

null 表示"没有对象",即该处不应该有值。​

所以 Object.prototype.proto 的值为 null 跟 Object.prototype 没有原型,其实表达了一个意思。​

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

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

其中,蓝色为原型链。

其他相关

构造函数

首先是 constructor 属性:

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

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

javascript 复制代码
person.constructor === Person.prototype.constructor

proto

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

继承

关于继承,前面提到"每一个对象都会从原型'继承'属性",实际上,继承是一个十分具有迷惑性的说法,引用《你不知道的JavaScript》中的话,就是:​

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

词法作用域和动态作用域

作用域

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

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

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

静态作用域和动态作用域

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

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

javascript 复制代码
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,看看打印的值是多少。

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

思考

看一个面试题:

javascript 复制代码
// case 1​
var scope = "global scope";​
function checkscope(){​
    var scope = "local scope";​
    function f(){​
        return scope;​
    }​
    return f();​
}​
checkscope();​
​
// case 2​
var scope = "global scope";​
function checkscope(){​
    var scope = "local scope";​
    function f(){​
        return scope;​
    }​
    return f;​
}​
checkscope()();

执行上下文

执行顺序

写过 JavaScript 的开发者都会有个直观的印象,那就是顺序执行:

javascript 复制代码
var foo = function () {​
​
    console.log('foo1');​
​
}​
​
foo();  // foo1​
​
var foo = function () {​
​
    console.log('foo2');​
​
}​
​
foo(); // foo2

那这段呢?

javascript 复制代码
function foo() {​
​
    console.log('foo1');​
​
}​
​
foo();  // foo2​
​
function foo() {​
​
    console.log('foo2');​
​
}​
​
foo(); // foo2

打印的结果却是两个 foo2。​

这是因为 JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个"准备工作",那这个"一段一段"中的"段"究竟是怎么划分的呢?​

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

javascript 复制代码
console.log(add2(1,1)); //输出2​
function add2(a,b){​
    return a+b;​
}​
console.log(add1(1,1));  //报错:add1 is not a function​
var add1 = function(a,b){​
    return a+b;​
}​
​
// 用函数语句创建的函数add2,函数名称和函数体均被提前,在声明它之前就使用它。​
// 但是使用var表达式定义函数add1,只有变量声明提前了,变量初始化代码仍然在原来的位置,没法提前执行。

可执行代码

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

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

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

执行上下文栈

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

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

javascript 复制代码
ECStack = [];

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

javascript 复制代码
ECStack = [​
    globalContext​
];
javascript 复制代码
function fun3() {​
    console.log('fun3')​
}​
​
function fun2() {​
    fun3();​
}​
​
function fun1() {​
    fun2();​
}​
​
fun1();

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

javascript 复制代码
// 伪代码​
​
// 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

回顾上文

javascript 复制代码
// case 1​
var scope = "global scope";​
function checkscope(){​
    var scope = "local scope";​
    function f(){​
        return scope;​
    }​
    return f();​
}​
checkscope();​
​
// case 2​
var scope = "global scope";​
function checkscope(){​
    var scope = "local scope";​
    function f(){​
        return scope;​
    }​
    return f;​
}​
checkscope()();

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

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

模拟第一段代码:

javascript 复制代码
ECStack.push(<checkscope> functionContext);​
ECStack.push(<f> functionContext);​
ECStack.pop();​
ECStack.pop();

模拟第二段:

javascript 复制代码
ECStack.push(<checkscope> functionContext);​
ECStack.pop();​
ECStack.push(<f> functionContext);​
ECStack.pop();

这就是上文说到的区别。

变量对象

基础

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

  • 对于每个执行上下文,都有三个重要属性:
  • 变量对象(Variable object,VO);
  • 作用域链(Scope chain);
  • this;

这里着重讲变量对象的内容

变量对象

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

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

全局上下文

  • 全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。
  • 在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。
  • 例如,当JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。

简单点说:​

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

javascript 复制代码
console.log(this);

2.全局对象是由 Object 构造函数实例化的一个对象。

javascript 复制代码
console.log(this instanceof Object);

3.预定义的属性是否可用​

javascript 复制代码
console.log(Math.random());​
console.log(this.Math.random());

4.作为全局变量的宿主

javascript 复制代码
var a = 1;​
console.log(this.a);

5.客户端 JavaScript 中,全局对象有 window 属性指向自身

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

函数上下文

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

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

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

执行过程

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

  • 进入执行上下文;
  • 代码执行;

进入执行上下文

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

变量对象会包括:​

  • 函数的所有形参 (如果是函数上下文)
    由名称和对应值组成的一个变量对象的属性被创建;
    没有实参,属性值设为 undefined;
  • 函数声明
    由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建;
    如果变量对象已经存在相同名称的属性,则完全替换这个属性;
  • 变量声明
    由名称和对应值(undefined)组成一个变量对象的属性被创建;
    如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性;

举个例子:

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

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

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

执行阶段

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

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

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

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

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

练习

javascript 复制代码
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 的值是:

javascript 复制代码
AO = {​
    arguments: {​
        length: 
        0​
    }​
}

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

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

example 2

javascript 复制代码
console.log(foo);​
​
function foo(){​
    console.log("foo");​
}​
​
var foo = 1;

作用域链

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

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

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

作用域链​

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

函数创建

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

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

举个例子:

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

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

javascript 复制代码
foo.[[scope]] = [​
  globalContext.VO​
];​
​
bar.[[scope]] = [​
    fooContext.AO,​
    globalContext.VO​
];​
​

函数激活

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

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

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

总结

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

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

执行过程如下:

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

javascript 复制代码
checkscope.[[scope]] = [​
    globalContext.VO​
];

2.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

javascript 复制代码
ECStack = [​
    checkscopeContext,​
    globalContext​
];

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

javascript 复制代码
checkscopeContext = {​
    Scope: checkscope.[[scope]],​
}

4.第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明

javascript 复制代码
checkscopeContext = {​
    AO: {​
        arguments: {​
            length: 0​
        },​
        scope2: undefined​
    },​
    Scope: checkscope.[[scope]],​
}

5.第三步:将活动对象压入 checkscope 作用域链顶端

javascript 复制代码
checkscopeContext = {​
    AO: {​
        arguments: {​
            length: 0​
        },​
        scope2: undefined​
    },​
    Scope: [AO, [[Scope]]]​
}

6.准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值

javascript 复制代码
checkscopeContext = {​
    AO: {​
        arguments: {​
            length: 0​
        },​
        scope2: 'local scope'​
    },​
    Scope: [AO, [[Scope]]]​
}

7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

javascript 复制代码
ECStack = [​
  globalContext​
];
相关推荐
老码沉思录5 分钟前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
我不当帕鲁谁当帕鲁9 分钟前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂14 分钟前
工程化实战内功修炼测试题
前端·javascript
爱吃生蚝的于勒14 分钟前
C语言内存函数
c语言·开发语言·数据结构·c++·学习·算法
小白学大数据2 小时前
Python爬虫开发中的分析与方案制定
开发语言·c++·爬虫·python
冰芒猓3 小时前
SpringMVC数据校验、数据格式化处理、国际化设置
开发语言·maven
失落的香蕉3 小时前
C语言串讲-2之指针和结构体
java·c语言·开发语言
红中马喽3 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
杜杜的man4 小时前
【go从零单排】Closing Channels通道关闭、Range over Channels
开发语言·后端·golang
java小吕布4 小时前
Java中Properties的使用详解
java·开发语言·后端