1. 这个报错到底在说什么
控制台里冷不丁蹦出一行红字Uncaught ReferenceError: $ is not defined,然后整页的交互全废了——下拉菜单不弹、轮播图不动、弹窗点了没反应。这个场景做前端的人多少都碰过,尤其是接手别人写好的页面、或者把一个老项目从本地搬到服务器上跑的时候。它不是什么高深的错误,但它极其能拖时间,因为报错信息只告诉你“$这个变量找不到”,完全不告诉你“为什么找不到”。这篇东西就是把我这些年处理jQuery 报错、特别是Uncaught ReferenceError: $ is not defined这一类问题的排查路径完整摊开,从根因分类、加载时序、路径解析,一直到模块化环境下的特殊形态,配上可以直接照抄的代码和一张速查表。不管你是刚接触 jQuery 的新手,还是被迫维护历史项目的资深开发,看完至少能在十分钟内定位到问题出在哪一层,而不是靠到处乱加script标签碰运气。
1.1 $ 只是一个普通变量名,别把它当魔法
很多人对$有一种错觉,觉得它是浏览器自带的东西,或者是一种语法。其实它什么都不是,就是一个合法的标识符,和a、foo地位完全一样。jQuery 在初始化的时候做了一件事:把自己挂到全局对象上,同时把$这个名字也指向自己,本质上就是执行了类似window.jQuery = window.$ = jQuery的赋值。所以当你写$('#box')的时候,浏览器在作用域链里一层层往上找$这个名字,最后找到全局对象上,发现那儿空空如也,于是抛出ReferenceError。理解了这一点,排查方向就一下子清晰了:要么是 jQuery 根本没加载进来,要么是加载进来了但没挂到$上,要么是挂上了但当前这段代码的作用域里$被别的东西挡住了。这三种情况对应完全不同的修法,混在一起瞎试只会浪费时间。
1.2 ReferenceError 和 TypeError 的区别,直接决定你先查什么
这两种错误经常被人混为一谈,但它们指向的问题层级完全不同。ReferenceError: $ is not defined的意思是“这个名字我压根没找到”,问题出在变量是否存在这一层;而TypeError: $(...).somePlugin is not a function的意思是“$找到了,但它身上没有这个方法”,问题出在插件加载或者 jQuery 实例不一致这一层。我见过不少人看到$ is not defined就跑去检查插件有没有引入,方向就偏了。还有一种更绕的情况:页面上有两个版本的 jQuery 被先后加载,插件注册到了后加载的那个实例上,而业务代码用的是前一个实例的$,表现就是“我明明引入了插件啊,怎么还报错”。这时候报错信息甚至不是$ is not defined,而是插件方法不存在。所以拿到报错的第一步,永远是先在控制台敲两行:
typeof $ typeof jQuery如果两个都是"undefined",那 jQuery 确实没到位,往下看加载和路径的问题。如果jQuery是"function"而$是"undefined",那就是命名被让出去了,直接跳到noConflict那一节。如果两个都在,报错却还在,说明报错发生在 jQuery 加载之前的那段代码里,属于时序问题,不是加载失败。
1.3 先把根因分成四类,别一上来就改代码
我把这类报错归纳成四类,实际项目里九成以上都能对上号。第一类是加载顺序:业务脚本跑在了 jQuery 之前,代码执行时 jQuery 还没就位。第二类是资源没到位:路径写错、文件 404、SRI 校验失败、服务器返回了 HTML 错误页而不是 JS 文件,浏览器拿到的东西根本不是可执行的脚本。第三类是命名被占用或被让出:noConflict之后$被归还给了别的库,或者代码里自己声明了同名变量把全局的$遮住了。第四类是工程化环境的作用域隔离:模块打包之后$不再挂在全局,代码里没import就直接用,自然找不到。这四类的排查手法差异很大,但有个共同点——都能在浏览器开发者工具里用几分钟确认。下面按出现频率从高到低逐类拆。
2. 加载顺序:十次里有六次栽在这
加载顺序问题之所以高发,是因为它有一个极具迷惑性的特征:本地开发时正常,上线或者换台机器就报错。原因往往在于本地文件已经在缓存里、或者本地用了某个自动注入的模板,而线上环境是干干净净的一次加载。还有一种典型场景是页面主体是服务端渲染的,头部引入了一堆东西,中间某个内联脚本里写了$(document).ready(...),看起来“包在 ready 里就安全了”,但如果$这个符号本身在解析阶段就找不到,ready根本没机会执行。更微妙的是,即使 jQuery 后来加载成功了,那段已经抛错的脚本也不会自动重跑一遍,页面依然是死的。
2.1 浏览器执行脚本的真实顺序,比你想的要严格
普通<script>标签(不带async、defer、type="module")的行为是:浏览器解析到它的时候,暂停 DOM 解析,去下载并立即执行,执行完了再继续往下解析。这就意味着文档里的标签顺序就是执行顺序,非常确定。所以如果你把 jQuery 的<script>放在业务脚本后面,业务脚本执行时$必然不存在,这个没什么可侥幸的。反过来,有些人为了“保险”,把 jQuery 放在<head>里,业务脚本放在</body>前,这个组合是安全的,但会导致首屏渲染被阻塞——jQuery 没下载完,页面就不往下解析。我个人的习惯是两者都放到</body>前,jQuery 在前,业务在后,既不阻塞首屏,顺序也清楚。如果你非要把 jQuery 放头部,那就给它加上defer,同时业务脚本也加defer,两者都会等到 DOM 解析完成后按文档顺序执行,顺序关系依然保持。
2.2 defer 和 async 怎么选,选错了就是随机报错
async是这三个属性里最容易惹事的。它的语义是:下载完立刻执行,不管 DOM 解析到哪,也不管其他async脚本。也就是说,两个都标了async的脚本,谁先下载完谁先跑,顺序完全不可控。我遇到过不止一次这样的情况:jQuery 和某个插件都用了async,本地测十次有九次是 jQuery 先到,上线之后偶发报错,因为偶尔插件先下载完了。这种“偶发”问题最难查,因为它不可稳定复现。结论很直接:业务脚本依赖 jQuery 的时候,绝对不要给这两个都用async。defer则是按文档顺序执行的,两个都加defer是安全组合,而且不阻塞解析。有一个细节要注意,defer只对外部脚本有效,写在<script>标签上的内联代码加defer是无效的,内联脚本永远在解析到它的那一刻同步执行。
2.3 内联脚本是最容易被忽略的时序炸弹
页面模板里经常能看到这种写法:某个区块里直接内联一段$(function(){ ... }),而 jQuery 是在页面底部统一引入的。这段内联代码所在的<script>在解析到它的那一刻就执行了,此时 jQuery 还没下载,$不存在,直接抛错。修法有两种:一是把这段内联逻辑挪到 jQuery 之后,或者抽到一个外部文件里按顺序引入;二是把 jQuery 的引入提到<head>并加defer——但注意,加了defer的外部脚本会在 DOM 解析完成后才执行,而内联脚本是解析时就执行的,所以内联脚本反而更早跑,还是不行。真正稳妥的做法只有一条:内联脚本里不要直接依赖尚未加载的库。如果确实需要在模板任意位置写逻辑,可以把它包进一个自定义事件,等 jQuery 就位后再统一触发:
// 页面任意位置先登记 document.addEventListener('jquery-ready', function () { $('#dialog').show(); }); // jQuery 加载完成后统一派发 (function () { var fire = function () { document.dispatchEvent(new Event('jquery-ready')); }; if (window.jQuery) { fire(); } else { document.addEventListener('DOMContentLoaded', fire); } })();这套写法看起来绕,但在那种“模板分散、不好统一调整引入顺序”的老项目里非常实用,我靠它救过好几个不敢动结构的遗留页面。
3. 路径与资源:404 之后才暴露的连锁反应
顺序对了,还是报$ is not defined,那就该怀疑文件到底有没有真的加载进来。这里有一个非常隐蔽的点:浏览器不会因为某个脚本 404 就停止后续脚本执行,它只是跳过这个文件继续往下走。所以最终你看到的报错是业务代码里$未定义,而真正的起因在更早的一行 404 上,视觉上完全对不上号。
3.1 相对路径的基准点,不是 JS 文件所在目录
这是新手最容易搞反的地方。<script src="./lib/jquery.min.js">里的./是相对于当前 HTML 文档的 URL,不是相对于某个 JS 文件的位置。如果页面是通过服务端路由渲染的,URL 可能是/admin/user/list,那./lib/jquery.min.js会被解析成/admin/user/lib/jquery.min.js,而你真实的文件在根目录/lib/jquery.min.js,于是 404。带多级路由的单页应用里,这个问题尤其常见:首页能跑,进到二级路由就报错。修法要么改成以斜杠开头的绝对路径/lib/jquery.min.js,要么在<head>里加一个<base href="/">统一基准。还有一种更阴的情况——URL 结尾有没有斜杠会导致解析结果差一级。/admin/user会把相对路径解析成/admin/lib/...,而/admin/user/会解析成/admin/user/lib/...。所以我在项目里定了一条规矩:静态资源一律用绝对路径,前端路由一律用 history 模式时显式配置 base,能从源头掐掉一大半这类问题。
3.2 三步确认资源是不是真的到位了
排查资源问题,我从来不靠猜,固定走这三步。第一步,打开 Network 面板,刷新页面,在过滤框里输入jquery,看这条请求的状态码。200说明拿到了,404是路径错,304是走缓存(正常),200但大小只有几 KB 而且内容看起来像 HTML,说明服务器配置有问题,把 404 页面当成 200 返回了——这个坑在部分 Nginx 的try_files配置或者前端路由的 fallback 里很常见。第二步,点开这条请求看 Response 标签,确认返回的是 JS 源码而不是一坨 HTML。第三步,切到 Console,敲typeof jQuery,返回"function"才算真正可用。这三步做完,资源层面就没有模糊地带了。顺手再敲一句jQuery.fn.jquery,能直接拿到版本号,如果是空字符串或者一个非常老的版本号,那说明页面被某个遗留的组件偷偷引入了旧版 jQuery,这往往是后续一系列诡异问题的源头。
3.3 SRI 校验和 MIME 类型,这两个隐藏杀手
这两个问题都不会在 Network 面板里表现得特别刺眼,但都能让脚本“看起来加载成功了却完全没执行”。先说 SRI——也就是<script>标签上的integrity属性。这个机制是浏览器拿到文件后做一次哈希比对,只要文件内容和声明的哈希不一致,就拒绝执行整个脚本,控制台会报一条Failed to find a valid digest in the 'integrity' attribute。问题在于,很多人在只看错误列表的时候,第一眼看到的是下面业务代码抛出的$ is not defined,把上面那条更关键的信息给漏了。哈希不一致的原因可能是资源提供方更新了文件、可能是中间有代理改写了内容、也可能是你自己替换了本地文件却忘了更新哈希。修法很简单:要么删掉integrity,要么重新计算。再说 MIME 类型,如果服务器给.js文件返回的Content-Type是text/html或者text/plain,现代浏览器会直接拒绝执行,报Refused to execute script ... MIME type ('text/html') is not executable。这通常是静态资源服务的配置缺失,或者路径写错落到了默认页面上。这两条都属于“看了一眼就知道,不看就查半天”的类型,所以我排查时习惯性地先把 Console 里所有红字从上往下读完,而不是只盯着最后一条。
4. $ 被让出、被遮蔽、被隔离
确认 jQuery 加载成功了,typeof jQuery === "function",但typeof $ === "undefined",那就是命名层面的问题了。这类问题在混合技术栈的项目里特别多,尤其是页面同时用了两三个老牌库的时候。
4.1 noConflict 的机制与两种恢复写法
jQuery.noConflict()这个方法的存在意义,是让出$这个短名字,给其他也想占用它的库使用。调用之后,window.$会被恢复成 jQuery 加载之前的值(通常是undefined),而 jQuery 本身依然可以通过jQuery这个名字访问。所以如果你在代码里看到过$.noConflict()或者某个插件内部悄悄调用了它,那业务代码里的$就自然会报未定义。处理方式有两种。第一种是局部传参,把 jQuery 作为参数传进立即执行函数,这样函数内部就能安全地用$:
(function ($) { $(function () { $('#menu').on('click', function () { /* ... */ }); }); })(jQuery);第二种是重新拿回全局名字,适合整个项目都依赖$且不打算和其他库共存的情况:
var $j = jQuery.noConflict(); $j('#box').hide(); // 用新别名 // 或者把所有用到 $ 的脚本都放在 jQuery 加载之后,再执行这一句 window.$ = jQuery;第二种写法有个前提:必须在 jQuery 加载完成之后执行,而且要在所有业务脚本之前。我一般把它放在单独的一个bridge.js里,紧跟 jQuery 之后引入,整个项目的其他脚本就不用改了。这种做法在维护老项目时效率极高,但有代价——如果页面上还引着其他依赖$的库(比如某些早期的动画库、工具库),重新占用$会让它们出问题。所以动手之前先在控制台跑一下window.$看看原本是谁,别抢了别人的名字。
4.2 变量遮蔽:自己写的代码把 $ 挡住了
这种情况相对少见但确实存在。比如在某个函数里写了var $ = document.querySelectorAll.bind(document);,或者更常见的,从别处复制来一段代码,里面有个局部变量叫$。由于 JavaScript 的作用域链查找是从内往外的,内层的$会优先命中,导致这个函数内部的行为完全不是 jQuery。还有一种是在严格模式下配合let/const的暂时性死区——在let $声明之前使用$会直接抛ReferenceError,报错信息同样是$ is not defined,但原因完全不同。这类问题的定位技巧是:在报错的那一行打个断点,或者在控制台里用debugger停下来,然后在 Scope 面板里一层层看作用域链上有没有$,一眼就能看出是被谁挡住的。
4.3 模块化环境里,$ 不再自动属于 window
这是近几年最常见的一类。项目用上了构建工具,代码被拆成一个个模块,import $ from 'jquery'这种写法很自然。但问题出在两种混合形态上:一种是在index.html里用<script>标签引入了 jQuery,然后在某个.js模块里直接写$(...)而没有import。理论上模块里的自由变量会去全局找,能找到window.$,所以这条通常是通的——前提是 jQuery 真的在模块执行前加载完成。而模块脚本默认是延迟执行的,普通<script>是立即执行的,顺序上反而没问题。真正出问题的是另一种:jQuery 通过 npm 安装,由打包工具打进 bundle 里,此时 jQuery 存在于模块作用域,并没有挂到window上。业务代码如果没import就直接用$,全局找不到,报错就来了。修法有三种,按侵入性从低到高:
// 方案一:显式引入,最推荐,依赖关系清晰 import $ from 'jquery'; // 方案二:在入口统一挂到全局,适合大量老代码不改动的情况 import jQuery from 'jquery'; window.$ = window.jQuery = jQuery; // 方案三:用构建工具的自动注入能力(以 webpack 为例) // new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery' })方案三在迁移老项目时最省事,它会自动给每个模块注入$,不用改一行业务代码。但它也有副作用:所有用到$的模块都会被打进 jQuery 的依赖,代码分割时容易重复打包。所以我一般只在“先把项目跑起来”的阶段用它,后面有空了再逐步替换成显式import。
5. 分场景落地解法
同样的报错,在不同工程形态下的解法差别很大。下面按我这几年接触最多的四种场景分别给方案,你可以直接对号入座。
5.1 传统多页站点:把引入顺序固化成模板
这类项目的典型特征是每个页面一个 HTML,共用一套头部和尾部片段。最稳的做法是把 jQuery 的引入放进公共的尾部模板里,位置固定在</body>之前,并且保证它出现在所有业务脚本之前。为了防住“某个页面自己又加了一遍”的情况,我会在尾部模板里加一段简单的重复引入检测:
if (window.jQuery && window.jQuery.fn.jquery) { console.log('[jquery] 已加载,版本 ' + window.jQuery.fn.jquery); }别小看这一行日志,重复引入 jQuery 是这类项目的高发问题,两个版本的$同时存在时,插件注册到哪个实例上完全是随机的,排查起来极其痛苦。另外,我会在代码规范里明确一条:业务脚本一律不加async。老项目里有很多脚本之间存在隐式依赖,一旦加了async,那就是给自己埋雷。
5.2 模块打包工程:从依赖声明入手
用现代构建工具的项目,$ is not defined基本只有一个原因——依赖没声明。所以解法也就一条:凡是用了$的地方,文件顶部必须有import $ from 'jquery'。为了防住遗漏,可以在构建配置里加一条规则:对不包含import ... from 'jquery'却出现了$(的文件给出告警。更省事的做法是配合静态检查工具的no-undef规则,把$和jQuery声明为全局白名单之外的变量,一旦有文件漏了引入,检查阶段就会报出来,不用等到运行。还有一个细节值得注意:有些第三方插件是 UMD 格式的,它期待全局有jQuery,构建时被当成纯模块处理,插件内部拿不到$。这时候用ProvidePlugin或者define垫片把它接上就行,不必改插件源码。
5.3 服务端模板与组件化后台:小心片段化的引入
后台管理系统喜欢把页面拆成很多组件片段,每个片段可能有自己的<script>。这种结构下,顺序问题会变得非常隐蔽——某个弹窗组件在片段里内联了一段$(...),而 jQuery 是在布局页的底部引入的,如果这个片段被渲染在布局的脚本之前,就会炸。我处理这类项目的原则是:组件片段里不写内联脚本,所有逻辑挂到统一的入口文件里按模块注册。如果实在改不动,就退而求其次,把 jQuery 的引入提到<head>里并加defer,同时把所有组件脚本也改成延迟执行,通过自定义事件在 DOM 就绪后统一初始化。这套约定听起来啰嗦,但它把一个靠运气的问题变成了一条可验证的规则。
5.4 混合页面与内嵌浏览器:环境差异是主要变量
还有一种场景是页面被内嵌在别的容器里运行,比如客户端应用里的 WebView、或者第三方系统里的 iframe。这类环境的坑在于,宿主可能会注入自己的脚本、可能会改写资源地址、可能对某些域名做了限制。表现就是“同一份代码,在浏览器里好好的,嵌进去就报$ is not defined”。排查的第一步永远是看 Network 里那条 jQuery 请求有没有发出去——如果压根没有请求,说明脚本标签被过滤掉了;如果请求了但失败,看失败原因;如果成功了但typeof jQuery还是 undefined,那大概率是被执行环境拦了,或者脚本执行到一半抛了别的错被中断了。这种情况下,把 jQuery 的引入地址换成与被嵌入页面同源的本地文件,比什么都管用。
6. 排查实录与速查表
前面讲的是分类和方法,这一节换成我实际碰到过的几个案例——全部做了匿名化处理,但技术细节是真实的。
6.1 三个典型场景的定位过程
场景一:本地一切正常,部署后全站报错。这是个典型的静态资源路径问题。本地开发服务器把项目根目录当作站点根,所以./js/jquery.min.js能找到;部署后应用被挂在一个子路径下,比如/app/,页面 URL 变成/app/index.html,相对路径解析成/app/js/jquery.min.js——这个其实是对的。真正的坑在于某个页面用了前端路由,URL 显示为/app/order/detail,相对路径解析成/app/order/js/jquery.min.js,404 了。定位方法:打开 Network 过滤jquery,直接看请求的完整 URL,一眼就能看出多了一层。修法是把引入路径改成<base href="/app/">配合相对路径,或者干脆全部写成以/app/开头的绝对路径。
场景二:只有某一个页面报错,其他页面都正常。这种“单点故障”通常是那个页面有额外的内联脚本,或者引入了某个额外的库把$抢走了。我在一个后台项目里遇到过,某个报表页面引了一个老旧的图表库,那个库在加载时会执行window.$ = window.$ || 自己的实现,恰好它排在了 jQuery 后面,于是$被替换掉了。表现就是 jQuery 明明在,$却指向了别的东西,调用$('#x')时报的错五花八门。定位技巧:在这个页面控制台敲$ === jQuery,返回false就说明被抢了。修法是调整引入顺序,或者在那个库之后重新执行window.$ = jQuery。
场景三:偶发报错,刷新一下就好了。这种最折磨人。排查下来十有八九是async或者动态注入导致的竞态。具体案例是一个页面用 JS 动态创建<script>标签去加载 jQuery,加载完成后再执行初始化逻辑——但初始化逻辑写在了标签创建之后、加载完成之前,它依赖的是onload回调,偏偏有个分支没走回调直接执行了。修法是把所有依赖 jQuery 的逻辑统一放到onload里,或者用 Promise 包一层:
function loadScript(src) { return new Promise(function (resolve, reject) { var s = document.createElement('script'); s.src = src; s.onload = resolve; s.onerror = function () { reject(new Error('加载失败: ' + src)); }; document.head.appendChild(s); }); } loadScript('/lib/jquery.min.js') .then(function () { return loadScript('/lib/plugin.js'); }) .then(function () { /* 这里再执行业务初始化 */ }) .catch(function (err) { console.error(err); });用 Promise 串起来之后,依赖关系就变成了代码里显式表达的,不会再出现“偶尔跑在加载完成之前”的情况。顺带一提,动态加载一定要处理onerror,否则加载失败时你只会看到一个业务侧的$ is not defined,完全不知道真正的原因是网络或路径。
6.2 常见问题速查表
遇到报错时按这张表从上往下走,基本不会跑偏。
| 现象 | 最可能的原因 | 快速验证 | 处理方式 |
|---|---|---|---|
typeof $和typeof jQuery都是 undefined | 文件没加载或加载失败 | Network 面板过滤 jquery,看状态码 | 修正路径、检查 404、检查服务器 MIME |
jQuery有值但$没有 | 被noConflict让出,或被别的库占用 | 控制台执行$ === jQuery | 用jQuery替代,或在之后重设window.$ |
控制台有 SRI 报错,紧跟$ is not defined | 完整性哈希不匹配,脚本被拒绝执行 | 看是否有Failed to find a valid digest | 删除integrity或重新计算哈希 |
报错MIME type is not executable | 服务器返回的 Content-Type 不对,或路径落到了默认页 | Response 里看返回内容是不是 HTML | 修正静态资源服务配置 |
| 只有部分页面报错 | 该页面有额外库抢占了$,或内联脚本位置靠前 | 在该页执行$ === jQuery | 调整引入顺序,或统一入口初始化 |
| 偶发报错,刷新可恢复 | async或动态注入的竞态 | 反复刷新观察请求瀑布图 | 用defer或 Promise 串行加载 |
| 模块项目里只在某个文件报错 | 该文件漏了import $ from 'jquery' | 全局搜索该文件的引入语句 | 补 import,或配置自动注入 |
插件方法不存在而不是$未定义 | 存在两份 jQuery,插件注册到了另一份 | jQuery.fn.jquery看版本 | 去掉重复引入,统一到一份 |
6.3 我个人的几条经验
第一条,永远先看 Console 里所有红字,从上往下读。$ is not defined往往是第二条或第三条错误,真正的原因在上面。SRI 失败、MIME 拒绝执行、CORS 拦截,这些都会先报出来,漏看一条就要多查半小时。
第二条,不要在报错的行附近改代码。$ is not defined报出的那一行,通常是完全无辜的业务代码,真正的问题在页面头部或者加载链路里。我见过有人在那行前面加window.$ = window.jQuery || {}来“消除报错”,结果页面不报错了但功能全废,问题反而更难查。
第三条,给项目定一条硬规矩:静态资源用绝对路径,脚本依赖用显式声明。这两条规矩能消灭掉这类报错里绝大部分的偶发情况。规则本身不高级,但坚持执行的项目,我几乎没在里面见过反复出现的加载时序问题。
第四条,版本号是排查的重要线索。jQuery.fn.jquery这一句几乎是免费的,但能立刻告诉你页面上跑的是哪个版本、有没有被重复引入。有些老插件对版本敏感,1.x 和 3.x 的行为差异不小,先确认版本再查其他,效率高得多。
这套排查路径我在不同的项目里用了很多年,从早期的纯静态页面到现在的构建工程,变的只是报错的表层形态,底下那四类根因一直没变。真要说有什么心得,就是先分类再动手——花两分钟判断问题属于哪一类,比花两小时试各种引入方式要划算得多。