做了好几年前端,每次要给元素加过渡效果,我还是会下意识写一句$("#modal").fadeIn(200)。但真正把淡入淡出用明白,是最近在一个项目里被坑过一次之后。当时给一个弹窗加淡入,结果弹窗根本不是"淡"入的——它先是瞬间显示出来,然后透明度从 1 往下降,整个交互显得特别愣。排查半天,发现是我对 jQuery 淡入淡出内部机制的理解一直停留在"改 opacity"这个表层。这篇就从那次踩坑说起,把fadeIn、fadeOut、fadeToggle、fadeTo这四个方法的使用边界、参数逻辑、时序陷阱和实际项目中的组合玩法一次讲透,适合刚接触 jQuery 动画的初学者,也适合写了几年但没仔细抠过细节的兄弟一起复盘。
1. 淡入淡出的本质:它操作的是 display 状态机,不是动画曲线
1.1 opacity 只是表象,display 才是开关
很多人以为fadeIn就是"把 opacity 从 0 平滑变成 1",其实只对了一半。jQuery 的fadeIn真正做的事情是两件:先把元素的display从一个"隐藏值"切换成可见值,比如block、inline-block、flex(取决于元素原本的 display 类型),然后才开始对opacity做动画。反过来,fadeOut是先把opacity从当前值平滑降到 0,等动画彻底完成之后,再把display设成none。
这就是我踩坑的根源。我那个弹窗在 CSS 里写了display:none,然后直接调fadeIn。jQuery 拿到元素后,发现display:none,于是先把它改成block,再打算把opacity从 0 开始淡入。但问题出在 CSS 里我为了弹窗的层级样式,同时写了opacity: 0的初始状态吗?并没有。我当时的弹窗 CSS 只有display:none,没有初始 opacity,所以 jQuery 看到 opacity 当前是 1,动画目标也是 1,瞬间就完成了,视觉上就是"直接出现"。
如果想让fadeIn真正产生淡入效果,元素在动画前的可见状态必须是:display为非 none,同时opacity是 0(或者通过 CSS 初始 opacity 设为 0)。更准确地说,jQuery 在开始fadeIn前如果检测到元素处于隐藏状态,会先强制把 opacity 设成 0,再执行动画。但这个"检测隐藏"的机制,依赖的是display值,而不是 opacity 值。如果元素display:none且没有初始 opacity 声明,jQuery 的隐藏检测会触发,它会先把 opacity 设 0 再动画——这个逻辑在新版本里是有的,但如果你用的是旧版本,行为可能不一致,这也是很多老项目里 fadeIn 直接闪现的原因。
1.2 为什么动画要挂在 display 状态机上
理解"必先在 opacity 和 display 之间切换"这个点,很多现象就能解释了。
第一个常见现象:fadeOut之后马上fadeIn,元素会闪一下。原因是fadeOut的完成回调里才设置display:none,如果你在回调之外、动画还没结束时就调用了fadeIn,jQuery 发现元素当前还在动画队列里,而元素此刻的 display 是可见的,于是它按"从当前 opacity 渐入到 1"执行,结果就是从半透明状态再淡入,视觉上呈现一个不自然的跳动。
第二个现象:链式调用fadeIn().fadeOut()会排队执行,而不是叠加。jQuery 的动画都是挂在元素的fx队列上的,不是并行跑的。同一个元素上连续调用多个动画,后面的会等前面的完成再启动。这是"淡入淡出看起来好像没生效"的另一个潜在原因——你以为第二个动画覆盖了第一个,实际上它还在排队。
第三个现象:对display:inline的元素做fadeIn,动画完成前后布局会跳动。因为元素从display:none切到 inline 后,它会参与文档流,宽度、高度都重新计算,视觉上就会"弹出"而不是"淡入"。这是很多 tooltip 场景常见的坑,后面实战部分会给出解法。
2. fadeIn / fadeOut / fadeToggle / fadeTo:四个方法的取舍与参数陷阱
2.1 四个方法的签名与行为速查
| 方法 | 签名 | 默认动画 | 是否改变 display | 适用场景 |
|---|---|---|---|---|
fadeIn | fadeIn([duration],[easing],[complete]) | 400ms | 动画前设为可见 | 隐藏元素的淡入显示 |
fadeOut | fadeOut([duration],[easing],[complete]) | 400ms | 动画后设为 none | 可见元素的淡出消失 |
fadeToggle | fadeToggle([duration],[easing],[complete]) | 400ms | 视当前状态而定 | 切换类显隐,如折叠面板 |
fadeTo | fadeTo(duration, opacity,[easing],[complete]) | 必填 duration 和 opacity | 不改变 display | 元素保持占位,只调透明度 |
注意fadeTo是四个方法里唯一一个"不碰 display"的方法。它只做一件事:把元素当前透明度渐变到你传入的目标值。这意味着如果元素本身是display:none,调用fadeTo是看不到任何东西的,因为 display 还是 none。反过来,如果你只想让一个占位元素变淡一点(比如一个按钮禁用时降低透明度),用fadeTo最合适,它不会在动画结束把你元素直接display:none弄消失。
2.2 fadeToggle 的隐藏值逻辑
fadeToggle内部会根据元素当前display状态来决定执行淡入还是淡出。如果可见,就执行淡出;如果隐藏,就执行淡入。它有一个容易被忽略的行为:淡入时的最终 opacity 不一定是 1。
假设元素在内联样式里写了style="opacity:0.4",第一次点按钮,display:none状态下的元素被淡入到 0.4,而不是 1。第二次点按钮,元素从 0.4 淡出到 0 再隐藏。第三次再点,又从 0 淡入到 0.4。这个"记住原始 opacity"的机制,在一些需要半透明开关的场景里确实方便,比如水印层、半透明遮罩的显隐切换。但你如果期望所有场景下 toggle 都是"完全不透明和消失"之间的切换,就可能觉得它行为诡异。
最好的习惯是:在使用fadeToggle的元素上,明确通过内联样式或 CSS 将opacity初始化成 1,或者在调用前用.css("opacity", 1)强制归位。否则你不知道它最后淡入到哪里。
2.3 fadeTo 为什么建议单独拎出来讲
实战中我经常看到有人用它做"淡出后隐藏",写法是fadeTo(200, 0, function(){ $(this).hide(); })。这个没什么问题,但既然最后调用了hide(),为什么不直接用fadeOut?我理解是这些人想要"动画过程中保持占位,结束后再消失"的精细控制。但其实fadeOut内部就是先透明度降到 0 再设 display:none,如果你的需求就是完全消失,用fadeOut就够了,代码也更短。
fadeTo真正有存在意义的场景是:元素自始至终都要占位,只是透明度变化。典型例子是"呼吸灯"提示、滚动加载时的 loading 条透明度调整,或者某个操作成功后把错误提示文字淡化成半透明状态再移除。在这些场景里,用别的 fade 方法反而要用回调里控制 CSS,绕了一圈。
3. 动画时序与回调的坑:duration、complete、promise
3.1 duration 字符串的实际毫秒映射
jQuery 的 duration 参数可以传数字毫秒,也可以传三个字符串关键字:"slow"和"fast"。很多人以为"slow"是 600 毫秒、"fast"是 200 毫秒,这其实是早期 jQuery 文档里的定义。落实到代码层面,jQuery 内部定义是"slow": 600,"fast": 200。而如果你传默认值,也就是什么都不传,动画时间统一是 400 毫秒。
这里想提醒一点:不要在同一个页面上混用多种时长。淡入 200、淡出 600、某个按钮用"fast",整个页面动画节奏会非常乱。我个人的经验是:
- 弹窗、提示条这类短暂反馈型元素,150~250ms 最合适,太快会觉得愣,太慢会觉得卡。
- 页面大区块的显隐切换、轮播主体切换,300~500ms 比较舒服。
- 超过 600ms 的淡入淡出,必须在视觉上有明确的"氛围感"需求才用,否则用户会感觉操作不跟手。
3.2 回调什么时候触发,什么时候不触发
complete回调是在动画完全结束之后执行的。这个"动画结束"表示的是队列中该动画的最后一个关键帧执行完毕,而不是元素变为目标状态那一刻。举个例子:
$("#box").fadeOut(200, function () { console.log($(this).css("display")); });这段代码回调里打印的 display 值,理论上应该是"none"。正常情况下确实如此,因为 fadeOut 的"设 display:none"逻辑发生在最后一个动画帧执行完后,jQuery 内部会把它作为动画的最终状态应用到元素上,然后触发 complete。但如果你在 fadeOut 执行到一半、还没结束的时候,手动修改了 display 或者调用了stop(),回调触发时元素可能还是可见的,甚至回调不触发。
还有一个容易忽略的点:complete回调里的this指向当前 DOM 元素,不是 jQuery 对象。所以你需要在回调里用 jQuery 方法时,得先做$(this)包裹。直接this.fadeIn()会报错,因为没有原生的 DOM fadeIn 方法。
3.3 队列编排:什么时候该用 promise
在复杂度稍微高一点的交互里,比如"先淡出遮罩,再淡入新弹窗,最后把按钮高亮",如果每个动画都嵌套回调,代码会变成回调地狱。jQuery 提供的.promise()方法可以把动画队列的执行结果变成一个 Promise 对象,配合.done()做编排。
$("#mask").fadeOut(200).promise().done(function () { $("#modal").fadeIn(300); $("#submitBtn").addClass("highlight"); });这里.promise()监听的是元素上的动画队列,等 fadeOut 完全结束后再执行 done 里的内容。如果不用 promise,而是直接在 fadeOut 的 complete 回调里去 fadeIn,很容易踩到"多个动画同时在跑,后续操作时序全乱"的坑。尤其在遮罩和弹窗是不同元素的情况下,complete 回调只能保证自己这个元素动画结束,不能保证其他元素的动画编排。
我的建议是:凡是页面里同时有两个以上元素参与淡入淡出组合,一律用.promise()或回调嵌套,别裸写setTimeout去猜动画时长。动画时长一旦调整,setTimeout 里的数字就变成魔法数字,维护起来很痛苦。
4. 实战组合:弹窗遮罩、tooltip、轮播与表单提示
4.1 弹窗遮罩:stop(true, true) 是唯一武器
弹窗是最典型的淡入淡出组合场景:半透明遮罩淡入,弹窗主体淡入,关闭时再淡出。第一次写的时候,我没有在打开前调用.stop(),结果就是连续快速触发按钮时,弹窗会"卡在中间状态"——透明度和 display 状态不一致。比如第一次点击打开,动画还没执行完,第二次点击又要打开,jQuery 会把新的动画压进队列,而旧的动画还在跑,最后弹窗的最终状态完全取决于你最后一次调用的参数。
正确的做法是每次操作前先用.stop(true, true)清空队列并跳到最终状态。第一个true表示清除队列中的所有动画,第二个true表示直接跳转到当前动画的最终值。
$("#openBtn").on("click", function () { $("#mask").stop(true, true).fadeIn(200); $("#modal").stop(true, true).fadeIn(250); }); $("#closeBtn").on("click", function () { $("#mask").stop(true, true).fadeOut(200); $("#modal").stop(true, true).fadeOut(200); });如果你不做stop,连续点击 3 次打开按钮,动画队列里会积累 3 个动画,你会看到弹窗在那里闪来闪去。做了stop之后,不管用户点多快,每次都是"从当前状态立刻跳到最后状态,再开始新动画",这是弹窗交互的标准防抖做法。
另外说一个遮罩层的细节:遮罩通常是一个全屏固定定位的半透明层,初始 CSS 应该是display:none; opacity:0.6。注意这里的 opacity 是遮罩的目标值,不是初始值。你调用fadeIn时如果 element 处于隐藏状态,jQuery 会先把 opacity 设为 0,再动画到 CSS 指定的 0.6,这个过程是自动的,不需要你自己写回调去设置最终浓度。但如果遮罩的 opacity 没有在 CSS 里写死,而是在 JS 里通过.css("opacity", 0.6)设置的,就得在fadeIn之后单独再设一次,否则动画会直接到 1。这个坑我见过不止一次。
4.2 tooltip:hover 与 fadeToggle 的边界问题
tooltip 的需求往往是:鼠标移入显示,移出消失。用fadeToggle是最省事的写法,但必须加stop来防止移入移出过快导致动画堆积。
$(".trigger").hover(function () { $(this).next(".tooltip").stop(true, true).fadeToggle(150); }, function () { $(this).next(".tooltip").stop(true, true).fadeToggle(150); });两个问题需要注意。
第一个问题是 tooltip 的定位。如果 tooltip 是position:absolute的块级元素,那么display:none切换到块级显示后,它不占据文档流空间,淡入淡出不会引起布局跳动,这个没问题。但如果 tooltip 是 inline 元素,fadeToggle在显示和隐藏之间切换时,位移动画会让它上面的兄弟元素左右跳动。解决方法是给 tooltip 加display:inline-block或position:absolute,让它在 display 切换时不影响周围布局。
第二个问题是fadeToggle的"半透明目标值"问题。如果 tooltip 在某些状态下透明度不是 1,hover 进去第一次可能只淡入到 0.4,看起来像没显示全。所以使用fadeToggle前,最好显式声明一次.css("opacity", 1),把不确定状态排除掉。
4.3 轮播:fadeIn/fadeOut 习惯性误区与 fadeTo 选型
老式 jQuery 轮播图最常写的切换逻辑是:把所有 slide 绝对定位叠在一起,点击按钮时,当前 slide fadeOut,下一张 fadeIn。这能跑,但有一个天生的性能浪费:fadeOut 会先把当前 slide 的 opacity 降到 0,然后设 display:none;fadeIn 又会把下一张从 display:none 切到可见,再淡入。两张 slide 在切换过程中,浏览器要在两个元素上同时做 repaint,而且为了叠加效果,两个元素需要同时处于可见状态,但 fadeOut 的 display:none 只在动画结束时才生效,这就导致切换瞬间页面上可能同时有两张半透明的图片在渲染,CPU 消耗会比较明显。
更高效的写法是只用一张"当前层"的透明度变化,或者用fadeTo控制单张元素的透明度,再配合 z-index 让层级明确:
function switchTo(index) { slides.stop(true, true).css("opacity", 0); slides.eq(index).css("opacity", 1).fadeTo(400, 1); currentIndex = index; }这里把其他 slide 的 opacity 直接设为 0,只对当前目标 slide 做 fadeTo 动画。因为 fadeTo 不操作 display,所有 slide 还是绝对定位叠在一起,只是透明度变化,视觉上更平滑,而且不会有多余的 display 切换布局计算。如果你希望切换时旧图片有渐出效果,可以同时让旧图 fadeTo 到 0,新图 fadeTo 到 1,但这种双动画场景要用 promise 做编排,否则第二轮切换时旧图可能还没降完透明度。
4.4 表单校验提示:callback 里 setTimeout 的闭包细节
表单校验失败时,通常要在输入框附近弹一个红色提示条,显示 2 秒后自动消失。我的写法是:
$("#loginForm").on("submit", function (e) { e.preventDefault(); var tip = $("#tip").stop(true, true); if ($("#user").val().trim() === "") { tip.text("用户名不能为空").fadeIn(150).delay(1500).fadeOut(300); } });注意这里用了.delay(1500)而不是在回调里写setTimeout。delay 是 jQuery 动画队列的一部分,它和 fadeIn、fadeOut 是同一个队列,所以会严格按顺序执行:淡入 150 毫秒、停留 1500 毫秒、再淡出 300 毫秒。如果用 setTimeout,需要额外关心闭包里的元素引用和动画堆积问题,而且每次表单提交时如果没有先 clearTimeout,上一次的定时器还会把提示条淡出,导致这次刚 fadeIn 就被上次的定时器 fadeOut 了,逻辑很容易乱。
顺带一提:如果校验失败的提示需要反复触发,最好在提示条 fadeIn 前先清除文字内容和动画队列状态,避免上一次的 delay 残留干扰本轮显示。.stop(true, true)会把队列里没跑完的 delay 和 fadeOut 全部清掉,这也是上面代码里为什么开头就要.stop(true, true)的原因。
5. 升级 jQuery 3.x 后的动画行为变化与性能止损建议
5.1 3.x 的动画引擎变化:requestAnimationFrame 带来的观感差异
如果你从 jQuery 2.x 或更早版本迁移到 3.x,淡入淡出的实际表现会有微妙差异。3.x 开始,jQuery 的 animate 内部改用requestAnimationFrame驱动,替代了旧版本的setInterval。
带来的直接变化是:动画刷新率跟屏幕刷新率对齐,通常从 60fps 降为更流畅的节奏,动画在低性能设备上不再像以前那样跳帧严重。但副作用是,如果你的页面里在很短的时间内触发大量动画(比如同时 fadeIn 十几个列表项),采用 rAF 的 jQuery 会把动画帧集中到下一次绘制周期,视觉上可能出现"顿一下,然后全部一下子出现"的效果。这不是 bug,是动画驱动的机制变了。
另一个 3.x 的行为变化是:对隐藏元素执行fadeIn时,jQuery 3.x 修复了旧版本中"fadeIn 前闪一下"的部分问题,处理更干净。但如果你混合使用 CSS transition 和 jQuery 动画,同样一个元素上可能会有两套动画机制打架,表现就是 fadeIn 老是不生效或者一闪而过。我踩过这个坑,当时给元素加了transition: opacity 0.3s,再用 jQuery 的 fadeIn 去控制,结果 opacity 变化被浏览器原生 CSS transition 接管,jQuery 动画反而被跳过了。遇到这种问题,检查元素 CSS 里是不是同时存在 transition 属性。
5.2 什么时候别硬用 jQuery 动画
淡入淡出最多的场景无非是显隐切换和透明度变化。但如果你在做大型页面,比如数据看板、长列表、或者需要跟手动画的交互动画,jQuery 的动画在性能和自然度上都不如 CSS 动画或 Web Animations API。
原因在于 jQuery 动画本质上是逐帧修改元素的opacity和display,每帧都会引发样式重算和重绘。而 CSS 动画如果只操作opacity和transform,浏览器可以把这些元素的绘制放到 GPU 合成层上,帧率稳定且消耗低。对于几十个 DOM 元素同时做淡入淡出的场景,jQuery 写法往往会让页面掉帧,而 CSS 动画配合will-change: opacity就平滑得多。
所以我的建议是分场景选型:
| 场景 | 推荐方案 |
|---|---|
| 简单的显隐切换、弹窗、tooltip | jQueryfadeIn/fadeOut/fadeToggle,代码量小、易维护 |
| 需要精确控制时序、多个动画编排 | jQuery +.promise(),或改为 CSS transition + 事件监听 |
| 大量元素同时动画、GPU 合成要求高 | CSStransition或requestAnimationFrame手写动画 |
| 复杂交互、需要暂停/倒放/进度控制 | Web Animations API,或成熟的动效库 |
5.3 零碎但高频的坑位笔记
最后记录几条不成体系但高频出现的细节,都是实际项目里被问过多次的。
第一个:动画引擎的全局开关。$.fx.off = true会把所有动画瞬间跳到最终状态。这个开关在低功耗模式或无障碍偏好里很好用,但如果你在业务代码里误把它设成了 true,所有 fadeIn/fadeOut 都会"瞬间完成",看起来就像动画失效了。排查此类问题时,可以先在控制台输入$.fx.off看看是不是被置 true 了。
第二个:inline 元素的 display 问题。前面说过,as inline 元素做 fade 动画会导致布局跳动。更隐蔽的是,display:none的 inline 元素被 fadeIn 后,jQuery 会恢复成 inline,但某些场景你希望它变成 inline-block 或 block。jQuery 的 fadeIn 不会智能判断你需要的 display 类型,它只会恢复元素在隐藏前"原本的 display 类型"。如果你要的是其他类型,初始化时就要通过 CSS 或 jQuery 显式设置。
第三个:重复绑定事件导致的多个动画竞争。如果你在循环里给同一个元素绑定了多次 hover 或 click,每次触发都会启动一个独立动画,多个动画通过队列压栈,最终表现就是元素闪个不停。解决办法是绑定前先off解绑,或者每次动画前stop(true, true)。这里stop(true, true)的第二次参数true尤其关键,它代表跳转到最终状态,只传第一个参数的话,动画会停在当前中间透明度,视觉上仍然会卡住。
我在实际项目里现在的习惯是:凡是淡入淡出,先把动画时长定义成常量,比如const FADE_SPEED = 200;,避免每处魔法数字。连续触发一律先stop(true, true)。只要涉及两个以上元素的动画组合,优先.promise().done()。坚持这三条之后,项目里的动画状态问题基本绝迹。淡入淡出看着是入门级 API,能把它的状态机、队列、时序都理顺,再配合合理的性能取舍,才算真正把它用明白。