React Native中实现动态导入的示例代码

目录
  • 背景
    • 多业务包
    • 动态导入
  • Metro 打包原理
    • 打包过程
    • bundle 分析
    • __d函数
    • __r函数
  • 方案设计
    • 识别入口
    • 树拆分
    • bundle 生成
  • 总结

背景

随着业务的发展,每一个 React Native 应用的代码数量都在不断增加,bundle 体积不断膨胀,对应用性能的负面影响愈发明显。虽然我们可以通过 React Native 官方工具 Metro 进行拆包处理,拆分为一个基础包和一个业务包进行一定程度上的优化,但对日益增长的业务代码也无能为力,我们迫切地需要一套方案来减小我们 React Native 应用的体积。

多业务包

第一个想到的就是拆分多业务包,既然拆分为一个业务包不够,那我多拆为几个业务包不就可以了。当一个 React Native 应用拆分为多个业务包之后其实就相当于拆分为多个应用了,只不过代码在同一仓库里。这虽然可以解决单个应用不断膨胀的问题,但是有不少局限性。接下来一一分析:

  • 链接替换,不同的应用需要不同的地址,替换成本较高。
  • 页面之间通信,之前是个单页应用,不同页面之间可以直接通信;拆分之后是不同应用相互通信需要借助客户端桥接实现。
  • 性能损耗,打开每个拆分的业务包都需要单独起一个 React Native 容器,容器初始化、维持都需要消耗内存、占用CPU。
  • 粒度不够,最小的维度也是页面,无法继续对页面中的组件进行拆分。
  • 重复打包,部分在不同页面之间共享的工具库,每个业务包都会包含。
  • 打包效率,每一个业务包的打包过程,都要经过一遍完整的 Metro 打包过程,拆分多个业务包打包时间成倍增加。

动态导入

作为一个前端想到的另一方案自然就是动态导入(Dynamic import)了,基于其动态特性对于多业务包的众多缺点,此方案都可避免。此外拥有了动态导入我们就可以实现页面按需加载,组件懒加载等等能力。但是 Metro 官方并不支持动态导入,因此需要对 Metro 进行深度定制,这也是本文即将介绍的在 React Native 中实现动态导入。

Metro 打包原理

在介绍具体方案之前我们先看下 Metro 的打包机制及其构建产物。

打包过程

如下图所示Metro打包会经过三个阶段,分别是 Resolution、Transformation、Serialization。

image

Resolution 的作用是从入口开始构建依赖图;Transformation 是和 Resolution 阶段同时执行的,其目的是将所有 module(一个模块就是一个 module ) 转换为目标平台可识别语言,这里面既有高级 JavaCript 语法的转换(依赖 BaBel),也有对特定平台,比如安卓的特殊 polyfills。这两个阶段主要是生产中间产物 IR 为最后一阶段所消费。

Serialization 则是将所有 module 组合起来生成 bundle,这里需要特别注意 Metro API 文档中 Serializer Options 中的两个配置:

  • 签名为 createModuleIdFactory, type 为 () => (path: string) => number。 这个函数为每个 module 生成一个唯一的 moduleId,默认情况下是自增的数字。所有的依赖关系都依仗此 moduleId。
  • 签名为 processModuleFilter, type 为 (module: Array) => boolean。这个函数用来过滤模块,决定是否打入 bundle。

bundle 分析

一个 React Native 典型的 bundle 从上到下可以分为三个部分:

  • 第一部分为 polyfills,主要是一些全局变量如 DEV;以及通过 IIFE 声明的一些重要全局函数,如: __d、 __r 等;
  • 第二部分是各个 module 的定义,以 __d 开头,业务代码全部在这一块;
  • 第三部分是应用的初始化 __r(react-native/Libraries/Core/InitializeCore.js moduleId) 和 __r(${入口 moduleId})
    我们看下具体函数的分析

__d函数

function define(factory, moduleId, dependencyMap) {
    const mod = {
        dependencyMap,
        factory,
        hasError: false,
        importedAll: EMPTY,
        importedDefault: EMPTY,
        isInitialized: false,
        publicModule: {
            exports: {}
        }
    };
    modules[moduleId] = mod;
}

__d 其实就是 define 函数,可以看到其实现很简单,做的就是声明一个 mode,同时 moduleId 与 mode 做了一层映射,这样通过 moduleId 就可以拿到 module 实现。我们看下 __d 如何使用:

__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
    var _reactNative = _$$_REQUIRE(_dependencyMap[0], "react-native");

    var _reactNavigation = _$$_REQUIRE(_dependencyMap[1], "react-navigation");

    var _reactNavigationStack = _$$_REQUIRE(_dependencyMap[2], "react-navigation-stack");

    var _routes = _$$_REQUIRE(_dependencyMap[3], "./src/routes");

    var _appJson = _$$_REQUIRE(_dependencyMap[4], "./appJson.json");

    var AppNavigator = (0, _reactNavigationStack.createStackNavigator)(_routes.RouteConfig, (0, _routes.InitConfig)());
    var AppContiner = (0, _reactNavigation.createAppContainer)(AppNavigator);

    _reactNative.AppRegistry.registerComponent(_appJson.name, function () {
        return AppContiner;
    });
}, 0, [1, 552, 636, 664, 698], "index.android.js");

这是 __d 的唯一用处,定义一个 module。这里解释下入参,第一个是个函数,就是 module 的工厂函数,所有的业务逻辑都在这里面,其是在 __r 之后调用的;第二个是 moduleId,模块的唯一标识;第三部分是其依赖的模块的 moduleId;第四个是此模块的文件名称。

__r函数

function metroRequire(moduleId) {

    ...

    const moduleIdReallyIsNumber = moduleId;
    const module = modules[moduleIdReallyIsNumber];
    return module && module.isInitialized
        ? module.publicModule.exports
        : guardedLoadModule(moduleIdReallyIsNumber, module);
}

function guardedLoadModule(moduleId, module) {

    ...

    return loadModuleImplementation(moduleId, module);
}

function loadModuleImplementation(moduleId, module) {

    ...

    const moduleObject = module.publicModule;
    moduleObject.id = moduleId;
    factory(
        global,
        metroRequire,
        metroImportDefault,
        metroImportAll,
        moduleObject,
        moduleObject.exports,
        dependencyMap
    );
    return moduleObject.exports;

    ...
}

__r 其实就是 require 函数。如上精简后的代码所示,require 方法首先判断所要加载的模块是否已经存在并初始化完成,若是则直接返回模块,否则调用 guardedLoadModule 方法,最终调用的是 loadModuleImplementation 方法。loadModuleImplementation 方法获得模块定义时传入的 factory 方法并调用,最后返回。

方案设计

基于以上对 Metro 工作原理及其产物 bundle 的分析,我们可以大致得出这样一个结论:React Native 启动时,JS 测(即 bundle)会先初始化一些变量,接着通过 IIFE 声明核心方法 define 和 require;接着通过 define 方法定义所有的模块,各个模块的依赖关系通moduleId 维系,维系的纽带就是 require;最后通过 require 应用的注册方法实现启动。

实现动态导入自然需要将目前的 bundle 进行重新拆分和组合,整个方案的关键点在于:分和合,分就是 bundle 如何拆分,什么样的 module 需要拆分出去,什么时候进行拆分,拆分之后的 bundle 存储在哪里(涉及到后续如何获取);合就是拆出去的 bundle 如何获取,并在获取之后仍在正确的上下文内执行。

前面有说过 Metro 工作的三个阶段,其中之一就是 Resolution,这一阶段的主要任务是从入口开始构建整个应用依赖图,这里为了方便示意以树来代替。

image

识别入口

如上所示就是一个依赖树,正常情况下会打出一个 bundle,包含模块 A、B、C、D、E、F、G。现在我想对模块 B 和 F 做动态导入。怎么做呢第一步当然是标识,既然叫动态导入自然而然的想到了 JavaScript 语法上的动态导入。

只需要将 import A from '.A' 改成 const A = import('A') 即可,这就需要引入 Babel 插件()了,事实上官方 Metro 相关配置包 metro-config 已经集成了此插件。官方做的不仅仅于此,在 Transformation 阶段还对采用动态导入的 module 增加了唯一标识 Async = true

此外在最终产物 bundle 上 Metro 提供了一个名叫 AsyncRequire.js 的文件模版来做动态导入的语法的 polyfill,具体实现如下

const dynamicRequire = require;

module.exports = function(moduleID) {
    return Promise.resolve().then(() => dynamicRequire.importAll(moduleID));
};

总结一下 Metro 默认会如何处理动态导入:在 Transformation 通过 Babel 插件处理动态导入语法,并在中间产物上增加标识 Async,在 Serialization 阶段用 Asyncrequire.js 作为模板替换动态导入的语法,即

const A = import(A);

//变为

const A = function(moduleID) {
    return Promise.resolve().then(() => dynamicRequire.importAll(moduleID));
};

Asyncrequire.js 不仅关乎我们如何拆分,还和我们最后的合息息相关,留待后续再谈。

树拆分

通过上文我们知道构建过程中会生成一颗依赖树,并对其中使用动态的导入的模块做了标识,接下来就是树如何进行拆分了。对于树的通用处理办法就是 DFS,通过对上图依赖树做 DFS 分析之后可以得到如下做了拆分的树,包含一颗主树和两颗异步树。对于每棵树的依赖进行收集即可得到如下三组 module 集合:A、E、C;B、D、E、G;F、G。

image

当然在实际场景中,各个模块的依赖远比这个复杂,甚至存在循环依赖的情况,在做 DFS 的过程中需要遵循两个原则:

  • 已经在处理过的 module,后续遇到直接退出循环
  • 各个异步树依赖的非主树 module 都需要包含进来

bundle 生成

通过这三组 module 集合即可得到三个bundle(我们将主树生成的 bundle 称为主 bundle;异步树生成的称为异步 bundle)。至于如何生成,直接借助前文提到的 Metro 中 processBasicModuleFilter 方法即可。Metro 原本在一次构建过程中,只会经过一次 Serialization 阶段生成一个 bundle。现在我们需要对每一组 module 都进行一次 bundle 生成。

这里需要注意几个问题:

  • 去重,一种是已经打入主 bundle 的 module 异步 bundle 不需要打入;一种是同时存在于不同异步树内的 module,对于这种 module,我们可以将其标记为动态导入单独打包,见下图

image

  • 生成顺序,需要先生成异步 bundle,再生成主 bundle。因为需要将异步 bundle 的信息(比如文件名称、地址)与 moduleId 做映射填入主 bundle,这样在真正需要的时候可以通过 moduleId 的映射拿到异步 bundle 的地址信息。
  • 缓存控制,为了保证每个异步 bundle 在能够享受缓存机制的同时能够及时更新,需要对异步 bundle 做 content hash 添加到文件名上
  • 存储,异步 bundle 如何存储,是和主 bundle 一起,还是单独存储,需要时再去获取呢。这个需要具体分析:对于采用了bundle 预加载的可以将异步 bundle 和主 bundle 放到一起,需要时直接从本地拿即可(所谓预加载就是在客户端启动时就已经将所有 bundle 下载下来了,在用户打开 React Native 页面时无需再去下载 bundle)。对于大部分没有采用预加载技术的则分开存储更合适。

至此我们已经获得了主 bundle 和异步 bundle,大致结构如下:

/* 主 bundle */

// moduleId 与 路径映射
var REMOTE_SOURCE_MAP = {${id}: ${path}, ... }

// IIFE __r 之类定义
(function (global) {
  "use strict";
  global.__r = metroRequire;
  global.__d = define;
  global.__c = clear;
  global.__registerSegment = registerSegment;
  ...
})(typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this);

//  业务模块
__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
  var _reactNative = _$$_REQUIRE(_dependencyMap[0], "react-native");
  var _asyncModule = _$$_REQUIRE(_dependencyMap[4], "metro/src/lib/bundle-modules/asyncRequire")(_dependencyMap[5], "./asyncModule")
  ...
},0,[1,550,590,673,701,855],"index.ios.js");

...

// 应用启动
__r(91);
__r(0);

/* 异步 bundle */

// 业务模块
__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
  var _reactNative = _$$_REQUIRE(_dependencyMap[0], "react-native");
  ...
},855,[956, 1126],"asyncModule.js");

大部分工作其实在分这一阶段已经做完了,接下来就是如何合了,前面有提到过动态导入的语法在生成的 bundle 中会被 AsyncRequire.js 中的模板所替代。仔细研究下其代码发现其是用 Promise 包裹了一层 require(moduleId) 来实现。

现在我们直接 require(moduleId) 必然是拿不到真正的 module 实现了,因为异步 bundle 还没有获取到,module 还没有定义。但可以对 AsyncRequire.js 做如下改造

const dynamicRequire = require;
module.exports = function (moduleID) {
    return fetch(REMOTE_SOURCE_MAP[moduleID]).then(res => {  // 行1
        new Function(res)();                                 // 行2
        return dynamicRequire.importAll(moduleID)            // 行3
    });
};

接下来一行行进行分析

  • 行1将之前 mock 的 Promise 替换为真正的 Promise 请求,先去获取 bundle 资源,REMOTE_SOURCE_MAP 是在生成阶段写入主 bundle 的 moduleId 与异步 bundle 资源地址的映射。fetch 根据异步 bundle 的存储方式的不同选择不同的方式获取真正的代码资源;
  • 行2通过 Function 方法执行获取到的代码,即是模块的声明,这样最后返回 module 的时候就已经是定义过的了;
  • 行3 返回真正的模块实现。
    这样我们就实现了合,异步 bundle 的获取、执行就都在 AsyncRequire.js 内完成了。

总结

至此我们就完成了 React Native 动态导入的改造。相对于多业务包,因为其动态特性使得业务方使用的时候所有修改都在同一个 React Native 应用内部闭环完成,外部无感知,多业务包的众多缺陷也就不存在了。与此同时构建时会充分利用第一次的生产的 IR,这样每一个 bundle 不需要再单独走 Metro 的完整构建流程。

当然有一点是必须需要考虑的,那就是我们对 Metro 进行改造之后,对于后续的升级是否有影响,导致只能锁定 React Native 和 Metro 版本。这个其实完全不用担心,从前面的分析可以知道,我们对于整个流程的改造可以分为两部分:构建时、运行时。在构建时我们确实新增了不少能力,比如新的分组算法、代码生成;但是运行时则是完全基于现有版本能力的增强。这就使得动态导入的运行时无兼容性问题,即使升级到新版本依然不会报错,只不过再我们再次改造构建时之前失去了动态导入的能力。

最后真正在生产环境上使用还有一些工程上的改造,比如:构建平台适配、提供快速接入组件等等限于篇幅就不在此详述了。

到此这篇关于React Native中实现动态导入的示例代码的文章就介绍到这了,更多相关React Native动态导入内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • React Native项目框架搭建的一些心得体会

    React Native 是Facebook于2015年4月开源的跨平台移动应用开发框架, 短短的一两年的发展就已经有很多家公司支持并采用此框架来搭建公司的移动端的应用, React Native使你能够在Javascript和React的基础上获得完全一致的开发体验,构建世界一流的原生APP. 项目框架与项目结构 1. 项目中使用的技术栈 react native.react hook.typescript.immer.tslint.jest等. 都是比较常见的,就不多做介绍了 2. 数据处理

  • 搭建React Native热更新平台的详细过程

    目录 一,什么是热更新 二,热更新方案 三,热更新原理 四,CDN 热更新方案 五,纯 CDN 方案的弊端 六,线上方案 七.整体方案梳理 八,总结 一,什么是热更新 所谓热更新,也叫做动态更新,一种类似 Web 的更新方式.相对于 App 的发版更新而言,热更新能及时的修复线上存在的问题,大幅的提升业务迭代效率.我们都知道,互联网业务讲究兵贵神速,如果业务能够通过热更新来快速发版和迭代,这就相当于在产品和用户之间搭建了一座能够随时通行的桥梁,代替了原来好几周才能有一次迭代的问题. 那么,热更新

  • React Native 启动流程详细解析

    导读:本文以 react-native-cli 创建的示例工程(安卓部分)为例,分析 React Native 的启动流程. 工程创建步骤可以参考官网.本文所分析 React Native 版本为 v0.64.2. 我们知道上述工程是一个安卓应用,打开 android/ 目录下源码文件,首先发现它创建了两个 java 文件:MainApplication.java 和 MainActivity.java,分别做了应用以及主 Activity 的定义. 安卓应用的启动流程是:在启动第一个 acti

  • react-native动态切换tab组件的方法

    在APP中免不了要使用tab组件,有的是tab切换,也有的是tab分类切换. 这些组件分成下面两种. 第一种非常简单,同时大多数第三方组件都能达到效果.这里重点讲述第二种,我们要让第二种组件不仅能左右滑动,同时还能够在点击的时候自动滑动,将点击的位置滑动到正中间. 准备 我们先来分析一波.一个滑动组件在APP上是一种什么状态. 这里可以看出,tab组件需要考虑到长度超过APP的屏幕,并且在超过之后能够滑动. 同时计算出当前位置需要滑动多少距离才能够将位置居中. 需要滑动的位置=点击位置的左边距-

  • React Native Popup实现示例

    目录 具体实现 使用方法 React Native 官方提供了 Modal 组件,但 Modal 是属于全屏的弹出层,当 Modal 显示时,操作区域只有 Modal 里的元素,而且焦点会被 Modal 劫持.虽然移动端不常见,但有些场景还是希望可以用轻量级一点的 Popup. 在 React Native 里,元素的层级是不可以被穿透的,子元素无论如何都不能遮挡父元素.所以选择了在顶层添加 Popup,设置绝对定位,显示时根据指定元素来动态调整 Popup 的位置的方案. 具体实现 Popup

  • React Native中实现动态导入的示例代码

    目录 背景 多业务包 动态导入 Metro 打包原理 打包过程 bundle 分析 __d函数 __r函数 方案设计 分 识别入口 树拆分 bundle 生成 合 总结 背景 随着业务的发展,每一个 React Native 应用的代码数量都在不断增加,bundle 体积不断膨胀,对应用性能的负面影响愈发明显.虽然我们可以通过 React Native 官方工具 Metro 进行拆包处理,拆分为一个基础包和一个业务包进行一定程度上的优化,但对日益增长的业务代码也无能为力,我们迫切地需要一套方案来

  • React Native 中实现确认码组件示例详解

    目录 正文 实现原理 开源方案 正文 确认码控件也是一个较为常见的组件了,乍一看,貌似较难实现,但实则主要是障眼法. 实现原理 上图 CodeInput 组件的 UI 结构如下: <View style={[styles.container]}> <TextInput autoFocus={true} /> <View style={[styles.cover, StyleSheet.absoluteFillObject]} pointerEvents="none&

  • React Native中Navigator的使用方法示例

    前言 众所周知在React Native中如何实现页面的跳转,这是一个突破点也是一个难点,想让我们的页面切换起来动起来应该是每一个初学者所追求的,那么在RN中实现这样的功能那必须要了解Navigator的用法了.Navigator是React Native自带的组件,不需要导入第三方组件,下面就来具体说明如何使用. 方法如下 首先,我们最好不要把index.ios.js和index.android.js文件写的很冗余很多代码,可以仅仅将其当做是一个工程的入口,好,废话不多说,开始上代码. 1.习

  • React Native 中限制导入某些组件和模块的方法

    目录 限制使用 Touchable 限制使用 Image 同时限制两者 示例 有些时候,我们不希望使用某些组件,而是想使用其他组件.这时候,我们可以使用一个名为 no-restricted-imports 的eslint 规则,该规则允许你指定一个或多个组件的名称,以防止使用这些组件. no-restricted-imports 是 eslint 自带的一个规则,我们不需要额外引入一个插件. 限制使用 Touchable 假如我们希望小伙伴们不要使用 Touchable 系列组件,而是使用 Pr

  • React Native中ScrollView组件轮播图与ListView渲染列表组件用法实例分析

    本文实例讲述了React Native中ScrollView组件轮播图与ListView渲染列表组件用法.分享给大家供大家参考,具体如下: 1.Scroll View ScrollView是React Native提供的滚动视图组件,渲染一组视图,用户可以进行滑动响应交互,其常用属性如下: 滚动的偏移量:通过event.nativeEvent.contentOffset.x可以得到水平偏移量. horizontal={bool},属性为true时,所有子视图在水平方向排列,否则在纵向排列.默认为

  • React Native 中添加自定义字体的方法

    目录 添加字体 定义 assets 目录 执行 link 命令 在样式中使用字体 示例 在 React Native 中,如何添加自定义字体呢?React Native 提供了便捷的命令行工具来帮助我们. 添加字体 在项目根目录下创建 assets/fonts 文件夹,把字体文件放到这个文件夹下. 如图: 定义 assets 目录 在项目根目录下创建 react-native.config.js 文件,编辑其中内容,留意第 6 行,这和我们自定义字体文件所在目录一致. module.export

  • 在 React Native 中使用 CSS Modules的配置方法

    目录 安装依赖和配置 使用 示例 有些前端工程师希望也能像开发 web 应用那样,使用 CSS Modules 来开发 React Native.本文将介绍如何在 React Native 中使用 CSS Modules. 安装依赖和配置 首先安装 react-native-sass-transformer 使得我们可以在 React Native 应用中使用 sass 样式. yarn add react-native-sass-transformer sass -D 参考如下配置,修改 me

  • React Native 中处理 Android 手机吞字的解决方案

    目录 复现问题 解决问题 示例 React Native App 在部分型号的 Android 手机上,可能会发生文字显示不全的问题. 官方也有一个 相关的 Issue ,并提供了如下的解决方案: const defaultFontFamily = { ...Platform.select({ android: { fontFamily: "" }, }), } const oldRender = Text.render Text.render = function (...args)

  • 在 React Native 中给第三方库打补丁的过程解析

    目录 安装 使用 示例 有时使用了某个React Native 第三方库,可是它有些问题,我们不得不修改它的源码.本文介绍如何修改源码又不会意外丢失修改结果的方法. 我们可能不方便给原作者提 Pull Request,因为他们可能不愿意接受我们的更改.又或者原作者无法及时发布新版本. 种种原因,我们只有去修改 node_modules 目录下的文件. 可是当我们执行 yarn install 或 yarn add 时,原先的修改会丢失. 有没有办法让我们可以在 yarn install 或 ya

  • React Native 中实现倒计时功能

    目录 正文 首次实现 最终实现 示例 正文 在 React Native,该如何实现一个倒计时功能呢? 首次实现 表面看来很简单,譬如像下面这样: const timer = useRef<ReturnType<typeof setInterval> | null>(null) const [count, setCount] = useState(0) const start = () => { setCount(10) timer.current = setInterval

随机推荐