3

React Native SDK 升级问题及分包方案

 2 years ago
source link: https://segmentfault.com/a/1190000040511729
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

文章首发个人博客: 高先生的博客

我们团队一直是将 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");

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK