Flutter web - 5 项目打包优化
介绍
目前 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 进行分片
function splitFile() {
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 = new Map(); // 记录新旧文件的文件名和文件路径信息
const mainDartJsFileMap = {}; // 记录分片后的
// 文件名添加 hash 值
async function hashFile() {
const files = await glob(
["**/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}
*/
function getFileMD5(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
标签。
async loadEntrypoint(e) {
let {
entrypointUrl: r = `${l}main.dart.js`,
onEntrypointLoaded: t,
nonce: i,
} = e || {};
return this._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);
else
return new Promise((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
代码:
function loadChunkScript(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();
});
}
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";
// 插入加载分片脚本
function insertLoadChunkScript() {
// 读取 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 里的 _createScriptTag
const 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(
new Option("-e, --env <string>", "dev or prod environment") // 运行的环境
.choices(["dev", "prod"])
.default("dev")
)
.addOption(
new Option("--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
*/
function build(args) {
// 要打包的项目路劲
const buildTargetPath = path.resolve(`./lib/${args.project}`);
// 打包文件输出位置,如:build/dev/project_1
const buildOutPath = path.resolve(`./build/${args.env}/${args.project}`);
// 见下方解释,具体根据部署路劲设置
const baseHref = `/${args.project}/`;
const hashFileMap = new Map();
const mainDartJsFileMap = {};
// 删除原打包文件
fs.rmSync(buildOutPath, { recursive: true, force: true });
// 打包命令 -o 指定输出位置
// --release 构建发布版本,有对代码进行混淆压缩等优化
// --pwa-strategy none 不使用 pwa
const 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();
await hashFile();
insertLoadChunkScript();
if (stderr) {
console.error(`stderr: ${stderr}`);
return;
}
}
);
// 对 main.dart.js 进行分片
function splitFile() {
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 值
async function hashFile() {
const files = await glob(
["**/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}
*/
function getFileMD5(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);
}
// 插入加载分片脚本
function insertLoadChunkScript() {
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 里的 _createScriptTag
const 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 分片优化