基於React Native的跨三端應用架構實踐

做者|陳子涵css

編輯|覃雲html

「一次編寫, 處處運行」(Write once, run anywhere ) 是不少前端團隊孜孜以求的目標。實現這個目標,不但能以最快的速度,將應用推廣到各個渠道,並且還能節省大量人力物力。前端

React Native 的推出,爲跨平臺的開發帶來了新的曙光。 雖然 Facebook 官方 blog 的說法 React Native 支持「Learn once, write anywhere.」。react

但通過開源社區的不斷努力,React Native 已經能夠達到「一次編寫, 處處運行」的目標。能夠說超過了 Facebook 的預期。做者在最近的幾個項目中,運用 React Native 技術,成功實現跨越 iOS,Android,Web 三端的前端架構。這裏將使用到的技術和過程當中遇到的困難和問題揭示出來,供讀者探討。android

技術選型git

咱們的目標是但願一套代碼同時支持 iOS,Android App 和微信公衆號內的網頁(同時保留未來支持桌面瀏覽器的能力)。在開始重構以前,咱們盤點了目前可用的一些技術:es6

 

① SPA:single page web application,就是隻有一張 html 頁面的應用。僅在該 Web 頁面初始化時加載相應的 HTML、JavaScript、CSS。一旦頁面加載完成,SPA 不會由於用戶的操做而進行頁面的從新加載或跳轉,而是利用 JavaScript 動態的變換 HTML(採用的是 div 切換顯示和隱藏),從而實現 UI 與用戶的交互。github

② MPA: multipage web application, 相對於 SPA,MPA 有多個 html 頁面。頁面間跳轉刷新全部資源,公共資源 (js、css 等) 需選擇性從新加載。web

本人於 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 管理應用的所有狀態,效果不理想,主要問題有如下幾點:

  1. 跟後臺的異步交互所得到的數據,若是所有經過 Redux Store 管理,寫法太繁瑣。

  2. 同一個頁面組件在不一樣場景(路由)下,訪問同一個 Store。數據究竟是清空呢,仍是不清空呢?這是一個視具體狀況而定的問題。

  3. 須要屢次異步請求才能完成的操做,須要用 Saga 之類的中間件處理,比較麻煩。

後面的項目中咱們試圖徹底不用狀態管理庫,回到依賴 React 組件的 State 來管理狀態,實操下來發現難覺得繼,特別是有主頁面和承接頁面的狀況下,若是承接頁的交互,會反映到主頁面的狀況下,很難經過純粹的頁面內 State 來實現。

通過摸索,咱們最後在架構中採用了 MobX 來做爲應用全局狀態管理器。同時相對弱化了 Store 的地位,僅僅在一些須要採用 Store 的地方利用 Store。經驗看來如下場景中利用 Store 是比較好的設計模式:

  1. 管理會話狀態,處理用戶登陸,登出狀態時,經過 Action & Store 隔絕視圖層和後臺服務調用,視圖層不須要處理登陸後跳轉到具體頁面,會話超時須要調轉到登陸頁等具體而繁瑣的邏輯。只須要經過 Action 來調用封裝好的方法便可。

  2. 主頁面跳轉到承接頁,承接頁進行交互以後,須要主頁面 UI 進行更新的場景。好比主頁面是一個待錄入的產品列表,其中有一項「生產廠商」須要跳轉到承接頁面中選擇,選擇完成以後回到主頁面,並把選中的廠商名字顯示在主界面上。能夠在承接頁面中經過 Action 修改 Store,主頁面中監聽 Store 的變動實現。

  3. 不但願頻繁從服務器獲取的數據,好比產品列表數據,錯誤類型數據字典,也能夠存入 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,遠景能源負責移動和雲產品相關設計和開發工做。

相關文章
相關標籤/搜索