你不懂的JS學習筆記(作用域和閉包)

Sleet發表於2019-02-13

You don`t KnowJS

引語:你不懂的JS這本書github上已經有了7w的star最近也是張野大大給我推薦了一波,閱讀過之後感覺對js的基礎又有了更好的理解。本來我是從來不這種讀書筆記的,但是這本書的內容實在是太多太多哪哪都是重點。所以
也就決定記錄以下重要的地方便於以後複習方便如果有錯誤,感謝指出

第一部分:作用域和閉包

第一章

編譯的三個步驟(當然也就是編譯器乾的事情了)

  1. 分詞/詞法分析
    通俗來說就是編譯器會將我們寫的程式碼首先拆分成可以進行編譯的程式碼 eg:var a = 2;可以被編譯器分割為var,a,=,2,; 空格是否會被當作詞法單元,取決於空格在這門語言中是否具有意義。
  2. 解析/語法分析
    AST:抽象語法樹的概念他會把上述分割好的程式碼進組裝成為一個語法樹m,=,var,a,2 都回變成語法樹的各個節點,從而為編譯做準備。
  3. 程式碼生成
    編譯器最終會將這樣的AST語法樹編譯為可執行的底層程式碼。特別要強調的是JS的引擎在編譯器執行是會幫助編譯器做程式碼優化,同通常來說他不會編譯的過程就發生在引擎執行程式碼的前很短的時間,並不是像執行C/C++等這些程式碼需要先build完整個檔案再進行run這樣的方式。

理解作用域 (通常指的是詞法作用域或者也可以叫做靜態作用域)

首先說一下基本的執行順序首先是編譯器由上面的步驟編譯程式碼然後對於一些變數的宣告會在編譯期間交給作用域然後作用域就會組成一個
像是一個樹的結構,全域性作用域下面會有巢狀的函式作用域。最後JS引擎根據作用域去執行程式碼,大概就是這樣的一個流程。
介紹以下三個關鍵的概念:

  1. 編譯器: 用來在引擎執行程式碼前提供給引擎程式碼並且向作用域提供組成“樹”的節點
  2. 引擎:用來負責執行和編譯的環境 配合作用域組成自己的上下文
  3. 作用域:負責收集並維護由所有宣告的識別符號(變數)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的程式碼對這些識別符號的訪問許可權。
    LHS和RHS:“賦值操作的目標是誰(LHS)”以及“誰是賦值操作的源頭(RHS)”。PS:rhs參考物件為語句中的常量例如:console.log(1)就是誰來對於1進行操作,lh參考物件為語句中的常量例如:a = 22應該賦值給誰如果是 console.log(a)應該就是RHS和LHS一起

引擎和作用域的關係

下面是我寫的書上的題
測驗的答案:LHS:foo->c,2->a,a->b
ps:1. 可以理解為foo需要知道自己應該賦值給誰所以LHR
ps:2. 可以理解為2需要知道自己賦值給誰這裡是foo的引數a
ps:3. 可以理解為a需要給誰賦值
測試的答案:RHS:2->foo,a->foo,b->a,a+b->return
ps: 1. 可以理解為是誰呼叫2,所以是foo
ps:2. 同上可以知道後續的三個答案

作用域的巢狀

作用域的巢狀:作用域是個家族,爸爸認識兒子的人,爺爺認識爸爸認識的人,每次問兒子有沒有有認識的人,如果沒有再問爸爸。 也就是
上文提到的樹結構
ReferenceError:你找了作用域整個家族都不認識的人就會出錯,並沒有申明這個引用了沒有宣告的變數的錯誤.
LHS查詢的時候需要特別注意的是 如果LHS在全域性作用域當中都無法找到變數就會建立一個變數(非嚴格模式)
如果查詢的目的是對變數進行賦值,那麼就會使用 LHS 查詢;如果目的是獲取變數的值,就會使用 RHS 查詢

第二章: 詞法的作用域

詞法階段

1.一個詞法不可能同時在兩個作用域中,作用域查詢會在找到第一個匹配的識別符號時停止
2.全域性變數會自動成為全域性物件的屬性(據阮老師的部落格上說這是由於js的設計這為了減少記憶體了留下的歷史問題)
3.無論函式在哪裡被呼叫,也無論它如何被呼叫,它的詞法作用域都只由函式被宣告時所處的位置決定。(詞法作用域是靜態作用域和動態的沒有關係)

欺騙詞法作用域

1.eval函式:接受字串程式碼他會在編譯器執行在引擎快要執行的時候將這段程式碼寫在他位於的位置,不推薦使用.不過可以解決var出現的變數死區的問題
2.with函式:簡單來說with函式{}以內的語句在當前的位置以內建立了一個作用域而且自動放入了吧obj物件當中的屬性放了進去,這就有點想是在Chrome中的命令列寫global.a = 0然後a=1進行賦值時一樣的,依然不推薦使用

//with的用法
var obj = {  
    a: 1,
    b: 2,
    c: 3 
};
// 單調乏味的重複 "obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 簡單但是不快捷的方式 
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}
//obj.a = 3
/***************我是分界線*****************/
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——不好,a 被洩漏到全域性作用域上了!複製程式碼

這就是前面說的o2.a會進行LHS查詢當查詢到頂級時就會給全域性變數賦值
eval和with我認為並不是作為詞法作用域的範圍,因為詞法作用域是在編譯前就做好的,所以這個叫做欺騙詞法作用域,當然因為是動態的所以會消耗效能

第三章:函式作用域和塊作用域

函式中的作用域

先看下面程式碼函式bar(..) 擁有自己的作用域範圍,全域性作用域也有自己的作用域範圍,它只包含了一個識別符號:foo。而foo可以理解為一層樓進入一個房間的門,是一個入口.函式作用域
主要提供函式變數的訪問,找不到一個變數就會去上一個作用域找,而之後所提到的原型鏈是一個物件的原型鏈是在這個物件的內部找屬性找不到的時候就會去查詢.(自己在看書的時候不小心弄混了)

function foo(a) { 
    var b = 2;
    // 一些程式碼
    function bar() { 
        // ...
    }
    var c = 3;
}複製程式碼

隱藏內部實現

在下面的程式碼當中doSomethingElse的呼叫並不是最安全的,因為其他函式都可以呼叫

function doSomething(a) {
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );
}
function doSomethingElse(a) { 
    return a - 1;
}
var b;
doSomething( 2 ); // 15複製程式碼

而下面的函式則是比較安全的

function doSomething(a) { 
    function doSomethingElse(a) {
        return a - 1; 
    }
    var b;
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );
}
doSomething( 2 ); // 15複製程式碼

所以function(){}用來隱藏程式碼解決衝突(這是因為js在es5當中只有函式作用域並沒有塊作用域)

函式作用域

js當中為了能夠模仿塊級作用域,邊有人想到了用函式作用域模仿的概念.先來看看下面程式碼

//並不理想
function foo() { 
    var a = 3; 
    console.log( a ); 
} 
foo(); 
//方法1:
(function foo(){ 
    console.log( a ); // 3 
})();複製程式碼

雖然這種技術可以解決一些問題,但是它並不理想,因為會導致一些額外的問題。首先,必須宣告一個具名函式 foo(),意味著 foo 這個名稱本身“汙染”了所在作用域(在這個 例子中是全域性作用域)。其次,必須顯式地通過函式名foo()呼叫這個函式才能執行其中的程式碼。然而使用了自執行函式以後欺騙編譯器對於通過()或者+-*等等欺騙了編譯器的檢查(後面會提到)
所以忽略了function的宣告語句.而這個語句的結果值就是這個函式呼叫以後就會執行

匿名函式和立即執行函式還有函式的宣告和函式表示式

編寫帶有名字的函式便於理解

setTimeout( function timeoutHandler() { 
    // <-- 快看,我有名字了! 
    console.log( "I waited 1 second!" );
}, 1000 );複製程式碼

function 和 var 編譯時存在函式的提升,也就是說var 和 function會優先的被編譯器識別交給作用域。然後引擎在訪問程式碼的時候就能夠查詢到編譯器交過來的變數了
下面說的方法其實是通過一些其他的表示式干擾編譯器的判斷,讓編譯器認為這並不是一個宣告,對於函式的表示式和函式的宣告還有立即執行函式可以看看這兩個博主的文章看看我還有我
1.編譯器在編譯程式碼的時候發現一行程式碼如果第一個出現的是funtion則會被理解為函式的宣告語句(var也是而let的機制可能就不同),編譯器就會自動把它叫給作用域.函式的宣告是不會有結果值的
2.當一個有function的函式的宣告加入其他的東西時(例如括號+或者-等)編譯器會把他認為是一個非宣告的語句,而這些語句是需要引擎來執行的
3.當引擎執行程式碼的時候會發現這裡面藏著一個非宣告的語句於是就執行他這時候是有結果值的,所以可以對他進行呼叫

下面的程式碼就是例子(感受一下js的黑暗吧)

//下面對可以對返回的結果值進行呼叫,括號的位置並不影響因為function(){}被作為表示式執行完以後會就會返回函式所以兩個都行
( function foo(){} )()//ƒ foo(){}
( function foo(){} () )//ƒ foo(){}
//我自己又寫了一下感受邪惡吧
(function(){return (a)=>{ console.log(a); return (b)=>{console.log(b)}}})()(1)(2)
//下面沒有返回的結果值就不可以所以報錯
function foo(){}()
//所以你可以依靠this和作用域來實現let,如果沒有宣告就會出錯
function foo(){console.log(a); (function(){this.a = 10})(); console.log(a); }
function foo(){console.log(a); let a = 10; console.log(a); }複製程式碼

塊作用域

{}無法建立塊作用域因為js並不支援塊作用域,但是try{}catch{}卻可以,和function(){
}我認為他們建立的其實是函式作用域,其實他們一直是在用函式作用域模擬塊作用域

第四章:提升

函式優先的原則

看下面的程式碼

foo(); // 1
var foo;
function foo() { 
    console.log( 1 );
}
foo = function() { 
    console.log( 2 );
};複製程式碼

上面的程式碼會執行1和下面的程式碼是等價的,這說明函式的宣告是要比var提前的,我認為可能編譯器在發現有function生命的時候會把var替換掉

function foo() { 
    console.log( 1 );
}
foo(); // 1
foo = function() { console.log( 2 );
};複製程式碼

第五章: 閉包

什麼是閉包

其實我把閉包想象為一個被儲存的作用域,而實現方式通常使用function(){}建立這樣一個函式作用域的方式(當然也有其他的方式)

function foo() { 
    var a = 2;
    function bar() { 
        console.log( a );
    }
    return bar; 
}
var baz = foo();
baz(); // 2 —— 朋友,這就是閉包的效果。複製程式碼

看起來你並不覺得這有什麼牛逼的地方,但是其實js當中閉包是十分常用的功能(比如所有的回撥函式其實都是閉包)

//當你把閉包的返回進入另一個函式內部的時候,你就可以在另一個函式內部訪問他的變數!!!!!!!
function foo() { 
    var a=2;
    function baz() { 
        console.log( a ); // 2
    }
    bar( baz ); 
}
function bar(fn) {
    fn(); // 媽媽快看呀,這就是閉包!
}
//通過作用域訪問的方式進行傳遞閉包
var fn; 
function foo() {
    var a=2;
    function baz() { 
        console.log( a );
    }   
    fn = baz; // 將 baz 分配給全域性變數 
}
function bar() {
    fn(); // 媽媽快看呀,這就是閉包!
}
foo();
bar(); // 2複製程式碼

作者也告訴我們不僅僅如此,閉包之所用重要是因為 在定時器、事件監聽器、 Ajax請求、跨視窗通訊、Web Workers或者任何其他的非同步(或者同步)任務中,只要使用了回撥函式,本質都是在使用閉包!

function wait() {
    let a = 1;
    function test(){
        console.log(this.a)
        console.log(a)
    }
    return test;
}
var a = 2;
wait()();
// 定時器
function wait(message) {
    //這就是閉包
    function timer() {
        console.log( message );
    }
    //下面的就可以理解為引擎會在1s內呼叫一個函式,而這個函式就是閉包,他會訪問message的作用域
    setTimeout( timer, 1000 ); 
}
wait( "Hello, closure!" );
//事件監聽器
function setupBot(name, selector) {
$( selector ).click( function activator() {
console.log( "Activating: " + name ); });
}
     setupBot( "Closure Bot 1", "#bot_1" );
     setupBot( "Closure Bot 2", "#bot_2" );
//觸發的activator函式也可以看做是一個閉包複製程式碼

迴圈和閉包

先看看程式碼

for (var i=1; i<=5; i++) { 
setTimeout( function timer() {
    console.log( i );
}, i*1000 );
}複製程式碼

其實以上的輸出結果並不會是1,2,3,4,5.反而回是6,6,6,6,6.這是因為setTimeout閉包並不是立即執行的,而是延遲執行的.所以第一步會先把for迴圈走完,當延遲執行的函式重新回到這個作用域的時候,這裡的變數已經面目全非了,所以為了能夠維護閉包呼叫的作用域我們會才去一些措施(我記得大搜車的筆試題就有這個)

//這樣是不行的,雖然我們確實建立了一個供閉包將來回頭檢視的作用域,但是這個作用域裡面什麼都沒有
for (var i=1; i<=5; i++) { 
    (function() { 
        setTimeout( function timer() { console.log( i );}, i*1000 );})();
}
//所以像下面這樣的才能夠執行,因為這裡面維護的作用域就不再是空了,當然也是因為這裡面是一個值變數
for (var i=1; i<=5; i++) { 
(function() {
    var j = i;
    setTimeout( function timer() {
                 console.log( j );
             }, j*1000 );
})(); }複製程式碼

模組

在前端方面最早的模組機制的實現其實就是閉包,開頭我說通常使用function(){}來維持一個特定的作用域,而下面的返回object的物件將各個維持特定作用域的function(){}組合起來,也能夠實現閉包.

function CoolModule() { 
var something = "cool";
var another = [1, 2, 3];
function doSomething() { 
    console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {    
doSomething: doSomething, 
doAnother: doAnother
};}
var foo = CoolModule(); 
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3複製程式碼

首先,CoolModule() 只是一個函式,必須要通過呼叫它來建立一個模組例項。如果不執行外部函式,內部作用域和閉包都無法被建立。
其次,CoolModule()返回一個用物件字面量語法{ key: value, … }來表示的物件。這個返回的物件中含有對內部函式而不是內部資料變數的引用。我們需要保持內部資料變數是隱藏且私有的狀態。可以將這個物件型別的返回值看作本質上是模組的公共 API。
從模組中返回一個實際的物件並不是必須的,也可以直接返回一個內部函式。jQuery 就是一個很好的例子。jQuery 和 $ 識別符號就是 juery 模組的公共 API但它們本身都是函式(使用jq的時候其實是呼叫了他的建構函式創造了一個jq的節點)
這樣就實現了訪問API中的方法但是卻又不會使變數汙染,但是你必須使用它然後自己賦值一個變數
閉包的形成必須有兩個條件:1.必須有像上面一CoolModule()一樣的封閉函式,也就是閉包所能保留的作用域範圍.2.封閉函式至少要返回一個函式去作為探測這個作用域的閉包

現代和未來的模組機制

看看下面程式碼

//這裡的模組定義就像上面的那樣返回的是由閉包函式組成的物件
var MyModules = (
    function Manager() {
    var modules = {};
    //這個函式是將建立定義,模組的名字,和制定自己所依賴的模組當你需要依賴其他模組的時候就會在這裡進行載入,傳入實現函式進行載入,最後是這個模組的實現
    function define(name, deps, impl) { 
        for (var i=0; i<deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply( impl, deps );
    }
    function get(name) { return modules[name];}
    return {
        define: define,
        get: get 
    }
})();
//首先定義一個自己的模組bar,用來分裝一個說你好的方式
MyModules.define( "bar", [], function() { 
    function hello(who) {return "Let me introduce: " + who; }
    return {
    hello: hello
};});
//foo依賴於
MyModules.define( "foo", ["bar"], function(bar) {
    var hungry = "hippo";
    function awesome(){
        console.log(bar.hello(hungry).toUpperCase())
    }
    return {
        awesome: awesome
    }; 
});
var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );
console.log(bar.hello( "hippo" )); // Let me introduce: hippo 
foo.awesome(); // LET ME INTRODUCE: HIPPO複製程式碼

實際上在foo中得到的閉包bar閉包和 var bar = MyModules.get( “bar” );得到的閉包是一樣的

未來的模組機制

  1. 在es6中會把一個檔案當做一個模組,我個人理解就是用一個(function 檔名(匯入的其他檔案){})將整個檔案程式碼括起來
  2. es6的模組是比較穩定的,在之前的模組機制用函式來分裝模組會導致只有在引擎執行程式碼的時候才會知道為什麼錯,但是es6的模組機制會被編譯器識別也就會在執行知道將會有什麼錯誤.
  3. 這裡對應的應該是require和import的區別因為在webstorm編寫程式碼的時候,require即便是路徑寫的不對也不會在webstorm出現錯誤,但是使用import匯入的時候如果不存在webstorm會提示你沒辦法匯入,我想著就是webstorm後臺為你編譯進行提示錯誤吧
  4. 還有一點好處是如果b塊裡面用了c,當b匯入到a中c也就會自己匯入
  5. module和import的區別

    import 可以將一個模組中的一個或多個 API 匯入到當前作用域中,並分別繫結在一個變數 上(在我們的例子裡是 hello)。module 會將整個模組的 API 匯入並繫結到一個變數上(在 我們的例子裡是 foo 和 bar)。export 會將當前模組的一個識別符號(變數、函式)匯出為公 共 API。這些操作可以在模組定義中根據需要使用任意多次。

附錄的內容

動態作用域

通過前面的學習我們知道靜態作用域也就是詞法作用域,也就是詞法作用域是由編譯器提前執行程式碼的時候構造出來的一個作用域,我覺得他一定是採用樹進行儲存的.而動態的作用域實際上更多的是指的this指標,也就是說在引擎執行程式碼的過程中進行變化的.(大部分的作用域應該是詞法作用域,但是難免的要使用一些在執行過程中變化的作用域)

function foo(){
    console.log(a)//2
}
function bar(){
    var a = 3;
    foo(); 
}
var a = 2;
bar();複製程式碼

上面的程式碼當中foo()作用域中沒有a變數,也就是說要執行RHS引用(當然也沒有console變數他會執行LHS引用)所以會找到2

塊作用域

之前說過js中是沒有塊級作用域的但是這其實並不是一個正常的程式語言的行為,所以模擬塊級作用域是非常重要的.其實在一些語法中就已經有了塊級作用域
比如with 和 catch

try{throw 2;}catch(a){ 
    console.log( a ); // 2
}
console.log( a ); // ReferenceError複製程式碼

this詞法

這裡主要提到了箭頭函式的用法,比如說

var obj = {
    id:"awesome",
    cool:function coolFn(){
        console.log(this.id);
    }
}
var id = "not awesome"
obj.cool();//"awesome"
setTimeout(obj.cool,100);//"not awesome"複製程式碼

obj.cool()固然是隱式繫結但是當放在函式當中的時候其實這個隱式繫結會被斷開因為他把這個函式
的指標賦給了setTimeout的引數變數所以呼叫的時候其實是cool()這種方式。除了文章中提到的self儲存住this的方法,就是使用箭頭函式的繫結可以寫成這個樣子

var obj = {
    count: 0,
    cool: function coolFn() {
        if (this.count < 1) {
            setTimeout( () => {  // 箭頭函式是什麼鬼東西?
            this.count++;
            console.log( "awesome?" );
            }, 100 );
        }
    }
};
obj.cool(); // "awesome?"複製程式碼

箭頭函式的筆記在後面還會詳細的學習記錄下。

遺留問題

  1. 最後我還是沒有弄懂,附錄中動態作用域的問題,作者也說了動態作用域關心的是這個呼叫的位置而不是宣告的位置,所以如果按照作者的動態作用域的觀點會輸出this可是
    作者自己又否定了說this的實現原理並不是一個純粹的動態作用域。那他到底是個什麼?
    function foo(){
     console.log(a)//2
    }
    function bar(){
     var a = 3;
     foo(); 
    }
    var a = 2;
    bar();複製程式碼

相關文章