Flutter for Web(FFW)從 2021 年發布至今,在國內外互聯網公司已經得到較多的應用。作為 Flutter 技術在 Web 領域的有力擴充,FFW 可以讓熟悉 Flutter 的客戶端同學直接上手寫 H5,復用 App 端代碼高效支撐業務需求;在 App 側 FFW 也可作為 Flutter 動態下發的兜底方案??偟膩碚f在業務和技術上 FFW 都具有相當的價值。
然而在使用 FFW 時有一個明顯的問題:其編譯產物 main.dart.js
較大,初始的 Hello world 工程編譯后產物 js 大小為 1.2 MB,添加業務代碼后 js 的大小還會繼續增加。在阿里賣家的內容外投業務中,3 個頁面的工程 js 大小為 2.0 MB,js 文件過大直接的影響就是頁面首次首屏加載的速度。針對 js 的大小有較多優化方法,本文主要記錄 main.dart.js
分片優化方案的實現。
1.方案總覽
圖 1. FFW js 分片示意
頁面 js 加載速度提升一般從兩個角度考慮:
- 減少 js 文件大小
- 提升 js 加載效率
對應到 js 分片方案,主要通過如下兩點提升加載速度:
按需加載:在工程中存在多個頁面時,不論打開哪個頁面都需要加載完整的main.dart.js
,而這里包含了很多不需要的頁面代碼。如果將各個頁面的代碼拆分只加載當前頁面所需要的代碼,則可減少 js 文件體積,而且當其他頁面越多邏輯越復雜時,其提升的效果越明顯。
并行加載:將 js 分片后會生成多個大小不一的 js 文件,在帶寬充足的情況下如果使用并行加載則可以節省較小的分片加載時間。
注:js 文件壓縮在線上部署的時候會自動處理,這里不做處理。
2 . 工程實踐
通過按需和并行加載提升加載速度,首先需要完成 js 的分片。分片和按需加載操作通常是綁定的,如在前端 Vue 開發中,可使用 webpack 的 code splitting[1] 工具在定義好各類庫的使用關系后實現文件分割和按需加載,類似的在 flutter 中則可使用 延遲加載組件[2] 功能。
2.1 延遲加載組件
Flutter 為 App 設計的延遲組件加載功能同樣適用于 FFW。在 dart 代碼中通過關鍵字 deffered as
引入相關代碼庫并在使用時加載即可實現延遲加載功能。在官方的示例中可以通過如下的方式實現 box.dart
的延遲加載。
// box.dart
import 'package:flutter/material.dart';
/// 一個正常方式編寫的 widget,后面會被延遲加載
class DeferredBox extends StatelessWidget {
const DeferredBox({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 30,
width: 30,
color: Colors.blue,
);
}
}
在需要使用 box.dart
的地方通過 deferred as
關鍵字引入 box.dart
/// some_widget.dart
import 'package:flutter/material.dart';
/// 1. deferred as 引入
import 'box.dart' deferred as box;
class SomeWidget extends StatefulWidget {
const SomeWidget({Key? key}) : super(key: key);
@override
State<SomeWidget> createState() => _SomeWidgetState();
}
之后調用延遲加載庫的加載方法,加載完成后使用即可
/// some_widget.dart
class _SomeWidgetState extends State<SomeWidget> {
late Future<void> _libraryFuture;
@override
void initState() {
/// 2. 使用時加載延遲加載庫
_libraryFuture = box.loadLibrary();
super.initState();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _libraryFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {return Text('Error: ${snapshot.error}');}
/// 3. 延遲加載庫加載完成后使用
return box.DeferredBox();
}
return const CircularProgressIndicator();
},
);
}
}
經過上述操作后,在 FFW 中編譯后可生成類似如下的兩個 js 文件:
├── [1.2M] main.dart.js /// FFW 引擎和主工程內容
├── [616B] main.dart.js_1.part.js /// 存放 box.dart 對應的內容
在多頁面的工程中使用延遲組件加載即可完成多頁面的分片,可進行接下來的改造工作。
2.2 延遲加載改造
在阿里賣家 FFW 工程中,為了盡可能的做到只加載必須內容,我們從路由跳轉位置將各頁面改造為延遲加載方式。
2.2.1 主工程代碼
/// main.dart
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AliSupplier Headline',
debugShowCheckedModeBanner: false,
onGenerateRoute: RouteConfiguration.onGenerateRoute,
onGenerateInitialRoutes: (settings) {
return [RouteConfiguration.onGenerateRoute(RouteSettings(name: settings))];
},
);
}
}
2.2.2 原路由代碼
/// routes.dart
import 'package:alisupplier_content/business/distribution/page/sellerapp_page.dart';
import 'package:alisupplier_content/business/webmain/page/web_news_detail_page.dart';
import 'package:alisupplier_content/debug/page/debug_main_page.dart';
/// 路由和頁面 builder 的 map
static Map<String, RouteWidgetBuilder?> builders = {
'/debug': (context, params) {
return DebugMainPage(title: 'Debug');
},
'/web_news_detail': (context, params) {
return WebNewsDetailPage(
courseCode: params?['courseCode'] ?? params?['c'] ?? '',
sourceId: params?['sourceId'] ?? params?['s'] ?? '',
);
},
'/sellerapp': (context, params) {
return SellerAppPage(
url: params?['url'] ?? '',
sourceId: params?['sourceId'] ?? params?['s'] ?? '',
);
},
};
/// routes.dart
class RouteConfiguration {
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
return NoAnimationMaterialPageRoute(
settings: settings,
builder: (context) {
var uri = Uri.parse(settings.name ?? '');
/// 根據 path 找頁面的 builder
var route = builders[uri.path];
if (route != null) {
return route(context, uri.queryParameters);
} else {
/// 404 頁面
return CommonPageNotFound(routeSettings: settings);
}
},
);
}
}
2.2.3 改造代碼
創建 DeferredLoaderWidget
執行各頁面加載操作
/// routes.dart
class RouteConfiguration {
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
return NoAnimationMaterialPageRoute(
settings: settings,
builder: (context) {
/// 承擔路由和加載工作
return DeferredLoaderWidget(
settings: settings,
);
},
);
}
}
在 DeferredLoaderWidget
中將各頁面通過 deferred as
方式引入
/// deferred_loader_widget.dart, 新添加的文件
import '../../business/distribution/page/sellerapp_page.dart' deferred as sellerapp;
import '../../business/webmain/page/web_news_detail_page.dart' deferred as web_news_detail;
import '../../debug/page/debug_main_page.dart' deferred as debug;
import '../../ability/common/page/common_page_not_found.dart' deferred as pageNotFound;
import 'package:flutter/material.dart';
typedef WidgetConstructer = Widget Function(Map? params);
/// 分包加載: library 加載 map
/// <頁面地址,library加載方法>
var _loadLibraryMap = {
'/sellerapp': sellerapp.loadLibrary,
'/web_news_detail': web_news_detail.loadLibrary,
'/debug': debug.loadLibrary,
};
/// 分包加載: 頁面 widget 創建方法 map
/// <頁面地址,widget 創建方法>
var _constructorMap = {
'/sellerapp': () => sellerapp.widgetConstructor,
'/web_news_detail': () => web_news_detail.widgetConstructor,
'/debug': () => debug.widgetConstructor,
};
之后在需要的時候對頁面進行加載,在 _DeferredLoaderWidgetState.initState
中執行加載操作:
/// deferred_loader_widget.dart
@override
void initState() {
super.initState();
/// 路由解析
Uri uri = Uri.parse(widget.settings.name ?? '');
path = uri.path;
params = uri.queryParameters;
/// 根據 path 找到 libraryLoad 方法
Future Function()? loadLibrary = _loadLibraryMap[path];
/// 未找到時使用 404 頁面 loadLibrary
if (loadLibrary == null) {
loadLibrary = pageNotFound.loadLibrary;
params = {'settings': widget.settings};
}
loadFuture = loadLibrary.call();
}
DeferredLoaderWidgetState.build
中進行 widget 的創建:
/// deferred_loader_widget.dart
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: loadFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
return Text('頁面加載失敗,請重試');
}
var constructor = _constructorMap[path];
if (constructor == null) {
/// 頁面未找到
constructor = () => pageNotFound.widgetConstructor;
}
return constructor().call(params);
} else {
return Container();
}
},
);
}
其中對于每個頁面在其頭部定義構造統一的構造方法,以 sellerapp
為例:
/// sellerapp_page.dart
/// 頁面構造方法
WidgetConstructer widgetConstructor = (params) {
return SellerAppPage(
url: params?['url'] ?? '',
sourceId: params?['sourceId'] ?? params?['s'] ?? '',
);
};
在進行延遲加載改造時有兩個需要注意的點:
- 各頁面構造方法封裝一定要寫到各頁面的 dart 文件中,這樣才能通過
deferred as
命名引用到 - 各頁面的
widgetConstructor
需要在相應的 library load 之后才能實際調用,在此之前引用的值會在使用時無效,如將deferred_loader_widget
中_constructorMap
進行如下修改:
圖 2. widgetConstructor 錯誤使用方式說明
則運行時會得到如下的報錯信息
圖 3. widgetConstructor 錯誤使用方式報錯信息
2.2.4 分片效果
改造完成后即可進行編譯調試,查看 js 分片和按需加載的效果。
產物對比
查看編譯產物發現 main.dart.js
被拆分成了一個較小的 main.dart.js
和諸多小的 main.dart.js_xx.part.js
圖 4. 分片前后編譯產物對比
頁面加載對比
在瀏覽器中查看頁面 js 加載發現資訊頁和下載頁總的 js 大小均有減少,下載頁因壓縮問題傳輸 js 會比分包前稍大,但總大小有所減少,另外因為分包實現了部分的并行加載,總體耗時有所減少:
表1. 資訊頁 js 加載情況對比
表2. 下載頁 js 加載情況對比
在實驗室環境經過多次測試后取平均時間,發現下載頁耗時減少 15%,資訊頁加載總加載耗時減少 9%。由于下載頁 js 減少更多結果符合預期。
2.3 并行加載
經過延遲加載改造后,產物 js 分成了多個包,相關頁面加載耗時也有所減少,但是在加載中發現一個問題,main.dart.js
和其他分片的 js 不是同時加載的:
圖 5. 分片后 js 加載時序
main.dart.js_xx.part.js
是在 main.dart.js
加載完成之后過了相當一段時間才開始加載,這浪費了很多的加載時間,如果所有的分片 js 都在 main.dart.js
加載時同時加載,則加載耗時基本只會和 main.dart.js
加載耗時相同。
2.3.1 分片加載原理
為了讓所有分片 js 同時加載,首先觀察分片的加載過程。打開頁面后檢查頁面發現情況如下,頁面內被注入了分片 js 的加載代碼:
圖 6. FFW 自動注入的分片加載代碼
在 main.dart.js
中查找相關分片的文件名,可發現如下內容:
圖 7. 分片 main.dart.js 內的 js 加載信息
猜測 main.dart.js
內部包含的各頁面所需 js 分片信息的相關字段含義如下:
- deferredPartUris: 分片文件的列表
- deferredLibraryParts: 每個組件所需分片在列表中的 index
考慮如果能將 main.dart.js
中注入分片的時間提前到 main.dart.js
加載時,則可實現理想的并行加載效果。由于 main.dart.js
還未加載相關注入的代碼不可用,則只能在 index.html
中添加分片的加載代碼。
2.3.2 并行加載實現
有了實現的思路,接下來就是進行操作和驗證。我們使用構建腳本中解析延遲組件信息,并將解析處理后的信息寫入 index.html
中的方案來實現 js 分片的并行加載。
首先在 index.html
中增加加載 js 分片的代碼:
<!-- ffw 分包并行加載,根據頁面 path 并行加載相關的 part.js,不用等到 ffw 執行時自己去加載 -->
<script id="flutterJsPatchLoad">
// 使用腳本替換內容
var deferredLibraryParts = {};
// 使用腳本替換內容
var deferredPartUris = [];
// 使用腳本替換內容
var base = "";
// 根據頁面路徑加載所需 js 分片,為了方便要求 DeferredLoaderWidget 中 _loadLibraryMap key 的名稱
// 和延遲組件的名稱相同
var hash = window.location.hash.substring(2);
var path = hash.split('?')[0];
if (deferredLibraryParts[path]) {
for (var index in deferredLibraryParts[path]) {
loadScript(deferredPartUris[index])
}
}
function loadScript(url) {
var script = document.createElement("script");
script.type = "text/javascript";
script.src = base + url;
document.body.appendChild(script);
}
</script>
之后在構建腳本中解析組件信息,并替換到 deferredLibraryParts
和 deferredPartUris
中,同時在線上發布時將分片 js 的 base 路徑替換為實際的 cdn 地址:
# 從 main.dart.js 中獲取 js 分包信息,寫入 index.html 中預加載部分的變量中
def write_js_patch_info():
# 從 main.dart.js 獲取兩個參數:deferredLibraryParts、deferredPartUris
# 這個階段在本地編譯時執行
parts = reg_find_file_content('./build/web/main.dart.js', r'deferredLibraryParts:{(.*?)},')[0]
uris = reg_find_file_content('./build/web/main.dart.js', r'deferredPartUris:\[(.*?)\],')[0]
str_replace_file_content('./build/web/index.html', r'deferredLibraryParts = {}', r'deferredLibraryParts = {' + parts + r'}')
str_replace_file_content('./build/web/index.html', r'deferredPartUris = []', r'deferredPartUris = [{}]'.format(uris))
# 修改 index.html 中的 base 為實際的cdn地址
def change_base(version, publish_env):
str_replace_file_content('./build/web/index.html', r'base = ""', r'base = "{}"'.format(get_base(version, publish_env)))
構建過程中經過腳本的替換,index.html
內容更新如下:
<!-- ffw 分包并行加載,根據頁面 path 并行加載相關的 part.js,不用等到 ffw 執行時自己去加載 -->
<script id="flutterJsPatchLoad">
// 使用腳本替換內容
var deferredLibraryParts = {sellerapp:[0,1,2,3],web_news_detail:[0,4,1,5,2,6],debug:[0,4,1,7,5,8],pageNotFound:[0,4,7,9]};
// 使用腳本替換內容
var deferredPartUris = ["main.dart.js_3.part.js","main.dart.js_9.part.js","main.dart.js_7.part.js","main.dart.js_6.part.js","main.dart.js_4.part.js","main.dart.js_11.part.js","main.dart.js_10.part.js","main.dart.js_2.part.js","main.dart.js_12.part.js","main.dart.js_1.part.js"];
// 使用腳本替換內容
var base = "https://g.alicdn.com/algernon/alisupplier_content_web/2.0.5/";
// 根據頁面路徑加載所需 js 分片,為了方便要求 DeferredLoaderWidget 中 _loadLibraryMap key 的名稱
// 和延遲組件的名稱相同
var hash = window.location.hash.substring(2);
var path = hash.split('?')[0];
if (deferredLibraryParts[path]) {
for (var index in deferredLibraryParts[path]) {
loadScript(deferredPartUris[index])
}
}
function loadScript(url) {
var script = document.createElement("script");
script.type = "text/javascript";
script.src = base + url;
document.body.appendChild(script);
}
</script>
構建部署完成后測試加載過程如下,發現各分片 js 加載完成時間接近,基本與 main.dart.js
加載完成時間相同:
圖 8. 并行加載改造后的 js 加載時序
同時檢查頁面發現,FFW 沒有再額外注入分片 js 的加載代碼,至此分片 js 并行加載達到了理想的效果。
圖 9. 并行加載改造后 FFW 不再注入分片加載代碼
2.3.3 異常說明
在實際使用中發現 deferredLibraryParts
中包含的信息與實際所需分片可能不完全相同,如在 main.dart.js
中資訊頁面的deferredLibraryParts
加載信息為 0,4,1,5,2,6
6 個分片,但在實際打開頁面的時候發現還會加載 index 為 7
的分片:
圖 10. FFW 額外需加載的 js 分片
簡單的解析 deferredLibraryParts
不夠精確,要做到更精確還需深入分析 main.dart.js
代碼,這里目前采用人工修正的方式處理。
2.3.4 并行效果
經過并行加載改造后,資訊頁面總加載耗時進一步減少,加載耗時由 -9% 變為 -15%。下載頁則提升不明顯,考慮原因為下載頁多圖片資源占比稍大,IO資源在非并行的狀態下已經得到了較為充分的使用。
3 . 效果分析
由于當前阿里賣家 FFW 頁面訪問量不夠大,同時線上性能數據為初次啟動和非初次啟動的混合數據不易區分,這里使用多次實驗取平均數方式分析效果。
圖 11. 資訊頁下載頁分片及并行改造結果對比
分析結論如下:
- 資訊頁:從分片到并行耗時分別減少 9% 和減少 15%,資訊頁主要包括 js 加載和數據請求,受益于 domContentLoaded 時間減少數據請求可以更快進行,并行化處理后提速明顯。
- 下載頁:從分片到并行耗時維持在減少 15% 左右,下載頁主要受益于 js 按需加載,而包含多個圖片帶寬在非理想的并行情況下也得到了較為充分的使用,所以并行化處理效果不明顯。
4 . 未來展望
分片之后 main.dart.js
還有 1.3 MB 的體積,還有優化空間,另外延遲加載信息的解析還未做到完全精確??傮w來說在加載提速上未來可做的事情還有:
- FFW 引擎功能及代碼精簡,繼續減少
main.dart.js
大小 - 延遲加載信息精確分析,做到延遲加載信息的完全精確
- 非當前頁面分片預加載,提升多頁面切換速度
FFW 在生產環境使用的條件已經成熟,在當前開發人員存量的情況,FFW 是端技術同學的一大利器。FFW 當前與前端體系的分離是影響其在前端推廣使用的一大阻力,如果能做好 FFW 和現有前端體系的融合,相信會更加的繁榮。
參考資料
[1]code splitting: https://webpack.js.org/guides/code-splitting/
[2]延遲加載組件: https://flutter.cn/docs/perf/deferred-components