React Native SDK 升级问题及分包方案
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
common-entry.js
入口文件
// 按照你的需求导入你所需的放入公共包中的npm 模块 import "react"; import "react-native"; require("react-native/Libraries/Core/checkNativeVersion");
编写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; }; }
编写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, }, }), }, };
运行打包命令
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
- 上面并没有使用
processModuleFilter
,因为针对common-entry.js
为入口而言,所有的模块都是需要的; - 上面实现了两种方式生成 moduleId:一种是以数字的方式,一种是以路径的方式;两者区别都不大,但是建议使用数字的方式。原因如下:
- 数字相比字符串更小,bundle 体积越小;
- 多个 module 可能因为名称相同,使用字符串的方式会造成多个 module 可能会存在模块冲突的问题;如果使用数字则不会,因为数字是使用随机的;
- 数字更加安全,如果 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;否则使用业务包分包的逻辑;
- 编写 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; }; }
- 编写对指定的模块进行过滤
// 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; }
- 运行命令进行打包
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
使用 android
的 CookieManager
进行管理;但是我们内部却没有使用其进行管理;在 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
呢?
- 一个可行的思路是客户端是有自己的
CookieManager
的时候,同步更新android.CookieManager
; 但是此方案是需要客户端同学的支持; - 客户端拿到 cookie,传递给 RN,RN 使用 jsb 将 cookie 传递给
android/ios
我们采用的是方案二:
- 第一步,客户端将
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);
- 第二步, RN 拿到 Cookie
// this.props是RN 根组件的props document.cookie = this.props.Cookie;
- 第三步,设置 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 进行全局数据的管理,则需要对数据进行隔离;业界通用的方式是使用微前端qiankun
对window
进行 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");
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK