Skip to content

Commit b0811a6

Browse files
cyfung1031CodFrmCopilot
authored
✨ 以 Navigation API 实现 TM 的 window.onurlchange (#1315)
* 以 Navigation API 实现 TM 的 window.onurlchange * 整理档案架构 * 增加 seq判断 避免重复触发 * Update src/app/service/content/gm_api/navigation_handle.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 修改代码 * Update navigation_handle.ts * 修改代码 * 修复e2e * ✅ 补充 window.onurlchange 单元测试与示例脚本 - 修复 attachNavigateHandler 在不支持 Navigation API 时提前设置 attached 的问题 - 新增 navigation_handle.test.ts 覆盖事件派发、去重、单例注册等行为 - 新增 example/window_onurlchange.js 示例脚本 * 遵循 TM 例子写法 https://www.tampermonkey.net/documentation.php?locale=en&q=window#api:window.onurlchange * update code --------- Co-authored-by: wangyizhi <yz@ggnb.top> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b3db769 commit b0811a6

6 files changed

Lines changed: 226 additions & 7 deletions

File tree

e2e/vscode-connect.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ async function openToolsPage(context: Parameters<typeof openOptionsPage>[0], ext
1515
return page;
1616
}
1717

18-
/** 获取「开发调试」卡片区域的定位器 */
18+
/** 获取「开发工具」卡片区域的定位器 */
1919
function getDevCard(page: Page) {
20-
// 开发调试 / Development Debugging 卡片是页面上第二个 Card
20+
// 开发工具 / Development Tool 卡片是页面上第二个 Card
2121
return page.locator(".arco-card").nth(1);
2222
}
2323

@@ -106,7 +106,7 @@ test.describe("VSCode 连接", () => {
106106
const card = getDevCard(page);
107107

108108
// 卡片标题
109-
await expect(card.getByText(/development debugging|/i)).toBeVisible();
109+
await expect(card.getByText(/development tool|/i)).toBeVisible();
110110

111111
// VSCode URL 输入框
112112
const urlInput = card.locator(".arco-input");

example/window_onurlchange.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// ==UserScript==
2+
// @name window.onurlchange 示例
3+
// @namespace https://bbs.tampermonkey.net.cn/
4+
// @version 0.1.0
5+
// @description 监听页面 URL 变化(兼容 Tampermonkey)
6+
// @author You
7+
// @match *://*/*
8+
// @grant window.onurlchange
9+
// ==/UserScript==
10+
11+
if (window.onurlchange === null) {
12+
// feature is supported
13+
14+
// 方式一:使用 window.onurlchange 赋值
15+
window.onurlchange = function (e) {
16+
console.log("URL changed to:", e.url);
17+
};
18+
19+
// 方式二:使用 addEventListener
20+
window.addEventListener("urlchange", function (e) {
21+
console.log("URL changed (addEventListener):", e.url);
22+
});
23+
}

src/app/service/content/create_context.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { protect } from "./gm_api/gm_context";
77
import { isEarlyStartScript } from "./utils";
88
import { ListenerManager } from "./listener_manager";
99
import { createGMBase } from "./gm_api/gm_api";
10+
import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle";
1011

1112
// 构建沙盒上下文
1213
export const createContext = (
@@ -102,6 +103,10 @@ export const createContext = (
102103
}
103104
}
104105
context.unsafeWindow = window;
106+
if (scriptGrants.has("window.onurlchange") && context.onurlchange === undefined) {
107+
context.onurlchange = null;
108+
attachNavigateHandler(window as any);
109+
}
105110
return context;
106111
};
107112

@@ -370,6 +375,23 @@ export const createProxyContext = <const Context extends GMWorldContext>(context
370375
},
371376
};
372377

378+
// @grant window.onurlchange
379+
if (context?.onurlchange === null) {
380+
let currentValue: ((this: GlobalEventHandlers, ev: UrlChangeEvent) => any) | null = null;
381+
ownDescs.onurlchange = {
382+
enumerable: true,
383+
configurable: true,
384+
get() {
385+
return currentValue;
386+
},
387+
set(nv) {
388+
if (typeof nv !== "function") nv = null;
389+
currentValue = nv;
390+
return true;
391+
},
392+
};
393+
}
394+
373395
// 把初始Copy加上特殊变量后,生成一份新Copy
374396
mySandbox = Object.create(Object.getPrototypeOf(sharedInitCopy), ownDescs);
375397

@@ -389,7 +411,7 @@ export const createProxyContext = <const Context extends GMWorldContext>(context
389411

390412
// 把 GM context物件的 window属性内容移至exposedWindow
391413
// 由于目前只有 window.close, window.open, window.onurlchange, 不需要循环 window
392-
const cWindow = context.window;
414+
const cWindow = context.window as (Window & Record<string, any>) | undefined;
393415

394416
// @grant window.close
395417
if (cWindow?.close) {
@@ -402,9 +424,11 @@ export const createProxyContext = <const Context extends GMWorldContext>(context
402424
}
403425

404426
// @grant window.onurlchange
405-
if (cWindow?.onurlchange === null) {
406-
// 目前 TM 只支援 null. ScriptCat不需要grant预设启用?
407-
mySandbox.onurlchange = null;
427+
if (context?.onurlchange === null) {
428+
const handle = function (this: Window & Record<string, any>, e: UrlChangeEvent) {
429+
this.onurlchange?.(e);
430+
} as EventListener;
431+
(<EventTarget>window).addEventListener("urlchange", handle.bind(mySandbox), false);
408432
}
409433

410434
// 从网页 console 隔离出来的沙盒 console

src/app/service/content/global.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const Native = {
1111
ownFragment: new DocumentFragment(),
1212
objectCreate: Object.create.bind(Object),
1313
objectGetOwnPropertyDescriptors: Object.getOwnPropertyDescriptors.bind(Object),
14+
objectGetOwnPropertyDescriptor: Object.getOwnPropertyDescriptor.bind(Object),
15+
objectGetPrototypeOf: Object.getPrototypeOf.bind(Object),
1416
} as const;
1517

1618
export const customClone = (o: any) => {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { UrlChangeEvent } from "./navigation_handle.js";
3+
4+
// attachNavigateHandler 使用模块级 attached 单例,需要在每个测试前重置模块
5+
const importFresh = async () => {
6+
vi.resetModules();
7+
return await import("./navigation_handle.js");
8+
};
9+
10+
describe("UrlChangeEvent", () => {
11+
it.concurrent("应包含 url 属性", () => {
12+
const ev = new UrlChangeEvent("urlchange", "https://example.com/new");
13+
expect(ev.type).toBe("urlchange");
14+
expect(ev.url).toBe("https://example.com/new");
15+
expect(ev).toBeInstanceOf(Event);
16+
});
17+
});
18+
19+
describe("attachNavigateHandler", () => {
20+
// 创建一个模拟的 window 对象,location.href 需要通过 getter 提供
21+
// 因为 getPropGetter 会遍历原型链查找 PropertyDescriptor
22+
const createMockWin = (href = "https://example.com/") => {
23+
const listeners: Record<string, EventListener[]> = {};
24+
const dispatched: Event[] = [];
25+
let currentHref = href;
26+
const location = Object.create(null);
27+
Object.defineProperty(location, "href", {
28+
get: () => currentHref,
29+
set: (v: string) => {
30+
currentHref = v;
31+
},
32+
configurable: true,
33+
enumerable: true,
34+
});
35+
return {
36+
win: {
37+
location,
38+
navigation: {
39+
addEventListener: vi.fn((type: string, handler: EventListener) => {
40+
(listeners[type] ||= []).push(handler);
41+
}),
42+
},
43+
dispatchEvent: vi.fn((ev: Event) => dispatched.push(ev)),
44+
addEventListener: vi.fn(),
45+
} as any,
46+
listeners,
47+
dispatched,
48+
// 模拟触发 navigate 事件
49+
fireNavigate(destUrl: string) {
50+
// 更新 location.href 模拟浏览器行为
51+
currentHref = destUrl;
52+
const ev = { type: "navigate", destination: { url: destUrl } } as any;
53+
for (const fn of listeners["navigate"] || []) {
54+
fn(ev);
55+
}
56+
},
57+
};
58+
};
59+
60+
beforeEach(() => {
61+
vi.restoreAllMocks();
62+
});
63+
64+
it("不支持 Navigation API 时不应注册监听器", async () => {
65+
const { attachNavigateHandler } = await importFresh();
66+
const win = { location: { href: "https://example.com/" } } as any;
67+
attachNavigateHandler(win);
68+
// 没有 navigation 属性,不应报错也不应标记为 attached
69+
// 再次调用带 navigation 的 win 应该能正常注册
70+
const mock = createMockWin();
71+
attachNavigateHandler(mock.win);
72+
expect(mock.win.navigation.addEventListener).toHaveBeenCalledWith("navigate", expect.any(Function), false);
73+
});
74+
75+
it("应在 win.navigation 上注册 navigate 监听器", async () => {
76+
const { attachNavigateHandler } = await importFresh();
77+
const mock = createMockWin();
78+
attachNavigateHandler(mock.win);
79+
expect(mock.win.navigation.addEventListener).toHaveBeenCalledTimes(1);
80+
expect(mock.win.navigation.addEventListener).toHaveBeenCalledWith("navigate", expect.any(Function), false);
81+
});
82+
83+
it("多次调用只注册一次", async () => {
84+
const { attachNavigateHandler } = await importFresh();
85+
const mock = createMockWin();
86+
attachNavigateHandler(mock.win);
87+
attachNavigateHandler(mock.win);
88+
attachNavigateHandler(mock.win);
89+
expect(mock.win.navigation.addEventListener).toHaveBeenCalledTimes(1);
90+
});
91+
92+
it("URL 变化时应派发 urlchange 事件", async () => {
93+
const { attachNavigateHandler } = await importFresh();
94+
const mock = createMockWin("https://example.com/");
95+
attachNavigateHandler(mock.win);
96+
mock.fireNavigate("https://example.com/new");
97+
// handler 是 async,等待 microtask 完成
98+
await vi.waitFor(() => {
99+
expect(mock.dispatched.length).toBe(1);
100+
});
101+
const ev = mock.dispatched[0] as UrlChangeEvent;
102+
expect(ev.type).toBe("urlchange");
103+
expect(ev.url).toBe("https://example.com/new");
104+
});
105+
106+
it("URL 未变化时不应派发事件", async () => {
107+
const { attachNavigateHandler } = await importFresh();
108+
const mock = createMockWin("https://example.com/");
109+
attachNavigateHandler(mock.win);
110+
// destination.url 与当前 href 相同
111+
mock.fireNavigate("https://example.com/");
112+
await new Promise((r) => setTimeout(r, 50));
113+
expect(mock.dispatched.length).toBe(0);
114+
});
115+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Native } from "../global";
2+
3+
export class UrlChangeEvent extends Event {
4+
readonly url: string;
5+
constructor(type: string, url: string) {
6+
super(type);
7+
this.url = url;
8+
}
9+
}
10+
11+
let attached = false;
12+
13+
const getPropGetter = <T>(obj: T, key: keyof T) => {
14+
// 避免直接 obj[key] 读取。或会被 hack
15+
for (let t = obj; t; t = Native.objectGetPrototypeOf(t)) {
16+
const pd = Native.objectGetOwnPropertyDescriptor(t, key);
17+
if (pd) return pd.get?.bind(obj);
18+
}
19+
};
20+
21+
// Chrome 102+, Firefox 147+
22+
// https://developer.chrome.com/docs/web-platform/navigation-api
23+
// https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API#browser_compatibility
24+
export const attachNavigateHandler = (win: Window & { navigation: EventTarget }) => {
25+
if (attached) return;
26+
if (!win.navigation) return; // 不支持 Navigation API
27+
attached = true;
28+
// 以 location.href 判断避免 replaceState/pushState 重复执行重复触发
29+
const loc = win.location;
30+
const getUrl = getPropGetter(loc, "href");
31+
const dispatch = win.dispatchEvent.bind(win);
32+
let lastUrl = getUrl?.();
33+
let callSeq = 0;
34+
const handler = async (ev: Event): Promise<void> => {
35+
callSeq = callSeq > 512 ? 1 : callSeq + 1;
36+
const seq = callSeq;
37+
let newUrl = getUrl?.(); // 取得当前 location.href
38+
const destUrl = (ev as any).destination?.url;
39+
if (destUrl !== newUrl && newUrl === lastUrl) {
40+
// 某些情况,location.href 未更新就触发了
41+
// 用 postMessage 推迟到下一个 macrotask 阶段
42+
await new Promise((resolve) => {
43+
self.addEventListener("message", resolve, { once: true });
44+
self.postMessage({ [`${Math.random()}`]: {} }, "*"); // 传一个 dummy message
45+
});
46+
if (seq !== callSeq) return; // 等待时,或许已经触发了其他 navigate
47+
newUrl = getUrl?.(); // 再次取得当前 location.href
48+
}
49+
if (newUrl === lastUrl) return;
50+
lastUrl = newUrl;
51+
const urlChangeEv = new UrlChangeEvent("urlchange", (destUrl || newUrl) as string);
52+
dispatch(urlChangeEv);
53+
};
54+
win.navigation?.addEventListener("navigate", handler, false);
55+
};

0 commit comments

Comments
 (0)