進階 JavaScript

1定義命名空間

大部分 OOP 語言都支援命名空間:定義類別、程式庫和模組的命名領域。命名空間產生一個新的區域作用域,以便區隔其程式碼與全域作用域,開始建立程式庫時,命名空間很有用,因為它避免了衝突,例如,透過命名空間,程式中定義的 error() 函數不會與匯入程式庫中同名的 error() 函數相衝突。
JavaScript 對命名空間的支援與其他 OOP 語言不同,不過可藉由定義一個物件來建立命名空間:
        var someNamespace = {
          someProperty: 23,
          someMethod: function(){...}
        };
      

一經定義後,就能使用物件標記法存取屬性和方法:someNamespace.somPropertysomeNamespace.someMethod(),最早在 U 程式庫(Utilities(工具程式)的縮寫)中達到,實際上,這就是命名空間的全部概念:建立一個全域可用的物件,再於物件中封裝區域作用域的有用程式碼

定義命名空間時,必須確保命名空間物件的識別字是唯一,藉助自己的姓名或字首作為名稱的一部份,或許就能滿足需求,如果建立的命名空間代表一個經常更新和維護的程式庫,事先在定義中增加程式庫的相關資訊是明智的做法:
        var LARRYULLMAN_UTILITIES = {
          NAME: 'Larry Ullman Utilities Library',
          VERSION: 1.6,
          ...
        };
      
依照慣例,上述訊息以一個常數表示,而 JavaScript 並為普遍支援常數,然而名稱仍以全大寫字母列出(和其他語言中常數的寫法相同),指出類域常數的意圖。
        
建立一個組態物件
除了命名空間以外,物件在分散式程式的另一種常件用途是作為組態工具,組態物件的建立方法是:定義一個自訂物件,再利用其屬性代表網站專用的設定
          var CONFIG = {
            host: 'http://example.com/',
            errorClass: 'error',
            outputElement: 'output',
            something: 'value'
          };
        
以後,其他的 JavaScript 程式碼便可引用 CONFIG,例如:
          elem.className = CONFIG.errorClass;
        
透過組態物件,我們能夠更輕鬆地在多個專案上使用相同的 JavaScript 程式,而不需要找遍大量的程式碼才能編輯特定的內容,而且,如果有合適的組態物件,JavaScript 程式庫的其餘部分便可在不修改設定、不破壞網站的情況下進行升級。

2建立自訂物件

JavaScript 定義了許多服務必要任務的物件:Math、String 等。但是,有時候我們希望能有自訂的物件類型,以便貼切地表示特定應用程式要處理的資料類型,在典型的 OOP 語言中,自訂物件透過類別的定義而完成,類別成為一種範本,然後程式碼產生該類別的實例──物件,因為 JavaScript 是原型化的 OOP 語言(不基於類別),所以需要不同的方法,對此有兩種選擇:可依需求建立單個自訂物件,或者是同一個自訂物件的多個實例

建立單個自訂物件


若想建立單個自訂物件,只需要產生一個 Object 類型的新變數
        var employee = {
          firstName: 'Joseph',
          lastName: 'Doe',
          department: 'Accounting',
          hireDate: new Date(),
          getName: function(){
            return this.lastName + ', ' + this.firstName;
          }
        };
      
上述程式碼適用於多種情況,但是不能以這種方式建立兩個類似的自訂物件(例如,兩個單獨的員工)。

建立自訂物件的多個實例


建立自訂物件的替代方式是使用一個函數作為物件產生器。下列程式重寫了員工範例:
        function Employee(firstName, lastName, department){
          this.firstName = firstName;
          this.lastName = lastName;
          this.department = department;
          this.hireDate = new Date();
          this.getName = function(){
            return this.firstName + ' ' + this.lastName;
          };
        }
      
詳細了解發生的事情,首先,當成物件產生器的函數(稱為建構子函數)遵循慣例,名稱的第一個字元為大寫字母,其次,函數中透過 this 引用變數,本例的 this 始終引用目前的物件,最後,此函數的重要特徵是不返回任何值。

針對這個例子,預期目標是在第一次呼叫函數時傳入不同員工的內容,接著存放於變數中,Employee 函數本身也定義了一個函數 getName(),目的是回傳員工的名和姓,兩者之間以空格分隔。
函數的呼叫是透過 new 關鍵字建立一個物件:
        var e1 = new Employee('Jane', 'Doe', 'Accounting');
      
上述程式中的 new 關鍵字,以具體的參數值產生一個 Employee 函數定義的副本。
可以利用下列程式建立更多的員工物件:
        var e2 = new Employee('John', 'Smith', 'Human Resources');
      
使用建構子函數時一定要藉助 new 關鍵字,否則可能會發生奇怪的副作用!
現在便能使用物件的屬性和方法,就像對待其他物件一樣:
        e1.firstName;     //回傳 Jane
        e2.getName();     //回傳 John Smith
      
實際上,整個概念和建立 String、Number 或 Object 變數的方法(但通常不這樣做)相同:
        var lang = new String('JavaScript');
      
甚至可以將自訂物件傳遞給函數,就像其他任何物件一樣,底下是另一個物件的定義,其中一個方法以物件為參數:
        function Department(name){
          this.name = name;
          this.employees = [];
          this.addEmployee = function(emp){
            this.employee.push(emp);
            emp.department = this.name;
          };
          //另一種方案是依照索引位置返回員工
          this.getEmployee(index){
            return employees[index];
          }
        }
        var hr = new Department('Human Resources');     //建立一個新物件
        hr.addEmployee(e2);                             //新增一個 employee 物件
        hr.employees[0].lastName;                       //回傳 Smith
        hr.getEmployee(0).lastName;                     //一樣回傳 Smith
      

更完整的自訂物件


為了與 JavaScript 的其他物件有一致的行為,可以為自訂物件定義 toString()valueOf() 方法,toString() 方法總式返回物建的字串呈現形式,針對自訂物件,合適的呈現形式由開發人員決定:
        function Employee(firstName, lastName, department){
          this.toString = function(){
            return 'Name: ' + this.firstName + ' ' + this.lastName + '\nDepartment: ' + this.department;
          }
        }
      
valueOf() 方法返回物件及其內容的最簡形式,對許多物件來說,這兩種方法返回相同的結果,但對某些物件而言則不然,例如,Date 的 valueOf() 方法返回一個數字:日期的時間戳記──從 UNIX 時間戳記以來的毫秒數,而 Date 物件的 toString() 方法則回傳一個代表日期和時間的字串。
就 Employee 物件而言,個人傾向以 valueOf() 返回員工姓名,而不加上任何標籤或附加資訊,如果合適,valueOf() 方法也可以返回同樣唯一而有意義的值,例如員工的 ID 號碼。

整合


加強的任務管理應用程式的另一個版本,這個版本的每個任務都有三個屬性:任務名稱、優先順序、是否完成:
          //定義 Task() 函數,將建立一個新的 Task 物件,它有兩個參數:任務名稱、優先順序
          function Task(name, priority) {
            'use strict';

            //將參數指派給內部變數
          	this.name = name;
          	this.priority = priority;
          	this.completed = false;

            //定義 toString() 函數,以「任務(優先順序)」的格式回傳任務
          	this.toString = function() {
          		return this.name + ' (' + this.priority + ')';
          	};
          }

          //定義視窗載入事件處理函數
          window.onload = function(){
            'use strict';

            //取得各個元素的參照
          	var task = document.getElementById('task');
          	var priority = document.getElementById('priority');
          	var output = document.getElementById('output');

            //宣告 tasks 變數,同腳本的前一個版本一般,這個陣列儲存所有任務
          	var tasks = [];

            //定義處理表單提交的函數,當提交表單時建立一個新的 Task 物件,並新增到 tasks 陣列
          	document.getElementById('theForm').onsubmit = function() {
          		var t = new Task(task.value, priority.value);     //建立一個新的 Task 物件
          		tasks.push(t);                                    //並新增到陣列

              //更新輸出 DIV,並完成兩個匿名函數
              output.innerHTML = 'There are now ' + tasks.length + ' item(s) in the to-do list. Just added:
' + t.toString(); return false; }; };

3了解原型

JavaScript 是不同類型的 OOP 語言,它是原型化的語言,這表示我們建立的變數繼承自一個預定模型──原型(Prototype),而非類別定義,無論原型內建於 JavaScript 或者自行定義,這種原型化特性都存在:
        var lang = 'JavaScript';
        var el = new Employee('Jane', 'Doe', 'Accounting');
      
上述程式碼中,lang 變數是 String 原型的實例,而 el 則是 Employee 實例。
注意一個技術細節:lang 的值是一個文字字串,但在使用時會自動轉換成 String 物件。

原型化繼承


每個 JavaScript 物件都繼承其原型的屬性和方法,因此,如果檢查新建的自訂物件,就會發現一些不是由自己產生的方法和屬性。
而且正如 Employee 範例所示,也可以為物件指定原型中沒有的自訂屬性和方法,例如,Department 有一個 addEmployee() 方法,而其 Object 原型則無。

有些變數的原型之上還有原型,例如,el 物件基於 Employee 原型,而 Employee 又基於 Object,這種現象稱為原型鍵(Prototype chain),當引用任何物件屬性或方法時,JavaScript 遍覽物件的原型鏈,再尋找對應的屬性,JavaScript 在到達根原型時便停止搜尋──所有原型的根源是 Object,如果在原型鏈中沒有找到對應的屬性或方法,便返回 undefined。

為了區分物件內定義以及原型鏈中的屬性或方法,可以呼叫 hasOwnProperty() 方法,再傳入想要區分的屬性作為參數,這個方法定義於 Object 中,因而所有的其他物件皆能繼承,例如:
        var test = { thing: 1 };
        test.hasOwnProperty('thing');       //回傳 true
        test.valueOf();
        test.hasOwnProperty('valueOf');     //回傳 false
      
Math 物件是無法建立實例的少數物件之一(也就是說,它不能作為其他物件的原型)。
參考 MDN hasOwnProperty():https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwnProperty

增加原型方法


在基於類別的 OOP 語言中,我們能夠修改底層的類別定義,藉以改變每個實例物件的屬性和方法,由於 JavaScript 沒有類別,但可透過編輯物件的 prototype 屬性,更新原型的屬性和方法,例如,可於定義 Employee 物件之後進行修改,下面的程式加入一個新方法:
        function Employee(firstName, lastName, department){
          ...
        }
        Employee.prototype.getNameBackwards = function(){     //新增新方法
          return this.lastName + ', ' + this.firstName;
        }
      
現在,新方法已經增加到原型中,可以在任何實例物件上呼叫:el.getNameBackwards(),實際上,即使 el 變數產生於新增方法到原型之前,仍舊能夠執行這個方法!

追溯改變原型的能力,甚至能讓我們修改內建於 JavaScript 的物件定義,例如 String,底下程式為 String 新增一個 trim() 方法(如果 String 還沒有這個方法):
        if(typeof String.prototype.trim == 'undefined'){
          String.prototype.trim = function(){
            return this.replace(/^\s+|\s+$/g, '');
          }
        }
      
真正去除字串開始和尾部空白的功能需要一個常規運算式,執行上述程式碼,JavaScript String 物件將提供一個 trim() 方法,不管其是否為瀏覽器固有的方法(ECMAScript 5 已經增加了這個方法)。
另舉一例,尚可擴充 Date 物件,使其擁有 getMonthName() 方法,以便返回日期月份的文字版本。

儘管 JavaScript 允許修改原型,但是這種有時被稱為「猴子補丁」(Monkey patching)的思路應該謹慎地使用,因為原型的改動是全面性,影響該原型的所有實例,為內建的 JavaScript 物件(如 String)增加獨特的方法和屬性,或許會在沒有預期這類變化的程式碼中造成臭蟲,此概念的最佳用途是像 String.prototype.trim() 範例一般,建立向後相容的物件(產生能夠在任何瀏覽器類型或版本上可靠運作的 String 物件)。
無論需要與否,加入原型的每種方法都會新增到原型的所有實例,如果只需要在特定的實例上使用一個函數,可以單獨建立該函數,再以物件為參數進行呼叫:
        function doSomething(obj){
          ...
        }
      
也可將函數定義加入單個實例:
        var obj = {};
        obj.doSomething = function(){
          ...
        }
      
修改原型也能用來避免閉包的建立
參考 w3school JavaScript Object Prototypes:http://www.w3schools.com/js/js_object_prototypes.asp

4使用閉包

閉包(Closures)是 JavaScript 最重要又抽象的概念之一,網路上有多種描述閉包的方式,不過最簡單的是將閉包當成有記憶的一次性函數呼叫,換句話說,閉包是與其建立時所在的作用域緊緊相繫的函數,這代表閉包函數可以使用函數建立時(在同一個作用域)已有的變數,粗略地說,下列情形可能遇到閉包: 下文列出一個能夠更完整解釋閉包的例子:(為建立自訂物件──整合範例)
        window.onload = function(){
          document.getElmentById('theForm').onsubmit = function(){
            task.push(t);
          };
        }
      
onload 函數只被執行一次:載入頁面的時候,該函數定義一些變數,包括 tasks 陣列,在正常、非閉包的情形下,函數變數在函數執行完成後就無法使用,這意味著如果沒有閉包,tasks 陣列將在 onload 函數執行完所有命令之後便不復存在
在 onload 函數外層定義另一個函數時產生了閉包,所有閉包都要求在另一個函數定義函數,內層函數處理表單提交,會被執行許多次,不過時間總是在外層函數結束執行之後(順序是 Window loads → End of onload Function → Form Submission → ...)。
因為 onload 函數結束執行之後會呼叫 onsubmit 函數,而且前者具有與後者相同作用域的變數,所以 JavaScript 在 onload 函數結束之後仍然保留那些變數,進而形成閉包,感謝閉包之故,onsubmit 函數便可使用 tasks,因為該變數仍處於作用中──這正式閉包的標誌:函數定義時可用的局部變數在稍後呼叫時仍然可用,閉包產生了具有持續性的區域變數

與上述同一腳本的變形也有閉包,採用立即呼叫函數:
        (function(){
          var tasks = [];
          function addTask(){
            ...
          }
          function init(){
            document.getElementById('theForm').onsubmit = addTask;
          }
          window.onload = init;
        })();
      
最外面的 JavaScript 匿名函數最先被執行,函數中的 tasks 變數存放在區域作用域,每當表單提交時便呼叫 addTask() 函數,始終在最先執行的匿名函數停止之後才出現,由於 addTask() 保留 tasks 變數的存取,因此再次造成了閉包。

一個經常被混淆的要點:閉包函數的一個關鍵特徵是可以存取定義閉包時既有(相同作用域)的區域變數,重點在於,閉包讀取的是閉包函數執行時,而非定義時的函數值。
例如下面的 onload 函數是一個閉包,能夠存取變數 i:
        (function(){
          var i = 1;
          window.onload = function(){
            alert(i);     //回傳 2
          };
          i = 2;
        })();
      
定義內層函數時,i 被賦值為 1,呼叫內層函數時(在外層立即執行函數之後),i 的值已經被修改為 2。

這種問題最常出現在以迴圈建立閉包時,例如,假設希望為頁面的每個連結增加一個點擊事件處理器,之後點擊連結時,事件處理器就能進行某些動作,你可能認為實作的方式如下:
        function someFunction(){
          var link = document.getElementByTagName('a');
          for(var i = 0, count = links.length; i < count; i++){
            links[i].onclick = function(){    //使用 links[i] 無效
              return false;
            }
          }
        }
      
事後發現,onclick 函數的 links[i] 始終返回 undefined,為了瞭解此一原因,假設頁面上有兩個連結,for 迴圈執行兩次,並成功地為每個連結新增點擊處理器,當 i 變為 2(大於 links 陣列的長度)時迴圈終止,此刻點擊其中一個連結時,由於閉包的關係,因此 onclick 匿名函數仍然能夠存取 i,不過 i 的值為 2,這是該變數最後的內容。

閉包還有更進階的用法,其中最引人注目的是由函數返回另一個函數的情況,但是此類主題十分難以掌握,因此從最容易理解的用法開始,具體說明關於事件處理的用途,底下的閉包範例將使用一個計時器,計時器最基本的特性是在一個時點定義函數,而在另一個時點執行,後續的例子將透過計時器和閉包,藉以製造淡出效果,將元素的不透明度從 100% 改成 0%,進而呈現逐漸淡出的特效。
範例中的可以建立任何 id 值為 target 的可見元素,此元素是文字段落或圖形都無關緊要,不過,必須將底下的 CSS 新增到頁面中,好讓 IE7 以下 識別出不透明度的變化:
        #target{zoom: 1;}
      
          //定義 onload 匿名函數
          window.onload = function() {
          	'use strict';
          	var target = document.getElementById('target');    //取得目標元素參照
          	var opacity = 100;                                 //設定初始的不透明度(100%)

            //定義 setInterval() 函數,以另一個函數作為第一個參數
            //該函數將在外層匿名 onload 函數終止之後被重複執行,它是一個閉包,所以可以存取 target 和 opacity 變數
          	var fader = setInterval(function() {
          		opacity -= 10;                                   //每次呼叫都使目標變淡 10%
              //如果透明度大於或等於 0,則修改不透明度樣式,否則停止計時器
          		if (opacity >= 0) {
          			if (typeof target.style.opacity == 'string') { //取決瀏覽器支援 opacity 或 filter
          				target.style.opacity = (opacity/100);
          			} else {
          				target.style.filter = 'alpha(opacity=' + opacity + ')';
          			}
          		} else {
          			clearInterval(fader);
          		}
          	}, 100);                                           //每隔 100 毫秒執行一次

          };
        

5替代類型識別

typeof 運算子目的是作為識別變數類型(或任何資料類型)的一種手段:
        if(typeof myVar == 'number'){
          ...
        }
        if(typeof myVar == 'undefined'){
          ...
        }
      
不過,有些情況下藉由 typeof 得到的結果過於模糊,例如,Object 變數的 typeof 值為 object,無論變數是陣列、null 還是自訂物件(如 Employee),結果都一樣。

備選方案之一是透過 instanceof 運算子確認物件的原型:
        if(myVar instanceof Number){
          ...
        }
      
這表示 String 的 instanceof 運算既符合 String,也匹配 Object,但是 instanceof 能夠區分這兩者,例如,分辨 Array 和 Object。
請注意,兩個比較運算元都加上引號,右邊的物件原型必須轉換為大寫以符合名稱,這和採用小寫字母(例如,以「number」代表 Number 類型)的 typeof 不同。
另外,測試的物件必須由 new 關鍵字建立的變數。

更可靠的類型檢查可以透過變數的 constructor 屬性來完成,constructor 屬性反應建立物件時首先呼叫的函數:
        if(myVar.constructor == Number){
          ...
        }
      

測試變數類型的另一種方法是使用「鴨子類型」(Duck typing),這種方法得名於 James Whitcomb Riley 的一段話:「當我看到一隻鳥,它走路像鴨子,游泳像鴨子,發出的叫聲也像鴨子,那麼這隻鳥就叫做鴨子。」設計程式時,此方法的前提是:有時候一個值在技術上是不是某種類型(例如 String)並不重要,重要的是它是否可以當成那種類型使用,可以檢查後續程式要求的特性是否存在,進一步執行鴨子類型:亦即使用物件檢測,在物件導向的術語中,物件檢測就是找尋即將進行的工作所需的屬性和方法,如果存在必要的屬性或方法,就能將該物件視為「鴨子」!

6精簡程式碼

編寫、測試、偵錯、優化和定稿所有程式碼之後,接著就是發佈到網路上,也就是說:將程式交付到實際網站。在此之前還可以採取一個步驟:精簡程式碼。精簡程式碼是刪除所有註解和多餘的空白,以便盡可能地壓縮程式碼,此舉能夠顯著地減少檔案大小,幅度可能多達 50%,由於使用者需要下載的資料更少,因此瀏覽器便能更快地載入網站。

有幾種工具能夠協助精簡程式碼,基於 Web 的 MinifyJavaScript(連結失效)是一種簡單的解決方案,只要將程式碼貼到網頁頂部的文字區域,點擊 Compress JavaScript 按鈕,精簡過的版本就會出現在第二個文字區域中。
另一個基於 Web 的選擇是 Dean Edwards 撰寫的 Packer,Packer 進一步改進了精簡過程──縮短變數名稱,使程式碼更濃縮

然後將精簡後的程式碼貼到新的腳本,命名為 filename.min.js(例如,tasks.min.js),依照慣例,檔案名稱中出現 .min 表示精簡過的程式碼
記得千萬不要替換原有的程式碼或檔案,因為精簡過程中將遺失所有註解和原來的格式。

透過如下的命令行工具,還能達到更徹底的精簡動作: 對應的網站或者透過搜尋引擎,便可找到上述框架的使用說明。
        
展望未來
在學習更進階的 JavaScript 技術時,建議先研究下面三個主題:
  • 閉包的更多知識
  • 執行上下文
  • call()apply()bind() 方法
以前術術語搜尋網路,藉以瀏覽更多結果。
DEMO MEMO