Flutter web - 5 项目打包优化

kerry丶 2024-10-06 17:33:01 阅读 87

介绍

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

优化打包产物体积

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

在这里插入图片描述

打包产物目录结构:

<code>├── 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 多项目架构设计 文章基础上修改)

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.jsloadEntrypoint 函数加载的,实际是通过调用 _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}`;code>

});

// 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>`;code>

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}`;code>

});

// 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>`;code>

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 分片优化



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。