使用 grunt-webpack 将语言包拆分为异步 chunk

解读

在国内前端工程化面试中,“老项目还在用 Grunt,如何借助 grunt-webpack 实现代码分割” 是高频追问。面试官想确认三点:

  1. 你是否理解 Grunt 与 Webpack 的职责边界(Grunt 负责任务编排,Webpack 负责模块打包);
  2. 你是否能把 动态 importWebpack 的 splitChunks 配置结合起来,产出按需加载的异步 chunk;
  3. 你是否能在 Gruntfile.js 里正确接入 grunt-webpack,并保证本地调试、生产构建两条流水线都能输出带 hash 的语言包文件,且路径能被运行时正确解析。

一句话:“让 Grunt 调 Webpack,让 Webpack 把语言包拆成独立 chunk,浏览器只在切换语言时才去拉对应文件。”

知识点

  • grunt-webpack:把 Webpack 编译阶段注册成 Grunt 的一个 task,支持多环境配置数组;
  • output.publicPath:决定异步 chunk 的拉取路径,国内项目常配 CDN 域名,必须以 / 结尾
  • optimization.splitChunks.cacheGroups[i18n]:通过 test / priority / name / chunks: 'async' 四元组把语言包单独成包;
  • 动态 import():返回 Promise,必须配合 @babel/plugin-syntax-dynamic-import 才能在老 Babel 环境编译通过;
  • webpack_public_path:运行时变量,在入口顶部赋值可解决“同一份构建产物部署到多个域名”的痛点;
  • Grunt 的并发模型:grunt-webpack 默认阻塞式,若后续还有 zip、上传 oss 等任务,需用 grunt-contrib-watch 的 spawn: false 模式防止进程被提前杀掉

答案

  1. 安装依赖(公司内网源可替换为 cnpm 或私有 registry)
npm i -D grunt-webpack webpack webpack-cli babel-loader @babel/core @babel/preset-env
npm i -D @babel/plugin-syntax-dynamic-import file-loader
  1. 在 Gruntfile.js 里声明 grunt-webpack 任务
module.exports = function(grunt) {
  const webpackConfig = {
    mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
    entry: './src/app.js',
    output: {
      path: __dirname + '/dist',
      filename: 'js/[name].[contenthash:8].js',
      chunkFilename: 'js/i18n/[name].[contenthash:8].js',
      publicPath: grunt.option('cdn') || '/assets/'   // 支持命令行覆盖
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env'],
              plugins: ['@babel/plugin-syntax-dynamic-import']
            }
          }
        }
      ]
    },
    optimization: {
      splitChunks: {
        cacheGroups: {
          i18n: {
            test: /[\\/]src[\\/]locales[\\/]/,
            name(module, chunks, cacheGroupKey) {
              const lang = module.rawRequest.match(/locales\/(\w+)\//)[1];
              return `i18n.${lang}`;
            },
            chunks: 'async',
            priority: 20,
            reuseExistingChunk: true
          }
        }
      }
    }
  };

  grunt.initConfig({
    webpack: {
      options: webpackConfig,
      dev: { mode: 'development', devtool: 'cheap-source-map' },
      prod: { mode: 'production' }
    }
  });

  grunt.loadNpmTasks('grunt-webpack');
  grunt.registerTask('default', ['webpack:dev']);
  grunt.registerTask('build', ['webpack:prod']);
};
  1. 业务代码中动态加载语言包
// src/app.js
if (window.__webpack_public_path__) {
  __webpack_public_path__ = window.cdnUrl;   // 运维注入的变量
}

function loadLocale(lang) {
  return import(
    /* webpackChunkName: "i18n.[request]" */
    `./locales/${lang}/messages.js`
  );
}

document.getElementById('lang-switch').addEventListener('change', e => {
  loadLocale(e.target.value).then(module => {
    i18n.setLocaleMessage(e.target.value, module.default);
  });
});
  1. 运行
# 本地开发
grunt
# 生产构建并上传 CDN
grunt build --cdn=https://cdn.example.com/assets/

构建结束后,dist/js/i18n 目录会产出 i18n.zh.1234abcd.js、i18n.en.1234abcd.js 等异步 chunk,首次加载不包含语言包,切换语言时才触发网络请求,达到拆分目的。

拓展思考

  • 降级方案:如果项目仍需兼容 IE11,动态 import 需加 webpack 的 require.ensure 垫片,并在 Gruntfile 里多配一个 entry: 'core-js/stable' 做 polyfill 隔离;
  • 缓存策略:国内 CDN 往往强制缓存 1 年,文件名必须带 contenthash;同时给 HTML 注入 <link rel="preload"> 可让浏览器提前解析 DNS,但切忌提前 preload 所有语言包,否则失去拆分意义
  • 灰度发布:大型项目会按用户维度灰度语言包,可在 splitChunks.name 函数里读取 process.env.GRAY_LANG,把灰度语言包拆成 i18n.zh.gray.js,通过 Nginx 的 cookie 判断走灰度 CDN 路径
  • 监控与回滚:在 chunk 加载失败时,监听 import().catch() 并上报 SLS / 阿里 Sentry,同时自动回退到默认语言,防止页面卡死
  • 未来迁移:Grunt 已停止迭代,面试官可能追问“如何无痛迁移到 Vite/Rollup”。可回答:“先让 grunt-webpack 输出 manifest.json,再用 rollup 消费相同 entry,保证 chunk name 与 publicPath 一致,实现灰度迁移。”