Vite已经成为前端工程化的主流选择,但真实业务中很少有项目能一步到位地完全脱离历史包袱。大量团队手里还攥着一堆基于jQuery开发的老插件——日期选择器、树形表格、图表组件等等,它们直接读取全局的jQuery或$变量,内部还是CommonJS或直接挂window的写法。把这些库迁到Vite工程里,最常见的现象就是构建不报错、运行时白屏,控制台抛出$ is not defined。这篇文章就来系统梳理这类库在Vite下的打包与优化思路。

一、先搞清楚Vite的预构建机制为什么会出问题
Vite在开发阶段会调用esbuild对node_modules里的依赖做预构建(pre-bundling),把CommonJS或UMD格式的包转换成ESM,并缓存到node_modules/.vite目录下。这套机制对现代库非常友好,但jQuery类老库有几个特殊点:第一,很多jQuery插件不会显式声明对jQuery的依赖,而是默认全局环境里已经有$;第二,UMD格式在esbuild转换后,模块作用域被隔离,插件再也拿不到window上的jQuery;第三,插件的引入顺序问题在ESM静态分析下会暴露得更彻底。
理解了这一点就明白,解决思路本质上只有两条:要么让jQuery真正成为全局变量,要么把插件改造成显式依赖jQuery的模块。前者改动小、见效快,适合快速迁移;后者更符合长期演进方向。下面分别展开。
二、方案一:安装jQuery并自动注册为全局变量
这是最省事的做法。先安装jQuery,然后在入口文件显式挂到window上:
import $ from 'jquery' window.$ = window.jQuery = $ // 之后再引入依赖jQuery的插件 import 'jquery-datatables' import './legacy/datepicker.js'
注意引入顺序非常关键,插件必须在jQuery挂载完成之后再加载。ESM的import是静态提升的,所以更稳妥的写法是把插件引入放到单独一个文件里,或者用动态import控制时序:
// main.js
import $ from 'jquery'
window.$ = window.jQuery = $
import('./legacy-plugins') // 动态引入,保证在全局挂载之后执行如果老插件是直接在HTML里用script标签引入的,还要确保Vite的index.html中script标签写在模块入口之后,或者干脆用Vite的transformIndexHtml钩子注入。这种方案的缺点是所有插件代码都会进主bundle,体积优化空间有限,后面第四节会讲怎么压缩。
三、方案二:通过Vite配置解决依赖解析与兼容问题
有些老插件发布的是非ESM格式,Vite会报Failed to resolve import或者does not provide an export named 'default'。这时候需要在vite.config.js里做几件事:配置optimizeDeps.include强制预构建、配置resolve.alias处理路径别名、必要时关闭严格ESM转换。
// vite.config.js
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
export default defineConfig({
resolve: {
alias: {
// 老代码里可能写的是绝对路径或旧别名
'jquery': 'jquery/dist/jquery.min.js',
'@legacy': fileURLToPath(new URL('./src/legacy', import.meta.url))
}
},
optimizeDeps: {
// 强制esbuild预构建这些CommonJS包
include: ['jquery', 'jquery-datatables'],
exclude: []
},
build: {
commonjsOptions: {
// 处理require动态调用
transformMixedEsModules: true
}
}
})还有一个高频坑:某些插件执行了require('jquery'),但包内部没有正确导出。可以用Vite插件的transform钩子在代码层面补丁。例如写一个简单插件,把插件源码里的require('jquery')替换成从全局取:
function patchJqueryPlugin() {
return {
name: 'patch-jquery-plugin',
transform(code, id) {
if (id.includes('some-old-plugin')) {
return code.replace(
/require\(['"]jquery['"]\)/g,
'window.jQuery'
)
}
return null
}
}
}这种正则替换看起来暴力,但对于无法修改源码的闭源老库非常实用。需要注意的是要精确匹配文件路径,避免误伤其他代码。
四、打包体积优化:外部化与按需加载
jQuery加上一堆插件很容易让bundle膨胀几百KB,首屏性能压力不小。优化手段主要有三个层次。
第一层是外部化(external)。如果项目允许CDN,可以把jQuery从构建产物中剔除,直接在index.html里引入CDN版本,然后配置rollupOptions.external:
// vite.config.js
export default defineConfig({
rollupOptions: {
external: ['jquery'],
output: {
globals: {
jquery: 'jQuery' // 告诉Rollup用全局jQuery替代import
}
}
}
})第二层是按需加载。用动态import把只在特定页面用到的插件拆成异步chunk,配合路由懒加载,让首屏只加载核心代码:
async function initChartPage() {
const [{ default: $ }, chartPlugin] = await Promise.all([
import('jquery'),
import('./legacy/chart-plugin.js')
])
window.$ = window.jQuery = $
$('#chart').highcharts()
}第三层是压缩层面的处理。Vite生产构建默认用esbuild压缩,对老代码效果不错,但jQuery本身已经很难再tree-shake(它的插件机制决定了大部分代码都有副作用)。可以在build配置里调整chunk策略,把vendor单独拆分,利用浏览器缓存:
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['jquery'],
legacyPlugins: [
'./src/legacy/datepicker.js',
'./src/legacy/table.js'
]
}
}
}
}这样jQuery只会在版本升级时才改变hash,用户浏览器可以长期缓存这份文件,间接降低加载成本。
五、几个典型报错的排查清单
最后整理一份实战排查清单,迁移时按顺序过一遍能解决大部分问题。报$ is not defined,检查全局挂载顺序,确保插件加载晚于jQuery挂载;报does not provide an export named,把包名加入optimizeDeps.include强制预构建;老插件内部用了document.write这类API,开发模式下会被Vite拦截,需要在插件初始化前处理;生产环境图标字体路径错误,通常是插件内部的CSS用了相对路径引用字体文件,需要配置assetsInclude或手动调整路径。
还有一个容易被忽略的点:Vite开发服务器的依赖缓存。改了vite.config.js里的optimizeDeps配置后,一定要删掉node_modules/.vite目录再重启,否则旧的预构建缓存不会更新,会让你误以为配置没生效。这个缓存目录本身也可以通过cacheDir选项改到别处,方便CI环境下做缓存管理。
总体来说,jQuery类老库接入Vite并不复杂,核心就是理解预构建对模块作用域的影响,然后在全球变量注册和显式模块依赖之间选一条适合当前项目阶段的路。短期内用全局挂载快速过渡,长期在迭代中逐步把插件改造成ESM模块并按需加载,才是兼顾交付速度和技术债务的稳妥做法。