使用 grunt 注入 SSR 错误边界脚本并捕获异常

解读

这是一道**“老工具新场景”的实战题,考察候选人能否把 Grunt 的“文件流转”能力与 Node 服务端渲染(SSR)的异常治理结合起来。国内主流 SSR 框架(Next.js、Nuxt、Egg + React/Vue)普遍把错误边界放在运行时,但线上灰度或降级场景**需要“兜底脚本”在 HTML 首屏就注入,确保:

  1. 服务端抛错时,浏览器能立即感知并上报;
  2. 错误信息不泄露源码路径;
  3. 注入过程可灰度、可回滚、可溯源。

Grunt 虽“老”,但在存量项目、内网构建、Jenkins 流水线中仍大量存在,面试官想看你是否能用“土办法”解决“新问题”,而不是直接换 Vite/Webpack。

知识点

  • Grunt 任务生命周期:init → loadNpmTasks → registerTask → grunt.task.run
  • grunt-contrib-copy / grunt-string-replace / grunt-injector 等“文件中间态”插件的执行顺序与管道机制
  • Node SSR 异常分类:renderToString 同步错误、asyncData 异步错误、资源加载 404、ChunkLoadError
  • 浏览器端错误边界与 window.onerror / window.addEventListener('unhandledrejection') 的拾取范围差异
  • source-map-support 与 error-stack-parser 在 SSR 环境下的映射原理
  • 国内监控体系:阿里 SLS、腾讯 RUM、字节 APM,均要求统一 errorId + 用户行为回溯
  • 灰度密钥:通过 grunt-replace 注入 window.__GRAY_KEY__,与 CDN 边缘规则联动
  • 安全合规:生产环境需脱敏 stack,防止路径泄露;内网源站需屏蔽 x-powered-by

答案

  1. 安装依赖
npm i -D grunt grunt-contrib-copy grunt-string-replace grunt-injector cheerio
  1. Gruntfile.js 中定义“注入 + 捕获”双任务
module.exports = function(grunt) {
  grunt.initConfig({
    // 1. 先拷贝构建产物,形成“可写副本”
    copy: {
      ssrHtml: {
        src: '.next/server/pages/**/*.html',
        dest: 'dist/ssr/',
        expand: true,
        flatten: false
      }
    },

    // 2. 注入错误边界脚本
    'string-replace': {
      errorBoundary: {
        files: [{
          expand: true,
          cwd: 'dist/ssr/',
          src: '**/*.html',
          dest: 'dist/ssr/'
        }],
        options: {
          replacements: [{
            pattern: '</head>',
            replacement: function() {
              const code = `
<script>
(function(){
  window.__SSR_ERROR_ID__='<%= grunt.template.today("yyyymmddHHMMss") %>';
  window.onerror=function(m,u,l,c,o){window.__SSR_ERROR__=o;};
  window.addEventListener('unhandledrejection',function(e){
    window.__SSR_ERROR__=e.reason;
  });
  if(window.__NEXT_DATA__&&window.__NEXT_DATA__.err){
    window.__SSR_ERROR__=window.__NEXT_DATA__.err;
  }
})();
</script></head>`;
              return code;
            }
          }]
        }
      }
    },

    // 3. 上报脚本(可灰度)
    injector: {
      options: {
        starttag: '<!-- injector:js -->',
        endtag: '<!-- endinjector -->',
        transform: function(filePath) {
          return '<script src="' + filePath + '?key=' + grunt.option('grayKey') + '"></script>';
        }
      },
      local: {
        files: {
          'dist/ssr/**/*.html': ['static/report.js']
        }
      }
    }
  });

  grunt.loadNpmTasks('grunt-contrib-copy');
  grunt.loadNpmTasks('grunt-string-replace');
  grunt.loadNpmTasks('grunt-injector');

  grunt.registerTask('ssr-error', function() {
    // 从环境变量读取灰度 key,缺省走全量
    grunt.option('grayKey', process.env.GRAY_KEY || '0000');
    grunt.task.run(['copy:ssrHtml', 'string-replace:errorBoundary', 'injector:local']);
  });

  // 4. 捕获异常并写日志(供 Jenkins 采集)
  grunt.registerTask('ssr-capture', function() {
    const done = this.async();
    const glob = require('glob');
    const fs   = require('fs');
    const path = require('path');
    glob('dist/ssr/**/*.html', function(er, files) {
      if (er) grunt.fail.fatal(er);
      files.forEach(f => {
        const html = fs.readFileSync(f, 'utf8');
        const $ = cheerio.load(html);
        const err = $('script').text().match(/window\.__SSR_ERROR__\s*=\s*(.+);/);
        if (err) {
          const logFile = path.join('logs', path.basename(f, '.html') + '.json');
          grunt.file.write(logFile, JSON.stringify({
            file: f,
            error: err[1],
            buildTime: grunt.template.today('isoDateTime')
          }));
        }
      });
      grunt.log.ok('SSR 异常捕获完成,共 ' + files.length + ' 文件');
      done();
    });
  });

  grunt.registerTask('default', ['ssr-error', 'ssr-capture']);
};
  1. 在 CI(Jenkins / GitLab Runner)里调用
GRAY_KEY=1234 grunt default

产物 dist/ssr/ 下的 HTML 已注入边界脚本,日志目录 logs/ 会被 SonarQube 插件采集,实现“构建即治理”。

拓展思考

  • 灰度闭环:把 grayKey 写入 HTML 注释,边缘节点(阿里云 CDN、腾讯云 ECDN) 可根据 Cookie 或 Header 做按号段灰度,回源时带上 ?grayKey=1234,实现“脚本灰度 + 内容灰度”双通道。
  • 性能权衡:错误边界脚本仅 1.2 kB(gzip),但仍有阻塞风险;可改用 requestIdleCallback 延迟上报,或把 report.js 改成 async 属性,首屏时间增加 < 5 ms
  • 与新版工具链共存:老项目用 Grunt,新项目用 Vite,可在统一容器镜像里把 grunt default 作为 prebuild 钩子,产物供 Vite 静态托管,实现“双引擎构建,同一套监控”。
  • 合规升级:等保 2.0 要求“用户敏感信息不出域”,可把 window.__SSR_ERROR__AES 对称加密,密钥走内网 KMS 接口动态获取,Grunt 任务里通过 grunt-secret 插件注入,满足金融、政府项目审计
  • 未来迁移:一旦团队决定废弃 Grunt,可把上述逻辑抽象成独立 npm 包grunt-ssr-error-boundary),向下兼容 Grunt,向上暴露 Webpack/Vite 插件入口,实现“老任务平滑退役,监控能力零丢失”。