使用 grunt-markdown 生成 HTML 并注入 Prism.js

解读

在国内前端工程化面试中,**“用 Grunt 把 Markdown 转成 HTML 并自动注入 Prism.js 代码高亮”常被用来考察候选人对“老项目维护 + 构建链整合”**的能力。
面试官真正想听的不是“能跑起来”,而是:

  1. 你能否在不破坏原有 Gruntfile 结构的前提下,最小侵入地插入一个新任务;
  2. 你能否精准控制生成 HTML 的 head 与 body 片段,让 Prism 的 CSS/JS 只在含有 <pre><code> 的页面出现;
  3. 你能否解决国内网络问题(Prism CDN 不稳定)并把依赖锁版本、本地化
  4. 你能否保证开发体验,即文件监听 + Livereload 仍能实时预览高亮效果;
  5. 你能否输出可交付物,让测试、运维同学一键 grunt dist 拿到带高亮的静态文档。

知识点

  • grunt-markdowntemplate/templateContext 选项,支持用 Lodash 模板复写整页骨架
  • grunt-contrib-copy + grunt-contrib-concat,把 Prism 的 CSS/JS 从 node_modules 拷到 dist 并合并版本号,避免国内 CDN 抖动
  • grunt-contrib-uglifycssmin,对 Prism 进行二次压缩,减少 15% 体积
  • grunt-contrib-watchspawn: false 模式,复用进程,让 Markdown 修改后 200 ms 内刷新浏览器
  • grunt-banner 在生成的 HTML 里追加公司版权注释,满足国内甲方合规审计
  • Gruntfile.js 的 task 依赖图,必须让 markdown:docsinject:prismuseminhtmlmin 串行,防止 MD5 文件名错乱
  • Prism 插件体系line-numberscopy-to-clipboardtoolbar,面试时要能说出如何只注册需要的语言包,避免把 2 MB 全量打包进去
  • Node 版本锁.nvmrc 写死 14.21.3,对齐淘宝镜像源,否则 node-sassprismjs 在 M1 机器上会编译失败

答案

  1. 安装依赖(淘宝源加速
npm i -D grunt-markdown grunt-contrib-copy grunt-contrib-concat grunt-contrib-cssmin grunt-contrib-uglify grunt-contrib-watch grunt-banner
npm i -D prismjs@1.29.0  # 锁版本
  1. 目录约定(国内团队惯用
docs-src/*.md
docs-src/templates/page.html   // Lodash 模板
static/prism/                  // 构建后存放本地化 Prism 资源
dist/docs/                     // 最终交付物
  1. Gruntfile 核心片段(可直接粘贴进老项目
module.exports = function(grunt) {
  grunt.initConfig({
    // 1. 把 Markdown 转成 HTML
    markdown: {
      options: {
        template: 'docs-src/templates/page.html',
        templateContext: {
          prismCss: '<link rel="stylesheet" href="../static/prism/prism.min.css">',
          prismJs:  '<script src="../static/prism/prism.min.js"></script>'
        },
        markdownOptions: {
          gfm: true,
          highlight: function(code, lang) {
            // 关键:让 Prism 在 Node 端就高亮,减少浏览器端闪烁
            const prism = require('prismjs');
            const loadLanguages = require('prismjs/components/');
            loadLanguages([lang || 'markup']);
            return prism.highlight(code, prism.languages[lang], lang);
          }
        }
      },
      docs: {
        files: [{
          expand: true,
          cwd: 'docs-src',
          src: '*.md',
          dest: 'dist/docs',
          ext: '.html'
        }]
      }
    },

    // 2. 本地化 Prism 资源
    copy: {
      prism: {
        files: [
          {src: 'node_modules/prismjs/themes/prism-tomorrow.css',
           dest: 'static/prism/prism.css'},
          {src: 'node_modules/prismjs/prism.js',
           dest: 'static/prism/prism.js'},
          {expand: true, cwd: 'node_modules/prismjs/components',
           src: ['prism-javascript.min.js', 'prism-css.min.js', 'prism-markup.min.js'],
           dest: 'static/prism/components'}
        ]
      }
    },

    concat: {
      prismCss: {
        src: ['static/prism/prism.css'],
        dest: 'static/prism/prism.min.css'
      },
      prismJs: {
        src: ['static/prism/prism.js', 'static/prism/components/*.js'],
        dest: 'static/prism/prism.min.js'
      }
    },

    cssmin: {
      prism: { src: 'static/prism/prism.min.css', dest: 'static/prism/prism.min.css' }
    },

    uglify: {
      prism: { src: 'static/prism/prism.min.js', dest: 'static/prism/prism.min.js' }
    },

    // 3. 注入公司版权
    usebanner: {
      dist: {
        options: { banner: '<!-- Copyright © <%= grunt.template.today("yyyy") %> 公司名,All Rights Reserved. -->' },
        files: { src: 'dist/docs/*.html' }
      }
    },

    // 4. 监听
    watch: {
      md: {
        files: ['docs-src/**/*.md', 'docs-src/templates/*.html'],
        tasks: ['markdown:docs', 'usebanner'],
        options: { livereload: 1337, spawn: false }
      }
    }
  });

  grunt.loadNpmTasks('grunt-markdown');
  grunt.loadNpmTasks('grunt-contrib-copy');
  grunt.loadNpmTasks('grunt-contrib-concat');
  grunt.loadNpmTasks('grunt-contrib-cssmin');
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-banner');
  grunt.loadNpmTasks('grunt-contrib-watch');

  grunt.registerTask('prism', ['copy:prism', 'concat', 'cssmin', 'uglify']);
  grunt.registerTask('docs', ['prism', 'markdown:docs', 'usebanner']);
  grunt.registerTask('default', ['docs', 'watch']);
};
  1. 模板文件 docs-src/templates/page.html只展示关键片段
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title><%= title %></title>
  <%= prismCss %>
</head>
<body>
  <div class="markdown-body"><%= content %></div>
  <%= prismJs %>
</body>
</html>
  1. 一键交付
grunt docs      // 生成带高亮的静态文档
grunt default   // 开发模式,支持 Livereload

拓展思考

  • 如果文档站点后续要迁移到 Vite 或 Webpack 5,如何复用已有的 Prism 语言包列表避免双份构建
    思路:把 static/prism/components/*.js 改成 ES Module,用 import.meta.glob 动态加载,Grunt 与 Vite 共用同一份语言包 JSON 索引,实现无痛迁移

  • 当 Markdown 里出现自定义容器(::: warning)时,Grunt 生态没有现成插件,可手写一个 30 行的 Grunt 插件

    1. markdown-it-container 在 Node 侧解析;
    2. 把解析结果缓存到 grunt.config('__mdTokens')
    3. templateContext 里作为变量注入,实现警告框高亮
  • 对于国内移动端场景,Prism 的 line-numbers 插件在 UC 浏览器下错位,可关闭该插件并改用 postcss-write-svg 生成1 px 竖线背景图兼容老旧内核