【React Native】一個簡單的拆分Bundle&資源做法
本文的RN程式碼基於0.43版本
一般應用React Native(RN)後,隨著使用頁面的增加,bundle包(攜帶資源)會逐漸加大,這會帶來以下兩個缺點:
頁面啟動速度&記憶體佔用增加 這是不言而喻的,一個頁面啟動時會載入其他無關頁面的程式碼,自然會有記憶體佔用加大、啟動時間增加的問題,這部分的消耗是不應該的。
更新流量消耗增加 要更新某塊程式碼必須下發整個bundle,儘管只更新其中1/10部分的程式碼。
官方的打包並沒有做類似拆分的事情,它打包出來就是一份bundle+資源。可能唯一值得一提的是它的unbundle ( https://github.com/facebook/react-native/blob/master/local-cli/bundle/output/unbundle/index.js
Bundle程式碼結構一覽
RN打出來的Bundle其實就是一個js檔案,如果設定了--assets-dest
則會將引用到的資源輸出,它的結構由上至下分為三部分,我們來分別探索一下:
1. Polyfills
它們是Bundle最開始的一段程式碼,主要是向Javascript直譯器上下文注入一些能力,比如模組系統、require、console等都在這裡注入。
這些polyfills的用途根據其名字就大概能猜到了,有興趣的朋友可以自行探索,這裡不展開講。除了它們,還會加入額外兩個polyfill,它們相當於是元元件,是連這些polyfills都需要依賴的幾個元件,會出現在bundle的最前面,它們是:
global.__DEV__
的設定模組;
模組系統,模組定義函式、require函式,都在這裡定義,這樣javascript直譯器才能擁有模組系統的功能。
這些polyfills生成到bundle的程式碼就是閉包的呼叫,生成規則在packager/src/Resolver/index.js
中可以看到:
我們看到它是函式的定義&呼叫,通過注入global變數來將一些全域性使用的元素attach到global上。
2. Module Declaration
這裡通過解析入口模組(--entry-file
指定的檔案)的依賴,將所有引用到的模組轉化成module list,按依賴順序進行註冊輸出。
之所以說改版的node-haste,是因為這塊程式碼已經不隨原倉庫,而是在RN packager中的一個子module獨立維護了(見node-haste/index.js ( https://github.com/facebook/react-native/blob/master/packager/src/node-haste/index.js
)),由於需要處理ES6、Flow,它需要通過babylon來處理原始碼後,再對轉碼後的AST(詞法分析樹,Abstract Syntax Tree)解析模組依賴,還需要解析資原始檔,這些在原版程式碼中都沒有。
關於模組依賴樹解析這裡不講太深,提出幾個關鍵程式碼有興趣的同學可以自己參考:
那我們來看看模組註冊的程式碼生成規則,還是在上面那個檔案packager/src/Resolver/index.js
中,我們可以看到函式 defineModuleCode
,它負責生成模組註冊部分的程式碼。
這裡的code是已經被babel轉碼過的程式碼,關於這個__d
,可以在之前的polyfills:polyfills/require.js
中看到:global.__d = define;
,這個define函式會將對應id的模組註冊到一個全域性變數modules
裡。
3. Module Calls
由於前面定義模組時並沒有呼叫任何模組,它只是將模組程式碼放在閉包中註冊給全域性module。要讓程式執行起來,就必須呼叫必要的程式碼。這最後部分Module Calls就是一些預定義的模組呼叫及入口模組(傳入的--entry-file
)呼叫。
這塊程式碼的新增可以在packager/src/Bundler/Bundle.js
中看到,它預設會加入的是InitializeCore
模組
這裡新增的程式碼就非常非常簡單了,就是一個require(moduleId);
。
資源引用方式探索
接下來再說說資源(主要指圖片)是怎麼被使用的。假如我們在程式碼中使用了隨Bundle的資源,比如圖片,那麼它會被打到--asset-dest
指定的目錄中,隨著--platform
的不同,打出來資源路徑也不同。在Android中會打出drawable-xdpi
這樣的目錄,在iOS(預設platform)則基本直接是相對工程根目錄的路徑。
我以Android中資源引用為例,來聊聊這個話題。首先我有一個元件引用了資源,它是一個圖片:
packageName是在package.json中宣告的工程名,在RN中會被解析為專案根路徑
首先,很明顯的是這個資源引用會被解析為一個模組依賴,在node-haste解析到它時,會將它轉換成一個資源模組AssetModule
。是否是資源模組的判斷很簡單,就是查詢匹配字尾,預設的資源字尾名可以在packager/defaults.js中看到,就是一些圖片、視訊、文件的字尾。資源模組生成程式碼的規則可以在packager/src/Bundler.index.js#_generateAssetObjAndCode中看到,我們直接拿一個打好的資源模組看看:
那麼問題來了:RN是怎麼找圖片資源的呢? Bundle包可能在asset中,可能在檔案系統,又有可能是開發者模式下的網路路徑,它去哪裡找對應的圖片?要資源分包必須搞清楚這一點。
那我們自然而然會去看AssetRegistry
這個類,但是它裡面功能很少,只是將資源json註冊到一個全域性變數中,返回它的id,可以隨時拉取。我們可以去Image.js
的render函式中看,在解析、使用資源時,用到的是resolveAssetResource.js
這個模組。它會呼叫AssetResolver.defaultAsset()
去解析圖片uri,返回給圖片。我們去看看:
RN中有一個SourceCode
模組,它是一個Native模組,持有常量scriptURL,意為bundle的路徑。在JS中通過拿到這個路徑,可以區分出是由網路、資源還是檔案系統中載入的程式碼。那上面這個的返回邏輯比較清晰,只不過具體的實現細節比較多,我在這裡歸納一下:
如果是由網路載入的圖片,則將
httpServerLocation
拼接至sourceUrl上;如果是由檔案系統載入,則有如下兩步:
將
httpServerLocation
抹去前面的/assets/
,並將’/‘替換為’_’,對於上面的例子,它會被轉換為src_assets_naruto.jpeg
將處理後的location拼接上scale對應的dpi drawable路徑,再拼接到sourceURL上。對於上面的例子,它會被轉換為
sourceURL/drawable-mdpi/src_assets_naruto.jpeg
其他情況則直接去資源中查詢,查詢的資源id是檔案系統第一步中對location的改造後的id(src_assets_naruto
)。
拆分Bundle第一步 - 解析&拆分程式碼
假設我們要拆分出兩個bundle包:base/business。其中base包括react-native程式碼、部分自定義module程式碼;business包括業務程式碼。
首先我們要解析bundle,拆分出polyfill、module宣告、module呼叫三部分程式碼,必須明確的幾點是:
polyfill、react-native宣告的、依賴的module要放在base裡
自定義新增到base裡的module、它們依賴的module要放在base裡
business入口所依賴的任何非base的module放在拆分出的bundle裡
這一步我們可以通過一些JS解析工具,比如babel&babylon( https://github.com/babel/babylon ),或者UglifyJS( https://github.com/mishoo/UglifyJS2 )來解析bundle,由於polyfill、module declaration、module call三種類型的程式碼格式是完全按照規範來,所以它們對應的也就是三種AST node,我們只需要按照按照對應規則來解析就好了,比如 module declaration:
可以看到這個判斷非常簡單,其實只要在解析的時候將它們打出來觀察規律即可。然後從node的api中找到它所宣告的模組值,記錄下來。在解析模組宣告時,還需要注意解析它直接依賴的模組,記錄在案,方便後續收集模組依賴。
至於收集依賴的方法就比較見仁見智了,很多方法可以做,可以通過babel.traverse(ASTNode, callback)
,或者更簡單的,由於bundle是已經被轉碼成es5程式碼,可以直接使用正則表示式在ASTNode所屬的程式碼塊中查詢require
字樣(我使用了這個方法,表示式:/require\s?\(([0-9]+)[^)]*\)/g
)。收集一級摸快依賴後,後續必須向下迴圈收集所有被依賴到的模組,這一塊稍微需要一點技巧,可以到我的倉庫中看。
同時如果被依賴的模組時資源時,還需要額外記錄,在下一步中可以對資源進行操作。
這一步需要做到的目標就是解析出base包、business包各自所需要包含的所有程式碼,及各自包含的資源模組。
拆分Bundle第二步 - 移動資源
在預設拆分出的bundle中,它的目錄是這樣:
root/
|- index.bundle
|- drawable-mdpi/src_assets_naruto.jpeg
但是我們拆分出的bundle後,肯定不能資源攪在一起,我們希望的目錄分級是這樣:
root/
|- base/
|- index.bundle
|- drawable-mdpi/xxx.jpg
|-business/
|- index.bundle
|- drawable-mdpi/src_assets_naruto.jpeg
這下就不是特別好辦了,所以我採用了注入bundle程式碼的形式來做資源引用。什麼意思呢?就是當解析到資源模組時,我們向這個資源模組注入它所屬的bundle名,例如:
我們通過一些程式碼操作trick可以做到這個事情,然後在資源使用處resolveAssetSource.js
中發現有一段很有意思的程式碼:
我們發現它其實是可以自定義資源查詢路徑的,於是當然大有可為,我們就將這個resolve邏輯稍微進行修改,讓它去找子module路徑下的資源,而不是寫死的直接找scriptURL
路徑下。這一步做法也很多,最簡單的可以改SourceCode.scriptURL
路徑為bundle的上層路徑,然後加入一層子bundle目錄。
能夠自己定址,就很好了,我們在解析到資源module後直接將它的目標檔案移動到對應的目錄下即可。
拆分bundle的第三步 - 修改Native程式碼
首先RN框架的bundle載入是和它宣告週期寫死的,如果我們需要按需載入子module就對框架要有一些修改。
混淆程式碼
首先這個做法不支援RN自帶的minify bundle,這樣它會剔除一些我們要用到的資訊(比如模組id對應的模組名字,雖然會儲存在另外檔案中,但是會對操作帶來更多困難)。但是我們可以通過手動uglify對bundle進行混淆,此時需要注意保留兩個值:__d
與require
,它們是我們解析AST中比較需要用到的兩個值。並且minify以後的閉包呼叫會變成!function(){}()
這樣的用法(比(function(){})()
這樣的用法少了一個字元),AST的解析規則也要對應的有一點修改。
後續的必要事情
對RN打包出的bundle進行拆分雖然做起來很簡單,但是它還有一個大坑:在模組關係變動、新增&刪減模組時很難保持一致性。
比如,我們在base裡新增了一個模組,由於模組id是按依賴順序生成的,那base裡面的模組id就會不一樣。這樣就造成了一個比較蛋疼的後果:後面所有的business的模組在引用base時基本上都會受到影響,因為原先使用的base module id都被改動了,這就造成了升級base,其他bu也要升級;或者 一個bu會影響其他bu的這樣一種結果。對於這種情況,也是可以見仁見智地處理。
我建議的做法是:直接捨棄module ID,將所有的module ID替換為module名(即字串)。這樣一來無論怎麼升級都不會影響。主要是bundle體積會增大一點,但是我認為是值得的,因為這樣比較無風險。做法也很簡單,三件事情:
將Module宣告的引數進行替換;
Module程式碼、Module呼叫的require(id)替換成require(name);
將require這個polyfill中對moduleId型別字串的強制檢查去掉;
其實通過ASTNode分析與一些字串替換就能做到,在我的Example裡已經做了,大家可以移步參考。