当前位置: 首页 > article >正文

Flutter web - 5 项目打包优化

介绍

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

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}`;
  });
  // 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 分片优化


http://www.kler.cn/a/454264.html

相关文章:

  • Kubernetes 安装 Nginx以及配置自动补全
  • TDengine 新功能 VARBINARY 数据类型
  • Flink的Watermark水位线详解
  • 海格通信嵌入式面试题及参考答案
  • Spring Boot 中 Map 的最佳实践
  • 1075 链表元素分类
  • NVIDIA GPU 内部架构介绍
  • 剑指Offer|LCR 014. 字符串的排列
  • Spring02 - 代理和事务篇
  • ModbusTCP从站转Profinet主站案例
  • LangChain教程 - 表达式语言 (LCEL) -构建智能链
  • windows下Redis的使用
  • Python vs PHP:哪种语言更适合网页抓取
  • 计算机基础复习12.22
  • 记录jvm进程号
  • jangow-01-1.0.1靶机
  • 16.3、网络安全风险评估项目流程与工作内容
  • 骑砍2霸主MOD开发(26)-Mono脚本系统
  • 《VQ-VAE》:Stable Diffusion设计的架构源泉
  • 在 Ubuntu 服务器上添加和删除用户
  • Redis篇--应用篇4--自动提示,自动补全
  • Oracle怎么写存储过程的定时任务执行语句
  • 骁龙 8 至尊版:AI 手机的变革先锋
  • 青少年编程与数学 02-005 移动Web编程基础 02课题、视口与像素
  • QT--模型/视图
  • 如何使用 Django 框架创建简单的 Web 应用?