knysa:異步等待風格PhantomJS腳本編程
要點
-
knysa允許異步等待風格的PhantomJS異步編程;
-
knysa減少對柯里化(curry)的需求;
-
knysa支持try/catch/finally流程塊;
-
knysa對瀏覽器的AJAX調用有更好的支持;
-
knysa試程序流程更加自然;
PhantomJS是提供JavaScript API的可編程無頭瀏覽器(無圖形界面)。它非常適合頁面自動化和測試。其JavaScript API非常優秀,提供了許多高級功能,但同時也陷入了JavaScript常常遇到的“回調地獄(callback hell)”,既深度嵌套的回調。
目前,已經有很多庫和框架致力于解決這個問題。對于PhantomJS來說,CasperJS是其中一個流行的解決方案,但是它僅僅減輕了問題,并沒有解決問題。knysa從另一方面優雅的解決了這個問題。與類似CasperJS,knysa允許開發者有順序的編寫步驟。不同于CasperJS,knysa不會添加大量的樣板代碼(如casper.then()等)。
更重要的是,knysa允許開發者使用諸如if/else/while/break/try/catch/finally等代碼結構,更加自然的控制程序流程。
讓我們使用一個示例來演示嵌套問題和knysa的理念。以下示例是一段CasperJS腳本,其流程是在Google上搜索關鍵字“CasperJS”,然后檢查搜索結果頁面上的每個鏈接到的頁面是否包含關鍵字“CasperJS”:
-
(第9行)打開Google網頁,等待頁面加載完畢;
-
(第11行)網頁加載完畢后,填充搜索框并提交,然后等待響應;
-
(第13行)處理響應:
-
(第16、17行)訪問響應中的每個鏈接,并且等待頁面加載;
-
(第18-23行)當鏈接的頁面加載完畢后,檢查關鍵字“CasperJS”是否存在;
上面的描述非常簡單直接,但是CasperJS的嵌套語法使得代碼看上去比較復雜。
1 var links = []; 2 var casper = require('casper').create(); 3 function getLinks() { 4 var links = document.querySelectorAll('h3.r a'); 5 return Array.prototype.map.call(links, function(e) { 6 return e.getAttribute('href'); 7 }); 8 } 9 casper.start('http://google.com/', function() { 10 // 通過google表單搜索“CasperJS”關鍵字 11 this.fill('form[action="/search"]', { q: 'CasperJS' }, true); 12 }); 13 casper.then(function() { 14 // 聚合“CasperJS”關鍵字搜索結果 15 links = this.evaluate(getLinks); 16 for (var i = 0; i < links.length; i++) { 17 casper.thenOpen(links[i]); 18 casper.then(function() { 19 var isFound = this.evaluate(function() { 20 return document.querySelector('html').textContent.indexOf('CasperJS') >= 0; 21 }); 22 console.log('CasperJS is found on ' + links[i] + ':' + isFound); 23 }); 24 } 25 }); 26 casper.run();
我們可以看到,第18行的casper.then()嵌套在13行的另外一個casper.then()函數中。這樣的嵌套模糊了程序邏輯,使得程序流程混亂。腳本執行過程中,執行流程不是僅僅向前的,程序流程有3個混雜的階段:
- 階段1(第9、13、26行):通過使用casper.start()(第9行)和casper.then()(第13行)創建執行步驟(匿名函數)。這些步驟最后通過執行capser.run()(第26行)開始執行。
- 階段2(第11、15、16、17、18行):隨著步驟的執行,步驟中的代碼(匿名行數)被執行。
- 階段3(第19、20、21、22行):在原步驟列表中增加更多步驟,并且執行。
于是每個嵌套級別增加了一個執行階段。
由于這些混雜的階段,腳本中的每行代碼和腳本執行順序不再匹配。例如,13行在第11行前執行。這對于程序來說難以閱讀和定位問題。另一個問題是難以增加“if/else”的判斷邏輯或者處理任何異常。第三個問題是:第22行的 links[i] 總是會打印“undefined”!
這是為什么呢?
因為在階段3的第22行之前時,變量“i”已經在階段2中被修改成了 links.length 。為了修復這個問題,我們必須采取 柯里化 方式(10a/18b和22a行)。這里我們使用變量“link”來保存links[i]的值(第18a行),然后執行一個匿名函數來返回另一個匿名函數(第18b行):
18 casper.then(function() { 18a var link = links[i]; 18b return function() { 19 var isFound = this.evaluate(function() { 20 return document.querySelector('html').textContent.indexOf('CasperJS') >= 0; 21 }); 22 console.log('CasperJS is found on ' + link + ':' + isFound); 22a } 23 }());
我們可以看見,通過柯里化,“link”現在有了正確的值,但是柯里化增加了更多的嵌套代碼。這太糟糕了,我們能夠做的更好嗎?
答案是肯定的。
事實上,通過knysa,我們可以做的更好:我們可以完全去除代碼中的嵌套和柯里化,腳本將會更加干凈和可讀,同時程序執行流程也會更加自然。
以下是實現相同功能的knysa腳本(注意我們引入了隱式變量“kflow”和“kflow”上的函數,同時還有一些“knysa_”開頭的函數,我們將在后面進行介紹):
-
(第9行)打開Google網頁并等待網頁加載;
-
(第10行)在網頁加載后,填充和提交搜索表單,并等待響應返回;
-
(第13行)處理響應:
-
(第14行)訪問響應中的每個鏈接,并等待網頁加載完畢;
-
(第15-18行)當鏈接對應的頁面加載完畢后,檢查關鍵字“CasperJS”是否存在;
嵌套代碼和柯里化都消失了!現在,代碼的執行順序和腳本中的代碼行想對應了。這個順序也和上面描述的流程相同。整個代碼流程中只有一個階段,代碼變得可讀,問題定位也更方便。
1 var links = []; 2 var i, num, isFound; 3 function getLinks() { 4 var links = document.querySelectorAll('h3.r a'); 5 return Array.prototype.map.call(links, function(e) { 6 return e.getAttribute('href'); 7 }); 8 } 9 kflow.knysa_open('http://google.com/'); 10 kflow.knysa_fill('form[action="/search"]', { q: 'CasperJS' }); 11 links = kflow.evaluate(getLinks); 12 i = -1; 13 while (++i < links.length) { 14 kflow.knysa_open(links[i]); 15 isFound = kflow.evaluate(function() { 16 return document.querySelector('html').textContent.indexOf('CasperJS') >= 0; 17 }); 18 console.log('CasperJS is found on ' + links[i] + ':' + isFound); 19 } 20 phantom.exit();
這是什么魔法?魔法位于每個以“knysa_”為前綴的函數(位于第9、10和14行),這些函數都是異步( async )執行,knysa等待( await )當前異步調用結束,再繼續執行下一行。
knysa將每個腳本作為流程,并且在執行時賦予其一個ID。流程對象可以通過隱式變量“ kflow ”暴露出來。流程ID可以通過kflow.getId()獲取。
kflow提供了一些異步等待風格的瀏覽器導航行數,如knysa_open、knysa_fill、knysa_click和knysa_evaluate。對于新的網頁,knysa_open、knysa_fill和knysa_click行數會等待他們加載結束:
-
knysa_open (url):打開一個網頁;
-
knysa_click (selector):觸發點擊操作;
-
knysa_fill (formSelector, values):填充和提交表單
knysa_evaluate(func, kflowId[, arg0, arg1, ...]):和PhantomJS的page.evaluate()函數相同,可以在瀏覽器端(沙盒中)執行包括AJAX調用在內的任意JavaScript。相比于PhantomJS的page.evaluate()函數,knysa_evaluate提升了對AJAX的支持。它掛起腳本執行。為了恢復執行,“回調函數”內部的代碼(通常是AJAX調用的成功/失敗回調)必須調用“window.callPhantom(data)”,其中“data.kflowId”需設置成“kflowId”。這里有一個來自 opl.kns 的示例:AJAX請求用于續借圖書,腳本執行會在續借響應請求收到后恢復:
oneRenewResult = kflow.knysa_evaluate(renew, kflow.getId(), ...);
其中沙盒中的函數“renew”有以下幾行:
1 $.ajax({ 2 dataType: 'json', 3 inline_messaging: 1, 4 url: form.attr("action"), 5 data: form.serialize(), 6 success: function(e) { 7 console.log("success: " + JSON.stringify(e)); 8 window.callPhantom({kflowId : kflowId, status: 'success', data: e}); 9 }, 10 failure: function(e) { 11 console.log("failure: " + JSON.stringify(e)); 12 window.callPhantom({kflowId : kflowId, status: 'failure', data: e}); 13 } 14 });
腳本會再AJAX調用結束之后恢復。根據AJAX調用的結果,oneRenewResult將被設置為不同的值:
-
當AJAX調用成功,第8行恢復執行并將oneRenewResult設置為:{ kflowId : kflowId , status: 'success', data: e}
-
當AJAX調用失敗,第12行恢復執行,并將oneRenewResult設置為:{ kflowId : kflowId , status: 'failure', data: e}
注意:傳入window.callPhantom()函數的所有數據都將作為knysa_evaluate()的返回值。
kflow.sleep(milliseconds)是另一個異步等待函數,但是它被knysa特殊處理。
kflow同時也提供一些常規(非異步等待)函數。這些函數直接來自CasperJS API:
-
open(url)
-
click(selector)
-
fill(selector)
-
getHTML(selector, outer)
-
exists(selector)
-
download(url, path, method, data)
-
getElementAttr(selector, attrName)
-
render(path)
-
evaluate(func[, arg0, arg1...])
實現自己的異步等待風格函數
為了實現這個目的,只需要將函數名字加上“knysa_”前綴。這將告知knysa這是一個異步等待風格函數。當這樣的函數調用時,腳本執行將會掛起。但是自己實現的異步等待風格函數需要通過調用kflow.resume(data)函數自行恢復腳本執行。當執行恢復時,傳給kflow.resume函數的“data”參數將會變成異步等待函數的返回值。這里是一個來自 resume.kns 的示例:它首先休眠1秒,然后將輸入值“num”乘以100并返回:
1 function knysa_f1(kflow, num) { 2 setTimeout(function() { 3 kflow.resume(num * 100); 4 }, 1000); 5 // return num + 10; 6 }
該函數的返回值是傳遞給kflow.resume()函數的參數,例如num * 100。
重要提示1:在類似異步等待函數中,常規返回值將被忽略。例如,即使第5行沒有注釋,“return num + 10”語句的結果也會被簡單的丟棄。
重要提示2:異步等待風格函數的調用必須是一個單獨的語句。可以是:
knysa_my_func(...); 或者 ret = knysa_my_func(...);
也可以作為對象函數使用:
myObj.knysa_my_func(...); 或者 ret = myObj.knysa_my_func(...);
下面的調用方式無法支持:
if (knysa_my_func(...)) ... 可以改成這樣: val = knysa_my_func(...); if (val) ... var1 = abc * knysa_my_func(...) 可以改成這樣: val = knysa_my_func(...); var1 = abc * val;
這里是調用前面定義的knysa_f1函數的示例,其返回值會被賦值到一個變量:
ret = knysa_f1(5);
當這行代碼執行時,ret將在1秒延遲后被設置為500。
異常處理
knysa的異常處理機制出奇的簡單:老式的try/catch/finally結構。這樣的基礎設施在CasperJS中是缺失的。示例: try.kns 。
“ catch ”示例:以下代碼在發生任何異常時渲染一張調試圖片。
var err; // 變量必須在開頭定義 ... try { ... } catch (err) { kflow.render(image_path); console.log(err.stack); }
“finally”示例:以下代碼確保在發生異常時登出:
// 填充并提交表單,登錄網站 kflow.knysa_fill(...); try { ... } finally { // 打開登出鏈接以登出 kflow.knysa_open(logout_link); }
注意事項:
- “else if”語法不支持,請使用嵌套的“if/else”語句替代;
- “for”循環體不能有異步等待函數調用或者“break”語句,請使用“while”循環替代;
- 所有變量必須在開頭定義,包括catch(err)語句中的“err”變量;
- 隱式變量“kflow”不能用于變量定義;
內部工作原理:
knysa腳本在執行前首先會被轉換成JavaScript。轉換后的腳本是很多步驟的流程,每個步驟一個函數。每個函數的名字被編碼上流程控制信息:
- 每個函數都被編號(為了決定執行順序)。
- “ _async ”后綴表示腳本執行將會被掛起。腳本執行將會在恰當的條件滿足后恢復:例如頁面響應接收到,或者AJAX響應接受到等。每個異步等待語句被轉換成類似的函數。
- “ _while ”后綴但中間不包含“ endwhile ”的函數名表示while循環的開始。
- “ _while ”后綴但中間包含“ endwhile ”的函數名表示while循環的結束。
- 雖然沒有說明,“if/else/try/catch/finally/break”語句的轉化方式和“while”語句類似。
下面是之前示例中去Google搜索的knysa腳本轉換后的JavaScript腳本:
var knysa = require("./knysa.js"); function knycon_search_casperjs_10001() { var links = []; var i, num, isFound; function getLinks() { var links = document.querySelectorAll("h3.r a"); return Array.prototype.map.call(links, function(e) { return e.getAttribute("href"); }); } this.n50002_async = function(kflow) { kflow.knysa_open("
knysa.knysa_exec(new knycon_search_CasperJS_10001);</code></pre>
注意1:以上轉換后的JavaScript只是為了展示當前的實現細節。knysa的實現可能改變。例如,將來的版本可能會使用Promises。當然,當PhantomJS完全支持ES6的generators或者ES7中的async/await,knysa可能就不再需要。
注意2:雖然knysa減少了通過使用回調來控制腳本執行順序,knysa本身使用了PhantomJS的回調機制,例如page.onCallback()和page.onLoadFinished()。
實踐時間
現在我們已經看見通過kynsa來操作PhantomJS是多么容易和自然,為什么不自己嘗試呢? knysa 托管在github。我們可以從 示例 開始。我(作者)也期待聽到大家的反饋。由于knysa是新項目,還有很多提升空間,歡迎大家能夠對項目做出貢獻。貢獻的方式有多種:
- 處理 ticket ;
- 提供更多的示例腳本,不論大小;
- 或者更好的是,共享可以幫助處理日常零活的knysa腳本,這樣可以幫助其他人節省時間,提高工作效率;
致謝
- uglifyjs1 用于解析knysa腳本并生成響應javascript;
- 許多“kflow”函數直接從 CasperJS 提取;
來自:http://www.infoq.com/cn/articles/knysa-phantomjs-async-await
-
-