// ==UserScript==
// @name Universal Android-Back Button – 滚动记忆版
// @namespace https://viayoo.com/universal-back
// @version 2.0.0
// @description 零延迟返回按钮,自动记录和恢复滚动位置,支持无痕模式
// @author
// @run-at document-end
// @match *://*/*
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function () {
'use strict';
/* ---------- 共用工具 ---------- */
const CACHE_EXPIRY = 5 * 60 * 1000; // 5分钟缓存
const pageCache = new Map();
function isPseudoTurn(prev, next) {
try {
const u1 = new URL(prev);
const u2 = new URL(next);
// 1. 仅 hash 变化
if (u1.origin === u2.origin &&
u1.pathname === u2.pathname &&
u1.search === u2.search) return true;
// 2. 仅典型分页 query 变化
const PAGE_KEYS = ['page', 'p', 'start', 'offset'];
if (u1.origin === u2.origin && u1.pathname === u2.pathname) {
const s1 = new URLSearchParams(u1.search);
const s2 = new URLSearchParams(u2.search);
for (const k of PAGE_KEYS) { s1.delete(k); s2.delete(k); }
return s1.toString() === s2.toString();
}
return false;
} catch (e) { return false; }
}
/* ---------- 1. 状态管理 ---------- */
const TAB_ID_KEY = '_ub_tab_id';
if (!sessionStorage.getItem(TAB_ID_KEY)) {
sessionStorage.setItem(TAB_ID_KEY, Math.random().toString(36).slice(2));
}
const TAB_ID = sessionStorage.getItem(TAB_ID_KEY);
0
const STACK_KEY = `_ub_stack_${TAB_ID}`;
const CACHE_KEY = `_ub_cache_${TAB_ID}`;
const BACK_FLAG = `_ub_is_back_${TAB_ID}`;
const SCROLL_KEY = `_ub_scroll_${TAB_ID}`;
let stack = JSON.parse(sessionStorage.getItem(STACK_KEY) || '[]');
let cache = JSON.parse(sessionStorage.getItem(CACHE_KEY) || '{}');
let scrollPositions = JSON.parse(sessionStorage.getItem(SCROLL_KEY) || '{}');
function saveState() {
sessionStorage.setItem(STACK_KEY, JSON.stringify(stack));
sessionStorage.setItem(CACHE_KEY, JSON.stringify(cache));
sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollPositions));
}
// 清理过期缓存和滚动记录
function cleanCache() {
const now = Date.now();
for (const url in cache) {
if (cache[url].expiry < now) {
delete cache[url];
}
}
// 清理超过24小时的滚动记录
for (const url in scrollPositions) {
if (scrollPositions[url].timestamp < now - 24 * 60 * 60 * 1000) {
delete scrollPositions[url];
}
}
}
/* ---------- 2. 滚动位置监控 ---------- */
let scrollTimer = null;
let lastScrollY = window.scrollY;
let lastScrollX = window.scrollX;
// 实时保存滚动位置
function saveScrollPosition() {
scrollPositions[location.href] = {
x: window.scrollX,
y: window.scrollY,
timestamp: Date.now(),
height: document.documentElement.scrollHeight
};
saveState();
}
// 监听滚动事件
window.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
// 只有滚动距离超过50像素才保存
if (Math.abs(window.scrollY - lastScrollY) > 50 ||
Math.abs(window.scrollX - lastScrollX) > 50) {
saveScrollPosition();
lastScrollY = window.scrollY;
lastScrollX = window.scrollX;
}
}, 300); // 300ms延迟,避免频繁保存
});
// 页面即将离开时保存滚动位置
window.addEventListener('beforeunload', saveScrollPosition);
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
saveScrollPosition();
}
});
/* ---------- 3. 页面加载记录 ---------- */
(function recordPage() {
const isBack = sessionStorage.getItem(BACK_FLAG) === '1';
sessionStorage.removeItem(BACK_FLAG);
const ref = document.referrer;
if (!isBack && ref && ref !== location.href && !isPseudoTurn(ref, location.href)) {
// 保存上一页的滚动位置
if (ref) {
// 这里不需要额外保存,因为上一页离开时已经保存了
}
stack.push({
url: ref,
title: document.title,
timestamp: Date.now()
});
if (stack.length > 50) stack.shift();
saveState();
// 预加载上一页
if (!cache[ref] || cache[ref].expiry < Date.now()) {
prefetchPage(ref);
}
}
// 恢复滚动位置
if (scrollPositions[location.href]) {
const { x, y, height } = scrollPositions[location.href];
// 等待页面完全加载
const restoreScroll = () => {
// 检查页面高度是否足够
if (document.documentElement.scrollHeight >= height * 0.8) {
window.scrollTo(x, y);
// 显示提示
showScrollRestored(y);
} else {
// 如果页面还没加载完,稍后再试
setTimeout(restoreScroll, 100);
}
};
// 延迟执行,确保页面渲染完成
setTimeout(restoreScroll, 100);
}
})();
/* ---------- 4. SPA 导航优化 ---------- */
(function hijackSPA() {
const rawPush = history.pushState;
const rawReplace = history.replaceState;
function wrapper(rawFn) {
return function () {
const prev = location.href;
// 离开前保存滚动位置
saveScrollPosition();
rawFn.apply(this, arguments);
const next = location.href;
if (prev !== next && !isPseudoTurn(prev, next)) {
stack.push({
url: prev,
title: document.title,
timestamp: Date.now()
});
saveState();
// 预加载可能的目标页面
prefetchPossibleTargets();
}
};
}
history.pushState = wrapper(rawPush);
history.replaceState = wrapper(rawReplace);
window.addEventListener('popstate', () => {
const cur = location.href;
if (stack.length && stack[stack.length - 1].url === cur) {
stack.pop();
saveState();
}
// 恢复滚动位置
if (scrollPositions[cur]) {
const { x, y } = scrollPositions[cur];
setTimeout(() => {
window.scrollTo(x, y);
showScrollRestored(y);
}, 100);
}
});
})();
/* ---------- 5. 智能预加载系统 ---------- */
function prefetchPage(url) {
if (cache[url] && cache[url].expiry > Date.now()) return;
GM_xmlhttpRequest({
method: "GET",
url: url,
headers: {
"X-Purpose": "prefetch"
},
onload: function(response) {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, "text/html");
const content = extractMainContent(doc);
cache[url] = {
content: content,
title: doc.title,
expiry: Date.now() + CACHE_EXPIRY
};
saveState();
}
});
}
function prefetchPossibleTargets() {
// 预加载栈顶3个页面
stack.slice(-3).forEach(item => {
if (!cache[item.url] || cache[item.url].expiry < Date.now()) {
prefetchPage(item.url);
}
});
}
function extractMainContent(doc) {
const selectors = [
'article', 'main', '.article', '.post',
'.content', '#content', 'body'
];
for (const sel of selectors) {
const el = doc.querySelector(sel);
if (el) return el.innerHTML;
}
return doc.body.innerHTML;
}
/* ---------- 6. 零刷新导航 ---------- */
async function navigateTo(url) {
cleanCache();
// 保存当前页面的滚动位置
saveScrollPosition();
// 1. 尝试从缓存加载
if (cache[url]) {
updatePage(cache[url]);
history.replaceState(null, '', url);
// 恢复目标页面的滚动位置
restoreScrollForUrl(url);
return;
}
// 2. 显示加载状态
btn.textContent = '⌛';
toast('快速加载中...');
// 3. 尝试快速获取
try {
const response = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const content = extractMainContent(doc);
// 更新缓存
cache[url] = {
content: content,
title: doc.title,
expiry: Date.now() + CACHE_EXPIRY
};
saveState();
updatePage(cache[url]);
history.replaceState(null, '', url);
// 恢复目标页面的滚动位置
restoreScrollForUrl(url);
} catch (e) {
// 回退到传统导航
sessionStorage.setItem(BACK_FLAG, '1');
location.href = url;
} finally {
btn.textContent = '←';
}
}
function restoreScrollForUrl(url) {
if (scrollPositions[url]) {
const { x, y } = scrollPositions[url];
setTimeout(() => {
window.scrollTo({
left: x,
top: y,
behavior: 'smooth' // 平滑滚动
});
showScrollRestored(y);
}, 100);
}
}
function updatePage(data) {
document.title = data.title;
const container = document.querySelector(
'article, main, .article, .post, .content, #content'
) || document.body;
container.innerHTML = data.content;
// 重新插入按钮(因为替换了内容)
insertBtn();
// 触发可能的脚本重新执行
if (typeof window.onPageUpdated === 'function') {
window.onPageUpdated();
}
}
/* ---------- 7. 优化按钮实现 ---------- */
const btn = createButton();
function createButton() {
const btn = document.createElement('div');
btn.textContent = '←';
Object.assign(btn.style, {
position: 'fixed',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
width: '52px',
height: '52px',
lineHeight: '52px',
textAlign: 'center',
fontSize: '26px',
fontWeight: 'bold',
borderRadius: '50%',
color: '#000',
background: 'rgba(255,255,255,0.85)',
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
userSelect: 'none',
cursor: 'pointer',
transition: 'all .2s',
zIndex: 2147483646
});
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'translateX(-50%) scale(1.1)';
btn.style.background = 'rgba(255,255,255,0.95)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'translateX(-50%) scale(1)';
btn.style.background = 'rgba(255,255,255,0.85)';
});
btn.addEventListener('click', async (e) => {
e.stopPropagation();
// 保存当前滚动位置
saveScrollPosition();
// 先把连续的伪页全部弹掉
while (stack.length && isPseudoTurn(stack[stack.length - 1].url, location.href)) {
stack.pop();
}
if (stack.length) {
const target = stack.pop().url;
saveState();
await navigateTo(target);
} else if (history.length > 1) {
history.back();
} else if (document.referrer) {
await navigateTo(document.referrer);
} else {
toast('没有可返回的页面');
}
});
return btn;
}
function insertBtn() {
if (!document.body.contains(btn)) {
document.body.appendChild(btn);
}
}
/* ---------- 8. Toast 提示 ---------- */
function toast(msg) {
const t = document.createElement('div');
t.textContent = msg;
Object.assign(t.style, {
position: 'fixed',
bottom: '90px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0,0,0,0.8)',
color: '#fff',
padding: '10px 16px',
borderRadius: '20px',
fontSize: '14px',
zIndex: 2147483647,
opacity: '0',
transition: 'opacity .3s',
maxWidth: '80vw',
wordBreak: 'break-word'
});
document.body.appendChild(t);
requestAnimationFrame(() => t.style.opacity = '1');
setTimeout(() => {
t.style.opacity = '0';
setTimeout(() => t.remove(), 300);
}, 1800);
}
// 显示滚动恢复提示
function showScrollRestored(scrollY) {
if (scrollY > 100) { // 只有滚动超过100像素才显示提示
const indicator = document.createElement('div');
indicator.textContent = '↓ 已恢复到上次位置';
Object.assign(indicator.style, {
position: 'fixed',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(76, 175, 80, 0.9)',
color: '#fff',
padding: '8px 16px',
borderRadius: '20px',
fontSize: '13px',
zIndex: 2147483647,
opacity: '0',
transition: 'opacity .3s',
pointerEvents: 'none'
});
document.body.appendChild(indicator);
requestAnimationFrame(() => indicator.style.opacity = '1');
setTimeout(() => {
indicator.style.opacity = '0';
setTimeout(() => indicator.remove(), 300);
}, 2000);
}
}
/* ---------- 初始化 ---------- */
insertBtn();
prefetchPossibleTargets();
cleanCache(); // 启动时清理过期数据
})();