1. 程式人生 > >Linux 桌面玩家指南:19.深入理解 JavaScript,及其開發除錯工具

Linux 桌面玩家指南:19.深入理解 JavaScript,及其開發除錯工具

特別說明:要在我的隨筆後寫評論的小夥伴們請注意了,我的部落格開啟了 MathJax 數學公式支援,MathJax 使用$標記數學公式的開始和結束。如果某條評論中出現了兩個$,MathJax 會將兩個$之間的內容按照數學公式進行排版,從而導致評論區格式混亂。如果大家的評論中用到了$,但是又不是為了使用數學公式,就請使用\$轉義一下,謝謝。

想從頭閱讀該系列嗎?下面是傳送門:

  • Linux 桌面玩家指南:01. 玩轉 Linux 系統的方法論
  • Linux 桌面玩家指南:02. 以最簡潔的方式打造實用的 Vim 環境
  • Linux 桌面玩家指南:03. 針對 Gnome 3 的 Linux 桌面進行美化
  • Linux 桌面玩家指南:04. Linux 桌面系統字型配置要略
  • Linux 桌面玩家指南:05. 發部落格必備的圖片處理和視訊錄製神器
  • Linux 桌面玩家指南:06. 優雅地使用命令列及 Bash 指令碼程式語言中的美學與哲學
  • Linux 桌面玩家指南:07. Linux 中的 Qemu、KVM、VirtualBox、Xen 虛擬機器體驗
  • Linux 桌面玩家指南:08. 使用 GCC 和 GNU Binutils 編寫能在 x86 真實模式執行的 16 位程式碼
  • Linux 桌面玩家指南:09. X Window 的奧祕
  • Linux 桌面玩家指南:10. 沒有 GUI 的時候應該怎麼玩
  • Linux 桌面玩家指南:11. 在同一個硬碟上安裝多個 Linux 發行版以及為 Linux 安裝 Nvidia 顯示卡驅動
  • Linux 桌面玩家指南:12. 優秀的文字化編輯思想大碰撞(Markdown、LaTeX、MathJax)
  • Linux 桌面玩家指南:13. 使用 Git 及其 和 Eclipse 的整合
  • Linux 桌面玩家指南:14. 數值計算和符號計算
  • Linux 桌面玩家指南:15. 深度學習可以這樣玩
  • Linux 桌面玩家指南:16. 使用 CUDA 發揮顯示卡的計算效能
  • Linux 桌面玩家指南:17. 在 Ubuntu 中使用 deepin-wine,解決一些依賴 Windows 的痛點問題
  • Linux 桌面玩家指南:18. 使用 Docker 隔離自己的開發環境和部署環境

前言

必須說明,這一篇的內容主要是炒現飯,只是把我三年前寫的內容搬到了這裡,請大家輕噴。之所以這麼做,主要是為了這一個系列的完整性,因為我覺得 JavaScript 是我們這個系列必不可少的一部分。當然,時間過了這麼久,ECMAScript 標準又更新了幾代,所以我在後面添加了一點 ECMAScript 6 及其以上版本中加入的新內容。

再講一點題外話,我覺得時候結束這一個系列了。一是 Linux 桌面被我玩成這樣,基本上已經沒啥新意了。二是 Windows 系統這幾年崛起很快,作為程式設計師,在 Windows 系統下搭建開發環境也是越來越愉悅了。看看這些年微軟做的努力:①Windows 也支援開多個桌面了;②終於意識到 cmd 和 PowerShell 的介面是多麼了醜了,現在有 Terminal 可以用了; ③Visual Studio Code 本來就支援跨平臺,現在更好了,可以支援 Remote 開發了;④ WSL 都出 WSL2 了,使用的是虛擬機器方案,其實這才是最正確的開啟方式,不過我還是喜歡用 VMWare;⑤.net core,這也是一個值得學習的技術棧,不用多說吧。更不用提宇宙最強大 IDE 了。總之,如果 Windows 完美結合了美觀與方便的話,那麼 Linux 桌面也就沒啥可混的了。

下面開始聊 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 瀏覽器預覽圖片了。這樣,每次改動圖形定義檔案後,只需要點一下滑鼠生成圖片,再點一下滑鼠重新整理瀏覽器就可以實時預覽圖片了。雖然不是所見即所得,但是工作效率已經很高了。請看動畫:

Graphviz 中可以設定的屬性很多,具體內容可以檢視 Graphviz官網 上的文件。

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

關於變數的作用域這個問題應該不用多講,凡是接觸程式設計的童鞋,無不都要從這個基礎的概念開始。變數作用域的通用規則其實很簡單,無非三條: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 instanceo