hookehuyr

feat: unify route metadata sync for og and share

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const wxMocks = vi.hoisted(() => ({
wxReady: vi.fn(callback => callback()),
updateAppMessageShareData: vi.fn(),
updateTimelineShareData: vi.fn(),
onMenuShareWeibo: vi.fn(),
}))
vi.mock('weixin-js-sdk', () => ({
default: {
ready: wxMocks.wxReady,
updateAppMessageShareData: wxMocks.updateAppMessageShareData,
updateTimelineShareData: wxMocks.updateTimelineShareData,
onMenuShareWeibo: wxMocks.onMenuShareWeibo,
},
}))
vi.mock('@/utils/tools', () => ({
wxInfo: () => ({
isWeiXin: true,
}),
}))
import { installWxShareSync, normalizeShareImageUrl, resolveRouteShareData } from '../useShare'
const createRouterMock = (initialRoute = { meta: { title: '首页' } }) => {
let afterEachHook = null
return {
currentRoute: { value: initialRoute },
afterEach: vi.fn(handler => {
afterEachHook = handler
return () => {
afterEachHook = null
}
}),
triggerAfterEach(route) {
this.currentRoute.value = route
afterEachHook?.(route)
},
}
}
const flushMutationObserver = async () => {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('useShare', () => {
beforeEach(() => {
document.head.innerHTML = '<title>美乐爱觉</title>'
document.title = '美乐爱觉'
window.location.hash = '#/'
wxMocks.wxReady.mockClear()
wxMocks.updateAppMessageShareData.mockClear()
wxMocks.updateTimelineShareData.mockClear()
wxMocks.onMenuShareWeibo.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('根据路由 meta 解析分享数据', () => {
const shareData = resolveRouteShareData({
meta: {
title: '课程详情',
shareDesc: '课程简介',
shareImage: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
},
})
expect(shareData.title).toBe('课程详情')
expect(shareData.desc).toBe('课程简介')
expect(shareData.imgUrl).toContain('imageMogr2/thumbnail/200x/strip/quality/70')
expect(shareData.link).toBe(window.location.href)
})
it('未提供描述时不再回退为标题', () => {
const shareData = resolveRouteShareData({
meta: {
title: '课程详情',
},
})
expect(shareData.title).toBe('课程详情')
expect(shareData.desc).toBe('')
})
it('在路由切换和标题变化时同步微信分享数据', async () => {
const router = createRouterMock({
meta: { title: '首页' },
})
const teardown = installWxShareSync(router, { isEnabled: true })
await flushMutationObserver()
expect(wxMocks.updateAppMessageShareData).toHaveBeenCalled()
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].title).toBe('美乐爱觉')
window.location.hash = '#/courses/2'
router.triggerAfterEach({
meta: {
title: '课程详情',
shareDesc: '课程简介',
},
})
await flushMutationObserver()
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0]).toMatchObject({
title: '课程详情',
desc: '课程简介',
link: window.location.href,
})
expect(wxMocks.updateTimelineShareData.mock.lastCall[0].title).toBe('课程详情')
document.title = '高阶课程'
await flushMutationObserver()
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].title).toBe('高阶课程')
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].desc).toBe('课程简介')
teardown()
})
it('支持通过异步 resolver 合并分享描述与图片', async () => {
const router = createRouterMock({
meta: { title: '课程详情' },
})
installWxShareSync(router, {
isEnabled: true,
resolver: vi.fn(() =>
Promise.resolve({
desc: '课程副标题',
imgUrl: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
})
),
})
await flushMutationObserver()
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].desc).toBe('课程副标题')
expect(wxMocks.updateAppMessageShareData.mock.lastCall[0].imgUrl).toContain(
'imageMogr2/thumbnail/200x/strip/quality/70'
)
})
it('非 cdn 图片地址保持不变', () => {
expect(normalizeShareImageUrl('https://example.com/image.png')).toBe(
'https://example.com/image.png'
)
})
})
/*
* @Date: 2022-06-13 17:42:32
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-04 13:36:41
* @LastEditTime: 2026-03-30 14:47:11
* @FilePath: /mlaj/src/composables/useShare.js
* @Description: 微信分享相关逻辑
*/
import wx from 'weixin-js-sdk'
import { wxInfo } from '@/utils/tools'
const resolveShareMetaSource = route => route?.meta?.share || route?.meta || {}
const resolveShareTitle = (route, documentObj, { preferDocumentTitle = false } = {}) => {
const meta = route?.meta || {}
const documentTitle = documentObj?.title || ''
if (preferDocumentTitle && documentTitle) {
return documentTitle
}
return meta.shareTitle || meta.ogTitle || meta.title || documentTitle || ''
}
const createTitleObserver = (documentObj, onChange) => {
const titleEl = documentObj?.head?.querySelector('title')
if (!titleEl || typeof MutationObserver === 'undefined') {
return () => {}
}
const observer = new MutationObserver(() => {
onChange()
})
observer.observe(titleEl, {
childList: true,
subtree: true,
characterData: true,
})
return () => {
observer.disconnect()
}
}
/**
* @function normalize_image_url
* @function normalizeShareImageUrl
* @description 规范化分享图片地址;若域名为 cdn.ipadbiz.cn,追加压缩参数。
* @param {string} src 原始图片地址
* @returns {string} 处理后的图片地址
*/
function normalize_image_url(src) {
export function normalizeShareImageUrl(src) {
if (!src) return ''
if (src.includes('cdn.ipadbiz.cn')) {
const compress = 'imageMogr2/thumbnail/200x/strip/quality/70'
......@@ -25,46 +61,214 @@ function normalize_image_url(src) {
return src
}
const buildShareLink = locationObj => locationObj?.href || ''
/**
* @description: 微信分享功能
* @param {*} title 标题
* @param {*} desc 描述
* @param {*} imgUrl 图标
* @return {*}
*/
/**
* @function sharePage
* @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。
* @function buildWxSharePayload
* @description 生成微信分享配置,统一补默认值与图片压缩规则。
* @param {Object} params 分享参数
* @param {string} params.title 分享标题
* @param {string} params.desc 分享描述
* @param {string} params.imgUrl 分享图标地址
* @returns {void}
* @param {string} params.link 分享链接
* @param {Object} options 运行选项
* @param {Location} options.locationObj location 对象
* @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
*/
export const sharePage = ({ title = '生命力教育联盟', desc = '', imgUrl = '' }) => {
const shareData = {
title, // 分享标题
desc, // 分享描述
link: location.origin + location.pathname + location.hash, // 分享链接,需与公众号 JS 安全域名一致
imgUrl: normalize_image_url(imgUrl), // 分享图标,按规则追加压缩参数
success() {
// 设置成功回调
},
}
if (wx && typeof wx.ready === 'function') {
wx.ready(() => {
// 分享好友(微信好友或qq好友)
wx.updateAppMessageShareData(shareData)
// 分享到朋友圈或qq空间
wx.updateTimelineShareData(shareData)
// 分享到腾讯微博
if (typeof wx.onMenuShareWeibo === 'function') {
wx.onMenuShareWeibo(shareData)
export function buildWxSharePayload(
{ title = '', desc = '', imgUrl = '', link = '' } = {},
{ locationObj = window.location } = {}
) {
return {
title,
desc,
link: link || buildShareLink(locationObj),
imgUrl: normalizeShareImageUrl(imgUrl),
success() {},
}
})
} else {
// 微信 JSSDK 未初始化或未就绪,分享配置可能不会生效
}
/**
* @function applyWxShareData
* @description 配置微信分享标题、描述与图标;需在 wx.ready 后调用才会生效。
* @param {Object} params 分享参数
* @param {Object} options 运行选项
* @param {any} options.wxApi 微信 SDK 实例
* @param {Location} options.locationObj location 对象
* @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
*/
export function applyWxShareData(params = {}, { wxApi = wx, locationObj = window.location } = {}) {
const shareData = buildWxSharePayload(params, { locationObj })
if (!wxApi || typeof wxApi.ready !== 'function') {
console.warn('微信 JSSDK 未就绪:分享配置可能未生效')
return shareData
}
wxApi.ready(() => {
if (typeof wxApi.updateAppMessageShareData === 'function') {
wxApi.updateAppMessageShareData(shareData)
}
if (typeof wxApi.updateTimelineShareData === 'function') {
wxApi.updateTimelineShareData(shareData)
}
if (typeof wxApi.onMenuShareWeibo === 'function') {
wxApi.onMenuShareWeibo(shareData)
}
})
return shareData
}
/**
* @function resolveRouteShareData
* @description 根据当前路由与文档标题解析分享配置。
* @param {import('vue-router').RouteLocationNormalizedLoaded | null | undefined} route 当前路由
* @param {Object} options 运行选项
* @param {Document} options.documentObj document 对象
* @param {Location} options.locationObj location 对象
* @param {boolean} options.preferDocumentTitle 是否优先使用 document.title
* @returns {{title: string, desc: string, imgUrl: string, link: string}}
*/
export function resolveRouteShareData(
route,
{ documentObj = document, locationObj = window.location, preferDocumentTitle = false } = {}
) {
const metaSource = resolveShareMetaSource(route)
const title = resolveShareTitle(route, documentObj, { preferDocumentTitle })
return {
title,
desc:
metaSource.desc ||
metaSource.shareDesc ||
metaSource.description ||
metaSource.ogDescription ||
'',
imgUrl: normalizeShareImageUrl(
metaSource.imgUrl || metaSource.shareImage || metaSource.image || metaSource.ogImage || ''
),
link:
metaSource.link ||
metaSource.shareLink ||
metaSource.url ||
metaSource.ogUrl ||
buildShareLink(locationObj),
}
}
const mergeResolvedShareData = (
baseShareData,
resolvedShareData,
documentObj,
{ preferDocumentTitle = false } = {}
) => {
const mergedShareData = {
...baseShareData,
...(resolvedShareData || {}),
}
if (resolvedShareData?.description && !resolvedShareData?.desc) {
mergedShareData.desc = resolvedShareData.description
}
if (resolvedShareData?.image && !resolvedShareData?.imgUrl) {
mergedShareData.imgUrl = resolvedShareData.image
}
if (resolvedShareData?.url && !resolvedShareData?.link) {
mergedShareData.link = resolvedShareData.url
}
if (mergedShareData.imgUrl) {
mergedShareData.imgUrl = normalizeShareImageUrl(mergedShareData.imgUrl)
}
if (preferDocumentTitle && documentObj?.title) {
mergedShareData.title = documentObj.title
}
return mergedShareData
}
/**
* @function installWxShareSync
* @description 把微信分享配置绑定到路由与标题变化上,避免页面里重复手动调用。
* @param {import('vue-router').Router} router 路由实例
* @param {Object} options 运行选项
* @param {any} options.wxApi 微信 SDK 实例
* @param {Document} options.documentObj document 对象
* @param {Location} options.locationObj location 对象
* @param {boolean} options.isEnabled 是否启用同步;默认仅在微信环境且非开发环境启用
* @returns {() => void} 清理函数
*/
export function installWxShareSync(
router,
{ wxApi = wx, documentObj = document, locationObj = window.location, isEnabled, resolver } = {}
) {
const enabled =
typeof isEnabled === 'boolean' ? isEnabled : !import.meta.env.DEV && wxInfo().isWeiXin
if (!router || !enabled) {
return () => {}
}
let syncToken = 0
const buildShareData = async (route, { preferDocumentTitle = false } = {}) => {
const baseShareData = resolveRouteShareData(route, {
documentObj,
locationObj,
preferDocumentTitle,
})
const resolvedShareData =
typeof resolver === 'function'
? await resolver(route, { documentObj, locationObj, preferDocumentTitle })
: null
return mergeResolvedShareData(baseShareData, resolvedShareData, documentObj, {
preferDocumentTitle,
})
}
const syncFromRoute = async (route, options = {}) => {
const currentToken = ++syncToken
const shareData = await buildShareData(route, options)
if (currentToken !== syncToken) {
return
}
applyWxShareData(shareData, {
wxApi,
locationObj,
})
}
const syncFromDocumentTitle = () => {
syncFromRoute(router.currentRoute?.value, {
preferDocumentTitle: true,
})
}
const removeAfterEach = router.afterEach?.(to => {
syncFromRoute(to)
})
const stopObserveTitle = createTitleObserver(documentObj, syncFromDocumentTitle)
syncFromDocumentTitle()
return () => {
stopObserveTitle()
removeAfterEach?.()
}
}
/**
* @function sharePage
* @description 兼容旧调用方式的轻量封装。
* @param {Object} params 分享参数
* @returns {{title: string, desc: string, link: string, imgUrl: string, success: Function}}
*/
export const sharePage = (params = {}) => applyWxShareData(params)
......
......@@ -10,6 +10,9 @@ import { routes } from './routes'
import { checkAuth, hasVisitedWelcome, markWelcomeVisited } from './guards'
import { getUserIsLoginAPI } from '@/api/auth'
import { getUserInfoAPI } from '@/api/users'
import { installWxShareSync } from '@/composables/useShare'
import { installOgMetaSync } from '@/utils/ogMeta'
import { resolveRouteRuntimeMeta } from './pageMetaResolver'
const router = createRouter({
history: createWebHashHistory(import.meta.env.VITE_BASE || '/'),
......@@ -42,12 +45,11 @@ router.beforeEach(async (to, from, next) => {
if (hasVisitedWelcome()) {
// 已访问过,跳转到首页
return next('/')
} else {
}
// 首次访问,标记并显示欢迎页
markWelcomeVisited()
}
}
}
// 检查用户是否已登录
const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null')
......@@ -98,4 +100,7 @@ router.beforeEach(async (to, from, next) => {
next()
})
installOgMetaSync(router, { resolver: resolveRouteRuntimeMeta })
installWxShareSync(router, { resolver: resolveRouteRuntimeMeta })
export default router
......
import { getCourseDetailAPI } from '@/api/course'
const routeMetaCache = new Map()
const buildRouteCacheKey = route => {
const routeName = route?.name || route?.path || 'unknown'
const routeParams = JSON.stringify(route?.params || {})
return `${routeName}:${routeParams}`
}
const withRouteMetaCache = (route, resolver) => {
const cacheKey = buildRouteCacheKey(route)
if (!routeMetaCache.has(cacheKey)) {
routeMetaCache.set(
cacheKey,
Promise.resolve()
.then(resolver)
.catch(() => null)
)
}
return routeMetaCache.get(cacheKey)
}
const resolveCourseDetailMeta = async (route, { windowObj = window } = {}) => {
const courseId = route?.params?.id
if (!courseId) {
return null
}
const { code, data } = await getCourseDetailAPI({ i: courseId })
if (code !== 1 || !data) {
return null
}
const title = data.title || '课程详情'
const description = data.subtitle || title
const shareDescription = data.subtitle || ''
const image = data.cover || ''
const url = windowObj?.location?.href || ''
return {
title,
description,
image,
url,
desc: shareDescription,
imgUrl: image,
link: url,
}
}
/**
* 统一解析“路由运行时元信息”。
*
* 说明:
* - 静态页面优先走 route.meta。
* - 动态详情页需要接口数据时,在这里集中补充,避免页面里分散维护。
*/
export function resolveRouteRuntimeMeta(route, { windowObj = window } = {}) {
switch (route?.name) {
case 'CourseDetail':
return withRouteMetaCache(route, () => resolveCourseDetailMeta(route, { windowObj }))
default:
return null
}
}
export function resetRouteRuntimeMetaCache() {
routeMetaCache.clear()
}
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyOgMeta,
buildOgImageUrl,
installOgMetaSync,
resetOgMeta,
resetOgMetaManagerState,
} from '../ogMeta'
const createRouterMock = (initialRoute = { meta: { title: '首页' }, fullPath: '/#/' }) => {
let afterEachHook = null
return {
currentRoute: { value: initialRoute },
afterEach: vi.fn(handler => {
afterEachHook = handler
return () => {
afterEachHook = null
}
}),
triggerAfterEach(route) {
this.currentRoute.value = route
afterEachHook?.(route)
},
}
}
const flushMutationObserver = async () => {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('ogMeta', () => {
beforeEach(() => {
document.head.innerHTML = `
<meta property="og:description" content="默认描述">
<title>美乐爱觉</title>
`
document.title = '美乐爱觉'
window.location.hash = '#/'
resetOgMetaManagerState()
})
afterEach(() => {
resetOgMetaManagerState()
})
it('复用现有 og:description 并在 reset 时恢复初始值', () => {
applyOgMeta({
title: '课程详情',
description: '课程副标题',
image: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
url: 'https://example.com/#/courses/1',
})
const descriptionMetas = document.head.querySelectorAll('meta[property="og:description"]')
expect(descriptionMetas).toHaveLength(1)
expect(descriptionMetas[0].getAttribute('content')).toBe('课程副标题')
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'课程详情'
)
expect(
document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')
).toContain('imageMogr2/thumbnail/400x/strip/quality/70')
resetOgMeta()
expect(
document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
).toBe('默认描述')
expect(document.head.querySelector('meta[property="og:title"]')).toBeNull()
expect(document.head.querySelector('meta[property="og:image"]')).toBeNull()
expect(document.head.querySelector('meta[property="og:url"]')).toBeNull()
})
it('在路由切换和 document.title 变化时同步 og:title 与 og:url', async () => {
const router = createRouterMock({
meta: { title: '课程详情' },
fullPath: '/courses/1',
})
const teardown = installOgMetaSync(router)
await flushMutationObserver()
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'美乐爱觉'
)
expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute('content')).toBe(
window.location.href
)
window.location.hash = '#/courses/2'
router.triggerAfterEach({
meta: { title: '课程详情' },
fullPath: '/courses/2',
})
await flushMutationObserver()
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'课程详情'
)
expect(document.head.querySelector('meta[property="og:url"]')?.getAttribute('content')).toBe(
window.location.href
)
document.title = '高阶课程'
await flushMutationObserver()
expect(document.head.querySelector('meta[property="og:title"]')?.getAttribute('content')).toBe(
'高阶课程'
)
expect(
document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
).toBe('高阶课程')
teardown()
})
it('支持通过异步 resolver 合并描述与图片', async () => {
const router = createRouterMock({
meta: { title: '课程详情' },
fullPath: '/courses/1',
})
installOgMetaSync(router, {
resolver: vi.fn(() =>
Promise.resolve({
description: '课程副标题',
image: 'https://cdn.ipadbiz.cn/mlaj/course-cover.png',
})
),
})
await flushMutationObserver()
expect(
document.head.querySelector('meta[property="og:description"]')?.getAttribute('content')
).toBe('课程副标题')
expect(
document.head.querySelector('meta[property="og:image"]')?.getAttribute('content')
).toContain('imageMogr2/thumbnail/400x/strip/quality/70')
})
it('为非 cdn.ipadbiz.cn 图片保持原始地址', () => {
expect(buildOgImageUrl('https://example.com/image.png')).toBe('https://example.com/image.png')
})
})
/**
* 统一管理 Open Graph 元标签。
*
* 设计目标:
* 1. 任意路由切换时都能自动同步 OG 信息。
* 2. 兼容 index.html 中已经存在的静态 meta,例如 og:description。
* 3. 页面只负责更新 document.title;OG 的写入和恢复统一放在这里处理。
*/
const MANAGED_OG_TAGS = [
{ id: 'og-title', property: 'og:title', key: 'title' },
{ id: 'og-description', property: 'og:description', key: 'description' },
{ id: 'og-image', property: 'og:image', key: 'image' },
{ id: 'og-url', property: 'og:url', key: 'url' },
]
// 首次接管 OG 标签时,记录页面原始状态,便于 teardown/reset 时恢复。
let originalOgMetaState = null
const getHead = documentObj =>
documentObj?.head || documentObj?.getElementsByTagName?.('head')?.[0] || null
const getMetaByProperty = (documentObj, property) =>
getHead(documentObj)?.querySelector(`meta[property="${property}"]`) || null
const captureOriginalOgMetaState = documentObj => {
if (originalOgMetaState) {
return originalOgMetaState
}
// 只在首次接管时做快照,避免后续多次同步把“运行时状态”误当成“初始状态”。
originalOgMetaState = MANAGED_OG_TAGS.reduce((state, item) => {
const meta = getMetaByProperty(documentObj, item.property)
state[item.property] = {
existed: !!meta,
content: meta?.getAttribute('content') || '',
id: meta?.getAttribute('id') || '',
}
return state
}, {})
return originalOgMetaState
}
const ensureManagedMeta = (documentObj, item) => {
const head = getHead(documentObj)
if (!head) return null
// 优先按 property 查找,兼容原本就写在 index.html 里的静态节点。
// 找不到时再按约定 id 查找我们自己创建的节点。
let meta = getMetaByProperty(documentObj, item.property) || head.querySelector(`meta#${item.id}`)
if (!meta) {
meta = documentObj.createElement('meta')
const titleEl = head.querySelector('title')
if (titleEl) {
head.insertBefore(meta, titleEl)
} else if (head.firstChild) {
head.insertBefore(meta, head.firstChild)
} else {
head.appendChild(meta)
}
}
meta.setAttribute('id', item.id)
meta.setAttribute('property', item.property)
return meta
}
const resolveMetaSource = route => route?.meta?.og || route?.meta || {}
const resolveRouteTitle = (route, documentObj, { preferDocumentTitle = false } = {}) => {
const meta = route?.meta || {}
const documentTitle = documentObj?.title || ''
// 某些详情页会在异步数据返回后通过 useTitle 更新标题。
// 这种情况下优先读取 document.title,才能拿到页面最终标题,而不是路由默认标题。
if (preferDocumentTitle && documentTitle) {
return documentTitle
}
return meta.ogTitle || meta.title || documentTitle || ''
}
const createTitleObserver = (documentObj, onChange) => {
const titleEl = getHead(documentObj)?.querySelector('title')
if (!titleEl || typeof MutationObserver === 'undefined') {
return () => {}
}
const observer = new MutationObserver(() => {
onChange()
})
observer.observe(titleEl, {
childList: true,
subtree: true,
characterData: true,
})
return () => {
observer.disconnect()
}
}
/**
* 统一处理项目里的图片压缩规则。
* 目前仅对 cdn.ipadbiz.cn 做 imageMogr2 参数补充。
*/
export function buildOgImageUrl(src) {
if (!src) return ''
if (src.includes('cdn.ipadbiz.cn')) {
const compressParam = 'imageMogr2/thumbnail/400x/strip/quality/70'
if (src.includes('?')) {
return src.includes(compressParam) ? src : `${src}&${compressParam}`
}
return `${src}?${compressParam}`
}
return src
}
/**
* 写入或更新 OG 标签。
*
* 注意:
* - 所有标签都走统一入口,避免各页面各自 append/remove。
* - description 也会被统一接管,这样恢复逻辑才是对称的。
*/
export function applyOgMeta(payload = {}, { documentObj = document, windowObj = window } = {}) {
const head = getHead(documentObj)
if (!head) return
captureOriginalOgMetaState(documentObj)
const normalizedPayload = {
title: payload.title || documentObj?.title || '',
// description 没传时退化为 title,至少保证分享卡片不是空文案。
description: payload.description || payload.title || documentObj?.title || '',
image: buildOgImageUrl(payload.image || ''),
url: payload.url || windowObj?.location?.href || '',
}
MANAGED_OG_TAGS.forEach(item => {
const meta = ensureManagedMeta(documentObj, item)
if (!meta) return
meta.setAttribute('content', normalizedPayload[item.key] || '')
})
}
/**
* 恢复接管前的 OG 状态。
*
* 规则:
* - 原本存在的节点恢复 content/id。
* - 原本不存在的节点直接删除。
*/
export function resetOgMeta({ documentObj = document } = {}) {
const head = getHead(documentObj)
if (!head || !originalOgMetaState) return
MANAGED_OG_TAGS.forEach(item => {
const snapshot = originalOgMetaState[item.property]
const meta =
getMetaByProperty(documentObj, item.property) || head.querySelector(`meta#${item.id}`)
if (!meta) return
if (snapshot?.existed) {
meta.setAttribute('property', item.property)
meta.setAttribute('content', snapshot.content || '')
if (snapshot.id) {
meta.setAttribute('id', snapshot.id)
} else {
meta.removeAttribute('id')
}
return
}
meta.parentNode?.removeChild(meta)
})
}
export function resetOgMetaManagerState() {
// 仅用于测试或明确需要重置管理器内部状态的场景。
originalOgMetaState = null
}
/**
* 从当前路由解析出应写入的 OG 数据。
*
* 支持两种写法:
* - meta.ogDescription / meta.ogImage / meta.ogTitle
* - meta.og = { description, image, title, url }
*/
export function resolveRouteOgMeta(
route,
{ documentObj = document, windowObj = window, preferDocumentTitle = false } = {}
) {
const metaSource = resolveMetaSource(route)
const title = resolveRouteTitle(route, documentObj, { preferDocumentTitle })
return {
title,
description: metaSource.description || metaSource.ogDescription || title,
image: metaSource.image || metaSource.ogImage || '',
url: metaSource.url || metaSource.ogUrl || windowObj?.location?.href || '',
}
}
const mergeResolvedOgMeta = (
basePayload,
resolvedPayload,
documentObj,
{ preferDocumentTitle = false } = {}
) => {
const mergedPayload = {
...basePayload,
...(resolvedPayload || {}),
}
if (resolvedPayload?.desc && !resolvedPayload?.description) {
mergedPayload.description = resolvedPayload.desc
}
if (resolvedPayload?.imgUrl && !resolvedPayload?.image) {
mergedPayload.image = resolvedPayload.imgUrl
}
if (resolvedPayload?.link && !resolvedPayload?.url) {
mergedPayload.url = resolvedPayload.link
}
if (preferDocumentTitle && documentObj?.title) {
mergedPayload.title = documentObj.title
}
return mergedPayload
}
/**
* 把 OG 同步器挂到 router 上。
*
* 同步时机有两个:
* 1. 路由切换完成后:更新当前页面的默认 OG。
* 2. document.title 发生变化时:把异步详情页最终标题同步回 OG。
*/
export function installOgMetaSync(
router,
{ documentObj = document, windowObj = window, resolver } = {}
) {
if (!router || !documentObj) {
return () => {}
}
let syncToken = 0
const buildPayload = async (route, { preferDocumentTitle = false } = {}) => {
const basePayload = resolveRouteOgMeta(route, { documentObj, windowObj, preferDocumentTitle })
const resolvedPayload =
typeof resolver === 'function'
? await resolver(route, { documentObj, windowObj, preferDocumentTitle })
: null
return mergeResolvedOgMeta(basePayload, resolvedPayload, documentObj, { preferDocumentTitle })
}
const syncFromRoute = async (route, options = {}) => {
const currentToken = ++syncToken
const payload = await buildPayload(route, options)
if (currentToken !== syncToken) {
return
}
applyOgMeta(payload, {
documentObj,
windowObj,
})
}
const syncFromDocumentTitle = () => {
// 这里显式偏向 document.title,确保课程详情、学习详情这类异步标题能覆盖路由默认标题。
syncFromRoute(router.currentRoute?.value, {
preferDocumentTitle: true,
})
}
const removeAfterEach = router.afterEach?.(to => {
syncFromRoute(to)
})
const stopObserveTitle = createTitleObserver(documentObj, syncFromDocumentTitle)
// 首屏也立即同步一次,避免只有路由跳转后才有 OG。
syncFromDocumentTitle()
return () => {
stopObserveTitle()
removeAfterEach?.()
resetOgMeta({ documentObj })
}
}
This diff is collapsed. Click to expand it.