【小程序】迁移非主包组件以减少主包体积

代码位置

gitee.com/zhangkb/exa...

问题及背景

  • 微信小程序主包体积最大为 2M,超出体积无法上传。
  • 组件放在不同的目录下的表现不同:
    • src/components 目录中的组件会被打包到主包中,可以被所有页面引用。
    • src/pages/about/components 目录中的组件会被打印到对应分包中,只能被当前分包引用(只考虑微信小程序的话可以用分包异步化,我这边因为需要做不同平台所以不考虑这个方案)。

在之前的项目结构中,所有的组件都放在 src/components 目录下,因此所有组件都会被打包到主包中,这导致主包体积超出了 2M 的限制。

后续经过优化,将一些与主包无关的组件放到了对应分包中,但是有一些组件,在主包页面中没有被引用,但是被多个不同的分包页面引用 ,因此只能放到 src/components 目录下打包到主包中。

本文的优化思路就是将这一部分组件通过脚本迁移到不同的分包目录中,从而减少主包体积 ,这样做的缺点也显而易见:会增加代码包的总体积(微信还有总体积小于 20M 的限制 🤮)。

实现思路

项目中用 gulp 做打包流程管理,因此将这个功能封装成一个 task,在打包之前调用。

1. 分析依赖

分析 src/components 组件是否主包页面引用,有两种情况:

  1. 直接被主页引用。
  2. 间接被主页引用:主页引用 aa 引用 b,此时 a 为直接引用,b 为间接引用。
js 复制代码
const { series, task, src, parallel } = require("gulp");
const tap = require("gulp-tap");
const path = require("path");
const fs = require("fs");
const pages = require("../src/pages.json");

// 项目根目录
const rootPath = path.join(__dirname, "../");
const srcPath = path.join(rootPath, "./src");
const componentsPath = path.join(rootPath, "./src/components");

// 组件引用根路径
const componentRootPath = "@/components"; // 替换为 pages 页面中引入组件的路径

// 组件依赖信息
let componentsMap = {};

// 从 pages 文件中获取主包页面路径列表
const mainPackagePagePathList = pages.pages.map((item) => {
  let pathParts = item.path.split("/");

  return pathParts.join(`\\${path.sep}`);
});

/**
 * 组件信息初始化
 */
function initComponentsMap() {
  // 为所有 src/components 中的组件创建信息
  return src([`${srcPath}/@(components)/**/**.vue`]).pipe(
    tap((file) => {
      let filePath = transferFilePathToComponentPath(file.path);

      componentsMap[filePath] = {
        refers: [], // 引用此组件的页面/组件
        quotes: [], // 此组件引用的组件
        referForMainPackage: false, // 是否被主包引用,被主包引用时不需要 copy 到分包
      };
    })
  );
}

/**
 * 分析依赖
 */
function analyseDependencies() {
  return src([`${srcPath}/@(components|pages)/**/**.vue`]).pipe(
    tap((file) => {
      // 是否为主包页面
      const isMainPackagePageByPath = checkIsMainPackagePageByPath(file.path);

      // 分析页面引用了哪些组件
      const componentsPaths = Object.keys(componentsMap);
      const content = String(file.contents);

      componentsPaths.forEach((componentPath) => {
        if (content.includes(componentPath)) {
          // 当前页面引用了这个组件
          componentsMap[componentPath].refers.push(file.path);

          if (file.path.includes(componentsPath)) {
            // 记录组件被引用情况
            const targetComponentPath = transferFilePathToComponentPath(
              file.path
            );

            componentsMap[targetComponentPath].quotes.push(componentPath);
          }

          // 标记组件是否被主页引用
          if (isMainPackagePageByPath) {
            componentsMap[componentPath].referForMainPackage = true;
          }
        }
      });
    })
  );
}

/**
 * 分析间接引用依赖
 */
function analyseIndirectDependencies(done) {
  for (const componentPath in componentsMap) {
    const componentInfo = componentsMap[componentPath];

    if (!componentInfo.referForMainPackage) {
      const isIndirectReferComponent =
        checkIsIndirectReferComponent(componentPath);

      if (isIndirectReferComponent) {
        console.log("间接引用组件", componentPath);
        componentInfo.referForMainPackage = true;
      }
    }
  }

  done();
}

/**
 * 是否为被主页间接引用的组件
 */
function checkIsIndirectReferComponent(componentPath) {
  const componentInfo = componentsMap[componentPath];

  if (componentInfo.referForMainPackage) {
    return true;
  }

  for (const filePath of componentInfo.refers) {
    if (filePath.includes(componentsPath)) {
      const subComponentPath = transferFilePathToComponentPath(filePath);
      const result = checkIsIndirectReferComponent(subComponentPath);

      if (result) {
        return result;
      }
    }
  }
}

/**
 * 将文件路径转换为组件路径
 */
function transferFilePathToComponentPath(filePath) {
  return filePath
    .replace(componentsPath, componentRootPath)
    .replaceAll(path.sep, "/")
    .replace(".vue", "");
}

/**
 * 判断页面路径是否为主包页面
 */
function checkIsMainPackagePageByPath(filePath) {
  // 正则:判断是否为主包页面
  const isMainPackagePageReg = new RegExp(
    `(${mainPackagePagePathList.join("|")})`
  );

  return isMainPackagePageReg.test(filePath);
}

经过这一步后会得到一个 json,包含被引用文件信息和是否被主页引用,格式为:

json 复制代码
{
  "@/components/xxxx/xxxx": {
    "refers": [
      "D:\\code\\miniPrograme\\xxxxxx\\src\\pages\\xxx1\\index.vue",
      "D:\\code\\miniPrograme\\xxxxxx\\src\\pages\\xxx2\\index.vue",
      "D:\\code\\miniPrograme\\xxxxxx\\src\\components\\xxx\\xxx\\xxx.vue"
    ],
    "referForMainPackage": false
  }
}

2. 分发组件

经过第一步的依赖分析,我们知道了 referForMainPackage 值为 false 的组件是不需要放在主包中的,在这一步中将这些组件分发到对应的分包中。

思路:

  1. 遍历所有 referForMainPackage 值为 false 的组件。

  2. 遍历所有组件的 refers 列表,如果 refer 能匹配到分包,做以下动作:

    1. 在分包根目录下创建 componentsauto 目录,将组件复制到这里。
    2. 复制组件中引用的相对路径资源。
  3. 删除 pages/components 中的当前组件。

js 复制代码
const taskMap = {};
const changeFileMap = {};
const deleteFileMap = {};

// 分发组件
async function distributionComponents() {
  for (let componentPath in componentsMap) {
    const componentInfo = componentsMap[componentPath];

    // 未被主包引用的组件
    for (const pagePath of componentInfo.refers) {
      // 将组件复制到分包
      if (pagePath.includes(pagesPath)) {
        // 将组件复制到页面所在分包
        await copyComponent(componentPath, pagePath);
      }
    }
  }
}

/**
 * 复制组件
 * @param {*} componentPath
 * @param {*} targetPath
 * @returns
 */
async function copyComponent(componentPath, pagePath) {
  const componentInfo = componentsMap[componentPath];

  if (componentInfo.referForMainPackage) return;

  const key = `${componentPath}_${pagePath}`;

  // 避免重复任务
  if (taskMap[key]) return;

  taskMap[key] = true;

  const subPackageRoot = getSubPackageRootByPath(pagePath);

  if (!subPackageRoot) return;

  const componentFilePath = transferComponentPathToFilePath(componentPath);
  const subPackageComponentsPath = path.join(subPackageRoot, "componentsauto");
  const newComponentFilePath = path.join(
    subPackageComponentsPath,
    path.basename(componentFilePath)
  );
  const newComponentsPath = newComponentFilePath
    .replace(srcPath, "@")
    .replaceAll(path.sep, "/")
    .replaceAll(".vue", "");

  // 1. 复制组件及其资源
  await copyComponentWithResources(
    componentFilePath,
    subPackageComponentsPath,
    componentInfo
  );

  // 2. 递归复制引用的组件
  if (componentInfo.quotes.length > 0) {
    let tasks = [];

    componentInfo.quotes.map((quotePath) => {
      // 复制子组件
      tasks.push(copyComponent(quotePath, pagePath));

      const subComponentInfo = componentsMap[quotePath];

      if (!subComponentInfo.referForMainPackage) {
        // 2.1 修改组件引用的子组件路径
        const newSubComponentFilePath = path.join(
          subPackageComponentsPath,
          path.basename(quotePath)
        );
        const newSubComponentsPath = newSubComponentFilePath
          .replace(srcPath, "@")
          .replaceAll(path.sep, "/")
          .replaceAll(".vue", "");
        updateChangeFileInfo(
          newComponentFilePath,
          quotePath,
          newSubComponentsPath
        );
      }
    });
    await Promise.all(tasks);
  }

  // 3. 修改页面引用当前组件路径
  updateChangeFileInfo(pagePath, componentPath, newComponentsPath);

  // 4. 删除当前组件
  updateDeleteFileInfo(componentFilePath);
}

/**
 * 更新删除文件信息
 * @param {*} filePath
 */
function updateDeleteFileInfo(filePath) {
  deleteFileMap[filePath] = true;
}

/**
 * 更新修改文件内容信息
 * @param {*} filePath
 * @param {*} oldStr
 * @param {*} newStr
 */
function updateChangeFileInfo(filePath, oldStr, newStr) {
  if (!changeFileMap[filePath]) {
    changeFileMap[filePath] = [];
  }
  changeFileMap[filePath].push([oldStr, newStr]);
}

/**
 * 删除文件任务
 */
async function deleteFile() {
  for (const filePath in deleteFileMap) {
    try {
      await fs.promises.unlink(filePath).catch(console.log); // 删除单个文件
      // 或删除目录:await fs.rmdir('path/to/dir', { recursive: true });
    } catch (err) {
      console.error("删除失败:", err);
    }
  }
}

/**
 * 复制组件及其资源
 * @param {*} componentFilePath
 * @param {*} destPath
 */
async function copyComponentWithResources(componentFilePath, destPath) {
  // 复制主组件文件
  await new Promise((resolve) => {
    src(componentFilePath).pipe(dest(destPath)).on("end", resolve);
  });

  // 处理组件中的相对路径资源
  const content = await fs.promises.readFile(componentFilePath, "utf-8");
  const relativePaths = extractRelativePaths(content);

  await Promise.all(
    relativePaths.map(async (relativePath) => {
      const resourceSrcPath = path.join(componentFilePath, "../", relativePath);
      const resourceDestPath = path.join(destPath, path.dirname(relativePath));

      await new Promise((resolve) => {
        src(resourceSrcPath).pipe(dest(resourceDestPath)).on("end", resolve);
      });
    })
  );
}

/**
 * 修改页面引用路径
 */
async function changePageResourcePath() {
  for (const pagePath in changeFileMap) {
    const list = changeFileMap[pagePath];

    await new Promise((resolve) => {
      src(pagePath)
        .pipe(
          tap((file) => {
            let content = String(file.contents);

            for (const [oldPath, newPath] of list) {
              content = content.replaceAll(oldPath, newPath);
            }
            file.contents = Buffer.from(content);
          })
        )
        .pipe(dest(path.join(pagePath, "../")))
        .on("end", resolve);
    });
  }
}

// 获取分包根目录
function getSubPackageRootByPath(pagePath) {
  for (const subPackagePagePath of subPackagePagePathList) {
    const rootPath = `${path.join(pagesPath, subPackagePagePath)}`;
    const arr = pagePath.replace(pagesPath, "").split(path.sep);

    if (arr[1] === subPackagePagePath) {
      return rootPath;
    }
  }
}

注意事项

引用资源时不能用相对路径

避免使用相对路径引入资源,可以通过代码规范来限制(处理起来比较麻烦,懒得写了)。

不同操作系统未验证

代码仅在 windows 10 系统下运行,其他操作系统未验证,可能会存在资源路径无法匹配的问题。

uniapp 项目

本项目是 uniapp 项目,因此迁移的组件后缀为 .vue,原生语言或其他框架不能直接使用。

相关推荐
runnerdancer18 小时前
LLM是怎么处理messages数组的,提示词缓存又是什么
前端·agent
陈随易19 小时前
VSCode的Copilot扩展支持接入DeepSeek,Kimi了!
前端·后端·程序员
我不是外星人20 小时前
有了 Harness Engineering ,真的还需要研发工程师吗?
前端·后端·ai编程
IT_陈寒1 天前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
Jackson__1 天前
分享一个横向滚动案例,带悬停暂停,通用性很强
前端
MariaH1 天前
git rebase的使用
前端
_柳青杨1 天前
深入理解 JavaScript 事件循环
前端·javascript
阡陌Jony1 天前
关于前端性能优化的一些问题:
前端
用户600071819101 天前
【翻译】简化 TSRX
前端
IT乐手1 天前
佛德角逼平西班牙,国足还有啥借口?
前端