Javascript核心基础浅析

对象

ECMAScript做为一个高度抽象的面向对象语言,是通过对象来交互的。

一个对象就是一个属性集合,并拥有一个独立的prototype(原型)对象。这个prototype可以是一个对象或者null。

一个对象的prototype是以内部的[[Prototype]]属性来引用的。但是,在示意图里边我们将会使用__<internal-property>__ 下划线标记来替代两个括号,对于prototype对象来说是:__proto__

对于以下代码:

var foo = {
    x: 10,
    y: 20
};

我们拥有一个这样的结构,两个明显的自身属性和一个隐含的__proto__属性,这个属性是对foo原型对象的引用:

这里写图片描述

这些prototype有什么用?让我们以“原型链(prototype chain)”的概念来回答这个问题。

原型链

原型对象也是简单的对象并且可以拥有它们自己的原型。如果一个原型对象的原型是一个非null的引用,那么以此类推,这就叫作“原型链”。

原型链是一个用来实现继承和共享属性的有限对象链。

考虑这么一个情况,我们拥有两个对象,它们之间只有一小部分不同,其他部分都相同。显然,对于一个设计良好的系统,我们将会重用相似的功能/代码,而不是在每个单独的对象中重复它。在基于类的系统中,这个代码重用风格叫作”类继承”,你把相似的功能放入类A中,然后类 B和类 C继承类 A,并且拥有它们自己的一些小的额外变动。

ECMAScript中没有类的概念。但是,代码重用的风格并没有太多不同并且通过“原型链”来实现。这种继承方式叫作“委托继承”(或者叫作“原型继承“)。

跟例子中的类ABC相似,在ECMAScript中你创建对象:abc。于是,对象a中存储对象bc中通用的部分。然后bc只存储它们自身的额外属性或者方法。

var a = {
    x: 10,
    calculate: function(z){
        return this.x + this.y + z
    }
};

var b = {
    y: 20,
    __proto__: a
};

var c = {
    y: 30,
    __proto__: a
};

// call the inherited method
console.log(b.calculate(30)); // 60
console.log(c.calculate(40)); // 80

我们看到bc访问到了在对象a中定义的calculate方法。这是通过原型链实现的。

规则很简单:如果一个属性或者一个方法在对象自身中无法找到(也就是对象自身没有一个那样的属性),然后它会尝试在原型链中寻找这个属性/方法。如果这个属性在原型中没有查找到,那么将会查找这个原型的原型,以此类推,遍历整个原型链。第一个被查找到的同名属性/方法会被使用。因此,一个被查找到的属性叫作”继承属性“。如果在遍历了整个原型链之后还是没有查找到这个属性的话,返回undefined值。

注意,继承方法中所使用的this的值被设置为”原始对象“,而并不是在其中查找到这个方法的(原型)对象。也就是,在上面的例子中this.y取的是bc中的值,而不是a中的值。但是,this.x是取的是a中的值,并且又一次通过”原型链“机制完成。

如果没有明确为一个对象指定原型,那么它将会使用__proto__的默认值:Object.prototypeObject.prototype对象自身也有一个__proto__属性,这是原型链的终点,并且值为null

下一张图展示了对象abc之间的继承层级:

这里写图片描述

注意: ES5标准化了一个实现原型继承的可选方法,即使用Object.create函数:

var b = Object.create(a,{
    y: {
        value: 20
    }
});

var c = Object.create(a,{
    y: {
        value: 30
    }
});

通常情况下需要对象拥有”相同或者相似的状态结构“(也就是相同的属性集合),赋以不同的状态值。在这个情况下我们可能需要使用”构造函数(constructor function)“,其以”指定的模式“来创造对象。

构造函数

除了以指定模式创建对象之外,构造函数,也做了另一个有用的事情,它自动地为新创建的对象设置一个原型对象。这个原型对象存储在ConstructorFunction.prototype属性中。

换句话说,我们可以使用构造函数来重写上一个拥有对象b和对象c的例子。因此,对象a(一个原型对象)的角色由Foo.prototype来扮演:

// a constructor function
function Foo(y){
    // creation own 'y' property
    this.y = y;
}

// inherited property 'x'
Foo.prototype.x = 10;

// and inherited method 'calculate'
Foo.prototype.calculate = function(z){
    return this.x + this.y + z;
}

// now create our 'b' and 'c'
// objects using 'pattern' Foo
var b = new Foo(20);
var c = new Foo(30);

// call the inherited method
console.log(b.calculate(30)); // 60
console.log(c.calculate(40)); // 80

console.log(
b.__proto__ === Foo.prototype, // true
b.__proto__ === Foo.prototype, // true
b.constructor === Foo, // true
c.constructor === Foo, // true
Foo.prototype.constructor === Foo, // true
b.calculate === b.__proto__.calculate, // true
b.__proto__.calculate === Foo.prototype.calculate,// true 
b.calculate === Foo.prototype.calculate // true
);

这个代码可以表示为如下关系:

这里写图片描述

这张图又一次说明了每个对象都有一个原型。构造函数 Foo 也有自己的__proto__,值为Function.prototypeFunction.prototype 也通过其__proto__ 属性关联到 Object.prototype。因此,重申一下,Foo.prototype 就是 Foo 的一个明确的属性,指向对象 bc 的原型。

正式来说,如果思考一下分类的概念,那么构造函数和原型对象合在一起就可以称作”类“。实际上,举个例子,Python 的第一级(first-class)动态类(dynamic classes) 显然是以同样的属性/方法处理方案来实现的。从这个角度来说,Python中的类就是ECMAECMAScript 使用的委托继承的一个语法糖。

注意:在 ES6中类的概念被标准化了,并且实际上以一种构建在构造函数上面的语法糖来实现,就像上面的描述一样。从这个角度来看原型链成为了类继承的一种具体实现方式:

// ES6
class Foo{
    constructor(name){
        this._name = name;
    }

    getName(){
        return this._name;
    }
}

class Bar extends Foo{
    getName(){
        return super.getName() + "Doe";
    }
}

var bar = new Bar("John ");
console.log(bar.getName()); // John Doe

有关这个主题的完整、详细的解释可以在ES3系列的第七章找到。分为两个部分:7.1 面向对象基本理论,在那里你将会找到对各种面向对象范例、风格的描述以及它们和ECMAScript之间的对比,然后在7.2 面向对象ECMAScript实现,是对ECMAScript中面向对象的介绍。

现在,在我们知道了对象的基础之后,让我们看看”运行时程序的执行(runtime program execution)”在ECMAScript中是如何实现的。这叫作”执行上下文栈(execution context stack)”,其中的每个元素也可以抽象成为一个对象。是的,ECMAScript几乎在任何地方都和对象的概念打交道);

执行上下文堆栈

这里有三种类型的ECMAScript代码:全局代码函数代码eval 代码,每个代码时在其”执行上下文(execution context)“中被求值的。这里只有一个全局上下文,可能有多个函数执行上下文以及 eval 执行上下文。对一个函数的每次调用,会进入到函数执行上下文中,并对函数代码类型进行求值。每次对 eval 函数进行调用,会进入到 eval 执行上下文并对其代码进行求值。

注意:一个函数可能会创建无数的上下文,因为对函数的每次调用(即使这个函数递归的调用自己)都会生成一个具有新状态的上下文:

function foo(bar){
    // call the same function,
    // generate three different
    // contexts in each call, with
    // different context state (e.g.value of the "bar" argument)

    foo(10);
    foo(20);
    foo(30);
}

一个执行上下文可能会触发另一个上下文,比如:一个函数调用另一个函数(或者在全局上下文中调用一个全局函数),等等。从逻辑上来说,这是以栈的形式实现的,它叫做”执行上下文栈“。

一个触发其他上下文的上下文叫做”caller“。被触发的上下文叫做”callee“。callee 在同一时间可能是一些其他callee的caller(比如,一个在全局上下文中被调用的函数,之后调用了一些内部函数)。

当一个 caller 触发(调用)了一个 callee,这个 caller 会暂缓自身的执行,然后把控制权传递给 callee。这个 callee 被 push 到栈中,并成为一个”运行中(活动的)“执行上下文。在 callee 的上下文结束后,它会把控制权返回给 caller,然后 caller 的上下文继续执行(它可能触发其他上下文)直到它结束,以此类推。callee 可能简单的返回或者由于异常而退出。一个抛出的但是没有被抛出的异常可能退出(从栈中pop)一个或者多个上下文。

换句话说,所有ECMAScript程序的运行时,可以用”执行上下文栈“来表示,栈顶是当前活跃(active)上下文:

这里写图片描述
当程序开始的时候它会进入”全局执行上下文“,此上下文位于”栈底“,并且是栈中的第一个元素。然后全局代码进行一些初始化,创建需要的对象和函数。在全局上下文的执行过程中,它的代码可能触发其他(已经创建完成的函数),这些函数将会进入它们自己的执行上下文,向栈中 push 新的元素,以此类推。当初始化完成之后,运行时系统就会等待一些事件(比如,用户鼠标点击),这些事件将会触发一些函数,从而进入新的执行上下文中。

在下个图中,拥有一些 函数上下文 EC1 和 全局上下文 Global EC ,当 EC1 进入和退出全局上下文的时候下面的栈将会发生变化:

这里写图片描述

这就是 ECMAScript 的运行时系统如何真正管理代码执行的。如需了解执行上下文的相关知识,请点击该链接。

像我们所说的,栈中的每个执行上下文都可以用一个对象来表示。让我们来看看它的结构以及一个上下文到底需要“什么状态(什么属性)”来执行它的代码。

执行上下文

一个执行上下文可以抽象的表示为一个简单的对象。每一个执行上下文拥有一些属性(可以叫作“上下文状态”)用来跟踪和它相关的代码的执行过程。在下图中展示了一个上下文的结构:

这里写图片描述

除了这三个必须的属性(一个变量对象variable object),一个 this 值以及一个作用域链(scope chain))之外,执行上下文可以拥有任何附加的状态,这取决于实现。

变量对象

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

注意,函数表达式(与函数声明相对)不包含在变量对象之中。

变量对象是一个抽象概念。对于不同的上下文类型,在物理上,是使用不同的对象。比如,在全局上下文中变量对象就是“全局对象本身”,这就是为什么我们可以通过全局对象的属性名来关联全局 变量。

让我们在全局执行上下文中考虑下面这个例子:

var foo = 10;

function bar(){}  // function declaration,FD
(function baz(){}); // function expression,FE

console.log(
    this.foo == foo, // true
    window.bar == bar // true
);

console.log(baz); // Uncaught ReferenceError: baz is not defined

之后,全局上下文的变量对象(variable object,简称 VO)将会拥有如下属性:

这里写图片描述

再看一遍,函数baz是一个函数表达式,没有被包含在变量对象之中。这就是为什么当我们想要在函数自身之外访问它的时候会出现ReferenceError

注意,与其他语言(比如 C/C++)相比,在 ECMAScript 中只有函数,可以创建一个新的作用域。在函数作用域中所定义的变量和内部函数在函数外边是不能直接访问的,而且并不会污染全局变量对象。

使用 eval 我们也会进入一个新的(eval 类型)执行上下文。无论如何,eval 使用全局的变量对象或者使用 caller(比如 eval 被调用时所在的函数)的变量对象。

那么函数和它的变量对象是怎样的?在函数上下文中,变量对象是以“活动对象(activation object)”来表示的。

活动对象

当一个函数被 caller 所触发(被调用),一个特殊的对象,叫做“活动对象(activation object)”将会被创建。这个对象中包含“形参”和那个特殊的 arguments 对象(是对形参的一个映射,但是值是通过索引来获取的)。活动对象之后会作为函数上下文的变量对象来使用。

换句话说,函数的变量对象也是一个同样简单的变量对象,但是除了变量和函数声明之外,它还存储了形参和arguments 对象,并叫做“活动对象”。

考虑如下例子:

function foo(x,y){
    var z = 30;

    function bar(){} // FD
    (function baz(){}); // FE
}

foo(10,20);

我们看下函数 foo 的上下文中的活动对象(activation object,简称 AO);

这里写图片描述

并且“函数表达式”baz 还是没有被包含在变量/活动对象中。

关于这个主题所有细节方面(像变量和函数声明的“提升问题(hoisting)”) 的完整描述可以在同名的章节 第二章 变量对象 中找到。

然后我们向下一个部分前进。众所周知,在ECMAScript中我们可以使用“内部函数”,然后在这些函数内部,我们可以引用父函数的变量或者全局上下文中的变量。当我们把变量对象命名为上下文的作用域对象,与上面讨论的原型链相似,这里有一个作用域链的东西。

作用域链

作用域链是一个对象列表,上下文代码中出现的标识符在这个列表中进行查找。

这个规则还是与原型链同样简单以及相似:如果一个变量在函数自身的作用(在自身的变量/活动对象)中没有找到,那么将会查找它父函数(外层函数)的变量对象,以此类推。

就上下文而言,标识符指的是:变量名称,函数声明,形参,等等。当一个函数在其代码中引用一个不是局部变量(或者局部函数或者一个参数)的标识符,那么这个标识符就叫作“自由变量”。搜索这些自由变量正好就要用到“作用域链”。

在通常情况下,作用域链是一个包含所有父(函数)变量对象加上(在作用域链头部的)函数 自身变量/活动对象的一个列表。但是,这作用域链也可以包含任何其他对象,比如,在上下文执行过程中动态加入到作用域链中的对象—–像with 对象或者特殊的 catch 从句对象。

当解析(查找)一个标识符的时候,会从作用域链的活动对象开始查找,然后(如果这个标识符在函数本身的活动对象中没有被查找到)向作用域链的上一层查找,重复这个过程,就和原型链一样。

var x = 10;

(function foo(){
    var y = 20;

    (function bar(){
        var z = 30;

        // 'x' and 'y' are 'free variables'
        // and are found in the next (after
        // bar's activation object) object
        // of the bar's scope chain
        console.log(x + y + z);
    })();
})();

我们可以假设通过隐式的 __parent__ 属性来和作用域链对象进行关联,这个属性指向作用域链中的下一个对象。这个方案可能在真实的 Rhino代码中经过了测试,并且这个技术很明确得被用于 ES5 的语法环境中(在那里被叫作outer连接)。作用域链的另一个表现方式可以是一个简单的数组。利用 __parent__ 概念,可以用下面的图来变现上面的例子(并且父变量对象存储在函数的[[Scope]]属性中):

这里写图片描述

在代码执行过程中,作用域链可以通过使用 with 语句和 catch 从句对象来增强。并且由于这些对象是简单的对象,它们可以拥有原型(和原型链)。这个事实导致作用域链查找变为两个维度:1)首先是作用域链连接,然后(2)在每个作用域链连接上深入作用域链连接的原型链(如果此连接拥有原型)。

对于这个例子:

Object.prototype.x = 10;

var w = 20;
var y = 30;

console.log(x); // 10

(function foo(){
    var w = 40;
    var x = 100;

    with({z : 50}){
        console.log(w, x ,y ,z); // 40 10 30 50
    }

    console.log(w ,x); // 40 100
    console.log(window.w); // 20
})();

我们可可以给出如下的结构:

这里写图片描述

闭包

看如下代码:

function f1(){
    var n = 999;

    function f2(){
        alert(n);
    }
    return f2;
}
var result = f1();
result(); // 999

上述的 f2 函数,就是闭包。

各种专业文献上的“闭包”定义非常抽象,很难看懂,我的理解是:闭包就是能够读取其他函数内部变量的函数。

由于在 JavaScript 语言中,只有函数内部的子函数才能读取局部变量,因此,可以把闭包简单理解成“定义在一个函数内部的函数”。所以,在本质上,闭包就是将函数内部和函数外部链接起来的一座桥梁。

用途:闭包可以用在许多地方。它的最大用处有两个,一个是前面提到的可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中。怎么来理解这句话呢?请看下面代码:

function f1(){
    var n = 999;
    nAdd = function(){
        n+1;
    }

    function f2(){
        console.log(n);
    }
    return f2;
    // f2();
}

var result = f1();
result(); // 999
nAdd();
result();

在这段代码中,result实际上就是闭包f2函数。它一共运行了两次,第一次的值是999,第二次的值是1000。这证明了,函数f1中的局部变量n一直保存在内存中,并没有在f1调用后被自动清除。

为什么会这样呢?原因就在于f1f2的父函数,而f2被赋给了一个全局变量,这导致f2始终在内存中,而f2的存在依赖于f1,因此f1也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收

这段代码中另一个值得注意的地方,就是”nAdd=function(){n+=1}“这一行,首先在nAdd前面没有使用var关键字,因此nAdd是一个全局变量,而不是局部变量。其次,nAdd的值是一个匿名函数(anonymous function),而这个匿名函数本身也是一个闭包,所以nAdd相当于是一个setter,可以在函数外部对函数内部的局部变量进行操作。

  • 使用闭包的注意点:
    1)由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。
    2)闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

this

this 是Javascript 的一个关键字。它代表函数运行时,自动生成的一个内部对象,只能在函数内部使用。比如:

function test(){
    this.x = 1;
}

随着函数使用场合的不同,this 的值会发生变化。但是有一个总的原则,那就是 this 指的是,调用函数的那个对象。

下面分四种,详细讨论 this 的用法。

情况一:纯粹的函数调用

这是函数的最通常用法,属于全局性调用,因此 this 就代表全局对象 Global。

请看下面这段代码,它的运行结果是:1。

function test(){
    this.x = 1;
    alert(this.x);
}
test(); // 1

为了证明 this 就是全局对象,我对代码做出了一些改变:

var x = 1;

function test(){
    alert(this.x);
}
test(); // 1

运行结果还是:1。再变一下:

var x = 1;

function test(){
    this.x = 0;
}
test();
alert(x); // 0

情况二:作为对象方法的调用

函数还可以作为某个对象的方法调用,这时 this 就指这个对象上级。

function test(){
    alert(this.x);
}

var o = {
    x: 1,
    m: test
};
o.m(); // 1

情况三:作为构造函数调用

所谓构造函数,就是通过这个函数生成一个新对象(object)。这时,this 就指向这个新对象。

var x = 2;

function test(){
    this.x = 1;
}

var o = new test();
alert(x); // 2

运行结果为:2,表明全局 x 的值根本没变。

情况四:apply 调用

apply()是函数对象的一个方法,它的作用是改变函数的调用对象,它的第一个参数就表示改变后的调用这个函数的对象。因此,this 指的就是这第一个参数。

var x = 0;

function test(){
    alert(this.x);
}

var o = {
    x: 1,
    m: test
};
o.m.apply(); // 0

apply()的参数为空时,默认调用全局对象。因此,这时的运行结果为:0,证明 this 指的是全局对象。

如果把最后一行代码修改为:

o.m.apply(o); // 1

运行结果就变成了1,证明了这时 this 代表的对象是 o

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值