使用 grunt 合并子应用 manifest 并生成统一路由

解读

在国内微前端、多页应用、SSR 同构等场景下,主应用需要动态感知各子应用(独立仓库、独立部署)的入口与路由信息。
子应用各自维护一份 manifest.json(含 nameentrypublicPathroutes 等字段),
CI 阶段把这些 manifest 上传到统一制品库(Nexus/阿里云 OSS/企业内网静态服务器)。
Grunt 负责在构建主应用时,把所有子应用的 manifest 拉取、校验、合并、去重,最终输出一份 routes.json 供运行时加载器(import-map、qiankun、自研路由框架)消费。
面试时,考官想确认:

  1. 你是否理解“非源码资源聚合”这一构建环节;
  2. 能否用 Grunt 插件体系低成本落地,而不引入 webpack/vite 额外编译;
  3. 是否考虑国内网络稳定性缓存策略灰度发布回滚等工程化细节。

知识点

  • Grunt 多任务并发grunt-concurrentthis.async() 避免顺序拉取耗时。
  • HTTP 拉取grunt-http 或自定义 grunt.task.runXHR,需加 retry + 超时(国内机房跨区慢)。
  • JSON Schema 校验ajv 在 Grunt 插件里做 manifest 字段强校验,防止子应用写错字段导致主应用白屏。
  • 去重与冲突解决:同一子应用多版本并存时,按 semver 最大兼容版本灰度权重 保留一条记录。
  • 路由扁平化:把嵌套路由拍平,生成 path -> entry 的 Map,减少运行时递归。
  • 缓存与增量:利用 grunt-newer + ETag,只有子应用 manifest 的 etag 变化才重新合并,节省流水线时间。
  • 输出格式兼容:同时生成 TypeScript 声明文件(routes.d.ts),方便主应用代码提示。
  • 回滚策略:在 grunt-contrib-copy 之前先备份旧 routes.json,一旦校验失败可 自动回滚

答案

  1. 初始化任务
    在项目根目录安装依赖:
npm i -D grunt grunt-contrib-clean grunt-contrib-copy grunt-http ajv semver
  1. 编写自定义合并任务 tasks/merge-manifest.js
const http = require('axios');
const semver = require('semver');
const Ajv = require('ajv');
const ajv = new Ajv();

module.exports = function(grunt) {
  grunt.registerMultiTask('mergeManifest', '合并子应用 manifest', function() {
    const done = this.async();
    const options = this.options({
      output: 'dist/routes.json',
      schema: {
        type: 'object',
        properties: {
          name: {type: 'string'},
          entry: {type: 'string'},
          publicPath: {type: 'string'},
          routes: {
            type: 'array',
            items: {
              type: 'object',
              properties: {
                path: {type: 'string'},
                exact: {type: 'boolean'}
              },
              required: ['path']
            }
          }
        },
        required: ['name', 'entry', 'routes']
      }
    });

    const validate = ajv.compile(options.schema);
    const merged = {};

    Promise.all(options.urls.map(async url => {
      const res = await http.get(url, {timeout: 5000});
      const data = res.data;
      if (!validate(data)) throw new Error(`invalid manifest ${url}`);
      const key = `${data.name}@${semver.clean(data.version || '0.0.0')}`;
      if (!merged[key] || semver.gt(data.version, merged[key].version)) {
        merged[key] = data;
      }
    }))
    .then(() => {
      const routes = [];
      Object.values(merged).forEach(m => {
        m.routes.forEach(r => routes.push({
          ...r,
          entry: m.entry,
          publicPath: m.publicPath
        }));
      });
      grunt.file.write(options.output, JSON.stringify({routes}, null, 2));
      grunt.log.ok(`合并完成,共 ${routes.length} 条路由`);
      done();
    })
    .catch(done);
  });
};
  1. Gruntfile.js 配置
module.exports = function(grunt) {
  grunt.initConfig({
    clean: { dist: 'dist' },
    mergeManifest: {
      prod: {
        options: {
          urls: [
            'https://cdn1.xxx.com/app1/manifest.json',
            'https://cdn2.xxx.com/app2/manifest.json'
          ],
          output: 'dist/routes.json'
        }
      }
    },
    copy: {
      main: {
        files: [{ src: 'dist/routes.json', dest: 'public/routes.json' }]
      }
    }
  });

  grunt.loadTasks('tasks');
  grunt.loadNpmTasks('grunt-contrib-clean');
  grunt.loadNpmTasks('grunt-contrib-copy');

  grunt.registerTask('default', ['clean', 'mergeManifest', 'copy']);
};
  1. 在 CI 中调用
grunt default --env=prod

产物 public/routes.json 即为统一路由表,主应用通过 fetch('/routes.json') 动态注册子应用。

拓展思考

  • 灰度发布:在 manifest 里增加 weight 字段,合并任务按权重随机抽样,实现子应用灰度
  • 增量更新:把 etag 记录到 .grunt-manifest-cache,下次构建只拉取变化的子应用,节省 80% 网络耗时
  • 安全校验:在 CI 里用企业内网私钥对 routes.json 做签名,运行时公钥验签,防止运营商劫持插入恶意路由。
  • 可视化看板:把合并后的路由表同步到 内网低代码平台,运维同学可一键下线故障子应用,无需重新打包主应用。