介绍
目前
flutter
对
web
的打包产物优化较少,存在
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.js
、
main.dart.js_2.part.js
、
main.dart.js_x.part.js
等文件,可以一定程度上优化
main.dart.js
文件体积。
参考文章
开启 gzip 压缩
让服务端开启
gzip
压缩
文件分片
可以对
main.dart.js
文件进行分片处理,充分利用浏览器并行加载的机制来节省加载时间。
build.js
中加入分片代码 (文章中代码是在 Flutter web - 2 多项目架构设计 文章基础上修改)
import fs from"fs";import path from"path";// 对 main.dart.js 进行分片functionsplitFile(){const chunkCount =5;// 分片数量// buildOutPath 为打包输出路径,如未改动则为项目根目录下的 build/web 文件夹const targetFile = path.resolve(buildOutPath,`./main.dart.js`);const fileData = fs.readFileSync(targetFile,"utf8");const fileDataLen = fileData.length;// 计算每个分片的大小const eachChunkLen = Math.floor(fileDataLen / chunkCount);for(let i =0; i < chunkCount; i++){const start = i * eachChunkLen;const end = i === chunkCount -1? fileDataLen :(i +1)* eachChunkLen;const chunk = fileData.slice(start, end);const chunkFilePath = 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
中新增:
import fs from"fs";import path from"path";import{ glob }from"glob";// 使用了 glob 依赖包const hashFileMap =newMap();// 记录新旧文件的文件名和文件路径信息const mainDartJsFileMap ={};// 记录分片后的// 文件名添加 hash 值asyncfunctionhashFile(){const files =awaitglob(["**/main.dart_chunk_@(*).js"],// ["**/images/**.*", "**/*.{otf,ttf}", "**/main.dart@(*).js"],{cwd: buildOutPath,nodir:true,});// console.log(files);for(let i =0; i < files.length; i++){const oldFilePath = path.resolve(buildOutPath, files[i]);const newFilePath =
oldFilePath.substring(0,
oldFilePath.length - path.extname(oldFilePath).length
)+"."+getFileMD5({filePath: oldFilePath })+
path.extname(oldFilePath);
fs.renameSync(oldFilePath, newFilePath);const oldFileName = path.basename(oldFilePath);const newFileName = 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);const hash = crypto.createHash("md5");
hash.update(_fileContent);return hash.digest("hex").substring(0,8);}
修改
flutter.js
内容
查看
flutter.js
文件代码可以发现,
main.dart.js
是由
flutter.js
中
loadEntrypoint
函数加载的,实际是通过调用
_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){let i =typeof r =="function";if(!this._scriptLoaded){this._scriptLoaded =!0;let o =this._createScriptTag(e, t);if(i)
console.debug("Injecting <script> tag. Using callback."),(this._onEntrypointLoaded = r),
document.body.append(o);elsereturnnewPromise((s, c)=>{
console.debug("Injecting <script> tag. Using Promises. Use the callback approach instead!"),(this._didCreateEngineInitializerResolve = s),
o.addEventListener("error", c),
document.body.append(o);});}}_createScriptTag(e, r){let t = document.createElement("script");(t.type ="application/javascript"), r &&(t.nonce = r);let i = e;return(this._ttPolicy !=null&&(i =this._ttPolicy.createScriptURL(e)),(t.src = i),
t
);}
因为我们把
main.dart.js
分片处理了,就不需要加载原来的
main.dart.js
文件,只需要加载分片的文件,再合并起来就可以了。所以我们修改的主要地方是
_createScriptTag
函数。
思路:创建一个加载并合并
main.dart.js
分片文件的
loadChunk.js
脚本文件,把
_createScriptTag
函数中加载
main.dart.js
的代码替换成加载
loadChunk.js
即可。
loadChunk.js
代码:
functionloadChunkScript(url){returnnewPromise((resolve, reject)=>{const xhr =newXMLHttpRequest();
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();});}let retryCount =0;const mainDartJsFileMapJSON ="{}";const mainDartJsFileMap =JSON.parse(mainDartJsFileMapJSON);const promises = Object.keys(mainDartJsFileMap).sort().map((key)=>`${baseHref}${mainDartJsFileMap[key]}`).map(loadChunkScript);
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 >3){
console.error("load chunk fail");}else{_createScriptTag(url);}});
只要替换掉其中的
const mainDartJsFileMapJSON = "{}";
和
${baseHref}
即可,所以在
build.js
文件新增函数:
import fs from"fs";import path from"path";import{ minify_sync }from"terser";import{ transform }from"@babel/core";// 插入加载分片脚本functioninsertLoadChunkScript(){// 读取 loadChunk.js 内容,并替换let loadChunkContent = fs
.readFileSync(path.resolve("./scripts/buildScript/loadChunk.js")).toString();
loadChunkContent = loadChunkContent
.replace('const mainDartJsFileMapJSON = "{}";',`const mainDartJsFileMapJSON = '${JSON.stringify(mainDartJsFileMap)}';`).replace("${baseHref}",`${baseHref}`);// 使用 babel 进行代码降级const parseRes =transform(loadChunkContent,{presets:["@babel/preset-env"],});// 代码混淆和压缩const terserRes =minify_sync(parseRes.code,{compress:true,mangle:true,output:{beautify:false,comments:false,},});// 在打包产物中创建 script 文件夹if(!fs.existsSync(path.resolve(buildOutPath,"script")))
fs.mkdirSync(path.resolve(buildOutPath,"script"));// 文件名加 hash 值const loadChunkJsHash =getFileMD5({fileContent: terserRes.code });
fs.writeFileSync(
path.resolve(buildOutPath,`./script/loadChunk.${loadChunkJsHash}.js`),
Buffer.from(terserRes.code));// 替换 flutter.js 里的 _createScriptTagconst pattern =/_createScriptTag\([\w,]+\){(.*?)}/;const flutterJsPath = path.resolve(buildOutPath,"./flutter.js");let flutterJsContent = 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 加 hash
fs.writeFileSync(flutterJsPath, Buffer.from(flutterJsContent));const flutterJsHashName =`flutter.${getFileMD5({fileContent: flutterJsContent,})}.js`;
fs.renameSync(flutterJsPath, path.resolve(buildOutPath, flutterJsHashName));// 替换 index.html 内容const bridgeScript =`<script src="${flutterJsHashName}" defer></script>`;const htmlPath = path.resolve(buildOutPath,"./index.html");let htmlText = fs.readFileSync(htmlPath).toString();const headEndIndex = htmlText.indexOf("</head>");
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
import fs from"fs";import path from"path";import{ glob }from"glob";import crypto 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 <string>","project name")// 要打包的项目名.addOption(newOption("-e, --env <string>","dev or prod environment")// 运行的环境.choices(["dev","prod"]).default("dev")).addOption(newOption("--web-renderer <string>","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){// 要打包的项目路劲const buildTargetPath = path.resolve(`./lib/${args.project}`);// 打包文件输出位置,如:build/dev/project_1const buildOutPath = path.resolve(`./build/${args.env}/${args.project}`);// 见下方解释,具体根据部署路劲设置const baseHref =`/${args.project}/`;const hashFileMap =newMap();const mainDartJsFileMap ={};// 删除原打包文件
fs.rmSync(buildOutPath,{recursive:true,force:true});// 打包命令 -o 指定输出位置// --release 构建发布版本,有对代码进行混淆压缩等优化// --pwa-strategy none 不使用 pwaconst commandStr =`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(){const chunkCount =5;// 分片数量const targetFile = path.resolve(buildOutPath,`./main.dart.js`);const fileData = fs.readFileSync(targetFile,"utf8");const fileDataLen = fileData.length;const eachChunkLen = Math.floor(fileDataLen / chunkCount);for(let i =0; i < chunkCount; i++){const start = i * eachChunkLen;const end = i === chunkCount -1? fileDataLen :(i +1)* eachChunkLen;const chunk = fileData.slice(start, end);const chunkFilePath = path.resolve(`./build/${args.env}/${args.project}/main.dart_chunk_${i}.js`);
fs.writeFileSync(chunkFilePath, chunk);}
fs.unlinkSync(targetFile);}// 文件名添加 hash 值asyncfunctionhashFile(){const files =awaitglob(["**/main.dart@(*).js"],// ["**/images/**.*", "**/*.{otf,ttf}", "**/main.dart@(*).js"],{cwd: buildOutPath,nodir:true,});// console.log(files);for(let i =0; i < files.length; i++){const oldFilePath = path.resolve(buildOutPath, files[i]);const newFilePath =
oldFilePath.substring(0,
oldFilePath.length - path.extname(oldFilePath).length
)+"."+getFileMD5({filePath: oldFilePath })+
path.extname(oldFilePath);
fs.renameSync(oldFilePath, newFilePath);const oldFileName = path.basename(oldFilePath);const newFileName = 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);const hash = crypto.createHash("md5");
hash.update(_fileContent);return hash.digest("hex").substring(0,8);}// 插入加载分片脚本functioninsertLoadChunkScript(){let loadChunkContent = fs
.readFileSync(path.resolve("./scripts/buildScript/loadChunk.js")).toString();
loadChunkContent = loadChunkContent
.replace('const mainDartJsFileMapJSON = "{}";',`const mainDartJsFileMapJSON = '${JSON.stringify(mainDartJsFileMap)}';`).replace("${baseHref}",`${baseHref}`);const parseRes =transform(loadChunkContent,{presets:["@babel/preset-env"],});const terserRes =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"));const loadChunkJsHash =getFileMD5({fileContent: terserRes.code });
fs.writeFileSync(
path.resolve(buildOutPath,`./script/loadChunk.${loadChunkJsHash}.js`),
Buffer.from(terserRes.code));// 替换 flutter.js 里的 _createScriptTagconst pattern =/_createScriptTag\([\w,]+\){(.*?)}/;const flutterJsPath = path.resolve(buildOutPath,"./flutter.js");let flutterJsContent = 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 加 hash
fs.writeFileSync(flutterJsPath, Buffer.from(flutterJsContent));const flutterJsHashName =`flutter.${getFileMD5({fileContent: flutterJsContent,})}.js`;
fs.renameSync(flutterJsPath, path.resolve(buildOutPath, flutterJsHashName));// 替换 index.html 内容const bridgeScript =`<script src="${flutterJsHashName}" defer></script>`;const htmlPath = path.resolve(buildOutPath,"./index.html");let htmlText = fs.readFileSync(htmlPath).toString();const headEndIndex = htmlText.indexOf("</head>");
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 分片优化
版权归原作者 kerry丶 所有, 如有侵权,请联系我们删除。