RN JSBundle 拆分解決方案(3): 固定模組 BundleId,JSBundle 按需載入
實踐原始碼:react-native-split-bundle:https://github.com/songxiaoliang/react-native-split-bundle
前面兩篇文章分別從原始碼載入、Bundle檔案結構、Metro打包工具等做了簡單分析,逐步瞭解關於RN打包,Bundle 檔案載入的相關內容。本篇內容將結合程式碼來完成最終的實現方案。在閱讀該篇部落格內容前,可以點選如下檢視:
(2)JSBundle 檔案、打包工具 Metro 的結構分析
在《RN JSBundle 拆分解決方案(2): JSBundle 、Metro 結構分析》
從 .bundle 檔案的中,我們知道由於原始碼中使用 Number(int) 數值型 以 _d 的方式定義了程式碼模組ID,並使用 _r 的方式進行依賴行。如果存在模組間的改變或者修改,都有可能導致模組ID發生改變,導致舊的bundle檔案不能使用。所以在拆分公共部分與業務部分的過程中,需要我們解決模組間依賴的問題。
網上很多部落格介紹了基於 0.4 版本的解決方案。0.4版本較低,存在的已知問題較多,並且當前社群很多已經升為0.5以上,所以下程式碼會基於 0.5 實現。由於官方對 Metro 打包工具做了升級與改動,0.52
一、固定模組Id
0.50 ~ 0.52 (不包含)版本
在第二篇部落格中,我們簡單介紹了打包流程,所以我們按照這個流程來修改原始碼打包邏輯,以 模組路徑名稱 的方式固定模組ID。
(1)開啟 node_modules 目錄下找到 metro-bundler/src/Resolver/index.js 檔案,在 index.js 中,負責了模組的定義流程,核心程式碼如下:
wrapModule
wrapModule(_ref6) { 程式碼省略.... if (module.isJSON()) { code = `module.exports = ${code}`; } if (module.isPolyfill()) { code = definePolyfillCode(code); } else { const moduleId = getModuleId(module); // 獲取對應Module ID // 根據模組ID 使用 require 解析 code = this.resolveRequires( module, getModuleId, code, dependencyPairs, dependencyOffsets); name = module.localPath; // iOS 平臺使用該行程式碼,將 name 定義成模組路徑名稱 // name = module.localPath.replace(/\\/g, '_'); // Windows 平臺使用該行程式碼: 由於 windows 檔案路徑問題,此處需要將 \ 替換成 _ // 定義模組 code = defineModuleCode(moduleId, code, name, dev); } return { code, map }; }
在 wrapModule 方法中,呼叫了 resolveRequires()、defineModuleCode() 方法來分別執行模組的解析及定義。在該方法中,我們以模組 localPath 的方式重新定義了 name。
resolveRequires
resolveRequires(module,getModuleId,code,dependencyPairs) {
程式碼省略...
const resolvedDeps = Object.create(null);
// 構建一個所有需要字串(相對和絕對)的對映到它們引用的模組的規範ID
for (const _ref2 of dependencyPairs) {var _ref3 = _slicedToArray(_ref2, 2);
const name = _ref3[0];const path = _ref3[1];
resolvedDeps[name] = getModuleId({ path });
}
// 如果我們在這裡匯入的模組有一個規範ID,我們使用它,因此始終使用相同的方式呼叫require(每個模組的ID)
// 例如:
// - 在a / b.js中:require('./ c')=> require(3);
// - 在b / index.js中:require('../ a / c')=> require(3);
return dependencyOffsets.
reduceRight(
(_ref4, offset) => {var _ref5 = _slicedToArray(_ref4, 2);let unhandled = _ref5[0],handled = _ref5[1];return [
unhandled.slice(0, offset),
replaceDependencyID(unhandled.slice(offset) + handled, resolvedDeps)];},
[code, '']).
join('');
}
在 resolveRequires 方法中,對 dependencyPairs 進行遍歷,獲取每個模組對應的整數型ID,並生成 require(ID) 程式碼片段。
所以在遍歷中,我們要稍作修改,將 getModuleId 的方式換成類似 getModulePath 的方式來定義 require,修改如下:
for (const _ref2 of dependencyPairs) {var _ref3 = _slicedToArray(_ref2, 2);
const name = _ref3[0];const path = _ref3[1];
// resolvedDeps[name] = getModuleId({ path });
resolvedDeps[name] = "'" + this.getModuleForPath(path).localPath + "'"; // iOS 平臺使用該行程式碼
// resolvedDeps[name] = "'" + this.getModuleForPath(path).localPath.replace(/\\/g, '_') + "'"; // Windows 平臺使用該行程式碼
}
defineModuleCode
function defineModuleCode(moduleName, code, name) {
程式碼省略...
return [
`__d(/* ${verboseName} */`,
'function(global, require, module, exports) {',
code,
'\n}, ',
`${JSON.stringify(moduleName)}`, // module id, null = id map. used in ModuleGraph
dev ? `, null, ${JSON.stringify(verboseName)}` : '',
');'].
join('');
}
defineModuleCode 方法中邏輯很簡單,就是通過給定的引數來使用 _d 來定義模組。可以看到上面程式碼中,有一段註釋,標註了當前位置表示模組的ID,在wrapModule方法中,呼叫defineModuleCode 方法時,第一個引數傳入的是moduleId,即moduleName 就是對應的整數型ID,所以此處我們要修改成以 模組路徑名稱 為Id 作為引數:
function defineModuleCode(moduleName, code, name) {
程式碼省略...
return [
`__d(/* ${verboseName} */`,
'function(global, require, module, exports) {',
code,
'\n}, ',
`${JSON.stringify(name)}`, // 此處修改成以 name 作為 Id
dev ? `, null, ${JSON.stringify(verboseName)}` : '',
');'].
join('');
}
name 引數就是我們在 wrapModule 方法中以模組 localPath 的方式重新的。到此,生成 _d 的程式碼邏輯,並且在 _d 中當前模組使用 require 引用了其他模組的程式碼已經執行完成。接下來就是修改定義 require 模組的程式碼。
(2)開啟 node_modules 目錄下找到 metro-bundler/src/Bundler/Bundle.js 檔案,在 Bundle.js 檔案中的 _addRequireCall() 方法定義了 require 核心程式碼模組:
_addRequireCall(moduleId) {
const code = `;require(${JSON.stringify(moduleId)});`;
const name = 'require-' + moduleId; // require-0 、require-55
super.addModule(
new ModuleTransport({
name,
id: -this._numRequireCalls - 1,
code,
virtual: true,
sourceCode: code,
sourcePath: name + '.js',
meta: { preloaded: true } }));
this._numRequireCalls += 1;
}
在 _addRequireCall 方法中,通過 JSON.stringify(moduleId) 以 整數型Id 的方式來生成定義 require 的程式碼。因為在 _d 中我們以 模組路徑名稱 的方式來生成 require依賴程式碼,所以此處在定義時,也要以該方式定義,否則會出現無法找到對應模組的錯誤:
_addRequireCall(moduleId) {
// 1.定義 moduleName
let moduleName= moduleId;
// 2.遍歷 modules 根據路徑來定義 moduleName
this.getModules().map((key, value) => {
if (key.id == moduleId) {
// key.sourcePath:
// C:\Users\songlcy\Desktop\splitBundle\node_modules\react-native\Libraries\Core\InitializeCore.js
// C:\Users\songlcy\Desktop\splitBundle\index.js
moduleName = this.resolver.getModuleForPath(key.sourcePath).localPath; // iOS平臺使用該行程式碼
// modeName = this.resolver.getModuleForPath(key.sourcePath).localPath.replace(/\\/g, '_'); // Windows 平臺使用該行程式碼
}
});
// 3.使用moduleName 生成 require 程式碼
// require("node_modules\\react-native\\Libraries\\Core\\InitializeCore.js");
// require("index.js")
const code = `;require(${JSON.stringify(moduleName)});`;
// require-0 、require-55
const name = 'require-' + moduleId;
super.addModule(
new ModuleTransport({
name,
id: -this._numRequireCalls - 1,
code,
virtual: true,
sourceCode: code,
sourcePath: name + '.js',
meta: { preloaded: true } }));
this._numRequireCalls += 1;
}
在上述程式碼中,我們通過 this.resolver.getModuleForPath(key.sourcePath).localPath 來獲取模組路徑名稱,並將其作為code,來定義 require 程式碼。resolver 在 addModule() 方法中可以獲取到:
addModule(resolver, resolutionResponse, module, moduleTransport) {
this.resolver = resolver; // 定義 this.resolver
程式碼省略...
}
以上我們通過對 _d、_r 的修改,完成了以 整數型的模組Id 向 以 模組路徑名稱(String 型別)的修改過渡。打包原始碼的邏輯修改就大功告成。
0.52 ~ 0.55 版本
由於官方對 Metro 打包工具做了升級與改動,在RN 0.52版本以下與 0.52版本以上 metro-bundler 打包工具結構上存在了很大的變化。並且在 Metro 的基礎上提供了可配置的打包選項(react-native bundle --config)。但由於0.55版本的打包配置還不完善,暫只支援 0.56 ~ 0.57 版本。不過從metro原始碼我們能看到,在0.52版本之後,metro的打包流程方式自身已經利用了 factory 的方式,所以我們可以從原始碼角度,進行修改,實現拆包。
在《RN JSBundle 拆分解決方案(2): JSBundle 、Metro 結構分析》章節中,我們介紹了利用 createModuleIdFactory 、processModuleFilter 來幫助我們將JSBundle拆分為基礎包和業務模組包。拆分的過程就需要我們通過配置 config 檔案來完成。自定義 config 檔案的方式其實就是利用了 hook 的思想。那麼我們是否能直接修改 createModuleIdFactory 方法呢?
找到 node_modules/metro/src/lib/createModuleIdFactory.js 檔案:
function createModuleIdFactory() {
const fileToIdMap = new Map();
let nextId = 0;
return path => {
let id = fileToIdMap.get(path);
if (typeof id !== 'number') {
id = nextId++;
fileToIdMap.set(path, id);
}
return id;
};
}
module.exports = createModuleIdFactory;
我們知道,createModuleIdFactory 用於生成 require 語句的模組ID,從上述原始碼也可以看出,系統使用整數型的方式,從0開始遍歷所有模組,並依次使 Id 增加 1。所以我們可以修改此處邏輯,以模組路徑名稱的方式作為Id即可。
/**
* 生成模組Id
* Create by Songlcy
*/
'use strict';
function createModuleIdFactory() {
// 定義專案根目錄路徑,此處以 Windows 平臺為例
const projectRootPath = 'C:\\Users\\songlcy\\splitBundleTest';
// path 為模組路徑名稱
return path => {
let moduleName = '';
if(path.indexOf('node_modules\\react-native\\Libraries\\') > 0) {
moduleName = path.substr(path.lastIndexOf('\\') + 1);
} else if(path.indexOf(projectRootPath)==0){
moduleName = path.substr(projectRootPath.length + 1);
}
// 可按照自己的方式優化路徑名稱顯示
moduleName = moduleName.replace('.js', '');
moduleName = moduleName.replace('.png', '');
moduleName = moduleName.replace('/\\/g', '_'); // 適配Windows平臺路徑問題
return moduleName;
};
}
module.exports = createModuleIdFactory;
在上述程式碼中,我們依據模組路徑 path,在其基礎上進行自定義路徑名稱,並作為 Id 返回。搞定以模組路徑名稱(唯一)方式進行打包的解決方案。簡單快捷。
0.56 ~ 最新 版本
隨著 Metro 打包工具的不斷完善,在 RN 版本 0.56 以上,已經支援了通過命令列 --config 配置檔案的方式進行hook,來實現自定義打包方式。所以我們只需要定義 config 檔案,並在命令列作為引數傳遞給 --config 即可。例如,我們定義了 base_bundle.config.js 檔案:
/**
* 生成模組Id
* Create by Songlcy
*/
'use strict';
function createModuleIdFactory() {
// 定義專案根目錄路徑,此處以 Windows 平臺為例
const projectRootPath = 'C:\\Users\\songlcy\\splitBundleTest';
// path 為模組路徑名稱
return path => {
let moduleName = '';
if(path.indexOf('node_modules\\react-native\\Libraries\\') > 0) {
moduleName = path.substr(path.lastIndexOf('\\') + 1);
} else if(path.indexOf(projectRootPath)==0){
moduleName = path.substr(projectRootPath.length + 1);
}
// 可按照自己的方式優化路徑名稱顯示
moduleName = moduleName.replace('.js', '');
moduleName = moduleName.replace('.png', '');
moduleName = moduleName.replace('/\\/g', '_'); // 適配Windows平臺路徑問題
return moduleName;
};
}
module.exports = {
/* serializer options */
serializer: {
createModuleIdFactory: createModuleIdFactory
}
};
上述config檔案中,實現內容與我們修改原始碼時基本相同,唯一是在匯出時,需要將自定義的 createModuleIdFactory() 方法作為serializer 的成員屬性,使用打包命令即可:
react-native bundle ..... --config base_bundle.config.js
以上三種解決方案就可以輕鬆幫助我們完成拆包過程中,固定Id的打包流程。接下來我們來看如何實現 JSBundle 的打包、載入。
二、JSBundle 基礎模組、業務模組打包
在完成了打包原始碼修改後,接下來就是要分別打出基礎模組與業務模組的Bundle檔案。在打包之前,需要我們分別定義好基礎模組與業務模組檔案,核心程式碼如下:
Common.js
import 'react';
import 'react-native';
FirstModule.js
export default class FirstModule extends Component {
render() {
return (
<View style={ styles.container }>
<Text>
RN業務模組1
</Text>
<Image
style={ styles.icon }
source={ require('../../images/icon_1.png') } />
</View>
)
}
}
在:《React Native 實現熱部署、差異化增量熱更新》的部落格中,我曾經介紹了通過 Google 開源工具 google-diff-patch 來打出差異包。此處其實同樣可以使用該開源工具來完成基礎包和業務包的打包。這次我們換一種更輕便,快捷的方式:使用 comm 命令。熟悉 Linux 作業系統的應該都不陌生,我們簡單瞭解下 comm 命令:
comm 可以用於兩個檔案之間的比較,它有一些選項可以用來調整輸出,以便執行交集、求差、以及差集操作。
- 交集:打印出兩個檔案所共有的行。
- 求差:打印出指定檔案所包含的且不相同的行。
- 差集:打印出包含在一個檔案中,但不包含在其他指定檔案中的行。
comm 命令提供了非常方便的操作,幫助我們比較兩個檔案間的不同,使用方式也很簡單:
語法
comm(選項)(引數)
選項
-1:不顯示在第一個檔案出現的內容; -2:不顯示在第二個檔案中出現的內容; -3:不顯示同時在兩個檔案中都出現的內容。
引數
- 檔案1:指定要比較的第一個有序檔案;
- 檔案2:指定要比較的第二個有序檔案。
藉助 comm 命令,指定對應引數就可以輕鬆的完成基礎包和業務包的拆分了(此處我在 git bash下執行):
假設我們已經通過 react-native bundle 將基礎包(common.bundle)和業務(first.bundle)包生成。可以看到我們通過 -2 -3 的引數來執行。-2表示不顯示在第二個檔案中出現的內容, -3表示不顯示同時在兩個檔案中都出現的內容,所以就可以生成業務模組獨有的程式碼。可以看到執行命令後,系統會在當前視窗打印出結果,而我們此時要生成對應的業務模組檔案,所以藉助 > 來完成:
此時對於JSBundle的拆分已經完成。
三、實現 JSBundle 檔案按需載入
在 《RN JSBundle 拆分解決方案(1): 應用啟動、檢視載入原理解析》中我們分析了 Android 端 React Native 載入渲染流程。MainApplication 中 建立 ReactNativeHost,指定 Bundle 載入,並在 onCreate() 方法中執行 SoLoader.init() 方法初始化 C++ 層模組。在 ReactActivity 中,通過 startReactApplication 執行 createReactContextInBackground() 方法實現 ReactContext 的建立及 Bundle 的載入邏輯。最終將檢視繫結,完成渲染。
RN載入 js 程式碼、繫結檢視的邏輯可以分開非同步執行,所以藉助這個優勢,可以輕鬆的實現載入基礎包、繫結檢視的分步執行。
(1)初始化 ReactContext 上下文環境,載入基礎包
載入基礎包之,首先需要初始化RN的執行環境。載入基礎包使公共的模組程式碼優先執行,不會涉及檢視的繫結渲染,後面載入業務包時就可以實現介面秒開。所以,可以在 Application 初始化 ReactNativeHost 時指定基礎包模組,並在某個合適的時機執行 createReactContextInBackground() 方法完成 ReactContext 初始化,基礎包的載入。核心程式碼如下:
/**
* 建立 ReactContext,載入基礎包 Bundle
*/
private void loadReactContext() {
Log.e("loadReactContext:", "RN C++ 層開始載入");
SoLoader.init(getApplication(), false);
Log.e("loadReactContext:", "RN C++ 層載入完成");
Log.e("loadReactContext:", "基礎 bundle 開始載入");
if(!rim.hasStartedCreatingInitialContext()) {
rim.addReactInstanceEventListener(new ReactInstanceManager.ReactInstanceEventListener() {
// 初始化react上下文時呼叫(所有模組都已註冊)。 總是呼叫UI執行緒。
@Override
public void onReactContextInitialized(ReactContext context) {
Log.e("onReactContextInit:", "基礎 bundle 載入完成");
Toast.makeText(MainActivity.this, "基礎 bundle 載入完成", Toast.LENGTH_SHORT).show();
Log.e(this.getClass().getName(), "子模組 bundle 開始載入");
loadSubModule();
Log.e("onReactContextInit:", "子模組 bundle 載入完成");
Toast.makeText(MainActivity.this, "子模組 bundle 載入完成", Toast.LENGTH_SHORT).show();
rim.removeReactInstanceEventListener(this);
}
});
}
rim.createReactContextInBackground();
}
(2)按需載入業務包
在(1)過程中,我們看到在 loadReactContext() 方法中在 ReactInstanceManager 註冊了 ReactInstanceEventListener,並監聽 onReactContextInitialized() 方法,該方法在 ReactContext 初始化完成後被系統回撥執行。在該方法中,我們呼叫了 loadSubModule() 方法來載入子模組,核心程式碼如下:
/**
* 預載入子模組 bundle
*/
private void loadSubModule() {
ScriptUtil.loadScriptFromAsset(this,rim.getCurrentReactContext().getCatalystInstance(),"first_diff.bundle", false);
// ScriptUtil.loadScriptFromAsset(this,rim.getCurrentReactContext().getCatalystInstance(),"second_diff.bundle", false);
}
loadSubModule() 方法中,可以根據實際情況,按需載入對應子模組。也可以不預先載入,當跳轉對應RN檢視介面時,再載入也可以。
/**
* 從Asset目錄下載入
* @param context
* @param instance
* @param assetURL
* @param loadSynchronously
*/
public static void loadScriptFromAsset(Context context,
CatalystInstance instance,
String assetURL,
boolean loadSynchronously) {
String bundleSource = assetURL;
if(!assetURL.contains("assets://")) {
bundleSource = "assets://" + assetURL;
}
((CatalystInstanceImpl) instance).loadScriptFromAssets(context.getAssets(),bundleSource,loadSynchronously);
}
loadScriptFromAsset() 方法中通過呼叫 CatalystInstance 例項的 loadScriptFromAssets() 方法完成對 Bundle 檔案的載入。
(3)繫結檢視,實現渲染
在原始碼分析中,我們知道在 ReactActivityDelegate 的 onCreate() 方法中,呼叫了 startApplication() 方法 完成 ReactContext 初始化及呼叫 attachToReactInstanceManager() 方法完成檢視繫結。所以,我們可以直接繼承 ReactActivity,實現的 getMainComponentName() 方法,返回具體的 子 Bundle 即可。
@SuppressLint("Registered")
public class FirstModuleRNActivity extends ReactActivity {
@Nullable
@Override
protected String getMainComponentName() {
return "FirstModule";
}
}
也可以自己封裝 ReactActivity,在 onCreate() 方法中擴充套件自己的業務載入邏輯等。例如:
/**
* ReactActivity 擴充套件,支援自動載入基礎包 Bundle
* Create by Songlcy
*/
public abstract class ScriptReactActivity extends Activity
implements DefaultHardwareBackBtnHandler, PermissionAwareActivity {
private final ReactActivityDelegate mDelegate;
protected ScriptReactActivity() {
mDelegate = createReactActivityDelegate();
}
protected @Nullable String getMainComponentName() {
return null;
}
protected ReactActivityDelegate createReactActivityDelegate() {
return new ReactActivityDelegate(this, getMainComponentName());
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
initialReactContext(savedInstanceState);
}
/**
* 初始化 ReactContext 上下文環境
*/
private void initialReactContext(final Bundle saveInstanceState) {
final ReactInstanceManager rim = getReactInstanceManager();
boolean hasStartedCreatingInitialContext = rim.hasStartedCreatingInitialContext();
if(!hasStartedCreatingInitialContext) {
rim.addReactInstanceEventListener(new ReactInstanceManager.ReactInstanceEventListener() {
@Override
public void onReactContextInitialized(ReactContext context) {
// 載入子模組,繫結檢視
loadJSBundle();
attachReactRootView(saveInstanceState);
rim.removeReactInstanceEventListener(this);
}
});
// 初始化 ReactContext, 載入基礎 bundle
rim.createReactContextInBackground();
} else {
// 載入子模組,繫結檢視
loadJSBundle();
attachReactRootView(saveInstanceState);
}
}
/**
* 載入 bundle
*/
private void loadJSBundle() {
ScriptUtil.loadScriptFromAsset(this,catalystInstance,bundlePath,false);
}
/**
* 繫結檢視
*/
private void attachReactRootView(Bundle savedInstanceState) {
mDelegate.onCreate(savedInstanceState);
}
程式碼省略...
}
總結
整體來看 RN 的拆包流程,最終還是要歸功於 RN 基於 javascript 設計的靈活性。分步的執行方式能夠讓我們輕鬆的將 Bundle 的載入、檢視的渲染分步進行,互不影響。從上述可見,JSBundle 的拆分所帶來的優勢還是非常明顯的。例如,我們延遲執行了 SoLoader.init(),createReactContextInBackground() 方法,實現 RN 上下文環境及基礎包的延遲載入。並且不利用反射的方式來實現子模組 Bundle 的按需載入,降低了反射所帶來的效能影響等等。
- react 和 react-native 打包成 common.bundle
- 減小線上下發業務bundle體積,節省使用者流量
- 可預載入 common.bundle,提升開啟頁面速度
程式碼已經封裝並上傳到了Github,大家可以參考具體的實現流程。
react-native-split-bundle:https://github.com/songxiaoliang/react-native-split-bundle