文章首發個人部落格: 高先生的部落格
背景:
我們團隊一直是將 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");