若是你喜歡個人文章,但願點贊👍 收藏 📁 評論 💬 三連支持一下,謝謝你,這對我真的很重要!
上一篇文章主要從 Native 的角度分析了 React Native 的初始化流程,並從源碼出發,總結了幾個 React Native 容器初始化的優化點。本文主要從 JavaScript 入手,總結了一些 JS 側的優化要點。javascript
Hermes 是 FaceBook 2019 年中旬開源的一款 JS 引擎,從 release 記錄能夠看出,這個是專爲 React Native 打造的 JS 引擎,能夠說從設計之初就是爲 Hybrid UI 系統打造。前端
Hermes 支持直接加載字節碼,也就是說,Babel
、Minify
、Parse
和 Compile
這些流程所有都在開發者電腦上完成,直接下發字節碼讓 Hermes 運行就行,這樣作能夠省去 JSEngine 解析編譯 JavaScript 的流程,JS 代碼的加載速度將會大大加快,啓動速度也會有很是大的提高。java
更多關於 Hermes 的特性,你們能夠看個人舊文《移動端 JS 引擎哪家強》這篇文章,我作了更爲詳細的特性說明與數據對比,這裏就很少說了。react
前面的優化其實都是 Native 層的優化,從這裏開始就進入 Web 前端最熟悉的領域了。android
其實談到 JS Bundle 的優化,來來回回就是那麼幾條路:webpack
若是有 webpack 打包優化經驗的小夥伴,看到上面的優化方式,是否是腦海中已經浮現出 webpack 的一些配置項了?不過 React Native 的打包工具不是 webpack 而是 Facebook 自研的 Metro,雖然配置細節不同,但道理是相通的,下面我就這幾個點講講 React Native 如何優化 JS Bundle。git
Metro 打包 JS 時,會把 ESM 模塊轉爲 CommonJS 模塊,這就致使如今比較火的依賴於 ESM 的 Tree Shaking 徹底不起做用,並且根據官方回覆,Metro 將來也不會支持 Tree Shaking :github
由於這個緣由,咱們減少 bundle 體積主要是三個方向:web
下面咱們舉幾個例子來解釋上面的三個思路。算法
優化 bundle 文件前,必定要知道 bundle 裏有些什麼,最好的方式就是用可視化的方式把全部的依賴包列出來。web 開發中,能夠藉助 Webpack 的 webpack-bundle-analyzer
插件查看 bundle 的依賴大小分佈,React Native 也有相似的工具,能夠藉助 react-native-bundle-visualizer
查看依賴關係:
使用很是簡單,按照文檔安裝分析就可。
這是一個很是經典的例子。一樣是時間格式化的第三方庫, moment.js 體積 200 KB,day.js 體積只有 2KB,並且 API 與 moment.js 保持一致。若是項目裏用了 moment.js,替換爲 day.js 後能夠立馬減小 JSBundle 的體積。
lodash 基本上屬於 Web 前端的工程標配了,可是對於大多數人來講,對於 lodash 封裝的近 300 個函數,只會用經常使用的幾個,例如 get
、 chunk
,爲了這幾個函數全量引用仍是有些浪費的。
社區上面對這種場景,固然也有優化方案,好比說 lodash-es
,以 ESM 的形式導出函數,再借助 Webpack 等工具的 Tree Sharking 優化,就能夠只保留引用的文件。可是就如前面所說,React Native 的打包工具 Metro 不支持 Tree Shaking,因此對於 lodash-es
文件,其實還會全量引入,並且 lodash-es
的全量文件比 lodash
要大得多。
我作了個簡單的測試,對於一個剛剛初始化的 React Native 應用,全量引入 lodash 後,包體積增大了 71.23KB,全量引入 lodash-es
後,包體積會擴大 173.85KB。
既然 lodash-es
不適合在 RN 中用,咱們就只能在 lodash
上想辦法了。lodash 其實還有一種用法,那就是直接引用單文件,例如想用 join
這個方法,咱們能夠這樣引用:
// 全量 import { join } from 'lodash' // 單文件引用 import join from 'lodash/join'
這樣打包的時候就會只打包 lodash/join
這一個文件。
可是這樣作仍是太麻煩了,好比說咱們要使用 lodash 的七八個方法,那咱們就要分別 import 七八次,很是的繁瑣。對於 lodash 這麼熱門的工具庫,社區上確定有高人安排好了,babel-plugin-lodash
這個 babel 插件,能夠在 JS 編譯時操做 AST 作以下的自動轉換:
import { join, chunk } from 'lodash' // ⬇️ import join from 'lodash/join' import chunk from 'lodash/chunk'
使用方式也很簡單,首先運行 yarn add babel-plugin-lodash -D
安裝,而後在 babel.config.js
文件裏啓用插件便可:
// babel.config.js module.exports = { plugins: ['lodash'], presets: ['module:metro-react-native-babel-preset'], };
我以 join 這個方法爲例,你們能夠看一下各個方法增長的 JS Bundle 體積:
全量 lodash | 全量 loads-es | lodash/join 單文件引用 | lodash + babel-plugin-lodash |
---|---|---|---|
71.23 KB | 173.85 KB | 119 Bytes | 119 Bytes |
從表格可見 lodash
配合 babel-plugin-lodash
是最優的開發選擇。
babel-plugin-lodash
只能轉換 lodash
的引用問題,其實社區還有一個很是實用的 babel 插件:babel-plugin-import
,基本上它能夠解決全部按需引用的問題。
我舉個簡單的例子,阿里有個很好用的 ahooks 開源庫,封裝了不少經常使用的 React hooks,但問題是這個庫是針對 Web 平臺封裝的,好比說 useTitle
這個 hook,是用來設置網頁標題的,可是 React Native 平臺是沒有相關的 BOM API 的,因此這個 hooks 徹底沒有必要引入,RN 也永遠用不到這個 API。
這時候咱們就能夠用 babel-plugin-import
實現按需引用了,假設咱們只要用到 useInterval
這個 Hooks,咱們如今業務代碼中引入:
import { useInterval } from 'ahooks'
而後運行 yarn add babel-plugin-import -D
安裝插件,在 babel.config.js
文件裏啓用插件:
// babel.config.js module.exports = { plugins: [ [ 'import', { libraryName: 'ahooks', camel2DashComponentName: false, // 是否須要駝峯轉短線 camel2UnderlineComponentName: false, // 是否須要駝峯轉下劃線 }, ], ], presets: ['module:metro-react-native-babel-preset'], };
啓用後就能夠實現 ahooks 的按需引入:
import { useInterval } from 'ahooks' // ⬇️ import useInterval from 'ahooks/lib/useInterval'
下面是各類狀況下的 JSBundle 體積增量,綜合來看 babel-plugin-import
是最優的選擇:
全量 ahooks | ahooks/lib/useInterval 單文件引用 | ahooks + babel-plugin-import |
---|---|---|
111.41 KiB | 443 Bytes | 443 Bytes |
固然,babel-plugin-import
能夠做用於不少的庫文件,好比說內部/第三方封裝的 UI 組件,基本上均可以經過babel-plugin-import
的配置項實現按需引入。如有需求,能夠看網上其餘人總結的使用經驗,我這裏就很少言了。
移除 console 的 babel 插件也頗有用,咱們能夠配置它在打包發佈的時候移除 console
語句,減少包體積的同時還會加快 JS 運行速度,咱們只要安裝後再簡單的配置一下就行了:
// babel.config.js module.exports = { presets: ['module:metro-react-native-babel-preset'], env: { production: { plugins: ['transform-remove-console'], }, }, };
編碼規範的最佳實踐太多了,爲了切合主題(減小代碼體積),我就隨便舉幾點:
"react-native/no-unused-styles"
選項,藉助 ESLint 提示無效的樣式文件說實話這幾個優化其實減小不了幾 KB 的代碼,更大的價值在於提高項目的健壯性和可維護性。
Inline Requires
能夠理解爲懶執行,注意我這裏說的不是懶加載,由於通常狀況下,RN 容器初始化以後會全量加載解析 JS Bundle 文件,Inline Requires
的做用是延遲運行,也就是說只有須要使用的時候纔會執行 JS 代碼,而不是啓動的時候就執行。React Native 0.64 版本里,默認開啓了 Inline Requires
。
首先咱們要在 metro.config.js
裏確認開啓了 Inline Requires
功能:
// metro.config.js module.exports = { transformer: { getTransformOptions: async () => ({ transform: { experimentalImportSupport: false, inlineRequires: true, // <-- here }, }), }, };
其實 Inline Requires
的原理很是簡單,就是把 require 導入的位置改變了一下。
好比說咱們寫了個工具函數 join
放在 utils.js
文件裏:
// utils.js export function join(list, j) { return list.join(j); }
而後咱們在 App.js
裏 import 這個庫:
// App.js import { join } from 'my-module'; const App = (props) => { const result = join(['a', 'b', 'c'], '~'); return <Text>{result}</Text>; };
上面的寫法,被 Metro
編譯後,至關於編譯成下面的樣子:
const App = (props) => { const result = require('./utils').join(['a', 'b', 'c'], '~'); return <Text>{result}</Text>; };
實際編譯後的代碼其實長這個樣子:
上圖紅線中的 r()
函數,實際上是 RN 本身封裝的 require()
函數,能夠看出 Metro 自動把頂層的 import 移動到使用的位置。
值得注意的是,Metro 的自動 Inline Requires
配置,目前是不支持 export default
導出的,也就是說,若是你的 join 函數是這樣寫的:
export default function join(list, j) { return list.join(j); }
導入時是這樣的:
import join from './utils'; const App = (props) => { const result = join(['a', 'b', 'c'], '~'); return <Text>{result}</Text>; };
Metro 編譯轉換後的代碼,對應的 import 仍是處於函數頂層:
這個須要特別注意一下,社區也有相關的文章,呼籲你們不要用 export default
這個語法,感興趣的能夠了解一下:
深刻解析 ES Module(一):禁用 export default object
深刻解析 ES Module(二):完全禁用 default export
分包的場景通常出如今 Native 爲主,React Native 爲輔的場景裏。這種場景每每是這樣的:
你們從上面的例子裏能夠看出,600KB 的基礎包在多條業務線裏是重複的,徹底沒有必要屢次下載和加載,這時候一個想法天然而然就出來了:
把一些共有庫打包到一個common.bundle
文件裏,咱們每次只要動態下發業務包businessA.bundle
和businessB.bundle
,而後在客戶端實現先加載common.bundle
文件,再加載business.bundle
文件就能夠了
這樣作的好處有幾個:
common.bundle
能夠直接放在本地,省去多業務線的屢次下載,節省流量和帶寬common.bundle
,二次加載的業務包體積更小,初始化速度更快順着上面的思路,上面問題就會轉換爲兩個小問題:
拆包以前要先了解一下 Metro 這個打包工具的工做流程。Metro 的打包流程很簡單,只有三個步驟:
從上面流程能夠看出,咱們的拆包步驟只會在 Serialization
這一步。咱們只要藉助 Serialization
暴露的各個方法就能夠實現 bundle 分包了。
正式分包前,咱們先拋開各類技術細節,把問題簡化一下:對於一個全是數字的數組,如何把它分爲偶數數組和奇數數組?
這個問題太簡單了,剛學編程的人應該都能想到答案,遍歷一遍原數組,若是當前元素是奇數,就放到奇數數組裏,若是是偶數,放偶數數組裏。
Metro 對 JS bundle 分包實際上是一個道理。Metro 打包的時候,會給每一個模塊設置 moduleId,這個 id 就是一個從 0 開始的自增 number。咱們分包的時候,公有的模塊(例如 react
react-native
)輸出到 common.bundle
,業務模塊輸出到 business.bundle
就好了。
由於要兼顧多條業務線,如今業內主流的分包方案是這樣的:
1.先創建一個 common.js
文件,裏面引入了全部的公有模塊,而後 Metro 以這個 common.js
爲入口文件,打一個 common.bundle
文件,同時要記錄全部的公有模塊的 moduleId
// common.js require('react'); require('react-native'); ......
2. 對業務線 A 進行打包,Metro 的打包入口文件就是 A 的項目入口文件。打包過程當中要過濾掉上一步記錄的公有模塊 moduleId,這樣打包結果就只有 A 的業務代碼了
// indexA.js import {AppRegistry} from 'react-native'; import BusinessA from './BusinessA'; import {name as appName} from './app.json'; AppRegistry.registerComponent(appName, () => BusinessA);
3. 業務線 B C D E...... 打包流程同業務線 A
上面的思路看起來很美好,可是仍是存在一個問題:每次啓動 Metro 打包的時候,moduleId 都是從 0 開始自增,這樣會致使不一樣的 JSBundle ID 重複。
爲了不 id 重複,目前業內主流的作法是把模塊的路徑看成 moduleId(由於模塊的路徑基本上是固定且不衝突的),這樣就解決了 id 衝突的問題。Metro 暴露了 createModuleIdFactory
這個函數,咱們能夠在這個函數裏覆蓋原來的自增 number 邏輯:
module.exports = { serializer: { createModuleIdFactory: function () { return function (path) { // 根據文件的相對路徑構建 ModuleId const projectRootPath = __dirname; let moduleId = path.substr(projectRootPath.length + 1); return moduleId; }; }, }, };
整合一下第一步的思路,就能夠構建出下面的 metro.common.config.js
配置文件:
// metro.common.config.js const fs = require('fs'); module.exports = { transformer: { getTransformOptions: async () => ({ transform: { experimentalImportSupport: false, inlineRequires: true, }, }), }, serializer: { createModuleIdFactory: function () { return function (path) { // 根據文件的相對路徑構建 ModuleId const projectRootPath = __dirname; let moduleId = path.substr(projectRootPath.length + 1); // 把 moduleId 寫入 idList.txt 文件,記錄公有模塊 id fs.appendFileSync('./idList.txt', `${moduleId}\n`); return moduleId; }; }, }, };
而後運行命令行命令打包便可:
# 打包平臺:android # 打包配置文件:metro.common.config.js # 打包入口文件:common.js # 輸出路徑:bundle/common.android.bundle npx react-native bundle --platform android --config metro.common.config.js --dev false --entry-file common.js --bundle-output bundle/common.android.bundle
經過以上命令的打包,咱們能夠看到 moduleId 都轉換爲了相對路徑,而且 idList.txt
也記錄了全部的 moduleId:
第二步的關鍵在於過濾公有模塊的 moduleId,Metro 提供了 processModuleFilter
這個方法,藉助它能夠實現模塊的過濾。具體的邏輯可見如下代碼:
// metro.business.config.js const fs = require('fs'); // 讀取 idList.txt,轉換爲數組 const idList = fs.readFileSync('./idList.txt', 'utf8').toString().split('\n'); function createModuleId(path) { const projectRootPath = __dirname; let moduleId = path.substr(projectRootPath.length + 1); return moduleId; } module.exports = { transformer: { getTransformOptions: async () => ({ transform: { experimentalImportSupport: false, inlineRequires: true, }, }), }, serializer: { createModuleIdFactory: function () { // createModuleId 的邏輯和 metro.common.config.js 徹底同樣 return createModuleId; }, processModuleFilter: function (modules) { const mouduleId = createModuleId(modules.path); // 經過 mouduleId 過濾在 common.bundle 裏的數據 if (idList.indexOf(mouduleId) < 0) { console.log('createModuleIdFactory path', mouduleId); return true; } return false; }, }, };
最後運行命令行命令打包便可:
# 打包平臺:android # 打包配置文件:metro.business.config.js # 打包入口文件:index.js # 輸出路徑:bundle/business.android.bundle npx react-native bundle --platform android --config metro.business.config.js --dev false --entry-file index.js --bundle-output bundle/business.android.bundle
最後的打包結果只有 11 行(不分包的話得 398 行),能夠看出分包的收益很是大。
固然使用相對路徑做爲 moduleId 打包時,不可避免的會致使包體積變大,咱們可使用 md5 計算一下相對路徑,而後取前幾位做爲最後的 moduleId;或者仍是採用遞增 id,只不過使用更復雜的映射算法來保證 moduleId 的惟一性和穩定性。這部分的內容其實屬於很是經典的 Map key 設計問題,感興趣的讀者能夠了解學習一下相關的算法理論知識。
分包只是第一步,想要展現完整正確的 RN 界面,還須要作到「合」,這個「合」就是指在 Native 端實現多 bundle 的加載。
common.bundle 的加載比較容易,直接在 RN 容器初始化的時候加載就行了。容器初始化的流程上一節我已經詳細介紹了,這裏就很少言了。這時候問題就轉換爲 business.bundle
的加載問題。
React Native 不像瀏覽器的多 bundle 加載,直接動態生成一個 <script />
標籤插入 HTML 中就能夠實現動態加載了。咱們須要結合具體的 RN 容器實現來實現 business.bundle
加載的需求。這時候咱們須要關注兩個點:
對於第一個問題,咱們的答案是 common.bundle
加載完成後再加載 business.bundle
。
common.bundle
加載完成後,iOS 端會發送事件名稱是 RCTJavaScriptDidLoadNotification
的全局通知,Android 端則會向 ReactInstanceManager 實例中註冊的全部 ReactInstanceEventListener 回調 onReactContextInitialized()
方法。咱們在對應事件監聽器和回調中實現業務包的加載便可。
對於第二個問題,iOS 咱們可使用 RCTCxxBridge 的 executeSourceCode
方法在當前的 RN 實例上下文中執行一段 JS 代碼,以此來達到增量加載的目的。不過值得注意的是,executeSourceCode
是 RCTCxxBridge 的私有方法,須要咱們用 Category 將其暴露出來。
Android 端可使用剛剛創建好的 ReactInstanceManager 實例,經過 getCurrentReactContext()
獲取到當前的 ReactContext 上下文對象,再調用上下文對象的 getCatalystInstance()
方法獲取媒介實例,最終調用媒介實例的 loadScriptFromFile(String fileName, String sourceURL, boolean loadSynchronously)
方法完成業務 JSBundle 的增量加載。
iOS 和 Android 的示例代碼以下:
NSURL *businessBundleURI = // 業務包 URI NSError *error = nil; NSData *sourceData = [NSData dataWithContentsOfURL:businessBundleURI options:NSDataReadingMappedIfSafe error:&error]; if (error) { return } [bridge.batchedBridge executeSourceCode:sourceData sync:NO]
ReactContext context = RNHost.getReactInstanceManager().getCurrentReactContext(); CatalystInstance catalyst = context.getCatalystInstance(); String fileName = "businessBundleURI" catalyst.loadScriptFromFile(fileName, fileName, false);
本小節的示例代碼都屬於 demo 級別,若是想要真正接入生產環境,須要結合實際的架構和業務場景作定製。有一個 React Native 分包倉庫 react-native-multibundler 內容挺不錯的,你們能夠參考學習一下。
咱們通常會在 React Component 的 componentDidMount()
執行後請求網絡,從服務器獲取數據,而後再改變 Component 的 state 進行數據的渲染。
網絡優化是一個很是龐大很是獨立的話題,有很是多的點能夠優化,我這裏列舉幾個和首屏加載相關的網絡優化點:
因爲網絡這裏相對來講比較獨立,iOS/Android/Web 的優化經驗其實均可以用到 RN 上,這裏按照你們以往的優化經驗來就能夠了。
渲染這裏的耗時,基本上和首屏頁面的 UI 複雜度成正相關。能夠經過渲染流程查看哪裏會出現耗時:
咱們能夠在代碼裏開啓 MessageQueue
監視,看看 APP 啓動後 JS Bridge 上面有有些啥:
// index.js import MessageQueue from 'react-native/Libraries/BatchedBridge/MessageQueue' MessageQueue.spy(true);
從圖片裏能夠看出 JS 加載完畢後有大量和 UI 相關的 UIManager.createView()
UIManager.setChildren()
通信,結合上面的耗時總結,咱們對應着就有幾條解決方案:
上面的這些技巧我都在舊文《React Native 性能優化指南——渲染篇》裏作了詳細的解釋,這裏就很少解釋了。
從上面的咱們能夠看出,React Native 的渲染須要在 Bridge 上傳遞大量的 JSON 數據,在 React Native 初始化時,數據量過大會阻塞 bridge,拖慢咱們的啓動和渲染速度。React Native 新架構中的 Fraic 就能解決這一問題,JS 和 Native UI 再也不是異步的通信,能夠實現直接的調用,能夠大大加速渲染性能。
Fraic 能夠說是 RN 新架構裏最讓人期待的了,想了解更多內容,能夠去官方 issues 區圍觀。
本文主要從 JavaScript 的角度出發,分析了 Hermes 引擎的特色和做用,並總結分析了 JSBundle 的各類優化手段,再結合網絡和渲染優化,全方位提高 React Native 應用的啓動速度。
⚡️ React Native 啓動速度優化——Native 篇(內含源碼分析)
若是你喜歡個人文章,但願點贊👍 收藏 📁 評論 💬 三連支持一下,謝謝你,這對我真的很重要!
歡迎你們關注個人微信公衆號:滷蛋實驗室,目前專一前端技術,對圖形學也有一些微小研究。
原文連接 👉 ⚡️ React Native 啓動速度優化——JS 篇:更新更及時,閱讀體驗更佳