解放双手!优雅实现 Webpack/Vite 打包与 Source Map 上传流水线

48 阅读3分钟

1. webpack 插件实现

技术要点分层阐述:

1. **插件机制理解**
   - Webpack: 基于Tapable的插件系统,通过hooks介入构建生命周期
   - Vite: 基于Rollup的插件系统,提供构建阶段的钩子函数

2. **关键时机选择**
   - Webpack: 使用`afterEmit`钩子(资源已生成但未输出完成)
   - Vite: 使用`writeBundle`钩子(打包文件写入完成后)

3. **核心功能模块**
   - Sourcemap文件识别与收集
   - 文件上传逻辑(支持重试机制)
   - 错误处理与日志记录
   - 可选的本地文件清理

具体实现方案(展示技术深度)

分步骤阐述:

// 可以配合伪代码说明,但不要逐行念
"首先,插件需要接收配置参数,比如上传地址、认证信息等。然后在合适的构建钩子中:"

1. **文件发现阶段**
   - 遍历构建输出目录,识别`.map`后缀文件
   - 支持递归查找,确保嵌套目录中的sourcemap也被发现

2. **上传处理阶段**  
   - 使用FormData进行多部分表单上传
   - 实现指数退避的重试机制(网络波动容错)
   - 添加元数据:项目版本、构建时间、环境信息等

3. **后续处理阶段**
   - 成功上传后可选删除本地sourcemap(安全考虑)
   - 详细的上传结果日志记录
   - 错误分类处理(网络错误、认证失败、文件异常等)

基础版本

// webpack-sourcemap-upload-plugin.js
class WebpackSourcemapUploadPlugin {
  constructor(options = {}) {
    this.options = {
      uploadUrl: options.uploadUrl,
      apiKey: options.apiKey,
      project: options.project,
      version: options.version || '1.0.0',
      ...options
    };
  }

  apply(compiler) {
    // 使用 afterEmit 钩子,确保文件已经生成
    compiler.hooks.afterEmit.tapPromise(
      'WebpackSourcemapUploadPlugin',
      async (compilation) => {
        try {
          await this.uploadSourcemaps(compilation);
        } catch (error) {
          console.error('Sourcemap upload failed:', error);
        }
      }
    );
  }

  async uploadSourcemaps(compilation) {
    const sourcemaps = this.findSourcemaps(compilation);
    
    if (sourcemaps.length === 0) {
      console.log('No sourcemaps found to upload');
      return;
    }

    console.log(`Found ${sourcemaps.length} sourcemap files to upload`);

    for (const sourcemap of sourcemaps) {
      await this.uploadFile(sourcemap);
    }
  }

  findSourcemaps(compilation) {
    const sourcemaps = [];
    const assets = compilation.assets;

    for (const filename in assets) {
      if (filename.endsWith('.map')) {
        const sourcePath = compilation.outputOptions.path;
        const fullPath = require('path').join(sourcePath, filename);
        sourcemaps.push({
          filename,
          path: fullPath,
          size: assets[filename].size()
        });
      }
    }

    return sourcemaps;
  }

  async uploadFile(fileInfo) {
    const formData = new FormData();
    const fileContent = require('fs').readFileSync(fileInfo.path);
    
    formData.append('file', new Blob([fileContent]), fileInfo.filename);
    formData.append('project', this.options.project);
    formData.append('version', this.options.version);

    const response = await fetch(this.options.uploadUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.options.apiKey}`,
      },
      body: formData,
    });

    if (response.ok) {
      console.log(`✅ Successfully uploaded: ${fileInfo.filename}`);
    } else {
      throw new Error(`Upload failed for ${fileInfo.filename}: ${response.statusText}`);
    }
  }
}

module.exports = WebpackSourcemapUploadPlugin;

webpack.config.js 配置

const WebpackSourcemapUploadPlugin = require('./webpack-sourcemap-upload-plugin');

module.exports = {
  mode: 'production',
  devtool: 'source-map',
  
  entry: './src/index.js',
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  
  plugins: [
    new WebpackSourcemapUploadPlugin({
      uploadUrl: 'https://api.your-service.com/sourcemaps',
      apiKey: process.env.SOURCEMAP_API_KEY,
      project: 'your-project-name',
      version: process.env.npm_package_version,
    })
  ]
};

2. Vite 插件实现

基础版本

// vite-sourcemap-upload-plugin.js
function viteSourcemapUploadPlugin(options = {}) {
  let config;
  
  return {
    name: 'vite-sourcemap-upload',
    
    configResolved(resolvedConfig) {
      config = resolvedConfig;
    },
    
    async writeBundle() {
      // 只在生产构建时上传
      if (config.command === 'build' && !config.build.ssr) {
        try {
          await this.uploadSourcemaps();
        } catch (error) {
          console.error('Sourcemap upload failed:', error);
        }
      }
    },
    
    methods: {
      async uploadSourcemaps() {
        const sourcemaps = this.findSourcemaps();
        
        if (sourcemaps.length === 0) {
          console.log('No sourcemaps found to upload');
          return;
        }
        
        console.log(`Found ${sourcemaps.length} sourcemap files to upload`);
        
        for (const sourcemap of sourcemaps) {
          await this.uploadFile(sourcemap);
        }
      },
      
      findSourcemaps() {
        const fs = require('fs');
        const path = require('path');
        const outputDir = config.build.outDir || 'dist';
        const sourcemaps = [];
        
        function scanDirectory(dir) {
          const files = fs.readdirSync(dir);
          
          for (const file of files) {
            const fullPath = path.join(dir, file);
            const stat = fs.statSync(fullPath);
            
            if (stat.isDirectory()) {
              scanDirectory(fullPath);
            } else if (file.endsWith('.map')) {
              sourcemaps.push({
                filename: file,
                path: fullPath,
                relativePath: path.relative(outputDir, fullPath)
              });
            }
          }
        }
        
        scanDirectory(outputDir);
        return sourcemaps;
      },
      
      async uploadFile(fileInfo) {
        const formData = new FormData();
        const fs = require('fs');
        const fileContent = fs.readFileSync(fileInfo.path);
        
        formData.append('file', new Blob([fileContent]), fileInfo.filename);
        formData.append('project', options.project);
        formData.append('version', options.version);
        formData.append('platform', 'web');
        
        const response = await fetch(options.uploadUrl, {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${options.apiKey}`,
          },
          body: formData,
        });
        
        if (response.ok) {
          console.log(`✅ Successfully uploaded: ${fileInfo.relativePath}`);
        } else {
          throw new Error(`Upload failed for ${fileInfo.filename}: ${response.statusText}`);
        }
      }
    }
  };
}

export default viteSourcemapUploadPlugin;

vite.config.js 配置

import { defineConfig } from 'vite';
import viteSourcemapUploadPlugin from './vite-sourcemap-upload-plugin';

export default defineConfig({
  build: {
    sourcemap: true, // 确保生成 sourcemap
  },
  plugins: [
    viteSourcemapUploadPlugin({
      uploadUrl: 'https://api.your-service.com/sourcemaps',
      apiKey: process.env.SOURCEMAP_API_KEY,
      project: 'your-project-name',
      version: process.env.npm_package_version,
    })
  ]
});

3. 增强功能版本

支持删除本地 sourcemap

// 增强的 uploadFile 方法
async uploadFile(fileInfo, deleteAfterUpload = true) {
  try {
    const fs = require('fs');
    const formData = new FormData();
    const fileContent = fs.readFileSync(fileInfo.path);
    
    // 上传逻辑...
    await this.performUpload(formData, fileInfo);
    
    // 上传成功后删除本地文件
    if (deleteAfterUpload) {
      fs.unlinkSync(fileInfo.path);
      console.log(`🗑️ Deleted local sourcemap: ${fileInfo.filename}`);
    }
  } catch (error) {
    console.error(`Upload failed for ${fileInfo.filename}:`, error);
    throw error;
  }
}

支持重试机制

async uploadWithRetry(fileInfo, maxRetries = 3) {
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await this.uploadFile(fileInfo);
      return; // 成功则返回
    } catch (error) {
      lastError = error;
      if (attempt < maxRetries) {
        console.log(`Retry ${attempt}/${maxRetries} for ${fileInfo.filename}`);
        await this.delay(1000 * attempt); // 指数退避
      }
    }
  }
  
  throw lastError;
}

delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

4. 实际使用示例

package.json 脚本

{
  "scripts": {
    "build": "webpack --mode=production",
    "build:with-upload": "SOURCEMAP_API_KEY=your-key webpack --mode=production"
  }
}

环境变量配置

# .env
SOURCEMAP_API_KEY=your-api-key-here
SOURCEMAP_UPLOAD_URL=https://api.your-service.com/sourcemaps
PROJECT_NAME=your-project

主要注意事项

  1. 安全性: API密钥不要硬编码在配置中,使用环境变量
  2. 错误处理: 上传失败不应阻断构建过程
  3. 性能: 大文件上传考虑分片或压缩
  4. 清理: 上传后建议删除本地sourcemap文件避免泄露

这样的实现可以很好地集成到你的CI/CD流程中,确保每次构建后自动上传sourcemap文件。