@viewfly/router 3.0.0-alpha.8 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -12
- package/dist/hooks/_api.d.ts +1 -0
- package/dist/hooks/use-params.d.ts +2 -0
- package/dist/index.esm.js +526 -105
- package/dist/index.js +525 -102
- package/dist/link.d.ts +1 -1
- package/dist/providers/_api.d.ts +1 -0
- package/dist/providers/navigator.d.ts +23 -9
- package/dist/providers/query-encoding.d.ts +3 -0
- package/dist/providers/router.d.ts +29 -13
- package/dist/providers/routes.d.ts +32 -0
- package/dist/providers/url-parser.d.ts +1 -1
- package/dist/router-module.d.ts +8 -2
- package/dist/router-outlet.d.ts +1 -2
- package/package.json +2 -2
package/dist/index.esm.js
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
|
-
import { Injectable, comparePropsWithCallbacks, createContext, inject, internalWrite, jsx, makeError, onUnmounted, reactive, readonlyProxyHandler, shallowReactive } from "@viewfly/core";
|
|
2
|
-
import { Subject, Subscription, fromEvent } from "@tanbo/stream";
|
|
1
|
+
import { Injectable, InjectionToken, comparePropsWithCallbacks, createContext, inject, internalWrite, jsx, makeError, onUnmounted, reactive, readonlyProxyHandler, shallowReactive, watch } from "@viewfly/core";
|
|
2
|
+
import { Subject, Subscription, fromEvent, microTask } from "@tanbo/stream";
|
|
3
3
|
//#region src/providers/router.ts
|
|
4
4
|
var routerErrorFn$1 = makeError("Router");
|
|
5
|
-
|
|
5
|
+
/** 与 matchRoute 的 pathname 参数同粒度比较(去掉首尾 `/`) */
|
|
6
|
+
function normalizeRoutePathSegment(path) {
|
|
7
|
+
return path.replace(/^\/+|\/+$/g, "") || "";
|
|
8
|
+
}
|
|
9
|
+
var Router = class Router {
|
|
6
10
|
onRefresh;
|
|
11
|
+
lastResolvedParams = null;
|
|
12
|
+
/** 当前重定向链上已解析过的 path 段(规范化后);非重定向成功匹配后清空 */
|
|
13
|
+
redirectTrail = null;
|
|
14
|
+
static maxRedirectHops = 32;
|
|
15
|
+
/** 当前层最近一次匹配所消费的 URL 段数(默认 1,保持历史行为) */
|
|
16
|
+
consumedSegments = 1;
|
|
17
|
+
routeParams = {};
|
|
18
|
+
/**
|
|
19
|
+
* 本次 `resolve` 命中的 `route.path` 上的动态段(供子 Router、`canActivate`、`redirectTo`)。
|
|
20
|
+
* 与 `params` 按层隔离(对齐 Angular):有 `parent` 时 `params` 只表示本层注入域,不由子级匹配覆盖。
|
|
21
|
+
*/
|
|
22
|
+
lastResolvePathParams = {};
|
|
7
23
|
get deep() {
|
|
8
|
-
return this.parent ? this.parent.deep +
|
|
24
|
+
return this.parent ? this.parent.deep + this.parent.consumedSegments : 0;
|
|
25
|
+
}
|
|
26
|
+
get remainingPaths() {
|
|
27
|
+
return this.navigator.urlTree.paths.slice(this.deep);
|
|
9
28
|
}
|
|
10
29
|
get path() {
|
|
11
|
-
return this.
|
|
30
|
+
return this.remainingPaths[0] || "";
|
|
31
|
+
}
|
|
32
|
+
get params() {
|
|
33
|
+
return this.routeParams;
|
|
34
|
+
}
|
|
35
|
+
setParams(params) {
|
|
36
|
+
this.routeParams = { ...params };
|
|
12
37
|
}
|
|
13
38
|
refreshEvent = new Subject();
|
|
14
39
|
constructor(navigator, parent) {
|
|
@@ -16,17 +41,17 @@ var Router = class {
|
|
|
16
41
|
this.parent = parent;
|
|
17
42
|
this.onRefresh = this.refreshEvent.asObservable();
|
|
18
43
|
}
|
|
19
|
-
navigateTo(path,
|
|
20
|
-
this.navigator.to(path, this,
|
|
44
|
+
navigateTo(path, queryParams, hash) {
|
|
45
|
+
this.navigator.to(path, this, queryParams, hash);
|
|
21
46
|
}
|
|
22
|
-
replaceTo(path,
|
|
23
|
-
this.navigator.replace(path, this,
|
|
47
|
+
replaceTo(path, queryParams, hash) {
|
|
48
|
+
this.navigator.replace(path, this, queryParams, hash);
|
|
24
49
|
}
|
|
25
50
|
refresh() {
|
|
26
51
|
this.refreshEvent.next();
|
|
27
52
|
}
|
|
28
|
-
|
|
29
|
-
return this.matchRoute(routes, this.
|
|
53
|
+
resolve(routes) {
|
|
54
|
+
return this.matchRoute(routes, this.remainingPaths);
|
|
30
55
|
}
|
|
31
56
|
back() {
|
|
32
57
|
this.navigator.back();
|
|
@@ -37,35 +62,217 @@ var Router = class {
|
|
|
37
62
|
go(offset) {
|
|
38
63
|
this.navigator.go(offset);
|
|
39
64
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
65
|
+
clearRedirectTrail() {
|
|
66
|
+
this.redirectTrail = null;
|
|
67
|
+
}
|
|
68
|
+
getNavigatorParams() {
|
|
69
|
+
const pathname = "/" + this.navigator.urlTree.paths.join("/");
|
|
70
|
+
return {
|
|
71
|
+
pathname: pathname === "/" ? pathname : pathname.replace(/\/+/g, "/"),
|
|
72
|
+
queryParams: this.navigator.urlTree.queryParams,
|
|
73
|
+
hash: this.navigator.urlTree.hash
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
cloneNavigatorParams(params) {
|
|
77
|
+
return {
|
|
78
|
+
pathname: params.pathname,
|
|
79
|
+
queryParams: { ...params.queryParams },
|
|
80
|
+
hash: params.hash
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
beginRedirectResolution(pathname) {
|
|
84
|
+
const key = normalizeRoutePathSegment(pathname);
|
|
85
|
+
if (this.redirectTrail === null) this.redirectTrail = /* @__PURE__ */ new Set();
|
|
86
|
+
if (this.redirectTrail.size >= Router.maxRedirectHops) {
|
|
87
|
+
const chain = [...this.redirectTrail].join(" -> ");
|
|
88
|
+
this.clearRedirectTrail();
|
|
89
|
+
throw routerErrorFn$1(`Redirect chain exceeded ${Router.maxRedirectHops} hops (last segment: '${pathname}', chain: ${chain})`);
|
|
90
|
+
}
|
|
91
|
+
this.redirectTrail.add(key);
|
|
92
|
+
}
|
|
93
|
+
assertRedirectTarget(pathname, target) {
|
|
94
|
+
const cur = normalizeRoutePathSegment(pathname);
|
|
95
|
+
const next = normalizeRoutePathSegment(target);
|
|
96
|
+
if (next === cur) {
|
|
97
|
+
this.clearRedirectTrail();
|
|
98
|
+
throw routerErrorFn$1(`Self-redirect at '${pathname}' (redirect target equals current path)`);
|
|
51
99
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
100
|
+
if (this.redirectTrail.has(next)) {
|
|
101
|
+
const chain = `${[...this.redirectTrail].join(" -> ")} -> ${next}`;
|
|
102
|
+
this.clearRedirectTrail();
|
|
103
|
+
throw routerErrorFn$1(`Redirect cycle detected (chain: ${chain})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
splitRoutePath(path) {
|
|
107
|
+
const normalized = normalizeRoutePathSegment(path);
|
|
108
|
+
return normalized ? normalized.split("/") : [];
|
|
109
|
+
}
|
|
110
|
+
/** 单段路由:`user` | `:id` | `:id?`(`?` 仅表示可选;整条 path 里 `:…?` 只允许出现在最后一段,由 matchRoutePath 校验) */
|
|
111
|
+
parseRouteSegment(segment) {
|
|
112
|
+
if (!segment) return null;
|
|
113
|
+
if (segment.startsWith(":")) {
|
|
114
|
+
const optionalMatch = /^:([^:?]+)\?$/.exec(segment);
|
|
115
|
+
if (optionalMatch) {
|
|
116
|
+
const name = optionalMatch[1];
|
|
117
|
+
return name ? {
|
|
118
|
+
kind: "param",
|
|
119
|
+
name,
|
|
120
|
+
optional: true
|
|
121
|
+
} : null;
|
|
122
|
+
}
|
|
123
|
+
const name = segment.slice(1);
|
|
124
|
+
if (!name || name.includes("?")) return null;
|
|
125
|
+
return {
|
|
126
|
+
kind: "param",
|
|
127
|
+
name,
|
|
128
|
+
optional: false
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
kind: "static",
|
|
133
|
+
value: segment
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
matchRoutePath(routePath, remainingPaths) {
|
|
137
|
+
if (!routePath || routePath === "*") return null;
|
|
138
|
+
const rawSegments = this.splitRoutePath(routePath);
|
|
139
|
+
const segments = [];
|
|
140
|
+
for (const s of rawSegments) {
|
|
141
|
+
const parsed = this.parseRouteSegment(s);
|
|
142
|
+
if (!parsed) {
|
|
143
|
+
if (s.startsWith(":")) throw routerErrorFn$1(`Empty or invalid path parameter segment '${s}' in '${routePath}' (use ':name' with a non-empty name).`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
segments.push(parsed);
|
|
147
|
+
}
|
|
148
|
+
for (let i = 0; i < segments.length; i++) {
|
|
149
|
+
const s = segments[i];
|
|
150
|
+
if (s.kind === "param" && s.optional && i !== segments.length - 1) throw routerErrorFn$1(`Optional path parameter ':${s.name}?' must be the last segment of '${routePath}' (optional params are only allowed at the end of the path).`);
|
|
151
|
+
}
|
|
152
|
+
const seenParamNames = /* @__PURE__ */ new Set();
|
|
153
|
+
for (const s of segments) if (s.kind === "param") {
|
|
154
|
+
if (seenParamNames.has(s.name)) throw routerErrorFn$1(`Duplicate path parameter ':${s.name}' in '${routePath}' (each name must appear at most once).`);
|
|
155
|
+
seenParamNames.add(s.name);
|
|
156
|
+
}
|
|
157
|
+
const urlLen = remainingPaths.length;
|
|
158
|
+
let ui = 0;
|
|
159
|
+
const pathParams = {};
|
|
160
|
+
for (let ri = 0; ri < segments.length; ri++) {
|
|
161
|
+
const seg = segments[ri];
|
|
162
|
+
if (seg.kind === "static") {
|
|
163
|
+
if (ui >= urlLen || remainingPaths[ui] !== seg.value) return null;
|
|
164
|
+
ui++;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!seg.optional) {
|
|
168
|
+
if (ui >= urlLen) return null;
|
|
169
|
+
pathParams[seg.name] = remainingPaths[ui];
|
|
170
|
+
ui++;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (ui >= urlLen) {
|
|
174
|
+
pathParams[seg.name] = "";
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
pathParams[seg.name] = remainingPaths[ui];
|
|
178
|
+
ui++;
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
consumedSegments: ui,
|
|
182
|
+
params: pathParams
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
matchRoute(routes, remainingPaths) {
|
|
186
|
+
this.lastResolvePathParams = {};
|
|
187
|
+
const pathname = remainingPaths[0] || "";
|
|
188
|
+
this.beginRedirectResolution(pathname);
|
|
189
|
+
let matchedRoute = null;
|
|
190
|
+
let matchedRouteResult = null;
|
|
191
|
+
let defaultRoute = null;
|
|
192
|
+
let fallbackRoute = null;
|
|
193
|
+
for (const item of routes) {
|
|
194
|
+
const matchResult = this.matchRoutePath(item.path, remainingPaths);
|
|
195
|
+
if (matchResult) {
|
|
196
|
+
matchedRoute = item;
|
|
197
|
+
matchedRouteResult = matchResult;
|
|
198
|
+
break;
|
|
199
|
+
} else if (item.path === "*") {
|
|
200
|
+
if (!fallbackRoute) fallbackRoute = item;
|
|
201
|
+
} else if (item.path === "") {
|
|
202
|
+
if (!defaultRoute) defaultRoute = item;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const route = matchedRoute || defaultRoute || fallbackRoute;
|
|
206
|
+
if (!route) {
|
|
207
|
+
this.consumedSegments = 0;
|
|
208
|
+
this.routeParams = {};
|
|
209
|
+
this.lastResolvePathParams = {};
|
|
210
|
+
this.clearRedirectTrail();
|
|
211
|
+
this.lastResolvedParams = this.cloneNavigatorParams(this.getNavigatorParams());
|
|
212
|
+
return route;
|
|
213
|
+
}
|
|
214
|
+
if (route === defaultRoute) {
|
|
215
|
+
this.consumedSegments = 0;
|
|
216
|
+
this.routeParams = {};
|
|
217
|
+
this.lastResolvePathParams = {};
|
|
218
|
+
} else if (route === matchedRoute && matchedRouteResult) {
|
|
219
|
+
this.consumedSegments = matchedRouteResult.consumedSegments;
|
|
220
|
+
this.lastResolvePathParams = { ...matchedRouteResult.params };
|
|
221
|
+
if (this.parent === null) this.routeParams = { ...matchedRouteResult.params };
|
|
222
|
+
} else {
|
|
223
|
+
this.consumedSegments = remainingPaths.length > 0 ? 1 : 0;
|
|
224
|
+
this.routeParams = {};
|
|
225
|
+
this.lastResolvePathParams = {};
|
|
226
|
+
}
|
|
227
|
+
if (typeof route.redirectTo === "function") {
|
|
228
|
+
const to = this.cloneNavigatorParams(this.getNavigatorParams());
|
|
229
|
+
const from = this.lastResolvedParams ? this.cloneNavigatorParams(this.lastResolvedParams) : null;
|
|
230
|
+
const p = route.redirectTo({
|
|
231
|
+
to,
|
|
232
|
+
from,
|
|
233
|
+
router: this,
|
|
234
|
+
params: this.lastResolvePathParams
|
|
235
|
+
});
|
|
236
|
+
if (typeof p === "string") {
|
|
237
|
+
this.assertRedirectTarget(pathname, p);
|
|
238
|
+
this.navigateTo(p);
|
|
239
|
+
} else if (typeof p === "object") {
|
|
240
|
+
this.assertRedirectTarget(pathname, p.pathname);
|
|
241
|
+
this.navigateTo(p.pathname, p.queryParams, p.hash);
|
|
242
|
+
} else {
|
|
243
|
+
this.clearRedirectTrail();
|
|
244
|
+
throw routerErrorFn$1(`Router redirect to '${pathname}' not supported`);
|
|
245
|
+
}
|
|
59
246
|
return null;
|
|
60
247
|
}
|
|
61
|
-
if (typeof
|
|
62
|
-
this.
|
|
248
|
+
if (typeof route.redirectTo === "string") {
|
|
249
|
+
this.assertRedirectTarget(pathname, route.redirectTo);
|
|
250
|
+
this.navigateTo(route.redirectTo);
|
|
63
251
|
return null;
|
|
64
252
|
}
|
|
65
|
-
|
|
253
|
+
this.clearRedirectTrail();
|
|
254
|
+
this.lastResolvedParams = this.cloneNavigatorParams(this.getNavigatorParams());
|
|
255
|
+
return route;
|
|
66
256
|
}
|
|
67
257
|
};
|
|
68
258
|
//#endregion
|
|
259
|
+
//#region src/providers/query-encoding.ts
|
|
260
|
+
/** 查询串组件:写入 URL 时 encode,从 URL 解析出对象时 decode(与常见路由约定一致)。 */
|
|
261
|
+
function encodeQueryParamComponent(value) {
|
|
262
|
+
try {
|
|
263
|
+
return encodeURIComponent(value);
|
|
264
|
+
} catch {
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function decodeQueryParamComponent(value) {
|
|
269
|
+
try {
|
|
270
|
+
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
271
|
+
} catch {
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
69
276
|
//#region src/providers/url-parser.ts
|
|
70
277
|
var UrlParser = class {
|
|
71
278
|
index = 0;
|
|
@@ -84,7 +291,7 @@ var UrlParser = class {
|
|
|
84
291
|
this.index++;
|
|
85
292
|
this.tokens.push({
|
|
86
293
|
type: "query",
|
|
87
|
-
|
|
294
|
+
queryParams: this.readQuery()
|
|
88
295
|
});
|
|
89
296
|
} else if (this.peek("#")) {
|
|
90
297
|
this.index++;
|
|
@@ -114,7 +321,7 @@ var UrlParser = class {
|
|
|
114
321
|
urlTree.paths.push(item.value);
|
|
115
322
|
break;
|
|
116
323
|
case "query":
|
|
117
|
-
urlTree.queryParams = item.
|
|
324
|
+
urlTree.queryParams = item.queryParams;
|
|
118
325
|
break;
|
|
119
326
|
case "hash": urlTree.hash = item.value;
|
|
120
327
|
}
|
|
@@ -128,11 +335,11 @@ var UrlParser = class {
|
|
|
128
335
|
readQuery() {
|
|
129
336
|
const query = {};
|
|
130
337
|
while (this.index < this.url.length) {
|
|
131
|
-
const key = this.readQueryKey();
|
|
338
|
+
const key = decodeQueryParamComponent(this.readQueryKey());
|
|
132
339
|
let value = "";
|
|
133
340
|
if (this.peek("=")) {
|
|
134
341
|
this.index++;
|
|
135
|
-
value = this.readQueryValue();
|
|
342
|
+
value = decodeQueryParamComponent(this.readQueryValue());
|
|
136
343
|
}
|
|
137
344
|
const oldValue = query[key];
|
|
138
345
|
if (oldValue) if (Array.isArray(oldValue)) oldValue.push(value);
|
|
@@ -215,33 +422,33 @@ var Navigator = class {
|
|
|
215
422
|
};
|
|
216
423
|
function formatUrl(pathname, urlFormatParams) {
|
|
217
424
|
pathname = pathname.replace(/\/+/g, "/");
|
|
218
|
-
const { queryParams,
|
|
219
|
-
return pathname + (queryParams ? "?" + formatQueryParams(queryParams) : "") + (
|
|
425
|
+
const { queryParams, hash } = urlFormatParams;
|
|
426
|
+
return pathname + (queryParams ? "?" + formatQueryParams(queryParams) : "") + (hash !== void 0 && hash !== null ? "#" + hash : "");
|
|
220
427
|
}
|
|
221
428
|
function formatQueryParams(queryParams) {
|
|
222
429
|
const params = [];
|
|
223
430
|
Object.keys(queryParams).forEach((key) => {
|
|
431
|
+
const encKey = encodeQueryParamComponent(key);
|
|
224
432
|
const values = queryParams[key];
|
|
225
433
|
if (Array.isArray(values)) values.forEach((i) => {
|
|
226
|
-
params.push(`${
|
|
434
|
+
params.push(`${encKey}=${encodeQueryParamComponent(i)}`);
|
|
227
435
|
});
|
|
228
|
-
else params.push(`${
|
|
436
|
+
else params.push(`${encKey}=${encodeQueryParamComponent(values)}`);
|
|
229
437
|
});
|
|
230
438
|
return params.join("&");
|
|
231
439
|
}
|
|
232
440
|
var BrowserNavigator = class BrowserNavigator extends Navigator {
|
|
233
441
|
onUrlChanged;
|
|
442
|
+
pendingNavigation = null;
|
|
234
443
|
/** 挂载在 location 上的路径前缀;'' 或 '/' 表示站点根,不做剥离 */
|
|
235
444
|
get basePathPrefix() {
|
|
236
445
|
return this.baseUrl === "/" || this.baseUrl === "" ? "" : this.baseUrl;
|
|
237
446
|
}
|
|
238
447
|
get pathname() {
|
|
239
|
-
|
|
240
|
-
if (!this.basePathPrefix) return pathname;
|
|
241
|
-
return pathname.startsWith(this.basePathPrefix) ? pathname.substring(this.basePathPrefix.length) : pathname;
|
|
448
|
+
return this.stripBaseFromLocationPathname(location.pathname);
|
|
242
449
|
}
|
|
243
450
|
urlParser = new UrlParser();
|
|
244
|
-
urlTree = this.
|
|
451
|
+
urlTree = this.readUrlTreeFromLocation();
|
|
245
452
|
urlChangeEvent = new Subject();
|
|
246
453
|
subscription = new Subscription();
|
|
247
454
|
constructor(baseUrl, hooks = {}) {
|
|
@@ -249,51 +456,62 @@ var BrowserNavigator = class BrowserNavigator extends Navigator {
|
|
|
249
456
|
this.hooks = hooks;
|
|
250
457
|
this.onUrlChanged = this.urlChangeEvent.asObservable();
|
|
251
458
|
this.subscription.add(fromEvent(window, "popstate").subscribe(() => {
|
|
252
|
-
this.urlTree = this.
|
|
459
|
+
this.urlTree = this.readUrlTreeFromLocation();
|
|
253
460
|
this.urlChangeEvent.next();
|
|
254
461
|
}));
|
|
255
|
-
if (this.basePathPrefix && !location.pathname.startsWith(this.basePathPrefix))
|
|
462
|
+
if (this.basePathPrefix && !location.pathname.startsWith(this.basePathPrefix)) {
|
|
463
|
+
history.replaceState(null, "", this.baseUrl);
|
|
464
|
+
this.urlTree = this.readUrlTreeFromHistoryHref(this.baseUrl);
|
|
465
|
+
}
|
|
256
466
|
}
|
|
257
|
-
to(pathName, relative, queryParams,
|
|
258
|
-
const url = this.join(pathName, relative, queryParams,
|
|
467
|
+
to(pathName, relative, queryParams, hash) {
|
|
468
|
+
const url = this.join(pathName, relative, queryParams, hash);
|
|
259
469
|
if (location.origin + url === location.href) return true;
|
|
260
470
|
this.runHooks({
|
|
261
471
|
pathname: this.pathname,
|
|
262
472
|
queryParams: this.urlTree.queryParams,
|
|
263
|
-
|
|
473
|
+
hash: this.urlTree.hash
|
|
264
474
|
}, {
|
|
265
475
|
pathname: pathName,
|
|
266
476
|
queryParams: queryParams || {},
|
|
267
|
-
|
|
477
|
+
hash: hash ?? null
|
|
268
478
|
}, () => {
|
|
479
|
+
this.pendingNavigation = {
|
|
480
|
+
type: "push",
|
|
481
|
+
from: this.getCurrentUrl()
|
|
482
|
+
};
|
|
269
483
|
history.pushState(null, "", url);
|
|
270
|
-
this.urlTree = this.
|
|
484
|
+
this.urlTree = this.readUrlTreeFromHistoryHref(url);
|
|
271
485
|
this.urlChangeEvent.next();
|
|
272
486
|
});
|
|
273
487
|
return true;
|
|
274
488
|
}
|
|
275
|
-
replace(pathName, relative, queryParams,
|
|
276
|
-
const url = this.join(pathName, relative, queryParams,
|
|
489
|
+
replace(pathName, relative, queryParams, hash) {
|
|
490
|
+
const url = this.join(pathName, relative, queryParams, hash);
|
|
277
491
|
if (location.origin + url === location.href) return true;
|
|
278
492
|
this.runHooks({
|
|
279
493
|
pathname: this.pathname,
|
|
280
494
|
queryParams: this.urlTree.queryParams,
|
|
281
|
-
|
|
495
|
+
hash: this.urlTree.hash
|
|
282
496
|
}, {
|
|
283
497
|
pathname: pathName,
|
|
284
498
|
queryParams: queryParams || {},
|
|
285
|
-
|
|
499
|
+
hash: hash ?? null
|
|
286
500
|
}, () => {
|
|
501
|
+
this.pendingNavigation = {
|
|
502
|
+
type: "replace",
|
|
503
|
+
from: this.getCurrentUrl()
|
|
504
|
+
};
|
|
287
505
|
history.replaceState(null, "", url);
|
|
288
|
-
this.urlTree = this.
|
|
506
|
+
this.urlTree = this.readUrlTreeFromHistoryHref(url);
|
|
289
507
|
this.urlChangeEvent.next();
|
|
290
508
|
});
|
|
291
509
|
return true;
|
|
292
510
|
}
|
|
293
|
-
join(pathname, relative, queryParams,
|
|
511
|
+
join(pathname, relative, queryParams, hash) {
|
|
294
512
|
if (pathname.startsWith("/")) return formatUrl(this.baseUrl + pathname, {
|
|
295
513
|
queryParams,
|
|
296
|
-
|
|
514
|
+
hash
|
|
297
515
|
});
|
|
298
516
|
const beforePath = this.urlTree.paths.slice(0, relative.deep);
|
|
299
517
|
while (true) {
|
|
@@ -308,9 +526,11 @@ var BrowserNavigator = class BrowserNavigator extends Navigator {
|
|
|
308
526
|
}
|
|
309
527
|
break;
|
|
310
528
|
}
|
|
311
|
-
|
|
529
|
+
const tail = [...beforePath, pathname].join("/");
|
|
530
|
+
const base = this.baseUrl.replace(/\/+$/, "");
|
|
531
|
+
return formatUrl((base ? `${base}/${tail}` : `/${tail}`).replace(/\/+/g, "/"), {
|
|
312
532
|
queryParams,
|
|
313
|
-
|
|
533
|
+
hash
|
|
314
534
|
});
|
|
315
535
|
}
|
|
316
536
|
back() {
|
|
@@ -322,22 +542,64 @@ var BrowserNavigator = class BrowserNavigator extends Navigator {
|
|
|
322
542
|
go(offset) {
|
|
323
543
|
history.go(offset);
|
|
324
544
|
}
|
|
545
|
+
confirmNavigation() {
|
|
546
|
+
this.pendingNavigation = null;
|
|
547
|
+
}
|
|
548
|
+
cancelNavigation() {
|
|
549
|
+
const pending = this.pendingNavigation;
|
|
550
|
+
this.pendingNavigation = null;
|
|
551
|
+
if (!pending) return;
|
|
552
|
+
if (pending.type === "push") {
|
|
553
|
+
history.back();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
history.replaceState(null, "", pending.from);
|
|
557
|
+
this.urlTree = this.readUrlTreeFromHistoryHref(pending.from);
|
|
558
|
+
this.urlChangeEvent.next();
|
|
559
|
+
}
|
|
325
560
|
destroy() {
|
|
326
561
|
this.subscription.unsubscribe();
|
|
327
562
|
}
|
|
328
563
|
runHooks(beforeParams, currentParams, next) {
|
|
329
|
-
if (typeof this.hooks.beforeEach === "function")
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
564
|
+
if (typeof this.hooks.beforeEach === "function") {
|
|
565
|
+
let called = false;
|
|
566
|
+
let warningTimer = null;
|
|
567
|
+
const proceed = () => {
|
|
568
|
+
if (called) return;
|
|
569
|
+
called = true;
|
|
570
|
+
if (warningTimer) clearTimeout(warningTimer);
|
|
571
|
+
next();
|
|
572
|
+
this.hooks.afterEach?.(currentParams);
|
|
573
|
+
};
|
|
574
|
+
this.hooks.beforeEach?.(beforeParams, currentParams, proceed);
|
|
575
|
+
const env = globalThis?.process?.env;
|
|
576
|
+
if (!(env?.env === "production" || env?.NODE_ENV === "production") && !called) warningTimer = setTimeout(() => {
|
|
577
|
+
if (!called) console.warn("[Viewfly Router] NavigatorHooks.beforeEach did not call next(); navigation remains pending.");
|
|
578
|
+
}, 300);
|
|
579
|
+
} else {
|
|
334
580
|
next();
|
|
335
581
|
this.hooks.afterEach?.(currentParams);
|
|
336
582
|
}
|
|
337
583
|
}
|
|
338
|
-
|
|
584
|
+
stripBaseFromLocationPathname(fullPathname) {
|
|
585
|
+
if (!this.basePathPrefix) return fullPathname;
|
|
586
|
+
return fullPathname.startsWith(this.basePathPrefix) ? fullPathname.substring(this.basePathPrefix.length) : fullPathname;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* 按与 `history.pushState` / `replaceState` 写入会话的 URL 解析 urlTree(与传入 `join` 的相对路径一致)。
|
|
590
|
+
* History 更新后若仍仅从 `Location` 读路径,可能与实际会话 URL 不一致(如 Location 与 document 的同步时序、部分宿主环境)。
|
|
591
|
+
*/
|
|
592
|
+
readUrlTreeFromHistoryHref(hrefRelativeToOrigin) {
|
|
593
|
+
const abs = new URL(hrefRelativeToOrigin, location.origin);
|
|
594
|
+
const logicalPathname = this.stripBaseFromLocationPathname(abs.pathname);
|
|
595
|
+
return this.urlParser.parse(`${logicalPathname}${abs.search}${abs.hash}`);
|
|
596
|
+
}
|
|
597
|
+
readUrlTreeFromLocation() {
|
|
339
598
|
return this.urlParser.parse(this.pathname + location.search + location.hash);
|
|
340
599
|
}
|
|
600
|
+
getCurrentUrl() {
|
|
601
|
+
return location.pathname + location.search + location.hash;
|
|
602
|
+
}
|
|
341
603
|
};
|
|
342
604
|
BrowserNavigator = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [String, Object])], BrowserNavigator);
|
|
343
605
|
//#endregion
|
|
@@ -368,12 +630,68 @@ function useQueryParams() {
|
|
|
368
630
|
return queryParams;
|
|
369
631
|
}
|
|
370
632
|
//#endregion
|
|
633
|
+
//#region src/hooks/use-params.ts
|
|
634
|
+
function useParams() {
|
|
635
|
+
const router = inject(Router);
|
|
636
|
+
const pathParams = { ...router.params };
|
|
637
|
+
const readonlyParams = new Proxy(pathParams, readonlyProxyHandler);
|
|
638
|
+
const subscription = router.onRefresh.subscribe(() => {
|
|
639
|
+
comparePropsWithCallbacks(pathParams, router.params, (key) => {
|
|
640
|
+
internalWrite(() => {
|
|
641
|
+
Reflect.deleteProperty(pathParams, key);
|
|
642
|
+
});
|
|
643
|
+
}, (key, value) => {
|
|
644
|
+
internalWrite(() => {
|
|
645
|
+
pathParams[key] = value;
|
|
646
|
+
});
|
|
647
|
+
}, (key, value) => {
|
|
648
|
+
internalWrite(() => {
|
|
649
|
+
pathParams[key] = value;
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
onUnmounted(() => {
|
|
654
|
+
subscription.unsubscribe();
|
|
655
|
+
});
|
|
656
|
+
return readonlyParams;
|
|
657
|
+
}
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/providers/routes.ts
|
|
660
|
+
var Routes = new InjectionToken("Routes");
|
|
661
|
+
//#endregion
|
|
371
662
|
//#region src/link.tsx
|
|
372
663
|
function Link(props) {
|
|
373
664
|
const navigator = inject(Navigator);
|
|
374
665
|
const router = inject(Router);
|
|
666
|
+
function buildDomAttrs() {
|
|
667
|
+
const attrs = {};
|
|
668
|
+
Object.keys(props).forEach((key) => {
|
|
669
|
+
switch (key) {
|
|
670
|
+
case "to":
|
|
671
|
+
case "active":
|
|
672
|
+
case "exact":
|
|
673
|
+
case "queryParams":
|
|
674
|
+
case "hash":
|
|
675
|
+
case "tag":
|
|
676
|
+
case "children": return;
|
|
677
|
+
default: attrs[key] = props[key];
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
return attrs;
|
|
681
|
+
}
|
|
682
|
+
function normalizePathname(path) {
|
|
683
|
+
const pathname = (path.split("#")[0].split("?")[0] || "/").replace(/\/+$/, "") || "/";
|
|
684
|
+
const baseUrl = navigator.baseUrl === "/" || navigator.baseUrl === "" ? "" : navigator.baseUrl;
|
|
685
|
+
if (!baseUrl) return pathname;
|
|
686
|
+
const base = baseUrl.replace(/\/+$/, "") || "/";
|
|
687
|
+
if (pathname === base) return "/";
|
|
688
|
+
if (pathname.startsWith(base + "/")) return pathname.substring(base.length);
|
|
689
|
+
return pathname;
|
|
690
|
+
}
|
|
375
691
|
function getActive() {
|
|
376
|
-
|
|
692
|
+
const currentPathname = normalizePathname(navigator.pathname);
|
|
693
|
+
const targetPathname = normalizePathname(navigator.join(props.to, router));
|
|
694
|
+
return props.exact ? currentPathname === targetPathname : currentPathname === targetPathname || currentPathname.startsWith(targetPathname + "/");
|
|
377
695
|
}
|
|
378
696
|
const isActive = reactive({ value: getActive() });
|
|
379
697
|
const subscription = navigator.onUrlChanged.subscribe(() => {
|
|
@@ -382,21 +700,27 @@ function Link(props) {
|
|
|
382
700
|
onUnmounted(() => {
|
|
383
701
|
subscription.unsubscribe();
|
|
384
702
|
});
|
|
703
|
+
/** 仅左键且无修饰键时由 SPA 接管,其余交给浏览器(新标签、下载默认等) */
|
|
704
|
+
function shouldHandleSpaClick(ev) {
|
|
705
|
+
if (ev.button !== 0) return false;
|
|
706
|
+
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false;
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
385
709
|
function navigate(ev) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
710
|
+
const isAnchorTag = !props.tag || props.tag === "a";
|
|
711
|
+
if (isAnchorTag && props.target === "_blank") return;
|
|
712
|
+
if (isAnchorTag && ev.cancelable) ev.preventDefault();
|
|
713
|
+
router.navigateTo(props.to, props.queryParams, props.hash);
|
|
389
714
|
}
|
|
390
715
|
return () => {
|
|
391
716
|
const Tag = props.tag || "a";
|
|
392
|
-
const attrs = Object.assign(
|
|
393
|
-
onClick(ev)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (Tag === "a") attrs.href = navigator.join(props.to, router, props.queryParams, props.fragment);
|
|
717
|
+
const attrs = Object.assign(buildDomAttrs(), { onClick(ev) {
|
|
718
|
+
props.onClick?.(ev);
|
|
719
|
+
if (ev.defaultPrevented) return;
|
|
720
|
+
if (!shouldHandleSpaClick(ev)) return;
|
|
721
|
+
navigate(ev);
|
|
722
|
+
} });
|
|
723
|
+
if (Tag === "a") attrs.href = navigator.join(props.to, router, props.queryParams, props.hash);
|
|
400
724
|
if (isActive.value && props.active) attrs.class = [attrs.class, props.active];
|
|
401
725
|
return /* @__PURE__ */ jsx(Tag, {
|
|
402
726
|
...attrs,
|
|
@@ -409,9 +733,9 @@ function Link(props) {
|
|
|
409
733
|
var RouterModule = class {
|
|
410
734
|
subscription = new Subscription();
|
|
411
735
|
navigator;
|
|
412
|
-
constructor(
|
|
413
|
-
this.
|
|
414
|
-
this.navigator = new BrowserNavigator(
|
|
736
|
+
constructor(config) {
|
|
737
|
+
this.config = config;
|
|
738
|
+
this.navigator = new BrowserNavigator(config.baseUrl || "", config.hooks);
|
|
415
739
|
}
|
|
416
740
|
setup(app) {
|
|
417
741
|
const navigator = this.navigator;
|
|
@@ -419,13 +743,20 @@ var RouterModule = class {
|
|
|
419
743
|
this.subscription.add(navigator.onUrlChanged.subscribe(() => {
|
|
420
744
|
router.refresh();
|
|
421
745
|
}));
|
|
422
|
-
app.provide([
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
746
|
+
app.provide([
|
|
747
|
+
{
|
|
748
|
+
provide: Navigator,
|
|
749
|
+
useValue: navigator
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
provide: Router,
|
|
753
|
+
useValue: router
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
provide: Routes,
|
|
757
|
+
useValue: this.config.routes || []
|
|
758
|
+
}
|
|
759
|
+
]);
|
|
429
760
|
}
|
|
430
761
|
onDestroy() {
|
|
431
762
|
this.subscription.unsubscribe();
|
|
@@ -438,37 +769,127 @@ var routerErrorFn = makeError("RouterOutlet");
|
|
|
438
769
|
function RouterOutlet(props) {
|
|
439
770
|
const router = inject(Router, null);
|
|
440
771
|
if (router === null) throw routerErrorFn("cannot found parent Router.");
|
|
441
|
-
const
|
|
772
|
+
const currentRouter = router;
|
|
773
|
+
const routes = inject(Routes, []);
|
|
774
|
+
const navigator = inject(Navigator);
|
|
775
|
+
const childRouter = new Router(navigator, currentRouter);
|
|
442
776
|
const Context = createContext([{
|
|
443
777
|
provide: Router,
|
|
444
778
|
useValue: childRouter
|
|
445
779
|
}]);
|
|
446
780
|
const children = shallowReactive({ value: null });
|
|
447
|
-
|
|
781
|
+
let confirmedParams = null;
|
|
782
|
+
const subscription = currentRouter.onRefresh.pipe(microTask()).subscribe(() => {
|
|
448
783
|
updateChildren();
|
|
449
784
|
});
|
|
785
|
+
/** 每次 `updateChildren` 调用递增;并发/卸载时旧的一次性任务应放弃写 UI */
|
|
786
|
+
let navigationGeneration = 0;
|
|
450
787
|
onUnmounted(() => {
|
|
788
|
+
navigationGeneration += 1;
|
|
451
789
|
subscription.unsubscribe();
|
|
452
790
|
});
|
|
453
|
-
let
|
|
791
|
+
let activateRoute = null;
|
|
792
|
+
let activateRouteParamsKey = "";
|
|
793
|
+
watch(() => props.name, () => {
|
|
794
|
+
activateRoute = null;
|
|
795
|
+
updateChildren();
|
|
796
|
+
});
|
|
797
|
+
watch(() => props.children, () => {
|
|
798
|
+
if (activateRoute) return;
|
|
799
|
+
updateChildren();
|
|
800
|
+
});
|
|
801
|
+
function isStaleNavigation(token) {
|
|
802
|
+
return token !== navigationGeneration;
|
|
803
|
+
}
|
|
804
|
+
function getParamsKey(params) {
|
|
805
|
+
return Object.keys(params).sort().map((key) => `${key}=${params[key]}`).join("&");
|
|
806
|
+
}
|
|
807
|
+
function getNavigatorParams() {
|
|
808
|
+
const pathname = "/" + navigator.urlTree.paths.join("/");
|
|
809
|
+
return {
|
|
810
|
+
pathname: pathname === "/" ? pathname : pathname.replace(/\/+/g, "/"),
|
|
811
|
+
queryParams: navigator.urlTree.queryParams,
|
|
812
|
+
hash: navigator.urlTree.hash
|
|
813
|
+
};
|
|
814
|
+
}
|
|
454
815
|
async function updateChildren() {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
816
|
+
const token = navigationGeneration += 1;
|
|
817
|
+
const to = getNavigatorParams();
|
|
818
|
+
const route = currentRouter.resolve(routes);
|
|
819
|
+
if (!route) {
|
|
820
|
+
navigator.confirmNavigation();
|
|
821
|
+
childRouter.setParams({});
|
|
822
|
+
confirmedParams = to;
|
|
823
|
+
activateRoute = null;
|
|
824
|
+
activateRouteParamsKey = "";
|
|
458
825
|
children.value = props.children || null;
|
|
459
826
|
return;
|
|
460
827
|
}
|
|
461
|
-
|
|
462
|
-
|
|
828
|
+
childRouter.setParams({ ...currentRouter.lastResolvePathParams });
|
|
829
|
+
if (route === activateRoute && activateRouteParamsKey === getParamsKey(currentRouter.lastResolvePathParams)) {
|
|
830
|
+
navigator.confirmNavigation();
|
|
831
|
+
confirmedParams = to;
|
|
832
|
+
childRouter.refresh();
|
|
833
|
+
return;
|
|
463
834
|
}
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
835
|
+
if (typeof route.canActivate === "function") {
|
|
836
|
+
const ok = await route.canActivate({
|
|
837
|
+
to,
|
|
838
|
+
from: confirmedParams,
|
|
839
|
+
router: childRouter,
|
|
840
|
+
params: currentRouter.lastResolvePathParams
|
|
841
|
+
});
|
|
842
|
+
if (isStaleNavigation(token)) return;
|
|
843
|
+
if (!ok) {
|
|
844
|
+
navigator.cancelNavigation();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
await applyRoute(route, token);
|
|
849
|
+
if (isStaleNavigation(token)) return;
|
|
850
|
+
navigator.confirmNavigation();
|
|
851
|
+
confirmedParams = to;
|
|
852
|
+
}
|
|
853
|
+
async function applyRoute(route, token) {
|
|
854
|
+
let Component = null;
|
|
855
|
+
if (props.name) {
|
|
856
|
+
const namedComponents = route.namedComponents || [];
|
|
857
|
+
for (const named of namedComponents) if (named.name === props.name) {
|
|
858
|
+
if (named.component) Component = named.component;
|
|
859
|
+
else if (typeof named.asyncComponent === "function") {
|
|
860
|
+
Component = await named.asyncComponent();
|
|
861
|
+
if (isStaleNavigation(token)) return;
|
|
862
|
+
}
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
} else if (route.component) Component = route.component;
|
|
866
|
+
else if (typeof route.asyncComponent === "function") {
|
|
867
|
+
Component = await route.asyncComponent();
|
|
868
|
+
if (isStaleNavigation(token)) return;
|
|
869
|
+
if (!Component) {
|
|
870
|
+
activateRoute = null;
|
|
871
|
+
children.value = props.children || null;
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (!Component) Component = RouterOutlet;
|
|
876
|
+
let subRoutes = [];
|
|
877
|
+
if (route.path !== "*") {
|
|
878
|
+
if (Array.isArray(route.children)) subRoutes = route.children;
|
|
879
|
+
else if (typeof route.children === "function") {
|
|
880
|
+
subRoutes = await route.children();
|
|
881
|
+
if (isStaleNavigation(token)) return;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (!Array.isArray(subRoutes)) subRoutes = [];
|
|
885
|
+
const Context = createContext([{
|
|
886
|
+
provide: Routes,
|
|
887
|
+
useValue: subRoutes
|
|
888
|
+
}]);
|
|
889
|
+
if (isStaleNavigation(token)) return;
|
|
890
|
+
activateRoute = route;
|
|
891
|
+
activateRouteParamsKey = getParamsKey(currentRouter.lastResolvePathParams);
|
|
892
|
+
children.value = /* @__PURE__ */ jsx(Context, { children: /* @__PURE__ */ jsx(Component, {}) });
|
|
472
893
|
}
|
|
473
894
|
updateChildren();
|
|
474
895
|
return () => {
|
|
@@ -476,4 +897,4 @@ function RouterOutlet(props) {
|
|
|
476
897
|
};
|
|
477
898
|
}
|
|
478
899
|
//#endregion
|
|
479
|
-
export { BrowserNavigator, Link, Navigator, Router, RouterModule, RouterOutlet, UrlParser, formatQueryParams, formatUrl, useQueryParams };
|
|
900
|
+
export { BrowserNavigator, Link, Navigator, Router, RouterModule, RouterOutlet, Routes, UrlParser, formatQueryParams, formatUrl, useParams, useQueryParams };
|