原文:Using JavaScript in Swift Projects: Building a Markdown to HTML Editor
譯者:kmyhy
一直想寫一篇文章,關於如何將 Swift 和 Javascript 結合在一起,以構建強大的支持富文本的 App。這並不是我們第一次聽人說要將 Javacript 代碼嵌入到 iOS 專案中了,但當你讀完本文后,你會感到這個過程會變得前所未有的簡單,仿佛魔術一般,你只需要做很少的工作。其中的奧妙就是一個叫做 JavaScriptCore framework 的框架。
你可能會想,为什麼總是有人愛用 JavaScript,為什麼不用 Swift 實現所有的功能?其實這也是我想問的,這裡我們陳述幾條理由:
- 那些曾經寫過 web App 、已經忘記 Javascript 怎麼寫的 iOS 開發者,通過 JavaScriptCore 框架,就有機會再次使用他們所鐘愛的語言啦。
- 對於某些任務,很可能已經有現成的 JavaScript 庫存在,它們和你要用 Swift 實現的功能其實並無區別。為什麼不使用現成的呢?
- 很可能某些任務用 JavaScript 來做會更容易一點。
- 你可能想遠程控制 App 的行為。可以將 JavaScript 程式碼放到服務器而不是 App bundle 里。這樣做時需要小心,因為這很可能會導致一場災難。
- 可以讓你的 App 更具彈性和更加強大。
- 你有強烈的好奇心,希望在你的 iOS 專案中使用 JavaScript。
當然,除此之外,你可能還想到了更好的在 iOS 使用 JavaScript 的理由。現在,你別忙著高興,讓我們看一下需要什麼必要的背景知識吧。首先,JavaScript 有它獨立的運行環境,或者更明確地說,它需要在虛擬機中運行。在 JavaScriptCore 框架中,用 JSVirtualMachine 類來代表虛擬機,當然通常你不會和它打交道。在一個 App 中可以運行多個虛擬機,它們之間無法直接交換數據。
其次,你使用得最多的其實是 JSContext
。這個類相對於執行 JavaScript 腳本的真實環境(context)。在一個虛擬機(JSVirtualMachine)可以存在多個 context,你可以在 context 之間傳遞數據。如同你在後續內容中所看到, JSContext
會將 Swift 程式碼暴露給 JavaScript,將 JavaScript 程式碼暴露給 Swift。 我們會大量使用到它,但大部分用法都是相同的。
JSContext
中的所有值都是 JSValue 對象,JSValue
類用於表示任意類型的 JavaScript 值。如果你要從 Swift 中訪問 JavaScript 變數或函式,都可以用 JSValue
對象。當然也有將 JSValue 轉換成特定數據類型的方法。例如,轉換成字符串用 toString()
方法,轉換成字典用 toDictionary()
方法 (後面會看到)。在這裡有一個完整的方法列表。
我建議你閱讀官方的 JavaScriptCore 框架文檔。前面所說的這些可能會讓你對將要用到的工具有一個大概的了解,也有助你進一步理解後面的內容。
現在,讓我們正式開始。先來看一下今天的“菜譜”都有些什麼。
Demo 專案概覽
我們將通過一個簡單的示範專案來了解 JavaScriptCore 框架極其特性,這個專案演示了如何在 Swift 中使用 JavaScript。我們將使用經典 “Hello World” 示例(我最喜歡用的例子),它會把一個字符串值保存到 JavaScript 變數中。我們首先關心的是如何從 Swift 中訪問這個變數,我們不妨用 Xcode 控制台來將它打印出來。我們會連續做幾個簡單的例子,以逐步研究更多的特性。當然,我們不僅僅要學習如何從 JavaScript 專遞值給 Swift;我們也要研究反方向的傳遞。因此,我們既需要寫 Swift 代碼也要寫 JavaScript 代碼。但不用擔心,其實 JavaScript 並沒有那麼難打交道。一點也不難!注意,從這裡開始所有的輸出都在控制台中進行,這樣我們就可以將注意力放在真正值得注意的地方。
我們已經了解了足夠多的基礎知識了,我們可以來研究下如何在一種語言中使用另一種語言了。
為了更真實,我們先使用第三方 JavaScript 庫來試試。在專案的第二部分,我們會編寫一個 MarkDown/HTML 轉換器,或者說,我們會通過一個“轉換器的庫”來為我們干這個。我們的工作僅僅是從編輯框中(一個簡單的 UITextView
)搜集用戶輸入的 MarkDown 文本,然後將它傳給 JavaScript 環境進行轉換,并將 JavaScript 環境返回的 HTML 顯示到一個 UIWebView
中。用一個按鈕來觸發轉換動作,并調用我們的程式碼。看下圖:
在第三部分和最後一部分,我們將演示如何傳遞帶屬性和方法的自定義類給 JavaScript Context。此外,我們還會在 JavaScript 中按照這個類的定義來創建一個對象并對其屬性進行賦值。我們最終會顯示一個 iPhone 從面世以來的設備類型列表(model 名),以及它們的最早和最晚的 OS 版本,以及它們的圖片。數據保存在一個 csv 檔案中,我們將用一個第三方庫進行解析。要獲得解析后的數據,我們將在 JavaScript 中使用我們的自定義 Swift 類,用這個類來渲染自定義對象的數據,然後將結果返回給 Swift。我們會用一個 TableView 來顯示這個列表。如下圖所示:
The above describe in general the three distinct tasks that will let us get to know the JavaScriptCore framework. As there are a lot of things wrapped up together in the package of one, we’ll have an initial menu screen that we’ll use to navigate to the proper part of the project:
為便於給你偷懶,我們提供了一個開始專案,你可以在這裡下載。當你下載完后,你就可以開始你的 JavaScriptCore 之旅了。在本文中,我們會做幾件事情,但最終會明白它們的大部分其實都是標準套路,為了實現最終目標,我們不得不重複這些套路而已。
開始出發吧!
從 Swift 中呼叫 JavaScript
就如介紹中所言,JavaScriptCore 中最主要的角色就是 JSContext
類。一個 JSContext
對象是位於 JavaScript 環境和本地 Javascript 腳本之間的橋樑。因此一開始我們就需要在 BasicsViewController
中宣告這個屬性。在 BasicsViewController.swift
檔案中,找到類的頭部,添加如下變數:
var jsContext: JSContext!
jsContext
對象必須是一個類屬性,如果你在方法體中初始化它為本地變數,那麼當方法一結束你就無法訪問到它了。
現在我們必須導入 JavaScriptCore 框架,在檔案頭部添加這句:
import JavaScriptCore
接下來要初始化 jsContext
對象,然後使用它。但在此之前,我們先寫點基本的 JavaScript 程式碼。我們將在一個 jssource.js 檔案中編寫它們,你可以在開始專案的專案導航器中找到這個檔案。我們會在裡面宣告一個 “Hello World” 的字符串變數,然後實現幾個簡單的函式,我們將通過 iOS 來訪問它們。如果你沒有學過 JavaScript 也沒關係,它們真的太簡單了,你一眼就能夠看懂。
打開 jssource.js
檔案,在開頭添加這個變數:
var helloWorld = "Hello World!"
在控制台中打印這個變數是我們接下來的第一目標!
回到 BasicsViewController.swift
檔案,創建一個方法來完成 2 個任務:
- 對我們早先宣告的
jsContext
屬性進行初始化。 - 加載 jssource.js 檔案,將檔案內容傳給 JavaScript 運行時,這樣它才能訪問檔案中編寫的程式碼。
在 BasicsViewController
中新建一個方法,初始化 jsContext
變數。方法非常簡單:
func initializeJS() {
self.jsContext = JSContext()
}
上面的第二條任務分成幾個步驟,但也非常簡單。我們先來看看一下源碼,然後在來進行討論:
func initializeJS() {
...
// 指定 jssource.js 檔案路徑
if let jsSourcePath = Bundle.main.path(forResource: "jssource", ofType: "js") {
do {
// 將檔案內容加載到 String
let jsSourceContents = try String(contentsOfFile: jsSourcePath)
// 通過 jsContext 對象,將 jsSourceContents 中包含的腳本添加到 Javascript 運行時
self.jsContext.evaluateScript(jsSourceContents)
}
catch {
print(error.localizedDescription)
}
}
}
源碼中的注釋很明白地解釋了它們的意思。首先,我們指定了 jssource.js 檔案路徑,然後加載檔案內容到 jsSourceContents
字符串中 (目前,這些內容就是你先前在 jssource.js 檔案中編寫的內容)。 如果成功,則接下來這句就重要了:我們用 jsContext
來“计算”这些 JavaScript 程式碼,通过这种方法我們可以立即將我們的 JS 程式碼傳遞到 JavaScript 環境。
接著增加一個全新的方法:
func helloWorld() {
if let variableHelloWorld = self.jsContext.objectForKeyedSubscript("helloWorld") {
print(variableHelloWorld.toString())
}
}
這個方法雖然很簡單,但作用可不小。這個方法的核心部分是 objectForKeyedSubscript(_:)
一句,我們通過它來訪問 JavasScript 中的 hellowWorl
變量。第一條語句返回的是一個 JSValue對象(如果沒有值則返回為 nil),同時把它放到 variableHelloWorld
中保存。簡單說,這就完成了我們的第一個目標,因為我們在 Swift 中寫了一些 JavaScript,我們可以用任何方式來處理它!我們要怎樣處理這個保存著 “Hello World” 字符串的變量呢?把它輸出到控制台中而已。
現在,我們在 viewDidAppear(_:)
中呼叫這兩個新方法:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.initializeJS()
self.helloWorld()
}
運行 App,點擊第一個標題為 Basics 的按鈕。打開 Xcode 的控制台,我們的 “Hello World” 字樣被 JavaScriptCore 框架輸出到了控制台!
在混合使用 Swift 和 JavaScript 時,肯定不僅僅是為了定義幾個變量,然後打印它們的值。因此,讓我們來創建第一個 JavaScript 函式吧,讓我們來看看要如何使用它。
我找不到其他簡單的例子,因此使用下面這個函式,用於將姓和名組合成全名。在 jssource.js 檔案中加入:
function getFullname(firstname, lastname) {
return firstname + " " + lastname;
}
人名中的姓和名分別被作為函式的兩個參數。保存檔案,返回 BasicsViewController.swift
。
在 Swift 中呼叫 JavaScript 函式有兩步:
首先,詢問 jsContext
要呼叫的函式名稱,這會返回一個 JSValue 對象,這和我們訪問 helloWorld 變量是一樣的。然後,通過方法名來呼叫這個函式,將它需要的參數傳入。一會你就明白了,現在先實現一個新方法:
func jsDemo1() {
let firstname = "Mickey"
let lastname = "Mouse"
if let functionFullname = self.jsContext.objectForKeyedSubscript("getFullname") {
}
}
現在,Swift 通過 functionFullname
引用了getFullname
JS 函式。然後是第二步呼叫這個 JS 函式:
func jsDemo1() {
let firstname = "Mickey"
let lastname = "Mouse"
if let functionFullname = self.jsContext.objectForKeyedSubscript("getFullname") {
// Call the function that composes the fullname.
if let fullname = functionFullname.call(withArguments: [firstname, lastname]) {
print(fullname.toString())
}
}
}
call(withArguments:)
方法用於呼叫 getFullName 函式,并導致它的執行。call
方法只接收一個參數,這是一個任意對象類型的陣列,如果函式沒有參數,你可以傳遞一個 nil。在我們的例子中,我們傳遞了 firstname 和 lastname。這個方法的返回值也是一個 JSValue 對象,我們會將它打印到控制台中。在後面你會看到,方法的返回值對我們來說不一定是有意義的,因此我們也會不使用它。
現在,讓我們呼叫 jsDemo1()
方法:
override func viewDidAppear(_ animated: Bool) {
...
self.jsDemo1()
}
運行項目,會在控制台中看到如下輸出:
這一點也不有趣,但你要明白你所看到的是在 Swift 中呼叫 JS 函式所得到的結果。同時,我們通過這部分內容可以總結出這樣一個固定流程:
- 構建一個 JSContext 對象。
- 裝載 JavaScript 程式碼,計算(evaluate)它的值 (或者說將它傳遞給 JavaScript 環境)。
- 通過
JSContext
的objectForKeyedSubscript(_:)
方法訪問 JS 函式。 - 呼叫 JS 函式,處理返回值(可選)。
處理 JavaScript 異常
在開發中,編碼時現錯誤總是不可避免的,但錯誤出現必須讓開發者看到,這樣他們才會去解決它。如果進行 JS 和 Swift 混合編程,你怎麼知道應該去哪兒調試?Swift 還是 JS?在 Swift 中對錯誤進行輸出很容易,但我們能看到發生在 JS 端的錯誤嗎?
幸好,JavaScriptCore 框架提供了一個在 Swift 中捕捉 JS 環境中出現的異常的方法。觀察異常是一種標準程序,我們會在後面了解,但如何處理它們很顯然是一件很主觀的事情。
回到我們剛剛編寫的程式碼,我們來修改一下 initializeJS()
方法,以捕捉 JS 運行時異常。在這個方法中,在 jsContext 初始化之後,添加如下語句:
func initializeJS() {
self.jsContext = JSContext()
// Add an exception handler.
self.jsContext.exceptionHandler = { context, exception in
if let exc = exception {
print("JS Exception:", exc.toString())
}
}
...
}
看到了吧,exceptionHandler 是一個閉包,每當 jsContext 發生一個錯誤時都會呼叫這個閉包。它有兩個參數:異常發生時所在的 context (即JSContext
),以及異常本身。這個 exception 是一個 JSValue 對象。在這裡,我們為了簡單起見,僅僅將異常消息打印到控制台。
我們來試著製造一個異常,以測試這種方法是否行得通。為此,我們必須在 jssource.js 中編寫另一個 JS 函式,這個函式用一個整數陣列作為參數(整數和負數),返回一個包含了這個陣列中最大值、最小值和平均值的字典。
打開 jssource.js 檔案,添加函式:
function maxMinAverage(values) {
var max = Math.max.apply(null, values);
var min = Math.min.apply(null, values);
var average = Math.average(values);
return {
"max": max,
"min": min,
"average": average
};
}
代碼中的錯誤在於,在 Math
對象中根本沒有一個 average 函式,因此這句完全不對:
var average = Math.average(values);
假裝我們不知道這個情況,回到 BasicsViewController.swift
,添加一個新方法:
func jsDemo2() {
let values = [10, -5, 22, 14, -35, 101, -55, 16, 14]
if let functionMaxMinAverage = self.jsContext.objectForKeyedSubscript("maxMinAverage") {
if let results = functionMaxMinAverage.call(withArguments: [values]) {
if let resultsDict = results.toDictionary() {
for (key, value) in resultsDict {
print(key, value)
}
}
}
}
}
首先,我們創建了一個隨機數字構成的陣列。我們用它作為調用 maxMinAverage
方法時的參數,這個方法在 Swift 中通過 functionMaxMinAverage
對象來引用。在呼叫 call 方法時,我們將這個陣列作為唯一參數傳遞。如果一切正常,我們會按照 Dictionary(注意 toDictionary()
方法)的方式來處理返回結果,將其中的值一一打印到控制台(maxMinAverage方法返回的是字典,因此我們同時打印了 key 和 value)
是時候測試一下了,但我們必須先呼叫這個 jsDemo2()
方法:
override func viewDidAppear(_ animated: Bool) {
...
self.jsDemo2()
}
運行 App,我們期望打印出陣列的最大、最小和平均值。
但是,我們從 JS 運行時環境得到的上一個醜陋的、非常直白的異常:
JS Exception: TypeError: Math.average is not a function. (In 'Math.average(values)', 'Math.average' is undefined)
在解決這個有意製造的錯誤之前,讓我們先想一下這樣做的意義。試想,如果不能捕捉到 JS 異常,則你根本不可能找出錯誤真正的所在。為了節省我們的時間,尤其對於大型的複雜的 App 來說,錯誤並不是我們有意設計的,那麼兩眼一抹黑地去查找錯誤真的是一件讓人痛苦的事情。
因此,說教完之後,我們該來解決下問題了。在 jssource.js 檔案中,修改 code>minMaxAverage 函式為:
function maxMinAverage(values) {
var max = Math.max.apply(null, values);
var min = Math.min.apply(null, values);
var average = null;
if (values.length > 0) {
var sum = 0;
for (var i=0; i