Javascript中几个高级语言特性

2018-06-09 16:08 更新

感谢Node.js开发指南,参考了它的附录部分内容。

作用域

Javascript中的作用域是通过函数来确定的,这一点与CJava等静态语言有一些不一样的地方。

最简单的例子

if (true) {
    var a =  'Value';
}
console.log(a); // Value

上面的代码片段将会输出Value。(在浏览器环境中)

更加common的例子

再来一个更加common的例子,

var a1 = 'Valve';
var foo1 = function() {
    console.log(a1);
};
foo1(); // Value
var foo2 = function() {
    var a1 = 'DOTA2';
    console.log(a1);
}
foo2(); // DOTA2

显然,foo1的结果是Value,foo2的结果是DOTA2,这应该很容易理解。

有点迷惑的例子

接下来这个例子将会让人感到迷惑,

var a1 = 'mercurial';
var foo = function() {
    console.log(a1);
    var a1 = 'git';
}
foo(); // undefined

此时,结果将会是undefined

因为在函数foo内部的a1将会覆盖函数外部的变量a1,js搜索作用域是按照从内到外的,而且当执行到console.log时,函数作用域内部的a1还尚未被初始化,所以会输出undefined。

其实这里还涉及到一个变量悬置的概念,即在Javascript的函数中,无论在何处声明或者初始化的变量都等效于函数的起始位置声明,在实际位置赋值。如下,

var foo = function() {
    // do something
    var a = 'ok';
    console.log(a);
    // do something
}

上面这段代码等效于,

var foo = function() {
    var a; // 注意看这里!
    // do something
    a = 'ok';
    console.log(a);
    // do something
}

最后还有一点需要说明的就是,未定义变量定义但未被初始化的变量,虽然他们的值输出都是undefined,但是在js内部的实现上还是有区别的。未定义的变量存在于js的局部语义表上,但是未被分配内存,而定义却未初始化的变量时实际分配了内存的。

嵌套作用域

接下来这个例子将会演示函数作用域的嵌套,

var foo = function() {
    var a1 = 'foo';
    (function() {
        var a1 = 'foo1';
        (function() {
            console.log(a1);
        })();
    })();
};
foo(); // foo1

输出结果是foo1。这里我在最内层的console.log中打印a1,此时,因为最内层的作用域中没有a1的相关定义,所以会往上层作用域搜索,得到a1=’foo1’。这里实际上有一个嵌套的作用域关系。

静态作用域

这里还有一点需要注意,就是函数作用的嵌套关系是在定义时就会确定的,而非调用的时候。也即js的作用域是静态作用域,好像又叫词法作用域,因为在代码做语法分析时就确定下来了。看下面的这个例子,

var a1 = 'global';
var foo1 = function() {
    console.log(a1);
};
foo1(); // global
var foo2 = function() {
    var a1 = 'locale';
    foo1();
};
foo2(); // global

示例的输出结果都将会是globalfoo1()的执行结果为global不需要太多的解释,很容易明白。

因为foo2在执行时,调用foo1foo1方法会从他自己的作用域开始搜索变量a1,最终在其父级作用域中找到a1,即a1 = 'global'。由此可以看出,foo2内部的foo1在执行时并没有去拿foo2作用域中的变量a1

以说作用域的嵌套关系并不是在执行时确定的,而是在定义时就确定好了的!

全局作用域

最后提一下全局作用域。通过字面的意思就能知道,全局作用域中的变量也好,属性也好,在任何函数中都能直接访问。

其中有一点需要注意,在任何地方没有通过var关键字声明的变量都是定义在全局变量中。其实,在模块化编程中,应该尽量避免使用全局变量,声明变量时,无论如何都应该避免使用var关键字。

闭包

闭包是函数式编程语言的一大语言特性。w3c上关于闭包的严格定义如下:由函数(环境)及其封闭的自由变量组成的集合体。这句话比较晦涩难懂,反正刚开始我是没看懂。下面通过一些例子来说明。

闭包解释

var closure = function() {
    var count = 0;
    return function() {
        count ++;
        return count;
    };
};
var counter = closure();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

最后的结果是1,2,3。

这个demo中,closure是一个函数(其实他相当于一个类的构造函数),并且返回一个函数(这个被返回的函数加上其定义环境通俗上被称为闭包)。
在返回的函数中,引用了外部的count变量。在var counter = closure();这句代码之后,counter实际上就是一个函数,这样每次在counter()时,先将count自增然后打印出来。
这里counter的函数内部并没有关于count的定义,所以在执行时会往上层作用域搜索,而他的上层作用域是closure函数,而不是counter()执行时所在的上层作用域。

为什么它的上层作用域是closure函数呢?因为,

  • 第一,这是在定义的时候就已经确定好的函数作用域嵌套关系,
  • 更重要的是第二点,闭包的返回不但有函数而且还包含定义函数的上下文环境。这里上下文环境就是closure函数的内部作用域,所以能够拿到closure函数中的count变量。

从这里可以看出,闭包会造成对原作用域和其上层作用域的持续引用。在这里,count变量持续被引用,其所占用的内存就不会被释放掉。

在看下面的这个例子,

var closure = function() {
    var count = 0;
    return function() {
        count ++;
        return count;
    };
};
var counter1 = closure();
var counter2 = closure();
console.log(counter1()); // 1
console.log(counter1()); // 2
console.log(counter2()); // 1
console.log(counter2()); // 2
console.log(counter1()); // 3

从结果可以看出,生成的闭包实例是各自独立的,他们内部引用的count变量分别属于各自不同的运行环境。
我们可以这样理解,在闭包生成时,将原上下文环境做了一份拷贝副本,这样不同的闭包实例就有自己独立的运行环境了。

闭包的应用场景

闭包目前来说有两大用处,

  • 第一是嵌套的回调函数
  • 第二是隐藏对象的部分细节
$('#id0').animate({
    left: '+50px'
}, 1000, function() {
    $('#id1').animate({
        left: '+50px'
    }, 1000, function() {
        $('#id2').animate({
            left: '+50px'
        }, 1000, function() {
            alert('done');
        });
    });
});

Javascript的对象没有私有成员的概念。一般的编码规范中会要求类似_privateProp的形式来定义私有属性。但是这是一个非正式的约定,而且_privateProp仍然能够被访问到。

我们可以通过闭包来实现私有成员,如下,

var student = function(yourName, yourAge) {
    var name, age;
    name = yourName || '';
    age = yourAge || 0;
    return {
        getName: function() {
            return name;
        },
        getAge: function() {
            return age;
        },
        setName: function(yourName) {
            name = yourName;
        },
        setAge: function(yourAge) {
            age = yourAge;
        }
    };
}
var mamamiya = student('mamamiya', 23);
mamamiya.getName();
mamamiya.getAge();

这里我封装了一个student类,并设置了两个属性nameage。这两个属性除了通过student对象的访问器方法访问之外,绝无其他的方法能够访问到。这里就实现了对部分属性的隐藏。

对象

Javascript的对象是基于原型的,和其他的一些面向对象语言有一些区别。

创建和访问

我们可以通过如下的这种形式来创建一个js对象。

var foo = {
    'a': 'baz',
    'b': 'foz',
    'c': function() {
        return 'hello js';
    }
};

我们还可以通过构造函数来创建对象。

function user(name, uri) {
    this.name = name;
    this.uri = uri;
    this.show = function() {
        console.log(this.name);
    }
};
var mamamiya = new user('mamamiya', 'http://blog.gejiawen.com');
mamamiya.show();

Javascript中上下文对象就是this,他表示被调用函数所处的环境。他的作用就是在一个函数内部引用调用它自己。

在Javascript中,任何函数都是被某个对象调用。

applycall

在Javascript中applycall是两个神奇的方法,他们的作用是以不同的上下文环境来调用函数。通俗点就是说,一个对象可以调用另一个对象的方法

看下面的例子,

var user = {
    name: 'mamamiya',
    show: function(words) {
        console.log(this.name + ' says ' + words);
    }
};
var foo = {
    name: 'baz'
};
user.show.call(foo, 'hello'); // baz says hello

这段代码的结果是baz says hello。这里通过call方法改变了user.show方法的上下文环境,user.show方法在执行时,内部的this指向的是foo对象。

bind方法

可以使用bind方法永久的改变函数的上下文。bind将会返回一个函数引用。

看下面的这个例子,

var user = {
    name: 'mamamiya',
    func: function() {
        console.log(this.name);
    }
};
var foo = {
    name: 'baz'
};
foo.func = user.func;
foo.func(); //baz
foo.func1 = user.func.bind(user);
foo.func1(); //mamamiya
func = user.func.bind(foo);
func(); //baz
func2 = func;
func2(); //baz

其实,bind还可以在绑定上下文时附带一些参数。

不过有时候,bind会有一些让人迷惑的地方,看下面这个例子,

var user = {
    name: 'mamamiya',
    func: function() {
        console.log(this.name);
    }
};
var foo = {
    name: 'baz'
};
func  = user.func.bind(foo);
func(); //baz
func2 = func.bind(user);
func2(); //baz

这里为什么func2函数的输出结果仍然是baz呢?

也就是说,我企图将func的上下文环境还原到user上为什么没有起作用?

我们这样来看,

func = user.func.bind(foo) ≈ function() {
    return user.func.call(foo);
};
func2 = func.bind(user) = function() {
    return func.call(user);
};

ok,现在可以看出来,func2中实际上是以userthis指针调用了func,但是在func中并没有使用this

prototype

通过构造函数和原型都能生成对象,但是两者之间有一些区别。看下面的这个列子,

function Class() {
    var a = 'hello';
    this.prop1 = 'git';
    this.func1 = function() {
        a = '';
    };
}
Class.prototype.prop2 = 'Mercurial';
Class.prototype.func2 = function() {
    console.log(this.prop2);
};
var class1 = new Class();
var class2 = new Class();
console.log(class1.func1 === class2.func1); //false
console.log(class1.func2 === class2.func2); //true

所以说,挂在prototype上的属性,会被不同的实例会共享。通过构造函数创建出来的属性,每一个实例都有一份独立的副本。

原型及基于原型的面向对象

那么,什么叫原型链?

JavaScript中有两个特殊的对象:ObjectFunction,它们都是构造函数,用于生成对象。
Object.prototype是所有对象的祖先,Function.prototype是所有函数的原型,包括构造函数。

我把JavaScript中的对象分为三类,

  • 一类是用户创建的对象,即一般意义上用new语句显式构造的对象
  • 一类是构造函数对象,即普通的构造函数,通过new调用生成普通对象的函数
  • 一类是原型对象,即构造函数prototype属性指向的对象

这三类对象中每一种都有一个__proto__属性,它指向该对象的原型。任何对象沿着它开始遍历都可以追溯到Object.prototype

构造函数对象有prototype属性,指向一个原型对象,通过该构造函数创建对象时,被创建对象的__proto__属性将会指向构造函数的prototype属性。原型对象有constructor属性,指向它对应的构造函数。

看下面的这个例子,帮助理解,

function foo() { }
Object.prototype.name = 'My Object';
foo.prototype.name = 'baz';
var obj = new Object();
var foo = new foo();
console.log(obj.name); // My Object
console.log(foo.name); // baz
console.log(foo.__proto__.name); // baz
console.log(foo.__proto__.__proto__.name); // My Object
console.log(foo.__proto__.constructor.prototype.name); // baz

在Javascript中,继承是依靠一套叫做原型链的机制实现的。
说的通俗一点就是,在继承的时候,将父类的实例对象直接赋值给子类的prototype对象,这样子类就拥有了父类的全部属性。子类还可以在自己的prototype对象上增加自己的特殊属性。

看下面的例子,

function ClassA() { }
ClassA.prototype.color = "blue";
ClassA.prototype.sayColor = function () {
    alert(this.color);
};
function ClassB() { }
ClassB.prototype = new ClassA();

对象的复制

Javascript中所有的对象类型的变量都是指向对象的引用。所以在赋值和传递的实际上都是对象的引用。

在Javascript中,对象的复制分为浅拷贝深拷贝

下面的示例是浅拷贝,

Object.prototype.makeCopy = funciton() {
    var newObj = {};
    for (var i in this) {
        newObj[i] = this[i];
    }
    return newObj;
};
var obj = {
    name: 'mamamiya',
    likes: ['js']
};
var newObj = obj.makeCopy();
obj.likes.push('python');
console.log(obj.likes); // ['js', 'python']
console.log(newObj.likes); // ['js', 'python']

从上面的代码可以看出,浅拷贝只是复制了一些基本属性,但是对象类型的属性是被共享的。obj.likesnewObj.likes都指向同一个数组。

想要做深拷贝,并不是一件容易的事情,因为除了基本数据类型,还有多种不同的对象,对象内部还有复杂的结构,因此需要用递归的方式来实现。

看下面的例子,

Object.prototype.makeDeepCopy = function() {
    var newObj = {};
    for (var i in this) {
        if (typeof(this[i]) === 'object' || typeof(this[i]) === 'function') {
            newObj[i] = this[i].makeDeepCopy();
        } else {
            newObj[i] = this[i];
        }
    }
    return newObj;
};
Array.prototype.makeDeepCopy = function() {
    var newArray = [];
    for (var i = 0; i < this.length; i++) {
        if (typeof(this[i]) === 'object' || typeof(this[i]) === 'function') {
            newArray[i] = this[i].makeDeepCopy();
        } else {
            newArray[i] = this[i];
        }
    }
    return newArray;
};
Function.prototype.makeDeepCopy = function() {
    var self = this;
    var newFunc = function() {
        return self.apply(this, arguments);
    }
    for (var i in this) {
        newFunc[i] = this[i];
    }
    return newFunc;
};
var obj = {
    name: 'mamamiya',
    likes: ['js'],
    show: function() {
        console.log(this.name);
    }
};
var newObj = obj.makeDeepCopy();
newObj.likes.push('python');
console.log(obj.likes); // ['js']
console.log(newObj.likes); // ['js', 'python']
console.log(newObj.show == obj.show); // false

上面的示例代码中很好的实现了对象,函数,数组在做深拷贝的逻辑。在一般情况下都是比较好用的。但是有一种情况下,这种方法却无能为力。如下:

var obj1 = {
    ref: null
};
var obj2 = {
    ref: obj1
};
obj1.ref = obj2;

上面这段代码块的逻辑很简单,就是两个相互引用的对象。

当我们试图使用深拷贝来复制obj1obj2中的任何一个时,问题就出现了。因为深拷贝的做法是遇到对象就进行递归复制,那么结果只能无限循环下去。

对于这种情况,简单的递归已经无法解决,必须设计一套图论算法,分析对象之间的依赖关系,建立一个拓扑结构图,然后分别依次复制每个顶点,并重新构建它们之间的依赖关系。这已经超出了这里的讨论范围,而且在实际的工程操作中 几乎不会遇到这种需求,所以我们就不继续讨论了。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号