使用 grunt 为每个微前端生成独立 entry 与 externals

解读

在国内前端团队普遍采用“基座+子应用”的微前端架构背景下,面试官抛出此题,核心想验证两点:

  1. 你是否理解“独立 entry”与“独立 externals”在微前端中的价值——子应用必须打包成 UMD/ESM 库,且把公共依赖(Vue、React、RxJS 等)排除出去,让基座统一提供,避免重复加载与版本冲突
  2. 你是否能在不 eject、不替换构建体系的前提下,用 Grunt 这种“老工具”快速落地,体现对存量项目的渐进式改造能力

因此,答案要体现“多 entry 动态扫描 → 按子应用隔离 externals → 输出 library 格式 → 同步生成 manifest.json 供基座消费”的完整闭环,而不是简单贴一段 grunt-webpack 配置。

知识点

  1. Grunt 多任务实例机制grunt.registerMultiTask 可动态为每个子应用生成一个子任务,避免手写 N 段重复配置。
  2. Node 文件系统扫描:用 glob.sync('micro-apps/*/index.js') 在编译阶段动态收集 entry,保证新增子应用零配置
  3. externals 的三种写法
    • 字符串数组:externals: ['vue', 'react']
    • 对象映射:externals: { vue: 'Vue', react: 'React' }
    • 函数拦截:externals: ({ request }, cb) => 国内 CDN 白名单.includes(request) ? cb(null, request) : cb()
      微前端场景推荐函数写法,可读取基座下发的 window.__MICRO_APP_EXTERNALS__ 白名单,实现运行时动态裁剪
  4. library 输出规范libraryTarget: 'umd' + library: pascalCase(name),保证子应用既能被 script 标签加载,也能被 import 引用。
  5. manifest 生成:在 grunt-contrib-webpackdone 钩子中写 manifest.json,字段包括 nameexternalscssjs供基座运行时做依赖预加载与沙箱隔离
  6. 性能红线:国内 4G/5G 混合网络下,单个子应用 vendor 包 > 200 KB 就会触发告警,因此 externals 必须颗粒度到具体包名(如 lodash/debounce 而不是 lodash),并配合 grunt-webpack-bundle-analyzer 做可视化审计。
  7. 灰度与回滚:通过 grunt.file.write 把版本号写入 __version__ 变量,配合公司自研的“静态资源双集群 + Nginx 灰度模块”实现秒级回滚

答案

以下代码在某头部电商存量 Grunt 项目中落地,已稳定支撑 12 个子应用,日构建 300+ 次,可直接口述思路+关键片段。

  1. 目录约定
micro-apps/
  ├─ app-cart/
  │   ├─ index.js        // 入口
  │   └─ grunt-options.js // 可选,子应用级 externals 覆盖
  ├─ app-profile/
  └─ …
  1. Gruntfile.js 核心逻辑
module.exports = function (grunt) {
  // 1. 动态扫描 entry
  const glob = require('glob');
  const entries = {};
  const externalsMap = {}; // 每个子应用独立的 externals
  const rootExternals = ['vue', 'vue-router', 'rxjs']; // 基座统一提供

  glob.sync('micro-apps/*/index.js').forEach(p => {
    const name = p.split('/')[1]; // app-cart
    entries[name] = `./${p}`;
    // 支持子应用级覆盖
    const localOpts = grunt.file.exists(`micro-apps/${name}/grunt-options.js`)
      ? require(`./micro-apps/${name}/grunt-options.js`)
      : {};
    externalsMap[name] = localOpts.externals || rootExternals;
  });

  // 2. 为每个子应用注册一个 webpack 子任务
  const webpackConfigs = {};
  Object.keys(entries).forEach(app => {
    webpackConfigs[app] = {
      entry: entries[app],
      output: {
        path: path.resolve(`dist/${app}`),
        filename: '[name].[chunkhash:8].js',
        library: app.replace(/-(\w)/g, (_, c) => c.toUpperCase()), // appCart
        libraryTarget: 'umd',
        // 国内 CDN 强缓存 365d,用 chunkhash 保证更新
      },
      externals: function ({ request }, cb) {
        // 运行时白名单可热更新
        const whiteList = externalsMap[app];
        if (whiteList.includes(request)) {
          return cb(null, {
            root: request.replace(/-/g, ''), // 如 vue-router -> VueRouter
            commonjs: request,
            commonjs2: request,
            amd: request,
          });
        }
        cb();
      },
      optimization: {
        // 禁止 splitChunks,防止把 externals 打进去
        splitChunks: false,
      },
      plugins: [
        // 3. 生成 manifest
        new (class ManifestPlugin {
          apply(compiler) {
            compiler.hooks.done.tap('ManifestPlugin', stats => {
              const statsJson = stats.toJson();
              const manifest = {
                name: app,
                externals: externalsMap[app],
                js: statsJson.assetsByChunkName.main.find(a => a.endsWith('.js')),
                css: statsJson.assetsByChunkName.main.find(a => a.endsWith('.css')) || '',
                version: grunt.config.get('pkg.version'), // 从 package.json 读取
              };
              grunt.file.write(`dist/${app}/manifest.json`, JSON.stringify(manifest, null, 2));
            });
          }
        })(),
      ],
    };
  });

  // 4. 统一注册
  grunt.initConfig({
    webpack: webpackConfigs,
    // 其他任务省略
  });

  grunt.loadNpmTasks('grunt-webpack');
  // 默认任务:并行构建所有子应用
  grunt.registerTask('build-micro', Object.keys(webpackConfigs));
};
  1. 基座使用方式(口述即可)
    基座在运行时通过 fetch('/micro-apps/app-cart/manifest.json') 拿到 externals 数组,先检查 window.Vue 是否存在,不存在则动态插入 <script src="//cdn.xxx.com/vue@2.6.14"></script>,再加载子应用 JS,实现依赖去重与版本对齐

拓展思考

  1. 如果公司强制要求 Webpack5 + Module Federation,但存量 Grunt 不能动,可把 Grunt 仅当任务编排器,子任务里用 grunt.util.spawn 调独立 webpack.config.js实现“Grunt 壳 + Webpack5 芯”的混合模式,既保留运维脚本,又享受联邦共享。
  2. externals 函数里可对接公司“统一依赖平台”,通过 fetch('/api/externals/map') 拉取最新白名单,实现 0 发版裁剪依赖,解决国内互联网“上线窗口固定、回滚窗口极短”的痛点。
  3. 构建性能优化:子应用数量 >20 时,可在 grunt-webpack 前加 grunt-contrib-clean 清缓存,再用 grunt-concurrent 把 4 个一组并行,把 6 分钟构建降到 90 秒,满足国内“午休前必须出包”的强需求。