关于javaScript变量、作用域链以及this指针的详解

1)引子 undefined

先上代码:

var a = 1;

function hehe()

{

         window.alert(a);

         var a = 2;

         window.alert(a);

}

hehe();

第一个alert:

第二个alert:

原理我描述如下:

按照javascript作用域链的原理,当一个变量在当前作用域下找不到该变量的定义,会沿着作用域链往上找直到在全局作用域里查找。

按上面的代码所示,虽然函数内部重新定义了变量的值,但在定义之前使用了该变量;按照作用域链的原理会在全局作用域里找到变量定义,而实际情况却是变量未定义,这到底是怎么回事呢?

下面开始本文的主要内容,我会从基础知识一步步讲起。

2) Javascript的变量

变量的两种类型:基本类型和引用类型。基本类型是指:Undefined、Null、Boolean、Number和String,引用类型是指多个基本类型构成的对象。

下面我们来看看下面的代码:

var str = "sharpxiajun";

str.attr01 = "hello world";

console.log(str);//  运行结果:sharpxiajun

console.log(str.attr01);// 运行结果:undefined

运行之,发现基本类型无法添加属性,当然方法也同样不可以,例如下面的代码:

str.ftn = function(){

    console.log("str ftn");

}

str.ftn();

运行之,结果如下图所示:

当我们使用引用类型时候,结果就和上面完全不同了,大家请看下面的代码:

var obj1 = new Object();

obj1.name = "obj1 name";

console.log(obj1.name);// 运行结果:obj1 name

Javascript里的基本类型是存放在栈区(内存里的栈内存),存储结构如下图所示:

javascript里引用类型的存储需要内存的栈区和堆区(内存里的堆内存)共同完成,如下图所示:

场景一:如下代码所示

var qqq;

console.log(qqq);// 运行结果:undefined

上面的代码就是命名了变量但未初始化,此时在内存里只有栈区的变量标示符而没有变量值,更没有堆区存储的对象。

场景二:如下代码所示

var qqq;

console.log(qqq);// 运行结果:undefined

console.log(xxx);

提示变量未定义!在任何语言里变量未定义就使用都是违法的,但是我们javascript变量未定义也可以使用,怎么我的例子里却不能使用了?

那么我们看看下面的代码:

xxx = "outer xxx";

console.log(xxx);// 运行结果:outer xxx

function testFtn(){

     sss = "inner sss";

     console.log(sss);// 运行结果:outer sss

}

testFtn();

console.log(sss);//运行结果:outer sss

console.log(window.sss);//运行结果:outer sss

我们看看window对象的结构,如下图所示:

由这两个场景我们可以知道:

javascript变量如果没有被var定义也没有赋值操作才会报出“xxx is not defined”,后续的代码将不能正常运行;而只有赋值操作的变量,不管在哪个作用域里赋值,都是全局变量即window对象

下面我修改一下代码,如下所示:

//var a = 1;

function hehe()

{

     console.log(a);

     var a = 2;

     console.log(a);

}

hehe();

结果如下图所示:

我再改下代码:

//var a = 1;

function hehe()

{

    console.log(a);

    // var a = 2;

    console.log(a);

}

hehe();

运行之,结果如下所示:

对比二者代码,我们发现问题的关键是var a=2所引起的!

javascript变量是松散类型即定义时不需要指定变量类型,当变量值确定后才有类型,但没有使用var的变量必须有赋值。

javascript代码运行前还有一个预加载,构造运行环境例如全局环境、函数运行环境和作用域链。环境和作用域的构造就是指定好变量属于哪个作用域,变量的定义是在预加载时完成而非在运行时。预加载时会扫描所有代码但不运行,当扫描到被赋值操作的变量没有var定义,那么该变量就会被赋予全局变量即window对象。

重新分析引子里的代码:在函数的局部作用域内变量a被重新定义了,在预加载时a的作用域也就不再属于全局变量而是函数作用域;赋值操作是在运行期执行的,第一次使用a变量时,a变量在局部作用域内还没被赋值,只有栈区的标示符,结果就undefined了。

javascript两个特别的类型:undefined和null。变量的值为undefined,说明只有栈区的标示符;如果对变量进行赋值基本类型,那么栈区就有值了;如果赋值引用类型那么堆区会有一个对象,而栈区的值则是堆区对象的地址。变量的值是null,说明这个变量是个空对象,栈区的标示符和值都有值,堆区也有个空对象,这么说来null其实比undefined更耗内存了!

那么我们看看下面的代码:

var ooo = null;

console.log(ooo);// 运行结果:null

console.log(ooo == undefined);// 运行结果:true

console.log(ooo == null);// 运行结果:true

 console.log(ooo === undefined);// 运行结果:false

console.log(ooo === null);// 运行结果:true

运行之,null可以和undefined相等;但使用更加精确的三等号“===”,发现还是有点不同,其实javascript里null是undefined的父类,要让一个变量是null必须使用等号“=”进行赋值!

javascript开发规范要求变量定义时马上赋值,好处就是程序很难因为变量未定义报错从而终止程序的运行,而且变量定义最好放在变量作用域的最前端。

下面我们再看一段代码:

var str;

    if (undefined != str && null != str && "" != str){

        console.log("true");

    }else{

        console.log("false");

    }

    if (undefined != str && "" != str){

        console.log("true");

    }else{

        console.log("false");

    }

    if (null != str && "" != str){

        console.log("true");

    }else{

        console.log("false");

    }

    if (!!str){

        console.log("true");

    }else{

        console.log("false");

    }

    str = "";

    if (!!str){

        console.log("true");

    }else{

        console.log("false");

    }

运行之,结果都是打印出false。

使用双等号“==”时,undefined和null是一回事,所以第一个if语句的写法完全多余,而第二种和第三种写法是等价,究其本质前三种写法本质都是一致的;第四种写法更加完美,javascript里如果if语句的条件是undefined和null,if判断是false,所以判断代码是否为未定义和null时最好使用!运算符。

代码四里字符串被赋值空字符串时,if的判断也是false。javascript里有五种基本类型,undefined、null、boolean、Number和string,现在我们发现除了Number都可以使用!来判断if的ture和false,那么基本类型Number呢?

var num = 0;

if (!!num){

        console.log("true");

}else{

        console.log("false");

}

运行之,结果是false;如果我们把num改为负数或正数,那么运行之的结果就是true了。

所以变量初始化值如果基本类型是string就赋值空字符串,如果是number就赋值0,if语句就可以判断变量是否初始化过

但是当变量是对象时候,结果却不一样了,如下代码:

var obj = {};

if (!!obj){

        console.log("true");

}else{

        console.log("false");

}

运行之,代码是true。

所以引用类型变量赋值null,if语句就可以判断变量是否初始化过!

场景三:复制变量的值和函数传递参数

首先看看这个场景的代码:

var s1 = "sharpxiajun";

var s2 = s1;

console.log(s1);//// 运行结果:sharpxiajun

console.log(s2);//// 运行结果:sharpxiajun

s2 = "xtq";

console.log(s1);//// 运行结果:sharpxiajun

console.log(s2);//// 运行结果:xtq

上面是基本类型变量的复制,我们再看看下面的代码:

var obj1 = new Object();

obj1.name = "obj1 name";

console.log(obj1.name);// 运行结果:obj1 name

var obj2 = obj1;

console.log(obj2.name);// 运行结果:obj1 name

obj1.name = "sharpxiajun";

console.log(obj2.name);// 运行结果:sharpxiajun

当复制的是对象,obj1和obj2两个对象被关联起来了,obj1的属性改变时,obj2的属性也改变。

我们看看下面的代码:

function testFtn(sNm,pObj){

        console.log(sNm);// 运行结果:new Name

        console.log(pObj.oName);// 运行结果:new obj

        sNm = "change name";

        pObj.oName = "change obj";

}

var sNm = "new Name";

var pObj = {oName:"new obj"};

testFtn(sNm,pObj);

console.log(sNm);// 运行结果:new Name

console.log(pObj.oName);// 运行结果:change obj

这个结果和变量复制的结果是一致的。

函数传参的本质就是外部的变量复制到函数参数的变量里,是按值传递的

如下两张图所示:

在javascript里变量的存储有三个位置:

位置一:栈区变量标示符;

位置二:栈区变量值(栈区对象地址);

位置三:堆区对象。

javascript的变量复制(函数传参也是)本质是传栈区变量值。基本类型的值在栈区变量值里,复制时两个变量是独立的;复制引用类型时,栈区变量值是堆区对象的地址,因此其中一个改变,另一个也改变。

原理讲完了,下面我列举一个拔高的例子,代码如下:

var ftn1 = function(){

        console.log("test:ftn1");

    };

var ftn2 = function(){

        console.log("test:ftn2");

};

function ftn(f){

       f();

       f = ftn2;

}

ftn(ftn1);// 运行结果:test:ftn1

console.log("====================华丽的分割线======================");

ftn1();// 运行结果:test:ftn1

这个代码以前是这么分析的:f是函数的参数,属于函数的局部作用域,更改f的值,是没法改变ftn1的值,因为到了外部作用域f就失效了。但是这种解释很难说明我上文里给出的函数传参的实例。其实这个问题应该这么分析:在javascript里函数也是对象,局部作用域里f = ftn2操作是将f在栈区变量地址改为ftn2的地址,对ftn1和ftn2没有影响。

记住:javascript变量复制和函数传参是在传递栈区变量值,当栈区变量值为undefined、null、“”(空字符串)、0、false时,if判断是为false,我们可以通过!运算符计算。

当我们的代码如下:

var obj = {};

    if (!!obj){

        console.log("true");

    }else{

        console.log("false");

}

结果则是true,其中var obj = {}相当于var obj = new Object()。虽然对象里没什么内容,但是在堆区里对象的内存已经分配,栈区变量值已经是内存地址了,所以if判断是true。

3) 作用域链相关的问题

了解作用域链前先了解作用域:

作用域在许多程序设计语言中非常重要。 通常来说,一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域。 作用域的使用提高了程序逻辑的局部性,增强程序的可靠性,减少名字冲突。

在java里通过{}来设置作用域,在{}里面的变量会得到保护,这种保护就是不让{}里的变量被外部变量混淆。那么{}的方式适合于javascript吗?

我们看看下面的例子:

var s1 = "sharpxiajun";

    function ftn(){

        var s2 = "xtq";

        console.log(this);// 运行结果: window

        console.log("s1:" + this.s1 + ";s2:" + this.s2);//运行结果:s1:sharpxiajun;s2:undefined

        console.log("s1:" + this.s1 + ";s2:" + s2);// 运行结果:s1:sharpxiajun;s2:xtq

    }

    ftn();

javascript最大的作用域是window,加载时自动构造。上面代码里的大括号是函数定义,函数作用域内定义的s2变量不能被window对象访问的。可以说s2变量是被{}保护起来了,它的生命周期和这个函数的生命周期有关。

但是在javascript语言里还有非函数的{},我们再看看下面的例子:

if (true){

        var a = "aaaa";

    }

    console.log(a);// 运行结果:aaaa

我们发现javascript里{}有时是起不到定义作用域的功能。这也说明javascript里的作用域定义是和其他语言例如java不同的。

javascript作用域execution context,翻译成执行上下文或执行环境。我们来想想javascript里哪些情况是执行的:

情况一:页面加载时script标签下的代码会顺序执行,而这些能被执行的代码都是属于window的变量或函数;

情况二:函数的名字后面加上小括号(),例如ftn(),执行的是函数。

如此说来,javascript里的执行环境有两类一类是全局执行环境,即window代表的全局环境;一类是函数代表的函数执行环境,这也就是局部作用域

执行环境有个对象叫做variable object,翻译为变量对象或上下文变量,存储其执行环境里所有的变量和函数,全局执行环境的上下文变量就是window。

javascript执行环境栈execution context stack:每个要被执行的函数会先把函数的执行环境压入到执行环境栈里,执行完后被弹出,控制权交给全局环境;如果函数后面还有代码,代码接着执行;如果函数里嵌套了函数,那么嵌套函数执行完毕后,执行环境栈的控制权就交由了外部函数,然后依次类推,最后就是全局执行环境了。

函数的执行环境被压入到执行环境栈里后,函数执行的第一步不是函数里的第一行代码而是在上下文变量里构造一个作用域链scope chain,保证执行环境里有权访问的变量和函数是有序的。

我们看看下面的代码:

var b1 = "b1";

    function ftn1(){

        var b2 = "b2";

        var b1 = "bbb";

        function ftn2(){

            var b3 = "b3";

            b2 = b1;

            b1 = b3;

            console.log("b1:" + b1 + ";b2:" + b2 + ";b3:" + b3);// 运行结果:b1:b3;b2:bbb;b3:b3

        }

        ftn2();

    }

    ftn1();

console.log(b1);// 运行结果:b1

ftn2函数可以访问变量b1,b2,这个体现了有权访问;当ftn1作用域里改变了b1的值并且把b1变量重新定义为ftn1的局部变量,那么ftn2访问到的b1就是ftn1的,ftn2访问到b1后就不会在全局作用域里查找b1了,这个体现了有序性。

总结:作用域指的是执行环境execution context,通过上下文变量variable object体现。当函数的执行环境压入到了执行环境栈execution context stack时上下文变量会构造一个对象作用域链scope chain保证执行环境里有权访问的变量和函数是有序的。作用域链只能向上访问变量,直到window,不允许向下访问。

4)   this、new、apply和call详解

this对象:上下文变量构建作用域链时还会构造一个this对象,是当前执行环境外部上下文变量的一份拷贝,不包含作用域链变量。

例如代码:

var b1 = "b1";

    function ftn1(){

        console.log(this);// 运行结果: window

        var b2 = "b2";

        var b1 = "bbb";

        function ftn2(){

            console.log(this);// 运行结果: window

            var b3 = "b3";

            b2 = b1;

            b1 = b3;

            console.log("b1:" + b1 + ";b2:" + b2 + ";b3:" + b3);// 运行结果:b1:b3;b2:bbb;b3:b3

        }

        ftn2();

    }

    ftn1();

函数ftn1和ftn2里的this指针都是指向window,这是为什么了?因为通过function xxx(){}形式定义函数,这个函数不管在哪定义都属于window。

但是我们知道很多this指针都不是指向window,例如下面的代码:

var obj = {

    name:"sharpxiajun",

    ftn:function(){

        console.log(this);// 运行结果: Object { name="sharpxiajun", ftn=function()}

        console.log(this.name);//运行结果: sharpxiajun

    }

}

obj.ftn();// :

这里this指针指向了Object,我前文不是说javascript里作用域只有两种:一个是全局的一个是函数,为什么这里Object也是可以制造出作用域?

那我们看看下面的代码:

var obj1 = new Object();

obj1.name = "xtq";

obj1.ftn = function(){

    console.log(this);// 运行结果: Object { name="xtq", ftn=function()}

    console.log(this.name);//运行结果: xtq

}

obj1.ftn();

这两种写法是等价的,第一种叫做字面量定义,而第二种是标准写法。Object对象的本质也是个function,调用对象里的函数时,函数的外部执行环境就是obj1本身,this指针也是指向了obj1。

下面我们看看在java语言里是如何使用this指针的,代码如下:

public class Person {

    private String name;
    private String sex;
    private int age;
    private String job;

    public Person(String name, String sex, int age, String job) {
        super();
        this.name = name;
        this.sex = sex;
        this.age = age;
        this.job = job;
    }

    private void showPerson(){
        System.out.println("姓名:" + this.name);
        System.out.println("性别:" + this.sex);
        System.out.println("年龄:" + this.age);
        System.out.println("工作:" + this.job);
    }

    public void printInfo(){
        this.showPerson();
    }

    public static void main(String[] args) {
        Person person = new Person("马云", "男", 46, "董事长");
        person.printInfo();
    }

}

//姓名:马云
//性别:男
//年龄:46
//工作:董事长

上面的代码执行后没有任何问题,下面我修改下这个代码,加一个静态的方法,静态方法里使用this指针调用类里的属性,如下图所示:

我们发现IDE会报出语法错误“Cannot use this in a static context”,this指针在java语言里是不能在静态方法使用的。

在面向对象编程里有两个重要的概念:一个是类,一个是实例化的对象。类是一个抽象的概念,类就像一个模具,而实例化对象就是通过这个模具制造出来的产品。

有上面代码我们可以看到,this指针在java语言里只能在实例化对象里使用,this指针等于这个被实例化好的对象,而this后面加上点操作符后面的东西就是this所拥有的东西,例如:姓名,工作,手,脚等等。

javascript的this指针也只能在实例化对象里使用,但是javascript的this指针却比java难以理解的多,原因得有三:

原因一:javascript是一个函数编程语言,也是面向对象的语言;javascript的函数是高阶函数,可以作为对象传递,还可以作为构造函数,创建实例化对象,结果导致方法执行时候this指针的指向会不断发生变化。

原因二:由上面java的例子我们看到,this指针只有在使用new操作符后才会生效;但是javascript里的this在没有进行new操作也会生效,这时候this往往会指向全局对象window。

原因三:javascript里call和apply操作符可以改变this指针。

我们先看看下面的代码:

<script type="text/javascript">
    this.a = "aaa";
    console.log(a);//aaa
    console.log(this.a);//aaa
    console.log(window.a);//aaa
    console.log(this);// window
    console.log(window);// window
    console.log(this == window);// true
    console.log(this === window);// true
</script>

script标签里直接使用this指针就指向window,window在页面加载时由javascript引擎完成。程序员无法通过编程语言来控制这个实例化过程,所以开发时就没有构建这个this指针的感觉,常常会忽视它。

还和function的使用有关,我们看看下面的代码:

<script type="text/javascript">
    function ftn01(){
       console.log("I am ftn01!");
    }
    var ftn02 = function(){
        console.log("I am ftn02!");
    }
</script>

上面是两种定义函数的方式,第一种称作声明函数,第二种称作函数表达式,这两种方式的区别常常会让我们混淆this指针的使用。

我们再看看下面的代码:

<script type="text/javascript">
    console.log(ftn01);//ftn01()  注意:在firebug下这个打印结果是可以点击,点击后会显示函数的定义
    console.log(ftn02);// undefined
    function ftn01(){
       console.log("I am ftn01!");
    }
    var ftn02 = function(){
        console.log("I am ftn02!");
    }
</script>

这又是一段没有按顺序执行的代码,ftn02打印结果是undefined,有栈区变量标识符,没有栈区变量值,堆区没有对象,这是javascript引擎在预处理时扫描变量定义所致。ftn01打印出完整的函数定义,这只能说明一个问题:

声明函数会在预处理把函数定义和赋值操作都完成,且声明函数都是window对象的属性

关于函数表达式的写法还有秘密可以探寻,我们看下面的代码:

<script type="text/javascript">
    function ftn03(){
        var ftn04 = function(){
            console.log(this);// window
        };
        ftn04();
    }
    ftn03();
</script>

ftn04虽然在ftn03作用域下,但是它里面的this指针指向window。说明函数表达式在函数内部写时,this指针指向window。

原因是javascript里任何匿名函数都是属于window,在全局作用域构造时完成定义和赋值。匿名函数是没有名字的变量,定义时会返回内存地址;如果有个变量接收了这个地址,那么匿名函数就能使用了;匿名函数是在全局执行环境构造时定义和赋值,所以匿名函数内的this指向window对象。

这下子坏了,this都指向window,那我们到底怎么才能改变它了?

在本文开头我说出了this的秘密,this都是指向实例化对象,前面讲到那么多情况this都指向window,就是因为这些时候只做了一次实例化操作,实例化window对象,所以this都是指向window。我们要把this从window变成别的对象,就得要让function被实例化,那如何让javascript的function实例化呢?

答案就是使用new操作符。我们看看下面的代码:

<script type="text/javascript">
    var obj = {
        name:"sharpxiajun",
        job:"Software",
        show:function(){
            console.log("Name:" + this.name + ";Job:" + this.job);
            console.log(this);// Object { name="sharpxiajun", job="Software", show=function()}
        }
    };
    var otherObj = new Object();
    otherObj.name = "xtq";
    otherObj.job = "good";
    otherObj.show = function(){
        console.log("Name:" + this.name + ";Job:" + this.job);
        console.log(this);// Object { name="xtq", job="good", show=function()}
    };
    obj.show();//Name:sharpxiajun;Job:Software
    otherObj.show();//Name:xtq;Job:good
</script>

这是我上篇讲到的关于this指向Object的实例。Javascript里通过字面量方式定义对象的方式是new Object的简写,二者是等价的,本质也是new操作符。

下面我使用javascript来重写本篇开头用java定义的类,代码如下:

<script type="text/javascript">
    function Person(name,sex,age,job){
        this.name = name;
        this.sex = sex;
        this.age = age;
        this.job = job;
        this.showPerson = function(){
            console.log("姓名:" + this.name);
            console.log("性别:" + this.sex);
            console.log("年龄:" + this.age);
            console.log("工作:" + this.job);
            console.log(this);// Person { name="马云", sex="男", age=46, 更多...}
        }
    }
    var person = new Person("马云", "男", 46, "董事长");
    person.showPerson();
</script>

function Person相当于在定义一个类,javascript的function既是函数又是对象;javascript的构造函数可以理解为类和构造函数合二为一,当然javascript语言规范里是没有类的概念

new操作符会让构造函数产生如下变化:

1. 创建一个新对象;

2. 将构造函数的作用域赋给新对象(因此this就指向了这个新对象);

3. 执行构造函数中的代码(为这个新对象添加属性);

4. 返回新对象

第四点要着重讲下,记住构造函数被new操作,要让new正常作用最好不要在构造函数里写return,没有return的构造函数都是按上面四点执行,有了return情况就复杂了,这个知识我会在讲prototype时候讲到。

Javascript改变this指针还有call和apply,它们的作用相同,只是参数不同。call和apply的第一个参数一样,apply第二个参数是数组,call从第二个参数开始后面有许多参数。

我们看看下面的代码:

<script type="text/javascript">
    var name = "sharpxiajun";
    function ftn(name){
        console.log(name);
        console.log(this.name);
        console.log(this);
    }
    ftn("101");
    var obj = {
      name:"xtq"
    };
    ftn.call(obj,"102");
    /*
    * 结果如下所示:
    *101
     T002.html (第 73 行)
     sharpxiajun
     T002.html (第 74 行)
     Window T002.html
     T002.html (第 75 行)
     T002.html (第 73 行)
     xtq
     T002.html (第 74 行)
     Object { name="xtq"}
    * */
</script>

其实理清上面情况也是有迹可循的,就以定义对象里的方法有传入函数参数为例:

情形一:传入的参数是函数的别名,那么函数的this就是指向window;

情形二:传入的参数是被new过的构造函数,那么this就是指向实例化的对象本身;

情形三:如果想把被传入的函数对象里this的指针指向外部字面量定义的对象,那么我们就是用apply和call

我们可以通过代码看出我的结论,代码如下:

<script type="text/javascript">
var name = "I am window";
var obj = {
    name:"sharpxiajun",
    job:"Software",
    ftn01:function(obj){
        obj.show();
    },
    ftn02:function(ftn){
        ftn();
    },
    ftn03:function(ftn){
        ftn.call(this);
    }
};
function Person(name){
    this.name = name;
    this.show = function(){
        console.log("姓名:" + this.name);
        console.log(this);
    }
}
var p = new Person("Person");
obj.ftn01(p);
obj.ftn02(function(){
   console.log(this.name);
   console.log(this);
});
obj.ftn03(function(){
    console.log(this.name);
    console.log(this);
});
</script>

结果如下:

最后再总结一下:

如果在javascript语言里没有通过new(包括对象字面量定义)、call和apply改变函数的this指针,那么函数的this指针都是指向window的

阅读更多
文章标签: javascript 对象 函数
个人分类: javascript
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭