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

  • Post author:
  • Post category:IT
  • Post comments:0评论

最近工作需要,重新看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.js的addModule()方法里加上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.js–runServer()创建一个 http server,用 connect 框架,加上各种中间层处理。

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

iPhone 真机也可以用本地 Server 的方式运行和调试,只要在同一个网络环境下。在执行主工程AwesomeProject.xcodeproj的react-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协议,实现了RCTJSCExecutor和RCTWebSocketExecutor两种执行引擎,对应 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,可能这种工具他们的要求是能跑就好。

发表回复