解释在 grunt 中实现 A/B 实验埋点分桶

解读

面试官并非想听“Grunt 能不能跑 A/B 测试”,而是考察候选人能否把前端构建流程数据驱动业务打通:

  1. 用 Grunt 的插件体系在编译时把“分桶逻辑”注入代码;
  2. 保证分桶结果可复现、可灰度、可回滚
  3. 让埋点字段与分桶 ID 严格一致,避免数据偏差。
    回答时要体现“构建即实验”的思想:把实验配置当成代码的一部分,在 Gruntfile 里版本化、自动化、可视化

知识点

  1. Grunt 任务生命周期:initConfig → registerTask → run;
  2. npm confgrunt-replace 做“编译期字符串替换”,把实验号写进代码;
  3. grunt-crypto 或自建 Node 脚本做“一致性哈希分桶”(uuid→bucketId);
  4. grunt-contrib-uglify 开启 sourceMap,保证压缩后仍能映射实验号;
  5. grunt-contrib-watch + grunt-contrib-connect 实现本地“多桶并行预览”(localhost:3000/?exp=b1);
  6. 埋点规范:spm、gmkey-gokey、dt-traceid 等国内主流字段;
  7. 灰度发布:结合阿里云 OSS/腾讯云 COS 的“目录级灰度”(如 /exp/1.0.0_b001/);
  8. 数据回传:埋点必须带 expId+bucketId+uidHash,方便实时计算显著性。

答案

  1. 实验配置代码化
    config/ab.json 中声明实验:

    {
      "expId": "homepage_btn_color_2025Q2",
      "buckets": {
        "CONTROL": 50,
        "RED": 25,
        "BLUE": 25
      },
      "salt": "2025Q2_salt_xxx"
    }
    

    文件随 Git 版本化,任何变更走 MR+CodeReview

  2. Gruntfile 注入分桶常量
    安装 grunt-replace

    npm i -D grunt-replace
    

    在 Gruntfile.js 里:

    grunt.initConfig({
      replace: {
        ab: {
          options: {
            patterns: [{
              match: /__AB_CONFIG__/,
              replacement: () => JSON.stringify(grunt.file.readJSON('config/ab.json'))
            }]
          },
          files: [{ src: 'src/entry.js', dest: '.tmp/entry.js' }]
        }
      },
      uglify: { build: { files: { 'dist/js/entry.min.js': '.tmp/entry.js' } } }
    });
    grunt.registerTask('build', ['replace:ab', 'uglify']);
    
  3. 运行时一致性分桶
    .tmp/entry.js 顶部注入:

    window.AB = JSON.parse('__AB_CONFIG__');
    function getBucket(uid) {
      const hash = md5(uid + AB.salt).slice(0, 4);
      const n = parseInt(hash, 16) % 100;
      let acc = 0;
      for (const [k, v] of Object.entries(AB.buckets)) {
        acc += v;
        if (n < acc) return k;
      }
      return 'CONTROL';
    }
    

    该算法纯前端、无后端依赖,且同 UID 永远进同桶

  4. 埋点联动
    按钮渲染时:

    const bucket = getBucket(Cookies.get('uid') || uuid());
    document.querySelector('#buyBtn').classList.add(bucket);
    // 埋点
    sendUbt({
      spm: 'a2145.home.buy',
      expId: AB.expId,
      bucketId: bucket,
      uidHash: md5(uid).slice(0, 6)
    });
    
  5. 本地多桶并行验证
    grunt-contrib-connect 起三个端口:

    grunt.initConfig({
      connect: {
        control: { options: { port: 3000, base: 'dist', middleware: (c,o,n)=>n(c,o.setHeader('x-ab-bucket','CONTROL')) } },
        red:     { options: { port: 3001, base: 'dist', middleware: (c,o,n)=>n(c,o.setHeader('x-ab-bucket','RED')) } },
        blue:    { options: { port: 3002, base: 'dist', middleware: (c,o,n)=>n(c,o.setHeader('x-ab-bucket','BLUE')) } }
      }
    });
    grunt.registerTask('serve:ab', ['build', 'connect', 'watch']);
    

    测试同学可同时打开三窗口验证 UI 差异。

  6. 线上灰度与回滚
    构建产出按 #{expId}_#{bucket} 目录隔离,通过 CDN 边缘规则把 10% 流量切到实验目录;若指标异常,回滚只需把流量切回原版目录,无需重新打包。

拓展思考

  1. 服务端分层分桶:如果实验涉及接口字段,需把 Grunt 生成的 ab.json 上传到配置中心,让 Node/Java 服务用同一套 salt 与比例,保证前后端桶一致,避免“前端红、后端蓝”的数据错乱。
  2. 实时显著性监控:埋点数据直接打到阿里云 SLS腾讯灯塔,用 SQL WHERE expId='xxx' GROUP BY bucketId 每 5 分钟计算置信区间,Grunt 任务里可集成 grunt-sls-upload 把构建元数据(commitId、builder、时间)一并上传,实现版本-实验-指标三联追踪。
  3. SSR/边缘渲染场景:Grunt 构建时把分桶脚本打成 ab-loader.js,在CDN EdgeRoutine里先执行分桶,再回源带 X-AB-Bucket 头,实现边缘决定桶,减少前端闪烁。
  4. 合规与隐私:国内《个人信息保护法》要求“最小必要”,因此 UID 必须脱敏哈希,且实验配置中禁止出现用户明文属性;Grunt 任务加一道 grunt-contrib-jshint 规则,扫描代码是否意外上传了 openid、手机号等敏感字段。