作者|陈子涵

编辑|覃云

“一次编写, 到处运行”(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 管理应用的全部状态,效果不理想,主要问题有以下几点:

  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,远景能源负责移动和云产品相关设计和开发工作。

基于React Native的跨三端应用架构实践的更多相关文章

  1. 基于React Native的移动平台研发实践分享

    转载:http://blog.csdn.net/haozhenming/article/details/72772787 本文目录: 一.React Native 已经成为了移动前端技术的趋势 二.基 ...

  2. Vue 全家桶 + Electron 开发的一个跨三端的应用

    代码地址如下:http://www.demodashi.com/demo/11738.html GitHub Repo:vue-objccn Follow: halfrost · GitHub 利用 ...

  3. React Native学习(三)—— 使用导航器Navigation跳转页面

    本文基于React Native 0.52 参考文档https://reactnavigation.org/docs/navigators/navigation-prop 一.基础 1.三种类型 Ta ...

  4. 基于React Native的Material Design风格的组件库 MRN

    基于React Native的Material Design风格的组件库.(为了平台统一体验,目前只打算支持安卓) 官方网站 http://mrn.js.org/ Github https://git ...

  5. React Native在特赞的应用与实践

    基于React技术栈构建开发前端项目,并使用React Native开发特赞移动APP 目前正在使用Node.js开发和维护特赞服务网关,希望Node.js能够在更轻量级的微服务架构中发挥重要作用 课 ...

  6. 基于React Native的58 APP开发实践

    React Native在iOS界早就炒的火热了,随着2015年底Android端推出后,一套代码能运行于双平台上,真正拥有了Hybrid框架的所有优势.再加上Native的优秀性能,让越来越多的公司 ...

  7. React Native组件(三)Text组件解析

    相关文章 React Native探索系列 React Native组件系列 前言 此前介绍了最基本的View组件,接下来就是最常用的Text组件,对于Text组件的一些常用属性,这篇文章会给出简单的 ...

  8. React Native框架如何白盒测试-HIPPY接口测试架构篇

    本文转载自腾讯TMQ团队 ,侵权删. 1.开天辟地 Hippy是什么呢?简单点,能用JavaScript来写Android和iOS应用的框架, 类似业界的React Native. 好吧,我们还是严谨 ...

  9. React Native 中 跨页面间通信解决方案之 react-native-event-bus

    https://github.com/crazycodeboy/react-native-event-bus 用法: A页面和B页面中都有相同的列表,点击B页面中的收藏按钮,A页面会跟着更新 impo ...

随机推荐

  1. 链接socket加异常

    try { channel = AmqpClient::Channel::Create("10.10.22.105", 5672, "admin", " ...

  2. Hybrid App技术解析 — 原理篇

    Hybrid App技术解析 — 原理篇 原文出处:   https://segmentfault.com/a/1190000015678155 引言 随着 Web 技术和移动设备的快速发展,Hybr ...

  3. 深入理解TCP协议及其源代码

    本次实验,我们来探究connect及bind.listen.accept背后的三次握手. 实验原理 首先简要回顾一下TCP三次握手的过程: 第一次握手:client向server发送SYN=1的数据报 ...

  4. oracle 数据恢复

    闪回表删除之前 flashback table t1 to before drop; 如果彻底删除表此方法无效 若要彻底删除表,则使用语句:drop table <table_name> ...

  5. 21.Semaphore信号量

    Semaphore是一种基于计数的信号量.它可以设定一个阈值,基于此,多个线程竞争获取许可信号,做自己的申请后归还,超过阈值后,线程申请许可信号将会被阻塞.Semaphore可以用来构建一些对象池,资 ...

  6. 20.ReenterLock重入锁

    import java.util.concurrent.locks.ReentrantLock; /** * 重入锁 ReenterLock 一个线程允许连续获得同一把锁,注意:必须释放相同次数,释放 ...

  7. boost库:智能指针

    1. C98里的智能指针 std::auto_ptr ,本质上是一个普通的指针,通过地址来访问你一个动态分配的对象,初始化时需要传递一个由new操作符返回的对象地址. std::auto_ptr的析构 ...

  8. ofbiz webservice 例解

    1.定义controller.xml文件,controller文件:ofbiz当前项目的所有请求的入口,通过对应request-map:将所有的请求uri对应到指定的处理函数上. <reques ...

  9. css使子元素在父元素居中的各种方法

    html结构: <div class="parent"> <div class="child"></div> </di ...

  10. python 去除字符串两端字符串

    转载:http://blog.sina.com.cn/s/blog_940224600100w8l0.html Python中的strip用于去除字符串的首位字符,同理,lstrip用于去除左边的字符 ...