ogMeta.js 8.75 KB
/**
 * 统一管理 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' in resolvedPayload && !('description' in resolvedPayload)) {
    mergedPayload.description = resolvedPayload.desc
  }

  if (resolvedPayload && 'imgUrl' in resolvedPayload && !('image' in resolvedPayload)) {
    mergedPayload.image = resolvedPayload.imgUrl
  }

  if (resolvedPayload && 'link' in resolvedPayload && !('url' in resolvedPayload)) {
    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 })
  }
}