DEV Community

Cover image for React Native SDK upgrade issues and split jsbundle
mrgao
mrgao

Posted on • Updated on

React Native SDK upgrade issues and split jsbundle

Background:

Our team has always integrated ReactNative (hereinafter referred to as RN) as a sub-module into the existing android/ios application; the original RN version used was 0.55 ; as the times change, RN has 0.65 the version; the upgrade span is larger ; Here I will give a brief summary of the problems encountered in the recent SDK upgrade.

Question 1: How to split RN bundle

Preface

In the previous version RN , metro does not currently support the use of processModuleFilter for module filtering; if you google for RN split bundle, you will find it difficult to have an article detailing how RN performs split bundle; this article will detail how to perform RN split bundle.

RN split bundle, in the new version of metro , in fact, most of us only need to pay attention to the two apis of metro:

  • createModuleIdFactory : Create a unique id for each module of RN;
  • processModuleFilter : Select which modules are needed for the current build

First, let's talk about how to give an Id name to a module. The name according to the id that comes with metro is self-increasing according to the number:

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

According to this, the moduleId will be incremented from 0 in turn;

Let's talk about processModuleFilter , a simplest processModuleFilter as follows:

function processModuleFilter(module) {
  return true;
}
Enter fullscreen mode Exit fullscreen mode

It means that all modules of RN are needed, and there is no need to filter some modules;

With the above foundation, let's start to consider how to split bundle the RN; I believe everyone is clear about the general situation. We divide the entire jsbundle into the common package and the bussiness package; the common package is generally built into the App; and the bussiness package It is issued dynamically. Following this line of thinking, let's start subcontracting;

common package split bundle scheme

As the name suggests, the package is a common resource for all RN pages. Generally, there are several requirements for common

  • module will not change frequently
  • module is universal
  • generally does not put all npm packages under node_modules in the base package

According to the above requirements, a project basis we will generally react , react-native , redux , react-redux and other changes infrequently general public npm package on package; So how do we divide public bag? There are generally two ways:

  • Scheme 1 [PASS]. to analyze service entry as an entry packet, in processModuleFilter (module.path) to manually remove the module through the module path past
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;
  };
}
Enter fullscreen mode Exit fullscreen mode

If you follow this way, trust me, you will definitely give up. Because it has a huge disadvantage: needs to manually handle the dependencies of packages such as react/react-native ; that is to say, it’s not that you wrote 4 modules and packaged these 4 modules. It is possible that these 4 modules depend on others. Module, so when running the common package, the basic package will directly report an error.

This led to the second plan:

Create a public package entry in the root directory and import the modules you need; use this entry when packaging.

Note: provides an entry file for the public package, so the code after packaging will report error Module AppRegistry is not registered callable module (calling runApplication) ; you need to manually delete the last line of code ;

For detailed code, please see: react-native-dynamic-load

  1. common-entry.js entry file
// some module that you want
import "react";
import "react-native";
require("react-native/Libraries/Core/checkNativeVersion");
Enter fullscreen mode Exit fullscreen mode
  1. can write createModuleIdFactory
function createCommonModuleIdFactory() {
  let nextId = 0;
  const fileToIdMap = new Map();

  return (path) => {
    if (!moduleIdByIndex) {
      const name = getModuleIdByName(base, path);
      const relPath = pathM.relative(base, path);
      if (!commonModules.includes(relPath)) {
        // record path
        commonModules.push(relPath);
        fs.writeFileSync(commonModulesFileName, JSON.stringify(commonModules));
      }
      return name;
    }
    let id = fileToIdMap.get(path);

    if (typeof id !== "number") {
      // Use numbers for module id, and record the path and id for subsequent subcontracting of business packages, and filter out public packages
      id = nextId + 1;
      nextId = nextId + 1;
      fileToIdMap.set(path, id);
      const relPath = pathM.relative(base, path);
      if (!commonModulesIndexMap[relPath]) {
        commonModulesIndexMap[relPath] = id;
        fs.writeFileSync(
          commonModulesIndexMapFileName,
          JSON.stringify(commonModulesIndexMap)
        );
      }
    }
    return id;
  };
}
Enter fullscreen mode Exit fullscreen mode
  1. write 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,
      },
    }),
  },
};
Enter fullscreen mode Exit fullscreen mode
  1. Run packaging command
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
Enter fullscreen mode Exit fullscreen mode

be careful:

  1. above does not use processModuleFilter , since for common-entry.js inlet, all modules are required;

  2. There are two ways to generate moduleId in the above implementation: one is a number, the other is a path; the difference between the two is not big, but it is recommended to use a number. The reasons are as follows:

  • The number is smaller than the string, the smaller the bundle size;
  • Multiple modules may have the same name, and the use of strings may cause module conflicts in multiple modules; if you use numbers, you won’t, because the numbers are random;
  1. Numbers are more secure, if the app is attacked, it is impossible to know exactly which module the code is

business package and split bundle plan

I talked about the subcontracting of the public package. When the public package is subcontracted, the module path and module id in the public package will be recorded; for example:

{
  "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
  // ...
}
Enter fullscreen mode Exit fullscreen mode

In this way, when subcontracting the business package, you can judge by the path whether the current module is already in the basic package, if it is in the public package, use the corresponding id directly; otherwise, use the logic of business package subcontracting;

  1. write createModuleIdFactory
function createModuleIdFactory() {
  // Why use a random number? It is to avoid rn module conflicts in singleton mode due to the same moduleId
  let nextId = randomNum;
  const fileToIdMap = new Map();

  return (path) => {
    // Use name as id
    if (!moduleIdByIndex) {
      const name = getModuleIdByName(base, path);
      return name;
    }
    const relPath = pathM.relative(base, path);
    // Whether the current module is already in the basic package, if it is in the public package, use the corresponding id directly; otherwise, use the logic of business package split bundle
    if (commonModulesIndexMap[relPath]) {
      return commonModulesIndexMap[relPath];
    }

    let id = fileToIdMap.get(path);
    if (typeof id !== "number") {
      id = nextId + 1;
      nextId = nextId + 1;
      fileToIdMap.set(path, id);
    }
    return id;
  };
}
Enter fullscreen mode Exit fullscreen mode
  1. Write to filter the specified module
// processModuleFilter
function processModuleFilter(module) {
  const { path } = module;
  const relPath = pathM.relative(base, path);
  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;
  }
  if (!moduleIdByIndex) {
    if (commonModules.includes(relPath)) {
      return false;
    }
  } else {
    // The modules in the public package are directly filtered out
    if (commonModulesIndexMap[relPath]) {
      return false;
    }
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode
  1. Run commands to package
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
Enter fullscreen mode Exit fullscreen mode

The packaged effect is as follows:

// 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);
Enter fullscreen mode Exit fullscreen mode

General code for subcontracting

How RN performs dynamic subcontracting and dynamic loading, please see: https://github.com/MrGaoGang/react-native-dynamic-load

Question 2: Cookie expiration problem

background

To Android for example, the common will Cookie use android of CookieManager manage; but we did not use it for internal management; the 0.55 version of the initialization time when you can set up a 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());
Enter fullscreen mode Exit fullscreen mode

Among them, ReactCookieProxyImpl can be implemented by yourself, or you can control how the Cookie is written to RN;

But in the latest RN, okhttp is used for network request, and andrid's CookieManager used for management; the code is as follows:

// 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();
}
}
Enter fullscreen mode Exit fullscreen mode

Then there is no use android.CookieManagercase of how toReactNativeinjectionCookie it?

solution

  1. One possible idea is that clients have their own CookieManager when synchronizing update android.CookieManager ; but this scheme is the need for client support students;
  2. The client gets the cookie and passes it to RN, and RN uses jsb to pass the cookie to android/ios

We adopted the second option:

  1. The first step, the client will cookie by props passed to the RN

Bundle bundle = new Bundle();
// get cookie in native
String cookie = WebUtil.getCookie("https://example.a.com");
bundle.putString("Cookie", cookie);
// start
rootView.startReactApplication(manager, jsComponentName, bundle);

Enter fullscreen mode Exit fullscreen mode
  1. The second step, RN gets the Cookie
// this.props is the rn root component props
document.cookie = this.props.Cookie;
Enter fullscreen mode Exit fullscreen mode
  1. The third step is to set cookies to the client
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);
  });
}
Enter fullscreen mode Exit fullscreen mode

The premise of use is that the client already has a corresponding native module. For details, please see:

https://github.com/MrGaoGang/cookies

Among them, the version of the rn community is mainly modified. Android cookies cannot be set at one time. You need to set

    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);
        }
    }
Enter fullscreen mode Exit fullscreen mode

Question 3: Window isolation problem in singleton mode

Background In the RN singleton mode, if each page uses the window for global data management, the data needs to be isolated; the industry-wide method is to use the micro front end qiankun to proxy window This is indeed a good method, but it may be more responsible in RN; the method used by the author is:

Use babel to replace global variables, so that you can ensure that for different pages, setting and using window have different effects on the following; for example:

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

The code after escaping is:

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");
Enter fullscreen mode Exit fullscreen mode

Top comments (0)