二、前端性能优化:打包体积减少19.2%,打包速度提升22.8%
主站 分类 云安全 AI安全 开发安全 2025-11-25 02:15:30 Author: www.freebuf.com(查看原文) 阅读量:1 收藏

freeBuf

主站

分类

云安全 AI安全 开发安全 终端安全 数据安全 Web安全 基础安全 企业安全 关基安全 移动安全 系统安全 其他安全

特色

热点 工具 漏洞 人物志 活动 安全招聘 攻防演练 政策法规

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

本文时间跨度较大,阶段一距离阶段二有长达三个月之久。阶段一准备时间较短,主要目的就是缩减打包时间,阅读时可以单独看待;阶段二较全面,阶段之间互有补充和影响,若阅读时发现问题期待多多讨论。

阶段二:打包体积减小

此处距离上文已三月之久,当前项目信息如下:

入口文件总大小: 42.2 MB (84.4 MB 未压缩)

JavaScript 模块: 54.7 MB

  • node_modules: 19.3 MB (4978 个模块)

  • src: 35.4 MiB (3838 个模块)

CSS 模块: 667 KB

  • antd: 623 KB

  • src: 23.8 KB

  • swiper: 20.1 KB

资源文件: 21.2 MB (304 个资源)

JSON 模块: 1.16 MB (84 个模块)

SVG 资源: 1.17 MB (JavaScript) + 3.35 MB (Asset) (799 个模块)

图片资源: 17.5 KB (JavaScript) + 5.72 MB (Asset) (217 个模块)

当前打包时间:156035 ms

源文件体积分析

const fs = require('fs');
const path = require('path');

/**​
 * 格式化文件大小显示​
 * @param {number} bytes - 字节数​
 * @returns {string} 格式化后的大小字符串​
 */
function formatFileSize(bytes) {
    if (bytes === 0) return '0 Bytes';

    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));

    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

/**​
 * 递归遍历目录,收集文件信息​
 * @param {string} dir - 目录路径​
 * @param {Array} files - 收集文件信息的数组​
 */
function traverseDirectory(dir, files) {
    const entries = fs.readdirSync(dir, { withFileTypes: true });

    for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);

        if (entry.isDirectory()) {
            // 递归遍历子目录​
            traverseDirectory(fullPath, files);
        } else if (entry.isFile()) {
            // 获取文件信息​
            const stats = fs.statSync(fullPath);
            files.push({
                path: fullPath,
                size: stats.size,
                formattedSize: formatFileSize(stats.size)
            });
        }
    }
}
/**​
 * 分析项目src目录下的文件体积​
 */
function analyzeFileSizes() {
    const projectRoot = path.join(__dirname, '..');
    const srcDir = path.join(projectRoot, 'src');
    const files = [];

    console.log('分析目录:', srcDir);
    console.log('正在收集文件信息...');

    try {
        // 遍历src目录​
        traverseDirectory(srcDir, files);

        // 按文件大小降序排序​
        files.sort((a, b) => b.size - a.size);

        // 计算总大小​
        const totalSize = files.reduce((sum, file) => sum + file.size, 0);

        console.log('\n=== 文件大小分析结果 ===');
        console.log(`总文件数: ${files.length}`);
        console.log(`总大小: ${formatFileSize(totalSize)}`);
        console.log('\n体积最大的文件(前20个):');
        console.log('------------------------------------------');
        console.log('序号 | 相对路径 | 大小 | 占比');
        console.log('------------------------------------------');

        // 显示前20个最大的文件​
        const topFiles = files.slice(0, 20);
        topFiles.forEach((file, index) => {
            const relativePath = path.relative(projectRoot, file.path);
            const percentage = ((file.size / totalSize) * 100).toFixed(2);
            console.log(`${String(index + 1).padStart(3)} | ${relativePath.padEnd(50)} | ${file.formattedSize.padEnd(10)} | ${percentage}%`);
        });

        console.log('------------------------------------------');

        // 按文件类型统计​
        console.log('\n按文件类型统计:');
        const typeStats = {};

        files.forEach(file => {
            const ext = path.extname(file.path).toLowerCase() || '.noext';
            if (!typeStats[ext]) {
                typeStats[ext] = { count: 0, size: 0 };
            }
            typeStats[ext].count++;
            typeStats[ext].size += file.size;
        });

        // 转换为数组并排序​
        const typeStatsArray = Object.entries(typeStats)
            .map(([type, data]) => ({
                type,
                count: data.count,
                size: data.size,
                formattedSize: formatFileSize(data.size),
                percentage: ((data.size / totalSize) * 100).toFixed(2)
            }))
            .sort((a, b) => b.size - a.size);

        console.log('文件类型 | 文件数 | 总大小 | 占比');
        console.log('------------------------------------------');

        typeStatsArray.forEach(stat => {
            console.log(`${stat.type.padEnd(10)} | ${String(stat.count).padStart(6)} | ${stat.formattedSize.padEnd(10)} | ${stat.percentage}%`);
        });

    } catch (error) {
        console.error('分析文件大小时出错:', error.message);
        process.exit(1);
    }
}

// 执行分析​
analyzeFileSizes();

1763430182_691bcf264d022fc6d81a2.png!small?1763430182964

1763430199_691bcf372cf589f93df17.png!small?1763430199567

当前分析结果如上,占比较大的还是一些资源文件。

优化无引用项目文件

分析项目中的无引用文件,将其删除。

const fs = require('fs');
const path = require('path');
const { promisify } = require('util');

// 颜色输出工具
const colors = {
    reset: '\x1b[0m',
    green: '\x1b[32m',
    yellow: '\x1b[33m',
    blue: '\x1b[34m',
    magenta: '\x1b[35m',
    cyan: '\x1b[36m',
    red: '\x1b[31m',
    white: '\x1b[37m'
};

// 项目根目录
const projectRoot = path.resolve(process.cwd(), './');
const srcDir = path.join(projectRoot, 'src');

// 要分析的文件扩展名
const VALID_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.vue', '.css', '.scss', '.less'];

// 忽略的目录
const IGNORE_DIRS = [
    'node_modules',
    'dist',
    'build',
    '.git',
    '.vscode',
    '.cursor',
    'public',
    'analysis-duplication',
    '_mock'
];

// 忽略的文件
const IGNORE_FILES = [
    'vite-env.d.ts',
    'main.tsx', // 入口文件通常不会被引用
    'index.html',
    'package.json',
    'tsconfig.json',
    'tailwind.config.ts',
    'vite.config.ts',
    'App.tsx' // 应用主组件
];

// 异步读取文件
const readFile = promisify(fs.readFile);

/**
 * 递归获取目录下的所有文件
 */
async function getAllFiles(dir) {
    let files = [];

    const entries = await fs.promises.readdir(dir, { withFileTypes: true });

    for (const entry of entries) {
        const fullPath = path.join(dir, entry.name);

        // 忽略指定目录
        if (entry.isDirectory() && IGNORE_DIRS.includes(entry.name)) {
            continue;
        }

        if (entry.isDirectory()) {
            const subFiles = await getAllFiles(fullPath);
            files = [...files, ...subFiles];
        } else if (entry.isFile()) {
            // 只处理有效的文件扩展名
            const ext = path.extname(entry.name);
            if (VALID_EXTENSIONS.includes(ext) && !IGNORE_FILES.includes(entry.name)) {
                files.push(fullPath);
            }
        }
    }

    return files;
}

/**
 * 从文件路径获取模块名(用于匹配import语句)
 */
function getModuleName(filePath, projectRoot) {
    const relativePath = path.relative(projectRoot, filePath);
    // 移除扩展名
    const nameWithoutExt = relativePath.replace(/\.[^/.]+$/, '');
    // 转换为可导入的路径格式
    return nameWithoutExt.replace(/\\/g, '/');
}

/**
 * 解析文件中的导入语句
 */
function extractImports(content, filePath, projectRoot) {
    const imports = [];
    const relativePath = path.relative(projectRoot, filePath);
    const dirPath = path.dirname(relativePath).replace(/\\/g, '/');

    // 匹配ES模块导入
    const esImportRegex = /import\s+(?:.*?)\s+from\s+['"](.+?)['"]/g;
    let match;
    while ((match = esImportRegex.exec(content)) !== null) {
        imports.push(match[1]);
    }

    // 匹配require导入
    const requireRegex = /require\(['"](.+?)['"]\)/g;
    while ((match = requireRegex.exec(content)) !== null) {
        imports.push(match[1]);
    }

    // 匹配动态导入
    const dynamicImportRegex = /import\(['"](.+?)['"]\)/g;
    while ((match = dynamicImportRegex.exec(content)) !== null) {
        imports.push(match[1]);
    }

    return imports;
}

/**
 * 检查文件是否被引用
 */
function isFileReferenced(filePath, allImports, projectRoot) {
    const relativePath = path.relative(projectRoot, filePath);
    const moduleName = getModuleName(filePath, projectRoot);
    const fileName = path.basename(filePath);
    const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, '');
    const dirName = path.basename(path.dirname(filePath));

    // 特殊处理组件和UI相关文件,它们可能通过配置或路由表引用
    if (relativePath.includes('/components/') ||
        relativePath.includes('/ui/') ||
        relativePath.includes('/pages/') ||
        relativePath.includes('/routes/')) {
        // 这些文件可能通过路由配置、组件库配置等方式引用
        // 检查是否有导入包含文件名或组件名
        const componentLikeImports = allImports.some(importPath => {
            // 检查是否包含文件名(可能是组件名)
            return importPath.includes(fileNameWithoutExt) ||
                importPath.includes(camelCaseToKebab(fileNameWithoutExt)) ||
                importPath.includes(PascalCaseToKebab(fileNameWithoutExt));
        });
        if (componentLikeImports) {
            return true;
        }
    }

    // 检查是否有导入路径匹配此文件
    return allImports.some(importPath => {
        // 检查完整路径匹配
        if (importPath === moduleName ||
            importPath === moduleName + '.js' ||
            importPath === moduleName + '.ts' ||
            importPath === moduleName + '.jsx' ||
            importPath === moduleName + '.tsx' ||
            importPath === moduleName + '/index' ||
            importPath === moduleName + '/index.js' ||
            importPath === moduleName + '/index.ts' ||
            importPath === moduleName + '/index.jsx' ||
            importPath === moduleName + '/index.tsx') {
            return true;
        }

        // 检查相对路径匹配
        const possibleRelativePaths = [
            './' + fileName,
            './' + fileNameWithoutExt,
            '../' + fileName,
            '../' + fileNameWithoutExt,
            './' + fileNameWithoutExt + '/index',
            '../' + fileNameWithoutExt + '/index',
            './' + dirName + '/' + fileNameWithoutExt,
            '../' + dirName + '/' + fileNameWithoutExt
        ];

        return possibleRelativePaths.some(relPath => {
            // 精确匹配相对路径,避免部分匹配导致的误报
            return importPath === relPath ||
                importPath.startsWith(relPath + '/') ||
                importPath === relPath + '.js' ||
                importPath === relPath + '.ts' ||
                importPath === relPath + '.jsx' ||
                importPath === relPath + '.tsx';
        });
    });
}

/**
 * 驼峰命名转短横线命名
 */
function camelCaseToKebab(str) {
    return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}

/**
 * 帕斯卡命名转短横线命名
 */
function PascalCaseToKebab(str) {
    return str.charAt(0).toLowerCase() + str.slice(1).replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}

/**
 * 主函数
 */
async function main() {
    try {
        console.log(`${colors.cyan}=====================================${colors.reset}`);
        console.log(`${colors.cyan}未引用文件分析工具${colors.reset}`);
        console.log(`${colors.cyan}=====================================${colors.reset}`);

        console.log(`${colors.yellow}开始扫描项目文件...${colors.reset}`);
        const allFiles = await getAllFiles(srcDir);
        console.log(`${colors.green}共扫描到 ${allFiles.length} 个文件${colors.reset}`);

        console.log(`${colors.yellow}\n开始分析文件引用关系...${colors.reset}`);
        const allImports = [];
        const fileContents = new Map();

        // 读取所有文件内容并提取导入
        for (let i = 0; i < allFiles.length; i++) {
            const filePath = allFiles[i];
            try {
                const content = await readFile(filePath, 'utf-8');
                fileContents.set(filePath, content);
                const imports = extractImports(content, filePath, projectRoot);
                allImports.push(...imports);

                // 显示进度
                if ((i + 1) % 10 === 0 || i === allFiles.length - 1) {
                    console.log(`${colors.blue}进度: ${i + 1}/${allFiles.length} 个文件${colors.reset}`);
                }
            } catch (error) {
                console.log(`${colors.red}读取文件失败: ${filePath} - ${error.message}${colors.reset}`);
            }
        }

        console.log(`${colors.yellow}\n开始识别未引用文件...${colors.reset}`);
        const unusedFiles = [];

        for (const filePath of allFiles) {
            if (!isFileReferenced(filePath, allImports, projectRoot)) {
                unusedFiles.push(filePath);
            }
        }

        console.log(`${colors.cyan}\n=====================================${colors.reset}`);
        console.log(`${colors.cyan}分析结果${colors.reset}`);
        console.log(`${colors.cyan}=====================================${colors.reset}`);

        console.log(`${colors.blue}- 总文件数: ${colors.white}${allFiles.length}${colors.reset}`);
        // 按目录分组未引用文件
        const unusedFilesByDir = {};
        unusedFiles.forEach(filePath => {
            const dir = path.dirname(filePath);
            const relativeDir = path.relative(projectRoot, dir);
            if (!unusedFilesByDir[relativeDir]) {
                unusedFilesByDir[relativeDir] = [];
            }
            unusedFilesByDir[relativeDir].push(filePath);
        });

        console.log(`${colors.blue}- 未引用文件数: ${colors.white}${unusedFiles.length}${colors.reset}`);

        if (unusedFiles.length > 0) {
            console.log(`${colors.yellow}\n未引用的文件列表(按目录分组):${colors.reset}`);

            Object.entries(unusedFilesByDir).forEach(([dir, files]) => {
                console.log(`${colors.cyan}\n${dir} (${files.length} 个文件):${colors.reset}`);
                files.forEach((file, index) => {
                    const relativePath = path.relative(projectRoot, file);
                    console.log(`${colors.red}  ${index + 1}. ${path.basename(file)}${colors.reset}`);
                });
            });

            console.log(`${colors.yellow}\n未引用文件详情列表:${colors.reset}`);
            unusedFiles.forEach((file, index) => {
                const relativePath = path.relative(projectRoot, file);
                console.log(`${colors.red}${index + 1}. ${relativePath}${colors.reset}`);
            });

            // 计算未引用文件占用的磁盘空间
            let totalSize = 0;
            for (const filePath of unusedFiles) {
                try {
                    const stats = fs.statSync(filePath);
                    totalSize += stats.size;
                } catch (error) {
                    // 忽略错误
                }
            }

            console.log(`${colors.yellow}\n未引用文件总计大小: ${colors.white}${(totalSize / 1024).toFixed(2)} KB${colors.reset}`);

        } else {
            console.log(`${colors.green}\n恭喜!没有发现未引用的文件。${colors.reset}`);
        }

        console.log(`${colors.cyan}\n=====================================${colors.reset}`);
        console.log(`${colors.cyan}分析完成${colors.reset}`);
        console.log(`${colors.cyan}=====================================${colors.reset}`);

    } catch (error) {
        console.error(`${colors.red}分析过程中发生错误:${colors.white} ${error.message}${colors.reset}`);
        console.error(error.stack);
        process.exit(1);
    }
}

// 执行主函数
main();

1763431862_691bd5b6a27cad626d6f5.png!small

不建议写脚本直接删除,因为分析结果不准确性较大。此外可能有多个文件已废弃但互相引用的情况存在,这个分析结果只供参考,还是更多的需要人工判断。

优化未引用图片资源

分析未引用的图片和SVG文件,因为项目业务原因,排除一下资源目录。

  • country-flag → src/assets/imges/countryimg(仅 .png)
  • fingerprint-label → src/assets/svg/fingerprintLower(.svg 和 .png)
const fs = require('fs');
const path = require('path');

// 配置
const ASSETS_DIR = path.join(__dirname, '../src/assets');
const SRC_DIR = path.join(__dirname, '../src');
const EXCLUDED_DIRS = [
    'src/assets/imges/countryimg',
    'src/assets/svg/fingerprintLower'
];

// 需要扫描的代码文件扩展名
const CODE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.json', '.less', '.css', '.scss'];

// 图片文件扩展名
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'];

// 结果存储
const unusedImages = [];
const unusedSvgs = [];
const stats = {
    totalImages: 0,
    totalSvgs: 0,
    ch

已在FreeBuf发表 0 篇文章

本文为 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)


文章来源: https://www.freebuf.com/articles/others-articles/458850.html
如有侵权请联系:admin#unsafe.sh