<h2 id="8.1">console對象</h2>
`console`對象是JavaScript的原生對象,它有點像Unix系統的標準輸出`stdout`和標準錯誤`stderr`,可以輸出各種信息用來調試程序,而且還提供了很多額外的方法,供開發者調用。它的常見用途有兩個。
- 顯示網頁代碼運行時的錯誤信息。
- 提供了一個命令行接口,用來與網頁代碼互動。
## 瀏覽器實現
`console`對象的瀏覽器實現,包含在瀏覽器自帶的開發工具之中。以Chrome瀏覽器的“開發者工具”(Developer Tools)為例,首先使用下面三種方法的一種打開它。
1. 按F12或者`Control + Shift + i`(PC平臺)/ `Alt + Command + i`(Mac平臺)。
2. 在菜單中選擇“工具/開發者工具”。
3. 在一個頁面元素上,打開右鍵菜單,選擇其中的“Inspect Element”。

打開“開發者工具”以后,可以看到在頂端有八個面板卡可供選擇,分別是:
- **Elements**:用來調試網頁的HTML源碼和CSS代碼。
- **Resources**:查看網頁加載的各種資源文件(比如代碼文件、字體文件、css文件等),以及在硬盤上創建的各種內容(比如本地緩存、Cookie、Local Storage等)。
- **Network**:查看網頁的HTTP通信情況。
- **Sources**:調試JavaScript代碼。
- **Timeline**:查看各種網頁行為隨時間變化的情況。
- **Profiles**:查看網頁的性能情況,比如CPU和內存消耗。
- **Audits**:提供網頁優化的建議。
- **Console**:用來運行JavaScript命令。
這八個面板都有各自的用途。以下內容只針對Console面板,又稱為控制臺。Console面板基本上就是一個命令行窗口,你可以在提示符下,鍵入各種命令。
## console對象的方法
`console`對象提供的各種方法,用來與控制臺窗口互動。
### log(),info(),debug()
`console.log`方法用于在console窗口輸出信息。它可以接受多個參數,將它們的結果連接起來輸出。
```javascript
console.log("Hello World")
// Hello World
console.log("a","b","c")
// a b c
```
`console.log`方法會自動在每次輸出的結尾,添加換行符。
```javascript
console.log(1);
console.log(2);
console.log(3);
// 1
// 2
// 3
```
如果第一個參數是格式字符串(使用了格式占位符),console.log方法將依次用后面的參數替換占位符,然后再進行輸出。
```javascript
console.log(' %s + %s = %s', 1, 1, 2)
// 1 + 1 = 2
```
上面代碼中,`console.log`方法的第一個參數有三個占位符(`%s`),第二、三、四個參數會在顯示時,依次替換掉這個三個占位符。`console.log`方法支持的占位符格式有以下一些,不同格式的數據必須使用對應格式的占位符。
- %s 字符串
- %d 整數
- %i 整數
- %f 浮點數
- %o 對象的鏈接
- %c CSS格式字符串
```javascript
var number = 11 * 9;
var color = 'red';
console.log('%d %s balloons', number, color);
// 99 red balloons
```
上面代碼中,第二個參數是數值,對應的占位符是`%d`,第三個參數是字符串,對應的占位符是`%s`。
使用`%c`占位符時,對應的參數必須是CSS語句,用來對輸出內容進行CSS渲染。
```javascript
console.log('%cThis text is styled!',
'color: red; background: yellow; font-size: 24px;'
)
```
上面代碼運行后,輸出的內容將顯示為藍底綠字。
`console.log`方法的兩種參數格式,可以結合在一起使用。
```javascript
console.log(' %s + %s ', 1, 1, '= 2')
// 1 + 1 = 2
```
如果參數是一個對象,`console.log`會顯示該對象的值。
```javascript
console.log({foo: 'bar'})
// Object {foo: "bar"}
console.log(Date)
// function Date() { [native code] }
```
上面代碼輸出`Date`對象的值,結果為一個構造函數。
`console.info()`和`console.debug()`都是`console.log`方法的別名,用法完全一樣。只不過`console.info`方法會在輸出信息的前面,加上一個藍色圖標。
console對象的所有方法,都可以被覆蓋。因此,可以按照自己的需要,定義console.log方法。
```javascript
['log', 'info', 'warn', 'error'].forEach(function(method) {
console[method] = console[method].bind(
console,
new Date().toISOString()
);
});
console.log("出錯了!");
// 2014-05-18T09:00.000Z 出錯了!
```
上面代碼表示,使用自定義的`console.log`方法,可以在顯示結果添加當前時間。
### warn(),error()
warn方法和error方法也是輸出信息,它們與log方法的不同之處在于,warn方法輸出信息時,在最前面加一個黃色三角,表示警告;error方法輸出信息時,在最前面加一個紅色的叉,表示出錯,同時會顯示錯誤發生的堆棧。其他用法都一樣。
```javascript
console.error("Error: %s (%i)", "Server is not responding",500)
// Error: Server is not responding (500)
console.warn('Warning! Too few nodes (%d)', document.childNodes.length)
// Warning! Too few nodes (1)
```
本質上,log方法是寫入標準輸出(stdout),warn方法和error方法是寫入標準錯誤(stderr)。
### table()
對于某些復合類型的數據,console.table方法可以將其轉為表格顯示。
```javascript
var languages = [
{ name: "JavaScript", fileExtension: ".js" },
{ name: "TypeScript", fileExtension: ".ts" },
{ name: "CoffeeScript", fileExtension: ".coffee" }
];
console.table(languages);
```
上面代碼的language,轉為表格顯示如下。
(index)|name|fileExtension
-------|----|-------------
0|"JavaScript"|".js"
1|"TypeScript"|".ts"
2|"CoffeeScript"|".coffee"
復合型數據轉為表格顯示的條件是,必須擁有主鍵。對于上面的數組來說,主鍵就是數字鍵。對于對象來說,主鍵就是它的最外層鍵。
```javascript
var languages = {
csharp: { name: "C#", paradigm: "object-oriented" },
fsharp: { name: "F#", paradigm: "functional" }
};
console.table(languages);
```
上面代碼的language,轉為表格顯示如下。
(index)|name|paradigm
-------|----|--------
csharp|"C#"|"object-oriented"
fsharp|"F#"|"functional"
### count()
count方法用于計數,輸出它被調用了多少次。
```javascript
function greet(user) {
console.count();
return "hi " + user;
}
greet('bob')
// : 1
// "hi bob"
greet('alice')
// : 2
// "hi alice"
greet('bob')
// : 3
// "hi bob"
```
上面代碼每次調用greet函數,內部的console.count方法就輸出執行次數。
該方法可以接受一個字符串作為參數,作為標簽,對執行次數進行分類。
```javascript
function greet(user) {
console.count(user);
return "hi " + user;
}
greet('bob')
// bob: 1
// "hi bob"
greet('alice')
// alice: 1
// "hi alice"
greet('bob')
// bob: 2
// "hi bob"
```
上面代碼根據參數的不同,顯示bob執行了兩次,alice執行了一次。
### dir()
dir方法用來對一個對象進行檢查(inspect),并以易于閱讀和打印的格式顯示。
```javascript
console.log({f1: 'foo', f2: 'bar'})
// Object {f1: "foo", f2: "bar"}
console.dir({f1: 'foo', f2: 'bar'})
// Object
// f1: "foo"
// f2: "bar"
// __proto__: Object
```
上面代碼顯示dir方法的輸出結果,比log方法更易讀,信息也更豐富。
該方法對于輸出DOM對象非常有用,因為會顯示DOM對象的所有屬性。
```javascript
console.dir(document.body)
```
### assert()
assert方法接受兩個參數,第一個參數是表達式,第二個參數是字符串。只有當第一個參數為false,才會輸出第二個參數,否則不會有任何結果。
```javascript
// 實例
console.assert(true === false, "判斷條件不成立")
// Assertion failed: 判斷條件不成立
```
下面是另一個例子,判斷子節點的個數是否大于等于500。
```javascript
console.assert(list.childNodes.length < 500, "節點個數大于等于500")
```
### time(),timeEnd()
這兩個方法用于計時,可以算出一個操作所花費的準確時間。
```javascript
console.time("Array initialize");
var array= new Array(1000000);
for (var i = array.length - 1; i >= 0; i--) {
array[i] = new Object();
};
console.timeEnd("Array initialize");
// Array initialize: 1914.481ms
```
time方法表示計時開始,timeEnd方法表示計時結束。它們的參數是計時器的名稱。調用timeEnd方法之后,console窗口會顯示“計時器名稱: 所耗費的時間”。
### profile(),profileEnd()
console.profile方法用來新建一個性能測試器(profile),它的參數是性能測試器的名字。
```javascript
console.profile('p')
// Profile 'p' started.
```
console.profileEnd方法用來結束正在運行的性能測試器。
```javascript
console.profileEnd()
// Profile 'p' finished.
```
打開瀏覽器的開發者工具,在profile面板中,可以看到這個性能調試器的運行結果。
### group(),groupend(),groupCollapsed()
console.group和console.groupend這兩個方法用于將顯示的信息分組。它只在輸出大量信息時有用,分在一組的信息,可以用鼠標折疊/展開。
```javascript
console.group('Group One');
console.group('Group Two');
// some code
console.groupEnd(); // Group Two 結束
console.groupEnd(); // Group One 結束
```
console.groupCollapsed方法與console.group方法很類似,唯一的區別是該組的內容,在第一次顯示時是收起的(collapsed),而不是展開的。
```javascript
console.groupCollapsed('Fetching Data');
console.log('Request Sent');
console.error('Error: Server not responding (500)');
console.groupEnd();
```
上面代碼只顯示一行”Fetching Data“,點擊后才會展開,顯示其中包含的兩行。
### trace(),clear()
`console.trace`方法顯示當前執行的代碼在堆棧中的調用路徑。
```javascript
console.trace()
// console.trace()
// (anonymous function)
// InjectedScript._evaluateOn
// InjectedScript._evaluateAndWrap
// InjectedScript.evaluate
```
console.clear方法用于清除當前控制臺的所有輸出,將光標回置到第一行。
## 命令行API
在控制臺中,除了使用console對象,還可以使用一些控制臺自帶的命令行方法。
(1)$_
$_屬性返回上一個表達式的值。
```javascript
2+2
// 4
$_
// 4
```
(2)$0 - $4
控制臺保存了最近5個在Elements面板選中的DOM元素,$0代表倒數第一個,$1代表倒數第二個,以此類推直到$4。
(3)$(selector)
$(selector)返回一個數組,包括特定的CSS選擇器匹配的所有DOM元素。該方法實際上是document.querySelectorAll方法的別名。
```javascript
var images = $('img');
for (each in images) {
console.log(images[each].src);
}
```
上面代碼打印出網頁中所有img元素的src屬性。
(4)$$(selector)
$$(selector)返回一個選中的DOM對象,等同于document.querySelectorAll。
(5)$x(path)
$x(path)方法返回一個數組,包含匹配特定XPath表達式的所有DOM元素。
```javascript
$x("//p[a]")
```
上面代碼返回所有包含a元素的p元素。
(6)inspect(object)
inspect(object)方法打開相關面板,并選中相應的元素:DOM元素在Elements面板中顯示,JavaScript對象在Profiles中顯示。
(7)getEventListeners(object)
getEventListeners(object)方法返回一個對象,該對象的成員為登記了回調函數的各種事件(比如click或keydown),每個事件對應一個數組,數組的成員為該事件的回調函數。
(8)keys(object),values(object)
keys(object)方法返回一個數組,包含特定對象的所有鍵名。
values(object)方法返回一個數組,包含特定對象的所有鍵值。
```javascript
var o = {'p1': 'a', 'p2': 'b'};
keys(o)
// ["p1", "p2"]
values(o)
// ["a", "b"]
```
(9)`monitorEvents(object[, events]) ,unmonitorEvents(object[, events])`
`monitorEvents(object[, events])`方法監聽特定對象上發生的特定事件。當這種情況發生時,會返回一個Event對象,包含該事件的相關信息。unmonitorEvents方法用于停止監聽。
```javascript
monitorEvents(window, "resize");
monitorEvents(window, ["resize", "scroll"])
```
上面代碼分別表示單個事件和多個事件的監聽方法。
```javascript
monitorEvents($0, "mouse");
unmonitorEvents($0, "mousemove");
```
上面代碼表示如何停止監聽。
monitorEvents允許監聽同一大類的事件。所有事件可以分成四個大類。
- mouse:"mousedown", "mouseup", "click", "dblclick", "mousemove", "mouseover", "mouseout", "mousewheel"
- key:"keydown", "keyup", "keypress", "textInput"
- touch:"touchstart", "touchmove", "touchend", "touchcancel"
- control:"resize", "scroll", "zoom", "focus", "blur", "select", "change", "submit", "reset"
```javascript
monitorEvents($("#msg"), "key");
```
上面代碼表示監聽所有key大類的事件。
(10)`profile([name])`,profileEnd()
profile方法用于啟動一個特定名稱的CPU性能測試,profileEnd方法用于結束該性能測試。
```javascript
profile("My profile")
profileEnd("My profile")
```
(11)其他方法
命令行API還提供以下方法。
- clear()方法清除控制臺的歷史。
- copy(object)方法復制特定DOM元素到剪貼板。
- dir(object)方法顯示特定對象的所有屬性,是console.dir方法的別名。
- dirxml(object)方法顯示特定對象的XML形式,是console.dirxml方法的別名。
## debugger語句
`debugger`語句必須與除錯工具配合使用,如果沒有除錯工具,debugger語句不會產生任何結果。
在Chrome瀏覽器中,當代碼運行到debugger指定的行時,就會暫停運行,自動打開控制臺界面。它的作用類似于設置斷點。
```javascript
for(var i = 0;i < 5;i++){
console.log(i);
if (i === 2) debugger;
}
```
上面代碼打印出0,1,2以后,就會暫停,自動打開console窗口,等待進一步處理。
## 移動端開發
(本節暫存此處)
### 模擬手機視口(viewport)
chrome瀏覽器的開發者工具,提供一個選項,可以模擬手機屏幕的顯示效果。
打開“設置”的Overrides面板,選擇相應的User Agent和Device Metrics選項。

User Agent可以使得當前瀏覽器發出手機瀏覽器的Agent字符串,Device Metrics則使得當前瀏覽器的視口變為手機的視口大小。這兩個選項可以獨立選擇,不一定同時選中。
### 模擬touch事件
我們可以在PC端模擬JavaScript的touch事件。
首先,打開chrome瀏覽器的開發者工具,選擇“設置”中的Overrides面板,勾選“Enable touch events”選項。

然后,鼠標就會觸發touchstart、touchmove和touchend事件。(此時,鼠標本身的事件依然有效。)
至于多點觸摸,必須要有支持這個功能的設備才能模擬,具體可以參考[Multi-touch web development](http://www.html5rocks.com/en/mobile/touch/)。
### 模擬經緯度
chrome瀏覽器的開發者工具,還可以模擬當前的經緯度數據。打開“設置”的Overrides面板,選中Override Geolocation選項,并填入相應經度和緯度數據。

### 遠程除錯
(1) Chrome for Android
Android設備上的Chrome瀏覽器支持USB除錯。PC端需要安裝Android SDK和Chrome瀏覽器,然后用usb線將手機和PC連起來,可參考[官方文檔](https://developers.google.com/chrome-developer-tools/docs/remote-debugging)。
(2) Opera
Opera瀏覽器的除錯環境Dragonfly支持遠程除錯([教程](http://www.codegeek.net/blog/2012/mobile-debugging-with-opera-dragonfly/))。
(3) Firefox for Android
參考[官方文檔](https://hacks.mozilla.org/2012/08/remote-debugging-on-firefox-for-android/)。
(4) Safari on iOS6
你可以使用Mac桌面電腦的Safari 6瀏覽器,進行遠程除錯([教程](http://www.mobilexweb.com/blog/iphone-5-ios-6-html5-developers))。
## Google Closure
(本節暫存此處)
Google Closure是Google提供的一個JavaScript源碼處理工具,主要用于壓縮和合并多個JavaScript腳本文件。
Google Closure使用Java語言開發,使用之前必須先安裝Java。然后,前往[官方網站](https://developers.google.com/closure/)進行下載,這里我們主要使用其中的編譯器(compiler)。
首先,查看使用幫助。
```bash
java -jar /path/to/closure/compiler.jar --help
```
直接在腳本命令后面跟上要合并的腳本,就能完成合并。
```bash
java -jar /path/to/closure/compiler.jar *.js
```
使用--js參數,可以確保按照參數的先后次序合并文件。
```bash
java -jar /path/to/closure/compiler.jar --js script1.js --js script2.js --js script3.js
```
但是,這樣的運行結果是將合并后的文件全部輸出到屏幕(標準輸出),因此需要使用--js_output_file參數,指定合并后的文件名。
```bash
java -jar /path/to/closure/compiler.jar --js script1.js --js script2.js --js script3.js --js_output_file scripts-compiled.js
```
## Javascript 性能測試
(本節暫存此處)
### 第一種做法
最常見的測試性能的做法,就是同一操作重復n次,然后計算每次操作的平均時間。
```javascript
var totalTime,
start = new Date,
iterations = 6;
while (iterations--) {
// Code snippet goes here
}
// totalTime → the number of milliseconds it took to execute
// the code snippet 6 times
totalTime = new Date - start;
```
上面代碼的問題在于,由于計算機的性能不斷提高,如果只重復6次,很可能得到0毫秒的結果,即不到1毫秒,Javascript引擎無法測量。
### 第二種做法
另一種思路是,測試單位時間內完成了多少次操作。
```javascript
var hz,
period,
startTime = new Date,
runs = 0;
do {
// Code snippet goes here
runs++;
totalTime = new Date - startTime;
} while (totalTime < 1000);
// convert ms to seconds
totalTime /= 1000;
// period → how long per operation
period = totalTime / runs;
// hz → the number of operations per second
hz = 1 / period;
// can be shortened to
// hz = (runs * 1000) / totalTime;
```
這種做法的注意之處在于,測試結構受外界環境影響很大,為了得到正確結構,必須重復多次。
<h2 id="8.2">Gulp:任務自動管理工具</h2>
Gulp與Grunt一樣,也是一個自動任務運行器。它充分借鑒了Unix操作系統的管道(pipe)思想,很多人認為,在操作上,它要比Grunt簡單。
## 安裝
Gulp需要全局安裝,然后再在項目的開發目錄中安裝為本地模塊。先進入項目目錄,運行下面的命令。
```bash
npm install -g gulp
npm install --save-dev gulp
```
除了安裝gulp以外,不同的任務還需要安裝不同的gulp插件模塊。舉例來說,下面代碼安裝了gulp-uglify模塊。
```bash
$ npm install --save-dev gulp-uglify
```
## gulpfile.js
項目根目錄中的gulpfile.js,是Gulp的配置文件。下面就是一個典型的gulpfile.js文件。
```javascript
var gulp = require('gulp');
var uglify = require('gulp-uglify');
gulp.task('minify', function () {
gulp.src('js/app.js')
.pipe(uglify())
.pipe(gulp.dest('build'))
});
```
上面代碼中,gulpfile.js加載gulp和gulp-uglify模塊之后,使用gulp模塊的task方法指定任務minify。task方法有兩個參數,第一個是任務名,第二個是任務函數。在任務函數中,使用gulp模塊的src方法,指定所要處理的文件,然后使用pipe方法,將上一步的輸出轉為當前的輸入,進行鏈式處理。
task方法的回調函數使用了兩次pipe方法,也就是說做了兩種處理。第一種處理是使用gulp-uglify模塊,壓縮源碼;第二種處理是使用gulp模塊的dest方法,將上一步的輸出寫入本地文件,這里是build.js(代碼中省略了后綴名js)。
執行minify任務時,就在項目目錄中執行下面命令就可以了。
```bash
$ gulp minify
```
從上面的例子中可以看到,gulp充分使用了“管道”思想,就是一個數據流(stream):src方法讀入文件產生數據流,dest方法將數據流寫入文件,中間是一些中間步驟,每一步都對數據流進行一些處理。
下面是另一個數據流的例子。
```javascript
gulp.task('js', function () {
return gulp.src('js/*.js')
.pipe(jshint())
.pipe(uglify())
.pipe(concat('app.js'))
.pipe(gulp.dest('build'));
});
```
上面代碼使用pipe命令,分別進行jshint、uglify、concat三步處理。
## gulp模塊的方法
### src()
gulp模塊的src方法,用于產生數據流。它的參數表示所要處理的文件,這些指定的文件會轉換成數據流。參數的寫法一般有以下幾種形式。
- js/app.js:指定確切的文件名。
- js/*.js:某個目錄所有后綴名為js的文件。
- js/\*\*/*.js:某個目錄及其所有子目錄中的所有后綴名為js的文件。
- !js/app.js:除了js/app.js以外的所有文件。
- *.+(js|css):匹配項目根目錄下,所有后綴名為js或css的文件。
src方法的參數還可以是一個數組,用來指定多個成員。
```javascript
gulp.src(['js/**/*.js', '!js/**/*.min.js'])
```
### dest()
dest方法將管道的輸出寫入文件,同時將這些輸出繼續輸出,所以可以依次調用多次dest方法,將輸出寫入多個目錄。如果有目錄不存在,將會被新建。
```javascript
gulp.src('./client/templates/*.jade')
.pipe(jade())
.pipe(gulp.dest('./build/templates'))
.pipe(minify())
.pipe(gulp.dest('./build/minified_templates'));
```
dest方法還可以接受第二個參數,表示配置對象。
```javascript
gulp.dest('build', {
cwd: './app',
mode: '0644'
})
```
配置對象有兩個字段。cwd字段指定寫入路徑的基準目錄,默認是當前目錄;mode字段指定寫入文件的權限,默認是0777。
### task()
task方法用于定義具體的任務。它的第一個參數是任務名,第二個參數是任務函數。下面是一個非常簡單的任務函數。
```javascript
gulp.task('greet', function () {
console.log('Hello world!');
});
```
task方法還可以指定按順序運行的一組任務。
```javascript
gulp.task('build', ['css', 'js', 'imgs']);
```
上面代碼先指定build任務,它由css、js、imgs三個任務所組成,task方法會并發執行這三個任務。注意,由于每個任務都是異步調用,所以沒有辦法保證js任務的開始運行的時間,正是css任務運行結束。
如果希望各個任務嚴格按次序運行,可以把前一個任務寫成后一個任務的依賴模塊。
```javascript
gulp.task('css', ['greet'], function () {
// Deal with CSS here
});
```
上面代碼表明,css任務依賴greet任務,所以css一定會在greet運行完成后再運行。
task方法的回調函數,還可以接受一個函數作為參數,這對執行異步任務非常有用。
```javascript
// 執行shell命令
var exec = require('child_process').exec;
gulp.task('jekyll', function(cb) {
// build Jekyll
exec('jekyll build', function(err) {
if (err) return cb(err); // return error
cb(); // finished task
});
});
```
如果一個任務的名字為default,就表明它是“默認任務”,在命令行直接輸入gulp命令,就會運行該任務。
```javascript
gulp.task('default', function () {
// Your default task
});
// 或者
gulp.task('default', ['styles', 'jshint', 'watch']);
```
執行的時候,直接使用gulp,就會運行styles、jshint、watch三個任務。
### watch()
watch方法用于指定需要監視的文件。一旦這些文件發生變動,就運行指定任務。
```javascript
gulp.task('watch', function () {
gulp.watch('templates/*.tmpl.html', ['build']);
});
```
上面代碼指定,一旦templates目錄中的模板文件發生變化,就運行build任務。
watch方法也可以用回調函數,代替指定的任務。
```javascript
gulp.watch('templates/*.tmpl.html', function (event) {
console.log('Event type: ' + event.type);
console.log('Event path: ' + event.path);
});
```
另一種寫法是watch方法所監控的文件發生變化時(修改、增加、刪除文件),會觸發change事件。可以對change事件指定回調函數。
```javascript
var watcher = gulp.watch('templates/*.tmpl.html', ['build']);
watcher.on('change', function (event) {
console.log('Event type: ' + event.type);
console.log('Event path: ' + event.path);
});
```
除了change事件,watch方法還可能觸發以下事件。
- end:回調函數運行完畢時觸發。
- error:發生錯誤時觸發。
- ready:當開始監聽文件時觸發。
- nomatch:沒有匹配的監聽文件時觸發。
watcher對象還包含其他一些方法。
- watcher.end():停止watcher對象,不會再調用任務或回調函數。
- watcher.files():返回watcher對象監視的文件。
- watcher.add(glob):增加所要監視的文件,它還可以附件第二個參數,表示回調函數。
- watcher.remove(filepath):從watcher對象中移走一個監視的文件。
## gulp-load-plugins模塊
一般情況下,gulpfile.js中的模塊需要一個個加載。
```javascript
var gulp = require('gulp'),
jshint = require('gulp-jshint'),
uglify = require('gulp-uglify'),
concat = require('gulp-concat');
gulp.task('js', function () {
return gulp.src('js/*.js')
.pipe(jshint())
.pipe(jshint.reporter('default'))
.pipe(uglify())
.pipe(concat('app.js'))
.pipe(gulp.dest('build'));
});
```
上面代碼中,除了gulp模塊以外,還加載另外三個模塊。
這種一一加載的寫法,比較麻煩。使用gulp-load-plugins模塊,可以加載package.json文件中所有的gulp模塊。上面的代碼用gulp-load-plugins模塊改寫,就是下面這樣。
```javascript
var gulp = require('gulp'),
gulpLoadPlugins = require('gulp-load-plugins'),
plugins = gulpLoadPlugins();
gulp.task('js', function () {
return gulp.src('js/*.js')
.pipe(plugins.jshint())
.pipe(plugins.jshint.reporter('default'))
.pipe(plugins.uglify())
.pipe(plugins.concat('app.js'))
.pipe(gulp.dest('build'));
});
```
上面代碼假設package.json文件包含以下內容。
```javascript
{
"devDependencies": {
"gulp-concat": "~2.2.0",
"gulp-uglify": "~0.2.1",
"gulp-jshint": "~1.5.1",
"gulp": "~3.5.6"
}
}
```
## gulp-livereload模塊
gulp-livereload模塊用于自動刷新瀏覽器,反映出源碼的最新變化。它除了模塊以外,還需要在瀏覽器中安裝插件,用來配合源碼變化。
```javascript
var gulp = require('gulp'),
less = require('gulp-less'),
livereload = require('gulp-livereload'),
watch = require('gulp-watch');
gulp.task('less', function() {
gulp.src('less/*.less')
.pipe(watch())
.pipe(less())
.pipe(gulp.dest('css'))
.pipe(livereload());
});
```
上面代碼監視less文件,一旦編譯完成,就自動刷新瀏覽器。
<h2 id="8.3">Browserify:瀏覽器加載Node.js模塊</h2>
隨著JavaScript程序逐漸模塊化,在ECMAScript 6推出官方的模塊處理方案之前,有兩種方案在實踐中廣泛采用:一種是AMD模塊規范,針對模塊的異步加載,主要用于瀏覽器端;另一種是CommonJS規范,針對模塊的同步加載,主要用于服務器端,即node.js環境。
Browserify是一個node.js模塊,主要用于改寫現有的CommonJS模塊,使得瀏覽器端也可以使用這些模塊。使用下面的命令,在全局環境下安裝Browserify。
```bash
$ npm install -g browserify
```
## 基本用法
先看一個例子。假定有一個很簡單的CommonJS模塊文件foo.js。
```javascript
// foo.js
module.exports = function(x) {
console.log(x);
};
```
然后,還有一個main.js文件,用來加載foo模塊。
```javascript
// main.js
var foo = require("./foo");
foo("Hi");
```
使用Browserify,將main.js轉化為瀏覽器可以加載的腳本compiled.js。
```bash
browserify main.js > compiled.js
# 或者
browserify main > compiled.js
# 或者
browserify main.js -o compiled.js
```
之所以轉化后的文件叫做compiled.js,是因為該文件不僅包括了main.js,還包括了它所依賴的foo.js。兩者打包在一起,保證瀏覽器加載時的依賴關系。
```html
<script src="compiled.js"></script>
```
使用上面的命令,在瀏覽器中運行compiled.js,控制臺會顯示Hi。
我們再看一個在服務器端的backbone模塊轉為客戶端backbone模塊的例子。先安裝backbone和它所依賴的jQuery模塊。
```bash
npm install backbone jquery
```
然后,新建一個main.js文件。
```javascript
// main.js
var Backbone = require('backbone');
var $ = Backbone.$ = require('jquery/dist/jquery')(window);
var AppView = Backbone.View.extend({
render: function(){
$('main').append('<h1>Browserify is a great tool.</h1>');
}
});
var appView = new AppView();
appView.render();
```
接著,使用browserify將main.js轉為app.js。
```bash
browserify main.js -o app.js
```
app.js就可以直接插入HTML網頁了。
```html
<script src="app.js"></script>
```
注意,只要插入app.js一個文件就可以了,完全不需要再加載backbone.js和jQuery了。
## 管理前端模塊
Browserify的主要作用是將CommonJS模塊轉為瀏覽器可以調用的格式,但是純粹的前端模塊,也可以用它打包。
首先,新建一個項目目錄,添加package.json文件。
```javascript
{
"name": "demo",
"version": "1.0.0"
}
```
接著,新建index.html。
```html
<!doctype html>
<html>
<head>
<title>npm and jQuery demo</title>
</head>
<body>
<span class="title-tipso tipso_style" title="This is a loaded TIPSO!">
Roll over to see the tip
</span>
<script src="./bundle.js"/>
</body>
</html>
```
上面代碼中的bundle.js,就是Browserify打包后將生成的文件。
然后,安裝jquery和它的插件。
```javascript
$ npm install --save jquery tipso
```
接著,新建一個文件entry.js。
```javascript
global.jQuery = require('jquery');
require('tipso');
jQuery(function(){
jQuery('.title-tipso').tipso();
});
```
上面的文件中,第一行之所以要把jQuery寫成global的屬性,是為了轉碼之后,它可以變成一個全局變量。
最后,Browserify打包。
```bash
$ browserify entry.js --debug > bundle.js
```
上面代碼中,--debug參數表示在打包后的文件中加入source map以便除錯。
這時,瀏覽器打開index.html,腳本已經可以運行。如果不希望將jQuery一起打包,而是通過CDN加載,可以使用browserify-shim模塊。
另外一個問題是,某些jQuery插件還有自帶的CSS文件,這時可以安裝parcelify模塊。
```bash
$ npm install -g parcelify
```
然后,在package.json中寫入規則,聲明CSS文件的位置。
```javascript
"style": [
"./node_modules/tipso/src/tipso.css"
]
```
接著,運行parcelify進行CSS打包。
```bash
$ parcelify entry.js -c bundle.css
```
最后,將打包后的CSS文件插入index.html。
```html
<link rel="stylesheet" href="bundle.css" />
```
## 生成前端模塊
有時,我們只是希望將node.js的模塊,移植到瀏覽器,使得瀏覽器端可以調用。這時,可以采用browserify的-r參數(--require的簡寫)。
```bash
browserify -r through -r ./my-file.js:my-module > bundle.js
```
上面代碼將through和my-file.js(后面的冒號表示指定模塊名為my-module)都做成了模塊,可以在其他script標簽中調用。
```html
<script src="bundle.js"></script>
<script>
var through = require('through');
var myModule = require('my-module');
/* ... */
</script>
```
可以看到,-r參數的另一個作用,就是為瀏覽器端提供require方法。
## 腳本文件的實時生成
Browserify還可以實時生成腳本文件。
下面是一個服務器端腳本,啟動Web服務器之后,外部用戶每次訪問這個腳本,它的內容是實時生成的。
```javascript
var browserify = require('browserify');
var http = require('http');
http.createServer(function (req, res) {
if (req.url === '/bundle.js') {
res.setHeader('content-type', 'application/javascript');
var b = browserify(__dirname + '/main.js').bundle();
b.on('error', console.error);
b.pipe(res);
}
else res.writeHead(404, 'not found')
});
```
## browserify-middleware模塊
上面是將服務器端模塊直接轉為客戶端腳本,然后在網頁中調用這個轉化后的腳本文件。還有一種思路是,在運行時動態轉換模塊,這就需要用到[browserify-middleware模塊](https://github.com/ForbesLindesay/browserify-middleware)。
比如,網頁中需要加載app.js,它是從main.js轉化過來的。
```html
<!-- index.html -->
<script src="app.js"></script>
```
你可以在服務器端靜態生成一個app.js文件,也可以讓它動態生成。這就需要用browserify-middleware模塊,服務器端腳本要像下面這樣寫。
```javascript
var browserify = require('browserify-middleware');
var express = require('express');
var app = express();
app.get('/app.js', browserify('./client/main.js'));
app.get('/', function(req, res){
res.render('index.html');
});
```
<h2 id="8.4">Source Map</h2>
## 概述
隨著JavaScript腳本變得越來越復雜,大部分源碼(尤其是各種函數庫和框架)都要經過轉換,才能投入生產環境。
常見的源碼轉換,主要是以下三種情況:
- 壓縮,減小體積。比如jQuery 1.9的源碼,壓縮前是252KB,壓縮后是32KB。
- 多個文件合并,減少HTTP請求數。
- 其他語言編譯成JavaScript。最常見的例子就是CoffeeScript。
這三種情況,都使得實際運行的代碼不同于開發代碼,除錯(debug)變得困難重重。
通常,JavaScript的解釋器會告訴你,第幾行第幾列代碼出錯。但是,這對于轉換后的代碼毫無用處。舉例來說,jQuery 1.9壓縮后只有3行,每行3萬個字符,所有內部變量都改了名字。你看著報錯信息,感到毫無頭緒,根本不知道它所對應的原始位置。
這就是Source map想要解決的問題。
簡單說,Source map就是一個信息文件,里面儲存著位置信息。也就是說,轉換后的代碼的每一個位置,所對應的轉換前的位置。
有了它,出錯的時候,除錯工具將直接顯示原始代碼,而不是轉換后的代碼。這無疑給開發者帶來了很大方便。
目前,暫時只有Chrome瀏覽器支持這個功能。在Developer Tools的Setting設置中,確認選中"Enable source maps"。
## 生成和啟用
生成Source Map的最常用方法,是使用Google的[Closure編譯器](https://developers.google.com/closure/compiler/)。
生成命令的格式如下:
```java
java -jar compiler.jar \
--js script.js \
--create_source_map ./script-min.js.map \
--source_map_format=V3 \
--js_output_file script-min.js
```
各個參數的意義如下:
- js: 轉換前的代碼文件
- create_source_map: 生成的source map文件
- source_map_format:source map的版本,目前一律采用V3。
- js_output_file: 轉換后的代碼文件。
其他的生成方法可以參考[這篇文章](http://net.tutsplus.com/tutorials/tools-and-tips/source-maps-101/)。
啟用Source map的方法很簡單,只要在轉換后的代碼頭部或尾部,加上一行就可以了。
```javascript
//# sourceMappingURL=/path/to/file.js.map
```
或者
```javascript
/*# sourceMappingURL=/path/to/file.js.map */
```
map文件可以放在網絡上,也可以放在本地文件系統。
## 格式
打開Source map文件,它大概是這個樣子:
```javascript
{
version : 3,
file: "out.js",
sourceRoot : "",
sources: ["foo.js", "bar.js"],
names: ["src", "maps", "are", "fun"],
mappings: "AAgBC,SAAQ,CAAEA"
}
```
整個文件就是一個JavaScript對象,可以被解釋器讀取。它主要有以下幾個屬性:
- version:Source map的版本,目前為3。
- file:轉換后的文件名。
- sourceRoot:轉換前的文件所在的目錄。如果與轉換前的文件在同一目錄,該項為空。
- sources:轉換前的文件。該項是一個數組,表示可能存在多個文件合并。
- names:轉換前的所有變量名和屬性名。
- mappings:記錄位置信息的字符串。
## mappings屬性
轉換前后的代碼一一對應的關鍵,就是map文件的mappings屬性。這是一個很長的字符串,它分成三層。
第一層是行對應,以分號(;)表示,每個分號對應轉換后源碼的一行。所以,第一個分號前的內容,就對應源碼的第一行,以此類推。
第二層是位置對應,以逗號(,)表示,每個逗號對應轉換后源碼的一個位置。所以,第一個逗號前的內容,就對應該行源碼的第一個位置,以此類推。
第三層是位置轉換,以[VLQ編碼](http://en.wikipedia.org/wiki/Variable-length_quantity)表示,代表該位置對應的轉換前的源碼位置。
舉例來說,假定mappings屬性的內容如下:
```javascript
mappings:"AAAAA,BBBBB;CCCCC"
```
它表示,轉換后的源碼分成兩行,第一行有兩個位置,第二行有一個位置。
每個位置使用五位,表示五個字段。從左邊算起,
- 第一位,表示這個位置在(轉換后的代碼的)的第幾列。
- 第二位,表示這個位置屬于sources屬性中的哪一個文件。
- 第三位,表示這個位置屬于轉換前代碼的第幾行。
- 第四位,表示這個位置屬于轉換前代碼的第幾列。
- 第五位,表示這個位置屬于names屬性中的哪一個變量。
有幾點需要說明。首先,所有的值都是以0作為基數的。其次,第五位不是必需的,如果該位置沒有對應names屬性中的變量,可以省略第五位。再次,每一位都采用VLQ編碼表示;由于VLQ編碼是變長的,所以每一位可以由多個字符構成。
如果某個位置是AAAAA,由于A在VLQ編碼中表示0,因此這個位置的五個位實際上都是0。它的意思是,該位置在轉換后代碼的第0列,對應sources屬性中第0個文件,屬于轉換前代碼的第0行第0列,對應names屬性中的第0個變量。
## VLQ編碼
這種編碼最早用于MIDI文件,后來被多種格式采用。它的特點就是可以非常精簡地表示很大的數值。
VLQ編碼是變長的。如果(整)數值在-15到+15之間(含兩個端點),用一個字符表示;超出這個范圍,就需要用多個字符表示。它規定,每個字符使用6個兩進制位,正好可以借用[Base 64編碼](http://en.wikipedia.org/wiki/Base_64)的字符表。
在這6個位中,左邊的第一位(最高位)表示是否"連續"(continuation)。如果是1,代表這6個位后面的6個位也屬于同一個數;如果是0,表示該數值到這6個位結束。
這6個位中的右邊最后一位(最低位)的含義,取決于這6個位是否是某個數值的VLQ編碼的第一個字符。如果是的,這個位代表"符號"(sign),0為正,1為負(Source map的符號固定為0);如果不是,這個位沒有特殊含義,被算作數值的一部分。
```bash
Continuation
| Sign
| |
V V
101011
```
下面舉例如何對數值16進行VLQ編碼。
(1) 將16改寫成二進制形式10000。
(2) 在最右邊補充符號位。因為16大于0,所以符號位為0,整個數變成100000。
(3) 從右邊的最低位開始,將整個數每隔5位,進行分段,即變成1和00000兩段。如果最高位所在的段不足5位,則前面補0,因此兩段變成00001和00000。
(4) 將兩段的順序倒過來,即00000和00001。
(5) 在每一段的最前面添加一個"連續位",除了最后一段為0,其他都為1,即變成100000和000001。
(6) 將每一段轉成Base 64編碼。查表可知,100000為g,000001為B。因此,數值16的VLQ編碼為gB。
上面的過程,看上去好像很復雜,做起來其實很簡單,具體的實現可以參考官方的[base64-vlq.js](https://github.com/mozilla/source-map/blob/master/lib/source-map/base64-vlq.js)文件,里面有詳細的注釋。
<h2 id="8.5">JavaScript 程序測試</h2>
## 為什么要寫測試?
Web應用程序越來越復雜,這意味著有更多的可能出錯。測試是幫助我們提高代碼質量、降低錯誤的最好方法和工具之一。
- 測試可以確保得到預期結果。
- 加快開發速度。
- 方便維護。
- 提供用法的文檔。
通過測試提供軟件的質量,在開始的時候,可能會降低開發速度。但是從長期看,尤其是那種代碼需要長期維護、不斷開發的情況,測試會大大加快開發速度,減輕維護難度。
## 測試的類型
### 單元測試
單元測試(unit testing)指的是以軟件的單元(unit)為單位,對軟件進行測試。單元可以是一個函數,也可以是一個模塊或組件。它的基本特征就是,只要輸入不變,必定返回同樣的輸出。
“單元測試”這個詞,本身就暗示,軟件應該以模塊化結構存在。每個模塊的運作,是獨立于其他模塊的。一個軟件越容易寫單元測試,往往暗示著它的模塊化結構越好,各模塊之間的耦合就越弱;越難寫單元測試,或者每次單元測試,不得不模擬大量的外部條件,很可能暗示軟件的模塊化結構越差,模塊之間存在較強的耦合。
單元測試的要求是,每個模塊都必須有單元測試,而軟件由模塊組成。
單元測試通常采取斷言(assertion)的形式,也就是測試某個功能的返回結果,是否與預期結果一致。如果與預期不一致,就表示測試失敗。
單元測試是函數正常工作、不出錯的最基本、最有效的方法之一。 每一個單元測試發出一個特定的輸入到所要測試的函數,看看函數是否返回預期的輸出,或者采取了預期的行動。單元測試證明了所測試的代碼行為符合預期。
單元測試有助于代碼的模塊化,因此有助于長期的重用。因為有了測試,你就知道代碼是可靠的,可以按照預期運行。從這個角度說,測試可以節省開發時間。單元測試的另一個好處是,有了測試,就等于就有了代碼功能的文檔,有助于其他開發者了解代碼的意圖。
單元測試應該避免依賴性問題,比如不存取數據庫、不訪問網絡等等,而是使用工具虛擬出運行環境。這種虛擬使得測試成本最小化,不用花大力氣搭建各種測試環境。
一般來說,單元測試的步驟如下。
- 準備所有的測試條件
- 調用(觸發)所要測試的函數
- 驗證運行結果是否正確
- 還原被修改的記錄
### 其他測試類型
(1)集成測試
集成測試(Integration test)指的是多個部分在一起測試,比如測試一個數據庫連接模塊,是否能夠連接數據庫。
(2)功能測試
功能測試(Functional test)指的是,自動測試整個應用程序的某個功能,比如使用Selenium工具自動打開瀏覽器運行程序。
(3)端對端測試
端對端測試(End-to-End testing)指的是全鏈路測試,即從開始端到終止端的測試,比如測試從用戶界面、通過網絡、經過應用程序處理、到達數據庫,是否能夠返回正確結果。端對端測試的目的是,確保整個系統能夠正常運行,各個子系統之間依賴關系正常,數據能夠在子系統之間、模塊之間正確傳遞。
(4)冒煙測試
冒煙測試(smoke testing)指的是,正式的全面測試開始之前,對主要功能進行的預測試。它的主要目的是,確認主要功能能否滿足需要,軟件是否能運行。冒煙測試可以是手工測試,也可以是自動化測試。
這個名字最早來自對電子元件的測試,第一次對電子元件通電,看看它是否會冒煙。如果沒有冒煙,說明通過了測試;如果電流達到某個臨界點之后,才出現冒煙,這時可以評估是否能夠接受這個臨界點。
## 開發模式
測試不僅能夠驗證軟件功能、保證代碼質量,也能夠影響軟件開發的模式。
### TDD
TDD是“測試驅動的開發”(Test-Driven Development)的簡稱,指的是先寫好測試,然后再根據測試完成開發。使用這種開發方式,會有很高的測試覆蓋率。
TDD的開發步驟如下。
- 先寫一個測試。
- 寫出最小數量的代碼,使其能夠通過測試。
- 優化代碼。
- 重復前面三步。
TDD開發的測試覆蓋率通常在90%以上,這意味著維護代碼和新增特性會非常容易。因為測試保證了你可以信任這些代碼,修改它們不會破壞其他代碼的運行。
TDD接口提供以下四個方法。
- suite()
- test()
- setup()
- teardown()
下面代碼是測試計數器是否加1。
```javascript
suite('Counter', function() {
test('tick increases count to 1', function() {
var counter = new Counter();
counter.tick();
assert.equal(counter.count, 1);
});
});
```
### BDD
BDD是“行為驅動的開發”(Behavior-Driven Development)的簡稱,指的是寫出優秀測試的最佳實踐的總稱。
BDD認為,不應該針對代碼的實現細節寫測試,而是要針對行為寫測試。BDD測試的是行為,即軟件應該怎樣運行。
BDD接口提供以下六個方法。
- describe()
- it()
- before()
- after()
- beforeEach()
- afterEach()
下面是測試計數器是否加1的BDD寫法。
```javascript
describe('Counter', function() {
it('should increase count by 1 after calling tick', function() {
var counter = new Counter();
var expectedCount = counter.count + 1;
counter.tick();
assert.equal(counter.count, expectedCount);
});
});
```
下面是一個BDD開發的示例。現在,需要開發一個`Foo`類,該類的實例有一個`sayHi`方法,會對類參數說“Hi”。這就是`Foo`類的規格,根據這個規格,我們可以寫出測試用例文件`foo.spec.js`。
```javascript
describe('Simple object', function() {
var foo;
beforeEach(function() {
foo = new Foo('John');
});
it('should say hi', function() {
expect(foo.sayHi()).toEqual('John says hi!');
});
});
```
有了測試用例以后,我們再寫出實際的腳本文件`foo.js`。
```javascript
function Foo(name) {
this.name = name;
}
Foo.prototype.sayHi = function() {
return this.name + ' says hi!';
};
```
為了把測試用例與腳本文件分開,我們通常把測試用例放在`test`子目錄之中。然后,我們就可以使用Mocha、Jasmine等測試框架,執行測試用例,看看腳本文件是否通過測試。
### BDD術語
(1)測試套件
測試套件(test suite)指的是,一組針對軟件規格的某個方面的測試用例。也可以看作,對軟件的某個方面的描述(describe)。
測試套件由一個`describe`函數構成,它接受兩個參數:第一個參數是字符串,表示測試套件的名字或標題,表示將要測試什么;第二個參數是函數,用來實現這個測試套件。
```javascript
describe("A suite", function() {
// ...
});
```
(2)測試用例
測試用例(test case)指的是,針對軟件一個功能點的測試,是軟件測試的最基本單位。一組相關的測試用例,構成一個測試套件。測試用例由`it`函數構成,它與`describe`函數一樣,接受兩個參數:第一個參數是字符串,表示測試用例的標題;第二個參數是函數,用來實現這個測試用例。
```javascript
describe("A suite", function() {
it("contains spec with an expectation", function() {
// ...
});
});
```
(3)斷言
斷言(assert)指的是對代碼行為的預期。一個測試用例內部,包含一個或多個斷言(assert)。
斷言會返回一個布爾值,表示代碼行為是否符合預期。測試用例之中,只要有一個斷言為false,這個測試用例就會失敗,只有所有斷言都為`true`,測試用例才會通過。
```javascript
describe("A suite", function() {
it("contains spec with an expectation", function() {
expect(true).toBe(true);
});
});
```
## 斷言
斷言是判斷實際值與預期值是否相等的工具。
斷言有assert、expext、should三種風格,或者稱為三種寫法。
```javascript
// assert風格
assert.equal(event.detail.item, '(item)‘);
// expect風格
expect(event.detail.item).to.equal('(item)');
// should風格
event.detail.item.should.equal('(item)');
```
Chai.js是一個很流行的斷言庫,同時支持上面三種風格。
(1) assert風格
```javascript
var assert = require('chai').assert;
var foo = 'bar';
var beverages = { tea: [ 'chai', 'matcha', 'oolong' ] };
assert.typeOf(foo, 'string', 'foo is a string');
assert.equal(foo, 'bar', 'foo equal `bar`');
assert.lengthOf(foo, 3, 'foo`s value has a length of 3');
assert.lengthOf(beverages.tea, 3, 'beverages has 3 types of tea');
```
上面代碼中,assert方法的最后一個參數是錯誤提示信息,只有測試沒有通過時,才會顯示。
(2)expect風格
```javascript
var expect = require('chai').expect;
var foo = 'bar';
var beverages = { tea: [ 'chai', 'matcha', 'oolong' ] };
expect(foo).to.be.a('string');
expect(foo).to.equal('bar');
expect(foo).to.have.length(3);
expect(beverages).to.have.property('tea').with.length(3);
```
(3)should風格
```javascript
var should = require('chai').should();
var foo = 'bar';
var beverages = { tea: [ 'chai', 'matcha', 'oolong' ] };
foo.should.be.a('string');
foo.should.equal('bar');
foo.should.have.length(3);
beverages.should.have.property('tea').with.length(3);
```
## Mocha.js
### 概述
Mocha(發音“摩卡”)是現在最流行的前端測試框架之一,此外常用的測試框架還有[Jasmine](http://jasmine.github.io/)、[Tape](https://github.com/substack/tape/)、[zuul](https://github.com/defunctzombie/zuul/)等。所謂“測試框架”,就是運行測試的工具。
Mocha使用下面的命令安裝。
```bash
# 全局安裝
$ npm install -g mocha chai
# 項目內安裝
$ npm i -D mocha chai
```
上面代碼中,除了安裝Mocha以外,還安裝了斷言庫`chai`,這是因為Mocha自身不帶斷言庫,必須安裝外部斷言庫。
測試套件文件一般放在`test`子目錄下面,配置文件`mocha.opts`也放在這個目錄里面。
### 瀏覽器測試
使用瀏覽器測試時,先用`mocha init`命令在指定目錄生成初始化文件。
```bash
$ mocha init <path>
```
運行上面命令,就會在該目錄下生成一個`index.html`文件,以及配套的腳本和樣式表。
```html
<!DOCTYPE html>
<html>
<head>
<title>Unit.js tests in the browser with Mocha</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="mocha.css" />
</head>
<body>
<h1>Unit.js tests in the browser with Mocha</h1>
<div id="mocha"></div>
<script src="mocha.js"></script>
<script>
mocha.setup('bdd');
</script>
<script src="tests.js"></script>
<script>
mocha.run();
</script>
</body>
</html>
```
然后在該文件中,加入你要測試的文件(比如`app.js`)、測試腳本(`app.spec.js`)和斷言庫(`chai.js`)。
```html
<script src="app.js"></script>
<script src="http://chaijs.com/chai.js"></script>
<script src="app.spec.js"></script>
```
各個文件的內容如下。
```javascript
// app.js
function add(x, y){
return x + y;
}
// app.spec.js
var expect = chai.expect;
describe('測試add函數', function () {
it('1加1應該等于2', function () {
expect(add(1, 1)).to.equal(2);
});
});
```
### 命令行測試
Mocha除了在瀏覽器運行,還可以在命令行運行。
還是使用上面的文件,作為例子,但是要改成CommonJS格式。
```javascript
// app.js
function add(x, y){
return x + y;
}
module.exports = add;
// app.spec.js
var expect = require('chai').expect;
var add = require('../app');
describe('測試add函數', function () {
it('1加1應該等于2', function () {
expect(add(1, 1)).to.equal(2);
});
});
```
然后,在命令行下執行`mocha`,就會執行測試。
```bash
$ mocha
```
上面的命令等同于下面的形式。
```bash
$ mocha test --reporter spec --recursive --growl
```
### mocha.opts
所有Mocha的命令行參數,都可以寫在`test`目錄下的配置文件`mocha.opts`之中。
下面是一個典型的配置文件。
```javascript
--reporter spec
--recursive
--growl
```
上面三個設置的含義如下。
- 使用spec報告模板
- 包括子目錄
- 打開桌面通知插件growl
如果希望測試非存放于test子目錄的測試用例,可以在`mocha.opts`寫入以下內容。
```bash
server-tests
--recursive
```
上面代碼指定運行`server-tests`目錄及其子目錄之中的測試腳本。
### 生成規格文件
Mocha支持從測試用例生成規格文件。
```bash
$ mocha test/app.spec.js -R markdown > spec.md
```
上面命令生成單個`app.spec.js`規格。
生成HTML格式的報告,使用下面的命令。
```bash
$ mocha test/app.spec.js -R doc > spec.html
```
如果要生成整個`test`目錄,對應的規格文件,使用下面的命令。
```bash
$ mocha test -R markdown > spec.md --recursive
```
只要提供測試腳本的路徑,Mocha就可以運行這個測試腳本。
```javascript
$ mocha -w src/index.test.js
```
上面命令運行測試腳本`src/index.test.js`,參數`-w`表示watch,即當這個腳本一有變動,就會運行。
指定測試腳本時,可以使用通配符,同時指定多個文件。
```bash
$ mocha --reporter spec spec/{my,awesome}.js
$ mocha --ui tdd test/unit/*.js etc
```
上面代碼中,參數`--reporter`指定生成的報告格式(上面代碼是spec格式),`-ui`指定采用哪一種測試模式(上面代碼是tdd模式)。
除了使用shell通配符,還可以使用node通配符。
```bash
$ mocha --compilers js:babel-core/register 'test/**/*.@(js|jsx)'
```
上面代碼指定運行`test`目錄下面任何子目錄中,文件后綴名為`js`或`jsx`的測試腳本。注意,Node的通配符要放在單引號之中,因為否則星號(`*`)會先被shell解釋。
如果要改用shell通配符,執行`test`目錄下面任何子目錄的測試腳本,要寫成下面這樣。
```bash
$ mocha test/**.js
```
如果測試腳本不止一個,最好將它們放在專門的目錄當中。Mocha默認執行`test`目錄的測試腳本,所以可以將所有測試腳本放在`test`子目錄。`--recursive`參數可以指定運行子目錄之中的測試腳本。
```bash
$ mocha --recursive
```
上面命令會運行`test`子目錄之中的所有測試腳本。
`--grep`參數用于搜索測試用例的名稱(即it方法的第一個參數),然后只執行匹配的測試用例。
```bash
$ mocha --reporter spec --grep "Fnord:" server-test/*.js
```
上面代碼只測試名稱中包含“Fnord:”的測試用例。
`--invert`參數表示只運行不符合條件的測試腳本。
```bash
$ mocha --grep auth --invert
```
如果測試腳本用到了ES6語法,還需要用`--compiler`參數指定babel進行轉碼。
```bash
$ mocha --compilers js:babel/register --recursive
```
上面命令會在運行測試腳本之前,先用Babel進行轉碼。`--compilers`參數的值是用冒號分隔的一個字符串,冒號左邊是文件的后綴名,右邊是用來處理這一類文件的模塊名。上面代碼表示,運行測試之前,先用`babel/register`模塊,處理一下JS文件。
`--require`參數指定測試腳本默認包含的文件。下面是一個`test_helper.js`文件。
```javascript
// test/test_helper.js
import chai from 'chai';
```
使用`--require`參數,將上面這個腳本包含進所有測試腳本。
```bash
$ mocha --compilers js:babel/register --require ./test/test_helper.js --recursive
```
### 測試腳本的寫法
測試腳本中,describe方法和it方法都允許調用only方法,表示只運行某個測試套件或測試用例。
```javascript
// 例一
describe('Array', function(){
describe.only('#indexOf()', function(){
...
});
});
// 例二
describe("using only", function() {
it.only("this is the only test to be run", function() {
});
it("this is not run", function() {
});
});
```
上面代碼中,只有帶有`only`方法的測試套件或測試用例會運行。
describe方法和it方法還可以調用skip方法,表示跳過指定的測試套件或測試用例。
```javascript
// 例一
describe.skip('Article', function() {
// ...
});
// 例二
describe("using only", function() {
it.skip("this is the only test to be run", function() {
});
it("this is not run", function() {
});
});
```
上面代碼中,帶有`skip`方法的測試套件或測試用例會被忽略。
如果測試用例包含異步操作,可以done方法顯式指定測試用例的運行結束時間。
```javascript
it('logs a', function(done) {
var f = function(){
console.log('logs a');
done();
};
setTimeout(f, 500);
});
```
上面代碼中,正常情況下,函數f還沒有執行,Mocha就已經結束運行了。為了保證Mocha等到測試用例跑完再結束運行,可以手動調用done方法
## Promise的測試
對于異步的測試,測試用例之中,通常必須調用`done`方法,顯式表明異步操作的結束。
```javascript
var expect = require('chai').expect;
it('should do something with promises', function(done) {
var result = asyncTest();
result.then(function(data) {
expect(data).to.equal('foobar');
done();
}, function(error) {
assert.fail(error);
done();
});
});
```
上面代碼之中,Promise對象的`then`方法之中,必須指定`reject`時的回調函數,并且使用`assert.fail`方法拋出錯誤,否則這個錯誤就不會被外界感知。
```javascript
result.then(function(data) {
expect(data).to.equal(blah);
done();
});
```
上面代碼之中,如果Promise被`reject`,是不會被捕獲的,因為Promise之中的錯誤,不會”泄漏“到外界。
Mocha內置了對Promise的支持。
```javascript
it('should fail the test', function() {
var p = Promise.reject('Promise被reject');
return p;
});
```
上面代碼中,Mocha能夠捕獲`reject`的Promise。
因此,使用Mocha時,Promise的測試可以簡化成下面的寫法。
```javascript
var expect = require('chai').expect;
it('should do something with promises', function() {
var result = asyncTest();
return result.then(function(data) {
expect(data).to.equal('foobar');
});
});
```
## 模擬數據
單元測試時,很多時候,測試的代碼會請求HTTP服務器。這時,我們就需要模擬服務器的回應,不能在單元測試時去請求真實服務器數據,否則就不叫單元測試了,而是連同服務器一起測試了。
一些工具庫可以模擬服務器回應。
- [nock](https://github.com/pgte/nock)
- [sinon](http://sinonjs.org/docs/#server)
- [faux-jax](https://github.com/algolia/faux-jax)
- [MITM](https://github.com/moll/node-mitm)
## 覆蓋率
測試的覆蓋率需要安裝istanbul模塊。
```bash
$ npm i -D istanbul
```
然后,在package.json設置運行覆蓋率檢查的命令。
```javascript
"scripts": {
"test:cover": "istanbul cover -x *.test.js _mocha -- -R spec src/index.test.js",
"check-coverage": "istanbul check-coverage --statements 100 --branches 100 --functions 100 --lines 100"
}
```
上面代碼中,`test:cover`是生成覆蓋率報告,`check-coverage`是設置覆蓋率通過的門檻。
然后,將`coverage`目錄寫入`.gitignore`防止連這個目錄一起提交。
如果希望在`git commit`提交之前,先運行一次測試,可以安裝ghooks模塊,配置`pre-commit`鉤子。
安裝ghooks。
```bash
$ npm i -D ghooks
```
在package.json之中,配置`pre-commit`鉤子。
```javascript
"config": {
"ghooks": {
"pre-commit": "npm run test:cover && npm run check-coverage"
}
}
```
還可以把覆蓋率檢查,加入`.travis.yml`文件。
```bash
script:
- npm run test:cover
- npm run check-coverage
```
如果測試腳本使用ES6,`scripts`字段還需要加入Babel轉碼。
```javascript
"scripts": {
"test": "mocha src/index.test.js -w --compilers js:babel/register",
"test:cover": "istanbul cover -x *.test.js _mocha -- -R spec src/index.test.js --compilers js:babel/register"
}
```
覆蓋率報告可以上傳到[codecov.io](https://codecov.io/)。先安裝這個模塊。
```bash
$ npm i -D codecov.io
```
然后在package.json增加一個字段。
```javascript
"scripts": {
"report-coverage": "cat ./coverage/lcov.info | codecov"
}
```
最后,在CI的配置文件`.travis.yml`之中,增加運行這個命令。
```
after_success:
- npm run report-coverage
- npm run semantic-release
```
## WebDriver
WebDriver是一個瀏覽器的自動化框架。它在各種瀏覽器的基礎上,提供一個統一接口,將接收到的指令轉為瀏覽器的原生指令,驅動瀏覽器。
WebDriver由Selenium項目演變而來。Selenium是一個測試自動化框架,它的1.0版叫做Selenium RC,通過一個代理服務器,將測試腳本轉為JavaScript腳本,注入不同的瀏覽器,再由瀏覽器執行這些腳本后返回結果。WebDriver就是Selenium 2.0,它對每個瀏覽器提供一個驅動,測試腳本通過驅動轉換為瀏覽器原生命令,在瀏覽器中執行。
### 定制測試環境
DesiredCapabilities對象用于定制測試環境。
- 定制DesiredCapabilities對象的各個屬性
- 創建DesiredCapabilities實例
- 將DesiredCapabilities實例作為參數,新建一個WebDriver實例
### 操作瀏覽器的方法
WebDriver提供以下方法操作瀏覽器。
close():退出或關閉當前瀏覽器窗口。
```javascript
driver.close();
```
quit():關閉所有瀏覽器窗口,中止當前瀏覽器driver和session。
```javascript
driver.quit();
```
getTitle():返回當前網頁的標題。
```javascript
driver.getTitle();
```
getCurrentUrl():返回當前網頁的網址。
```javascript
driver.getCurrentUrl();
```
getPageSource():返回當前網頁的源碼。
```javascript
// 斷言是否含有指定文本
assert(driver.getPageSource().contains("Hello World"),
"預期含有文本Hello World");
```
click():模擬鼠標點擊。
```javascript
// 例一
driver.findElement(By.locatorType("path"))
.click();
// 例二
driver.get("https://www.google.com");
driver.findElement(By.name("q"))
.sendKeys("webDriver");
driver.findElement(By.id("sblsbb"))
.click();
```
clear():清空文本輸入框。
```javascript
// 例一
driver.findElement(By.locatorType("path")).clear();
// 例二
driver.get("https://www.google.com");
driver.findElement(By.name("q"))
.sendKeys("webDriver");
driver.findElement(By.name("q"))
.clear();
driver.findElement(By.name("q"))
.sendKeys("testing");
```
sendKeys():在文本輸入框輸入文本。
```javascript
driver.findElement(By.locatorType("path"))
.sendKeys("your text");
```
submit():提交表單,或者用來模擬按下回車鍵。
```javascript
// 例一
driver.findElement(By.locatorType("path"))
.submit();
// 例二
driver.get("https://www.google.com");
driver.findElement(By.name("q"))
.sendKeys("webdriver");
element.submit();
```
findElement():返回選中的第一個元素。
```javascript
driver.get("https://www.google.com");
driver.findElement(By.id("lst-ib"));
```
findElements():返回選中的所有元素(0個或多個)。
```javascript
// 例一
driver.findElement(By.id("searchbox"))
.sendKeys("webdriver");
driver.findElements(By.xpath("//div[3]/ul/li"))
.get(0)
.click();
// 例二
driver.findElements(By.tagName("select"))
.get(0)
.findElements(By.tagName("option"))
.get(3)
.click()
.get(4)
.click()
.get(5)
.click();
// 例三:獲取頁面所有鏈接
var links = driver
.get("https://www.google.com")
.findElements(By.tagName("a"));
var linkSize = links.size();
var linksSrc = [];
console.log(linkSize);
for(var i=0;i<linkSize;i++) {
linksSrc[i] = links.get(i).getAttribute("href");
}
for(int i=0;i<linkSize;i++){
driver.navigate().to(linksSrc[i]);
Thread.sleep(3000);
}
```
可以使用`size()`,查看到底選中了多少個元素。
### 網頁元素的定位
WebDriver提供8種定位器,用于定位網頁元素。
- By.id:HTML元素的id屬性
- By.name:HTML元素的name屬性
- By.xpath:使用XPath語法選中HTML元素
- By.cssSelector:使用CSS選擇器語法
- By.className:HTML元素的class屬性
- By.linkText:鏈接文本(只用于選中鏈接)
- By.tagName:HTML元素的標簽名
- By.partialLinkText:部分鏈接文本(只用于選中鏈接)
下面是一個使用id定位器,選中網頁元素的例子。
```javascript
driver.findElement(By.id("sblsbb")).click();
```
### 網頁元素的方法
以下方法屬于網頁元素的方法,而不是webDriver實例的方法。需要注意的是,有些方法是某些元素特有的,比如只有文本框才能輸入文字。如果在網頁元素上調用不支持的方法,WebDriver不會報錯,也不會給出給出任何提示,只會靜靜地忽略。
getAttribute():返回網頁元素指定屬性的值。
```javascript
driver.get("https://www.google.com");
driver.findElement(By.xpath("//div[@id='lst-ib']"))
.getAttribute("class");
```
getText():返回網頁元素的內部文本。
```javascript
driver.findElement(By.locatorType("path")).getText();
```
getTagName():返回指定元素的標簽名。
```javascript
driver.get("https://www.google.com");
driver.findElement(By.xpath("//div[@class='sbib_b']"))
.getTagName();
```
isDisplayed():返回一個布爾值,表示元素是否可見。
```javascript
driver.get("https://www.google.com");
assert(driver.findElement(By.name("q"))
.isDisplayed(),
'搜索框應該可選擇');
```
isEnabled():返回一個布爾值,表示文本框是否可編輯。
```javascript
driver.get("https://www.google.com");
var Element = driver.findElement(By.name("q"));
if (Element.isEnabled()) {
driver.findElement(By.name("q"))
.sendKeys("Selenium Essentials");
} else {
throw new Error();
}
```
isSelected():返回一個布爾值,表示一個元素是否可選擇。
```javascript
driver.findElement(By.xpath("//select[@name='jump']/option[1]"))
.isSelected()
```
getSize():返回一個網頁元素的寬度和高度。
```javascript
var dimensions=driver.findElement(By.locatorType("path"))
.getSize();
dimensions.width;
dimensions.height;
```
getLocation():返回網頁元素左上角的x坐標和y坐標。
```javascript
var point = driver.findElement(By.locatorType("path")).getLocation();
point.x; // 等同于 point.getX();
point.y; // 等同于 point.getY();
```
getCssValue():返回網頁元素指定的CSS屬性的值。
```javascript
driver.get("https://www.google.com");
var element = driver.findElement(By.xpath("//div[@id='hplogo']"));
console.log(element.getCssValue("font-size"));
console.log(element.getCssValue("font-weight"));
console.log(element.getCssValue("color"));
console.log(element.getCssValue("background-size"));
```
### 頁面跳轉的方法
以下方法用來跳轉到某一個頁面。
get():要求瀏覽器跳到某個網址。
```javascript
driver.get("URL");
```
navigate().back():瀏覽器回退。
```javascript
driver.navigate().back();
```
navigate().forward():瀏覽器前進。
```javascript
driver.navigate().forward();
```
navigate().to():跳轉到瀏覽器歷史中的某個頁面。
```javascript
driver.navigate().to("URL");
```
navigate().refresh():刷新當前頁面。
```javascript
driver.navigate().refresh();
// 等同于
driver.navigate()
.to(driver.getCurrentUrl());
// 等同于
driver.findElement(By.locatorType("path"))
.sendKeys(Keys.F5);
```
### cookie的方法
getCookies():獲取cookie
```javascript
driver.get("https://www.google.com");
driver.manage().getCookies();
```
getCookieNamed() :返回指定名稱的cookie。
```javascript
driver.get("https://www.google.com");
console.log(driver.manage().getCookieNamed("NID"));
```
addCookie():將cookie加入當前頁面。
```javascript
driver.get("https://www.google.com");
driver.manage().addCookie(cookie0);
```
deleteCookie():刪除指定的cookie。
```javascript
driver.get("https://www.google.co.in");
driver.manage().deleteCookieNamed("NID");
```
### 瀏覽器窗口的方法
maximize():最大化瀏覽器窗口。
```javascript
var driver = new FirefoxDriver();
driver.manage().window().maximize();
```
getSize():返回瀏覽器窗口、圖像、網頁元素的寬和高。
```javascript
driver.manage().window().getSize();
```
getPosition():返回瀏覽器窗口左上角的x坐標和y坐標。
```javascript
console.log("Position X: " + driver.manage().window().getPosition().x);
console.log("Position Y: " + driver.manage().window().getPosition().y);
console.log("Position X: " + driver.manage().window().getPosition().getX());
console.log("Position Y: " + driver.manage().window().getPosition().getY());
```
setSize():定制瀏覽器窗口的大小。
```javascript
var d = new Dimension(320, 480);
driver.manage().window().setSize(d);
driver.manage().window().setSize(new Dimension(320, 480));
```
setPosition():移動瀏覽器左上角到指定位置。
```javascript
var p = new Point(200, 200);
driver.manage().window().setPosition(p);
driver.manage().window().setPosition(new Point(300, 150));
```
getWindowHandle():返回當前瀏覽器窗口。
```javascript
var parentwindow = driver.getWindowHandle();
driver.switchTo().window(parentwindow);
```
getWindowHandles():返回所有瀏覽器窗口。
```javascript
var childwindows = driver.getWindowHandles();
driver.switchTo().window(childwindow);
```
switchTo.window():在瀏覽器窗口之間切換。
```javascript
driver.SwitchTo().Window(childwindow);
driver.close();
driver.SwitchTo().Window(parentWindow);
```
### 彈出窗口
以下方法處理瀏覽器的彈出窗口。
dismiss() :關閉彈出窗口。
```javascript
var alert = driver.switchTo().alert();
alert.dismiss();
```
accept():接受彈出窗口,相當于按下接受OK按鈕。
```javascript
var alert = driver.switchTo().alert();
alert.accept();
```
getText():返回彈出窗口的文本值。
```javascript
var alert = driver.switchTo().alert();
alert.getText();
```
sendKeys():向彈出窗口發送文本字符串。
```javascript
var alert = driver.switchTo().alert();
alert.sendKeys("Text to be passed");
```
authenticateUsing():處理HTTP認證。
```javascript
var user = new UserAndPassword("USERNAME", "PASSWORD");
alert.authenticateUsing(user);
```
### 鼠標和鍵盤的方法
以下方法模擬鼠標和鍵盤的動作。
- click():鼠標在當前位置點擊。
- clickAndHold():按下鼠標不放
- contextClick():右擊鼠標
- doubleClick():雙擊鼠標
- dragAndDrop():鼠標拖放到目標元素
- dragAndDropBy():鼠標拖放到目標坐標
- keyDown():按下某個鍵
- keyUp():從按下狀態釋放某個鍵
- moveByOffset():移動鼠標到另一個坐標位置
- moveToElement():移動鼠標到另一個網頁元素
- release():釋放拖拉的元素
- sendKeys():控制鍵盤輸出