产品的移动化,这将是我们展开这篇文章的背景,我们会先了解小菜的产品托管在哪些端上,然后感受这些端带来的挑战,最后是我们聚焦如何做移动端的框架封装,包括必要的基建部分。

小菜大前端的端有哪些

小菜早期围绕着蔬菜销地以客户集单批发的模式摸爬滚打几年,从上游的蔬菜供应商到下游批发市场的摊位老板,在这个长长的链路中,我们诞生了这样几款线上产品来服务于不同的人群和场景,之前文章中也有介绍,这里再汇总一下,共 9 款 App:

  • 宋小菜 服务于销地批发老板的下单工具
  • 宋小福 服务于小菜内部销售团队的 CRM 销售管理与客户管理工具
  • 宋小仓 连接司机-物流-采购-销售的蔬菜在途位置监控工具
  • 采秘    服务于小菜内部采购团队的蔬菜品类采购工具
  • 麦大蔬 服务于上游蔬菜供应商的大宗农产品交易平台
  • 宋大仓 服务于上游囤货配资的进出库管理平台
  • 云掌柜 服务于产区加工厂进销存的移动 Saas
  • 卖大蔬 服务于产销行情与货源泛用户的内容小程序
  • 行情宝 服务于产销两地的内部行情采集和预测工具

前 7 款 App 都是基于 ReactNative 开发的 iOS/Android App,最后两个是微信小程序,它们涵盖了公司几乎所有的协同场景和工作流。

多端带来的技术挑战

1. 【物理现状】移动端的碎片化

古典互联网时代,因为要兼容 IE678 而痛苦不堪,Hack 黑魔法经验基本代表前端水平,如今互联网早已移动化,我们理想中的移动端开发,看上去是可以大胆使用新语法特性,只需要做好尺寸兼容就好了,但事实并非如此,不仅在移动端的浏览器不是如此,在移动端开发 RN App 也是如此,这是我们某一款 App 一段时间内,所收集上来的手机厂商分布:

可以发现 Android 的碎片化非常严重,每一个厂商下面有不同时期推出的不同型号的手机,这些手机有着不同版本的操作系统,不同的分辨率和用电策略,不同的后台进程管理方式和用户权限,要让一款 App 在哪怕头部 40% 的手机上兼容,都是一件艰难的事情,这个客观物理现状叠加下面的社区现状,App 质量保证这件事情会变得雪上加霜。

2. 【社区现状】技术框架的不稳定性

回到本文的开头,我们在长链路的 B2B 生鲜场景中,为了更快更轻,开发出了 7 款 App,而且将来随着业务场景的拓展会诞生更多独立 App 甚至是集大成的 App,所以技术选型不太可能选择原生的 Java/Object-C 开发,尤其对于创业公司,7 款 App 得需要多少名原生开发工程师才能搞定,高频繁重的业务变化又怎样靠堆人来保证?

想清楚这些,一开始我们就调研 ReactNative,并最终全部从原生切换到了 RN。通过跑过来的这 4 年来看,使用 RN 为公司节约了大量的人力成本同时,也尽可能的满足到了几乎所有的需要快速迭代的业务场景,又快又轻,成为宋小菜大前端团队做事的一个典型特征。

但换一个角度看,就是带来的问题。又快又轻的背后是 RN 版本的飞速迭代,截止到目前,也就是 2019 年 4 月份,RN 还没有推出一个官方的正式的长期维护的稳定版本,什么意思?就是 RN 目前依然处在不稳定的研发周期内,我们依然站在刀尖上起舞,用不稳定的 RN 版本试图开发稳定的应用。四年走来,我们在 RN 的框架里,多少次面对旧版本局限性和新版本不稳定性都进退不得,旧版本的 Bug 可能会在新版本中修复,新版本引进则会带来新版本自己的问题。

除了 RN 自身版本,还有第二个问题,围绕着 RN 有很多业界优秀的组件,但这些社区组件甚至官方组件,都不一定能及时跟进最新的 RN 版本,同时还能兼容到较老的 RN 版本,所以 RN 升级导致的组件不兼容性,会引发你 Fork 修改组件的冲动,但这样会带来额外的开发成本和版本维护成本,取舍会成为版本升降的终极问题。

在国内开发,还有第三个问题,就是中文文档缺乏,社区资源匮乏,参考文献陈旧,可拿来主义的开源工程方案甚至社区线上线下会议分享都很缺乏,一个不小心就会踩坑,这就是 RN 社区的现状,我们在刀尖浪花上独步,App 选型背后的技术栈稳定性则成为悬在头上的一把铡刀,你不知道什么时候会咔嚓一声。

3. 【人才现状】人员能力的长短不齐

我们知道有一个词叫做主观能动性,表示没有条件创造条件也可以上。这个词的主体就是人,聊完移动端设备现状和社区现状后,我们来聊聊人的问题。RN 在国内真正开始普及使用,是从 2015 年开始,也就意味着,到 2019 年,一个 RN 工程师最多也就只有 4 年的工作经验,而 RN 的 “Learn once, write anywhere” 也刺激着一切 Care 人员开支, Care 产品研发投入性价比的公司纷纷跳水研究 RN,争抢 RN 人才,RN 是前端中的移动前端,前端有多抢手,那么 RN 工程师就比它还要抢手。

这就导致基本上 RN 工程师很难靠外部招聘,只能靠内部培养。这也是小菜前端的成长历程,我们有  2 名资深 RN 工程师,一个是从服务端 Java,一个是从原生 Android 开发转过来的。如果 RN 人手不足,产品支持的力度和速度就一定会遇到瓶颈,这就是我们曾经面临的问题,就是人才现状,外招数量不足,内培速度有限,RN 工程师的数量和能力就时不时成为公司业务扩张的瓶颈。

4. 【公司现状】高密集业务的交付质量

作为工程师,我们有很强的自尊心和不容挑战的代码洁癖,但在一个创业公司里面,甚至大公司的一个创业团队里面,我们需要对接一些关键的业务节点,冲刺一些特定的时间窗口,并且要及时响应多变的业务,和业务背后多变的产品形态,这都会带来非常密集的需求队列。

这些密集的需求队列对我们的代码质量有非常高的挑战,一个组件用 5 分钟思考如何抽象和用 50 分钟思考,实现后的稳定性、兼容性都是不同的。如何保证产品按期交付上线,会是摆在我们面前一个非常关键的命题,而这个难题之外,还有一个更难的命题等着我们,那就是如何保证交付不延期的同时,还能保证交付质量。

要知道,如果一个项目代码赶的太毛糙,后期维护起来的成本会是巨大的,甚至只能用更高的成本重构重写。本质上,再次重构就一定是公司在为早期的猛冲买单,为这些技术债买单,如何不去买单或者如何用最小的成本买单,这跟我们早期的业务密集程度,交付周期,质量把控有很大的关系。

综上,移动端碎片化所带来的兼容难度,RN 框架的局限性,版本间差异带来的不稳定性,技术社区资源的匮乏和前端团队技术能力掣肘,再叠加上高密度的业务排期,让前端开发这个本来很酷的事情,变得晴雨不定。

这些避不开的现实,是绕不过去的坎儿,必须通过人才储备和技术基建来缓解,接下来我们进入到本文的重点 - RN 框架的封装。

RN 的 App 工程如何架构

RN 的 App 工程骨架,全部抽象完毕,再搭配上组件化,就可以称为一个基于 ReactNative 定制的 App 框架了,而 RN 涉及到原生层面的技术细节太多,我们暂不做讨论,只专注在工程与业务的封装上。

我们在构建 RN App 工程时需要关注这几个关键要素:

  • 配置管理
  • 静态文件管理
  • 网络请求
  • 组件管理
  • 路由管理
  • 数据缓存
  • App 的热更新
  • 数据搜集
  • 应用状态管理

1. 配置管理

配置管理是指可以灵活合理的管理 App 的内部环境,主要包括:

  • App 本身的一些配置
  • 所使用三方插件的配置

我们在构建工程时尽量将所有的配置抽象统一放置在一个地方,这样便于查找和修改。但是由于大多数配置都统一放在同一个地方,那么就难免有部分文件要使用某个配置时其引用路径比较长,比如:

import { pluginAConfig } from '../../../../../config'

这样就造成了阅读性很差且代码不美观,因此我们可以使用 Facebook 的 fbjs 模块提供的一个功能 providesModule :

//config.js
/**
 * config for all
 * @providesModule config
 * 使用 providesModule 将 config 暴露出去
 **/
import pluginAConfig from './plugin_a_config'

export default {
    pluginAConfig
}

// 然后在其他文件中调用
// A.js
import { pluginAConfig } from 'config'

这样就能很方便地在 App 的任意一处使用 config 了,但是我们要避免滥用 providesMoudle ,因为使用了 providesMoudle 进行声明的模块的源码,想要在编辑器中使用跳转到定义的方式去查看比较困难,不利于团队多人合作。

2. 静态资源

静态资源泛指会被多次调用的图片或 icon,我们一般在 RN 使用图片时是直接引用的:

import { Image } from 'react-native'

render(){
  return (
    <Image source={{uri: './logo.png'}} />
  )
}

当图片需要在多处使用时,我们可能会将这些可能会被反复使用的图片统一管理到 assets 文件夹中,统一管理和使用,但是当需要使用图片资源的文件嵌套较深时,引用图片就变得麻烦:

render(){
  return (
    <Image source={{uri: '../../../../assets/logo.png'}} />
  )
}

这个问题与配置管理的问题一样,可以首先将图片资源按照类型进行分类,比如 assets 文件夹下有 button/icon/img/splash/svg 等,每一个类型的结构如下:

- icon/
 - asset/
 - index.js

其中 asset 文件夹保存我们的图片资源,在 index.js 中对图片进行引用并暴露为模块:

// index.js
export default {
   IconAlarmClockOrange: require('./asset/icon_alarm_clock_orange.png'),
   IconAvatarBlue: require('./asset/icon_avatar_blue.png'),
   IconArrowLeftBlue: require('./asset/icon_arrow_left_blue.png'),
   IconArrowUpGreen: require('./asset/icon_arrow_up_green.png')
}

然后再在 assets 文件夹下编辑 index.js ,将所有的图片资源作为 assets 模块暴露出去,为了避免和其他模块冲突你可以修改模块名为 xxAssets

// assets/index.js
/**
 * @providesModule myAssets
 **/
 import Splash from './splash'
 import Icon from './icon'
 import Img from './img'
 import Btn from './button'
 import Svg from './svg'

 export {
   Splash,
   Icon,
   Img,
   Btn,
   Svg
 }

// A.js
import { Icon } from 'myAssets'

render(){
  return (
    <Image source={Icon.IconAlarmClockOrange} />
  )
}

这样,我们就能很方便地将分散在项目各处的图片资源统一到一个地方进行管理了,使用起来也非常方便。

3. 网络请求

网络请求这块,react-native 使用 whatwg-fetch,我们也可以选择其他的三方包如 axios 来做网络请求。但有时候我们会在开发中遇到一个问题,那就是我们明明已经在代码里已经修改了 cookie, 但是每次请求可能还是会带上之前的 cookie 从而造成一些困扰,所以这里推荐一个实用的组件 Networking :

import { NativeModules } from 'react-native'
const { Networking } = NativeModules

// 手动清除已缓存 Cookie,这样就能解决上述的问题了
Networking.clearCookies(callBack)

当然,Networking 的功能不止于此,还有很多其他有趣的功能可以发掘,可以直接用它来包装自己的网络请求工具,还支持 abort ,可以参考 源码 来具体把玩。

4. 组件化

使用 RN 开发 App 本身效率就比较高,如果想要继续进阶就要考虑组件化开发,一旦涉及到组件化开发,就不可避免地会涉及到组件管理的问题,这里的组件管理比较宽泛,它实际上应该指的是:

  • 组件规范
  • 组件类型划分
  • 组件开发标准

组件规范指的是 UI 设计规范,我们可以与设计同学交流规定好一套特定的规范,然后将通用的样式属性(如主题颜色,按钮轮廓,返回按键,Tab 基础样式等)定义出来,便于所有的组件让开发者在开发时使用,而不是开发者各自为政在开发时重复写样式文件,这里推荐一个比较好用的用于样式定义的三方插件 react-native-extended-stylesheet ,我们可以使用这个插件定义我们的通用属性:

// mystyle
import { PixelRatio, Dimensions } from 'react-native'
import EStyleSheet from 'react-native-extended-stylesheet'

const { width, height } = Dimensions.get('window')

const globals = {
  /** build color **/
  $Primary: '#aa66ff',
  $Secondary: '#77aa33',
  $slimLine: 1 / PixelRatio.get(),
  /** dimensions **/
  $windowWidth: width,
  $windowHeight: height
}

EStyleSheet.build(globals)

module.exports = {
  ...EStyleSheet,
  create: styleObject => EStyleSheet.create(styleObject),
  build: (obj) => {
    if (!obj) {
      return
    }
    EStyleSheet.build(_.assign(obj, globals))
  }
}

// view.js
import MyStyleSheet from 'mystyle'

const s = MyStyleSheet.create({
  container: {
    backgroundColor: '$Secondary',
    width: '$windowWidth'
  }
})

render....

这样,我们就能在开发的任意插件或者 App 中直接使用这些基础属性,当某些属性需要修改时只需要更新 mystyle 组件即可,另外还可以衍生出主题切换等功能,使得开发更加灵活。

关于组件类型我们会抛开三方组件以及原生组件,因为一旦涉及到这两者,需要写的东西就太多了,我们将组件按使用范围分为通用组件和业务组件两大类。

首先什么是业务组件?即我们在开发某个业务产品常用到的组件,这个组件绑定了与业务相关的一些特殊属性,除了这个业务开发以外,其他地方都不适用,但是在开发这个业务时多个页面会频繁地使用到,所以我们有必要将其抽象出来,方便使用。

什么是通用组件?即可以在 App 范围内使用甚至于跨 App 使用的组件,这里可以对这个类别进行细分,我们将能跨 App 使用的组件上传到了自己的搭建的私有 npm  仓库,方便我们的 App 开发者使用,同时,具有 App 自己特色的组件则放到工程中统一管理,同样适用 providesModules 暴露出去。

制定一整套组件开发标准的是很重要的,因为很多组件开发可能是多人维护的,有一套既定的规范就可以降低维护成本,组件使用的说明文档的完善也同样重要。

5. 路由管理

开发 App 就不可避免地会遇到如何管理页面以及处理页面跳转等问题,也就是路由管理问题,自从 Facebook 取消了 RN 本身自带的 Navigator 以后,许多依赖于这个组件的开发者不得不将目光投向百花齐放的社区三方组件,FB 随后推荐大家使用的是 react-community 推出的 react-navigation ,现在这个路由组件已经独立出来了。我们在开发时就是使用的这个组件作为路由管理组件,只不过是在其基础上做了一些定制 ,使得使用更加简单,部分跳转动作更加符合我们的产品场景,推荐大家使用这个组件。当然,除去这个组件还有很多其他的组件可供选择:

路由管理作为整个 App 的骨架,它是这几个部分中最重要的一部分,合理地定制和使用路由管理可以极大地简化我们的开发复杂度。

6. 数据缓存

一般情况下需要缓存的数据基本上就可能是我们会在 App 很多地方都会使用到的全局数据,如用户信息,App 设置(非应用层面的设置)等,RN 提供一个 AsyncStorage 存储引擎,通常的使用方式是对这个数据引擎进行包装后暴露出符合我们要求的读写接口。这里推荐另外一种使用方式:

既然需要缓存的数据可能是会在 App 很多地方使用到的全局数据,那么我们可以将这些全局数据使用 redux 来进行管理,而利器 redux-persist 则能让我们很优雅地读写我们的缓存数据。

同时,如果对 react-navigation 进行合理的定制,接管其路由管理,那么我们还能实现保存用户退出 App 之前最后浏览的页面的状态,用户在下次打开 App 依然可以从之前浏览的地方继续使用 App,当然,这个功能要谨慎使用!

7. 热更新

App 的版本更新,RN 除了传统的 App 更新外还有一个热更新的可选项(传统 App 更新也有热更新,其原理就不太一样了),社区大多数人都推荐使用 codepush 来进行热更新,至于其后端解决方案 貌似已经有了一个 code-push-server ,我们是使用自己的热更新方案,其原理就是在不更新原生代码的基础上更新 JS 代码和静态资源文件。

8. 数据搜集

搜集的 App 使用数据(包括异常数据)并对此分析,根据分析来定位问题是保证 App 质量的有效手段之一。你可以选择自己搭建一套数据搜集服务,包括客户端 SDK 和服务端搜集服务,或者选择市场上已有的工具,目前较为成熟的收据搜集工具比较多,如友盟,mixpanel, countly 等等,在此不作赘述。

9. 应用状态管理

React只是视图层的解决方案,对于复杂应用,需要涉及状态之间的共享、各层级组件之间的通信、多接口之间调用的同步等等,就需要进行应用状态管理,Facebook最早提出了Flux架构思想,后来社区又涌现了Redux、Mobx等很多种模式。经过调研比较,我们选择了Redux进行应用状态管理,Redux的核心概念主要是通过Store、Action、Reducer、Dispatch实现单向数据流动,具体概念请参考官方文档。Redux通过middleware机制,可以对Redux进行各种能力增强,这个增强其实是在action分发至任务处理reducer之前做一些额外的工作,dispatch发布的action先依次传递给中间件,然后最终到达reducer,所以使用middleware机制我们可以拓展很多能力,例如我们使用了状态持久化插件redux-persist,状态记录和重播插件redux-logger,而异步操作插件我们经历了两轮技术选型redux-thunk和redux-saga。

支持函数action的redux-thunk通过简单的几行代码使得只处理plain object的action支持异步操作。

if (typeof action === 'function') {
    return action(dispatch, getState, extraArgument);
}
return next(action);

Redux-thunk的实现非常简单,使用也非常灵活。我们可以在action中处理各种异步操作,也可以做任何事情,但是它的缺点是它缺乏对异步的直接处理,异步操作分散在各个action 中,而同步接口等操作依赖使用者自己的实现。

于是我们进而选择了支持generator的redux-saga。Redux-saga通过一个类似于独立线程的方式管理你的应用程序中的副作用,这意味着你可以通过普通的redux action开始、暂停或者取消saga线程。Redux-saga使用ES6的generator来管理异步流,使得业务逻辑的读写和测试变得更简单。在我们最新的架构中,我们其实使用的是蚂蚁金服开源的dva-core。之所以选用dva-core,主要是因为dva-core整合了redux和redux-saga,并且使开发者可以通过一个命名的model文件集中管理一个业务逻辑的state,通过定义的effects管理副作用操作,通过定义reducers管理其他处理函数。一个完整的model大概是这样的:

export default {
    namespace: 'order',
  effects: {...},
  reducers: {...},
  subscription: {...}
}

最后,关于应用状态管理,还有一个话题可以讨论,就是状态的不可变性immutable。在redux中状态是不可变的,每个reducer都会产生新的不可变状态。那么这个不可变性是否需要不可变js库(比如immutable.js)的支持呢?简单来说,immutable.js可以带来计算效率和存储效率的提升,但是它需要使用库支持的数据类型,所以如果从头构建一个应用,可以选择。如果是对于一个已有的复杂应用进行重构,那就需要综合考虑一下了。

小结

总结一下,一个 RN App 架构应该要保证 App 的运行稳定以及开发的便捷。运行稳定这一方面,除了从 JS 层面(如单元测试,JS 错误上报等)保证之外,很大程度上还要依赖于原生层面的处理,所以团队里面要有同学的精力可以投在原生研究上面,至于开发便捷,我们尽量将复杂重要或者简单繁琐的操作在构建工程时就做掉,这样也可以大幅度提高我们的开发效率,降低开发者之间的合作沟通成本。

:::info
Scott 近两年无论是面试还是线下线上的技术分享,遇到许许多多前端同学,由于团队原因,个人原因,职业成长,技术方向,甚至家庭等等原因,在理想国与现实之间,在放弃与坚守之间,摇摆不停,心酸硬扛,大家可以找我聊聊南聊聊北,对工程师的宿命有更多的了解,有更多的看见与听见,Scott 微信: codingdream,也可以来 关注 Scott 语雀跟进最新动态,本文未经许可不许转载,获得许可请联系 Scott,否则在公众号上直接转载,尤其是裁剪内容后转载,我都会直接进行投诉处理。
:::


03-05 16:53