作者|陳子涵
編輯|覃雲
“一次編寫, 到處運行”(Write once, run anywhere ) 是很多前端團隊孜孜以求的目標。實現這個目標,不但能以最快的速度,將應用推廣到各個渠道,而且還能節省大量人力物力。
React Native 的推出,為跨平台的開發帶來了新的曙光。 雖然 Facebook 官方 blog 的說法 React Native 支持“Learn once, write anywhere.”。
但經過開源社區的不斷努力,React Native 已經可以達到“一次編寫, 到處運行”的目標。可以說超過了 Facebook 的預期。作者在最近的幾個項目中,運用 React Native 技術,成功實現跨越 iOS,Android,Web 三端的前端架構。這里將使用到的技術和過程中遇到的困難和問題揭示出來,供讀者探討。
技術選型
我們的目標是希望一套代碼同時支持 iOS,Android App 和微信公眾號內的網頁(同時保留將來支持桌面瀏覽器的能力)。在開始重構之前,我們盤點了目前可用的一些技術:
① SPA:single page web application,就是只有一張 html 頁面的應用。僅在該 Web 頁面初始化時加載相應的 HTML、JavaScript、CSS。一旦頁面加載完成,SPA 不會因為用戶的操作而進行頁面的重新加載或跳轉,而是利用 JavaScript 動態的變換 HTML(采用的是 div 切換顯示和隱藏),從而實現 UI 與用戶的交互。
② MPA: multipage web application, 相對於 SPA,MPA 有多個 html 頁面。頁面間跳轉刷新所有資源,公共資源 (js、css 等) 需選擇性重新加載。
本人於 2012 年開始接觸 Cordova & Ionic,應該說 Cordova 在 React-Native 出現之前確實是跨平台的主流技術。但是現在是 2018 年,Cordova 在性能上肯定達不到我們的要求,首先被 pass 掉。
Vue.js 也是我們團隊的備選前端框架,主要用於桌面瀏覽器展示的項目。缺乏原生移動解決方案,以及實際用下來感覺 template 表現力比不上 JSX。另外我們用到了螞蟻金服優秀的前端控件庫 ant design mobile, 暫時不支持 Vue。
2018 年 7 月份我們對 Flutter(0.5.1) 和 React-Native(0.51.0)進行了一次性能比較測試。我們在 Android 上用 Flutter 和 React-Native 分別實現了一個含圖文的新聞客戶端,比較了頁面加載,圖片加載,頁面跳轉等關鍵性能。實測下來 Flutter 在 List 加載,跳轉到詳情頁時都有明顯掉幀。另外代碼無法移植到 web 上。這些原因導致我們放棄了 Flutter。
最終我們選擇了 React-Native 作為我們項目的實現技術,除了上述的一些優點之外,我們在如下一些方面收益頗多。
項目架構
我們在項目中用到的前端整體架構如下圖:
以下對上圖中一些技術點進行介紹:
應用支持層
作為應用和后台服務 & 原生 App 之間的橋梁,應用支持層需要處理諸如端到端通訊,數據加密解密,數據緩存,數據攔截,原生應用功能訪問等基礎服務。最大限度的屏蔽掉平台間差異,讓位於其上的層盡量做到平台無關。
原生模塊封裝
React-Native 可以方便的封裝原生應用模塊。對於有 UI 的原生模塊,既支持在一個新的 ViewController(Activity)中展示, 也支持將其封裝成一個 View,嵌入到 React-Native 的上下文中。 這也是 React-Native 最接地氣的特性,遠超 Cordova。在一些場景下需要等待原生模塊中的事件,諸如用戶操作等異步事件之后才能返回,這時需要用到 Promise 作為原生模塊的參數。
比如通過調用手機攝像頭,對銀行卡進行掃描,這時會調用原生第三發控件的 ScanCardViewController 進行掃描,掃描結果通過代理函數回調。整個調用和回調的流程無法直接在一個函數中完成,這時可以用 React native 的 Promise 實現對 JS 端 Promise 的無縫對接。
@protocol RCTBankCardScannerDelegate <NSObject> -(void)onScanCardResult:(NSDictionary *) result; @end @interface RCTBankCardScanner()<RCTBankCardScannerDelegate> @property(nonatomic, strong) RCTPromiseResolveBlock resolveBlock; @property(nonatomic, strong) RCTPromiseRejectBlock rejectBlock; @end @implementation RCTBankCardScanner RCT_EXPORT_MODULE(); RCT_REMAP_METHOD(scan, resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { // 異步調用,函數本體不返回,需要保留 resolve,和 reject 函數指針 self.resolveBlock = resolve; self.rejectBlock = reject; // 跳轉到掃描銀行卡控件的 ViewController ScanCardViewController * viewController = [ScanCardViewController new]; UIViewController *rootViewController = RCTPresentedViewController(); [rootViewController presentViewController:viewController animated:YES completion:nil]; } #pragma mark RCTBankCardScannerDelegate -(void)onScanCardResult:(NSDictionary *) result { // 在原生 ViewController 回調處,再返回 Promise 的處理結果 if(result != nil && [[result objectForKey:@"code"] isEqualToString:@"0"]){ if(self.resolveBlock != nil){ self.resolveBlock(result); } }else if(result != nil){ if(self.rejectBlock != nil){ self.rejectBlock([result objectForKey:@"code"], @"failed", nil); } }else{ if(self.rejectBlock != nil){ self.rejectBlock(@"-100", @"invaild response", nil); } } }
上述代碼實現了銀行卡掃描控件的封裝。調用 scan 函數的時候會新啟動攝像頭,完成身份證掃描識別之后將結果傳回 JavaScript. 在 JavaScript 中,可以通過
import {NativeModules} from 'react-native' const BankCardScanner = NativeModules. BankCardScanner const { code, no } = await BankCardScanner.scan()
實現對原生層的異步調用,並等待 ScanCardViewController 完成並回調。
后台接口封裝
到服務器的端到端訪問通過繼承 BaseService 類實現.BaseService 負責處理跟服務端交互,加密,解密,錯誤處理等。
import BaseService from '../common/base-service' import Page from './Page' export default class DemoService extends BaseService { constructor(props) { super(props) this.page = new Page(this.getDemoList.bind(this)) } /** * 獲取示例列表詳情 */ async getDemoList (params) { const res = await this.postJson('getDemoList', params) return res } }
Page 類實現了對分頁數據的加載和存儲封裝,使其與頁面解除耦合。通過指定支持分頁的方法,可以實現分頁加載。
PaginationHoc 則封裝了需要暴露給頁面的分頁相關方法,包括獲取設置支持分頁的 Service,獲取分頁對象,加載下一頁數據,設置搜索參數等。
一個包含分頁的頁面例子如下:
@Pagination @Loading export default class DemoPage extends Component { constructor(props) { super(props); this.props.setService(new DemoService(this.props)); } async componentDidMount() { await this.props.loadMore(); } render() { return ( <View> <FlatListView style={styles.list} data={this.props.getPage().list} renderItem={this.renderRow.bind(this)} hasMore={this.props.hasMore()} onEndReached={this.props.loadMore.bind(this)} /> </View> ); } }
全局異常捕獲
在 web 開發中,可以使用 window.onerror = function(){message, source, …} 來捕獲未處理的 JavaScript 錯誤。但是對於一個遍布異步調用的復雜應用來說,window.onerror 沒太大用。通常需要捕獲的是未處理的異步調用異常,即 unhandled rejection。
在 web 中,unhandled rejection 可以通過收聽'unhandledrejection'事件來處理。
window.addEventListener('unhandledrejection', function(event) { const error = event.reason handleErrors(error); })
增加了全局'unhandledrejection'事件監聽之后,依然可以通過 try catch 實現對某個異常的自定義處理,這時全局'unhandledrejection'事件監聽就不會被調用到。如:
try{ await this.service.getDemoList(); } catch (error) { Modal.alert(‘數據獲取異常’) }
Promise 目前在 WebKit 系的瀏覽器支持的比較好,如果需要在非 Webkit 內核瀏覽器上使用,通常需要添加 polyfill。這里需要注意的是項目不能采用 promise-polyfill。因為 promise-polyfill 的實現沒有考慮到'unhandledrejection', 並且會覆蓋瀏覽器原生的 Promise 實現。我們選用的是 es6-promise-promise 庫作為 Promise 的 polyfill 方案。
對於 react-native。異步異常捕獲未見於其官方文檔。但 react-native 的 Promise 模塊引用的是 Then Promise 。Then Promise 對於'unhandledrejection',提供了處理鈎子函數:
require('promise/lib/rejection-tracking') .enable({ allRejections: true, onUnhandled: function(id, error){ ... } });
需要注意的是 Then Promise 對 onUnhandle 的默認定義是: 2 秒鍾內沒有被處理的 Promise rejection,因此錯誤處理時一定要考慮到這 2 秒鍾的等待時間。
應用狀態層
相信本文讀者應該多少了解通過 Flux、 Redux、VueX 來管理前端應用狀態的意義了。嚴格說來, 前端應用就是一個通過渲染層,將狀態渲染出來,並通過響應事件來修改狀態的單向數據流模型。對於狀態管理庫的選擇和應用場景,我們在前后幾個項目中經歷了多次嘗試。最開始我們使用 Redux,嘗試按照單向數據流的原教旨主義,通過 Redux 管理應用的全部狀態,效果不理想,主要問題有以下幾點:
-
跟后台的異步交互所獲得的數據,如果全部通過 Redux Store 管理,寫法太繁瑣。
-
同一個頁面組件在不同場景(路由)下,訪問同一個 Store。數據到底是清空呢,還是不清空呢?這是一個視具體情況而定的問題。
-
需要多次異步請求才能完成的操作,需要用 Saga 之類的中間件處理,比較麻煩。
后面的項目中我們試圖完全不用狀態管理庫,回到依賴 React 組件的 State 來管理狀態,實操下來發現難以為繼,特別是有主頁面和承接頁面的情況下,如果承接頁的交互,會反映到主頁面的情況下,很難通過純粹的頁面內 State 來實現。
經過摸索,我們最后在架構中采用了 MobX 來作為應用全局狀態管理器。同時相對弱化了 Store 的地位,僅僅在一些需要采用 Store 的地方利用 Store。經驗看來以下場景中利用 Store 是比較好的設計模式:
-
管理會話狀態,處理用戶登錄,登出狀態時,通過 Action & Store 隔絕視圖層和后台服務調用,視圖層不需要處理登錄后跳轉到具體頁面,會話超時需要調轉到登錄頁等具體而繁瑣的邏輯。只需要通過 Action 來調用封裝好的方法即可。
-
主頁面跳轉到承接頁,承接頁進行交互之后,需要主頁面 UI 進行更新的場景。比如主頁面是一個待錄入的產品列表,其中有一項“生產廠商”需要跳轉到承接頁面中選擇,選擇完成之后回到主頁面,並把選中的廠商名字顯示在主界面上。可以在承接頁面中通過 Action 修改 Store,主頁面中監聽 Store 的變更實現。
-
不希望頻繁從服務器獲取的數據,比如產品列表數據,錯誤類型數據字典,也可以存入 Store。
虛擬 Dom 層
以往手機瀏覽器中復雜頁面的性能優化往往要付出巨大的代價。究其原因是因為手機瀏覽器 DOM 渲染的性能遠遠落后於 JavaScript 執行引擎的性能。而且不同層次(layer)的 Dom 結構和屬性變化,會導致瀏覽器的重繪 (redraw) 和重排 (reflow),需要付出高昂的性能代價。這也是為什么基於 Cordova 的混合應用,受其性能影響,不適合做有復雜用戶交互,且重視用戶體驗的應用的深度原因。
而 React 創造性的用虛擬 Dom 解決的這個問題。虛擬 DOM,以及其高效的 Diff 算法。這讓我們在大部分情況下直接讓頁面重繪,而不用擔心性能問題,由虛擬 DOM 來確保只對界面上真正變化的部分進行實際的 DOM 操作。
虛擬 Dom 帶來的另一個好處是構建了超越平台的 Dom 語言(JSX),使得原來瀏覽器界用於描述界面結構的 Dom 語言,能夠以最小代價適用於其他各種原生應用平台。在這個領域已經涌現出了部分優秀的開源框架。
經過對比,我們選用 react-native-web 作為 react-native 在 Web 上的實現。 react-native-web 是一個通過將 react-native 的組件和 APIs 在 Web 上重新實現,使得 react-native 應用經過少量更改,可以在瀏覽器上運行的開源項目。官方宣稱支持到 react-native 0.55, 但是我們實測下來,兼容 react-native 最新版 (截止項目結束時) 0.57.4 沒什么問題。
公共模塊層
選擇了 react,我們就擁有了大量成熟的開源庫,包括 UI 組件和工具類庫。但是前端的技術迭代周期是非常快的,今年流行的庫,明年說不定就 out 了。
架構設計時必須要考慮前端頁面跟具體控件解除耦合。我們的做法是設計出一套標准的控件 IDL(接口描述語言),作為媒介溝通頁面跟具體組件實現。比如我們用到了某一個開源的 UI 組件,我們會根據實際業務抽象出一份標准接口,對開源組件進行二次封裝之后再調用。這樣即使后續需要更換其他組件,也不需要對頁面進行改動。
所有的 UI 組件,不論是我們自己造輪子寫的,還是開源的,都是按照:1. 定義 IDL -> 2. 進行封裝 -> 3. 實現並上傳 cnpm 服務器 -> 4. 項目 depencency 中引用來自 cnpm 的組件 IDL。 這樣的流程來進行引用。
高階組件層
在函數式編程的中,Hoc(高階組件) 被廣泛的用於組件中公共功能的復用,以及函數式編程的方式實現組件的擴展。我覺得講 Hoc 講的比較好的一篇文章是:《React Higher Order Components in depth》(https://medium.com/@franleplant/react-higher-order-components-in-depth-cf9032ee6c3e) , 把 Hoc 的幾種應用場景都講的比較透,而且還有 github 代碼直接可以拿來用。
這里結合我們項目中用到 Hoc 的場景,稍微展開一下。比如大家都知道 React 不像 Vue 提供了 v-model 的語法糖實現雙向數據綁定(MVVM)。如果一定要雙向綁定怎么辦呢?可以利用 Input-Hoc 實現:
- (NSURLSessionDataTask *)sendRequest:(NSURLRequest *)request withDelegate:(id<RCTURLRequestDelegate>)delegate { // Lazy setup if (!_session && [self isValid]) { NSOperationQueue *callbackQueue = [NSOperationQueue new]; callbackQueue.maxConcurrentOperationCount = 1; callbackQueue.underlyingQueue = [[_bridge networking] methodQueue]; NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; [configuration setHTTPShouldSetCookies:YES];
可以通過替換掉 defaultSessionConfiguration,來達到對 http 請求進行攔截的目的。當然可以直接修改 react-native 的代碼,不過我偏向於利用 Objective-C 的 method swizzling:
@implementation NSURLSessionConfiguration (extend) +(void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ [self swizzleClassMethod:@selector(defaultSessionConfiguration) withMethod:@selector(aopDefaultSessionConfiguration)]; }); } +(NSURLSessionConfiguration *) aopDefaultSessionConfiguration{ NSURLSessionConfiguration * instance = [self aopDefaultSessionConfiguration]; Class secureKeyboardURLProtocol = NSClassFromString(@"AOPURLProtocol"); if (secureKeyboardURLProtocol){ instance.protocolClasses = @[AOPURLProtocol]; }return instance; } @end
然后我們就可以定義自己的 NSURLProtocol 來對特殊 url 的請求進行攔截了。
@implementation AOPProtocol + (BOOL)canInitWithRequest:(NSURLRequest *)request { if (request != nil) { NSURL* url = [request URL]; if(url.scheme != nil && [url.scheme isEqualToString:@"demo"]){ return YES; } } return NO; } - (void)startLoading{ NSURL *url = [self.request URL]; NSString * path = [url.absoluteString stringByReplacingOccurrencesOfString:@"demo://" withString: @""]; NSData * imgData = [SecureImage imageWithPath: path]; NSDictionary * headersDict = [NSDictionary dictionaryWithObjectsAndKeys:[NSString stringWithFormat:@"%ld", [imgData length]], @"Content-Length",@"image/png",@"Content-Type",nil]; NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:[self.request URL] statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:headersDict]; [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed]; [self.client URLProtocol: self didLoadData:imgData]; [self.client URLProtocolDidFinishLoading: self]; } }
這樣,在前端通過請求 demo:// 開頭的,按一定規則索引的 url,就可以返回對應的 png 圖片,順利繞過 base64 圖片的問題。
RN 對中文輸入的支持問題
在 react-native 0.57 之前,如果像這樣寫:
<TextInput value={this.state.value} onChange={val => this.setState({value: val})} />
會面臨中文輸入時無法輸入的問題,解決辦法是不做 value 綁定,而是通過 ref 來獲取值。當然這樣 input-hoc 也沒法用了。
好在 react-native0.57 之后,Facebook 修復了這個問題。
WebView 相關問題
雖然在絕大部分的常見,React-Native 的性能都要超過 WebView。但是由於 React-Native 上目前還缺乏可以媲美 highbharts, e-charts 的報表組件,所以需要繪制報表的時候,還是需要通過 WebView 內嵌 html 的方式實現。
在使用 WebView 時,遇到的問題有兩個:
1.viewport: 頁面指定 viewport 為 device-width 的話,會按屏幕寬度來展現頁面內容。 如果希望 webview 內容不按整個屏幕寬度顯示,則需要計算好 viewport 的寬度,並傳入 webview 里面的 html 中。
2.Android : android 上 webview 不支持 require 方式加載的 html 資源文件。比如<WebView source={require('../../components/charts/charts.html')} />
在 iOS 上沒問題,但是在 Android 上實際加載不了。解決的辦法是要么把 html 文件放進 android 的 assets 目錄,要么通過網絡加載。
如:
<WebView source={Platform.OS === 'android' ? 'file:///android_asset/charts/charts.html' : require('../../components/charts/charts.html')} />
總 結
本文介紹了我們基於 React-Native 構建跨平台的前端應用架構中的一些實踐經驗,以及期間踩的一些坑。希望通過開放地描述我們的技術實現,拋磚引玉供大家探討,得到有益的改進意見和建議。
作者簡介:
陳子涵,7 年以上前端 & 移動架構,跨平台應用架構設計和開發經驗。曾在 SAP Labs,遠景能源負責移動和雲產品相關設計和開發工作。