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

2 weeks ago
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 }