1. 程式人生 > >【React Native】一個簡單的拆分Bundle&資源做法

【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

),它會將所有module進行拆分。那今天我就分享一下最近研究的成果,對RN打出來的bundle進行處理並自定義拆分程式碼&資源,一種無侵入式的後處理機制。不夠完美,但是基本可用。

Bundle程式碼結構一覽

RN打出來的Bundle其實就是一個js檔案,如果設定了--assets-dest則會將引用到的資源輸出,它的結構由上至下分為三部分,我們來分別探索一下:

1. Polyfills

它們是Bundle最開始的一段程式碼,主要是向Javascript直譯器上下文注入一些能力,比如模組系統、require、console等都在這裡注入。

0?wx_fmt=png

這些polyfills的用途根據其名字就大概能猜到了,有興趣的朋友可以自行探索,這裡不展開講。除了它們,還會加入額外兩個polyfill,它們相當於是元元件,是連這些polyfills都需要依賴的幾個元件,會出現在bundle的最前面,它們是:

  1. global.__DEV__的設定模組;

  1. 模組系統,模組定義函式、require函式,都在這裡定義,這樣javascript直譯器才能擁有模組系統的功能。

0?wx_fmt=png

這些polyfills生成到bundle的程式碼就是閉包的呼叫,生成規則在packager/src/Resolver/index.js中可以看到:

0?wx_fmt=png

我們看到它是函式的定義&呼叫,通過注入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,它負責生成模組註冊部分的程式碼。

0?wx_fmt=png

這裡的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模組
0?wx_fmt=png

這裡新增的程式碼就非常非常簡單了,就是一個require(moduleId);

資源引用方式探索

接下來再說說資源(主要指圖片)是怎麼被使用的。假如我們在程式碼中使用了隨Bundle的資源,比如圖片,那麼它會被打到--asset-dest指定的目錄中,隨著--platform的不同,打出來資源路徑也不同。在Android中會打出drawable-xdpi這樣的目錄,在iOS(預設platform)則基本直接是相對工程根目錄的路徑。

我以Android中資源引用為例,來聊聊這個話題。首先我有一個元件引用了資源,它是一個圖片:

0?wx_fmt=png

packageName是在package.json中宣告的工程名,在RN中會被解析為專案根路徑

首先,很明顯的是這個資源引用會被解析為一個模組依賴,在node-haste解析到它時,會將它轉換成一個資源模組AssetModule。是否是資源模組的判斷很簡單,就是查詢匹配字尾,預設的資源字尾名可以在packager/defaults.js中看到,就是一些圖片、視訊、文件的字尾。資源模組生成程式碼的規則可以在packager/src/Bundler.index.js#_generateAssetObjAndCode中看到,我們直接拿一個打好的資源模組看看:

0?wx_fmt=png
那麼問題來了:RN是怎麼找圖片資源的呢? Bundle包可能在asset中,可能在檔案系統,又有可能是開發者模式下的網路路徑,它去哪裡找對應的圖片?要資源分包必須搞清楚這一點。

那我們自然而然會去看AssetRegistry這個類,但是它裡面功能很少,只是將資源json註冊到一個全域性變數中,返回它的id,可以隨時拉取。我們可以去Image.js的render函式中看,在解析、使用資源時,用到的是resolveAssetResource.js這個模組。它會呼叫AssetResolver.defaultAsset()去解析圖片uri,返回給圖片。我們去看看:
0?wx_fmt=png
RN中有一個SourceCode模組,它是一個Native模組,持有常量scriptURL,意為bundle的路徑。在JS中通過拿到這個路徑,可以區分出是由網路、資源還是檔案系統中載入的程式碼。那上面這個的返回邏輯比較清晰,只不過具體的實現細節比較多,我在這裡歸納一下:

  1. 如果是由網路載入的圖片,則將httpServerLocation拼接至sourceUrl上;

  2. 如果是由檔案系統載入,則有如下兩步:

  • 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:

0?wx_fmt=png

可以看到這個判斷非常簡單,其實只要在解析的時候將它們打出來觀察規律即可。然後從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名,例如:

0?wx_fmt=png
我們通過一些程式碼操作trick可以做到這個事情,然後在資源使用處resolveAssetSource.js中發現有一段很有意思的程式碼:

0?wx_fmt=png

我們發現它其實是可以自定義資源查詢路徑的,於是當然大有可為,我們就將這個resolve邏輯稍微進行修改,讓它去找子module路徑下的資源,而不是寫死的直接找scriptURL路徑下。這一步做法也很多,最簡單的可以改SourceCode.scriptURL路徑為bundle的上層路徑,然後加入一層子bundle目錄。

能夠自己定址,就很好了,我們在解析到資源module後直接將它的目標檔案移動到對應的目錄下即可。

拆分bundle的第三步 - 修改Native程式碼

首先RN框架的bundle載入是和它宣告週期寫死的,如果我們需要按需載入子module就對框架要有一些修改。

混淆程式碼

首先這個做法不支援RN自帶的minify bundle,這樣它會剔除一些我們要用到的資訊(比如模組id對應的模組名字,雖然會儲存在另外檔案中,但是會對操作帶來更多困難)。但是我們可以通過手動uglify對bundle進行混淆,此時需要注意保留兩個值:__drequire,它們是我們解析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裡已經做了,大家可以移步參考。