深入理解JavaScript,這一篇就夠了


前言

  JavaScript 是我接觸到的第二門編程語言,第一門是 C 語言。然后才是 C++、Java 還有其它一些什么。所以我對 JavaScript 是非常有感情的,畢竟使用它有十多年了。早就想寫一篇關於 JavaScript 方面的東西,但是在博客園中,寫 JavaScript 的文章是最多的,從入門的學習筆記到高手的心得體會一應俱全,不管我怎么寫,都難免落入俗套,所以遲遲沒有動筆。另外一個原因,也是因為在 Ubuntu 環境中一直沒有找到很好的 JavaScript 開發工具,這種困境直到 Node.js 和 Visual Studio Code 的出現才完全解除。

  十多年前,對 JavaScript 的介紹都是說他是基於對象的編程語言,而從沒有哪本書會說 JavaScript 是一門面向對象的編程語言。基於對象很好理解,畢竟在 JavaScript 中一切都是對象,我們隨時可以使用點號操作符來調用某個對象的方法。但是十多年前,我們編寫 JavaScript 程序時,都是像 C 語言那樣使用函數來組織我們的程序的,只有在論壇的某個角落中,有少數的高手會偶爾提到你可以通過修改某個對象的prototype來讓你的函數達到更高層次的復用,直到 Flash 的 ActionScript 出現時,才有人系統介紹基於原型的繼承。十余年后的現在,使用 JavaScript 的原型鏈和閉包來模擬經典的面向對象程序設計已經是廣為流傳的方案,所以,說 JavaScript 是一門面向對象的編程語言也絲毫不為過。

  我喜歡 JavaScript,是因為它非常具有表現力,你可以在其中發揮你的想象力來組織各種不可思議的程序寫法。也許 JavaScript 語言並不完美,它有很多缺陷和陷阱,而正是這些很有特色的語言特性,讓 JavaScript 的世界出現了很多奇技淫巧。

 

對象和原型鏈

  JavaScript 是一門基於對象的編程語言,在 JavaScript 中一切都是對象,包括函數,也是被當成第一等的對象對待,這正是 JavaScript 極其富有表現力的原因。在 JavaScript 中,創建一個對象可以這么寫:

var someThing = new Object();

  這和在其它面向對象的語言中使用某個類的構造函數創建一個對象是一模一樣的。但是在 JavaScript 中,這不是最推薦的寫法,使用對象字面量來定義一個對象更簡潔,如下:

var anotherThing = {};

  這兩個語句其本質是一樣的,都是生成一個空對象。對象字面量也可以用來寫數組以及更加復雜的對象,這樣:

var weekDays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];

  這樣:

var person = { name : "youxia", age : 30, gender : "male", sayHello : function(){ return "Hello, my name is " + this.name; } }

  甚至這樣數組和對象互相嵌套:

var workers = [{name : "somebody", speciality : "Java"}, {name : "another", speciality : ["HTML", "CSS", "JavaScript"]}];

  需要注意的是,對象字面量中的分隔符都是逗號而不是分號,而且即使 JavaScript 對象字面量的寫法和 JSON 的格式相似度很高,但是它們還是有本質的區別的。

  在我們搗鼓 JavaScript 的過程中,工具是非常重要的。我這里介紹的第一個工具就是 Chromium 瀏覽器中自帶的 JavaScript 控制台。在 Ubuntu 中安裝 Chromium 瀏覽器只需要一個命令就可以搞定,如下:

sudo apt-get install chromium

  啟動 Chromium 瀏覽器后,只需要按 F12 就可以調出 JavaScript 控制台。當然,在菜單中找出來也可以。下面,讓我把上面的示例代碼輸入到 JavaScript 控制台中,一是可以看看我們寫的代碼是否有語法錯誤,二是可以看看 JavaScript 對象的真面目。如下圖:

  對於博客園中廣大的前端攻城獅來講,Chromium 的 JavaScript 控制台已經是一個爛大街的工具了,在控制台中寫console.log("Hello, World!");就像是在 C 語言中寫printf("Hello, World!");一樣成為了入門標配。在控制台中輸入 JavaScript 語句后,一按 Enter 該行代碼就立即執行,如果要輸入多行代碼怎么辦呢?一個辦法就是按 Shift+Enter 進行換行,另外一個辦法就是在別的編輯器中寫好然后復制粘貼。其實在 Chromium 的 JavaScript 控制台中還有一些不那么廣泛流傳的小技巧,比如使用console.dir()函數輸出 JavaScript 對象的內部結構,如下圖:

  從圖中,可以很容易看出每一個對象的屬性、方法和原型鏈。

  和其它的面向對象編程語言不同, JavaScript 不是基於類的代碼復用體系,它選擇了一種很奇特的基於原型的代碼復用機制。通俗點說,如果你想創建很多對象,而這些對象有某些相同的屬性和行為,你為每一個對象編寫單獨的代碼肯定是不合算的。在其它的面向對象編程語言中,你可以先設計一個類,然后再以這個類為模板來創建對象。我這里稱這種方式為經典的面向對象體系。而在 JavaScript 中,解決這個問題的方式是把一個對象作為另外一個對象的原型,擁有相同原型的對象自然擁有了相同的屬性和行為。對象擁有原型,原型又有原型的原型,最終構成一個原型鏈。當訪問一個對象的屬性或方法的時候,先在對象本身中查找,如果找不到,則到原型中查找,如果還是找不到,則進一步在原型的原型中查找,一直到原型鏈的最末端。在現代 JavaScript 模式中,硬是用函數、閉包和原型鏈模擬了經典的面向對象體系。

  原型這個概念本身並不復雜,復雜的是 JavaScript 中的隱式原型和函數對象。什么是隱式原型,就是說在 JavaScript 中不管你以什么方式創建一個對象,它都會自動給你生成一個原型對象,我們的對象中,有一個隱藏的__proto__屬性,它指向這個自動生成的原型對象;並且在 JavaScript 中不管你以什么方式創建一個對象,它最終都是從構造函數生成的,以對象字面量構造的對象也有構造函數,它們分別是Object()Array(),每一個構造函數都有一個自動生成的prototype屬性,它也指向那個自動生成的原型對象。而且在 JavaScript 中一切都是對象,構造函數也不例外,所以構造函數既有prototype屬性,又有__proto__屬性。再而且,自動生成的原型對象也是對象,所以它也應該有自己的原型對象。你看,說起來都這么拗口,理解就更加不容易了,更何況 JavaScript 中還內置了Object()Array()String()Number()Boolean()Function()這一系列的構造函數。看來不畫個圖是真的理不順了。下面我們來抽絲剝繭。

  先考察空對象someThing,哪怕它是以對象字面量的方式創建的,它也是從構造函數Object()構造出來的。這時,JavaScript 會自動創建一個原型對象,我們稱這個原型對象為Object.prototype,構造函數Object()prototype屬性指向這個對象,對象someThing__proto__屬性也指向這個對象。也就是說,構造函數Object()prototype屬性和對象someThing__proto__屬性指向的是同一個原型對象。而且,這個原型對象中有一個constructor屬性,它又指回了構造函數Object(),這樣形成了一個環形的連接。如下圖:

  要注意的是,這個圖中所顯示的關系是對象剛創建出來的時候的情況,這些屬性的指向都是可以隨意修改的,改了就不是這個樣子了。下面在 JavaScript 控制台中驗證一下上圖中的關系:

  請注意,構造函數Object()prototype屬性和__proto__屬性是不同的,只有函數對象才同時具有這兩個屬性,普通對象只有__proto__屬性,而且這個__proto__屬性是隱藏屬性,不是每個瀏覽器都允許訪問的,比如 IE 瀏覽器。下面,我們來看看 IE 瀏覽器的開發者工具:

  這是一個反面教材,它既不支持console.dir()來查看對象,也不允許訪問__proto__內部屬性。所以,在后面我講到繼承時,需要使用特殊的技巧來避免在我們的代碼中使用__proto__內部屬性。上面的例子和示意圖中,都只說構造函數Object()prototype屬性指向原型對象,沒有說構造函數Object()__proto__屬性指向哪里,那么它究竟指向哪里呢?這里先留一點懸念。

  下一步,我們自己創建一個構造函數,然后使用這個構造函數創建一個對象,看看它們之間原型的關系,代碼是這樣的:

function Person(name, age, gender){ this.name = name; this.age = age; this.gender = gender; } Person.prototype.sayHello = function(){ return "Hello, my name is " + this.name; }; var somebody = new Person("youxia", 30, "male");

  輸入到 Chromium 的 JavaScript 控制台中,然后使用console.dir()分別查看構造函數Person()和對象somebody,如下兩圖:

  用圖片來表示它們之間的關系,應該是這樣的:

  我使用藍色表示構造函數,黃色表示對象,如果是 JavaScript 自帶的構造函數和 prototype 對象,則顏色深一些。從上圖中可以看出,構造函數Person()有一個prototype屬性和一個__proto__屬性,__proto__屬性的指向依然留懸念,prototype屬性指向Person.prototype對象,這是系統在我們定義構造函數Person()的時候,自動創建的一個和構造函數Person()相關聯的原型對象,請注意,這個原型對象是和構造函數Person()相關聯的原型對象,而不是構造函數Person()的原型對象。當我們使用構造函數Person()創建對象somebody時,somebody的原型就是這個系統自動創建的原型對象Person.prototype,就是說對象somebody__proto__屬性指向原型對象Person.prototype。而這個原型對象中有一個constructor屬性,又指回構造函數Person(),形成一個環。這和空對象和構造函數Object()是一樣的。而且原型對象Person.prototype__proto__屬性指向Object.prototype。如果在這個圖中把空對象和構造函數Object()加進去的話,看起來是這樣的:

  有點復雜了,是嗎?不過這還不算最復雜的,想想看,如果把JavaScript 內置的Object()Array()String()Number()Boolean()Function()這一系列的構造函數以及與它們相關聯的原型對象都加進去,會是什么情況?每一個構造函數都有一個和它相關聯的原型對象,Object()Object.prototypeArray()Array.prototype,依此類推。其中最特殊的是Function()Function.prototype,因為所有的函數和構造函數都是對象,所以所有的函數和構造函數都有構造函數,而這個構造函數就是Function()。也就是說,所有的函數和構造函數都是由Function()生成,包括Function()本身。所以,所有的構造函數的__proto__屬性都應該指向Function.prototype,前面留的懸念終於有答案了。如果只考慮構造函數Person()Object()Function()及其關聯的原型對象,在不解決懸念的情況下,圖形是這樣的:

  可以看到,每一個構造函數和它關聯的原型對象構成一個環,而且每一個構造函數的__proto__屬性無所指。通過前面的分析我們知道,每一個函數和構造函數的__proto__屬性應該都指向Function.prototype。我用紅線標出這個關系,結果應該如下圖:

  如果我們畫出前面提到過的所有構造函數、對象、原型對象的全家福,會是個什么樣子呢?請看下圖:

  暈菜了沒?歡迎指出錯誤。把圖一畫,就發現其實 JavaScript 中的原型鏈沒有那么復雜,有幾個內置構造函數就有幾個配套的原型對象而已。我這里只畫了六個內置構造函數和一個自定義構造函數,還有幾個內置構造函數沒有畫,比如Date()Math()Error()RegExp(),但是這不影響我們理解。寫到這里,是不是應該介紹一下我使用的畫圖工具了?

 

我使用的畫圖工具Graphviz

  在我的 Linux 系列中,有一篇介紹畫圖工具的文章,不過我這次使用的工具是另辟蹊徑的 Graphviz,據說這是一個由貝爾實驗室的幾個牛人開發和使用的畫流程圖的工具,它使用一種腳本語言定義圖形元素,然后自動進行布局和生成圖片。首先,在 Ubuntu 中安裝 Graphiz 非常簡單,一個命令的事兒:

sudo apt-get install graphviz

  然后,創建一個文本文件,我這里把它命名為sample.gv,其內容如下:

digraph GraphvizDemo{

    Alone_Node;
    
    Node1 -> Node2 -> Node3;
    
}

  這是一個最簡單的圖形定義文件了,在 Graphviz 中圖形僅僅由三個元素組成,它們分別是:1、Graph,代表整個圖形,上面源代碼中的digraph GraphvizDemo{}就定義了一個 Graph,我們還可以定義 SubGraph,代表子圖形,可以用 SubGraph 將圖形中的元素分組;2、Node,代表圖形中的一個節點,可以看到 Node 的定義非常簡單,上面源碼中的Alone_Node;就是定義了一個節點;3、Edge,代表連接 Node 的邊,上面源碼中的Node1 -> Node2 -> Node3;就是定義了三個節點和兩條邊,可以先定義節點再定義邊,也可以直接在定義邊的同時定義節點。然后,調用 Graphviz 中的dot命令,就可以生成圖形了:

dot -Tpng sample.gv > sample.png

  生成的圖形如下:

  上面的圖形中都是用的默認屬性,所以看起來效果不咋地。我們可以為其中的元素定義屬性,包括定義節點的形狀、邊的形狀、節點之間的距離、字體的大小和顏色等等。比如下面是一個稍微復雜點的例子:

digraph GraphvizDemo{
    
    nodesep=0.5;
    ranksep=0.5;
        
    node [shape="record",style="filled",color="black",fillcolor="#f4a582",fontname="consolas",fontsize=15];
    edge [style="solid",color="#053061"];
        
    root  [label="<l>left|<r>right"];
    left  [label="<l>left|<r>right"];
    right [label="<l>left|<r>right"];
    leaf1 [label="<l>left|<r>right"];
    leaf2 [label="<l>left|<r>right"];
    leaf3 [label="<l>left|<r>right"];
    leaf4 [label="<l>left|<r>right"];
    
    root:l:s -> left:n;
    root:r:s -> right:n;
    left:l:s -> leaf1:n;
    left:r:s -> leaf2:n;
    right:l:s -> leaf3:n;
    right:r:s -> leaf4:n;
}

  在這個例子中,我們使用了nodesep=0.5;ranksep=0.5設置了 Graph 的全局屬性,使用了node [shape=...];[edge [style=...];這樣的語句設置了 Node 和 Edge 的全局屬性,並且在每一個 Node 和 Edge 后面分別設置了它們自己的屬性。在這些屬性中,比較特別的是 Node 的shape屬性,我將它設置為record,這樣就可以很方便地利用 Node 的label屬性來繪制出類似表格的效果了。同時,在定義 Edge 的時候還可以指定箭頭的起始點。

  執行dot命令,可以得到這樣的圖形:

  是不是漂亮了很多?雖然以上工作使用任何文本編輯器都可以完成,但是為了提高工作效率,我當然要祭出我的神器 Eclipse 了。在 Eclipse 中可以定義外部工具,所以我寫一個 shell 腳本,將它定義為一個外部工具,這樣,每次編寫完圖形定義文件,點一下鼠標,就可以自動生成圖片了。使用 Eclipse 還可以解決預覽的問題,只需要編寫一個 html 頁面,該頁面中只包含生成的圖片,就可以利用 Eclipse 自帶的 Web 瀏覽器預覽圖片了。這樣,每次改動圖形定義文件后,只需要點一下鼠標生成圖片,再點一下鼠標刷新瀏覽器就可以實時預覽圖片了。雖然不是所見即所得,但是工作效率已經很高了。請看動畫:

  

作用域鏈、上下文環境和閉包

  關於變量的作用域這個問題應該不用多講,凡是接觸編程的童鞋,無不都要從這個基礎的概念開始。變量作用域的通用規則其實很簡單,無非三條:1.內層的代碼可以訪問外層代碼定義的變量,外層代碼不能訪問內層代碼定義的變量;2.變量要先定義后使用;3.退出代碼的作用域時,變量會被銷毀。以 C 語言代碼為例:

int a0 = 0; { int a1 = 1; printf("%d\n", a0); //可以訪問外層變量,打印 0 printf("%d\n", a2); //錯誤,變量 a2 還沒定義呢 int a2 = 2; //變量要先定義后使用 } /* 而且,退出作用域后,變量 a1 和 a2 會被自動銷毀 */ printf("%d\n", a1); //錯誤,外層代碼不能訪問內層變量

  但是在 JavaScript 中,以上三條規則都有可能會被打破。從現在開始,我們就要開始踩坑了,在 JavaScript 語言滿滿的陷阱中,關於變量這一塊的最多。首先第一個坑, JavaScript 中沒有塊作用域,只有函數作用域。也就是說,要在 JavaScript 中實現以上類似 C 語言的效果,我們的代碼應該這樣寫:

var a0 = 0; function someFunc(){ var a1 = 1; console.log(a1); //可以訪問外層變量,打印 0 console.log(a2); //你以為會出現錯誤,因為變量沒有定義,但是你錯了,這里不會發生錯誤,而是打印 undefined var a2 = 2; } someFunc(); /* someFunc()執行完之后,變量 a1 和 a2 會被自動銷毀 */ console.log(a1); //錯誤,外層代碼不能訪問內層變量

  把這段代碼復制到控制台中驗證一下,我就不截圖了,畢竟我這是一篇超長的熊文,圖片太多會被罵的,大家自己驗證就可以了。注意,定義函數后需要調用它,函數內的代碼才會執行,為了方便,我以后把它寫成定義完后立即調用的自執行格式。這里碰到的第二個坑就是變量提升,在 JavaScript 中,你本以為沒有定義變量 a2 就使用會出現錯誤,哪知道定義在后面的var a2 = 2;被提升到代碼塊的前面了,結果就輸出 undefined。把上面的例子稍微改一改,就可以看到經典的變量提升的坑,如下:

var a0 = 0; (function (){ var a1 = 1; console.log(a0); //本以為會訪問外層變量a0,打印 0,哪知道定義在后面的 var a0 = 1; 被提升了,所以打印 undefined var a0 = 1; })(); //為了省事,寫成匿名函數自執行格式 console.log(a1); //錯誤,外層代碼不能訪問內層變量

  本以為這里會訪問外層變量a0,打印 0,哪知道定義在后面的 var a0 = 1; 被提升了,所以打印 undefined。為什么是 undefined 而不是 1 呢?那是因為變量提升只是提升了變量的定義,沒有提升變量的賦值。不僅變量定義會被提升,函數定義也會被提升,這也是一個經典的坑。如下代碼:

if(true){ //因為條件恆為true,所以肯定會執行這個分支 function someFunc(){ console.log("true"); } }else{ function someFunc(){ console.log("false"); } } someFunc(); //本以為會輸出 true,結果卻輸出 false,就是因為定義在 else 分支中的函數被提升了,覆蓋了定義在 true 分支中的函數

  當然,以上 Bug 只會在部分瀏覽器中出現,在 Chromium 和 FireFox 中還是能正確輸出 true 的。為了避免函數定義的提升造成的問題,在這種情況下,我們可以使用函數表達式而不是函數定義,代碼如下:

if(true){ //因為條件恆為true,所以肯定會執行這個分支 var someFunc = function(){ console.log("true"); } }else{ var someFunc = function(){ console.log("false"); } } someFunc();

  關於函數定義和函數表達式的區別,我這里就不深入討論了。

  內層代碼可以訪問外層變量,所以內層代碼在訪問一個變量的時候,會從內層到外層逐層搜索該變量,這就是變量作用域鏈,理解這一點有時有助於我們優化 JavaScript 代碼的執行速度,對變量的搜索的路徑越短,代碼執行就越快。另外,除了全局變量外,定義在函數內部的變量只有在函數執行的時候后,這個變量才會被創建,這就是執行上下文,裝逼說法叫 context,每一個函數執行的時候就會創建一個 context。前面提過,在 C 語言中,一個代碼塊退出的時候,這個代碼塊的 context 和里面的變量也會被銷毀,但是在 JavaScript 函數執行結束后,函數的 context 和里面的變量會被銷毀嗎?那可不一定哦。如果一個函數中定義的變量被捕獲,那么這個函數的 context 和里面的變量就會保留,比如閉包。這個不叫坑,叫語言特性。

  在博客園中,有很多人寫閉包,但是都寫得無比復雜,定義也不是很准確。其實閉包就是定義在內層的函數捕獲了定義在外層函數中的變量,並把內層函數傳遞到外層函數的作用域之外執行,則外層函數的 context 不能銷毀,就形成了閉包。把內層函數傳遞到外層函數的作用域之外有很多方法,最常見的是使用return,其它的方法還有把內層函數賦值給全局對象的屬性,或者設置為某個控件的事件處理程序,甚至使用setTimeoutsetInterval都可以。

  其實閉包並不是 JavaScript 語言特有的概念,只要是把函數當成頭等對象的語言都有。C 語言和早期的 C++ 和 Java 沒有,想想看,我們根本就沒辦法在上述語言中定義函數內部的函數。不過自從 C++ 和 Java 引入了 lambda 表達式之后,就有了閉包的概念了。

  下面,我們來探索 JavaScript 中的函數執行上下文和閉包。為了印象深刻,我這里定義了一個嵌套四層的函數,函數first()返回定義在first()內的second()second()返回定義在second()內的third()third()再返回一個匿名函數,代碼如下:

var a0 = 0; var b0 = "Global context"; function first(){ var a1 = 1; var b1 = "first() context"; function second(){ var a2 = 2; var b2 = "second() context"; function third(){ var a3 = 3; var b3 = "third() context"; return function(){ var a4 = 4; var b4 = "what's matter, can I see it?"; console.log([ a1, a2, a3, a4 ]); console.log([ b1, b2, b3, b4]); } } return third; } return second; }

  然后,調用var what = first()()();返回最內層的匿名函數,使用console.dir(what);來查看這個匿名函數,如下圖:

  從圖中可以看到,返回的最內層函數被命名為function anonymous(),其中有一個<function scope>屬性,將它展開,可以看到由於function anonymous()對外層變量a1a2a3b1b2b3的捕獲而產生了三個 Closure,也就是閉包,而function anonymous()不僅可以訪問這三個閉包中的變量,還可以訪問 Global 中的變量。

  下面問題來了,為什么我們看不到我們定義的變量a4b4呢?因為a4b4只有在function anonymous()被執行后才會產生。我們這里只是返回了function anonymous(),還沒有執行它呢。其實就算執行它我們也看不到變量a4b4所在的 context,因為函數的執行總是一閃而過,如果沒有形成閉包,函數一執行完該 context 就銷毀了。除非我們能讓該函數執行到快完的時候定住。有什么辦法呢?你是不是想到了調試器?只要我們在這個函數中設置一個 breakpoint,是不是就可以看到它的 context 了呢?

  Chromium 當然是自帶調試功能的。不過要想在 Chromium 中調試代碼就得把以上 JavaScript 代碼加到 HTML 頁面中。我懶得這么做。這里,我就要祭出 Node.js 和 Visual Studio Code 了。在 Ubuntu 中安裝 Node.js 非常方便,只需要使用如下命令:

sudo apt-get install nodejs sudo apt-get install nodejs-legacy

  為什么要安裝nodejs-legacy呢?那是因為nodejs中的命令是nodejs,而nodejs-legacy中的命令是node,同時安裝這兩個包可以兼容不同的命令調用方式,其實它們本質是一樣的。而編輯器技術哪家強?自從有了 Visual Studio Code 自然就不考慮其它的了。不過 Visual Studio Code 需要自己去它的 官網 下載。

  把上面的代碼寫成一個.js文件,然后在編輯器中每個函數的返回點設置斷點,直接使用 Node.js 的調試功能,就可以查看所有的函數執行時的 context 了,如下動圖:

  把斷點設置在每一個函數的最后一條語句,按 F5 開始調試,每次暫停都可以看到這個函數執行時產生的 context,在這個 context 中,可以看到該函數中定義的變量和函數,也就是其中顯示的Local范圍的變量,以及該函數可以訪問的外層變量,也就是其中顯示的ClosureGlobal范圍的變量。使用調試功能,我們終於可以看到a4b4了,同時還可以發現,在每一個函數的 context 中,都有一個特殊的變量this,下一節,我們來討論函數this函數原型閉包this是使用 JavaScript 模擬經典的基於類的面向對象編程的基本要素。不過在進入下一節之前,我還要來展示一下 Eclipse。

  Eclipse 的最新版本 neon 終於改進了,在前一個版本中,它只支持 ECMAScript 3,而且其網頁預覽還是使用的 Webkit-1.0,在今年發布的這個新版本中,終於支持 ECMAScript 5了,Webkit 也用到了最新版。還加入了對 Node.js 的支持。不過 Eclipse 中關於 JavaScript 的智能提示似乎還是很差勁。Eclipse 的更新速度實在是太慢了。不過用 Eclipse 配合 Node.js 調試 JavaScript 也還不錯,下面直接上圖:

  還有 Eclipse 的死對頭,IntelliJ IDEA 和 WebStorm 調試 JavaScript 也是不錯的,我就不多說了。

  關於內層函數怎么捕獲變量的問題,在編程語言界還有一個經典的爭議,那就是關於詞法作用域和動態作用域的爭議。所謂詞法作用域,就是在函數定義時的環境中去尋找外層變量,而動態作用域,就是在函數運行時的環境中去尋找外層變量。大多數現在程序設計語言都是采用詞法作用域規則,而只有為數不多的幾種語言采用動態作用域規則,包括APL、Snobol和Lisp的某些方言,還有 C 語言中的宏定義。很顯然, JavaScript 采用的是詞法作用域,變量的作用域鏈是在函數定義的時候就決定了的。而對於動態作用域的例子,我們可以看看如下的用 LISP 語言定義的一個函數:

(let ((y 7)) (defun scope-test (x) (list x y)))

  這個函數調用時,如果是采用動態作用域的語言中,如 emacs lisp,它不是在定義它的環境中去尋找自由變量y,也就是說y的值不是7,而是在它運行的環境中向前回溯,尋找變量y的值,所以這樣的代碼:

(let ((y 5)) (scope-test 3))

  在 emacs lisp 的運行結果為(3 5),而在采用詞法作用域規則的編程語言中,如 common lisp,它會在定義函數的環境中尋找自由變量y的值,所以這段代碼的運行結果為(3 7)

  另外,還有一個關於閉包和循環的一個經典的坑,當閉包遇到循環的時候,如下代碼:

(function(){ var i; for(i = 1; i <= 10; i++){ setTimeout(function(){console.log(i);}, 500); //本以為會輸出數字 1-10,結果輸出了 10 次 11 } })();

  在上面代碼中,我為了簡潔,都使用了匿名函數。之所以會出現這樣意想不到的結果,就是因為定義在內層的匿名函數都捕獲了外層函數中的變量i,所以當它們運行的時候,都是輸出的這個i的最終的值,那就是11。如果要想得到預期的輸出 1-10 這樣的結果,就應該在定義內層函數的時候讓它接受一個參數,然后把i當做參數傳遞給它。代碼改成這樣就行:

(function(){ var i; for(i = 1; i <= 10; i++){ setTimeout((function(a){console.log(a);})(i), 500); } })();

  全部寫成匿名函數自調用格式簡潔是簡潔了不少,但是可讀性就差了許多。網上的關於這個坑的描述所用的示例代碼往往是將內層函數設置為某個按鈕的onClick事件處理程序,而我不想在我的示范中和 BOM、DOM 產生太多的耦合,所以我選擇了setTimeout()。如果不信,可以自己在 Chromium 的 JavaScript 控制台中驗證效果。

函數和this

  從前面的調試過程中我們可以看出,每一個函數執行的 context 中都有一個特殊的變量this。對this大家都不會陌生,很多面向對象的編程語言中都有,但是在 JavaScript 中,this會稍有不同,它的取值會隨着函數的調用方式不同而變化。JavaScript 中函數的調用方式多種多樣,總結起來主要有四種:

  1. 做為構造函數調用,比如前面的new Person();new Object();
  2. 做為對象的方法調用,比如前面的somebody.sayHello();
  3. 做為普通函數調用,這是用得最多的,比如前面的first();what();
  4. 通過applycallbind方式調用,這種調用方式我后面會舉例。

  在第一種調用方式中,this的取值就是該構造函數即將創建的對象。在第二種方式中,this的取值就是該方法所在的對象。這兩種調用方式和經典的面向對象編程語言沒有什么不同,非常容易理解。第三種方式,做為普通函數調用,這時,函數中的this永遠都指向全局對象,不管函數的定義嵌套得有多深,切記切記。而第四中調用方法最特別,它可以改變函數中this的取值,因此,這種方式調用最靈活,妙用最多,這個需要幾個例子才能說明。先回顧一下我前面定義的Person()構造函數以及somebody對象:

function Person(name, age, gender){ this.name = name; this.age = age; this.gender = gender; } Person.prototype.sayHello = function(){ console.log("Hello, my name is " + this.name); }; var somebody = new Person("youxia", 30, "male");

  如果我們調用:

somebody.sayHello(); //sayHello()中的this指向somebody,所以輸出"Hello, my name is youxia"

  那么這個sayHello();方法中的this指向somebody對象,所以輸出結果很符合預期。但是,如果該函數不是通過對象的方法調用,結果就會大不相同。比如這樣:

var sayHi = somebody.sayHello; sayHi(); //做為普通函數調用,該函數中的this指向全局變量所以輸出"Hello, my name is "

  在上面的例子中,因為全局變量中沒有name屬性,所以輸出的結果中就沒有名字了。

  然后,我為了偷懶,不想定義一個構造函數,只使用對象字面量定義了一個對象worker,代表一個具有Java技術的程序員,如下:

var worker = {name:"javaer", speciality:"Java"};

  這個對象沒有sayHello()方法,但是我們可以這樣借用somebodysayHello()方法:

somebody.sayHello.call(worker); //輸出"Hello, my name is javaer"

  所有的函數都可以通過.call().apply().bind()的形式調用,因為這三個方法是定義在Function.prototype中的,而所有的函數的原型鏈中都有Function.prototype。這三個函數都會把調用函數的this設置為這幾個方法的第一個參數。所不同者,.call()是接受任意多個參數,而.apply()只接受兩個參數,其第二個參數必須是一個數組,而.bind()返回另外一個函數,這個函數的this綁定到.bind()的參數所指定的對象。

  可以看到,如果某個對象具有和其它對象相同的屬性,比如這里的name屬性,就通過.call()的方式借用別的對象的方法。由於.apply()接受的第二個參數是一個數組,所以,如果有某個函數本身只接受不定數量的參數,而要操作的確是一個數組的時候,就可以用.apply()來在它們之間適配。最常見的例子就是Math.max()方法,該方法接受的是不定數量的參數,假如我們手頭只有一個數組,比如這樣:

var numbers = [3, 2, 5, 1, 7, 9, 8, 2];

  而我們又要找出數組中的最大值的話,可以這樣調用:

Math.max.apply(null, numbers);

  把第一個參數設置為null,則Math.max()中的this就會自動指向全局對象。不過在這個例子中,this的值不重要。這里只是改變了Math.max()方法接受參數的形式。

  在 JavaScript 中經常使用.call()調用來借用內置對象的方法,最常見的是借用Object.prototype.toString()方法。雖然我們所有的對象都是從Object繼承,所有的對象都有從Object繼承的toString()方法,但是,這些方法可以隨時被重寫。比如在我們前面定義的Person類中,我們可以重寫它的toString()方法,如下:

Person.prototype.toString = function(){ return 'Person {name: "' + this.name + '", age: ' + this.age + ', gender: "' + this.gender + '"}'; }

  這時,調用somebodytoString()方法,會得到這樣的輸出:

somebody.toString(); //輸出 "Person {name: "youxia", age: 30, gender: "male"}"

  但是如果借用Object.prototype.toString()方法,則會得到另外一種輸出:

Object.prototype.toString.call(somebody); //輸出 "[object Object]"

  所以這種技術常被各種庫用來判斷對象的類型。如下:

Object.prototype.toString.call(somebody); //輸出 "[object Object]" Object.prototype.toString.call(Person); //輸出 "[object Function]" Object.prototype.toString.call("Hello, World!"); //輸出 "[object String]" Object.prototype.toString.call(["one", "two", "three"]); //輸出 "[object Array]" Object.prototype.toString.call(3.14); //輸出 "[object Number]"

  從上面可以看出,使用.call()借用別的對象中的方法,不會受到本對象中重寫的同名方法的影響。所以,也可以在子類中使用此技巧調用父類中的方法,后面我講面向對象和繼承的時候會用到這個技巧。

  下面又要開始踩坑了,這個坑是關於this的。上面提到過,凡是作為普通函數調用的函數,其 context 中的this都是指向全局對象的。所以,如果我們在某個對象的構造函數或方法中定義了內部函數,本以為使用this可以訪問這個新構造的對象,結果會事與願違。如下代碼:

function Worker(name, speciality){ this.name = name; this.speciality = speciality; this.doWork = function(){ function work(){console.log(this.name + " is working with " + this.speciality);} work(); } } var worker = new Worker("youxia", "Java"); worker.doWork();

  本以為會輸出"youxia is working with Java",但是由於其中定義的work()是一個普通函數,所以其中的this指向全局對象,而全局對象的namespeciality屬性是沒有定義的,所以會輸出"is working with undefined"。如果要解決這個問題,可以在構造函數中先臨時保存this的值,在網絡中,大家一般喜歡用that這個詞。更改后的代碼如下:

function Worker(name, speciality){ this.name = name; this.speciality = speciality; var that = this; this.doWork = function(){ function work(){console.log(that.name + " is working with " + that.speciality);} work(); } } var worker = new Worker("youxia", "Java"); worker.doWork();

  這回輸出就完全正確了。同時,這里也提示出一個小技巧,那就是當我們位於一個閉包中時,如果想訪問全局對象,只需要定義一個普通函數,然后訪問這個普通函數的this即可。

 

用JavaScript模擬經典的面向對象編程

  經典的面向對象編程語言比如 C++、C#、Java 等都是基於類的,它們都有一套成熟的體系,包括對象的構造、類的繼承、對象的多態、對象屬性的訪問控制等。在 JavaScript 中,多態這個問題可以不用考慮,因為 JavaScript 語言本身就是動態的,所以不存在類型不符合就編譯不通過這樣的問題。在 JavaScript 中主要考慮的問題就是對象的構造和繼承的問題。

  對象的構造是需要首先考慮的問題,其目標就是要獲得一個合理的對象內存布局。在 JavaScript 中沒有類的概念,但是有構造函數和this就足夠了,所以我們可以這樣簡單地創建對象:

function Person(name, age, gender){ this.name = name; this.age = age; this.gender = gender; this.sayHello = function(){ console.log("Hello, my name is " + this.name); }; } var somebody = new Person("somebody", 30, "male"); var another = new Person("another", 20, "female");

  這和經典的面向對象編程語言在形式上是很像的,經典的面向對象的編程語言是在類里面定義屬性和方法,而這里是在構造函數中定義屬性和方法。然而,仔細分析的話,其在內存布局上還有不合理的地方,經典的面向對象編程語言中每個對象的屬性是單獨的,但是方法在內存中只有一個拷貝,而上述 JavaScript 代碼每構建一個對象,都會為每個對象定義一個方法,如果對象數量很大的話,就會浪費很多內存。

  根據所有對象共享方法的原則,以及 JavaScript 的語言特色,我們應該把方法放到其原型中,所以代碼應更改如下:

function Person(name, age, gender){ this.name = name; this.age = age; this.gender = gender; } Person.prototype.sayHello = function(){ console.log("Hello, my name is " + this.name); }; var somebody = new Person("youxia", 30, "male"); var another = new Person("another", 20, "female");

  同時,如果為了防止別人在調用構造函數的時候忘記使用new而踩入this的陷阱的話,該代碼還可以繼續這樣完善:

function Person(name, age, gender){ if(!(this instanceof Person)){ return new Person(name, age, gender); } this.name = name; this.age = age; this.gender = gender; } Person.prototype.sayHello = function(){ console.log("Hello, my name is " + this.name); }; var somebody = new Person("youxia", 30, "male"); var another = new Person("another", 20, "female");

  下面再來看繼承。假設我們每個人都有一個工作者身份,我們會使用我們掌握的某項技能進行工作,這里用 Worker 代表工作者,而 Worker 從 Person 繼承。我們先來寫 Worker,由於 JavaScript 是一個基於原型的語言,所以理論上講,要讓 Worker 繼承自 Person,只需要把 Person 類的一個對象加入到 Worker 的原型鏈中即可,如下:

function Worker(name, age, gender, speciality){ this.name = name; this.age = age; this.gender = gender; this.speciality = speciality; } Worker.prototype = new Person(name, age, gender); Worker.prototype.doWork = function(){console.log(this.name + " is working with " + this.speciality);}

  很顯然,這也不是很合理的,在這里需要構建一個 Person 類的對象(這里暫且這么稱呼吧,雖然 JavaScript 中沒有類),而構建 Person 類的對象時又要傳遞參數,這些參數哪里來呢?很顯然編碼不是很方便。同時,既然在 Person 類的對象中構造了nameagegender等屬性,再在 Worker 類的對象中構建一次就重復了。而且修改了Worker.prototype后,constructor屬性也變了,還要一條語句改回來。如果從經典的面向對象編程語言的角度來考慮,我們需要繼承的僅僅只是 Person 類中的方法而已。如果從 JavaScript 語言的角度分析,我們只需要把Person.prototype加入到 Worker 類的對象的原型鏈中即可。代碼是這樣:

Worker.prototype.__proto__ = Person.prototype;

  我們還可以使用前面提到的.call()來借用 Person 類的構造函數讓代碼更簡潔。完整的繼承代碼如下:

function Worker(name, age, gender, speciality){ Person.call(this, name, age, gender); this.speciality = speciality; } Worker.prototype.__proto__ = Person.prototype; Worker.prototype.doWork = function(){console.log(this.name + " is working with " + this.speciality);}

  這樣使用它:

var worker = new Worker("youxia", 30, "male", ["JavaScript","HTML","CSS"]); worker.sayHello(); //從Person類繼承的 worker.doWork(); //Worker類中自己定義的

  這是目前最接近經典面向對象語言的 JavaScript 模擬了。不過還有一個小小的問題,在 JavaScript 中,__proto__是一個隱藏屬性,不是所有的 JavaScript 平台都支持的,比如前面展示的 IE 瀏覽器這個反面教材。這時,還是要把Worker.prototype設置為一個 Person 類的對象,但是,構建一個 Person 類的對象是個浪費,所以我們可以借助一個空構造函數來完成這個事情:

function EmptyFunc(){} EmptyFunc.prototype = Person.prototype; Worker.prototype = new EmptyFunc();

  當然,別忘了把constructor改回來:

Worker.prototype.constructor = Worker;

  這幾條語句有點多,所以可以寫一個輔助函數來解決這個問題:

function inherit(Sub, Super){ function F(){} F.prototype = Super.prototype; Sub.prototype = new F(); Sub.constructor = Sub; }

  完整代碼如下:

function inherit(Sub, Super){ function F(){} F.prototype = Super.prototype; Sub.prototype = new F(); Sub.constructor = Sub; } function Person(name, age, gender){ if(!(this instanceof Person)){ return new Person(name, age, gender); } this.name = name; this.age = age; this.gender = gender; } Person.prototype.sayHello = function(){ console.log("Hello, my name is " + this.name); }; var somebody = new Person("youxia", 30, "male"); var another = new Person("another", 20, "female"); function Worker(name, age, gender, speciality){ if(!(this instanceof Person)){ return new Worker(name, age, gender, speciality); } Person.call(this, name, age, gender); this.speciality = speciality; } inherit(Worker, Person); Worker.prototype.doWork = function(){console.log(this.name + " is working with " + this.speciality);} var worker = new Worker("youxia", 30, "male", ["JavaScript","HTML","CSS"]); worker.sayHello(); //從Person類繼承的 worker.doWork(); //Worker類中自己定義的

  上面的代碼運行結果如下圖:

 

JavaScript的模塊化寫法

  更進一步,還要解決一個問題,那就是怎么把這么大一坨代碼封裝起來,專業的說法,那叫模塊化寫法。

  JavaScript 的一個缺陷就是它沒有模塊化的機制,像前文中我所寫的所有構造函數都是直接暴露在全局作用域中的,這很不科學,一是污染了全局作用域,二是容易和別人寫的代碼發生沖突。當代碼量增大的時候,肯定要考慮將我們自己的代碼組織成一個模塊。怎么辦呢?很顯然,在 JavaScript 中只能用自執行函數和閉包來模擬。比如這樣:

(function(){ function inherit(Sub, Super){ ... } function Person(name, age, gender){ ... } Person.prototype.sayHello = function(){ console.log("Hello, my name is " + this.name); }; function Worker(name, age, gender, speciality){ ... } inherit(Worker, Person); Worker.prototype.doWork = function(){console.log(this.name + " is working with " + this.speciality);} window.myModule = { inherit : inherit, Person : Person, Worker : Worker }; })();

  代碼比較長,我省略了一部分。最關鍵的代碼其實是最后的幾句,我們通過window對象的myModule屬性來暴露我們想暴露的函數和構造函數。然后,我們可以在 HTML 頁面中這樣使用:

<!DOCTYPE html> <html > <head> <meta charset="utf-8"> <script src="./myModule.js"></script> </head> <body> <script> var worker = new myModule.Worker("youxia", 30, "male", ["Java","C++"]); worker.doWork(); </script> </body> </html>

  下面是我在 FireFox 瀏覽器的開發者工具中的截圖,在調試器中可以看到源文件,添加一個斷點,就可以在右側看到定義在myModule中的函數和構造函數了。Chromium 很牛,FireFox 也不錯。

  當然,這只是一個最簡單的前端模塊。我是直接把模塊的 JavaScript 文件路徑寫死在 HTML 中的。我在 HTML 中直接引用我自己的模塊是沒有問題的。但是在使用別人寫的模塊的時候就不一定有這么簡單了,因為別人的模塊中可能會引用更多另外的模塊,而這些互相引用的模塊我們不可能全部都寫死在 HTML 中,我們更加不可能控制這些模塊的加載順序。因此,需要有統一的模塊規范來解決模塊加載和依賴的問題。目前,在瀏覽器中常用的規范有 AMD 規范和 CMD 規范。

  AMD 規范是指異步模塊定義,它是 RequireJS 在推廣過程中對模塊定義的規范化產出。在 AMD 中,所有的模塊將被異步加載,模塊加載不影響后面語句運行。所有依賴某些模塊的語句均放置在回調函數中。AMD規范定義了一個全局 define 的函數:

define( id?, dependencies?, factory );

  第一個參數 id 為字符串類型,表示了模塊標識,為可選參數。第二個參數,dependencies 是一個數組,表示當前模塊依賴的模塊。第三個參數,factory,就是用來定義我們自己模塊的工廠方法, factory 接受的參數和 dependencies 完全一致。加入我們自己的模塊依賴於模塊module1module2module3的話,我們的模塊定義就應該這樣寫:

define("myModule", ["module1", "module2", "module3"], function(module1, module2, module3){ // 這里定義我們自己的模塊的功能,可以使用 module1、module2、module3中提供的功能 var myModule = { a: function(){...}, b: function(){...} } return myModule; //必須return個什么才能被別人使用 }

  如果要使用 myModule 就應該這么寫:

define("", ["myModule"], function(myModule){ myModule.a(); myModule.b(); }

  CMD 規范是指通用模塊定義,它是是 SeaJS 在推廣過程中對模塊定義的規范化產出。和 AMD 比較類似的是,它也定義了一個全局 define 函數,而且其形式也很類似,都是

define( id?, dependencies?, factory );

  所不同者,是它的 factory 接受的參數必須是 require、exports 和 moudle,在 factory 內部,可以使用 require 引用依賴的模塊,可以使用 exports 導出自己的功能,這和 Node.js 自帶的 CommonJS 規范是比較相似的,其用法如下:

define( 'module', ['module1', 'module2'], function( require, exports, module ){ var a = require("./a"); a.doSomething(); exports.do = function(){...}; } );

  AMD 和 CMD 的區別就是:1.對於依賴的模塊,AMD 是提前執行,CMD 是延遲執行。2.CMD 推崇依賴就近,AMD 推崇依賴前置。示例代碼是這樣:

// CMD define(function(require, exports, module) { var a = require('./a') a.doSomething() ... var b = require('./b') //依賴可以就近書寫 b.doSomething() ... }) // AMD 默認推薦的是 define(['./a', './b'], function(a, b) { //依賴必須一開始就寫好 a.doSomething() ... b.doSomething() ... })

  當然,這里面都有一個約定俗成的規則,那就是每一個模塊都是一個同名的.js文件,我們在寫模塊名的時候,可以省略這個文件的擴展名。以上規范都是定義在前端的瀏覽器中的,而在后端的 Node.js 中就簡單多了。Node.js 采用的是 CommonJS 模塊規范,每一個文件就是一個模塊,也不需要定義 define 什么的,也不需要定義自執行函數。在這個文件中,可以直接使用 exports 和 module。

  有時,我們需要讓我們編寫的模塊在前后端都能使用,這個要求不過分哦,比如我們想在 Node.js 中對模塊進行單元測試,然后再發布到瀏覽器執行。利用之前提到的每種模塊定義規范的特點,我們可以寫出前后端通用的模塊,代碼片段如下:

var hasDefine = (typeof define !== "undefined"); var hasModule = (typeof module !== "undefined" && typeof module.exports !== "undefined"); if(hasDefine){ //運行在符合 AMD 或 CMD 規范的環境中 define("myModule",function(){ return { inherit : inherit, Person : Person, Worker : Worker }; }); }else if(hasModule){ //運行在Node.js中 module.exports = { inherit : inherit, Person : Person, Worker : Worker }; }else{ //否則直接加入到全局對象中 window.myModule = { inherit : inherit, Person : Person, Worker : Worker }; }

  下面測試一下我們寫的模塊是否能在前后端通用。先在 Node.js 中測試,寫一個main.js,其內容如下:

var myModule = require("./myModule"); var worker = new myModule.Worker("youxia", 30, "male", ["HTML","CSS","JavaScript"]); worker.doWork();

  運行結果如下圖:

  如果直接寫死在 HTML 中呢?運行結果如下圖:

  最后,我們看看使用 RequireJS 的情況。先到 RequireJS 的官網下載require.js文件,然后編寫一個main_in_amd.js文件,內容如下:

requirejs(["./myModule"], function(myModule){ var worker = new myModule.Worker("youxia", 30, "male", ["HTML","CSS","JavaScript"]); worker.doWork(); });

  再然后,寫一個 HTML 文件,這樣引用require.jsmain_in_amd.js文件:

<!DOCTYPE html> <html > <head> <meta charset="utf-8"> <script src="./require.js" defer async="true" data-main="./main_in_amd.js"></script> </head> <body> </body> </html>

  最后的運行結果如下圖:

  從圖中可以看出,我們的模塊確確實實是前后端可以通用的。該模塊的完整代碼如下:

(function(){ function inherit(Sub, Super){ function F(){} F.prototype = Super.prototype; Sub.prototype = new F(); Sub.constructor = Sub; } function Person(name, age, gender){ if(!(this instanceof Person)){ return new Person(name, age, gender); } this.name = name; this.age = age; this.gender = gender; } Person.prototype.sayHello = function(){ console.log("Hello, my name is " + this.name); }; function Worker(name, age, gender, speciality){ if(!(this instanceof Person)){ return new Worker(name, age, gender, speciality); } Person.call(this, name, age, gender); this.speciality = speciality; } inherit(Worker, Person); Worker.prototype.doWork = function(){ console.log(this.name + " is working with " + this.speciality); } var hasDefine = (typeof define !== "undefined"); var hasModule = (typeof module !== "undefined" && typeof module.exports !== "undefined"); if(hasDefine){ //運行在符合 AMD 或 CMD 規范的環境中 define("myModule", function(){ return { inherit : inherit, Person : Person, Worker : Worker }; }); }else if(hasModule){ //運行在Node.js中 module.exports = { inherit : inherit, Person : Person, Worker : Worker }; }else{ //否則直接加入到全局對象中 window.myModule = { inherit : inherit, Person : Person, Worker : Worker }; } })();
 

關於函數的更多探討

  前面探討了 JavaScript 模擬經典面向對象的寫法,而實際應用中,也有不少場景會使用 JavaScript 模擬函數式編程語言的寫法,比如偏函數和柯里化什么的。JavaScript 之父本身是一個 Scheme(一種 LISP 方言) 高手,所以他在創建 JavaScript 之初就從函數式編程語言中吸收了很多東西,將函數當成一等公民對待就是其證明。在 JavaScript 中,函數是一等公民,可以把函數當參數傳遞給另外的函數,也可以從函數中返回函數,所以在 JavaScript 中使用高階函數是一個很簡單很常見的事情。

  在前面的示例的截圖中可以看到,每一個函數中都包含幾個特殊的變量。前面已經介紹了其中一個特殊變量this,另外還有一個特殊變量arguments,它代表了傳遞給函數的所有參數,它是一個類數組的對象(說明它不是數組,但是可以利用前面介紹的.call()借用數組的方法)。什么時候會用到arguments呢?就是當函數的形參個數和實參個數不一樣的時候,可以使用arguments變量訪問傳遞給函數的所有參數。這使得定義可變數量參數的函數成為可能。如下示例,隨便定義一個函數,沒有定義形參,但是調用時可以指定任意的實參:

function someFunc(){ console.log("arguments count: " + arguments.length); for(var i = 0; i < arguments.length; i++){ console.log(arguments[i]); } }

  不管用多少個參數調用該函數,都可以輸出參數的個數和所有的參數,如下:

someFunc(1, 2, 3); //輸出以下內容 // arguments count: 3 // 1 // 2 // 3 someFunc("one", "two", "three", "four"); //輸出以下內容 // arguments count: 4 // one // two // three // four

  在 JavaScript 中,常使用一種叫偏函數的技巧來簡化某些需要太多參數的函數的使用。有時有些函數需要接受很多參數,但是其中一些參數是經常重復的,但是調用時又必須輸入這些參數,比較麻煩。這時,就可以創建這些函數的偏函數版本,把那些經常重復的參數預先設定為固定的值,調用這些偏函數時,只需要傳遞少量參數就行了。舉例說明,可以利用前面提到的判斷對象類型的方法定義一個isType()函數,如下:

function isType(obj, type){ if(Object.prototype.toString.call(obj) === "[object " + type + "]"){ return true; }else{ return false; } }

  可以這樣調用該函數:

var msg = "Hello, World!"; var obj = {}; var arr = []; isType(msg, "String"); isType(obj, "Object"); isType(arr, "Array");

  每次調用都需要輸入兩個參數,第二個參數通過字符串來指定需要判斷的類型,如果需要判斷類型的對象特別多,這樣調用就特別麻煩,而且容易出錯。所以可以創建這個函數的偏函數版本,比如isFunction()isString()isArray()isObject()等,這些函數只需要接受一個參數就可以了。怎么創建呢?先要寫一個返回偏函數的函數:

function partial(fn, type){ //接受需要偏函數化的原函數和需要預先設置的參數作為參數 return function(obj){ //返回偏函數 return fn(obj, type); } }

  然后利用這個函數返回isType()的各個偏函數版本,如下:

var isFunction = partial(isType, "Function"); var isString = partial(isType, "String"); var isArray = partial(isType, "Array"); var isObject = partial(isType, "Object");

  然后這樣調用這些函數:

isString(msg); isObject(obj); isArray(arr); isFunction(Person);

  是不是簡單多了,也不容易出錯。但是上面只是把本來需要兩個參數的函數節約了一個參數,收益並不是很大。其實,可以使用偏函數的理論創建一個將任意函數進行偏函數化的函數partialAny(),如下:

function partialAny(fn){ //接受原函數 var originalArgs = Array.prototype.slice.call(arguments, 1); //獲得原始參數,其參數個數和原函數需要的個數相同,其中有占位符 return function(){ //返回偏函數 var partialArgs = Array.prototype.slice.call(arguments); //獲得偏函數的參數,其個數應該和占位符的個數相同 var newArgs = []; for(var i=0; i < originalArgs.length; i++){ if(originalArgs[i] === "_"){ //如果碰到占位符,則用偏函數的參數填補 newArgs[i] = partialArgs.shift(); }else{ newArgs[i] = originalArgs[i]; } } // 如果有任何多余的參數,則添加到尾部 return fn.apply(this, newArgs.concat(partialArgs)); } }

  先用它了創建一個前面的isType()函數的偏函數試一試:

var isString = partialAny(isType, "_", "String"); isString("abc"); // 返回 true

  我這里選擇了下划線做為占位符,大家可以根據自己的情況酌情選擇。下面來個更復雜的例子,比如經常需要創建 RGB 顏色的函數:

function makeColor(r, g, b){ return "#" + r + g + b; }

  該函數一般情況下需要三個參數,但是可以通過偏函數的方式讓 R、G、B 的任何一個分量固定,比如這樣:

var redMax = partialAny(makeColor, "ff", "_", "_"); var blueMax = partialAny(makeColor, "_", "_", "ff"); var greenMax = partialAny(makeColor, "_", "ff", "_"); var magentaMax = partialAny(makeColor, 'ff', "_", 'ff');

  然后這樣調用它們:

redMax("33", "44"); // 輸出"#ff3344" blueMax("55", "66"); // 輸出"#5566ff" greenMax("77", "88"); // 輸出"#77ff88" magentaMax("99"); // 輸出"#ff99ff"

  函數也是對象,所以可以重寫函數對象的toString()valueOf()方法來達到意想不到的效果。舉例說明,假如我們想創建一個add()函數,它既可以這樣調用:

add(1,2);

又可以這樣調用:

add(1,2)(3); add(1)(2)(3)(4);

  可以看出,這很類似於函數式編程語言中的柯里化(currying)。仔細觀察可以發現,要實現以上效果,add()必須返回一個函數,這樣才能繼續后面的調用,如下:

function add(){ var result = 0; for(var i=0; i < arguments.length; i++){ //先計算第一層調用 result += arguments[i]; } function temp(){ for(var i=0; i < arguments.length; i++){ //再計算后續的調用 result += arguments[i]; } return temp; //返回函數,所以可以無限調用下去 } return temp; //返回函數,所以可以無限調用下去 }

  這樣很方便就解決了函數連續調用的問題,但是又引出了新問題:該函數永遠返回的是函數,那怎樣才能得到求和的值呢?這時就該toString()valueOf()上場了,我們只需要重寫函數temp()toString()valueOf()方法,就可以在函數調用結束后,獲得該表達式的值,如下:

temp.toString = temp.valueOf = function(){ return result; };

  完整代碼如下:

function add(){ var result = 0; for(var i=0; i < arguments.length; i++){ //先計算第一層調用 result += arguments[i]; } function temp(){ for(var i=0; i < arguments.length; i++){ //再計算后續的調用 result += arguments[i]; } return temp; //返回函數,所以可以無限調用下去 } temp.toString = temp.valueOf = function(){ return result; }; //這樣可以獲得函數的值 return temp; //返回函數,所以可以無限調用下去 }

  然后,就可以這樣隨意調用add()了:

add(1,2); //得到3 add(1,2)(3); //得到6 add(1,2)(3,4)()(5); //得到15 add(1,2)(3)(4)(5)()(6,7,8,9); //得到45

  當然,這並不是最嚴格的柯里化,柯里化只是函數式編程語言中的一個特性,嚴格的柯里化每次只接受一個參數,直到總共接受了指定數量的參數后函數才執行。使用柯里化的好處是參數復用和延遲執行。所以,從本質上講,前面提到的偏函數更接近函數式編程語言中柯里化的功能。上面的例子雖然形式上類似柯里化,但是比柯里化更靈活,可以接受無限多個參數和無限次調用,然而,這並沒有什么卵用,炫耀技巧而已。

 

總結

  好吧,就寫這么多吧,這篇文章已經夠長了。但是仍然不可能覆蓋 JavaScript 的方方面面。在我這篇文章中,主要關注的是 JavaScript 語言本身,而沒有涉及瀏覽器中的 BOM、DOM 操作,也沒有涉及 Node.js 的 API。

  這篇文章中的內容都是我根據自己的理解寫成的,我沒有《JavaScript 權威指南》和《JavaScript 高級程序設計》那么全面和啰嗦,我創建對象和實現繼承的方式也許和網絡上那些流行的做法不一樣,但是,Whatever,這就是我的理解,歡迎大家不服來辯。如果你能堅持讀到這里,請不要吝嗇點個贊。謝謝!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM