You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
OneAuth/ui/vhtml/vrouter.js

625 lines
16 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import vproxy from './vproxy.js'
import vget from './vget.js'
// 解析URL字符串提取路径、查询参数和hash
function parseUrlString(urlString, scoped) {
let url
let path
// 判断是否为完整URL包含协议
if (urlString.startsWith('http://') || urlString.startsWith('https://')) {
url = new URL(urlString)
// 如果是外部URL返回null不处理外部链接
if (url.origin !== window.location.origin) {
return null
}
if (url.pathname.startsWith(scoped)) {
path = url.pathname.slice(scoped.length) // 去掉根路径
}
} else {
// 相对路径基于当前origin构建完整URL
url = new URL(urlString, window.location.href)
path = url.pathname
}
// 解析查询参数
const query = {}
url.searchParams.forEach((value, key) => {
query[key] = value
})
return {
path: path,
query,
hash: url.hash
}
}
class VRouter {
#routes = []
#history = []
#current = null
#scoped = ''
#listeners = []
#pageCache = new Map()
#node = null
#env = null
#originContent = []
#loaded = false
#vhtml = null
#routesByName = new Map() // 添加按名称索引的路由缓存
constructor() {
this.init()
}
get routes() { return this.#routes.slice() }
get history() { return this.#history.slice() }
get current() { return this.#current }
get query() { return this.#current?.query || {} }
get params() { return this.#current?.params || {} }
get scoped() { return this.#scoped }
onChange(fc) {
this.#listeners.push(fc)
}
addRoute(route) {
if (!route.path) throw new Error('Route must have a path')
if (route.path != '/' && route.path.endsWith('/')) {
route.path = route.path.slice(0, -1)
}
const routeConfig = {
path: route.path,
component: route.component,
name: route.name,
meta: route.meta || {},
children: route.children || [],
matcher: new RouteMatcher(route.path, route.name),
description: route.description || '',
layout: route.layout || '',
}
this.#routes.push(routeConfig)
// 如果有名称,添加到名称索引中
if (route.name) {
this.#routesByName.set(route.name, routeConfig)
}
// 递归处理子路由
if (route.children?.length > 0) {
route.children.forEach(child => {
const childPath = route.path + (child.path.startsWith('/') ? child.path : '/' + child.path)
const layout = child.layout || route.layout || ''
const meta = { ...route.meta, ...child.meta }
this.addRoute({ ...child, path: childPath, parent: routeConfig, layout, meta })
})
}
}
addRoutes(routes) {
routes.forEach(route => this.addRoute(route))
}
#notifyListeners(to, from) {
this.#listeners.forEach(listener => {
if (typeof listener === 'function') {
try {
listener(to, from)
} catch (error) {
console.error('Error in router listener:', error)
}
}
})
}
#setRouterPath(matchedRoute) {
const oldRoute = this.#current
this.#current = {
path: matchedRoute.path,
fullPath: matchedRoute.fullPath,
params: matchedRoute.params || {},
query: matchedRoute.query || {},
hash: new URL(matchedRoute.fullPath, window.location.origin).hash,
meta: matchedRoute.route?.meta || {},
description: matchedRoute.route?.description || '',
layout: matchedRoute.route?.layout || '',
name: matchedRoute.route?.name,
matched: matchedRoute.route ? [matchedRoute.route] : []
}
this.#history.push(this.#current)
if (this.#scoped && !matchedRoute.fullPath.startsWith('http')) {
history.pushState({}, '', this.#scoped + matchedRoute.fullPath)
} else {
history.pushState({}, '', matchedRoute.fullPath)
}
this.#notifyListeners(this.#current, oldRoute)
}
// 优化后的路由匹配方法,支持多种参数类型
matchRoute(to) {
// 处理不同类型的路由参数
const routeInfo = this.normalizeRouteTarget(to)
if (!routeInfo) return null
const { path, query, params, name } = routeInfo
// 如果是按名称匹配
if (name) {
const route = this.#routesByName.get(name)
if (!route) return null
// 构建带参数的路径
let resolvedPath = route.path
Object.entries(params).forEach(([key, value]) => {
resolvedPath = resolvedPath.replace(`:${key}`, value)
})
const match = route.matcher.match(resolvedPath)
if (match) {
return {
route,
params: { ...match.params, ...params },
matched: match.matched,
path: resolvedPath,
query,
name
}
}
return null
}
// 按路径匹配
for (const route of this.#routes) {
const match = route.matcher.match(path)
if (match && route.component) {
return {
route,
params: { ...match.params, ...params },
matched: match.matched,
description: route.description,
layout: route.layout,
path,
query,
name: route.name
}
}
}
return null
}
// 标准化路由目标参数
normalizeRouteTarget(to) {
let path, query = {}, params = {}, hash = '', name
if (typeof to === 'string') {
// 字符串类型解析可能包含的URL、query、hash
const parsed = parseUrlString(to, this.#scoped)
if (!parsed) return null // 外部URL或解析失败
path = parsed.path
query = { ...parsed.query }
hash = parsed.hash
} else if (to && typeof to === 'object') {
if (to.path) {
// {path} 类型path可能也包含query和hash
const parsed = parseUrlString(to.path, this.#scoped)
if (!parsed) return null
path = parsed.path
// 合并query参数对象中的query优先级更高
query = { ...parsed.query, ...(to.query || {}) }
hash = to.hash || parsed.hash
params = to.params || {}
} else if (to.name) {
// {name} 类型
name = to.name
query = to.query || {}
params = to.params || {}
hash = to.hash || ''
} else {
return null
}
} else {
return null
}
// 标准化路径
if (path && !path.startsWith('/')) {
path = '/' + path
}
if (this.#scoped) {
path = path.startsWith(this.#scoped) ? path.slice(this.#scoped.length) : path
}
if (!path.startsWith('/')) {
path = '/' + path
}
if (path != '/' && path.endsWith('/')) {
path = path.slice(0, -1)
}
return { path, query, params, hash, name }
}
matchTo(to) {
const matchResult = this.matchRoute(to)
if (!matchResult) return null
const { route, params, query, path, name } = matchResult
// 构建查询字符串
let search = ''
if (query && Object.keys(query).length > 0) {
search = '?' + Object.entries(query)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&')
}
const fullPath = (path || matchResult.path) + search
return {
route,
params,
query,
name: name || route.name,
path: path || matchResult.path,
fullPath,
matched: [route]
}
}
buildUrl(baseUrl, additionalQuery = {}) {
const url = new URL(baseUrl, window.location.origin)
Object.entries(additionalQuery).forEach(([key, value]) => {
url.searchParams.append(key, value)
})
return url
}
resolveRoutePath(route, params = {}) {
let path = route.component || route.path
Object.entries(params).forEach(([key, value]) => {
path = path.replace(`:${key}`, value)
})
if (path === '/' || path === '') path = '/index'
if (!path.startsWith('/')) path = '/' + path
if (path.endsWith('.html')) path = path.slice(0, -5)
if (path.endsWith('/')) path = path.slice(0, -1)
return path
}
async #navigateTo(matchedRoute) {
if (!matchedRoute) {
console.warn(`No route matched`)
return
}
const { route, params, query } = matchedRoute
const to = {
path: matchedRoute.path,
fullPath: matchedRoute.fullPath,
params,
query,
hash: new URL(matchedRoute.fullPath, window.location.origin).hash,
meta: route.meta,
description: route.description,
layout: route.layout,
name: route.name,
matched: [route]
}
if (this.beforeEnter) {
try {
let shouldContinue = true
const result = await this.beforeEnter(to, this.#current, (next) => {
if (next) {
shouldContinue = false
this.push(next)
}
})
if (result === false || !shouldContinue) return
} catch (error) {
console.error('Error in beforeEnter guard:', error)
return
}
}
const cacheKey = matchedRoute.fullPath
let page = this.#pageCache.get(cacheKey)
this.#setRouterPath(matchedRoute)
if (page) {
page.activate()
} else {
page = new Page(this.#vhtml, this.#node, matchedRoute)
await page.mount(this.#env, this.#originContent, to.layout)
this.#pageCache.set(cacheKey, page)
}
}
async push(to) {
const matchedRoute = this.matchTo(to)
if (!matchedRoute) {
const target = typeof to === 'string' ? to : (to.path || `name: ${to.name}`)
console.warn(`No route matched for ${target}`)
return
}
await this.#navigateTo(matchedRoute)
}
replace(to) {
this.push(to)
if (this.#history.length > 1) {
this.#history.splice(-2, 1)
}
}
go(n) { history.go(n) }
back() { history.back() }
forward() { history.forward() }
init() {
if (this.#loaded) return
this.#loaded = true
document.body.addEventListener('click', (event) => {
const linkElement = event.target.closest('a')
if (!linkElement) return
const href = linkElement.getAttribute('href')
if (!href || href.startsWith('http') || href.startsWith('#')) return
event.preventDefault()
const reload = linkElement.hasAttribute('reload')
if (reload) {
window.location.href = href
} else {
this.push(href)
}
}, true)
window.addEventListener('popstate', () => {
this.push(window.location.href)
})
}
ParseVrouter($vhtml, $node, env) {
this.#node = $node
this.#env = env
this.#scoped = env.scoped || ''
this.#originContent = Array.from($node.childNodes)
this.#vhtml = $vhtml
this.push(window.location.href)
}
}
// 优化后的路由匹配器
class RouteMatcher {
constructor(path, name) {
this.originalPath = path
this.name = name
this.keys = []
this.regexp = this.pathToRegexp(path)
}
pathToRegexp(path) {
const paramPattern = /:([^(/]+)/g
let regexpStr = path.replace(paramPattern, (match, key) => {
this.keys.push(key)
return `(?<${key}>[^/]+)`
})
// 处理 *path 形式的通配符
regexpStr = regexpStr.replace(/\*(\w+)/g, (match, key) => {
this.keys.push(key)
return `(?<${key}>.*)`
});
// 如果有未处理的*号,将其替换为允许匹配任意数量字符的正则表达式
regexpStr = regexpStr.replace(/\*/g, '.*')
return new RegExp(`^${regexpStr}$`)
}
// 优化的匹配方法,支持多种参数类型
match(target) {
let path
// 处理不同类型的输入
if (typeof target === 'string') {
path = target
} else if (target && typeof target === 'object') {
if (target.path) {
path = target.path
} else if (target.name && target.name === this.name) {
// 如果按名称匹配且名称相符,返回基本匹配
return {
path: this.originalPath,
params: target.params || {},
matched: this.originalPath
}
} else {
return null
}
} else {
return null
}
const match = this.regexp.exec(path)
if (!match) return null
const params = {}
this.keys.forEach(key => {
if (match.groups?.[key]) {
params[key] = match.groups[key]
}
})
return {
path: this.originalPath,
params,
matched: match[0]
}
}
}
const layoutCache = new Map()
class Page {
constructor(vhtml, node, matchedRoute) {
this.vhtml = vhtml
this.node = node
this.layoutDom = undefined
this.matchedRoute = matchedRoute
this.htmlPath = this.resolveHtmlPath(matchedRoute)
}
resolveHtmlPath(matchedRoute) {
let path = matchedRoute.route.component || matchedRoute.route.path
if (typeof path === 'function') {
path = path(matchedRoute.path)
}
Object.entries(matchedRoute.params).forEach(([key, value]) => {
path = path.replace(`:${key}`, value)
})
if (!path.startsWith('/')) path = '/' + path
if (path.endsWith('/')) path = path.slice(0, -1)
if (!path.endsWith('.html')) path = path + '.html'
return path
}
async mount(env, originContent, layout) {
const parser = await vget.FetchUI(this.htmlPath, env)
if (parser.err) {
console.warn(parser.err)
let dom = document.createElement('div')
Object.assign(dom.style, { width: '100%', height: '100%' })
dom.append(...originContent)
this.node.innerHTML = ''
this.node.append(dom)
this.vhtml.parseRef(this.htmlPath, dom, {}, env, null, true)
return
}
this.title = parser.title || ''
const slots = {}
const dom = document.createElement("div")
dom.setAttribute('vsrc', this.htmlPath)
slots[''] = [dom]
this.slots = slots
if (!layout) {
this.node.innerHTML = ''
this.node.append(dom)
this.vhtml.parseRef(this.htmlPath, dom, {}, env, null)
return
}
let layoutDom = layoutCache.get(layout)
if (!layoutDom) {
let layoutUrl = layout
if (!layoutUrl.startsWith('/')) {
layoutUrl = '/' + layout
}
if (!layoutUrl.endsWith('.html')) {
layoutUrl += '.html'
}
if (!layoutUrl.startsWith('/layout')) {
layoutUrl = '/layout' + layoutUrl
}
const layoutParser = await vget.FetchUI(layoutUrl, env)
if (layoutParser.err) {
console.warn(`get layout ${layoutUrl} failed.`, layoutParser.err)
this.node.innerHTML = ''
this.node.append(dom)
this.vhtml.parseRef(this.htmlPath, dom, {}, env, null)
return
}
layoutDom = layoutParser.body.cloneNode(true)
layoutCache.set(layout, layoutDom)
dom.$refData = vproxy.Wrap({})
layoutDom.$refSlots = vproxy.Wrap({ ...slots })
this.node.innerHTML = ''
this.node.append(layoutDom)
this.layoutDom = layoutDom
this.vhtml.parseRef('/layout/' + layout, layoutDom, {}, env, null, true)
} else {
this.layoutDom = layoutDom
this.activate()
}
}
activate() {
if (this.title) document.title = this.title
const layoutDom = this.layoutDom
if (layoutDom) {
layoutDom.querySelectorAll("vslot").forEach(e => {
if (e.closest('[vref]') === layoutDom && this.slots[e.getAttribute('name') || '']) {
e.innerHTML = ''
}
})
Object.keys(layoutDom.$refSlots).forEach(key => {
delete layoutDom.$refSlots[key]
})
Object.assign(layoutDom.$refSlots, this.slots)
if (!layoutDom.isConnected) {
this.node.innerHTML = ''
}
this.node.append(layoutDom)
} else {
this.node.innerHTML = ''
const dom = this.slots['']
if (dom instanceof Array) {
this.node.append(...dom)
} else {
this.node.append(dom)
}
}
}
}
const $router = new VRouter()
const DefaultRoutes = [
{
path: '/',
component: '/page/index.html',
name: 'home',
},
{
path: '/404',
component: '/page/404.html',
name: '404'
},
{
path: '*',
component: (path) => {
if (path.endsWith('.html')) return path
return '/page' + path + '.html'
},
}
]
export default { $router, DefaultRoutes }