useScrollRestoration.js
11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
import { nextTick } from "vue";
export const useScrollRestoration = ({
get_key,
get_scroll_el,
max_age_ms = 30 * 60 * 1000,
is_log_enabled,
log_prefix = "scroll",
logger,
} = {}) => {
/**
* @description 输出滚动调试日志(默认关闭;由 is_log_enabled 控制)
* @param {...any} args - 日志参数
* @returns {void}
*/
const log = (...args) => {
const enabled = typeof is_log_enabled === "function" ? Boolean(is_log_enabled()) : Boolean(is_log_enabled);
if (!enabled) return;
if (typeof logger !== "function") return;
logger(`[${String(log_prefix || "scroll")}]`, ...args);
};
/**
* @description 等待一帧(优先使用 requestAnimationFrame;降级为 setTimeout)
* @returns {Promise<void>}
*/
const wait_frame = () => {
return new Promise((resolve) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => resolve());
return;
}
setTimeout(() => resolve(), 16);
});
};
/**
* @description 轮询等待条件达成(用于等待布局稳定/DOM 渲染完成)
* @param {Function} check - 返回 true 表示达成;支持返回 Promise
* @param {number} timeout_ms - 超时时间(0 表示不超时)
* @param {number} interval_ms - 轮询间隔(<=0 时按帧轮询)
* @returns {Promise<boolean>} 是否在超时前达成
*/
const wait_until = async (check, timeout_ms, interval_ms) => {
const start = Date.now();
const timeout = typeof timeout_ms === "number" ? timeout_ms : 0;
const interval = typeof interval_ms === "number" ? interval_ms : 16;
while (Date.now() - start < timeout || timeout === 0) {
let ok = false;
try {
ok = Boolean(await check());
} catch (e) {
ok = false;
}
if (ok) return true;
if (interval <= 0) {
await wait_frame();
} else {
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
return false;
};
/**
* @description 判断是否是“手动刷新”进入(用于处理浏览器滚动回填)
* @returns {boolean}
*/
const is_page_reload = () => {
try {
const entry =
typeof performance !== "undefined" && performance.getEntriesByType
? performance.getEntriesByType("navigation")?.[0]
: null;
if (entry && entry.type) return entry.type === "reload";
if (typeof performance !== "undefined" && performance.navigation) return performance.navigation.type === 1;
} catch (e) {
return false;
}
return false;
};
/**
* @description 计算本次滚动状态的存储 key(用于区分不同页面/参数)
* @returns {string}
*/
const resolve_key = () => {
if (typeof get_key === "function") return String(get_key() || "");
return String(get_key || "");
};
/**
* @description 解析滚动容器(支持 window 或元素滚动容器)
* @returns {Element|Window}
*/
const resolve_scroll_el = () => {
if (typeof get_scroll_el === "function") return get_scroll_el();
const el = typeof document !== "undefined" ? document.querySelector(".app-content") : null;
return el || window;
};
/**
* @description 获取当前滚动位置
* @param {Element|Window} scroll_el - 滚动容器
* @returns {number}
*/
const get_scroll_top = (scroll_el) => {
if (scroll_el === window) return window.scrollY || 0;
return scroll_el?.scrollTop || 0;
};
/**
* @description 设置滚动位置
* @param {Element|Window} scroll_el - 滚动容器
* @param {number} top - 目标滚动高度
* @param {string} behavior - 滚动行为
* @returns {void}
*/
const set_scroll_top = (scroll_el, top, behavior = "auto") => {
if (scroll_el === window) {
window.scrollTo({ top, left: 0, behavior });
return;
}
if (scroll_el && typeof scroll_el.scrollTo === "function") {
scroll_el.scrollTo({ top, left: 0, behavior });
return;
}
if (scroll_el) {
scroll_el.scrollTop = top;
}
};
/**
* @description 等待某个“就绪条件”达成(例如:布局/高度/元素渲染完成)
* @param {Function} check - 返回 true 表示就绪
* @param {Object} opts - 配置项
* @param {number} opts.timeout_ms - 超时时间
* @param {number} opts.interval_ms - 检查间隔
* @returns {Promise<boolean>} 是否在超时前就绪
*/
const wait_for_ready = async (check, { timeout_ms = 1500, interval_ms = 16 } = {}) => {
const ok = await wait_until(check, timeout_ms, interval_ms);
log("等待就绪结束", { ok, timeout_ms, interval_ms });
return ok;
};
/**
* @description 强制滚动到指定位置(可重复多次,对抗浏览器的滚动位置回填)
* @param {number} top - 目标滚动高度
* @param {Object} opts - 配置项
* @param {number} opts.times - 重复次数
* @param {number} opts.settle_frames - 每次滚动后等待的帧数
* @param {string} opts.behavior - 滚动行为
* @returns {Promise<{before_top:number, after_top:number}>}
*/
const force_scroll_to = async (
top,
{ times = 2, settle_frames = 1, behavior = "auto" } = {}
) => {
const scroll_el = resolve_scroll_el();
const repeat = Math.max(1, Number(times || 1));
const frames = Math.max(0, Number(settle_frames || 0));
let first_before = get_scroll_top(scroll_el);
let last_after = first_before;
for (let i = 0; i < repeat; i++) {
const before_top = get_scroll_top(scroll_el);
set_scroll_top(scroll_el, Math.max(0, Number(top || 0)), behavior);
for (let f = 0; f < frames; f++) {
await wait_frame();
}
last_after = get_scroll_top(scroll_el);
log("强制滚动执行", { round: i + 1, before_top, after_top: last_after, target_top: top });
}
return { before_top: first_before, after_top: last_after };
};
/**
* @description 读取当前 key 对应的滚动状态(自动处理 JSON 异常与过期)
* @returns {null|Object}
*/
const read_state = () => {
const key = resolve_key();
if (!key) return null;
const raw = sessionStorage.getItem(key);
if (!raw) return null;
try {
const state = JSON.parse(raw);
if (!state || typeof state !== "object") return null;
if (max_age_ms && state.saved_at && Date.now() - state.saved_at > max_age_ms) {
sessionStorage.removeItem(key);
return null;
}
return state;
} catch (e) {
sessionStorage.removeItem(key);
return null;
}
};
/**
* @description 清除当前 key 对应的滚动状态
* @returns {void}
*/
const clear_state = () => {
const key = resolve_key();
if (!key) return;
sessionStorage.removeItem(key);
};
/**
* @description 保存当前滚动位置到 sessionStorage(可附带业务字段)
* @param {Object} payload - 额外写入的状态字段(例如 anchor_id、calendar_height 等)
* @returns {void}
*/
const save_state = (payload = {}) => {
const key = resolve_key();
if (!key) return;
const scroll_el = resolve_scroll_el();
const scroll_top = scroll_el === window ? (window.scrollY || 0) : (scroll_el?.scrollTop || 0);
const state = {
scroll_top,
scroll_y: scroll_top,
saved_at: Date.now(),
...payload,
};
sessionStorage.setItem(key, JSON.stringify(state));
};
/**
* @description 恢复滚动状态(支持锚点恢复、等待就绪、条件恢复、恢复后清理)
* @param {Object} opts - 配置项
* @param {Function} opts.get_anchor_el - 通过 anchor_id 解析锚点元素
* @param {Function} opts.get_scroll_top - 计算目标滚动高度(优先级高于锚点)
* @param {Function} opts.should_restore - 是否允许恢复(返回 false 将跳过恢复)
* @param {boolean} opts.clear_after_restore - 是否在恢复后清理状态
* @param {string} opts.behavior - 滚动行为(auto/smooth)
* @param {Function} opts.wait_for - 等待条件(例如日历高度稳定/列表渲染完成)
* @param {number} opts.wait_for_timeout_ms - 等待超时
* @param {number} opts.wait_for_interval_ms - 等待间隔
* @param {number} opts.settle_frames - 恢复前额外等待的帧数(用于布局稳定)
* @returns {Promise<null|Object>} 返回恢复前读取到的 state;无恢复则为 null
*/
const restore_state = async ({
get_anchor_el,
get_scroll_top,
should_restore,
clear_after_restore = true,
behavior = "auto",
wait_for,
wait_for_timeout_ms = 1500,
wait_for_interval_ms = 16,
settle_frames = 2,
} = {}) => {
const state = read_state();
if (!state) return null;
const key = resolve_key();
const scroll_el = resolve_scroll_el();
if (typeof should_restore === "function" && !should_restore(state, scroll_el)) {
if (clear_after_restore) sessionStorage.removeItem(key);
return null;
}
await nextTick();
if (typeof wait_for === "function") {
await wait_until(
() => wait_for(state, scroll_el),
wait_for_timeout_ms,
wait_for_interval_ms
);
}
const frames = typeof settle_frames === "number" ? settle_frames : 0;
for (let i = 0; i < Math.max(0, frames); i++) {
await wait_frame();
}
let target_top = null;
if (typeof get_scroll_top === "function") {
target_top = get_scroll_top(state, scroll_el);
} else if (state.anchor_id && typeof get_anchor_el === "function") {
const anchor_el = get_anchor_el(state.anchor_id);
if (anchor_el) {
const scroll_rect = scroll_el === window ? { top: 0 } : scroll_el.getBoundingClientRect();
const anchor_rect = anchor_el.getBoundingClientRect();
const current_scroll_top = scroll_el === window ? (window.scrollY || 0) : (scroll_el.scrollTop || 0);
target_top = Math.max(0, anchor_rect.top - scroll_rect.top + current_scroll_top - 10);
}
}
if (typeof target_top !== "number") {
const fallback = typeof state.scroll_top === "number" ? state.scroll_top : state.scroll_y;
if (typeof fallback === "number") target_top = fallback;
}
if (typeof target_top === "number") {
if (scroll_el === window) {
window.scrollTo({ top: target_top, left: 0, behavior });
} else if (scroll_el && typeof scroll_el.scrollTo === "function") {
scroll_el.scrollTo({ top: target_top, left: 0, behavior });
} else if (scroll_el) {
scroll_el.scrollTop = target_top;
}
}
if (clear_after_restore) {
sessionStorage.removeItem(key);
}
return state;
};
return {
read_state,
save_state,
restore_state,
clear_state,
resolve_scroll_el,
log,
is_page_reload,
wait_for_ready,
force_scroll_to,
};
};