滚动卡顿复盘:隐藏浮层为什么还在定位
阅读掘金小册时遇到一个滚动明显卡顿的页面。页面上没有展开菜单,滚动期间却能看到浮层定位函数持续执行。当次检查中,7 个已经隐藏的浮层仍在监听滚动。
沿实例创建过程继续追踪,可以定位到页面所用组件封装的一条生命周期缺口:隐藏元素的尺寸通知进入更新方法,在未检查显示状态的情况下创建 Popper,并开启滚动监听。Tooltip 关闭、销毁后,同一条路径还可能把实例重新创建回来。
谁在滚动时更新
当次检查的小册页面加载了这份代码产物。其中 Popper 负责测量参考元素、计算浮层坐标和边界避让;外层的 ByteTooltip / BytePopover 负责显示、关闭以及定位实例的生命周期。
滚动进入 Popper 的 scheduleUpdate 后,通过 requestAnimationFrame 调度更新,再执行测量、modifier 和样式写入。这条耗时路径说明定位工作正在执行,还需要继续核对实例为什么处于启用状态。
2026-11-02,在全新的匿名 Chrome 环境中打开页面,观察到 2 个 Tooltip 和 5 个 Popover 都处于下列状态。业务传入的 popperOptions 均为空对象。
| 检查位置 | 当次状态 | 含义 |
|---|---|---|
组件 popperStatus |
false |
组件处于关闭状态 |
浮层计算样式 display |
none |
浮层没有显示 |
Popper state.eventsEnabled |
true |
定位实例仍在监听事件 |
display: none 会让元素退出布局,但不会自动解绑已有的滚动监听。这里要解释的失配是:组件明确处于关闭状态,内部定位实例却仍然开启监听。初次现场的源码位置与停止监听对照见现场观察摘要弹窗。
尺寸回调绕过了显示状态
共用封装已经有关闭时调用 disableEventListeners 的 watcher。缺口出现在另一个入口:挂载时注册的尺寸观察回调,不检查 popperStatus 就调用更新方法。
下面保留 watcher、尺寸观察注册和两个更新方法。updatePopper 在有实例时调度更新,没有实例时进入创建流程;createPopper 等其余方法保留在组件生命周期源码弹窗中。
// 0266bcd.js:4:178424,Popper 组件封装的相关字段
// 保留标识符与控制流,中文注释为文章添加。
t.default = {
// ...省略 props、data 和其他生命周期
// 0266bcd.js:4:179166
watch: {
// e 是新的显示状态;这个 watcher 依赖状态值发生变化。
popperStatus: function (e) {
(e
? (this.$emit('on-show'), this.popperJS && this.popperJS.enableEventListeners(), this.updatePopper())
: (this.$emit('on-hide'), this.popperJS && this.popperJS.disableEventListeners()),
this.$emit('input', e));
},
disabled: function (e) {
e && (this.popperStatus = !1);
},
},
// autoFixPosition 默认 true;组件挂载后观察浮层尺寸。
// 0266bcd.js:4:179590
mounted: function () {
var e = this;
this.$nextTick(
function () {
((0, r.default)(this, e),
this.autoFixPosition && this.popperElm && (0, d.addResizeListener)(this.popperElm, this.handleResize));
}.bind(this),
);
},
methods: {
// ...省略 createPopper 等方法;其完整实现见补充材料
// 0266bcd.js:4:181769
updatePopper: function () {
this.popperJS
? (this.popperJS.scheduleUpdate(), (this.popperJS.popper.style.zIndex = c.default.nextZIndex()))
: this.createPopper();
},
// 这个入口没有检查 popperStatus。
// 0266bcd.js:4:181918
handleResize: function () {
this.updatePopper();
},
},
};
初次尺寸通知创建了隐藏实例
这次运行中,原生 ResizeObserver 对 7 个隐藏元素分别发来一次 0 × 0 通知。当时它们都还没有 Popper 实例,handleResize 随即调用 updatePopper,后者进入 createPopper,在 $nextTick 中执行构造。
Popper 1.x 的 eventsEnabled 默认开启,新实例因此注册了滚动监听。组件的 popperStatus 一直为 false,没有发生值变化;关闭分支的 watcher 不会因实例创建而再执行一次,也就没有机会纠正这份新监听。
关闭后,尺寸通知又把实例创建回来
Tooltip 的过渡结束回调会执行 destroyPopper。本次开关验证中,元素隐藏后的尺寸通知到达时,实例已经被销毁;handleResize 又走到「没有实例就创建」的分支。最终回到「显示关闭、监听开启」的状态。
这解释了为什么只在关闭 watcher 中停止监听还不够。后来的尺寸通知可以创建另一份实例,而显示状态仍然保持 false。尺寸观察本身不需要持续触发;创建后留下的滚动监听,才是后续每次滚动仍会更新的原因。
只改一个入口验证触发链
为了验证这条链,在两个独立浏览器上下文中加载相同哈希的 bundle,只在第二个上下文的响应里将 handleResize 改为:
// 受控验证用的替换方法;其余线上逻辑保持原样,不修改服务器文件。
t.default = {
// ...省略其他配置
methods: {
// ...省略其他方法
handleResize: function () {
if (this.popperStatus) this.updatePopper();
},
},
};
| 检查项 | 原逻辑 | 尺寸回调检查显示状态 |
|---|---|---|
| 初始隐藏且启用监听的浮层 | 7 | 0 |
| 24 次真实 window 滚动触发的隐藏实例 onUpdate | 168 | 0 |
| 一个 Tooltip 打开后 | 显示,监听开启 | 显示,监听开启 |
| 该 Tooltip 关闭并等待过渡后 | 隐藏,但实例重新创建且监听开启 | 隐藏,实例已销毁 |
这是状态和调用次数的受控对照,没有测量整页性能收益;匿名页面、评论数量和浏览器条件也可能随时间变化。原始尺寸通知、实例状态、替换内容和运行环境保存在组件生命周期对照记录弹窗中。
单入口替换消除了这批隐藏实例及其滚动更新,说明触发缺口位于 ByteTooltip / BytePopover 共用封装的生命周期管理:尺寸通知可以在关闭状态创建定位实例。页面传入的配置与组件显示状态没有表现出错误;当前证据支持修正组件封装中的创建、订阅条件。Popper 在这里按默认配置启动监听,并不知道外层组件的 popperStatus。
初次现场中,暂停隐藏实例的监听后,脚本耗时下降,但页面仍有较高的布局成本。因此,这条链能够解释隐藏浮层的无效更新,尚不能解释原站全部卡顿,也不能把更新次数的下降直接换算成帧率或耗时收益。
自己的组件先约束生命周期
这个案例需要约束的是所有可能启动定位的入口。只监听 visible 的变化,覆盖不了尺寸观察、过渡回调和延迟创建任务。
- 更新前检查状态。尺寸、滚动和业务触发的更新应遵守相同的显示条件。关闭状态下不启动新的定位工作,打开过渡中的预定位需求则需要明确保留。
- 延迟执行时再次检查。排入
$nextTick、rAF 或定时器时仍然打开,不代表任务执行时也打开。创建实例前重新核对显示、销毁状态,必要时取消过期任务。 - 清理后检查能否重新进入。关闭和卸载时释放定位订阅,同时处理尺寸观察与未完成任务;保留 DOM 时也要防止这些入口重新创建实例。
- 验证状态转换。除了正常打开,检查初始关闭、关闭过渡结束、快速开关和卸载前已有待执行任务的情况。重点观察清理后的实例与监听,而不只是浮层是否消失。
本次 handleResize 的单入口替换验证了触发原因。组件库完整修复还应覆盖上述异步时序与过渡需求,不能将这个局部验证直接视为全部修复验收。
组件库通常把定位订阅与显示状态关联起来,并控制更新触发条件。按 2026-11-02 核对的官方资料与开发分支源码,可以参考这些处理;具体项目仍需检查实际依赖版本。
| 实现 | 可以借鉴的处理 |
|---|---|
| Floating UI | 根据相关祖先滚动、尺寸变化和布局位移更新;逐帧检测默认关闭,要求只在浮层打开时启动并及时清理。 |
| Radix UI | 使用 Floating UI,默认 optimized;选择 always 才开启逐帧位置检测。 |
| Element Plus | 随 visible 切换 Popper 的事件监听;定位交给 Popper 2。 |
定位源码解释每次更新的成本
生命周期约束决定一次更新是否应当发生。需要显示的浮层仍然要完成定位;已经错误启动的隐藏实例,也会进入相同的测量与计算链。接下来的源码用于说明这部分工作包含哪些成本。
定位通常先读取按钮位置与浮层尺寸,再计算坐标并写入位置。加减坐标往往很便宜;如果读取发生在尚未完成的布局变更之后,浏览器可能需要先完成布局,才能返回最新尺寸。
强制同步布局指脚本读取最新几何信息时,迫使浏览器提前完成尚未处理的布局工作。反复交错布局写入与读取,可能让原本可以合并的工作执行多次,通常称为 layout thrashing。Chrome 的布局性能说明解释了这种成本。
从压缩产物对照到上游源码
这份 bundle 末尾确实声明了 //# sourceMappingURL=0266bcd.js.map。2026-11-02 检查时,JS 仍可访问且哈希与现场样本一致,但声明的 map 地址返回 404。因此,目前没有拿到这份掘金 bundle 对应的 source map。
source map 是构建工具生成的产物位置与源文件位置之间的映射。有可用映射和原始源码时,DevTools 才能显示作者使用的文件与命名;格式化本身只恢复排版。Chrome 的说明介绍了这两者的关系。
这段定位逻辑可以追到 Popper 1.x。公开的 popper.js@1.16.1 发布包带有 source map,其中 sourcesContent 保存了 61 个源文件的内容,包括 src/modifiers/preventOverflow.js。这让我们能直接读到 popperStyles、boundaries、check 等真实命名,而不必给单字母变量猜名字。
下方使用这份上游函数解释机制,关键参数、样式读写和边界公式与现场代码逐项对照,并用 120 组坐标、宽度和越界开关输入核对了结果。这个函数的对照仍不足以确定现场依赖的精确版本;上游 map 的产物坐标也不属于掘金 bundle,不能直接给它挂载使用。原始 map 检查与对照范围见source map 与上游对照记录弹窗,现场格式化代码仍保留在定位源码与行内注释弹窗中。
没有对应 map 或可核对的上游时,AST 分析、反混淆和 AI 重命名可以辅助生成阅读版,但被删除的原始名字、注释和目录结构通常不能无损恢复。此时需要把推断命名与真实源码分开标明。
preventOverflow 中哪些操作接触 DOM
读源码时先分清两个对象:data.instance.popper 是真实浮层元素,data.offsets.popper 是本轮定位的矩形数据。下面函数中的局部变量 popper 指后者,修改它的 left 并不等于立即修改元素样式。
preventOverflow 负责把浮层坐标约束在边界附近。上游原注释说明,临时撤去定位样式,是为了计算文档尺寸时排除浮层自身造成的影响。随后测量边界、恢复样式,再对坐标对象逐边修正。这里完整展示上游函数及其导入,英文注释保留原文,中文说明为文章添加。
// Popper.js@1.16.1/src/modifiers/preventOverflow.js:1-89
// 提取自公开发布包 source map;英文注释为上游原文,中文注释为本文添加。
import getOffsetParent from "../utils/getOffsetParent";
import getBoundaries from "../utils/getBoundaries";
import getSupportedPropertyName from "../utils/getSupportedPropertyName";
/**
* @function
* @memberof Modifiers
* @argument {Object} data - The data object generated by `update` method
* @argument {Object} options - Modifiers configuration and options
* @returns {Object} The data object, properly modified
*/
export default function preventOverflow(data, options) {
let boundariesElement =
options.boundariesElement || getOffsetParent(data.instance.popper);
// If offsetParent is the reference element, we really want to
// go one step up and use the next offsetParent as reference to
// avoid to make this modifier completely useless and look like broken
if (data.instance.reference === boundariesElement) {
boundariesElement = getOffsetParent(boundariesElement);
}
// NOTE: DOM access here
// resets the popper's position so that the document size can be calculated excluding
// the size of the popper element itself
// 保存并临时清空定位样式。上面的英文原注释说明,此举是为了测量不含浮层自身影响的文档尺寸。
const transformProp = getSupportedPropertyName("transform");
const popperStyles = data.instance.popper.style; // assignment to help minification
const { top, left, [transformProp]: transform } = popperStyles;
popperStyles.top = "";
popperStyles.left = "";
popperStyles[transformProp] = "";
// 测量边界。若之前的样式写入使布局失效,这里可能触发同步布局。
const boundaries = getBoundaries(
data.instance.popper,
data.instance.reference,
options.padding,
boundariesElement,
data.positionFixed,
);
// NOTE: DOM access here
// restores the original style properties after the offsets have been computed
// 测量完成,恢复三个原定位样式。
popperStyles.top = top;
popperStyles.left = left;
popperStyles[transformProp] = transform;
options.boundaries = boundaries;
const order = options.priority;
// 此处的 popper 是坐标数据对象;真实 DOM 是 data.instance.popper。
let popper = data.offsets.popper;
const check = {
// left / top 分支:越过较小边界时,用 Math.max 把坐标向内推。
primary(placement) {
let value = popper[placement];
if (popper[placement] < boundaries[placement] && !options.escapeWithReference) {
value = Math.max(popper[placement], boundaries[placement]);
}
return { [placement]: value };
},
// right / bottom 分支:从边界减掉宽高,换算要修正的 left / top。
secondary(placement) {
const mainSide = placement === "right" ? "left" : "top";
let value = popper[mainSide];
if (popper[placement] > boundaries[placement] && !options.escapeWithReference) {
value = Math.min(
popper[mainSide],
boundaries[placement] - (placement === "right" ? popper.width : popper.height),
);
}
return { [mainSide]: value };
},
};
// 按配置优先级逐边检查。以下只改坐标对象,未逐项写入 DOM 样式。
order.forEach((placement) => {
const side = ["left", "top"].indexOf(placement) !== -1 ? "primary" : "secondary";
popper = { ...popper, ...check[side](placement) };
});
data.offsets.popper = popper;
return data;
}
前半段实际接触 DOM 的顺序是写样式、读边界、恢复样式;后半段的 primary、secondary 和优先级循环主要处理 JS 对象。后续 computeStyle、applyStyle 再把计算结果转换为最终样式并写回元素。代码来源与保留原文见上游函数原文弹窗。
用一个横向例子看两个分支:左边越界时,将 left 推到左边界;右边越界时,从右边界扣掉宽度,换算出允许的 left。两步都在修正坐标数据。
临时清空样式有它要解决的几何问题,处理性能时不能只删掉这几次赋值。应先确认测量是否必要、哪些写入使布局失效,以及能否在保持定位结果正确的前提下合并工作。
独立实验:读写顺序怎样影响布局
下面用一个独立的 DOM 示例观察读写顺序的成本。示例中的浮层始终显示,不加载掘金组件或 Popper;它用于比较相同工作量的两种组织方式,不能用来验证原站的实例创建缺陷。
浮层挂在 body 下,锚点留在独立的文档流中。每个浮层读取一次锚点矩形、一次自身高度,再赋值 left、top。两种模式使用相同的测量、计算、写入函数和 rAF 去重调度,只改变调用顺序。
measure 只读取锚点矩形和浮层高度,返回要写入的坐标;apply 把坐标赋给样式。锚点在独立的文档流中,因此浮层位置写入不会改变后续锚点的测量结果。两种循环的区别可以直接落到这四个函数上,外围界面与调度代码省略。
// scroll-positioning/examples/demo.js:21-42
function measure(item) {
const rect = item.anchor.getBoundingClientRect();
const height = item.popup.offsetHeight;
return { x: rect.left, y: rect.top - height - 2 };
}
function apply(item, position) {
item.popup.style.left = `${position.x}px`;
item.popup.style.top = `${position.y}px`;
}
function updateInterleaved() {
for (const item of items) {
const position = measure(item);
apply(item, position);
}
}
function updateBatched() {
const positions = items.map(measure);
items.forEach((item, index) => apply(item, positions[index]));
}
图中标有「布局?」的节点表示可能被迫提前执行的布局工作。批量版本的第一次读取仍可能补做尚未完成的布局,但各次读取之间没有插入浮层位置写入,浏览器也有机会合并最后一批写入后的布局。
这份批量化成立的前提是,本轮位置写入不会改变已读取的几何信息。如果写宽度会引起换行,或写高度会推动后面的锚点,就要按依赖分阶段测量,不能把所有读取机械地搬到最前面。
先在「8 · 普通负载」下切换两种模式并滚动,再选「256 · 压力负载」点击「自动对比」。压力档增加实际浮层数量,用来放大布局差异;没有人为延时或空转循环。两种模式轮流运行,避免慢方案占用同一个主线程时,把并排运行的对照方案一起拖慢。
自动对比按交错、批量、批量、交错的顺序执行四轮(ABBA),每轮先预热 8 次,再测量 48 次。两边沿相同路径触发真实滚动,并逐次等待定位完成。页面展示的是定位回调耗时,包含读取触发的同步布局,不包含末次写入后的延迟布局与绘制。
本次采样使用 Windows、Chrome 156.0.8078.74、1100 × 1000 视口、DPR 1,无头运行并开启录屏,未设置 CPU 降速。256 个浮层的结果如下,时间会受设备、浏览器和当前负载影响。
| 指标 | 读写交错 | 先读后写 |
|---|---|---|
| 定位回调平均耗时,各 96 个样本 | 45.99 ms | 0.73 ms |
| 定位回调 P95 | 51.30 ms | 1.00 ms |
| 独立浏览器采样:24 次滚动的 LayoutCount 增量 | 6144 | 24 |
P95 表示至少 95% 的样本耗时不高于该值。耗时来自页面内 ABBA 测量;布局次数通过 Chrome DevTools Protocol(CDP)读取 Performance 计数器,来自另一组固定 24 次滚动的采样。两组统计口径分别保留在实验原始记录弹窗中。位置断言确认两种模式得到相同坐标,桌面与手机尺寸下也检查了交互和横向溢出。
这组压力实验说明,在同样的几何读取和样式赋值次数下,读写顺序能够改变浏览器的布局工作量。6144 恰好等于 256 × 24,是这份实现和本次采样的结果;不能把几何 API 调用次数直接当成所有页面的布局次数,也不能用这组收益估算掘金原站的收益。
需要显示时,再减少重复工作
简单浮层如果可以和按钮放在同一定位容器中,使用绝对定位自然跟随滚动,就能省掉一部分 JS 更新。需要挂到 body、跨容器定位和边界避让时,再使用完整的定位引擎。
实现 JS 定位时,重点检查这些位置:
- 本轮测量是否复用。箭头、翻转和避让尽量共享已经得到的矩形,减少重复读取。跨轮缓存尺寸时要有尺寸变化通知等失效条件,不能把会随滚动变化的视口坐标一直缓存下来。
- 写入是否改变下一轮尺寸。让浮层在定位前后保持一致的宽度约束,避免位置变化引起换行和重复测量。位置可优先通过
transform更新,减少对布局的影响;它仍不能消除测量本身的成本。Floating UI 的布局要求与位置样式配置说明了对应条件。 - 同一批请求是否重复执行。用待执行标记合并更新请求。每次事件都直接调用
requestAnimationFrame(update)仍可能排入多个回调;rAF 也不会把一次昂贵计算变便宜。MDN 的滚动事件说明提醒,rAF 本身并不是降低滚动回调频率的节流器。 - 坐标更新是否带动无关界面。把位置状态限制在浮层需要的范围,减少整页响应式更新;结果没变化时避免重复业务更新。尺寸监听回调里写尺寸,还要检查是否形成重新测量的反馈循环。
Popper 2 的更新实现会保存本轮参考元素和浮层矩形,并合并尚未执行的普通更新;插件按读、计算、写等阶段组织。不过,单个实例有阶段划分,不代表多个浮层实例之间已经实现了全局批量读写,最终仍要用性能记录检查真实页面。
总结
这次排查定位到的缺口,是组件关闭状态与定位实例监听状态脱节:尺寸通知可以创建隐藏实例,关闭后的销毁也可能被后续通知抵消。只在 watcher 中停止监听,无法约束这些重新启动定位的入口。受控浏览器中,增加尺寸回调的显示判断后,24 次滚动触发的隐藏实例更新从 168 次降为 0 次。
耗时函数与触发原因需要分别追踪。Popper 的源码说明了测量和写入发生在哪里,组件封装则决定这些工作何时启动。本例支持修正封装层的生命周期管理;原站剩余布局开销仍需继续测量。
对确实需要执行的定位,独立实验展示了另一个优化方向:在几何依赖允许的前提下组织读写,让浏览器合并布局。它可以帮助理解成本,但实验中的批量读写收益不能作为原站修复收益。
掘金小册现场观察摘要
这是当次现场观察的整理记录,不是原始 CPU Profile。保留了目标页面、资源哈希、产物定位点、隐藏浮层状态和证据局限。线上页面后续可能变化。
观察记录与源码定位
{
"recordType": "manual-observation-summary",
"observedDate": "2026-11-01",
"note": "根据当次页面调试记录整理;不是原始 CPU Profile、事件日志或完整性能追踪。",
"pageUrl": "https://juejin.cn/book/7223230325122400288/section/7224404685799555111",
"browser": "Edge 156",
"devicePixelRatio": 2,
"bundle": {
"url": "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/0266bcd.js",
"bytes": 2471378,
"sha256": "22d84ad71517db20c780b71753b612c62cda915737e9b00fc14090f807019be3",
"locations": [
{
"name": "scheduleUpdate",
"line": 4,
"column": 775534,
"behavior": "requestAnimationFrame 调度 update"
},
{
"name": "B / update",
"line": 4,
"column": 763308,
"behavior": "测量参考元素、执行 modifiers、调用 onUpdate"
},
{
"name": "preventOverflow.fn",
"line": 4,
"column": 768709,
"behavior": "临时清空 top/left/transform,读取边界后恢复"
},
{
"name": "updatePopper",
"line": 4,
"column": 181782,
"behavior": "组件层调用 scheduleUpdate"
}
]
},
"hiddenOverlayObservation": {
"count": 7,
"display": "none",
"stateEventsEnabled": true
},
"controlledComparisonNotes": {
"action": "相同滚动路径,临时停止这 7 个隐藏浮层的事件监听",
"original": {
"scriptDurationSeconds": 3.5,
"taskDurationSeconds": 5.58,
"wallSeconds": 5.6
},
"listenersDisabled": {
"scriptDurationSeconds": 0.94,
"taskDurationSeconds": 5.02,
"wallSeconds": 5.11
},
"note": "一次现场对照的手工摘要,不能外推为固定收益;并非对原站源码的正式修复。"
},
"limits": [
"未取得完整 CPU Profile,不能把全部卡顿归给某一次函数调用。",
"Long Animation Frame 脚本区间可能包含回调后的微任务,不能视为该回调的纯函数自身耗时。",
"几何 API 调用次数不等于整页重新布局次数。",
"未以相同方式单独测量正常显示中的原站浮层;配套压力实验是独立模型。"
]
}读写顺序实验的原始样本与 CDP 指标
记录包含浏览器、视口、浮层数量、四轮逐次回调耗时、统计口径和独立 CDP Performance 指标。自动测试包含真实 scroll 事件与两次 rAF 间的渲染机会,回调计时本身不等于完整帧耗时。
原始实验记录
{
"measuredAt": "2026-11-02T00:17:58.365Z",
"browser": "156.0.8078.74",
"conditions": {
"width": 1100,
"height": 1000,
"dpr": 1,
"overlayCount": 256,
"cpuThrottling": "none",
"warmupScrolls": 4,
"measuredScrolls": 24,
"headless": true,
"videoRecording": true
},
"cdp": [
{
"mode": "interleave",
"metrics": {
"LayoutCount": 6144,
"LayoutDuration": 0.542814,
"RecalcStyleCount": 6144,
"RecalcStyleDuration": 0.012604,
"ScriptDuration": 0.021001,
"TaskDuration": 0.685001
}
},
{
"mode": "batched",
"metrics": {
"LayoutCount": 24,
"LayoutDuration": 0.003271000000000024,
"RecalcStyleCount": 24,
"RecalcStyleDuration": 0.004742,
"ScriptDuration": 0.008659999999999998,
"TaskDuration": 0.09074700000000002
}
},
{
"mode": "batched",
"metrics": {
"LayoutCount": 24,
"LayoutDuration": 0.004007000000000094,
"RecalcStyleCount": 24,
"RecalcStyleDuration": 0.005034,
"ScriptDuration": 0.010606000000000004,
"TaskDuration": 0.10683900000000002
}
},
{
"mode": "interleave",
"metrics": {
"LayoutCount": 6144,
"LayoutDuration": 0.9778180000000001,
"RecalcStyleCount": 6144,
"RecalcStyleDuration": 0.031728000000000006,
"ScriptDuration": 0.044053,
"TaskDuration": 1.1935479999999998
}
}
],
"automatic": {
"schemaVersion": 1,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36",
"viewport": {
"width": 1100,
"height": 1000,
"dpr": 1
},
"overlayCount": 256,
"readsPerUpdate": 512,
"assignmentsPerUpdate": 512,
"timingScope": "position callback; includes forced synchronous layout; excludes deferred rendering",
"runs": [
{
"mode": "interleave",
"samplesMs": [
44,
43.5,
43.20000000298023,
45.69999999925494,
48.400000002235174,
45.5,
49.5,
48,
46.899999998509884,
46.599999997764826,
43.399999998509884,
44.19999999925494,
43.600000001490116,
46.30000000074506,
46.100000001490116,
43.19999999925494,
43,
41.5,
44.70000000298023,
43.5,
48.600000001490116,
46.69999999925494,
44.899999998509884,
42.899999998509884,
42.69999999925494,
44.19999999925494,
46.80000000074506,
45,
45.900000002235174,
43,
43.100000001490116,
44.100000001490116,
46.80000000074506,
44.600000001490116,
44.399999998509884,
44.599999997764826,
42.599999997764826,
46.399999998509884,
47.30000000074506,
45.899999998509884,
46.69999999925494,
42.399999998509884,
42.100000001490116,
43.5,
41.69999999925494,
47,
48.80000000074506,
47.899999998509884
]
},
{
"mode": "batched",
"samplesMs": [
0.6999999992549419,
0.3999999985098839,
0.5,
0.6999999992549419,
0.5,
1.1000000014901161,
0.6999999992549419,
0.6999999992549419,
0.5,
0.5,
0.5,
0.6000000014901161,
0.5999999977648258,
0.8000000007450581,
0.5999999977648258,
0.9000000022351742,
0.9000000022351742,
1,
0.8000000007450581,
0.9000000022351742,
1,
0.9000000022351742,
0.7000000029802322,
0.8000000007450581,
0.7000000029802322,
0.9000000022351742,
0.8000000007450581,
0.6000000014901161,
0.6999999992549419,
0.7000000029802322,
0.6999999992549419,
0.8000000007450581,
1,
0.8999999985098839,
0.8000000007450581,
0.8000000007450581,
0.8000000007450581,
0.8000000007450581,
0.8000000007450581,
0.6999999992549419,
0.6000000014901161,
0.6999999992549419,
0.8000000007450581,
0.6999999992549419,
0.6999999992549419,
0.6999999992549419,
0.8000000007450581,
0.6999999992549419
]
},
{
"mode": "batched",
"samplesMs": [
0.8000000007450581,
0.6999999992549419,
0.7999999970197678,
0.7999999970197678,
0.8000000007450581,
0.6999999992549419,
0.5,
0.5999999977648258,
0.7999999970197678,
0.6999999992549419,
0.8999999985098839,
0.8000000007450581,
0.8999999985098839,
0.8000000007450581,
0.6999999992549419,
0.6999999992549419,
0.6999999992549419,
0.5,
0.6999999992549419,
0.5,
0.6000000014901161,
0.5,
1.0999999977648258,
0.8000000007450581,
0.5,
0.7000000029802322,
0.6000000014901161,
0.7000000029802322,
0.9000000022351742,
0.6999999992549419,
0.6999999992549419,
0.5999999977648258,
0.8000000007450581,
0.6000000014901161,
0.6999999992549419,
0.8000000007450581,
0.6999999992549419,
0.7999999970197678,
0.8000000007450581,
0.9000000022351742,
0.8000000007450581,
0.6999999992549419,
0.6999999992549419,
0.5999999977648258,
0.5999999977648258,
0.5,
0.6999999992549419,
0.5999999977648258
]
},
{
"mode": "interleave",
"samplesMs": [
46.899999998509884,
45.20000000298023,
52.19999999925494,
43.19999999925494,
42.399999998509884,
43.30000000074506,
43.100000001490116,
43.899999998509884,
47,
47.100000001490116,
46,
54,
45,
44.70000000298023,
47.5,
45.399999998509884,
49.70000000298023,
48.5,
44.600000001490116,
49.600000001490116,
47.69999999925494,
49.899999998509884,
47.900000002235174,
47.30000000074506,
50.5,
57.600000001490116,
46.599999997764826,
47.599999997764826,
46.899999998509884,
47.80000000074506,
47.30000000074506,
46.399999998509884,
44.79999999701977,
46.30000000074506,
43.69999999925494,
46.099999997764826,
47.19999999925494,
45.899999998509884,
54.899999998509884,
46.20000000298023,
51.29999999701977,
46.80000000074506,
45.5,
44.100000001490116,
44.79999999701977,
43.100000001490116,
44.400000002235174,
45.80000000074506
]
}
],
"summary": {
"interleave": {
"count": 96,
"meanMs": 45.99062499997672,
"p95Ms": 51.29999999701977
},
"batched": {
"count": 96,
"meanMs": 0.725000000015522,
"p95Ms": 1
}
}
},
"mobile": {
"width": 390,
"scrollWidth": 390,
"count": 8
},
"errors": []
}浮层定位更新与布局测量源码
打开原文件 popper-layout.annotated.js
这份阅读片段从同一份产物提取,保留组件默认配置、完整的 preventOverflow、更新入口、modifier 调用、边界测量及最终样式写入。各段按调用关系排列,外层模块和其余 helper 已省略,不能作为独立脚本运行。中文解释注释为文章添加;原标识符和表达式保持不变。
格式化并加注释的源码片段
// 源文件:https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/0266bcd.js
// SHA-256:22d84ad71517db20c780b71753b612c62cda915737e9b00fc14090f807019be3
// 从当次产物提取,以 Prettier 格式化;中文解释注释为本文添加。
// 保留原标识符与表达式,按调用关系排列。省略外层模块、其他 modifier 及辅助函数,供阅读而非独立运行。
// 逗号表达式从左到右执行,并返回最后一项;不能把其中几次操作误认为同时发生。
// 0266bcd.js:4:178938
t.default = {
// ...省略组件的 props、watch 和其他方法
data: function () {
return {
popperJS: null,
popperStatus: !1,
// 组件层的默认选项把边界设为 window,覆盖库默认的 scrollParent。
// 这里展示默认数据;调用方仍可通过 popperOptions 提供选项。
popperOptionsData: {
placement: this.placement,
computeStyle: { gpuAcceleration: !0 },
modifiers: { preventOverflow: { boundariesElement: "window" } },
},
_timer: null,
_disabledAppendToBody: !1,
};
},
};
// 0266bcd.js:4:768668,pe.modifiers.preventOverflow
var pe = {
// ...省略其他默认选项
modifiers: {
// ...省略其他 modifier
preventOverflow: {
// 300 是在 modifier 链中的执行顺序,!0 等价于 true。
order: 300,
enabled: !0,
// data 是本轮定位数据;e 是 preventOverflow 的运行时选项。
fn: function (data, e) {
// t 是边界元素或边界类型;_ 是获取定位父元素的 helper。
// 边界若恰好是参考元素自身,就继续向外取定位父元素。
var t = e.boundariesElement || _(data.instance.popper);
data.instance.reference === t && (t = _(t));
// n 是浏览器支持的 transform 属性名;r 是浮层的 style。
// o、l、c 保存现有的 top、left、transform 字符串。
var n = K("transform"),
r = data.instance.popper.style,
o = r.top,
l = r.left,
c = r[n];
// 写 DOM:临时清空浮层的三个定位样式。
((r.top = ""), (r.left = ""), (r[n] = ""));
// 读几何信息:E 计算边界,内部通过 M、I 等函数测量。
// 如果前面的样式改动使布局失效,读取可能在这里触发同步布局。
// 参数依次是浮层、参考元素、内边距、边界、fixed 定位标记。
var d = E(
data.instance.popper,
data.instance.reference,
e.padding,
t,
data.positionFixed,
);
// 再次写 DOM:恢复原定位样式,再把边界 d 留在选项对象上。
((r.top = o), (r.left = l), (r[n] = c), (e.boundaries = d));
// h 是边界检查顺序;f 是当前浮层的坐标和尺寸。
// 以下 primary / secondary 只计算并合并坐标对象,不逐项写 DOM。
var h = e.priority,
f = data.offsets.popper,
m = {
// 处理 left / top:越过最小边界时把坐标向内推。
// 此处参数 t 是边名,和外层的边界元素 t 属于不同作用域。
primary: function (t) {
var n = f[t];
return (
f[t] < d[t] && !e.escapeWithReference && (n = Math.max(f[t], d[t])),
// T({}, t, n) 在这里生成只有一个坐标属性的对象,等价于 { [t]: n }。
T({}, t, n)
);
},
// 处理 right / bottom:换算为 left / top,再扣除浮层宽高。
// escapeWithReference 开启时,下面的边界修正条件不成立。
secondary: function (t) {
var n = "right" === t ? "left" : "top",
r = f[n];
return (
f[t] > d[t] &&
!e.escapeWithReference &&
(r = Math.min(f[n], d[t] - ("right" === t ? f.width : f.height))),
T({}, n, r)
);
},
};
return (
// 按优先级逐边修正。这里的 e 是边名,遮蔽了外层的选项参数 e。
h.forEach(function (e) {
var t = -1 !== ["left", "top"].indexOf(e) ? "primary" : "secondary";
// W 在这里相当于 Object.assign:合并本次修正后的坐标。
f = W({}, f, m[t](e));
}),
// 写回本轮数据并返回;后续 computeStyle / applyStyle 负责最终位置样式。
(data.offsets.popper = f),
data
);
},
priority: ["left", "right", "top", "bottom"],
padding: 5,
// 这是库默认值,可被组件和调用方覆盖;组件默认配置见配套源码。
boundariesElement: "scrollParent",
},
},
};
// 0266bcd.js:4:763308
// B 是定位更新入口:先构造 data,测量参考元素并生成浮层初始坐标,再交给 D。
// O 计算参考元素偏移,P 选择 placement,F 生成浮层初始矩形。
function B() {
if (!this.state.isDestroyed) {
var data = {
instance: this,
styles: {},
arrowStyles: {},
attributes: {},
flipped: !1,
offsets: {},
};
((data.offsets.reference = O(
this.state,
this.popper,
this.reference,
this.options.positionFixed,
)),
(data.placement = P(
this.options.placement,
data.offsets.reference,
this.popper,
this.reference,
this.options.modifiers.flip.boundariesElement,
this.options.modifiers.flip.padding,
)),
(data.originalPlacement = data.placement),
(data.positionFixed = this.options.positionFixed),
(data.offsets.popper = F(this.popper, data.offsets.reference, data.placement)),
(data.offsets.popper.position = this.options.positionFixed ? "fixed" : "absolute"),
(data = D(this.modifiers, data)),
this.state.isCreated
? this.options.onUpdate(data)
: ((this.state.isCreated = !0), this.options.onCreate(data)));
}
}
// 0266bcd.js:4:762824
// D 依次执行 modifier;e 是 modifier 数组,t 是可选的截断位置。
// 数组在实例初始化时按 order 排序;这里仅执行 enabled 且 fn 可调用的项目。
function D(e, data, t) {
return (
(void 0 === t
? e
: e.slice(
0,
(function (e, t, n) {
if (Array.prototype.findIndex)
return e.findIndex(function (e) {
return e[t] === n;
});
var r = j(e, function (e) {
return e[t] === n;
});
return e.indexOf(r);
})(e, "name", t),
)
).forEach(function (e) {
e.function && console.warn("`modifier.function` is deprecated, use `modifier.fn`!");
var t = e.function || e.fn;
e.enabled &&
c(t) &&
((data.offsets.popper = G(data.offsets.popper)),
(data.offsets.reference = G(data.offsets.reference)),
// 调用具体 modifier,包括正文展示的 preventOverflow.fn。
(data = t(data, e)));
}),
data
);
}
// 0266bcd.js:4:760717
// E 计算边界:e 是浮层,t 是参考元素,n 是 padding,r 是边界元素或类型。
// 第五个参数通过 arguments[4] 读取,o 表示是否为 fixed 定位。
// c 是几何计算参照;L / x 分别处理 fixed 参照和共同定位祖先。
function E(e, t, n, r) {
var o = arguments.length > 4 && void 0 !== arguments[4] && arguments[4],
l = { top: 0, left: 0 },
c = o ? L(e) : x(e, t);
if ("viewport" === r) l = A(c, o);
else {
var d = void 0;
"scrollParent" === r
? "BODY" === (d = f(h(t))).nodeName && (d = e.ownerDocument.documentElement)
: (d = "window" === r ? e.ownerDocument.documentElement : r);
// 非 viewport 分支先选出边界节点 d,再测量它相对参照 c 的矩形。
var m = M(d, c, o);
if ("HTML" !== d.nodeName || N(c)) l = m;
else {
var v = V(e.ownerDocument),
y = v.height,
_ = v.width;
((l.top += m.top - m.marginTop),
(l.bottom = y + m.top),
(l.left += m.left - m.marginLeft),
(l.right = _ + m.left));
}
}
// 以下只根据 padding 收缩边界对象,不修改元素样式。
var w = "number" == typeof (n = n || 0);
return (
(l.left += w ? n : n.left || 0),
(l.top += w ? n : n.top || 0),
(l.right -= w ? n : n.right || 0),
(l.bottom -= w ? n : n.bottom || 0),
l
);
}
// 0266bcd.js:4:759397
// M 把元素 e 的矩形换算到参照元素 t 的坐标系,兼顾边框、margin 和滚动。
function M(e, t) {
var n = arguments.length > 2 && void 0 !== arguments[2] && arguments[2],
r = y(10),
o = "HTML" === t.nodeName,
// 读取两份矩形。I 内部会调用 getBoundingClientRect 和尺寸属性。
l = I(e),
c = I(t),
h = f(e),
// d(t) 读取计算样式,下面使用参照元素的边框与 margin。
m = d(t),
v = parseFloat(m.borderTopWidth, 10),
_ = parseFloat(m.borderLeftWidth, 10);
n && o && ((c.top = Math.max(c.top, 0)), (c.left = Math.max(c.left, 0)));
var w = G({
top: l.top - c.top - v,
left: l.left - c.left - _,
width: l.width,
height: l.height,
});
if (((w.marginTop = 0), (w.marginLeft = 0), !r && o)) {
var x = parseFloat(m.marginTop, 10),
S = parseFloat(m.marginLeft, 10);
((w.top -= v - x),
(w.bottom -= v - x),
(w.left -= _ - S),
(w.right -= _ - S),
(w.marginTop = x),
(w.marginLeft = S));
}
return (
(r && !n ? t.contains(h) : t === h && "BODY" !== h.nodeName) && (w = k(w, t)),
w
);
}
// 0266bcd.js:4:758788
// I 读取 DOM 几何信息,并做浏览器兼容与尺寸修正;G 补出 right、bottom。
// 只有读取需要更新的布局时才可能触发同步布局,不是每次 API 调用都必然重排。
function I(element) {
var rect = {};
try {
if (y(10)) {
rect = element.getBoundingClientRect();
var e = S(element, "top"),
t = S(element, "left");
((rect.top += e), (rect.left += t), (rect.bottom += e), (rect.right += t));
} else rect = element.getBoundingClientRect();
} catch (e) {}
var n = {
left: rect.left,
top: rect.top,
width: rect.right - rect.left,
height: rect.bottom - rect.top,
},
r = "HTML" === element.nodeName ? V(element.ownerDocument) : {},
o = r.width || element.clientWidth || n.right - n.left,
l = r.height || element.clientHeight || n.bottom - n.top,
c = element.offsetWidth - o,
h = element.offsetHeight - l;
if (c || h) {
var f = d(element);
((c -= Z(f, "x")), (h -= Z(f, "y")), (n.width -= c), (n.height -= h));
}
return G(n);
}
// 0266bcd.js:4:765735
// ie 是最终样式写入 helper:尺寸和位置的数值补 px,其余属性直接写 style。
function ie(element, e) {
Object.keys(e).forEach(function (t) {
var n = "";
(-1 !== ["width", "height", "top", "right", "bottom", "left"].indexOf(t) &&
ne(e[t]) &&
(n = "px"),
(element.style[t] = e[t] + n));
});
}
// 0266bcd.js:4:774779
var pe = {
modifiers: {
// applyStyle 排在 order 900,接收前面计算的 data.styles,并通过 ie 写回 DOM。
// 保留 onLoad 初始化分支,避免把初始化写入和滚动时 fn 的写入混为一谈。
applyStyle: {
order: 900,
enabled: !0,
fn: function (data) {
var element, e;
return (
ie(data.instance.popper, data.styles),
(element = data.instance.popper),
(e = data.attributes),
Object.keys(e).forEach(function (t) {
!1 !== e[t] ? element.setAttribute(t, e[t]) : element.removeAttribute(t);
}),
data.arrowElement &&
Object.keys(data.arrowStyles).length &&
ie(data.arrowElement, data.arrowStyles),
data
);
},
onLoad: function (e, t, n, r, o) {
var l = O(o, t, e, n.positionFixed),
c = P(
n.placement,
l,
t,
e,
n.modifiers.flip.boundariesElement,
n.modifiers.flip.padding,
);
return (
t.setAttribute("x-placement", c),
ie(t, { position: n.positionFixed ? "fixed" : "absolute" }),
n
);
},
gpuAcceleration: void 0,
},
},
};
source map 公开地址检查与上游函数对照
打开原文件 source-map-investigation.json
记录包含掘金 JS 和 map 的地址、状态、哈希,以及已获取的 Popper 发布包 map 中的源文件数量、所选源码位置和哈希。120 组对照隔离了边界测量,核对了坐标修正、测量参数和样式恢复,不能据此确定整个依赖版本。source map 的映射归属于具体产物;其他构建的 map 不能直接复用到现场 bundle。
检查结果与验证边界
{
"checkedAt": "2026-11-02T01:23:30.327Z",
"siteBundle": {
"url": "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/0266bcd.js",
"status": 200,
"sha256": "22d84ad71517db20c780b71753b612c62cda915737e9b00fc14090f807019be3",
"sourceMappingUrls": [
"0266bcd.js.map"
],
"sourceMapHeader": null,
"xSourceMapHeader": null
},
"declaredMap": {
"url": "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/0266bcd.js.map",
"status": 404,
"contentType": "application/json",
"error": "The specified key does not exist."
},
"retrievedUpstreamMap": {
"url": "https://unpkg.com/popper.js@1.16.1/dist/umd/popper.js.map",
"sha256": "1408c0eb5570ed3279b7d6a9a6f5b3c1cdeb6e6a67e2ec60cfb49bfd608dcd92",
"bytes": 140747,
"version": 3,
"sources": 61,
"sourcesContent": 61,
"names": 381,
"selectedSourceIndex": 54,
"selectedSourcePath": "../../src/modifiers/preventOverflow.js",
"selectedSourceSha256": "e97d67ceabc87a119f95c58f45daacfab9fdb409b0fa54d99f5a3268fa2141e9"
},
"comparison": {
"sameNormalizedMinifiedCode": false,
"behaviorCases": 120,
"behaviorsEqual": true,
"scope": "对比 preventOverflow 函数;用桩隔离边界测量,核对坐标、样式恢复、测量时样式与调用参数。不能证明整个依赖版本相同。",
"aliases": {
"_": "getOffsetParent",
"K": "getSupportedPropertyName",
"E": "getBoundaries",
"T": "_defineProperty",
"W": "_extends"
}
},
"limits": [
"没有取得掘金 bundle 对应的 source map,不能直接建立其压缩位置到作者源文件的位置映射。",
"上游 1.16.1 仅作为已核对的函数对照,不能据此锁定现场依赖版本或恢复掘金业务源码。",
"120 组对照隔离了边界测量,并核对坐标、样式恢复及测量调用;不能替代完整浏览器几何验证。"
]
}Popper 1.16.1 的 preventOverflow 原始源码
源文件从公开发布包的 source map 的 sourcesContent 提取,未添加正文中的中文注释。原路径为 src/modifiers/preventOverflow.js,包的 source map URL 和文件哈希保留在对照记录中。源码版权归 Federico Zivolo,按 MIT 许可提供;这里保存的是上游文件,并非掘金业务源码的还原结果。
上游源码原文
import getOffsetParent from '../utils/getOffsetParent';
import getBoundaries from '../utils/getBoundaries';
import getSupportedPropertyName from '../utils/getSupportedPropertyName';
/**
* @function
* @memberof Modifiers
* @argument {Object} data - The data object generated by `update` method
* @argument {Object} options - Modifiers configuration and options
* @returns {Object} The data object, properly modified
*/
export default function preventOverflow(data, options) {
let boundariesElement =
options.boundariesElement || getOffsetParent(data.instance.popper);
// If offsetParent is the reference element, we really want to
// go one step up and use the next offsetParent as reference to
// avoid to make this modifier completely useless and look like broken
if (data.instance.reference === boundariesElement) {
boundariesElement = getOffsetParent(boundariesElement);
}
// NOTE: DOM access here
// resets the popper's position so that the document size can be calculated excluding
// the size of the popper element itself
const transformProp = getSupportedPropertyName('transform');
const popperStyles = data.instance.popper.style; // assignment to help minification
const { top, left, [transformProp]: transform } = popperStyles;
popperStyles.top = '';
popperStyles.left = '';
popperStyles[transformProp] = '';
const boundaries = getBoundaries(
data.instance.popper,
data.instance.reference,
options.padding,
boundariesElement,
data.positionFixed
);
// NOTE: DOM access here
// restores the original style properties after the offsets have been computed
popperStyles.top = top;
popperStyles.left = left;
popperStyles[transformProp] = transform;
options.boundaries = boundaries;
const order = options.priority;
let popper = data.offsets.popper;
const check = {
primary(placement) {
let value = popper[placement];
if (
popper[placement] < boundaries[placement] &&
!options.escapeWithReference
) {
value = Math.max(popper[placement], boundaries[placement]);
}
return { [placement]: value };
},
secondary(placement) {
const mainSide = placement === 'right' ? 'left' : 'top';
let value = popper[mainSide];
if (
popper[placement] > boundaries[placement] &&
!options.escapeWithReference
) {
value = Math.min(
popper[mainSide],
boundaries[placement] -
(placement === 'right' ? popper.width : popper.height)
);
}
return { [mainSide]: value };
},
};
order.forEach(placement => {
const side =
['left', 'top'].indexOf(placement) !== -1 ? 'primary' : 'secondary';
popper = { ...popper, ...check[side](placement) };
});
data.offsets.popper = popper;
return data;
}
ByteTooltip 与 BytePopover 共用的 Popper 封装
打开原文件 component-lifecycle.annotated.js
从相同哈希的 0266bcd.js 提取默认配置、watch、挂载与销毁生命周期,以及实例创建、销毁、更新和尺寸回调。保留产物中的标识符和控制流;各段注明压缩资源行列,外围模块、导入和其他方法已省略,片段不能独立运行。createPopper 内的 l.default 为该模块导入的 Popper 构造器,d.addResizeListener 由 ResizeObserver 通知尺寸变化。
格式化后的组件生命周期源码
// 0266bcd.js:4:178424,Popper 组件封装的相关字段
// 保留标识符与控制流,中文注释为文章添加。
t.default = {
props: {
// ...省略其余 props
// 0266bcd.js:4:178895
autoFixPosition: { type: Boolean, default: !0 },
},
// 0266bcd.js:4:178938
data: function () {
return {
popperJS: null,
popperStatus: !1,
popperOptionsData: {
placement: this.placement,
computeStyle: { gpuAcceleration: !0 },
modifiers: { preventOverflow: { boundariesElement: "window" } },
},
_timer: null,
_disabledAppendToBody: !1,
};
},
// 0266bcd.js:4:179166
watch: {
popperStatus: function (e) {
(e
? (this.$emit("on-show"),
this.popperJS && this.popperJS.enableEventListeners(),
this.updatePopper())
: (this.$emit("on-hide"), this.popperJS && this.popperJS.disableEventListeners()),
this.$emit("input", e));
},
disabled: function (e) {
e && (this.popperStatus = !1);
},
},
// 0266bcd.js:4:179590
mounted: function () {
var e = this;
this.$nextTick(
function () {
((0, r.default)(this, e),
this.autoFixPosition &&
this.popperElm &&
(0, d.addResizeListener)(this.popperElm, this.handleResize));
}.bind(this),
);
},
// 0266bcd.js:4:179778
beforeDestroy: function () {
(this.destroyPopper(),
this.autoFixPosition &&
this.popperElm &&
(0, d.removeResizeListener)(this.popperElm, this.handleResize));
},
methods: {
// ...省略鼠标事件、延时开关和箭头添加等方法
// 0266bcd.js:4:180404
destroyPopper: function () {
(this.popperJS && (this.popperJS.destroy(), (this.popperJS = null)),
this.appendedToBody &&
((this.appendedToBody = !1), document.body.removeChild(this.popperElm)));
},
// 0266bcd.js:4:180578
createPopper: function () {
var e = this;
this.$nextTick(
function () {
if (
((0, r.default)(this, e),
this.visibleArrow && this.appendArrow(this.popperElm),
!this.appendToBody ||
this.appendedToBody ||
this._disabledAppendToBody ||
((this.appendedToBody = !0), document.body.appendChild(this.popperElm)),
this.popperJS && this.popperJS.destroy && this.popperJS.destroy(),
this.boundariesSelector)
) {
var t = document.querySelector(this.boundariesSelector);
t &&
((this.popperOptionsData.modifiers = (0, o.default)(
{},
this.popperOptionsData.modifiers,
)),
(this.popperOptionsData.modifiers.preventOverflow = (0, o.default)(
{},
this.popperOptionsData.modifiers.preventOverflow,
)),
(this.popperOptionsData.modifiers.preventOverflow.boundariesElement = t));
}
((this.popperOptionsData.onCreate = function () {
((0, r.default)(this, e), this.$nextTick(this.updatePopper));
}.bind(this)),
(this.popperJS = new l.default(
this.referenceElm,
this.popperElm,
this.popperOptionsData,
)),
(this.popperJS.popper.style.width = this.width),
(this.popperJS.popper.style["max-width"] = this.maxWidth));
}.bind(this),
);
},
// 0266bcd.js:4:181769
updatePopper: function () {
this.popperJS
? (this.popperJS.scheduleUpdate(),
(this.popperJS.popper.style.zIndex = c.default.nextZIndex()))
: this.createPopper();
},
// 0266bcd.js:4:181918
handleResize: function () {
this.updatePopper();
},
},
};
隐藏实例创建与滚动更新的受控对照
打开原文件 component-lifecycle-comparison.json
两个全新匿名 Chrome 上下文分别加载原 bundle 和仅替换 handleResize 的 bundle。记录尺寸通知到达前的实例状态、24 次真实 window 滚动的事件与 onUpdate 次数,以及一个 Tooltip 的开关结果。网络替换只发生在受控浏览器内,不修改服务器。结果用于核对触发链,不代表完整 CPU Profile 或整页性能收益。
生命周期状态、尺寸通知与对照结果
{
"observedAt": "2026-11-02T01:55:06.425Z",
"pageUrl": "https://juejin.cn/book/7223230325122400288/section/7224404685799555111",
"bundleUrl": "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/0266bcd.js",
"browser": "156.0.8078.74",
"viewport": {
"width": 1280,
"height": 960
},
"needle": "handleResize:function(){this.updatePopper()}",
"replacement": "handleResize:function(){if(this.popperStatus)this.updatePopper()}",
"scope": "全新匿名浏览器上下文;仅在受控浏览器网络响应内替换 handleResize,不修改原站;只记录状态与更新次数,不测量性能收益。",
"runs": [
{
"patched": false,
"originalHash": "22d84ad71517db20c780b71753b612c62cda915737e9b00fc14090f807019be3",
"replacementCount": 0,
"before": [
{
"uid": 58,
"component": "ByteTooltip",
"popperStatus": false,
"display": "none",
"hasInstance": true,
"eventsEnabled": true
},
{
"uid": 60,
"component": "ByteTooltip",
"popperStatus": false,
"display": "none",
"hasInstance": true,
"eventsEnabled": true
},
{
"uid": 90,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": true,
"eventsEnabled": true
},
{
"uid": 106,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": true,
"eventsEnabled": true
},
{
"uid": 122,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": true,
"eventsEnabled": true
},
{
"uid": 138,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": true,
"eventsEnabled": true
},
{
"uid": 154,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": true,
"eventsEnabled": true
}
],
"scroll": {
"events": 24,
"updates": 168
},
"transition": {
"component": "ByteTooltip",
"open": {
"popperStatus": true,
"display": "block",
"hasInstance": true,
"eventsEnabled": true
},
"closed": {
"popperStatus": false,
"display": "none",
"hasInstance": true,
"eventsEnabled": true
}
},
"log": {
"resize": [
{
"at": 1471.5,
"uid": 58,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1474,
"uid": 60,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1671.800000000745,
"uid": 90,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1673.199999999255,
"uid": 106,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1673.8999999985099,
"uid": 122,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1674.3999999985099,
"uid": 138,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1675.199999999255,
"uid": 154,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 6244.300000000745,
"uid": 58,
"popperStatus": true,
"hadInstanceBeforeCallback": true,
"display": "block",
"width": 204,
"height": 20
},
{
"at": 6853,
"uid": 58,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
}
],
"actualScrollEvents": 24,
"updates": 170
},
"errors": []
},
{
"patched": true,
"originalHash": "22d84ad71517db20c780b71753b612c62cda915737e9b00fc14090f807019be3",
"replacementCount": 1,
"before": [
{
"uid": 58,
"component": "ByteTooltip",
"popperStatus": false,
"display": "none",
"hasInstance": false,
"eventsEnabled": null
},
{
"uid": 60,
"component": "ByteTooltip",
"popperStatus": false,
"display": "none",
"hasInstance": false,
"eventsEnabled": null
},
{
"uid": 90,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": false,
"eventsEnabled": null
},
{
"uid": 106,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": false,
"eventsEnabled": null
},
{
"uid": 122,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": false,
"eventsEnabled": null
},
{
"uid": 138,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": false,
"eventsEnabled": null
},
{
"uid": 154,
"component": "BytePopover",
"popperStatus": false,
"display": "none",
"hasInstance": false,
"eventsEnabled": null
}
],
"scroll": {
"events": 24,
"updates": 0
},
"transition": {
"component": "ByteTooltip",
"open": {
"popperStatus": true,
"display": "block",
"hasInstance": true,
"eventsEnabled": true
},
"closed": {
"popperStatus": false,
"display": "none",
"hasInstance": false,
"eventsEnabled": null
}
},
"log": {
"resize": [
{
"at": 1326.5999999977648,
"uid": 58,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1327.0999999977648,
"uid": 60,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1490.3999999985099,
"uid": 90,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1490.5999999977648,
"uid": 106,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1490.5999999977648,
"uid": 122,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1490.7999999970198,
"uid": 138,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 1490.8999999985099,
"uid": 154,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
},
{
"at": 6131.699999999255,
"uid": 58,
"popperStatus": true,
"hadInstanceBeforeCallback": true,
"display": "block",
"width": 204,
"height": 20
},
{
"at": 6732.199999999255,
"uid": 58,
"popperStatus": false,
"hadInstanceBeforeCallback": false,
"display": "none",
"width": 0,
"height": 0
}
],
"actualScrollEvents": 24,
"updates": 0
},
"errors": []
}
]
}