React Native 源码导读(零) – 创建/运行/调试

2017-7-31

最近工作需要,重新看 React Native (以下简称RN) 源码,了解机制,寻找优化空间,过程中看能不能整理出一些东西。

RN 这个项目已经是庞然大物,打开 github 项目主页,根目录下文件和文件夹就多达五六十个,看起来一脸懵逼,不知道哪些是源码,在看源码之前先理理 RN 最终用到哪些代码,项目是怎样创建,怎样跑起来的。以下皆以 iOS 端为例。

流程

先看看标准 RN 项目创建和运行过程:

  1. RN 根据教程装完环境后,会有一个全局命令 react-native,执行 react-native init AwesomeProject 可以创建一个新 RN 项目。
  2. XCode 打开自动生成的项目,编译到模拟器或真机,一个 RN hello world 程序成功运行了。
  3. 在模拟器运行同时会在 chrome 打开一个页面,在页面里使用 developer tools 可以直接断点调试 RN 页面上的 JS 源码。

疑问

上述流程跑下来整个过程是个黑盒,对黑盒里的处理有一些疑问点:

  1. react-native init AwesomeProject 这个命令做了什么,是怎样创建 RN 模板项目的?
  2. 项目 JS 源码在哪里,如何跑起来的?
  3. 怎样做到可以在 chrome 调试 JS 源码?

接下来一条条看。

解析

I. 创建 RN 项目

按教程装完环境后,会在 /usr/local/bin/ 加上 react-native 脚本,实际是个 node.js 脚本,也就是 github 项目上的 react-native-cli/index.js ,在命令行全局调用 react-native 就会调到这个脚本。

从这个文件的注释也可以看到,这只是一个转接层,所有命令都会转接到 local-cli上,但很奇怪,react-native init 创建工程的逻辑部分在这个转接层react-native-cli/index.js,部分在 local-cli/init/init.js,其他命令则全部转接到 local-cli上。

看看执行 react-native init AwesomeProject 的流程:

  1. 安装 react-native 依赖:在 AwesomeProject 目录执行 npm install react-native,安装 react-native 所有依赖的 node 模块。这是 init 命令第一个做的事情,代码在 react-native-cli/index.js -> run()
  2. 复制项目模板:安装依赖后 init 命令随即转接到 local-cli,通过 local-cli/generator 初始化项目,复制项目模板,模板文件在 local-cli/templates里。
  3. 链接 native 代码源文件:项目模板复制后需要把刚才安装的 node_module/react-native 里的源文件链接到 natvie 工程上,不同平台有不同逻辑,都在local-cli/link 里处理 native 工程的链接。iOS 处理逻辑在 local-cli/link/ios/

这一步骤处理后, AwesomeProject.xcodeproj 所需要的模块都链接完成,可以直接运行,可以看到工程 Libraries 里所有模块都是从 AwesomeProject/node_modules/react-native/ 里链接过来的。

react-native 模块依赖了 500 多个 npm 模块,这在前端届也算是正常,这些模块小部分是 RN 源码依赖的 JS 模块,大部分是用于前端构建,包括 JS 编译/打包/语法检测/http服务中间层等。

RN 模板项目创建过程大致就是这样。

II. 如何跑起来

在生成的 AwesomeProject 模板项目里,iOS 端所依赖的所有模块和源码直接可以在工程里看到。但 JS 端的源码在项目里只看到业务实现代码 index.ios.js ,XCode 项目跑起来后,index.ios.js 就执行生效了,RN 核心 JS 代码在哪里,有哪些,怎么跑起来的,都是个黑盒,接下来拆解下,看看 JS 代码是怎样运行起来的。

两种模式

RN 在 iOS 上对 JS 脚本的处理分两种模式:

  1. 本地 Server 模式。在本地自建一个 Server,客户端通过请求的方式获取 JS 代码。对于在模拟器跑 debug 版,会使用这种方式,用于接入 chrome 调试和脚本实时更新。
  2. 本地静态 bundle 模式。编译时就把所有相关 JS 文件打包编译到 APP 里,运行时直接本地读取。对于所有 release 版,或无法连接本地 Server 的 iPhone 真机上的 debug 版,会使用这种方式。

本地 Server 模式在下一节 chrome 调试再描述,这里先看看本地静态 bundle 模式。

本地静态 bundle

在本地静态 Bundle 模式中,最终所有 JS 代码都会打包成一个文件,客户端最终只需读取一个打包后的 JS 文件执行。这里从依赖分散的 JS 源文件,到最终可执行的单个 JS,有一个编译和打包 JS 的处理过程。

这套处理过程的启动是在主工程 AwesomeProject.xcodeproj Build Phases 里执行了一个脚本 node_modules/react-native/packager/react-native-xcode.sh,最终它在 Release 版或真机上执行了这样一条打包命令:

react-native bundle --entry-file index.ios.js --platform ios --dev true --reset-cache --bundle-output main.bundle --assets-dest assets

这个命令最终会输出一个 main.bundle 文件,实际是个 JS 文件,包含了 RN 所有核心代码和我们项目的业务代码(这里只有 index.ios.js)。

这个打包命令包含非常多处理,流程很长,算是整个 RN 部署工具的核心,主要实现在 react-native/packager 里,在这个生成静态 bundle 的流程里,主要做的事情是:

1. 编译/解析依赖

现代前端工程中,编译几乎已经是必须的了,这里编译主要做两件事:ES6 -> 通用JS,JSX -> JS。

RN 源码以及业务代码都是以 ES6 的语法去写,像 import xxx 这种写法在不支持 ES6 语法的 JS 引擎上是无法运行的,需要编译成 require('xxx')。此外像 JSX 这种在 JS 代码里嵌入 XML 标签的语法糖也需要编译成普通 JS 语法才能在 JS 引擎上运行,所以需要一个编译的过程。

此外需要把 JS 文件的依赖也解析出来,因为这涉及到对 JS 代码的解析,把 require('xxx') 语句解析出来,所以这部分也是在编译过程中处理。

这里统一用 Babel 这个库去做所有编译的工作。它的官网也说得很清楚它做了什么工作,除了编译,后续会提到的 SourceMap 也是用它生成,由 packager/src/JSTransformer 去封装编译解析后的数据。

解析依赖是在 packager/src/JSTransformer/worker/extract-dependencies.js,这里用 babel 解析出当前文件中 require 的内容后组装返回。编译是在 packager/src/JSTransformer/worker/worker.js 里。

2. 管理依赖、打包压缩

上述解析依赖仅提取了当前 JS 文件依赖的文件名,并没有做依赖文件查找/读取/拼装/更新等工作,这个工作在 packager/src/node-haste 里做,把一个个 JS 文件封装成一个个 Module,根据上述解析出来的依赖信息,去读取依赖文件,并递归检测依赖,直到所有依赖都加载完毕。

这里面还有层层处理,最终所有依赖模块会封装成一个 packager/src/Bundler,提供给 cli 命令行调用,打包压缩是小意思,在 local-cli/bundle.js 里处理了。

3. 请求执行

在本地静态 bundle 模式下,RN 最终会统一执行上述生成的 main.bundle,所有 JS 代码都在这里面,由 RCTBundleURLProvider.m 处理执行,整个 RN 应用就跑起来了。

main.bundle 里是合并后的 JS 代码,如果想要看这个 JS 文件合并之前是包括哪些 JS 源文件,可以在上述模块组装的过程中去打出每个模块的信息,例如在 packager/src/Bundler/Bundle.jsaddModule() 方法里加上 console.log(moduleTransport.sourcePath) 就能看到所有依赖的 JS 文件路径。另外通过下述 SourceMap 能更方便地看到。

代码流程

从 cli 命令 – 编译文件 – 解析依赖 – 组装数据 – 写入文件,这个过程在代码中实现流程很长,这里就不列出来了,大致涉及的几个文件的作用列一下:

local-cli/bundle/ - cli命令入口,传参,获取组装好的 Bundle 压缩/写入文件
packager/src/Bundler/Bundle.js - 保存 bundle 相关的所有模块信息/依赖/源码
packager/src/Bundler/index.js - 组装 Bundle 对象
packager/src/JSTransformer - babel 转接,编译 JS,解析依赖
packager/src/node-haste - 管理依赖 cache,把 JS 源文件模块封装成 Module 对象
packager/src/Resolver - JS 模块组装打包成一个文件并不只是直接把 JS 源码拼一起,还需要重新封装模块,处理引用逻辑,这块由 Resolver 处理。

III. 如何调试

为了要让客户端上的 RN JS 代码可以在 chrome 上调试,煞费苦心,做了三件事:

1. 本地 Server

为什么要搭建本地 Server,因为要用 chrome 的开发者工具调试 JS,就必须让 JS 在 chrome 中运行,搭建本地 Server 的方式可以让 chrome 以标准的方式访问 JS 文件执行并进行调试。

在模拟器运行时 RN 项目会自动启动一个本地 Server,让客户端用远程获取方式去请求本地 JS 文件:

  1. 在编译执行 React.xcodeproj 时,会走到 Build Phases 里定义的脚本,执行 ./node_module/react-native/packager/launchPackager.command
  2. 这个脚本新建一个控制台,执行 package.sh,最终实际是执行 local-cli/cli.js start 命令。
  3. local-cli/server 对外提供的命令就是 start,最终走到 local-cli/server/runServer.jsrunServer() 创建一个 http server,用 connect 框架,加上各种中间层处理。

这里客户端最终仍然只访问一个打包后的 JS 文件,这个 JS 文件的生成原理跟上一节的流程一样,只不过这个 JS 并没有实体文件,每次客户端向 Server 请求时,Server 动态执行编译/解析/打包流程直接输出结果给客户端,保证每次请求都是最新的代码,便于调试。在上一节描述的编译/解析/打包过程中每个模块都有缓存,并有缓存更新机制,所以这个过程并不会很慢。

iPhone 真机也可以用本地 Server 的方式运行和调试,只要在同一个网络环境下。在执行主工程 AwesomeProject.xcodeprojreact-native-xcode.sh 脚本时,会把当前网络 ip 地址写入一个文件,iPhone 真机 debug 运行程序时会去检测这个 ip 是否连得上,若连得上就用 Server 的方式,否则用读取本地 Bundle 的方式。这个处理逻辑在 RCTBundleURLProvider.m 里。

2. Executor

在 RN 里 JS 和 native 有一套通信机制(详见 React Native通信机制详解),这套通信机制不限制 JS 执行引擎,因为最终只是简单地传输字符串,JS 执行引擎可以是本地的 JavaScriptCore / UIWebView,也可以是远程的 chrome,让 JS 和 native 通过 websocket 通信。

实现上 RN 定义了 RCTJavaScriptExecutor 协议,实现了 RCTJSCExecutorRCTWebSocketExecutor 两种执行引擎,对应 JavaScriptCore 引擎和 WebSocket 远程引擎。

启动 Server 时会自动在 chrome 打开一个页面,JS 文件就在这个页面执行的,在执行到需要跟 native 通信交互时,会调用到 websocket 进行远程通信,这样就可以在页面上通过 chrome 自带的开发者工具断点调试 JS 了。

3. SourceMap

虽然 chrome 上可以调试 JS 了,但这里执行的 JS 是经过编译/打包/压缩后的 JS,与开发的代码差别很大,调试起来会很不方便,出错提示的代码和行数也与原代码不同。这种情况在前端中挺常见,于是出现了 SourceMap 技术去解这个问题。

SourceMap 就是在编译/合并/压缩代码的同时生成一份映射文件,可以让处理前的代码和处理后的所有代码位置都能一一映射,从而在执行处理后的 JS 代码时能映射到原 JS 代码,进行原代码断点调试和提供准确的错误信息。详细实现原理可以参考这里,这个技术早在2011年就提出,只是在前端越来越复杂的今天才应用越来越多。

在 chrome 调试 RN 代码时,调试工具 Source 上可以看到已经处理的 index.ios.bundle 文件,这是请求执行的唯一一个 JS 文件,但同时这里也可以看到所有 JS 源文件,并且可以在 JS 源文件上断点调试,这里都是通过 index.ios.bundle 最后一行 SourceMap 文件进行映射的://# sourceMappingURL=/index.ios.map?platform=ios&dev=true&minify=false

打开这个 /index.ios.map 文件,可以看到这样一个 JSON 文件:

{
    version: 3,
    sources: ['xxx.js', 'xxx.js', ...],
    sourceContent: [...],
    names: [...],
    mappings: '',
}

在 sources 里就可以清楚看到,我们的项目最终用到了哪些 JS 源文件。

最后

希望上述描述能把前面关于 RN 是怎样跑起来的三个问题大致说清楚,更多细节只能看代码了。虽然 RN 把这个过程封装起来,正常使用不用太多了解,但限制也很明显,最终直接执行一个大 js 文件,启动速度堪忧,若要优化就得了解这整个过程,再进行自定义。

RN 是前端主导的项目,上述基本上都是前端领域的东西,JS 编译实际上跟 Xcode 编译 OC 代码类似,编译成机器能识别的方式,合并成一个可执行文件,只不过前端编译没有一个统一的流程和标准,各个项目自己实现一套,了解起来挺费劲。

最后吐个槽,这一系列 RN 工具看起来是挺蛋疼的,逻辑绕来绕去,功能没封装好,依赖混乱,像 packager 有非常多直接以相对路径的方式依赖了上几层目录的某些文件,写得很 dirty,可能这种工具他们的要求是能跑就好。

评论

2017年7月31日 16:37

webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler: function(webView, message, frame, completionHandler) {

require(‘UIAlertController,UIAlertAction’)
var cm = block(‘BOOL’,completionHandler);
var alertController = UIAlertController.alertControllerWithTitle_message_preferredStyle(“提示”,message,1)

alertController.addAction((UIAlertAction.actionWithTitle_style_handler(“取消”, 1, block(‘UIAlertAction*’, function(action) {
cm(false)
}))))

alertController.addAction((UIAlertAction.actionWithTitle_style_handler(“确认”, 0, block(‘UIAlertAction*’, function(action) {
cm(true)
}))))

self.presentViewController_animated_completion(alertController, YES, null)
} JSPatch为什么翻译这一段在iOS10.3上会报错呢?

2017年7月31日 17:39

哇 压哨成功

2017年8月1日 9:31

感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/o35cwk 欢迎点赞支持!
欢迎订阅《iOS 以及等等》https://toutiao.io/subjects/183

2017年8月3日 18:30

不考虑用weex和vue吗? 什么原因考虑用React Native呢?

2017年8月10日 17:29

阿里自己的weex不用, 用RN?

2017年8月21日 20:19

分析的源码应该是v0.46以下的版本吧?v0.47源码变化挺大的,复杂了很多。

2017年10月10日 10:25

为什么我创建完,没有 index.ios.js 文件?

2017年11月1日 12:37

2017年11月1日 12:39

….

2019年2月27日 15:40

很不错的源码(流程)解析文章,篇幅不多,但是逻辑清晰,深入浅出。