做者|陈子涵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 管理应用的所有状态,效果不理想,主要问题有如下几点:
跟后台的异步交互所得到的数据,若是所有经过 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,远景能源负责移动和云产品相关设计和开发工做。