关于flutter:Flutter-Web-在一起漫部的性能优化探索与实践

3次阅读

共计 17951 个字符,预计需要花费 45 分钟才能阅读完成。

一起漫部 是基于区块链技术发明的新型数字生存。

目录

  • 前言
  • 开发环境
  • 渲染模式
  • 首屏白屏
  • 优化计划

    • 启屏页优化
    • 包体积优化

      • 去除无用的 icon
      • 裁剪字体文件
      • deferred 提早加载
      • 启用 gzip 压缩
    • 加载优化

      • 大文件分片下载
      • 资源文件 hash 化
      • 资源文件 cdn 化
  • 成绩
  • 参考链接
  • 总结

前言

不久前,App 小组面临一场开发挑战,即『一起漫部』须要在 App 的根底上开发出一套 H5 版本。

因为一起漫部 App 版本是应用 Flutter 技术开发的,对于 H5 版本的技术选型,Flutter Web 成为咱们的第一选择对象。通过调研,咱们理解到在 Flutter 1.0 发布会上由介绍如何让 Flutter 运行在 Web 上而提出 Flutter Web 的概念,到 Flutter1.5.4 版本推出 Flutter Web 的预览版,到 Flutter 2.0 官网发表 Flutter Web 现已进入稳定版,再到现在 Flutter 对 Web 的不断更新,咱们看到了 Flutter Web 的倒退劣势。同时,为了复用现有 App 版本的代码,咱们团队决定尝试应用 Flutter Web 来实现一起漫部 H5 版本的开发。

通过 App 组小伙伴的共同努力,一起漫部在 Flutter Web 的反对下实现了 H5 端的复刻版本,使 H5 端放弃了和 App 同样的性能以及交互体验。在我的项目实际过程中,Flutter Web 带来的整体验还不错,但仍然存在较大的性能问题,次要体现在 首屏渲染工夫长,用户白屏体验差,本篇文章也将围绕此问题,剖析一起漫部是如何逐渐优化,晋升用户体验的。

开发环境

剖析性能问题之前,简略介绍下所应用的开发环境,次要包含设施环境、Flutter 环境和 Nginx 环境三方面。

设施环境

Flutter 环境

如图所示,咱们团队是在 Flutter 3.0.5 版本上进行 App to Web 的工作。

Nginx 环境

    server {
       listen       9090;
       server_name  localhost;

       location / {
            root   /build/web;
            index  index.html index.htm;
            try_files $uri $uri/ /index.html;
       }

       location /api {proxy_pass xxx-xxx-xxx; # your server domain}
    }

为了不便公布测试,我在本地搭建了一个 nginx 服务器,版本是 1.21.6,同时新建了个 server 配置,将本地 9090 端口指向 Flutter Web 打包产物的根门路,当在浏览器输出http://localhost:9090/ 即可失常拜访一起漫部 Web 利用,具体的的 server 配置见上图。

渲染模式

对开发环境有了大略理解后,咱们再学习下如何构建 Flutter Web 利用。

官网提供了 Flutter build web 命令来构建 Web 利用,并且反对 canvaskit、html 两种渲染器模式,通过 --web-renderer 参数来抉择应用。

canvaskit

当应用 canvaskit 渲染器模式时,flutter 将 Skia 编译成 WebAssembly 格局,并应用 WebGL 渲染元素

  • 长处:渲染性能更好,跨端一致性高,
  • 毛病:利用体积变大,关上速度慢(须要加载 canvaskit.wasm 文件),兼容性绝对差

html

当应用 html 渲染器模式时,flutter 采纳 HTML 的 Custom Element、CSS、SVG、2D Canvas 和 WebGL 组合渲染元素

  • 长处:利用体积更小,关上速度较快,兼容性更好
  • 毛病:渲染性能绝对差,跨端一致性受到影响

此外,执行 Flutter build web 命令构建时,--web-renderer参数的默认值是 auto,即理论执行的是flutter build web --web-renderer auto 命令。乏味的是,auto模式会主动依据以后运行环境来抉择渲染器,当运行在挪动浏览器端时应用 html 渲染器,当运行在桌面浏览器端时应用 canvaskit 渲染器。

一起漫部 H5 版本次要是运行在挪动浏览器端,为了有更好的兼容性、更快的关上速度以及绝对较小的利用体积,间接采纳 html 渲染器模式。

首屏白屏

当执行 flutter build web --web-renderer html 命令实现 Web 利用构建后,咱们应用 Chrome 浏览器间接拜访 http://192.168.1.4:9090/,很显著的感觉到了首屏加载慢,用户白屏的体验,即 首屏白屏 问题。那么为什么会呈现白屏问题?

首先,咱们须要理解浏览器渲染过程:

  1. 解析 HTML,构建 DOM 树
  2. 解析 CSS,构建 CSSOM 树
  3. 合并 DOM 树和 CSSOM 树,构建 Render 渲染树
  4. 遍历 Render 渲染树计算节点地位大小进行布局
  5. 依据节点地位大小信息,进行绘制
  6. 遇到 script 暂停渲染,优先解析执行 javascript,再持续渲染
  7. 最初绘制出所有节点,展示页面

通过 Performance 工具剖析:

  • 浏览器期待 HTML 文档返回,此时处于白屏状态,实践白屏工夫
  • 解析完 HTML 文档后开始渲染首屏,呈现灰屏 (测试背景) 状态,理论白屏工夫 - 实践白屏工夫
  • 加载 JS、解析 JS 等过程耗时长,导致界面长时间处于灰屏 (测试背景) 状态
  • JS 解析实现后,界面渲染出大略的框架结构
  • 申请 API 获取到数据后开始显示渲染出首屏页面

通过 Network 工具剖析:

  • 首屏页面总共发动 21 个 request,传输 7.3MB 数据,耗时 8.31s;
  • 依据申请资源大小排序,main.dart.js传输 5.6M 资源耗时 5.22s,MaterialIcons-Regular.otf传输 1.6M 资源耗时 1.58s,其它资源传输数据小耗时短。

由剖析得出结论,在首屏渲染过程当中,因为期待资源文件加载、DOM 树构建、JS 解析、布局和绘制等耗时工作,导致用户长时间处于不可交互的白屏状态,给用户的一种网页很 的感觉。

优化计划

如果网站太慢会影响用户体验,那么要如何优化呢?

启屏页优化

针对白屏问题,咱们从 Flutter 为 Android 提供 SplashScreenDrawable 的设置失去启发,在 Web 上同样建设一个启屏页,在启屏页中 通过增加 Loading 或骨架屏去给用户出现了一个动静的页面,从而升高白屏体验差的影响。当然,这只是一个治标不治本的计划,因为从根本上没有解决加载慢的问题。具体实现的话,在 index.html 外面搁置一起漫部的 logo 并增加相应的动画款式,在 window 的 load 事件 触发时显示 logo,最初在应用程序第一帧渲染实现后移除即可。

启屏页实现代码,仅供参考:


<div id="loading">
    <style>
    body {
      inset: 0;
      overflow: hidden;
      margin: 0;
      padding: 0;
      position: fixed;
      left: 0;
      top: 0;
      right: 0;
      bottom: 0;
    }
    #loading {
      width: 100%;
      height: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    #loading img {
      border-radius: 16px;
      width: 90px;
      height: 90px;
      animation: 1s ease-in-out 0s infinite alternate breathe;
      opacity: 0.66;
      transition: opacity 0.4s;
    }
    #loading.main_done img {opacity: 1;}
    #loading.init_done img {opacity: 0.05;}
    @keyframes breathe {
      from {transform: scale(1);
      }
      to {transform: scale(0.95);
      }
    }
    </style>
    <img src="icons/Icon-192.png" alt="Loading..."/>
</div>
<script>
  window.addEventListener("load", function (ev) {var loading = document.querySelector("#loading");
    // Download main.dart.js
    _flutter.loader
      .loadEntrypoint({
        serviceWorker: {serviceWorkerVersion: serviceWorkerVersion,},
      })
      .then(function (engineInitializer) {loading.classList.add("main_done");
        return engineInitializer.initializeEngine();})
      .then(function (appRunner) {loading.classList.add("init_done");
        return appRunner.runApp();})
      .then(function (app) {
        // Wait a few milliseconds so users can see the "zoooom" animation
        // before getting rid of the "loading" div.
        window.setTimeout(function () {loading.remove();
        }, 200);
      });
  });
</script>

包体积优化

咱们先理解下 Flutter Web 的打包文件构造:

├── assets                                          // 动态资源文件,次要包含图片、字体、清单文件等
│   ├── AssetManifest.json                    // 资源 (图片、视频、文件等) 清单文件
│   ├── FontManifest.json                     // 字体清单文件
│   ├── NOTICES
│   ├── fonts
│   │   └── MaterialIcons-Regular.otf   // 字体文件,Material 格调的图标
│   ├── images                                // 图片文件夹
├── canvaskit                                       // canvaskit 渲染模式构建产生的文件
├── favicon.png
├── flutter.js                                      // FlutterLoader 的实现,次要是下载 main.dart.js 文件、读取 service worker 缓存等,被 index.html 调用
├── flutter_service_worker.js                       // service worker 的应用,次要实现文件缓存
├── icons                                           // pwa 利用图标
├── index.html                                      // 入口文件
├── main.dart.js                                    // JS 主体文件,由 flutter 框架、第三方库、业务代码编译产生的
├── manifest.json                                   // pwa 利用清单文件
└── version.json                                    // 版本文件

剖析可知,Flutter Web 实质上也是个单应用程序,次要由 index.html 入口文件、main.dart.js主体文件和其它资源文件组成。浏览器申请 index.html 后,首先下载 main.dart.js 主文件,再解析和执行 js 文件,最初渲染出页面。通过首屏白屏问题剖析,咱们晓得网页慢次要是加载资源文件耗时过长,尤其是 main.dart.jsMaterialIcons-Regular.otf两个文件,针对这两个文件咱们又进行了以下优化。

去除无用的 icon

Flutter 默认会援用 cupertino_icons,打包 Web 利用会产生一个大小 283KB 的CupertinoIcons.ttf 文件,如果不需要的话能够在 pubspec.yaml 文件中去掉 cupertino_icons: ^2.0.0 的援用,缩小这些资源的加载。

裁剪字体文件

Flutter 默认会打包 MaterialIcons-Regular.otf 字体库,外面蕴含了一些预置的 Material 设计格调 icon,所以体积比拟大。然而每次都加载一个 1.6M 的字体文件是不合理的,咱们发现 flutter 提供 --tree-shake-icons 命令去裁剪掉没有应用的图标,在尝试 flutter build web --web-renderer html --tree-shake-icons 打包 Web 利用时却出现异常。

通过剖析咱们发现 flutter build apk 命令也会对 MaterialIcons-Regular.otf 字体文件进行了裁剪并且没有呈现构建异样,因而咱们在 Flutter Web 下应用 Android 下 MaterialIcons-Regular.otf 字体文件,后果字体大小从 1.6M 降落到 6kb。

cp -r ./build/app/intermediates/flutter/release/flutter_assets/fonts ./web/assets

MaterialIcons-Regular.otf 拷贝至 /web/assets 目录下,当前每次进行 Web 利用构建将会应用 Android 下 MaterialIcons-Regular.otf 字体。

deferred 提早加载

main.dart.js包含我的项目中所有的 Dart 代码,导致文件体积很大,对此官网提供了 deferred 关键字来实现 Widget 的提早加载,具体应用查看官网文档

咱们对 deferred 的应用进行了封装解决,仅供参考:

/// loadLibrary
typedef AppLibraryLoader = Future<dynamic> Function();

/// deferredWidgetBuilder
typedef AppDeferredWidgetBuilder = Widget Function();

/// 提早加载组件
/// 不在 build 里应用 FutureBuilder 加载,因为 build 执行多少次就会导致 widget 创立多少次
/// 这里在 initState 加载,或者当 AppDeferredWidgetBuilder 扭转时从新加载
class AppDeferredWidget extends StatefulWidget {
  const AppDeferredWidget({
    Key? key,
    required this.libraryLoader,
    required this.builder,
    Widget? placeholder,
  })
      : placeholder = placeholder ?? const AppDeferredLoading(),
        super(key: key);

  final AppLibraryLoader libraryLoader;
  final AppDeferredWidgetBuilder builder;
  final Widget placeholder;

  static final Map<AppLibraryLoader, Future<dynamic>> _moduleLoaders =
  <AppLibraryLoader, Future<dynamic>>{};
  static final Set<AppLibraryLoader> _loadedModules = <AppLibraryLoader>{};

  /// 预加载
  static Future<dynamic> preload(AppLibraryLoader loader) {if (!_moduleLoaders.containsKey(loader)) {_moduleLoaders[loader] = loader().then((_) {_loadedModules.add(loader);
      });
    }
    return _moduleLoaders[loader]!;
  }

  @override
  State<AppDeferredWidget> createState() => _AppDeferredWidgetState();
}

class _AppDeferredWidgetState extends State<AppDeferredWidget> {
  Widget? _loadedChild;
  AppDeferredWidgetBuilder? _loadedBuilder;

  @override
  void initState() {super.initState();
    if (AppDeferredWidget._moduleLoaders.containsKey(widget.libraryLoader)) {_onLibraryLoaded();
    } else {AppDeferredWidget.preload(widget.libraryLoader)
          .then((_) => _onLibraryLoaded());
    }
  }

  void _onLibraryLoaded() {setState(() {
      _loadedBuilder = widget.builder;
      _loadedChild = _loadedBuilder?.call();});
  }

  @override
  Widget build(BuildContext context) {if (_loadedBuilder != widget.builder && _loadedChild != null) {
      _loadedBuilder = widget.builder;
      _loadedChild = _loadedBuilder?.call();}
    return _loadedChild ?? widget.placeholder;
  }
}

/// 提早加载 Loading
class AppDeferredLoading extends StatelessWidget {const AppDeferredLoading({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      alignment: Alignment.center,
      child: const AppLogo(),);
  }
}
import '../groups/login/login_phone/view/login_phone_page.dart' deferred as login_phone_page;
{
    AppRoutes.routes_login_phone: (BuildContext context,
        {Map<String, dynamic>? arguments}) =>
    AppDeferredWidget(
      libraryLoader: login_phone_page.loadLibrary,
      builder: () => login_phone_page.LoginPhonePage(),
    )
}

应用 deferred 提早加载后,业务代码被拆分到多个 xxx.part.js 的文件,同时主体 main.dart.js 文件体积从 5.6M 缩小至 4.3M,对包体积优化有肯定成果。

一开始,咱们将我的项目中所有的路由都应用 deferred 进行提早加载,然而 50 个页面却产生了近 200 个 xxx.part.js 文件,如何治理数量增多的 xxx.part.js 文件成了新的问题,况且 main.dart.js 体积减小并没有达到预期,起初咱们决定放弃全量应用 deferred 提早加载,仅在不同模块间应用。

启用 gzip 压缩

  #开启 gzip
  gzip  on;
  #低于 1kb 的资源不压缩
  gzip_min_length 1k;
  # 设置压缩所须要的缓冲区大小
  gzip_buffers 16 64k;
  #压缩级别 1 -9,越大压缩率越高,同时耗费 cpu 资源也越多,倡议设置在 5 左右。gzip_comp_level 5;
  #须要压缩哪些响应类型的资源,多个空格隔开。不倡议压缩图片.
  gzip_types text/plain text/css text/javascript text/xml application/json application/x-javascript application/javascript application/xml application/xml+rss image/jpeg image/gif image/png image/jpg;
  #配置禁用 gzip 条件,反对正则。此处示意 ie6 及以下不启用 gzip(因为 ie 低版本不反对)gzip_disable "MSIE [1-6]\.";
  #是否增加“Vary: Accept-Encoding”响应头
  gzip_vary on;

通过配置 nginx 开启 gzip 压缩,main.dart.js传输大小从 5.6M 降落到 1.6M,耗时从 5.22s 缩小到 1.42s,速度晋升显著。

加载优化

加载优化的话次要从大文件分片下载、资源文件 hash 化和资源文件 cdn 化和三方面思考,对此咱们又做了以下优化。具体的编码实现请参考 Web Optimize

大文件分片下载

因为 main.dart.js 体积大,独自下载大文件速度慢,势必影响首屏的加载性能。对此,咱们提出 分片加载 的计划,具体实现如下:

  1. 通过脚本将 main.dart.js 切割成 6 个独自的纯文本文件
  2. 通过 XHR 的形式并行下载 6 个纯文本文件
  3. 期待下载实现后,将 6 个纯文本文件依照程序拼接,失去残缺的 main.dart.js 文件
  4. 创立 script 标签,将残缺的 main.dart.js 文件内容赋值给 text 属性
  5. 最初将 script 标签插入到 html body

分片代码,仅供参考:

// 写入单个文件
Future<bool> writeSingleFile({
  required File file,
  required String filename,
  required int startIndex,
  required endIndex,
}) {final Completer<bool> completer = Completer();
  final File f = File(path.join(file.parent.path, filename));
  if (f.existsSync()) {f.deleteSync();
  }
  final RandomAccessFile raf = f.openSync(mode: FileMode.write);
  final Stream<List<int>> inputStream = file.openRead(startIndex, endIndex);
  inputStream.listen((List<int> data) {raf.writeFromSync(data);
    },
    onDone: () {raf.flushSync();
      raf.closeSync();
      completer.complete(true);
    },
    onError: (dynamic data) {raf.flushSync();
      raf.closeSync();
      completer.completeError(data);
    },
  );
  return completer.future;
}

final int totalChunk = 6;
final Uint8List bytes = file.readAsBytesSync();
int chunkSize = (bytes.length / totalChunk).ceil();
final List<Future<bool>> futures = List<Future<bool>>.generate(
  totalChunk,
  (int index) {
    return writeSingleFile(
      file: file,
      filename: 'main.dart_$index.js',
      startIndex: index * chunkSize,
      endIndex: (index + 1) * chunkSize,
    );
  },
);
await Future.wait(futures);

/// 分片实现后删除 main.dart.js
file.deleteSync();

并行下载代码,仅供参考:

    _downloadSplitJs(url){return new Promise((resolve, reject)=>{const xhr = new XMLHttpRequest();
        xhr.open("get", url, true);
        xhr.onreadystatechange = () => {if (xhr.readyState == 4) {if (xhr.status >= 200 && xhr.status < 300 || xhr.status == 304){resolve(xhr.responseText);
                }
            }
        };
        xhr.onerror = reject;
        xhr.ontimeout = reject;
        xhr.send();})
    }
    
  _retryCount = 0;  

  const promises = Object.keys(jsManifest).filter(key => /main.dart_\d.js/g.test(key)).sort().map(key => `${assetBase}${jsManifest[key]}`).map(this._downloadSplitJs);
  Promise.all(promises).then((values)=>{const contents = values.join("");
    const script = document.createElement("script");
    script.text = contents;
    script.type = "text/javascript";

    this._didCreateEngineInitializerResolve = resolve;
    script.addEventListener("error", reject);
    document.body.appendChild(script);
  }).catch(()=>{// console.error("main.dart.js download fail,refresh and try again");

    // retry again
    if (++this._retryCount > 3) {const element = document.createElement("a");
      element.href = "javascript:location.reload()";
      element.style.textAlign = "center";
      element.style.margin = "50px auto";
      element.style.display = "block";
      element.style.color = "#f89800";
      element.innerText = "加载失败,点击从新申请页面";
      document.body.appendChild(a);
    } else {this._loadEntrypoint(entrypointUrl);
    }
  });

通过分片加载后,同时开启 6 个下载工作,最高耗时 634ms,加载进一步晋升。

资源文件 hash 化

浏览器会对同名文件缓存,为防止性能更新不及时,咱们须要对资源文件进行 hash 化。

首先,咱们须要确定哪些资源文件须要进行 hash 化?

通过对打包产物剖析,会频繁变动的资源次要是图片、字体和 js 文件,因而须要对这些资源进行 hash 解决,实现步骤如下:

  1. 遍历图片、字体和 js 等文件
  2. 计算每个文件的 hash 值
  3. 为新文件命名为[name].[hash].[extension]

    资源 hash 实现后,如何加载新文件?针对图片、字体和 js 咱们别离做了以下优化

  • js 文件次要分为两类,main.dart.js分片后的文件是由 XHR 间接下载拼接失去的,deferred提早加载拆分的文件是通过 window.dartDeferredLibraryLoader自定义办法 间接组成 script 标签插入 html 中,在对文件 hash 解决时记录下新旧文件名的映射关系,在获取 js 文件时就能够通过旧文件名获取到新文件名进行加载。
  • 图片和字体文件,通过对 main.dart.js 源码的剖析,咱们发现程序在启动时会先去读取 AssetManifest.jsonFontManifest.json清单文件,依据清单文件外面的资源映射关系去加载对应的图片和字体,因而咱们在打包后去批改资源映射关系,将外面的文件名换成 hash 后的新文件名就行了。

    资源 hash 代码,仅供参考:

    /// md5
    String md5(File file) {final Uint8List bytes = file.readAsBytesSync();
    // 截取 8 位即可
    final md5Hash = crypto.md5.convert(bytes).toString().substring(0, 8);
    
    // 文件名应用 hash 值
    final basename = path.basenameWithoutExtension(file.path);
    final extension = path.extension(file.path);
    return '$basename.$md5Hash$extension';
    }
    
    /// 替换
    String replace(
    Match match,
    File file,
    String key,
    Map<String, String> hashFiles,
    ) {
    // 文件名应用 hash 值
    final String filename = md5(file);
    final dirname = path.dirname(key);
    final String newKey = path.join(dirname, filename);
    
    // hash 文件门路
    final String newPath = path.join(path.dirname(file.path), filename);
    hashFiles[file.path] = newPath;
    
    return '${match[1]}$newKey${match[3]}';
    }
    
    // 读取资源清单文件
    final File assetManifest =
      File('$webArtifactsOutputDir/assets/AssetManifest.json');
    String assetManifestContent = assetManifest.readAsStringSync();
    // 读取字体清单文件
    final File fontManifest =
      File('$webArtifactsOutputDir/assets/FontManifest.json');
    String fontManifestContent = fontManifest.readAsStringSync();
    
    // 遍历 assets 目录
    final Directory assetsDir = Directory(webArtifactsOutputDir);
    Map<String, String> hashFiles = <String, String>{};
    assetsDir
      .listSync(recursive: true)
      .whereType<File>() // 文件类型
      .where((File file) => !path.basename(file.path).startsWith('.'))
      .forEach((File file) {if (RegExp(r'main.dart(.*)\.js$').hasMatch(file.path)) {
      // 替换资 js 文件
      final String filename = md5(file);
      hashFiles[file.path] = path.join(path.dirname(file.path), filename);
      jsManifest[path.basename(file.path)] = filename;
    }
    
    if (file.path.contains('$webArtifactsOutputDir/assets')) {
      final String key =
          path.relative(file.path, from: '$webArtifactsOutputDir/assets');
      // 替换资源清单文件
      assetManifestContent = assetManifestContent.replaceAllMapped(RegExp('(.*)($key)(.*)'),
        (Match match) => replace(match, file, key, hashFiles),
      );
      // 替换字体清单文件
      fontManifestContent = fontManifestContent.replaceAllMapped(RegExp('(.*)($key)(.*)'),
        (Match match) => replace(match, file, key, hashFiles),
      );
    }
    });
    
    // 重命名文件
    hashFiles.forEach((String key, String value) {File(key).renameSync(value);
    });
    
    // 写入资源、字体清单文件
    assetManifest.writeAsStringSync(assetManifestContent);
    fontManifest.writeAsStringSync(fontManifestContent);

    测试后果:当初图片、字体和 js 都曾经是加载 hash 后的资源,仍然失常运行,证实此计划是可行的。

    资源文件 cdn 化

    cdn 具备减速性能,为了进步网页加载速度,咱们须要对资源文件进行 cdn 化。在实践中,发现 Flutter 仅反对相对路径的资源加载形式,而且对于图片和 Javascript 资源加载逻辑也不雷同,为此咱们须要别离进行优化。

图片解决

通过对 main.dart.js 源码的剖析,咱们发现在加载图片资源时,会先在 index.html 中查找 <meta name="assetBase" content="任意值"> 的 meta 标签,获取 meta 标签的 content 值作为 baseUrlasset(图片名称)进行拼接,最初依据拼接好的 URL 来加载资源。然而咱们在 index.html 中并没有找到这种 meta 标签,于是就会依据相对路径进行图片加载。对此,咱们在打包时向 index.html 注入 meta 标签并把 content 设置为 CDN 门路,就样就实现了图片资源 cdn 化。

JS 解决

通过对 main.dart.js 源码的剖析,咱们发现在加载 xxx.part.js 文件时会先判断 window.dartDeferredLibraryLoader 是否存在,如果存在的话则应用自定义的 dartDeferredLibraryLoader 办法加载,否则应用默认的 script 标签加载。对此,咱们在打包时向 index.html 注入 dartDeferredLibraryLoader 办法的实现,将传过来的 uriAsString 参数批改成 CDN 的地址,这样就实现了 JS 资源的 cdn 化。

实现代码,仅供参考:

import 'package:html/dom.dart';
import 'package:html/parser.dart' show parse;
import 'package:path/path.dart' as path;

final File file = File('$webArtifactsOutputDir/index.html');
final String contents = file.readAsStringSync();
final Document document = parse(contents);

/// 注入 meta 标签
final List<Element> metas = document.getElementsByTagName('meta');
final Element? headElement = document.head;
if (headElement != null) {final Element meta = Element.tag('meta');
  meta.attributes['name'] = 'assetBase';
  meta.attributes['content'] = 'xxx';

  if (metas.isNotEmpty) {
    final Element lastMeta = metas.last;
    lastMeta.append(Text('\n'));
    lastMeta.append(Comment('content 值必须以 / 结尾'));
    lastMeta.append(Text('\n'));
    lastMeta.append(meta);
  } else {headElement.append(Comment('content 值必须以 / 结尾'));
    headElement.append(Text('\n'));
    headElement.append(meta);
    headElement.append(Text('\n'));
  }
}

/// 注入 script
String dartDeferredLibraryLoader = r'''
// auto-generate, dont edit!!!!!!
var assetBase = null;
var jsManifest = null;
function dartDeferredLibraryLoader(uri, successCallback, errorCallback, loadId) {console.info('===>', uri, successCallback, errorCallback, loadId);
  
  let src;
  try {const url = new URL(uri);
    src = `${assetBase}${jsManifest[url.pathname.substring(1)]}`;
  } catch (e) {src = `${assetBase}${jsManifest[uri.substring(1)]}`;
  }
  
  script = document.createElement("script");
  script.type = "text/javascript";
  script.src = src;
  script.addEventListener("load", successCallback, false);
  script.addEventListener("error", errorCallback, false);
  document.body.appendChild(script);
}
'''.replaceAll(RegExp('var assetBase = null;'),'var assetBase = xxx;')
    .replaceAll(RegExp('var jsManifest = null;'),
      'var jsManifest = ${jsonEncode(jsManifest)};',
      // 'var jsManifest = {"main.dart_0.js":"main.dart_0.7a183f1b.js","main.dart.js_1.part.js":"main.dart.js_1.part.0445cc90.js"};',
    );
final List<Element> scripts = document.getElementsByTagName('script');
// 是否注入 js
bool isInjected = false;
for (int i = 0; i < scripts.length; i++) {final Element element = scripts[i];
  if (element.text.contains(RegExp(r'var serviceWorkerVersion'))) {element.text = '${element.text}\n$dartDeferredLibraryLoader';
    isInjected = true;
    break;
  }
}
if (!isInjected) {
  final Element? headElement = document.head;
  if (headElement != null) {final Element script = Element.tag('script');
    script.text = '\n$dartDeferredLibraryLoader';

    if (scripts.length > 1) {
      final Element firstScript = scripts.first;
      headElement.insertBefore(script, firstScript);
      headElement.insertBefore(Text('\n'), firstScript);
    } else {headElement.append(script);
      headElement.append(Text('\n'));
    }
  }
}

// 写入文件
file.writeAsStringSync(document.outerHtml);

为了不便测试,在本地应用 nginx 搭建了个文件服务,再将 build/web 文件下的 assets 和 js 文件上传到 build/cdn 下,用于模仿 cdn 服务。

nginx 配置,仅供参考:

server {
    listen       9091;
    server_name  localhost;
    root   /build/cdn;

    #   指定容许跨域的办法,* 代表所有
    add_header Access-Control-Allow-Methods *;

    #   预检命令的缓存,如果不缓存每次会发送两次申请
    add_header Access-Control-Max-Age 3600;
    #   不带 cookie 申请,并设置为 false
    add_header Access-Control-Allow-Credentials false;

    #   示意容许这个域跨域调用(客户端发送申请的域名和端口)#   $http_origin 动静获取申请客户端申请的域   不必 * 的起因是带 cookie 的申请不反对 * 号
    add_header Access-Control-Allow-Origin $http_origin;

    #   示意申请头的字段 动静获取
    add_header Access-Control-Allow-Headers 
    $http_access_control_request_headers;

     #缓存配置
    location ~ .*\.(jpg|png|ico)(.*){expires 30d;}
    #缓存配置
    location ~ .*\.(js|css)(.*){expires 7d;}

    location / {
        autoindex on;             #显示索引
        autoindex_exact_size off; #显示大小
        autoindex_localtime on;   #显示工夫
        charset utf-8;            #防止中文乱码
    }

    #开启 gzip
    gzip  on;  
    #低于 1kb 的资源不压缩 
    gzip_min_length 1k;
    # 设置压缩所须要的缓冲区大小
    gzip_buffers 16 64k;
    #压缩级别 1 -9,越大压缩率越高,同时耗费 cpu 资源也越多,倡议设置在 5 左右。gzip_comp_level 5; 
    #须要压缩哪些响应类型的资源,多个空格隔开。不倡议压缩图片.
    gzip_types text/plain text/css text/javascript text/xml application/json application/x-javascript application/javascript application/xml application/xml+rss image/jpeg image/gif image/png image/jpg;
    #配置禁用 gzip 条件,反对正则。此处示意 ie6 及以下不启用 gzip(因为 ie 低版本不反对)gzip_disable "MSIE [1-6]\.";  
    #是否增加“Vary: Accept-Encoding”响应头
    gzip_vary on;
}

测试后果:尽管网页申请 url 端口与图片申请 url 和 js 申请 url 的端口不统一,仍然失常运行,证实此计划是可行的。

成绩

  • 一起漫部 App
  • 一起漫部 Web

参考链接

  • FlutterWeb 性能优化摸索与实际

总结

综上所述,就是《一起漫部》对 Flutter Web 的性能优化摸索与实际,目前咱们所做的性能优化是无限的,将来咱们会持续在 Flutter Web 上做更多的摸索与实际。如果您对 Flutter Web 也感兴趣,欢送在评论区留言或者给出倡议,非常感谢。

正文完
 0