清华主页 - 清华新闻 - 综合时讯 - 正文

Flutter web

介绍

目前 flutterweb的打包产物优化较少,存在 main.dart.js单个文件体积过大问题,打包文件名没有 hash值,如果有使用 CDN会存在资源不能及时更新问题。本文章会对这些问题进行优化。

优化打包产物体积

从打包产物中可以看到其中 main.dart.js文件体积较大,且该文件是 flutter web运行的主要文件之一,该文件体积会随着业务代码的增多而变大,如果不对其体积进行优化,会造成页面白屏时间过长,影响用户体验。

在这里插入图片描述

打包产物目录结构:

├── assets                                    // 静态资源文件,主要包括图片、字体、清单文件等│   ├── AssetManifest.json                    // 资源(图片、视频等)清单文件│   ├── FontManifest.json                     // 字体清单文件│   ├── NOTICES│   ├── fonts│   │   └─ MaterialIcons-Regular.otf          // 字体文件,Material风格的图标│   ├── packages│   │   └─ cupertino_icons                    // 字体文件│   │      └─ cupertino_icons  │   │         └─ assets│   │            └─CupertinoIcons.ttf│   ├── images                                // 图片文件夹├── canvaskit                                 // canvaskit渲染模式构建产生的文件├── favicon.png├── flutter.js                                // 主要下载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版本(3.19.0)在 build web时,默认开启了 tree-shake-icons,可以自行运行 flutter build web -h查看。所以优化的重心为 main.dart.js文件。

打包脚本目录结构:

├── scripts│   ├── buildScript   │   │   ├─ build.js       // 打包脚本│   │   └─ loadChunk.js  // 加载并合并分片脚本

使用 deferred 延迟加载

dart官方提供了 deferred关键字来实现 widget页面的延迟加载。
文档

使用 deferred关键字标识的 widget页面就会从 main.dart.js文件中抽离出来,生成如 main.dart.js_1.part.jsmain.dart.js_2.part.jsmain.dart.js_x.part.js等文件,可以一定程度上优化 main.dart.js文件体积。

参考文章

开启 gzip 压缩

让服务端开启 gzip压缩

文件分片

可以对 main.dart.js文件进行分片处理,充分利用浏览器并行加载的机制来节省加载时间。

build.js中加入分片代码 (文章中代码是在 Flutter web - 2 多项目架构设计 文章基础上修改)

importfs from"fs";importpath from"path";// 对 main.dart.js 进行分片functionsplitFile(){ constchunkCount =5;// 分片数量// buildOutPath 为打包输出路径,如未改动则为项目根目录下的 build/web 文件夹consttargetFile =path.resolve(buildOutPath,`./main.dart.js`);constfileData =fs.readFileSync(targetFile,"utf8");constfileDataLen =fileData.length;// 计算每个分片的大小consteachChunkLen =Math.floor(fileDataLen /chunkCount);for(leti =0;i <chunkCount;i++){ conststart =i *eachChunkLen;constend =i ===chunkCount -1?fileDataLen :(i +1)*eachChunkLen;constchunk =fileData.slice(start,end);constchunkFilePath =path.resolve(`./build/${ args.env}/${ args.project}/main.dart_chunk_${ i}.js`);fs.writeFileSync(chunkFilePath,chunk);}// 删除 main.dart.js 文件fs.unlinkSync(targetFile);}

分片后还需修改 flutter.js内容,使其加载分片后的文件,在后续步骤中会讲解。

文件名添加 hash 值

build.js中新增:

importfs from"fs";importpath from"path";import{ glob }from"glob";// 使用了 glob 依赖包consthashFileMap =newMap();// 记录新旧文件的文件名和文件路径信息constmainDartJsFileMap ={ };// 记录分片后的// 文件名添加 hash 值asyncfunctionhashFile(){ constfiles =awaitglob(["**/main.dart_chunk_@(*).js"],// ["**/images/**.*", "**/*.{ otf,ttf}", "**/main.dart@(*).js"],{ cwd:buildOutPath,nodir:true,});// console.log(files);for(leti =0;i <files.length;i++){ constoldFilePath =path.resolve(buildOutPath,files[i]);constnewFilePath =oldFilePath.substring(0,oldFilePath.length -path.extname(oldFilePath).length      )+"."+getFileMD5({ filePath:oldFilePath })+path.extname(oldFilePath);fs.renameSync(oldFilePath,newFilePath);constoldFileName =path.basename(oldFilePath);constnewFileName =path.basename(newFilePath);hashFileMap.set(oldFileName,{ oldFilePath,newFilePath,newFileName,});if(oldFileName.includes("main.dart_chunk"))mainDartJsFileMap[oldFileName]=newFileName;}}/** * 获取文件的 md5 值 * @param { { fileContent?: string, filePath?: string}} options * @returns { string} */functiongetFileMD5(options){ const{ fileContent,filePath }=options;const_fileContent =fileContent ||fs.readFileSync(filePath);consthash =crypto.createHash("md5");hash.update(_fileContent);returnhash.digest("hex").substring(0,8);}

修改 flutter.js内容

查看 flutter.js文件代码可以发现,main.dart.js是由 flutter.jsloadEntrypoint函数加载的,实际是通过调用 _createScriptTag函数,在 DOM中插入了有 main.dart.js地址的 script标签。

asyncloadEntrypoint(e){ let{ entrypointUrl:r =`${ l}main.dart.js`,onEntrypointLoaded:t,nonce:i,}=e ||{ };returnthis._loadEntrypoint(r,t,i);}_loadEntrypoint(e,r,t){ leti =typeofr =="function";if(!this._scriptLoaded){ this._scriptLoaded =!0;leto =this._createScriptTag(e,t);if(i)console.debug("Injecting `;consthtmlPath =path.resolve(buildOutPath,"./index.html");lethtmlText =fs.readFileSync(htmlPath).toString();constheadEndIndex =htmlText.indexOf("");htmlText =htmlText.substring(0,headEndIndex)+bridgeScript +htmlText.substring(headEndIndex);fs.writeFileSync(htmlPath,Buffer.from(htmlText));}

完整代码

需安装依赖:pnpm i chalk crypto terser glob @babel/core commander @babel/preset-env -D

importfs from"fs";importpath from"path";import{ glob }from"glob";importcrypto from"crypto";import{ minify_sync }from"terser";import{ exec }from"child_process";import{ transform }from"@babel/core";import{ program,Option }from"commander";program  .command("build").requiredOption("-p, --project ","project name")// 要打包的项目名.addOption(newOption("-e, --env ","dev or prod environment")// 运行的环境.choices(["dev","prod"]).default("dev")).addOption(newOption("--web-renderer ","web renderer mode")// 渲染方式.choices(["auto","html","canvaskit"]).default("auto")).action((cmd)=>{ build(cmd);});program.parse(process.argv);/** * @param { {  project: string, env: string, webRenderer: string }} args */functionbuild(args){ // 要打包的项目路劲constbuildTargetPath =path.resolve(`./lib/${ args.project}`);// 打包文件输出位置,如:build/dev/project_1constbuildOutPath =path.resolve(`./build/${ args.env}/${ args.project}`);// 见下方解释,具体根据部署路劲设置constbaseHref =`/${ args.project}/`;consthashFileMap =newMap();constmainDartJsFileMap ={ };// 删除原打包文件fs.rmSync(buildOutPath,{ recursive:true,force:true});// 打包命令 -o 指定输出位置// --release 构建发布版本,有对代码进行混淆压缩等优化// --pwa-strategy none 不使用 pwaconstcommandStr =`fvm flutter build web --base-href ${ baseHref}--web-renderer ${ args.webRenderer}--release --pwa-strategy none -o ${ buildOutPath}--dart-define=INIT_ENV=${ args.env}`;exec(commandStr,{ cwd:buildTargetPath,},async(error,stdout,stderr)=>{ if(error){ console.error(`exec error: ${ error}`);return;}console.log(`stdout: ${ stdout}`);splitFile();awaithashFile();insertLoadChunkScript();if(stderr){ console.error(`stderr: ${ stderr}`);return;}});// 对 main.dart.js 进行分片functionsplitFile(){ constchunkCount =5;// 分片数量consttargetFile =path.resolve(buildOutPath,`./main.dart.js`);constfileData =fs.readFileSync(targetFile,"utf8");constfileDataLen =fileData.length;consteachChunkLen =Math.floor(fileDataLen /chunkCount);for(leti =0;i <chunkCount;i++){ conststart =i *eachChunkLen;constend =i ===chunkCount -1?fileDataLen :(i +1)*eachChunkLen;constchunk =fileData.slice(start,end);constchunkFilePath =path.resolve(`./build/${ args.env}/${ args.project}/main.dart_chunk_${ i}.js`);fs.writeFileSync(chunkFilePath,chunk);}fs.unlinkSync(targetFile);}// 文件名添加 hash 值asyncfunctionhashFile(){ constfiles =awaitglob(["**/main.dart@(*).js"],// ["**/images/**.*", "**/*.{ otf,ttf}", "**/main.dart@(*).js"],{ cwd:buildOutPath,nodir:true,});// console.log(files);for(leti =0;i <files.length;i++){ constoldFilePath =path.resolve(buildOutPath,files[i]);constnewFilePath =oldFilePath.substring(0,oldFilePath.length -path.extname(oldFilePath).length        )+"."+getFileMD5({ filePath:oldFilePath })+path.extname(oldFilePath);fs.renameSync(oldFilePath,newFilePath);constoldFileName =path.basename(oldFilePath);constnewFileName =path.basename(newFilePath);hashFileMap.set(oldFileName,{ oldFilePath,newFilePath,newFileName,});if(oldFileName.includes("main.dart_chunk"))mainDartJsFileMap[oldFileName]=newFileName;}}/**   * 获取文件的 md5 值   * @param { { fileContent?: string, filePath?: string}} options   * @returns { string}   */functiongetFileMD5(options){ const{ fileContent,filePath }=options;const_fileContent =fileContent ||fs.readFileSync(filePath);consthash =crypto.createHash("md5");hash.update(_fileContent);returnhash.digest("hex").substring(0,8);}// 插入加载分片脚本functioninsertLoadChunkScript(){ letloadChunkContent =fs      .readFileSync(path.resolve("./scripts/buildScript/loadChunk.js")).toString();loadChunkContent =loadChunkContent      .replace('const mainDartJsFileMapJSON = "{ }";',`const mainDartJsFileMapJSON = '${ JSON.stringify(mainDartJsFileMap)}';`).replace("${ baseHref}",`${ baseHref}`);constparseRes =transform(loadChunkContent,{ presets:["@babel/preset-env"],});constterserRes =minify_sync(parseRes.code,{ compress:true,mangle:true,output:{ beautify:false,comments:false,},});if(!fs.existsSync(path.resolve(buildOutPath,"script")))fs.mkdirSync(path.resolve(buildOutPath,"script"));constloadChunkJsHash =getFileMD5({ fileContent:terserRes.code });fs.writeFileSync(path.resolve(buildOutPath,`./script/loadChunk.${ loadChunkJsHash}.js`),Buffer.from(terserRes.code));// 替换 flutter.js 里的 _createScriptTagconstpattern =/_createScriptTag\([\w,]+\){ (.*?)}/;constflutterJsPath =path.resolve(buildOutPath,"./flutter.js");letflutterJsContent =fs.readFileSync(flutterJsPath).toString();flutterJsContent =flutterJsContent.replace(pattern,(match,p1)=>{ return`_createScriptTag(){ let t=document.createElement("script");t.type="application/javascript";t.src='${ baseHref}script/loadChunk.${ loadChunkJsHash}.js';return t}`;});// flutter js 加 hashfs.writeFileSync(flutterJsPath,Buffer.from(flutterJsContent));constflutterJsHashName =`flutter.${ getFileMD5({ fileContent:flutterJsContent,})}.js`;fs.renameSync(flutterJsPath,path.resolve(buildOutPath,flutterJsHashName));// 替换 index.html 内容constbridgeScript =``;consthtmlPath =path.resolve(buildOutPath,"./index.html");lethtmlText =fs.readFileSync(htmlPath).toString();constheadEndIndex =htmlText.indexOf("");htmlText =htmlText.substring(0,headEndIndex)+bridgeScript +htmlText.substring(headEndIndex);fs.writeFileSync(htmlPath,Buffer.from(htmlText));}}

存在问题

目前只处理的 main.dart_chunk_i.js等分片文件,未对延迟加载文件、图片、字体等文件进行处理。

参考文章

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

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

2025-06-24 12:11:33

相关新闻

清华大学新闻中心版权所有,清华大学新闻网编辑部维护,电子信箱: news@tsinghua.edu.cn
Copyright 2001-2020 news.tsinghua.edu.cn. All rights reserved.