@bm-fe/react-native-multi-bundle 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INTEGRATION.md +371 -0
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/package.json +86 -0
- package/scripts/build-multi-bundle.js +483 -0
- package/scripts/release-codepush-dev.sh +90 -0
- package/scripts/release-codepush.js +420 -0
- package/scripts/sync-bundles-to-assets.js +252 -0
- package/src/index.ts +40 -0
- package/src/multi-bundle/DevModeConfig.ts +23 -0
- package/src/multi-bundle/HMRClient.ts +157 -0
- package/src/multi-bundle/LocalBundleManager.ts +155 -0
- package/src/multi-bundle/ModuleErrorFallback.tsx +85 -0
- package/src/multi-bundle/ModuleLoaderMock.ts +169 -0
- package/src/multi-bundle/ModuleLoadingPlaceholder.tsx +34 -0
- package/src/multi-bundle/ModuleRegistry.ts +295 -0
- package/src/multi-bundle/README.md +343 -0
- package/src/multi-bundle/config.ts +141 -0
- package/src/multi-bundle/createModuleLoader.tsx +92 -0
- package/src/multi-bundle/createModuleRouteLoader.tsx +31 -0
- package/src/multi-bundle/devUtils.ts +48 -0
- package/src/multi-bundle/init.ts +131 -0
- package/src/multi-bundle/metro-config-helper.js +140 -0
- package/src/multi-bundle/preloadModule.ts +33 -0
- package/src/multi-bundle/routeRegistry.ts +118 -0
- package/src/types/global.d.ts +14 -0
- package/templates/metro.config.js.template +45 -0
- package/templates/multi-bundle.config.json.template +31 -0
- package/templates/native/android/ModuleLoaderModule.kt +227 -0
- package/templates/native/android/ModuleLoaderPackage.kt +26 -0
- package/templates/native/ios/ModuleLoaderModule.h +13 -0
- package/templates/native/ios/ModuleLoaderModule.m +60 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevModeConfig - 开发模式配置
|
|
3
|
+
*
|
|
4
|
+
* HTTP 模式:使用 ModuleLoaderMock,通过 HTTP 加载 bundle
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type DevMode = 'http';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 获取当前开发模式
|
|
11
|
+
* 始终返回 'http'(HTTP 模式)
|
|
12
|
+
*/
|
|
13
|
+
export function getDevMode(): DevMode {
|
|
14
|
+
return 'http';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 检查是否使用 HTTP 模式(ModuleLoaderMock)
|
|
19
|
+
*/
|
|
20
|
+
export function useHttpMode(): boolean {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 自定义 HMR Client
|
|
5
|
+
* 用于监听 Metro 的 /hot 端点,实现多 Bundle 的热更新
|
|
6
|
+
*/
|
|
7
|
+
export class HMRClient {
|
|
8
|
+
private static instance: HMRClient;
|
|
9
|
+
private ws: WebSocket | null = null;
|
|
10
|
+
private host: string = 'localhost';
|
|
11
|
+
private port: number = 8081;
|
|
12
|
+
private isEnabled: boolean = false;
|
|
13
|
+
private pendingEntryPoints: string[] = [];
|
|
14
|
+
|
|
15
|
+
private constructor() {}
|
|
16
|
+
|
|
17
|
+
public static getInstance(): HMRClient {
|
|
18
|
+
if (!HMRClient.instance) {
|
|
19
|
+
HMRClient.instance = new HMRClient();
|
|
20
|
+
}
|
|
21
|
+
return HMRClient.instance;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 初始化并连接 WebSocket
|
|
26
|
+
*/
|
|
27
|
+
public setup(host: string, port: number) {
|
|
28
|
+
if (this.isEnabled) return;
|
|
29
|
+
|
|
30
|
+
this.host = host;
|
|
31
|
+
this.port = port;
|
|
32
|
+
this.isEnabled = true;
|
|
33
|
+
|
|
34
|
+
this.connect();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private connect() {
|
|
38
|
+
if (!this.isEnabled) return;
|
|
39
|
+
|
|
40
|
+
const url = `ws://${this.host}:${this.port}/hot`;
|
|
41
|
+
console.log('[HMRClient] Connecting to:', url);
|
|
42
|
+
|
|
43
|
+
this.ws = new WebSocket(url);
|
|
44
|
+
|
|
45
|
+
this.ws.onopen = () => {
|
|
46
|
+
console.log('[HMRClient] Connected');
|
|
47
|
+
this.flushEntryPoints();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
this.ws.onmessage = (event) => {
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(event.data as string);
|
|
53
|
+
this.processMessage(data);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error('[HMRClient] Failed to parse message:', e);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
this.ws.onerror = (e) => {
|
|
60
|
+
console.log('[HMRClient] Connection error:', (e as any).message);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
this.ws.onclose = () => {
|
|
64
|
+
console.log('[HMRClient] Disconnected');
|
|
65
|
+
this.ws = null;
|
|
66
|
+
// 尝试重连
|
|
67
|
+
if (this.isEnabled) {
|
|
68
|
+
setTimeout(() => this.connect(), 2000);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 注册 Bundle 入口
|
|
75
|
+
* 当 Bundle 加载时调用,告诉 Metro 我们关心这个 Bundle 的更新
|
|
76
|
+
*/
|
|
77
|
+
public registerBundle(bundleUrl: string) {
|
|
78
|
+
if (!__DEV__) return;
|
|
79
|
+
|
|
80
|
+
// 避免重复注册
|
|
81
|
+
if (!this.pendingEntryPoints.includes(bundleUrl)) {
|
|
82
|
+
this.pendingEntryPoints.push(bundleUrl);
|
|
83
|
+
this.flushEntryPoints();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private flushEntryPoints() {
|
|
88
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.pendingEntryPoints.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const message = {
|
|
93
|
+
type: 'register-entrypoints',
|
|
94
|
+
entryPoints: this.pendingEntryPoints,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
this.ws.send(JSON.stringify(message));
|
|
98
|
+
// 注意:不要清空 pendingEntryPoints,因为断线重连后需要重新注册
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private processMessage(message: any) {
|
|
102
|
+
if (message.type === 'update') {
|
|
103
|
+
console.log('[HMRClient] Received update');
|
|
104
|
+
this.applyUpdate(message.body);
|
|
105
|
+
} else if (message.type === 'update-start') {
|
|
106
|
+
console.log('[HMRClient] Update start');
|
|
107
|
+
} else if (message.type === 'update-done') {
|
|
108
|
+
console.log('[HMRClient] Update done');
|
|
109
|
+
} else if (message.type === 'error') {
|
|
110
|
+
console.error('[HMRClient] Metro error:', message.body);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private applyUpdate(body: any) {
|
|
115
|
+
const { modified, added, deleted } = body;
|
|
116
|
+
|
|
117
|
+
if (modified && modified.length > 0) {
|
|
118
|
+
modified.forEach((mod: any) => {
|
|
119
|
+
const [moduleId, moduleCode] = mod.module;
|
|
120
|
+
// console.log(`[HMRClient] Applying update for module: ${moduleId}`);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
// 执行新的模块代码
|
|
124
|
+
// 模块代码通常包含 __d(...) 调用
|
|
125
|
+
// 我们使用 Function 构造函数或 eval 来执行它
|
|
126
|
+
|
|
127
|
+
// 如果 moduleCode 已经是字符串形式的代码
|
|
128
|
+
// 注意:Metro HMR 发送的代码可能需要处理
|
|
129
|
+
|
|
130
|
+
// 在 React Native 环境中,__d 是全局函数
|
|
131
|
+
// 直接执行代码应该会更新模块定义
|
|
132
|
+
|
|
133
|
+
// 使用 Function 更加安全,但需要确保作用域正确
|
|
134
|
+
// 这里简单使用 indirect eval
|
|
135
|
+
(0, eval)(moduleCode);
|
|
136
|
+
|
|
137
|
+
// 强制刷新引用了该模块的组件?
|
|
138
|
+
// 如果 React Fast Refresh 启用,它应该会自动处理
|
|
139
|
+
// 但我们需要确保 Metro 的 runtime 能够感知到模块变化
|
|
140
|
+
|
|
141
|
+
// HACK: 触发一个全局事件或者直接调用 RN 的刷新机制
|
|
142
|
+
// 但由于我们是自定义加载,可能需要手动触发重新渲染
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error(`[HMRClient] Failed to apply update for module ${moduleId}:`, error);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public disable() {
|
|
151
|
+
this.isEnabled = false;
|
|
152
|
+
if (this.ws) {
|
|
153
|
+
this.ws.close();
|
|
154
|
+
this.ws = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalBundleManager - Bundle 管理接口封装
|
|
3
|
+
* - 开发环境:从开发服务器获取 manifest(HTTP 模式)
|
|
4
|
+
* - 生产环境:从 Native CodePush 模块获取 manifest(直接返回 JSON 对象)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BundleManifest } from './ModuleRegistry';
|
|
8
|
+
import { Platform, NativeModules } from 'react-native';
|
|
9
|
+
import { getDevMode, type DevMode } from './DevModeConfig';
|
|
10
|
+
import { getGlobalConfig } from './config';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 获取当前激活包的根目录
|
|
14
|
+
* HTTP 模式:返回空字符串(不需要 root path)
|
|
15
|
+
*/
|
|
16
|
+
async function getCurrentPackageRootPath(): Promise<string> {
|
|
17
|
+
// HTTP 模式:不需要 root path
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 将 Native 返回的 WritableMap 转换为 BundleManifest
|
|
23
|
+
*/
|
|
24
|
+
function convertNativeManifestToBundleManifest(nativeManifest: any): BundleManifest {
|
|
25
|
+
// Native 可能返回字符串或对象,需要先处理
|
|
26
|
+
let manifestObj: any;
|
|
27
|
+
|
|
28
|
+
if (typeof nativeManifest === 'string') {
|
|
29
|
+
// 如果是字符串,先解析为 JSON 对象
|
|
30
|
+
try {
|
|
31
|
+
manifestObj = JSON.parse(nativeManifest);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('[LocalBundleManager] Failed to parse native manifest string:', error);
|
|
34
|
+
throw new Error('Invalid manifest format: failed to parse JSON string');
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
// 如果已经是对象,直接使用
|
|
38
|
+
manifestObj = nativeManifest;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
formatVersion: manifestObj.formatVersion || 1,
|
|
43
|
+
main: {
|
|
44
|
+
file: manifestObj.main?.file || '',
|
|
45
|
+
version: manifestObj.main?.version || '1.0.0',
|
|
46
|
+
},
|
|
47
|
+
modules: (manifestObj.modules || []).map((m: any) => ({
|
|
48
|
+
id: m.id,
|
|
49
|
+
file: m.file,
|
|
50
|
+
version: m.version || '1.0.0',
|
|
51
|
+
dependencies: m.dependencies || [],
|
|
52
|
+
lazy: m.lazy !== false,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 获取当前激活包的 manifest
|
|
59
|
+
* - 开发环境:从开发服务器获取 manifest(HTTP 模式)
|
|
60
|
+
* - 生产环境:从 Native CodePush 模块获取 manifest(直接返回 JSON 对象)
|
|
61
|
+
*/
|
|
62
|
+
async function getCurrentBundleManifest(): Promise<BundleManifest> {
|
|
63
|
+
// 生产环境:从 Native 模块获取 manifest
|
|
64
|
+
if (!__DEV__) {
|
|
65
|
+
try {
|
|
66
|
+
const nativeManifest = await NativeModules.CodePush.getCurrentBundleManifestContent();
|
|
67
|
+
if (nativeManifest) {
|
|
68
|
+
return convertNativeManifestToBundleManifest(nativeManifest);
|
|
69
|
+
} else {
|
|
70
|
+
// Native 模块返回 null 或 undefined
|
|
71
|
+
throw new Error(
|
|
72
|
+
'[LocalBundleManager] Native module returned null manifest. ' +
|
|
73
|
+
'Please ensure bundle-manifest.json exists in the app bundle.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(
|
|
78
|
+
`[LocalBundleManager] Failed to get manifest from Native: ${error}`
|
|
79
|
+
);
|
|
80
|
+
// 生产环境无法获取 manifest 时抛出异常
|
|
81
|
+
throw new Error(
|
|
82
|
+
`[LocalBundleManager] Failed to get manifest from Native module: ${error}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 开发环境:从开发服务器获取 manifest
|
|
88
|
+
const config = getGlobalConfig();
|
|
89
|
+
const devServer = config?.devServer;
|
|
90
|
+
|
|
91
|
+
if (devServer) {
|
|
92
|
+
try {
|
|
93
|
+
const protocol = devServer.protocol || 'http';
|
|
94
|
+
const platform = Platform.OS;
|
|
95
|
+
const manifestUrl = `${protocol}://${devServer.host}:${devServer.port}/bundle-manifest.json?platform=${platform}`;
|
|
96
|
+
const response = await fetch(manifestUrl);
|
|
97
|
+
if (response.ok) {
|
|
98
|
+
const manifest: BundleManifest = await response.json();
|
|
99
|
+
return manifest;
|
|
100
|
+
} else {
|
|
101
|
+
console.warn(
|
|
102
|
+
`[LocalBundleManager] Failed to fetch manifest: HTTP ${response.status} ${response.statusText}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.warn(
|
|
107
|
+
`[LocalBundleManager] Failed to fetch manifest from dev server: ${error}`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
// 降级到默认配置(向后兼容)
|
|
112
|
+
try {
|
|
113
|
+
const host = Platform.OS === 'android' ? '10.0.2.2' : 'localhost';
|
|
114
|
+
const platform = Platform.OS;
|
|
115
|
+
const manifestUrl = `http://${host}:8081/bundle-manifest.json?platform=${platform}`;
|
|
116
|
+
const response = await fetch(manifestUrl);
|
|
117
|
+
if (response.ok) {
|
|
118
|
+
const manifest: BundleManifest = await response.json();
|
|
119
|
+
return manifest;
|
|
120
|
+
} else {
|
|
121
|
+
console.warn(
|
|
122
|
+
`[LocalBundleManager] Failed to fetch manifest: HTTP ${response.status} ${response.statusText}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.warn(
|
|
127
|
+
`[LocalBundleManager] Failed to fetch manifest from dev server: ${error}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 所有尝试都失败,抛出异常
|
|
133
|
+
throw new Error(
|
|
134
|
+
'[LocalBundleManager] Failed to get bundle manifest: ' +
|
|
135
|
+
'Unable to fetch manifest from dev server or Native module. ' +
|
|
136
|
+
'Please ensure Metro server is running and bundle-manifest.json is accessible.'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 获取当前开发模式
|
|
144
|
+
*/
|
|
145
|
+
function getCurrentDevMode(): DevMode {
|
|
146
|
+
return getDevMode();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
export const LocalBundleManager = {
|
|
151
|
+
getCurrentPackageRootPath,
|
|
152
|
+
getCurrentBundleManifest,
|
|
153
|
+
getCurrentDevMode,
|
|
154
|
+
};
|
|
155
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModuleErrorFallback - 模块加载失败的兜底组件
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
moduleId: string;
|
|
10
|
+
error: Error | null;
|
|
11
|
+
onRetry: () => void;
|
|
12
|
+
onGoHome?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ModuleErrorFallback({
|
|
16
|
+
moduleId,
|
|
17
|
+
error,
|
|
18
|
+
onRetry,
|
|
19
|
+
onGoHome,
|
|
20
|
+
}: Props) {
|
|
21
|
+
return (
|
|
22
|
+
<View style={styles.container}>
|
|
23
|
+
<Text style={styles.title}>{moduleId} 模块暂时无法使用</Text>
|
|
24
|
+
{error && (
|
|
25
|
+
<Text style={styles.errorText} selectable>
|
|
26
|
+
{error.message || String(error)}
|
|
27
|
+
</Text>
|
|
28
|
+
)}
|
|
29
|
+
<View style={styles.buttonContainer}>
|
|
30
|
+
<TouchableOpacity style={styles.button} onPress={onRetry}>
|
|
31
|
+
<Text style={styles.buttonText}>重试</Text>
|
|
32
|
+
</TouchableOpacity>
|
|
33
|
+
{onGoHome && (
|
|
34
|
+
<TouchableOpacity style={styles.button} onPress={onGoHome}>
|
|
35
|
+
<Text style={styles.buttonText}>返回首页</Text>
|
|
36
|
+
</TouchableOpacity>
|
|
37
|
+
)}
|
|
38
|
+
</View>
|
|
39
|
+
</View>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const styles = StyleSheet.create({
|
|
44
|
+
container: {
|
|
45
|
+
flex: 1,
|
|
46
|
+
justifyContent: 'center',
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
backgroundColor: '#fff',
|
|
49
|
+
padding: 20,
|
|
50
|
+
},
|
|
51
|
+
title: {
|
|
52
|
+
fontSize: 20,
|
|
53
|
+
fontWeight: 'bold',
|
|
54
|
+
color: '#333',
|
|
55
|
+
marginBottom: 16,
|
|
56
|
+
textAlign: 'center',
|
|
57
|
+
},
|
|
58
|
+
errorText: {
|
|
59
|
+
fontSize: 14,
|
|
60
|
+
color: '#d32f2f',
|
|
61
|
+
marginBottom: 24,
|
|
62
|
+
textAlign: 'center',
|
|
63
|
+
padding: 12,
|
|
64
|
+
backgroundColor: '#ffebee',
|
|
65
|
+
borderRadius: 4,
|
|
66
|
+
width: '100%',
|
|
67
|
+
},
|
|
68
|
+
buttonContainer: {
|
|
69
|
+
width: '100%',
|
|
70
|
+
maxWidth: 300,
|
|
71
|
+
},
|
|
72
|
+
button: {
|
|
73
|
+
backgroundColor: '#007AFF',
|
|
74
|
+
padding: 15,
|
|
75
|
+
borderRadius: 8,
|
|
76
|
+
marginVertical: 8,
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
},
|
|
79
|
+
buttonText: {
|
|
80
|
+
color: '#fff',
|
|
81
|
+
fontSize: 16,
|
|
82
|
+
fontWeight: '600',
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModuleLoaderMock - Native ModuleLoader 的 Mock 实现
|
|
3
|
+
* 用于开发环境,模拟 Native 模块加载能力
|
|
4
|
+
*
|
|
5
|
+
* 注意:生产环境需要真实的 Native 实现
|
|
6
|
+
*
|
|
7
|
+
* 在开发环境中,我们通过 Metro bundler 的 HTTP 服务器加载 bundle
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Platform } from 'react-native';
|
|
11
|
+
import { HMRClient } from './HMRClient';
|
|
12
|
+
|
|
13
|
+
interface LoadResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
errorMessage?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
// 已完成的 bundle
|
|
21
|
+
const loadedBundles = new Set<string>();
|
|
22
|
+
// 正在加载中的 bundle(并发复用)
|
|
23
|
+
const inflightBundles = new Map<string, Promise<LoadResult>>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 根据 bundleId 构建 HTTP URL(开发环境)
|
|
27
|
+
*
|
|
28
|
+
* 在开发环境中,我们通过 Metro bundler 的 HTTP 服务器加载 bundle
|
|
29
|
+
* 根据 bundleId 直接构建对应的 HTTP URL
|
|
30
|
+
*/
|
|
31
|
+
function buildHttpUrlFromBundleId(bundleId: string): string {
|
|
32
|
+
const host = Platform.OS === 'android' ? '10.0.2.2' : 'localhost';
|
|
33
|
+
|
|
34
|
+
// 简单的模块 ID 到目录名的映射
|
|
35
|
+
// 假设模块 ID 为小写,目录名为首字母大写
|
|
36
|
+
// 例如: home -> Home
|
|
37
|
+
const moduleName = bundleId.charAt(0).toUpperCase() + bundleId.slice(1);
|
|
38
|
+
|
|
39
|
+
// 构建 Metro 请求 URL
|
|
40
|
+
// 请求源码入口文件,Metro 会自动打包
|
|
41
|
+
// 注意:使用 .bundle 后缀,Metro 会识别并构建对应的 .ts/.tsx 文件
|
|
42
|
+
return `http://${host}:8081/src/modules/${moduleName}/index.bundle` +
|
|
43
|
+
`?platform=${Platform.OS}` +
|
|
44
|
+
`&dev=true` +
|
|
45
|
+
`&minify=false` +
|
|
46
|
+
`&modulesOnly=true` + // ✅ 只生成 __d(...),不带 runtime
|
|
47
|
+
`&runModule=true`; // ✅ 不自动执行入口
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 加载 bundle 文件(Mock 实现)
|
|
52
|
+
*
|
|
53
|
+
* 在开发环境中,我们通过 HTTP 服务器加载 bundle 并执行
|
|
54
|
+
* 实际生产环境应该调用 Native CodePush.loadBusinessBundle
|
|
55
|
+
*
|
|
56
|
+
* @param bundleId 模块 ID
|
|
57
|
+
* @param bundlePath bundle 文件路径(开发环境可能不使用,但为保持接口一致性保留)
|
|
58
|
+
*/
|
|
59
|
+
async function loadBusinessBundle(bundleId: string, bundlePath: string): Promise<LoadResult> {
|
|
60
|
+
try {
|
|
61
|
+
// 1. 基本校验
|
|
62
|
+
if (!bundleId || bundleId.trim() === '') {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
errorMessage: 'EMPTY_BUNDLE_ID',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!__DEV__) {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
errorMessage:
|
|
73
|
+
'ModuleLoaderMock should not be used in production. Please implement Native CodePush.loadBusinessBundle.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 开发环境:根据 bundleId 构建 HTTP URL
|
|
78
|
+
const httpUrl = buildHttpUrlFromBundleId(bundleId);
|
|
79
|
+
|
|
80
|
+
// 已经加载过的 bundle,不再重复执行
|
|
81
|
+
if (loadedBundles.has(httpUrl)) {
|
|
82
|
+
// 确保即使加载过也注册 HMR,处理重新连接或初始化时机问题
|
|
83
|
+
if (__DEV__) {
|
|
84
|
+
HMRClient.getInstance().registerBundle(httpUrl);
|
|
85
|
+
}
|
|
86
|
+
return { success: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 正在加载中,复用同一个 Promise
|
|
90
|
+
const existing = inflightBundles.get(httpUrl);
|
|
91
|
+
if (existing) {
|
|
92
|
+
return existing;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const task: Promise<LoadResult> = (async () => {
|
|
96
|
+
// 从 HTTP 服务器获取 bundle
|
|
97
|
+
const response = await fetch(httpUrl);
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
// 如果 HTTP 加载失败
|
|
100
|
+
console.warn(
|
|
101
|
+
`[ModuleLoaderMock] HTTP load failed (${response.status})`
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
success: false,
|
|
106
|
+
errorMessage: `HTTP_ERROR_${response.status}`
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const bundleCode = await response.text();
|
|
111
|
+
|
|
112
|
+
// 在 React Native 中执行 bundle 代码
|
|
113
|
+
try {
|
|
114
|
+
// 帮 stack trace 标一下来源(部分引擎支持)
|
|
115
|
+
const wrappedCode = bundleCode.includes('//# sourceURL=')
|
|
116
|
+
? bundleCode
|
|
117
|
+
: `${bundleCode}\n//# sourceURL=${httpUrl}`;
|
|
118
|
+
// 使用 Function 构造函数执行代码(更安全)
|
|
119
|
+
const executeCode = new Function(wrappedCode);
|
|
120
|
+
executeCode();
|
|
121
|
+
loadedBundles.add(httpUrl);
|
|
122
|
+
if (__DEV__) {
|
|
123
|
+
HMRClient.getInstance().registerBundle(httpUrl);
|
|
124
|
+
}
|
|
125
|
+
return { success: true };
|
|
126
|
+
} catch (evalError) {
|
|
127
|
+
// 如果 Function 构造函数失败,尝试直接 eval(不推荐,但作为后备)
|
|
128
|
+
console.warn(
|
|
129
|
+
'[ModuleLoaderMock] Function constructor failed, trying eval'
|
|
130
|
+
);
|
|
131
|
+
try {
|
|
132
|
+
// eslint-disable-next-line no-eval
|
|
133
|
+
eval(bundleCode);
|
|
134
|
+
loadedBundles.add(httpUrl);
|
|
135
|
+
if (__DEV__) {
|
|
136
|
+
HMRClient.getInstance().registerBundle(httpUrl);
|
|
137
|
+
}
|
|
138
|
+
return { success: true };
|
|
139
|
+
} catch (e) {
|
|
140
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
141
|
+
console.error(`[ModuleLoaderMock] Eval failed: ${msg}`);
|
|
142
|
+
return { success: false, errorMessage: msg };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
inflightBundles.set(httpUrl, task);
|
|
147
|
+
|
|
148
|
+
const result = await task;
|
|
149
|
+
inflightBundles.delete(httpUrl);
|
|
150
|
+
return result;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const errorMessage =
|
|
153
|
+
error instanceof Error ? error.message : String(error);
|
|
154
|
+
console.error(`[ModuleLoaderMock] Load failed: ${errorMessage}`);
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
errorMessage: `EXCEPTION: ${errorMessage}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const ModuleLoader = {
|
|
163
|
+
loadBusinessBundle,
|
|
164
|
+
// 为了向后兼容,保留 loadBundleFile 别名
|
|
165
|
+
loadBundleFile: loadBusinessBundle,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type { LoadResult };
|
|
169
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModuleLoadingPlaceholder - 模块加载中的占位组件
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
moduleId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ModuleLoadingPlaceholder({ moduleId }: Props) {
|
|
13
|
+
return (
|
|
14
|
+
<View style={styles.container}>
|
|
15
|
+
<ActivityIndicator size="large" color="#007AFF" />
|
|
16
|
+
<Text style={styles.text}>正在加载 {moduleId} 模块...</Text>
|
|
17
|
+
</View>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
container: {
|
|
23
|
+
flex: 1,
|
|
24
|
+
justifyContent: 'center',
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
backgroundColor: '#f5f5f5',
|
|
27
|
+
},
|
|
28
|
+
text: {
|
|
29
|
+
marginTop: 16,
|
|
30
|
+
fontSize: 16,
|
|
31
|
+
color: '#666',
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|