使用 grunt 打包组件为 umd 并上传私有 npm

解读

面试官抛出这道题,核心不是“会不会用 Grunt”,而是考察候选人能否在国内真实企业场景下,用 Grunt 完成“组件库闭环交付”:

  1. 把源码打包成浏览器、Node、AMD、CMD 都能消费的 UMD 格式
  2. 把产物干净地发布到私有 npm(Verdaccio、cnpm、Nexus、阿里效优私服等),并兼顾版本号、权限、CI 集成
  3. 整个链路可重复、可回滚、可审计,符合国内企业对合规、安全、速度的硬性要求。
    如果候选人只回答“grunt-uglify + grunt-contrib-copy”而忽略私有源配置、scope 命名、2FA、CI 令牌、.npmrc 优先级、lock 文件一致性,就会被判定为“只会写配置,不会落地”。

知识点

  1. UMD 规范:兼容 AMD、CommonJS、全局变量,需用 grunt-umd、grunt-wrap 或 rollup-grunt 插件生成统一包裹;
  2. Grunt 任务链:grunt-contrib-clean → grunt-contrib-concat → grunt-umd → grunt-contrib-uglify → grunt-contrib-copy → grunt-contrib-rename;
  3. 私有 npm 鉴权
    • .npmrc 优先级:项目级 > 用户级 > 全局级,国内私服必须显式声明 registry=https://registry.xxx.comalways-auth=true
    • authToken 来源:CI 场景下通过 NPM_TOKEN 环境变量注入,避免明文写进仓库;
  4. 版本号管理:grunt-bump 支持 semver 三件套(patch/minor/major),配合 conventional-changelog-grunt 自动生成符合国内审计要求的 ChangeLog;
  5. 包名与作用域:企业私服强制要求 @company/xxx 格式,需在 package.json 中声明 "publishConfig":{"registry":"https://registry.xxx.com"},防止误发到公网;
  6. 预发布校验:grunt-contrib-jshint/eslint、grunt-mocha-test、grunt-karma 必须跑在 pack 之前,确保私有库质量;
  7. CI 集成:Jenkins/GitLab-Runner/阿里效优 流水线里,先 npm ci --registry=$PRIVATE_REGISTRY,再 grunt release,最后 npm publish --registry=$PRIVATE_REGISTRY,全程用 --verbose 留痕;
  8. 回滚策略:grunt-contrib-compress 把每次产物打成 tgz,上传至企业对象存储(OSS、COS),实现“包级回滚”;
  9. 性能优化:国内网络下,grunt 插件安装耗时高,需提前在 Docker 基础镜像里预缓存 node_modules,并把 grunt-cli 锁定在 1.4.3 以上,避免 grunt-sass 二进制反复编译;
  10. 合规与审计:私有包必须附带 LICENSESECURITY.md,grunt-license-finder 扫描依赖许可证,防止 GPL 污染。

答案

步骤一:初始化项目结构

my-ui/
├─ src/
│  └─ index.js
├─ dist/          (gitignore)
├─ .npmrc
├─ .nvmrc
├─ Gruntfile.js
└─ package.json

package.json 关键字段:

"name": "@company/my-ui",
"version": "1.0.0",
"main": "dist/my-ui.umd.min.js",
"publishConfig": {"registry":"https://registry.company.com"}

.npmrc(项目级,防止误发公网):

registry=https://registry.company.com
always-auth=true
//registry.company.com/:_authToken=${NPM_TOKEN}

步骤二:Gruntfile.js 完整任务

module.exports = function(grunt) {
  grunt.initConfig({
    clean: { dist: 'dist/*' },
    concat: {
      umdRaw: {
        src: ['src/**/*.js'],
        dest: 'dist/my-ui.js'
      }
    },
    umd: {
      all: {
        options: {
          src: 'dist/my-ui.js',
          dest: 'dist/my-ui.umd.js',
          objectToExport: 'MyUI',
          amdModuleId: 'my-ui',
          globalAlias: 'MyUI',
          deps: { 'default': ['jquery'], amd: ['jquery'], cjs: ['jquery'], global: ['jQuery'] }
        }
      }
    },
    uglify: {
      options: { banner: '/*! <%= pkg.name %> v<%= pkg.version %> */' },
      target: { files: { 'dist/my-ui.umd.min.js': 'dist/my-ui.umd.js' } }
    },
    copy: {
      css: { src: 'src/*.css', dest: 'dist/my-ui.css' }
    },
    compress: {
      artifact: {
        options: { archive: 'releases/<%= pkg.name %>-v<%= pkg.version %>.tgz' },
        files: [{ src: ['dist/**'], dest: '.' }]
      }
    },
    bump: {
      options: {
        files: ['package.json'],
        commit: true,
        commitMessage: 'chore: release v%VERSION%',
        commitFiles: ['package.json', 'CHANGELOG.md'],
        createTag: true,
        tagName: 'v%VERSION%',
        push: false   // CI 里手动 push,防止权限漂移
      }
    },
    conventionalChangelog: {
      release: {
        options: { changelogFile: 'CHANGELOG.md' }
      }
    },
    eslint: { target: ['src/**/*.js'] },
    mochaTest: { src: ['test/**/*.spec.js'] }
  });

  grunt.loadNpmTasks('grunt-contrib-clean');
  grunt.loadNpmTasks('grunt-contrib-concat');
  grunt.loadNpmTasks('grunt-umd');
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-copy');
  grunt.loadNpmTasks('grunt-contrib-compress');
  grunt.loadNpmTasks('grunt-bump');
  grunt.loadNpmTasks('grunt-conventional-changelog');
  grunt.loadNpmTasks('grunt-eslint');
  grunt.loadNpmTasks('grunt-mocha-test');

  grunt.registerTask('test', ['eslint', 'mochaTest']);
  grunt.registerTask('build', ['clean', 'concat', 'umd', 'uglify', 'copy']);
  grunt.registerTask('artifact', ['compress']);
  grunt.registerTask('prerelease', ['test', 'build', 'artifact']);
  grunt.registerTask('release', function(type){
    grunt.task.run('prerelease');
    grunt.task.run('bump:' + (type || 'patch'));
    grunt.task.run('conventionalChangelog');
  });
};

步骤三:本地验证

npx grunt release:minor

产物:

  • dist/my-ui.umd.min.js
  • releases/@company/my-ui-v1.1.0.tgz
  • CHANGELOG.md 已更新

步骤四:CI 发布
Jenkinsfile(关键片段):

stage('Publish'){
  environment { NPM_TOKEN = credentials('npm-token-company') }
  steps {
    sh 'npm ci --registry=https://registry.company.com'
    sh 'npx grunt release:patch'
    sh 'npm publish --registry=https://registry.company.com'
  }
}

注意

  • 私服若使用 cnpm core,需加 --tag latest 才能被 npm install @company/my-ui 解析;
  • 若私服开启 2FA,需改用 npm publish --registry=xxx --otp=$(input),CI 中通过流水线交互插件输入;
  • 发布成功后,立即在效优知识库登记版本号与变更摘要,满足国内审计。

拓展思考

  1. Grunt 已老,为何企业仍考?
    国内大量存量项目(2014-2018)仍用 Grunt,迁移成本 > 维护成本,面试官想看候选人能否在遗留体系里继续交付价值,而非盲目追新。
  2. 如何与 Vite/Rollup 共存?
    可保留 Grunt 做“遗留任务调度器”,把 vite build 作为子进程 spawn,实现渐进式迁移
  3. 私服高可用
    在生产环境部署 Verdaccio 集群 + 阿里云 OSS 后端 + CDN 回源,解决单点故障;
  4. 安全加固
    使用 grunt-sri 生成子资源完整性哈希,防止 CDN 投毒;
  5. 度量与复盘
    在 Grunt 任务尾部插入 grunt-metrics,把构建耗时、包体积、依赖数量推送到企业 Prometheus,每月复盘性能基线,让“老 Grunt”也能讲出数据故事。