使用 grunt 注入 aria-live 区域并模拟播报

解读

国内前端面试中,可访问性(a11y)自动化构建的结合越来越被重视。
题目表面是“用 Grunt 干一件小事”,实则考察三点:

  1. 你是否把 Grunt 仅当“压缩打包工具”,还是能理解其“任务编排 + 文件变换”的本质;
  2. 能否在不改动业务源码的前提下,通过构建层一次性为遗留项目注入无障碍播报能力
  3. 对 aria-live 的语义级别、优先级、DOM 位置是否真懂,而不是“加个属性就行”。

因此,回答要体现:任务拆分 → 插件选型 → 模板引擎 → 轻量级运行时 → 可测试

知识点

  1. aria-live="polite/assertive"区域必须首次渲染时就存在于 DOM,动态插入会被读屏软件忽略。
  2. Grunt 的文件对象格式(files array format)动态映射(expand:true),保证多页面批量注入。
  3. grunt-contrib-copy + grunt-string-replace 组合:先复制,再对指定占位符做安全替换,避免破坏原有 DOM 结构。
  4. grunt-templategrunt-includes 可预编译一段播报模板,把变化点抽离成变量,方便后期统一修改。
  5. 播报逻辑需极轻量(<2 kB),用** MutationObserver **监听 data-announce 属性即可,不依赖框架,避免与 React/Vue 冲突。
  6. 国内政企项目常要求IE11仍可用,因此运行时脚本必须ES5输出;用 grunt-babel 自动降级。
  7. 测试阶段用 grunt-contrib-connect + grunt-contrib-watch 启动静态服务,配合 NVDATalkBack 真机验证,而不是只看 Lighthouse 分数。

答案

  1. 目录约定
    src/
    ├─ pages/ // 原始静态页
    └─ inject/
    └─ announce.html // 要注入的 aria-live 片段

  2. 安装依赖
    npm i -D grunt grunt-contrib-copy grunt-string-replace grunt-contrib-watch grunt-contrib-connect

  3. Gruntfile.js 核心配置

module.exports = function(grunt){
  grunt.initConfig({
    copy: {
      inject: {
        expand: true,
        cwd: 'src/pages/',
        src: '*.html',
        dest: 'dist/'
      }
    },
    'string-replace': {
      injectLive: {
        files: [{
          expand: true,
          cwd: 'dist/',
          src: '*.html',
          dest: 'dist/'
        }],
        options: {
          replacements: [{
            pattern: /<!--\s*INJECT_ANNOUNCE\s*-->/,
            replacement: function(){
              return [
                '<div id="a11y-announcer" aria-live="polite" aria-atomic="true" style="position:absolute;left:-10000px;"></div>',
                '<script>',
                '(function(){',
                '  var box=document.getElementById("a11y-announcer");',
                '  window.announce=function(txt){ box.innerHTML=""; setTimeout(function(){ box.textContent=txt; },100); };',
                '}());',
                '</script>'
              ].join('');
            }
          }]
        }
      }
    },
    connect: { server: { options: { base: 'dist', port: 9000, livereload: true } } },
    watch: {
      html: {
        files: ['src/pages/*.html'],
        tasks: ['copy','string-replace'],
        options: { livereload: true }
      }
    }
  });

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

  grunt.registerTask('default', ['copy','string-replace','connect','watch']);
};
  1. 业务侧调用
    在任何 JS 里执行 window.announce('保存成功') 即可触发读屏播报,无需额外框架

  2. 验证
    运行 grunt → 打开 http://localhost:9000 → 用 NVDA 中文语音 测试,确认“保存成功”被朗读,且不干扰正常 Tab 顺序

拓展思考

  1. 如果页面是服务端模板(JSP/Thymeleaf),可把 grunt-string-replace 换成 grunt-replace,支持**@@占位符**,让后端开发也能感知构建层注入,避免“前后端各维护一份”。
  2. 当项目已迁移到 Webpack,仍可用 grunt-webpack 把上述任务作为子进程保留,形成“双轨构建”,让老页面继续享受 Grunt 的稳定性,新页面走 Webpack,平滑过渡
  3. 对于多语言场景,可把播报文案抽成 i18n JSON,通过 grunt-file-creator 在构建时动态生成 window.ANNOUNCE_MSG['zh-CN'],实现文案统一收口,方便合规审计
  4. 国内金融、政务项目要求无障碍等级三级,需记录播报日志;可在 window.announce 里同时 fetch('/log/a11y', {method:'POST', body:txt}),构建层通过 grunt-contrib-uglify 自动混淆加密该片段,兼顾可维护 + 安全