React Native 简直太火了,国内大公司都在争先恐后的尝鲜,让人难以相信这是诞生刚刚一年的开源项目。正因为它的年轻,在使用它进行开发时难免会遇到这样那样的坑,因此,我们邀请了《React Native 入门与实战》的作者之一,魅族高级研发经理魏晓军来为我们解析 RN 开发中的痛点。本文分享的是在环境搭建和扩展中会遇到的问题与解决方案。
引言
React Native 的出现,为 APP 开发者们带来了冲动和激情,令 Native 开发者和 Web 开发者都为之痴迷。瞬间各类技术论坛、技术社区甚至出版社都争先报道其相关内容。然而对于一般的初学者来说,最简单要求莫过于按照官方提供的向导来完成基于 React Native 的处女之作。就是这么简单的一个要求,却把无数开发者拒之门外。其中原因在于初学者在按照教程搭建的过程中,总会出现这样那样的问题,给开发者们的锐气重重一击。下文将以其中一些比较突出的问题为起点,从开发前和开发中两方面来加以分析并给出相应的解决方案,希望能够给广大开发者们提供些许实战经验,少走一些不必要的弯路。
开发前
面临的问题
按照官方的向导,在 Homebrew 、Watchman、Flow、nvm、node 等运行环境安装完后,要做的第一件事情便是安装 React Native 命令行工具并由其初始化 React Native 项目。代码如下:
<p>$ npm install -g react-native-cli $ react-native init AwesomeProject</p>
然而就是这么两句简短的代码,却运行的也不是那么的顺利。经常会听到开发者们抱怨安装个命令行工具也要翻墙。
对于初学者们,最捷径的办法,莫过于参考别人的项目。经常会遇到这么一个场景, 项目中要用到一个 slider 组件,聪明的开者们很快想到了 github,在其上一搜发现还真不少,于是乎兴高采烈的将其整个项目 clone 下来,找到其中的 slider 组件,纳入自己的项目中,简单的添加了几句引入代码,便急冲冲的执行 CMD+R,瞬间模拟器内一片红屏,分析原因,最后发现是 fontsize 不支持数字了一定要以字符串的方式设置,这是多么沉痛的打击。
玩了一段时间的 React Native 开发者们应该也会发现,随着开发出来的 React Native 项目的增多,电脑的存储空间会越来越小了,翻开 React Native 的历代版本可以看到,gzip 之后 70M 左右,解压后在 350M 左右。这样的体积大小,几个项目下来,几个 G 的空间瞬间没了。
那么归纳起来主要是以下几类问题制约着开发者们。
- React Native 命令行环境搭建困难
- React Native 初始化项目困难
- React Native 版本升级经常带来 API 不支持
- 随着 React Native 项目的增多,占用空间越大
原因分析
React Native 命令行环境搭建困难,主要是慢,为什么呢,从代码
<p>npm install -g react-native-cli</p>
中可以了解到,这是在从 npm 服务器上拉取 react-native-cli。所以慢的原因便是因为 npm 服务器不在国内。聪明的国人已给出了解决办法,通过翻墙来解决此问题。更高兴的是 npm 提供了一个 register 的属性,可以让开发者自由的设置镜像地址。开发者们最常用的便是淘宝的镜像地址。据统计国内比较常用的镜像地址有:
<p>http://r.cnpmjs.org/ http://registry.npm.taobao.org/ http://registry.npmjs.eu/ http://registry.npmjs.org.au/ http://npm.strongloop.com/ https://registry.nodejitsu.com/ http://registry.npmjs.pt/</p>
这么多眼花缭乱的地址,的要感谢国人开源意识的强大,是他们给开发者们带来了福音,让开发者们再也不用担心下载不到 nodejs 包了。
这是官网初始化 React Native 项目的代码:
<p>react-native init AwesomeProject</p>
可以看到这是通过"react-native init"这个命令来进行初始化的。那“react-native”这个命令又是从哪里来的呢,起初给笔者带来很大的困惑,上面只安装了“react-native-cli”这个 node 包,怎么会冒出个“react-native”命令而不是“react-native-cli”命令呢。我们翻开“react-native-cli”的安装目录,mac 上是在:
<p>/usr/local/lib/node_modules/react-native-cli</p>
目录,打开其中的“package.json”文件,可以看到有这么一段代码:
<p>"bin": { "react-native": "index.js" },</p>
这里简单介绍下 bin 属性,"bin"是由多个“{ 命令名:文件名 }”组成的一个 map。在安装的时候会将每个“命令名”链接到 prefix/bin(全局初始化)或者./node_modules/.bin/(本地初始化)。上面代码在安装的时候,会将 index.js 链接到 /usr/local/bin/react-native。这样使用"react-native init"进行初始化的困惑也就可以理解啦。那么这个“react-native init”究竟做了什么呢。继续跟踪,打开 index.js,其中的部分代码片段:
if (args[0] === 'init') { if (args[1]) { init(args[1]); } else { } } else { ...... }
可以看出,index.js 其实只对“init”方法做了处理,具体到“init”方法中又做了些什么呢,截取了部分主要代码如下:
<p>1、fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(packageJson)); 2、run('npm install --save react-native', function(e) {</p>
其中代码 1 是动态生成 package.json,代码 2 是在本地安装 react-native 模块。到此,之前提到的“React Native 初始化项目困难”也就不难理解啦,原因都是 npm 惹的祸。
既然已经看到这里啦,那就顺带完整的介绍下 react-native 这个命令。除了“react-native init”命令外,官网还提供了 “react-native bundle”、“react-native run-android”等命令,而 index.js 文中却只有“init”命令。那其他命令又是怎么来的呢。带着疑问,笔者又找到了如下的代码:
var CLI_MODULE_PATH = function() { return path.resolve( process.cwd(), 'node_modules', 'react-native', 'cli' ); }; var cli; try { cli = require(CLI_MODULE_PATH()); } catch(e) {} if (cli) { cli.run(); } else { ...... }
打开“CLI_MODULE_PATH”所指的 cli 文件,其指向了“module.exports = require(’./local-cli/cli.js’);”,继续打开“/local-cli/cli.js”部分代码如下:
看到这里,也就眼前豁然开朗起来,原来除“init”命令外,其它命令都在这里啦,这让笔者明白了一件事情:也就是说“init”可以在任何地方使用,而其他命令只能在 React Native 项目的根目录下使用 ,不得不佩服 Facebook 设计之巧妙。 与此同时,另一件重要的事情也就显得清晰可见了。那就是在“React Native”的开发中,其实有两个“react-native”,一个是的“react-native-cli”生成的全局的 react-native 命令,一个是在初始化项目时安装的“react-native”模块,即在应用开发中通过“require(‘react-native’)”所引用的模块。这两个简单区别便是,一个是全局的模块,一个是局部的模块。全局模块中只提供了一个“init”方法,而局部模块提供了除“init”方法外的所有命令以及 React Native 开发中用到的所有功能。
经过上面对"react-native-cli"与“react-native”的分析,可以看出 Facebook 应该是推荐“react-native”模块局部化,所以不论在 React Native 项目初始化的过程中,还是 clone 已有的 React Native 项目,都需要在当前项目下下载和安装“react-native”模块,使得 React Native 项目占用的空间越来越大。
解决办法
1、私有 NPM 搭建
虽然上面讲的国内镜像,已使 NPM 的下载速度很快啦,但是仍然不如在自己内网架设个 NPM 私有服务器,给团队成员提供更快捷的下载速度。架设私服,除了速度快以外,还有一个重要的原因就是一些内部的隐私模块也可以发在私服上供内部成员使用。 市面上的 NPM 私服也有很多,这里推荐的是一个叫“sinopia”的 NPM 私服。“sinopia”的做法是优先从自己的仓库中拉取模块,如果发现没有,便从远端的 NPM 服务器拉取。也许有的开发者早已注意到,这个私服其实在“react-native-cli”的 NPM 库中 react-native-cli 就有介绍,笔者猜想应该是 Facebook 也推荐诸位使用“sinopia”来搭建 NPM 私服吧。“sinopia”的 github 地址为: https://github.com/rlidwka/sinopia 。“sinopia”的搭建比较简单,步骤如下:
- 安装命令:
<p>$ npm install -g sinopia</p>
###### - 启动命令
<p>$ sinopia</p>
启动后的日志:
<p>warn --- config file - /Users/youname/.config/sinopia/config.yaml warn --- http address - http://localhost:4873/</p>
日志中的“config file”为“sinopia”的配置地址,“http address”为“sinopia”的主页地址。打开“/Users/youname/.config/sinopia/config.yaml”可以看到默认的配置信息如下:
如果想看更完整的配置可以参考这里 https://github.com/rlidwka/sinopia/blob/master/conf/full.yaml 。
- 创建新用户
<p>$ npm adduser --registry http://localhost:4873</p>
按照命令行中的提示,依次输入 Username、Passworld、Email 即可完成用户的创建。
- 设置 npm 镜像地址
<p>$ npm set registry http://localhost:4873/</p>
###### - 发布 npm 包
在发布模块前,需要先登录
<p>$ npm adduser Username:xxx Passworld:xxx Email:xxx@xx.com</p>
登录完成后,便可进入待发布模块的根目录进行发布了。
<p>$ npm publish</p>
若没有“package.json”文件的话,需先执行“npm init”进行创建,然后执行上面的命令即可将模块推送到自己的 NPM 服务器上了。这样我们在安装该模块的时候,便是从自己的 NPM 服务器上下载了。
- 远端访问
上面只是在本地搭起了 NPM 服务器,只能通过本地来访问,如果要做到远端访问的话,需要这样来启动“sinopia”:
<p>$ sinopia -l IP 地址: 端口 </p>
#### - 配置 React Native 的 sinopia 服务器
对于 react-native 的配置,官网建议修改 packages 和 max_body_size 的配置如下:
...... packages: 'react-native': allow_access: $all allow_publish: $all 'react-native-cli': allow_access: $all allow_publish: $all '*': allow_access: $all proxy: npmjs max_body_size: '50mb'
从修改中可以看到,主要是对模块发布做了限制,只允许发布‘react-native’和’react-native-cli’模块,其他模块一概不允许发布,猜想应该是怕将其他模块覆盖掉吧。对 max_body_size 的设置,主要是出于对模块大小的考虑,避免产生"request entity too large"的错误,因为默认的大小为 1mb。
服务器配好后,接下来就需要将 react-native 模块和 react-native-cli 模块发布上去了。为了方便起见,我们建立如下的目录结构:
react_native_modules react-native v0.21.0 node_modules react-native v0.20.0 node_modules react-native react-native-cli v1.0.0 node_modules react-native-cli
其中 react_native_modules 为我们在用户目录下创建文件夹。之所以设计成这样的结构是为了我们方便现在 NPM 服务器上的模块。如我们要下载 0.19.0 版本的 react-native 模块,我们只需要创建 react_native_modules\react-native\v0.19.0 文件夹,然后在该文件夹中执行
<p>$ npm install react-native@0.19.0</p>
即可完成从 NPM 服务器上对该模块的下载。接下来进入到 node_modules\react-native 目录,执行
<p>$ npm set registry http://host:port/ // 要记得切换到 sinopia 服务器哦,否则会将模块发 在 NPM 服务器上而不是 sinopia 服务器上 $ npm publish</p>
实现多版本管理
如果说 sinopia 是用来解决速度的问题,那么多版本的管理可以说是用来解决体积的问题。做过 node.js 开发的同学,都清楚 nvm,它是 nodejs 的版本管理工具,甚至包括 React Native 的官网也有谈到使用 nvm 来安装 node.js。在 react-native 版本迭代如此频繁的阶段,居然没有 react-native 的版本管理工具,这让开发人员们很是受伤。所以,这里将尝试着设计一个 react-native 的版本管理工具,我们可以亲切的叫它 rnvm(react-native version manager)。在了解 rnvm 的思路前,先了解下 rnvm 的使用场景.
-rnvm 的使用场景
rnvm 如其名字中的那样,主要是对 react-native 的版本进行管理的。那么它的使用场景都有哪些呢。这的从一个 React Native 项目的的获得方式说起。通常情况下有如下几种方式:
a、通过 react-native 命令初始化项目获得
b、通过从 github 上 clone 获得
c、通过拷贝获得
对于 a 中的使用场景,在 react-native 初始化项目的时候,正常情况下 rnvm 是插不上手的。如果真要用 rnvm,需要侵入 /usr/local/lib/node_modules/react-native-cli/index.js 文件,将 run('npm install --save react-native’改为 run('rnvm use ', 或者也可以给 rnvm 添加一个 init 命令来取代 react-native init 命令, 使用方式为 rnvm init AwesomeProject。
对于 b、c 场景,可以直接使用 rnvm 命令进行处理。
然而,这并不是 rvnm 的优势。rnvm 的核心思想是将 react-native 模块安装在全局目录下,这样每个 React Native 项目在使用的时候,不需要在本地目录中安装一份,只需要调用全局目录中的 react-native 即可,给开发者节省了不少的空间。再者 rnvm 给 React Native 项目中的对 react-native 版本的使用带来了灵活性,所以 rnvm 更适合多 React Native 项目的开发。
-rnvm 的目录结构
prefix_node_modules node_modules react-native react-native-cli react_native_modules react-native v0.21.0 node_modules react-native v0.20.0 node_modules react-native react-native-cli v1.0.0 node_modules react-native-cli
还是在用户目录下创建 react_native_modules、prefix_node_modules 两个目录结构。react_native_modules 的目录和上面 sinopia 发布模块用的是一样的结构,都是用来存放模块的。默认的全局安装目录在“/usr/local/lib/node_modules”,这里的 prefix_node_modules 目录就是用替换原有的全局安装目录,这样做的好处是不需要每次装全局模块时都要 sudo。
-rnvm 的执行流程
这里需要结合一个场景来分析 rnvm 的执行流程,某天开发人员从 github clone 了一份别人写的 React Native 的代码,重命名为 mycloneproject,想在本地运行起来,正常情况下应该是进入 mycloneproject 项目的根目录,然后执行 npm install。这样就会将 package.json 中指定的所有依赖模块都安装在当前目录的 node_modules 目录中。那么使用 rnvm 是怎么安装的呢。
现在假设 rnvm 只有一个 use 命令,格式为 rnvm use version。
具体的执行代码如下:
<p>$ cd mycloneproject $ npm config set prefix ~/prefix_node_modules/node_modules $ npm set registry http://host:port/ $ rnvm use 0.20.0</p>
在这个过程中发生了些什么呢?
a、先进入 mycloneproject 项目的根目录。
b、设置全局模块安装目录为~/prefix_node_modules/node_modules。
c、设置 npm 的镜像指向自己的 sinopia 服务器,这样之后的所有 npm 命令就会从 sinopia 服务器获取模块了。
d、执行 rnvm use 命令。代码中看到可以使用全局“rnvm”命令,这就要求 rnvm 是一个 node.js 的模块,且该模块实现了 package.jon 中的 bin 配置,使其支持全局安装。
e、rnvm 接收到两个参数之后的行为:
- 在拿到参数后,rvnm 会去 react_native_modules/react-native 中查找是否有 v0.20.0 目录,如果有则进入该目录,并执行 npm link
- 如果没有,则创建 v0.20.0 目录,并进入该目录执行 npm install react-native@0.20.0。
- 执行完后,进入 node_modules/react-native 中,执行 npm link
- 接着在回到 mycloneproject 项目根目录,执行 npm link react-native,然后在执行 npm install。这样 mycloneproject 项目的依赖模块就都安装完毕,且使用了 0.20.0 版本的 react-native,
这便是一个 rnvm 的基本执行流程。当然,这里也可能会有些特殊情况,如只使用 rnvm use 不传版本信息,这样 rnvm 就需要先分析 package.json 中的 react-native 的版本信息,并结合 npm info react-native 获取来的版本信息进行处理,得出最终需要的版本信息,然后在执行 rnvm use 最终的版本信息。
这只是个 use 命令的执行分析。也可以像 nvm 一样,实现 rnvm install、rnvm ls、rnvm current 等命令。
rnmv 的 github 地址: https://github.com/GammaGos/rnvm/
3、完整架构
图 1 基于 rnvm 的开发架构图
基于上面的图形,这里做简短的描述。总体分为两个大的部分,一个是 server 端,一个是 client 端。server 端是指 sinopia 服务所在的端,主要负责提供 NPM 私有服务。在搭建该端的时候,需将常用的 react-native 版本和 react-native-cli 版本都推送到该服务器上,便于之后客户端的使用。client 端是指用户端也就是开发者端。该端负责 React Native 项目的构建。该端属于消费端是主战场。 在上图中,该端主要发生的逻辑为:
- 开发者先构建了一个 React Native ProjectA 项目
- 然后使用 rnvm 来安装依赖模块,
- rnmv 接着在指定的目录下判断是否有对应的模块,
- 有的话会先找到对应的模块,然后再去模块根目录下做 npm link 的操作,然后回到 React Native ProjectA 项目的根目录,执行 npm link react-native 操作,接着执行 npm instal 的操作。
- 如果没有找到对应模块的话,会向 sinopia 服务器发送请求,
- 请求下载需要的模块,并放入指定的目录中,
- 待模块下载完毕后, 执行 4 中的操作。
- 如果 sinopia 服务器也没有的话,会像 npm 服务器发起请求
- 待模块下载完后,执行 4 中的操作。
- 然后,在将改模块 publish 到 sinopia 服务器上。
开发中
面临的问题
通常项目中,App 需要开发 Android 和 iOS 两个版本, 经常会用到一些图片,并需要将这些图片打入 App 中。当开发 iOS 版本时,需要手动加载这些图片资源到 xcode 中。当开发 Android 版本时又需要手动的加载一次。这样,当某天某个图片需要更新时,就需要对 Android 和 iOS 都进行修改。如果要是能够让两个版本引用同一个图片, 那么就会使开发变得简便。
解决办法
起初的想法
我们可以借助 shell 脚本创建、搬运、解析文件的能力,加上一些自定义的规则,来实现 Andorid、iOS 两个版本引用同一个图片的功能。
下面来简短的介绍下实现思想。
iOS 版本在 Images.xcassets 文件夹中创建符合规则的图片文件以及文件夹。Android 版本在 drawable-hdpi,mdpi 等文件夹中创建符合规则的图片。那么这个规则是什么呢,我们可以通过使用 json 形式的 congfig 文件来定义这个规则,格式如下:
{ "resources":[ { // 资源的别名 "name":"rose", // 资源的类型 "type":"image", // 资源路径,可以相对也可以绝对 "url":"resources/image/rose.png", ...... }, { "name":"flower", "type":"image", "url":"http://host/path/imagename.jpg" ...... } ] }
然后再借助 Shell 的 jq 插件,通过解析刚才定义的 congfig 文件来获得约定的规则,获得规则的主要 shell 代码如下:
<p><code>...... index=0; flag=0; while ((flag<=0));do read imgname <<< $(cat ./../../resources/image/resource.json |./jq '.[]' |./jq '.['$index']'|./jq '.name') read url <<< $(cat ./../../resources/image/resource.json |./jq '.[]' |./jq '.['$index']'|./jq '.url') done /** 省略创建图片的代码 **/</code></p>
待获得规则后,就可以根据规则生成各个版本对应的图片。到此,shell 脚本的主体逻辑已经介绍完成,是时候把它融合到两个版本的实际项目中运行了。
在 Android 版本中,我们可以通过开发一个 Unix executable 文件,来封装自己的 run-android 运行命令,代码如下:
<p>/** 省略前面解析 json 与创建图片的代码 **/ cd ../<android 项目路径 >/ react-native run-android</p>
待启动 Android 项目后,图片顺利的读取到了。
在 iOS 版本中,我们可以通过开发一个 shell 脚本,并把它添加到 Xcode 项目的 run script phase 中,待启动 iOS 项目后,却发现资源文件根本读不到。这是为什么呢?
原因分析
-iOS 中 React Native 项目启动顺序:
- 在启动 React Native Xcode 项目时,会先加载项目所依赖的 React 项目,接着运行 React 项目中事先定义好的 run script phase,最后运行 packger.sh。
- 其中 packger.sh 中我们看到如下的代码:
node "$THIS_DIR/../local-cli/cli.js" start "$@"
- 接着我们找到了 cli.js,看到里面调用了好多模块。其中 default.config.js 模块指定了 JS 和资源的加载路径,server.js 模块除了指定 server 监听的默认端口外还有检测 node 版本等功能,runServer.js 模块用来启动 server。
- 待 server 启动成功后,才运行到 iOS native code。也就是这个时候,才会运行 Xcode 项目中,事先定义好的 run script phase 中指定的 shell 脚本,而在这个时候,在 shell 脚本中创建资源路径是没有用的。所以就会出现了上面资源文件读不到的情况。
-Android 中 React Native 项目启动顺序:
- 首先执行上面封装好的 Unix executable 文件,该文件中会调用资源文件生成的代码,将资源文件生成。
- 然后在该文件中会继续再执行 react-native run-android 命令,此时根据 react-native-cli 模块的 package.json 中 bin 的定义,调用 node.js 执行 $prefix/react-native-cli/index.js。
- 在 index.js 中会先加载 cli.js 模块然后运行其 run 方法。在 cli.js 模块中做的工作和上面分析的 iOS 中的 cli.js 做的工作是一样的。
- 待 server 启动成功后,才会运行到 Android native code, 所以运行封装好的 Unix executable 是不会导致资源失效的,因为资源生成代码已经在 react-native run-android 命令运行之前被执行过了。
最终的解决办法
为了让资源生成的代码执行顺序提前,可以先增加一个名为 AppPrepare 的 Command 类型项目,来运行此 Shell。然后在 Xcode 项目 Target Dependencies 中添加 AppPrepare 项目,这样就会先运行 AppPrepare 的项目后才会运行 Xcode 项目,从而达到了我们的目的。
图 2 Xcode 项目依赖图
感谢徐川对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论