React Native SDK 升級問題及分包方案

mrgaogang發表於2021-08-13

文章首發個人部落格: 高先生的部落格

背景:

我們團隊一直是將 ReactNative(下文簡稱 RN)當做一個子模組整合到現有的 android/ios 應用中;起初使用的 RN 版本是 0.55;隨著時代的變遷,RN 已經到 0.65 的版本了;升級跨度較大;下面我這邊就最近 SDK 升級所遇到的問題進行一個簡單的總結。

問題 1: RN 如何進行分包

前言

在之前的舊版本 RN 中的 metro 暫時還不支援使用processModuleFilter 進行模組過濾;如果你 google 一下 RN 分包,會發現很難有一篇文章詳細去介紹 RN 怎麼進行分包;本文將詳細講述如何進行 RN 分包;

RN 分包,在新版的 metro 中其實大多數我們只需要關注 metro 的兩個 api:

  • createModuleIdFactory: 給 RN 的每個模組建立一個唯一的 id;
  • processModuleFilter: 選擇當前構建需要哪些模組

首先我們來談一談如何給給個模組取一個 Id 名稱,按照 metro 自帶的 id 取名是按照數字進行自增長的:

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;
  };
}

按照這樣,moduleId 會依次從 0 開始進行遞增;

我們再來談一談processModuleFilter,一個最簡單的processModuleFilter如下:

function processModuleFilter(module) {
  return true;
}

意味著 RN 的所有模組都是需要的,無需過濾一些模組;

有了上面的基礎,下面我們開始著手考慮如何進行 RN 分包了;相信大家都比較清楚一般的情況我們將整個 jsbundle 分為common包和bussiness包;common 包一般會內建到 App 內;而 bussiness 包則是動態下發的。按照這樣的思路下面我們開始分包;

common 包分包方案

顧名思義 common 包是所有的 RN 頁面都會公用的資源,一般抽離公共包有幾個要求:

  • 模組不會經常變動
  • 模組是通用的
  • 一般不會將 node_modules 下的所有 npm 包都放在基礎包中

按照上面的要求,一個基礎的專案我們一般會react,react-native,redux,react-redux等不常更改的通用 npm 包放在公共包中;那麼我們如何進行分公共包呢?一般有兩種方式:

  • 方案 1【PASS】. 以業務入口為入口進行包的分析,在processModuleFilter中透過過去模組路徑(module.path)手動移除相關模組
const commonModules = ["react", "react-native", "redux", "react-redux"];
function processModuleFilter(type) {
  return (module) => {
    if (module.path.indexOf("__prelude__") !== -1) {
      return true;
    }
    for (const ele of commonModules) {
      if (module.path.indexOf(`node_modules/${ele}/`) !== -1) {
        return true;
      }
    }
    return false;
  };
}

如果你按照這樣的方式,相信我,你一定會放棄的。因為其有一個巨大的缺點:需要手動處理 react/react-native 等包的依賴;也就是說不是你寫了 4 個模組打包後就是這 4 個模組,有可能這 4 個模組依賴了其他的模組,所以在執行 common 包的時候,基礎包會直接報錯。

由此推出了第二個方案:

在根目錄下建立一個公共包的入口,匯入你所需要的模組;在打包的時候使用此入口即可。

注意點: 由於給公共包一個入口檔案,這樣打包之後的程式碼執行會報錯Module AppRegistry is not registered callable module (calling runApplication);需要手動刪除最後一行程式碼;

詳細程式碼請見:react-native-dynamic-load

  1. common-entry.js入口檔案
// 按照你的需求匯入你所需的放入公共包中的npm 模組
import "react";
import "react-native";
require("react-native/Libraries/Core/checkNativeVersion");
  1. 編寫createModuleIdFactory即可
function createCommonModuleIdFactory() {
  let nextId = 0;
  const fileToIdMap = new Map();

  return (path) => {
    // module id使用名稱作為唯一表示
    if (!moduleIdByIndex) {
      const name = getModuleIdByName(base, path);
      const relPath = pathM.relative(base, path);
      if (!commonModules.includes(relPath)) {
        // 記錄路徑
        commonModules.push(relPath);
        fs.writeFileSync(commonModulesFileName, JSON.stringify(commonModules));
      }
      return name;
    }
    let id = fileToIdMap.get(path);

    if (typeof id !== "number") {
      // 使用數字進行模組id,並將路徑和id進行記錄下來,以供後面業務包進行分包使用,過濾出公共包
      id = nextId + 1;
      nextId = nextId + 1;
      fileToIdMap.set(path, id);
      const relPath = pathM.relative(base, path);
      if (!commonModulesIndexMap[relPath]) {
        // 記錄路徑和id的關係
        commonModulesIndexMap[relPath] = id;
        fs.writeFileSync(
          commonModulesIndexMapFileName,
          JSON.stringify(commonModulesIndexMap)
        );
      }
    }
    return id;
  };
}
  1. 編寫metro.common.config.js
const metroCfg = require("./compile/metro-base");
metroCfg.clearFileInfo();
module.exports = {
  serializer: {
    createModuleIdFactory: metroCfg.createCommonModuleIdFactory,
  },
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
};
  1. 執行打包命令
react-native bundle --platform android --dev false --entry-file  common-entry.js --bundle-output android/app/src/main/assets/common.android.bundle --assets-dest android/app/src/main/assets --config ./metro.base.config.js --reset-cache && node ./compile/split-common.js android/app/src/main/assets/common.android.bundle

注意點:

  1. 上面並沒有使用processModuleFilter,因為針對common-entry.js為入口而言,所有的模組都是需要的;
  2. 上面實現了兩種方式生成 moduleId:一種是以數字的方式,一種是以路徑的方式;兩者區別都不大,但是建議使用數字的方式。原因如下:
  • 數字相比字串更小,bundle 體積越小;
  • 多個 module 可能因為名稱相同,使用字串的方式會造成多個 module 可能會存在模組衝突的問題;如果使用數字則不會,因為數字是使用隨機的;
  1. 數字更加安全,如果 app 被攻擊則無法準確知道程式碼是那個模組

business 包分包方案

前面談到了公共包的分包,在公共包分包的時候會將公共包中的模組路徑和模組 id 進行記錄;比如:

{
  "common-entry.js": 1,
  "node_modules/react/index.js": 2,
  "node_modules/react/cjs/react.production.min.js": 3,
  "node_modules/object-assign/index.js": 4,
  "node_modules/@babel/runtime/helpers/extends.js": 5,
  "node_modules/react-native/index.js": 6,
  "node_modules/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.android.js": 7,
  "node_modules/@babel/runtime/helpers/interopRequireDefault.js": 8,
  "node_modules/react-native/Libraries/EventEmitter/RCTDeviceEventEmitter.js": 9
  // ...
}

這樣在分業務包的時候,則可以透過路徑的方式判斷,當前模組是否已經在基礎包中,如果在公共包中則直接使用對應的 id;否則使用業務包分包的邏輯;

  1. 編寫 createModuleIdFactory
function createModuleIdFactory() {
  // 為什麼使用一個隨機數?是為了避免因為moduleId相同導致單例模式下rn module衝突問題
  let nextId = randomNum;
  const fileToIdMap = new Map();

  return (path) => {
    // 使用name的方式作為id
    if (!moduleIdByIndex) {
      const name = getModuleIdByName(base, path);
      return name;
    }
    const relPath = pathM.relative(base, path);
    // 當前模組是否已經在基礎包中,如果在公共包中則直接使用對應的id;否則使用業務包分包的邏輯
    if (commonModulesIndexMap[relPath]) {
      return commonModulesIndexMap[relPath];
    }
    // 業務包的Id
    let id = fileToIdMap.get(path);
    if (typeof id !== "number") {
      id = nextId + 1;
      nextId = nextId + 1;
      fileToIdMap.set(path, id);
    }
    return id;
  };
}
  1. 編寫對指定的模組進行過濾
// processModuleFilter
function processModuleFilter(module) {
  const { path } = module;
  const relPath = pathM.relative(base, path);
  // 一些簡單通用的已經放在common包中了
  if (
    path.indexOf("__prelude__") !== -1 ||
    path.indexOf("/node_modules/react-native/Libraries/polyfills") !== -1 ||
    path.indexOf("source-map") !== -1 ||
    path.indexOf("/node_modules/metro-runtime/src/polyfills/require.js") !== -1
  ) {
    return false;
  }
  // 使用name的情況
  if (!moduleIdByIndex) {
    if (commonModules.includes(relPath)) {
      return false;
    }
  } else {
    // 在公共包中的模組,則直接過濾掉
    if (commonModulesIndexMap[relPath]) {
      return false;
    }
  }
  // 否則其他的情況則是業務包中
  return true;
}
  1. 執行命令進行打包
react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/business.android.bundle --assets-dest android/app/src/main/assets --config ./metro.business.config.js  --reset-cache

打包後的效果如下:

// bussiness.android.js
__d(function(g,r,i,a,m,e,d){var t=r(d[0]),n=r(d[1])(r(d[2]));t.AppRegistry.registerComponent('ReactNativeDynamic',function(){return n.default})},832929992,[6,8,832929993]);
// ...
__d(function(g,r,i,a,m,e,d){Object.defineProperty(e,"__esModule",
__r(832929992);

分包通用程式碼

RN 如何進行動態分包及動態載入,請見:https://github.com/MrGaoGang/react-native-dynamic-load

問題 2: Cookie 失效問題

背景

Android 為例,常見會將 Cookie 使用 androidCookieManager 進行管理;但是我們內部卻沒有使用其進行管理;在 0.55 的版本的時候在初始化 RN 的時候可以設定一個 CookieProxy:

        ReactInstanceManagerBuilder builder = ReactInstanceManager.builder()
                .setApplication(application)
                .setUseDeveloperSupport(DebugSwitch.RN_DEV)
                .setJavaScriptExecutorFactory(null)
                .setUIImplementationProvider(new UIImplementationProvider())
                .setNativeModuleCallExceptionHandler(new NowExceptionHandler())
                .setInitialLifecycleState(LifecycleState.BEFORE_CREATE);
                .setReactCookieProxy(new ReactCookieProxyImpl());

其中 ReactCookieProxyImpl是可以自己進行實現的,也可以自己控制 Cookie 如何寫入 RN;

但是在最新的 RN 裡面,是使用 okhttp 進行網路請求的, 且使用的是 andrid 的 CookieManager 進行管理;程式碼如下:

// OkHttpClientProvider
    OkHttpClient.Builder client = new OkHttpClient.Builder()
      .connectTimeout(0, TimeUnit.MILLISECONDS)
      .readTimeout(0, TimeUnit.MILLISECONDS)
      .writeTimeout(0, TimeUnit.MILLISECONDS)
      .cookieJar(new ReactCookieJarContainer());

// ReactCookieJarContainer
public class ReactCookieJarContainer implements CookieJarContainer {

  @Nullable
  private CookieJar cookieJar = null;

  @Override
  public void setCookieJar(CookieJar cookieJar) {
    this.cookieJar = cookieJar;
  }

  @Override
  public void removeCookieJar() {
    this.cookieJar = null;
  }

  @Override
  public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
    if (cookieJar != null) {
      cookieJar.saveFromResponse(url, cookies);
    }
  }

  @Override
  public List<Cookie> loadForRequest(HttpUrl url) {
    if (cookieJar != null) {
      List<Cookie> cookies = cookieJar.loadForRequest(url);
      ArrayList<Cookie> validatedCookies = new ArrayList<>();
      for (Cookie cookie : cookies) {
        try {
          Headers.Builder cookieChecker = new Headers.Builder();
          cookieChecker.add(cookie.name(), cookie.value());
          validatedCookies.add(cookie);
        } catch (IllegalArgumentException ignored) {
        }
      }
      return validatedCookies;
    }
    return Collections.emptyList();
  }
}

那麼在 沒有使用android.CookieManager 的情況下,如何給 ReactNative 注入 Cookie 呢?

解決方案

  1. 一個可行的思路是客戶端是有自己的 CookieManager 的時候,同步更新 android.CookieManager; 但是此方案是需要客戶端同學的支援;
  2. 客戶端拿到 cookie,傳遞給 RN,RN 使用 jsb 將 cookie 傳遞給 android/ios

我們採用的是方案二:

  1. 第一步,客戶端將 cookie 透過 props 傳遞給 RN
Bundle bundle = new Bundle();
// 獲取cookie,因為跨程式獲取cookie,所以一般來說是會出現問題的,重新種一次要
String cookie = WebUtil.getCookie("https://example.a.com");
bundle.putString("Cookie", cookie);

// 啟動的時候
rootView.startReactApplication(manager, jsComponentName, bundle);
  1. 第二步, RN 拿到 Cookie
// this.props是RN  根元件的props
document.cookie = this.props.Cookie;
  1. 第三步,設定 Cookie 給客戶端
const { RNCookieManagerAndroid } = NativeModules;
if (Platform.OS === "android") {
  RNCookieManagerAndroid.setFromResponse(
    "https://example.a.com",
    `${document.cookie}`
  ).then((res) => {
    // `res` will be true or false depending on success.
    console.log("RN_NOW: 設定CookieManager.setFromResponse =>", res);
  });
}

使用的前提是客戶端已經有對應的 native 模組了,詳細請見:

https://github.com/MrGaoGang/cookies

其中相對 rn 社群的版本主要修改,android 端 cookie 不能一次性設定,需要逐個設定

    private void addCookies(String url, String cookieString, final Promise promise) {
        try {
            CookieManager cookieManager = getCookieManager();
            if (USES_LEGACY_STORE) {
                // cookieManager.setCookie(url, cookieString);
                String[] values = cookieString.split(";");
                for (String value : values) {
                    cookieManager.setCookie(url, value);
                }
                mCookieSyncManager.sync();
                promise.resolve(true);
            } else {
                // cookieManager.setCookie(url, cookieString, new ValueCallback<Boolean>() {
                //     @Override
                //     public void onReceiveValue(Boolean value) {
                //         promise.resolve(value);
                //     }
                // });
                String[] values = cookieString.split(";");
                for (String value : values) {
                    cookieManager.setCookie(url, value);
                }
                promise.resolve(true);

                cookieManager.flush();
            }
        } catch (Exception e) {
            promise.reject(e);
        }
    }

問題 3: 單例模式下 window 隔離問題

背景在 RN 單例模式下,每一個頁面如果有使用到 window 進行全域性資料的管理,則需要對資料進行隔離;業界通用的方式是使用微前端qiankunwindow進行 Proxy。這的確是一個好方法,但是在 RN 中也許較為負責;筆者採用的方式是:

使用 babel 進行全域性變數替換,這樣可以保證對於不同的頁面,設定和使用 window 即在不同的作用於下面;比如:
// 業務程式碼
window.rnid = (clientInfo && clientInfo.rnid) || 0;
window.bundleRoot = (clientInfo && clientInfo.bundleRoot) || "";
window.clientInfo = clientInfo;
window.localStorage = localStorage = {
  getItem: () => {},
  setItem: () => {},
};
localStorage.getItem("test");

轉義之後的程式碼為:

import _window from "babel-plugin-js-global-variable-replace-babel7/lib/components/window.js";

_window.window.rnid = (clientInfo && clientInfo.rnid) || 0;
_window.window.bundleRoot = (clientInfo && clientInfo.bundleRoot) || "";
_window.window.clientInfo = clientInfo;
_window.window.localStorage = _window.localStorage = {
  getItem: () => {},
  setItem: () => {},
};

_window.localStorage.getItem("test");

相關文章