注意
由于Metro默认打包配置,在bundle头添加了如下globalVariables的全局配置,因此在打第一个包的时候注意执行拼接,下面已封装为nodeJS代码,自行执行
主包, 主包,切记!不需要每个包都包含
"bundle:ios-main": "react-native bundle --entry-file index.js --platform ios --dev false --bundle-output dist/ios/main.jsbundle --assets-dest dist/ios --config metro.index.config.js && node insertGlobals.js",
// insertGlobals.js
const fs = require('fs');
const path = require('path');
// 定义要插入的全局变量
const globalVariables =
'var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now(),__DEV__=false,process=this.process||{},__METRO_GLOBAL_PREFIX__=\'\';process.env=process.env||{};process.env.NODE_ENV=process.env.NODE_ENV||"production";';
// 定义生成的主包文件路径
const bundleFilePath = path.resolve(__dirname, 'dist/ios/main.jsbundle');
// 读取 bundle 文件内容
fs.readFile(bundleFilePath, 'utf8', (err, data) => {
if (err) {
console.error('Error reading the bundle file:', err);
return;
}
// 插入全局变量到 bundle 文件的开头
const modifiedData = globalVariables + data;
// 写入修改后的内容到 bundle 文件
fs.writeFile(bundleFilePath, modifiedData, 'utf8', err => {
if (err) {
console.error('Error writing the modified bundle file:', err);
} else {
console.log('Global variables inserted successfully.');
}
});
});
序言
在开始本章之前,如果你尝试过其它的分包方案,得到如下错误:
png: missing-asset-registry-path could not be found within the project or in these directories:
node_modules
../../../node_modules
该问题大概率由于你使用当前的metro版本,而分包使用的metro.config.js的代码是老版本的,简单讲进行代码适配就行啦!
ReactNative分包
踩在巨人的肩膀,我们得知Metro工具在序列化阶段其实调用的是createModuleIdFactory和processModuleFilter。
createModuleIdFactory 是一个在 React Native 的 Metro Bundler 配置中用于生成模块 ID 的函数。这个函数的目的是为每个模块生成一个唯一的标识符,以便在打包过程中使用。
processModuleFilter 是在 React Native 的 Metro Bundler 配置中使用的函数之一。它用于过滤模块,在打包过程中决定哪些模块应该被包含,哪些应该被排除。
具体的拆包逻辑就孕育而生:
第一阶段
- 建立index1.js业务模块,demo是以index.js为入口第一个主包,index1.js入口的第二个子包(因为懒省事直接使用了默认的iOS工程,正常混合开发是原生页面加载common+业务1+业务2,demo中是index 模态 index1, 原理是一样的,这点不过多阐述)
// index1.js
import {AppRegistry} from 'react-native';
import App1 from './App1';
AppRegistry.registerComponent('App1', () => App1);
//App1.tsx
import {StyleSheet, Text, View} from 'react-native';
import React from 'react';
const App1 = () => {
return (
<View>
<Text>我是App1</Text>
</View>
);
};
export default App1;
- 设置打包的配置文件,不使用默认的(metro.config.js是全量的打包),这里重新新建metro.index.config.js, 后续其实都用这一个就行,只需要注意打包的顺序
// metro.index.config.js
'use strict';
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
const fs = require('fs');
const pathSep = require('path').sep;
var commonModules = null;
/**
* 检查模块路径是否已经在清单文件中
*
* @param {string} path - 要检查的模块路径
* @returns {boolean} - 如果模块在清单中,返回 true,否则返回 false
*/
function isInManifest(path) {
const manifestFile = './dist/common_manifest.txt';
// 如果清单文件尚未加载到内存中,则加载它
if (commonModules === null && fs.existsSync(manifestFile)) {
const lines = String(fs.readFileSync(manifestFile))
.split('\n')
.filter(line => line.length > 0);
commonModules = new Set(lines);
} else if (commonModules === null) {
commonModules = new Set();
}
return commonModules.has(path);
}
/**
* 将模块路径添加到清单文件中
*
* @param {string} path - 要添加的模块路径
*/
function manifest(path) {
if (path.length) {
const manifestFile = './dist/common_manifest.txt';
if (!fs.existsSync(manifestFile)) {
// 如果清单文件不存在,则创建它
fs.writeFileSync(manifestFile, path);
} else {
// 如果清单文件已存在,则在文件末尾追加路径
fs.appendFileSync(manifestFile, '\n' + path);
}
}
}
/**
* 过滤要处理的模块
*
* @param {object} module - 要过滤的模块
* @returns {boolean} - 如果模块需要处理,返回 true,否则返回 false
*/
function processModuleFilter(module) {
if (module.path.indexOf('__prelude__') >= 0) {
return false;
}
if (isInManifest(module.path)) {
return false;
}
manifest(module.path);
return true;
}
/**
* 创建唯一模块 ID 的工厂函数
*
* @returns {function} - 用于生成模块 ID 的函数
*/
function createModuleIdFactory() {
return path => {
let name = '';
if (path.startsWith(__dirname)) {
name = path.substr(__dirname.length + 1);
}
let regExp =
pathSep == '\\' ? new RegExp('\\\\', 'gm') : new RegExp(pathSep, 'gm');
return name.replace(regExp, '_');
};
}
// Metro 配置
const config = {
serializer: {
createModuleIdFactory,
processModuleFilter,
},
};
// 合并默认配置并导出
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
- 配置打包命令,注意上面我们说的主包bundle要注入部分metro的默认配置,所以主包用到了insertGlobals.js
//package.json, 这里以iOS为例(安卓命令自行添加), ios-full是全量包,下面是主包和副包,当然如果有common.js主包应该是以common.js为入口,下面只是为了配合后续的demo实例
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"bundle:ios-full": "react-native bundle --entry-file index.js --platform ios --dev false --bundle-output dist/ios/sub.jsbundle --assets-dest dist/ios --config metro.config.js",
"bundle:ios-main": "react-native bundle --entry-file index.js --platform ios --dev false --bundle-output dist/ios/main.jsbundle --assets-dest dist/ios --config metro.index.config.js && node insertGlobals.js",
"bundle:ios-sub": "react-native bundle --entry-file index1.js --platform ios --dev false --bundle-output dist/ios/sub.jsbundle --assets-dest dist/ios --config metro.index.config.js",
"test": "jest"
},
打包命令不做过多阐述,需要注意的是入口文件--entry-file 的路径以及使用的--config 文件的路径,需要根据使用者的的目录结构,(demo使用的是index.js, index1.js, metro.index.config.js,处于根目录下), 和打包后生成的文件名称自定义,其实我们完全可以使用脚本.sh打包。
- 在原有iOS基础上更改吧,只写部分代码,具体业务代码,比如避免重复加载啦请自行添加判断逻辑,简单加个数组就行啦,避免多次进入同一模块重复加载;
// 首先- (void)executeSourceCode:(NSData *)sourceCode withSourceURL:(NSURL *)url sync:(BOOL)sync是RCTCxxBridge里面的方法,属于私有方法,我们使用category将其暴露出来;
//
// RCTBridge+CustomerBridge.h
// MetroBundlersDemo
//
// Created by 产品1 on 2024/8/7.
//
#import <Foundation/Foundation.h>
#import <React/RCTBridge.h>
NS_ASSUME_NONNULL_BEGIN
@interface RCTBridge (CustomerBridge)
- (void)executeSourceCode:(NSData *)sourceCode withSourceURL:(NSURL *)url sync:(BOOL)sync;
@end
NS_ASSUME_NONNULL_END
//
// RCTBridge+CustomerBridge.m
// MetroBundlersDemo
//
// Created by 产品1 on 2024/8/7.
//
#import "RCTBridge+CustomerBridge.h"
@implementation RCTBridge (CustomerBridge)
- (void)executeSourceCode:(nonnull NSData *)sourceCode withSourceURL:(nonnull NSURL *)url sync:(BOOL)sync {
NSLog(@"执行我啦");
}
@end
// 其次直接更改appdelegate里面的,只写简单的拼接过程示例,业务逻辑自己处理哈,注意避免重复加载相同的bundle
#import <RCTAppDelegate.h>
#import <UIKit/UIKit.h>
@interface AppDelegate : RCTAppDelegate
@end
#import "AppDelegate.h"
#import "RCTBridge+CustomerBridge.h"
#import <React/RCTBundleURLProvider.h>
#import <React/RCTAssert.h>
#import "MainViewController.h"
#import <React/RCTBridge+Private.h>
#import <React/RCTBridge.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.moduleName = @"MetroBundlersDemo";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// 模拟进入业务模块,注意这里添加逻辑判断,避免多次加载,例如数组记录
[self loadJSBundle:@"sub" sync:NO];
MainViewController *vc = [MainViewController new];
vc.bridge = self.bridge;
[self.window.rootViewController presentViewController:vc animated:true completion:nil];
});
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (void)loadJSBundle:(NSString *)bundleName sync:(BOOL)sync {
NSURL *bundleURL = [[NSBundle mainBundle] URLForResource:bundleName withExtension:@"jsbundle"];
NSData *bundleData = [NSData dataWithContentsOfURL:bundleURL];
if (bundleData) {
[self.bridge.batchedBridge executeSourceCode:bundleData withSourceURL:bundleURL sync:sync];
} else {
NSLog(@"解析错误");
}
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
//#if DEBUG
// return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
//#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
//#endif
}
@end
以上过程已经完成了主包和业务包的拆分,依次我们还可以打包多个不同的业务包
第三阶段
优化注意事项:
- 可使用脚本编辑打包过程,避免过多的手动输入,比如把需要打包的入口文件以及配置文件生成map对象,根据选择打对应的common和business包,具体根据情况使用node.js或者.sh自行设置;
- iOS打包后资源文件怎么处理?通常来讲加载的资源文件是按照第一个bundle的assets加载的,在进行离线下载热更新的时候,我们需要在客户端进行assets的merge,合并到第一个通常来讲是common的assets目录下面;
- 开发调试的时候怎么办呢?开发调试的时候我们建议将不同的business.js入口文件导入index.js中,进行单文件调试;
- 混合开发的路由和网络请求如何处理?混合开发通常使用的网络请求和路由都是原生端提供,
路由:原生端使用viewcontroller或者activity进行加载对应的RN_View, 在加载的时候我们可以设置不同的moduleName以及在附加参数中添加对应的pageName传递给RN,RN根据注册的ModuleName和页面的pageName显示对应的界面,rn界面的路由就可以类似这样:app://rnbase?page=home, pop的时候也根据此进行返回;
网络请求:网络请求使用原生端网络请求,根据bridge进行方法的相互调用,可解决登录状态,header头设置等问题,也避免重复写网络请求。- 在调试出现错误的时候,捕获异常可以使用@sentry/react-native在每个业务的componentDidCatch中,手动captureException到sentry解决问题,可以动态注入;
后记,demo工程只用OC写了iOS的加载,安卓自行补充
Metro已经很成熟啦,最近也是因为收到很多人的邮件反馈不能使用的问题,对此进行了更新,大家在以后使用过程中如果遇到无法加载,有时候可以尝试打全量包和你打的主包bundle进行大致的对比找出问题所在,这是一点意见哈