深入理解JSCore

美團技術團隊發表於2018-08-24

背景

動態化作為移動客戶端技術的一個重要分支,一直是業界積極探索的方向。目前業界流行的動態化方案,如Facebook的React Native,阿里巴巴的Weex都採用了前端系的DSL方案,而它們在iOS系統上能夠順利的執行,都離不開一個背後的功臣:JavaScriptCore(以下簡稱JSCore),它建立起了Objective-C(以下簡稱OC)和JavaScript(以下簡稱JS)兩門語言之間溝通的橋樑。無論是這些流行的動態化方案,還是WebView Hybrid方案,亦或是之前廣泛流行的JSPatch,JSCore都在其中發揮了舉足輕重的作用。作為一名iOS開發工程師,瞭解JSCore已經逐漸成為了必備技能之一。

從瀏覽器談起

在iOS 7之後,JSCore作為一個系統級Framework被蘋果提供給開發者。JSCore作為蘋果的瀏覽器引擎WebKit中重要組成部分,這個JS引擎已經存在多年。如果想去追本溯源,探究JSCore的奧祕,那麼就應該從JS這門語言的誕生,以及它最重要的宿主-Safari瀏覽器開始談起。

JavaScript歷史簡介

JavaScript誕生於1995年,它的設計者是Netscape的Brendan Eich,而此時的Netscape正是瀏覽器市場的霸主。

而二十多年前,當時人們在瀏覽網頁的體驗極差,因為那會兒的瀏覽器幾乎只有頁面的展示能力,沒有和使用者的互動邏輯處理能力。所以即使一個必填輸入框傳空,也需要經過服務端驗證,等到返回結果之後才給出響應,再加上當時的網速很慢,可能半分鐘過去了,返回的結果是告訴你某個必填欄位未填。所以Brendan花了十天寫出了JavaScript,由瀏覽器解釋執行,從此之後瀏覽器也有了一些基本的互動處理能力,以及表單資料驗證能力。

而Brendan可能沒有想到,在二十多年後的今天。JS這門解釋執行的動態指令碼語言,不光成為前端屆的“正統”,還入侵了後端開發領域,在程式語言排行榜上進入前三甲,僅次於Python和Java。而如何解釋執行JS,則是各家引擎的核心技術。目前市面上比較常見的JS引擎有Google的V8(它被運用在Android作業系統以及Google的Chrome上),以及我們今天的主角--JSCore(它被運用在iOS作業系統以及Safari上)。

WebKit

我們每天都會接觸瀏覽器,使用瀏覽器進行工作、娛樂。讓瀏覽器能夠正常工作最核心的部分就是瀏覽器的核心,每個瀏覽器都有自己的核心,Safari的核心就是WebKit。WebKit誕生於1998年,並於2005年由Apple公司開源,Google的Blink也是在WebKit的分支上進行開發的。

WebKit由多個重要模組組成,通過下圖我們可以對WebKit有個整體的瞭解:

圖片1

簡單點講,WebKit就是一個頁面渲染以及邏輯處理引擎,前端工程師把HTML、JavaScript、CSS這“三駕馬車”作為輸入,經過WebKit的處理,就輸出成了我們能看到以及操作的Web頁面。從上圖我們可以看出來,WebKit由圖中框住的四個部分組成。而其中最主要的就是WebCore和JSCore(或者是其它JS引擎),這兩部分我們會分成兩個小章節詳細講述。除此之外,WebKit Embedding API是負責瀏覽器UI與WebKit進行互動的部分,而WebKit Ports則是讓Webkit更加方便的移植到各個作業系統、平臺上,提供的一些呼叫Native Library的介面,比如在渲染層面,在iOS系統中,Safari是交給CoreGraphics處理,而在Android系統中,Webkit則是交給Skia。

WebCore

在上面的WebKit組成圖中,我們可以發現只有WebCore是紅色的。這是因為時至今日,WebKit已經有很多的分支以及各大廠家也進行了很多優化改造,唯獨WebCore這個部分是所有WebKit共享的。WebCore是WebKit中程式碼最多的部分,也是整個WebKit中最核心的渲染引擎。那首先我們來看看整個WebKit的渲染流程:

圖片2

首先瀏覽器通過URL定位到了一堆由HTML、CSS、JS組成的資原始檔,通過載入器(這個載入器的實現也很複雜,在此不多贅述)把資原始檔給WebCore。之後HTML Parser會把HTML解析成DOM樹,CSS Parser會把CSS解析成CSSOM樹。最後把這兩棵樹合併,生成最終需要的渲染樹,再經過佈局,與具體WebKit Ports的渲染介面,把渲染樹渲染輸出到螢幕上,成為了最終呈現在使用者面前的Web頁面。

JSCore

概述

終於講到我們這期的主角 -- JSCore。JSCore是WebKit預設內嵌的JS引擎,之所以說是預設內嵌,是因為很多基於WebKit分支開發的瀏覽器引擎都開發了自家的JS引擎,其中最出名的就是Chrome的V8。這些JS引擎的使命都相同,那就是解釋執行JS指令碼。而從上面的渲染流程圖我們可以看到,JS和DOM樹之間存在著互相關聯,這是因為瀏覽器中的JS指令碼最主要的功能就是操作DOM樹,並與之互動。同樣的,我們也通過一張圖看下它的工作流程:

圖片3

可以看到,相比靜態編譯語言生成語法樹之後,還需要進行連結,裝載生成可執行檔案等操作,解釋型語言在流程上要簡化很多。這張流程圖右邊畫框的部分就是JSCore的組成部分:Lexer、Parser、LLInt以及JIT的部分(之所以JIT的部分是用橙色標註,是因為並不是所有的JSCore中都有JIT部分)。接下來我們就搭配整個工作流程介紹每一部分,它主要分為以下三個部分:詞法分析、語法分析以及解釋執行。

PS:嚴格的講,語言本身並不存在編譯型或者是解釋型,因為語言只是一些抽象的定義與約束,並不要求具體的實現,執行方式。這裡講JS是一門“解釋型語言”只是JS一般是被JS引擎動態解釋執行,而並不是語言本身的屬性。

詞法分析 -- Lexer

詞法分析很好理解,就是把一段我們寫的原始碼分解成Token序列的過程,這一過程也叫分詞。在JSCore,詞法分析是由Lexer來完成(有的編譯器或者直譯器把分詞叫做Scanner)。

這是一句很簡單的C語言表示式:

sum = 3 + 2; 
複製程式碼

將其標記化之後可以得到下表的內容:

元素 標記型別
sum 識別符號
= 賦值操作符
3 數字
+ 加法操作符
2 數字
; 語句結束

這就是詞法分析之後的結果,但是詞法分析並不會關注每個Token之間的關係,是否匹配,僅僅是把它們區分開來,等待語法分析來把這些Token“串起來”。詞法分析函式一般是由語法分析器(Parser)來進行呼叫的。在JSCore中,詞法分析器Lexer的程式碼主要集中在parser/Lexer.h、Lexer.cpp中。

語法分析 -- Parser

跟人類語言一樣,我們講話的時候其實是按照約定俗成,交流習慣按照一定的語法講出一個又一個詞語。那類比到計算機語言,計算機要理解一門計算機語言,也要理解一個語句的語法。例如以下一段JS語句:

var sum = 2 + 3;
var a = sum + 5;
複製程式碼

Parser會把Lexer分析之後生成的token序列進行語法分析,並生成對應的一棵抽象語法樹(AST)。這個樹長什麼樣呢?在這裡推薦一個網站:esprima Parser,輸入JS語句可以立馬生成我們所需的AST。例如,以上語句就被生成這樣的一棵樹:

圖片4

之後,ByteCodeGenerator會根據AST來生成JSCore的位元組碼,完成整個語法解析步驟。

解釋執行 -- LLInt和JIT

JS原始碼經過了詞法分析和語法分析這兩個步驟,轉成了位元組碼,其實就是經過任何一門程式語言必經的步驟--編譯。但是不同於我們編譯執行OC程式碼,JS編譯結束之後,並不會生成存放在記憶體或者硬碟之中的目的碼或可執行檔案。生成的指令位元組碼,會被立即被JSCore這臺虛擬機器進行逐行解釋執行。

執行指令位元組碼(ByteCode)是JS引擎中很核心的部分,各家JS引擎的優化也主要集中於此。JSByteCode的解釋執行是一套很複雜的系統,特別是加入了OSR和多級JIT技術之後,整個解釋執行變的越來越高效,並且讓整個ByteCode的執行在低延時之間和高吞吐之間有個很好的平衡:由低延時的LLInt來解釋執行ByteCode,當遇到多次重複呼叫或者是遞迴,迴圈等條件會通過OSR切換成JIT進行解釋執行(根據具體觸發條件會進入不同的JIT進行動態解釋)來加快速度。由於這部分內容較為複雜,而且不是本文重點,故只做簡單介紹,不做深入的討論。

JSCore值得注意的Feature

除了以上部分,JSCore還有幾個值得注意的Feature。

基於暫存器的指令集結構

JSCore採用的是基於暫存器的指令集結構,相比於基於棧的指令集結構(比如有些JVM的實現),因為不需要把操作結果頻繁入棧出棧,所以這種架構的指令集執行效率更高。但是由於這樣的架構也造成記憶體開銷更大的問題,除此之外,還存在移植性弱的問題,因為虛擬機器中的虛擬暫存器需要去匹配到真實機器中CPU的暫存器,可能會存在真實CPU暫存器不足的問題。

基於暫存器的指令集結構通常都是三地址或者二地址的指令集,例如:

i = a + b;
//轉成三地址指令:
add i,a,b; //把a暫存器中的值和b暫存器中的值相加,存入i暫存器
複製程式碼

在三地址的指令集中的運算過程是把a和b分別mov到兩個暫存器,然後把這兩個暫存器的值求和之後,存入第三個暫存器。這就是三地址指令運算過程。

而基於棧的一般都是零地址指令集,因為它的運算不依託於具體的暫存器,而是使用對運算元棧和具體運算子來完成整個運算。

單執行緒機制

值得注意的是,整個JS程式碼是執行在一條執行緒裡的,它並不像我們使用的OC、Java等語言,在自己的執行環境裡就能申請多條執行緒去處理一些耗時任務來防止阻塞主執行緒。JS程式碼本身並不存在多執行緒處理任務的能力。但是為什麼JS也存在多執行緒非同步呢?強大的事件驅動機制,是讓JS也可以進行多執行緒處理的關鍵。

事件驅動機制

之前講到,JS的誕生就是為了讓瀏覽器也擁有一些互動,邏輯處理能力。而JS與瀏覽器之間的互動是通過事件來實現的,比如瀏覽器檢測到發生了使用者點選,會傳遞一個點選事件通知JS執行緒去處理這個事件。 那通過這一特性,我們可以讓JS也進行非同步程式設計,簡單來講就是遇到耗時任務時,JS可以把這個任務丟給一個由JS宿主提供的工作執行緒(WebWorker)去處理。等工作執行緒處理完之後,會傳送一個message讓JS執行緒知道這個任務已經被執行完了,並在JS執行緒上去執行相應的事件處理程式。(但是需要注意,由於工作執行緒和JS執行緒並不在一個執行環境,所以它們並不共享一個作用域,故工作執行緒也不能操作window和DOM。)

JS執行緒和工作執行緒,以及瀏覽器事件之間的通訊機制叫做事件迴圈(EventLoop),類似於iOS的runloop。它有兩個概念,一個是Call Stack,一個是Task Queue。當工作執行緒完成非同步任務之後,會把訊息推到Task Queue,訊息就是註冊時的回撥函式。當Call Stack為空的時候,主執行緒會從Task Queue裡取一條訊息放入Call Stack來執行,JS主執行緒會一直重複這個動作直到訊息佇列為空。

圖片5

以上這張圖大概描述了JSCore的事件驅動機制,整個JS程式其實就是這樣跑起來的。這個其實跟空閒狀態下的iOS Runloop有點像,當基於Port的Source事件喚醒runloop之後,會去處理當前佇列裡的所有source事件。JS的事件驅動,跟訊息佇列其實是“異曲同工”。也正因為工作執行緒和事件驅動機制的存在,才讓JS有了多執行緒非同步能力。

iOS中的JSCore

iOS7之後,蘋果對WebKit中的JSCore進行了Objective-C的封裝,並提供給所有的iOS開發者。JSCore框架給Swift、OC以及C語言編寫的App提供了呼叫JS程式的能力。同時我們也可以使用JSCore往JS環境中去插入一些自定義物件。

iOS中可以使用JSCore的地方有多處,比如封裝在UIWebView中的JSCore,封裝在WKWebView中的JSCore,以及系統提供的JSCore。實際上,即使同為JSCore,它們之間也存在很多區別。因為隨著JS這門語言的發展,JS的宿主越來越多,有各種各樣的瀏覽器,甚至是常見於服務端的Node.js(基於V8執行)。隨時使用場景的不同,以及WebKit團隊自身不停的優化,JSCore逐漸分化出不同的版本。除了老版本的JSCore,還有2008年宣佈的執行在Safari、WKWebView中的Nitro(SquirrelFish)等等。而在本文中,我們主要介紹iOS系統自帶的JSCore Framework。

iOS官方文件對JSCore的介紹很簡單,其實主要就是給App提供了呼叫JS指令碼的能力。我們首先通過JSCore Framework的15個開放標頭檔案來“管中窺豹”,如下圖所示:

圖片6

乍一看,概念很多。但是除去一些公共標頭檔案以及一些很細節的概念,其實真正常用的並不多,筆者認為很有必要了解的概念只有4個:JSVM,JSContext,JSValue,JSExport。鑑於講述這些概念的文章已經有很多,本文儘量從一些不同的角度(比如原理,延伸對比等)去解釋這些概念。

JSVirtualMachine

一個JSVirtualMachine(以下簡稱JSVM)例項代表了一個自包含的JS執行環境,或者是一系列JS執行所需的資源。該類有兩個主要的使用用途:一是支援併發的JS呼叫,二是管理JS和Native之間橋物件的記憶體。

JSVM是我們要學習的第一個概念。官方介紹JSVM為JavaScript的執行提供底層資源,而從類名直譯過來,一個JSVM就代表一個JS虛擬機器,我們在上面也提到了虛擬機器的概念,那我們先討論一下什麼是虛擬機器。首先我們可以看看(可能是)最出名的虛擬機器——JVM(Java虛擬機器)。 JVM主要做兩個事情:

  1. 首先它要做的是把JavaC編譯器生成的ByteCode(ByteCode其實就是JVM的虛擬機器器指令)生成每臺機器所需要的機器指令,讓Java程式可執行(如下圖)。
  2. 第二步,JVM負責整個Java程式執行時所需要的記憶體空間管理、GC以及Java程式與Native(即C,C++)之間的介面等等。

圖片7

從功能上來看,一個高階語言虛擬機器主要分為兩部分,一個是直譯器部分,用來執行高階語言編譯生成的ByteCode,還有一部分則是Runtime執行時,用來負責執行時的記憶體空間開闢、管理等等。實際上,JSCore常常被認為是一個JS語言的優化虛擬機器,它做著JVM類似的事情,只是相比靜態編譯的Java,它還多承擔了把JS原始碼編譯成位元組碼的工作。

既然JSCore被認為是一個虛擬機器,那JSVM又是什麼?實際上,JSVM就是一個抽象的JS虛擬機器,讓開發者可以直接操作。在App中,我們可以執行多個JSVM來執行不同的任務。而且每一個JSContext(下節介紹)都從屬於一個JSVM。但是需要注意的是每個JSVM都有自己獨立的堆空間,GC也只能處理JSVM內部的物件(在下節會簡單講解JS的GC機制)。所以說,不同的JSVM之間是無法傳遞值的。

值得注意的還有,在上面的章節中,我們提到的JS單執行緒機制。這意味著,在一個JSVM中,只有一條執行緒可以跑JS程式碼,所以我們無法使用JSVM進行多執行緒處理JS任務。如果我們需要多執行緒處理JS任務的場景,就需要同時生成多個JSVM,從而達到多執行緒處理的目的。

JS的GC機制

JS同樣也不需要我們去手動管理記憶體。JS的記憶體管理使用的是GC機制(Tracing Garbage Collection)。不同於OC的引用計數,Tracing Garbage Collection是由GCRoot(Context)開始維護的一條引用鏈,一旦引用鏈無法觸達某物件節點,這個物件就會被回收掉。如下圖所示:

圖片8

JSContext

一個JSContext表示了一次JS的執行環境。我們可以通過建立一個JSContext去呼叫JS指令碼,訪問一些JS定義的值和函式,同時也提供了讓JS訪問Native物件,方法的介面。

JSContext是我們在實際使用JSCore時,經常用到的概念之一。"Context"這個概念我們都或多或少的在其它開發場景中見過,它最常被翻譯成“上下文”。那什麼是上下文?比如在一篇文章中,我們看到一句話:“他飛快的跑了出去。”但是如果我們不看上下文的話,我們並不知道這句話究竟是什麼意思:誰跑了出去?他是誰?他為什麼要跑?

寫計算機理解的程式語言跟寫文章是相似的,我們執行任何一段語句都需要有這樣一個“上下文”的存在。比如之前外部變數的引入、全域性變數、函式的定義、已經分配的資源等等。有了這些資訊,我們才能準確的執行每一句程式碼。

同理,JSContext就是JS語言的執行環境,所有JS程式碼的執行必須在一個JSContext之中,在WebView中也是一樣,我們可以通過KVC的方式獲取當時WebView的JSContext。通過JSContext執行一段JS程式碼十分簡單,如下面這個例子:

    JSContext *context = [[JSContext alloc] init];
    [context evaluateScript:@"var a = 1;var b = 2;"];
    NSInteger sum = [[context evaluateScript:@"a + b"] toInt32];//sum=3
複製程式碼

藉助evaluateScript API,我們就可以在OC中搭配JSContext執行JS程式碼。它的返回值是JS中最後生成的一個值,用屬於當前JSContext中的JSValue(下一節會有介紹)包裹返回。

我們還可以通過KVC的方式,給JSContext塞進去很多全域性物件或者全域性函式:

    JSContext *context = [[JSContext alloc] init];
		context[@"globalFunc"] =  ^() {
        NSArray *args = [JSContext currentArguments];
        for (id obj in args) {
            NSLog(@"拿到了引數:%@", obj);
        }
    };
	context[@"globalProp"] = @"全域性變數字串";
   [context evaluateScript:@"globalFunc(globalProp)"];//console輸出:“拿到了引數:全域性變數字串”
複製程式碼

這是一個很好用而且很重要的特性,有很多著名的藉助JSCore的框架如JSPatch,都利用了這個特性去實現一些很巧妙的事情。在這裡我們不過多探討可以利用它做什麼,而是去研究它究竟是怎樣運作的。在JSContext的API中,有一個值得注意的只讀屬性 -- JSValue型別的globalObject。它返回當前執行JSContext的全域性物件,例如在WebKit中,JSContext就會返回當前的Window物件。而這個全域性物件其實也是JSContext最核心的東西,當我們通過KVC方式與JSContext進去取值賦值的時候,實際上都是在跟這個全域性物件做互動,幾乎所有的東西都在全域性物件裡,可以說,JSContext只是globalObject的一層殼。對於上述兩個例子,本文取了context的globalObject,並轉成了OC物件,如下圖:

圖片9

可以看到這個globalObject儲存了所有的變數與函式,這更加印證了上文的說法(至於為什麼globalObject對應OC物件是NSDictionary型別,我們將在下節中講述)。所以我們還能得出另外一個結論,JS中所謂的全域性變數,全域性函式不過是全域性物件的屬性和函式。

同時值得注意的是,每個JSContext都從屬於一個JSVM。我們可以通過JSContext的只讀屬性 -- virtualMachine獲得當前JSContext繫結的JSVM。JSContext和JSVM是多對一的關係,一個JSContext只能繫結一個JSVM,但是一個JSVM可以同時持有多個JSContext。而上文中我們提到,每個JSVM同時只有整個一個執行緒來執行JS程式碼,所以綜合來看,一次簡單的通過JSCore執行JS程式碼,並在Native層獲取返回值的過程大致如下:

圖片10

JSValue

JSValue例項是一個指向JS值的引用指標。我們可以使用JSValue類,在OC和JS的基礎資料型別之間相互轉換。同時我們也可以使用這個類,去建立包裝了Native自定義類的JS物件,或者是那些由Native方法或者Block提供實現JS方法的JS物件。

在JSContext一節中,我們接觸了大量的JSValue型別的變數。在JSContext一節中我們瞭解到,我們可以很簡單的通過KVC操作JS全域性物件,也可以直接獲得JS程式碼執行結果的返回值(同時每一個JS中的值都存在於一個執行環境之中,也就是說每個JSValue都存在於一個JSContext之中,這也就是JSValue的作用域),都是因為JSCore幫我們用JSValue在底層自動做了OC和JS的型別轉換。

JSCore一共提供瞭如下10種型別互換:

   Objective-C type  |   JavaScript type
 --------------------+---------------------
         nil         |     undefined
        NSNull       |        null
       NSString      |       string
       NSNumber      |   number, boolean
     NSDictionary    |   Object object
       NSArray       |    Array object
        NSDate       |     Date object
       NSBlock 		   |   Function object 
          id         |   Wrapper object 
        Class        | Constructor object
複製程式碼

同時還提供了對應的互換API(節選):

+ (JSValue *)valueWithDouble:(double)value inContext:(JSContext *)context;
+ (JSValue *)valueWithInt32:(int32_t)value inContext:(JSContext *)context;
- (NSArray *)toArray;
- (NSDictionary *)toDictionary;
複製程式碼

在講型別轉換前,我們先了解一下JS這門語言的變數型別。根據ECMAScript(可以理解為JS的標準)的定義:JS中存在兩種資料型別的值,一種是基本型別值,它指的是簡單的資料段。第二種是引用型別值,指那些可能由多個值構成的物件。基本型別值包括"undefined","nul","Boolean","Number","String"(是的,String也是基礎型別),除此之外都是引用型別。對於前五種基礎型別的互換,應該沒有太多要講的。接下來會重點講講引用型別的互換:

NSDictionary <--> Object

在上節中,我們把JSContext的globalObject轉換成OC物件,發現是NSDictionary型別。要搞清楚這個轉換,首先我們對JS這門語言物件導向的特性進行一個簡單的瞭解。在JS中,物件就是一個引用型別的例項。與我們熟悉的OC、Java不一樣,物件並不是一個類的例項,因為在JS中並不存在類的概念。ECMA把物件定義為:無序屬性的集合,其屬性可以包含基本值、物件或者函式。從這個定義我們可以發現,JS中的物件就是無序的鍵值對,這和OC中的NSDictionary,Java中的HashMap何其相似。

	var person = { name: "Nicholas",age: 17};//JS中的person物件
	NSDictionary *person = @{@"name":@"Nicholas",@"age":@17};//OC中的person dictionary
複製程式碼

在上面的例項程式碼中,筆者使用了類似的方式建立了JS中的物件(在JS中叫“物件字面量”表示法)與OC中的NSDictionary,相信可以更有助理解這兩個轉換。

NSBlock <--> Function Object

在上節的例子中,筆者在JSContext賦值了一個"globalFunc"的Block,並可以在JS程式碼中當成一個函式直接呼叫。我還可以使用"typeof"關鍵字來判斷globalFunc在JS中的型別:

    NSString *type = [[context evaluateScript:@"typeof globalFunc"] toString];//type的值為"function"
複製程式碼

通過這個例子,我們也能發現傳入的Block物件在JS中已經被轉成了"function"型別。"Function Object"這個概念對於我們寫慣傳統面嚮物件語言的開發者來說,可能會比較晦澀。而實際上,JS這門語言,除了基本型別以外,就是引用型別。函式實際上也是一個"Function"型別的物件,每個函式名實則是指向一個函式物件的引用。比如我們可以這樣在JS中定義一個函式:

	var sum = function(num1,num2){
		return num1 + num2; 
	}
複製程式碼

同時我們還可以這樣定義一個函式(不推薦):

	var sum = new Function("num1","num2","return num1 + num2");
複製程式碼

按照第二種寫法,我們就能很直觀的理解到函式也是物件,它的建構函式就是Function,函式名只是指向這個物件的指標。而NSBlock是一個包裹了函式指標的類,JSCore把Function Object轉成NSBlock物件,可以說是很合適的。

JSExport

實現JSExport協議可以開放OC類和它們的例項方法,類方法,以及屬性給JS呼叫。

除了上一節提到的幾種特殊型別的轉換,我們還剩下NSDate型別,與id、class型別的轉換需要弄清楚。而NSDate型別無需贅述,所以我們在這一節重點要弄清楚後兩者的轉換。

而通常情況下,我們如果想在JS環境中使用OC中的類和物件,需要它們實現JSExport協議,來確定暴露給JS環境中的屬性和方法。比如我們需要向JS環境中暴露一個Person的類與獲取名字的方法:

@protocol PersonProtocol <JSExport>
- (NSString *)fullName;//fullName用來拼接firstName和lastName,並返回全名
@end

@interface JSExportPerson : NSObject <PersonProtocol>
  
- (NSString *)sayFullName;//sayFullName方法

@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;

@end
複製程式碼

然後,我們可以把一個JSExportPerson的一個例項傳入JSContext,並且可以直接執行fullName方法:

    JSExportPerson *person = [[JSExportPerson alloc] init];
    context[@"person"] = person;
    person.firstName = @"Di";
    person.lastName =@"Tang";
    [context evaluateScript:@"log(person.fullName())"];//調Native方法,列印出person例項的全名
    [context evaluateScript:@"person.sayFullName())"];//提示TypeError,'person.sayFullName' is undefined
複製程式碼

這就是一個很簡單的使用JSExport的例子,但請注意,我們只能呼叫在該物件在JSExport中開放出去的方法,如果並未開放出去,如上例中的"sayFullName"方法,直接呼叫則會報TypeError錯誤,因為該方法在JS環境中並未被定義。

講完JSExport的具體使用方法,我們來看看我們最開始的問題。當一個OC物件傳入JS環境之後,會轉成一個JSWrapperObject。那問題來了,什麼是JSWrapperObject?在JSCore的原始碼中,我們可以找到一些線索。首先在JSCore的JSValue中,我們可以發現這樣一個方法:

@method
@abstract Create a JSValue by converting an Objective-C object.
@discussion The resulting JSValue retains the provided Objective-C object.
@param value The Objective-C object to be converted.
@result The new JSValue.
*/
+ (JSValue *)valueWithObject:(id)value inContext:(JSContext *)context;
複製程式碼

這個API可以傳入任意一個型別的OC物件,然後返回一個持有該OC物件的JSValue。那這個過程肯定涉及到OC物件到JS物件的互換,所以我們只要分析一下這個方法的原始碼(基於這個分支進行分析)。由於原始碼實現過長,我們只需要關注核心程式碼,在JSContext中有一個"wrapperForObjCObject"方法,而實際上它又是呼叫了JSWrapperMap的"jsWrapperForObject"方法,這個方法就可以解答所有的疑惑:

//接受一個入參object,並返回一個JSValue
- (JSValue *)jsWrapperForObject:(id)object
{
    //對於每個物件,有專門的jsWrapper
    JSC::JSObject* jsWrapper = m_cachedJSWrappers.get(object);
    if (jsWrapper)
        return [JSValue valueWithJSValueRef:toRef(jsWrapper) inContext:m_context];
    JSValue *wrapper;
    //如果該物件是個類物件,則會直接拿到classInfo的constructor為實際的Value
    if (class_isMetaClass(object_getClass(object)))
        wrapper = [[self classInfoForClass:(Class)object] constructor];
    else {
        //對於普通的例項物件,由對應的classInfo負責生成相應JSWrappper同時retain對應的OC物件,並設定相應的Prototype
        JSObjCClassInfo* classInfo = [self classInfoForClass:[object class]];
        wrapper = [classInfo wrapperForObject:object];
    }
    JSC::ExecState* exec = toJS([m_context JSGlobalContextRef]);
    //將wrapper的值寫入JS環境
    jsWrapper = toJS(exec, valueInternalValue(wrapper)).toObject(exec);
    //快取object的wrapper物件
    m_cachedJSWrappers.set(object, jsWrapper);
    return wrapper;
}
複製程式碼

在我們建立"JSWrapperObject"的物件過程中,我們會通過JSWrapperMap來為每個傳入的物件建立對應的JSObjCClassInfo。這是一個非常重要的類,它有這個類對應JS物件的原型(Prototype)與建構函式(Constructor)。然後由JSObjCClassInfo去生成具體OC物件的JSWrapper物件,這個JSWrapper物件中就有一個JS物件所需要的所有資訊(即Prototype和Constructor)以及對應OC物件的指標。之後,把這個jsWrapper物件寫入JS環境中,即可在JS環境中使用這個物件了。這也就是"JSWrapperObject"的真面目。而我們上文中提到,如果傳入的是類,那麼在JS環境中會生成constructor物件,那麼這點也很容易從原始碼中看到,當檢測到傳入的是類的時候(類本身也是個物件),則會直接返回constructor屬性,這也就是"constructor object"的真面目,實際上就是一個建構函式。

那現在還有兩個問題,第一個問題是,OC物件有自己的繼承關係,那麼在JS環境中如何描述這個繼承關係?第二個問題是,JSExport的方法和屬性,又是如何讓JS環境中呼叫的呢?

我們先看第一個問題,繼承關係要如何解決?在JS中,繼承是通過原型鏈來實現,那什麼是原型呢?原型物件是一個普通物件,而且就是建構函式的一個例項。所有通過該建構函式生成的物件都共享這一個物件,當查詢某個物件的屬性值,結果不存在時,這時就會去物件的原型物件繼續找尋,是否存在該屬性,這樣就達到了一個封裝的目的。我們通過一個Person原型物件快速瞭解:

//原型物件是一個普通物件,而且就是Person建構函式的一個例項。所有Person建構函式的例項都共享這一個原型物件。
Person.prototype = {
   name:  'tony stark',
   age: 48,
   job: 'Iron Man',
   sayName: function() {
     alert(this.name);
   }
}
複製程式碼

而原型鏈就是JS中實現繼承的關鍵,它的本質就是重寫建構函式的原型物件,連結另一個建構函式的原型物件。這樣查詢某個物件的屬性,會沿著這條原型鏈一直查詢下去,從而達到繼承的目的。我們通過一個例子快速瞭解一下:

	function mammal (){}
 	mammal.prototype.commonness = function(){
   		alert('哺乳動物都用肺呼吸');
 	}; 

	function Person() {}
	Person.prototype = new mammal();//原型鏈的生成,Person的例項也可以訪問commonness屬性了
	Person.prototype.name = 'tony stark';
	Person.prototype.age  = 48;
	Person.prototype.job  = 'Iron Man';
	Person.prototype.sayName = function() {
  		alert(this.name);
	}

	var person1 = new Person();
	person1.commonness(); // 彈出'哺乳動物都用肺呼吸'
	person1.sayName(); // 'tony stark'
複製程式碼

而我們在生成物件的classinfo的時候(具體程式碼見"allocateConstructorAndPrototypeWithSuperClassInfo"),還會生成父類的classInfo。對每個實現過JSExport的OC類,JSContext裡都會提供一個prototype。比如NSObject類,在JS裡面就會有對應的Object Prototype。對於其它的OC類,會建立對應的Prototype,這個prototype的內部屬性[Prototype]會指向為這個OC類的父類建立的Prototype。這個JS原型鏈就能反應出對應OC類的繼承關係,在上例中,Person.prototype被賦值為一個mammal的例項物件,即原型的連結過程。

講完第一個問題,我們再來看看第二個問題。那JSExport是如何暴露OC方法到JS環境的呢?這個問題的答案同樣出現在我們生成物件的classInfo的時候:

        Protocol *exportProtocol = getJSExportProtocol();
        forEachProtocolImplementingProtocol(m_class, exportProtocol, ^(Protocol *protocol){
            copyPrototypeProperties(m_context, m_class, protocol, prototype);
            copyMethodsToObject(m_context, m_class, protocol, NO, constructor);
        });
複製程式碼

對於每個宣告在JSExport裡的屬性和方法,classInfo會在prototype和constructor裡面存入對應的property和method。之後我們就可以通過具體的methodName和PropertyName生成的setter和getter方法,來獲取實際的SEL。最後就可以讓JSExport中的方法和屬性得到正確的訪問。所以簡單點講,JSExport就是負責把這些方法打個標,以methodName為key,SEL為value,存入一個map(prototype和constructor本質上就是一個Map)中去,之後就可以通過methodName拿到對應的SEL進行呼叫。這也就解釋了上例中,我們呼叫一個沒有在JSExport中開放的方法會顯示undefined,因為生成的物件里根本沒有這個key。

總結

JSCore給iOS App提供了JS可以解釋執行的執行環境與資源。對於我們實際開發而言,最主要的就是JSContext和JSValue這兩個類。JSContext提供互相呼叫的介面,JSValue為這個互相呼叫提供資料型別的橋接轉換。讓JS可以執行Native方法,並讓Native回撥JS,反之亦然。

圖片11

利用JSCore,我們可以做很多有想象空間的事。所有基於JSCore的Hybrid開發基本就是靠上圖的原理來實現互相呼叫,區別只是具體的實現方式和用途不大相同。大道至簡,只要正確理解這個基本流程,其它的所有方案不過是一些變通,都可以很快掌握。

一些引申閱讀

JSPatch的物件和方法沒有實現JSExport協議,JS是如何調OC方法的?

JS調OC並不是通過JSExport。通過JSExport實現的方式有諸多問題,我們需要先寫好Native的類,並實現JSExport協議,這個本身就不能滿足“Patch”的需求。

所以JSPatch另闢蹊徑,使用了OC的Runtime訊息轉發機制做這個事情,如下面這一個簡單的JSPatch呼叫程式碼:

require('UIView') 
var view = UIView.alloc().init() 
複製程式碼
  1. require在全域性作用域裡生成UIView變數,來表示這個物件是一個OCClass。

  2. 通過正則把.alloc()改成._c('alloc'),來進行方法收口,最終會呼叫_methodFunc()把類名、物件、MethodName通過在Context早已定義好的Native方法,傳給OC環境。

  3. 最終呼叫OC的CallSelector方法,底層通過從JS環境拿到的類名、方法名、物件之後,通過NSInvocation實現動態呼叫。

JSPatch的通訊並沒有通過JSExport協議,而是藉助JSCore的Context與JSCore的型別轉換和OC的訊息轉發機制來完成動態呼叫,實現思路真的很巧妙。

橋方法的實現是怎麼通過JSCore互動的?

市面上常見的橋方法呼叫有兩種:

  1. 通過UIWebView的delegate方法:shouldStartLoadWithRequest來處理橋接JS請求。JSRequest會帶上methodName,通過WebViewBridge類呼叫該method。執行完之後,會使用WebView來執行JS的回撥方法,當然實際上也是呼叫的WebView中的JSContext來執行JS,完成整個呼叫回撥流程。

  2. 通過UIWebView的delegate方法:在webViewDidFinishLoadwebViewDidFinishLoad裡通過KVC的方式獲取UIWebView的JSContext,然後通過這個JSContext設定已經準備好的橋方法供JS環境呼叫。

參考資料

  1. 《JavaScript高階程式設計》
  2. Webkit Architecture
  3. 虛擬機器隨談1:直譯器...
  4. 戴銘:深入剖析 WebKit
  5. JSCore-Wiki
  6. [知乎Tw93]iOS中的JSCore

作者簡介

唐笛,美團點評高階工程師。2017年加入原美團,目前作為外賣iOS團隊主力開發,主要負責移動端基礎設施建設,動態化等方向相關推進工作,致力於提升移動端研發效率與研發質量。

招聘

美團外賣長期招聘Android、iOS、FE 高階/資深工程師和技術專家,base 北京、上海、成都,歡迎有興趣的同學投遞簡歷到chenhang03#meituan.com

深入理解JSCore