Flutter Web 在《一起漫部》的性能优化探索与实践

一起漫部 是基于区块链技术创造的新型数字生活。

目录

前言

不久前,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的端口不一致,依然正常运行,证明此方案是可行的。

成果

参考链接

总结

综上所述,就是《一起漫部》对Flutter Web的性能优化探索与实践,目前我们所做的性能优化是有限的,未来我们会继续在Flutter Web上做更多的探索与实践。如果您对 Flutter Web 也感兴趣,欢迎在评论区留言或者给出建议,非常感谢。

作者:怕老婆的领结 原文地址:https://segmentfault.com/a/1190000042664763

%s 个评论

要回复文章请先登录注册