使用 grunt 将代码切割为懒加载片段

解读

在国内一线/二线前端面试中,面试官问“用 Grunt 做代码切割并支持懒加载”,并不是想听你背诵 Webpack 的 import(),而是考察三件事:

  1. 你是否真正理解 Grunt 的定位——它只是一个“任务编排器”,本身不具备模块解析能力;
  2. 你是否能在不依赖 Webpack/Rollup 等 bundler 的前提下,用 Grunt 插件生态把“切割→注入加载逻辑→产出映射表”整条链路跑通;
  3. 你是否能给出可落地的工程化方案,包括本地开发、测试、上线 CDN 路径、缓存策略、回退方案,而不是跑通 demo 就结束。

因此,回答时要先否定“Grunt 直接切割 ESModule”这一误区,再给出“Grunt + 手工拆分 + 动态加载器”的完整闭环,体现你对国内网络环境、HTTP/1.1 多域名并发、CDN 缓存命中率、Nginx 灰度发布的考虑。

知识点

  • Grunt 生命周期:initConfig → registerTask → 插件串行执行,所有插件只看文件系统,不懂依赖图。
  • 懒加载本质:把“初始不需要”的代码拆成独立物理文件,在运行时通过动态 script 标签或 JSONP 延迟拉取;关键点在于运行时如何知道该文件的 URL 与依赖关系
  • 国内常用切割策略
    1. 按路由页面拆分(pageA.js、pageB.js);
    2. 按体积阈值拆分(>200KB 再拆);
    3. 按 NPM 包拆分(vendor.js、common.js)。
  • Grunt 可用插件
    • grunt-webpack:如果允许把 Webpack 当黑盒,直接借其 import(),但面试官会追问“那还要 Grunt 干嘛?”;
    • grunt-browserify + factor-bundle:CommonJS 流派,可产出共用 bundle 与入口 bundle;
    • grunt-contrib-uglify:仅压缩,不切割;
    • grunt-hashmap:为切割后的文件生成带 hash 的映射表,解决 CDN 缓存;
    • grunt-assets-inline:把映射表注入到 HTML 的 window.ASSETS,供运行时加载器读取。
  • 运行时加载器手写要点
    • 使用 new Image().src = url 做预加载,探测错误上报 Sentry;
    • 支持 script.async = false 保证执行顺序;
    • 支持 localStorage 缓存机制,应对弱网/2G
    • 暴露 __lazyChunk('pageA') 返回 Promise,与 Vue/React 路由的 lazy() 对齐。
  • 国内 CDN 细节
    • 域名拆分 static1.example.com / static2.example.com,绕过浏览器单域 6 并发;
    • 开启 gzip_staticbrotli_static,减少 30% 体积;
    • 使用 Cache-Control: max-age=31536000, immutable,文件名必须带 hash;
    • 灰度时通过 Nginx map $cookie_gray $upstream按用户灰度,避免全量推包。

答案

“Grunt 本身不会分析依赖图,所以我们要把切割动作前置到开发规范层,再用 Grunt 做自动化。我的做法是三步:

第一步,手工按页面拆分入口。目录规范强制要求: src/pages/pageA/main.js src/pages/pageB/main.js common 工具放在 src/lib,体积小于 200KB 允许合并,超过就再拆。

第二步,Grunt 任务链

  1. grunt-browserify 对每个入口执行打包,配合 factor-bundle 插件把公共模块抽成 common.bundle.js
  2. grunt-uglify 分别压缩,输出到 dist/pages/pageA.[hash:8].js
  3. grunt-hashmap 生成 assets.json: { "pageA": "//static1.cdn.com/pages/pageA.3f4e2a8b.js", "pageB": "//static2.cdn.com/pages/pageB.9c1d4e7f.js", "common": "//static1.cdn.com/common.5a6b3c4d.js" }
  4. grunt-include-replaceassets.json 内联到 HTML 的 <script>window.__ASSETS__ = {...}</script>,保证首屏就能读到映射表。

第三步,运行时加载器(手写 80 行): window.__lazyChunk = function(chunkName) { return new Promise(function(resolve, reject) { var url = window.ASSETS[chunkName]; if (!url) return reject(new Error('chunk ' + chunkName + ' not found')); var script = document.createElement('script'); script.charset = 'utf-8'; script.async = true; script.onerror = reject; script.onload = function() { resolve(__webpackChunk); }; // 约定全局变量 script.src = url; document.head.appendChild(script); }); }; 在 Vue-Router 里直接 component: () => __lazyChunk('pageA').then(() => _import('./pages/pageA.vue')),实现路由级懒加载。

上线后,通过 Jenkins 执行 grunt build,把 dist 目录同步到阿里云 OSS,CDN 回源 OSS。灰度发布时,只替换 assets.json 里 20% 用户的 hash,确保回滚只需切换 JSON。”

拓展思考

  1. 如果团队后续要迁移到 Webpack5,如何让 Grunt 与 Webpack5 共存?我的方案是把“切割”交给 Webpack experiments.lazyCompilation,Grunt 只做雪碧图、SVG 压缩、离线包签名等偏运维任务,通过 grunt-webpackwatch: false 解耦,逐步让 Grunt 退位为“运维任务 runner”。
  2. 当业务出现千人千面的模块组合时,hash 数量爆炸,assets.json 体积超过 50KB,可改用服务端渲染时注入
    • Node 中间层根据用户画像拼接 pageA+recommend+pay 三个 chunk,只返回对应的三条 URL,前端加载器再串行拉取;
    • 同时把 assets.json 拆成 assets.<userType>.json,利用 CDN 的边缘规则 /_assets/(\\w+).json 回源到 Node,降低首屏 HTML 体积。
  3. 为了小程序跨端复用同一套切割逻辑,可再把加载器封装成 wx.$lazyChunk,内部使用 wx.loadSubpackage,实现 Grunt 切割产物直接复用到微信小程序分包,一次构建多端投放,这在阿里系电商业务里已落地,性能数据提升 18% 首屏渲染。