1. 程式人生 > >RN JSBundle 拆分解決方案(3): 固定模組 BundleId,JSBundle 按需載入

RN JSBundle 拆分解決方案(3): 固定模組 BundleId,JSBundle 按需載入

 

實踐原始碼:react-native-split-bundlehttps://github.com/songxiaoliang/react-native-split-bundle

前面兩篇文章分別從原始碼載入、Bundle檔案結構、Metro打包工具等做了簡單分析,逐步瞭解關於RN打包,Bundle 檔案載入的相關內容。本篇內容將結合程式碼來完成最終的實現方案。在閱讀該篇部落格內容前,可以點選如下檢視:

(1)RN 應用啟動、檢視載入原理解析(原始碼層)

(2)JSBundle 檔案、打包工具 Metro 的結構分析

《RN JSBundle 拆分解決方案(2): JSBundle 、Metro 結構分析》

中,我們圍繞 Bundle 檔案結構,Metro 打包工具做了很多的分析描述。實現拆包方案的核心原理,一句話概括的說,就是將 Bundle 檔案的 公共部分 業務部分 分離,分別進行載入,繫結。

從 .bundle 檔案的中,我們知道由於原始碼中使用 Number(int) 數值型 以 _d 的方式定義了程式碼模組ID,並使用 _r 的方式進行依賴行。如果存在模組間的改變或者修改,都有可能導致模組ID發生改變,導致舊的bundle檔案不能使用。所以在拆分公共部分與業務部分的過程中,需要我們解決模組間依賴的問題。

網上很多部落格介紹了基於 0.4 版本的解決方案。0.4版本較低,存在的已知問題較多,並且當前社群很多已經升為0.5以上,所以下程式碼會基於 0.5 實現。由於官方對 Metro 打包工具做了升級與改動,0.52

以下版本與 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-bundlehttps://github.com/songxiaoliang/react-native-split-bundle