使用 grunt 将代码切割为懒加载片段
解读
在国内一线/二线前端面试中,面试官问“用 Grunt 做代码切割并支持懒加载”,并不是想听你背诵 Webpack 的 import(),而是考察三件事:
- 你是否真正理解 Grunt 的定位——它只是一个“任务编排器”,本身不具备模块解析能力;
- 你是否能在不依赖 Webpack/Rollup 等 bundler 的前提下,用 Grunt 插件生态把“切割→注入加载逻辑→产出映射表”整条链路跑通;
- 你是否能给出可落地的工程化方案,包括本地开发、测试、上线 CDN 路径、缓存策略、回退方案,而不是跑通 demo 就结束。
因此,回答时要先否定“Grunt 直接切割 ESModule”这一误区,再给出“Grunt + 手工拆分 + 动态加载器”的完整闭环,体现你对国内网络环境、HTTP/1.1 多域名并发、CDN 缓存命中率、Nginx 灰度发布的考虑。
知识点
- Grunt 生命周期:initConfig → registerTask → 插件串行执行,所有插件只看文件系统,不懂依赖图。
- 懒加载本质:把“初始不需要”的代码拆成独立物理文件,在运行时通过动态 script 标签或 JSONP 延迟拉取;关键点在于运行时如何知道该文件的 URL 与依赖关系。
- 国内常用切割策略:
- 按路由页面拆分(pageA.js、pageB.js);
- 按体积阈值拆分(>200KB 再拆);
- 按 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_static与brotli_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 任务链:
grunt-browserify对每个入口执行打包,配合factor-bundle插件把公共模块抽成common.bundle.js;grunt-uglify分别压缩,输出到dist/pages/pageA.[hash:8].js;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" }grunt-include-replace把assets.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。”
拓展思考
- 如果团队后续要迁移到 Webpack5,如何让 Grunt 与 Webpack5 共存?我的方案是把“切割”交给 Webpack
experiments.lazyCompilation,Grunt 只做雪碧图、SVG 压缩、离线包签名等偏运维任务,通过grunt-webpack的watch: false解耦,逐步让 Grunt 退位为“运维任务 runner”。 - 当业务出现千人千面的模块组合时,hash 数量爆炸,assets.json 体积超过 50KB,可改用服务端渲染时注入:
- Node 中间层根据用户画像拼接
pageA+recommend+pay三个 chunk,只返回对应的三条 URL,前端加载器再串行拉取; - 同时把 assets.json 拆成
assets.<userType>.json,利用 CDN 的边缘规则/_assets/(\\w+).json回源到 Node,降低首屏 HTML 体积。
- Node 中间层根据用户画像拼接
- 为了小程序跨端复用同一套切割逻辑,可再把加载器封装成
wx.$lazyChunk,内部使用wx.loadSubpackage,实现 Grunt 切割产物直接复用到微信小程序分包,一次构建多端投放,这在阿里系电商业务里已落地,性能数据提升 18% 首屏渲染。