React Native(二):分包機制與動態下發

React Native(二):分包機制與動態下發

前言

隨着 Flutter 的出現,React Native 的熱度在逐漸下降,而 facebook 自己對於 React Native 的重構也在進行當中。以目前的狀況來講,React Native 要開發一個完整的應用程序,或者成爲應用程序的一部分,都須要開發者可以瞭解兩側客戶端的實現機制,由於不少的依賴都須要注入到客戶端代碼當中,更不要說橋接 native 和 React Native 的 bridge 了。java

在這種場景下,React Native 仍舊在不少大型 app 裏面都有着本身的一席之地。熱更新機制依舊是 React Native 最靈活,也是讓人難以割捨的優勢。node

對比

提到 React Native,就不得不提到 Flutter。可是於我看來,二者致力於解決的方向並不相同。Flutter 更但願可以統一兩端的開發體驗,讓一套代碼能夠不加修改的直接在兩端運行。而 React Native 則是能夠解決傳統 Hybrid App 的劣勢,那就是 H5 的體驗。即便在高端移動端設備上,H5 都很難達到近似於 native 的體驗,更別說大部分用戶都還在使用中端、甚至低端機型了。python

爲了保證這些用戶的體驗,就不得不犧牲掉部分靈活性,採用 React Native 的方案,來保證能夠進行動態化更新以及向下兼容。畢竟大部分時候,客戶端須要提供給 H5 或者是 React Native 的絕大部分功能在接入的開始都會肯定好。從開始接入 React Native 的 native 版本開始,就可讓代碼近乎無痛兼容。react

場景

React Native 的使用場景通常有如下幾種:android

  • 完整的 app 都用 React Native 來進行開發:

這種方案比較適合我的開發者。移動端應用不可能放棄兩個平臺中的任何一個,對於我的開發者而言,同時學習 objective-c / swift 以及 kotlin / java 的成本過高,而且不可以保證迭代速度。因此,全站 React Native 便成爲了一種可行的選擇,也是比較好的選擇。既可以給到用戶較好的體驗,也可以保證迭代效率。固然,在 Flutter 大行其道的今天,更多的開發者採用 Flutter 來代替 React Native 進行兩端統一開發,Flutter 的坑更少,兩端的體驗更加統一,開發體驗也要優於目前的 React Native 版本。ios

  • 部分動態化:

這種方案在不少大型 app 中都有實踐,包括我曾經接觸到的千萬 DAU 的產品。在某些業務場景下,咱們既須要能夠脫離客戶端的版本進行獨立更新,又須要較好的用戶體驗。這時就須要基於 React Native 來進行開發。通常都是在客戶端的某個 view 上掛載 React Native 的 RCTRootView,將整個 view 經過 React Native 進行渲染。git

這種場景主要是某個業務模塊,徹底使用 React Native 來進行開發,經過分包機制,將 React Native 的基礎庫打到一個 bundle 中,而後將業務代碼打到另一個 bundle 中,兩個 bundle 獨立更新,由於基礎庫的更新並不頻繁,而業務代碼的更新可能會更加頻繁一些。github

關於分包和下發會在後文中說到。objective-c

  • (極致的)動態化 -- 跟隨數據下發:

這種方案並非一個常見的方案,也算是咱們根據本身的業務場景找到的一個解決方案,也是一個本身想到的解決思路。shell

和上一個解決方案相似,不過,有時候可能咱們不須要整個 view 都是 React Native 來進行管理,由於 React Native 的長列表性能並非很是理想。做爲 feed 流這類場景會產生很大的問題,好比 android 機型內存消耗過大,或者是崩潰閃退等問題。

並且,有些時候,feed 流中的內容動態化會比較強,若是經過發版來知足要求,有極可能跟不上節奏。

好比在箭頭所指的地方,插入一個奇形怪狀的廣告(以掘金爲例,圖片侵刪)

因此咱們考慮將 feed 流中的部分動態化程度較高的 cell 經過 React Native 來進行渲染。最初的方案和上一個方案是一致的,咱們將業務 cell 的代碼進行分包,打包在業務包中。若是有了新的 cell,經過更新業務包的方式,來讓用戶可以渲染新的 cell 內容。


若是這樣就太平平無奇了~~


首先考慮這樣下發會存在的問題,當咱們啓動 app,進入 feed 的時候,咱們的 app 檢測到業務包有變化,則會去 CDN 來拉取業務包,而後加載,再經過 JSCore 來執行業務包中的 JavaScript 代碼,這樣咱們纔可以進行渲染。彷佛看起來沒有什麼問題,可是因爲 feed 流的其餘模塊採用 native 進行渲染,一旦產生更新,業務包拉取速度比較慢的時候,React Native 的 cell 會白屏很長時間,而且可能會因爲包中的某個 cell 模塊的 error 致使其餘 cell 渲染錯誤。

因而咱們採用了一種新的方案:將每一個 cell 的業務包分離,對於 bundle 進行 zip 壓縮以後,進行 base64 編碼,而後,跟隨每一個 cell 的渲染數據一塊兒下發。

這樣作的好處在於:

  1. bundle 隨着業務數據下發,因爲每一個業務包都很小,因此解壓以及加載的時間很短,基本能夠保證數據加載完成,便可完成 cell 的渲染。

  2. 每一個 bundle 做用域隔離,一個 bundle 報錯不會影響到其餘 bundle 代碼的執行。

  3. bundle 和 cell 的業務一一對應,若是某一個 cell 的樣式或者功能須要更新,只須要配置一個新的 bundle 存起來就能夠了,後臺在下發新的數據的時候,就能夠直接拉取到新的 bundle,不須要每一個都更新整個業務包。

固然,對於幾乎全部問題來講,都沒有銀彈,這個解決方案也存在本身的問題,我會在文章的最後,說一下我遇到的問題。

分包

React Native 的動態化方案,都難以脫離一個分包。React Native 的基礎庫不小,再加上咱們須要的一些依賴,好比 React-Native-Vector-Icons 這樣的公共依賴,這些不常改變的依賴,須要和常常變化的業務代碼分離,壓縮業務代碼的大小,保證業務包在進行更新時候的最優。

metro

React Native 很早就提供了 metro 來進行 bundle 的分包。使用 metro 來進行打包,須要配置一個 metro.config.js 文件來進行分包。

這裏是官方的配置文檔

文檔看起來選項很是多,不過不少都是針對特定的場景的。

咱們分包須要用的選項主要是兩個:

  • createModuleIdFactory:這個函數傳入要打包的 module 文件的絕對路徑,返回這個 module 在打包的時候生成的 id。
  • processModuleFilter:這個函數傳入 module 信息,返回一個 boolean 值,false 則表示這個文件不打入當前的包。

分包策略

咱們的分包策略是這樣的:

  • common.bundle:打入全部的公共依賴庫,這個依賴庫跟隨客戶端版本下發,不進行熱更新;
  • business.bundle:大型業務模塊的業務代碼庫,這個包的數量和業務模塊數量相關,也就是第一節中說到的部分動態化場景中用到的業務包。
  • RN-xxx.bundle:feed 流中不一樣 cell 的業務包,根據 cell 的種類不一樣,能夠有多個。也就是第一節中說到的跟隨數據下發場景中用到的業務包。

其中,common.bundlebusiness.bundle 是預先打包在客戶端代碼中的,由於這兩個包較大,而 business.bundle 支持動態化下發。common.bundle 體積過大,仍是安安心心放到客戶端代碼中吧,不然下發成本太大。而且,大部分時候,若是你須要增長一個新的 React Native 依賴,就不得不在客戶端中增長相應的客戶端依賴代碼(就是你執行 react-native link 的時候,會在客戶端中添加的客戶端依賴),因此 common.bundle 跟着客戶端發版也是很合理的事情。

分包

主包(common.bundle)

由於 metro 的配置能夠將依賴進行分離,因此首先將須要打包到 common.bundle 中的代碼引入到一個文件中:

// common.js
import {} from 'react';
import {} from 'react-native';
import {} from 'react-redux';
import Sentry from 'react-native-sentry';

// 還能夠增長一些公共代碼,好比統一監控之類的
Sentry.config('dsn').install();
複製代碼

相應的,common.bundle 須要有一個配置文件:

'use strict';

const fs = require('fs');
const pathSep = require('path').sep;

function manifest (path) {
    if (path.length) {
        const manifestFile = `./dist/common_manifest_${process.env.PLATFORM}.txt`;
        if (!fs.existsSync(manifestFile)) {
            fs.writeFileSync(manifestFile, path);
        } else {
            fs.appendFileSync(manifestFile, '\n' + path);
        }
    }
}

function processModuleFilter(module) {
    if (module['path'].indexOf('__prelude__') >= 0) {
        return false;
    }
    manifest(module['path']);
    return true;
}

function createModuleIdFactory () {
    return path => {
        let name = '';
        if (path.startsWith(__dirname)) {
            name = path.substr(__dirname.length + 1);
        }
        let regExp = pathSep == '\\' ?
            new RegExp('\\\\', "gm") :
            new RegExp(pathSep, "gm");
        return name.replace(regExp, '_');
    }
}

module.exports = {
    serializer: {
        createModuleIdFactory,
        processModuleFilter
    }
};
複製代碼

完成打包的配置以後,執行:

node node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file ./common.js --bundle-output ./dist/common.bundle --config ./common.config.js
複製代碼

這段命令很長,你能夠根據本身的需求,寫到 shellpython 或者 package.json 腳本中。

這樣,咱們就獲得了兩個文件:

  • common.bundle:全部的 common.js 中引入的公共依賴,都會被打包到這個 bundle 裏面,後續在客戶端進行引入的時候,首先引入這個 bundle 就能夠了。
  • common_manifest_ios(android).txt:保存了主包中的依賴信息,這樣在打業務包的時候,經過讀取這個文件的內容,就能夠識別主包中已經打入的依賴,不進行重複打包了。
業務包

全部的業務包,包含跟隨請求一塊兒下發的業務包,以及跟隨客戶端版本,或者 patch 下發的業務包的打包流程都是一致的,能夠根據本身的需求進行修改。

// 咱們這裏用 charts 做爲咱們須要打包的業務包名字,固然你能夠根據需求來隨便起名~
// charts.js

import React from 'react';
import { AppRegistry, View } from 'react-native';

export default class Charts extends React.Component {
    render() {
        return (
            <View>
                <Text>Charts</Text>
            </View>
        );
    }
};

AppRegistry.registerComponent('charts', () => Charts);
複製代碼

打包配置:

// business.config.js
'use strict'
const fs = require('fs');

const pathSep = require('path').sep;
var commonModules = null;

function isInManifest (path) {
    const manifestFile = `./dist/common_manifest_${process.env.PLATFORM}.txt`;

    if (commonModules === null && fs.existsSync(manifestFile)) {
        const lines = String(fs.readFileSync(manifestFile)).split('\n').filter(line => line.length > 0);
        commonModules = new Set(lines);
    } else if (commonModules === null) {
        commonModules = new Set();
    }

    if (commonModules.has(path)) {
        return true;
    }

    return false;
}

function processModuleFilter(module) {
    if (module['path'].indexOf('__prelude__') >= 0) {
        return false;
    }
    if (isInManifest(module['path'])) {
        return false;
    }
    return true;
}

function createModuleIdFactory() {
    return path => {
        let name = '';
        if (path.startsWith(__dirname)) {
            name = path.substr(__dirname.length + 1);
        }
        let regExp = pathSep == '\\' ?
            new RegExp('\\\\',"gm") :
            new RegExp(pathSep,"gm");
        
        return name.replace(regExp,'_');
    };
}


module.exports = {
    serializer: {
        createModuleIdFactory,
        processModuleFilter,
    }
};
複製代碼

業務包的打包配置和 common.bundle 很是相似,有一點不一樣在於,打包到 common.bundle 中的依賴須要在業務包打包的時候進行過濾,不然在進行業務包下發的時候會致使業務包體積過大。

咱們經過上面的 processModuleFilter 來進行過濾,返回當前的 path 是否位於剛纔的 manifest 文件中,判斷是否進行過濾。

完成配置後,執行:

node node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file ./src/charts.js --bundle-output ./dist/charts.bundle --config ./business.config.js
複製代碼

就能夠獲得一個很是精簡的業務代碼包了。上面的那一小段代碼打包出來應該只有不到 1 KB,相對於下發一個 H5 動輒幾 KB 到幾十 KB 的大小來講,能夠說是很是節約網絡資源了。

壓縮前:

壓縮後:

能夠看到,在進行了 zip 壓縮以後,包的大小基本能夠忽略不計。

下發以及客戶端加載

完成打包以後,就須要在業務層面進行處理了。這部分又能夠分爲兩個部分,首先咱們須要將代碼包進行存儲,而後下發到客戶端。以後客戶端在對於這些包進行加載,就能夠顯示到用戶的客戶端裏面了。

下發

根據上一節獲得的分包結果,咱們獲得了 common.bundle 這個包含了通用依賴的依賴包,也獲得了一個 charts.bundle 的業務包,這個業務包不包含任何與通用依賴相關的代碼。

common.bundle 因爲更改的並很少,而且這個包的大小通常都會比較大,因此能夠直接打包到客戶端內部。固然若是你但願可以保持動態更新功能的話也是能夠的。

針對咱們應用的特殊場景:

在一條滾動的 feed 流中插入多條 React Native 的 cell,因此咱們採用了前文所述的方案,將這個 cell 的 bundle 跟着數據一塊兒下發。

這樣作的優勢在於:

  1. feed 流極可能是用戶進入的第一個界面,bundle 跟着數據一塊兒下發,能夠防止 bundle 在更新時產生的白屏問題,不存在其餘 cell 已經渲染完成,而 React Native 的 cell 還在下載 bundle 更新的狀況;
  2. 相比起原生來講,咱們能夠獲得近似於客戶端的用戶體驗,而且也擁有了動態更新的功能。feed 流做爲承載用戶閱讀時長的載體,其中偶爾仍是須要動態化地插入活動或者是廣告內容的。

固然也存在必定的缺點,也就是客戶端相關的功能須要提早支持,若是增長了新的功能,可能須要從新發布 common.bundle 包。

若是採用跟隨數據下發的方式來下發 bundle,最好的策略是將其 zip 壓縮,減少 bundle 的大小,而後再將壓縮後的 zip 壓縮包進行 base64 編碼,轉換爲字符串的形式,下發到客戶端。

固然,你也能夠設計實現一個平臺,來進行包的上傳和配置,配置完成後能夠直接落庫,讓後端在數據分發的時候,遇到了 React Native 的內容,直接去取對應包的 base64。

客戶端加載

客戶端加載 React Native 的流程以及代碼執行的過程在上一篇文章中已經有相關解釋了。要作 React Native 的動態加載,不免要改動到客戶端的代碼。這裏對於 iOS 客戶端加載 React Native 的整個方案進行一下梳理:

加載

對於 React Native 來講,native 和 JavaScript 代碼的橋樑都是靠 RCTBridge 來進行橋接的。包括 JavaScript 代碼執行一直到客戶端渲染成爲原生組件,以及 JavaScript 與 native 之間的相互通訊過程。固然,包的加載也是這樣的。

首先,咱們須要一個對於包的加載進行管理的類,這個繼承自 <React/RCTBridge.h>

NSString *const COMMON_BUNDLE = @"common.bundle";

// BundleLoader.m
@interface RCTBridge (PackageBundle)

- (RCTBridge *)batchedBridge;
- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;

@end

@interface BundleLoader()<RCTBridgeDelegate>
// 一些加載相關的變量
@property (nonatomic, strong) RCTBridge *bridge;
@property (nonatomic, strong) NSString *currentLoadingBundle;
@property (nonatomic, strong) NSMutableArray *loadingQueue;
@property (nonatomic, strong) NSMutableDictionary *bundles;
@property (nonatomic, strong) NSMutableSet *loadedBundle;
@property (nonatomic, copy) NSString *commonPath;
@end

@implementation BundleLoader

// 因爲這個實例須要是惟一的,因此咱們實現一個單例
+ (instancetype)sharedInstance {
    static dispatch_once_t pred;
    static BundleLoader *instance;
    dispatch_once(&pred, ^{
        instance = [[BundleLoader alloc] init];
    });
    return instance;
}

// 進行類的初始化
- (instancetype)init {
    self = [super init];
    if (self) {
        // 這裏還要初始化各類變量
        // 在 Native 中打印 React Native 中的日誌,方便真機調試
        RCTSetLogThreshold(RCTLogLevelInfo);
        RCTAddLogFunction(^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) {
            NSLog(@"React Native log: %@, %@", @(source), message);
        });
        
        // 保證 React Native 的錯誤被靜默
        RCTSetFatalHandler(^(NSError *error) {
            NSLog(@"React Native Fatal Error: %@", error.localizedDescription);
            // 將錯誤事件上報,進行統一處理
            [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeFatalErrorNotification object:nil];
        });
    }
    [self initBridge];
    return self;
}

// 進行 React Native 的初始化
- (void)initBridge {
    if (!self.bridge) {
        // 加載 common.bundle,而且將其標記爲正在加載
        commonPath = [self loadCommonBundle];
        currentLoadingBundle = COMMON_BUNDLE;
        // 初始化 bridge,而且加載主包
        self.bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:nil];
        // 初始化全部事件監聽
        [self addObservers];
    }
}

// 這個方法 override 了 RCTBridge 的同名方法,指定了主包所在的位置來讓 RCTBridge 進行初始化
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
    NSString *filePath = self.commonPath;
    NSURL *url = [NSURL fileURLWithPath:filePath];
    return url;
}

- (void)addObservers {
    @weakify(self)
    // JavaScript 包加載完成後觸發
    [[NSNotificationCenter defaultCenter] addObserver:self name:RCTJavaScriptDidLoadNotification dispatchQueue:dispatch_get_main_queue() block:^(NSNotification *notification) {
        @strongify(self)
        [self handleJSDidLoadNotification:notification];
    }];
    // JavaScript 包加載失敗觸發
    [[NSNotificationCenter defaultCenter] addObserver:self name:RCTJavaScriptDidFailToLoadNotification dispatchQueue:dispatch_get_main_queue() block:^(NSNotification *notification) {
        @strongify(self)
        [self handleJSDidFailToLoadNotification:notification];
    }];
}

// 將沙盒中的 common.bundle 拷貝到目標應用程序目錄當中,而且推入到 bundle 加載隊列當中
- (void)loadCommonBundle {
    // 完成 common.bundle 的拷貝,獲得文件所在的目錄
    // 省略了拷貝沙盒文件的過程,獲得文件的路徑: path
    NSString *path = @"這裏是 common ";
    return path;
}

// 加載當前隊列的第一個包
- (void)loadBundle {
    // 取出隊列中的第一個包
    NSDictionary *bundle = self.loadingQueue.firstObject;

    if (!bundle) {
        return;
    }

    NSString *bundleName = bundle.name;
    NSString *path = bundle.path;

    // 若是在加載業務包的時候,COMMON 包尚未加載,則將業務包暫存
    if (![self.loadedBundle containsObject:bundleName] && bundleName != COMMON_BUNDLE) {
        return;
    }
    
    // 標記當前正在加載的包
    self.currentLoadingBundle = bundleName;
    [self.loadingQueue removeFirstObject];

    // 若是須要加載的 bundle 不存在,則繼續加載下一個 bundle
    if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
        dispatch_async(dispatch_get_main_queue(), ^{
            [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeDidFailToLoadNotification object:nil bundle:@{@"name": bundleName}];
            [self loadBundle];
        });
        return;
    }

    NSURL *fileUrl = [NSURL fileURLWithPath:path];

    // 加載而且執行對應的 bundle
    @weakify(self)
    [RCTJavaScriptLoader loadBundleAtURL:fileUrl onProgress:nil onComplete:^(NSError *error, RCTSource *source) {
        @strongify(self)
        if (!error && source.data) {
            // JavaScript 代碼加載成功,而且成功獲取到源代碼 source.data,則執行這些代碼
            dispatch_async(dispatch_get_main_queue(), ^{
                [self.bridge.batchedBridge executeSourceCode:source.data sync:YES];
                [self.loadedBundle addObject:bundleName];
                [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeDidExecutedNotification object:nil bundle:@{@"name": bundleName}];
                // 若是這個包加載完了就不須要了,能夠進行移除
                // [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
            });
        } else {
            // JavaScript 代碼加載失敗
            dispatch_async(dispatch_get_main_queue(), ^{
                [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeDidFailToLoad object:nil bundle:@{@"name": bundleName}];
                [self loadBundle];
            });
        }
    }];
}
複製代碼

上面的代碼是 React Native 包的核心加載代碼,咱們來理一下 React Native 代碼的加載流程:

  1. 首先,在 app 啓動,或者其餘你須要的時間點上來進行整個 React Native 的初始化;
  2. React Native 的初始化基於 RCTBridge 進行,RCTBridge 是整個 React Native 加載和執行的核心(上一篇文章中有過介紹);
  3. 實現 sourceURLForBridge 方法,返回從沙盒拷貝的 app 運行目錄的 common.bundle 的路徑;
  4. 實例化 RCTBridge

這樣,和主包相關的內容就完成了加載。

在主包加載完成以後,會觸發 RCTJavaScriptDidLoadNotification 事件,咱們能夠在這個事件的處理函數當中,判斷當前加載到了哪一個包,當 common.bundle 加載完成以後,就能夠對於隊列中的業務包進行加載了。

// BundleLoader.m
- (void)handleJSDidLoadNotification:(NSNotification *)notification {
    NSString *bundleName = self.currentLoadingBundle;

    if ([bundleName isEqualToString:COMMON_BUNDLE]) {
        [self loadBundle];
    }
}
複製代碼

在須要使用 React Native 的 view 當中,能夠監聽上面 JavaScript 代碼執行完成後發出的事件通知:ReactNativeDidExecutedNotification。在其後,將 RCTRootView 掛載到指定的 view 上,展現出來。

因爲咱們在上傳包的時候,進行了 zip 壓縮來減小體積,以後進行了 base64 編碼,因此須要先將拿到的代碼包進行還原:

// BundleLoader.m
- (void)extractBundle:(NSString *bundle) {
    // 還原 bundle
    NSData *decodedBundle = [[NSData alloc] initWithBase64EncodedString:bundle options:0];
    // 將 zip 保存到指定路徑
    [[NSFileManager defaultManager] createFileAtPath:zipPath contents:decodedBundle attributes:nil];
    // 將文件解壓縮
    [zipArchive UnzipOpenFile:zipPath];
    [zipArchive UnzipFileTo:bundleDir overWrite:YES];
    [zipArchive UnzipCloseFile];
    
    // 而後將包推到待加載的隊列當中,進行執行
}
複製代碼

業務代碼中監聽 ReactNativeDidExecutedNotification 來進行 React Native 的掛載:

// charts.m
- (void)addObservers {
    WeakifySelf
    [[NSNotificationCenter defaultCenter] addObserver:self name:ReactNativeDidExecutedNotification dispatchQueue:dispatch_get_main_queue() block:^(NSNotification *notification) {
        StrongifySelf
        NSString *loadedBundle = notification.bundle[@"name"];
        if ([loadedBundle isEqualToString:self.bundle]) {
            [self _initRCTRootView];
        }
    }];
}

- (void)_initRCTRootView {
    // 進行 React Native 容器的初始化,而且進行掛載
    self.rctRootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:moduleName initialProperties:initialProperties];
    [self.contentView addSubview:self.rctRootView];
}
複製代碼

這樣就完成了一個 React Native 組件的掛載。

總體打包、分發和加載流程,以下:

結論

目前,業務已經在線上穩定運行了一個多月,後續也加入了一些新的功能以及新的業務 cell 種類,讓後端直接進行分發,脫離客戶端開發版本。

其實不管是 feed 流,仍是其餘場景,這種方案均可以讓 Native 界面進行 「部分」 動態化,不須要動態化的地方,能夠享受到原生的良好體驗(雖然目前看來,React Native 的體驗也是不錯的)。

因爲 React Native 還存在不少問題,好比長列表性能,內存消耗過大等問題。這些問題一直都是 React Native 的阿喀琉斯之踵,但願此次 facebook 對於 React Native 的重構可以下降 React Native 的使用成本吧~

相關文章
相關標籤/搜索