每天一个高级前端知识 - Day 18

6 阅读3分钟

每天一个高级前端知识 - Day 18

今日主题:前端工程化进阶 - 自定义构建工具与Babel插件开发

核心概念:工程化是将"不确定性"转化为"确定性"的过程

前端工程化不仅仅是Webpack配置,而是可复用的抽象能力自动化思维

🔧 自定义构建工具(基于ESBuild)

// build.js - 轻量级构建工具
const esbuild = require('esbuild');
const fs = require('fs-extra');
const path = require('path');
const chokidar = require('chokidar');

class CustomBuilder {
  constructor(config) {
    this.config = {
      entry: './src/index.ts',
      outdir: './dist',
      format: 'esm', // esm, cjs, iife
      target: 'es2020',
      minify: process.env.NODE_ENV === 'production',
      sourcemap: true,
      bundle: true,
      plugins: [],
      ...config
    };
    
    this.watcher = null;
    this.buildCount = 0;
  }
  
  async build() {
    const startTime = Date.now();
    
    try {
      // 清理输出目录
      await fs.emptyDir(this.config.outdir);
      
      // 执行构建
      const result = await esbuild.build({
        ...this.config,
        metafile: true,
        write: true
      });
      
      // 生成分析报告
      await this.generateReport(result.metafile);
      
      const duration = Date.now() - startTime;
      console.log(`✅ 构建完成 (${duration}ms) | 文件数: ${Object.keys(result.metafile.outputs).length}`);
      
      return result;
    } catch (error) {
      console.error('❌ 构建失败:', error);
      throw error;
    }
  }
  
  async generateReport(metafile) {
    const report = {
      totalSize: 0,
      assets: [],
      chunks: [],
      dependencies: {}
    };
    
    for (const [file, info] of Object.entries(metafile.outputs)) {
      const size = info.bytes;
      report.totalSize += size;
      report.assets.push({
        file: path.basename(file),
        size,
        sizeFormatted: this.formatBytes(size)
      });
    }
    
    // 排序并保存
    report.assets.sort((a, b) => b.size - a.size);
    await fs.writeJSON(path.join(this.config.outdir, 'build-report.json'), report, { spaces: 2 });
    
    // 输出摘要
    console.log(`📦 总大小: ${this.formatBytes(report.totalSize)}`);
    console.log(`📊 最大文件: ${report.assets[0]?.file} (${report.assets[0]?.sizeFormatted})`);
  }
  
  formatBytes(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }
  
  async watch() {
    console.log('👀 启动监听模式...');
    
    this.watcher = chokidar.watch(['./src/**/*'], {
      ignored: /(^|[\/\\])\../,
      persistent: true
    });
    
    let debounceTimer;
    
    this.watcher.on('all', (event, file) => {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(async () => {
        console.log(`📝 检测到变更: ${file}`);
        await this.build();
        this.buildCount++;
      }, 100);
    });
    
    // 首次构建
    await this.build();
  }
  
  async serve(port = 3000) {
    await this.build();
    
    const express = require('express');
    const app = express();
    
    app.use(express.static(this.config.outdir));
    app.use(express.static('./public'));
    
    // 热更新中间件
    const clients = new Set();
    
    app.get('/__reload', (req, res) => {
      clients.add(res);
      req.on('close', () => clients.delete(res));
    });
    
    this.watcher = chokidar.watch('./src/**/*');
    this.watcher.on('change', async () => {
      await this.build();
      clients.forEach(client => {
        client.write('data: reload\n\n');
      });
    });
    
    app.listen(port, () => {
      console.log(`🚀 开发服务器启动: http://localhost:${port}`);
    });
  }
  
  stop() {
    if (this.watcher) {
      this.watcher.close();
    }
  }
}

// 使用示例
const builder = new CustomBuilder({
  entry: './src/index.tsx',
  outdir: './dist',
  format: 'esm',
  minify: true,
  external: ['react', 'react-dom']
});

// 开发模式
if (process.env.NODE_ENV === 'development') {
  await builder.watch();
} else {
  await builder.build();
}

🔌 Babel插件开发

// ============ 1. 基础Babel插件结构 ============
module.exports = function() {
  return {
    name: 'babel-plugin-custom',
    visitor: {
      // 访问者模式
    }
  };
};

// ============ 2. 实际插件:自动导入组件 ============
// babel-plugin-auto-import.js
module.exports = function({ types: t }) {
  return {
    name: 'babel-plugin-auto-import',
    visitor: {
      Program: {
        enter(path, state) {
          const { opts } = state;
          const { components, from } = opts;
          
          if (!components) return;
          
          // 检查是否已存在导入
          const existingImports = new Set();
          path.node.body.forEach(node => {
            if (t.isImportDeclaration(node) && node.source.value === from) {
              node.specifiers.forEach(spec => {
                if (t.isImportSpecifier(spec)) {
                  existingImports.add(spec.imported.name);
                }
              });
            }
          });
          
          // 添加缺失的导入
          const missingComponents = components.filter(c => !existingImports.has(c));
          
          if (missingComponents.length > 0) {
            const importDeclaration = t.importDeclaration(
              missingComponents.map(comp => 
                t.importSpecifier(t.identifier(comp), t.identifier(comp))
              ),
              t.stringLiteral(from)
            );
            
            // 插入到文件开头
            path.node.body.unshift(importDeclaration);
          }
        }
      }
    }
  };
};

// ============ 3. 插件:console.log自动移除 ============
// babel-plugin-remove-console.js
module.exports = function({ types: t }) {
  return {
    name: 'babel-plugin-remove-console',
    visitor: {
      CallExpression(path, state) {
        const { opts } = state;
        const methods = opts.methods || ['log', 'info', 'warn', 'error', 'debug'];
        
        if (t.isMemberExpression(path.node.callee)) {
          const object = path.node.callee.object;
          const property = path.node.callee.property;
          
          if (
            t.isIdentifier(object, { name: 'console' }) &&
            t.isIdentifier(property) &&
            methods.includes(property.name)
          ) {
            // 生产环境移除
            if (process.env.NODE_ENV === 'production') {
              path.remove();
            }
            // 开发环境替换为noop
            else if (opts.replaceWithNoop) {
              path.replaceWith(t.identifier('noop'));
            }
          }
        }
      }
    }
  };
};

// ============ 4. 插件:组件props类型自动注入 ============
// babel-plugin-inject-proptypes.js
module.exports = function({ types: t }) {
  return {
    name: 'babel-plugin-inject-proptypes',
    visitor: {
      ExportDefaultDeclaration(path, state) {
        const declaration = path.node.declaration;
        
        if (!t.isFunctionDeclaration(declaration) && !t.isArrowFunctionExpression(declaration)) {
          return;
        }
        
        const componentName = declaration.id?.name || 'Component';
        const props = this.extractPropsFromUsage(declaration);
        
        if (props.length > 0) {
          const propTypes = this.generatePropTypes(props);
          
          // 插入PropTypes声明
          path.insertAfter(
            t.expressionStatement(
              t.assignmentExpression(
                '=',
                t.memberExpression(
                  t.identifier(componentName),
                  t.identifier('propTypes')
                ),
                propTypes
              )
            )
          );
        }
      }
    },
    
    extractPropsFromUsage(fn) {
      const props = new Set();
      
      // 遍历函数体,找出使用的props
      fn.body.body.forEach(node => {
        if (t.isExpressionStatement(node)) {
          this.traverseProps(node.expression, props);
        }
      });
      
      return Array.from(props);
    },
    
    traverseProps(node, props) {
      if (!node) return;
      
      if (t.isMemberExpression(node)) {
        if (t.isIdentifier(node.object, { name: 'props' })) {
          if (t.isIdentifier(node.property)) {
            props.add(node.property.name);
          }
        }
        this.traverseProps(node.object, props);
      }
      
      if (t.isLogicalExpression(node)) {
        this.traverseProps(node.left, props);
        this.traverseProps(node.right, props);
      }
    },
    
    generatePropTypes(props) {
      const properties = {};
      
      props.forEach(prop => {
        properties[prop] = t.memberExpression(
          t.memberExpression(t.identifier('PropTypes'), t.identifier('any')),
          t.identifier('isRequired')
        );
      });
      
      return t.objectExpression(
        Object.entries(properties).map(([key, value]) =>
          t.objectProperty(t.identifier(key), value)
        )
      );
    }
  };
};

// ============ 5. 插件:国际化自动提取 ============
// babel-plugin-i18n-extract.js
const fs = require('fs');
const path = require('path');

module.exports = function({ types: t }) {
  const messages = new Map();
  
  return {
    name: 'babel-plugin-i18n-extract',
    visitor: {
      CallExpression(path, state) {
        const { opts } = state;
        const { funcName = 't', outputPath = './locales/zh-CN.json' } = opts;
        
        if (t.isIdentifier(path.node.callee, { name: funcName })) {
          const arg = path.node.arguments[0];
          
          if (t.isStringLiteral(arg)) {
            const key = arg.value;
            const defaultMessage = key;
            
            if (!messages.has(key)) {
              messages.set(key, defaultMessage);
            }
            
            // 开发环境下添加注释
            if (process.env.NODE_ENV === 'development') {
              path.addComment('leading', ` i18n: ${key} `);
            }
          }
        }
      }
    },
    post() {
      // 生成语言文件
      const locales = {};
      for (const [key, value] of messages) {
        locales[key] = value;
      }
      
      const outputPath = path.resolve(process.cwd(), './locales/zh-CN.json');
      fs.writeFileSync(outputPath, JSON.stringify(locales, null, 2));
      console.log(`✅ 提取了 ${messages.size} 条国际化消息到 ${outputPath}`);
    }
  };
};

⚡ 自定义ESLint规则

// eslint-plugin-custom/rules/no-hooks-in-loop.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: '禁止在循环中使用React Hooks',
      category: 'Best Practices',
      recommended: true
    },
    fixable: 'code',
    schema: []
  },
  
  create(context) {
    let inLoop = false;
    const loopNodes = new Set();
    
    return {
      // 检测循环
      ForStatement(node) {
        loopNodes.add(node);
        inLoop = true;
      },
      'ForStatement:exit'(node) {
        loopNodes.delete(node);
        inLoop = loopNodes.size > 0;
      },
      
      WhileStatement(node) {
        loopNodes.add(node);
        inLoop = true;
      },
      'WhileStatement:exit'(node) {
        loopNodes.delete(node);
        inLoop = loopNodes.size > 0;
      },
      
      DoWhileStatement(node) {
        loopNodes.add(node);
        inLoop = true;
      },
      'DoWhileStatement:exit'(node) {
        loopNodes.delete(node);
        inLoop = loopNodes.size > 0;
      },
      
      // 检测Hook调用
      CallExpression(node) {
        if (!inLoop) return;
        
        const callee = node.callee;
        let hookName = null;
        
        if (t.isIdentifier(callee)) {
          hookName = callee.name;
        } else if (t.isMemberExpression(callee) && t.isIdentifier(callee.property)) {
          hookName = callee.property.name;
        }
        
        if (hookName && hookName.startsWith('use')) {
          context.report({
            node,
            message: `不要在循环中使用 Hook "${hookName}",这会导致性能问题或状态错误`,
            fix(fixer) {
              // 提供修复建议:将Hook移到循环外
              return fixer.insertTextBefore(node, '/* 警告: Hook不应该在循环中 */ ');
            }
          });
        }
      }
    };
  }
};

// 使用配置 .eslintrc.js
module.exports = {
  plugins: ['custom'],
  rules: {
    'custom/no-hooks-in-loop': 'error',
    'custom/no-console-in-production': process.env.NODE_ENV === 'production' ? 'error' : 'off'
  }
};

📦 自动化发布工具

// scripts/release.js
const semver = require('semver');
const inquirer = require('inquirer');
const execa = require('execa');
const chalk = require('chalk');
const fs = require('fs-extra');

class ReleaseManager {
  constructor(options) {
    this.packagePath = './package.json';
    this.pkg = require(this.packagePath);
    this.currentVersion = this.pkg.version;
  }
  
  async run() {
    console.log(chalk.cyan(`\n📦 当前版本: ${this.currentVersion}`));
    
    // 1. 选择版本类型
    const { versionType } = await inquirer.prompt([
      {
        type: 'list',
        name: 'versionType',
        message: '选择版本增量类型:',
        choices: [
          { name: `补丁 (patch) ${chalk.gray('bug修复')}`, value: 'patch' },
          { name: `次要 (minor) ${chalk.gray('新功能,向后兼容')}`, value: 'minor' },
          { name: `主要 (major) ${chalk.red('破坏性变更')}`, value: 'major' },
          { name: '自定义版本', value: 'custom' }
        ]
      }
    ]);
    
    let newVersion;
    if (versionType === 'custom') {
      const { version } = await inquirer.prompt([
        { type: 'input', name: 'version', message: '输入新版本号:', validate: v => !!semver.valid(v) }
      ]);
      newVersion = version;
    } else {
      newVersion = semver.inc(this.currentVersion, versionType);
    }
    
    // 2. 确认版本
    const { confirmed } = await inquirer.prompt([
      {
        type: 'confirm',
        name: 'confirmed',
        message: `确定将版本从 ${this.currentVersion} 升级到 ${chalk.green(newVersion)}?`,
        default: true
      }
    ]);
    
    if (!confirmed) return;
    
    // 3. 更新版本号
    await this.updateVersion(newVersion);
    
    // 4. 生成CHANGELOG
    await this.generateChangelog();
    
    // 5. 运行测试
    await this.runTests();
    
    // 6. 构建
    await this.build();
    
    // 7. 提交代码
    await this.commit(newVersion);
    
    // 8. 创建Git标签
    await this.createTag(newVersion);
    
    // 9. 发布到NPM
    await this.publish();
    
    // 10. 推送到远程
    await this.push();
    
    console.log(chalk.green(`\n✅ 发布成功!版本 ${newVersion} 已发布\n`));
  }
  
  async updateVersion(version) {
    this.pkg.version = version;
    await fs.writeJson(this.packagePath, this.pkg, { spaces: 2 });
    console.log(chalk.green(`✅ 版本更新: ${this.currentVersion}${version}`));
  }
  
  async generateChangelog() {
    try {
      await execa('conventional-changelog', ['-p', 'angular', '-i', 'CHANGELOG.md', '-s', '-r', '0']);
      console.log(chalk.green('✅ CHANGELOG 生成完成'));
    } catch (error) {
      console.log(chalk.yellow('⚠️  CHANGELOG 生成失败,跳过'));
    }
  }
  
  async runTests() {
    console.log(chalk.cyan('\n🧪 运行测试...'));
    try {
      await execa('npm', ['test']);
      console.log(chalk.green('✅ 测试通过'));
    } catch (error) {
      console.log(chalk.red('❌ 测试失败'));
      process.exit(1);
    }
  }
  
  async build() {
    console.log(chalk.cyan('\n🔨 构建项目...'));
    try {
      await execa('npm', ['run', 'build']);
      console.log(chalk.green('✅ 构建成功'));
    } catch (error) {
      console.log(chalk.red('❌ 构建失败'));
      process.exit(1);
    }
  }
  
  async commit(version) {
    console.log(chalk.cyan('\n📝 提交代码...'));
    try {
      await execa('git', ['add', '.']);
      await execa('git', ['commit', '-m', `chore(release): ${version}`]);
      console.log(chalk.green('✅ 提交成功'));
    } catch (error) {
      console.log(chalk.red('❌ 提交失败'));
    }
  }
  
  async createTag(version) {
    try {
      await execa('git', ['tag', `v${version}`]);
      console.log(chalk.green(`✅ 标签创建: v${version}`));
    } catch (error) {
      console.log(chalk.red('❌ 标签创建失败'));
    }
  }
  
  async publish() {
    console.log(chalk.cyan('\n📤 发布到NPM...'));
    
    const { otp } = await inquirer.prompt([
      { type: 'input', name: 'otp', message: '输入NPM OTP (如果需要):' }
    ]);
    
    try {
      const args = ['publish'];
      if (otp) args.push('--otp', otp);
      await execa('npm', args);
      console.log(chalk.green('✅ 发布成功'));
    } catch (error) {
      console.log(chalk.red('❌ 发布失败'));
      process.exit(1);
    }
  }
  
  async push() {
    console.log(chalk.cyan('\n📡 推送到远程仓库...'));
    try {
      await execa('git', ['push', 'origin', 'main', '--tags']);
      console.log(chalk.green('✅ 推送成功'));
    } catch (error) {
      console.log(chalk.red('❌ 推送失败'));
    }
  }
}

// 执行发布
const release = new ReleaseManager();
release.run().catch(console.error);

🎯 今日挑战

实现自定义脚手架工具,要求:

  1. 支持create-xxx命令创建项目
  2. 可选的模板(React/Vue/Node)
  3. 支持TypeScript配置选项
  4. 自动安装依赖
  5. 初始化Git仓库
  6. 生成配置文件(ESLint/Prettier/tsconfig)
# 使用示例
npx create-my-app my-project --template react --typescript --eslint

# 输出
✅ 创建项目文件夹
✅ 克隆模板
✅ 安装依赖
✅ 配置TypeScript
✅ 配置ESLint
✅ Git初始化成功

🚀 项目创建完成!cd my-project && npm start

明日预告:Web Worker 深度应用 - 多线程前端计算与高性能数据处理

💡 工程化箴言:"自动化一切重复劳动"——工程师的时间应该花在解决问题,而不是重复配置!