通过打包脚本来解决 Flutter Web 首屏加载速度过慢和缓存更新不及时的问题。
思路在 之前的文章 里提过,就是进行大文件切片并行下载,以及资源文件的文件名哈希化。
实践中还需要对部分特殊的资源引用做处理。
Flutter Web 目前(3.16.5)的编译成品是一个 SPA(单页应用),通过挂载 JS 脚本到宿主 HTML 的 body 上来渲染页面。
和其他类似前端框架不同的是,它基本不使用原生的控件,而是在屏幕上放了一块画布,所有的UI绘制,动画,页面滚动等都是自己的引擎来实现的。
成也萧何败也萧何,这样做的好处就是屏蔽了各端的差异,保持了体验上的高一致性;坏处则是无法享受浏览器自身的优化,
在部分场景下有明显的性能差距等。且不论是哪种渲染器,都需要携带渲染引擎,因此即使是一个空 Demo,挂载脚本也至少有2M多的大小。
本文通过打包脚本进行一些资源加载流程的改进,以优化体验。
整体流程#
- 编译(指定 HTML render,关闭 PWA)
- 复制
/build/web
到指定目录,删除不需要的文件
- 拆分 main.dart.js 文件
- 改造 index.html 中并行下载和挂载使用的方法
- 字体文件添加 hash 后缀,更改清单文件中的文件名
- 图片等一般资源文件添加 hash 后缀,更新对应的清单文件
- 通过 rootBundle 读取的资源文件,不会从清单中读取,添加版本号后缀
具体步骤#
整个脚本文件就不放上来了,摘录一些主要的方法以供参考。
下文中 dart 方法执行时所在的根目录,均为编译打包后复制到指定目录的 /build/web
。
1. 编译#
1
|
flutter build web --web-renderer html --pwa-strategy none --release
|
2. 清理无用文件#
编译成品中不会用到的,需要删除的文件和目录有:
1
2
3
4
|
./canvaskit/ // html 渲染模式下无用
./assets/NOTICES // 版权声明
./last_build_id // 这个 id 有时候不会变,还是版本号靠谱
./flutter_service_worker.js // 用于提供PWA功能,已为空文件
|
3. 拆分 main.dart.js#
包含了业务逻辑和渲染引擎,下载它是首屏加载缓慢的元凶。
此处平均拆分为几个小js文件,数量可以指定,本文一般选择 6 个。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
Future splitMainJs() async {
final mainJsSrc = targetPath + '/main.dart.js';
final mainJs = File(mainJsSrc);
final mainJsContent = await mainJs.readAsString();
// 取文件哈希值的前四位
mainJsHash = mainJsContent.hashCode.toRadixString(16).substring(0, 4);
// 按长度拆分,pieceNum 为分片的数量
final atomLen = mainJsContent.length ~/ pieceNum;
for (var i = 0; i < pieceNum; i++) {
final start = i * atomLen;
final len = i == pieceNum - 1 ? mainJsContent.length - start : atomLen;
final part = mainJsContent.substring(start, start + len);
final partFile = File(mainJsSrc.replaceFirst(
'main.dart.js', 'main_${mainJsHash}_${i + 1}.js'));
await partFile.writeAsString(part);
}
await mainJs.delete();
}
|
4. 改造 index.html#
这里提前规定了被打包项目的 HTML 中,body 的第一个 <script>
块为挂载方法,
因此直接取了第一个。默认可用,如果前面添加了其他方法需要自己确定位置来替换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
Future modifyIndexHtml() async {
final indexHtmlSrc = targetPath + '/index.html';
final indexHtml = File(indexHtmlSrc);
final indexHtmlContent = await indexHtml.readAsString();
// 替换原有的加载方法
final bodyStart = indexHtmlContent.indexOf('<body>');
final start = indexHtmlContent.indexOf('<script>', bodyStart);
final end = indexHtmlContent.indexOf('</script>', bodyStart) + 9;
final oldScript = indexHtmlContent.substring(start, end);
final newHtmlContent =
indexHtmlContent.replaceFirst(oldScript, getNewLoadScript());
indexHtml.writeAsString(newHtmlContent);
}
String getNewLoadScript() {
// 下载分片,合并挂载的 JS 方法
return '''
<script>
var pieceLs = [];
for (var i = 0; i < $pieceNum; i++) {
pieceLs.push('main_${mainJsHash}_' + (i + 1) + '.js');
}
function 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;
function loadEntrypoint() {
const promises = pieceLs.map(downloadSplitJs);
Promise.all(promises).then((values) => {
const contents = values.join("");
const script = document.createElement("script");
script.text = contents;
script.type = "text/javascript";
document.body.appendChild(script);
}).catch(() => {
if (retryCount > 2) {
const element = document.createElement("a");
element.href = "javascript:location.reload()";
element.style.textAlign = "center";
element.style.margin = "50px auto";
element.style.display = "block";
element.innerText = "加载失败,点击重新请求页面";
document.body.appendChild(a);
} else {
retryCount++;
loadEntrypoint();
}
});
}
loadEntrypoint();
</script>
''';
|
5. 字体添加哈希后缀#
字体资源是从专门的清单文件读取使用的,其实一般不会更改,这里主要是展现一下修改名称的过程。
先通过清单文件得到所有资源的路径,然后遍历修改文件名,再把清单文件中的路径更新了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
Future modifyFontFiles() async {
var curDir = targetPath + '/assets';
// 字体是单独的清单文件
// 读取字体清单文件
final fontJsonFile = File(curDir + '/FontManifest.json');
final fontJsonRaw = await fontJsonFile.readAsString();
final fontJsonLs = jsonDecode(fontJsonRaw);
// 遍历Ls,把找到的文件都添加上hash后缀
for (var i = 0; i < fontJsonLs.length; i++) {
for (var j = 0; j < fontJsonLs[i]['fonts'].length; j++) {
// print(fontJsonLs[i]['fonts'][j]['asset']);
final asset = fontJsonLs[i]['fonts'][j]['asset'].toString();
final fontFile = File(curDir + '/' + asset);
final fontFileContent = await fontFile.readAsBytes();
final fontFileHash =
fontFileContent.hashCode.toRadixString(16).substring(0, 4);
final fontFileNewName = fontFile.path.substring(
fontFile.path.lastIndexOf('/') + 1,
fontFile.path.lastIndexOf('.')) +
r'_' +
fontFileHash +
fontFile.path.substring(fontFile.path.lastIndexOf('.'));
await fontFile.rename(
fontFile.path.substring(0, fontFile.path.lastIndexOf('/') + 1) +
fontFileNewName);
fontJsonLs[i]['fonts'][j]['asset'] =
asset.substring(0, asset.lastIndexOf('/') + 1) + fontFileNewName;
}
}
await fontJsonFile.writeAsString(jsonEncode(fontJsonLs));
}
|
6. 图片等添加哈希后缀#
图片等一般资源,修改的方式和字体是一样的,只有两个区别:
- 清单文件编译后有3个,但实际使用的是
AssetManifest.bin.json
。
- 和特殊引用的资源是放在一起的,遍历时需要判断排除。
解析这个 .bin.json 文件时需要先 base64 解码,然后得到一段二进制内容,
实际就是同名 .bin 文件的内容,是对 JSON 使用了 Flutter 官方的 standard_message_codec
进行了编码。
处理时可以读取同名 .json 文件的内容,处理完之后再二进制编码,转 base64 后覆盖保存到 .bin.json 文件中。
7. 特殊资源添加版本后缀#
按之前的步骤进行改造后,查看加载时开发者工具的网络页面,可以发现改名后的大部分资源都被正常下载和引用了。
但是也有些特殊资源,比如代码中通过 rootBundle
使用的资源,并没有根据清单文件调用重命名后的文件,还是按代码中调用了原始文件。
这就导致资源更换后,部分缓存不能清除的场景无法及时更新到最新版。
不想每次都在代码中手动更改这些资源的名称,最终考虑的折中办法是,把他们改为按 名称_版本号
的形式来使用。
打包时读取 version.json
中的项目版本号,重命名资源文件。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// files 为特殊资源的路径列表
Future modifyRootBundleFiles(List<String> files) async {
final versionRaw = await File(targetPath + '/version.json').readAsString();
final version = jsonDecode(versionRaw)['version'].toString();
for (var i = 0; i < files.length; i++) {
final file = File(targetPath + files[i]);
final name = file.path
.substring(file.path.lastIndexOf('/') + 1, file.path.lastIndexOf('.'));
// print(name);
await file.rename(file.path.replaceFirst(
name, name + '_' + version, file.path.lastIndexOf('/')));
}
}
|
打包脚本并没有修改编译的代码,只是修改了加载方式和资源的引用路径。
其实对于首屏加载缓慢的问题,官方也可以完成这个拆分,可能是觉得开启 Gzip 压缩之后分片带来的提升没那么明显吧。
按目前的路线图,应该是专注于支持 Wasm,这是未来的趋势,性能也会更好。
后续的平台API肯定会有变化,希望以后还能保留 HTML render 这个编译选项,给老业务留点共存的机会,哈哈。
本文对整个流程的梳理应该是比较完善了,算是对 上一篇文章 的具体补充吧。
看好 Flutter 的未来,但也不必过于依赖,该用原生时还得原生。没有最好的技术,只有最合适的方案。
PS. 也许生活的常态就是,不断的妥协,一点点的前进……偶尔还会摔个大跟头 :)