React Native 启动速度优化——JS 篇(全网最全,值得收藏)

2145次阅读  |  发布于3年以前

前言

本文主要从 JavaScript 入手,总结了一些 JS 侧的优化要点。

1.JSEngine

rn_start_jsEngine

Hermes

Hermes 是 FaceBook 2019 年中旬开源的一款 JS 引擎,从 release[1] 记录可以看出,这个是专为 React Native 打造的 JS 引擎,可以说从设计之初就是为 Hybrid UI 系统打造。

Hermes 支持直接加载字节码,也就是说,BabelMinifyParseCompile 这些流程全部都在开发者电脑上完成,直接下发字节码让 Hermes 运行就行,这样做可以省去 JSEngine 解析编译 JavaScript 的流程,JS 代码的加载速度将会大大加快,启动速度也会有非常大的提升。

Hermes

更多关于 Hermes 的特性,大家可以看我的旧文[《移动端 JS 引擎哪家强》] 这篇文章,我做了更为详细的特性说明与数据对比,这里就不多说了。

2.JS Bundle

rn_start_jsBundle

前面的优化其实都是 Native 层的优化,从这里开始就进入 Web 前端最熟悉的领域了。

其实谈到 JS Bundle 的优化,来来回回就是那么几条路:

如果有 webpack 打包优化经验的小伙伴,看到上面的优化方式,是不是脑海中已经浮现出 webpack 的一些配置项了?不过 React Native 的打包工具不是 webpack 而是 Facebook 自研的 Metro[2],虽然配置细节不一样,但道理是相通的,下面我就这几个点讲讲 React Native 如何优化 JS Bundle。

2.1 减小 JS Bundle 体积

Metro 打包 JS 时,会把 ESM 模块转为 CommonJS 模块,这就导致现在比较火的依赖于 ESM 的 Tree Shaking 完全不起作用,而且根据官方回复[3],Metro 未来也不会支持 Tree Shaking :

(Tree Shaking 太 low 了,我们做了个更酷的 Hermes)

因为这个原因,我们减小 bundle 体积主要是三个方向:

下面我们举几个例子来解释上面的三个思路。

2.1.0 使用 react-native-bundle-visualizer 查看包体积

优化 bundle 文件前,一定要知道 bundle 里有些什么,最好的方式就是用可视化的方式把所有的依赖包列出来。web 开发中,可以借助 Webpack 的 webpack-bundle-analyzer 插件查看 bundle 的依赖大小分布,React Native 也有类似的工具,可以借助 react-native-bundle-visualizer[4] 查看依赖关系:

使用非常简单,按照文档安装分析就可。

2.1.1 moment.js 替换为 day.js

这是一个非常经典的例子。同样是时间格式化的第三方库, moment.js[5] 体积 200 KB,day.js[6] 体积只有 2KB,而且 API 与 moment.js 保持一致。如果项目里用了 moment.js,替换为 day.js 后可以立马减少 JSBundle 的体积。

2.1.2 lodah.js 配合 babel-plugin-lodash

lodash 基本上属于 Web 前端的工程标配了,但是对于大多数人来说,对于 lodash 封装的近 300 个函数,只会用常用的几个,例如 getchunk,为了这几个函数全量引用还是有些浪费的。

社区上面对这种场景,当然也有优化方案,比如说 lodash-es,以 ESM 的形式导出函数,再借助 Webpack 等工具的 Tree Sharking 优化,就可以只保留引用的文件。但是就如前面所说,React Native 的打包工具 Metro 不支持 Tree Shaking,所以对于 lodash-es 文件,其实还会全量引入,而且 lodash-es 的全量文件比 lodash 要大得多。

我做了个简单的测试,对于一个刚刚初始化的 React Native 应用,全量引入 lodash 后,包体积增大了 71.23KB,全量引入 lodash-es 后,包体积会扩大 173.85KB。

既然 lodash-es 不适合在 RN 中用,我们就只能在 lodash 上想办法了。lodash 其实还有一种用法,那就是直接引用单文件,例如想用 join 这个方法,我们可以这样引用:

// 全量
import { join } from 'lodash'

// 单文件引用
import join from 'lodash/join'

这样打包的时候就会只打包 lodash/join 这一个文件。

但是这样做还是太麻烦了,比如说我们要使用 lodash 的七八个方法,那我们就要分别 import 七八次,非常的繁琐。对于 lodash 这么热门的工具库,社区上肯定有高人安排好了,babel-plugin-lodash[7] 这个 babel 插件,可以在 JS 编译时操作 AST 做如下的自动转换:

import { join, chunk } from 'lodash'
// ⬇️
import join from 'lodash/join'
import chunk from 'lodash/chunk'

使用方式也很简单,首先运行 yarn add babel-plugin-lodash -D 安装,然后在 babel.config.js 文件里启用插件即可:

// babel.config.js

module.exports = {
  plugins: ['lodash'],
  presets: ['module:metro-react-native-babel-preset'],
};

我以 join 这个方法为例,大家可以看一下各个方法增加的 JS Bundle 体积:

全量 lodash 全量 loads-es lodash/join 单文件引用 lodash + babel-plugin-lodash
71.23 KB 173.85 KB 119 Bytes 119 Bytes

从表格可见 lodash 配合 babel-plugin-lodash 是最优的开发选择。

2.1.3 babel-plugin-import 的使用

babel-plugin-lodash 只能转换 lodash 的引用问题,其实社区还有一个非常实用的 babel 插件:babel-plugin-import[8],基本上它可以解决所有按需引用的问题

我举个简单的例子,阿里有个很好用的 ahooks[9] 开源库,封装了很多常用的 React hooks,但问题是这个库是针对 Web 平台封装的,比如说 useTitle 这个 hook,是用来设置网页标题的,但是 React Native 平台是没有相关的 BOM API 的,所以这个 hooks 完全没有必要引入,RN 也永远用不到这个 API。

这时候我们就可以用 babel-plugin-import 实现按需引用了,假设我们只要用到 useInterval 这个 Hooks,我们现在业务代码中引入:

import { useInterval } from 'ahooks'

然后运行 yarn add babel-plugin-import -D 安装插件,在 babel.config.js 文件里启用插件:

// babel.config.js

module.exports = {
  plugins: [
    [
      'import',
      {
        libraryName: 'ahooks',
        camel2DashComponentName: false, // 是否需要驼峰转短线
        camel2UnderlineComponentName: false, // 是否需要驼峰转下划线
      },
    ],
  ],
  presets: ['module:metro-react-native-babel-preset'],
};

启用后就可以实现 ahooks 的按需引入:

import { useInterval } from 'ahooks'
// ⬇️
import useInterval from 'ahooks/lib/useInterval'

下面是各种情况下的 JSBundle 体积增量,综合来看 babel-plugin-import 是最优的选择:

全量 ahooks ahooks/lib/useInterval 单文件引用 ahooks + babel-plugin-import
111.41 KiB 443 Bytes 443 Bytes

当然,babel-plugin-import 可以作用于很多的库文件,比如说内部/第三方封装的 UI 组件,基本上都可以通过babel-plugin-import 的配置项实现按需引入。若有需求,可以看网上其他人总结的使用经验,我这里就不多言了。

2.1.4 babel-plugin-transform-remove-console

移除 console 的 babel 插件也很有用,我们可以配置它在打包发布的时候移除 console 语句,减小包体积的同时还会加快 JS 运行速度,我们只要安装后再简单的配置一下就好了:

// babel.config.js

module.exports = {
    presets: ['module:metro-react-native-babel-preset'],
    env: {
        production: {
            plugins: ['transform-remove-console'],
        },
    },
};

2.1.5 制定良好的编码规范

编码规范的最佳实践太多了,为了切合主题(减少代码体积),我就随便举几点:

说实话这几个优化其实减少不了几 KB 的代码,更大的价值在于提升项目的健壮性和可维护性

2.2 Inline Requires

Inline Requires 可以理解为懒执行,注意我这里说的不是懒加载,因为一般情况下,RN 容器初始化之后会全量加载解析 JS Bundle 文件,Inline Requires 的作用是延迟运行,也就是说只有需要使用的时候才会执行 JS 代码,而不是启动的时候就执行。React Native 0.64 版本里,默认开启了 Inline Requires

首先我们要在 metro.config.js 里确认开启了 Inline Requires 功能:

// metro.config.js

module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true, // <-- here
      },
    }),
  },
};

其实 Inline Requires 的原理非常简单,就是把 require 导入的位置改变了一下。

比如说我们写了个工具函数 join 放在 utils.js 文件里:

// utils.js

export function join(list, j) {
  return list.join(j);
}

然后我们在 App.js 里 import 这个库:

// App.js

import { join } from 'my-module';

const App = (props) => {
  const result = join(['a', 'b', 'c'], '~');

  return <Text>{result}</Text>;
};

上面的写法,被 Metro 编译后,相当于编译成下面的样子:

const App = (props) => {
  const result = require('./utils').join(['a', 'b', 'c'], '~');

  return <Text>{result}</Text>;
};

实际编译后的代码其实长这个样子:

rn_start_inlineRequire

上图红线中的 r() 函数,其实是 RN 自己封装的 require() 函数,可以看出 Metro 自动把顶层的 import 移动到使用的位置。

值得注意的是,Metro 的自动 Inline Requires 配置,目前是不支持export default 导出的,也就是说,如果你的 join 函数是这样写的:

export default function join(list, j) {
  return list.join(j);
}

导入时是这样的:

import join from './utils';

const App = (props) => {
  const result = join(['a', 'b', 'c'], '~');

  return <Text>{result}</Text>;
};

Metro 编译转换后的代码,对应的 import 还是处于函数顶层

rn_start_require

这个需要特别注意一下,社区也有相关的文章,呼吁大家不要用 export default 这个语法,感兴趣的可以了解一下:

深入解析 ES Module(一):禁用 export default object[11]

深入解析 ES Module(二):彻底禁用 default export[12]

2.3 JSBundle 分包加载

分包的场景一般出现在 Native 为主,React Native 为辅的场景里。这种场景往往是这样的:

大家从上面的例子里可以看出,600KB 的基础包在多条业务线里是重复的,完全没有必要多次下载和加载,这时候一个想法自然而然就出来了:

把一些共有库打包到一个 common.bundle 文件里,我们每次只要动态下发业务包 businessA.bundlebusinessB.bundle,然后在客户端实现先加载 common.bundle 文件,再加载 business.bundle 文件就可以了

这样做的好处有几个:

顺着上面的思路,上面问题就会转换为两个小问题:

2.3.1 JS Bundle 拆包

拆包之前要先了解一下 Metro 这个打包工具的工作流程。Metro 的打包流程很简单,只有三个步骤:

从上面流程可以看出,我们的拆包步骤只会在 Serialization 这一步。我们只要借助 Serialization 暴露的各个方法就可以实现 bundle 分包了。

正式分包前,我们先抛开各种技术细节,把问题简化一下:对于一个全是数字的数组,如何把它分为偶数数组和奇数数组?

这个问题太简单了,刚学编程的人应该都能想到答案,遍历一遍原数组,如果当前元素是奇数,就放到奇数数组里,如果是偶数,放偶数数组里。

Metro 对 JS bundle 分包其实是一个道理。Metro 打包的时候,会给每个模块设置 moduleId,这个 id 就是一个从 0 开始的自增 number。我们分包的时候,公有的模块(例如 react``react-native)输出到 common.bundle,业务模块输出到 business.bundle 就行了。

因为要兼顾多条业务线,现在业内主流的分包方案是这样的:

**1.**先建立一个 common.js 文件,里面引入了所有的公有模块,然后 Metro 以这个 common.js 为入口文件,打一个 common.bundle 文件,同时要记录所有的公有模块的 moduleId

// common.js

require('react');
require('react-native');
......

2.对业务线 A 进行打包,Metro 的打包入口文件就是 A 的项目入口文件。打包过程中要过滤掉上一步记录的公有模块 moduleId,这样打包结果就只有 A 的业务代码了

// indexA.js

import {AppRegistry} from 'react-native';
import BusinessA from './BusinessA';
import {name as appName} from './app.json';

AppRegistry.registerComponent(appName, () => BusinessA);

**3.**业务线 B C D E...... 打包流程同业务线 A

上面的思路看起来很美好,但是还是存在一个问题:每次启动 Metro 打包的时候,moduleId 都是从 0 开始自增,这样会导致不同的 JSBundle ID 重复

为了避免 id 重复,目前业内主流的做法是把模块的路径当作 moduleId(因为模块的路径基本上是固定且不冲突的),这样就解决了 id 冲突的问题。Metro 暴露了 createModuleIdFactory 这个函数,我们可以在这个函数里覆盖原来的自增 number 逻辑:

module.exports = {
  serializer: {
    createModuleIdFactory: function () {
      return function (path) {
        // 根据文件的相对路径构建 ModuleId
        const projectRootPath = __dirname;
        let moduleId = path.substr(projectRootPath.length + 1);
        return moduleId;
      };
    },
  },
};

整合一下第一步的思路,就可以构建出下面的 metro.common.config.js 配置文件:

// metro.common.config.js

const fs = require('fs');

module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
  serializer: {
    createModuleIdFactory: function () {
      return function (path) {
        // 根据文件的相对路径构建 ModuleId
        const projectRootPath = __dirname;
        let moduleId = path.substr(projectRootPath.length + 1);

        // 把 moduleId 写入 idList.txt 文件,记录公有模块 id
        fs.appendFileSync('./idList.txt', `${moduleId}\n`);
        return moduleId;
      };
    },
  },
};

然后运行命令行命令打包即可:

# 打包平台:android
# 打包配置文件:metro.common.config.js
# 打包入口文件:common.js
# 输出路径:bundle/common.android.bundle

npx react-native bundle --platform android --config metro.common.config.js --dev false --entry-file common.js --bundle-output bundle/common.android.bundle

通过以上命令的打包,我们可以看到 moduleId 都转换为了相对路径,并且 idList.txt 也记录了所有的 moduleId:

common.android.bundle idList.js

第二步的关键在于过滤公有模块的 moduleId,Metro 提供了 processModuleFilter 这个方法,借助它可以实现模块的过滤。具体的逻辑可见以下代码:

// metro.business.config.js

const fs = require('fs');

// 读取 idList.txt,转换为数组
const idList = fs.readFileSync('./idList.txt', 'utf8').toString().split('\n');

function createModuleId(path) {
  const projectRootPath = __dirname;
  let moduleId = path.substr(projectRootPath.length + 1);
  return moduleId;
}

module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
  serializer: {
    createModuleIdFactory: function () {
      // createModuleId 的逻辑和 metro.common.config.js 完全一样
      return createModuleId;
    },
    processModuleFilter: function (modules) {
      const mouduleId = createModuleId(modules.path);

      // 通过 mouduleId 过滤在 common.bundle 里的数据
      if (idList.indexOf(mouduleId) < 0) {
        console.log('createModuleIdFactory path', mouduleId);
        return true;
      }
      return false;
    },
  },
};

最后运行命令行命令打包即可:

# 打包平台:android
# 打包配置文件:metro.business.config.js
# 打包入口文件:index.js
# 输出路径:bundle/business.android.bundle

npx react-native bundle --platform android --config metro.business.config.js --dev false --entry-file index.js --bundle-output bundle/business.android.bundle

最后的打包结果只有 11 行(不分包的话得 398 行),可以看出分包的收益非常大。

business.android.bundle

当然使用相对路径作为 moduleId 打包时,不可避免的会导致包体积变大,我们可以使用 md5 计算一下相对路径,然后取前几位作为最后的 moduleId;或者还是采用递增 id,只不过使用更复杂的映射算法来保证 moduleId 的唯一性和稳定性。这部分的内容其实属于非常经典的 Map key 设计问题,感兴趣的读者可以了解学习一下相关的算法理论知识。

2.3.2 Native 实现多 bundle 加载

分包只是第一步,想要展示完整正确的 RN 界面,还需要做到「合」,这个「合」就是指在 Native 端实现多 bundle 的加载。

common.bundle 的加载比较容易,直接在 RN 容器初始化的时候加载就好了。容器初始化的流程上一节我已经详细介绍了,这里就不多言了。这时候问题就转换为 business.bundle 的加载问题。

React Native 不像浏览器的多 bundle 加载,直接动态生成一个 <script /> 标签插入 HTML 中就可以实现动态加载了。我们需要结合具体的 RN 容器实现来实现 business.bundle 加载的需求。这时候我们需要关注两个点:

  1. 时机:什么时候开始加载?
  2. 方法:如何加载新的 bundle?

对于第一个问题,我们的答案是 common.bundle 加载完成后再加载 business.bundle

common.bundle 加载完成后,iOS 端会发送事件名称是 RCTJavaScriptDidLoadNotification 的全局通知,Android 端则会向 ReactInstanceManager 实例中注册的所有 ReactInstanceEventListener 回调 onReactContextInitialized() 方法。我们在对应事件监听器和回调中实现业务包的加载即可。

对于第二个问题,iOS 我们可以使用 RCTCxxBridge 的 executeSourceCode 方法在当前的 RN 实例上下文中执行一段 JS 代码,以此来达到增量加载的目的。不过值得注意的是,executeSourceCode 是 RCTCxxBridge 的私有方法,需要我们用 Category 将其暴露出来。

Android 端可以使用刚刚建立好的 ReactInstanceManager 实例,通过 getCurrentReactContext() 获取到当前的 ReactContext 上下文对象,再调用上下文对象的 getCatalystInstance() 方法获取媒介实例,最终调用媒介实例的 loadScriptFromFile(String fileName, String sourceURL, boolean loadSynchronously) 方法完成业务 JSBundle 的增量加载。

iOS 和 Android 的示例代码如下:

NSURL *businessBundleURI = // 业务包 URI
NSError *error = nil;
NSData *sourceData = [NSData dataWithContentsOfURL:businessBundleURI options:NSDataReadingMappedIfSafe error:&error];
if (error) { return }
[bridge.batchedBridge executeSourceCode:sourceData sync:NO]
ReactContext context = RNHost.getReactInstanceManager().getCurrentReactContext();
CatalystInstance catalyst = context.getCatalystInstance();
String fileName = "businessBundleURI"
catalyst.loadScriptFromFile(fileName, fileName, false);

本小节的示例代码都属于 demo 级别,如果想要真正接入生产环境,需要结合实际的架构和业务场景做定制。有一个 React Native 分包仓库 react-native-multibundler[13] 内容挺不错的,大家可以参考学习一下。

3.Network

rn_start_network

我们一般会在 React Component 的 componentDidMount() 执行后请求网络,从服务器获取数据,然后再改变 Component 的 state 进行数据的渲染。

网络优化是一个非常庞大非常独立的话题,有非常多的点可以优化,我这里列举几个和首屏加载相关的网络优化点:

由于网络这里相对来说比较独立,iOS/Android/Web 的优化经验其实都可以用到 RN 上,这里按照大家以往的优化经验来就可以了。

4.Render

rn_start_render

渲染这里的耗时,基本上和首屏页面的 UI 复杂度成正相关。可以通过渲染流程查看哪里会出现耗时:

我们可以在代码里开启 MessageQueue 监视,看看 APP 启动后 JS Bridge 上面有有些啥:

// index.js

import MessageQueue from 'react-native/Libraries/BatchedBridge/MessageQueue'
MessageQueue.spy(true);

rn_start_MessageQueue

从图片里可以看出 JS 加载完毕后有大量和 UI 相关的 UIManager.createView()``UIManager.setChildren() 通讯,结合上面的耗时总结,我们对应着就有几条解决方案:

上面的这些技巧我都在旧文[《React Native 性能优化指南——渲染篇》] 里做了详细的解释,这里就不多解释了。

Fraic

从上面的我们可以看出,React Native 的渲染需要在 Bridge 上传递大量的 JSON 数据,在 React Native 初始化时,数据量过大会阻塞 bridge,拖慢我们的启动和渲染速度。React Native 新架构中的 Fraic 就能解决这一问题,JS 和 Native UI 不再是异步的通讯,可以实现直接的调用,可以大大加速渲染性能。

Fraic 可以说是 RN 新架构里最让人期待的了,想了解更多内容,可以去官方 issues [14]区围观。

总结

本文主要从 JavaScript 的角度出发,分析了 Hermes 引擎的特点和作用,并总结分析了 JSBundle 的各种优化手段,再结合网络和渲染优化,全方位提升 React Native 应用的启动速度。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8