Flutter for Web 首次首屏优化——JS 分片优化

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: Flutter for Web 首次首屏优化——JS 分片优化

Flutter for Web(FFW)从  2021 年发布至今,在国内外互联网公司已经得到较多的应用。作为 Flutter 技术在 Web 领域的有力扩充,FFW 可以让熟悉 Flutter 的客户端同学直接上手写 H5,复用 App 端代码高效支撑业务需求;在 App 侧 FFW 也可作为 Flutter 动态下发的兜底方案。总的来说在业务和技术上 FFW 都具有相当的价值。

然而在使用 FFW 时有一个明显的问题:其编译产物 main.dart.js 较大,初始的 Hello world 工程编译后产物 js 大小为 1.2 MB,添加业务代码后 js 的大小还会继续增加。在阿里卖家的内容外投业务中,3 个页面的工程 js 大小为 2.0 MB,js 文件过大直接的影响就是页面首次首屏加载的速度。针对 js 的大小有较多优化方法,本文主要记录 main.dart.js 分片优化方案的实现。

1.方案总览

image.png页面 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 进行如下修改:


image.png

2.2.4 分片效果

改造完成后即可进行编译调试,查看 js 分片和按需加载的效果。

产物对比

查看编译产物发现 main.dart.js 被拆分成了一个较小的 main.dart.js 和诸多小的 main.dart.js_xx.part.js

image.png

image.png

main.dart.js_xx.part.js 是在 main.dart.js 加载完成之后过了相当一段时间才开始加载,这浪费了很多的加载时间,如果所有的分片 js 都在 main.dart.js 加载时同时加载,则加载耗时基本只会和 main.dart.js 加载耗时相同。

2.3.1 分片加载原理

为了让所有分片 js 同时加载,首先观察分片的加载过程。打开页面后检查页面发现情况如下,页面内被注入了分片 js 的加载代码:

image.png

猜测 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>

之后在构建脚本中解析组件信息,并替换到 deferredLibraryPartsdeferredPartUris 中,同时在线上发布时将分片 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 加载完成时间相同:

image.png

2.3.3 异常说明

在实际使用中发现 deferredLibraryParts 中包含的信息与实际所需分片可能不完全相同,如在 main.dart.js 中资讯页面的deferredLibraryParts加载信息为 0,4,1,5,2,6 6 个分片,但在实际打开页面的时候发现还会加载 index 为 7 的分片:

image.png

简单的解析 deferredLibraryParts 不够精确,要做到更精确还需深入分析 main.dart.js 代码,这里目前采用人工修正的方式处理。

2.3.4 并行效果

经过并行加载改造后,资讯页面总加载耗时进一步减少,加载耗时由 -9% 变为 -15%。下载页则提升不明显,考虑原因为下载页多图片资源占比稍大,IO资源在非并行的状态下已经得到了较为充分的使用。

3. 效果分析

由于当前阿里卖家 FFW 页面访问量不够大,同时线上性能数据为初次启动和非初次启动的混合数据不易区分,这里使用多次实验取平均数方式分析效果。

image.png

分析结论如下:

  • 资讯页:从分片到并行耗时分别减少 9% 和减少 15%,资讯页主要包括 js 加载和数据请求,受益于 domContentLoaded 时间减少数据请求可以更快进行,并行化处理后提速明显。
  • 下载页:从分片到并行耗时维持在减少 15% 左右,下载页主要受益于 js 按需加载,而包含多个图片带宽在非理想的并行情况下也得到了较为充分的使用,所以并行化处理效果不明显。

4. 未来展望

分片之后 main.dart.js 还有 1.3 MB 的体积,还有优化空间,另外延迟加载信息的解析还未做到完全精确。总体来说在加载提速上未来可做的事情还有:

  • FFW 引擎功能及代码精简,继续减少 main.dart.js 大小
  • 延迟加载信息精确分析,做到延迟加载信息的完全精确
  • 非当前页面分片预加载,提升多页面切换速度

FFW 在生产环境使用的条件已经成熟,在当前开发人员存量的情况,FFW 是端技术同学的一大利器。FFW 当前与前端体系的分离是影响其在前端推广使用的一大阻力,如果能做好 FFW 和现有前端体系的融合,相信会更加的繁荣。

相关文章
|
2月前
|
Web App开发 前端开发 JavaScript
探索Python科学计算的边界:利用Selenium进行Web应用性能测试与优化
【10月更文挑战第6天】随着互联网技术的发展,Web应用程序已经成为人们日常生活和工作中不可或缺的一部分。这些应用不仅需要提供丰富的功能,还必须具备良好的性能表现以保证用户体验。性能测试是确保Web应用能够快速响应用户请求并处理大量并发访问的关键步骤之一。本文将探讨如何使用Python结合Selenium来进行Web应用的性能测试,并通过实际代码示例展示如何识别瓶颈及优化应用。
125 5
|
13天前
|
缓存 前端开发 JavaScript
优化CSS和JavaScript加载
优化CSS和JavaScript加载
|
17天前
|
缓存 JavaScript 前端开发
介绍一下 JavaScript 中数组方法的常见优化技巧
通过合理运用这些优化技巧,可以提高 JavaScript 中数组方法的执行效率,提升代码的整体性能。在实际开发中,需要根据具体的业务场景和数据特点选择合适的优化方法。
24 6
|
15天前
|
缓存 前端开发 JavaScript
优化CSS和JavaScript加载
Next.js和Nuxt.js在优化CSS和JavaScript加载方面提供了多种策略和工具。Next.js通过代码拆分、图片优化和特定的CSS/JavaScript优化措施提升性能;Nuxt.js则通过代码分割、懒加载、预渲染静态页面、Webpack配置和服务端缓存来实现优化。两者均能有效提高应用性能。
|
23天前
|
JSON 监控 JavaScript
Node.js-API 限流与日志优化
Node.js-API 限流与日志优化
|
2月前
|
存储 JavaScript 前端开发
JavaScript垃圾回收机制与优化
【10月更文挑战第21】JavaScript垃圾回收机制与优化
31 5
|
2月前
|
机器学习/深度学习 缓存 监控
利用机器学习优化Web性能和用户体验
【10月更文挑战第16天】本文探讨了如何利用机器学习技术优化Web性能和用户体验。通过分析用户行为和性能数据,机器学习可以实现动态资源优化、预测性缓存、性能瓶颈检测和自适应用户体验。文章还介绍了实施步骤和实战技巧,帮助开发者更有效地提升Web应用的速度和用户满意度。
|
2月前
|
SQL 关系型数据库 数据库
优化Web开发流程:Python ORM的优势与实现细节
【10月更文挑战第4天】在Web开发中,数据库操作至关重要,但直接编写SQL语句既繁琐又易错。对象关系映射(ORM)技术应运而生,让开发者以面向对象的方式操作数据库,显著提升了开发效率和代码可维护性。本文探讨Python ORM的优势及其实现细节,并通过Django ORM的示例展示其应用。ORM提供高级抽象层,简化数据库操作,提高代码可读性,并支持多种数据库后端,防止SQL注入。Django内置强大的ORM系统,通过定义模型、生成数据库表、插入和查询数据等步骤,展示了如何利用ORM简化复杂的数据库操作。
61 6
|
2月前
|
缓存 前端开发 JavaScript
探索现代Web开发中的前端性能优化策略
【10月更文挑战第5天】探索现代Web开发中的前端性能优化策略
|
2月前
|
缓存 前端开发 JavaScript
构建高性能Web应用:优化前端性能的策略
构建高性能Web应用:优化前端性能的策略