天天看點

深入了解ECMAScript中的聲明提升、this關鍵字及作用域(鍊)聲明提升this關鍵字作用域(Scope)和閉包(closure)

聲明提升

大部分程式設計語言都是先聲明變量再使用,但在JavaScript中,事情有些不一樣:

console.log(a);//undefined
var a = 0;
           

上面是合法的JavaScript代碼,正常輸出undefined而不是報錯Uncaught ReferenceError: a is not defined。為什麼?就是因為聲明提升(hoisting)。

1.1 變量聲明

參考:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/var

文法:

var varname1 [= value1 [, varname2 [, varname3 ... [, varnameN]]]]
           

變量名可以是任意合法辨別符;值可以是任意合法表達式。

重點:

  • 變量聲明,不管在哪裡發生,都會在任意代碼執行前處理;
  • 聲明的變量的作用域就是目前執行上下文,即某個函數或者全局作用域;
  • 指派給未聲明的變量,當執行時會隐式建立全局變量;

聲明的變量和未聲明的變量的差別:

  • 聲明的變量通常是局部的,未聲明的變量通常是全局的;
  • 聲明變量在任意代碼執行前建立,未聲明的變量直到指派時才存在;
  • 聲明的變量是執行上下文(function/global)的non-configurable屬性,未聲明的變量則是configurable;
  • 在es5 strict mode,指派給未聲明的變量将報錯;

1.2 定義函數

定義一個函數有兩種方式:函數聲明和函數表達式

函數聲明:

文法:function name(arguments){}

函數表達式:

文法:var fun = function(arguments){}

函數表達式中函數可以不需要名字,即匿名函數。

其它

還可以用Function構造函數來建立函數。

文法:var function_name = new Function(arg1, arg2, ..., argN, function_body);

在函數内部引用函數本身有3中方式。比如var foo = function bar(){};

  • 函數名字,即bar();
  • arguments.callee();
  • foo();

1.3 聲明提升

聲明的變量會在任意代碼執行前處理,這意味着在任意地方聲明變量都等同于在頂部聲明-即聲明提升。生命提升中,需要綜合考慮一般變量和函數。

在JavaScript中,一個變量名進入作用域的方式有四種:

  1. Language-defined:所有的作用域預設都會給出this和arguments兩個變量名(global沒有arguments);
  2. Formal parameters(形參):函數有形參,形參會添加到函數的作用域中;
  3. Function declarations(函數聲明):如function foo(){};
  4. Variable declarations(變量聲明):如var foo,包括函數表達式;

函數聲明和變量聲明總是會被移動(即hoist)到它們所在的作用域的頂部(這對你是透明的)。

而變量的解析順序(優先級),與變量進入作用域的4中方式的順序一緻。

一個詳細的例子:

function testOrder(arg) {
    console.log(arg);    //arg是形參,不會被重新定義
    console.log(a);    //因為函數聲明比變量聲明優先級高,是以這裡a是函數
    var arg = 'hello';    //var arg;變量聲明被忽略,arg = 'hello'被執行
    var a = 10;    //var a;被忽視;a = 10被執行,a變成number
    function a() {
        console.log('fun');
    }    //被提升到作用域頂部
    console.log(a);    //輸出10
    console.log(arg);    //輸出hello
};
testOrder('hi');
> hi
function a() {
    console.log('fun');
}
10
hello
           

變量進入函數作用域的順序為this,arguments --> 形參 --> 函數聲明 --> 變量聲明。如果重新定義與形參名字相同的變量,則變量聲明部分會被忽略,形參會被重新指派。代碼執行前,聲明提升部分已完成,函數的聲明會比變量的聲明先進入作用域。是以,在變量指派前列印變量,對于函數則輸出函數聲明(包括函數體),對于變量則輸出undefined。

this關鍵字

this關鍵字是JavaScript中最令人疑惑的機制之一。this是非常特殊的關鍵詞辨別符,在每個函數的作用域中被自動建立,但它到底指向什麼(對象),很多人弄不清。

當函數被調用,一個activation record(即execution context)被建立。這個record包含資訊:函數在哪調用(call-stack),函數怎麼調用的,參數等等。record的一個屬性就是this,指向函數執行期間的this對象。

  • this不是author-time binding,而是runtime binding(即this的值不是在代碼解釋階段确定的,而是在函數被調用時确定的);
  • this的上下文基于函數調用的情況。和函數在哪定義無關,而和函數怎麼調用有關。

this在具體情況下的分析

2.1 Global context

在全局上下文,this指向全局對象。

console.log(this === window);    //true
           

2.2 Function context

在函數内部時,this由函數怎麼調用來确定。

2.2.1 Simple call

簡單調用,即獨立函數調用。由于this沒有通過call來指定,且this必須指向對象,那麼預設就指向全局對象。

function f1(){
    return this;
}

f1() === window;    // global object
           

在嚴格模式下,this保持進入execution context時被設定的值。如果沒有設定,那麼預設是undefined。它可以被設定為任意值(包括null/undefined/1等等基礎值,不會被轉換為對象)。

function f2(){
    "use strict";
    return this;
}

console.log(f2() === undefined);    //true
           

2.2.2 Arrow functions

在箭頭函數中,this有詞法/靜态作用域設定(set lexically)。它被設定為包含它的execution context的this,并且不再被調用方式影響(call/apply/bind)。

var globalObject = this;
var foo = (() => this);
console.log(foo() === globalObject);    //true

//Call as a method of  a object
var obj = {foo: foo};
console.log(obj.foo() === globalObject);    //true

//Attempt to set this using call
console.log(foo.call(obj) === globalObject);    //true

//Attempt to set this using bind
foo = foo.bind(obj);
console.log(foo() === globalObject);    //true
           

2.2.3 As an object method

當函數作為對象方法調用時,this指向該對象。

var o = {
    prop: 37,
    f: function() {
        return this.prop;
    }
};
console.log(o.f());    //logs 37
           

this on the object's prototype chain

原型鍊上的方法跟對象方法一樣,作為對象方法調用時this指向該對象。

2.2.4 構造函數

在構造函數(函數用new調用)中,this指向要被constructed的新對象。

2.2.5 call和apply

Function.prototype上的call和apply可以指定函數運作時的this。

function add(c, d) {
    return this.a + this.b + c +d;
}

var o = {a:1, b:3};
add.call(o, 5, 7);    // 1 + 3 + 5 + 7 = 16
add.apply(o, [10, 20]);    // 1 + 3 + 10 + 20 = 34
           

注意,當用call和apply而穿進去作為this的不是對象時,将會調用内置的ToObject操作轉換成對象。是以4将會轉換成new Number(4),而null/undefined由于無法轉換成對象,全局對象将作為this。

2.2.6 bind

ES5引進了Function.prototype.bind。f.bind(someObject)會建立新的函數(函數體和作用域與原函數一緻),但this被永久綁定到someObject,不論你怎麼調用。

2.2.7 As a DOM event handler

this自動設定為觸發事件的dom元素。

作用域(Scope)和閉包(closure)

3.1 Scope是什麼?

先嘗試從幾個方面描述下:

  • Scope這個術語被用來描述在某個代碼塊可見的所有實體(或有效的所有辨別符),更精準一點,叫做上下文(context)或環境(environment)。
  • 目前執行的上下文(The current context of execution)。

綜合一下,Scope即上下文,包含目前所有可見的變量。

Scope分為Lexical Scope和Dynamic Scope。Lexical Scope正如字面意思,即詞法階段定義的Scope。換種說法,作用域是根據源代碼中變量和塊的位置,在詞法分析器(lexer)處理源代碼時設定。

讓我們考慮下面的代碼來分析Lexical Scope:

function foo(a) {
    //    inner scope 'foo'
    //    defined argument a, and look-up b upwards
    console.log( a + b );
}

// outmost/global scope
// defined b
var b = 2;
foo(2);    //4
           

Scope是分層的,内層的Scope可以通路外層Scope的變量,反之則不行。上面的代碼中即有嵌套Scope。

Scope在我們寫代碼的時候就被定義好了,比如誰嵌套在誰裡面。

3.2 JavaScript Scope

JavaScript采用Lexical Scope。

于是,我們僅僅通過檢視代碼(因為JavaScript采用Lexical Scope),就可以确定各個變量到底指代哪個值。

另外,變量的查找是從裡往外的,直到最頂層(全局作用域),并且一旦找到,即停止向上查找。是以内層的變量可以shadow外層的同名變量。

3.2.1 Cheating Lexical

如果Scope僅僅由函數在哪定義的決定(在寫代碼時決定),那麼還有方式更改Scope嗎?JavaScript有eval和with兩種機制,但兩者都會導緻代碼性能差。

3.2.1.1 eval

eval接受字元串為參數,把這些字元串當做真的在程式的這個點寫下的代碼--意味着可以編碼方式來在某個點生成代碼,就像真的在程式運作前在這裡寫了代碼。

function foo(str, a) {
    eval( str );    // cheating
    console.log( a, b );
}

var b = 2;
foo("var b = 3;", 1);    // 1, 3
           

預設情況下,eval會動态執行代碼,并改變目前Scope。但非直接(indirectly)調用eval可以讓代碼執行在全局作用域,即修改全局Scope。

function bar(str) {
    (0, eval)(str);    //     cheating in global!
}
bar("var hello = 'hi'");

window.hello;    //"hi"
           

另外,嚴格模式下,eval運作在它自己的Scope下,即不會修改包含它的Scope。

function foo(str) {
    "use strict";
    eval(str);
    console.log(a);    // ReferenceError: a is not defined
}

foo("var a = 2");
           

3.2.1.2 with

function foo(obj) {
    with(obj){
        a = 2;
    }
}

var o1 = {
    a: 3
};

var o2 = {
    b: 3
};

foo(o1);
console.log(o1.a);    // 2

foo(o2);
console.log(o2.a);    // undefined
console.log(a);    // 2 -- Oops, leaked global!
           

with以對象為參數,并把這個對象當做完全獨立的Lexical Scope(treats that object as if it is a wholly separate lexical scope),然後這個對象的屬性就被當做定義的變量了。

注意:盡管把對象當做Scope,var定義的變量仍然scoped到包含with的函數中。

不像eval可以改變目前Scope,with憑空建立了全新的Scope,并把對象傳禁區。是以o1傳進去時可以正确更改o1.a,而o2傳進去時,建立了全局變量a。

3.3 Dynamic Scope?

JavaScript沒有Dynamic Scope。JavaScript中的this機制跟Dynamic Scope很像,都是運作時綁定。

3.4 Function vs. Block Scope

JavaScript沒有Block Scope

除了Global Scope,隻有function可以建立新作用域(Function Scope)。不過這已經是老黃曆了,ES6引入了Block Scope。

{
    let x = 0;
}
console.log(x);    // Uncaught ReferenceError: x is not defined
           

另外,with和try catch都可以建立Block Scope

try {
    undefined();    // illegal operation to force an exception!
}
catch(err){
    console.log(err);    //works!
}

console.log(err);    // ReferenceError: 'err' is not defined
           

繼續閱讀